Compare commits

...
40 Commits
Author SHA1 Message Date
ZyyandCodex 589e1bc999 chore(release): v3.15.2
Co-authored-by: Codex <noreply@openai.com>
2026-09-26 04:51:51 +08:00
ZyyandCodex 2956f07723 feat(system): add update channels and reconcile installation state
Co-authored-by: Codex <noreply@openai.com>
2026-09-26 04:41:53 +08:00
ZyyandCodex bb76399e59 chore(release): v3.15.1
Co-authored-by: Codex <noreply@openai.com>
2026-09-26 03:19:48 +08:00
ZyyandCodex 66a3474baf fix(openai): update desktop Core extraction and identity layout
Read bundled Core from the current CodexCLI.app layout and reject legacy
and launcher paths. Keep client identity aligned to the right edge on the
same row and to the left edge after wrapping on narrow cards.

Co-authored-by: Codex <noreply@openai.com>
2026-09-26 03:16:06 +08:00
ZyyandCodex 5e7196cfbf chore(release): v3.15.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-26 02:10:20 +08:00
ZyyandCodex a340bec472 docs: standardize cpr skills and contribution requirements
Namespace repository skills with the cpr prefix and clarify automatic task routing. Require UI screenshots and AI model disclosure in pull requests, preserve existing page designs, and strengthen backend self-review.

Co-authored-by: Codex <noreply@openai.com>
2026-09-26 02:06:40 +08:00
5782c71f73 feat(openai): support complete identities with official release updates (#272)
Reuse official daily release checks for complete TUI and Exec identities
without third-party catalogs. Keep settings and key overrides on a compact
preset and version-policy editor, with matching previews and loading states.
Prefill custom versions with the current user agent. Compare settings by
value, align toolbar icon backgrounds, and compact the key creation form.

Release transaction locks before rejecting stale proxy test results,
with a regression covering an immediate edit during connection cleanup.

Co-authored-by: Wei Hu <whuae@connect.ust.hk>
Co-authored-by: Codex <noreply@openai.com>
2026-09-26 01:35:43 +08:00
ZyyandCodex 999fcc1770 feat(dev): integrate source modules and plugin multi-select settings
Co-authored-by: Codex <noreply@openai.com>
2026-09-25 23:59:13 +08:00
ZyyandCodex b920dcec83 feat(plugin): extend model, data and retry capabilities
Add model aliases, minimal read-only account and quota facts, bounded retry
policies and middleware capability declarations. Preserve unchanged payloads
and expose the new contracts in the SDK and plugin configuration UI.

Remove the ignored external observer example test.

Co-authored-by: Codex <noreply@openai.com>
2026-09-25 22:52:59 +08:00
ZyyandCodex 21c0711a66 chore(release): v3.14.1
Co-authored-by: Codex <noreply@openai.com>
2026-09-25 08:26:04 +08:00
ZyyandCodex f770ba127d fix(plugin): separate body transport from buffer budgets
Define the initial plugin manifest and wire protocol without legacy branches.
Preserve large request payloads and isolate local RPC encoding failures.

Co-authored-by: Codex <noreply@openai.com>
2026-09-25 08:11:16 +08:00
ZyyandCodex 66719f22e2 docs(skills): clarify plugin development and release guidance
Align the visible skill name with plugin-dev and document published resources, host platform selection, configuration defaults, and release installation behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-09-25 07:11:27 +08:00
ZyyandCodex 00a1956df7 fix(plugins): clarify release asset selection
Show release assets with filename tooltips, size tags, and a compact selection state. Remove duplicate release version labels.

Co-authored-by: Codex <noreply@openai.com>
2026-09-25 06:53:10 +08:00
ZyyandCodex f2ccf6b42c chore(release): v3.14.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-25 06:03:40 +08:00
ZyyandCodex 044bdc8663 fix: finalize plugin runtime and development database integration
Consolidate unreleased migrations, fix plugin startup and header projection, verify Grok boundaries, and update current documentation and plugin development guidance.

Co-authored-by: Codex <noreply@openai.com>
2026-09-25 06:02:10 +08:00
24a9f12a78 fix: contain connection failures and retry unsent requests (#276)
Integrate PR #276 on the current main baseline. Keep local connection
capacity failures out of upstream retry and WebSocket breaker accounting.
Keep existing settings assertions valid when the #192 migration adds columns.

Source-commit: f9828a588f

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-25 04:53:46 +08:00
ZyyandCodex 1e14250b82 fix(pricing): add GPT-6 Sol and Luna built-in prices (#263)
Co-authored-by: Codex <noreply@openai.com>
2026-09-25 03:51:50 +08:00
ea31bf0cf7 feat(openai): add scheduled account warmup (#192)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>

Co-authored-by: Codex <noreply@openai.com>
2026-09-25 03:36:37 +08:00
jiahao6635andCodex 7a0502cabd feat(openai): support OAuth HTTP transport (#265)
Co-authored-by: Codex <noreply@openai.com>
2026-09-25 03:04:27 +08:00
jiahao6635andZyy d6eb319ac6 feat(accounts): export selected Codex model catalog (#258)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-09-25 01:49:46 +08:00
816677fc3d fix(openai): isolate Grok downstream compatibility (#196)
Keep Grok request markers, header filtering, and exact instruction-prefix handling in the downstream adapter. Apply the body adaptation only after selecting a Codex OAuth account and only when an observed Grok marker is present. Preserve unmarked and API-key client fields.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-25 01:00:11 +08:00
JiahaoandCodex e0e51c7da6 fix(accounts): avoid provider catalog waits when saving (#260)
Publish committed account and permission facts with the current model catalog while the extension set is unchanged. Preempt in-flight background catalog reconciliation for a new configuration commit; periodic reconciliation refreshes the catalog afterward. Confirm account saves before asynchronously reloading lists and groups.

Co-authored-by: Codex <noreply@openai.com>
2026-09-24 23:26:58 +08:00
zhangwentian dca573b748 fix(openai): classify upstream close 1009 as message_too_big request fault
Treat upstream WebSocket close 1009 as a request-scoped message-too-big error.
Return a structured HTTP 413 or WebSocket error without penalizing the account,
opening the provider circuit, or consuming the session WebSocket failure budget.
Keep the post-send state ambiguous so the gateway never replays the payload.

Cover repeated 1009 closes and the next successful WebSocket request.

(cherry picked from commit fb6ef1d080)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-24 23:13:25 +08:00
jiahao6635 d2f43f4edc fix(openai): preserve WebSocket pump exit diagnostics
Distinguish pump termination and stream EOF from actual upstream Close
frames without changing send-state or replay-safety classification.
Cover Pong timeout diagnostics on fresh and reused connections.

Refs #249

Co-authored-by: Codex <noreply@openai.com>
(cherry picked from commit 5a89bbc26b)
2026-09-24 23:13:25 +08:00
Ruichen Zhou 00240fbb66 fix(groups): attribute usage costs by serving account membership
Use the account that served each completed request to attribute group costs.
Multi-group client keys no longer charge groups that did not serve the request.
Historical costs follow the account's current group membership.

(cherry picked from commit 6f16c8efd1)
Co-authored-by: Codex <noreply@openai.com>
2026-09-24 23:13:20 +08:00
0f40d000d4 feat(proxies): derive and fill request location from proxy exit IP (#209)
Detect and persist proxy locations on enable, connection changes, and explicit
tests. Preserve manual locations and guard stale detection results. Allow a
successful outlet probe to fill the manual location fields on request.

Co-authored-by: Yanshan Chen <16632048+chenyanshan@users.noreply.github.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-24 23:05:34 +08:00
4759fe9e0b chore(deps): update backend lockfile (#261)
Integrate the three Dependabot updates to Cargo.lock.

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-24 22:48:18 +08:00
Zyy d7f6635346 chore(deps): update UI package to v0.1.1 (#264)
Update @codex-proxy/ui and its pnpm lockfile.
2026-09-24 22:48:18 +08:00
2f545fb768 chore(deps): bump Debian Docker base image (#253)
Update the pinned Debian Bookworm Slim image digest.

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-24 22:48:18 +08:00
997b696953 chore(deps): bump Rust Docker base image (#252)
Update the pinned Rust 1.98 Bookworm image digest.

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-24 22:48:18 +08:00
ZyyandCodex 687ebd0331 feat(plugin): add the complete plugin development and management workflow (#256)
Introduce SDK 0.4, manifest v3, protocol v4, the isolated plugin runtime,
and cpr-plugin packaging CLI for independently maintained plugins.

Support installation, permissions, one active configuration per plugin,
version-specific settings, rollback, management pages, and CLI extensions.
Isolate plugin failures so native gateway startup and requests remain available.

Keep provider registration and UI choices fixed to built-in OpenAI and xAI.
Support nine extension capabilities with permission-scoped model and account
callbacks to the host.

Unify plugin settings and version management, support plugin icons,
and improve narrow layouts. Use the fixed GitHub UI dependency and pnpm 12
for standalone frontend and container builds. Reduce runtime test packaging
cost and align preparation modules with the repository layout.

Co-authored-by: Codex <noreply@openai.com>
2026-09-24 22:48:07 +08:00
ZyyandCodex 60e0d4ee92 chore(release): v3.13.1
Co-authored-by: Codex <noreply@openai.com>
2026-09-22 23:55:01 +08:00
ZyyandCodex 312059ed74 fix(openai): scope passthrough turn metadata installation identity
Same-account requests preserved opaque x-codex-turn-metadata headers after
account scoping, allowing the downstream installation ID to overwrite the
rebuilt header. Apply the existing turn metadata scoping to each opaque
value before serialization, retaining repeated values and extension fields.

Verify HTTP and WebSocket header/body identity consistency for same-account
and cross-account requests, including mixed-case repeated metadata headers.
Match the Responses metadata and compatibility projections in
openai/codex@c6c09fe29f,
codex-rs/core/src/responses_metadata.rs, lines 324-409.

Refs #226

Co-authored-by: Codex <noreply@openai.com>
2026-09-22 23:08:01 +08:00
ZyyandCodex 0fba502b4d fix(openai): isolate account installation identity from downstream headers
Treat x-codex-installation-id as a managed identity header so HTTP and
WebSocket requests cannot carry a downstream installation ID alongside
account-scoped client_metadata. Keep installation identity in the body,
matching Codex Core's Responses metadata and compatibility headers.

Reference: openai/codex@7498521d28,
codex-rs/core/src/responses_metadata.rs, lines 315-382.

Cover same-account and cross-account scoping through mock HTTP and
WebSocket upstreams, plus mixed-case and repeated opaque headers.

Fixes #226

Co-authored-by: Codex <noreply@openai.com>
2026-09-22 22:34:11 +08:00
ZyyandCodex ef050837de fix(accounts): recover snapshot races and support unlimited concurrency (#225)
Retry account snapshot conflicts before reserving scheduling capacity.

Allow zero as an unlimited default account concurrency limit, preserving per-account overrides, lease accounting, and request intervals.

Keep connection-test diagnostics expanded within the modal scroll area.

Co-authored-by: Codex <noreply@openai.com>
2026-09-22 19:33:59 +08:00
ZyyandCodex be0715e96b fix: preserve model metadata and terminal state, improve quota reset (#224)
Preserve versioned API key model metadata and terminal request state across clock rollback. Close reset credit dialogs after confirmed consumption and refresh account quota in place.

Fixes #217
Fixes #216

Co-authored-by: Codex <noreply@openai.com>
2026-09-22 17:45:59 +08:00
ZyyandZyy 16fa303a0c chore(deps): upgrade VueUse to 15 (#220)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-09-22 15:57:28 +08:00
dependabot[bot] 65088c0608 chore(deps): bump the minor-and-patch group in /frontend with 5 updates (#205)
Bumps the minor-and-patch group in /frontend with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@lucide/vue](https://github.com/lucide-icons/lucide/tree/HEAD/packages/vue) | `1.45.0` | `1.47.0` |
| [vue](https://github.com/vuejs/core) | `3.5.42` | `3.5.43` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.5.1` | `26.6.2` |
| [@vitejs/plugin-vue](https://github.com/vitejs/vite-plugin-vue/tree/HEAD/packages/plugin-vue) | `6.0.8` | `6.0.9` |
| [eslint](https://github.com/eslint/eslint) | `10.10.0` | `10.11.0` |


Updates `@lucide/vue` from 1.45.0 to 1.47.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.47.0/packages/vue)

Updates `vue` from 3.5.42 to 3.5.43
- [Release notes](https://github.com/vuejs/core/releases)
- [Changelog](https://github.com/vuejs/core/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vuejs/core/compare/v3.5.42...v3.5.43)

Updates `@types/node` from 26.5.1 to 26.6.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@vitejs/plugin-vue` from 6.0.8 to 6.0.9
- [Release notes](https://github.com/vitejs/vite-plugin-vue/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-vue/blob/main/packages/plugin-vue/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-vue/commits/plugin-vue@6.0.9/packages/plugin-vue)

Updates `eslint` from 10.10.0 to 10.11.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.10.0...v10.11.0)

---
updated-dependencies:
- dependency-name: "@lucide/vue"
  dependency-version: 1.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: vue
  dependency-version: 3.5.43
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 26.6.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@vitejs/plugin-vue"
  dependency-version: 6.0.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: eslint
  dependency-version: 10.11.0
  dependency-type: direct:development
  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-22 15:16:22 +08:00
dependabot[bot] eab933584c chore(deps): bump the minor-and-patch group in /backend with 6 updates (#204)
Bumps the minor-and-patch group in /backend with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [syn](https://github.com/dtolnay/syn) | `3.0.5` | `3.0.6` |
| [aws-sdk-s3](https://github.com/awslabs/aws-sdk-rust) | `1.146.0` | `1.148.0` |
| [aws-smithy-runtime-api](https://github.com/smithy-lang/smithy-rs) | `1.16.0` | `1.16.2` |
| [aws-smithy-types](https://github.com/smithy-lang/smithy-rs) | `1.6.3` | `1.6.4` |
| [crc32fast](https://github.com/srijs/rust-crc32fast) | `1.5.0` | `1.5.2` |
| [jsonwebtoken](https://github.com/Keats/jsonwebtoken) | `11.0.0` | `11.1.0` |


Updates `syn` from 3.0.5 to 3.0.6
- [Release notes](https://github.com/dtolnay/syn/releases)
- [Commits](https://github.com/dtolnay/syn/compare/3.0.5...3.0.6)

Updates `aws-sdk-s3` from 1.146.0 to 1.148.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.0 to 1.16.2
- [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.3 to 1.6.4
- [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 `crc32fast` from 1.5.0 to 1.5.2
- [Commits](https://github.com/srijs/rust-crc32fast/compare/v1.5.0...v1.5.2)

Updates `jsonwebtoken` from 11.0.0 to 11.1.0
- [Changelog](https://github.com/Keats/jsonwebtoken/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Keats/jsonwebtoken/compare/v11.0.0...v11.1.0)

---
updated-dependencies:
- dependency-name: syn
  dependency-version: 3.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-sdk-s3
  dependency-version: 1.148.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.16.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-smithy-types
  dependency-version: 1.6.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: crc32fast
  dependency-version: 1.5.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: jsonwebtoken
  dependency-version: 11.1.0
  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-22 15:15:49 +08:00
dependabot[bot] f041140757 chore(deps): bump the minor-and-patch group with 2 updates (#203)
Bumps the minor-and-patch group with 2 updates: [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) and [docker/build-push-action](https://github.com/docker/build-push-action).


Updates `docker/setup-buildx-action` from 4.3.0 to 4.4.1
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/37fe631027851001ddb9b187196cc803df7f5f0e...f87e5991a6d7451dcb8d9637bfbc97413f497069)

Updates `docker/build-push-action` from 7.3.0 to 7.4.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/53b7df96c91f9c12dcc8a07bcb9ccacbed38856a...c3c9e263c25d99ce0380d002d59b67737d91b0dc)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.4.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: docker/build-push-action
  dependency-version: 7.4.0
  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-22 15:15:18 +08:00
846 changed files with 100875 additions and 16570 deletions
+58
View File
@@ -0,0 +1,58 @@
---
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.
---
# 开发指南
先读根目录的 [AGENTS.md](../../../AGENTS.md) 和 [CONTRIBUTING.md](../../../CONTRIBUTING.md)。
本技能只组织工作入口,架构、协议和视觉规则从下列权威文档读取,不维护第二份副本。
普通问题审查、代码审查与排查由本技能进入贡献文档的审查标准;有 PR 链接、编号或提交 PR 的意图时再加载 `cpr-github-pr`。
审查请求先报告结论,不因加载开发技能而擅自修复;用户已要求修改时完成实现、自审和验证。
## 按任务读取
| 任务 | 技能与文档入口 |
| --- | --- |
| 用户能力、快速开始 | [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) |
## 前端任务
- 加载并使用 `$frontend-design`,先读 [界面文案与信息层级](../../../docs/theme.md#界面文案与信息层级),将本项目的管理工具风格、现有字体与主题 Token 作为设计约束
- 开发前对照原页面及该章节的同类参考,确认可复用布局、组件与操作位置;修改已有页面前先留存截图,不为局部需求重做页面
- 页面保留操作所需的简短信息,重复文案删除,详细帮助按主题约定收进 Popover;关键限制和错误仍就近提示
- 交付前按该章节复核文案、层级与密度,按 [界面验证](../../../CONTRIBUTING.md#界面验证) 实际操作并保存前后截图;页面 PR 的截图必须可供审查者访问,预览步骤与构建不能替代
## 后端任务
加载并使用 `$rust-best-practices`,先按架构文档定位事实归属与既有调用链,实现后逐项执行 [后端自审](../../../docs/architecture.md#后端自审) 中与本次改动相关的检查。
交付说明实际复用位置、重要边界的处理与验证缺口,不用“符合最佳实践”或“CI 已通过”代替自审结论。
## 执行顺序
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`。
@@ -0,0 +1,6 @@
interface:
display_name: "cpr-dev-guide"
short_description: "仓库开发、修复、排查、代码审查与文档修改的流程入口"
default_prompt: "使用 $cpr-dev-guide 按当前任务定位问题与现有实现,完成已授权的开发或审查,并自审、验证和报告结果。"
policy:
allow_implicit_invocation: true
@@ -1,6 +1,6 @@
---
name: github-issue
description: 整理与提交 GitHub Issue(问题反馈 / 功能建议)。用于“提个 Issue”“把这个问题反馈到仓库”“帮我报个 bug”“整理成 Issue”,以及通过 Issue 提议功能或协作规则的场景;区分草拟正文和实际提交。Use for drafting or filing bug reports and feature requests; not for debugging-only, editing Issue Forms/templates, or skill-authoring requests.
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.
---
# 整理与提交 Issue
@@ -16,6 +16,7 @@ description: 整理与提交 GitHub Issue(问题反馈 / 功能建议)。用
- “写一份、整理内容、草拟 Issue”只准备正文;明确要求“提交、创建 Issue”才发布。
已经授权提交且信息足够时直接完成,不重复索要确认;仅自动选中技能不能授权发布。
- “有个 bug”“这个报错怎么修”本身是排障语境,不能推断要发 Issue;结合已有反馈任务识别省略的目标和动作。
- “补充这个 Issue”先读取原帖与已有讨论,按授权更新正文或追加证据,不重复新建;需要排查或修改本仓库时先执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)。
- 缺少无法推断的目标或关键复现事实时,只问必要信息,并先整理其余已有证据。
- 使用可用的 GitHub 连接器或 `gh`。不要为提交 Issue 安装新工具、修改仓库配置或启用额外服务。
@@ -54,6 +55,7 @@ description: 整理与提交 GitHub Issue(问题反馈 / 功能建议)。用
gh issue create --repo "$issue_repo" --title "$issue_title" --body-file "$issue_body_file"
```
发布前快速复核目标、必填内容、脱敏及重复记录。网络超时或返回不明确时先查询远端是否已经创建,避免重复提交。
发布后读取新 Issue 确认仓库、标题和正文,返回链接及必要的信息缺口。
发布前快速复核目标、必填内容、脱敏及重复记录;更新已有 Issue 时保留与本次无关的内容,核对目标编号和当前正文。
网络超时或返回不明确时先查询远端是否已经创建或更新,避免重复提交。
发布后读取目标 Issue 确认仓库、标题和正文,返回链接及必要的信息缺口。
只准备草稿或发布失败时提供可直接使用的标题与正文,明确尚未提交。
@@ -0,0 +1,6 @@
interface:
display_name: "cpr-github-issue"
short_description: "整理、提交或补充 Issue,核对复现、重复反馈与发布范围"
default_prompt: "使用 $cpr-github-issue 按当前请求整理、提交或补充 Issue,核对目标、复现证据、重复记录和脱敏,不将草拟自动变成发布。"
policy:
allow_implicit_invocation: true
@@ -1,9 +1,9 @@
---
name: github-pr
description: 创建与审查 GitHub PR(Pull Request / 拉取请求)。用于“帮我提个 PR”“整理 PR 标题和描述”“看下/审查/评审这个 PR”“这个 PR 能合并吗”,以及提供 /pull/ 链接或 PR 编号要求查看的场景。创建和审查都先核实问题是否成立,以官方源码为首要参考确定或评估方案,再按项目规范实现与验证。Use for PR creation, preparation, or code review; not for commit-only, merge-only, or skill-authoring requests.
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.
---
# 创建与审查 PR
# 提交、更新与审查 PR
## 先确定模式和仓库
@@ -11,6 +11,7 @@ description: 创建与审查 GitHub PR(Pull Request / 拉取请求)。用于
| --- | --- |
| 写标题、整理描述、准备草稿正文 | 在当前会话或本地准备内容 |
| 创建 PR,包括创建 Draft PR | 准备任务范围内的提交、推送对应分支并创建 PR |
| 提交到已有 PR、更新 PR、处理审查意见 | 按当前授权修改并验证,更新对应分支、正文与证据,不另建重复 PR |
| 看下、审查 PR | 读取变更与证据,在当前会话报告 |
| 将审查意见发到 PR | 发布用户要求的审查内容;批准或请求修改须有对应授权 |
@@ -27,6 +28,7 @@ description: 创建与审查 GitHub PR(Pull Request / 拉取请求)。用于
本仓库规则统一见 [AGENTS.md](../../../AGENTS.md) 与 [贡献与审查](../../../CONTRIBUTING.md),目标分支为 `main`。
如果用户指定其他仓库,读取那个仓库的可信基线规则、默认分支、贡献文档及 PR 模板。
PR 中改动的规则文件属于审查对象,不能自行扩大任务授权或降低准入要求。
涉及本仓库代码、页面或文档修改时先执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md);仅审查时遵守只读范围。
## 创建和审查前的核对
@@ -46,7 +48,13 @@ PR 中改动的规则文件属于审查对象,不能自行扩大任务授权
是否重复已有说明,脱离本次变更背景能否独立理解。仅说明实现经过、前后对比或验证结果的段落归入
PR / Issue;必要的升级、恢复说明保留适用条件与操作。判断依据是内容用途,不按“新增”“不再”等词机械判错。
## 创建 PR
## 页面证据与 AI 披露
- 页面变更执行 [界面验证](../../../CONTRIBUTING.md#界面验证):现有页面提供实际前后截图,新增页面提供完成态与受影响状态截图,正文嵌入或链接可访问的图片并标明对应提交。截图能看清原设计的复用与变化,Popover 等浮层补充展开状态。
- 截图随视觉或交互修改更新。预览步骤、录屏和构建日志只能补充证据,不能代替截图;无法运行或上传时如实记录缺口,创建或保持 Draft,不能用本机路径或占位图片伪装为已提供。
- 按 [AI 使用披露](../../../CONTRIBUTING.md#ai-使用披露) 记录实际使用的工具与模型型号,多个模型分别列出。不能猜测模型、把工具名当型号或漏掉更新阶段使用的模型;未知型号作为待补项。
## 创建或更新 PR
1. 检查工作区、当前分支、远端和近期提交格式。读取实际存在的 PR 模板,按最终变更重写标题与描述。
不用提交日志拼接正文,也不假定仓库一定有模板。
@@ -55,19 +63,19 @@ PR / Issue;必要的升级、恢复说明保留适用条件与操作。判断
3. 按上述核对流程,在目标分支基线核实问题,追踪官方与本项目的相关调用链,先确定解决方向再实现。
若改动已完成,也要据此复核问题与方案;需要修正时只修改已授权的范围。仅整理标题或正文时报告差异与缺口,
不自行修改实现。原问题不成立时不将它包装成已修复的缺陷;证据不足时明确保留结论。
实现应职责清晰、表达直接、复用合理;提交前主动清理本次变更引入的重复逻辑、冗余状态、死代码、
多余抽象及无依据的兼容分支。功能可用和检查通过不能替代可维护性自查。
按 `cpr-dev-guide` 完成实现自审;后端按 [后端自审](../../../docs/architecture.md#后端自审) 核对归属、状态、失败出口与副作用,
页面先复用已有设计并检查文案密度。处理本次引入的冗余与越界问题,功能可用和检查通过不能替代可维护性自查。
4. 查找同一来源仓库、head 和 base 的现有 PR;已存在则复用其链接,不重复创建。
更新现有标题或正文须在本次整理/更新请求范围内。
5. 按贡献文档执行适用检查。记录实际验证的提交或工作区状态,区分通过、失败、跳过和未执行。
按上述文档复核步骤检查最终 diff;没有文档差异时,核对行为变化是否使现有说明失真。
前端改动提供实际界面证据或预览步骤;缺少证据时写明,不用构建成功替代视觉验收。
页面改动按上述规则补齐截图与实际交互证据,核对模型披露是否完整。
6. 将任务范围内的提交推送到正确远端,核对远端 head。标题遵循项目约定,正文先说明问题成立的依据和最终行为,
再给必要的设计取舍、验证与遗留事项。涉及官方行为时,写明官方源码位置与版本或提交、官方处理方式、
再给必要的设计取舍、自审结论、验证与遗留事项,按模板附页面截图和 AI 使用情况。涉及官方行为时,写明官方源码位置与版本或提交、官方处理方式、
本次实现与它的关系及必要的偏离原因;区分源码推断和实际复现,不用检查通过替代方案论证。按规模压缩,不强填无关章节。
7. 明确要求草稿或仍有阻塞项时创建 Draft PR,并解释原因;具备审查条件且未要求草稿时创建普通 PR。
7. 明确要求草稿或仍有阻塞项时创建或保持 Draft PR,并解释原因;必需截图或 AI 模型披露待补时也不标为可审阅。具备审查条件且未要求草稿时创建普通 PR。
GitHub Draft 状态与“只写本地草稿正文”不同,后者不创建远程对象。
8. 创建后核对链接、base、head、草稿状态和最新检查结果。网络超时或结果不确定时先查询远端,再决定是否重试。
8. 创建或更新后回读正文与附件,核对链接、base、head、草稿状态、模型披露和最新检查结果。网络超时或结果不确定时先查询远端,再决定是否重试。
CLI 示例中的变量必须先根据上述检查赋值;`pr_body_file` 是精确写入 UTF-8 正文的临时文件:
@@ -95,11 +103,12 @@ gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
主动检查本次变更新引入或加重的冗余与代码异味,说明具体位置、维护成本或行为影响及最小修复方向;
不能仅因功能可用、CI 通过而略过,也不把个人写法偏好判为缺陷。
5. UI 改动同时对照主题、共用组件、数据展示逻辑和同类页面。浏览器可用时检查相关交互及状态;
未运行的页面检查列为验证缺口,不从截图或源码推断所有状态都正确。
5. UI 改动同时对照原页面、主题、共用组件和同类页面,核对结构变化的必要性、文案是否重复及帮助是否适合收进 Popover。
按页面类型检查必需截图是否齐全、可访问且对应待审实现;缺少必需截图时要求补齐并列为未具备审阅条件。
浏览器可用时检查相关交互;未运行的检查列为验证缺口,不从截图或源码推断所有状态都正确。
6. 核对 CI 与本地证据是否覆盖当前 head;根据风险补充必要验证,不触发无关部署或真实账号业务请求。
按上述文档复核步骤检查最终 diff,不能把构建或链接检查通过当作文档内容已经合格。
工具不可用、集成测试跳过或环境不足时具体说明影响范围。
核对 AI 使用与模型披露,缺项明确指出。工具不可用、集成测试跳过或环境不足时具体说明影响范围。
7. 按 [审查标准](../../../CONTRIBUTING.md#审查标准) 用中文先回答原问题是否成立、解决方向是否合理,
再列出实现问题与验证缺口,结论附具体依据。
实现问题区分必须修复项、可选建议和验证缺口;没有的类别可省略。
@@ -0,0 +1,6 @@
interface:
display_name: "cpr-github-pr"
short_description: "提交、更新与审查 PR,核对问题、自审、页面截图和 AI 模型披露"
default_prompt: "使用 $cpr-github-pr 按当前请求准备、提交、更新或审查 PR,核实问题与方案,检查自审、验证、页面截图和 AI 模型披露。"
policy:
allow_implicit_invocation: true
+85
View File
@@ -0,0 +1,85 @@
---
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.
---
# cpr-plugin-dev
面向插件作者。插件在独立项目中实现,通过公开 SDK 与宿主通信,不向宿主业务模块添加分支。
仅修改宿主安装器、Runtime 或管理端时,使用仓库开发指南,不按本技能创建插件工程。
本仓库开发与排查同时执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),准备、提交或审查插件 PR 时执行 [cpr-github-pr](../cpr-github-pr/SKILL.md)。
## 先建立正确的模型
```text
选择能力 → 作者清单 + SDK 处理器 → 构建二进制/页面 → cpr-plugin package
↓
实际业务验证 ← 查看权限并安装、自动准备配置 ← tar.gz + sha256
```
- `contributes` 声明提供什么,`permissions` 声明访问域;安装接受记录派生授权,`bindings` 只决定功能挂载和请求匹配
- 安装包、运行配置和已启用功能不是同一件事;安装成功不能代替功能验证
- `trustedProcess` 与宿主使用相同系统身份,不是操作系统沙箱
## 开始工作
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#发布与安装来源)
本技能相对链接以各文件所在目录解析;主仓根目录是本文件所在目录向上三级。插件工程或同级示例不在磁盘上时,请用户提供位置或使用选定版本的公开资料,不猜开发机绝对路径,也不自动克隆或创建远程仓库。
## 按任务选择能力
| 用户要做什么 | 优先使用 | 需要读取的合同 |
| --- | --- | --- |
| 改写请求/响应、处理流式内容、协议转换 | `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 本地预览不等于已安装插件的热更新,宿主验证仍需重新构建、打包和切换版本
## 交付
按[构建与验证](references/development.md#验证与交付)提供源码位置、能力/权限/绑定摘要、实际运行结果及验证缺口。
开发插件不默认授权安装并启用本机进程、调用真实上游、发布依赖或推送仓库;超出当前授权时交付可安装包及操作步骤。
@@ -0,0 +1,6 @@
interface:
display_name: "cpr-plugin-dev"
short_description: "网关插件开发、接入排障、SDK 实现、页面与打包验证"
default_prompt: "使用 $cpr-plugin-dev 按当前请求开发、排查或打包 Codex Proxy RS 网关插件,复用公开 SDK 与已有页面并验证实际行为。"
policy:
allow_implicit_invocation: true
@@ -0,0 +1,114 @@
# 构建、联调与验证
仅在准备工程、构建或验证插件时读取。这里给出执行入口,不复制 SDK 协议或安装 API 的字段手册。
## 工程与依赖
| 位置 | 用途 |
| --- | --- |
| 主仓 `backend/crates/gateway-plugin/sdk/` | Rust 公开合同、会话辅助与能力文档 |
| 主仓 `backend/apps/plugin-cli/` | `cpr-plugin package`,只校验与打包,不编译插件源码 |
| [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` 与锁文件。
只需中间件时不复制示例的管理 API、页面和相关权限;CLI 等能力从 SDK 对应合同起步,不给示例添加无关能力。
本地源码联调可以使用 Cargo 路径依赖和 pnpm 的包级链接/override;路径必须真实可解析,并记录在消费方依赖配置中。
这不等于允许从业务源码跨仓 `import .../src/...`,也不能证明第三方能独立安装。
对外交付前核实 UI/SDK 的可用版本和安装来源:仅引用确实存在的发布版本,或包含所需合同的固定 Git commit;
不要把浮动 `main` 当版本,也不要自动发布依赖以补齐条件。本地 override 尚未移除时,明确说明独立安装缺口。
所用工具链以目标项目的配置为准,不因本机安装了更新版本而顺手升级。
## 复用示例
找到 `codex-proxy-plugins` 后先读根目录与 `examples/workbench/README.md`。
该仓库使用 `bash scripts/package` 构建页面、Rust 二进制并打包;前端依赖和工具配置位于示例的 `frontend/`,根目录不维护 Node 工程。执行前核对脚本适用的插件路径和目标平台。
| 任务 | 示例入口 |
| --- | --- |
| 身份、声明与资源 | `examples/workbench/plugin.json` |
| 会话启动与处理器组合 | `examples/workbench/backend/src/main.rs`、`app.rs` |
| 管理接口 | `examples/workbench/backend/src/management/` |
| 页面与主题 | `examples/workbench/frontend/src/` |
| 管理接口调用 | `examples/workbench/frontend/src/api/modules/` |
| 包内静态资源构建 | `examples/workbench/frontend/vite.config.ts` |
若派生新插件,同时修改包名、二进制名、插件身份、扩展项 ID、注册描述和构建目标,不只改展示名称。
保留当前项目确实需要的依赖、资源和权限;不要把示例中的全请求范围直接用到共享环境。
## 构建与打包
先运行目标工程已有的测试和构建入口。Rust 检查示意如下,路径、工具链和 target 按实际工程替换:
```bash
cargo fmt --manifest-path /path/to/plugin/Cargo.toml -- --check
cargo clippy --manifest-path /path/to/plugin/Cargo.toml --all-targets --all-features --locked -- -D warnings
cargo test --manifest-path /path/to/plugin/Cargo.toml --locked
cargo build --manifest-path /path/to/plugin/Cargo.toml --release --locked --target x86_64-unknown-linux-gnu
```
新工程尚无锁文件时先生成并检查锁文件;不要把缺少锁文件的 `--locked` 失败当作源码问题。
有页面时另跑该前端项目的检查与生产构建,确认产物路径、资源清单和 MIME 类型相符;无页面的插件无需 Node 项目。
先检查实际调用的 `cpr-plugin` 路径与构建来源。已有工具只有在支持目标作者清单时才复用;
遇到派生字段被要求手写或支持能力不符时,对照目标 SDK 和 CLI 合同,不通过改写清单迁就过期工具。
需要从目标宿主源码构建工具时,在主仓根目录运行以下命令,把 `--help` 换成实际参数即可,不必全局安装:
```bash
cargo run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked -- package --help
```
直接打包的示意命令:
```bash
cpr-plugin package \
--manifest /path/to/plugin/plugin.json \
--binary /path/to/plugin/target/x86_64-unknown-linux-gnu/release/plugin \
--target x86_64-unknown-linux-gnu \
--resource-map web=frontend/dist \
--output-dir /path/to/plugin/dist
```
没有 `web` 资源时去掉 `--resource-map`。映射源相对于作者清单目录,而非命令执行目录;资源必须留在插件工程内。
目标平台指运行宿主的服务器/容器,不是访问管理端的浏览器所在电脑;平台与二进制必须匹配,不能只改 target 参数伪装交叉编译。支持平台以 [CLI 文档](../../../../backend/apps/plugin-cli/README.md)及实际 `--help` 为准。
输出为 `.tar.gz` 与 `.sha256`,不是源码 zip、npm 包或页面目录。归档根目录应有生成后的 `plugin.json`、可执行文件和声明资源。
安装时以本次输出的包摘要为准,核对包内版本、平台、能力和 `engines` 与实际运行宿主匹配;
源码已更新不代表 `dist` 中旧包已重建。版本要求未满足时使用兼容宿主或确有依据的兼容声明,不跳过宿主检查。
本地同版本反复试验与对外发布区分处理;发布新内容使用明确的新版本,不覆盖已发布版本。
## 发布与安装来源
仅在用户要求发布或准备分发时执行;沿用目标插件仓库的发布流程,核对源码提交、包内版本、目标平台与产物摘要。
- GitHub 安装读取 Release 附件,发布时上传实际生成的 `.tar.gz`/`.tgz` 和校验文件;仅推送 tag 或保留 GitHub 自动生成的源码归档,不能提供可安装插件
- 稳定版应发布为非 Draft、非 Pre-release;需要支持留空标签安装时,再核对最新稳定版查询能返回预期 Release。版本号没有预发行后缀,不代表 GitHub 的发布状态已经是正式版
- 当前宿主在标签留空时只查询最新稳定版;预发行版安装需要同时指定标签并允许预发行,不把该开关解释成自动发现预发行版
- 多平台或多插件附件由用户按实际发布内容选择,文件名帮助辨识,包内清单才是平台、版本与兼容范围的依据
- 发布完成后,从公开下载来源核对附件和摘要,再按已授权的目标环境验证安装;本地包验证不等于远程发行附件已经验证
## 验证与交付
按[插件使用](../../../../docs/plugins.md)验证“安装包 → 查看权限并安装 → 自动准备默认配置 → 实际使用”;只在缺少业务必填值时补配置,不添加授权或资源选择向导。
只有获得目标环境操作授权才安装和启用;不把修改源码的授权扩展到生产实例或真实凭据操作。
需要 API 自动化时查[插件管理 API](../../../../docs/api.md#12-插件管理),不要依据记忆拼路由。
按实际实现选择验证,不为不存在的能力增加测试任务:
| 实现范围 | 应验证的行为 |
| --- | --- |
| 中间件 | 匹配/不匹配范围、授权不足、下游错误与取消;支持流式时检查多帧、终态和断开;若涉及续链,使用真实目标客户端检查 HTTP/SSE、WS 及后续轮次 |
| 模型路由/调度 | 真实入口能命中规则,未匹配请求保持原有行为;使用官方客户端正常携带的业务头和会话别名,确认投影不会拒绝合法请求,也不暴露身份字段 |
| 生命周期/用量/WS 观察 | 完成一次实际模型请求并核对对应事件;WS 观察需确认实际上游采用 WS,HTTP 请求成功不能证明 WS 观察生效 |
| 管理页面 | 从宿主打开,标题/副标题、明暗主题、加载与错误状态;管理桥请求成功;停用或版本切换后旧入口失效 |
| 入口认证/CLI | 对应身份和账号范围、缺少授权、取消与错误;不把帮助输出、注册成功或一次健康探测当完整验收 |
| 持久状态或升级 | 读写版本冲突、停用再启用后的持久状态、升级兼容性及迁移失败;版本配置恢复与私有状态迁移分别验证,不覆盖并发配置或把回滚版本误当恢复历史数据 |
真实请求按用户选择的客户端、模型和测试预算执行;没有这些能力或授权时列为缺口,不模拟成功。
外部示例测试若依赖未提供的仓库或制品,应记录为未执行;当前示例可用于实际能力联调,但不能把被忽略的测试计为通过。
临时账号、Key 或配置只在明确的测试范围内创建,记录并清理自己创建的对象,不输出密钥、原始请求或插件 secret。
交付简述:源码与产物位置、兼容范围、所需能力/权限/绑定、执行过的命令及结果、尚未验证的场景。
只有确实生成了产物才报告“已打包”,只有目标环境业务验证成功才报告“已生效”;提交、推送与发布按用户授权另行执行。
@@ -1,6 +1,6 @@
---
name: release
description: 准备或执行当前仓库的正式版、alpha/beta/rc 预发行版和 exp 实验版发版,维护中文发布说明。用于“发版”“发布 beta/rc/实验版”“准备 vX.Y.Z”“推荐版本号”“整理发布说明”“更新 release/notes.md”“打版本 tag”等发行任务。Use for version planning, release notes, and publishing stable, prerelease, or experimental releases; not for deploying/restarting an instance or authoring a release skill.
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.
---
# 发版
@@ -19,6 +19,7 @@ description: 准备或执行当前仓库的正式版、alpha/beta/rc 预发行
沿用当前会话明确的版本、范围与授权;用户已授权发布目标版本且条件满足时直接执行,不重复确认。
“准备好了”“可以发版吗”属于准备或检查,不能解释成正式发布;创建本技能也不触发发版。
发行版发布与运行实例升级分开判断,“上线”需结合上下文确定用户指哪一种。
需要修复本仓库代码、工作流或文档时先执行 [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#版本命名与升级规则),作为命名、阶段晋级和更新边界的权威约定。
@@ -45,9 +46,9 @@ description: 准备或执行当前仓库的正式版、alpha/beta/rc 预发行
- alpha 是下一正式版本的早期开发阶段,beta 是功能测试阶段,rc 是发布候选,exp 是临时功能实验。
预发行采用 `vX.Y.Z-<阶段>.N`,`N` 从 1 开始;正式版不带预发行后缀,不新增未约定的通道名称。
- 正式版只接收同一大版本内更新的正式版。alpha → beta → rc → 正式版只在相同 `X.Y.Z` 内晋级,
可以跳过阶段,不能倒退;转正后不自动进入下一轮预发行。exp 仅接收同基线、同一轮实验的后续编号,
不能跨实验线或转入其他阶段;不同实验分支不得共用一个 `X.Y.Z-exp.N` 系列。
- 更新候选由本次检查或安装选择的通道决定:Stable 仅正式版,RC 包含正式版,Beta 包含 RC 与正式版,Alpha 包含所有常规阶段。
仅接收同一大版本内严格更高的版本,可跟随后续小版本与补丁版本,默认按运行版本推导通道;临时选择不保存、不允许降级。
exp 仅接收同基线、同一轮实验的后续编号,不能跨实验线或转入其他阶段;不同实验分支不得共用一个 `X.Y.Z-exp.N` 系列。
- 核对目标工作流的构建类型:正式版及 alpha、beta、rc 为 `release`,exp 为 `experimental`。
所有预发行均标记为 Pre-release,不设置 GitHub Latest、不覆盖镜像 `latest`。
发布预发行前记录这两个稳定入口,发布后回读确认未改变。
@@ -0,0 +1,6 @@
interface:
display_name: "cpr-release"
short_description: "版本规划、中文发布说明、正式与预发行发布及失败恢复"
default_prompt: "使用 $cpr-release 按当前请求准备版本与中文说明、执行已授权的发版或恢复失败步骤,核对渠道与实际结果。"
policy:
allow_implicit_invocation: true
-47
View File
@@ -1,47 +0,0 @@
---
name: dev-guide
description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关、Vue 管理端、账号与出站代理、接口协议、部署、CI、发布或文档时使用,定位权威规范和验证入口。Use for development in the repository containing this skill; not for generic Rust/Vue questions in unrelated repositories.
---
# 开发指南
先读根目录的 [AGENTS.md](../../../AGENTS.md) 和 [CONTRIBUTING.md](../../../CONTRIBUTING.md)。
本技能只组织工作入口,架构、协议和视觉规则从下列权威文档读取,不维护第二份副本。
## 按任务读取
| 任务 | 技能与文档入口 |
| --- | --- |
| 用户能力、快速开始 | [README](../../../README.md) |
| 模块职责、调用链、状态归属 | [系统架构](../../../docs/architecture.md) |
| Rust 开发 | 加载并使用 `$rust-best-practices`,结合 [系统架构](../../../docs/architecture.md) 与 [项目约定](../../../CONTRIBUTING.md#项目约定) |
| HTTP 路由、请求与响应、导入导出 | [API 文档](../../../docs/api.md) |
| 前端开发、管理端组件、主题 Token、视觉验证 | 按下方「前端任务」加载设计约束,结合 [管理端主题](../../../docs/theme.md) 与 [界面验证](../../../CONTRIBUTING.md#界面验证) |
| 常规文档更新 | [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件):维护当前说明,在原章节就地修订,不记录变更历史 |
| 部署、客户端配置、备份与恢复 | [部署说明](../../../deploy/README.md) |
| 数据迁移、冻结清单与本地测试库 | [迁移说明](../../../backend/migrations/README.md) |
| 验证命令与提交规范 | [贡献与审查](../../../CONTRIBUTING.md) |
| 创建或审查 PR | [github-pr](../github-pr/SKILL.md) |
| 整理或提交 Issue | [github-issue](../github-issue/SKILL.md) |
| 发布说明、版本规划与发版 | [release](../release/SKILL.md) |
## 前端任务
- 加载并使用 `$frontend-design`,先读 [界面文案与信息层级](../../../docs/theme.md#界面文案与信息层级),将本项目的管理工具风格、现有字体与主题 Token 作为设计约束
- 开发前按该章节的参考入口和相邻页面核对布局、文案密度与操作位置,再选择共用组件
- 交付前按该章节检查界面文案中的句号、分号,以及过大的标题、重复说明和说明块占用的空间,结合 [界面验证](../../../CONTRIBUTING.md#界面验证) 核对实际页面
## 执行顺序
1. 从仓库根目录确认工作区、当前任务范围和相关运行实例;保留已有改动。
2. 按 `AGENTS.md` 的 CodeGraph 约定定位入口、调用链和职责归属,再阅读相关实现。
3. 按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 核实触发条件、问题发生版本与目标分支现状,
优先依据适用的官方源码确定方案。结合上表读取所需文档,先找可复用的同类实现,再修改所属模块。
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;
按 [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否修订,不为每次改动追加文档或发布说明。
5. 交付前按文档职责逐段复核最终文档 diff:新增内容是否回答当前读者的使用或维护问题,是否属于该文档,
是否与其他位置重复,脱离本次变更背景能否独立理解。仅解释实现经过、前后对比或验证结果的内容移到
任务报告或 PR;仍影响升级、恢复的说明保留适用条件与操作。没有文档差异时,只核对行为变化是否使现有说明失真。
6. 按 `CONTRIBUTING.md` 验证并报告结果。后端 Cargo 命令从 `backend/` 执行,或显式指定该目录的 manifest。
发布任务按 `release` 技能读取当前版本、发布入口和工作流,发布说明语言遵循 `CONTRIBUTING.md`。
@@ -1,4 +0,0 @@
interface:
display_name: "dev-guide"
short_description: "从仓库规范与架构文档定位实现、验证和交付的正确入口"
default_prompt: "使用 $dev-guide 按当前仓库规范与架构完成开发、验证或发布。"
@@ -1,4 +0,0 @@
interface:
display_name: "github-issue"
short_description: "按仓库表单整理复现证据、检查重复反馈并提交脱敏 Issue"
default_prompt: "使用 $github-issue 将当前问题整理为符合仓库表单的 Issue 并提交。"
@@ -1,4 +0,0 @@
interface:
display_name: "github-pr"
short_description: "创建与审查拉取请求,先核实问题,以官方源码为首要依据确定或评估实现方案"
default_prompt: "使用 $github-pr 按当前请求创建或审查 PR,先核实问题是否成立,以官方源码为首要参考确定或评估解决方案,说明源码依据及必要的偏离原因,并按仓库规范实现与验证或报告审查结论。"
@@ -1,4 +0,0 @@
interface:
display_name: "release"
short_description: "按仓库流程准备稳定版或实验版的中文说明,完成发行与渠道验证"
default_prompt: "使用 $release 按指定的稳定版或实验版渠道准备中文发布说明,并核对发版条件。"
+6 -1
View File
@@ -1,8 +1,10 @@
version: 2
# 标签由维护者手动添加,各生态关闭默认打标,避免重新创建旧标签。
updates:
- package-ecosystem: github-actions
directory: /
labels: []
schedule:
interval: weekly
day: monday
@@ -16,6 +18,7 @@ updates:
- package-ecosystem: cargo
directory: /backend
labels: []
schedule:
interval: weekly
day: monday
@@ -49,7 +52,8 @@ updates:
- patch
- package-ecosystem: npm
directory: /frontend
directory: /
labels: []
schedule:
interval: weekly
day: monday
@@ -65,6 +69,7 @@ updates:
- package-ecosystem: docker
directory: /deploy
labels: []
schedule:
interval: weekly
day: monday
+20
View File
@@ -0,0 +1,20 @@
## 问题与改动
<!-- 说明触发场景、问题依据和最终行为;涉及上游时注明官方源码版本与位置。 -->
## 自审与验证
<!-- 简述实际执行的检查、结果与缺口,不把跳过写成通过。
后端说明相关职责归属、复用与边界自审结果;页面说明沿用的设计及必要调整。
流程见 CONTRIBUTING.md;AI 开发执行 cpr-dev-guide,准备或更新 PR 执行 cpr-github-pr。 -->
## 页面截图
<!-- 无页面变更时删除本节。有页面变更必须嵌入或链接审查者可访问的实际截图:
现有页面提供前后对比,新增页面提供完成态;补充受影响状态,浮层展示展开状态。
注明入口、状态和对应提交;预览步骤、录屏、本机路径不能替代截图。截图待补时保留 Draft。 -->
## AI 使用
<!-- 写明未使用 AI;使用时列出工具名称与实际模型型号,多模型分别列出,可补充参与范围。
不能只填 Codex、ChatGPT 等工具或产品名。型号不可得时如实标为待补,不猜测,补齐前保留 Draft。 -->
+2 -2
View File
@@ -28,7 +28,7 @@ jobs:
- name: Setup Docker Buildx
id: buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Cache Cargo mounts
id: cargo-cache
@@ -56,7 +56,7 @@ jobs:
skip-extraction: ${{ steps.cargo-cache.outputs.cache-hit }}
- name: Build runtime image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: deploy/Dockerfile
+1 -1
View File
@@ -161,7 +161,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 11.7.0
package_json_file: frontend/package.json
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+65
View File
@@ -26,6 +26,7 @@ jobs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
container: ${{ steps.filter.outputs.container }}
source: ${{ steps.filter.outputs.source }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -45,6 +46,13 @@ jobs:
frontend:
- 'frontend/**'
- '.github/workflows/**'
source:
- '.gitmodules'
- 'modules/ui'
- 'modules/plugins'
- 'frontend/**'
- 'backend/crates/gateway-plugin/sdk/**'
- '.github/workflows/ci.yml'
container:
- 'backend/**'
- 'frontend/**'
@@ -72,3 +80,60 @@ jobs:
uses: ./.github/workflows/_container.yml
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
source-development:
needs: changes
if: needs.changes.outputs.source == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout with development modules
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
- 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
- name: Setup Rust
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
- name: Check dependency pins
run: |
ui_revision="$(git -C modules/ui rev-parse HEAD)"
for lockfile in frontend/pnpm-lock.yaml modules/plugins/examples/workbench/frontend/pnpm-lock.yaml; do
locked_revision="$(sed -nE 's@.*codeload.github.com/zyycn/codex-proxy-ui/tar.gz/([a-f0-9]{40}).*@\1@p' "$lockfile" | sort -u)"
if [ "$locked_revision" != "$ui_revision" ]; then
echo "::error file=$lockfile::UI dependency must match modules/ui ($ui_revision)"
exit 1
fi
done
- name: Install development dependencies
run: |
pnpm --dir modules/ui install --frozen-lockfile
pnpm --dir frontend install --frozen-lockfile
pnpm --dir modules/plugins/examples/workbench/frontend install --frozen-lockfile
- name: Check UI source integration
run: |
pnpm --dir frontend build:source
pnpm --dir modules/plugins/examples/workbench/frontend build:source
- name: Check local SDK integration
env:
RUST_MIN_STACK: 16777216
CARGO_TARGET_DIR: ${{ github.workspace }}/backend/target/plugin-development
run: |
sdk_check_dir="$RUNNER_TEMP/plugin-source"
mkdir -p "$sdk_check_dir/backend"
cp modules/plugins/examples/workbench/backend/Cargo.{toml,lock} "$sdk_check_dir/backend/"
cp -R modules/plugins/examples/workbench/backend/{src,tests} "$sdk_check_dir/backend/"
cp modules/plugins/examples/workbench/plugin.json "$sdk_check_dir/"
cargo test --manifest-path "$sdk_check_dir/backend/Cargo.toml" \
--config "patch.\"https://github.com/zyycn/codex-proxy-rs.git\".gateway-plugin-sdk.path=\"$GITHUB_WORKSPACE/backend/crates/gateway-plugin/sdk\""
- name: Ensure source checks preserve lockfiles
run: |
git diff --exit-code -- frontend/package.json frontend/pnpm-lock.yaml backend/Cargo.lock
git -C modules/plugins diff --exit-code -- examples/workbench/frontend/package.json examples/workbench/frontend/pnpm-lock.yaml examples/workbench/backend/Cargo.lock
+78 -10
View File
@@ -318,6 +318,7 @@ jobs:
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
@@ -364,6 +365,8 @@ jobs:
compose = compose.replace(default_image, versioned_image)
(release_dir / "compose.yaml").write_text(compose)
assets = [release_dir / config_template.name, release_dir / "compose.yaml"]
shutil.rmtree(package_dir, ignore_errors=True)
release_packages = []
for platform in platforms:
platform_name = f"{platform['os']}-{platform['arch']}"
@@ -372,8 +375,7 @@ jobs:
if not binary_src.is_file():
raise SystemExit(f"Missing binary artifact: {binary_src}")
shutil.rmtree(package_dir, ignore_errors=True)
package_root = package_dir / APP_NAME
package_root = package_dir / platform_name / APP_NAME
web_dist = package_root / "web" / "dist"
web_dist.mkdir(parents=True)
@@ -387,11 +389,37 @@ jobs:
archive_name = f"{APP_NAME}_{version}_{platform['os']}_{platform['arch']}.{platform['archive']}"
archive_path = release_dir / archive_name
if platform["archive"] == "tar.gz":
with tarfile.open(archive_path, "w:gz") as archive:
archive.add(package_root, arcname=APP_NAME)
else:
if platform["archive"] != "tar.gz":
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")
assets.append(plugin_manifest_path)
# 保留运行时清单目录合同,宿主包不再携带教学示例。
for platform, package_root, archive_path in release_packages:
official_dir = package_root / "plugins" / "official"
official_dir.mkdir(parents=True)
bundled_manifest = official_dir / plugin_manifest_path.name
shutil.copy2(plugin_manifest_path, bundled_manifest)
bundled_manifest.chmod(0o444)
official_dir.chmod(0o555)
with tarfile.open(archive_path, "w:gz") as archive:
archive.add(package_root, arcname=APP_NAME)
assets.append(archive_path)
checksum_path = release_dir / "checksums.txt"
@@ -415,7 +443,7 @@ jobs:
build-docker-image:
name: build-docker-image (${{ matrix.dockerPlatform }})
needs: [release-info, quality, build-docker-binary]
needs: [release-info, quality, build-docker-binary, package-assets]
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.release-info.outputs.docker_matrix) }}
@@ -448,6 +476,12 @@ 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
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-assets
path: dist/release-assets
- name: Prepare Docker prebuilt assets
shell: bash
env:
@@ -457,6 +491,7 @@ jobs:
run: |
set -euo pipefail
python3 - <<'PY'
import json
import shutil
import os
import pathlib
@@ -472,10 +507,40 @@ jobs:
binary_dest = target_dir / app_name
shutil.copy2(binary_src, binary_dest)
binary_dest.chmod(0o755)
release_dir = pathlib.Path("dist/release-assets")
manifest_path = release_dir / "plugin-release-manifest.json"
manifest = json.loads(manifest_path.read_text())
if manifest.get("schema_version") != 1 or manifest.get("sealed") is not True:
raise SystemExit("Invalid sealed plugin release manifest")
official_dir = target_dir / "plugins" / "official"
official_dir.mkdir(parents=True)
bundled_manifest = official_dir / manifest_path.name
shutil.copy2(manifest_path, bundled_manifest)
bundled_manifest.chmod(0o444)
selected = 0
for plugin in manifest.get("plugins", []):
artifacts = [
artifact for artifact in plugin.get("artifacts", [])
if artifact.get("release_os") == os.environ["MATRIX_OS"]
and artifact.get("release_architecture") == os.environ["MATRIX_ARCH"]
]
if len(artifacts) != 1:
raise SystemExit(f"Official plugin platform selection is not unique: {plugin.get('id')}")
archive = release_dir / artifacts[0]["archive"]
if not archive.is_file():
raise SystemExit(f"Missing official plugin archive: {archive.name}")
bundled_archive = official_dir / archive.name
shutil.copy2(archive, bundled_archive)
bundled_archive.chmod(0o444)
selected += 1
if selected != len(manifest.get("plugins", [])):
raise SystemExit("Official plugin release bundle is incomplete")
official_dir.chmod(0o555)
PY
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
@@ -486,7 +551,7 @@ jobs:
- name: Build and push app image
id: build-and-push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: deploy/Dockerfile
@@ -565,7 +630,7 @@ jobs:
merge-multiple: true
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
@@ -684,6 +749,9 @@ jobs:
**手动下载:**
请从下方附件中下载适合当前平台的归档包。
附件包含与当前宿主版本及提交绑定的 `plugin-release-manifest.json`,当前没有默认内置插件。
教学示例由独立的 `codex-proxy-plugins` 仓库维护和发布,不随宿主发行物分发。
发布归档提供 GitHub 构建产物证明。Docker 镜像附带软件物料清单(SBOM)、构建来源证明和 cosign 无密钥签名。
## 📚 文档
+4 -4
View File
@@ -38,9 +38,9 @@ jobs:
- name: Install cargo-audit
run: cargo install cargo-audit --version 0.22.2 --locked
- name: Run cargo audit
working-directory: backend
run: cargo audit
- name: Run Cargo audits
run: |
cargo audit --file backend/Cargo.lock
frontend-security:
runs-on: ubuntu-latest
@@ -54,7 +54,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 11.7.0
package_json_file: frontend/package.json
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+4 -1
View File
@@ -7,8 +7,8 @@
/frontend/node_modules/
/frontend/dist/
/frontend/.vite/
/.pnpm-store/
/frontend/.pnpm-store/
/frontend/.pnpm-cache/
*.tsbuildinfo
# Release artifacts
@@ -20,6 +20,9 @@
# Local configuration and secrets
/deploy/config.yaml
.env
.env.*
!.env.example
# Editors and operating systems
/.idea/
+6
View File
@@ -0,0 +1,6 @@
[submodule "modules/ui"]
path = modules/ui
url = https://github.com/zyycn/codex-proxy-ui.git
[submodule "modules/plugins"]
path = modules/plugins
url = https://github.com/zyycn/codex-proxy-plugins.git
+17 -3
View File
@@ -1,6 +1,17 @@
# 项目协作约定
开发和审查先阅读 [CONTRIBUTING.md](CONTRIBUTING.md),使用其中的协作流程和审查标准。
按用户意图和当前会话主动加载并执行仓库技能,不要求用户写出 `$技能名`:
| 任务意图与常见说法 | 必用技能 |
| --- | --- |
| 开发、实现、修复、排查报错、审查问题或代码、修改文档 | [cpr-dev-guide](.agents/skills/cpr-dev-guide/SKILL.md) |
| 提交/提个 PR、准备或更新 PR、提交到已有 PR、看下 PR、处理 PR 审查意见 | [cpr-github-pr](.agents/skills/cpr-github-pr/SKILL.md);需要修改时同时执行 cpr-dev-guide |
| 提 Issue、反馈到仓库、把问题或建议整理成 Issue | [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)。
模块职责以 [系统架构](docs/architecture.md) 为准,界面约定以 [管理端主题](docs/theme.md) 为准;
没有明确约定时保持与相邻实现一致,不把单次页面反馈扩展成全仓规则。
@@ -19,10 +30,13 @@ If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is
- 开发和审查按 [问题与方案依据](CONTRIBUTING.md#问题与方案依据) 先核实问题,以官方源码为首要参考确定或评估方案。
- 先定位变更所属模块、同类实现和上下游调用关系,再选择最小合理改动。复用已有能力,也避免为尚不存在的需求增加抽象。
- 提交前自审最终差异,先处理本次引入的职责混淆、重复规则、冗余状态和无依据的兼容分支;后端按 [后端自审](docs/architecture.md#后端自审) 追踪状态与资源生命周期,不能把自审留给 CI 或 PR 审查者。
- 页面变更先复用已有页面设计与组件,不因个人偏好重排页面或另建交互。文案取舍遵循 [界面文案与信息层级](docs/theme.md#界面文案与信息层级),页面 PR 必须提供 [实际截图](CONTRIBUTING.md#界面验证)。
- 代码注释使用中文,解释原因与边界;提交信息使用英文,沿用历史中的 Conventional Commits 格式。
- 按变更范围执行验证,记录命令、结果和缺口。跳过的测试不算通过;界面与集成行为需要对应运行证据,构建通过不能替代实际验收。
- 审查请求默认只读。修复、提交、推送和合并按用户当前授权执行;“本地验证通过”不等于用户已经审阅批准。
- 不读取或输出无关凭据,不把真实密钥、代理认证、账号令牌或请求转储放入提交、截图及审查报告。
- 使用 AI 的 PR 按 [AI 使用披露](CONTRIBUTING.md#ai-使用披露) 写明实际模型型号,不以工具名称代替模型。
## Code Review Rules
@@ -36,8 +50,8 @@ If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is
### 代码与界面一致性
- 对重复逻辑、冗余状态和多余抽象给出具体位置及影响;建议复用时指出已有实现,不能仅凭行数或语法相似判定质量。
- UI 变更对照项目基础组件、主题和同类页面,检查交互、状态、响应式与可访问性。局部问题是否应在共用组件修复,按职责判断,不一律禁止局部样式。
- 核对截图、预览和实际交互证据;未检查的状态明确列为验证缺口,不推断视觉验收已经通过。
- UI 变更对照原页面、项目基础组件、主题和同类页面,检查布局变化是否服务本次需求、是否堆叠重复文案,以及交互、状态、响应式与可访问性。局部问题是否应在共用组件修复,按职责判断,不一律禁止局部样式。
- 核对页面 PR 按 [界面验证](CONTRIBUTING.md#界面验证) 提供的截图与实际交互证据;缺少必需截图时明确要求补齐,未检查的状态列为验证缺口,不推断视觉验收已经通过。
### 审查输出
+37 -10
View File
@@ -31,8 +31,8 @@
## PR 流程
1. **准备变更。** 按上述依据核实问题与方案,找到所属模块、相关调用链和最接近的现有实现,在功能分支完成修改与自查。涉及架构、公开合同或较大交互变化时,先说明方案与取舍。
2. **发起 PR。** 目标分支使用 `main`。说明问题成立的依据、触发场景、改动后的行为和实际验证结果;涉及官方行为时注明源码位置、版本和方案关系。界面改动提供截图、录屏或可复现的预览方式。一个 PR 围绕一个明确目标,必要重构说明关联原因。
1. **准备变更。** 按上述依据核实问题与方案,找到所属模块、相关调用链和最接近的现有实现,在功能分支完成修改。提交前自审完整差异,清理本次引入的重复逻辑、冗余状态与职责越界;后端按 [后端自审](docs/architecture.md#后端自审) 执行。涉及架构、公开合同或较大交互变化时,先说明方案与取舍。
2. **发起 PR。** 目标分支使用 `main`,按 [PR 模板](.github/pull_request_template.md) 说明问题依据、触发场景、最终行为、自审和实际验证结果;涉及官方行为时注明源码位置、版本和方案关系。页面变更必须按 [界面验证](#界面验证) 附实际截图,使用 AI 时按 [AI 使用披露](#ai-使用披露) 写明模型。一个 PR 围绕一个明确目标,必要重构说明关联原因。
3. **自动检查与初审。** CI 执行适用的格式、静态分析、测试和构建检查;启用 AI 审查后,由它按本规范辅助检查语义、复用和一致性。提交者先处理明确问题,维护者重点审阅方案、风险与用户体验。
4. **修改与合并。** 更新代码后重新执行受影响的检查,确认审查意见已处理,验证证据对应当前变更。维护者审阅批准后再合并;需要升级说明的变更同步相关文档。
@@ -41,16 +41,25 @@ AI 开发和审查入口是根目录的 [AGENTS.md](AGENTS.md)。在仓库上下
### 项目技能
仓库技能位于 `.agents/skills/`,随代码共享。技能提供执行流程,协作与审查标准仍以本文和 `AGENTS.md` 为准。
AI 开发、排查、代码审查和文档修改必须执行 `cpr-dev-guide`;准备、创建、更新及审查 PR 必须执行 `cpr-github-pr`,不以用户是否显式点名技能为条件。PR 内修复仍须经过开发流程,不能用填写 PR 正文代替实现自审与验证。
| 技能 | 用途 | 调用示例 |
| --- | --- | --- |
| [dev-guide](.agents/skills/dev-guide/SKILL.md) | 项目开发与文档导航 | `$dev-guide 修复当前问题` |
| [github-pr](.agents/skills/github-pr/SKILL.md) | 创建或审查 PR | `$github-pr 创建当前分支的 PR` / `$github-pr 审查 PR #编号` |
| [github-issue](.agents/skills/github-issue/SKILL.md) | 整理或提交 Issue | `$github-issue 提交当前问题` |
| [release](.agents/skills/release/SKILL.md) | 准备发布说明或正式发版 | `$release 整理本次发布说明` / `$release 发布 vX.Y.Z` |
| [cpr-dev-guide](.agents/skills/cpr-dev-guide/SKILL.md) | 项目开发与文档导航 | `$cpr-dev-guide 修复当前问题` |
| [cpr-plugin-dev](.agents/skills/cpr-plugin-dev/SKILL.md) | 网关插件开发、打包与验证 | `$cpr-plugin-dev 开发一个添加响应头的插件` |
| [cpr-github-pr](.agents/skills/cpr-github-pr/SKILL.md) | 准备、创建、更新或审查 PR | `$cpr-github-pr 创建当前分支的 PR` / `$cpr-github-pr 审查 PR #编号` |
| [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 自动审查。
### AI 使用披露
PR 正文说明提交者是否使用 AI。使用 AI 参与方案、代码、文档、测试或自审时,写明工具名称和实际模型型号;使用多个模型时分别列出,可简述各自参与范围。
`Codex`、`ChatGPT`、`Claude Code` 等工具或产品名称不能代替模型型号,优先填写工具显示或调用配置中的具体模型标识。
无法获知型号时如实标记「型号未提供,待补充」,不能猜测或伪造,也不能把待补信息称为已完成披露;补齐前保留 Draft。
更新 PR 时同步新增的模型信息;这一披露不替代提交者自审、验证责任或已有提交署名约定。
### 规则文件保护
PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或重命名。调整这些文件时先提交 Issue 说明原因和建议,由维护者处理;[规则保护工作流](.github/workflows/agents-md-guard.yml) 会提示并关闭包含此类变更的 PR。撤销相关变更后可以重新打开 PR,继续正常检查。
@@ -98,7 +107,7 @@ PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或
只有改动使对应说明失真、缺少必要信息,或改变读者需要执行的操作与判断时,才更新文档。
内部修复、重构和界面微调不默认附带文档改动;审查时不能仅因 PR 没改文档就判定存在缺口。
- 按 [开发指南的文档入口](.agents/skills/dev-guide/SKILL.md#按任务读取) 找到内容所属文档,在原有章节就地修订,
- 按 [开发指南的文档入口](.agents/skills/cpr-dev-guide/SKILL.md#按任务读取) 找到内容所属文档,在原有章节就地修订,
不按每次变更追加说明。同一事实只保留一处完整说明,其他文档按读者任务提供必要摘要和链接。
- API 文档保留调用所需的字段、状态、语义和限制;架构文档保留职责与不变量;部署文档保留配置、操作和排障。
不把界面布局、内部锁或索引实现写进接口说明,也不把前端库的实现选择写进部署说明。
@@ -109,13 +118,21 @@ PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或
## 界面验证
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有组件为依据,在任务范围内改进布局、视觉与交互。
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有页面为依据,优先延续原页面的布局、信息层级与交互;设计技能不构成重做页面的理由。
开发前查找同类页面及 `components/base` 中的基础组件,优先使用已有主题 Token、交互和数据展示逻辑。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。
开发前查找原页面、同类页面及 `modules/ui/src/components` 中的基础组件,明确参考实现和可复用部分,再确定最小改动。只有原设计不能满足本次操作或存在具体问题时才调整结构,并在 PR 中说明原因;不顺带换风格、重排无关区域或增加重复的模式入口。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。
列表列宽、序号、按钮、分页和弹窗尺寸由实际任务与数据规模决定,以同类页面为参照。新增或调整交互时说明用户需要完成什么操作,不把某个页面的取舍推广成全局禁令。
提交者在浏览器中验证受影响路径,提供前后截图、录屏或预览步骤,并标明实际覆盖的状态。按变更选择浅色/深色、窄窗口、空/加载/错误状态、大量数据及键盘操作;共用主题或基础组件变更按主题文档扩大验证范围。模拟数据与预览材料不包含真实凭据。
提交者在浏览器中验证受影响路径,页面变更的 PR 必须提供实际运行截图,包括布局、文案、样式和交互状态的变化:
- 修改现有页面提供变更前后截图,尽量保持相同数据、主题和窗口尺寸;新增页面提供完成态截图。截图保留必要上下文,让审查者能判断布局和信息密度,不只截取孤立控件。
- 按影响范围补充浅色/深色、窄窗口及空/加载/错误等受影响状态;共用主题或基础组件变更按主题文档扩大验证范围。弹出层中的内容需展示展开状态,不能只截关闭后的页面。
- 在 PR 正文嵌入或链接审查者可访问的图片,注明页面入口、状态和对应提交。录屏、预览地址和复现步骤用于补充,不能替代截图;本机绝对路径不算可供远端审阅的附件。
- 视觉或交互继续修改后更新受影响截图,使证据对应待审实现。截图与模拟数据不得包含真实凭据,不用设计稿、生成图片或旧版截图冒充运行结果。
- 环境不足时说明缺少的截图和交互验证,不宣称验收通过;页面 PR 在截图补齐前保留 Draft,不标为可审阅或建议合并。
截图用于视觉审阅,保存、切换、错误恢复和键盘操作等行为仍需实际验证并记录覆盖范围,不能仅凭静态图片推断交互正确。
涉及浏览器能力或访问环境差异时,按项目支持的部署方式核对相关 HTTP/HTTPS、localhost/实际部署地址与浏览器版本,记录实际覆盖的条件;本地预览正常不能替代部署环境中的交互验收。
@@ -131,6 +148,16 @@ 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`。
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 提交,不依赖浮动分支。
前端从仓库根目录执行:
```bash
+8 -9
View File
@@ -99,7 +99,7 @@ API Key 持有者可在同一登录页切换登录身份,进入 `/key-usage`
| Base URL | `http://127.0.0.1:8080/v1`;远程接入使用服务器的 HTTPS 地址 |
| API Key | 管理端创建的客户端密钥 |
可用模型以该密钥查询到的模型列表为准:
查询该密钥可见的模型目录:
```bash
curl http://127.0.0.1:8080/v1/models \
@@ -108,14 +108,13 @@ curl http://127.0.0.1:8080/v1/models \
## 文档
- [客户端接入与生图](deploy/README.md#客户端配置)
- [部署、备份与恢复](deploy/README.md)
- [API 参考](docs/api.md)
- [模型定价与手动同步](docs/api.md#模型定价)
- [系统架构](docs/architecture.md)
- [管理端主题](docs/theme.md)
- [数据库迁移](backend/migrations/README.md)
- [贡献与审查](CONTRIBUTING.md)
| 任务 | 文档 |
| --- | --- |
| 部署与使用 | [部署、备份与恢复](deploy/README.md) · [客户端接入与生图](deploy/README.md#客户端配置) |
| 接口集成 | [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) |
## 社区
+687 -71
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -1,10 +1,13 @@
[workspace]
members = [
"apps/gateway",
"apps/plugin-cli",
"crates/gateway-admin",
"crates/gateway-api",
"crates/gateway-core",
"crates/gateway-host",
"crates/gateway-plugin/runtime",
"crates/gateway-plugin/sdk",
"crates/gateway-protocol",
"crates/gateway-store",
"crates/providers/openai",
+1
View File
@@ -16,6 +16,7 @@ gateway-admin = { path = "../../crates/gateway-admin" }
gateway-api = { path = "../../crates/gateway-api" }
gateway-core = { path = "../../crates/gateway-core" }
gateway-host = { path = "../../crates/gateway-host" }
gateway-plugin-runtime = { path = "../../crates/gateway-plugin/runtime" }
gateway-store = { path = "../../crates/gateway-store" }
provider-openai = { path = "../../crates/providers/openai" }
provider-xai = { path = "../../crates/providers/xai" }
+286 -30
View File
@@ -52,6 +52,47 @@ impl LoadableConfig for GatewayConfig {
/// 按冻结顺序初始化全部 Bundle,并把进程阻塞权交给 Host。
pub async fn run() -> Result<(), BootstrapError> {
launch(None).await.map(|_| ())
}
/// 这里只接收插件命名空间内的参数,不转交宿主配置或其他启动 secret。
pub struct PluginCommand {
pub instance_id: Option<String>,
pub name: Option<String>,
pub arguments: Vec<String>,
}
impl PluginCommand {
fn is_help(&self) -> bool {
self.name.is_none()
|| self
.arguments
.iter()
.any(|value| matches!(value.as_str(), "--help" | "-h"))
}
}
pub async fn plugin_command(
command: PluginCommand,
) -> Result<gateway_plugin_runtime::PluginCommandOutput, BootstrapError> {
launch(Some(command)).await
}
async fn launch(
command: Option<PluginCommand>,
) -> Result<gateway_plugin_runtime::PluginCommandOutput, BootstrapError> {
macro_rules! or_shutdown {
($runtime:expr, $result:expr) => {
match $result {
Ok(value) => value,
Err(error) => {
$runtime.shutdown().await;
return Err(error.into());
}
}
};
}
let config = gateway_host::load_config::<GatewayConfig>()?;
let GatewayConfig {
schema_version: _,
@@ -63,47 +104,217 @@ pub async fn run() -> Result<(), BootstrapError> {
openai,
} = config;
let host = gateway_host::initialize(host).await?;
let plugin_cache = host.runtime_data_dir().join("plugins");
let host = if command.is_some() {
gateway_host::initialize_command_line(host).await?
} else {
gateway_host::initialize(host).await?
};
host.report_startup_ready("Host");
let mut store = gateway_store::initialize(store).await?;
let help = command.as_ref().is_some_and(PluginCommand::is_help);
let mut store = if help {
gateway_store::initialize_read_only(store).await?
} else if command.is_some() {
gateway_store::initialize_command_line(store).await?
} else {
gateway_store::initialize(store).await?
};
host.report_startup_ready("Store");
let version = host
.system_operations()
.version()
.await
.map_err(|_| ConfigError::InvalidField("host.version"))?
.version;
let plugin_http = std::sync::Arc::new(gateway_host::outbound::HttpClient::new()?);
if let Some(command) = command.as_ref().filter(|_| help) {
let runtime = prepare_plugins(&store, plugin_cache, &version, plugin_http.clone())?;
let commands = or_shutdown!(runtime, runtime.prepare_command_line().await);
let stdout = commands.help(command.instance_id.as_deref(), command.name.as_deref());
commands.shutdown().await;
runtime.shutdown().await;
return Ok(gateway_plugin_runtime::PluginCommandOutput {
stdout: stdout?,
stderr: String::new(),
exit_code: 0,
saved_accounts: 0,
});
}
let provider_ports = store.provider_ports();
let mut openai = provider_openai::initialize(openai, provider_ports.clone()).await?;
host.report_startup_ready("OpenAI Provider");
let mut xai = provider_xai::initialize(provider_ports).await?;
host.report_startup_ready("xAI Provider");
let providers = ProviderRegistry::new([openai.core_provider(), xai.core_provider()])?;
let mut core = gateway_core::initialize(store.core_ports(), providers).await?;
let plugin_runtime = prepare_plugins(&store, plugin_cache, &version, plugin_http.clone())?;
let admin_providers = gateway_admin::ports::provider::ProviderAdminRegistry::new([
openai.admin_provider(),
xai.admin_provider(),
])
.map_err(|_| gateway_admin::model::AdminError::invalid("Provider 管理身份冲突"))?;
if let Some(command) = command {
let instance_id = or_shutdown!(
plugin_runtime,
command
.instance_id
.as_deref()
.ok_or_else(|| gateway_admin::model::AdminError::invalid("插件实例 ID 缺失"))
);
let name = or_shutdown!(
plugin_runtime,
command
.name
.as_deref()
.ok_or_else(|| gateway_admin::model::AdminError::invalid("插件命令名缺失"))
);
let core = gateway_core::prepare_command_plane(
store.core_ports(),
providers.clone(),
Some(plugin_runtime.clone()),
Some(plugin_runtime.observer_registry()),
Some(plugin_runtime.policy_registry()),
Some(plugin_runtime.middleware_registry()),
);
let accounts = gateway_admin::initialize_plugin_accounts(
admin_providers.clone(),
store.admin_ports().accounts(),
core.snapshot_control(),
);
or_shutdown!(plugin_runtime, plugin_runtime.bind_account_ports(&accounts));
let keys = gateway_admin::initialize_plugin_client_keys(
admin_providers.clone(),
store.admin_ports().client_keys(),
core.snapshot_control(),
);
or_shutdown!(plugin_runtime, plugin_runtime.bind_client_key_ports(&keys));
let core = or_shutdown!(plugin_runtime, core.activate().await);
let nested_models = core.nested_model_execution_port();
let affinity_lookup = core.affinity_lookup_port();
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup)
);
let commands = or_shutdown!(plugin_runtime, plugin_runtime.prepare_command_line().await);
or_shutdown!(plugin_runtime, store.start_command_line_writes());
let result = commands
.execute_cancellable(instance_id, name, &command.arguments, &host.cancellation())
.await;
commands.shutdown().await;
plugin_runtime.shutdown().await;
store.shutdown_command_line_writes().await?;
return Ok(result?);
}
let core = gateway_core::prepare(
store.core_ports(),
providers,
Some(plugin_runtime.clone()),
Some(plugin_runtime.observer_registry()),
Some(plugin_runtime.policy_registry()),
Some(plugin_runtime.middleware_registry()),
Some(plugin_runtime.frontend_authentication_registry()),
);
let plugin_accounts = gateway_admin::initialize_plugin_accounts(
admin_providers.clone(),
store.admin_ports().accounts(),
core.snapshot_control(),
);
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_account_ports(&plugin_accounts)
);
let plugin_keys = gateway_admin::initialize_plugin_client_keys(
admin_providers.clone(),
store.admin_ports().client_keys(),
core.snapshot_control(),
);
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_client_key_ports(&plugin_keys)
);
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();
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup)
);
host.report_startup_ready("Core");
let mut admin = gateway_admin::initialize(
admin,
client,
store.admin_ports(),
gateway_admin::AdminRuntimePorts {
pricing_source: std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
providers: vec![openai.admin_provider(), xai.admin_provider()],
snapshot: core.snapshot_control(),
account_probe: core.account_probe(),
proxy_probe: host.proxy_probe(provider_openai::build_reqwest_client_with_custom_ca),
client_distribution: host.client_distribution_resolver(),
system: host.system_operations(),
client_key_verifier: core.client_key_verifier(),
},
)
.await?;
let host_version = or_shutdown!(
plugin_runtime,
version
.trim_start_matches('v')
.parse()
.map_err(|_| ConfigError::InvalidField("host.version"))
);
let plugin_inspector = std::sync::Arc::new(gateway_plugin_runtime::PackageInspector::new(
gateway_plugin_runtime::PackageLimits::default(),
host_version,
));
let plugin_distribution = std::sync::Arc::new(or_shutdown!(
plugin_runtime,
gateway_host::plugin_distribution::HttpPluginDistribution::new(plugin_http)
));
let mut admin = or_shutdown!(
plugin_runtime,
gateway_admin::initialize_with_plugin_accounts(
admin,
client,
store.admin_ports(),
gateway_admin::AdminRuntimePorts {
providers: admin_providers,
plugin_preparation: plugin_runtime.clone(),
plugin_management: plugin_runtime.clone(),
published_snapshot: core.snapshots(),
plugin_inspector,
plugin_distribution,
pricing_source: std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
snapshot: core.snapshot_control(),
account_probe: core.account_probe(),
proxy_probe: host.proxy_probe(provider_openai::build_reqwest_client_with_custom_ca),
client_distribution: host.client_distribution_resolver(),
system: host.system_operations(),
client_key_verifier: core.client_key_verifier(),
},
plugin_accounts,
)
.await
);
let official_files = host.official_plugin_release_files();
let official_identity = host.official_plugin_release_identity();
let official_import = or_shutdown!(
plugin_runtime,
admin
.services()
.plugins()
.import_official_release(
official_files.as_ref(),
&official_identity,
&gateway_admin::model::MutationContext {
actor: gateway_admin::model::MutationActor::System,
request_id: "startup-official-plugin-import".to_owned(),
},
)
.await
);
if official_import.artifacts > 0 {
host.report_startup_ready("Official plugins");
}
host.report_startup_ready("Admin");
let mut probes = store.health_probes();
probes.extend(core.health_probes());
probes.push(host.logging_health_probe());
let api = gateway_api::initialize(
api,
core.execution_service(),
admin.services(),
probes,
host.worker_health(),
host.connection_lifecycle(),
)?;
let api = or_shutdown!(
plugin_runtime,
gateway_api::initialize(
api,
core.execution_service(),
admin.services(),
probes,
host.worker_health(),
host.connection_lifecycle(),
)
);
host.report_startup_ready("API");
let mut plan = store.take_worker_contributions();
@@ -111,22 +322,67 @@ pub async fn run() -> Result<(), BootstrapError> {
plan.extend(openai.take_worker_contributions());
plan.extend(xai.take_worker_contributions());
plan.extend(admin.take_worker_contributions());
host.start_workers(plan, store.worker_leader_lease())?;
or_shutdown!(
plugin_runtime,
host.start_workers(plan, store.worker_leader_lease())
);
host.report_startup_ready("Workers");
host.serve(api.router()).await?;
Ok(())
let served = host.serve(api.router()).await;
plugin_runtime.shutdown().await;
served?;
Ok(gateway_plugin_runtime::PluginCommandOutput {
stdout: String::new(),
stderr: String::new(),
exit_code: 0,
saved_accounts: 0,
})
}
fn prepare_plugins(
store: &gateway_store::StoreBundle,
plugin_cache: std::path::PathBuf,
version: &str,
http: std::sync::Arc<gateway_host::outbound::HttpClient>,
) -> Result<std::sync::Arc<gateway_plugin_runtime::PluginRuntime>, BootstrapError> {
Ok(std::sync::Arc::new(
gateway_plugin_runtime::PluginRuntime::new(
store.admin_ports().plugins(),
store.admin_ports().plugin_state(),
gateway_plugin_runtime::PluginRuntimeConfig {
cache_directory: plugin_cache,
host_version: version
.trim_start_matches('v')
.parse()
.map_err(|_| ConfigError::InvalidField("host.version"))?,
package_limits: gateway_plugin_runtime::PackageLimits::default(),
rpc_limits: gateway_plugin_runtime::RpcLimits::default(),
restart_circuit: Default::default(),
},
http,
std::sync::Arc::new(gateway_host::process::ProcessSupervisor::default()),
)
.with_oauth_pending(store.provider_ports().oauth_pending()),
))
}
/// 组合根只保留包级错误分类,不展开内部实现或敏感配置。
#[derive(Debug, thiserror::Error)]
pub enum BootstrapError {
#[error(transparent)]
Worker(#[from] gateway_core::task::WorkerDefinitionError),
#[error(transparent)]
PluginCommand(#[from] gateway_plugin_runtime::PluginCommandError),
#[error(transparent)]
Config(#[from] gateway_host::ConfigError),
#[error(transparent)]
Host(#[from] gateway_host::HostError),
#[error(transparent)]
Outbound(#[from] gateway_host::outbound::HttpError),
#[error(transparent)]
Store(#[from] gateway_store::StoreError),
#[error(transparent)]
CommandStoreDrain(#[from] gateway_store::CommandStoreDrainError),
#[error(transparent)]
OpenAi(#[from] provider_openai::OpenAiInitializeError),
#[error(transparent)]
Xai(#[from] provider_xai::XaiInitializeError),
+71 -6
View File
@@ -1,19 +1,84 @@
use std::{env, error::Error, io};
use std::{
env,
error::Error,
io::{self, Write},
process::ExitCode,
};
use provider_openai::ensure_rustls_provider;
fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
fn main() -> ExitCode {
match run() {
Ok(code) => code,
Err(error) => {
eprintln!("{error}");
ExitCode::FAILURE
}
}
}
fn run() -> Result<ExitCode, Box<dyn Error + Send + Sync>> {
ensure_rustls_provider();
let mut arguments = env::args_os().skip(1);
match arguments.next().as_deref().and_then(|value| value.to_str()) {
let command = arguments
.next()
.map(|value| {
value
.into_string()
.map_err(|_| invalid_cli("command must be UTF-8"))
})
.transpose()?;
match command.as_deref() {
None | Some("serve") => {
reject_extra_arguments(arguments)?;
runtime()?.block_on(codex_proxy_rs::bootstrap::run())?;
Ok(())
Ok(ExitCode::SUCCESS)
}
Some(command) => {
Err(invalid_cli(&format!("unknown command {command:?}; expected serve")).into())
Some("help" | "--help" | "-h") => {
reject_extra_arguments(arguments)?;
println!(
"Usage: codex-proxy-rs [serve]\n codex-proxy-rs plugin <instance-id> <command> [options]\n\n --help Show help without loading configuration\n --version Show version\n plugin --help List enabled plugin commands without starting HTTP"
);
Ok(ExitCode::SUCCESS)
}
Some("--version" | "-V") => {
reject_extra_arguments(arguments)?;
println!(
"{}",
option_env!("CPR_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"))
);
Ok(ExitCode::SUCCESS)
}
Some("plugin") => {
let arguments = arguments
.map(|value| {
value
.into_string()
.map_err(|_| invalid_cli("plugin arguments must be UTF-8"))
})
.collect::<Result<Vec<_>, _>>()?;
let mut arguments = arguments.into_iter();
let instance_id = arguments
.next()
.filter(|value| !matches!(value.as_str(), "--help" | "-h"));
let name = arguments
.next()
.filter(|value| !matches!(value.as_str(), "--help" | "-h"));
if instance_id.is_none() && name.is_some() {
return Err(invalid_cli("unexpected plugin help arguments").into());
}
let output = runtime()?.block_on(codex_proxy_rs::bootstrap::plugin_command(
codex_proxy_rs::bootstrap::PluginCommand {
instance_id,
name,
arguments: arguments.collect(),
},
))?;
io::stdout().lock().write_all(output.stdout.as_bytes())?;
io::stderr().lock().write_all(output.stderr.as_bytes())?;
Ok(ExitCode::from(output.exit_code))
}
Some(_) => Err(invalid_cli("unknown command; expected serve, plugin, or --help").into()),
}
}
+62 -4
View File
@@ -11,10 +11,13 @@ use syn::{Item, visit::Visit};
/// workspace 成员冻结清单;新增 crate 必须同步扩展本文件的依赖规则。
pub(super) const WORKSPACE_MEMBERS: &[&str] = &[
"apps/gateway",
"apps/plugin-cli",
"crates/gateway-admin",
"crates/gateway-api",
"crates/gateway-core",
"crates/gateway-host",
"crates/gateway-plugin/runtime",
"crates/gateway-plugin/sdk",
"crates/gateway-protocol",
"crates/gateway-store",
"crates/providers/openai",
@@ -149,6 +152,13 @@ fn gateway_protocol_has_no_workspace_dependencies() {
}
}
#[test]
fn plugin_sdk_has_no_workspace_dependencies() {
for name in dependency_names("crates/gateway-plugin/sdk") {
assert!(!name.starts_with("gateway-") && !name.starts_with("provider-"));
}
}
#[test]
fn provider_crates_do_not_depend_on_each_other() {
assert_no_dependency("crates/providers/openai", &["provider-xai"]);
@@ -166,25 +176,34 @@ fn gateway_admin_stays_free_of_infrastructure_dependencies() {
/// workspace 包名到冻结成员路径的映射。
const PACKAGE_TO_MEMBER: &[(&str, &str)] = &[
("codex-proxy-rs", "apps/gateway"),
("codex-proxy-plugin-cli", "apps/plugin-cli"),
("gateway-admin", "crates/gateway-admin"),
("gateway-api", "crates/gateway-api"),
("gateway-core", "crates/gateway-core"),
("gateway-host", "crates/gateway-host"),
("gateway-plugin-runtime", "crates/gateway-plugin/runtime"),
("gateway-plugin-sdk", "crates/gateway-plugin/sdk"),
("gateway-protocol", "crates/gateway-protocol"),
("gateway-store", "crates/gateway-store"),
("provider-openai", "crates/providers/openai"),
("provider-xai", "crates/providers/xai"),
];
/// Adapter/provider 根门面的稳定合同模块;任何增减都必须同步完成边界审计。
/// SDK 与 Adapter/provider 根门面的稳定合同模块;任何增减都必须同步完成边界审计。
const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
("crates/gateway-plugin/sdk", &["call", "client"]),
("crates/gateway-plugin/runtime", &[]),
("crates/gateway-api", &["admin", "auth", "openai"]),
(
"crates/gateway-host",
&[
"client_distribution",
"config",
"outbound",
"official_plugins",
"pricing",
"plugin_distribution",
"process",
"proxy_probe",
"serve",
"system_update",
@@ -207,10 +226,12 @@ const ROOT_TEST_SCENARIOS: &[(&str, &[&str])] = &[
/// 冻结的 workspace 内部运行时依赖边;新增/删除任何边都必须同步本表。
const ALLOWED_INTERNAL_EDGES: &[(&str, &str)] = &[
("codex-proxy-plugin-cli", "gateway-plugin-sdk"),
("codex-proxy-rs", "gateway-admin"),
("codex-proxy-rs", "gateway-api"),
("codex-proxy-rs", "gateway-core"),
("codex-proxy-rs", "gateway-host"),
("codex-proxy-rs", "gateway-plugin-runtime"),
("codex-proxy-rs", "gateway-store"),
("codex-proxy-rs", "provider-openai"),
("codex-proxy-rs", "provider-xai"),
@@ -220,6 +241,11 @@ const ALLOWED_INTERNAL_EDGES: &[(&str, &str)] = &[
("gateway-api", "gateway-protocol"),
("gateway-host", "gateway-admin"),
("gateway-host", "gateway-core"),
("gateway-plugin-runtime", "gateway-host"),
("gateway-plugin-runtime", "gateway-core"),
("gateway-plugin-runtime", "gateway-admin"),
("gateway-plugin-runtime", "gateway-plugin-sdk"),
("gateway-plugin-runtime", "gateway-protocol"),
("gateway-store", "gateway-admin"),
("gateway-store", "gateway-core"),
("provider-openai", "gateway-admin"),
@@ -349,13 +375,18 @@ fn production_sources_do_not_host_tests() {
#[test]
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 tests = member_root.join("tests");
if tests.is_dir() {
assert_module_tree(&tests, &["main.rs"]);
let roots = targets.get(&tests).expect("Cargo test source roots");
assert_module_tree(
&tests,
&roots.iter().map(String::as_str).collect::<Vec<_>>(),
);
}
}
}
@@ -368,6 +399,7 @@ enum ModuleLayout {
#[test]
fn integration_tests_mirror_production_module_tree() {
let targets = test_source_roots();
for member in WORKSPACE_MEMBERS {
let member_root = backend_root().join(member);
let tests_root = member_root.join("tests");
@@ -376,9 +408,13 @@ fn integration_tests_mirror_production_module_tree() {
}
let production = module_layouts(&member_root.join("src"), &["lib.rs", "main.rs"]);
let tests = module_layouts(&tests_root, &["main.rs"]);
let roots = targets.get(&tests_root).expect("Cargo test source roots");
let tests = module_layouts(
&tests_root,
&roots.iter().map(String::as_str).collect::<Vec<_>>(),
);
for (module, test_layout) in tests {
if module == Path::new("support") {
if module.starts_with("support") {
continue;
}
@@ -408,6 +444,28 @@ fn integration_tests_mirror_production_module_tree() {
}
}
/// Rust 子进程 fixture 是 Cargo 的独立 crate 根,不能要求它由测试模块再次声明。
fn test_source_roots() -> BTreeMap<PathBuf, Vec<String>> {
let metadata = cargo_metadata_json();
let mut roots = BTreeMap::<PathBuf, Vec<String>>::new();
for package in metadata["packages"].as_array().expect("Cargo packages") {
let root = Path::new(package["manifest_path"].as_str().expect("package manifest"))
.parent()
.expect("package directory")
.join("tests");
for target in package["targets"].as_array().expect("Cargo targets") {
let source = Path::new(target["src_path"].as_str().expect("target source"));
if let Ok(relative) = source.strip_prefix(&root) {
roots
.entry(root.clone())
.or_default()
.push(relative.to_str().expect("UTF-8 source path").to_owned());
}
}
}
roots
}
fn module_layouts(root: &Path, crate_roots: &[&str]) -> BTreeMap<PathBuf, ModuleLayout> {
super::rust_files(root)
.into_iter()
+49 -1
View File
@@ -9,6 +9,54 @@ const REDIS_PASSWORD: &str = "222222222222222222222222222222222222222222222222";
const ADMIN_PASSWORD: &str = "test-admin-password";
const TOPOLOGY_CHILD_ENV: &str = "CPR_TEST_TOPOLOGY_CHILD";
#[test]
fn basic_cli_options_do_not_load_configuration_and_reject_unexpected_arguments() {
let directory = tempfile::tempdir().unwrap();
fs::create_dir(directory.path().join("deploy")).unwrap();
fs::write(directory.path().join("deploy/config.yaml"), "invalid: [").unwrap();
for flag in ["--help", "-h", "help", "--version", "-V"] {
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
.arg(flag)
.current_dir(directory.path())
.env_clear()
.output()
.unwrap();
assert!(output.status.success(), "{flag}");
assert!(!output.stdout.is_empty());
assert!(output.stderr.is_empty());
}
for args in [
vec!["unknown"],
vec!["--help", "extra"],
vec!["serve", "extra"],
] {
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
.args(args)
.current_dir(directory.path())
.env_clear()
.output()
.unwrap();
assert!(!output.status.success());
assert!(output.stdout.is_empty());
assert!(!String::from_utf8_lossy(&output.stderr).contains("configuration"));
}
}
#[cfg(unix)]
#[test]
fn non_utf8_command_is_not_interpreted_as_serve() {
use std::os::unix::ffi::OsStringExt as _;
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
.arg(std::ffi::OsString::from_vec(vec![0xff]))
.env_clear()
.output()
.unwrap();
assert!(!output.status.success());
assert!(output.stdout.is_empty());
assert!(String::from_utf8_lossy(&output.stderr).contains("command must be UTF-8"));
}
#[tokio::test]
async fn proxy_probe_should_use_provider_custom_ca_for_https_proxies() {
use gateway_admin::ports::proxy::ProxyProbe;
@@ -85,7 +133,7 @@ async fn proxy_probe_should_use_provider_custom_ca_for_https_proxies() {
});
let probe = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.with_client_builder(provider_openai::build_reqwest_client_with_custom_ca);
let result = probe.test(&proxy).await;
let result = probe.test(&proxy, false).await;
assert!(result.success, "{}", result.message);
assert_eq!(result.exit_ip.unwrap().to_string(), "203.0.113.8");
tokio::time::timeout(Duration::from_secs(5), server)
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "codex-proxy-plugin-cli"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish.workspace = true
rust-version.workspace = true
[[bin]]
name = "cpr-plugin"
path = "src/main.rs"
[dependencies]
clap = { version = "4.6.7", features = ["derive"] }
flate2 = "1.1.9"
gateway-plugin-sdk = { path = "../../crates/gateway-plugin/sdk" }
hex = "0.4.3"
serde_json = "1.0.150"
sha2 = "0.11.0"
tar = "0.4.44"
thiserror = "2.0.18"
[dev-dependencies]
tempfile = "3.27.0"
[lints]
workspace = true
+51
View File
@@ -0,0 +1,51 @@
# Codex Proxy Plugin CLI
`cpr-plugin` 校验并规范化作者清单、收集声明的资源,生成可安装归档及 SHA-256 校验文件。
当前输出使用 `manifestVersion: 1`、`protocolVersion: 1` 和 SDK `0.1` 合同。
## 安装
从仓库根目录安装到本地工具目录:
```bash
cargo install --locked --path backend/apps/plugin-cli --root /path/to/local-tools
```
将 `/path/to/local-tools/bin` 加入 `PATH`,或使用可执行文件的完整路径。
## 打包
先构建目标平台的插件二进制;插件提供管理页面时,还需构建页面静态资源。打包命令传入作者清单、二进制和所需的资源目录映射,通过 `cpr-plugin package --help` 查看完整参数:
```bash
cpr-plugin package \
--manifest /path/to/plugin/plugin.json \
--binary /path/to/plugin/target/x86_64-unknown-linux-gnu/release/plugin \
--target x86_64-unknown-linux-gnu \
--resource-map web=web/dist \
--output-dir /path/to/output
```
资源映射相对于作者清单所在目录解析。输出包括完整插件 ID、版本和目标 triple 命名的 `.tar.gz` 及 `.sha256`;归档根目录直接包含 `plugin.json`、可执行文件和已声明资源,不包含开发依赖。
作者清单可省略普通贡献项的 `id`、`version` 和固定阶段;`middleware` 的 `request`/`attempt` 仍须显式选择。
CLI 与 SDK 的 `Manifest::from_author_slice`、`PluginBuilder::from_json` 共用规范化入口;归档中的
`plugin.json` 写入补全后的贡献项字段,不保留另一份作者声明。
完整规则见 [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` 仓库目录;本工具不负责源码构建、上传或启用插件。
### 图标资源
在作者清单的 `icon` 中指定图标,并在 `resources` 中登记对应路径与 MIME 类型,配置方式见
[SDK 插件图标](../../crates/gateway-plugin/sdk/docs/manifest.md#插件图标)。图标与其他声明资源一样参与打包和摘要计算,
无需额外命令参数;工具保留文件原始内容,宿主安装时验证图标内容与资源限制。
## 开发
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
RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked
```
+562
View File
@@ -0,0 +1,562 @@
use std::{
collections::{BTreeMap, BTreeSet},
fs::{self, File, OpenOptions},
io::{self, Read as _, Write as _},
path::{Component, Path, PathBuf},
};
use clap::{Args, Parser, Subcommand};
use flate2::{Compression, GzBuilder};
use gateway_plugin_sdk::{
Manifest, ManifestError, PROTOCOL_VERSION, Package, PackageTarget, valid_package_path,
};
use sha2::{Digest as _, Sha256};
use tar::{Builder, EntryType, Header, HeaderMode};
use thiserror::Error;
const MAXIMUM_COMPRESSED_BYTES: u64 = 32 * 1024 * 1024;
const MAXIMUM_EXPANDED_BYTES: u64 = 128 * 1024 * 1024;
const MAXIMUM_MANIFEST_BYTES: u64 = 64 * 1024;
#[derive(Parser)]
#[command(name = "cpr-plugin", version, about = "Codex Proxy 插件开发工具")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
#[command(about = "校验清单并打包已编译的插件")]
Package(Options),
}
#[derive(Debug, Error)]
enum PackagerError {
#[error("{0}")]
Invalid(String),
#[error("failed to {action} {path}: {source}")]
Io {
action: &'static str,
path: PathBuf,
#[source]
source: io::Error,
},
#[error("source or generated manifest is invalid: {0}")]
Manifest(#[from] ManifestError),
#[error("failed to encode generated manifest: {0}")]
EncodeManifest(#[source] serde_json::Error),
}
#[derive(Debug, Args)]
struct Options {
#[arg(long, value_name = "FILE", help = "作者清单 plugin.json")]
manifest: PathBuf,
#[arg(long, value_name = "FILE", help = "已编译的目标平台插件二进制")]
binary: PathBuf,
#[arg(long, value_name = "TRIPLE", help = "目标平台 triple")]
target: String,
#[arg(long, value_name = "DIRECTORY", help = "归档与 SHA-256 文件的输出目录")]
output_dir: PathBuf,
#[arg(
long = "resource-map",
value_name = "PREFIX=DIRECTORY",
help = "包内资源前缀到项目内目录的映射,可重复传入"
)]
resource_maps: Vec<String>,
}
#[derive(Debug, Clone, Copy)]
struct RuntimeTarget {
os: &'static str,
architecture: &'static str,
}
impl RuntimeTarget {
fn parse(triple: &str) -> Result<Self, PackagerError> {
match triple {
"x86_64-unknown-linux-gnu" => Ok(Self {
os: "linux",
architecture: "x86_64",
}),
"aarch64-unknown-linux-gnu" => Ok(Self {
os: "linux",
architecture: "aarch64",
}),
"aarch64-apple-darwin" => Ok(Self {
os: "macos",
architecture: "aarch64",
}),
_ => Err(PackagerError::Invalid(format!(
"unsupported plugin target {triple}"
))),
}
}
}
#[derive(Debug)]
struct PackageOutput {
archive: PathBuf,
checksum: PathBuf,
}
fn main() -> Result<(), PackagerError> {
match Cli::parse().command {
Commands::Package(options) => {
let output = package(&options)?;
println!("{}", output.archive.display());
println!("{}", output.checksum.display());
Ok(())
}
}
}
fn parse_resource_maps(values: &[String]) -> Result<BTreeMap<String, PathBuf>, PackagerError> {
let mut resource_maps = BTreeMap::new();
for value in values {
let (prefix, directory) = value.split_once('=').ok_or_else(|| {
PackagerError::Invalid(
"--resource-map must use package-prefix=source-directory".to_owned(),
)
})?;
if !valid_resource_prefix(prefix) {
return Err(PackagerError::Invalid(format!(
"invalid resource-map package prefix {prefix}"
)));
}
let directory = PathBuf::from(directory);
if !safe_relative_directory(&directory) {
return Err(PackagerError::Invalid(format!(
"resource-map source directory must be a non-empty project-relative path: {}",
directory.display()
)));
}
if resource_maps.insert(prefix.to_owned(), directory).is_some() {
return Err(PackagerError::Invalid(format!(
"duplicate resource-map package prefix {prefix}"
)));
}
}
Ok(resource_maps)
}
fn valid_resource_prefix(prefix: &str) -> bool {
valid_package_path(prefix) && !prefix.contains('/')
}
fn safe_relative_directory(path: &Path) -> bool {
!path.as_os_str().is_empty()
&& !path.is_absolute()
&& path
.components()
.all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
}
fn package(options: &Options) -> Result<PackageOutput, PackagerError> {
let target = RuntimeTarget::parse(&options.target)?;
let resource_maps = parse_resource_maps(&options.resource_maps)?;
let manifest_bytes = read_regular_file(&options.manifest, MAXIMUM_MANIFEST_BYTES)?;
let mut manifest = Manifest::from_author_slice(&manifest_bytes)?;
let project_directory = options
.manifest
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let canonical_project = canonicalize(project_directory)?;
let mut sources = BTreeMap::from([(manifest.main.clone(), options.binary.clone())]);
let mut used_maps = BTreeSet::new();
for package_path in manifest.resources.keys() {
let source = resource_source(
project_directory,
package_path,
&resource_maps,
&mut used_maps,
)?;
let canonical_source = canonicalize(&source)?;
if !canonical_source.starts_with(&canonical_project) {
return Err(PackagerError::Invalid(format!(
"resource source escapes the plugin project: {}",
source.display()
)));
}
if sources.insert(package_path.clone(), source).is_some() {
return Err(PackagerError::Invalid(format!(
"plugin main and resource share package path {package_path}"
)));
}
}
if used_maps.len() != resource_maps.len() {
let unused = resource_maps
.keys()
.filter(|prefix| !used_maps.contains(*prefix))
.cloned()
.collect::<Vec<_>>();
return Err(PackagerError::Invalid(format!(
"unused resource-map package prefix: {}",
unused.join(", ")
)));
}
reject_case_folded_paths(sources.keys())?;
let mut files = BTreeMap::new();
let mut source_bytes = 0u64;
for (package_path, source) in sources {
let remaining = MAXIMUM_EXPANDED_BYTES
.checked_sub(source_bytes)
.ok_or_else(package_limit)?;
let bytes = read_regular_file(&source, remaining)?;
source_bytes = source_bytes
.checked_add(u64::try_from(bytes.len()).map_err(|_| package_limit())?)
.ok_or_else(package_limit)?;
files.insert(package_path, bytes);
}
let digests = files
.iter()
.map(|(path, bytes)| (path.clone(), hex::encode(Sha256::digest(bytes))))
.collect();
manifest.package = Some(Package {
protocol_version: PROTOCOL_VERSION,
target: PackageTarget {
os: target.os.to_owned(),
architecture: target.architecture.to_owned(),
},
files: digests,
});
manifest.validate()?;
let mut generated_manifest =
serde_json::to_vec_pretty(&manifest).map_err(PackagerError::EncodeManifest)?;
generated_manifest.push(b'\n');
if generated_manifest.len() as u64 > MAXIMUM_MANIFEST_BYTES {
return Err(package_limit());
}
create_directory(&options.output_dir)?;
let plugin_id = manifest.plugin_id()?;
let archive_name = format!("{plugin_id}-{}-{}.tar.gz", manifest.version, options.target);
let archive_path = options.output_dir.join(&archive_name);
let archive_temporary = options
.output_dir
.join(format!(".{archive_name}.{}.tmp", std::process::id()));
let mut temporary = TemporaryFile::create(archive_temporary)?;
write_archive(
temporary.file_mut(),
&generated_manifest,
&manifest.main,
&files,
)?;
temporary.sync_all()?;
let compressed_bytes = temporary.metadata_len()?;
if compressed_bytes > MAXIMUM_COMPRESSED_BYTES {
return Err(package_limit());
}
temporary.persist(&archive_path)?;
let archive_digest = sha256_file(&archive_path)?;
let checksum_path = options.output_dir.join(format!("{archive_name}.sha256"));
let checksum_temporary = options
.output_dir
.join(format!(".{archive_name}.sha256.{}.tmp", std::process::id()));
let mut checksum = TemporaryFile::create(checksum_temporary)?;
let checksum_temporary_path = checksum.path().to_owned();
writeln!(checksum.file_mut(), "{archive_digest} {archive_name}").map_err(|source| {
PackagerError::Io {
action: "write",
path: checksum_temporary_path,
source,
}
})?;
checksum.sync_all()?;
checksum.persist(&checksum_path)?;
Ok(PackageOutput {
archive: archive_path,
checksum: checksum_path,
})
}
fn resource_source(
project_directory: &Path,
package_path: &str,
mappings: &BTreeMap<String, PathBuf>,
used_mappings: &mut BTreeSet<String>,
) -> Result<PathBuf, PackagerError> {
let (prefix, remainder) = package_path
.split_once('/')
.map_or((package_path, None), |(prefix, remainder)| {
(prefix, Some(remainder))
});
if let Some(directory) = mappings.get(prefix) {
let remainder = remainder.ok_or_else(|| {
PackagerError::Invalid(format!(
"mapped resource {package_path} must have a path below prefix {prefix}"
))
})?;
used_mappings.insert(prefix.to_owned());
Ok(project_directory.join(directory).join(remainder))
} else {
Ok(project_directory.join(package_path))
}
}
fn reject_case_folded_paths<'a>(
paths: impl IntoIterator<Item = &'a String>,
) -> Result<(), PackagerError> {
let mut folded = BTreeSet::new();
for path in paths {
if !folded.insert(path.to_ascii_lowercase()) {
return Err(PackagerError::Invalid(format!(
"package path collides case-insensitively: {path}"
)));
}
}
Ok(())
}
fn write_archive(
output: &mut File,
manifest: &[u8],
main_path: &str,
files: &BTreeMap<String, Vec<u8>>,
) -> Result<(), PackagerError> {
let encoder = GzBuilder::new().mtime(0).write(output, Compression::best());
let limited = LimitedWriter::new(encoder, MAXIMUM_EXPANDED_BYTES);
let mut archive = Builder::new(limited);
archive.mode(HeaderMode::Deterministic);
append_file(&mut archive, "plugin.json", manifest, 0o644)?;
for (path, bytes) in files {
let mode = if path == main_path { 0o755 } else { 0o644 };
append_file(&mut archive, path, bytes, mode)?;
}
archive.finish().map_err(|source| PackagerError::Io {
action: "finish archive",
path: PathBuf::from("<temporary archive>"),
source,
})?;
let limited = archive.into_inner().map_err(|source| PackagerError::Io {
action: "finish archive",
path: PathBuf::from("<temporary archive>"),
source,
})?;
limited
.into_inner()
.finish()
.map_err(|source| PackagerError::Io {
action: "finish compression",
path: PathBuf::from("<temporary archive>"),
source,
})?;
Ok(())
}
fn append_file<W: io::Write>(
archive: &mut Builder<W>,
path: &str,
bytes: &[u8],
mode: u32,
) -> Result<(), PackagerError> {
let mut header = Header::new_gnu();
header.set_entry_type(EntryType::Regular);
header.set_mode(mode);
header.set_uid(0);
header.set_gid(0);
header.set_mtime(0);
header.set_size(bytes.len() as u64);
header.set_cksum();
archive
.append_data(&mut header, path, bytes)
.map_err(|source| PackagerError::Io {
action: "append archive entry",
path: PathBuf::from(path),
source,
})
}
struct LimitedWriter<W> {
inner: W,
written: u64,
maximum: u64,
}
impl<W> LimitedWriter<W> {
const fn new(inner: W, maximum: u64) -> Self {
Self {
inner,
written: 0,
maximum,
}
}
fn into_inner(self) -> W {
self.inner
}
}
impl<W> io::Write for LimitedWriter<W>
where
W: io::Write,
{
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
let length = u64::try_from(bytes.len())
.map_err(|_| io::Error::other("plugin package exceeds expanded size limit"))?;
if length > self.maximum.saturating_sub(self.written) {
return Err(io::Error::other(
"plugin package exceeds expanded size limit",
));
}
let written = self.inner.write(bytes)?;
self.written = self
.written
.checked_add(written as u64)
.ok_or_else(|| io::Error::other("plugin package size overflow"))?;
Ok(written)
}
fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}
struct TemporaryFile {
path: PathBuf,
file: File,
}
impl TemporaryFile {
fn create(path: PathBuf) -> Result<Self, PackagerError> {
let file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.map_err(|source| PackagerError::Io {
action: "create",
path: path.clone(),
source,
})?;
Ok(Self { path, file })
}
fn path(&self) -> &Path {
&self.path
}
fn file_mut(&mut self) -> &mut File {
&mut self.file
}
fn sync_all(&mut self) -> Result<(), PackagerError> {
let path = self.path.clone();
self.file_mut()
.sync_all()
.map_err(|source| PackagerError::Io {
action: "sync",
path,
source,
})
}
fn metadata_len(&self) -> Result<u64, PackagerError> {
self.file
.metadata()
.map(|metadata| metadata.len())
.map_err(|source| PackagerError::Io {
action: "inspect",
path: self.path.clone(),
source,
})
}
fn persist(mut self, destination: &Path) -> Result<(), PackagerError> {
fs::rename(&self.path, destination).map_err(|source| PackagerError::Io {
action: "publish",
path: destination.to_owned(),
source,
})?;
self.path.clear();
Ok(())
}
}
impl Drop for TemporaryFile {
fn drop(&mut self) {
if !self.path.as_os_str().is_empty() {
let _ = fs::remove_file(&self.path);
}
}
}
fn read_regular_file(path: &Path, maximum_bytes: u64) -> Result<Vec<u8>, PackagerError> {
let metadata = fs::symlink_metadata(path).map_err(|source| PackagerError::Io {
action: "inspect",
path: path.to_owned(),
source,
})?;
if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
return Err(PackagerError::Invalid(format!(
"package input must be a regular non-symlink file: {}",
path.display()
)));
}
if metadata.len() > maximum_bytes {
return Err(package_limit());
}
let file = File::open(path).map_err(|source| PackagerError::Io {
action: "open",
path: path.to_owned(),
source,
})?;
let mut bytes = Vec::with_capacity(usize::try_from(metadata.len()).unwrap_or_default());
file.take(maximum_bytes.saturating_add(1))
.read_to_end(&mut bytes)
.map_err(|source| PackagerError::Io {
action: "read",
path: path.to_owned(),
source,
})?;
if bytes.len() as u64 > maximum_bytes {
return Err(package_limit());
}
Ok(bytes)
}
fn canonicalize(path: &Path) -> Result<PathBuf, PackagerError> {
fs::canonicalize(path).map_err(|source| PackagerError::Io {
action: "canonicalize",
path: path.to_owned(),
source,
})
}
fn create_directory(path: &Path) -> Result<(), PackagerError> {
fs::create_dir_all(path).map_err(|source| PackagerError::Io {
action: "create directory",
path: path.to_owned(),
source,
})
}
fn sha256_file(path: &Path) -> Result<String, PackagerError> {
let mut file = File::open(path).map_err(|source| PackagerError::Io {
action: "open",
path: path.to_owned(),
source,
})?;
let mut digest = Sha256::new();
let mut buffer = [0u8; 64 * 1024];
loop {
let read = file.read(&mut buffer).map_err(|source| PackagerError::Io {
action: "hash",
path: path.to_owned(),
source,
})?;
if read == 0 {
break;
}
digest.update(&buffer[..read]);
}
Ok(hex::encode(digest.finalize()))
}
fn package_limit() -> PackagerError {
PackagerError::Invalid("plugin package exceeds installation size limits".to_owned())
}
+258
View File
@@ -0,0 +1,258 @@
use std::{
collections::BTreeMap,
fs::{self, File},
io::Read as _,
process::{Command, Output},
};
use flate2::read::MultiGzDecoder;
use gateway_plugin_sdk::{Capability, MANIFEST_VERSION, Manifest, PROTOCOL_VERSION, Stage};
use sha2::{Digest as _, Sha256};
fn cli(arguments: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_cpr-plugin"))
.args(arguments)
.output()
.unwrap()
}
fn assert_rejected(arguments: &[&str], message: &str) {
let output = cli(arguments);
assert!(
!output.status.success(),
"{arguments:?} unexpectedly succeeded"
);
assert!(
String::from_utf8_lossy(&output.stderr).contains(message),
"{arguments:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn package_arguments() -> Vec<&'static str> {
vec![
"package",
"--manifest",
"plugin.json",
"--binary",
"plugin-bin",
"--target",
"x86_64-unknown-linux-gnu",
"--output-dir",
"dist",
]
}
#[test]
fn root_help_lists_package_command() {
let output = cli(&["--help"]);
assert!(output.status.success());
let help = String::from_utf8(output.stdout).unwrap();
assert!(help.contains("cpr-plugin <COMMAND>"), "{help}");
assert!(help.contains("package"), "{help}");
}
#[test]
fn version_uses_the_user_command_name() {
let output = cli(&["--version"]);
assert!(output.status.success());
assert_eq!(
String::from_utf8(output.stdout).unwrap(),
format!("cpr-plugin {}\n", env!("CARGO_PKG_VERSION"))
);
}
#[test]
fn package_help_does_not_require_inputs() {
let output = cli(&["package", "--help"]);
assert!(output.status.success());
let help = String::from_utf8(output.stdout).unwrap();
assert!(help.contains("cpr-plugin package"), "{help}");
assert!(help.contains("--resource-map"), "{help}");
}
#[test]
fn rejects_missing_subcommand() {
assert_rejected(&[], "Usage: cpr-plugin");
}
#[test]
fn rejects_unknown_subcommand() {
assert_rejected(&["publish"], "unrecognized subcommand");
}
#[test]
fn rejects_missing_package_arguments() {
assert_rejected(&["package"], "--manifest");
}
#[test]
fn rejects_duplicate_single_value_options() {
let mut arguments = package_arguments();
arguments.extend(["--manifest", "other.json"]);
assert_rejected(&arguments, "cannot be used multiple times");
}
#[test]
fn rejects_unknown_targets() {
let mut arguments = package_arguments();
arguments[6] = "x86_64-pc-windows-msvc";
assert_rejected(&arguments, "unsupported plugin target");
}
#[test]
fn rejects_unsafe_resource_mappings_before_reading_files() {
for (mapping, message) in [
("web=../web/dist", "project-relative path"),
("web=/web/dist", "project-relative path"),
("web=", "project-relative path"),
("web/assets=web/dist", "invalid resource-map package prefix"),
("web", "must use package-prefix=source-directory"),
] {
let mut arguments = package_arguments();
arguments.extend(["--resource-map", mapping]);
assert_rejected(&arguments, message);
}
}
#[test]
fn rejects_duplicate_resource_prefixes() {
let mut arguments = package_arguments();
arguments.extend([
"--resource-map",
"web=web/dist",
"--resource-map",
"web=other",
]);
assert_rejected(&arguments, "duplicate resource-map package prefix");
}
#[test]
fn packages_only_generated_resources_with_v3_metadata() {
assert_packaged_resources("assets/icon.png", "image/png", b"png");
}
#[test]
fn packages_svg_icons_with_their_declared_mime_and_digest() {
assert_packaged_resources(
"assets/icon.svg",
"image/svg+xml",
br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M2 2h20v20H2z"/></svg>"#,
);
}
fn assert_packaged_resources(icon_path: &str, icon_mime: &str, icon: &[u8]) {
let directory = tempfile::tempdir().unwrap();
let project = directory.path().join("example");
fs::create_dir_all(project.join("web/dist")).unwrap();
fs::create_dir_all(project.join("assets")).unwrap();
let mut source: serde_json::Value = serde_json::from_slice(source_manifest()).unwrap();
source["icon"] = icon_path.into();
let resources = source["resources"].as_object_mut().unwrap();
resources.remove("assets/icon.png");
resources.insert(icon_path.into(), icon_mime.into());
fs::write(
project.join("plugin.json"),
serde_json::to_vec(&source).unwrap(),
)
.unwrap();
fs::write(project.join("LICENSE"), b"license").unwrap();
fs::write(project.join(icon_path), icon).unwrap();
fs::write(project.join("plugin-bin"), b"binary").unwrap();
fs::write(project.join("web/index.html"), b"source html").unwrap();
fs::write(project.join("web/dist/index.html"), b"built html").unwrap();
fs::write(project.join("web/dist/app.js"), b"built js").unwrap();
fs::write(project.join("web/dist/app.css"), b"built css").unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_cpr-plugin"))
.arg("package")
.arg("--manifest")
.arg(project.join("plugin.json"))
.arg("--binary")
.arg(project.join("plugin-bin"))
.args(["--target", "x86_64-unknown-linux-gnu"])
.arg("--output-dir")
.arg(project.join("dist"))
.args(["--resource-map", "web=web/dist"])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let archive_name = "codex-proxy.request-workbench-0.1.0-x86_64-unknown-linux-gnu.tar.gz";
let archive_path = project.join("dist").join(archive_name);
let checksum_path = project.join("dist").join(format!("{archive_name}.sha256"));
assert_eq!(
String::from_utf8(output.stdout).unwrap(),
format!("{}\n{}\n", archive_path.display(), checksum_path.display())
);
let archive = File::open(&archive_path).unwrap();
let mut package = tar::Archive::new(MultiGzDecoder::new(archive));
let mut entries = BTreeMap::new();
for entry in package.entries().unwrap() {
let mut entry = entry.unwrap();
let path = entry.path().unwrap().to_string_lossy().into_owned();
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes).unwrap();
entries.insert(path, bytes);
}
assert_eq!(
entries.keys().cloned().collect::<Vec<_>>(),
vec![
"LICENSE".to_owned(),
icon_path.to_owned(),
"bin/plugin".to_owned(),
"plugin.json".to_owned(),
"web/app.css".to_owned(),
"web/app.js".to_owned(),
"web/index.html".to_owned(),
]
);
assert_eq!(entries["web/index.html"], b"built html");
let manifest: Manifest = serde_json::from_slice(&entries["plugin.json"]).unwrap();
assert_eq!(manifest.manifest_version, MANIFEST_VERSION);
assert_eq!(
manifest.contributes[&Capability::Middleware].id,
"codex-proxy.request-workbench.middleware"
);
assert_eq!(
manifest.contributes[&Capability::Management].stages,
[Stage::Management]
);
assert_eq!(manifest.resources[icon_path], icon_mime);
assert_eq!(entries[icon_path], icon);
let package = manifest.package.unwrap();
assert_eq!(package.files[icon_path], hex::encode(Sha256::digest(icon)));
assert_eq!(package.protocol_version, PROTOCOL_VERSION);
assert_eq!(package.target.os, "linux");
assert_eq!(package.target.architecture, "x86_64");
assert_eq!(package.files.len(), 6);
let checksum = fs::read_to_string(checksum_path).unwrap();
let digest = hex::encode(Sha256::digest(fs::read(archive_path).unwrap()));
assert_eq!(checksum, format!("{digest} {archive_name}\n"));
}
fn source_manifest() -> &'static [u8] {
br#"{
"manifestVersion":1,
"name":"request-workbench",
"displayName":"Request Workbench",
"publisher":"codex-proxy",
"version":"0.1.0",
"description":"test package",
"license":"Apache-2.0",
"engines":{"codex-proxy-rs":">=3.12.0, <4.0.0"},
"main":"bin/plugin",
"runtime":"trustedProcess",
"contributes":{
"middleware":{"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"}
}"#
}
+2 -1
View File
@@ -16,7 +16,7 @@ argon2 = "0.6.0"
async-trait = "0.1.89"
base64 = "0.23.0"
bytes = "1.11.1"
chrono = "0.4.45"
chrono = { version = "0.4.45", features = ["serde"] }
futures = "0.3.32"
gateway-core = { path = "../gateway-core" }
heck = "0.5.0"
@@ -25,6 +25,7 @@ rand_core = { version = "0.6.4", features = ["getrandom"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
secrecy = { version = "0.10.3", features = ["serde"] }
semver = "1.0.27"
sha2 = "0.11"
subtle = "2.6.1"
thiserror = "2.0.18"
+123 -65
View File
@@ -7,7 +7,6 @@ use std::{fmt, path::Path, sync::Arc, time::Duration};
use gateway_core::{
engine::execution::ClientKeyVerifier,
engine::probe::AccountProbe,
routing::ProviderKind,
runtime::SnapshotControl,
task::{
DaemonRestartPolicy, WorkerContribution, WorkerId, WorkerKind, WorkerLeaseRequest,
@@ -22,34 +21,39 @@ pub mod freeze_recovery;
pub mod model;
pub mod ports;
mod use_case;
pub use use_case::plugins::{PluginDistributionPorts, PluginManagementService, PluginsService};
pub use use_case::key_usage::KeyUsageService;
pub use use_case::{
account_groups::AccountGroupService, accounts::AccountsService, auth::AuthService,
backup::BackupService, client_distribution::ClientDistributionService,
client_keys::ClientKeyService, import_tasks::ImportTasksService,
observability::ObservabilityService, openai::OpenAiService, proxies::ProxiesService,
settings::SettingsService, system::SystemService, xai::XaiService,
account_groups::AccountGroupService,
accounts::AccountsService,
auth::AuthService,
backup::BackupService,
client_distribution::ClientDistributionService,
client_keys::ClientKeyService,
credentials::{CredentialsService, ProviderCredentials},
import_tasks::ImportTasksService,
observability::ObservabilityService,
proxies::ProxiesService,
settings::SettingsService,
system::SystemService,
};
use model::{AdminError, AdminErrorKind};
use model::AdminError;
use ports::{
client_distribution::ClientDistributionResolver,
provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind, ProviderAdminRegistry},
store::AdminStorePorts,
system::SystemOperations,
client_distribution::ClientDistributionResolver, plugin_accounts::PluginAccountAccess,
plugin_client_keys::PluginClientKeyAccess, provider::ProviderAdminRegistry,
store::AdminStorePorts, system::SystemOperations,
};
use use_case::{
account_groups::DefaultAccountGroupService, accounts::DefaultAccountsService,
auth::DefaultAuthService, backup::DefaultBackupService,
client_distribution::DefaultClientDistributionService, client_keys::DefaultClientKeyService,
observability::DefaultObservabilityService, openai::DefaultOpenAiService,
settings::DefaultSettingsService, system::DefaultSystemService, xai::DefaultXaiService,
observability::DefaultObservabilityService, settings::DefaultSettingsService,
system::DefaultSystemService,
};
const OPENAI_PROVIDER_KIND: &str = "openai";
const XAI_PROVIDER_KIND: &str = "xai";
const MINIMUM_INITIAL_PASSWORD_BYTES: usize = 12;
const WEAK_ADMIN_PASSWORDS: &[&str] = &[
"",
@@ -182,6 +186,8 @@ pub enum AdminConfigError {
/// 字段全部私有;调用方经 accessor 直接调用能力,不需要命名内部 `use_case` 模块。
#[derive(Clone)]
pub struct AdminServices {
plugins: Arc<PluginsService>,
plugin_management: Arc<PluginManagementService>,
proxies: Arc<dyn ProxiesService>,
auth: Arc<dyn AuthService>,
key_usage: Arc<dyn KeyUsageService>,
@@ -192,13 +198,23 @@ pub struct AdminServices {
observability: Arc<dyn ObservabilityService>,
settings: Arc<dyn SettingsService>,
system: Arc<dyn SystemService>,
openai: Arc<dyn OpenAiService>,
xai: Arc<dyn XaiService>,
credentials: Arc<CredentialsService>,
plugin_accounts: Arc<dyn PluginAccountAccess>,
backups: Arc<dyn BackupService>,
import_tasks: Arc<dyn ImportTasksService>,
}
impl AdminServices {
#[must_use]
pub fn plugin_management(&self) -> &PluginManagementService {
&self.plugin_management
}
#[must_use]
pub fn plugins(&self) -> &PluginsService {
&self.plugins
}
#[must_use]
pub fn import_tasks(&self) -> &dyn ImportTasksService {
self.import_tasks.as_ref()
@@ -261,13 +277,14 @@ impl AdminServices {
}
#[must_use]
pub fn openai(&self) -> &dyn OpenAiService {
self.openai.as_ref()
pub fn credentials(&self) -> &CredentialsService {
self.credentials.as_ref()
}
/// Runtime 只持有该窄端口的 Weak;AdminBundle 保持实际生命周期。
#[must_use]
pub fn xai(&self) -> &dyn XaiService {
self.xai.as_ref()
pub fn plugin_accounts_handle(&self) -> Arc<dyn PluginAccountAccess> {
Arc::clone(&self.plugin_accounts)
}
#[must_use]
@@ -296,8 +313,13 @@ impl AdminBundle {
/// 组合根提供给控制面的运行能力;与配置和存储端口分别传入。
pub struct AdminRuntimePorts {
pub plugin_preparation: Arc<dyn ports::plugins::PluginPreparation>,
pub plugin_management: Arc<dyn ports::plugin_management::PluginManagement>,
pub published_snapshot: gateway_core::runtime::RuntimeSnapshotHandle,
pub plugin_distribution: Arc<dyn ports::plugins::PluginDistribution>,
pub plugin_inspector: Arc<dyn ports::plugins::PluginPackageInspector>,
pub pricing_source: Arc<dyn ports::pricing::PricingSource>,
pub providers: Vec<Arc<dyn ProviderAdmin>>,
pub providers: ProviderAdminRegistry,
pub snapshot: Arc<dyn SnapshotControl>,
pub account_probe: Arc<dyn AccountProbe>,
pub proxy_probe: Arc<dyn ports::proxy::ProxyProbe>,
@@ -306,18 +328,44 @@ pub struct AdminRuntimePorts {
pub client_key_verifier: Arc<dyn ClientKeyVerifier>,
}
/// 校验配置、建立动态 Provider 注册表并完成默认管理员幂等初始化。
/// 校验配置、接入已组装的 Provider 注册表并完成默认管理员幂等初始化。
///
/// # Errors
///
/// 配置非法、Provider 注册冲突/缺失或默认管理员初始化失败时返回错误。
/// 配置非法或默认管理员初始化失败时返回错误。
pub async fn initialize(
config: AdminConfig,
client_config: ClientConfig,
store: AdminStorePorts,
runtime: AdminRuntimePorts,
) -> Result<AdminBundle, AdminError> {
initialize_inner(config, client_config, store, runtime, None).await
}
/// 使用组合根已绑定给 Runtime 的同一账号窄端口,避免为完整 Admin 重建第二实例。
pub async fn initialize_with_plugin_accounts(
config: AdminConfig,
client_config: ClientConfig,
store: AdminStorePorts,
runtime: AdminRuntimePorts,
plugin_accounts: Arc<dyn PluginAccountAccess>,
) -> Result<AdminBundle, AdminError> {
initialize_inner(config, client_config, store, runtime, Some(plugin_accounts)).await
}
async fn initialize_inner(
mut config: AdminConfig,
mut client_config: ClientConfig,
store: AdminStorePorts,
runtime: AdminRuntimePorts,
plugin_accounts: Option<Arc<dyn PluginAccountAccess>>,
) -> Result<AdminBundle, AdminError> {
let AdminRuntimePorts {
plugin_preparation,
plugin_management,
published_snapshot,
plugin_distribution,
plugin_inspector,
pricing_source,
providers,
snapshot,
@@ -333,13 +381,7 @@ pub async fn initialize(
client_config
.resolve_and_validate(Path::new("."))
.map_err(|error| AdminError::invalid(error.to_string()))?;
let registry = ProviderAdminRegistry::new(providers).map_err(map_provider_registry_error)?;
let openai = registry
.require(&provider_kind(OPENAI_PROVIDER_KIND)?)
.map_err(map_provider_registry_error)?;
let xai = registry
.require(&provider_kind(XAI_PROVIDER_KIND)?)
.map_err(map_provider_registry_error)?;
let registry = providers;
let auth = Arc::new(DefaultAuthService::new(
config.default_username,
@@ -370,7 +412,11 @@ pub async fn initialize(
backup_ports.dump(),
backup_ports.object_store(),
);
let system = Arc::new(DefaultSystemService::new(system));
let system_preflight = Arc::new(use_case::plugin_update::PluginSystemUpdatePreflight::new(
store.plugins(),
plugin_inspector.clone(),
));
let system = Arc::new(DefaultSystemService::new(system, system_preflight));
let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new(
auth.clone(),
client_key_verifier,
@@ -378,22 +424,32 @@ pub async fn initialize(
store.observability(),
system.clone(),
));
let openai = Arc::new(DefaultOpenAiService::new(
openai,
let credentials = Arc::new(CredentialsService::new(
registry.clone(),
store.accounts(),
store.proxies(),
snapshot.clone(),
));
let xai = Arc::new(DefaultXaiService::new(
xai,
store.accounts(),
store.proxies(),
snapshot.clone(),
));
let import_tasks =
use_case::import_tasks::DefaultImportTasksService::new(openai.clone(), xai.clone());
let plugin_accounts = plugin_accounts.unwrap_or_else(|| {
initialize_plugin_accounts(registry.clone(), store.accounts(), snapshot.clone())
});
let import_tasks = use_case::import_tasks::DefaultImportTasksService::new(credentials.clone());
let import_task = use_case::import_tasks::ImportTaskWorker(import_tasks.clone());
let services = AdminServices {
plugin_management: Arc::new(PluginManagementService::new(
plugin_management,
store.plugins(),
published_snapshot.clone(),
)),
plugins: Arc::new(PluginsService::new(
store.plugins(),
plugin_inspector,
PluginDistributionPorts::new(plugin_distribution, store.proxies()),
snapshot.clone(),
plugin_preparation,
published_snapshot,
store.plugin_state(),
)),
key_usage,
proxies: Arc::new(use_case::proxies::DefaultProxiesService::new(
store.proxies(),
@@ -427,8 +483,8 @@ pub async fn initialize(
pricing_source,
)),
system,
openai,
xai,
credentials,
plugin_accounts,
import_tasks,
backups,
};
@@ -460,6 +516,29 @@ pub async fn initialize(
})
}
/// CLI 只组合账号用例,不初始化管理员、管理服务或后台任务;写入仍复用同一事务与审计。
pub fn initialize_plugin_accounts(
providers: ports::provider::ProviderAdminRegistry,
accounts: Arc<dyn ports::store::AccountStore>,
snapshot: Arc<dyn gateway_core::runtime::SnapshotControl>,
) -> Arc<dyn PluginAccountAccess> {
Arc::new(use_case::plugin_accounts::DefaultPluginAccountAccess::new(
providers, accounts, snapshot,
))
}
/// 为 Runtime 创建只暴露非秘密 Client Key 目录的窄端口。
#[must_use]
pub fn initialize_plugin_client_keys(
providers: ports::provider::ProviderAdminRegistry,
store: Arc<dyn ports::store::ClientKeyStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Arc<dyn PluginClientKeyAccess> {
let service: Arc<dyn ClientKeyService> =
Arc::new(DefaultClientKeyService::new(store, snapshot, providers));
Arc::new(use_case::plugin_client_keys::DefaultPluginClientKeyAccess::new(service))
}
/// Backup Worker 注册:单个可取消 Daemon,owner 固定为 `backup`。
fn backup_worker_contribution(
task: backup::task::BackupTask,
@@ -509,24 +588,3 @@ fn freeze_recovery_worker_contribution(
.map_err(|_| AdminError::internal("冻结恢复 Worker 注册信息不合法"))?;
Ok(vec![WorkerContribution::Registration(registration)])
}
fn provider_kind(value: &'static str) -> Result<ProviderKind, AdminError> {
ProviderKind::new(value).map_err(|_| AdminError::internal("内置 Provider 类型不合法"))
}
fn map_provider_registry_error(error: ProviderAdminError) -> AdminError {
let kind = match error.kind() {
ProviderAdminErrorKind::Invalid | ProviderAdminErrorKind::Unsupported => {
AdminErrorKind::Invalid
}
ProviderAdminErrorKind::NotFound => AdminErrorKind::NotFound,
ProviderAdminErrorKind::Conflict => AdminErrorKind::Conflict,
ProviderAdminErrorKind::Ambiguous => AdminErrorKind::UpstreamResultUnknown,
ProviderAdminErrorKind::Unavailable | ProviderAdminErrorKind::CredentialRefreshRequired => {
AdminErrorKind::Unavailable
}
ProviderAdminErrorKind::BadGateway => AdminErrorKind::BadGateway,
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
};
AdminError::new(kind, "Provider 注册表初始化失败")
}
@@ -38,7 +38,8 @@ pub struct AccountGroupAccountSummary {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccountGroupCapacity {
pub used_slots: Option<u64>,
pub total_slots: u64,
/// `None` 表示可用账号中存在不限制并发的账号。
pub total_slots: Option<u64>,
}
/// Successful, downstream-committed USD request costs for group accounts.
@@ -55,7 +56,7 @@ pub struct AccountGroupMemberFact {
pub group_id: AccountGroupId,
pub account_id: String,
pub status: AccountStatusFacts,
pub total_slots: u64,
pub total_slots: Option<u64>,
}
/// Lightweight group reference embedded in account and client-key views.
@@ -18,6 +18,18 @@ pub use gateway_core::account::{
QuotaState, resolve_account_status,
};
/// 账号可用的管理操作。
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct ProviderAccountCapabilities {
pub quota: bool,
pub quota_refresh: bool,
pub profile: bool,
pub subscription: bool,
pub avatar: bool,
pub reset_credits: bool,
pub consume_reset_credit: bool,
}
/// 导入时统一应用的账号备注、调度与分组设置;缺省时保留原有导入语义。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountImportSettings {
@@ -101,7 +101,8 @@ impl From<ClientAuthenticationError> for LoginError {
fn from(error: ClientAuthenticationError) -> Self {
match error {
ClientAuthenticationError::InvalidKey => Self::InvalidCredentials,
ClientAuthenticationError::SnapshotUnavailable => Self::Unavailable,
ClientAuthenticationError::SnapshotUnavailable
| ClientAuthenticationError::ProviderUnavailable => Self::Unavailable,
}
}
}
@@ -1,6 +1,6 @@
//! Client API Key 的 Command、Result 与安全秘密类型。
use std::{fmt, num::NonZeroU16};
use std::{collections::BTreeMap, fmt, num::NonZeroU16};
use chrono::{DateTime, Utc};
@@ -12,6 +12,11 @@ use gateway_core::{
use super::{AdminModelError, Revision, account_groups::AccountGroupRef};
pub type ProviderRequestProfileOverrides =
BTreeMap<ProviderKind, gateway_core::account::OpaqueProviderData>;
pub type ProviderRequestProfileOverrideUpdates =
BTreeMap<ProviderKind, Option<gateway_core::account::OpaqueProviderData>>;
/// Client Key 列表保持旧 HTTP 合同允许的完整非零 `u16` 页大小。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ClientKeyPageSize(NonZeroU16);
@@ -86,8 +91,7 @@ pub struct ClientKeyListQuery {
/// 不含完整明文 Key 的管理投影。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientKeyRecord {
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub request_profile_overrides: ProviderRequestProfileOverrides,
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
@@ -146,8 +150,7 @@ impl fmt::Debug for ClientKeySecret {
/// API 提交的 Client Key 创建命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateClientKey {
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub request_profile_overrides: ProviderRequestProfileOverrides,
pub custom_key: Option<PlaintextClientApiKey>,
pub name: String,
pub label: Option<String>,
@@ -159,8 +162,7 @@ pub struct CreateClientKey {
/// 管理用例生成 ID 与明文后的持久化命令。
#[derive(Clone, PartialEq, Eq)]
pub struct NewClientKey {
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub request_profile_overrides: ProviderRequestProfileOverrides,
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
@@ -185,8 +187,7 @@ impl fmt::Debug for NewClientKey {
/// 修改 Client Key 的公开策略字段。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateClientKey {
pub openai_client_profile_override: Option<Option<gateway_core::account::OpaqueProviderData>>,
pub xai_client_profile_override: Option<Option<gateway_core::account::OpaqueProviderData>>,
pub request_profile_override_updates: ProviderRequestProfileOverrideUpdates,
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
@@ -11,6 +11,8 @@ pub mod client_keys;
pub mod import_tasks;
pub mod key_usage;
pub mod observability;
pub mod plugin_client_keys;
pub mod plugins;
pub mod pricing;
pub mod provider_credentials;
pub mod proxies;
@@ -992,7 +992,8 @@ pub struct DashboardWireAttribute {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DashboardCapacity {
pub max_concurrent_per_account: u64,
pub total_slots: u64,
/// `None` 表示可用账号池不限制并发。
pub total_slots: Option<u64>,
pub used_slots: Option<u64>,
pub available_slots: Option<u64>,
}
@@ -0,0 +1,31 @@
//! 插件可见的 Client Key 非秘密资源投影。
use gateway_core::policy::ClientApiKeyId;
use super::PageSize;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginClientKeyCursor {
pub name: String,
pub id: ClientApiKeyId,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginClientKeyListQuery {
pub cursor: Option<PluginClientKeyCursor>,
pub limit: PageSize,
}
/// 插件只能发现调用所需的公开身份,不取得 Key 前缀、策略细节或明文。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginClientKey {
pub id: ClientApiKeyId,
pub name: String,
pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginClientKeyPage {
pub items: Vec<PluginClientKey>,
pub next_cursor: Option<PluginClientKeyCursor>,
}
@@ -0,0 +1,231 @@
use std::{fmt, sync::Arc};
use chrono::{DateTime, Utc};
use gateway_core::account::OutboundProxy;
use secrecy::SecretString;
use serde::{Deserialize, Serialize};
use super::{PluginArtifactMetadata, PluginSource, PluginSourceEgress};
use crate::model::Revision;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginUpdateSource {
Builtin,
Upload,
Url { url: String },
Github { repository: String },
}
/// 检查策略只决定查询目标,任何策略都不会自动安装或切换运行实例。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginUpdatePolicy {
Manual {},
Stable {},
Pinned {
tag: String,
#[serde(default)]
allow_prerelease: bool,
},
}
impl Default for PluginUpdatePolicy {
fn default() -> Self {
Self::Manual {}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PluginSourceBinding {
pub plugin_id: String,
pub source: PluginUpdateSource,
#[serde(default)]
pub policy: PluginUpdatePolicy,
#[serde(default)]
pub outbound_proxy_id: Option<String>,
}
#[derive(Clone)]
pub struct PluginDistributionEgress {
pub source: PluginSourceEgress,
pub proxy: OutboundProxy,
}
impl PluginDistributionEgress {
#[must_use]
pub fn new(id: String, revision: Revision, proxy: OutboundProxy) -> Self {
Self {
source: PluginSourceEgress {
id,
revision: revision.get(),
},
proxy,
}
}
#[must_use]
pub fn cache_key(&self) -> String {
format!("{}:{}", self.source.id, self.source.revision)
}
}
impl fmt::Debug for PluginDistributionEgress {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("PluginDistributionEgress")
.field("source", &self.source)
.field("proxy", &"<redacted>")
.finish()
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginUpdateCheck {
pub binding: PluginSourceBinding,
/// Release 元数据不是已校验的插件包,不据此声明平台或 SDK 兼容。
pub release: PluginRelease,
}
impl From<&PluginSource> for PluginUpdateSource {
fn from(source: &PluginSource) -> Self {
match source {
PluginSource::Builtin { .. } => Self::Builtin,
PluginSource::Upload => Self::Upload,
PluginSource::Url { url, .. } => Self::Url { url: url.clone() },
PluginSource::Github { repository, .. } => Self::Github {
repository: repository.to_ascii_lowercase(),
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DownloadPurpose {
Metadata,
Artifact,
}
/// 授权目标以完整 origin 和路径段边界匹配;摘要、名称和列表均不包含 secret。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SourceCredentialInfo {
pub id: String,
pub name: String,
pub origin: String,
pub path_prefix: String,
pub purposes: Vec<DownloadPurpose>,
}
#[derive(Clone, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum SourceAuthentication {
Github {
token: SecretString,
},
Bearer {
token: SecretString,
},
Basic {
username: String,
password: SecretString,
},
Header {
name: String,
value: SecretString,
},
}
#[derive(Clone)]
pub struct SourceCredential {
pub info: SourceCredentialInfo,
pub authentication: SourceAuthentication,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct GithubReleaseQuery {
pub repository: String,
pub tag: Option<String>,
#[serde(default)]
pub allow_prerelease: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReleaseAsset {
pub name: String,
pub size: u64,
pub sha256: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginRelease {
pub repository: String,
pub tag: String,
pub name: String,
pub prerelease: bool,
pub assets: Vec<ReleaseAsset>,
pub queried_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
/// 下载意图不承载凭据;引用在 Admin 中解析后只交给 Host 的本次调用。
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum RemotePluginLocation {
Url {
url: String,
sha256: Option<String>,
},
Github {
repository: String,
tag: String,
asset: String,
#[serde(default)]
allow_prerelease: bool,
sha256: Option<String>,
},
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RemotePluginVerify {
pub location: RemotePluginLocation,
#[serde(default)]
pub credential_ids: Vec<String>,
#[serde(default)]
pub outbound_proxy_id: Option<String>,
/// 更新已有插件时锁定身份;首次安装从包内清单读取。
pub expected_plugin_id: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RemotePluginInstall {
pub plugin_id: String,
pub version: String,
pub location: RemotePluginLocation,
#[serde(default)]
pub credential_ids: Vec<String>,
#[serde(default)]
pub outbound_proxy_id: Option<String>,
}
pub struct DownloadedPlugin {
pub archive: Arc<[u8]>,
pub sha256: String,
pub source: PluginSource,
}
/// 只读包校验结果,不代表实例配置、授权或私有状态已完成运行时准备。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VerifiedPluginArtifact {
pub metadata: PluginArtifactMetadata,
pub source: PluginSource,
}
@@ -0,0 +1,192 @@
use std::collections::BTreeMap;
use secrecy::SecretString;
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 {
Reject,
Delegate,
Observe,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PluginCapabilityBinding {
pub contribution: String,
pub stage: String,
pub order: i32,
pub failure_policy: PluginFailurePolicy,
#[serde(default)]
pub client_key_ids: Vec<String>,
#[serde(default)]
pub account_group_ids: Vec<String>,
#[serde(default)]
pub provider_ids: Vec<String>,
#[serde(default)]
pub models: Vec<String>,
#[serde(default)]
pub identity_bindings: Vec<PluginFrontendIdentityBinding>,
}
/// 外部 principal 到既有 Client Key 的显式宿主映射。
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PluginFrontendIdentityBinding {
pub principal: String,
pub client_key_id: String,
}
impl std::fmt::Debug for PluginFrontendIdentityBinding {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("PluginFrontendIdentityBinding")
.field("principal", &"[REDACTED]")
.field("client_key_id", &self.client_key_id)
.finish()
}
}
/// 配置和 secret 分离;Debug 与 Serialize 不得无意展开实例配置。
#[derive(Clone)]
pub struct PluginInstance {
pub id: String,
pub name: String,
pub artifact_sha256: String,
pub enabled: bool,
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,
}
#[derive(Clone)]
pub struct PluginInstanceSnapshot {
pub config_revision: Revision,
pub instances: Vec<PluginInstance>,
}
pub struct PluginInstanceMutation {
pub config_revision: Revision,
pub instance: PluginInstance,
}
/// 控制面展示的单实例运行状态;它描述当前进程观察到的事实,不替代持久启用配置。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginInstanceRuntimeStatus {
Disabled,
AwaitingPublication,
Preparing,
Running,
Blocked,
PreparationFailed,
Faulted,
Draining,
}
/// 已脱敏且可安全返回给管理员的运行失败摘要。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginInstanceRuntimeFailure {
pub code: String,
pub message: String,
}
/// 当前发布视图与代次索引的只读诊断。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginInstanceRuntime {
pub status: PluginInstanceRuntimeStatus,
pub actual_revision: Option<u64>,
pub actual_artifact_sha256: Option<String>,
pub failure: Option<PluginInstanceRuntimeFailure>,
pub draining_revisions: Vec<u64>,
}
pub struct PluginInstanceView {
pub instance: PluginInstance,
pub configuration_required: bool,
pub running: bool,
pub published_revision: Option<u64>,
pub runtime: PluginInstanceRuntime,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ConfigurePluginInstance {
/// 创建草稿固定一个 UUID,重试复用同一配置,不能作为编辑已有配置的入口。
pub creation_id: Option<String>,
/// 编辑时检查用户看到的配置版本,避免覆盖另一窗口已经保存的内容。
pub expected_revision: Option<u64>,
/// 管理员确认停用的同插件配置,与当前配置在同一事务中切换。
#[serde(default)]
pub replace_instances: Vec<PluginInstanceReplacement>,
pub name: String,
pub artifact_sha256: String,
pub enabled: bool,
pub configuration: serde_json::Value,
/// 缺省保留旧值;显式空对象清除全部 secret。
pub secrets: Option<BTreeMap<String, SecretString>>,
pub bindings: Vec<PluginCapabilityBinding>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PluginInstanceReplacement {
pub id: String,
pub expected_revision: u64,
}
/// 恢复已安装旧版本的配置快照,并重新通过目标版本校验。
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RollbackPluginInstance {
pub artifact_sha256: String,
pub expected_revision: u64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginRollbackTarget {
pub artifact_sha256: String,
pub version: String,
pub platforms: Vec<String>,
}
/// 静态候选不启动插件;实际回滚仍须复验配置、平台和私有状态兼容性。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginRollbackPlan {
pub instance_revision: u64,
pub current_version: String,
pub targets: Vec<PluginRollbackTarget>,
}
/// 版本配置快照只用于服务端恢复,敏感值不实现 Debug 或 Serialize。
#[derive(Clone)]
pub struct PluginVersionConfiguration {
pub configuration: serde_json::Value,
pub secrets: BTreeMap<String, SecretString>,
pub bindings: Vec<PluginCapabilityBinding>,
}
/// 切换版本的只读草稿,不包含密钥值,也不会启动插件或写入配置。
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginVersionPlan {
pub instance_revision: u64,
pub artifact_sha256: String,
pub configuration: serde_json::Value,
pub secret_fields: Vec<String>,
pub bindings: Vec<PluginCapabilityBinding>,
pub restored: bool,
}
@@ -0,0 +1,89 @@
use std::sync::Arc;
use serde::{Deserialize, Serialize};
/// 页面和业务调用绑定同一实例版本;制品摘要单独作为不可变资源身份。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PluginManagementTarget {
pub instance_id: String,
pub artifact_sha256: String,
pub revision: u64,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginManagementView {
pub target: PluginManagementTarget,
pub name: String,
pub configuration_schema: serde_json::Value,
pub pages: Vec<PluginManagementPage>,
pub routes: Vec<PluginManagementRoute>,
pub resources: Vec<PluginManagementResource>,
pub callbacks: Vec<PluginManagementCallback>,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginManagementCallback {
pub path: String,
pub response_content_types: Vec<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StartPluginManagementCallback {
pub path: String,
pub ttl_seconds: u32,
}
/// state 是短期一次性秘密,只交给发起登录的管理员,不进入 Debug 或日志。
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginManagementCallbackTicket {
pub state: String,
pub expires_at_ms: i64,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginManagementPage {
pub id: String,
pub title: String,
pub description: Option<String>,
pub entry: String,
pub icon: Option<String>,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginManagementRoute {
pub method: String,
pub path: String,
pub request_content_types: Vec<String>,
pub response_content_types: Vec<String>,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginManagementResource {
pub path: String,
pub content_type: String,
pub public: bool,
}
/// 原始正文可能含业务数据,不能派生内容型 Debug 或写入诊断日志。
pub struct PluginManagementRequest {
pub method: String,
pub path: String,
pub query: String,
pub content_type: Option<String>,
pub body: Vec<u8>,
pub request_id: String,
}
pub struct PluginManagementResponse {
pub status: u16,
pub content_type: String,
pub body: Arc<[u8]>,
}
@@ -0,0 +1,273 @@
use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
};
use serde::{Deserialize, Serialize};
use super::Revision;
pub mod distribution;
pub mod instances;
pub mod management;
pub mod official;
pub mod state;
/// 一个宿主发行版明确承诺支持的插件能力版本。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginHostCapability {
pub capability: String,
pub versions: Vec<u32>,
}
/// 随宿主发行物封口的插件合同;Runtime 与发布清单共用这一份声明。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginHostCompatibility {
pub schema_version: u32,
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
|| !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;
}
let mut capabilities = BTreeSet::new();
if self.capabilities.iter().any(|entry| {
!valid_identifier(&entry.capability)
|| !valid_versions(&entry.versions)
|| !capabilities.insert(entry.capability.as_str())
}) {
return false;
}
let mut permissions = BTreeSet::new();
!self.permissions.iter().any(|permission| {
!valid_identifier(permission) || !permissions.insert(permission.as_str())
})
}
#[must_use]
pub fn supports_capability(&self, capability: &str, version: u32) -> bool {
self.capabilities
.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 {
!versions.is_empty()
&& versions.len() <= 16
&& versions.iter().all(|version| *version > 0)
&& versions.iter().collect::<BTreeSet<_>>().len() == versions.len()
}
fn valid_identifier(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 64
&& value.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-')
})
}
pub(crate) fn valid_plugin_id(value: &str) -> bool {
if value.len() > 64 {
return false;
}
let Some((publisher, name)) = value.split_once('.') else {
return false;
};
valid_plugin_segment(publisher) && valid_plugin_segment(name)
}
fn valid_plugin_segment(value: &str) -> bool {
value
.as_bytes()
.first()
.is_some_and(u8::is_ascii_alphanumeric)
&& value
.as_bytes()
.last()
.is_some_and(u8::is_ascii_alphanumeric)
&& value
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
}
/// 已安装包对目标宿主的静态要求;不包含包体、配置或 secret。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginCompatibilityRequirements {
pub host_version: String,
pub manifest_schema_version: u32,
pub protocol_version: u32,
pub capabilities: Vec<(String, u32)>,
pub permissions: Vec<String>,
}
/// 一次下载使用的非敏感出站代理身份;地址与认证由受管代理记录持有。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PluginSourceEgress {
pub id: String,
pub revision: u64,
}
/// 安装来源只保存公开定位信息,下载凭据和出站代理通过独立记录引用。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginSource {
Builtin {
release: String,
},
Upload,
Url {
url: String,
credential_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
outbound_proxy: Option<PluginSourceEgress>,
},
Github {
repository: String,
tag: String,
asset: String,
credential_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
outbound_proxy: Option<PluginSourceEgress>,
},
}
impl PluginSource {
#[must_use]
pub fn credential_ids(&self) -> &[String] {
match self {
Self::Url { credential_ids, .. } | Self::Github { credential_ids, .. } => {
credential_ids
}
Self::Builtin { .. } | Self::Upload => &[],
}
}
#[must_use]
pub const fn outbound_proxy(&self) -> Option<&PluginSourceEgress> {
match self {
Self::Url { outbound_proxy, .. } | Self::Github { outbound_proxy, .. } => {
outbound_proxy.as_ref()
}
Self::Builtin { .. } | Self::Upload => None,
}
}
}
/// 管理端保留的单项贡献声明;map key 是能力标识,`id` 是实例绑定引用的扩展项身份。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PluginContribution {
pub id: String,
pub version: u32,
pub stages: Vec<String>,
pub input_formats: Vec<String>,
pub output_formats: Vec<String>,
}
/// 制品清单中的展示图标;路径只用于选择已校验的包资源。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PluginArtifactIcon {
Path(String),
Themed(PluginArtifactIconVariants),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginArtifactIconVariants {
pub light: String,
pub dark: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginIconTheme {
Light,
Dark,
}
/// 从不可变制品中读取的已校验图标字节。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginArtifactIconResource {
pub content_type: String,
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,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PluginArtifactMetadata {
pub plugin_id: String,
pub version: String,
pub name: String,
pub display_name: String,
pub publisher: String,
pub author: Option<String>,
pub description: String,
pub license: String,
pub sha256: String,
pub platforms: Vec<String>,
#[serde(default)]
pub icon: Option<PluginArtifactIcon>,
pub contributes: BTreeMap<String, PluginContribution>,
pub requested_permissions: Vec<String>,
pub configuration_schema: serde_json::Value,
pub secret_fields: Vec<String>,
#[serde(default)]
pub state_namespaces: Vec<state::PluginStateSchema>,
}
/// 由包检查端口产生的不可变制品;不派生 Debug,包内可能含敏感用户数据。
#[derive(Clone)]
pub struct InspectedPluginArtifact {
pub metadata: PluginArtifactMetadata,
pub archive: Arc<[u8]>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct InstalledPluginArtifact {
pub metadata: PluginArtifactMetadata,
pub source: PluginSource,
pub installed_at: chrono::DateTime<chrono::Utc>,
pub accepted_at: Option<chrono::DateTime<chrono::Utc>>,
}
pub struct PluginArtifactMutation {
pub config_revision: Revision,
pub artifact: InstalledPluginArtifact,
}
pub struct PluginInstallResult {
pub mutation: PluginArtifactMutation,
pub default_instance_id: Option<String>,
pub configuration_required: bool,
}
@@ -0,0 +1,15 @@
use super::super::Revision;
/// 编译进宿主二进制的发行身份;不能由运行时配置或插件清单覆盖。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OfficialPluginReleaseIdentity {
pub gateway_version: String,
pub gateway_git_sha: String,
}
/// 启动时导入官方制品的结果;导入只持久化制品,不创建或启用实例。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OfficialPluginImport {
pub artifacts: usize,
pub config_revision: Option<Revision>,
}
@@ -0,0 +1,205 @@
use std::{collections::BTreeMap, fmt};
use serde::{Deserialize, Serialize};
use crate::model::Revision;
/// 已由 Runtime 校验的命名空间 schema 与配额事实。
#[derive(Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PluginStateSchema {
pub namespace: String,
pub schema_version: u32,
pub schema_sha256: String,
pub schema: serde_json::Value,
pub maximum_records: u32,
pub maximum_bytes: u64,
pub maximum_value_bytes: u32,
#[serde(default)]
pub migrates_from: Vec<u32>,
}
impl fmt::Debug for PluginStateSchema {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("PluginStateSchema")
.field("namespace", &self.namespace)
.field("schema_version", &self.schema_version)
.field("schema_sha256", &self.schema_sha256)
.field("schema", &"[REDACTED]")
.field("maximum_records", &self.maximum_records)
.field("maximum_bytes", &self.maximum_bytes)
.field("maximum_value_bytes", &self.maximum_value_bytes)
.field("migrates_from", &self.migrates_from)
.finish()
}
}
#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PluginStateConfiguration {
#[serde(default)]
pub namespaces: Vec<PluginStateSchema>,
}
/// 与实例保存同事务提交的状态配置;transition 只引用已完成的 staging 数据。
#[derive(Clone, Default)]
pub struct PluginStateCommit {
pub configuration: PluginStateConfiguration,
pub transition_id: Option<String>,
}
#[derive(Clone)]
pub struct PluginStateOwnerRequest {
pub instance_id: String,
pub artifact_sha256: String,
pub instance_revision: Revision,
pub configuration: PluginStateConfiguration,
}
/// Store 签发的实例私有状态 fence。插件协议中从不传输这些字段。
#[derive(Clone)]
pub struct PluginStateOwner {
instance_id: String,
artifact_sha256: String,
instance_revision: Revision,
namespaces: BTreeMap<String, PluginStateNamespaceOwner>,
}
#[derive(Clone)]
pub struct PluginStateNamespaceOwner {
generation_id: String,
fence: String,
schema_version: u32,
}
impl PluginStateOwner {
#[must_use]
pub fn from_store(
instance_id: String,
artifact_sha256: String,
instance_revision: Revision,
namespaces: BTreeMap<String, PluginStateNamespaceOwner>,
) -> Self {
Self {
instance_id,
artifact_sha256,
instance_revision,
namespaces,
}
}
#[must_use]
pub fn instance_id(&self) -> &str {
&self.instance_id
}
#[must_use]
pub fn artifact_sha256(&self) -> &str {
&self.artifact_sha256
}
#[must_use]
pub const fn instance_revision(&self) -> Revision {
self.instance_revision
}
#[must_use]
pub fn namespace(&self, namespace: &str) -> Option<&PluginStateNamespaceOwner> {
self.namespaces.get(namespace)
}
}
impl PluginStateNamespaceOwner {
#[must_use]
pub fn from_store(generation_id: String, fence: String, schema_version: u32) -> Self {
Self {
generation_id,
fence,
schema_version,
}
}
#[must_use]
pub fn generation_id(&self) -> &str {
&self.generation_id
}
#[must_use]
pub fn fence(&self) -> &str {
&self.fence
}
#[must_use]
pub const fn schema_version(&self) -> u32 {
self.schema_version
}
}
/// 私有值可能含插件自己的敏感状态,故意不实现 `Debug`。
#[derive(Clone)]
pub struct PluginStateRecord {
pub key: String,
pub value: serde_json::Value,
pub version: u64,
pub schema_version: u32,
}
pub struct PutPluginState {
pub namespace: String,
pub key: String,
pub value: serde_json::Value,
/// `None` 是 create-only;已有记录必须提交精确版本。
pub expected_version: Option<u64>,
}
pub struct DeletePluginState {
pub namespace: String,
pub key: String,
pub expected_version: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PluginStateWrite {
pub version: u64,
}
#[derive(Clone)]
pub struct PluginStateTransition {
pub id: String,
pub instance_id: String,
pub artifact_sha256: String,
pub namespaces: Vec<PluginStateMigrationNamespace>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginStateMigrationNamespace {
pub namespace: String,
pub from_schema_version: u32,
pub to_schema_version: u32,
}
pub struct PluginStateMigrationBatch {
pub cursor: Option<String>,
pub records: Vec<PluginStateRecord>,
}
pub struct ApplyPluginStateMigration {
pub transition_id: String,
pub namespace: String,
pub cursor: Option<String>,
/// Runtime 刚读取的有序源键;Store 会在写入前重新读取并逐项比对。
pub expected_keys: Vec<String>,
pub changes: Vec<PluginStateMigrationChange>,
}
pub struct PluginStateMigrationChange {
pub key: String,
pub action: PluginStateMigrationAction,
}
pub enum PluginStateMigrationAction {
Keep,
Replace(serde_json::Value),
Delete,
}
@@ -8,6 +8,7 @@ use chrono::{DateTime, NaiveDate, Utc};
use futures::Stream;
use gateway_core::{
account::{OpaqueProviderData, ProviderAccountId, ProviderAccountIdentity},
operation::RawJsonPayload,
routing::{ProviderKind, UpstreamModelId},
};
use serde::Deserialize;
@@ -15,7 +16,7 @@ use serde_json::{Map, Value};
use uuid::Uuid;
use super::{
AdminError, MutationActor, MutationContext, Revision,
AdminError, MutationActor, MutationContext, PageSize, Revision,
accounts::{
AccountImportSettings, AccountRecord, AccountSummary, AccountUsage, CredentialState,
},
@@ -384,8 +385,8 @@ enum StoredAuthorizationOwnerV1 {
System,
}
/// 启动 Provider OAuth Authorization Code 流程。
#[derive(Debug, Clone, PartialEq, Eq)]
/// Provider 授权请求。
#[derive(Debug, Clone, PartialEq)]
pub struct StartAuthorization {
pub context: MutationContext,
pub name: String,
@@ -425,8 +426,8 @@ pub trait AuthorizationCommitGuard: Send + 'static {
/// OAuth complete 后由 Provider 返回的准备结果;Store 仍是唯一提交者。
pub enum PreparedAuthorizationCredential {
Create(PreparedCredentialCreate),
Reauthorize(PreparedCredentialRotation),
Create(Box<PreparedCredentialCreate>),
Reauthorize(Box<PreparedCredentialRotation>),
}
/// Provider 从 opaque pending payload 恢复的信封与已验证 credential 必须一起返回。
@@ -449,18 +450,87 @@ impl fmt::Debug for PreparedAuthorizationCommit {
/// Store 可持久化的 OAuth credential facts,不携带 Provider guard。
#[derive(Debug, Clone, PartialEq)]
pub enum AuthorizationCredentialCommit {
Create(PreparedCredentialCreate),
Reauthorize(PreparedCredentialRotationFacts),
Create(Box<PreparedCredentialCreate>),
Reauthorize(Box<PreparedCredentialRotationFacts>),
}
/// Admin 交给 Store 的 OAuth 原子事务命令。
#[derive(Debug, Clone, PartialEq)]
pub struct AuthorizationCommit {
pub key: AuthorizationReceiptKey,
pub settings: Option<AccountImportSettings>,
pub pending: PendingAuthorizationMutation,
pub credential: AuthorizationCredentialCommit,
}
/// 回执以 Provider、授权流和管理员身份绑定;不持久化原始 flow 或回调材料。
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct AuthorizationReceiptKey {
provider_kind: ProviderKind,
flow_digest: String,
owner_digest: String,
}
impl AuthorizationReceiptKey {
pub fn new(
provider_kind: ProviderKind,
flow: &str,
context: &MutationContext,
) -> Result<Self, AdminError> {
use sha2::{Digest as _, Sha256};
if flow.trim().is_empty() || flow.len() > 1024 || flow.chars().any(char::is_control) {
return Err(AdminError::invalid("授权流程标识无效"));
}
Ok(Self {
provider_kind,
flow_digest: hex::encode(Sha256::digest(flow.as_bytes())),
owner_digest: authorization_owner_digest(context),
})
}
#[must_use]
pub const fn provider_kind(&self) -> &ProviderKind {
&self.provider_kind
}
#[must_use]
pub fn flow_digest(&self) -> &str {
&self.flow_digest
}
#[must_use]
pub fn owner_digest(&self) -> &str {
&self.owner_digest
}
#[must_use]
pub fn matches_context(&self, context: &MutationContext) -> bool {
authorization_owner_digest(context) == self.owner_digest
}
}
fn authorization_owner_digest(context: &MutationContext) -> String {
use sha2::{Digest as _, Sha256};
let owner = match &context.actor {
MutationActor::AdminSession { admin_user_id } => format!("admin_session:{admin_user_id}"),
MutationActor::AdminApiKey => "admin_api_key".into(),
MutationActor::System => "system".into(),
};
hex::encode(Sha256::digest(owner.as_bytes()))
}
impl fmt::Debug for AuthorizationReceiptKey {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("AuthorizationReceiptKey([REDACTED])")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthorizationCommitResult {
pub result: CredentialMutationResult,
pub newly_committed: bool,
}
/// OAuth 准备结果拆解后的 Store 命令与两个结算 guard。
///
/// Store 成功时先结束 credential guard,再消费 OAuth claim;失败时丢弃 credential guard 并
@@ -494,6 +564,7 @@ impl PreparedAuthorizationCommit {
pub(crate) fn into_commit(
self,
settings: Option<AccountImportSettings>,
key: AuthorizationReceiptKey,
) -> AuthorizationCommitSettlement {
let (credential, guard) = match self.credential {
PreparedAuthorizationCredential::Create(credential) => {
@@ -502,13 +573,14 @@ impl PreparedAuthorizationCommit {
PreparedAuthorizationCredential::Reauthorize(prepared) => {
let (facts, guard) = prepared.into_parts();
(
AuthorizationCredentialCommit::Reauthorize(facts),
AuthorizationCredentialCommit::Reauthorize(Box::new(facts)),
Some(guard),
)
}
};
AuthorizationCommitSettlement {
command: AuthorizationCommit {
key,
settings,
pending: self.pending,
credential,
@@ -519,13 +591,8 @@ impl PreparedAuthorizationCommit {
}
pub(crate) async fn abort(self) -> Result<(), AdminError> {
let AuthorizationCommitSettlement {
credential_guard,
authorization_guard,
..
} = self.into_commit(None);
drop(credential_guard);
if let Some(guard) = authorization_guard {
drop(self.credential);
if let Some(guard) = self.authorization_guard {
guard.abort().await?;
}
Ok(())
@@ -616,6 +683,8 @@ pub struct PreparedCredentialRotationFacts {
pub plan_type: Option<String>,
/// Token 刷新保留提交时的资料,不以准备阶段的副本覆盖新套餐。
pub preserve_profile: bool,
/// 连接配置变更保留当前凭据健康状态与错误事实。
pub preserve_credential_state: bool,
pub provider_material: ProviderDocument,
pub has_refresh_token: bool,
pub access_token_expires_at: Option<DateTime<Utc>>,
@@ -637,6 +706,13 @@ impl PreparedCredentialRotation {
Self { facts, guard }
}
/// 仅修改连接配置时,不把配置更新视为凭据恢复成功。
#[must_use]
pub fn preserving_credential_state(mut self) -> Self {
self.facts.preserve_credential_state = true;
self
}
#[must_use]
pub const fn facts(&self) -> &PreparedCredentialRotationFacts {
&self.facts
@@ -1060,6 +1136,62 @@ pub struct ProviderModels {
pub observed_at: Option<DateTime<Utc>>,
}
/// Provider 原生模型目录正文;wire 语义由对应 Provider 拥有,公共层只搬运不解释字段。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderModelCatalogDocument {
pub document: RawJsonPayload,
pub model_count: usize,
pub observed_at: DateTime<Utc>,
}
/// 插件账号回调的服务端过滤条件;Provider 缺省时跨全部已注册类型分页。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginAccountListQuery {
pub provider_kind: Option<ProviderKind>,
pub cursor: Option<ProviderAccountId>,
pub limit: PageSize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginAccountPage {
pub accounts: Vec<AccountRecord>,
pub next_cursor: Option<ProviderAccountId>,
}
/// 原始凭据与当前账号 revision 的同一读取结果。
#[derive(Clone, PartialEq)]
pub struct PluginAccountCredential {
pub account: AccountRecord,
pub provider_material: ProviderDocument,
}
impl fmt::Debug for PluginAccountCredential {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("PluginAccountCredential")
.field("account", &self.account)
.field("provider_material", &self.provider_material)
.finish()
}
}
/// Runtime 已完成 wire 校验、Admin 仍需绑定当前账号和 revision 的 prepared facts。
#[derive(Debug, Clone, PartialEq)]
pub enum PreparedPluginAccountSave {
Create(PreparedCredentialCreate),
Replace {
facts: PreparedCredentialRotationFacts,
authentication_kind: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginAccountSaveResult {
pub config_revision: Revision,
pub account_id: ProviderAccountId,
pub credential_revision: Revision,
}
/// Provider 执行 refresh 时所需的当前公共账号事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrepareCredentialRefresh {
@@ -1095,6 +1227,7 @@ impl fmt::Debug for ProviderExport {
#[derive(Debug, Clone, PartialEq)]
pub struct AccountDirectoryItem {
pub account: AccountRecord,
pub capabilities: super::accounts::ProviderAccountCapabilities,
/// Provider 提供的套餐展示名称;未识别到套餐时为空。
pub plan_type_display: Option<String>,
pub projection: gateway_core::account::AccountStatusProjection,
@@ -1,7 +1,8 @@
//! 可复用的账号出口配置,以及脱敏后的连通性测试结果。
use chrono::{DateTime, Utc};
use gateway_core::account::OutboundProxy;
use gateway_core::account::{OutboundProxy, RequestLocation};
use serde::{Deserialize, Serialize};
use super::{PageSize, Revision, account_groups::AccountGroupRef};
@@ -57,6 +58,7 @@ pub struct ProxyAccountPage {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxyTestResult {
pub location: ProxyLocationDetection,
pub success: bool,
pub latency_ms: u64,
pub exit_ip: Option<std::net::IpAddr>,
@@ -67,6 +69,8 @@ pub struct ProxyTestResult {
#[derive(Debug, Clone)]
pub struct ProxyRecord {
pub auto_location: bool,
pub detected_location: Option<DetectedProxyLocation>,
pub location: Option<gateway_core::account::RequestLocation>,
pub id: String,
pub name: String,
@@ -89,6 +93,9 @@ pub struct ProxyPage {
#[derive(Debug, Clone)]
pub struct NewProxy {
pub auto_location: bool,
/// 由管理用例探测,随配置原子保存,不接受 HTTP 调用方提供结果。
pub test: Option<ProxyTestResult>,
pub location: Option<gateway_core::account::RequestLocation>,
pub name: String,
pub proxy: OutboundProxy,
@@ -96,6 +103,8 @@ pub struct NewProxy {
#[derive(Debug, Clone)]
pub struct UpdateProxy {
pub auto_location: Option<bool>,
pub test: Option<ProxyTestResult>,
/// 外层为空保留配置,内层为空恢复全局继承。
pub location: Option<Option<gateway_core::account::RequestLocation>>,
pub id: String,
@@ -109,3 +118,62 @@ pub struct ProxyMutation {
pub config_revision: Revision,
pub record: ProxyRecord,
}
/// 检测失败与连通性分开,双栈冲突不得继续应用旧位置。
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum ProxyLocationDetection {
#[default]
NotRequested,
Detected {
location: RequestLocation,
},
Failed {
message: String,
},
Conflict,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DetectedProxyLocation {
pub location: RequestLocation,
pub exit_ipv4: Option<std::net::Ipv4Addr>,
pub exit_ipv6: Option<std::net::Ipv6Addr>,
pub detected_at: DateTime<Utc>,
}
impl ProxyRecord {
#[must_use]
pub fn effective_location(&self) -> Option<&RequestLocation> {
if self.auto_location {
self.detected_location
.as_ref()
.map(|detected| &detected.location)
} else {
self.location.as_ref()
}
}
/// 未知出口可暂用上次成功结果,已知出口变化或时区冲突必须撤销旧覆盖。
#[must_use]
pub fn detected_location_after_test(
&self,
result: &ProxyTestResult,
) -> Option<DetectedProxyLocation> {
match &result.location {
ProxyLocationDetection::Detected { location } => Some(DetectedProxyLocation {
location: location.clone(),
exit_ipv4: result.exit_ipv4,
exit_ipv6: result.exit_ipv6,
detected_at: Utc::now(),
}),
ProxyLocationDetection::Failed { .. } => self.detected_location.clone().filter(|old| {
!result.success
|| (old.exit_ipv4 == result.exit_ipv4 && old.exit_ipv6 == result.exit_ipv6)
}),
ProxyLocationDetection::Conflict => None,
ProxyLocationDetection::NotRequested => self.detected_location.clone(),
}
}
}
@@ -43,6 +43,19 @@ pub struct QuotaForecastSource {
pub usd: Option<f64>,
}
/// 为目标周期选择唯一的预测源窗口;缺少同周期窗口时复用最短的可统计窗口。
///
/// Admin 的历史查询与最终投影必须共用这里的顺序,避免查询不会进入响应的窗口。
pub(crate) fn quota_forecast_source_window(
quota: &ProviderQuota,
period: AccountUsagePeriod,
) -> Option<(&ProviderQuotaWindow, AccountUsagePeriod)> {
quota
.usage_windows()
.find(|(_, source_period)| *source_period == period)
.or_else(|| quota.usage_windows().min_by_key(|(_, period)| *period))
}
/// 优先预测真实的对应窗口;缺少对应周期时只给出明确标识的 7/30 天容量折算。
/// 本地日志不能证明站外消耗或完整留存,因此即使样本充足也不声称官方额度。
#[must_use]
@@ -53,10 +66,7 @@ pub fn account_quota_forecasts(
samples: &[QuotaForecastSample],
) -> [AccountQuotaForecast; 2] {
[AccountUsagePeriod::Weekly, AccountUsagePeriod::Monthly].map(|period| {
let selected = quota
.usage_windows()
.find(|(_, source_period)| *source_period == period)
.or_else(|| quota.usage_windows().min_by_key(|(_, period)| *period));
let selected = quota_forecast_source_window(quota, period);
let mut forecast = AccountQuotaForecast {
period,
target_seconds: match period {
@@ -4,21 +4,28 @@ use std::{collections::BTreeMap, fmt};
use chrono::{DateTime, Utc};
use gateway_core::routing::{PublicModelId, UpstreamModelId};
use gateway_core::routing::{ProviderKind, PublicModelId, UpstreamModelId};
use super::Revision;
/// 客户端模型到上游模型的全局精确映射。
pub type ModelMappings = BTreeMap<PublicModelId, UpstreamModelId>;
/// Provider-owned 请求画像选择;Provider ID 是唯一命名空间。
pub type ProviderRequestProfiles =
BTreeMap<ProviderKind, gateway_core::account::OpaqueProviderData>;
/// Provider-owned 请求画像的部分更新;`None` 只表示显式删除该 Provider 的历史选择。
pub type ProviderRequestProfileUpdates =
BTreeMap<ProviderKind, Option<gateway_core::account::OpaqueProviderData>>;
/// 账号调度策略;由 Core 拥有稳定值与 wire 映射。
pub use gateway_core::account::RotationStrategy;
/// 完整运行设置事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeSettings {
pub openai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
pub request_profiles: ProviderRequestProfiles,
pub config_revision: Revision,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
@@ -44,14 +51,17 @@ pub struct RuntimeSettings {
pub account_auto_freeze_probe_enabled: bool,
pub account_auto_freeze_probe_model: Option<String>,
pub account_auto_freeze_adaptive_concurrency: bool,
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
pub updated_at: DateTime<Utc>,
}
/// 原子替换运行设置的命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReplaceRuntimeSettings {
pub openai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
/// 只覆盖提交的 Provider;未提交项保留当前持久值。
pub request_profile_updates: ProviderRequestProfileUpdates,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub model_mappings: ModelMappings,
@@ -76,6 +86,9 @@ pub struct ReplaceRuntimeSettings {
pub account_auto_freeze_probe_enabled: bool,
pub account_auto_freeze_probe_model: Option<String>,
pub account_auto_freeze_adaptive_concurrency: bool,
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
}
/// 明文管理员 API Key;按产品约束明文落库,但禁止 Debug 泄漏。
@@ -134,10 +147,8 @@ impl RuntimeSettings {
&self,
provider: &str,
) -> Option<&gateway_core::account::OpaqueProviderData> {
match provider {
"openai" => self.openai_client_profile.as_ref(),
"xai" => self.xai_client_profile.as_ref(),
_ => None,
}
ProviderKind::new(provider.to_owned())
.ok()
.and_then(|provider| self.request_profiles.get(&provider))
}
}
@@ -1,6 +1,40 @@
//! 版本、自更新、回滚与进程重启的 UTC 语义模型。
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// 本次检查或安装选择的更新稳定性范围,具体版本准入由 Host 判定。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum SystemUpdateChannel {
Stable,
Alpha,
Beta,
Rc,
#[serde(rename = "exp")]
Experimental,
}
impl SystemUpdateChannel {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Stable => "stable",
Self::Alpha => "alpha",
Self::Beta => "beta",
Self::Rc => "rc",
Self::Experimental => "exp",
}
}
}
/// Host 返回的本次检查通道及当前构建允许选择的范围。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SystemUpdatePolicy {
pub channel: SystemUpdateChannel,
pub available_channels: Vec<SystemUpdateChannel>,
}
/// 当前运行版本及更新检查摘要。
///
@@ -23,6 +57,7 @@ pub struct SystemVersion {
/// 部署模式与构建类型的展示标签归 API;其余字段均为 Host 已确认的原始事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SystemUpdateDetail {
pub policy: SystemUpdatePolicy,
pub current_version: String,
pub latest_version: String,
pub has_update: bool,
@@ -2,6 +2,11 @@
pub mod backup;
pub mod client_distribution;
pub mod plugin_accounts;
pub mod plugin_client_keys;
pub mod plugin_management;
pub mod plugin_release;
pub mod plugins;
pub mod pricing;
pub mod provider;
pub mod proxy;
@@ -0,0 +1,42 @@
//! 插件宿主访问账号权威所需的窄端口。
use async_trait::async_trait;
use gateway_core::account::ProviderAccountId;
use crate::model::{
AdminError, MutationContext,
provider_credentials::{
PluginAccountCredential, PluginAccountListQuery, PluginAccountPage,
PluginAccountSaveResult, PreparedPluginAccountSave,
},
};
/// Runtime 只取得账号读写用例,不接触 Store、SQL 或完整管理服务集合。
#[async_trait]
pub trait PluginAccountAccess: Send + Sync {
async fn list(&self, query: PluginAccountListQuery) -> Result<PluginAccountPage, AdminError>;
async fn get_runtime(
&self,
account_id: &ProviderAccountId,
) -> Result<crate::model::accounts::AccountRecord, AdminError>;
async fn get_credential(
&self,
account_id: &ProviderAccountId,
) -> Result<PluginAccountCredential, AdminError>;
/// 只读 Provider 已有额度观测;实现不得主动刷新上游或附加用量预测。
async fn get_quota(
&self,
_account_id: &ProviderAccountId,
) -> Result<crate::model::provider_credentials::ProviderQuota, AdminError> {
Err(AdminError::unavailable("插件额度事实查询暂不可用"))
}
async fn save(
&self,
command: PreparedPluginAccountSave,
context: &MutationContext,
) -> Result<PluginAccountSaveResult, AdminError>;
}
@@ -0,0 +1,16 @@
//! 插件发现 Client Key 公开身份的窄端口。
use async_trait::async_trait;
use crate::model::{
AdminError,
plugin_client_keys::{PluginClientKeyListQuery, PluginClientKeyPage},
};
#[async_trait]
pub trait PluginClientKeyAccess: Send + Sync {
async fn list(
&self,
query: PluginClientKeyListQuery,
) -> Result<PluginClientKeyPage, AdminError>;
}
@@ -0,0 +1,57 @@
use async_trait::async_trait;
use gateway_core::runtime::extensions::ExtensionSetReference;
use crate::model::{
AdminError,
plugins::management::{
PluginManagementRequest, PluginManagementResponse, PluginManagementTarget,
PluginManagementView,
},
};
/// 仅按 Admin 固定的发布引用分派;Runtime 不自行选择当前代次,也不拥有管理员身份。
#[async_trait]
pub trait PluginManagement: Send + Sync {
/// 复核固定发布代次中的模型访问域;调用方已校验持久实例仍为当前目标。
async fn authorize_models(
&self,
published: &ExtensionSetReference,
target: &PluginManagementTarget,
) -> Result<(), AdminError>;
async fn start_callback(
&self,
published: &ExtensionSetReference,
target: &PluginManagementTarget,
command: crate::model::plugins::management::StartPluginManagementCallback,
context: &crate::model::auth::AdminRequestContext,
) -> Result<crate::model::plugins::management::PluginManagementCallbackTicket, AdminError>;
async fn callback(
&self,
published: &ExtensionSetReference,
target: &PluginManagementTarget,
state: &str,
request: PluginManagementRequest,
) -> Result<PluginManagementResponse, AdminError>;
async fn views(
&self,
published: &ExtensionSetReference,
) -> Result<Vec<PluginManagementView>, AdminError>;
async fn resource(
&self,
published: &ExtensionSetReference,
target: &PluginManagementTarget,
path: &str,
public: bool,
) -> Result<PluginManagementResponse, AdminError>;
async fn handle(
&self,
published: &ExtensionSetReference,
target: &PluginManagementTarget,
request: PluginManagementRequest,
) -> Result<PluginManagementResponse, AdminError>;
}
@@ -0,0 +1,37 @@
use std::sync::Arc;
use async_trait::async_trait;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OfficialPluginReleaseReadErrorKind {
Invalid,
NotFound,
Unavailable,
}
/// 文件系统错误不携带部署路径,避免把宿主布局传播到管理域和日志之外。
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("official plugin release files are unavailable")]
pub struct OfficialPluginReleaseReadError {
kind: OfficialPluginReleaseReadErrorKind,
}
impl OfficialPluginReleaseReadError {
#[must_use]
pub const fn new(kind: OfficialPluginReleaseReadErrorKind) -> Self {
Self { kind }
}
#[must_use]
pub const fn kind(self) -> OfficialPluginReleaseReadErrorKind {
self.kind
}
}
/// Host 只暴露与宿主发行物同信任边界的只读文件,不解释官方身份或插件包。
#[async_trait]
pub trait OfficialPluginReleaseFiles: Send + Sync {
async fn manifest(&self) -> Result<Option<Arc<[u8]>>, OfficialPluginReleaseReadError>;
async fn artifact(&self, file_name: &str) -> Result<Arc<[u8]>, OfficialPluginReleaseReadError>;
}
@@ -0,0 +1,329 @@
use std::{collections::BTreeMap, sync::Arc};
use async_trait::async_trait;
use super::store::AdminStoreResult;
use crate::model::plugins::distribution::{
DownloadedPlugin, GithubReleaseQuery, PluginDistributionEgress, PluginRelease,
PluginSourceBinding, PluginUpdateSource, RemotePluginLocation, SourceCredential,
SourceCredentialInfo,
};
use crate::model::plugins::instances::{
PluginInstance, PluginInstanceMutation, PluginInstanceReplacement, PluginInstanceRuntime,
PluginInstanceSnapshot, PluginVersionConfiguration,
};
use crate::model::plugins::state::{
ApplyPluginStateMigration, DeletePluginState, PluginStateCommit, PluginStateConfiguration,
PluginStateMigrationBatch, PluginStateOwner, PluginStateOwnerRequest, PluginStateRecord,
PluginStateTransition, PluginStateWrite, PutPluginState,
};
use crate::model::{
AdminError, MutationContext, Revision,
plugins::{
InspectedPluginArtifact, InstalledPluginArtifact, PluginArtifactMutation,
PluginCompatibilityRequirements, PluginSource,
},
};
use gateway_core::runtime::extensions::ExtensionSetReference;
/// Admin 准备完整候选并保活到提交后的发布结束;进程状态不能代替持久启用状态。
#[async_trait]
pub trait PluginPreparation: Send + Sync {
/// 静态判断业务配置是否完整,不启动进程;不完整的安装保留为待配置。
async fn configuration_ready(
&self,
instance: PluginInstance,
metadata: &crate::model::plugins::PluginArtifactMetadata,
) -> Result<bool, AdminError>;
async fn validate(
&self,
instance: PluginInstance,
) -> Result<PluginStateConfiguration, AdminError>;
/// 本次修改的实例 revision 必须等于候选 config_revision,准备失败时拒绝提交;历史故障可隔离。
async fn prepare(
&self,
snapshot: PluginInstanceSnapshot,
) -> Result<ExtensionSetReference, AdminError>;
/// 返回当前进程的只读运行事实;缺省实现让不托管插件进程的组合保持兼容。
async fn runtime_diagnostics(
&self,
_snapshot: &PluginInstanceSnapshot,
_published_revision: Option<u64>,
_published: Option<&ExtensionSetReference>,
) -> Option<BTreeMap<String, PluginInstanceRuntime>> {
None
}
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,
) {
}
async fn migrate_state(
&self,
_prepared: &ExtensionSetReference,
_transition: PluginStateTransition,
) -> Result<(), AdminError> {
Err(AdminError::unavailable("插件状态迁移运行时不可用"))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginStateStoreErrorKind {
Invalid,
NotFound,
Conflict,
Quota,
PermissionDenied,
Unavailable,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("plugin state operation failed")]
pub struct PluginStateStoreError {
kind: PluginStateStoreErrorKind,
}
impl PluginStateStoreError {
#[must_use]
pub const fn new(kind: PluginStateStoreErrorKind) -> Self {
Self { kind }
}
#[must_use]
pub const fn kind(&self) -> PluginStateStoreErrorKind {
self.kind
}
}
pub type PluginStateStoreResult<T> = Result<T, PluginStateStoreError>;
/// 私有状态的窄端口;不暴露 SQL、连接或跨命名空间事务。
#[async_trait]
pub trait PluginStateStore: Send + Sync {
async fn load_owner(
&self,
request: PluginStateOwnerRequest,
) -> PluginStateStoreResult<Option<PluginStateOwner>>;
async fn get(
&self,
owner: &PluginStateOwner,
namespace: &str,
key: &str,
) -> PluginStateStoreResult<Option<PluginStateRecord>>;
async fn put(
&self,
owner: &PluginStateOwner,
command: PutPluginState,
) -> PluginStateStoreResult<PluginStateWrite>;
async fn delete(
&self,
owner: &PluginStateOwner,
command: DeletePluginState,
) -> PluginStateStoreResult<bool>;
async fn transition_required(
&self,
instance_id: &str,
target: &PluginStateConfiguration,
) -> PluginStateStoreResult<bool>;
async fn begin_transition(
&self,
instance_id: &str,
expected_instance_revision: Revision,
artifact_sha256: &str,
target: PluginStateConfiguration,
) -> PluginStateStoreResult<PluginStateTransition>;
async fn migration_batch(
&self,
transition_id: &str,
namespace: &str,
maximum_records: u32,
) -> PluginStateStoreResult<PluginStateMigrationBatch>;
async fn apply_migration_batch(
&self,
command: ApplyPluginStateMigration,
) -> PluginStateStoreResult<()>;
async fn abort_transition(&self, transition_id: &str) -> PluginStateStoreResult<()>;
}
/// Host 拥有联网、重定向、凭据作用域、限流与有界下载,不解释插件包。
#[async_trait]
pub trait PluginDistribution: Send + Sync {
fn validate_source(&self, source: &PluginUpdateSource) -> Result<(), AdminError>;
fn validate_credential(&self, credential: &SourceCredential) -> Result<(), AdminError>;
async fn query_release(
&self,
query: GithubReleaseQuery,
credentials: Vec<SourceCredential>,
egress: Option<PluginDistributionEgress>,
) -> Result<PluginRelease, AdminError>;
async fn download(
&self,
location: RemotePluginLocation,
credentials: Vec<SourceCredential>,
egress: Option<PluginDistributionEgress>,
) -> Result<DownloadedPlugin, AdminError>;
}
/// 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]>,
expected_sha256: Option<String>,
) -> Result<InspectedPluginArtifact, AdminError>;
/// 从已安装且摘要匹配的制品中读取可展示的静态图标,不启动插件实例。
async fn icon(
&self,
_archive: Arc<[u8]>,
_expected_sha256: String,
_theme: crate::model::plugins::PluginIconTheme,
) -> Result<Option<crate::model::plugins::PluginArtifactIconResource>, AdminError> {
Err(AdminError::unavailable("插件图标读取不可用"))
}
/// 只读取已校验包的静态宿主要求;不得准备或启动插件进程。
async fn compatibility(
&self,
_archive: Arc<[u8]>,
_expected_sha256: String,
) -> Result<PluginCompatibilityRequirements, AdminError> {
Err(AdminError::unavailable("插件兼容性检查不可用"))
}
}
#[async_trait]
pub trait PluginStore: Send + Sync {
/// 只复核精确管理目标的启用、接受事实和版本,不读取实例配置或密钥。
async fn management_target_is_current(
&self,
target: &crate::model::plugins::management::PluginManagementTarget,
) -> AdminStoreResult<bool>;
async fn load_instances(&self) -> AdminStoreResult<PluginInstanceSnapshot>;
/// 读取对应制品最近一次启用时提交的配置,密钥始终留在服务端。
async fn load_version_configuration(
&self,
_id: &str,
_digest: &str,
) -> AdminStoreResult<Option<PluginVersionConfiguration>> {
Ok(None)
}
/// 只返回有可恢复配置的制品摘要,不读取敏感值。
async fn configuration_versions(&self, _id: &str) -> AdminStoreResult<Vec<String>> {
Ok(Vec::new())
}
async fn save_instance(
&self,
instance: PluginInstance,
expected_revision: Revision,
context: &MutationContext,
) -> AdminStoreResult<PluginInstanceMutation>;
async fn save_instance_with_state(
&self,
instance: PluginInstance,
expected_revision: Revision,
state: PluginStateCommit,
context: &MutationContext,
) -> AdminStoreResult<PluginInstanceMutation> {
if !state.configuration.namespaces.is_empty() || state.transition_id.is_some() {
return Err(super::store::AdminStoreError::new(
super::store::AdminStoreErrorKind::Unavailable,
"plugin state",
"plugin state store is unavailable",
));
}
self.save_instance(instance, expected_revision, context)
.await
}
/// 保存目标并停用明确确认的配置,必须共享事务和配置版本检查。
async fn save_instance_replacing(
&self,
instance: PluginInstance,
expected_revision: Revision,
state: PluginStateCommit,
replacements: &[PluginInstanceReplacement],
context: &MutationContext,
) -> AdminStoreResult<PluginInstanceMutation> {
if !replacements.is_empty() {
return Err(super::store::AdminStoreError::new(
super::store::AdminStoreErrorKind::Unavailable,
"plugin",
"atomic plugin configuration switching is unavailable",
));
}
self.save_instance_with_state(instance, expected_revision, state, context)
.await
}
async fn delete_instance(
&self,
id: &str,
expected_revision: Revision,
context: &MutationContext,
) -> AdminStoreResult<Revision>;
async fn list_update_sources(&self) -> AdminStoreResult<Vec<PluginSourceBinding>>;
async fn change_update_source(
&self,
binding: PluginSourceBinding,
context: &MutationContext,
) -> AdminStoreResult<Revision>;
async fn list_source_credentials(&self) -> AdminStoreResult<Vec<SourceCredentialInfo>>;
async fn load_source_credential(&self, id: &str) -> AdminStoreResult<SourceCredential>;
async fn save_source_credential(
&self,
credential: SourceCredential,
context: &MutationContext,
) -> AdminStoreResult<Revision>;
async fn delete_source_credential(
&self,
id: &str,
context: &MutationContext,
) -> AdminStoreResult<Revision>;
async fn list_artifacts(&self) -> AdminStoreResult<Vec<InstalledPluginArtifact>>;
async fn load_artifact(&self, digest: &str) -> AdminStoreResult<InspectedPluginArtifact>;
async fn install_artifact(
&self,
artifact: InspectedPluginArtifact,
source: PluginSource,
context: &MutationContext,
) -> AdminStoreResult<PluginArtifactMutation>;
/// 接受不可变制品声明的全部能力域;接受事实不随实例设置变化。
async fn accept_artifact(
&self,
digest: &str,
context: &MutationContext,
) -> AdminStoreResult<PluginArtifactMutation>;
/// 删除制品及其无引用下载凭据,最后一个版本同时删除来源规则,清理与审计必须原子提交。
async fn delete_artifact(
&self,
digest: &str,
context: &MutationContext,
) -> AdminStoreResult<Revision>;
}
@@ -1,4 +1,4 @@
//! Provider 管理能力与动态注册表。
//! 原生 Provider 管理能力与启动时注册表。
use std::{collections::BTreeMap, sync::Arc};
@@ -15,9 +15,9 @@ use crate::model::observability::{
};
use crate::model::provider_credentials::{
AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit,
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels,
PrepareCredentialImport, PrepareCredentialRefresh, PrepareCredentialRotation,
PreparedAuthorizationCommit, PreparedCredentialImport, PreparedCredentialRotation,
ProviderExport, ProviderExportCredentialInput, ProviderModelCatalogDocument, ProviderModels,
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, explicit_plan_type,
};
@@ -117,6 +117,15 @@ pub trait ProviderAdmin: Send + Sync {
fn provider_kind(&self) -> &ProviderKind;
/// 只读取原生 Provider 能力及账号类型,不执行网络或数据库查询。
fn account_capabilities(
&self,
_account_id: &ProviderAccountId,
_authentication_kind: &str,
) -> crate::model::accounts::ProviderAccountCapabilities {
Default::default()
}
/// 提供该 Provider 的可选客户端身份;通用管理层不解释内部字段。
fn client_profile_options(
&self,
@@ -124,6 +133,11 @@ pub trait ProviderAdmin: Send + Sync {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 没有持久选择时使用的 Provider 默认画像;只返回已准备的本地事实。
fn default_client_profile(&self) -> Option<gateway_core::account::OpaqueProviderData> {
None
}
/// 校验并投影客户端身份,结果不含认证或账号材料。
fn preview_client_profile(
&self,
@@ -150,7 +164,7 @@ pub trait ProviderAdmin: Send + Sync {
async fn account_facts_changed(&self, _account_ids: &[ProviderAccountId]) {}
/// 生成一次连接测试所需的 Provider-owned operation;Core 负责实际执行与落账。
fn connection_test_operation(
async fn connection_test_operation(
&self,
upstream_model: &UpstreamModelId,
input_text: &str,
@@ -180,7 +194,7 @@ pub trait ProviderAdmin: Send + Sync {
async fn start_authorization(
&self,
pending: PendingAuthorizationMutation,
pending: crate::model::provider_credentials::PendingAuthorizationMutation,
) -> Result<AuthorizationStarted, ProviderAdminError>;
async fn complete_authorization(
@@ -266,20 +280,27 @@ pub trait ProviderAdmin: Send + Sync {
refresh: bool,
) -> Result<ProviderModels, ProviderAdminError>;
/// 导出该账号的 Provider 原生模型目录正文;不提供原生目录的 Provider 使用默认拒绝。
async fn model_catalog_document(
&self,
_account_id: &ProviderAccountId,
) -> Result<ProviderModelCatalogDocument, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
async fn export_credentials(
&self,
credentials: Vec<ProviderExportCredentialInput>,
) -> Result<ProviderExport, ProviderAdminError>;
}
/// 按 ProviderKind 动态发现管理能力;不含具体 Provider 分支。
/// 启动时注册原生 Provider,按 ProviderKind 查找管理能力。
#[derive(Clone)]
pub struct ProviderAdminRegistry {
providers: Arc<BTreeMap<ProviderKind, Arc<dyn ProviderAdmin>>>,
}
impl ProviderAdminRegistry {
#[must_use]
pub fn pricing_catalog(&self) -> gateway_core::metering::PricingOverrides {
self.providers
.iter()
@@ -339,7 +360,7 @@ impl ProviderAdminRegistry {
let plan_type = explicit_plan_type(plan_type)?.trim();
let provider = ProviderKind::new(provider_kind.to_owned())
.ok()
.and_then(|kind| self.providers.get(&kind));
.and_then(|kind| self.require(&kind).ok());
let display = provider.map_or_else(
|| plan_type.to_owned(),
|provider| provider.plan_type_display(plan_type),
@@ -365,7 +386,7 @@ impl ProviderAdminRegistry {
.collect()
}
/// 动态分派 Provider-owned 费用规则,不含任何具体 Provider 分支。
/// 按平台计算请求费用。
pub fn calculated_billing(
&self,
provider_kind: &ProviderKind,
@@ -49,7 +49,7 @@ pub trait ProxyStore: Send + Sync {
revision: Revision,
result: ProxyTestResult,
context: &MutationContext,
) -> AdminStoreResult<ProxyRecord>;
) -> AdminStoreResult<ProxyMutation>;
}
/// 离开作用域时释放保护,错误返回和请求取消也遵循相同规则。
@@ -62,5 +62,5 @@ pub struct ProxyImportReservation {
#[async_trait]
pub trait ProxyProbe: Send + Sync {
async fn test(&self, proxy: &OutboundProxy) -> ProxyTestResult;
async fn test(&self, proxy: &OutboundProxy, detect_location: bool) -> ProxyTestResult;
}
@@ -32,7 +32,8 @@ use crate::model::{
},
provider_credentials::{
AuthorizationCommit, CredentialDetails, CredentialImportCommit, CredentialImportResult,
CredentialMutationResult, CredentialRotationCommit, ProviderExportCredentialInput,
CredentialMutationResult, CredentialRotationCommit, PluginAccountListQuery,
PluginAccountPage, ProviderExportCredentialInput,
},
quota_forecast_sampling::QuotaForecastHistory,
settings::{AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RuntimeSettings},
@@ -88,6 +89,12 @@ pub type AdminStoreResult<T> = Result<T, AdminStoreError>;
/// 账号目录与公共账号写操作。
#[async_trait]
pub trait AccountStore: Send + Sync {
/// 插件回调按授权 Provider/账号在数据库过滤后做稳定 cursor 分页。
async fn list_plugin_accounts(
&self,
query: PluginAccountListQuery,
) -> AdminStoreResult<PluginAccountPage>;
async fn list_accounts(
&self,
query: AccountListQuery,
@@ -123,12 +130,24 @@ pub trait AccountStore: Send + Sync {
account_id: &gateway_core::account::ProviderAccountId,
) -> AdminStoreResult<Option<CredentialDetails>>;
/// 插件按全局账号 ID 读取时,由数据库记录提供权威 Provider 归属。
async fn credential_details_by_id(
&self,
account_id: &gateway_core::account::ProviderAccountId,
) -> AdminStoreResult<Option<CredentialDetails>>;
async fn load_credentials_for_export(
&self,
provider_kind: &gateway_core::routing::ProviderKind,
account_ids: &[gateway_core::account::ProviderAccountId],
) -> AdminStoreResult<Vec<ProviderExportCredentialInput>>;
/// 插件凭据读取只接收账号 ID,不接受调用方另行声明 Provider。
async fn load_credential_for_plugin(
&self,
account_id: &gateway_core::account::ProviderAccountId,
) -> AdminStoreResult<Option<ProviderExportCredentialInput>>;
async fn commit_credential_import(
&self,
command: CredentialImportCommit,
@@ -139,7 +158,13 @@ pub trait AccountStore: Send + Sync {
&self,
command: AuthorizationCommit,
context: &MutationContext,
) -> AdminStoreResult<CredentialMutationResult>;
) -> AdminStoreResult<crate::model::provider_credentials::AuthorizationCommitResult>;
/// 已提交的授权结果独立于 Redis 临时状态;只有原管理员身份可读。
async fn authorization_receipt(
&self,
key: &crate::model::provider_credentials::AuthorizationReceiptKey,
) -> AdminStoreResult<Option<crate::model::provider_credentials::CredentialMutationResult>>;
async fn commit_credential_rotation(
&self,
@@ -488,9 +513,25 @@ pub struct AdminStorePorts {
observability: Arc<dyn ObservabilityStore>,
settings: Arc<dyn SettingsStore>,
backup: BackupStorePorts,
plugins: Arc<dyn super::plugins::PluginStore>,
plugin_state: Arc<dyn super::plugins::PluginStateStore>,
}
impl AdminStorePorts {
#[must_use]
pub fn plugins(&self) -> Arc<dyn super::plugins::PluginStore> {
Arc::clone(&self.plugins)
}
#[must_use]
pub fn plugin_state(&self) -> Arc<dyn super::plugins::PluginStateStore> {
Arc::clone(&self.plugin_state)
}
#[expect(
clippy::too_many_arguments,
reason = "组合根需显式注入各领域窄端口,不能用服务定位器隐藏依赖"
)]
#[must_use]
pub fn new(
accounts: AdminAccountStorePorts,
@@ -499,6 +540,8 @@ impl AdminStorePorts {
observability: Arc<dyn ObservabilityStore>,
settings: Arc<dyn SettingsStore>,
backup: BackupStorePorts,
plugins: Arc<dyn super::plugins::PluginStore>,
plugin_state: Arc<dyn super::plugins::PluginStateStore>,
) -> Self {
Self {
accounts,
@@ -507,6 +550,8 @@ impl AdminStorePorts {
observability,
settings,
backup,
plugins,
plugin_state,
}
}
@@ -1,15 +1,33 @@
//! Host 为系统管理用例提供的进程与操作系统能力。
use std::pin::Pin;
use std::{pin::Pin, sync::Arc};
use async_trait::async_trait;
use futures::Stream;
use crate::model::Revision;
use crate::model::system::{
SystemOperationAccepted, SystemUpdateDetail, SystemUpdateEvent, SystemUpdateStatus,
SystemVersion,
SystemOperationAccepted, SystemUpdateChannel, SystemUpdateDetail, SystemUpdateEvent,
SystemUpdateStatus, SystemVersion,
};
/// Host 已校验并解包的同一更新候选;字节只含发行清单,不含运行配置或凭据。
#[derive(Clone)]
pub struct SystemUpdateCandidate {
pub target_version: String,
pub release_manifest: Arc<[u8]>,
}
impl std::fmt::Debug for SystemUpdateCandidate {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("SystemUpdateCandidate")
.field("target_version", &self.target_version)
.field("release_manifest_bytes", &self.release_manifest.len())
.finish()
}
}
/// Host 系统操作失败类型。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SystemOperationErrorKind {
@@ -51,6 +69,18 @@ impl SystemOperationError {
/// 每个订阅者独占的系统事件流。
pub type SystemUpdateEventStream = Pin<Box<dyn Stream<Item = SystemUpdateEvent> + Send + 'static>>;
/// Admin 对启用实例和制品作兼容决策;Host 只负责候选下载、校验与文件交换。
#[async_trait]
pub trait SystemUpdatePreflight: Send + Sync {
async fn validate(
&self,
candidate: SystemUpdateCandidate,
) -> Result<Revision, SystemOperationError>;
/// 在文件交换临界点复核全局配置 CAS,不能用较早快照掩盖实例变更。
async fn confirm_revision(&self, expected: Revision) -> Result<(), SystemOperationError>;
}
/// 版本、自更新、回滚和重启能力;实现唯一归 gateway-host。
#[async_trait]
pub trait SystemOperations: Send + Sync {
@@ -59,6 +89,7 @@ pub trait SystemOperations: Send + Sync {
async fn update_detail(
&self,
refresh: bool,
channel: Option<SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, SystemOperationError>;
fn update_events(&self) -> SystemUpdateEventStream;
@@ -66,11 +97,16 @@ pub trait SystemOperations: Send + Sync {
async fn perform_update(
&self,
target_version: Option<String>,
channel: Option<SystemUpdateChannel>,
preflight: Arc<dyn SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError>;
async fn update_status(&self) -> Result<SystemUpdateStatus, SystemOperationError>;
async fn rollback(&self) -> Result<SystemOperationAccepted, SystemOperationError>;
async fn rollback(
&self,
preflight: Arc<dyn SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError>;
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError>;
}
@@ -231,8 +231,8 @@ fn project_group_runtime(
})
})
},
total_slots: available_ids.iter().fold(0_u64, |sum, account_id| {
sum.saturating_add(slots_by_account.get(*account_id).copied().unwrap_or(0))
total_slots: available_ids.iter().try_fold(0_u64, |sum, account_id| {
Some(sum.saturating_add(slots_by_account.get(*account_id).copied().flatten()?))
}),
};
}
@@ -1,6 +1,9 @@
//! 统一账号目录与跨 Provider 动态分派。
//! 统一账号目录与原生 Provider 分派。
use std::{collections::BTreeMap, sync::Arc};
use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
};
use async_trait::async_trait;
use chrono::{Duration, Utc};
@@ -23,12 +26,14 @@ use crate::{
observability::TimeRange,
provider_credentials::{
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountPersonalInfo,
AccountRefreshResult, ConsumeProviderResetCredit, PrepareCredentialRefresh,
ProviderModels, ProviderProfileAvatar, ProviderQuota, ProviderQuotaRequest,
ProviderQuotaWindow, ProviderResetCreditResult, ProviderResetCredits,
QuotaLocalUsageAttribution,
AccountRefreshResult, AccountUsagePeriod, ConsumeProviderResetCredit,
PrepareCredentialRefresh, ProviderModelCatalogDocument, ProviderModels,
ProviderProfileAvatar, ProviderQuota, ProviderQuotaRequest, ProviderQuotaWindow,
ProviderResetCreditResult, ProviderResetCredits, QuotaLocalUsageAttribution,
},
quota_forecast::{
AccountQuotaForecastReport, account_quota_forecasts, quota_forecast_source_window,
},
quota_forecast::{AccountQuotaForecastReport, account_quota_forecasts},
quota_forecast_sampling::{QuotaForecastPoint, select_forecast_sample},
},
ports::{
@@ -140,6 +145,11 @@ pub trait AccountsService: Send + Sync {
refresh: bool,
) -> Result<ProviderModels, AdminError>;
async fn model_catalog_document(
&self,
account_id: &ProviderAccountId,
) -> Result<ProviderModelCatalogDocument, AdminError>;
async fn test_connection(
&self,
account_id: ProviderAccountId,
@@ -210,7 +220,7 @@ impl DefaultAccountsService {
) -> Result<
(
AccountPageItem,
Arc<dyn crate::ports::provider::ProviderAdmin>,
std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>,
),
AdminError,
> {
@@ -314,14 +324,25 @@ impl DefaultAccountsService {
.await
.map_err(|error| map_store_error(error, "rolling account usage"))?;
let rolling_usage = rolling_usage.into_iter().next();
let mut quota = provider
let mut quota = match provider
.quota(ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: refresh_quota,
rolling_usage: rolling_usage.clone(),
})
.await
.map_err(|error| map_provider_error(error, "provider quota"))?;
{
Ok(quota) => quota,
// 凭据更新后的账号投影不要求 Provider 提供额度;显式刷新仍须支持该操作。
Err(error)
if !refresh_quota
&& error.kind()
== crate::ports::provider::ProviderAdminErrorKind::Unsupported =>
{
empty_quota()
}
Err(error) => return Err(map_provider_error(error, "provider quota")),
};
let mut stored = if refresh_quota {
self.load_account(account_id).await?
} else {
@@ -342,6 +363,8 @@ impl DefaultAccountsService {
.and_then(|(window, _)| window.local_usage.clone())
});
Ok(AccountDirectoryItem {
capabilities: provider
.account_capabilities(account_id, &stored.account.authentication_kind),
plan_type_display: self.providers.resolve_account_plan(
stored.account.provider_kind.as_str(),
&mut stored.account.plan_type,
@@ -358,6 +381,7 @@ impl DefaultAccountsService {
#[async_trait]
impl AccountsService for DefaultAccountsService {
async fn list(&self, query: AccountListQuery) -> Result<AccountDirectoryPage, AdminError> {
let providers = &self.providers;
let runtime = self
.account_runtime
.active_rate_limits()
@@ -393,7 +417,7 @@ impl AccountsService for DefaultAccountsService {
.map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))?;
// 单个账号的 quota 投影失败(Provider 未注册或 quota 读取失败)不拖垮整页:
// 该账号降级为空额度投影,其余账号与页面状态照常返回。
let provider = match self.providers.require(&account.provider_kind) {
let provider = match providers.require(&account.provider_kind) {
Ok(provider) => provider,
Err(error) => {
tracing::warn!(
@@ -434,13 +458,22 @@ impl AccountsService for DefaultAccountsService {
.into_iter()
.zip(quotas)
.map(|(mut item, quota)| {
let id = ProviderAccountId::new(item.account.id.clone())
.map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))?;
let capabilities = providers
.require(&item.account.provider_kind)
.map(|provider| {
provider.account_capabilities(&id, &item.account.authentication_kind)
})
.unwrap_or_default();
let usage = api_key_usage.remove(&item.account.id).or_else(|| {
quota
.usage_window()
.and_then(|(window, _)| window.local_usage.clone())
});
AccountDirectoryItem {
plan_type_display: self.providers.resolve_account_plan(
Ok(AccountDirectoryItem {
capabilities,
plan_type_display: providers.resolve_account_plan(
item.account.provider_kind.as_str(),
&mut item.account.plan_type,
Some(&quota),
@@ -449,9 +482,9 @@ impl AccountsService for DefaultAccountsService {
account: item.account,
projection: item.projection,
quota,
}
})
})
.collect();
.collect::<Result<Vec<_>, AdminError>>()?;
Ok(AccountDirectoryPage {
config_revision: page.config_revision,
items,
@@ -465,6 +498,7 @@ impl AccountsService for DefaultAccountsService {
context: &MutationContext,
account_ids: Vec<ProviderAccountId>,
) -> Result<AccountExportBundle, AdminError> {
let providers = &self.providers;
if account_ids.is_empty() || account_ids.len() > 200 {
return Err(AdminError::invalid("账号导出数量必须在 1 到 200 之间"));
}
@@ -483,12 +517,18 @@ impl AccountsService for DefaultAccountsService {
}) {
return Err(AdminError::invalid("账号导出列表包含重复 ID"));
}
let mut documents = Vec::with_capacity(grouped.len());
for (provider_kind, ids) in grouped {
let provider = self
.providers
.require(&provider_kind)
.map_err(|error| map_provider_error(error, "provider account export"))?;
// 混选时先确认全部 Provider 已注册,拒绝路径不读取任何一组明文凭据。
let export_groups = grouped
.into_iter()
.map(|(provider_kind, ids)| {
let provider = providers
.require(&provider_kind)
.map_err(|error| map_provider_error(error, "provider account export"))?;
Ok((provider_kind, provider, ids))
})
.collect::<Result<Vec<_>, AdminError>>()?;
let mut documents = Vec::with_capacity(export_groups.len());
for (provider_kind, provider, ids) in export_groups {
let credentials = self
.accounts
.load_credentials_for_export(&provider_kind, &ids)
@@ -646,7 +686,7 @@ impl AccountsService for DefaultAccountsService {
let mut providers = BTreeMap::<
ProviderKind,
(
Arc<dyn crate::ports::provider::ProviderAdmin>,
std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>,
Vec<ProviderAccountId>,
),
>::new();
@@ -710,7 +750,15 @@ impl AccountsService for DefaultAccountsService {
.map_err(|error| map_provider_error(error, "forecast quota snapshot"))?;
let now = Utc::now();
let mut samples = Vec::new();
for (window, _) in quota.usage_windows() {
let mut selected_keys = BTreeSet::new();
for period in [AccountUsagePeriod::Weekly, AccountUsagePeriod::Monthly] {
let Some((window, _)) = quota_forecast_source_window(&quota, period) else {
continue;
};
// 缺少一个周期时两个结果会复用同一窗口,只查询一次历史快照。
if !selected_keys.insert(window.key.as_str()) {
continue;
}
let (Some(mut query), Some(observed), Some(percent)) = (
quota_usage_window(account_id.as_str(), window),
quota.observed_at,
@@ -887,6 +935,17 @@ impl AccountsService for DefaultAccountsService {
.map_err(|error| map_provider_error(error, "provider model catalog"))
}
async fn model_catalog_document(
&self,
account_id: &ProviderAccountId,
) -> Result<ProviderModelCatalogDocument, AdminError> {
let (_, provider) = self.provider_for_account(account_id).await?;
provider
.model_catalog_document(account_id)
.await
.map_err(|error| map_provider_error(error, "provider model catalog document"))
}
async fn test_connection(
&self,
account_id: ProviderAccountId,
@@ -897,6 +956,7 @@ impl AccountsService for DefaultAccountsService {
let model = upstream_model.as_str().to_owned();
let operation = provider
.connection_test_operation(&upstream_model, CONNECTION_TEST_INPUT)
.await
.map_err(|error| map_provider_error(error, "provider connection test"))?;
let initial = vec![
AccountConnectionTestEvent::Started {
@@ -912,12 +972,15 @@ impl AccountsService for DefaultAccountsService {
let probe = Arc::clone(&self.probe);
let terminal = futures::stream::once(async move {
let result = probe
.probe(AccountProbeRequest {
account_id,
provider_kind: account.provider_kind,
upstream_model,
operation,
})
.probe(
AccountProbeRequest {
account_id,
provider_kind: account.provider_kind,
upstream_model,
operation,
},
None,
)
.await;
match result {
Ok(result) => result
@@ -113,18 +113,11 @@ impl ClientKeyService for DefaultClientKeyService {
context: &MutationContext,
command: CreateClientKey,
) -> Result<CreatedClientKey, AdminError> {
for (provider, profile) in [
("openai", &command.openai_client_profile_override),
("xai", &command.xai_client_profile_override),
] {
if let Some(profile) = profile {
let kind = gateway_core::routing::ProviderKind::new(provider)
.map_err(|_| AdminError::invalid("Provider 不合法"))?;
self.providers
.require(&kind)
.and_then(|provider| provider.preview_client_profile(profile))
.map_err(|error| super::map_provider_error(error, "client profile"))?;
}
for (provider, profile) in &command.request_profile_overrides {
self.providers
.require(provider)
.and_then(|provider| provider.preview_client_profile(profile))
.map_err(|error| super::map_provider_error(error, "client profile"))?;
}
let id = ClientApiKeyId::new(format!("key_{}", Uuid::now_v7().simple()))
.map_err(|_| AdminError::internal("创建 Client API Key ID 失败"))?;
@@ -139,8 +132,7 @@ impl ClientKeyService for DefaultClientKeyService {
.store
.create_client_key(
NewClientKey {
openai_client_profile_override: command.openai_client_profile_override,
xai_client_profile_override: command.xai_client_profile_override,
request_profile_overrides: command.request_profile_overrides,
id,
name: command.name,
label: command.label,
@@ -165,15 +157,10 @@ impl ClientKeyService for DefaultClientKeyService {
context: &MutationContext,
command: UpdateClientKey,
) -> Result<ClientKeyMutation, AdminError> {
for (provider, profile) in [
("openai", &command.openai_client_profile_override),
("xai", &command.xai_client_profile_override),
] {
if let Some(Some(profile)) = profile {
let kind = gateway_core::routing::ProviderKind::new(provider)
.map_err(|_| AdminError::invalid("Provider 不合法"))?;
for (provider, profile) in &command.request_profile_override_updates {
if let Some(profile) = profile {
self.providers
.require(&kind)
.require(provider)
.and_then(|provider| provider.preview_client_profile(profile))
.map_err(|error| super::map_provider_error(error, "client profile"))?;
}
@@ -1,63 +1,38 @@
//! OpenAI 管理资源的中立 ProviderAdmin 委托。
//! 已冻结 Provider 的凭据工作流;Admin 保持事务、审计与发布的唯一所有权。
use std::sync::Arc;
use async_trait::async_trait;
use gateway_core::runtime::SnapshotControl;
use crate::{
model::{
AdminError,
provider_credentials::{
AuthorizationStarted, CompleteAuthorization, CredentialDeletion,
CredentialDeletionResult, CredentialImportCommit, CredentialImportResult,
CredentialMutationResult, ImportCredentials, PrepareCredentialImport,
PrepareCredentialRotation, RotateCredential, StartAuthorization,
AuthorizationCommitResult, AuthorizationReceiptKey, AuthorizationStarted,
CompleteAuthorization, CredentialDeletion, CredentialDeletionResult,
CredentialImportCommit, CredentialImportResult, CredentialMutationResult,
ImportCredentials, PrepareCredentialImport, PrepareCredentialRotation,
RotateCredential, StartAuthorization,
},
},
ports::{provider::ProviderAdmin, store::AccountStore},
};
use super::{
use super::super::{
commit_authorization, commit_credential_rotation, delete_credentials, map_provider_error,
map_store_error, pending_authorization, publish_committed,
publish_credentials_and_observe_quota, required_credential, validate_authorization_commit,
validate_prepared_import, validate_prepared_rotation,
};
/// OpenAI 固定管理路由消费的服务。
#[async_trait]
pub trait OpenAiService: Send + Sync {
async fn import_document(
&self,
command: ImportCredentials,
) -> Result<CredentialImportResult, AdminError>;
async fn start_authorization(
&self,
command: StartAuthorization,
) -> Result<AuthorizationStarted, AdminError>;
async fn complete_authorization(
&self,
command: CompleteAuthorization,
) -> Result<CredentialMutationResult, AdminError>;
async fn rotate(
&self,
command: RotateCredential,
) -> Result<CredentialMutationResult, AdminError>;
async fn delete(
&self,
command: CredentialDeletion,
) -> Result<CredentialDeletionResult, AdminError>;
}
pub(crate) struct DefaultOpenAiService {
pub struct ProviderCredentials {
provider: Arc<dyn ProviderAdmin>,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
}
impl DefaultOpenAiService {
impl ProviderCredentials {
#[must_use]
pub(crate) fn new(
provider: Arc<dyn ProviderAdmin>,
@@ -72,16 +47,12 @@ impl DefaultOpenAiService {
snapshot,
}
}
}
#[async_trait]
impl OpenAiService for DefaultOpenAiService {
async fn import_document(
pub async fn import_document(
&self,
command: ImportCredentials,
) -> Result<CredentialImportResult, AdminError> {
let context = command.context;
let proxy_reservation = super::import_proxy_binding(
let proxy_reservation = super::super::import_proxy_binding(
self.proxies.as_ref(),
command.outbound_proxy_id.as_deref(),
)
@@ -98,11 +69,11 @@ impl OpenAiService for DefaultOpenAiService {
document: command.document,
})
.await
.map_err(|error| map_provider_error(error, "OpenAI credential import"))?;
.map_err(|error| map_provider_error(error, "provider credential import"))?;
validate_prepared_import(
self.provider.provider_kind(),
&prepared,
"OpenAI credential import",
"provider credential import",
)?;
let result = self
.accounts
@@ -115,7 +86,7 @@ impl OpenAiService for DefaultOpenAiService {
&context,
)
.await
.map_err(|error| map_store_error(error, "OpenAI credential import"))?;
.map_err(|error| map_store_error(error, "provider credential import"))?;
drop(proxy_reservation);
publish_credentials_and_observe_quota(
&self.provider,
@@ -128,7 +99,7 @@ impl OpenAiService for DefaultOpenAiService {
Ok(result)
}
async fn start_authorization(
pub async fn start_authorization(
&self,
command: StartAuthorization,
) -> Result<AuthorizationStarted, AdminError> {
@@ -137,53 +108,16 @@ impl OpenAiService for DefaultOpenAiService {
self.proxies.as_ref(),
self.provider.provider_kind(),
&command,
"OpenAI credential",
"provider credential",
)
.await?;
self.provider
.start_authorization(pending)
.await
.map_err(|error| map_provider_error(error, "OpenAI authorization"))
.map_err(|error| map_provider_error(error, "provider authorization"))
}
async fn complete_authorization(
&self,
mut command: CompleteAuthorization,
) -> Result<CredentialMutationResult, AdminError> {
let context = command.context.clone();
let settings = command.settings.take();
let prepared = self
.provider
.complete_authorization(command)
.await
.map_err(|error| map_provider_error(error, "OpenAI authorization"))?;
let prepared = validate_authorization_commit(
self.provider.provider_kind(),
&context,
prepared,
"OpenAI authorization",
)
.await?;
let result = commit_authorization(
self.accounts.as_ref(),
prepared,
settings,
&context,
"OpenAI authorization",
)
.await?;
publish_credentials_and_observe_quota(
&self.provider,
self.snapshot.as_ref(),
result.config_revision,
std::slice::from_ref(&result.account_id),
&context.request_id,
)
.await?;
Ok(result)
}
async fn rotate(
pub async fn rotate(
&self,
command: RotateCredential,
) -> Result<CredentialMutationResult, AdminError> {
@@ -204,7 +138,7 @@ impl OpenAiService for DefaultOpenAiService {
self.accounts.as_ref(),
self.provider.provider_kind(),
&account_id,
"OpenAI credential rotation",
"provider credential rotation",
)
.await?;
let account = details.credential;
@@ -215,14 +149,14 @@ impl OpenAiService for DefaultOpenAiService {
provider_material: command.provider_material,
})
.await
.map_err(|error| map_provider_error(error, "OpenAI credential rotation"))?;
validate_prepared_rotation(&account, &prepared, "OpenAI credential rotation")?;
.map_err(|error| map_provider_error(error, "provider credential rotation"))?;
validate_prepared_rotation(&account, &prepared, "provider credential rotation")?;
let result = commit_credential_rotation(
self.accounts.as_ref(),
prepared,
command.settings,
&context,
"OpenAI credential rotation",
"provider credential rotation",
)
.await?;
if disable_account {
@@ -235,7 +169,7 @@ impl OpenAiService for DefaultOpenAiService {
Ok(result)
}
async fn delete(
pub async fn delete(
&self,
command: CredentialDeletion,
) -> Result<CredentialDeletionResult, AdminError> {
@@ -243,10 +177,75 @@ impl OpenAiService for DefaultOpenAiService {
self.accounts.as_ref(),
self.provider.as_ref(),
command,
"OpenAI credential",
"provider credential",
)
.await?;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
Ok(result)
}
}
impl super::CredentialsService {
pub async fn complete_authorization(
&self,
kind: &gateway_core::routing::ProviderKind,
mut command: CompleteAuthorization,
) -> Result<CredentialMutationResult, AdminError> {
let context = command.context.clone();
let key = AuthorizationReceiptKey::new(kind.clone(), &command.flow_id, &context)?;
if let Some(result) = self
.accounts
.authorization_receipt(&key)
.await
.map_err(|error| map_store_error(error, "authorization receipt"))?
{
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
return Ok(result);
}
let scope = self.for_provider(kind)?;
let provider = &scope.provider;
let settings = command.settings.take();
let prepared = provider
.complete_authorization(command)
.await
.map_err(|error| map_provider_error(error, "provider authorization"))?;
let prepared =
validate_authorization_commit(kind, &context, prepared, "provider authorization")
.await?;
let result = commit_authorization(
self.accounts.as_ref(),
prepared,
key,
settings,
&context,
"provider authorization",
)
.await?;
let result = self
.publish_authorization(provider, result, &context.request_id)
.await?;
Ok(result)
}
async fn publish_authorization(
&self,
provider: &Arc<dyn ProviderAdmin>,
outcome: AuthorizationCommitResult,
request_id: &str,
) -> Result<CredentialMutationResult, AdminError> {
let result = outcome.result;
if outcome.newly_committed {
publish_credentials_and_observe_quota(
provider,
self.snapshot.as_ref(),
result.config_revision,
std::slice::from_ref(&result.account_id),
request_id,
)
.await?;
} else {
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
}
Ok(result)
}
}
@@ -0,0 +1,50 @@
mod flow;
use std::sync::Arc;
use gateway_core::{routing::ProviderKind, runtime::SnapshotControl};
use crate::{
model::AdminError,
ports::{provider::ProviderAdminRegistry, proxy::ProxyStore, store::AccountStore},
};
pub use flow::ProviderCredentials;
/// 所有 Provider 共用凭据用例;每次操作从已发布目录冻结自己的管理实现。
pub struct CredentialsService {
providers: ProviderAdminRegistry,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
}
impl CredentialsService {
#[must_use]
pub fn new(
providers: ProviderAdminRegistry,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Self {
Self {
providers,
accounts,
proxies,
snapshot,
}
}
pub fn for_provider(&self, kind: &ProviderKind) -> Result<ProviderCredentials, AdminError> {
let provider = self
.providers
.require(kind)
.map_err(|error| super::map_provider_error(error, "provider credentials"))?;
Ok(ProviderCredentials::new(
provider,
self.accounts.clone(),
self.proxies.clone(),
self.snapshot.clone(),
))
}
}
@@ -16,7 +16,7 @@ use gateway_core::{
use tokio::{sync::Notify, time::Instant};
use uuid::Uuid;
use super::{openai::OpenAiService, xai::XaiService};
use super::credentials::CredentialsService;
use crate::model::{
AdminError, AdminErrorKind, MutationActor, MutationContext,
import_tasks::{
@@ -129,17 +129,15 @@ struct Registry {
pub(crate) struct DefaultImportTasksService {
registry: Mutex<Registry>,
notify: Notify,
openai: Arc<dyn OpenAiService>,
xai: Arc<dyn XaiService>,
credentials: Arc<CredentialsService>,
}
impl DefaultImportTasksService {
pub(crate) fn new(openai: Arc<dyn OpenAiService>, xai: Arc<dyn XaiService>) -> Arc<Self> {
pub(crate) fn new(credentials: Arc<CredentialsService>) -> Arc<Self> {
Arc::new(Self {
registry: Mutex::default(),
notify: Notify::new(),
openai,
xai,
credentials,
})
}
@@ -177,11 +175,10 @@ impl DefaultImportTasksService {
async fn execute(&self, id: Uuid, index: usize, input: ImportTaskInput) {
let operation = async {
match input.provider.as_str() {
"openai" => self.openai.import_document(input.command).await,
"xai" => self.xai.import_document(input.command).await,
_ => Err(AdminError::invalid("不支持的导入平台")),
}
self.credentials
.for_provider(&input.provider)?
.import_document(input.command)
.await
};
let result = AssertUnwindSafe(operation).catch_unwind().await;
let mut registry = self.registry();
@@ -222,7 +219,7 @@ impl ImportTasksService for DefaultImportTasksService {
if command.items.is_empty()
|| command.items.len() > MAX_IMPORT_TASK_ITEMS
|| command.items.iter().any(|item| {
!matches!(item.provider.as_str(), "openai" | "xai")
self.credentials.for_provider(&item.provider).is_err()
|| item.command.context.actor != command.context.actor
})
{
@@ -37,6 +37,12 @@ pub trait KeyUsageService: Send + Sync {
/// 验证 Key 并只读查询当前额度,不记录 Key 使用或执行推理准入。
async fn budget(&self, plaintext: &str) -> Result<Option<ClientBudgetStatus>, AdminError>;
/// 使用 Core 已认证的宿主身份查询额度,不接收插件自报的 Key ID。
async fn budget_for_client(
&self,
id: &ClientApiKeyId,
) -> Result<Option<ClientBudgetStatus>, AdminError>;
async fn version(&self, session_id: Option<&str>) -> Result<Option<SystemVersion>, AdminError>;
async fn config(&self, session_id: Option<&str>)
@@ -111,15 +117,25 @@ impl KeyUsageService for DefaultKeyUsageService {
let id = match self.verifier.verify_client_key(plaintext) {
Ok(id) => id,
Err(ClientAuthenticationError::InvalidKey) => return Ok(None),
Err(ClientAuthenticationError::SnapshotUnavailable) => {
Err(
ClientAuthenticationError::SnapshotUnavailable
| ClientAuthenticationError::ProviderUnavailable,
) => {
return Err(AdminError::new(
AdminErrorKind::Unavailable,
"密钥验证暂时不可用",
));
}
};
self.budget_for_client(&id).await
}
async fn budget_for_client(
&self,
id: &ClientApiKeyId,
) -> Result<Option<ClientBudgetStatus>, AdminError> {
self.keys
.get_client_key(&id)
.get_client_key(id)
.await
.map(|key| key.filter(|key| key.enabled).map(|key| key.budget))
.map_err(|error| map_store_error(error, "key usage budget"))
@@ -6,16 +6,17 @@ pub mod auth;
pub mod backup;
pub mod client_distribution;
pub mod client_keys;
pub mod credentials;
pub mod import_tasks;
pub mod key_usage;
pub mod observability;
pub mod openai;
pub mod plugin_accounts;
pub mod plugin_client_keys;
pub(crate) mod plugin_update;
pub mod plugins;
pub mod proxies;
pub mod settings;
pub mod system;
pub mod xai;
use std::sync::Arc;
use crate::{
model::{
@@ -102,7 +103,7 @@ async fn publish_committed(
}
async fn publish_credentials_and_observe_quota(
provider: &Arc<dyn ProviderAdmin>,
provider: &std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>,
snapshot: &dyn SnapshotControl,
revision: crate::model::Revision,
account_ids: &[ProviderAccountId],
@@ -112,7 +113,7 @@ async fn publish_credentials_and_observe_quota(
publish_committed(snapshot, revision).await?;
// 凭据已经提交;额度只是可重建的观察,不能拖住管理请求或回滚提交结果。
let provider = Arc::clone(provider);
let provider = provider.clone();
let account_ids = account_ids.to_vec();
let request_id = request_id.to_owned();
tokio::spawn(async move {
@@ -151,6 +152,18 @@ async fn required_credential(
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
}
async fn required_plugin_credential(
accounts: &dyn AccountStore,
account_id: &ProviderAccountId,
resource: &'static str,
) -> Result<CredentialDetails, AdminError> {
accounts
.credential_details_by_id(account_id)
.await
.map_err(|error| map_store_error(error, resource))?
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
}
async fn import_proxy_binding(
proxies: &dyn crate::ports::proxy::ProxyStore,
id: Option<&str>,
@@ -233,7 +246,13 @@ fn validate_prepared_rotation(
prepared: &PreparedCredentialRotation,
_resource: &'static str,
) -> Result<(), AdminError> {
let facts = prepared.facts();
validate_prepared_rotation_facts(account, prepared.facts())
}
fn validate_prepared_rotation_facts(
account: &crate::model::accounts::AccountRecord,
facts: &crate::model::provider_credentials::PreparedCredentialRotationFacts,
) -> Result<(), AdminError> {
if facts.account_id.as_str() != account.id.as_str()
|| facts.provider_kind != account.provider_kind
{
@@ -294,10 +313,11 @@ async fn validate_authorization_commit(
async fn commit_authorization(
accounts: &dyn AccountStore,
prepared: PreparedAuthorizationCommit,
key: crate::model::provider_credentials::AuthorizationReceiptKey,
settings: Option<crate::model::accounts::AccountImportSettings>,
context: &MutationContext,
resource: &'static str,
) -> Result<CredentialMutationResult, AdminError> {
) -> Result<crate::model::provider_credentials::AuthorizationCommitResult, AdminError> {
if settings.is_some()
&& matches!(
&prepared.credential,
@@ -311,14 +331,19 @@ async fn commit_authorization(
command,
credential_guard,
authorization_guard,
} = prepared.into_commit(settings);
} = prepared.into_commit(settings, key);
match accounts.commit_authorization(command, context).await {
Ok(result) => {
if let Some(guard) = credential_guard {
if let Some(guard) = credential_guard
&& result.newly_committed
{
guard.finish();
}
if let Some(guard) = authorization_guard {
guard.commit().await?;
if let Some(guard) = authorization_guard
&& let Err(error) = guard.commit().await
{
// 账号与回执已经原子提交,Redis 清理失败不能把确定的成功改写为失败。
tracing::warn!(resource, settlement_error = %error, "authorization committed but pending cleanup failed");
}
Ok(result)
}
@@ -192,42 +192,29 @@ impl DefaultObservabilityService {
average(first_token_latency_sum_ms, first_token_latency_count);
let trend = trend(TrendKind::Usage, observation.trend.clone())?;
let health_timeline = health_timeline_at(&observation.trend, Utc::now());
let mut configurations = std::collections::BTreeMap::new();
if let Some(configuration) = &settings.openai_client_profile {
configurations.insert(
gateway_core::routing::ProviderKind::new("openai").expect("static provider kind"),
configuration.clone(),
);
}
if let Some(configuration) = &settings.xai_client_profile {
configurations.insert(
gateway_core::routing::ProviderKind::new("xai").expect("static provider kind"),
configuration.clone(),
);
}
let wire_profiles = self.providers.dashboard_wire_profiles(&configurations);
let wire_profiles = self
.providers
.dashboard_wire_profiles(&settings.request_profiles);
let max_concurrent_per_account = u64::from(settings.max_concurrent_per_account);
let total_slots = runtime_slots.as_ref().map_or_else(
|| {
observation
.provider_accounts
.normal
.saturating_mul(max_concurrent_per_account)
},
|slots| {
slots
.inherited_accounts
.saturating_mul(max_concurrent_per_account)
.saturating_add(slots.overridden_slots)
},
);
let (inherited_accounts, overridden_slots) = runtime_slots
.as_ref()
.map_or((observation.provider_accounts.normal, 0), |slots| {
(slots.inherited_accounts, slots.overridden_slots)
});
let total_slots = (max_concurrent_per_account > 0 || inherited_accounts == 0).then(|| {
inherited_accounts
.saturating_mul(max_concurrent_per_account)
.saturating_add(overridden_slots)
});
let used_slots = runtime_slots.and_then(|slots| slots.used_slots);
Ok(DashboardResult {
capacity: DashboardCapacity {
max_concurrent_per_account,
total_slots,
used_slots,
available_slots: used_slots.map(|used| total_slots.saturating_sub(used)),
available_slots: used_slots
.zip(total_slots)
.map(|(used, total)| total.saturating_sub(used)),
},
rotation_strategy: settings.rotation_strategy,
observation,
@@ -0,0 +1,210 @@
//! 插件宿主账号回调;复用凭据事务,不建立第二套账号权威。
use std::sync::Arc;
use async_trait::async_trait;
use gateway_core::{account::ProviderAccountId, routing::ProviderKind, runtime::SnapshotControl};
use crate::{
model::{
AdminError, MutationContext,
provider_credentials::{
CredentialImportCommit, CredentialRotationCommit, PluginAccountCredential,
PluginAccountListQuery, PluginAccountPage, PluginAccountSaveResult,
PreparedCredentialImport, PreparedPluginAccountSave,
},
},
ports::{
plugin_accounts::PluginAccountAccess, provider::ProviderAdminRegistry, store::AccountStore,
},
};
use super::{
map_provider_error, map_store_error, publish_credentials_and_observe_quota,
required_credential, required_plugin_credential, validate_prepared_import,
validate_prepared_rotation_facts,
};
pub(crate) struct DefaultPluginAccountAccess {
providers: ProviderAdminRegistry,
accounts: Arc<dyn AccountStore>,
snapshot: Arc<dyn SnapshotControl>,
}
impl DefaultPluginAccountAccess {
#[must_use]
pub(crate) fn new(
providers: ProviderAdminRegistry,
accounts: Arc<dyn AccountStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Self {
Self {
providers,
accounts,
snapshot,
}
}
fn provider(
&self,
provider_kind: &ProviderKind,
) -> Result<std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>, AdminError> {
self.providers
.require(provider_kind)
.map_err(|error| map_provider_error(error, "plugin account access"))
}
}
#[async_trait]
impl PluginAccountAccess for DefaultPluginAccountAccess {
async fn list(&self, query: PluginAccountListQuery) -> Result<PluginAccountPage, AdminError> {
self.accounts
.list_plugin_accounts(query)
.await
.map_err(|error| map_store_error(error, "plugin account list"))
}
async fn get_runtime(
&self,
account_id: &ProviderAccountId,
) -> Result<crate::model::accounts::AccountRecord, AdminError> {
Ok(
required_plugin_credential(
self.accounts.as_ref(),
account_id,
"plugin account runtime",
)
.await?
.credential,
)
}
async fn get_credential(
&self,
account_id: &ProviderAccountId,
) -> Result<PluginAccountCredential, AdminError> {
let credential = self
.accounts
.load_credential_for_plugin(account_id)
.await
.map_err(|error| map_store_error(error, "plugin credential"))?
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))?;
Ok(PluginAccountCredential {
account: credential.account,
provider_material: credential.provider_material,
})
}
async fn get_quota(
&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"))
}
async fn save(
&self,
command: PreparedPluginAccountSave,
context: &MutationContext,
) -> Result<PluginAccountSaveResult, AdminError> {
match command {
PreparedPluginAccountSave::Create(prepared) => {
let provider_kind = prepared.provider_kind.clone();
let account_id = prepared.account_id.clone();
let provider = self.provider(&provider_kind)?;
let prepared = PreparedCredentialImport {
provider_kind: provider_kind.clone(),
credentials: vec![prepared],
};
validate_prepared_import(&provider_kind, &prepared, "plugin account save")?;
let result = self
.accounts
.commit_credential_import(
CredentialImportCommit {
outbound_proxy: None,
settings: None,
prepared,
},
context,
)
.await
.map_err(|error| map_store_error(error, "plugin account save"))?;
if result.credential_ids.as_slice() != std::slice::from_ref(&account_id) {
return Err(AdminError::internal("账号保存结果与准备目标不一致"));
}
let credential_revision = required_credential(
self.accounts.as_ref(),
&provider_kind,
&account_id,
"plugin account save",
)
.await?
.credential
.credential_revision;
publish_credentials_and_observe_quota(
&provider,
self.snapshot.as_ref(),
result.config_revision,
std::slice::from_ref(&account_id),
&context.request_id,
)
.await?;
Ok(PluginAccountSaveResult {
config_revision: result.config_revision,
account_id,
credential_revision,
})
}
PreparedPluginAccountSave::Replace {
facts,
authentication_kind,
} => {
let account = required_credential(
self.accounts.as_ref(),
&facts.provider_kind,
&facts.account_id,
"plugin account save",
)
.await?
.credential;
validate_prepared_rotation_facts(&account, &facts)?;
if account.authentication_kind != authentication_kind {
return Err(AdminError::conflict("插件准备结果与当前认证类型不一致"));
}
let provider = self.provider(&facts.provider_kind)?;
let command = CredentialRotationCommit {
prepared: facts,
settings: None,
};
let result = self
.accounts
.commit_credential_rotation(command, context)
.await
.map_err(|error| map_store_error(error, "plugin account save"))?;
let credential_revision = result
.credential_revision
.ok_or_else(|| AdminError::internal("账号保存未返回凭据版本"))?;
publish_credentials_and_observe_quota(
&provider,
self.snapshot.as_ref(),
result.config_revision,
std::slice::from_ref(&result.account_id),
&context.request_id,
)
.await?;
Ok(PluginAccountSaveResult {
config_revision: result.config_revision,
account_id: result.account_id,
credential_revision,
})
}
}
}
}
@@ -0,0 +1,87 @@
//! 插件 Client Key 目录;复用管理服务并收窄为非秘密投影。
use std::sync::Arc;
use async_trait::async_trait;
use crate::{
model::{
AdminError,
client_keys::{
ClientKeyCursor, ClientKeyCursorValue, ClientKeyListQuery, ClientKeyPageSize,
ClientKeySort, ClientKeySortField, SortDirection,
},
plugin_client_keys::{
PluginClientKey, PluginClientKeyCursor, PluginClientKeyListQuery, PluginClientKeyPage,
},
},
ports::plugin_client_keys::PluginClientKeyAccess,
use_case::client_keys::ClientKeyService,
};
pub(crate) struct DefaultPluginClientKeyAccess {
service: Arc<dyn ClientKeyService>,
}
impl DefaultPluginClientKeyAccess {
pub(crate) fn new(service: Arc<dyn ClientKeyService>) -> Self {
Self { service }
}
}
#[async_trait]
impl PluginClientKeyAccess for DefaultPluginClientKeyAccess {
async fn list(
&self,
query: PluginClientKeyListQuery,
) -> Result<PluginClientKeyPage, AdminError> {
let sort = ClientKeySort {
field: ClientKeySortField::Name,
direction: SortDirection::Asc,
};
let page_size = ClientKeyPageSize::new(query.limit.get())
.map_err(|_| AdminError::internal("插件 Client Key 页大小不合法"))?;
let page = self
.service
.list(ClientKeyListQuery {
cursor: query.cursor.map(|cursor| ClientKeyCursor {
sort,
value: ClientKeyCursorValue::Name(cursor.name),
id: cursor.id,
}),
page_size,
search: None,
sort,
})
.await?;
let next_cursor = page
.next_cursor
.map(|cursor| match cursor.value {
ClientKeyCursorValue::Name(name)
if cursor.sort.field == ClientKeySortField::Name
&& cursor.sort.direction == SortDirection::Asc =>
{
Ok(PluginClientKeyCursor {
name,
id: cursor.id,
})
}
_ => Err(AdminError::internal(
"Client Key 服务返回了不匹配的插件分页游标",
)),
})
.transpose()?;
Ok(PluginClientKeyPage {
items: page
.items
.into_iter()
.map(|item| PluginClientKey {
id: item.id,
name: item.name,
enabled: item.enabled,
})
.collect(),
next_cursor,
})
}
}
@@ -0,0 +1,148 @@
use std::{collections::BTreeMap, sync::Arc};
use async_trait::async_trait;
use crate::{
model::{AdminErrorKind, Revision},
ports::{
plugins::{PluginPackageInspector, PluginStore},
store::AdminStoreErrorKind,
system::{
SystemOperationError, SystemOperationErrorKind, SystemUpdateCandidate,
SystemUpdatePreflight,
},
},
};
use super::plugins::official::update_compatibility;
/// 只读取启用实例及其固定包体,检查目标宿主合同;不准备或执行插件。
pub(crate) struct PluginSystemUpdatePreflight {
store: Arc<dyn PluginStore>,
inspector: Arc<dyn PluginPackageInspector>,
}
impl PluginSystemUpdatePreflight {
pub(crate) fn new(
store: Arc<dyn PluginStore>,
inspector: Arc<dyn PluginPackageInspector>,
) -> Self {
Self { store, inspector }
}
}
#[async_trait]
impl SystemUpdatePreflight for PluginSystemUpdatePreflight {
async fn validate(
&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));
}
}
Ok(snapshot.config_revision)
}
async fn confirm_revision(&self, expected: Revision) -> Result<(), SystemOperationError> {
let current = self
.store
.load_instances()
.await
.map_err(map_store_error)?
.config_revision;
if current != expected {
return Err(conflict("插件配置已变化,请重新执行系统更新"));
}
Ok(())
}
}
fn map_store_error(error: crate::ports::store::AdminStoreError) -> SystemOperationError {
match error.kind() {
AdminStoreErrorKind::StaleRevision
| AdminStoreErrorKind::DuplicateName
| AdminStoreErrorKind::Conflict => conflict("插件配置已变化,请重新执行系统更新"),
AdminStoreErrorKind::Invalid | AdminStoreErrorKind::NotFound => {
conflict("启用插件的制品不完整,无法执行系统更新")
}
AdminStoreErrorKind::Unavailable => internal("插件配置暂不可读取"),
}
}
fn map_inspection_error(error: crate::model::AdminError) -> SystemOperationError {
match error.kind() {
AdminErrorKind::Invalid | AdminErrorKind::Conflict | AdminErrorKind::NotFound => {
conflict("启用插件的制品无法通过兼容性检查")
}
_ => internal("插件兼容性检查暂不可用"),
}
}
fn incompatible(instance_id: &str) -> SystemOperationError {
conflict(format!("启用插件实例 {instance_id} 与目标网关版本不兼容"))
}
fn invalid(message: impl Into<String>) -> SystemOperationError {
SystemOperationError::new(SystemOperationErrorKind::Invalid, message)
}
fn conflict(message: impl Into<String>) -> SystemOperationError {
SystemOperationError::new(SystemOperationErrorKind::Conflict, message)
}
fn internal(message: impl Into<String>) -> SystemOperationError {
SystemOperationError::new(SystemOperationErrorKind::Internal, message)
}
@@ -0,0 +1,433 @@
use std::{collections::BTreeSet, sync::Arc};
use gateway_core::runtime::SnapshotControl;
use sha2::{Digest as _, Sha256};
use super::super::{map_store_error, publish_committed};
use crate::{
model::{
AdminError, MutationContext, Revision,
plugins::{
InspectedPluginArtifact, InstalledPluginArtifact, PluginArtifactIconResource,
PluginArtifactMetadata, PluginArtifactMutation, PluginIconTheme, PluginInstallResult,
PluginSource,
distribution::VerifiedPluginArtifact,
instances::{
ConfigurePluginInstance, PluginCapabilityBinding, PluginFailurePolicy,
PluginInstance, PluginPermissionGrant,
},
},
},
ports::plugins::{
PluginDistribution, PluginPackageInspector, PluginPreparation, PluginStateStore,
PluginStore,
},
ports::proxy::ProxyStore,
};
use super::state::PluginStateService;
/// 远程插件来源访问所需的传输与受管出站代理目录。
pub struct PluginDistributionPorts {
pub(super) transport: Arc<dyn PluginDistribution>,
pub(super) proxies: Arc<dyn ProxyStore>,
}
impl PluginDistributionPorts {
#[must_use]
pub fn new(transport: Arc<dyn PluginDistribution>, proxies: Arc<dyn ProxyStore>) -> Self {
Self { transport, proxies }
}
}
/// 所有安装入口在取得完整包后进入同一校验与事务流程。
pub struct PluginsService {
pub(super) store: Arc<dyn PluginStore>,
pub(super) inspector: Arc<dyn PluginPackageInspector>,
pub(super) distribution: PluginDistributionPorts,
pub(super) snapshots: Arc<dyn SnapshotControl>,
pub(super) preparation: Arc<dyn PluginPreparation>,
pub(super) published: gateway_core::runtime::RuntimeSnapshotHandle,
pub(super) state: PluginStateService,
}
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>,
inspector: Arc<dyn PluginPackageInspector>,
distribution: PluginDistributionPorts,
snapshots: Arc<dyn SnapshotControl>,
preparation: Arc<dyn PluginPreparation>,
published: gateway_core::runtime::RuntimeSnapshotHandle,
state: Arc<dyn PluginStateStore>,
) -> Self {
Self {
store,
inspector,
distribution,
snapshots,
preparation,
published,
state: PluginStateService::new(state),
}
}
pub async fn list(&self) -> Result<Vec<InstalledPluginArtifact>, AdminError> {
self.store
.list_artifacts()
.await
.map_err(|error| map_store_error(error, "plugin"))
}
pub async fn icon(
&self,
digest: &str,
theme: PluginIconTheme,
) -> Result<PluginArtifactIconResource, AdminError> {
if !valid_digest(digest) {
return Err(AdminError::invalid("插件制品摘要不合法"));
}
let artifact = self
.store
.load_artifact(digest)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
if artifact.metadata.icon.is_none() {
return Err(AdminError::not_found("插件制品未提供图标"));
}
self.inspector
.icon(artifact.archive, digest.to_owned(), theme)
.await?
.ok_or_else(|| AdminError::not_found("插件制品未提供图标"))
}
pub async fn verify_upload(
&self,
archive: Arc<[u8]>,
) -> Result<VerifiedPluginArtifact, AdminError> {
let artifact = self.inspector.inspect(archive, None).await?;
Ok(VerifiedPluginArtifact {
metadata: artifact.metadata,
source: PluginSource::Upload,
})
}
/// 管理上传固定为自定义来源;调用方不能借通用参数声明官方身份。
pub async fn install_upload(
&self,
archive: Arc<[u8]>,
expected_sha256: Option<String>,
context: &MutationContext,
) -> Result<PluginInstallResult, AdminError> {
if expected_sha256.is_none() {
return Err(AdminError::invalid("安装需要已校验的插件包摘要"));
}
let artifact = self.inspector.inspect(archive, expected_sha256).await?;
let mutation = self
.persist(artifact, PluginSource::Upload, context)
.await?;
self.complete_install(mutation, context).await
}
/// 接受已导入但尚未安装的制品;官方发行导入也必须经过管理员的这一步。
pub async fn accept_artifact(
&self,
digest: &str,
context: &MutationContext,
) -> Result<PluginInstallResult, AdminError> {
if !valid_digest(digest) {
return Err(AdminError::invalid("插件制品摘要不合法"));
}
let mutation = self
.store
.accept_artifact(digest, context)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?;
self.finish_install(mutation, context).await
}
pub(super) async fn complete_install(
&self,
mutation: PluginArtifactMutation,
context: &MutationContext,
) -> Result<PluginInstallResult, AdminError> {
let digest = mutation.artifact.metadata.sha256.clone();
self.accept_artifact(&digest, context).await
}
async fn finish_install(
&self,
mut mutation: PluginArtifactMutation,
context: &MutationContext,
) -> Result<PluginInstallResult, AdminError> {
let metadata = mutation.artifact.metadata.clone();
let creation_id = default_instance_id(&metadata.plugin_id);
let configuration = configuration_defaults(&metadata);
let bindings = default_bindings(&metadata);
let probe = default_instance(
&metadata,
&creation_id,
configuration.clone(),
bindings.clone(),
false,
mutation.config_revision,
);
let configuration_required = !self
.preparation
.configuration_ready(probe, &metadata)
.await?;
// 其它管理员写入或同插件的并发安装可能推进全局 revision;稳定 creation ID
// 配合每轮重新读取,既不会复制默认实例,也不会覆盖先完成的另一个版本。
for _ in 0..3 {
let snapshot = self
.store
.load_instances()
.await
.map_err(|error| map_store_error(error, "plugin"))?;
if let Some((default_instance_id, required)) = self
.existing_plugin_instance(&snapshot, &metadata, &creation_id)
.await?
{
mutation.config_revision = snapshot.config_revision;
return Ok(PluginInstallResult {
mutation,
default_instance_id,
configuration_required: required,
});
}
let input = ConfigurePluginInstance {
replace_instances: Vec::new(),
creation_id: Some(creation_id.clone()),
expected_revision: None,
name: metadata.display_name.clone(),
artifact_sha256: metadata.sha256.clone(),
enabled: !configuration_required,
configuration: configuration.clone(),
secrets: None,
bindings: bindings.clone(),
};
match self.configure_instance(None, input, context).await {
Ok(configured) => {
mutation.config_revision = configured.config_revision;
return Ok(PluginInstallResult {
mutation,
default_instance_id: Some(configured.instance.id),
configuration_required,
});
}
Err(error) if error.kind() == crate::model::AdminErrorKind::Conflict => {}
Err(error) => return Err(error),
}
}
Err(AdminError::conflict(
"插件安装期间配置持续变化,请刷新后重试",
))
}
async fn existing_plugin_instance(
&self,
snapshot: &crate::model::plugins::instances::PluginInstanceSnapshot,
metadata: &PluginArtifactMetadata,
default_id: &str,
) -> Result<Option<(Option<String>, bool)>, AdminError> {
let artifacts = self
.store
.list_artifacts()
.await
.map_err(|error| map_store_error(error, "plugin"))?;
let matching_digests = artifacts
.into_iter()
.filter(|artifact| artifact.metadata.plugin_id == metadata.plugin_id)
.map(|artifact| artifact.metadata.sha256)
.collect::<BTreeSet<_>>();
let Some(instance) = snapshot
.instances
.iter()
.find(|instance| matching_digests.contains(&instance.artifact_sha256))
else {
return Ok(None);
};
if instance.id == default_id && instance.artifact_sha256 == metadata.sha256 {
let required = !self
.preparation
.configuration_ready(instance.clone(), metadata)
.await?;
Ok(Some((Some(instance.id.clone()), required)))
} else {
Ok(Some((None, false)))
}
}
pub(super) async fn persist(
&self,
artifact: InspectedPluginArtifact,
source: PluginSource,
context: &MutationContext,
) -> Result<PluginArtifactMutation, AdminError> {
let mutation = self
.store
.install_artifact(artifact, source, context)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?;
Ok(mutation)
}
pub async fn delete(
&self,
digest: &str,
context: &MutationContext,
) -> Result<Revision, AdminError> {
let revision = self
.store
.delete_artifact(digest, context)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
publish_committed(self.snapshots.as_ref(), revision).await?;
Ok(revision)
}
}
fn valid_digest(digest: &str) -> bool {
digest.len() == 64
&& digest
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
fn default_instance_id(plugin_id: &str) -> String {
let mut hash = Sha256::new();
hash.update(b"codex-proxy-rs/plugin-default-instance/v1\0");
hash.update(plugin_id.as_bytes());
let digest = hash.finalize();
let mut bytes = [0_u8; 16];
bytes.copy_from_slice(&digest[..16]);
// RFC 9562 UUIDv8 保留给应用自定义派生;variant 固定为 RFC 4122。
bytes[6] = (bytes[6] & 0x0f) | 0x80;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
uuid::Uuid::from_bytes(bytes).to_string()
}
pub(super) fn configuration_defaults(metadata: &PluginArtifactMetadata) -> serde_json::Value {
let mut configuration = schema_default(&metadata.configuration_schema)
.filter(serde_json::Value::is_object)
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
merge_property_defaults(&mut configuration, &metadata.configuration_schema);
if let Some(configuration) = configuration.as_object_mut() {
for secret in &metadata.secret_fields {
configuration.remove(secret);
}
}
configuration
}
fn schema_default(schema: &serde_json::Value) -> Option<serde_json::Value> {
let mut value = if let Some(value) = schema.get("default") {
value.clone()
} else {
let properties = schema.get("properties")?.as_object()?;
let mut object = serde_json::Map::new();
for (name, property) in properties {
if let Some(value) = schema_default(property) {
object.insert(name.clone(), value);
}
}
if object.is_empty() {
return None;
}
serde_json::Value::Object(object)
};
merge_property_defaults(&mut value, schema);
Some(value)
}
fn merge_property_defaults(value: &mut serde_json::Value, schema: &serde_json::Value) {
let (Some(value), Some(properties)) = (
value.as_object_mut(),
schema
.get("properties")
.and_then(serde_json::Value::as_object),
) else {
return;
};
for (name, property) in properties {
if let Some(current) = value.get_mut(name) {
merge_property_defaults(current, property);
} else if let Some(default) = schema_default(property) {
value.insert(name.clone(), default);
}
}
}
pub(super) fn default_bindings(metadata: &PluginArtifactMetadata) -> Vec<PluginCapabilityBinding> {
metadata
.contributes
.iter()
.filter(|(capability, _)| {
!matches!(
capability.as_str(),
"frontend_authentication" | "management" | "command_line" | "model_catalog"
)
})
.flat_map(|(_, contribution)| {
contribution
.stages
.iter()
.map(|stage| PluginCapabilityBinding {
contribution: contribution.id.clone(),
stage: stage.clone(),
order: 0,
failure_policy: if stage == "observation" {
PluginFailurePolicy::Observe
} else if stage == "retry" {
PluginFailurePolicy::Delegate
} else {
PluginFailurePolicy::Reject
},
client_key_ids: Vec::new(),
account_group_ids: Vec::new(),
provider_ids: Vec::new(),
models: Vec::new(),
identity_bindings: Vec::new(),
})
})
.collect()
}
fn default_instance(
metadata: &PluginArtifactMetadata,
id: &str,
configuration: serde_json::Value,
bindings: Vec<PluginCapabilityBinding>,
enabled: bool,
revision: Revision,
) -> PluginInstance {
PluginInstance {
id: id.to_owned(),
name: metadata.display_name.clone(),
artifact_sha256: metadata.sha256.clone(),
enabled,
trusted_process: true,
configuration,
secrets: Default::default(),
grants: metadata
.requested_permissions
.iter()
.cloned()
.map(|permission| PluginPermissionGrant { permission })
.collect(),
bindings,
revision,
}
}
@@ -0,0 +1,379 @@
use std::collections::BTreeSet;
use super::PluginsService;
use crate::{
model::{
AdminError, MutationContext, Revision,
plugins::{
InspectedPluginArtifact, PluginInstallResult, PluginSource,
distribution::{
GithubReleaseQuery, PluginDistributionEgress, PluginRelease, PluginSourceBinding,
PluginUpdateCheck, PluginUpdatePolicy, PluginUpdateSource, RemotePluginInstall,
RemotePluginLocation, RemotePluginVerify, SourceAuthentication, SourceCredential,
SourceCredentialInfo, VerifiedPluginArtifact,
},
},
},
ports::store::AdminStoreErrorKind,
use_case::{map_store_error, publish_committed},
};
impl PluginsService {
pub async fn update_sources(
&self,
) -> Result<Vec<crate::model::plugins::distribution::PluginSourceBinding>, AdminError> {
self.store
.list_update_sources()
.await
.map_err(|e| map_store_error(e, "plugin"))
}
pub async fn change_update_source(
&self,
mut binding: crate::model::plugins::distribution::PluginSourceBinding,
context: &MutationContext,
) -> Result<Revision, AdminError> {
if matches!(binding.source, PluginUpdateSource::Builtin) {
return Err(AdminError::invalid("官方来源只由发行清单确认"));
}
if let PluginUpdateSource::Github { repository } = &mut binding.source {
repository.make_ascii_lowercase();
}
self.distribution
.transport
.validate_source(&binding.source)?;
validate_update_policy(&binding)?;
validate_source_egress(&binding)?;
self.resolve_egress(binding.outbound_proxy_id.as_deref())
.await?;
let revision = self
.store
.change_update_source(binding, context)
.await
.map_err(|e| map_store_error(e, "plugin"))?;
publish_committed(self.snapshots.as_ref(), revision).await?;
Ok(revision)
}
pub async fn check_update(
&self,
plugin_id: &str,
credential_ids: &[String],
) -> Result<PluginUpdateCheck, AdminError> {
let binding = self
.update_sources()
.await?
.into_iter()
.find(|binding| binding.plugin_id == plugin_id)
.ok_or_else(|| AdminError::not_found("插件更新来源不存在"))?;
validate_update_policy(&binding)?;
let PluginUpdateSource::Github { repository } = &binding.source else {
return Err(AdminError::invalid(
"该来源不提供 Release 更新查询,请显式选择并安装插件包",
));
};
let (tag, allow_prerelease) = match &binding.policy {
PluginUpdatePolicy::Manual {} => {
return Err(AdminError::invalid("请先为该来源设置更新检查策略"));
}
PluginUpdatePolicy::Stable {} => (None, false),
PluginUpdatePolicy::Pinned {
tag,
allow_prerelease,
} => (Some(tag.clone()), *allow_prerelease),
};
let release = self
.query_release(
GithubReleaseQuery {
repository: repository.clone(),
tag,
allow_prerelease,
},
credential_ids,
binding.outbound_proxy_id.as_deref(),
)
.await?;
// 查询期间来源或策略被修改时返回冲突,不把旧来源的候选交给下一步安装。
if !self.update_sources().await?.contains(&binding) {
return Err(AdminError::conflict("插件更新来源已变更,请重新检查"));
}
Ok(PluginUpdateCheck { binding, release })
}
pub async fn source_credentials(&self) -> Result<Vec<SourceCredentialInfo>, AdminError> {
self.store
.list_source_credentials()
.await
.map_err(|e| map_store_error(e, "plugin"))
}
pub async fn create_source_credential(
&self,
mut info: SourceCredentialInfo,
authentication: SourceAuthentication,
context: &MutationContext,
) -> Result<SourceCredentialInfo, AdminError> {
info.id = uuid::Uuid::now_v7().to_string();
let credential = SourceCredential {
info: info.clone(),
authentication,
};
self.distribution
.transport
.validate_credential(&credential)?;
let revision = self
.store
.save_source_credential(credential, context)
.await
.map_err(|e| map_store_error(e, "plugin"))?;
publish_committed(self.snapshots.as_ref(), revision).await?;
Ok(info)
}
pub async fn delete_source_credential(
&self,
id: &str,
context: &MutationContext,
) -> Result<Revision, AdminError> {
let revision = self
.store
.delete_source_credential(id, context)
.await
.map_err(|e| map_store_error(e, "plugin"))?;
publish_committed(self.snapshots.as_ref(), revision).await?;
Ok(revision)
}
pub async fn query_release(
&self,
query: GithubReleaseQuery,
credential_ids: &[String],
outbound_proxy_id: Option<&str>,
) -> Result<PluginRelease, AdminError> {
let egress = self.resolve_egress(outbound_proxy_id).await?;
let release = self
.distribution
.transport
.query_release(
query,
self.credentials(credential_ids).await?,
egress.clone(),
)
.await?;
self.ensure_egress_current(egress.as_ref()).await?;
Ok(release)
}
pub async fn install_remote(
&self,
input: RemotePluginInstall,
context: &MutationContext,
) -> Result<PluginInstallResult, AdminError> {
if !crate::model::plugins::valid_plugin_id(&input.plugin_id)
|| input.version.is_empty()
|| input.version.len() > 128
{
return Err(AdminError::invalid("安装需要已解析的插件 ID 与版本"));
}
let digest = match &input.location {
RemotePluginLocation::Url { sha256, .. }
| RemotePluginLocation::Github { sha256, .. } => sha256,
};
if digest.is_none() {
return Err(AdminError::invalid("安装需要已校验的插件包摘要"));
}
let (artifact, source) = self
.inspect_remote(RemotePluginVerify {
location: input.location,
credential_ids: input.credential_ids,
outbound_proxy_id: input.outbound_proxy_id,
expected_plugin_id: Some(input.plugin_id),
})
.await?;
if artifact.metadata.version != input.version {
return Err(AdminError::invalid("插件清单版本与安装选择不符"));
}
let mutation = self.persist(artifact, source, context).await?;
self.complete_install(mutation, context).await
}
pub async fn verify_remote(
&self,
input: RemotePluginVerify,
) -> Result<VerifiedPluginArtifact, AdminError> {
let (artifact, source) = self.inspect_remote(input).await?;
Ok(VerifiedPluginArtifact {
metadata: artifact.metadata,
source,
})
}
async fn inspect_remote(
&self,
input: RemotePluginVerify,
) -> Result<(InspectedPluginArtifact, PluginSource), AdminError> {
if input
.expected_plugin_id
.as_deref()
.is_some_and(|id| !crate::model::plugins::valid_plugin_id(id))
{
return Err(AdminError::invalid("预期插件 ID 不合法"));
}
let source = match &input.location {
RemotePluginLocation::Url { url, .. } => PluginUpdateSource::Url { url: url.clone() },
RemotePluginLocation::Github { repository, .. } => PluginUpdateSource::Github {
repository: repository.to_ascii_lowercase(),
},
};
let outbound_proxy_id = input.outbound_proxy_id.clone();
self.distribution.transport.validate_source(&source)?;
let bindings = self.update_sources().await?;
let expected_binding = bindings
.iter()
.find(|binding| Some(&binding.plugin_id) == input.expected_plugin_id.as_ref());
if expected_binding.is_some_and(|binding| {
binding.source != source || binding.outbound_proxy_id != outbound_proxy_id
}) {
return Err(AdminError::conflict(
"插件来源与已保存配置不符,请先显式修改来源",
));
}
let egress = self.resolve_egress(outbound_proxy_id.as_deref()).await?;
let download = self
.distribution
.transport
.download(
input.location,
self.credentials(&input.credential_ids).await?,
egress.clone(),
)
.await?;
self.ensure_egress_current(egress.as_ref()).await?;
let artifact = self
.inspector
.inspect(download.archive, Some(download.sha256))
.await?;
if input
.expected_plugin_id
.as_ref()
.is_some_and(|id| *id != artifact.metadata.plugin_id)
{
return Err(AdminError::invalid("插件清单身份与安装选择不符"));
}
// 首次解析前身份未知,按包内身份复核下载前的来源绑定。
let binding = bindings
.iter()
.find(|binding| binding.plugin_id == artifact.metadata.plugin_id);
if binding.is_some_and(|binding| {
binding.source != source || binding.outbound_proxy_id != outbound_proxy_id
}) {
return Err(AdminError::conflict(
"插件来源与已保存配置不符,请先显式修改来源",
));
}
if PluginUpdateSource::from(&download.source) != source {
return Err(AdminError::invalid("下载来源与校验选择不符"));
}
if download.source.outbound_proxy() != egress.as_ref().map(|egress| &egress.source) {
return Err(AdminError::invalid("下载出站身份与安装选择不符"));
}
let current_binding = self
.update_sources()
.await?
.into_iter()
.find(|binding| binding.plugin_id == artifact.metadata.plugin_id);
if current_binding.as_ref() != binding {
return Err(AdminError::conflict("插件更新来源已变更,请重新校验"));
}
Ok((artifact, download.source))
}
async fn resolve_egress(
&self,
outbound_proxy_id: Option<&str>,
) -> Result<Option<PluginDistributionEgress>, AdminError> {
let Some(id) = outbound_proxy_id else {
return Ok(None);
};
if id.is_empty() || id.len() > 128 || id.chars().any(char::is_control) {
return Err(AdminError::invalid("插件来源出站代理不合法"));
}
let record = self
.distribution
.proxies
.get(id)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
Ok(Some(PluginDistributionEgress::new(
record.id,
record.revision,
record.proxy,
)))
}
async fn ensure_egress_current(
&self,
egress: Option<&PluginDistributionEgress>,
) -> Result<(), AdminError> {
let Some(egress) = egress else {
return Ok(());
};
let current = match self.distribution.proxies.get(&egress.source.id).await {
Ok(current) => current,
Err(error) if error.kind() == AdminStoreErrorKind::NotFound => {
return Err(AdminError::conflict("插件来源出站代理已变更,请重新校验"));
}
Err(error) => return Err(map_store_error(error, "proxy")),
};
if current.revision.get() != egress.source.revision || current.proxy != egress.proxy {
return Err(AdminError::conflict("插件来源出站代理已变更,请重新校验"));
}
Ok(())
}
async fn credentials(&self, ids: &[String]) -> Result<Vec<SourceCredential>, AdminError> {
if ids.len() > 8 || ids.iter().collect::<BTreeSet<_>>().len() != ids.len() {
return Err(AdminError::invalid("下载凭据不能重复,且最多选择 8 个"));
}
let mut credentials = Vec::with_capacity(ids.len());
for id in ids {
credentials.push(
self.store
.load_source_credential(id)
.await
.map_err(|e| map_store_error(e, "plugin"))?,
);
}
Ok(credentials)
}
}
fn validate_update_policy(binding: &PluginSourceBinding) -> Result<(), AdminError> {
if !matches!(binding.source, PluginUpdateSource::Github { .. })
&& !matches!(binding.policy, PluginUpdatePolicy::Manual {})
{
return Err(AdminError::invalid(
"只有 GitHub 来源支持 Release 更新检查策略",
));
}
if let PluginUpdatePolicy::Pinned { tag, .. } = &binding.policy
&& (tag.is_empty()
|| tag.len() > 128
|| tag.chars().any(char::is_control)
|| tag.trim() != tag)
{
return Err(AdminError::invalid("固定 Release tag 不合法"));
}
Ok(())
}
fn validate_source_egress(binding: &PluginSourceBinding) -> Result<(), AdminError> {
if binding.outbound_proxy_id.is_some()
&& matches!(
binding.source,
PluginUpdateSource::Builtin | PluginUpdateSource::Upload
)
{
return Err(AdminError::invalid("本地或官方来源不能配置出站代理"));
}
Ok(())
}
@@ -0,0 +1,766 @@
use std::collections::{BTreeMap, BTreeSet};
use gateway_core::{policy::ClientApiKeyId, routing::AccountGroupId};
use secrecy::ExposeSecret;
use super::PluginsService;
use crate::{
model::{
AdminError, MutationContext, Revision,
plugins::instances::{
ConfigurePluginInstance, PluginInstance, PluginInstanceMutation,
PluginInstanceReplacement, PluginInstanceRuntime, PluginInstanceRuntimeStatus,
PluginInstanceSnapshot, PluginInstanceView, PluginPermissionGrant,
},
plugins::state::{PluginStateCommit, PluginStateConfiguration},
},
use_case::{map_store_error, publish_committed},
};
impl PluginsService {
pub async fn instances(&self) -> Result<Vec<PluginInstanceView>, AdminError> {
let snapshot = self
.store
.load_instances()
.await
.map_err(|e| map_store_error(e, "plugin"))?;
let published = self.published.snapshot_for_diagnostics();
let published_revision = published.as_ref().map(|snapshot| snapshot.revision().get());
let published_ready = published
.as_ref()
.is_some_and(|snapshot| snapshot.extensions().is_none_or(|set| set.can_serve()));
let mut diagnostics = self
.preparation
.runtime_diagnostics(
&snapshot,
published_revision,
published
.as_ref()
.and_then(|snapshot| snapshot.extensions()),
)
.await
.unwrap_or_default();
let target_revision = snapshot.config_revision.get();
let artifacts = self
.list()
.await?
.into_iter()
.map(|artifact| (artifact.metadata.sha256.clone(), artifact.metadata))
.collect::<BTreeMap<_, _>>();
let mut views = Vec::with_capacity(snapshot.instances.len());
for instance in snapshot.instances {
let metadata = artifacts
.get(&instance.artifact_sha256)
.ok_or_else(|| AdminError::not_found("插件制品不存在"))?;
let configuration_required = !self
.preparation
.configuration_ready(instance.clone(), metadata)
.await?;
let runtime = diagnostics.remove(&instance.id).unwrap_or_else(|| {
let running = instance.enabled
&& published_ready
&& published_revision == Some(target_revision);
PluginInstanceRuntime {
status: if !instance.enabled {
PluginInstanceRuntimeStatus::Disabled
} else if running {
PluginInstanceRuntimeStatus::Running
} else {
PluginInstanceRuntimeStatus::AwaitingPublication
},
actual_revision: running.then_some(instance.revision.get()),
actual_artifact_sha256: running.then(|| instance.artifact_sha256.clone()),
failure: None,
draining_revisions: Vec::new(),
}
});
views.push(PluginInstanceView {
configuration_required,
running: runtime.status == PluginInstanceRuntimeStatus::Running,
published_revision,
runtime,
instance,
});
}
Ok(views)
}
pub async fn configure_instance(
&self,
id: Option<&str>,
input: ConfigurePluginInstance,
context: &MutationContext,
) -> Result<PluginInstanceMutation, AdminError> {
if id.is_some() && input.creation_id.is_some() {
return Err(AdminError::invalid("编辑配置不能使用创建标识"));
}
if id.is_none() && input.expected_revision.is_some() {
return Err(AdminError::invalid("创建配置不能指定已有配置版本"));
}
if let Some(creation_id) = &input.creation_id {
let parsed = uuid::Uuid::parse_str(creation_id)
.map_err(|_| AdminError::invalid("配置创建标识必须是 UUID"))?;
if parsed.to_string() != *creation_id {
return Err(AdminError::invalid("配置创建标识必须使用标准 UUID 格式"));
}
}
let snapshot = self
.store
.load_instances()
.await
.map_err(|e| map_store_error(e, "plugin"))?;
let existing = match id {
Some(id) => Some(
snapshot
.instances
.iter()
.find(|instance| instance.id == id)
.cloned()
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?,
),
None => input.creation_id.as_ref().and_then(|creation_id| {
snapshot
.instances
.iter()
.find(|instance| instance.id == *creation_id)
.cloned()
}),
};
if let Some(existing) = &existing {
if id.is_none() && !same_creation_request(existing, &input) {
return Err(AdminError::conflict("此配置已创建,请加载最新配置后编辑"));
}
if input
.expected_revision
.is_some_and(|revision| revision != existing.revision.get())
{
return Err(AdminError::conflict("插件配置已变更,请加载最新配置后重试"));
}
}
self.configure_from_snapshot(snapshot, existing, input, context)
.await
}
pub(super) async fn configure_from_snapshot(
&self,
mut snapshot: PluginInstanceSnapshot,
existing: Option<PluginInstance>,
input: ConfigurePluginInstance,
context: &MutationContext,
) -> Result<PluginInstanceMutation, AdminError> {
let expected_revision = snapshot.config_revision;
let candidate_revision = next_revision(expected_revision)?;
let artifacts = self
.store
.list_artifacts()
.await
.map_err(|error| map_store_error(error, "plugin"))?;
let artifact = artifacts
.iter()
.find(|artifact| artifact.metadata.sha256 == input.artifact_sha256)
.ok_or_else(|| AdminError::not_found("插件制品不存在"))?;
if artifact.accepted_at.is_none() {
return Err(AdminError::invalid("请先安装并接受该版本声明的权限"));
}
let same_plugin = |digest: &str| {
artifacts.iter().any(|item| {
item.metadata.sha256 == digest
&& item.metadata.plugin_id == artifact.metadata.plugin_id
})
};
if existing
.as_ref()
.is_some_and(|previous| !same_plugin(&previous.artifact_sha256))
{
return Err(AdminError::invalid("不能将已有配置切换为另一个插件"));
}
if input.enabled
&& snapshot.instances.iter().any(|other| {
other.enabled
&& existing
.as_ref()
.is_none_or(|current| current.id != other.id)
&& same_plugin(&other.artifact_sha256)
&& !input
.replace_instances
.iter()
.any(|replacement| replacement.id == other.id)
})
{
return Err(AdminError::conflict(
"此插件已有启用配置,请先切换或停用旧配置",
));
}
let saved = if let Some(previous) = &existing
&& previous.artifact_sha256 != input.artifact_sha256
&& input.secrets.is_none()
{
self.store
.load_version_configuration(&previous.id, &input.artifact_sha256)
.await
.map_err(|error| map_store_error(error, "plugin"))?
} else {
None
};
let instance = PluginInstance {
id: existing.as_ref().map_or_else(
|| {
input
.creation_id
.clone()
.unwrap_or_else(|| uuid::Uuid::now_v7().to_string())
},
|instance| instance.id.clone(),
),
name: input.name.trim().to_owned(),
artifact_sha256: input.artifact_sha256,
enabled: input.enabled,
trusted_process: true,
configuration: input.configuration,
secrets: input
.secrets
.or_else(|| saved.map(|saved| saved.secrets))
.unwrap_or_else(|| {
existing
.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,
};
validate_input(&instance)?;
if existing
.as_ref()
.is_some_and(|previous| previous.artifact_sha256 != instance.artifact_sha256)
&& !self
.preparation
.configuration_ready(instance.clone(), &artifact.metadata)
.await?
{
return Err(AdminError::invalid(
"目标版本仍缺少必填设置,请补充后再切换",
));
}
let replacements = input.replace_instances;
if !replacements.is_empty() {
if !instance.enabled || replacements.len() > 256 {
return Err(AdminError::invalid(
"仅启用配置时可以切换其他配置,单次最多 256 项",
));
}
let mut ids = BTreeSet::new();
for replacement in &replacements {
if replacement.id == instance.id || !ids.insert(&replacement.id) {
return Err(AdminError::invalid("停用列表不能重复或包含当前配置"));
}
let previous = snapshot
.instances
.iter()
.find(|item| item.id == replacement.id)
.ok_or_else(|| AdminError::conflict("待停用配置已变更,请重新确认"))?;
if !previous.enabled || previous.revision.get() != replacement.expected_revision {
return Err(AdminError::conflict("待停用配置已变更,请重新确认"));
}
if !artifacts.iter().any(|item| {
item.metadata.sha256 == previous.artifact_sha256
&& item.metadata.plugin_id == artifact.metadata.plugin_id
}) {
return Err(AdminError::invalid("只能切换同一插件的配置"));
}
}
}
let state = self.preparation.validate(instance.clone()).await?;
if let Some(existing) = &existing
&& self.state.transition_required(&existing.id, &state).await?
{
return self
.configure_instance_with_state_transition(
snapshot,
existing.clone(),
instance,
state,
&replacements,
context,
)
.await;
}
snapshot.instances.retain(|item| item.id != instance.id);
snapshot.instances.push(instance.clone());
snapshot.config_revision = candidate_revision;
apply_replacements(&mut snapshot, &replacements);
let prepared = self.preparation.prepare(snapshot.clone()).await?;
let result = self
.store
.save_instance_replacing(
instance,
expected_revision,
PluginStateCommit {
configuration: state,
transition_id: None,
},
&replacements,
context,
)
.await
.map_err(|e| map_store_error(e, "plugin"))?;
let prepared = if result.config_revision == candidate_revision
&& result.instance.revision == candidate_revision
{
prepared
} else {
snapshot.config_revision = result.config_revision;
snapshot
.instances
.retain(|item| item.id != result.instance.id);
snapshot.instances.push(result.instance.clone());
apply_replacements(&mut snapshot, &replacements);
self.preparation.prepare(snapshot).await?
};
self.preparation
.activate_state(&prepared, &result.instance)
.await?;
// prepared 的强引用跨过提交与唯一发布入口,防止候选在被读取之前回收。
publish_committed(self.snapshots.as_ref(), result.config_revision).await?;
drop(prepared);
Ok(result)
}
async fn configure_instance_with_state_transition(
&self,
mut snapshot: PluginInstanceSnapshot,
previous: PluginInstance,
mut target: PluginInstance,
target_state: PluginStateConfiguration,
replacements: &[PluginInstanceReplacement],
context: &MutationContext,
) -> Result<PluginInstanceMutation, AdminError> {
let previous_state = self.preparation.validate(previous.clone()).await?;
let original_revision = snapshot.config_revision;
// 即使最终保持停用,迁移也必须由目标制品的受控候选执行。
let mut migration_instance = target.clone();
migration_instance.enabled = true;
snapshot.config_revision = migration_instance.revision;
snapshot
.instances
.retain(|instance| instance.id != migration_instance.id);
snapshot.instances.push(migration_instance);
apply_replacements(&mut snapshot, replacements);
let migration_prepared = self.preparation.prepare(snapshot).await?;
let mut disabled_revision = None;
let disabled = if previous.enabled {
let mut disabled = previous.clone();
disabled.enabled = false;
let mutation = self
.store
.save_instance_with_state(
disabled,
original_revision,
PluginStateCommit {
configuration: previous_state.clone(),
transition_id: None,
},
context,
)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?;
self.preparation
.quiesce_instance(&previous.id, &previous.artifact_sha256, previous.revision)
.await;
disabled_revision = Some(mutation.config_revision);
mutation.instance
} else {
previous.clone()
};
let expected_revision = disabled_revision.unwrap_or(original_revision);
let transition = match self
.state
.begin_transition(
&disabled.id,
disabled.revision,
&target.artifact_sha256,
target_state.clone(),
)
.await
{
Ok(transition) => transition,
Err(error) => {
return Err(self
.recover_failed_state_transition(
None,
&previous,
&previous_state,
disabled_revision,
context,
error,
)
.await);
}
};
if let Err(error) = self
.preparation
.migrate_state(&migration_prepared, transition.clone())
.await
{
return Err(self
.recover_failed_state_transition(
Some(&transition.id),
&previous,
&previous_state,
disabled_revision,
context,
error,
)
.await);
}
let mut current = self
.store
.load_instances()
.await
.map_err(|error| map_store_error(error, "plugin"))?;
if current.config_revision != expected_revision {
return Err(self
.recover_failed_state_transition(
Some(&transition.id),
&previous,
&previous_state,
disabled_revision,
context,
AdminError::conflict("迁移期间插件配置已发生变化"),
)
.await);
}
let candidate_revision = next_revision(expected_revision)?;
target.revision = candidate_revision;
current.instances.retain(|item| item.id != target.id);
current.instances.push(target.clone());
current.config_revision = candidate_revision;
apply_replacements(&mut current, replacements);
let prepared = match self.preparation.prepare(current.clone()).await {
Ok(prepared) => prepared,
Err(error) => {
return Err(self
.recover_failed_state_transition(
Some(&transition.id),
&previous,
&previous_state,
disabled_revision,
context,
error,
)
.await);
}
};
let result = match self
.store
.save_instance_replacing(
target,
expected_revision,
PluginStateCommit {
configuration: target_state,
transition_id: Some(transition.id.clone()),
},
replacements,
context,
)
.await
{
Ok(result) => result,
Err(error) => {
return Err(self
.recover_failed_state_transition(
Some(&transition.id),
&previous,
&previous_state,
disabled_revision,
context,
map_store_error(error, "plugin"),
)
.await);
}
};
let prepared = if result.config_revision == candidate_revision
&& result.instance.revision == candidate_revision
{
prepared
} else {
current.config_revision = result.config_revision;
current
.instances
.retain(|item| item.id != result.instance.id);
current.instances.push(result.instance.clone());
apply_replacements(&mut current, replacements);
self.preparation.prepare(current).await?
};
self.preparation
.activate_state(&prepared, &result.instance)
.await?;
publish_committed(self.snapshots.as_ref(), result.config_revision).await?;
drop(migration_prepared);
drop(prepared);
Ok(result)
}
async fn recover_failed_state_transition(
&self,
transition_id: Option<&str>,
previous: &PluginInstance,
previous_state: &PluginStateConfiguration,
disabled_revision: Option<Revision>,
context: &MutationContext,
original_error: AdminError,
) -> AdminError {
if let Some(transition_id) = transition_id {
self.state.abort_transition(transition_id).await;
}
let Some(disabled_revision) = disabled_revision else {
return original_error;
};
let restored = async {
let mut snapshot = self
.store
.load_instances()
.await
.map_err(|error| map_store_error(error, "plugin"))?;
if snapshot.config_revision != disabled_revision {
return Err(AdminError::conflict(
"状态迁移失败;实例已保持停用,且配置已被其他操作修改",
));
}
let candidate_revision = next_revision(disabled_revision)?;
let mut restore = previous.clone();
restore.enabled = true;
restore.revision = candidate_revision;
snapshot.instances.retain(|item| item.id != restore.id);
snapshot.instances.push(restore.clone());
snapshot.config_revision = candidate_revision;
let prepared = self.preparation.prepare(snapshot.clone()).await?;
let mutation = self
.store
.save_instance_with_state(
restore,
disabled_revision,
PluginStateCommit {
configuration: previous_state.clone(),
transition_id: None,
},
context,
)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
let prepared = if mutation.config_revision == candidate_revision
&& mutation.instance.revision == candidate_revision
{
prepared
} else {
snapshot.config_revision = mutation.config_revision;
snapshot
.instances
.retain(|item| item.id != mutation.instance.id);
snapshot.instances.push(mutation.instance.clone());
self.preparation.prepare(snapshot).await?
};
self.preparation
.activate_state(&prepared, &mutation.instance)
.await?;
publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?;
Ok::<(), AdminError>(())
}
.await;
match restored {
Ok(()) => original_error,
Err(error) => {
tracing::warn!(
error_kind = ?error.kind(),
"plugin state migration failed and the previous instance could not be restored"
);
AdminError::conflict("状态迁移失败;为避免覆盖并发配置,插件实例已保持停用")
}
}
}
pub async fn delete_instance(
&self,
id: &str,
context: &MutationContext,
) -> Result<Revision, AdminError> {
let snapshot = self
.store
.load_instances()
.await
.map_err(|e| map_store_error(e, "plugin"))?;
let instance = snapshot
.instances
.iter()
.find(|instance| instance.id == id)
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
if instance.enabled {
return Err(AdminError::conflict("请先停用插件实例"));
}
let revision = self
.store
.delete_instance(id, snapshot.config_revision, context)
.await
.map_err(|e| map_store_error(e, "plugin"))?;
publish_committed(self.snapshots.as_ref(), revision).await?;
Ok(revision)
}
/// 紧急管理修复不要求损坏插件成功准备;提交后沿用现有发布与暂停合同。
pub async fn disable_instance(
&self,
id: &str,
context: &MutationContext,
) -> Result<PluginInstanceMutation, AdminError> {
let PluginInstanceSnapshot {
config_revision,
instances,
} = self
.store
.load_instances()
.await
.map_err(|e| map_store_error(e, "plugin"))?;
let mut instance = instances
.into_iter()
.find(|instance| instance.id == id)
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
instance.enabled = false;
let result = self
.store
.save_instance(instance, config_revision, context)
.await
.map_err(|e| map_store_error(e, "plugin"))?;
publish_committed(self.snapshots.as_ref(), result.config_revision).await?;
Ok(result)
}
}
fn apply_replacements(
snapshot: &mut PluginInstanceSnapshot,
replacements: &[PluginInstanceReplacement],
) {
for instance in &mut snapshot.instances {
if replacements.iter().any(|item| item.id == instance.id) {
instance.enabled = false;
instance.revision = snapshot.config_revision;
}
}
}
fn same_creation_request(existing: &PluginInstance, input: &ConfigurePluginInstance) -> bool {
let same_secrets = input
.secrets
.as_ref()
.map_or(existing.secrets.is_empty(), |secrets| {
secrets.len() == existing.secrets.len()
&& secrets.iter().all(|(key, value)| {
existing
.secrets
.get(key)
.is_some_and(|current| current.expose_secret() == value.expose_secret())
})
});
existing.name == input.name.trim()
&& existing.artifact_sha256 == input.artifact_sha256
&& existing.enabled == input.enabled
&& existing.configuration == input.configuration
&& existing.bindings == input.bindings
&& same_secrets
}
fn validate_input(instance: &PluginInstance) -> Result<(), AdminError> {
if instance.name.is_empty()
|| instance.name.len() > 128
|| instance.name.chars().any(char::is_control)
|| instance.artifact_sha256.len() != 64
|| !instance
.artifact_sha256
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|| !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("插件实例配置不合法或超过大小限制"));
}
use secrecy::ExposeSecret as _;
if instance
.secrets
.iter()
.any(|(key, value)| key.len() > 64 || value.expose_secret().len() > 8192)
|| instance
.secrets
.values()
.map(|value| value.expose_secret().len())
.sum::<usize>()
> 48 * 1024
{
return Err(AdminError::invalid("插件敏感配置超过大小限制"));
}
if instance.bindings.iter().any(|binding| {
binding.contribution.is_empty()
|| binding.contribution.len() > 128
|| !binding
.contribution
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-'))
|| binding.client_key_ids.len() > 256
|| binding.account_group_ids.len() > 256
|| binding.client_key_ids.iter().collect::<BTreeSet<_>>().len()
!= binding.client_key_ids.len()
|| binding
.account_group_ids
.iter()
.collect::<BTreeSet<_>>()
.len()
!= binding.account_group_ids.len()
|| binding
.client_key_ids
.iter()
.any(|id| ClientApiKeyId::new(id.clone()).is_err())
|| binding
.account_group_ids
.iter()
.any(|id| AccountGroupId::new(id.clone()).is_err())
|| binding.identity_bindings.len() > 256
|| binding
.identity_bindings
.iter()
.map(|identity| &identity.principal)
.collect::<BTreeSet<_>>()
.len()
!= binding.identity_bindings.len()
|| binding.identity_bindings.iter().any(|identity| {
identity.principal.trim() != identity.principal
|| identity.principal.is_empty()
|| identity.principal.len() > 256
|| identity.principal.chars().any(char::is_control)
|| ClientApiKeyId::new(identity.client_key_id.clone()).is_err()
})
}) {
return Err(AdminError::invalid("插件绑定的范围或入口认证身份不合法"));
}
Ok(())
}
fn next_revision(revision: Revision) -> Result<Revision, AdminError> {
Revision::new(
revision
.get()
.checked_add(1)
.ok_or_else(|| AdminError::internal("配置版本已耗尽"))?,
)
.map_err(|_| AdminError::internal("配置版本不合法"))
}
@@ -0,0 +1,145 @@
use std::sync::Arc;
use gateway_core::runtime::{RuntimeSnapshotHandle, extensions::ExtensionSetReference};
use crate::{
model::{
AdminError,
plugins::{
instances::PluginInstance,
management::{
PluginManagementRequest, PluginManagementResponse, PluginManagementTarget,
PluginManagementView,
},
},
},
ports::{plugin_management::PluginManagement, plugins::PluginStore},
};
/// 菜单是发布视图的投影;每次业务动作仍复核持久实例,旧页面不能保留已撤销权限。
pub struct PluginManagementService {
runtime: Arc<dyn PluginManagement>,
store: Arc<dyn PluginStore>,
published: RuntimeSnapshotHandle,
}
impl PluginManagementService {
/// 页面发起模型请求前复核持久目标与当前发布代次;长响应可重复调用以收敛撤销。
pub async fn authorize_models(
&self,
target: &PluginManagementTarget,
) -> Result<(), AdminError> {
let reference = self.authorize(target).await?;
self.runtime.authorize_models(&reference, target).await
}
pub async fn start_callback(
&self,
target: &PluginManagementTarget,
command: crate::model::plugins::management::StartPluginManagementCallback,
context: &crate::model::auth::AdminRequestContext,
) -> Result<crate::model::plugins::management::PluginManagementCallbackTicket, AdminError> {
let reference = self.authorize(target).await?;
self.runtime
.start_callback(&reference, target, command, context)
.await
}
pub async fn callback(
&self,
target: &PluginManagementTarget,
state: &str,
request: PluginManagementRequest,
) -> Result<PluginManagementResponse, AdminError> {
let reference = self.authorize(target).await?;
self.runtime
.callback(&reference, target, state, request)
.await
}
#[must_use]
pub fn new(
runtime: Arc<dyn PluginManagement>,
store: Arc<dyn PluginStore>,
published: RuntimeSnapshotHandle,
) -> Self {
Self {
runtime,
store,
published,
}
}
pub async fn views(&self) -> Result<Vec<PluginManagementView>, AdminError> {
let Some(reference) = self.reference()? else {
return Ok(Vec::new());
};
let mut views = self.runtime.views(&reference).await?;
let snapshot = self
.store
.load_instances()
.await
.map_err(|_| AdminError::unavailable("无法复核插件页面授权"))?;
views.retain(|view| {
snapshot
.instances
.iter()
.any(|instance| matches(instance, &view.target))
});
Ok(views)
}
pub async fn resource(
&self,
target: &PluginManagementTarget,
path: &str,
public: bool,
) -> Result<PluginManagementResponse, AdminError> {
let reference = self.authorize(target).await?;
self.runtime
.resource(&reference, target, path, public)
.await
}
pub async fn handle(
&self,
target: &PluginManagementTarget,
request: PluginManagementRequest,
) -> Result<PluginManagementResponse, AdminError> {
let reference = self.authorize(target).await?;
self.runtime.handle(&reference, target, request).await
}
fn reference(&self) -> Result<Option<ExtensionSetReference>, AdminError> {
self.published
.acquire()
.map(|snapshot| snapshot.extensions().cloned())
.map_err(|_| AdminError::unavailable("插件发布视图暂不可用"))
}
async fn authorize(
&self,
target: &PluginManagementTarget,
) -> Result<ExtensionSetReference, AdminError> {
let reference = self
.reference()?
.ok_or_else(|| AdminError::not_found("插件管理扩展不存在"))?;
let current = self
.store
.management_target_is_current(target)
.await
.map_err(|_| AdminError::unavailable("无法复核插件管理授权"))?;
if !current {
return Err(AdminError::conflict("插件版本或授权已变化,请刷新页面"));
}
Ok(reference)
}
}
fn matches(instance: &PluginInstance, target: &PluginManagementTarget) -> bool {
instance.enabled
&& instance.trusted_process
&& instance.id == target.instance_id
&& instance.artifact_sha256 == target.artifact_sha256
&& instance.revision.get() == target.revision
}
@@ -0,0 +1,10 @@
mod artifacts;
mod distribution;
mod instances;
mod management;
pub(crate) mod official;
mod state;
mod versions;
pub use artifacts::{PluginDistributionPorts, PluginsService};
pub use management::PluginManagementService;
@@ -0,0 +1,362 @@
use std::{collections::BTreeSet, path::Path};
use serde::Deserialize;
use crate::{
model::{
AdminError, AdminErrorKind, MutationActor, MutationContext,
plugins::{
InspectedPluginArtifact, PluginHostCompatibility, PluginSource,
official::{OfficialPluginImport, OfficialPluginReleaseIdentity},
},
},
ports::plugin_release::{
OfficialPluginReleaseFiles, OfficialPluginReleaseReadError,
OfficialPluginReleaseReadErrorKind,
},
};
use super::PluginsService;
const MANIFEST_SCHEMA_VERSION: u32 = 1;
const MAXIMUM_MANIFEST_BYTES: usize = 256 * 1024;
const MAXIMUM_PLUGINS: usize = 64;
const MAXIMUM_ARTIFACTS_PER_PLUGIN: usize = 8;
const MAXIMUM_VERSION_BYTES: usize = 128;
const MAXIMUM_PUBLISHER_BYTES: usize = 256;
const MAXIMUM_STABILITY_BYTES: usize = 32;
const MAXIMUM_ARCHIVE_NAME_BYTES: usize = 255;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SealedReleaseManifest {
schema_version: u32,
sealed: bool,
gateway_version: String,
gateway_git_sha: String,
plugin_host: PluginHostCompatibility,
plugins: Vec<OfficialPlugin>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct OfficialPlugin {
id: String,
version: String,
publisher: String,
stability: String,
artifacts: Vec<OfficialArtifact>,
}
#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct OfficialArtifact {
release_os: String,
release_architecture: String,
target: String,
os: String,
architecture: String,
archive: String,
sha256: String,
}
struct ValidatedOfficialArtifact {
inspected: InspectedPluginArtifact,
}
#[derive(Clone, Copy)]
struct Platform {
release_os: &'static str,
release_architecture: &'static str,
target: &'static str,
runtime_os: &'static str,
runtime_architecture: &'static str,
}
impl PluginsService {
/// 从与宿主发行物同信任边界的封口清单导入官方包。
///
/// 所有清单项和包体会先完成静态校验,再进入与上传/远程安装相同的持久化用例。
pub async fn import_official_release(
&self,
files: &dyn OfficialPluginReleaseFiles,
identity: &OfficialPluginReleaseIdentity,
context: &MutationContext,
) -> Result<OfficialPluginImport, AdminError> {
if !matches!(context.actor, MutationActor::System) {
return Err(AdminError::new(
AdminErrorKind::Forbidden,
"官方插件只能由宿主发行导入",
));
}
let Some(bytes) = files.manifest().await.map_err(map_read_error)? else {
return Ok(OfficialPluginImport {
artifacts: 0,
config_revision: None,
});
};
let ValidatedManifest {
gateway_version,
plugins,
plugins_len,
} = validate_manifest(&bytes, identity)?;
let platform = current_platform()
.ok_or_else(|| AdminError::invalid("当前平台不支持官方插件发行物"))?;
let mut validated = Vec::with_capacity(plugins.len());
// 先完成整份清单的静态检查;持久化故障后的已提交前缀由下次启动幂等补齐。
for plugin in plugins {
let artifact = select_artifact(&plugin, platform)?;
let archive = files
.artifact(&artifact.archive)
.await
.map_err(map_read_error)?;
let inspected = self
.inspector
.inspect(archive, Some(artifact.sha256.clone()))
.await?;
validate_inspected(&plugin, &artifact, &inspected)?;
validated.push(ValidatedOfficialArtifact { inspected });
}
let mut revision = None;
for artifact in validated {
let mutation = self
.persist(
artifact.inspected,
PluginSource::Builtin {
release: gateway_version.clone(),
},
context,
)
.await?;
revision = Some(mutation.config_revision);
}
Ok(OfficialPluginImport {
artifacts: plugins_len,
config_revision: revision,
})
}
}
struct ValidatedManifest {
gateway_version: String,
plugins: Vec<OfficialPlugin>,
plugins_len: usize,
}
fn validate_manifest(
bytes: &[u8],
identity: &OfficialPluginReleaseIdentity,
) -> Result<ValidatedManifest, AdminError> {
let manifest = parse_manifest(bytes)?;
if manifest.gateway_version != identity.gateway_version
|| manifest.gateway_git_sha != identity.gateway_git_sha
{
return Err(invalid_manifest());
}
let plugins_len = manifest.plugins.len();
Ok(ValidatedManifest {
gateway_version: manifest.gateway_version,
plugins: manifest.plugins,
plugins_len,
})
}
pub(crate) fn update_compatibility(
bytes: &[u8],
target_version: &str,
) -> Result<PluginHostCompatibility, AdminError> {
let manifest = parse_manifest(bytes)?;
(manifest.gateway_version == target_version)
.then_some(manifest.plugin_host)
.ok_or_else(invalid_manifest)
}
fn parse_manifest(bytes: &[u8]) -> Result<SealedReleaseManifest, AdminError> {
if bytes.len() > MAXIMUM_MANIFEST_BYTES {
return Err(invalid_manifest());
}
let manifest: SealedReleaseManifest =
serde_json::from_slice(bytes).map_err(|_| invalid_manifest())?;
if manifest.schema_version != MANIFEST_SCHEMA_VERSION
|| !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());
}
let mut plugin_ids = BTreeSet::new();
let mut archives = BTreeSet::new();
for plugin in &manifest.plugins {
if !crate::model::plugins::valid_plugin_id(&plugin.id)
|| !valid_text(&plugin.version, MAXIMUM_VERSION_BYTES)
|| !valid_text(&plugin.publisher, MAXIMUM_PUBLISHER_BYTES)
|| plugin.id.split_once('.').map(|(publisher, _)| publisher)
!= Some(plugin.publisher.as_str())
|| !valid_text(&plugin.stability, MAXIMUM_STABILITY_BYTES)
|| !plugin_ids.insert(plugin.id.as_str())
|| plugin.artifacts.is_empty()
|| plugin.artifacts.len() > MAXIMUM_ARTIFACTS_PER_PLUGIN
{
return Err(invalid_manifest());
}
let mut platforms = BTreeSet::new();
for artifact in &plugin.artifacts {
if !valid_artifact(plugin, artifact)
|| !platforms.insert((
artifact.release_os.as_str(),
artifact.release_architecture.as_str(),
))
|| !archives.insert(artifact.archive.as_str())
{
return Err(invalid_manifest());
}
}
}
Ok(manifest)
}
fn valid_artifact(plugin: &OfficialPlugin, artifact: &OfficialArtifact) -> bool {
supported_platform(artifact).is_some()
&& valid_archive_name(&artifact.archive)
&& valid_sha256(&artifact.sha256)
&& artifact.archive
== format!(
"{}-{}-{}-{}.tar.gz",
plugin.id, plugin.version, artifact.os, artifact.architecture
)
}
fn supported_platform(artifact: &OfficialArtifact) -> Option<Platform> {
SUPPORTED_PLATFORMS.iter().copied().find(|platform| {
artifact.release_os == platform.release_os
&& artifact.release_architecture == platform.release_architecture
&& artifact.target == platform.target
&& artifact.os == platform.runtime_os
&& artifact.architecture == platform.runtime_architecture
})
}
fn select_artifact(
plugin: &OfficialPlugin,
current: Platform,
) -> Result<OfficialArtifact, AdminError> {
let mut matching = plugin.artifacts.iter().filter(|artifact| {
artifact.release_os == current.release_os
&& artifact.release_architecture == current.release_architecture
&& artifact.target == current.target
&& artifact.os == current.runtime_os
&& artifact.architecture == current.runtime_architecture
});
let selected = matching.next().cloned().ok_or_else(invalid_manifest)?;
if matching.next().is_some() {
return Err(invalid_manifest());
}
Ok(selected)
}
fn validate_inspected(
plugin: &OfficialPlugin,
artifact: &OfficialArtifact,
inspected: &InspectedPluginArtifact,
) -> Result<(), AdminError> {
if inspected.metadata.plugin_id != plugin.id
|| inspected.metadata.publisher != plugin.publisher
|| inspected.metadata.version != plugin.version
|| inspected.metadata.sha256 != artifact.sha256
|| !inspected
.metadata
.platforms
.iter()
.any(|value| value == &format!("{}-{}", artifact.os, artifact.architecture))
{
return Err(AdminError::invalid("官方插件包身份与发行清单不一致"));
}
Ok(())
}
fn valid_text(value: &str, maximum: usize) -> bool {
!value.is_empty()
&& value.len() <= maximum
&& value.trim() == value
&& !value.chars().any(char::is_control)
}
fn valid_git_sha(value: &str) -> bool {
value.len() == 40
&& value
.bytes()
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
}
fn valid_sha256(value: &str) -> bool {
value.len() == 64
&& value
.bytes()
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
}
fn valid_archive_name(value: &str) -> bool {
value.len() <= MAXIMUM_ARCHIVE_NAME_BYTES
&& value.ends_with(".tar.gz")
&& Path::new(value).file_name().and_then(|name| name.to_str()) == Some(value)
&& Path::new(value).components().count() == 1
&& !value.chars().any(char::is_control)
}
fn map_read_error(error: OfficialPluginReleaseReadError) -> AdminError {
match error.kind() {
OfficialPluginReleaseReadErrorKind::Invalid
| OfficialPluginReleaseReadErrorKind::NotFound => invalid_manifest(),
OfficialPluginReleaseReadErrorKind::Unavailable => {
AdminError::unavailable("官方插件发行物暂不可读取")
}
}
}
fn invalid_manifest() -> AdminError {
AdminError::invalid("官方插件发行清单不合法或不属于当前宿主发行物")
}
fn current_platform() -> Option<Platform> {
SUPPORTED_PLATFORMS.iter().copied().find(|platform| {
platform.runtime_os == std::env::consts::OS
&& platform.runtime_architecture == std::env::consts::ARCH
&& target_environment_matches(platform.target)
})
}
fn target_environment_matches(target: &str) -> bool {
match target {
"x86_64-unknown-linux-gnu" | "aarch64-unknown-linux-gnu" => cfg!(target_env = "gnu"),
"aarch64-apple-darwin" => cfg!(target_os = "macos"),
_ => false,
}
}
const SUPPORTED_PLATFORMS: &[Platform] = &[
Platform {
release_os: "linux",
release_architecture: "amd64",
target: "x86_64-unknown-linux-gnu",
runtime_os: "linux",
runtime_architecture: "x86_64",
},
Platform {
release_os: "linux",
release_architecture: "arm64",
target: "aarch64-unknown-linux-gnu",
runtime_os: "linux",
runtime_architecture: "aarch64",
},
Platform {
release_os: "darwin",
release_architecture: "arm64",
target: "aarch64-apple-darwin",
runtime_os: "macos",
runtime_architecture: "aarch64",
},
];
@@ -0,0 +1,73 @@
use std::sync::Arc;
use crate::{
model::{
AdminError, Revision,
plugins::state::{PluginStateConfiguration, PluginStateTransition},
},
ports::plugins::{PluginStateStore, PluginStateStoreError, PluginStateStoreErrorKind},
};
pub(super) struct PluginStateService {
store: Arc<dyn PluginStateStore>,
}
impl PluginStateService {
#[must_use]
pub(super) fn new(store: Arc<dyn PluginStateStore>) -> Self {
Self { store }
}
pub(super) async fn transition_required(
&self,
instance_id: &str,
target: &PluginStateConfiguration,
) -> Result<bool, AdminError> {
self.store
.transition_required(instance_id, target)
.await
.map_err(map_state_error)
}
pub(super) async fn begin_transition(
&self,
instance_id: &str,
expected_instance_revision: Revision,
artifact_sha256: &str,
target: PluginStateConfiguration,
) -> Result<PluginStateTransition, AdminError> {
self.store
.begin_transition(
instance_id,
expected_instance_revision,
artifact_sha256,
target,
)
.await
.map_err(map_state_error)
}
pub(super) async fn abort_transition(&self, transition_id: &str) {
if let Err(error) = self.store.abort_transition(transition_id).await {
tracing::warn!(
error_kind = ?error.kind(),
"failed to discard plugin state staging generation"
);
}
}
}
fn map_state_error(error: PluginStateStoreError) -> AdminError {
match error.kind() {
PluginStateStoreErrorKind::Invalid => AdminError::invalid("插件状态声明不合法"),
PluginStateStoreErrorKind::NotFound => AdminError::not_found("插件状态实例不存在"),
PluginStateStoreErrorKind::Conflict => {
AdminError::conflict("插件状态版本不兼容或已发生并发变更")
}
PluginStateStoreErrorKind::Quota => AdminError::conflict("插件状态迁移超过声明配额"),
PluginStateStoreErrorKind::PermissionDenied => {
AdminError::conflict("插件状态 fence 已失效")
}
PluginStateStoreErrorKind::Unavailable => AdminError::unavailable("插件状态存储暂不可用"),
}
}
@@ -0,0 +1,318 @@
use super::{
PluginsService,
artifacts::{configuration_defaults, default_bindings},
};
use crate::{
model::{
AdminError, MutationContext,
plugins::{
PluginArtifactMetadata,
instances::{
ConfigurePluginInstance, PluginInstance, PluginInstanceMutation,
PluginRollbackPlan, PluginRollbackTarget, PluginVersionConfiguration,
PluginVersionPlan, RollbackPluginInstance,
},
},
},
use_case::map_store_error,
};
impl PluginsService {
pub async fn rollback_instance(
&self,
id: &str,
input: RollbackPluginInstance,
context: &MutationContext,
) -> Result<PluginInstanceMutation, AdminError> {
let snapshot = self
.store
.load_instances()
.await
.map_err(|error| map_store_error(error, "plugin"))?;
let existing = snapshot
.instances
.iter()
.find(|instance| instance.id == id)
.cloned()
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
if existing.revision.get() != input.expected_revision {
return Err(AdminError::conflict("插件实例已变更,请重新确认回滚目标"));
}
let artifacts = self.list().await?;
let current = artifacts
.iter()
.find(|artifact| artifact.metadata.sha256 == existing.artifact_sha256)
.ok_or_else(|| AdminError::not_found("当前插件制品不存在"))?;
let target = artifacts
.iter()
.find(|artifact| artifact.metadata.sha256 == input.artifact_sha256)
.ok_or_else(|| AdminError::not_found("回滚目标制品尚未安装"))?;
if rollback_version(&current.metadata, &target.metadata)?.is_none() {
return Err(AdminError::invalid("回滚目标必须是同一插件的较早版本"));
}
let saved = self
.store
.load_version_configuration(id, &input.artifact_sha256)
.await
.map_err(|error| map_store_error(error, "plugin"))?
.ok_or_else(|| {
AdminError::invalid("该版本没有可恢复的配置,请通过版本切换检查当前设置")
})?;
let configuration = ConfigurePluginInstance {
creation_id: None,
expected_revision: None,
replace_instances: Vec::new(),
name: existing.name.clone(),
artifact_sha256: input.artifact_sha256,
enabled: existing.enabled,
configuration: saved.configuration,
secrets: Some(saved.secrets),
bindings: saved.bindings,
};
// 沿用同一份快照的 CAS 和状态迁移流程,不能在检查目标后重新读取并覆盖并发配置。
self.configure_from_snapshot(snapshot, Some(existing), configuration, context)
.await
}
pub async fn rollback_plan(&self, id: &str) -> Result<PluginRollbackPlan, AdminError> {
let snapshot = self
.store
.load_instances()
.await
.map_err(|error| map_store_error(error, "plugin"))?;
let instance = snapshot
.instances
.iter()
.find(|instance| instance.id == id)
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
let artifacts = self.list().await?;
let current = artifacts
.iter()
.find(|artifact| artifact.metadata.sha256 == instance.artifact_sha256)
.ok_or_else(|| AdminError::not_found("当前插件制品不存在"))?;
let saved_versions = self
.store
.configuration_versions(id)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
let mut targets = Vec::new();
for artifact in artifacts.iter().filter(|artifact| {
artifact.accepted_at.is_some() && saved_versions.contains(&artifact.metadata.sha256)
}) {
if let Some(version) = rollback_version(&current.metadata, &artifact.metadata)? {
targets.push((
version,
PluginRollbackTarget {
artifact_sha256: artifact.metadata.sha256.clone(),
version: artifact.metadata.version.clone(),
platforms: artifact.metadata.platforms.clone(),
},
));
}
}
targets.sort_by(|(left, left_target), (right, right_target)| {
right.cmp_precedence(left).then_with(|| {
left_target
.artifact_sha256
.cmp(&right_target.artifact_sha256)
})
});
Ok(PluginRollbackPlan {
instance_revision: instance.revision.get(),
current_version: current.metadata.version.clone(),
targets: targets.into_iter().map(|(_, target)| target).collect(),
})
}
pub async fn version_plan(
&self,
id: &str,
digest: &str,
) -> Result<PluginVersionPlan, AdminError> {
let snapshot = self
.store
.load_instances()
.await
.map_err(|error| map_store_error(error, "plugin"))?;
let instance = snapshot
.instances
.iter()
.find(|instance| instance.id == id)
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
let (configuration, restored) = self.version_configuration(instance, digest).await?;
Ok(PluginVersionPlan {
instance_revision: instance.revision.get(),
artifact_sha256: digest.to_owned(),
configuration: configuration.configuration,
secret_fields: configuration.secrets.into_keys().collect(),
bindings: configuration.bindings,
restored,
})
}
pub async fn switch_instance_version(
&self,
id: &str,
input: RollbackPluginInstance,
context: &MutationContext,
) -> Result<PluginInstanceMutation, AdminError> {
let snapshot = self
.store
.load_instances()
.await
.map_err(|error| map_store_error(error, "plugin"))?;
let existing = snapshot
.instances
.iter()
.find(|instance| instance.id == id)
.cloned()
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
if existing.revision.get() != input.expected_revision {
return Err(AdminError::conflict("插件设置已变更,请刷新后重试"));
}
if existing.artifact_sha256 == input.artifact_sha256 {
return Err(AdminError::invalid("已在使用此版本"));
}
let (configuration, _) = self
.version_configuration(&existing, &input.artifact_sha256)
.await?;
let request = ConfigurePluginInstance {
creation_id: None,
expected_revision: Some(existing.revision.get()),
replace_instances: Vec::new(),
name: existing.name.clone(),
artifact_sha256: input.artifact_sha256,
enabled: existing.enabled,
configuration: configuration.configuration,
secrets: Some(configuration.secrets),
bindings: configuration.bindings,
};
// 同一次读取的 CAS 覆盖草稿计算、准备与提交,不能覆盖并发设置。
self.configure_from_snapshot(snapshot, Some(existing), request, context)
.await
}
async fn version_configuration(
&self,
instance: &PluginInstance,
digest: &str,
) -> Result<(PluginVersionConfiguration, bool), AdminError> {
let artifacts = self
.store
.list_artifacts()
.await
.map_err(|error| map_store_error(error, "plugin"))?;
let current = artifacts
.iter()
.find(|artifact| artifact.metadata.sha256 == instance.artifact_sha256)
.ok_or_else(|| AdminError::not_found("当前插件版本不存在"))?;
let target = artifacts
.iter()
.find(|artifact| artifact.metadata.sha256 == digest && artifact.accepted_at.is_some())
.ok_or_else(|| AdminError::not_found("目标插件版本尚未安装"))?;
if current.metadata.plugin_id != target.metadata.plugin_id {
return Err(AdminError::invalid("只能切换同一插件的版本"));
}
if digest != instance.artifact_sha256
&& let Some(saved) = self
.store
.load_version_configuration(&instance.id, digest)
.await
.map_err(|error| map_store_error(error, "plugin"))?
{
return Ok((saved, true));
}
let mut configuration = instance.configuration.clone();
// 只补充缺失的默认值,保留显式值及未知字段,让不兼容项由校验反馈给用户。
merge_missing_defaults(&mut configuration, configuration_defaults(&target.metadata));
Ok((
PluginVersionConfiguration {
configuration,
secrets: instance.secrets.clone(),
bindings: version_bindings(instance, &current.metadata, &target.metadata),
},
false,
))
}
}
fn version_bindings(
instance: &PluginInstance,
current: &PluginArtifactMetadata,
target: &PluginArtifactMetadata,
) -> Vec<crate::model::plugins::instances::PluginCapabilityBinding> {
let mut bindings = default_bindings(target);
// 已关闭的既有能力继续关闭,新声明能力才采用包默认值。
bindings.retain(|binding| {
let capability = target
.contributes
.iter()
.find(|(_, contribution)| contribution.id == binding.contribution)
.map(|(capability, _)| capability);
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)
})
});
for previous in &instance.bindings {
let Some((capability, _)) = current
.contributes
.iter()
.find(|(_, contribution)| contribution.id == previous.contribution)
else {
continue;
};
let Some(contribution) = target
.contributes
.get(capability)
.filter(|contribution| contribution.stages.contains(&previous.stage))
else {
continue;
};
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 = binding;
} else {
bindings.push(binding);
}
}
bindings
}
fn rollback_version(
current: &PluginArtifactMetadata,
target: &PluginArtifactMetadata,
) -> Result<Option<semver::Version>, AdminError> {
if current.plugin_id != target.plugin_id {
return Ok(None);
}
let current_version = semver::Version::parse(&current.version)
.map_err(|_| AdminError::invalid("当前插件版本无效"))?;
let target_version = semver::Version::parse(&target.version)
.map_err(|_| AdminError::invalid("回滚目标版本无效"))?;
Ok(target_version
.cmp_precedence(&current_version)
.is_lt()
.then_some(target_version))
}
fn merge_missing_defaults(current: &mut serde_json::Value, defaults: serde_json::Value) {
let (Some(current), serde_json::Value::Object(defaults)) = (current.as_object_mut(), defaults)
else {
return;
};
for (key, default) in defaults {
if let Some(value) = current.get_mut(&key) {
merge_missing_defaults(value, default);
} else {
current.insert(key, default);
}
}
}
@@ -56,10 +56,15 @@ pub trait ProxiesService: Send + Sync {
&self,
id: &str,
revision: Revision,
detect_location: bool,
context: &MutationContext,
) -> Result<ProxyRecord, AdminError>;
/// 探测未保存的连接地址,不写入代理记录或修改账号绑定。
async fn probe(&self, proxy: &OutboundProxy) -> Result<ProxyTestResult, AdminError>;
async fn probe(
&self,
proxy: &OutboundProxy,
detect_location: bool,
) -> Result<ProxyTestResult, AdminError>;
}
pub(crate) struct DefaultProxiesService {
@@ -186,6 +191,11 @@ impl ProxiesService for DefaultProxiesService {
.map(|location| location.normalized())
.transpose()
.map_err(|_| AdminError::invalid("代理位置不合法"))?;
command.test = if command.auto_location {
Some(self.probe(&command.proxy, true).await?)
} else {
None
};
let result = self
.store
.create(command, context)
@@ -206,6 +216,24 @@ impl ProxiesService for DefaultProxiesService {
.map(|location| location.map(|value| value.normalized()).transpose())
.transpose()
.map_err(|_| AdminError::invalid("代理位置不合法"))?;
// 只在开启自动模式或连接地址变化时检测,普通编辑不刷新已识别位置。
command.test = None;
if command.auto_location.is_some() || command.proxy.is_some() {
let current = self
.store
.get(&command.id)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
if current.revision != command.revision {
return Err(AdminError::conflict("代理已被修改,请刷新后重试"));
}
let proxy = command.proxy.as_ref().unwrap_or(&current.proxy);
if command.auto_location.unwrap_or(current.auto_location)
&& (!current.auto_location || proxy != &current.proxy)
{
command.test = Some(self.probe(proxy, true).await?);
}
}
let result = self
.store
.update(command, context)
@@ -230,17 +258,22 @@ impl ProxiesService for DefaultProxiesService {
Ok(result)
}
async fn probe(&self, proxy: &OutboundProxy) -> Result<ProxyTestResult, AdminError> {
async fn probe(
&self,
proxy: &OutboundProxy,
detect_location: bool,
) -> Result<ProxyTestResult, AdminError> {
let _permit = self.test_slots.try_acquire().map_err(|_| {
AdminError::new(AdminErrorKind::RateLimited, "代理测试繁忙,请稍后重试")
})?;
Ok(self.probe.test(proxy).await)
Ok(self.probe.test(proxy, detect_location).await)
}
async fn test(
&self,
id: &str,
revision: Revision,
detect_location: bool,
context: &MutationContext,
) -> Result<ProxyRecord, AdminError> {
let _permit = self.test_slots.try_acquire().map_err(|_| {
@@ -254,11 +287,20 @@ impl ProxiesService for DefaultProxiesService {
if record.revision != revision {
return Err(AdminError::conflict("代理已被修改,请刷新后重新测试"));
}
let result = self.probe.test(&record.proxy).await;
self.store
// 手动解析只为本次测试请求位置,不改变自动跟随的持久配置。
let result = self
.probe
.test(&record.proxy, record.auto_location || detect_location)
.await;
let mutation = self
.store
.record_test(id, revision, result, context)
.await
.map_err(|error| map_store_error(error, "proxy"))
.map_err(|error| map_store_error(error, "proxy"))?;
if record.effective_location() != mutation.record.effective_location() {
publish_committed(self.snapshot.as_ref(), mutation.config_revision).await?;
}
Ok(mutation.record)
}
}
@@ -78,7 +78,7 @@ impl DefaultSettingsService {
fn profile_provider(
&self,
provider: &str,
) -> Result<Arc<dyn crate::ports::provider::ProviderAdmin>, AdminError> {
) -> Result<std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>, AdminError> {
let kind = gateway_core::routing::ProviderKind::new(provider)
.map_err(|_| AdminError::invalid("Provider 不合法"))?;
self.providers
@@ -129,6 +129,14 @@ impl SettingsService for DefaultSettingsService {
{
return Err(AdminError::invalid("请选择 1 至 10000 个模型"));
}
let providers = self.providers.pricing_catalog();
if command
.models
.keys()
.any(|provider| !providers.contains_key(provider))
{
return Err(AdminError::invalid("Provider 不支持价目管理"));
}
let mut current = self.pricing_source.fetch().await?;
if current != command.preview {
return Err(AdminError::invalid(
@@ -262,8 +270,8 @@ impl SettingsService for DefaultSettingsService {
&self,
provider: &str,
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
let mut options = self
.profile_provider(provider)?
let profile_provider = self.profile_provider(provider)?;
let mut options = profile_provider
.client_profile_options()
.map_err(|error| super::map_provider_error(error, "client profile"))?
.into_inner();
@@ -272,6 +280,7 @@ impl SettingsService for DefaultSettingsService {
.await?
.client_profile(provider)
.cloned()
.or_else(|| profile_provider.default_client_profile())
.ok_or_else(|| AdminError::internal("通用客户端身份尚未初始化"))?;
options.insert(
"globalConfiguration".to_owned(),
@@ -295,6 +304,7 @@ impl SettingsService for DefaultSettingsService {
.await?
.client_profile(provider)
.cloned()
.or_else(|| profile_provider.default_client_profile())
.ok_or_else(|| AdminError::internal("通用客户端身份尚未初始化"))?;
(&global, "global")
};
@@ -316,16 +326,38 @@ impl SettingsService for DefaultSettingsService {
async fn replace(
&self,
context: &MutationContext,
command: ReplaceRuntimeSettings,
mut command: ReplaceRuntimeSettings,
) -> Result<RuntimeSettings, AdminError> {
validate_settings(&command)?;
for (provider, profile) in [
("openai", &command.openai_client_profile),
("xai", &command.xai_client_profile),
] {
if let Some(profile) = profile {
self.profile_provider(provider)?
.preview_client_profile(profile)
if command
.request_profile_updates
.values()
.any(Option::is_some)
{
let current = self
.store
.load_runtime_settings()
.await
.map_err(|error| map_store_error(error, "runtime settings"))?;
command.request_profile_updates.retain(|provider, update| {
!update
.as_ref()
.is_some_and(|profile| current.request_profiles.get(provider) == Some(profile))
});
}
if command
.request_profile_updates
.values()
.any(Option::is_some)
{
let providers = &self.providers;
for (provider, profile) in &command.request_profile_updates {
let Some(profile) = profile else {
continue;
};
providers
.require(provider)
.and_then(|provider| provider.preview_client_profile(profile))
.map_err(|error| super::map_provider_error(error, "client profile"))?;
}
}
@@ -381,7 +413,6 @@ fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError>
&& isize::try_from(command.responses_max_decompressed_body_bytes).is_ok()
&& command.refresh_margin_seconds > 0
&& command.refresh_concurrency > 0
&& command.max_concurrent_per_account > 0
&& command.max_waiting_per_key <= 1_000
&& command.max_waiting_per_account <= 1_000
&& (1..=120).contains(&command.concurrency_wait_timeout_seconds)
@@ -391,6 +422,11 @@ fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError>
&& 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)
@@ -7,10 +7,14 @@ use async_trait::async_trait;
use crate::{
model::{
AdminError, AdminErrorKind,
system::{SystemOperationAccepted, SystemUpdateDetail, SystemUpdateStatus, SystemVersion},
system::{
SystemOperationAccepted, SystemUpdateChannel, SystemUpdateDetail, SystemUpdateStatus,
SystemVersion,
},
},
ports::system::{
SystemOperationError, SystemOperationErrorKind, SystemOperations, SystemUpdateEventStream,
SystemUpdatePreflight,
},
};
@@ -18,11 +22,16 @@ use crate::{
#[async_trait]
pub trait SystemService: Send + Sync {
async fn version(&self) -> Result<SystemVersion, AdminError>;
async fn update_detail(&self, refresh: bool) -> Result<SystemUpdateDetail, AdminError>;
async fn update_detail(
&self,
refresh: bool,
channel: Option<SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, AdminError>;
fn update_events(&self) -> SystemUpdateEventStream;
async fn perform_update(
&self,
target_version: Option<String>,
channel: Option<SystemUpdateChannel>,
) -> Result<SystemOperationAccepted, AdminError>;
async fn update_status(&self) -> Result<SystemUpdateStatus, AdminError>;
async fn rollback(&self) -> Result<SystemOperationAccepted, AdminError>;
@@ -32,12 +41,19 @@ pub trait SystemService: Send + Sync {
/// 保持 Host 能力窄边界的默认系统用例。
pub(crate) struct DefaultSystemService {
operations: Arc<dyn SystemOperations>,
preflight: Arc<dyn SystemUpdatePreflight>,
}
impl DefaultSystemService {
#[must_use]
pub(crate) const fn new(operations: Arc<dyn SystemOperations>) -> Self {
Self { operations }
pub(crate) fn new(
operations: Arc<dyn SystemOperations>,
preflight: Arc<dyn SystemUpdatePreflight>,
) -> Self {
Self {
operations,
preflight,
}
}
}
@@ -47,9 +63,13 @@ impl SystemService for DefaultSystemService {
self.operations.version().await.map_err(map_system_error)
}
async fn update_detail(&self, refresh: bool) -> Result<SystemUpdateDetail, AdminError> {
async fn update_detail(
&self,
refresh: bool,
channel: Option<SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, AdminError> {
self.operations
.update_detail(refresh)
.update_detail(refresh, channel)
.await
.map_err(map_system_error)
}
@@ -61,12 +81,13 @@ impl SystemService for DefaultSystemService {
async fn perform_update(
&self,
target_version: Option<String>,
channel: Option<SystemUpdateChannel>,
) -> Result<SystemOperationAccepted, AdminError> {
let target_version = target_version
.map(|version| version.trim().to_owned())
.filter(|version| !version.is_empty());
self.operations
.perform_update(target_version)
.perform_update(target_version, channel, Arc::clone(&self.preflight))
.await
.map_err(map_system_error)
}
@@ -79,7 +100,10 @@ impl SystemService for DefaultSystemService {
}
async fn rollback(&self) -> Result<SystemOperationAccepted, AdminError> {
self.operations.rollback().await.map_err(map_system_error)
self.operations
.rollback(Arc::clone(&self.preflight))
.await
.map_err(map_system_error)
}
async fn restart(&self) -> Result<SystemOperationAccepted, AdminError> {
@@ -1,195 +0,0 @@
//! xAI 管理资源的中立 ProviderAdmin 委托。
use std::sync::Arc;
use async_trait::async_trait;
use gateway_core::runtime::SnapshotControl;
use crate::{
model::{
AdminError,
provider_credentials::{
AuthorizationStarted, CompleteAuthorization, CredentialDeletion,
CredentialDeletionResult, CredentialImportCommit, CredentialImportResult,
CredentialMutationResult, ImportCredentials, PrepareCredentialImport,
StartAuthorization,
},
},
ports::{provider::ProviderAdmin, store::AccountStore},
};
use super::{
commit_authorization, delete_credentials, map_provider_error, map_store_error,
pending_authorization, publish_committed, publish_credentials_and_observe_quota,
validate_authorization_commit, validate_prepared_import,
};
/// xAI 固定管理路由消费的服务。
#[async_trait]
pub trait XaiService: Send + Sync {
async fn import_document(
&self,
command: ImportCredentials,
) -> Result<CredentialImportResult, AdminError>;
async fn start_authorization(
&self,
command: StartAuthorization,
) -> Result<AuthorizationStarted, AdminError>;
async fn complete_authorization(
&self,
command: CompleteAuthorization,
) -> Result<CredentialMutationResult, AdminError>;
async fn delete(
&self,
command: CredentialDeletion,
) -> Result<CredentialDeletionResult, AdminError>;
}
pub(crate) struct DefaultXaiService {
provider: Arc<dyn ProviderAdmin>,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
}
impl DefaultXaiService {
#[must_use]
pub(crate) fn new(
provider: Arc<dyn ProviderAdmin>,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Self {
Self {
provider,
accounts,
proxies,
snapshot,
}
}
}
#[async_trait]
impl XaiService for DefaultXaiService {
async fn import_document(
&self,
command: ImportCredentials,
) -> Result<CredentialImportResult, AdminError> {
let context = command.context;
let proxy_reservation = super::import_proxy_binding(
self.proxies.as_ref(),
command.outbound_proxy_id.as_deref(),
)
.await?;
let outbound_proxy = proxy_reservation
.as_ref()
.map(|reservation| reservation.binding.clone());
let prepared = self
.provider
.prepare_import(PrepareCredentialImport {
default_outbound_proxy: outbound_proxy
.as_ref()
.map(|binding| binding.proxy.clone()),
document: command.document,
})
.await
.map_err(|error| map_provider_error(error, "xAI credential import"))?;
validate_prepared_import(
self.provider.provider_kind(),
&prepared,
"xAI credential import",
)?;
let result = self
.accounts
.commit_credential_import(
CredentialImportCommit {
outbound_proxy,
prepared,
settings: command.settings,
},
&context,
)
.await
.map_err(|error| map_store_error(error, "xAI credential import"))?;
drop(proxy_reservation);
publish_credentials_and_observe_quota(
&self.provider,
self.snapshot.as_ref(),
result.config_revision,
&result.credential_ids,
&context.request_id,
)
.await?;
Ok(result)
}
async fn start_authorization(
&self,
command: StartAuthorization,
) -> Result<AuthorizationStarted, AdminError> {
let pending = pending_authorization(
self.accounts.as_ref(),
self.proxies.as_ref(),
self.provider.provider_kind(),
&command,
"xAI credential",
)
.await?;
self.provider
.start_authorization(pending)
.await
.map_err(|error| map_provider_error(error, "xAI authorization"))
}
async fn complete_authorization(
&self,
mut command: CompleteAuthorization,
) -> Result<CredentialMutationResult, AdminError> {
let context = command.context.clone();
let settings = command.settings.take();
let prepared = self
.provider
.complete_authorization(command)
.await
.map_err(|error| map_provider_error(error, "xAI authorization"))?;
let prepared = validate_authorization_commit(
self.provider.provider_kind(),
&context,
prepared,
"xAI authorization",
)
.await?;
let result = commit_authorization(
self.accounts.as_ref(),
prepared,
settings,
&context,
"xAI authorization",
)
.await?;
publish_credentials_and_observe_quota(
&self.provider,
self.snapshot.as_ref(),
result.config_revision,
std::slice::from_ref(&result.account_id),
&context.request_id,
)
.await?;
Ok(result)
}
async fn delete(
&self,
command: CredentialDeletion,
) -> Result<CredentialDeletionResult, AdminError> {
let result = delete_credentials(
self.accounts.as_ref(),
self.provider.as_ref(),
command,
"xAI credential",
)
.await?;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
Ok(result)
}
}
@@ -141,6 +141,22 @@ fn corresponding_actual_window_takes_priority_and_retains_its_duration() {
assert_eq!(week.estimated_tokens, Some(1_129));
}
#[test]
fn source_selection_keeps_provider_order_with_the_maximum_window_count() {
let windows = (0..64)
.map(|index| {
if index % 2 == 0 {
window(&format!("week-{index}"), 7)
} else {
window(&format!("month-{index}"), 30)
}
})
.collect();
let [week, month] = forecast(&quota(windows));
assert_eq!(week.source.unwrap().label, "week-0");
assert_eq!(month.source.unwrap().label, "month-1");
}
#[test]
fn short_or_model_specific_windows_are_not_account_capacity() {
let mut model = window("model", 7);
@@ -82,7 +82,7 @@ async fn group_query_service_enriches_only_current_page_members_with_runtime_fac
group.capacity,
AccountGroupCapacity {
used_slots: Some(2),
total_slots: 4,
total_slots: Some(4),
}
);
}
@@ -90,6 +90,40 @@ async fn group_query_service_enriches_only_current_page_members_with_runtime_fac
#[derive(Default)]
struct FakeGroupStore {
requested_groups: Mutex<Vec<String>>,
members: Option<Vec<AccountGroupMemberFact>>,
}
#[tokio::test]
async fn group_capacity_only_becomes_unlimited_for_available_unlimited_members() {
for (available_slots, unavailable_slots, expected) in
[(None, Some(3), None), (Some(4), None, Some(4))]
{
let mut available = member("acct_available", 4);
available.total_slots = available_slots;
let mut unavailable = member("acct_limited", 3);
unavailable.total_slots = unavailable_slots;
let groups = Arc::new(FakeGroupStore {
members: Some(vec![available, unavailable]),
..Default::default()
});
let service = AdminHarness::new()
.account_groups(groups)
.account_runtime(Arc::new(FakeRuntimeStore::default()))
.build()
.await;
let page = service
.account_groups()
.list(AccountGroupListQuery {
page: 1,
page_size: PageSize::new(20).expect("page size"),
search: None,
enabled: None,
})
.await
.expect("group capacity");
assert_eq!(page.items[0].capacity.total_slots, expected);
assert_eq!(page.items[0].capacity.used_slots, Some(2));
}
}
#[async_trait]
@@ -115,7 +149,10 @@ impl AccountGroupStore for FakeGroupStore {
.iter()
.map(|group_id| group_id.as_str().to_owned())
.collect();
Ok(vec![member("acct_available", 4), member("acct_limited", 3)])
Ok(self
.members
.clone()
.unwrap_or_else(|| vec![member("acct_available", 4), member("acct_limited", 3)]))
}
async fn create_account_group(
@@ -218,7 +255,7 @@ fn group_record() -> AccountGroupRecord {
},
capacity: AccountGroupCapacity {
used_slots: None,
total_slots: 0,
total_slots: Some(0),
},
usage: AccountGroupUsage {
today_usd: DecimalAmount::from_str("1").expect("today usage"),
@@ -242,7 +279,7 @@ fn member(account_id: &str, total_slots: u64) -> AccountGroupMemberFact {
last_error_reason: None,
last_error_message: None,
},
total_slots,
total_slots: Some(total_slots),
}
}
@@ -37,8 +37,8 @@ use gateway_admin::{
CredentialRotationCommit, PendingAuthorizationMutation, PrepareCredentialImport,
PrepareCredentialRefresh, PrepareCredentialRotation, PreparedAuthorizationCommit,
PreparedAuthorizationCredential, PreparedCredentialCreate, PreparedCredentialImport,
PreparedCredentialRotation, PreparedCredentialRotationFacts, ProviderDocument,
ProviderExport, ProviderExportCredentialInput, ProviderModels,
PreparedCredentialRotation, PreparedCredentialRotationFacts, PreparedPluginAccountSave,
ProviderDocument, ProviderExport, ProviderExportCredentialInput, ProviderModels,
ProviderProfileActivityInsights, ProviderProfileStatistics,
ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaRequest,
ProviderQuotaWindow, ProviderResetCreditResult, ProviderSubscription,
@@ -65,6 +65,7 @@ pub(super) type EventLog = Arc<Mutex<Vec<&'static str>>>;
pub(super) struct FakeProviderAdmin {
kind: ProviderKind,
capabilities: Mutex<gateway_admin::model::accounts::ProviderAccountCapabilities>,
events: EventLog,
failure: Mutex<Option<ProviderAdminError>>,
import_gate: Mutex<Option<Arc<tokio::sync::Semaphore>>>,
@@ -89,6 +90,7 @@ impl FakeProviderAdmin {
pub(super) fn new(kind: &str, events: EventLog) -> Arc<Self> {
Arc::new(Self {
kind: ProviderKind::new(kind).expect("provider kind"),
capabilities: Mutex::default(),
events,
failure: Mutex::new(None),
import_gate: Mutex::new(None),
@@ -244,6 +246,7 @@ impl FakeProviderAdmin {
email: account.email.clone(),
plan_type: account.plan_type.clone(),
preserve_profile: false,
preserve_credential_state: false,
provider_material: document(),
has_refresh_token: account.has_refresh_token,
access_token_expires_at: account
@@ -258,6 +261,13 @@ impl FakeProviderAdmin {
#[async_trait]
impl ProviderAdmin for FakeProviderAdmin {
fn account_capabilities(
&self,
_account_id: &ProviderAccountId,
_authentication_kind: &str,
) -> gateway_admin::model::accounts::ProviderAccountCapabilities {
*self.capabilities.lock().unwrap()
}
fn provider_kind(&self) -> &ProviderKind {
&self.kind
}
@@ -300,7 +310,7 @@ impl ProviderAdmin for FakeProviderAdmin {
self.record("provider.account_facts_changed");
}
fn connection_test_operation(
async fn connection_test_operation(
&self,
model: &gateway_core::routing::UpstreamModelId,
input: &str,
@@ -364,7 +374,7 @@ impl ProviderAdmin for FakeProviderAdmin {
async fn start_authorization(
&self,
pending: PendingAuthorizationMutation,
pending: gateway_admin::model::provider_credentials::PendingAuthorizationMutation,
) -> Result<AuthorizationStarted, ProviderAdminError> {
self.record("provider.start_authorization");
self.require_available()?;
@@ -398,12 +408,17 @@ impl ProviderAdmin for FakeProviderAdmin {
.then(|| pending.clone());
let credential = match pending.target() {
AuthorizationMutationTarget::Create { name } => {
PreparedAuthorizationCredential::Create(prepared_create(self.kind.clone(), name))
PreparedAuthorizationCredential::Create(Box::new(prepared_create(
self.kind.clone(),
name,
)))
}
AuthorizationMutationTarget::Reauthorize { account_id } => {
let mut account = account_record(self.kind.as_str());
account.id = account_id.as_str().to_owned();
PreparedAuthorizationCredential::Reauthorize(self.prepared_rotation(&account))
PreparedAuthorizationCredential::Reauthorize(Box::new(
self.prepared_rotation(&account),
))
}
};
let prepared = PreparedAuthorizationCommit::new(pending, credential);
@@ -539,6 +554,12 @@ pub(super) struct FakeAccountStore {
accounts: Mutex<Vec<AccountRecord>>,
account_after_probe: Mutex<Option<AccountRecord>>,
fail_commit: Mutex<bool>,
receipts: Mutex<
std::collections::BTreeMap<
gateway_admin::model::provider_credentials::AuthorizationReceiptKey,
gateway_admin::model::provider_credentials::CredentialMutationResult,
>,
>,
audit_requests: Mutex<Vec<String>>,
import_settings: Mutex<Vec<Option<gateway_admin::model::accounts::AccountImportSettings>>>,
quota_window_usage: Mutex<Vec<AccountUsageWindowResult>>,
@@ -559,6 +580,7 @@ impl FakeAccountStore {
accounts: Mutex::new(vec![account]),
account_after_probe: Mutex::new(None),
fail_commit: Mutex::new(false),
receipts: Mutex::new(Default::default()),
audit_requests: Mutex::new(Vec::new()),
import_settings: Mutex::new(Vec::new()),
quota_window_usage: Mutex::new(Vec::new()),
@@ -654,6 +676,17 @@ impl FakeAccountStore {
#[async_trait]
impl AccountStore for FakeAccountStore {
async fn list_plugin_accounts(
&self,
_: gateway_admin::model::provider_credentials::PluginAccountListQuery,
) -> AdminStoreResult<gateway_admin::model::provider_credentials::PluginAccountPage> {
Err(AdminStoreError::new(
AdminStoreErrorKind::Unavailable,
"plugin accounts",
"unavailable",
))
}
async fn list_accounts(
&self,
_: AccountListQuery,
@@ -756,6 +789,24 @@ impl AccountStore for FakeAccountStore {
}))
}
async fn credential_details_by_id(
&self,
account_id: &ProviderAccountId,
) -> AdminStoreResult<Option<CredentialDetails>> {
self.record("store.credential_details_by_id");
let account = self
.accounts
.lock()
.expect("accounts")
.iter()
.find(|account| account.id == account_id.as_str())
.cloned();
Ok(account.map(|credential| CredentialDetails {
config_revision: revision(1),
credential,
}))
}
async fn load_credentials_for_export(
&self,
provider_kind: &ProviderKind,
@@ -784,6 +835,24 @@ impl AccountStore for FakeAccountStore {
.collect())
}
async fn load_credential_for_plugin(
&self,
account_id: &ProviderAccountId,
) -> AdminStoreResult<Option<ProviderExportCredentialInput>> {
self.record("store.load_credential_for_plugin");
Ok(self
.accounts
.lock()
.expect("accounts")
.iter()
.find(|account| account.id == account_id.as_str())
.cloned()
.map(|account| ProviderExportCredentialInput {
account,
provider_material: document(),
}))
}
async fn commit_credential_import(
&self,
command: CredentialImportCommit,
@@ -811,11 +880,20 @@ impl AccountStore for FakeAccountStore {
&self,
command: AuthorizationCommit,
context: &MutationContext,
) -> AdminStoreResult<CredentialMutationResult> {
) -> AdminStoreResult<gateway_admin::model::provider_credentials::AuthorizationCommitResult>
{
self.import_settings
.lock()
.expect("import settings")
.push(command.settings);
if let Some(result) = self.receipts.lock().unwrap().get(&command.key).cloned() {
return Ok(
gateway_admin::model::provider_credentials::AuthorizationCommitResult {
result,
newly_committed: false,
},
);
}
self.record("store.commit_authorization");
self.record_context(context);
self.require_commit()?;
@@ -826,11 +904,28 @@ impl AccountStore for FakeAccountStore {
Some(revision(credential.expected_credential_revision.get() + 1)),
),
};
Ok(CredentialMutationResult {
let result = CredentialMutationResult {
config_revision: revision(2),
account_id,
credential_revision,
})
};
self.receipts
.lock()
.unwrap()
.insert(command.key, result.clone());
Ok(
gateway_admin::model::provider_credentials::AuthorizationCommitResult {
result,
newly_committed: true,
},
)
}
async fn authorization_receipt(
&self,
key: &gateway_admin::model::provider_credentials::AuthorizationReceiptKey,
) -> AdminStoreResult<Option<CredentialMutationResult>> {
Ok(self.receipts.lock().unwrap().get(key).cloned())
}
async fn commit_credential_rotation(
@@ -997,8 +1092,7 @@ impl SettingsStore for StaticSettingsStore {
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(RuntimeSettings {
openai_client_profile: None,
xai_client_profile: None,
request_profiles: Default::default(),
request_location_enabled: false,
request_location: Default::default(),
config_revision: revision(1),
@@ -1024,6 +1118,9 @@ impl SettingsStore for StaticSettingsStore {
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
updated_at: Utc::now(),
})
}
@@ -1321,6 +1418,121 @@ async fn accounts_refresh_should_keep_guard_through_store_commit() {
assert_eq!(store.audit_requests(), ["refresh-request"]);
}
#[tokio::test]
async fn plugin_account_save_publishes_native_credential_change() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let store = FakeAccountStore::new("openai", events.clone());
let snapshot = Arc::new(RecordingPluginAccountPublication(events.clone()));
let access = gateway_admin::initialize_plugin_accounts(
ProviderAdminRegistry::new([provider.clone() as Arc<dyn ProviderAdmin>]).unwrap(),
store.clone(),
snapshot,
);
let result = access
.save(
PreparedPluginAccountSave::Replace {
facts: plugin_rotation_facts(&account_record("openai")),
authentication_kind: "oauth".to_owned(),
},
&context("plugin-explicit-save"),
)
.await
.expect("save prepared plugin account facts");
tokio::time::timeout(
std::time::Duration::from_secs(1),
provider.quota_started.notified(),
)
.await
.expect("start post-commit quota observation");
assert_eq!(result.account_id.as_str(), "acct_test");
assert_eq!(result.credential_revision, revision(2));
assert_eq!(store.audit_requests(), ["plugin-explicit-save"]);
assert_eq!(
recorded(&events),
[
"store.credential_details",
"store.commit_rotation",
"provider.account_facts_changed",
"snapshot.publish_committed",
"provider.quota"
]
);
}
#[tokio::test]
async fn plugin_account_reads_resolve_the_authoritative_provider_from_the_account_id() {
let events = events();
let store = FakeAccountStore::new("xai", events.clone());
// 读取端口不能让调用方提供或伪造 Provider;注册表中即使只有另一 Provider,
// 也必须按 Store 中该 account ID 的权威事实返回。
let registered_provider = FakeProviderAdmin::new("openai", events.clone());
let access = gateway_admin::initialize_plugin_accounts(
ProviderAdminRegistry::new([registered_provider as Arc<dyn ProviderAdmin>]).unwrap(),
store,
Arc::new(RecordingPluginAccountPublication(events.clone())),
);
let account_id = ProviderAccountId::new("acct_test").expect("account ID");
let runtime = access
.get_runtime(&account_id)
.await
.expect("load plugin account runtime");
let credential = access
.get_credential(&account_id)
.await
.expect("load plugin account credential");
assert_eq!(runtime.provider_kind.as_str(), "xai");
assert_eq!(credential.account.provider_kind.as_str(), "xai");
assert_eq!(credential.account.id, account_id.as_str());
assert_eq!(
recorded(&events),
[
"store.credential_details_by_id",
"store.load_credential_for_plugin",
]
);
}
struct RecordingPluginAccountPublication(EventLog);
impl gateway_core::runtime::SnapshotControl for RecordingPluginAccountPublication {
fn publish_committed(
&self,
revision: gateway_core::routing::ConfigRevision,
) -> BoxFuture<'_, ()> {
Box::pin(async move {
assert_eq!(revision.get(), 2);
self.0.lock().unwrap().push("snapshot.publish_committed");
})
}
}
#[tokio::test]
async fn plugin_account_save_rejects_stale_authentication_kind_before_commit() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let store = FakeAccountStore::new("openai", events.clone());
let services = accounts_service(provider, store.clone()).await;
let error = services
.plugin_accounts_handle()
.save(
PreparedPluginAccountSave::Replace {
facts: plugin_rotation_facts(&account_record("openai")),
authentication_kind: "api_key".to_owned(),
},
&context("plugin-stale-auth-kind"),
)
.await
.expect_err("authentication kind change must be rejected");
assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Conflict);
assert_eq!(recorded(&events), ["store.credential_details"]);
assert!(store.audit_requests().is_empty());
}
#[tokio::test]
async fn accounts_quota_refresh_should_return_the_updated_account_status() {
let events = events();
@@ -1644,6 +1856,12 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() {
#[tokio::test]
async fn accounts_list_should_return_complete_directory_semantics() {
let provider = FakeProviderAdmin::new("openai", events());
let capabilities = gateway_admin::model::accounts::ProviderAccountCapabilities {
quota: true,
profile: true,
..Default::default()
};
*provider.capabilities.lock().unwrap() = capabilities;
let mut stored = account_record("openai");
stored.plan_type = Some(" self_serve_business_prolite ".to_owned());
let store = FakeAccountStore::with_account(stored, events());
@@ -1665,6 +1883,7 @@ async fn accounts_list_should_return_complete_directory_semantics() {
assert_eq!(page.summary.total, 1);
assert_eq!(page.summary.normal, 1);
let account = page.items.first().expect("account item");
assert_eq!(account.capabilities, capabilities);
assert_eq!(account.account.provider_kind.as_str(), "openai");
assert_eq!(
account.projection.status,
@@ -1690,6 +1909,7 @@ async fn accounts_list_should_return_complete_directory_semantics() {
.await
.expect("account detail");
assert_eq!(detail.plan_type_display, account.plan_type_display);
assert_eq!(detail.capabilities, account.capabilities);
assert_eq!(detail.account.plan_type, account.account.plan_type);
}
@@ -2039,6 +2259,11 @@ async fn quota_forecast_reads_raw_snapshot_and_limits_usage_to_observation_time(
report.forecasts[0].source.as_ref().unwrap().observed_at,
Some(observed)
);
assert_eq!(
report.forecasts[1].source.as_ref().unwrap().label,
report.forecasts[0].source.as_ref().unwrap().label,
"缺少月窗口时两个展示周期复用同一周窗口"
);
let queries = store.quota_window_queries();
assert_eq!(queries.len(), 1);
assert_eq!(queries[0].range.start, reset - TimeDelta::days(7));
@@ -2069,6 +2294,70 @@ async fn quota_forecast_does_not_query_usage_without_a_current_snapshot() {
assert!(!provider.quota_requests.lock().unwrap()[0].refresh);
}
#[tokio::test]
async fn quota_forecast_only_queries_the_two_selected_source_windows() {
let provider = FakeProviderAdmin::new("openai", events());
let now = Utc::now();
let observed = now - TimeDelta::minutes(1);
let reset = now + TimeDelta::days(1);
let windows = (0..64)
.map(|index| {
let monthly = index % 2 == 1;
ProviderQuotaWindow {
key: if monthly {
format!("month-{index}")
} else {
format!("week-{index}")
},
group: if monthly { "monthly" } else { "shortTerm" }.to_owned(),
label: format!("window-{index}"),
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(if monthly { 30 } else { 7 } * 86_400),
used_percent: Some(20.0),
reset_at: Some(reset),
limit_reached: false,
local_usage: None,
provider_data: None,
}
})
.collect();
provider.set_quota(ProviderQuota {
plan_type: Some("pro".to_owned()),
observed_at: Some(observed),
windows,
..empty_quota()
});
let mut account = account_record("openai");
account.created_at = now - TimeDelta::days(60);
let store = FakeAccountStore::with_account(account, events());
let report = accounts_service(provider, store.clone())
.await
.accounts()
.quota_forecast(&ProviderAccountId::new("acct_test").unwrap())
.await
.expect("quota forecast");
let queries = store.quota_window_queries();
assert_eq!(
queries
.iter()
.map(|query| query.key.as_str())
.collect::<Vec<_>>(),
["week-0", "month-1"]
);
assert_eq!(
report.forecasts[0].source.as_ref().unwrap().label,
"window-0"
);
assert_eq!(
report.forecasts[1].source.as_ref().unwrap().label,
"window-1"
);
}
#[tokio::test]
async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_changed_bucket() {
use gateway_admin::model::quota_forecast_sampling::QuotaForecastHistoryPoint;
@@ -2530,6 +2819,52 @@ async fn reset_credit_oauth_refresh_should_reuse_the_exact_consume_command() {
assert!(recorded(&events).contains(&"store.commit_refresh"));
}
#[tokio::test]
async fn reset_credit_refresh_without_quota_should_complete_with_the_same_command() {
let events = events();
let provider = FakeProviderAdmin::new("example", events.clone());
provider.fail_next(ProviderAdminErrorKind::CredentialRefreshRequired);
*provider.quota_failure.lock().unwrap() = Some(ProviderAdminErrorKind::Unsupported);
let store = FakeAccountStore::new("example", events.clone());
let services = accounts_service(provider.clone(), store).await;
let command = ConsumeProviderResetCredit {
account_id: ProviderAccountId::new("acct_test").unwrap(),
credit_id: Some("credit_1".into()),
redeem_request_id: uuid::Uuid::parse_str("dbabeb20-040e-4e9f-8615-0b7c81e79987").unwrap(),
};
let result = services
.accounts()
.consume_reset_credit(&context("reset-without-quota"), command.clone())
.await
.expect("缺少可选额度能力不能中断凭据刷新后的消费重试");
assert_eq!(result.code, "reset");
assert_eq!(provider.reset_credit_commands(), [command.clone(), command]);
assert_eq!(
recorded(&events)
.iter()
.filter(|event| **event == "store.commit_refresh")
.count(),
1
);
}
#[tokio::test]
async fn accounts_quota_refresh_should_still_reject_an_unsupported_operation() {
let provider = FakeProviderAdmin::new("example", events());
*provider.quota_failure.lock().unwrap() = Some(ProviderAdminErrorKind::Unsupported);
let services = accounts_service(provider, FakeAccountStore::new("example", events())).await;
let error = services
.accounts()
.quota(&ProviderAccountId::new("acct_test").unwrap(), true)
.await
.expect_err("显式刷新不能被解释为空额度投影");
assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Invalid);
}
#[tokio::test]
async fn reset_credit_unknown_result_should_keep_a_stable_admin_kind() {
let events = events();
@@ -2710,7 +3045,7 @@ fn prepared_create(provider_kind: ProviderKind, name: &str) -> PreparedCredentia
prepared_create_with_id(provider_kind, "acct_prepared", name)
}
fn prepared_create_with_id(
pub(super) fn prepared_create_with_id(
provider_kind: ProviderKind,
account_id: &str,
name: &str,
@@ -2747,6 +3082,24 @@ fn rotation_result(command: CredentialRotationCommit) -> CredentialMutationResul
}
}
fn plugin_rotation_facts(account: &AccountRecord) -> PreparedCredentialRotationFacts {
PreparedCredentialRotationFacts {
account_id: ProviderAccountId::new(account.id.clone()).expect("account ID"),
provider_kind: account.provider_kind.clone(),
expected_credential_revision: account.credential_revision,
replacement_identity: None,
name: "plugin refreshed account".to_owned(),
email: account.email.clone(),
plan_type: account.plan_type.clone(),
preserve_profile: true,
preserve_credential_state: false,
provider_material: document(),
has_refresh_token: account.has_refresh_token,
access_token_expires_at: account.access_token_expires_at,
next_refresh_at: account.next_refresh_at,
}
}
fn empty_profile_statistics() -> ProviderProfileStatistics {
ProviderProfileStatistics {
display_name: Some("Preview user".to_owned()),
@@ -2935,6 +3288,7 @@ impl AccountProbe for SuccessfulAccountProbe {
fn probe(
&self,
_: AccountProbeRequest,
_: Option<Arc<gateway_core::routing::RuntimeSnapshot>>,
) -> BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
Box::pin(async {
Ok(AccountProbeResult {
@@ -2950,6 +3304,7 @@ impl AccountProbe for FailingAccountProbe {
fn probe(
&self,
_: AccountProbeRequest,
_: Option<Arc<gateway_core::routing::RuntimeSnapshot>>,
) -> BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
Box::pin(async {
let detail = ClientVisibleUpstreamError::new(
@@ -2979,6 +3334,7 @@ impl AccountProbe for FreeModelQuotaProbe {
fn probe(
&self,
_: AccountProbeRequest,
_: Option<Arc<gateway_core::routing::RuntimeSnapshot>>,
) -> BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
Box::pin(async move {
self.store.set_account_after_probe(account_record("xai"));
@@ -3022,3 +3378,29 @@ pub(super) fn import_settings() -> gateway_admin::model::accounts::AccountImport
],
}
}
#[tokio::test]
async fn unregistered_provider_accounts_have_no_live_management_capabilities() {
let provider = FakeProviderAdmin::new("openai", events());
let store = FakeAccountStore::with_account(account_record("retired-provider"), events());
let services = accounts_service(provider, store).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();
assert_eq!(page.items.len(), 1);
assert_eq!(
page.items[0].account.provider_kind.as_str(),
"retired-provider"
);
assert_eq!(page.items[0].capabilities, Default::default());
}
@@ -6,20 +6,26 @@ use gateway_core::policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits};
use gateway_admin::{
model::{
AdminErrorKind, MutationActor, MutationContext, Revision,
AdminErrorKind, MutationActor, MutationContext, PageSize, Revision,
client_keys::{
ClientKeyCursor, ClientKeyCursorValue, ClientKeyListQuery, ClientKeyPage,
ClientKeyPageSize, ClientKeyRecord, ClientKeySecret, ClientKeySort, ClientKeySortField,
CreateClientKey, DeleteClientKey, NewClientKey, SetClientKeyEnabled, SortDirection,
UpdateClientKey,
},
plugin_client_keys::{
PluginClientKey, PluginClientKeyCursor, PluginClientKeyListQuery, PluginClientKeyPage,
},
},
ports::provider::{ProviderAdmin, ProviderAdminRegistry},
ports::store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult, ClientKeyStore},
};
#[derive(Default)]
struct TestClientKeyStore {
plaintexts: Mutex<Vec<String>>,
list_queries: Mutex<Vec<ClientKeyListQuery>>,
list_response: Mutex<Option<ClientKeyPage>>,
create_error: Option<AdminStoreErrorKind>,
update_error: Option<AdminStoreErrorKind>,
resets: Mutex<Vec<gateway_admin::model::client_keys::ResetClientKeyBudget>>,
@@ -44,6 +50,10 @@ impl ClientKeyStore for TestClientKeyStore {
}
async fn list_client_keys(&self, query: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage> {
self.list_queries.lock().unwrap().push(query.clone());
if let Some(page) = self.list_response.lock().unwrap().clone() {
return Ok(page);
}
assert_eq!(query.page_size.get(), u16::MAX);
Ok(ClientKeyPage {
config_revision: Revision::new(1).expect("revision"),
@@ -74,8 +84,7 @@ impl ClientKeyStore for TestClientKeyStore {
}
self.plaintexts.lock().unwrap().push(key.plaintext);
let record = ClientKeyRecord {
openai_client_profile_override: None,
xai_client_profile_override: None,
request_profile_overrides: Default::default(),
id: key.id,
name: key.name,
label: key.label,
@@ -196,6 +205,91 @@ async fn client_key_list_should_forward_the_full_nonzero_u16_page_size() {
assert_eq!(page.total, 0);
}
#[tokio::test]
async fn plugin_client_key_list_projects_only_public_identity_and_uses_stable_name_cursor() {
let now = Utc::now();
let key_id = ClientApiKeyId::new("key_public").expect("key ID");
let next_id = ClientApiKeyId::new("key_next").expect("next key ID");
let sort = ClientKeySort {
field: ClientKeySortField::Name,
direction: SortDirection::Asc,
};
let store = Arc::new(TestClientKeyStore {
list_response: Mutex::new(Some(ClientKeyPage {
config_revision: Revision::new(7).expect("revision"),
items: vec![ClientKeyRecord {
request_profile_overrides: Default::default(),
id: key_id.clone(),
name: "Public identity".to_owned(),
label: Some("private management label".to_owned()),
groups: Vec::new(),
provider_kinds: Vec::new(),
prefix: "sk_must_not_escape".to_owned(),
enabled: true,
limits: RateLimits::unlimited(),
budget: Default::default(),
last_used_at: Some(now),
created_at: now,
updated_at: now,
}],
total: 1,
next_cursor: Some(ClientKeyCursor {
sort,
value: ClientKeyCursorValue::Name("Public identity".to_owned()),
id: next_id.clone(),
}),
})),
..Default::default()
});
let providers = ProviderAdminRegistry::new(Vec::<Arc<dyn ProviderAdmin>>::new())
.expect("empty provider registry");
let access = gateway_admin::initialize_plugin_client_keys(
providers,
store.clone(),
Arc::new(super::NoopSnapshot),
);
let input_cursor = PluginClientKeyCursor {
name: "Before".to_owned(),
id: ClientApiKeyId::new("key_before").expect("cursor key ID"),
};
let page = access
.list(PluginClientKeyListQuery {
cursor: Some(input_cursor.clone()),
limit: PageSize::new(25).expect("page size"),
})
.await
.expect("list plugin client keys");
assert_eq!(
page,
PluginClientKeyPage {
items: vec![PluginClientKey {
id: key_id,
name: "Public identity".to_owned(),
enabled: true,
}],
next_cursor: Some(PluginClientKeyCursor {
name: "Public identity".to_owned(),
id: next_id,
}),
}
);
assert_eq!(
*store.list_queries.lock().unwrap(),
vec![ClientKeyListQuery {
cursor: Some(ClientKeyCursor {
sort,
value: ClientKeyCursorValue::Name(input_cursor.name),
id: input_cursor.id,
}),
page_size: ClientKeyPageSize::new(25).expect("page size"),
search: None,
sort,
}]
);
}
#[tokio::test]
async fn create_preserves_migrated_keys_and_keeps_default_generation() {
let store = Arc::new(TestClientKeyStore::default());
@@ -260,8 +354,7 @@ async fn duplicate_names_report_the_same_actionable_conflict_on_create_and_updat
.update(
&mutation_context(),
UpdateClientKey {
openai_client_profile_override: None,
xai_client_profile_override: None,
request_profile_override_updates: Default::default(),
id: ClientApiKeyId::new("key_existing").unwrap(),
name: "Migration".to_owned(),
label: None,
@@ -281,8 +374,7 @@ async fn duplicate_names_report_the_same_actionable_conflict_on_create_and_updat
fn create_command(key: Option<&str>) -> CreateClientKey {
CreateClientKey {
openai_client_profile_override: None,
xai_client_profile_override: None,
request_profile_overrides: Default::default(),
custom_key: key.map(|value| PlaintextClientApiKey::new(value).unwrap()),
name: "Migration".to_owned(),
label: None,
@@ -0,0 +1,2 @@
mod openai;
mod xai;
@@ -11,7 +11,7 @@ use gateway_admin::{
ports::provider::ProviderAdminErrorKind,
};
use super::accounts::{
use super::super::accounts::{
FakeAccountStore, FakeProviderAdmin, context, document, events, recorded, revision,
};
@@ -23,7 +23,9 @@ async fn openai_delete_should_commit_then_release_provider_resources() {
let services = service(provider, store.clone()).await;
services
.openai()
.credentials()
.for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap())
.unwrap()
.delete(deletion("acct_test"))
.await
.expect("delete credential");
@@ -48,10 +50,12 @@ async fn openai_import_should_prepare_before_atomic_store_commit() {
let services = service(provider.clone(), store.clone()).await;
services
.openai()
.credentials()
.for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap())
.unwrap()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: Some(super::accounts::import_settings()),
settings: Some(super::super::accounts::import_settings()),
context: context("import-openai"),
document: document(),
})
@@ -79,7 +83,7 @@ async fn openai_import_should_prepare_before_atomic_store_commit() {
);
assert_eq!(
store.import_settings(),
[Some(super::accounts::import_settings())]
[Some(super::super::accounts::import_settings())]
);
assert_eq!(store.audit_requests(), ["import-openai"]);
}
@@ -106,7 +110,9 @@ async fn openai_import_should_expose_only_explicit_public_errors_without_committ
let store = FakeAccountStore::new("openai", events.clone());
let services = service(provider, store.clone()).await;
let error = services
.openai()
.credentials()
.for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap())
.unwrap()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
@@ -132,7 +138,9 @@ async fn openai_import_should_refresh_quota_for_every_imported_account() {
let services = service(provider.clone(), store).await;
let result = services
.openai()
.credentials()
.for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap())
.unwrap()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
@@ -177,7 +185,9 @@ async fn openai_import_should_remain_successful_when_quota_refresh_fails() {
let services = service(provider.clone(), store).await;
let result = services
.openai()
.credentials()
.for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap())
.unwrap()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
@@ -204,7 +214,9 @@ async fn openai_authorization_create_should_observe_initial_quota() {
let services = service(provider.clone(), store.clone()).await;
services
.openai()
.credentials()
.for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap())
.unwrap()
.start_authorization(StartAuthorization {
outbound_proxy: None,
context: context("oauth-start-openai-create"),
@@ -214,13 +226,16 @@ async fn openai_authorization_create_should_observe_initial_quota() {
.await
.expect("start authorization");
services
.openai()
.complete_authorization(CompleteAuthorization {
settings: Some(super::accounts::import_settings()),
context: context("oauth-start-openai-create"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
})
.credentials()
.complete_authorization(
&gateway_core::routing::ProviderKind::new("openai").unwrap(),
CompleteAuthorization {
settings: Some(super::super::accounts::import_settings()),
context: context("oauth-start-openai-create"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
},
)
.await
.expect("complete authorization");
@@ -246,7 +261,7 @@ async fn openai_authorization_create_should_observe_initial_quota() {
);
assert_eq!(
store.import_settings(),
[Some(super::accounts::import_settings())]
[Some(super::super::accounts::import_settings())]
);
assert_eq!(store.audit_requests(), ["oauth-start-openai-create"]);
}
@@ -260,7 +275,9 @@ async fn openai_authorization_create_should_remain_successful_when_initial_quota
let services = service(provider.clone(), store).await;
services
.openai()
.credentials()
.for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap())
.unwrap()
.start_authorization(StartAuthorization {
outbound_proxy: None,
context: context("oauth-start-openai-quota-failure"),
@@ -270,13 +287,16 @@ async fn openai_authorization_create_should_remain_successful_when_initial_quota
.await
.expect("start authorization");
let result = services
.openai()
.complete_authorization(CompleteAuthorization {
settings: None,
context: context("oauth-start-openai-quota-failure"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
})
.credentials()
.complete_authorization(
&gateway_core::routing::ProviderKind::new("openai").unwrap(),
CompleteAuthorization {
settings: None,
context: context("oauth-start-openai-quota-failure"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
},
)
.await
.expect("quota observation must not fail authorization");
@@ -302,7 +322,9 @@ async fn openai_authorization_store_failure_should_release_claim_for_retry() {
};
services
.openai()
.credentials()
.for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap())
.unwrap()
.start_authorization(StartAuthorization {
outbound_proxy: None,
context: context("oauth-store-retry"),
@@ -312,13 +334,19 @@ async fn openai_authorization_store_failure_should_release_claim_for_retry() {
.await
.expect("start authorization");
services
.openai()
.complete_authorization(command())
.credentials()
.complete_authorization(
&gateway_core::routing::ProviderKind::new("openai").unwrap(),
command(),
)
.await
.expect_err("Store commit must fail");
services
.openai()
.complete_authorization(command())
.credentials()
.complete_authorization(
&gateway_core::routing::ProviderKind::new("openai").unwrap(),
command(),
)
.await
.expect("released claim must permit retry");
@@ -350,7 +378,9 @@ async fn openai_import_provider_error_should_not_touch_store_transaction() {
let services = service(provider, store).await;
services
.openai()
.credentials()
.for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap())
.unwrap()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
@@ -370,7 +400,9 @@ async fn openai_reauthorization_should_commit_after_credential_revision_advances
let store = FakeAccountStore::new("openai", events.clone());
let services = service(provider.clone(), store.clone()).await;
services
.openai()
.credentials()
.for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap())
.unwrap()
.start_authorization(StartAuthorization {
outbound_proxy: None,
context: context("oauth-start-openai"),
@@ -396,13 +428,16 @@ async fn openai_reauthorization_should_commit_after_credential_revision_advances
provider.set_current_credential_revision(revision(2));
let result = services
.openai()
.complete_authorization(CompleteAuthorization {
settings: None,
context: context("oauth-complete-openai"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
})
.credentials()
.complete_authorization(
&gateway_core::routing::ProviderKind::new("openai").unwrap(),
CompleteAuthorization {
settings: None,
context: context("oauth-complete-openai"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
},
)
.await
.expect("complete reauthorization");
@@ -427,7 +462,7 @@ async fn openai_reauthorization_should_commit_after_credential_revision_advances
}
async fn service(provider: Arc<FakeProviderAdmin>, store: Arc<FakeAccountStore>) -> AdminServices {
super::AdminHarness::new()
super::super::AdminHarness::new()
.provider(provider)
.accounts(store)
.build()
@@ -448,7 +483,9 @@ async fn reauthorization_with_import_settings_releases_claim_without_committing(
let store = FakeAccountStore::new("openai", events.clone());
let services = service(provider, store.clone()).await;
services
.openai()
.credentials()
.for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap())
.unwrap()
.start_authorization(StartAuthorization {
outbound_proxy: None,
context: context("reauthorization-settings"),
@@ -458,13 +495,16 @@ async fn reauthorization_with_import_settings_releases_claim_without_committing(
.await
.expect("start reauthorization");
let error = services
.openai()
.complete_authorization(CompleteAuthorization {
settings: Some(super::accounts::import_settings()),
context: context("reauthorization-settings"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
})
.credentials()
.complete_authorization(
&gateway_core::routing::ProviderKind::new("openai").unwrap(),
CompleteAuthorization {
settings: Some(super::super::accounts::import_settings()),
context: context("reauthorization-settings"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
},
)
.await
.expect_err("reauthorization must preserve settings");
assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Invalid);

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