Compare commits

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

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

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

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

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

Add decoder and HTTP delivery regression coverage.

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

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

Update architecture boundaries and add focused regression coverage.

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

---------

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

Refs #316

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

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

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

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

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

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

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

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

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

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

Fixes #236

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

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

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

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

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

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

Fixes #288

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

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

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

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

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

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

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

Co-authored-by: Codex <noreply@openai.com>
2026-09-26 05:32:04 +08:00
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
ZyyandCodex debba0e13c chore(release): v3.13.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-21 01:06:20 +08:00
ZyyandCodex a0836ec3f2 feat(key-usage): add API key budgets to CC Switch imports (#202)
Closes #198

Co-authored-by: Codex <noreply@openai.com>
2026-09-21 00:26:36 +08:00
ZyyandCodex dc0cdf2930 fix(openai): normalize rejected reasoning replay fields
Refs #194, #200.

Co-authored-by: Codex <noreply@openai.com>
2026-09-20 23:09:23 +08:00
ZyyandCodex d76bb57b8c fix(usage): show current account notes below email
Read account notes by internal account ID for usage lists and Dashboard recent
requests. Show nonempty notes as secondary text using the existing label style.

Cover same-email accounts, note changes and removal, deleted-account history,
and the admin API and client key visibility boundaries.

Refs #190

Co-authored-by: Codex <noreply@openai.com>
2026-09-20 19:13:52 +08:00
ZyyandCodex 6434edd554 fix(accounts): preserve quota when recovering disabled accounts
Enable scheduling without clearing observed quota, credential errors, or cooldowns for disabled accounts. Keep explicit recovery for enabled accounts and document both paths.

Co-authored-by: Codex <noreply@openai.com>
2026-09-20 18:41:22 +08:00
ZyyandCodex 3a90d8d0b6 fix(accounts): forecast quota totals within the current cycle
Preserve recorded cycle usage and estimate only remaining capacity from
recent samples. Restart cycle totals and sampling after quota resets,
including resets following an exhausted observation.

Clarify cycle totals and duration conversions in the quota modal and API
documentation, and prevent narrow layouts from overflowing.

Validated with the full backend suite using isolated PostgreSQL and Redis,
strict Clippy, Rustfmt, frontend lint and build, and browser checks.

Co-authored-by: Codex <noreply@openai.com>
2026-09-20 18:25:17 +08:00
ZyyandCodex f3236d0410 feat(key-usage): add about dialog with build information
Reuse the shared About dialog from a toolbar icon and expose version and commit identifiers through an authenticated key-only endpoint. Align the About and refresh button backgrounds with the configuration button in dark mode.

Validate the response field allowlist and session boundaries, and document the new endpoint.

Co-authored-by: Codex <noreply@openai.com>
2026-09-20 16:27:55 +08:00
ZyyandCodex ccb7357914 fix(usage): indicate long-context billing with a yellow info icon
Replace the inline long-context badge with a themed yellow detail trigger and retain the billing context in its title and accessible label.

Co-authored-by: Codex <noreply@openai.com>
2026-09-20 15:44:46 +08:00
ZyyandCodex c0ad2ae656 fix(openai): restrict capacity cooldown to explicit overload
Count only confirmed capacity rejections toward account cooldown, excluding
generic server failures and unrecognized upstream errors. Classify streamed
invalid_prompt failures as invalid requests, matching official Codex.

Cover HTTP and WebSocket failures around the cooldown threshold while
preserving retry boundaries, upstream error payloads, and Smart scoring.
Update the capacity cooldown contract in the API and architecture docs.

Validation: Rustfmt, strict workspace Clippy, and all backend suites with
isolated PostgreSQL 18 / Redis 8 (--test-threads=4).

Reference: openai/codex@7498521d28,
codex-rs/codex-api/src/sse/responses.rs.

Refs #177

Co-authored-by: Codex <noreply@openai.com>
2026-09-20 14:01:29 +08:00
ZyyandCodex 798d4f518e fix(update): run updates in background and retain release notes (#188)
Detach online updates from HTTP request lifetimes and recover their persisted status in the admin UI. Retain current release notes when no upgrade is available while preserving upgrade restrictions.

Fixes #185

Co-authored-by: Codex <noreply@openai.com>
2026-09-20 13:29:48 +08:00
ZyyandCodex 6819682176 docs(ui): define concise copy and layout guidance
Co-authored-by: Codex <noreply@openai.com>
2026-09-20 11:28:17 +08:00
ZyyandCodex b2d4a7e833 docs(readme): simplify punctuation and community guidance
Co-authored-by: Codex <noreply@openai.com>
2026-09-20 11:13:39 +08:00
ZyyandCodex 01006380ee chore(release): v3.12.1
Co-authored-by: Codex <noreply@openai.com>
2026-09-20 01:18:37 +08:00
ZyyandCodex c79e032575 fix(ui): refine usage page layout and controls
Co-authored-by: Codex <noreply@openai.com>
2026-09-20 01:14:13 +08:00
Akide LiuandClaude Fable 5 96cf46cca8 fix(openai): expand string input into a user message item (#179)
The public Responses API accepts `input` as a plain string, but the Codex
backend only accepts an item list and rejects such requests with
400 "Input must be a list". Expand a string `input` into a single user
message item in the downstream body compatibility layer so that HTTP/SSE
and WebSocket share the same rule; arrays and other shapes pass through
untouched.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-20 00:58:58 +08:00
ZyyandCodex ec605b3d1c chore(release): v3.12.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-19 23:43:27 +08:00
ZyyandCodex 890fb3299b fix(openai): stop client retries for exhausted account pools
Return 429 usage_limit_reached when all eligible accounts have exhausted their quota. Preserve provider fallback, continuation recovery, and other unavailability causes.

Cover HTTP and WebSocket error contracts and account selection boundaries. Verified with 2516 backend integration tests, strict Clippy, and the official Codex CLI using gpt-5.6-luna and a quota-exhausted account.

Co-authored-by: Codex <noreply@openai.com>
2026-09-19 23:28:37 +08:00
helenandCodex a18f0a2e11 fix(openai): normalize system messages for Codex (#172)
Normalize explicit system messages to developer at the shared Codex request boundary while preserving message content and other fields. Cover HTTP and WebSocket request bodies and document the compatibility behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-09-19 22:43:49 +08:00
ZyyandCodex a1ba6c0b61 fix(update): enforce release channel upgrade rules
Refs #169

Co-authored-by: Codex <noreply@openai.com>
2026-09-19 22:38:23 +08:00
ZyyandCodex 5c11b62f93 fix(ui): refine theme contrast and table interactions
Separate light control and table fills, unify input hover and focus feedback, and add smooth table row transitions. Keep column drag highlights attached to their keys and reduce account note text size.

Co-authored-by: Codex <noreply@openai.com>
2026-09-19 22:38:23 +08:00
ZyyandCodex 748c77da6a feat(ui): add key configuration and reorderable table columns
Add session-scoped Codex configuration and CCSwitch import for key holders. Persist table-column ordering with drag animations and refine key, quota, and usage layouts.

Validated with 395 gateway-api integration tests, Rustfmt, and strict Clippy for gateway-api and gateway-admin. Frontend validation is left to the maintainer as requested.

Co-authored-by: Codex <noreply@openai.com>
2026-09-19 19:44:14 +08:00
d0180b74b3 fix(openai): refresh expired quota windows after reset grace (#170)
Refresh non-exhausted accounts after the quota reset grace period while preserving observed usage and exhausted-window attribution. Keep periodic checks for exhausted accounts to detect early upstream resets, and cover refresh timing, throttling, and display regressions.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-19 18:13:33 +08:00
4669a29b46 feat(usage): show key names and long-context billing badges (#171)
Show current client key names in usage and error records, and persist the selected long-context pricing band in billing snapshots.

Display a long-context badge without assuming a fixed multiplier. Keep missing historical flags false while preserving reconstructed billing details, with regression coverage for OpenAI and xAI.

Validated with 36 billing tests, strict Clippy, Rustfmt, frontend lint/typecheck/build, and browser checks for both usage views.

Co-authored-by: 陈燕山 <chenyanshan@yanshan-mac.local>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-19 18:13:14 +08:00
e63c5fa28d chore(deps): align TLS with Codex Desktop 26.915 (#175)
Update rustls and AWS-LC to the versions bundled with Codex Core 0.155,
refresh the captured ClientHello baseline, and remove the rustls audit waiver.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-19 17:39:48 +08:00
1acd4a5f59 feat(settings): unify OpenAI and xAI client identities (#174)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-19 17:39:29 +08:00
ZyyandCodex 6659d0c1f9 fix(ui): shorten weekly budget label
Co-authored-by: Codex <noreply@openai.com>
2026-09-19 14:22:52 +08:00
ZyyandCodex ab828650e6 chore(release): v3.11.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-19 06:41:19 +08:00
ZyyandCodex 4c7e4e08f6 feat: refine admin controls and overload protection
Refine settings navigation, selective pricing sync, model deletion, and admin interactions. Consolidate unreleased migrations and keep Fast policy scoped to groups. Increase confirmed capacity-rejection feedback without changing ordinary failure recovery.

Co-authored-by: Codex <noreply@openai.com>
2026-09-19 06:41:03 +08:00
16ae8c7afa feat(auth): add administrator password changes (#166)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-19 06:07:17 +08:00
956f627f68 feat(keys): add manual budget reset and compact row actions (#165)
Add an admin-only reset for daily, weekly, or all key budget usage with atomic auditing and serialized settlement boundaries.

Keep budget limits, original expiration times, and billing history while moving secondary row actions into the existing popover pattern.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-19 04:46:22 +08:00
f3076e2338 feat(openai): fallback auxiliary account endpoints to official upstream on 404 (#160)
* feat(openai): fallback auxiliary account endpoints to official upstream on 404

When a custom upstream base URL is configured (such as a Codex Relay for chat and inference), non-inference auxiliary account endpoints (usage, profile statistics, reset credits, subscriptions) may return 404 Not Found if the relay only proxies /backend-api/codex/*. Automatically fallback to the official ChatGPT backend endpoint to retrieve account metadata and quota statistics, while keeping chat and model traffic routed through the configured relay.

* fix(openai): preserve account fallback failures and query budgets

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-19 02:44:52 +08:00
ZyyandCodex 7f320797a0 docs: add public quick preview access
Co-authored-by: Codex <noreply@openai.com>
2026-09-19 01:30:31 +08:00
ZyyandCodex 7bcc207055 fix(openai): stop cancelled artifact reads during shutdown
Return a terminal I/O error for cancelled or expired Desktop artifact reads so standard read helpers cannot retry forever and block runtime shutdown.

Cover cancellation before reading and during an in-flight range request.

Co-authored-by: Codex <noreply@openai.com>
2026-09-19 00:14:25 +08:00
fba8941068 feat(pricing): add configurable model prices and manual catalog sync (#159)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-18 20:22:15 +08:00
ZyyandCodex ede456877b docs(release): define experimental publishing and title numbering
Co-authored-by: Codex <noreply@openai.com>
2026-09-18 16:55:07 +08:00
Zyy e6587b6ca2 feat(openai): add configurable upstream client identities (#156)
Add six Desktop and CLI presets, global defaults and per-key overrides, custom identity parameters, and official release tracking. Keep the resolved identity stable throughout each request and its retries.
2026-09-18 16:39:29 +08:00
ZyyandCodex 4d1ed7917d fix(api): make final overload failures retryable for clients (#152)
Project capacity errors at the HTTP, SSE, and WebSocket delivery boundary while preserving upstream facts and existing gateway recovery policies.

Co-authored-by: Codex <noreply@openai.com>
2026-09-18 16:38:08 +08:00
ZyyandCodex 5fe892de82 docs(community): configure Chinese discussions and feedback routing
Add Chinese discussion forms, community guidance, and README and Issue chooser links.

Co-authored-by: Codex <noreply@openai.com>
2026-09-18 15:55:36 +08:00
ZyyandCodex 3328a4356a chore(release): v3.10.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 20:10:53 +08:00
77b0c29354 feat(proxies): add dual-stack diagnostics without blocking binding (#132)
Probe IPv4 and IPv6 exit endpoints concurrently and accept either successful address family. Persist both exit addresses with migration 0015 while preserving existing migration checksums and the legacy exitIp field.

Remove stale frontend IP preference payloads and display exit addresses without redundant family prefixes. Treat probe results as diagnostics and allow users to select and bind saved proxies regardless of probe status, while preserving missing-proxy checks, configuration consistency, import guards, and no-direct-fallback behavior.

Co-authored-by: zhangwentian <2han9wen71an@gmail.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 19:58:22 +08:00
ZyyandCodex c872bc7881 refactor(settings): refine Fast display and save feedback
Move the revised service-tier description into the card header and simplify the switch label. Keep the undo button filled and place the unsaved indicator before it.

Co-authored-by: Codex <noreply@openai.com>
2026-09-17 19:05:54 +08:00
487b2e87e2 feat: add global and group controls to disable Fast (#146)
Freeze global and bound-group Fast restrictions in each request snapshot, then apply the effective policy before OpenAI transport headers, observations, and billing. Preserve omitted update values and existing data with an additive migration.

Keep the current split settings layout, add a service-tier card, and require WebSocket transport from the first turn in the continuation regression test.

Validation: frontend lint, typecheck and build; Rust formatting; migration checksums; 15 focused tests. Live priority/default/priority requests preserved context on one WebSocket, but the upstream returned default for all three, so actual priority-tier execution remains unverified. Cloud CI was still running when the maintainer requested immediate merge.

Co-authored-by: Yanshan Chen <16632048+chenyanshan@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 18:55:31 +08:00
ZyyandCodex eb8a01541a refactor(ui): refine settings and account interactions
Split settings into focused cards while retaining the original tabs and page layout. Simplify save feedback and help controls, preserve settings drafts, and align account dialog and model restriction widths.

Keep select option scrolling tied to keyboard navigation and focus so hovering does not shift the dropdown.

Co-authored-by: Codex <noreply@openai.com>
2026-09-17 18:29:47 +08:00
ZyyandCodex 0acf114fa7 ci(release): remove major-version upgrade warning
Remove the same warning from release installation notes and deployment documentation.

Co-authored-by: Codex <noreply@openai.com>
2026-09-17 18:25:15 +08:00
2han9wen71anandZyy 5d941008fc fix(providers): let disabled accounts source their own model catalogs (#143)
* fix(providers): let disabled accounts source their own model catalogs

The admin per-account model query (connection test model dropdown)
resolves the account's plan catalog through refresh_account_catalog,
whose candidates come from catalog_candidates_by_scope over the
scheduling account listing. Disabled accounts never appear there, so a
disabled account either failed with NoEligibleCredential (no enabled
account in its plan scope) or silently sourced the catalog from other
accounts instead of itself, and the panel showed no test models.

Keep the pinned account reachable: when the target account is missing
from the eligibility-filtered candidates, append it (for xAI, load its
full credential) and let the preferred-first ordering try it before any
same-scope peers. A fully filtered listing is now treated as empty
candidates instead of an early error so the pinned account can still
answer alone. Client-facing catalog discovery, background sync and
scheduling listings continue to exclude disabled accounts.

* fix(xai): keep catalog credential revision aligned

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-09-17 16:07:58 +08:00
b44a790777 fix(openai): coalesce SOCKS proxy negotiation writes (#142)
* fix(openai): coalesce SOCKS proxy negotiation writes

Buffer proxy handshake writes until the existing protocol flush points to support SOCKS services that reject partial method greetings. Preserve the dependency handshake and return the unbuffered stream before TLS/WebSocket traffic.

Refs #139

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

* test(openai): make SOCKS negotiation coverage deterministic

Replace TCP peek boundary assumptions with an in-memory write transcript and fragmented responses. Cover authenticated and unauthenticated negotiation, retain the existing account proxy integration coverage, and verify the handshake buffer is drained.

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

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Zyy <39424122+zyycn@users.noreply.github.com>
2026-09-17 15:54:09 +08:00
06e94ef69b fix(settings): make Responses decompression limit configurable (#141)
Persist the output limit in runtime settings and use the authenticated snapshot for each compressed Responses request. Expose the setting in MiB and preserve bounded decoding and the independent zstd window limit.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 15:29:14 +08:00
0f2e0b18a9 feat(openai): recover quota-exhausted continuations through client replay (#140)
Project replay-safe quota rejections to the existing client history recovery
contract while preserving upstream failure facts and account isolation.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 15:07:31 +08:00
71e26f44b7 fix(usage): bill sent models and show response model mismatches (#136)
* feat(usage): show upstream response model mismatches

Persist observed response models separately from routing and billing facts.
Surface mismatches with the existing model arrow and native title tooltips.
Keep response observations isolated across retries and WebSocket reuse.

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

* fix(usage): calculate costs from the outbound model

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 14:55:54 +08:00
yertandCodex 8232e19bc0 fix(ui): make reauthorization account copyable (#135)
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 14:55:22 +08:00
dcc61915e9 fix(openai): default missing downstream store to false (#134)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 14:54:52 +08:00
47d08560a7 fix(api): allow ChatGPT remote Desktop sessions without app versions (#133)
* fix(api): allow ChatGPT remote Desktop sessions without app versions

Recognize the official Android and iOS remote User-Agent suffixes when
Desktop application version metadata is absent. Preserve explicit version
validation and Client Key authentication, cover the streaming request path,
and document the version enforcement boundary.

Refs #129

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

* fix(api): match ChatGPT remote client name family

Accept nonempty platforms in codex_chatgpt_*_remote while preserving suffix
token validation and explicit Desktop version enforcement. Cover future
platforms and malformed names in parser and streaming route tests.

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 14:54:17 +08:00
ZyyandCodex 590c4a163b fix(store): release rejected proxy import locks before returning
Explicitly unlock rejected import reservations before returning their error so immediate proxy operations do not race connection teardown. Cover repeated rejection followed by writes with PostgreSQL integration tests.

Co-authored-by: Codex <noreply@openai.com>
2026-09-17 05:36:00 +08:00
ZyyandCodex 9cfcd371d6 chore(release): v3.9.1
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 05:19:07 +08:00
ZyyandCodex 223021630e fix(openai): unify API key and OAuth passthrough
Share the simulated client profile and existing request/response paths across authentication modes. Forward native capabilities and unknown models upstream while preserving credential isolation and routing permissions.

Co-authored-by: Codex <noreply@openai.com>
2026-09-17 05:15:31 +08:00
ZyyandCodex 42c2b249c2 chore(release): v3.9.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 04:08:44 +08:00
ZyyandCodex 0b3ebc23f5 chore(workflow): refine documentation and release checks
Require documentation diff review by purpose, distinguish release migration baselines, and use the version tag directly in release commit titles.

Co-authored-by: Codex <noreply@openai.com>
2026-09-17 04:08:30 +08:00
ZyyandCodex 65bb9ab9cf docs: clarify setup and current contracts
Document one-click installation, consolidate manual deployment instructions, and describe current API and architecture contracts. Keep applicable configuration and upgrade guidance without recording implementation history.

Co-authored-by: Codex <noreply@openai.com>
2026-09-17 04:08:30 +08:00
ZyyandCodex 50d6f7444d fix(deploy): render installer configuration with Bash
Replace the embedded Python configuration writer with Bash quoting and placeholder validation. Reject control characters in administrator passwords and preserve existing deployment files on reruns.

Validation: Bash syntax, isolated installation checks, and loading generated YAML through the application configuration loader passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-17 04:08:17 +08:00
ZyyandCodex bcfea792e8 fix(config): tolerate unused startup settings
Ignore unused YAML fields across startup configuration and report their paths without values. Report missing required fields while retaining existing type and value validation.

Validation: configuration and bootstrap tests, strict Clippy, and architecture checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-17 04:08:17 +08:00
ZyyandCodex 1e7ea8d46f refactor(store): consolidate unreleased account migrations
Default account auto-freeze to off in its initial migration and renumber request location to 0011. Update checksum entries and migration tests.

These migrations were not included in v3.8.0. Existing PR freeze rules remain unchanged.

Validation: 18 gateway-store tests with isolated PostgreSQL, upgrade from the v3.8.0 schema, migration checksums and numbering, and Rustfmt.

Co-authored-by: Codex <noreply@openai.com>
2026-09-17 03:47:53 +08:00
bd61e0391a feat(settings): configure global and per-proxy request location (#123)
* feat(proxies): configure per-proxy request location

* feat(settings): manage global request timezone and location

Move request location from YAML into runtime settings with an enable switch. Preserve client fields when disabled and keep per-proxy overrides first. Share location inputs and simplify proxy and settings controls.

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 03:30:54 +08:00
499b43043e feat(accounts): add opt-in capacity cooldown and recovery (#126)
Add opt-in account capacity cooldown, recovery probes, and atomic adaptive concurrency updates. Include overload and slow_down in Smart scheduling feedback, expose rate-limit reasons, and keep probe results fenced by cooldown generation.

Exclude diagnostic probes and local connection failures from capacity evidence. Default auto-freeze to disabled and preserve explicitly saved settings with a forward migration.

Closes #120

Co-authored-by: zhangwentian <2han9wen71an@gmail.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 03:23:38 +08:00
ZyyandCodex ae2da31380 fix(accounts): improve provider selection and forecast hints
Expand provider choices to full-width labeled options and move the forecast caveat into an icon-only explanation popover.

Validation: frontend lint, typecheck and build passed; verified narrow-screen selection, keyboard navigation and explanation popover. Available forecast data layout was not exercised because local observations are insufficient.

Co-authored-by: Codex <noreply@openai.com>
2026-09-17 00:45:55 +08:00
1e6d69a349 fix(openai): align downstream requests with Codex protocol (#117)
Centralize downstream SDK, browser and proxy header compatibility in the
OpenAI provider. Normalize the session header alias and isolate known
non-Codex body parameters while preserving ordinary extensions.

Align account installation metadata with Codex Core and remove unverified
identity header classifications. Cover HTTP and WebSocket behavior and
document the request boundaries.

Validation: 1,105 scoped and architecture tests, Rustfmt and strict scoped
Clippy passed. Four real-account HTTP/WebSocket cases passed on gpt-5.5,
including connection reuse. Earlier gpt-reserve overload failures were
recorded separately without changing production retry policy.

Co-authored-by: Clarence <1297529513@qq.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-17 00:30:52 +08:00
2han9wen71anandCodex dac57a81c6 fix(providers): keep disabled accounts reachable for admin connection tests (#122)
Admin connection tests pin an account and bypass local availability, but
the scheduling store excludes disabled accounts before selection. Load
the pinned diagnostic account explicitly for OpenAI and xAI while keeping
normal scheduling and disabled-account state-write guards unchanged.

Apply the shared authentication and transport filters after restoring
OpenAI candidates. Align the memory stores with PostgreSQL filtering and
cover disabled-account diagnostics plus API key transport restrictions.

Validation: 652 OpenAI, 403 xAI, 350 Core, and 42 architecture/bootstrap
tests passed; Rustfmt and strict workspace Clippy passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-16 18:53:52 +08:00
d162f9b7f6 fix(accounts): run bulk imports as background tasks (#119)
* fix(openai): sync account plans with quota updates

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

* fix(accounts): run bulk imports as background tasks

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

* test(store): preserve settings in account plan refresh fixture

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-16 18:24:34 +08:00
866f71e3fb fix(openai): sync account plans with quota updates (#112)
* fix(openai): sync account plans with quota updates

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

* test(store): preserve settings in account plan refresh fixture

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-16 18:22:01 +08:00
yertandCodex 47ab48b4cf feat(deploy): add one-click installer (#116)
Automate version-matched Compose deployment, credential generation, bind-mount permissions, and startup health validation while preserving existing deployments.

Co-authored-by: Codex <noreply@openai.com>
2026-09-16 18:12:38 +08:00
e3cf4ab8be feat(ui): add persistent table column settings (#121)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-16 18:07:58 +08:00
4fb10f33f1 fix(update): reuse the served asset directory (#111)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-16 18:05:14 +08:00
c9318c54cd fix(proxy): support IPv6 egress probes (#110)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-16 18:05:08 +08:00
8d9aaf0bbd feat(accounts): support upstream API key accounts (#107)
Add Responses-compatible upstream accounts with isolated credentials, transport,
model catalogs, atomic settings updates, and account management controls.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-16 18:04:41 +08:00
ZyyandCodex 52d24d29c7 chore(github): improve issue reporting templates
Co-authored-by: Codex <noreply@openai.com>
2026-09-16 17:29:01 +08:00
ZyyandCodex 3fbd447d94 chore(release): prepare v3.8.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-16 03:03:43 +08:00
ZyyandCodex 5a5cdbdcde fix(ui): refine account settings and key displays
Show all batch account settings by default while updating only edited fields. Add ellipsis and full-text titles to API key names and labels, and adjust the token usage summary icon size and alignment.

Co-authored-by: Codex <noreply@openai.com>
2026-09-16 02:54:23 +08:00
ZyyandCodex cca89f2f96 feat(accounts): add per-account model restrictions (#105)
Add per-account model allowlists and denylists to frozen routing, account settings, and CPR import/export. Preserve existing scheduling and account state boundaries.

Closes #97

Co-authored-by: Codex <noreply@openai.com>
2026-09-16 02:29:56 +08:00
e25aad2231 feat(gateway): add bounded concurrency queues for keys and accounts (#104)
* feat(auth): add API key workspace

Add persistent API key sessions and enforce admin/key authorization boundaries. Expose key-scoped quotas, trends, health, and paginated usage records through server-projected data.

Share the application shell, system overview, and usage views between admin and key sessions, reorganize frontend domains around view ownership, and omit account, provider, and authentication details from the key workspace.

BREAKING CHANGE: Browser authentication now uses /api/auth/* and cpr_session; existing browser sessions must sign in again.

Validated frontend lint, type checking, production build, backend workspace tests, and live authenticated key views.

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

* feat(console): refine key workspace experience

Unify role-aware console routes and use mode/role in authentication contracts.
Reuse request state, stable pagination and performance projections across workspaces.
Refine quota cards, usage panels, empty states and key-aware theme previews.
Skip session restoration on login and retain form inputs until unmount.
Keep the full health timeline grid visible without samples.

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

* feat(key-usage): replace shared workspace with focused usage page

Preserve unified admin/key authentication, restore admin pages, and remove the previous client workspace implementation. Add a scoped usage page with metrics, trends, budgets, request logs, and section loading placeholders.

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

* feat(gateway): queue requests at key and account concurrency limits

Share one wait deadline across client admission and account selection.

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-16 02:00:26 +08:00
84382761fc feat(auth): add focused API key usage page (#102)
* feat(auth): add API key workspace

Add persistent API key sessions and enforce admin/key authorization boundaries. Expose key-scoped quotas, trends, health, and paginated usage records through server-projected data.

Share the application shell, system overview, and usage views between admin and key sessions, reorganize frontend domains around view ownership, and omit account, provider, and authentication details from the key workspace.

BREAKING CHANGE: Browser authentication now uses /api/auth/* and cpr_session; existing browser sessions must sign in again.

Validated frontend lint, type checking, production build, backend workspace tests, and live authenticated key views.

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

* feat(console): refine key workspace experience

Unify role-aware console routes and use mode/role in authentication contracts.
Reuse request state, stable pagination and performance projections across workspaces.
Refine quota cards, usage panels, empty states and key-aware theme previews.
Skip session restoration on login and retain form inputs until unmount.
Keep the full health timeline grid visible without samples.

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

* feat(key-usage): replace shared workspace with focused usage page

Preserve unified admin/key authentication, restore admin pages, and remove the previous client workspace implementation. Add a scoped usage page with metrics, trends, budgets, request logs, and section loading placeholders.

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-16 01:49:37 +08:00
0a09c1572f feat(accounts): add notes and refine account interactions (#100)
Persist optional account notes across creation, editing, imports, and credential refreshes. Show notes in compact account rows with overflow-only popovers.

Support vertically resizable textareas, bounded select menus with custom scrollbars, and quota updates without table loading.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-16 01:36:34 +08:00
ZyyandCodex 90deabdfe3 docs(skills): ground upstream compatibility reviews in evidence
Co-authored-by: Codex <noreply@openai.com>
2026-09-15 16:36:59 +08:00
2han9wen71an b1c24e276b fix(gateway-api): project bare WebSocket errors to response.failed (#99)
codex 的 WS 客户端(codex-api/src/endpoint/responses_websocket.rs)只消费
两类 error 帧:带非 2xx HTTP status 的包装错误,以及
websocket_connection_limit_reached / previous_response_not_found 内置可重
试错误码。OpenAI 上游的中流错误帧(如 server_is_overloaded)没有 status
字段,map_wrapped_websocket_error_event 返回 None,落回
process_responses_event 也没有 error 分支——整帧被静默忽略。

客户端收不到可识别的终态,只能空等到自身 300s stream_idle_timeout 超时
再重连,5 次重连累计 20-30 分钟"正在思考",最后才把失败呈现出来。CPA
等实现之所以秒级报错,正是因为它把错误负载重写成 codex 可消费的终态。

#90 已在 SSE 边界做了同样的投影,但客户端 WS 边界(push_websocket)仍原
样转发;使用 WS transport 的 codex 桌面端不受 #90 保护。本提交在该边界
对不可消费的错误帧投影成 response.failed(消息 JSON 自带 type 字段,经
process_responses_event 的 response.failed 分支映射为可重试错误并立即重
试);带非 2xx status 或特殊错误码的可消费形状原样透传,保持
deliverable_business_failures_are_not_rewritten... 既有契约。
2026-09-15 16:31:07 +08:00
1235 changed files with 177246 additions and 23606 deletions
+49
View File
@@ -0,0 +1,49 @@
---
name: cpr-dev-guide
description: Codex Proxy RS 仓库的开发、修复、排障、代码审查和文档修改入口;PR、Issue、插件创作与发版按任务另用对应技能
---
# 开发指南
## 加载边界
- 遵守已提供的 [AGENTS.md](../../../AGENTS.md),上下文已有的规则不重复读取
- 下表是条件导航,不是必读清单,只加载当前任务命中的技能与章节,进入新阶段再补读
- 文档链接带 `#章节` 不代表读取工具会自动截取内容,先用 `rg -n '^#{1,4} ' 文件` 找边界,再按范围读取;常规定位不整份加载 CONTRIBUTING、架构、API、主题或全部 references,确需全文审查时再展开
- 每次补读先明确要解决的未知项,已有资料足够就继续工作;文档提及 Rust、Vue、插件或发布,不等于要加载对应开发流程
## 按任务读取
| 当前任务 | 必读的相关部分 |
| --- | --- |
| 开发、排障、代码审查 | [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据),需要定位代码时按 AGENTS 的 CodeGraph 约定执行 |
| 模块或调用链变更 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)及所属模块章节,不通读整份架构 |
| Rust 开发或审查 | `$rust-best-practices`、所属模块、[后端自审](../../../docs/architecture.md#后端自审),验证时读[命令](../../../docs/architecture.md#验证命令) |
| 页面开发 | `$frontend-design`、[文案与信息层级](../../../docs/theme.md#界面文案与信息层级)、[界面验证](../../../CONTRIBUTING.md#界面验证);修改已有页面前留存截图,主题算法仅在涉及时读取 |
| 接口、部署或迁移 | 分别定位 [API](../../../docs/api.md)、[部署](../../../deploy/README.md)、[迁移](../../../backend/migrations/README.md)的对应章节 |
| SDK、Runtime、宿主扩展或独立插件 | [插件职责边界](references/plugin-boundaries.md);只有创作、排查独立网关插件时使用 [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md) |
| 修改或审查文档 | [文档检查](references/documentation.md)及目标章节,不默认读取代码开发、页面验收或发布流程 |
| 普通代码审查 | [审查标准](../../../CONTRIBUTING.md#审查标准);审查默认只读 |
| PR / Issue / 发版 | 按当前意图选 [PR](../cpr-github-pr/SKILL.md)、[Issue](../cpr-github-issue/SKILL.md)或[发版](../cpr-release/SKILL.md),普通提交不创建 PR |
## 参考仓库的优先级
- 涉及 Codex 行为、协议或兼容性时,以官方 [openai/codex](https://github.com/openai/codex) 对应版本的源码与调用链为第一手资料,先查官方实现
- 优先使用用户提供或本机已有的官方仓库,核对来源、版本或提交;资料不足时再查官方对应版本的源码、测试与文档
- 三方仓库只能提供排查线索或实现参考,不能代替官方行为依据;与官方不一致时回到官方源码核实,不直接照搬三方结论
- 结论注明适用版本或提交,区分官方事实、三方参考与本项目推断;官方证据不足时保留不确定性
## 执行与收尾
1. 确认工作区、任务范围与已有改动,先定位事实归属、同类实现和调用链,再修改;编码前只读[项目约定](../../../CONTRIBUTING.md#项目约定)的对应条目
2. 自审完整差异,处理本次引入的职责越界、重复规则、冗余状态与无依据分支;仅审查时报告,不自行修复
3. 按变更范围读取[验证](../../../CONTRIBUTING.md#验证),区分通过、失败、跳过与未执行;提交时补读[提交约定](../../../CONTRIBUTING.md#项目约定)
4. 交付说明实际结果与验证缺口;页面证据和后端生命周期检查按上表执行,不把构建通过当作行为验收
## 文档硬约束
**常规文档只描述当前状态,不写变更历史**,这项检查适用于每次开发收尾,不限于专门的文档任务
- 动笔前确定读者、所属章节与必须说明的当前事实,没有必要就不改文档
- 有文档差异时执行[文档检查](references/documentation.md),逐段决定保留、改写、删除或移入任务报告,未完成内容审查不能称文档已检查
- 没有文档差异时只确认现有说明是否失真;普通开发不自动修改 `release/notes.md`
@@ -0,0 +1,6 @@
interface:
display_name: "cpr-dev-guide"
short_description: "仓库开发、修复、排查、代码审查与文档修改的流程入口"
default_prompt: "使用 $cpr-dev-guide 按当前任务定位问题与现有实现,完成已授权的开发或审查,并自审、验证和报告结果。"
policy:
allow_implicit_invocation: true
@@ -0,0 +1,38 @@
# 文档检查
仅在准备修改文档或最终差异含文档时读取,职责与行文依据是 [文档职责与更新条件](../../../../CONTRIBUTING.md#文档职责与更新条件),只读该节
## 写之前
先明确这段内容服务谁、解决哪个当前使用或维护问题、属于哪个现有章节,再决定详略
接口保留调用合同,架构保留职责与不变量,部署保留操作与排障;同一事实只在所属位置完整说明,其他位置链接
常规文档在原章节修订,不追加本次变更小节,不为了“同步文档”补写修复总结
内部重构、实现经过、前后对比、验证结果应留在 PR、Issue 或任务报告
## 逐段验收
检查工作区和暂存区中本任务的最终文档差异,按内容用途处理,不只搜索关键词:
| 段落回答的问题 | 处理 |
| --- | --- |
| 现在如何使用、配置或理解结果 | 保留当前事实与必要边界,放回所属章节 |
| 谁负责状态、事务、资源和失败处理 | 放入所属架构章节,避免在 API 或部署文档复述实现 |
| 这次做了什么、以前怎样、为什么这样修、跑过哪些测试 | 移入 PR、Issue 或任务报告,不以换成现在时的方式留在常规文档 |
| 特定配置或数据状态下如何升级、恢复 | 写明适用条件、操作及限制,删除演变过程 |
| 别处已完整说明的事实 | 合并或改为必要摘要与链接 |
脱离提交日期、Issue 和本次任务背景后,读者仍应能独立理解正文
业务中的请求历史、审计记录、旧数据兼容与回滚操作是当前功能,不能因含“历史”“旧”等词而删除
## 交付检查
- 检查新段落的必要性、归属、重复与当前状态表达,发现变更叙述先改完再交付
- 标题、短列表、表格和独立短段通常不加句号,长段按主题拆分,命令与字段保持准确
- 检查相对链接、章节锚点、表格与命令示例,`git diff --check` 或构建通过不能代替内容审查
- 报告实际覆盖和缺口即可,不把检查记录写进常规文档
只有明确的发布说明任务才进入 `cpr-release`,`release/notes.md` 用于该次发行,不向 README、API、架构或部署文档复制版本变化列表
@@ -0,0 +1,11 @@
# 插件职责边界
仅在开发或审查 SDK、Runtime、宿主扩展接口及独立网关插件时读取
先看[插件扩展](../../../../docs/architecture.md#31-插件扩展)中的职责与执行边界,不通读整份架构
**宿主可以暴露底层能力,不能替插件完成业务**
- 宿主提供可独立组合的受控原生能力,负责授权、资源隔离、事务与审计,复用已有服务与状态归属
- 插件自身的触发条件、跨资源关联、策略和流程编排由插件实现,不能把某个使用场景做成宿主专属流程、状态或补偿逻辑
- 发现能力缺口时,界定可独立使用的基础接口,说明输入输出、权限、事务、失败及重复调用语义,不夹带插件私有数据模型或工作流
- 自审调用链、合同、测试与文档中的业务判断、自动串联和专属状态,仅换成通用名称不算解除耦合
+62
View File
@@ -0,0 +1,62 @@
---
name: cpr-github-issue
description: 草拟、创建、补充或回复 GitHub Issue,包括缺陷、功能和协作规则反馈;单独排障或修复不自动发布 Issue
---
# Issue 协作
## 目标与范围
从用户 URL、remote 和上下文确认接收仓库,fork 场景区分自己的仓库与上游
草拟只准备正文,明确创建或提交才发布;已有授权直接执行,自动命中技能本身不授权远程写入
补充已有 Issue 先读原帖和讨论,按授权更新或追加,不重复新建;无法推断的目标或关键事实才向用户补齐
## 按需取材
- 新建 Issue 时,本仓库只读[提交 Issue](../../../CONTRIBUTING.md#提交-issue)及本次匹配的一张表单:[接口问题](../../../.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题](../../../.github/ISSUE_TEMPLATE/general-bug-report.yml)或[功能建议](../../../.github/ISSUE_TEMPLATE/feature-request.yml);协作规则提案可用普通 Issue
- 回复时读取原帖、相关讨论与支撑答复的证据,按下方回复口吻组织内容,不套用新建 Issue 的表单
- 其他仓库读取它自己的相关规则与实际模板,长文档先定位标题再截取,不预读全部模板或 CONTRIBUTING
- 搜索同场景 open/closed Issues、相关 PR 和发布记录,区分主分支修复与已发布修复,不凭标题判断重复;没有新证据时返回已有链接,用户仍要求新建则按其要求处理
- 仅当需要额外排查或修改时使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),提交症状反馈不以找到根因为前提
- 引用上游行为或提出方案时读取[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据),涉及 Codex 时按[参考仓库优先级](../cpr-dev-guide/SKILL.md#参考仓库的优先级)先查官方 Codex 源码,三方仓库仅作参考;注明适用版本、来源与缺口
## 正文与证据
标题说明症状或期望行为,区分发生问题的部署版本、实测结果、预期结果与根因假设
功能和规则提案说明场景、缺少的能力及建议结果,无需硬套缺陷复现步骤
YAML 表单按 `attributes.label` 和字段顺序组织正文,手工核对必填项、选项与确认项,CLI 不会代做表单校验
未知信息如实标明,影响理解或复现的关键事实先补齐,不提交原始 YAML、占位答案或未确认的勾选项
日志和附件仅含定位所需的脱敏片段,不公开凭据、完整配置或请求转储,不擅自修改代理、TUN、证书环境取证
附件须获授权且可访问,本机路径不算附件;labels 和 assignees 仅按模板或用户要求设置,并确认实际存在
Issue 可记录复现、排查和变更经过,不因此向常规文档追加历史;确需改文档时执行[文档检查](../cpr-dev-guide/references/documentation.md)
## 回复口吻
草拟和发布 Issue 回复时遵守以下约定:
- 用第一人称“我”表达,称呼对方为“佬友”,称呼自然出现即可,不逐句重复,也不用“我们”“本助手”代称自己
- 语气客气、委婉,直接回应对方的具体问题;补充信息或提出不同意见时说明缘由,避免命令式、责备式表达
- 使用自然短句,简单回复用短段,只有并列信息较多时才分点;少用句号,避免客服套话、机械模板和空泛保证
- 只把实际完成的排查、复现和验证写成“我已……”;未确认的事实如实说明,委婉表达也要说清结论、限制和下一步
## 发布与收尾
使用可用连接器或 `gh`,不为提交反馈安装工具、修改仓库配置或启用服务
多行正文通过结构化字段或 UTF-8 文件传递,以下变量须先从已确认的目标与正文赋值:
```bash
gh issue create --repo "$issue_repo" --title "$issue_title" --body-file "$issue_body_file"
```
发布前核对目标、必填内容、脱敏与重复记录,更新时保留无关原文;不擅自评论、重开或关闭重复 Issue
超时或结果不明确时先查询远端再重试,发布后回读确认并返回链接;仅草拟或发布失败时提供正文并注明未提交
@@ -0,0 +1,6 @@
interface:
display_name: "cpr-github-issue"
short_description: "整理、提交或补充 Issue,核对复现、重复反馈与发布范围"
default_prompt: "使用 $cpr-github-issue 按当前请求整理、提交或补充 Issue,核对目标、复现证据、重复记录和脱敏,不将草拟自动变成发布。"
policy:
allow_implicit_invocation: true
+51
View File
@@ -0,0 +1,51 @@
---
name: cpr-github-pr
description: 准备、创建、更新或审查 GitHub PR,含回复讨论与处理审查意见;普通 Git 提交和无 PR 上下文的排障不创建 PR
---
# PR 协作
## 先确定范围
沿用当前会话的目标与授权,创建 PR 包含任务范围内的提交和推送,审查默认只读;二者都不包含合并
仅准备标题或正文不写远端,“能合并吗”按审查处理,发布评论、批准或请求修改须有对应授权
从 URL、remote 和上下文确认仓库,区分 fork 的推送端与 PR 接收端;存在无法消除的歧义时只补齐必要信息
本仓库目标分支为 `main`,其他仓库使用其可信基线规则与模板,PR 内修改的规则不能降低审查要求
## 按模式读取
只加载当前模式,已有上下文不重读;长文档先定位标题再截取对应章节,不因链接存在而加载全文或所有 references
| 当前动作 | 读取与执行 |
| --- | --- |
| 只整理标题或描述 | 当前 PR、最终 diff、实际模板、[AI 披露](../../../CONTRIBUTING.md#ai-使用披露),在授权范围内改文案;不读取推送或审查流程 |
| 创建或更新 PR | [创建流程](references/create.md)、[PR 流程](../../../CONTRIBUTING.md#pr-流程)与[规则文件保护](../../../CONTRIBUTING.md#规则文件保护) |
| 审查 PR | [审查流程](references/review.md)、[审查标准](../../../CONTRIBUTING.md#审查标准) |
| 回复讨论或审查意见 | 目标评论、相关上下文与实际处理结果,按下方回复口吻答复;仅回复不加载创建或完整审查流程 |
| 需要修改代码或文档 | 使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)的对应分支,完成自审与验证 |
| 含页面改动 | [界面验证](../../../CONTRIBUTING.md#界面验证),必需截图须可访问且对应当前实现 |
| 含文档改动 | [文档检查](../cpr-dev-guide/references/documentation.md),常规文档只说明当前状态,变更经过留在 PR |
## 回复口吻
草拟和发布 PR 讨论、审查意见的回复时遵守以下约定:
- 用第一人称“我”表达,称呼对方为“佬友”,称呼自然出现即可,不逐句重复,也不用“我们”“本助手”代称自己
- 语气客气、委婉,直接回应对方的具体问题;补充信息或提出不同意见时说明缘由,避免命令式、责备式表达
- 使用自然短句,简单回复用短段,只有并列信息较多时才分点;少用句号,避免客服套话、机械模板和空泛保证
- 只把实际完成的修改和验证写成“我已……”;未采纳的意见说明依据,待处理或未验证的内容如实交代,委婉表达也要说清结论和下一步
## 创建与审查的公共核对
- 按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核实触发条件、发生版本与目标分支现状;涉及 Codex 时按[参考仓库优先级](../cpr-dev-guide/SKILL.md#参考仓库的优先级)先查官方 Codex 源码,三方仓库仅作参考
- 核对最新目标分支及同场景 open/closed PR、Issue,按实际行为和测试判断重复,不凭标题下结论,也不擅自关闭他人 PR
- 自审或审查覆盖完整差异与调用链,文档逐段检查必要性、职责、重复和当前状态表达,构建与链接检查不能代替内容判断
- 核对[AI 使用披露](../../../CONTRIBUTING.md#ai-使用披露),填写实际工具与模型,不猜型号;必需截图或型号待补时保留 Draft
- 验证结果对应实际 head,区分通过、失败、跳过与未执行;无文档改动时只检查现有说明是否失真
远程操作优先使用可用连接器,`gh` 显式指定 `--repo`,多行正文使用结构化字段或 `--body-file`
返回链接或会话内结论,说明实际范围与缺口,不将检查通过称为维护者批准
@@ -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
@@ -0,0 +1,35 @@
# 创建或更新 PR
仅在创建、推送或更新 PR 时读取,先完成[入口](../SKILL.md)的范围判断与公共核对
1. 检查工作区、当前分支、远端和近期提交格式。读取实际存在的 PR 模板,按最终变更重写标题与描述。
不用提交日志拼接正文,也不假定仓库一定有模板。
2. 更新并确认 base/head,查看相对 merge-base 的完整差异和待提交内容;核对仓库的受保护文件与准入规则。
只纳入任务范围的文件和提交;需要整理时用独立功能分支,保留用户其他改动,不擅自改写已发布历史。
3. 按入口的公共核对,在目标分支基线核实问题,追踪官方与本项目的相关调用链,先确定解决方向再实现。
若改动已完成,也要据此复核问题与方案;需要修正时只修改已授权的范围。仅整理标题或正文时报告差异与缺口,
不自行修改实现。原问题不成立时不将它包装成已修复的缺陷;证据不足时明确保留结论。
按 `cpr-dev-guide` 完成实现自审;后端按 [后端自审](../../../../docs/architecture.md#后端自审) 核对归属、状态、失败出口与副作用,
页面先复用已有设计并检查文案密度。处理本次引入的冗余与越界问题,功能可用和检查通过不能替代可维护性自查。
4. 查找同一来源仓库、head 和 base 的现有 PR;已存在则复用其链接,不重复创建。
更新现有标题或正文须在本次整理/更新请求范围内。
5. 按[验证](../../../../CONTRIBUTING.md#验证)执行适用检查。记录实际验证的提交或工作区状态,区分通过、失败、跳过和未执行。
有文档差异时按[文档检查](../../cpr-dev-guide/references/documentation.md)检查最终 diff;没有文档差异时,核对行为变化是否使现有说明失真。
页面改动按入口链接的界面验证要求补齐截图与实际交互证据,核对模型披露是否完整。
6. 将任务范围内的提交推送到正确远端,核对远端 head。标题遵循项目约定,正文先说明问题成立的依据和最终行为,
再给必要的设计取舍、自审结论、验证与遗留事项,按模板附页面截图和 AI 使用情况。涉及官方行为时,写明官方源码位置与版本或提交、官方处理方式、
本次实现与它的关系及必要的偏离原因;区分源码推断和实际复现,不用检查通过替代方案论证。按规模压缩,不强填无关章节。
7. 明确要求草稿或仍有阻塞项时创建或保持 Draft PR,并解释原因;必需截图或 AI 模型披露待补时也不标为可审阅。具备审查条件且未要求草稿时创建普通 PR。
GitHub Draft 状态与“只写本地草稿正文”不同,后者不创建远程对象。
8. 创建或更新后回读正文与附件,核对链接、base、head、草稿状态、模型披露和最新检查结果。网络超时或结果不确定时先查询远端,再决定是否重试。
CLI 示例中的变量必须先根据上述检查赋值;`pr_body_file` 是精确写入 UTF-8 正文的临时文件:
```bash
gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
--title "$pr_title" --body-file "$pr_body_file"
```
按需添加 `--draft`。不要用 `gh pr create --dry-run` 做只读预览,它仍可能推送 Git 变更。
多行正文通过文件或连接器的结构化字段传递,不把未转义正文拼进 shell 命令。
创建完成返回链接、目标分支和验证摘要;不要称为已合并或已获维护者批准。
@@ -0,0 +1,33 @@
# 审查 PR
仅在审查 PR 时读取,执行[入口](../SKILL.md)的公共核对,默认只在当前会话报告
1. 获取 PR 的 base/head SHA、描述、文件差异、已有讨论及 CI 状态。记录实际审查的 head,使用 merge-base 差异。
需要源码上下文时先遵守目标仓库的定位规则;需检出时使用隔离工作区,保留用户当前分支和运行环境。
2. 先核实 PR 要解决的问题是否成立:提取触发场景、预期行为和声称的原因,在目标分支基线检查实际行为。
按入口的公共核对收集一手证据,区分原有问题、环境或配置差异与 PR 新引入的问题,说明主分支已覆盖的部分。
3. 从官方方案评估解决方向:说明官方如何处理同一场景,检查 PR 是否遵循其协议语义和职责归属、
是否复用已有能力或正确传递官方合同;核对偏离官方方案的理由与运行证据。
审查 OpenAI 官方上游与 Codex 的对接时,默认官方返回与 Codex 兼容、直接对接正常,重点检查本项目是否误解、
改写或破坏官方合同。不得仅凭自行构造的异常上游数据揣测官方不兼容,也不据此报告缺陷、提出改进建议,
或要求增加防御分支与测试。讨论特定版本或场景的对接例外须有真实响应或实际复现证据,并区分代理链路与官方直连。
4. 按贡献文档的审查维度追踪完整调用链,找到共享业务事实、同类实现及已有验证。
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
主动检查本次变更新引入或加重的冗余与代码异味,说明具体位置、维护成本或行为影响及最小修复方向;
不能仅因功能可用、CI 通过而略过,也不把个人写法偏好判为缺陷。
5. UI 改动同时对照原页面、主题、共用组件和同类页面,核对结构变化的必要性、文案是否重复及帮助是否适合收进 Popover。
按页面类型检查必需截图是否齐全、可访问且对应待审实现;缺少必需截图时要求补齐并列为未具备审阅条件。
浏览器可用时检查相关交互;未运行的检查列为验证缺口,不从截图或源码推断所有状态都正确。
6. 核对 CI 与本地证据是否覆盖当前 head;根据风险补充必要验证,不触发无关部署或真实账号业务请求。
有文档差异时按[文档检查](../../cpr-dev-guide/references/documentation.md)检查最终 diff,不能把构建或链接检查通过当作文档内容已经合格。
核对 AI 使用与模型披露,缺项明确指出。工具不可用、集成测试跳过或环境不足时具体说明影响范围。
7. 按 [审查标准](../../../../CONTRIBUTING.md#审查标准) 用中文先回答原问题是否成立、解决方向是否合理,
再列出实现问题与验证缺口,结论附具体依据。
实现问题区分必须修复项、可选建议和验证缺口;没有的类别可省略。
每个问题给变更中的文件位置、触发条件或规范依据、影响和最小修复方向。
复用建议指出具体已有实现,不把个人偏好写成缺陷;未发现问题时直接说明并交代覆盖范围。
8. 结束前核对 head 是否变化。发生变化时检查新增差异并更新结论,或明确结论只适用于原 SHA。
审查本身不修改源码、不发表评论、不提交 GitHub 的批准或请求修改状态。
用户明确要求发布时,先复核当前 head 和重复评论,再通过结构化字段或 `--body-file` 发布对应内容;
需要绑定具体提交时使用支持 commit ID 的审查接口。不要在旧提交结论上批准新的 head。
+40
View File
@@ -0,0 +1,40 @@
---
name: cpr-plugin-dev
description: 开发、排查或打包 Codex Proxy RS 独立网关插件;仅改宿主 Runtime、安装器或管理端时使用 cpr-dev-guide,不适用于 Codex 插件或 Vue 插件
---
# 网关插件开发
插件在独立项目中通过公开 SDK 实现业务,不向宿主业务模块添加专属流程
同时使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)的对应任务分支,已加载则复用,不递归重读
## 范围与合同
- 确认用途、工程位置、目标宿主版本与平台,沿用已有工程;位置无法推断时只补齐该信息,不把插件源码塞进宿主 workspace
- 核对运行宿主、[SDK 版本](../../../backend/crates/gateway-plugin/sdk/Cargo.toml)、[宿主支持清单](../../../backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json)及所用 CLI,清单、进程和能力版本分别判断
- `contributes` 是能力,`permissions` 是访问域,`bindings` 是挂载与匹配范围;安装成功不等于功能生效,`trustedProcess` 也不是操作系统沙箱
- 涉及 Codex 时按[参考仓库优先级](../cpr-dev-guide/SKILL.md#参考仓库的优先级)先查官方 Codex 源码,三方仓库仅作参考;宿主能力以目标版本的 SDK 为准
## 按当前步骤读取
只读取命中行与链接的指定章节,长文档先列标题再截取;不预读全部 SDK、示例、前端和打包资料
| 当前工作 | 入口 |
| --- | --- |
| 选择能力或确认能力缺口 | [能力与方法](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#能力与方法)、所需能力章节、[插件职责边界](../cpr-dev-guide/references/plugin-boundaries.md) |
| 清单与 Rust 处理器 | [实现参考](references/implementation.md)、所用 SDK 类型;只有修改清单字段时读[清单](../../../backend/crates/gateway-plugin/sdk/docs/manifest.md)对应章节 |
| 创建或修改页面 | [页面参考](references/frontend.md),不需要页面的插件不创建前端 |
| 准备工程或联调依赖 | [工程与依赖](references/development.md#工程与依赖),需要示例时再读[复用示例](references/development.md#复用示例) |
| 构建或打包 | [构建与打包](references/development.md#构建与打包) |
| 实际验收 | [验证与交付](references/development.md#验证与交付),按实现范围验证 |
| 分发插件 | [发布与安装来源](references/development.md#发布与安装来源),不自动进入宿主发版流程 |
| 修改插件文档 | [文档检查](../cpr-dev-guide/references/documentation.md),只写当前用法、合同和限制,不附修复过程 |
## 交付
说明源码与产物位置、兼容范围、能力与权限、实际验证及缺口,只报告真正生成的安装包或完成的业务验证
修改源码不默认授权在目标实例安装启用、调用真实上游或发布,沿用用户当前授权判断后续动作
PR 任务才使用 [cpr-github-pr](../cpr-github-pr/SKILL.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,115 @@
# 构建、联调与验证
仅在准备工程、构建或验证插件时读取对应章节,分发规则只在准备分发时读取
这里给出执行入口,不复制 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 |
先检查用户给定工程、`modules/plugins` 子模块或同级仓库是否存在,读取实际仓库约定及当前步骤所需的 README 章节、依赖与锁文件,不预读无关前端配置
只需中间件时不复制示例的管理 API、页面和相关权限;CLI 等能力从 SDK 对应合同起步,不给示例添加无关能力。
本仓库子模块联调使用[开发文档](../../../../docs/development.md#源码联调)中的 source 模式和本地 SDK 验证入口,不改写正式依赖或锁文件
其他工程按其自身联调配置临时使用包级链接或 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。
交付简述:源码与产物位置、兼容范围、所需能力/权限/绑定、执行过的命令及结果、尚未验证的场景。
只有确实生成了产物才报告“已打包”,只有目标环境业务验证成功才报告“已生效”;提交、推送与发布按用户授权另行执行。
@@ -0,0 +1,11 @@
# 插件页面
仅在创建或修改插件页面时读取,同时使用[开发入口](../../cpr-dev-guide/SKILL.md)的页面分支
- 沿用已有插件页面与 [管理端主题](../../../../docs/theme.md#界面文案与信息层级) 的设计和文案层级,详细帮助使用现有 Popover;页面 PR 按 [界面验证](../../../../CONTRIBUTING.md#界面验证) 提供实际截图
- 优先参考独立示例仓库的 `examples/workbench/frontend/src/api/`:业务路由放在 `modules/`,`request.ts` 封装公开宿主桥;按需使用 `@codex-proxy/ui` 包出口,不跨仓导入宿主或 UI 的内部源码
- Vue 页面保留 SFC,逻辑、脚本和配置使用 TypeScript;只实现业务内容,标题和副标题由 `ManagementPage` 交给宿主显示
- 隔离页面通过 `window.codexProxyPlugin.request` 调用已注册管理路由;`models.responses` 通过普通请求链交付 JSON/SSE,支持取消且不暴露 Key 明文。不直接 `fetch` 宿主、不读取管理 Cookie 或借用宿主 Vue 实例
- 管理路由的响应正文由插件业务定义,不默认套用宿主管理 API 的 `{ code, message, data }`;页面与处理器共享实际合同,GET 无正文时不附带 JSON 正文声明
- `host.model.*` 子请求跳过发起插件,不能用它证明本插件中间件/路由已参与;需要完整请求链的示例使用页面模型桥或真实客户端
- JS、CSS、图标等资源随包构建和声明;沿用宿主桥的主题同步。Vite 本地预览不等于已安装插件的热更新,宿主验证仍需重新构建、打包和切换版本
@@ -0,0 +1,24 @@
# 清单与处理器实现
仅在编写或排查插件清单、Rust 处理器时读取,涉及 Rust 时使用[开发入口](../../cpr-dev-guide/SKILL.md)的 Rust 分支
选择能力时只读 SDK 的[能力与方法](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#能力与方法)及所需能力章节
## 清单与处理器
- `publisher` 与机器短名 `name` 派生插件 ID,`displayName` 用于展示;复制示例后同时调整扩展项 ID 与注册结果
- `contributes` 每种能力最多一项,普通贡献项可省略派生 ID、能力版本和固定阶段;中间件阶段仍需显式选择。自定义完整扩展项 ID 属于自己的插件命名空间,运行注册与规范化清单一致
- 作者清单用 `Manifest::from_author_slice` 规范化,不手写 `package`、固定阶段或重复注册;CLI 生成平台、协议与资源摘要,版本范围不能使用全版本通配
- 安装后宿主会按 `configurationSchema` 准备默认配置,配置完整即可启用;默认绑定的空范围不限制请求。需要业务参数时声明真实必填项,通过 `secretFields` 声明敏感字段,不依赖用户再走一遍自建安装向导
- Rust 插件只依赖公开 `gateway-plugin-sdk`;需要异步会话辅助时开启 `io`,使用 `PluginSession` 管理握手、回调关联、流控、取消和关闭
- 优先用 `PluginBuilder::from_json` 组合 `.middleware`、`.management`、`.command_line` 与 `.on(methods::..., handler)`,由构建器生成注册并检查处理器;单一中间件也可用 `MiddlewarePlugin`
- stdout 是二进制协议通道,不能用 `println!` 输出诊断。使用基础设施 `host.log`,不输出 secret 或完整请求
## 中间件与宿主服务
- `request` 包裹整个逻辑请求,`attempt` 在每次选定账号的尝试中执行;根据作用范围选择,避免重试时重复副作用
- `next` 只能消费一次。使用 SDK 的正文保留与流式映射能力,不自行拼一套 SSE/取消/流控机制;请求、响应及策略处理使用 `requests` 域
- 持久化状态使用已声明命名空间的 `host.state.*`,账号读写使用 `host.auth.*`,不直接访问宿主数据库。见[账号与凭据](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#账号与凭据)和[状态、日志与迁移](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#状态日志与迁移)
- 权限从目标 SDK 的[访问域](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#访问域)选择,不在技能中维护枚举副本;日志和自身状态无需授权项,安装器不配置 Key/账号白名单,资源选择属于插件业务
- 模型、网络和亲和查询使用当前父调用的受管回调。管理/CLI 等独立入口调用模型时传明确的 Key ID;请求处理阶段继承父身份。见[Key、模型与模型调用](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#key模型与模型调用)
- SDK 操作名不是管理员 HTTP 路由,不能据此拼接口地址;宿主身份、账单事实和调用授权也不能由插件覆盖
- 路由与调度只读取宿主投影的请求事实,身份头可能被隐藏;使用已有会话和亲和合同,不依赖原始认证头或根据缓存键补造客户端身份
+38
View File
@@ -0,0 +1,38 @@
---
name: cpr-release
description: Codex Proxy RS 的版本规划、发布说明、正式或预发行发布与失败恢复;部署重启和普通 Git tag 清理不属于发版
---
# 发版
## 范围与授权
沿用用户确定的版本、范围与授权,准备版本或询问能否发布不执行远程写入;明确发布时完成已授权步骤,无需重复确认
仅打 tag 不自动扩展为完整发版,发布也不包含运行实例升级或合并实验功能
稳定版与 alpha、beta、rc 从 `main` 发布,exp 从对应 `experimental/<用途>` 分支发布
其他仓库使用其发布入口与规则,不套用本仓库路径;规则已在上下文中时不重复读取
## 按任务读取
只读当前阶段和指定章节,先定位标题再截取,不预读整份 CONTRIBUTING、工作流或所有参考文件;实际发布前仍须核对发布入口的完整副作用
| 任务 | 读取范围 |
| --- | --- |
| 推荐版本号 | [基线与版本](references/preparation.md#基线与版本) |
| 编写或翻译发布说明 | [基线与版本](references/preparation.md#基线与版本)、[发布说明](references/preparation.md#发布说明) |
| 检查发行条件 | [发行准备检查](references/preparation.md#发行准备检查) |
| 实际发布 | 完成准备,再读[通道核对](references/operations.md#发行通道核对)和[执行发版](references/operations.md#执行发版) |
| 失败恢复或验收 | [恢复与验收](references/operations.md#恢复与验收),先确定已完成步骤,不直接重跑入口 |
| 实验版 | 在对应阶段补读[实验流程](references/experimental.md)的版本、文案或验收章节 |
| 修复代码、工作流或常规文档 | 使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),有 PR 任务再使用 [cpr-github-pr](../cpr-github-pr/SKILL.md) |
## 必须保持的边界
- `release/publish` 会提交、打 tag、推送并触发工作流,没有只读预演模式,不能用于探测或演练
- 同名版本、超时或中断先查本地与远端状态,不删已发布 tag、不移动 tag、不强推掩盖冲突
- 预发行不得覆盖 GitHub Latest 或镜像 `latest`,完成后回读核对
- `release/notes.md` 只承载当前发行的说明,常规文档只写当前状态,不追加版本历史;其变更执行[文档检查](../cpr-dev-guide/references/documentation.md)
- 交付版本、提交、Release/工作流链接与实际结果,未完成构建或缺失产物时不称发布成功
@@ -0,0 +1,6 @@
interface:
display_name: "cpr-release"
short_description: "版本规划、中文发布说明、正式与预发行发布及失败恢复"
default_prompt: "使用 $cpr-release 按当前请求准备版本与中文说明、执行已授权的发版或恢复失败步骤,核对渠道与实际结果。"
policy:
allow_implicit_invocation: true
@@ -0,0 +1,65 @@
# 实验版发布
按当前任务只读版本、发布能力、文案或验收章节,不因准备实验说明而读取执行流程
适用于独立于主版本维护、供用户主动选择体验的实验功能,入口使用仓库的 `release/publish`,构建复用目标实验分支的工作流与平台清单
## 分支与版本
- 从用户指定或会话已确定的 `experimental/<用途>` 分支发布,核对它的 upstream 和实验功能范围。
使用独立 worktree 时保留其他工作区内容;不把实验代码合入 `main`,也不顺带升级运行实例。
- 版本采用 `vX.Y.Z-exp.N`:`X.Y.Z` 表示本轮采用的正式版基线,`N` 从 1 开始递增。
同一基线的 `exp.N` 只代表同一轮实验,不得用于不同实验分支;开始其他实验时使用不同的实际基线,不能仅接续编号。
后续只有实际同步了新的正式版基线,才调整 `X.Y.Z`;调整后属于另一实验线,发布说明需交代手动迁移方式。
- 核对已有 tag、Release 与目标分支源码,先区分重试和新发行。已发布的旧命名版本保留,
不为统一名称移动 tag 或替换原有产物。
- GitHub Release 展示标题采用 `vX.Y.Z-<用途>.M`,`<用途>` 取实验分支名去掉 `experimental/` 前缀后的部分。
发布前通过 `gh api` 核对相同正式版基线与用途的已有 Release 标题和旧命名 tag,新发行取已有最大编号加 1,首次从 1 开始。
展示编号 `M` 独立于实际 tag 的 `exp.N`,所有同基线、同用途的已有发行均计入,不随 tag 命名格式重置;同一发行重试沿用已确定编号。
例如已有 `v3.10.0-codex-anti-degradation.1`,下一次 tag 即使是 `v3.10.0-exp.1`,标题也应为 `v3.10.0-codex-anti-degradation.2`。
- tag 和产物继续使用 `exp.N`:镜像版本去掉 tag 开头的 `v`,安装包沿用项目的版本、系统和架构命名。
发布说明首行匹配实际 tag,关联 Issue 和试验目的写在正文中,已有 Release 不因新发行而改名
## 先核对发布能力
在目标实验分支读取实际工作流,确认以下行为;新实验分支若缺少支持,先在既有流程中补齐并验证:
- `exp.N` 产生 `experimental` 构建类型,所有平台的二进制与镜像使用同一版本和源码提交。
应用内更新仅允许同一轮实验,规则见[版本命名与升级规则](../../../../deploy/README.md#版本命名与升级规则)。
发版前核对上一实验版是否已包含该更新器;仍禁用在线更新的旧构建需要先手动升级。
- GitHub Release 标为 Pre-release,且不设为 Latest;Docker 发布版本标签,不覆盖稳定版 `latest`。
正式版和 alpha、beta、rc 均不接收实验版更新;环境变量不能改变发行线。
- 复用正式版平台清单、质量检查、完整安装包与多架构镜像,以及部署文件、校验和和现有产物证明。
对照本次清单确认平台,不以“实验版”为由仅发布单架构镜像。
- 部署说明交代与稳定实例并存的目录、配置、端口和数据库要求;需要 Compose 隔离配置时,
核对发布附件确实包含它,不能只在文案中承诺。
- 实验版正文使用下方完整文案,不再由工作流重复追加安装章节或自动生成混入正式版基线的提交列表。
- Release 的 `name` 使用上述展示标题。工作流若默认以 tag 为标题,发布成功后用 `gh release edit --title` 设置标题,
再通过 `gh api` 回读核对;仅修改 `name`,保留 tag、产物、正文和预发行状态。
## 中文发布说明
在目标分支的 `release/notes.md` 编写,首行为 `# vX.Y.Z-exp.N`。后续内容按实际需要组织:
1. **实验说明**:开头简述正式版基线、验证的问题与适用人群。效果尚未验证时明确说明,
不把假设写成已解决的问题,不承诺实验功能一定进入主版本。
2. **本次变化**:首次发行概括相对正式版基线新增的实验能力,关联 PR;后续发行只列相对上个实验版的变化。
同步主分支修复时说明它对实验用户的影响,不复制整份正式版更新记录。
3. **安装与使用**:给出本版本真实镜像命令、支持平台、部署文件及实验部署文档链接。
交代功能是否默认启用和必要配置;保留工作流实际提供的校验与证明说明。
4. **使用须知**:只写本次适用的已知限制、数据隔离、迁移与回退边界,以及在线更新条件和手动迁移方式。
5. **反馈与致谢**:链接关联 Issue 或已有反馈入口,感谢实际贡献者。没有相应内容的章节可以省略。
用户要求先审核文案时,先展示完整正文;已批准的同一版本文案无需重复确认。
正常新发行把说明纳入版本提交并从 tag 构建,不先创建公开空 Release 再回填附件。
## 执行与验收
按[版本与说明准备](preparation.md)的对应章节准备后,再按[执行发版](operations.md#执行发版)在实验分支调用入口
入口创建版本提交和 tag,并从该分支 dispatch 发布工作流;不要从 `main` 发布实验代码。
按[发行通道核对](operations.md#发行通道核对)确认稳定入口未改变,并检查实验线隔离与上一版本的更新能力。
检查展示标题的分支用途与历史续号、它所对应的实际 tag,以及目标 tag 的源码提交、完整产物、校验和、多架构平台和 Pre-release 标记。
同基线的 `X.Y.Z-exp.N` 按 SemVer 排序低于正式 `X.Y.Z`;渠道隔离仍以更新器过滤和发布标签规则为准。
失败时按[恢复与验收](operations.md#恢复与验收)从未完成步骤恢复,不通过换版本号或移动 tag 掩盖失败。
仅维护已有页面文案时不重新构建;报告实际完成状态、发布链接及验证缺口。
@@ -0,0 +1,49 @@
# 执行、恢复与验收
只在实际发布、恢复失败步骤或验收发行时读取对应章节,先核对[入口](../SKILL.md)中的范围与授权
## 发行通道核对
- alpha 是下一正式版本的早期开发阶段,beta 是功能测试阶段,rc 是发布候选,exp 是临时功能实验。
预发行采用 `vX.Y.Z-<阶段>.N`,`N` 从 1 开始;正式版不带预发行后缀,不新增未约定的通道名称。
- 更新候选由本次检查或安装选择的通道决定:Stable 仅正式版,RC 包含正式版,Beta 包含 RC 与正式版,Alpha 包含所有常规阶段。
仅接收同一大版本内严格更高的版本,可跟随后续小版本与补丁版本,默认按运行版本推导通道;临时选择不保存、不允许降级。
exp 仅接收同基线、同一轮实验的后续编号,不能跨实验线或转入其他阶段;不同实验分支不得共用一个 `X.Y.Z-exp.N` 系列。
- 核对目标工作流的构建类型:正式版及 alpha、beta、rc 为 `release`,exp 为 `experimental`。
所有预发行均标记为 Pre-release,不设置 GitHub Latest、不覆盖镜像 `latest`。
发布预发行前记录这两个稳定入口,发布后回读确认未改变。
- 验收更新器时,核对检查更新与执行更新使用相同规则。只有当前构建支持更新且存在允许的更高目标时
才能返回 `hasUpdate=true`;被禁止的目标即使版本领先,也不能出现在更新标记、可用版本或发布说明中。
检查失败须单独报告。核对上一发行实际包含的更新能力,不能因新包支持在线更新就宣称旧包也能在线安装它。
## 执行发版
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
脚本会更新 `release/version.yaml`,将它与最终工作区中的发布说明一起提交为 `chore(release): vX.Y.Z`。
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
本任务需要的提交按[项目约定](../../../../CONTRIBUTING.md#项目约定)保留英文 Conventional Commits 与相应署名。
在准备完成且用户已授权发布后,将 `release_version` 设为已确定的版本,执行:
```bash
release/publish "$release_version"
```
该命令会创建包含版本号和发布说明的单次提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
只调整已发布页面的标题或正文时,编辑对应 GitHub Release 的 `name` 或 `body` 并回读核对;
保持已有 tag、源码提交、附件和预发行状态,不重新执行发布入口。
## 恢复与验收
- 脚本中断或网络超时时,先核对本地提交与 tag、远端分支与 tag、对应工作流 run 及 Release,确定已完成到哪一步。
若本地已创建版本提交或 tag,不直接重跑入口,也不通过删 tag、回退版本或强推来消除报错。
- 提交和 tag 已推送但工作流未触发时,核对目标后使用脚本提示的 dispatch 命令补触发;
工作流已存在则跟踪或恢复对应 run,避免重复发布。发现 tag 指向不同提交时报告冲突,不移动已发布 tag。
- 跟踪与本次 tag 对应的 run ID,核对工作流解析的目标提交。触发工作流成功只表示已开始,不能当作发布完成。
- 发布完成后核对 GitHub Release 的版本、正式版/预发行状态和中文变更正文;对照平台清单与工作流确认归档、校验和及镜像产物。
工作流附加的安装说明和自动提交列表按其当前配置处理,不用它们替代手写的中文变更说明。
- 返回版本、tag 对应提交、Release/工作流链接及实际结果。失败、仍在构建或缺失产物时明确说明,不能称为发布成功;
Release 和镜像已生成也不表示用户的运行实例已经升级。
@@ -0,0 +1,36 @@
# 版本与说明准备
只在规划版本、编写发布说明或准备发行时读取对应部分,不因准备文案加载执行流程
## 基线与版本
1. 确认类型、分支、upstream、目标仓库、工作区与完整待发布范围,保留无关改动;未跟踪文件也需核对
2. 读取 [version.yaml](../../../../release/version.yaml)、相关 tag 和实际 Release,确定上一发布基线与本次目标提交,再核对二者间的最终 diff,不只看最近几条提交
3. 使用用户确定的目标版本,建议版本时说明兼容性、能力或修复依据;同名 tag 先区分重试与新发行,不递增版本掩盖冲突
4. 命名与晋级按[版本命名与升级规则](../../../../deploy/README.md#版本命名与升级规则),实验版另读[实验流程](experimental.md#分支与版本)
## 发布说明
读取当前 [notes.md](../../../../release/notes.md)与[项目约定](../../../../CONTRIBUTING.md#项目约定)中的发布说明条目
- 只在发布说明任务中修改 `release/notes.md`,替换为本次版本内容,首行为目标 tag,正文使用中文
- 按用户可感知的最终变化归并,省略空章节,不逐条翻译提交,不写排查过程或未经验证的承诺
- 剔除回退、重复及未进入发行范围的内容,升级事项须能追溯到实际迁移、配置或兼容变化
- 只翻译已有准确说明时保持事实与范围,不混入其他变更
- 常规文档仍只描述当前状态;必要修订执行[文档检查](../../cpr-dev-guide/references/documentation.md),不复制发布说明中的版本变化列表
实际发布由 `release/publish` 提交说明与版本文件,准备说明时不要提前单独提交或把版本文件改成目标版本
用户只要求准备时保留本地改动,纯说明修改无需重建前后端
## 发行准备检查
进入实际发行准备后,读取目标分支的 [release/publish](../../../../release/publish)、[工作流](../../../../.github/workflows/release.yml)与[平台清单](../../../../release/platforms.yaml),按当前入口执行,不另写脚本或绕过检查
按[验证要求](../../../../CONTRIBUTING.md#验证)核对目标提交的证据,区分通过、失败、跳过与未执行
数据库变更同时读取[迁移冻结规则](../../../../backend/migrations/README.md#冻结规则):
- 对照同一大版本的上一正式版和本轮预发行/实验线的已发布版本,验证迁移未改写及升级路径
- 区分未发布迁移、已执行迁移的源码部署实例与 CI 冻结基线,未发版不代表未执行,也不授权放宽冻结规则
- 兼容性与 CI 检查分别报告,跨大版本不默认支持在线升级
正式执行时再读取[通道核对](operations.md#发行通道核对)与[执行发版](operations.md#执行发版),触发工作流不等于发布成功
-38
View File
@@ -1,38 +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、视觉验证 | 加载并使用 `$frontend-design`,结合 [管理端主题](../../../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) |
## 执行顺序
1. 从仓库根目录确认工作区、当前任务范围和相关运行实例;保留已有改动。
2. 按 `AGENTS.md` 的 CodeGraph 约定定位入口、调用链和职责归属,再阅读相关实现。
3. 按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 核实触发条件、问题发生版本与目标分支现状,
优先依据适用的官方源码确定方案。结合上表读取所需文档,先找可复用的同类实现,再修改所属模块。
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;
按 [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否修订,不为每次改动追加文档或发布说明。
5. 按 `CONTRIBUTING.md` 验证并报告结果。后端 Cargo 命令从 `backend/` 执行,或显式指定该目录的 manifest。
发布任务按 `release` 技能读取当前版本、发布入口和工作流,发布说明语言遵循 `CONTRIBUTING.md`。
@@ -1,4 +0,0 @@
interface:
display_name: "dev-guide"
short_description: "从仓库规范与架构文档定位实现、验证和交付的正确入口"
default_prompt: "使用 $dev-guide 按当前仓库规范与架构完成开发、验证或发布。"
-58
View File
@@ -1,58 +0,0 @@
---
name: github-issue
description: 整理与提交 GitHub Issue(问题反馈 / 功能建议)。用于“提个 Issue”“把这个问题反馈到仓库”“帮我报个 bug”“整理成 Issue”,以及通过 Issue 提议功能或协作规则的场景;区分草拟正文和实际提交。Use for drafting or filing bug reports and feature requests; not for debugging-only, editing Issue Forms/templates, or skill-authoring requests.
---
# 整理与提交 Issue
本仓库流程见 [贡献与审查](../../../CONTRIBUTING.md),问题反馈使用当前的
[YAML Issue Form](../../../.github/ISSUE_TEMPLATE/bug_report.yml)。从表单读取字段与必填项,不在技能中复制一份字段清单。
用户指定其他仓库时,使用该仓库当前的贡献规则与 Issue 模板,不能把这里的表单套过去。
## 确定目标和动作
- 根据用户 URL、仓库上下文和 remote 确认接收 Issue 的仓库。fork 场景核对用户要反馈给自己的 fork 还是上游。
- “写一份、整理内容、草拟 Issue”只准备正文;明确要求“提交、创建 Issue”才发布。
已经授权提交且信息足够时直接完成,不重复索要确认;仅自动选中技能不能授权发布。
- “有个 bug”“这个报错怎么修”本身是排障语境,不能推断要发 Issue;结合已有反馈任务识别省略的目标和动作。
- 缺少无法推断的目标或关键复现事实时,只问必要信息,并先整理其余已有证据。
- 使用可用的 GitHub 连接器或 `gh`。不要为提交 Issue 安装新工具、修改仓库配置或启用额外服务。
## 证据与去重
1. 读取目标仓库实际 Issue 模板和配置,区分缺陷、功能建议与协作规则提案。
本仓库后两类允许普通 Issue;其他仓库按其配置选择入口。
2. 先搜索 open 和 closed Issues,结合关键词、错误信息与触发条件检查重复,而不只比较标题。
同时核对相关 PR 与发布记录,关联已有修复及其发布版本;仅主分支已修复时明确尚未发布。
同一问题已有记录且没有新增证据时返回已有链接;用户仍明确要求新建时按其要求处理。
不因发现重复而擅自评论、重开或关闭已有 Issue。
3. 从用户描述、当前版本与已有诊断中整理事实:区分实际结果、预期结果、稳定复现条件和未经证实的推测。
不把本地开发版本当作发生问题的部署版本;无法复现时如实说明已知条件。
引用官方行为或提出实现方案时,按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 注明来源、版本与缺口;
不把找到官方源码或证明根因作为提交症状反馈的前提。
4. 只收集定位所需信息。正文、日志片段和附件去除密钥、Cookie、令牌、代理认证、私人请求内容等敏感信息;
不直接附完整配置或请求转储。记录与复现相关的代理、TUN、证书条件,不擅自修改这些环境来收集材料。
## 编写正文
- 标题写具体症状或所需行为,不预先断言尚未证明的根因。语言与目标仓库及用户要求一致。
- YAML Issue Form 按字段顺序使用 `attributes.label` 作为 Markdown 小标题,填写相应答案。
`gh issue create --body-file` 直接提交正文,不会替你完成网页表单校验;手动核对必填字段、选项和必选确认项。
不把原始 YAML、占位文字或尚未确认的勾选项当成答案提交。
- 无法获知的信息明确写未提供或不适用,并说明理由;如果该事实关系到能否理解或复现问题,发布前补齐。
功能或规则提案说明当前场景、缺少的能力、建议结果和必要取舍,不强行填写缺陷复现步骤。
- 附件只使用用户授权发布且实际可访问的地址;本机 `/tmp/...` 路径不能作为 GitHub 附件。
没有可用附件时保留操作描述并说明缺口,不声称截图已上传。
- 使用模板规定或用户要求的 labels、assignees;先确认它们在目标仓库存在,不自行猜测。
## 提交与收尾
把核对后的 UTF-8 正文写入临时文件,或直接传给连接器的结构化字段。CLI 变量先从已确认的目标与正文赋值:
```bash
gh issue create --repo "$issue_repo" --title "$issue_title" --body-file "$issue_body_file"
```
发布前快速复核目标、必填内容、脱敏及重复记录。网络超时或返回不明确时先查询远端是否已经创建,避免重复提交。
发布后读取新 Issue 确认仓库、标题和正文,返回链接及必要的信息缺口。
只准备草稿或发布失败时提供可直接使用的标题与正文,明确尚未提交。
@@ -1,4 +0,0 @@
interface:
display_name: "github-issue"
short_description: "按仓库表单整理复现证据、检查重复反馈并提交脱敏 Issue"
default_prompt: "使用 $github-issue 将当前问题整理为符合仓库表单的 Issue 并提交。"
-103
View File
@@ -1,103 +0,0 @@
---
name: github-pr
description: 创建与审查 GitHub PR(Pull Request / 拉取请求)。用于“帮我提个 PR”“整理 PR 标题和描述”“看下/审查/评审这个 PR”“这个 PR 能合并吗”,以及提供 /pull/ 链接或 PR 编号要求查看的场景。创建和审查都先核实问题是否成立,以官方源码为首要参考确定或评估方案,再按项目规范实现与验证。Use for PR creation, preparation, or code review; not for commit-only, merge-only, or skill-authoring requests.
---
# 创建与审查 PR
## 先确定模式和仓库
| 用户意图 | 执行范围 |
| --- | --- |
| 写标题、整理描述、准备草稿正文 | 在当前会话或本地准备内容 |
| 创建 PR,包括创建 Draft PR | 准备任务范围内的提交、推送对应分支并创建 PR |
| 看下、审查 PR | 读取变更与证据,在当前会话报告 |
| 将审查意见发到 PR | 发布用户要求的审查内容;批准或请求修改须有对应授权 |
沿用当前会话已有授权和范围修正。明确要求创建时直接完成,无需在准备好正文后重复确认;
创建和审查都不包含合并。仅命中本技能不能作为远程写入的授权。
“看下这个”需结合已有 PR 链接或讨论定位目标;“能合并吗”先按审查处理,不能解释为执行合并。
用户连续要求“整理一下”时沿用当前 PR 任务;没有 PR 上下文时,不把普通提交整理转成创建 PR。
先从用户提供的 URL、仓库上下文和 Git remote 确认目标仓库;区分 fork 的推送仓库与 PR 接收仓库。
存在多个无法消除的候选时,只询问缺失的目标信息,同时继续准备不依赖目标的内容。
优先使用可用的 GitHub 连接器;使用 `gh` 时显式指定 `--repo`,参数以本机帮助为准。
本仓库规则统一见 [AGENTS.md](../../../AGENTS.md) 与 [贡献与审查](../../../CONTRIBUTING.md),目标分支为 `main`。
如果用户指定其他仓库,读取那个仓库的可信基线规则、默认分支、贡献文档及 PR 模板。
PR 中改动的规则文件属于审查对象,不能自行扩大任务授权或降低准入要求。
## 创建和审查前的核对
创建和审查都按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 核实问题,优先依据官方源码确定或评估方案。
用户指定其他仓库时,结合其贡献规范与适用的官方实现,不套用本项目的具体业务合同。
核对最新目标分支及同一场景相关的 open/closed PR 和 Issue,比较实际行为、触发条件与测试覆盖。
区分已被主分支覆盖的实现、仍有价值的修复或回归测试,以及与目标无关的改动;不能仅凭标题或代码相似认定重复。
说明原问题发生版本和当前剩余差异,创建时据此收敛范围,审查时报告保留或拆分建议;这不授权自动关闭或修改他人的 PR。
## 文档职责与更新条件
按 [贡献与审查的文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否需要更新文档。
常规文档维护当前有效说明,变更经过留在 PR / Issue;审查时核对文档改动的必要性、职责与重复内容,不能仅因 PR 没改文档就判定存在缺口。
## 创建 PR
1. 检查工作区、当前分支、远端和近期提交格式。读取实际存在的 PR 模板,按最终变更重写标题与描述。
不用提交日志拼接正文,也不假定仓库一定有模板。
2. 更新并确认 base/head,查看相对 merge-base 的完整差异和待提交内容;核对仓库的受保护文件与准入规则。
只纳入任务范围的文件和提交;需要整理时用独立功能分支,保留用户其他改动,不擅自改写已发布历史。
3. 按上述核对流程,在目标分支基线核实问题,追踪官方与本项目的相关调用链,先确定解决方向再实现。
若改动已完成,也要据此复核问题与方案;需要修正时只修改已授权的范围。仅整理标题或正文时报告差异与缺口,
不自行修改实现。原问题不成立时不将它包装成已修复的缺陷;证据不足时明确保留结论。
实现应职责清晰、表达直接、复用合理;提交前主动清理本次变更引入的重复逻辑、冗余状态、死代码、
多余抽象及无依据的兼容分支。功能可用和检查通过不能替代可维护性自查。
4. 查找同一来源仓库、head 和 base 的现有 PR;已存在则复用其链接,不重复创建。
更新现有标题或正文须在本次整理/更新请求范围内。
5. 按贡献文档执行适用检查。记录实际验证的提交或工作区状态,区分通过、失败、跳过和未执行。
前端改动提供实际界面证据或预览步骤;缺少证据时写明,不用构建成功替代视觉验收。
6. 将任务范围内的提交推送到正确远端,核对远端 head。标题遵循项目约定,正文先说明问题成立的依据和最终行为,
再给必要的设计取舍、验证与遗留事项。涉及官方行为时,写明官方源码位置与版本或提交、官方处理方式、
本次实现与它的关系及必要的偏离原因;区分源码推断和实际复现,不用检查通过替代方案论证。按规模压缩,不强填无关章节。
7. 明确要求草稿或仍有阻塞项时创建 Draft PR,并解释原因;具备审查条件且未要求草稿时创建普通 PR。
GitHub Draft 状态与“只写本地草稿正文”不同,后者不创建远程对象。
8. 创建后核对链接、base、head、草稿状态和最新检查结果。网络超时或结果不确定时先查询远端,再决定是否重试。
CLI 示例中的变量必须先根据上述检查赋值;`pr_body_file` 是精确写入 UTF-8 正文的临时文件:
```bash
gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
--title "$pr_title" --body-file "$pr_body_file"
```
按需添加 `--draft`。不要用 `gh pr create --dry-run` 做只读预览,它仍可能推送 Git 变更。
多行正文通过文件或连接器的结构化字段传递,不把未转义正文拼进 shell 命令。
创建完成返回链接、目标分支和验证摘要;不要称为已合并或已获维护者批准。
## 审查 PR
1. 获取 PR 的 base/head SHA、描述、文件差异、已有讨论及 CI 状态。记录实际审查的 head,使用 merge-base 差异。
需要源码上下文时先遵守目标仓库的定位规则;需检出时使用隔离工作区,保留用户当前分支和运行环境。
2. 先核实 PR 要解决的问题是否成立:提取触发场景、预期行为和声称的原因,在目标分支基线检查实际行为。
按上述核对流程收集一手证据,区分原有问题、环境或配置差异与 PR 新引入的问题,说明主分支已覆盖的部分。
3. 从官方方案评估解决方向:说明官方如何处理同一场景,检查 PR 是否遵循其协议语义和职责归属、
是否复用已有能力或正确传递官方合同;核对偏离官方方案的理由与运行证据。
4. 按贡献文档的审查维度追踪完整调用链,找到共享业务事实、同类实现及已有验证。
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
主动检查本次变更新引入或加重的冗余与代码异味,说明具体位置、维护成本或行为影响及最小修复方向;
不能仅因功能可用、CI 通过而略过,也不把个人写法偏好判为缺陷。
5. UI 改动同时对照主题、共用组件、数据展示逻辑和同类页面。浏览器可用时检查相关交互及状态;
未运行的页面检查列为验证缺口,不从截图或源码推断所有状态都正确。
6. 核对 CI 与本地证据是否覆盖当前 head;根据风险补充必要验证,不触发无关部署或真实账号业务请求。
工具不可用、集成测试跳过或环境不足时具体说明影响范围。
7. 按 [审查标准](../../../CONTRIBUTING.md#审查标准) 用中文先回答原问题是否成立、解决方向是否合理,
再列出实现问题与验证缺口,结论附具体依据。
实现问题区分必须修复项、可选建议和验证缺口;没有的类别可省略。
每个问题给变更中的文件位置、触发条件或规范依据、影响和最小修复方向。
复用建议指出具体已有实现,不把个人偏好写成缺陷;未发现问题时直接说明并交代覆盖范围。
8. 结束前核对 head 是否变化。发生变化时检查新增差异并更新结论,或明确结论只适用于原 SHA。
审查本身不修改源码、不发表评论、不提交 GitHub 的批准或请求修改状态。
用户明确要求发布时,先复核当前 head 和重复评论,再通过结构化字段或 `--body-file` 发布对应内容;
需要绑定具体提交时使用支持 commit ID 的审查接口。不要在旧提交结论上批准新的 head。
@@ -1,4 +0,0 @@
interface:
display_name: "github-pr"
short_description: "创建与审查拉取请求,先核实问题,以官方源码为首要依据确定或评估实现方案"
default_prompt: "使用 $github-pr 按当前请求创建或审查 PR,先核实问题是否成立,以官方源码为首要参考确定或评估解决方案,说明源码依据及必要的偏离原因,并按仓库规范实现与验证或报告审查结论。"
-80
View File
@@ -1,80 +0,0 @@
---
name: release
description: 准备或执行当前仓库发版,维护中文发布说明。用于“发版”“发个新版本”“准备 vX.Y.Z”“推荐版本号”“整理更新日志/发布说明”“更新 release/notes.md”“打版本 tag”等发行任务。Use for version planning, release notes, and publishing a repository release; not for deploying/restarting an instance, publishing application data, or authoring a release skill.
---
# 发版
## 识别任务范围
| 用户意图 | 执行范围 |
| --- | --- |
| 推荐版本号 | 根据本次变更与已有版本给出建议及理由 |
| 整理更新日志、写 note、准备发版 | 核对变更、编写说明、检查发布条件;未要求提交时保留本地改动 |
| 正式发布、发布指定版本、打版本 tag | 在已授权范围内执行对应步骤;仅打 tag 的请求不自动扩展为完整发版 |
| 重试失败的发版 | 核对上次执行状态,从未完成的步骤恢复 |
沿用当前会话明确的版本、范围与授权;用户已要求正式发版且条件满足时直接执行,不重复确认。
“准备好了”“可以发版吗”属于准备或检查,不能解释成正式发布;创建本技能也不触发发版。
发行版发布与运行实例升级分开判断,“上线”需结合上下文确定用户指哪一种。
## 读取当前流程
先读 [AGENTS.md](../../../AGENTS.md) 与 [贡献与审查](../../../CONTRIBUTING.md),再按任务检查:
| 事实 | 权威入口 |
| --- | --- |
| 当前版本 | [release/version.yaml](../../../release/version.yaml) |
| 本次发布说明及语言约定 | [release/notes.md](../../../release/notes.md)、贡献文档的项目约定 |
| 版本提交、tag、推送与工作流触发 | [release/publish](../../../release/publish) |
| 构建、检查、GitHub Release 和镜像发布 | [发布工作流](../../../.github/workflows/release.yml) |
| 平台与产物清单 | [release/platforms.yaml](../../../release/platforms.yaml) |
命令从仓库根目录执行;每次以当前脚本和工作流为准,不另写发布脚本或绕过已有质量检查。
用户明确指定其他仓库时先读取其发布流程,不能直接套用这里的路径和命令。
## 准备版本与说明
1. 确认发布分支、upstream、目标仓库、工作区和完整待推送范围。本仓库沿用 `main`;
未提交或未跟踪内容分别核对,不因脚本只检查已跟踪文件就认定所有内容都已纳入发布。
2. 读取版本文件、已有 tag 和实际发行记录,确认上一发布基线与本次目标提交。
根据基线到目标的提交和最终差异归纳变更,剔除回退、重复和未进入发布范围的内容。
不能只用当前版本文件或最近几条提交判断“本次更新”。
3. 使用用户指定或当前会话已确定的目标版本;需要建议时按兼容性、能力新增和修复范围说明理由。
目标 tag 必须未被占用;遇到已有同名版本先判断是重试还是新发版,不自动递增版本掩盖冲突。
4. 按贡献文档维护 `release/notes.md`。正文写中文,保留版本号和必要技术标识;
按用户可感知的最终行为归并,不逐条翻译 commit,也不把排查过程或未验证承诺写入说明。
可按新增功能、问题修复、体验优化、升级说明组织,省略没有内容的章节。
5. 替换为本次版本说明,首行与目标 tag 一致且正文非空。升级事项应能追溯到实际迁移、配置或兼容变化;
敏感信息脱敏,未合入功能不宣称已发布。已有准确说明仅需翻译时保持其事实和范围。
6. 按贡献规范检查变更与现有 CI 证据,明确通过、失败、跳过和未执行。
只修改说明时无需重建前后端;正式发版仍要跟踪发布工作流针对目标提交的检查与产物。
## 执行正式发版
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
脚本会更新 `release/version.yaml`,将它与最终工作区中的发布说明一起提交为 `chore(release): prepare vX.Y.Z`。
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
本任务需要的提交按贡献文档保留英文 Conventional Commits 与相应署名。
在准备完成且用户已授权正式发布后,将 `release_version` 设为已确定的版本,执行:
```bash
release/publish "$release_version"
```
该命令会创建包含版本号和发布说明的单次提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
## 恢复与验收
- 脚本中断或网络超时时,先核对本地提交与 tag、远端分支与 tag、对应工作流 run 及 Release,确定已完成到哪一步。
若本地已创建版本提交或 tag,不直接重跑入口,也不通过删 tag、回退版本或强推来消除报错。
- 提交和 tag 已推送但工作流未触发时,核对目标后使用脚本提示的 dispatch 命令补触发;
工作流已存在则跟踪或恢复对应 run,避免重复发布。发现 tag 指向不同提交时报告冲突,不移动已发布 tag。
- 跟踪与本次 tag 对应的 run ID,核对工作流解析的目标提交。触发工作流成功只表示已开始,不能当作发布完成。
- 发布完成后核对 GitHub Release 的版本、正式版/预发行状态和中文变更正文;对照平台清单与工作流确认归档、校验和及镜像产物。
工作流附加的安装说明和自动提交列表按其当前配置处理,不用它们替代手写的中文变更说明。
- 返回版本、tag 对应提交、Release/工作流链接及实际结果。失败、仍在构建或缺失产物时明确说明,不能称为发布成功;
Release 和镜像已生成也不表示用户的运行实例已经升级。
@@ -1,4 +0,0 @@
interface:
display_name: "release"
short_description: "按仓库流程整理中文发布说明、核对版本并完成发行与验证"
default_prompt: "使用 $release 准备下一个版本的中文发布说明,并核对发版条件。"
@@ -0,0 +1,33 @@
body:
- type: markdown
attributes:
value: |
欢迎提问。提交前可以搜索已有讨论,并查看[部署文档](https://github.com/zyycn/codex-proxy-rs/blob/main/deploy/README.md)。
明确的程序故障请使用 [Issue 模板](https://github.com/zyycn/codex-proxy-rs/issues/new/choose);不确定是不是 Bug,也可以先在这里交流。
请移除密钥、令牌、Cookie、代理认证和私人内容,不要上传完整配置或请求转储。解决后请标记帮助到你的答案。
- type: textarea
id: question
attributes:
label: 想解决什么问题
description: 说明希望完成的任务、当前遇到的困难,标题尽量具体。
validations:
required: true
- type: input
id: version
attributes:
label: 版本与部署方式
description: 涉及具体实例时,填写完整版本号或提交号,以及 Docker、二进制或源码部署方式。
- type: textarea
id: attempts
attributes:
label: 已尝试的方法与结果
description: 可以附看过的文档、相关讨论、脱敏错误片段或截图;未尝试可留空。
- type: textarea
id: environment
attributes:
label: 相关环境与补充信息
description: 按问题需要补充操作系统、客户端、反向代理或中转链路,无需提供无关信息。
@@ -0,0 +1,44 @@
body:
- type: markdown
attributes:
value: |
欢迎分享实验版本的使用体验,包括有效、无明显变化或遇到限制的情况。
请先查看对应[预发行页面](https://github.com/zyycn/codex-proxy-rs/releases)的部署与数据隔离说明,并搜索已有反馈。
明确的程序缺陷请使用 [Issue 模板](https://github.com/zyycn/codex-proxy-rs/issues/new/choose),在两处互相附链接即可,无需重复粘贴。
请移除密钥、令牌、Cookie、代理认证和私人内容,不要上传完整配置或请求转储。
- type: input
id: version
attributes:
label: 实验版本或提交号
description: 填写实际运行的完整版本号或提交号,不要只写 exp 或 latest。
validations:
required: true
- type: textarea
id: scenario
attributes:
label: 使用场景与环境
description: 说明启用的实验功能、部署方式、客户端及与结果相关的条件,不需要提供账号身份信息。
validations:
required: true
- type: textarea
id: observation
attributes:
label: 观察到的效果或限制
description: 描述实际现象、出现频率和观察时长;请区分观察结果与原因推测。
validations:
required: true
- type: textarea
id: comparison
attributes:
label: 对照与复现信息
description: 如已对比启用前后或不同配置,请说明条件和结果;没有对照也可以反馈,无需额外测试。
- type: textarea
id: references
attributes:
label: 相关链接与补充材料
description: 可附已有 Issue、讨论、脱敏日志片段或截图。
@@ -0,0 +1,29 @@
body:
- type: markdown
attributes:
value: |
这里适合交流尚在探索的想法、方案取舍和使用场景,不要求先给出完整实现。
已有明确场景与预期行为时,可以直接提交[功能建议 Issue](https://github.com/zyycn/codex-proxy-rs/issues/new?template=feature-request.yml),无需先开讨论。
请先搜索相关讨论,每个主题围绕一个问题展开。
- type: textarea
id: use-case
attributes:
label: 使用场景与当前不便
description: 希望完成什么任务,目前的做法有什么不足?
validations:
required: true
- type: textarea
id: idea
attributes:
label: 想法与希望讨论的方向
description: 可以说明期望体验、考虑过的替代方法,以及还不确定的地方。
validations:
required: true
- type: textarea
id: references
attributes:
label: 参考资料
description: 可附相关 Issue、讨论或其他项目的具体行为;示例和截图请先脱敏。
+174
View File
@@ -0,0 +1,174 @@
name: 接口问题反馈
description: 客户端调用模型、搜索等 API 时的报错、断流、超时或响应异常
body:
- type: markdown
attributes:
value: |
管理端页面、账号导入或服务部署的问题请选择“常规问题反馈”;新增能力请选择“功能建议”。
请提供最小复现信息。请求、日志和截图发布前请移除密钥、令牌、Cookie、代理认证及私人内容。
- type: textarea
id: description
attributes:
label: 问题描述
description: 说明发生了什么、实际结果以及出现的频率;已有正常账号、模型或客户端的对照结果也可说明。
validations:
required: true
- type: input
id: version
attributes:
label: 服务版本
description: 填写实际运行的完整版本号,不要只写“最新”或 latest;源码运行填写提交号。
placeholder: 完整版本号或提交号
validations:
required: true
- type: dropdown
id: deployment
attributes:
label: 部署方式
options:
- Docker Compose
- 其他容器部署
- 二进制运行
- 源码运行
- 其他方式(请在环境信息中说明)
validations:
required: true
- type: textarea
id: environment
attributes:
label: 环境信息
description: 填写服务端与客户端的操作系统及版本;性能或超时问题可补充 CPU、内存和容器资源限制。
placeholder: |
服务端操作系统与版本:
客户端操作系统与版本:
相关资源限制(如有):
validations:
required: true
- type: input
id: client-name
attributes:
label: 客户端名称
description: 区分 CLI、桌面端、编辑器扩展、SDK 或自编脚本。
placeholder: 例如 Codex CLI、Codex Desktop 或某个编辑器扩展
validations:
required: true
- type: input
id: client-version
attributes:
label: 客户端版本
description: 填写完整版本号;Codex CLI 可填写 codex --version 的输出。无法取得时说明原因。
placeholder: 完整版本号或相关 SDK 版本
validations:
required: true
- type: input
id: client-source
attributes:
label: 客户端安装来源与启动方式
description: 说明安装来源,以及是否通过包装脚本、启动器或第三方修改版运行;不确定时填写已知部分。
placeholder: 例如通过 npm 安装 @openai/codex,直接执行 codex
validations:
required: true
- type: input
id: endpoint
attributes:
label: 请求方法与端点
description: 填写发生异常的请求方法与路径,无需真实域名;不清楚端点时说明客户端当时执行的具体操作。
placeholder: 例如 POST /v1/responses、POST /v1/alpha/search 或 WebSocket /v1/responses
validations:
required: true
- type: dropdown
id: intermediary
attributes:
label: 是否经过中转或反向代理
description: |
指客户端到本服务之间的链路。API 中转包括 New API、One API、CCH 等;反向代理或 CDN 包括 Nginx、Caddy、Cloudflare Tunnel 等。
如有中转,请在下方填写链路详情;本服务到上游的出站代理请在补充信息中说明。
options:
- 否,直接连接本服务
- 是,仅经过 API 中转服务
- 是,仅经过反向代理或 CDN
- 是,同时经过 API 中转和反向代理或 CDN
- 不确定
validations:
required: true
- type: textarea
id: request-chain
attributes:
label: 请求链路
description: |
按调用顺序填写中间服务的名称与版本,例如:Codex → New API → Nginx → 本服务。不确定时填写已知部分。
如果已测试过直连本服务,请说明是否仍能复现;未测试可注明,不要求先完成对照测试。
placeholder: |
调用顺序与各中间服务版本:
是否启用请求参数或请求头改写(如已知):
直连本服务的对照结果(如已测试):
- type: textarea
id: account-model
attributes:
label: 平台、账号类型与模型
description: 填写与失败请求相关的信息,不要提供账号凭据;不涉及模型或无法确认的信息请注明。
placeholder: |
平台与认证类型:
账号套餐(如相关):
请求模型与映射后的上游模型(如已知):
- type: textarea
id: reproduction
attributes:
label: 复现步骤
description: 描述触发问题的操作;无法稳定复现时,说明已知触发条件。
placeholder: |
1. 使用什么配置或输入
2. 执行什么操作
3. 出现什么结果
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: 预期行为
description: 说明原本期望得到的结果。
validations:
required: true
- type: textarea
id: request-response
attributes:
label: 请求、响应与诊断附件
description: |
提供脱敏后的最小请求、网关实际状态/响应片段和客户端显示,区分上游、中转、网关与客户端的错误来源。
可在此直接上传管理端默认诊断包并检查其缺失说明;原始错误与日志片段需另行审阅脱敏,粘贴时请使用 Markdown 代码块。
按部署文档的“请求错误排查”检查关联 ID、错误详情与 application 日志,不默认开启完整 dump;无法取得的信息请注明。
placeholder: |
发生时间与时区:
传输(HTTP JSON / SSE / WebSocket):
脱敏后的最小请求:
网关关联 ID(x-gateway-request-id;配置的入口头请单独注明):
上游关联 ID(x-request-id / x-oai-request-id,注明来源):
网关实际状态、Content-Type 与响应片段:
中转服务的状态与错误(如有):
客户端显示的错误:
诊断包或无法取得的信息:
- type: textarea
id: additional-context
attributes:
label: 补充信息
description: 最近有升级时填写升级前后版本及前后端是否同步更新;网络问题补充出站代理、系统代理、TUN 和自定义证书等相关环境。
placeholder: |
服务或客户端升级前版本 → 当前版本(如有):
服务前后端是否同步更新(如有升级):
最近的配置变更:
其他相关环境:
-120
View File
@@ -1,120 +0,0 @@
name: 问题反馈
description: 提交可复现的问题,帮助定位和修复
body:
- type: markdown
attributes:
value: |
请提供最小复现信息。请求、日志和截图发布前请移除密钥、令牌、Cookie、代理认证及私人内容。
- type: dropdown
id: issue-type
attributes:
label: 问题类型
options:
- 接口请求或响应异常
- 页面或交互异常
- 配置或部署异常
- 其他问题
validations:
required: true
- type: textarea
id: description
attributes:
label: 问题描述
description: 说明发生了什么、实际结果以及出现的频率。
validations:
required: true
- type: input
id: version
attributes:
label: 服务版本
description: 填写具体版本号;从源码运行时填写提交号。
placeholder: 版本号或提交号
validations:
required: true
- type: dropdown
id: deployment
attributes:
label: 部署方式
options:
- Docker Compose
- 其他容器部署
- 二进制运行
- 源码运行
- 其他方式(请在环境信息中说明)
validations:
required: true
- type: textarea
id: environment
attributes:
label: 环境信息
description: 填写操作系统与版本;页面问题同时填写浏览器与版本。
placeholder: |
操作系统与版本:
浏览器与版本(页面问题):
validations:
required: true
- type: textarea
id: account-client
attributes:
label: 账号与客户端
description: 接口问题请填写平台、认证类型、模型和客户端信息,不要提供账号凭据。
placeholder: |
平台与认证类型:
模型名称:
客户端名称与版本:
- type: textarea
id: reproduction
attributes:
label: 复现步骤
description: 描述触发问题的操作;无法稳定复现时,说明已知触发条件。
placeholder: |
1. 使用什么配置或输入
2. 执行什么操作
3. 出现什么结果
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: 预期行为
description: 说明原本期望得到的结果。
validations:
required: true
- type: textarea
id: request-response
attributes:
label: 请求与响应
description: |
接口问题请提供脱敏后的最小请求、网关实际状态/响应片段和客户端终端显示,区分两者。
优先附管理端默认诊断包并检查其缺失说明;原始错误与日志片段需另行审阅脱敏。
先按部署文档的“请求错误排查”检查关联 ID、错误详情与 application 日志,不默认开启完整 dump。
placeholder: |
发生时间与时区:
传输(HTTP JSON / SSE / WebSocket):
网关关联 ID(x-gateway-request-id / 配置的入口头):
上游关联 ID(x-request-id / x-oai-request-id,注明来源):
网关实际状态与响应片段:
客户端终端显示:
诊断包或无法取得的信息:
render: text
- type: textarea
id: screenshots
attributes:
label: 截图或录屏
description: 页面或交互问题可在此拖入相关截图、录屏,并说明对应的操作。
- type: textarea
id: additional-context
attributes:
label: 补充信息
description: 最近的升级或配置变更;网络问题补充系统代理、TUN 和自定义证书等相关环境。
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: true
contact_links:
- name: 使用问答
url: https://github.com/zyycn/codex-proxy-rs/discussions/categories/使用问答
about: 安装部署、配置和日常使用求助;明确的 Bug 请选择上方问题反馈模板。
- name: 想法与方案讨论
url: https://github.com/zyycn/codex-proxy-rs/discussions/categories/想法讨论
about: 探索尚未明确的需求与方案;具体功能需求也可以直接提交功能建议。
- name: 实验版体验反馈
url: https://github.com/zyycn/codex-proxy-rs/discussions/categories/实验反馈
about: 分享实验版本的使用效果、适用场景和限制。
@@ -0,0 +1,39 @@
name: 功能建议
description: 说明使用场景、当前不便和期望行为,提出新能力或体验改进
body:
- type: markdown
attributes:
value: |
如果已有功能无法正常使用,请选择对应的故障模板。提交前可搜索已有 Issues,相关讨论请附链接。
每个 Issue 尽量围绕一个使用场景;截图和示例请移除密钥、令牌、Cookie、代理认证及私人内容。
- type: textarea
id: use-case
attributes:
label: 使用场景与当前不便
description: 说明你希望完成什么任务、目前如何操作,以及现有能力有什么不足。
placeholder: |
希望完成的任务:
当前做法与不便:
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: 期望行为
description: 描述改进后希望如何使用,最好给出一个具体例子;不要求先设计技术方案。
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: 已尝试的替代方案
description: 如果已有配置、操作方式或其他工具能部分满足需求,说明仍缺少什么;未尝试可留空。
- type: textarea
id: additional-context
attributes:
label: 补充信息
description: 可附当前使用的具体版本、相关 Issue、截图或参考链接;引用其他项目时说明希望借鉴的具体行为。
@@ -0,0 +1,143 @@
name: 常规问题反馈
description: 管理端页面、账号导入、安装部署、升级或其他非客户端 API 问题
body:
- type: markdown
attributes:
value: |
客户端调用模型、搜索等 API 的问题请选择“接口问题反馈”;新增能力或体验改进请选择“功能建议”。
请提供最小复现信息。请求、日志和截图发布前请移除密钥、令牌、Cookie、代理认证及私人内容。
- type: dropdown
id: issue-type
attributes:
label: 问题类型
options:
- 页面或交互异常
- 配置或部署异常
- 升级异常
- 其他问题
validations:
required: true
- type: textarea
id: description
attributes:
label: 问题描述
description: 说明发生了什么、实际结果以及出现的频率。
validations:
required: true
- type: input
id: version
attributes:
label: 服务版本
description: 填写实际运行的完整版本号,不要只写“最新”或 latest;源码运行填写提交号。服务无法启动时填写准备运行的版本。
placeholder: 完整版本号或提交号
validations:
required: true
- type: dropdown
id: deployment
attributes:
label: 部署方式
options:
- Docker Compose
- 其他容器部署
- 二进制运行
- 源码运行
- 其他方式(请在环境信息中说明)
validations:
required: true
- type: textarea
id: environment
attributes:
label: 环境信息
description: 填写操作系统与版本,并补充与问题相关的浏览器、访问方式或资源信息。
placeholder: |
操作系统与版本:
浏览器与版本(页面问题):
页面访问方式(HTTP / HTTPS,localhost / IP / 域名,无需真实地址):
CPU 核数与内存、容器资源限制(部署或性能问题):
Docker / Compose 版本(容器部署问题):
validations:
required: true
- type: dropdown
id: intermediary
attributes:
label: 是否经过中转或反向代理
description: |
指客户端或浏览器到本服务之间的链路。API 中转包括 New API、One API、CCH 等;反向代理或 CDN 包括 Nginx、Caddy、Cloudflare Tunnel 等。
如有中转,请在下方填写链路详情;本服务到上游的出站代理请在补充信息中说明。
options:
- 否,直接连接本服务
- 是,仅经过 API 中转服务
- 是,仅经过反向代理或 CDN
- 是,同时经过 API 中转和反向代理或 CDN
- 不确定
- 不适用(例如服务尚未启动)
validations:
required: true
- type: textarea
id: request-chain
attributes:
label: 请求链路
description: |
按调用顺序填写中间服务的名称与版本,例如:浏览器 → Cloudflare → Nginx → 本服务。不确定时填写已知部分。
如果已测试过直连本服务,请说明是否仍能复现;未测试可注明,不要求先完成对照测试。
placeholder: |
调用顺序与各中间服务版本:
是否启用请求参数或请求头改写(如已知):
直连本服务的对照结果(如已测试):
- type: textarea
id: reproduction
attributes:
label: 复现步骤
description: 描述触发问题的操作;批量操作补充数量,升级问题说明升级方式。无法稳定复现时,说明已知触发条件。
placeholder: |
1. 使用什么配置或输入
2. 执行什么操作
3. 出现什么结果
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: 预期行为
description: 说明原本期望得到的结果。
validations:
required: true
- type: textarea
id: logs
attributes:
label: 错误信息与日志
description: |
提供脱敏后的页面提示、浏览器控制台错误或相关启动、升级日志;如有失败请求,补充状态码与响应片段。
可以在此上传诊断包或日志附件;粘贴日志时请使用 Markdown 代码块。不要上传完整配置或请求转储。
placeholder: |
发生时间与时区:
页面提示或终端错误:
浏览器控制台 / 服务日志:
失败请求的状态码与响应片段(如有):
- type: textarea
id: screenshots
attributes:
label: 截图或录屏
description: 页面或交互问题可在此拖入相关截图、录屏,并说明对应的操作。
- type: textarea
id: additional-context
attributes:
label: 补充信息
description: 最近有升级时填写升级前后版本及前后端是否同步更新;网络问题补充出站代理、系统代理、TUN 和自定义证书等相关环境。
placeholder: |
升级前版本 → 当前版本(如有):
前后端是否同步更新(如有升级):
最近的配置变更:
其他相关环境:
+41
View File
@@ -0,0 +1,41 @@
name: Build frontend
description: Validate and build the frontend, optionally uploading release assets
inputs:
upload-artifact:
description: Upload frontend-dist for downstream release jobs
default: 'false'
runs:
using: composite
steps:
- name: Setup pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
package_json_file: frontend/package.json
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install frontend dependencies
shell: bash
run: pnpm --dir frontend install --frozen-lockfile
- name: Frontend format
shell: bash
run: pnpm --dir frontend format:check
- name: Frontend build
shell: bash
run: pnpm --dir frontend build
- name: Upload frontend artifact
if: inputs.upload-artifact == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-dist
path: frontend/dist
if-no-files-found: error
retention-days: 1
+27
View File
@@ -0,0 +1,27 @@
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
FROM busybox:1.37.0-musl@sha256:5cec3fc171c87218698e85a52af7087de727372aae264a787b8112901a5b0092 AS restore
ARG CACHE_STAMP
RUN --mount=type=bind,target=/cache \
--mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/app/backend/target \
test -n "$CACHE_STAMP" \
&& cp -a /cache/registry/. /usr/local/cargo/registry/ \
&& cp -a /cache/git/. /usr/local/cargo/git/ \
&& cp -a /cache/target/. /app/backend/target/
FROM busybox:1.37.0-musl@sha256:5cec3fc171c87218698e85a52af7087de727372aae264a787b8112901a5b0092 AS collect
ARG CACHE_STAMP
# 一次导出三个 mount,避免逐目录创建镜像、载入 Docker 再复制文件。
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/app/backend/target \
test -n "$CACHE_STAMP" \
&& mkdir -p /cache/registry /cache/git /cache/target \
&& cp -a /usr/local/cargo/registry/. /cache/registry/ \
&& cp -a /usr/local/cargo/git/. /cache/git/ \
&& cp -a /app/backend/target/. /cache/target/
FROM scratch AS snapshot
COPY --from=collect /cache/ /
+31
View File
@@ -0,0 +1,31 @@
name: Cargo cache mounts
description: Restore or export a validated Cargo snapshot for a BuildKit builder
inputs:
operation:
description: Cache operation, restore or snapshot
required: true
builder:
description: Buildx builder name
required: true
path:
description: Cargo snapshot directory on the runner
required: true
architecture:
description: Cargo target directory architecture, amd64 or arm64
required: true
outputs:
usable:
description: Whether the restored or exported snapshot contains Cargo dependencies
value: ${{ steps.cache.outputs.usable }}
runs:
using: composite
steps:
- id: cache
shell: bash
env:
ACTION_PATH: ${{ github.action_path }}
OPERATION: ${{ inputs.operation }}
BUILDER: ${{ inputs.builder }}
CACHE_PATH: ${{ inputs.path }}
ARCHITECTURE: ${{ inputs.architecture }}
run: bash "$ACTION_PATH/cargo-cache.sh" "$OPERATION" "$BUILDER" "$CACHE_PATH" "$ARCHITECTURE"
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -ne 4 ]; then
echo "usage: $0 restore|snapshot BUILDER DIRECTORY amd64|arm64" >&2
exit 2
fi
operation="$1"
builder="$2"
directory="$(realpath -m "$3")"
architecture="$4"
case "$operation" in restore|snapshot) ;; *) exit 2 ;; esac
case "$architecture" in amd64|arm64) ;; *) exit 2 ;; esac
dockerfile="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/Dockerfile"
usable() {
local root="$1" path crates=false libraries=false
# 目录或 buildstamp 存在不代表编译发生过,必须同时有下载包与目标依赖。
for path in "$root"/registry/cache/*/*.crate; do
if [ -f "$path" ] && [ -s "$path" ]; then crates=true; break; fi
done
for path in "$root"/target/"$architecture"/release/deps/*.rlib; do
if [ -f "$path" ] && [ -s "$path" ]; then libraries=true; break; fi
done
[ "$crates" = true ] && [ "$libraries" = true ]
}
command=(docker buildx build --builder "$builder" --file "$dockerfile" --progress plain)
# mount 的内容不参与层指纹,用时间戳重跑复制;--no-cache 会重置 cache mount。
command+=(--build-arg "CACHE_STAMP=$(date +%s%N)" --target "$operation")
valid=false
if [ "$operation" = restore ]; then
if usable "$directory"; then
mkdir -p "$directory"/{registry,git,target}
"${command[@]}" --output type=cacheonly "$directory"
valid=true
else
echo "No usable Cargo snapshot; cached image layers may still be available"
fi
else
# 导出只发送空上下文,新快照独立校验后替换旧目录,避免残留文件造成误判。
mkdir -p "$(dirname "$directory")"
temporary="$(mktemp -d "${directory}.XXXXXX")"
trap 'rm -rf "$temporary"' EXIT
mkdir "$temporary/context"
"${command[@]}" --output "type=local,dest=$temporary/snapshot" "$temporary/context"
if usable "$temporary/snapshot"; then
rm -rf "$directory"
mv "$temporary/snapshot" "$directory"
valid=true
fi
fi
echo "Cargo snapshot for $architecture: usable=$valid"
if [ "$valid" = true ]; then du -sh "$directory"; fi
if [ -n "${GITHUB_OUTPUT:-}" ]; then printf 'usable=%s\n' "$valid" >> "$GITHUB_OUTPUT"; fi
+9 -2
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
@@ -64,7 +68,10 @@ updates:
- patch
- package-ecosystem: docker
directory: /deploy
directories:
- /deploy
- /.github/actions/cargo-cache
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。 -->
+35 -20
View File
@@ -1,4 +1,4 @@
name: Reusable Container
name: Container
on:
workflow_call:
@@ -28,35 +28,30 @@ 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
- name: Restore Cargo snapshot
id: cargo-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ runner.temp }}/container-cargo
key: container-cargo-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('backend/Cargo.lock', 'backend/**/Cargo.toml', 'deploy/Dockerfile') }}-${{ inputs.ref }}
# 同一平台和依赖复用快照,源码变化由 Cargo 指纹判定是否重编译
key: container-cargo-v3-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml', 'deploy/Dockerfile', '.github/actions/cargo-cache/Dockerfile') }}
restore-keys: |
container-cargo-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('backend/Cargo.lock', 'backend/**/Cargo.toml', 'deploy/Dockerfile') }}-
container-cargo-v1-${{ runner.os }}-${{ runner.arch }}-
container-cargo-v3-${{ runner.os }}-${{ runner.arch }}-
container-cargo-v2-${{ runner.os }}-${{ runner.arch }}-
# The GHA layer exporter does not persist RUN --mount=type=cache contents.
# Extraction runs before actions/cache saves its directories in post steps.
- name: Restore Cargo mounts to BuildKit
uses: reproducible-containers/buildkit-cache-dance@5422eac04292c961a382e0f584ea0f03ad9da723 # v3.4.0
id: cargo-restore
uses: ./.github/actions/cargo-cache
with:
operation: restore
builder: ${{ steps.buildx.outputs.name }}
scratch-dir: ${{ runner.temp }}/container-cache-dance
cache-map: |
{
"${{ runner.temp }}/container-cargo/registry": "/usr/local/cargo/registry",
"${{ runner.temp }}/container-cargo/git": "/usr/local/cargo/git",
"${{ runner.temp }}/container-cargo/target": "/app/backend/target"
}
skip-extraction: ${{ steps.cargo-cache.outputs.cache-hit }}
path: ${{ runner.temp }}/container-cargo
architecture: amd64
- name: Build runtime image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: deploy/Dockerfile
@@ -67,7 +62,7 @@ jobs:
runtime-base
runtime
cache-from: type=gha,scope=runtime-ci
cache-to: type=gha,mode=max,scope=runtime-ci
cache-to: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'type=gha,mode=max,scope=runtime-ci' || '' }}
- name: Verify runtime image
uses: ./.github/actions/verify-container
@@ -75,3 +70,23 @@ jobs:
image: ${{ env.CPR_IMAGE }}
platform: linux/amd64
artifact-name: container-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}-amd64
# 构建层命中时 mount 仍可能为空,只有实际检查过内容的快照才允许保存。
- name: Export Cargo snapshot
if: >-
github.event_name == 'push' && github.ref == 'refs/heads/main' &&
(steps.cargo-cache.outputs.cache-hit != 'true' || steps.cargo-restore.outputs.usable != 'true')
id: cargo-snapshot
uses: ./.github/actions/cargo-cache
with:
operation: snapshot
builder: ${{ steps.buildx.outputs.name }}
path: ${{ runner.temp }}/container-cargo
architecture: amd64
- name: Save Cargo snapshot
if: steps.cargo-snapshot.outputs.usable == 'true' && steps.cargo-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ runner.temp }}/container-cargo
key: ${{ steps.cargo-cache.outputs.cache-primary-key }}
+46 -38
View File
@@ -1,4 +1,4 @@
name: Reusable Quality
name: Quality
on:
workflow_call:
@@ -7,11 +7,6 @@ on:
description: Exact commit SHA to validate
required: true
type: string
upload_frontend:
description: Upload the built frontend for release jobs
required: false
default: false
type: boolean
run_backend:
description: Run backend quality checks
required: false
@@ -111,6 +106,7 @@ jobs:
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: backend -> target
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
- name: Rust format
working-directory: backend
@@ -120,6 +116,19 @@ jobs:
working-directory: backend
run: cargo clippy --all-targets --all-features --locked -- -D warnings
- name: Setup nextest
uses: taiki-e/install-action@4cef1412cce204788f482e778a0b9187f9626a29 # v2.87.21
with:
tool: cargo-nextest@0.9.143
fallback: none
- name: Build Rust tests once
working-directory: backend
run: |
cargo metadata --format-version 1 --locked > "$RUNNER_TEMP/nextest-cargo-metadata.json"
cargo nextest list --test main --locked --list-type binaries-only --message-format json \
> "$RUNNER_TEMP/nextest-binaries.json"
- name: Configure PostgreSQL and Redis test authentication
shell: bash
run: |
@@ -144,7 +153,30 @@ jobs:
- name: Rust tests
working-directory: backend
run: cargo test --test main --locked
run: |
cargo nextest run \
--cargo-metadata "$RUNNER_TEMP/nextest-cargo-metadata.json" \
--binaries-metadata "$RUNNER_TEMP/nextest-binaries.json" \
--no-tests fail
- name: Store tests with Redis ACL authentication
working-directory: backend
shell: bash
run: |
set -euo pipefail
redis_acl_password="$(openssl rand -hex 24)"
echo "::add-mask::$redis_acl_password"
docker exec "${{ job.services.redis.id }}" redis-cli -e --no-auth-warning \
-u "$CPR_TEST_REDIS_URL" --user default ACL SETUSER cpr_acl reset on \
">$redis_acl_password" '~*' '&*' '+@all' >/dev/null
docker exec --env REDISCLI_AUTH="$redis_acl_password" "${{ job.services.redis.id }}" \
redis-cli -e --user cpr_acl ACL SETUSER default off >/dev/null
export CPR_TEST_REDIS_URL="redis://cpr_acl:${redis_acl_password}@127.0.0.1:6379"
# 从同一份构建清单选择 Redis 测试,保留 workspace 特性图且不重复运行 PostgreSQL
cargo nextest run \
--cargo-metadata "$RUNNER_TEMP/nextest-cargo-metadata.json" \
--binaries-metadata "$RUNNER_TEMP/nextest-binaries.json" \
--no-tests fail -E 'package(=gateway-store) & test(/^redis::/)'
frontend:
if: inputs.run_frontend
@@ -158,35 +190,8 @@ jobs:
with:
ref: ${{ inputs.ref }}
- name: Setup pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 11.7.0
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install frontend dependencies
run: pnpm --dir frontend install --frozen-lockfile
- name: Frontend format
run: pnpm --dir frontend format:check
- name: Frontend build
run: pnpm --dir frontend build
- name: Upload frontend artifact
if: inputs.upload_frontend
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-dist
path: frontend/dist
if-no-files-found: error
retention-days: 1
- name: Validate and build frontend
uses: ./.github/actions/build-frontend
workflow-lint:
runs-on: ubuntu-latest
@@ -206,5 +211,8 @@ jobs:
-color
.github/workflows/*.yml
- name: Check release script syntax
run: bash -n release/publish
- name: Check workflow script syntax
run: |
bash -n release/publish
shopt -s globstar nullglob
for script in .github/**/*.sh; do bash -n "$script"; done
+78 -3
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
@@ -37,23 +38,40 @@ jobs:
id: filter
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
with:
# 目录规则取并集,再统一排除说明文档,保留隐藏配置与子模块指针
predicate-quantifier: some-with-excludes
filters: |
backend:
- 'backend/**'
- 'release/version.yaml'
- '.github/workflows/**'
- '.github/workflows/ci.yml'
- '.github/workflows/_quality.yml'
- '!**/*.md'
frontend:
- 'frontend/**'
- '.github/workflows/**'
- '.github/actions/build-frontend/**'
- '.github/workflows/ci.yml'
- '.github/workflows/_quality.yml'
- '!**/*.md'
source:
- '.gitmodules'
- 'modules/ui'
- 'modules/plugins'
- 'frontend/**'
- 'backend/crates/gateway-plugin/sdk/**'
- '.github/workflows/ci.yml'
- '!**/*.md'
container:
- 'backend/**'
- 'frontend/**'
- 'deploy/**'
- '.dockerignore'
- 'release/version.yaml'
- '.github/actions/**'
- '.github/actions/verify-container/**'
- '.github/actions/cargo-cache/**'
- '.github/workflows/_container.yml'
- '.github/workflows/ci.yml'
- '!**/*.md'
quality:
needs: changes
@@ -72,3 +90,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
+138 -25
View File
@@ -35,6 +35,7 @@ jobs:
docker_platforms: ${{ steps.version.outputs.docker_platforms }}
previous_tag: ${{ steps.version.outputs.previous_tag }}
prerelease: ${{ steps.version.outputs.prerelease }}
build_type: ${{ steps.version.outputs.build_type }}
steps:
- name: Checkout
@@ -57,7 +58,7 @@ jobs:
require "yaml"
APP_NAME = "codex-proxy-rs"
SEMVER = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\z/
SEMVER = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:alpha|beta|rc|exp)\.[1-9]\d*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z/
def fail(message)
warn(message)
@@ -161,14 +162,67 @@ jobs:
puts "docker_platforms=#{docker_platforms.join(",")}"
puts "previous_tag=#{previous_tag}"
puts "prerelease=#{version.split("+", 2).first.include?("-")}"
puts "build_type=#{version.split("+", 2).first.include?("-exp.") ? "experimental" : "release"}"
RUBY
- name: Seal plugin release manifest
env:
VERSION: ${{ steps.version.outputs.version }}
GIT_SHA: ${{ steps.version.outputs.git_sha }}
run: |
ruby <<'RUBY'
require "fileutils"
require "json"
host_path = "backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json"
host = JSON.parse(File.read(host_path))
abort "Invalid plugin host compatibility declaration: #{host_path}" unless host["schema_version"] == 2
# 保留旧更新器可解析的传输格式,实际支持范围仍取自当前宿主合同。
host = host.merge("schema_version" => 1, "permissions" => [])
# 清单只依赖版本与兼容声明,平台归档和镜像消费同一份封口结果。
manifest = {
"schema_version" => 1,
"sealed" => true,
"gateway_version" => ENV.fetch("VERSION"),
"gateway_git_sha" => ENV.fetch("GIT_SHA"),
"plugin_host" => host,
"plugins" => [],
}
FileUtils.mkdir_p("dist/plugin-release-assets")
File.write("dist/plugin-release-assets/plugin-release-manifest.json", JSON.pretty_generate(manifest) + "\n")
RUBY
- name: Upload sealed plugin assets
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: plugin-release-assets
path: dist/plugin-release-assets
if-no-files-found: error
retention-days: 1
quality:
needs: [release-info]
uses: ./.github/workflows/_quality.yml
with:
ref: ${{ needs.release-info.outputs.git_sha }}
upload_frontend: true
run_frontend: false
frontend:
needs: release-info
runs-on: ubuntu-latest
timeout-minutes: 15
env:
CI: 'true'
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release-info.outputs.git_sha }}
- name: Validate and build frontend
uses: ./.github/actions/build-frontend
with:
upload-artifact: 'true'
build-docker-binary:
name: build-docker-binary (${{ matrix.os }}-${{ matrix.arch }})
@@ -192,10 +246,10 @@ jobs:
~/.cargo/registry
~/.cargo/git
backend/target
key: cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}-${{ needs.release-info.outputs.git_sha }}
# 按目标、工具链和依赖保存,不为每个发行提交复制整个 target
key: cargo-v3-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml', '.github/workflows/release.yml') }}
restore-keys: |
cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
cargo-v2-${{ runner.os }}-${{ matrix.target }}-
cargo-v3-${{ runner.os }}-${{ matrix.target }}-
- name: Build binary
shell: bash
@@ -221,7 +275,7 @@ jobs:
CPR_VERSION: ${{ needs.release-info.outputs.version }}
CPR_GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
CPR_BUILD_TIME: ${{ needs.release-info.outputs.build_time }}
CPR_BUILD_TYPE: release
CPR_BUILD_TYPE: ${{ needs.release-info.outputs.build_type }}
- name: Upload binary artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -258,10 +312,9 @@ jobs:
~/.cargo/registry
~/.cargo/git
backend/target
key: cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}-${{ needs.release-info.outputs.git_sha }}
key: cargo-v3-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml', '.github/workflows/release.yml') }}
restore-keys: |
cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
cargo-v2-${{ runner.os }}-${{ matrix.target }}-
cargo-v3-${{ runner.os }}-${{ matrix.target }}-
- name: Build binary
shell: bash
@@ -274,7 +327,7 @@ jobs:
CPR_VERSION: ${{ needs.release-info.outputs.version }}
CPR_GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
CPR_BUILD_TIME: ${{ needs.release-info.outputs.build_time }}
CPR_BUILD_TYPE: release
CPR_BUILD_TYPE: ${{ needs.release-info.outputs.build_type }}
- name: Upload binary artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -285,7 +338,7 @@ jobs:
retention-days: 1
package-assets:
needs: [release-info, quality, build-docker-binary, build-binary]
needs: [release-info, frontend, build-docker-binary, build-binary]
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
@@ -311,6 +364,12 @@ jobs:
pattern: codex-proxy-rs-*
path: dist/binaries
- name: Download sealed plugin assets
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: plugin-release-assets
path: dist/plugin-release-assets
- name: Package release assets
shell: bash
env:
@@ -362,6 +421,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']}"
@@ -370,8 +431,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)
@@ -385,11 +445,24 @@ 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_manifest_path = release_dir / "plugin-release-manifest.json"
shutil.copy2("dist/plugin-release-assets/plugin-release-manifest.json", plugin_manifest_path)
assets.append(plugin_manifest_path)
# 保留运行时清单目录合同,宿主包不再携带教学示例。
for platform, package_root, archive_path in release_packages:
official_dir = package_root / "plugins" / "official"
official_dir.mkdir(parents=True)
bundled_manifest = official_dir / plugin_manifest_path.name
shutil.copy2(plugin_manifest_path, bundled_manifest)
bundled_manifest.chmod(0o444)
official_dir.chmod(0o555)
with tarfile.open(archive_path, "w:gz") as archive:
archive.add(package_root, arcname=APP_NAME)
assets.append(archive_path)
checksum_path = release_dir / "checksums.txt"
@@ -410,10 +483,12 @@ jobs:
path: dist/release
if-no-files-found: error
retention-days: 1
# tar.gz 已压缩,Actions 只负责传递原始发行字节。
compression-level: 0
build-docker-image:
name: build-docker-image (${{ matrix.dockerPlatform }})
needs: [release-info, quality, build-docker-binary]
needs: [release-info, frontend, build-docker-binary]
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.release-info.outputs.docker_matrix) }}
@@ -446,6 +521,12 @@ jobs:
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
path: dist/docker-artifacts/codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
- name: Download sealed plugin assets
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: plugin-release-assets
path: dist/plugin-release-assets
- name: Prepare Docker prebuilt assets
shell: bash
env:
@@ -455,6 +536,7 @@ jobs:
run: |
set -euo pipefail
python3 - <<'PY'
import json
import shutil
import os
import pathlib
@@ -470,10 +552,40 @@ jobs:
binary_dest = target_dir / app_name
shutil.copy2(binary_src, binary_dest)
binary_dest.chmod(0o755)
release_dir = pathlib.Path("dist/plugin-release-assets")
manifest_path = release_dir / "plugin-release-manifest.json"
manifest = json.loads(manifest_path.read_text())
if manifest.get("schema_version") != 1 or manifest.get("sealed") is not True:
raise SystemExit("Invalid sealed plugin release manifest")
official_dir = target_dir / "plugins" / "official"
official_dir.mkdir(parents=True)
bundled_manifest = official_dir / manifest_path.name
shutil.copy2(manifest_path, bundled_manifest)
bundled_manifest.chmod(0o444)
selected = 0
for plugin in manifest.get("plugins", []):
artifacts = [
artifact for artifact in plugin.get("artifacts", [])
if artifact.get("release_os") == os.environ["MATRIX_OS"]
and artifact.get("release_architecture") == os.environ["MATRIX_ARCH"]
]
if len(artifacts) != 1:
raise SystemExit(f"Official plugin platform selection is not unique: {plugin.get('id')}")
archive = release_dir / artifacts[0]["archive"]
if not archive.is_file():
raise SystemExit(f"Missing official plugin archive: {archive.name}")
bundled_archive = official_dir / archive.name
shutil.copy2(archive, bundled_archive)
bundled_archive.chmod(0o444)
selected += 1
if selected != len(manifest.get("plugins", [])):
raise SystemExit("Official plugin release bundle is incomplete")
official_dir.chmod(0o555)
PY
- 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
@@ -484,7 +596,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
@@ -534,7 +646,8 @@ jobs:
retention-days: 1
publish:
needs: [release-info, package-assets, build-docker-image]
# 产物准备可与测试重叠,版本标签和 GitHub Release 必须等全部质量与镜像验证通过
needs: [release-info, quality, package-assets, build-docker-image]
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
@@ -563,7 +676,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
@@ -669,9 +782,6 @@ jobs:
shell: bash
env:
INSTALLATION_NOTES: |
> [!WARNING]
> **跨大版本升级:** 不支持跨大版本在线升级。请使用全新的 `.runtime/` 数据目录重新部署,再重新导入或重新授权服务商账号,并重新导入客户端密钥。详见[升级说明](https://github.com/${{ github.repository }}/blob/${{ needs.release-info.outputs.tag }}/deploy/README.md#镜像升级与源码构建)。
## 📥 安装
**Docker 部署:**
@@ -685,6 +795,9 @@ jobs:
**手动下载:**
请从下方附件中下载适合当前平台的归档包。
附件包含与当前宿主版本及提交绑定的 `plugin-release-manifest.json`,当前没有默认内置插件。
教学示例由独立的 `codex-proxy-plugins` 仓库维护和发布,不随宿主发行物分发。
发布归档提供 GitHub 构建产物证明。Docker 镜像附带软件物料清单(SBOM)、构建来源证明和 cosign 无密钥签名。
## 📚 文档
@@ -1,4 +1,4 @@
name: Agent Instructions Guard
name: Rules Guard
on:
pull_request_target:
@@ -13,7 +13,7 @@ permissions:
pull-requests: write
concurrency:
group: agents-md-guard-${{ github.event.pull_request.number }}
group: rules-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
@@ -22,7 +22,7 @@ jobs:
timeout-minutes: 5
steps:
# 只查询 PR 元数据,禁止检出或执行贡献分支中的代码。
- name: Enforce agent instructions policy
- name: Check protected files
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
@@ -33,12 +33,15 @@ jobs:
...params,
per_page: 100,
});
const isInstructions = path => typeof path === 'string'
&& (path === 'AGENTS.md' || path.endsWith('/AGENTS.md'));
const isProtectedRule = path => typeof path === 'string'
&& (path === 'AGENTS.md' || path.endsWith('/AGENTS.md')
|| path === 'CONTRIBUTING.md'
|| path === '.agents/skills' || path.startsWith('.agents/skills/')
|| path === '.github' || path.startsWith('.github/'));
// 同时检查重命名前后的路径,避免通过移走规则文件绕过检查。
if (!files.some(file => isInstructions(file.filename) || isInstructions(file.previous_filename))) {
core.info('No agent instruction changes detected.');
if (!files.some(file => isProtectedRule(file.filename) || isProtectedRule(file.previous_filename))) {
core.info('No protected contribution rule changes detected.');
return;
}
@@ -49,27 +52,31 @@ jobs:
return;
}
// 通知标记保持稳定,以便复用机器人已有评论,避免重复提醒
const marker = '<!-- agents-md-guard -->';
const body = [
marker,
'此 PR 修改了 `AGENTS.md`,已按贡献规则关闭。',
'佬友,我看到这个 PR 涉及受保护的协作规则文件,按仓库约定需要先关闭',
'',
'PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或重命名。',
'请撤销相关变更后重新打开 PR;如需调整协作规则,请先提交 Issue 说明原因和建议,由维护者处理。',
'保护范围包括根目录或子目录的 `AGENTS.md`、根目录的 `CONTRIBUTING.md`,以及 `.agents/skills/` 和 `.github/` 中的所有文件,涵盖新增、修改、删除和重命名',
'',
'麻烦先撤销这些变更,再重新打开 PR;如果希望调整协作规则,可以先提个 Issue 说明原因和建议,交由维护者处理,感谢理解',
].join('\n');
try {
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: pr.number, per_page: 100,
});
const notified = comments.some(comment => comment.user?.login === 'github-actions[bot]'
const notification = comments.find(comment => comment.user?.login === 'github-actions[bot]'
&& comment.body?.includes(marker));
if (!notified) {
if (!notification) {
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
} else if (notification.body !== body) {
await github.rest.issues.updateComment({ owner, repo, comment_id: notification.id, body });
}
} catch (error) {
core.warning(`Unable to post policy explanation: ${error.message}`);
}
await github.rest.pulls.update({ ...params, state: 'closed' });
core.setFailed('Pull requests must not change AGENTS.md.');
core.setFailed('Pull requests must not change protected contribution rules (AGENTS.md, CONTRIBUTING.md, .agents/skills/, .github/).');
@@ -1,4 +1,4 @@
name: Security Scan
name: Security
on:
push:
@@ -14,7 +14,7 @@ permissions:
contents: read
concurrency:
group: security-scan-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }}
group: security-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
jobs:
@@ -27,29 +27,15 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup Rust
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
- name: Cache Rust
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: backend -> target
- name: Install cargo-audit
run: cargo install cargo-audit --version 0.22.2 --locked
uses: taiki-e/install-action@4cef1412cce204788f482e778a0b9187f9626a29 # v2.87.21
with:
tool: cargo-audit@0.22.2
fallback: none
- name: Run cargo audit
working-directory: backend
shell: bash
- name: Run Cargo audits
run: |
# 临时接受此中危风险以保持 Codex 0.154.0 的 TLS 指纹;直接升级会新增 ML-DSA 签名算法。
# 仅豁免本条公告,2026-09-21 UTC 起恢复阻断;兼容升级验证完成后应提前删除豁免。
# https://github.com/rustls/rustls/security/advisories/GHSA-2mjx-qc3c-rqvc
audit_advisory_args=()
if [[ "$(date -u +%F)" < "2026-09-21" ]]; then
audit_advisory_args+=(--ignore RUSTSEC-2026-0285)
fi
cargo audit "${audit_advisory_args[@]}"
cargo-audit audit --file backend/Cargo.lock
frontend-security:
runs-on: ubuntu-latest
@@ -63,7 +49,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 -2
View File
@@ -6,9 +6,8 @@
# Frontend
/frontend/node_modules/
/frontend/dist/
/frontend/.vite/
/.pnpm-store/
/frontend/.pnpm-store/
/frontend/.pnpm-cache/
*.tsbuildinfo
# Release artifacts
@@ -20,6 +19,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
+27 -6
View File
@@ -1,8 +1,25 @@
# 项目协作约定
开发和审查先阅读 [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、补充或回复 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)
Issue 和 PR 回复分别按对应技能的[Issue 回复口吻](.agents/skills/cpr-github-issue/SKILL.md#回复口吻)与[PR 回复口吻](.agents/skills/cpr-github-pr/SKILL.md#回复口吻)执行
模块职责以 [系统架构](docs/architecture.md) 为准,界面约定以 [管理端主题](docs/theme.md) 为准;
没有明确约定时保持与相邻实现一致,不把单次页面反馈扩展成全仓规则。
没有明确约定时保持与相邻实现一致,不把单次页面反馈扩展成全仓规则
<!-- CODEGRAPH_START -->
## CodeGraph
@@ -17,27 +34,31 @@ If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is
## 工作方式
- 开发和审查按 [问题与方案依据](CONTRIBUTING.md#问题与方案依据) 先核实问题,以官方源码为首要参考确定或评估方案。
- 开发和审查按 [问题与方案依据](CONTRIBUTING.md#问题与方案依据) 先核实问题;涉及 Codex 行为、协议或兼容性时,按[参考仓库优先级](.agents/skills/cpr-dev-guide/SKILL.md#参考仓库的优先级)以官方 Codex 仓库对应版本源码为第一手资料,三方仓库仅作参考
- 先定位变更所属模块、同类实现和上下游调用关系,再选择最小合理改动。复用已有能力,也避免为尚不存在的需求增加抽象。
- 提交前自审最终差异,先处理本次引入的职责混淆、重复规则、冗余状态和无依据的兼容分支;后端按 [后端自审](docs/architecture.md#后端自审) 追踪状态与资源生命周期,不能把自审留给 CI 或 PR 审查者。
- 页面变更先复用已有页面设计与组件,不因个人偏好重排页面或另建交互。文案取舍遵循 [界面文案与信息层级](docs/theme.md#界面文案与信息层级),页面 PR 必须提供 [实际截图](CONTRIBUTING.md#界面验证)。
- 代码注释使用中文,解释原因与边界;提交信息使用英文,沿用历史中的 Conventional Commits 格式。
- 常规文档只描述当前状态,不记录变更历史;按[文档职责与更新条件](CONTRIBUTING.md#文档职责与更新条件)在所属章节就地修订,分清层次与详略,短段和列表少用句号,交付前逐段检查必要性、归属、重复与当前状态表达
- 按变更范围执行验证,记录命令、结果和缺口。跳过的测试不算通过;界面与集成行为需要对应运行证据,构建通过不能替代实际验收。
- 审查请求默认只读。修复、提交、推送和合并按用户当前授权执行;“本地验证通过”不等于用户已经审阅批准。
- 不读取或输出无关凭据,不把真实密钥、代理认证、账号令牌或请求转储放入提交、截图及审查报告。
- 使用 AI 的 PR 按 [AI 使用披露](CONTRIBUTING.md#ai-使用披露) 写明实际模型型号,不以工具名称代替模型。
## Code Review Rules
### 审查范围
- 确认目标分支与当前变更范围,结合调用方、被调用方及已有测试审查;结论对应实际审查的提交或工作区状态。
- 按 CONTRIBUTING.md 的维度检查设计、正确性、安全、复杂度、性能、可读性、验证、界面和文档,深度与变更风险相称。
- 按[审查标准](CONTRIBUTING.md#审查标准)检查设计、正确性、安全、复杂度、性能、可读性、验证、界面和文档,深度与变更风险相称
- 重点核对现有架构和数据合同:共享业务事实是否重复解释,异步与并发操作是否破坏状态,权限和敏感数据是否越界,协议适配是否影响兼容性。
- 修改既有特殊分支时先查场景和历史;放宽检查、修改测试或新增例外需要业务依据,不能仅以让检查通过为理由。
### 代码与界面一致性
- 对重复逻辑、冗余状态和多余抽象给出具体位置及影响;建议复用时指出已有实现,不能仅凭行数或语法相似判定质量。
- UI 变更对照项目基础组件、主题和同类页面,检查交互、状态、响应式与可访问性。局部问题是否应在共用组件修复,按职责判断,不一律禁止局部样式。
- 核对截图、预览和实际交互证据;未检查的状态明确列为验证缺口,不推断视觉验收已经通过。
- UI 变更对照原页面、项目基础组件、主题和同类页面,检查布局变化是否服务本次需求、是否堆叠重复文案,以及交互、状态、响应式与可访问性。局部问题是否应在共用组件修复,按职责判断,不一律禁止局部样式。
- 核对页面 PR 按 [界面验证](CONTRIBUTING.md#界面验证) 提供的截图与实际交互证据;缺少必需截图时明确要求补齐,未检查的状态列为验证缺口,不推断视觉验收已经通过。
### 审查输出
+119 -56
View File
@@ -1,58 +1,83 @@
# 贡献与审查
贡献应解决明确的问题,并保持或改善项目的可维护性。提交者、自动检查和审查者使用同一套标准,让重复问题在提交和初审阶段得到处理。
贡献应解决明确的问题,并保持或改善项目的可维护性。提交者、自动检查和审查者使用同一套标准,让重复问题在提交和初审阶段得到处理
本文定义协作流程与审查标准;模块职责以 [系统架构](docs/architecture.md) 为准,视觉规则以 [管理端主题](docs/theme.md) 为准。没有明确约定时遵循相邻实现;具体页面的布局决定不自动成为全项目限制。
本文定义协作流程与审查标准;模块职责以 [系统架构](docs/architecture.md) 为准,视觉规则以 [管理端主题](docs/theme.md) 为准。没有明确约定时遵循相邻实现;具体页面的布局决定不自动成为全项目限制
## 提交 Issue
## 交流与反馈
问题反馈使用仓库的 [Issue 表单](.github/ISSUE_TEMPLATE/bug_report.yml),填写问题类型、描述、版本与环境、复现步骤和预期结果。接口问题补充平台与认证类型、模型、客户端,以及脱敏后的请求、响应和请求 ID;页面问题附相关截图或录屏。
[讨论区](https://github.com/zyycn/codex-proxy-rs/discussions) 用于使用问答、想法讨论、实验反馈和经验分享。
提问时按需补充版本、部署方式、已尝试的方法及脱敏信息;问题解决后,请在「使用问答」中标记有帮助的答案
日志和配置只提供定位所需片段,不上传真实凭据或完整请求转储。功能建议与协作规则调整可直接提交 Issue,说明场景、原因和建议方案。
明确的 Bug、具体功能需求或协作规则建议使用 Issue,无需先开讨论。
讨论形成可执行事项后,可以新建 Issue 并互相附链接;已有 Issue 的后续信息继续补充在原帖,避免重复记录。
围绕问题友善交流,欢迎不同使用场景和方案观点
### 提交 Issue
按场景选择 [接口问题反馈](.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题反馈](.github/ISSUE_TEMPLATE/general-bug-report.yml) 或 [功能建议](.github/ISSUE_TEMPLATE/feature-request.yml) 表单,按对应提示提供信息。故障反馈填写实际运行版本和中转链路;接口问题区分客户端显示与网关实际响应,页面问题附相关截图或录屏
日志和配置只提供定位所需片段,不上传真实凭据或完整请求转储。功能建议说明使用场景、当前不便和期望行为;协作规则调整可直接提交普通 Issue,说明原因和建议方案
## 问题与方案依据
开发、创建 PR 和审查都先核实待解决的问题、触发条件与预期行为,再确定或评估实现。区分问题发生版本、目标分支现状和本次变更后的行为,不把需求描述、PR 描述或第三方项目的做法直接当成事实。目标分支已修复的问题,应说明当前仍需补齐的差异,不能据此否定旧版本中的问题。
开发、创建 PR 和审查都先核实待解决的问题、触发条件与预期行为,再确定或评估实现。区分问题发生版本、目标分支现状和本次变更后的行为,不把需求描述、PR 描述或第三方项目的做法直接当成事实。目标分支已修复的问题,应说明当前仍需补齐的差异,不能据此否定旧版本中的问题
涉及上游协议、客户端行为或兼容性时,以对应版本的官方源码为首要资料。优先读取用户提供或本机已有的官方源码,核对仓库来源、版本或提交;本地资料不足时查官方仓库的对应版本源码,再结合官方测试、文档、发布说明和运行证据。第三方资料只作线索或补充。引用具体文件、符号或行号,对外引用优先使用固定提交的源码链接;源码不可得或版本不匹配时说明证据缺口,不把最新实现当成当前部署行为。无上游依赖的内部变更,以本项目代码、合同和运行证据为依据。
涉及上游协议、客户端行为或兼容性时,以对应版本的官方源码为首要资料。优先读取用户提供或本机已有的官方源码,核对仓库来源、版本或提交;本地资料不足时查官方仓库的对应版本源码,再结合官方测试、文档、发布说明和运行证据。第三方资料只作线索或补充。引用具体文件、符号或行号,对外引用优先使用固定提交的源码链接;源码不可得或版本不匹配时说明证据缺口,不把最新实现当成当前部署行为。无上游依赖的内部变更,以本项目代码、合同和运行证据为依据
官方已有同一场景的处理方式时,优先沿用其协议语义、职责归属和解决路径,检查是否只需复用已有能力或正确传递官方合同。新增覆盖配置、补偿逻辑或采用第三方兼容方案,应说明官方路径为何不足;偏离官方方案需有本项目的实际约束和验证依据,不机械照搬源码,也不默认接受已有提案。
官方已有同一场景的处理方式时,优先沿用其协议语义、职责归属和解决路径,检查是否只需复用已有能力或正确传递官方合同。新增覆盖配置、补偿逻辑或采用第三方兼容方案,应说明官方路径为何不足;偏离官方方案需有本项目的实际约束和验证依据,不机械照搬源码,也不默认接受已有提案
反馈 Issue 时区分观察到的症状、根因假设和方案建议,关联已有 Issue、PR 及包含修复的发布版本。能说明问题的反馈可以先提交,不要求报告者先找到官方源码或证明根因;引用官方行为或提出实现方案时,按上述原则注明依据与缺口。
反馈 Issue 时区分观察到的症状、根因假设和方案建议,关联已有 Issue、PR 及包含修复的发布版本。能说明问题的反馈可以先提交,不要求报告者先找到官方源码或证明根因;引用官方行为或提出实现方案时,按上述原则注明依据与缺口
## PR 流程
1. **准备变更。** 按上述依据核实问题与方案,找到所属模块、相关调用链和最接近的现有实现,在功能分支完成修改与自查。涉及架构、公开合同或较大交互变化时,先说明方案与取舍。
2. **发起 PR。** 目标分支使用 `main`。说明问题成立的依据、触发场景、改动后的行为和实际验证结果;涉及官方行为时注明源码位置、版本和方案关系。界面改动提供截图、录屏或可复现的预览方式。一个 PR 围绕一个明确目标,必要重构说明关联原因。
3. **自动检查与初审。** CI 执行适用的格式、静态分析、测试和构建检查;启用 AI 审查后,由它按本规范辅助检查语义、复用和一致性。提交者先处理明确问题,维护者重点审阅方案、风险与用户体验。
4. **修改与合并。** 更新代码后重新执行受影响的检查,确认审查意见已处理,验证证据对应当前变更。维护者审阅批准后再合并;需要升级说明的变更同步相关文档。
1. **准备变更**:按上述依据核实问题与方案,找到所属模块、相关调用链和最接近的现有实现,在功能分支完成修改。提交前自审完整差异,清理本次引入的重复逻辑、冗余状态与职责越界;后端按 [后端自审](docs/architecture.md#后端自审) 执行。涉及架构、公开合同或较大交互变化时,先说明方案与取舍
2. **发起 PR**:目标分支使用 `main`,按 [PR 模板](.github/pull_request_template.md) 说明问题依据、触发场景、最终行为、自审和实际验证结果;涉及官方行为时注明源码位置、版本和方案关系。页面变更必须按 [界面验证](#界面验证) 附实际截图,使用 AI 时按 [AI 使用披露](#ai-使用披露) 写明模型。一个 PR 围绕一个明确目标,必要重构说明关联原因
3. **自动检查与初审**:CI 执行适用的格式、静态分析、测试和构建检查;启用 AI 审查后,由它按本规范辅助检查语义、复用和一致性。提交者先处理明确问题,维护者重点审阅方案、风险与用户体验
4. **修改与合并**:更新代码后重新执行受影响的检查,确认审查意见已处理,验证证据对应当前变更。维护者审阅批准后再合并;需要升级说明的变更同步相关文档
AI 开发和审查入口是根目录的 [AGENTS.md](AGENTS.md)。在仓库上下文中要求“审查 PR #编号”时,应按下列标准执行,无需再次列举要求。
AI 开发和审查入口是根目录的 [AGENTS.md](AGENTS.md)。在仓库上下文中要求“审查 PR #编号”时,应按下列标准执行,无需再次列举要求
### 项目技能
仓库技能位于 `.agents/skills/`,随代码共享。技能提供执行流程,协作与审查标准仍以本文和 `AGENTS.md` 为准。
AI 开发、排查、代码审查和文档修改必须执行 `cpr-dev-guide`;准备、创建、更新及审查 PR 必须执行 `cpr-github-pr`,不以用户是否显式点名技能为条件。PR 内修复仍须经过开发流程,不能用填写 PR 正文代替实现自审与验证
| 技能 | 用途 | 调用示例 |
| --- | --- | --- |
| [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 自动审查。
审查请求默认在当前会话报告;明确要求创建 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,继续正常检查。
PR 不接受以下协作规则文件的新增、修改、删除或重命名:
源码变更遵守现有架构边界与迁移冻结规则,由对应 CI 检查执行;不额外设立整块业务目录的 PR 禁改清单。构建、测试和安全扫描复用现有工作流。
- 根目录及任意子目录的 `AGENTS.md`
- 根目录的 `CONTRIBUTING.md`
- `.agents/skills/` 中的所有文件,包括技能入口、引用文档、配置和脚本
- `.github/` 中的所有文件,包括工作流、Actions、Issue/PR 模板和依赖更新配置
调整这些文件时先提交 Issue 说明原因和建议,由维护者处理;[规则保护工作流](.github/workflows/rules.yml) 会提示并关闭包含此类变更的 PR。撤销相关变更后可以重新打开 PR,继续正常检查
源码变更遵守现有架构边界与迁移冻结规则,由对应 CI 检查执行;不额外设立整块业务目录的 PR 禁改清单。构建、测试和安全扫描复用现有工作流
## 审查标准
按改动涉及的范围选择检查深度,无需为每个 PR 填写所有维度的报告。
按改动涉及的范围选择检查深度,无需为每个 PR 填写所有维度的报告
结论先回答原问题是否成立(成立、部分成立、不成立或证据不足),说明触发条件和依据;再判断解决方向是否合理,交代适用的官方方案、本次实现与它的关系及必要调整,最后报告实现问题与验证缺口。原问题是否成立与 PR 实现是否正确分别判断。源码推断、实际复现和检查结果分别说明,CI 通过不能代替问题与方案的论证。
结论先回答原问题是否成立(成立、部分成立、不成立或证据不足),说明触发条件和依据;再判断解决方向是否合理,交代适用的官方方案、本次实现与它的关系及必要调整,最后报告实现问题与验证缺口。原问题是否成立与 PR 实现是否正确分别判断。源码推断、实际复现和检查结果分别说明,CI 通过不能代替问题与方案的论证
| 维度 | 检查重点 |
| --- | --- |
@@ -68,51 +93,81 @@ PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或
审查意见区分三类:
- **必须修复:** 有具体触发条件的缺陷、违反现有合同或明确项目规范的问题。说明位置、依据、影响和最小修复方向。
- **建议:** 不影响合同或规范的替代实现、命名偏好和局部优化。个人偏好不能单独阻止合并,也不应强求与本次目标无关的重构。
- **验证缺口:** 尚未检查的行为、状态或运行环境。说明缺少什么证据,由维护者评估是否需要补齐,不能把未验证当作已通过或已发现缺陷。
- **必须修复:** 有具体触发条件的缺陷、违反现有合同或明确项目规范的问题。说明位置、依据、影响和最小修复方向
- **建议:** 不影响合同或规范的替代实现、命名偏好和局部优化。个人偏好不能单独阻止合并,也不应强求与本次目标无关的重构
- **验证缺口:** 尚未检查的行为、状态或运行环境。说明缺少什么证据,由维护者评估是否需要补齐,不能把未验证当作已通过或已发现缺陷
复杂度不按行数判断,复用不只看语法相似。建议合并重复逻辑时应指出共享的业务含义;建议保留特殊分支时应能说明它服务的场景。例外应有依据,不能仅为绕过检查而放宽规则。
复杂度不按行数判断,复用不只看语法相似。建议合并重复逻辑时应指出共享的业务含义;建议保留特殊分支时应能说明它服务的场景。例外应有依据,不能仅为绕过检查而放宽规则
## 项目约定
- AI 进行 Rust 开发时,加载并使用 `$rust-best-practices` 技能。模块、可见性、依赖方向和测试目录按架构文档执行,使用现有 Rustfmt 与 Clippy 配置。
- Vue 使用 TypeScript、`<script setup>` 和现有 ESLint 配置;API 请求通过已有模块及 `request` 封装,沿用逐行请求配置格式。
- 业务数据的解析、校验和展示优先复用已有逻辑。新增抽象放在拥有该职责的模块,避免重复事实源或无实际需求的通用层。
- 新增代码注释使用中文。提交信息使用英文 Conventional Commits,参考近期历史,按完整功能归并;确由 Codex 参与的提交保留 `Co-authored-by: Codex <noreply@openai.com>`。
- 发布说明写入 `release/notes.md`,标题首行为目标版本的 `# vX.Y.Z`,章节标题和正文使用中文;命令、配置键、协议名等保留原文。只说明本次版本的实际变化,按影响交代升级事项。
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理。
- AI 进行 Rust 开发时,加载并使用 `$rust-best-practices` 技能。模块、可见性、依赖方向和测试目录按架构文档执行,使用现有 Rustfmt 与 Clippy 配置
- Vue 使用 TypeScript、`<script setup>` 和现有 ESLint 配置;API 请求通过已有模块及 `request` 封装,沿用逐行请求配置格式
- 业务数据的解析、校验和展示优先复用已有逻辑。新增抽象放在拥有该职责的模块,避免重复事实源或无实际需求的通用层
- 新增代码注释使用中文。提交信息使用英文 Conventional Commits,参考近期历史,按完整功能归并;确由 Codex 参与的提交保留 `Co-authored-by: Codex <noreply@openai.com>`
- 发布说明写入 `release/notes.md`,标题首行为目标版本的 `# vX.Y.Z`,章节标题和正文使用中文;命令、配置键、协议名等保留原文。只说明本次版本的实际变化,按影响交代升级事项
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理
## 文档职责与更新条件
常规文档说明当前有效的用法、接口合同、模块职责和维护约束,不承担变更历史。
只有改动使对应说明失真、缺少必要信息,或改变读者需要执行的操作与判断时,才更新文档。
内部修复、重构和界面微调不默认附带文档改动;审查时不能仅因 PR 没改文档就判定存在缺口。
**文档只描述当前有效状态,不记录变更历史**,在所属章节就地修订,不追加“本次新增”“此前行为”或修复经过
- 按 [开发指南的文档入口](.agents/skills/dev-guide/SKILL.md#按任务读取) 找到内容所属文档,在原有章节就地修订,
不按每次变更追加说明。同一事实只保留一处完整说明,其他文档按读者任务提供必要摘要和链接。
- API 文档保留调用所需的字段、状态、语义和限制;架构文档保留职责与不变量;部署文档保留配置、操作和排障。
不把界面布局、内部锁或索引实现写进接口说明,也不把前端库的实现选择写进部署说明。
- 改动原因、前后对比、排查经过和本次验证结果放在 PR / Issue 或任务报告。版本变更与升级影响按维护者的发版安排维护
`release/notes.md`,不为普通开发任务或 PR 自动追加发布说明。
- 仍影响实际升级、恢复或兼容使用的限制与操作应保留,并注明适用条件;不能仅因涉及旧版就删除。
审查文档改动时核对必要性、所属职责与重复内容,区分当前使用说明和仅解释此次修复的内容。
只有用法、合同、职责、约束或读者需要执行的操作发生变化时才更新,内部重构和界面微调不默认附带文档改动
### 内容归属
| 文档 | 回答的问题 | 详略边界 |
| --- | --- | --- |
| [README](README.md) | 项目能做什么,如何开始 | 简短介绍、最短可用路径和文档导航 |
| [部署与运维](deploy/README.md) | 如何安装、接入、排障和恢复 | 前置条件、配置、操作步骤、结果判断与风险 |
| [开发与源码联调](docs/development.md) | 如何准备环境、运行源码和联调独立仓库 | 可执行命令、目录关系和依赖维护 |
| [API 参考](docs/api.md) | 调用者如何正确请求和解释结果 | 鉴权、字段、默认值、错误、限制与幂等语义,不展开内部实现或界面布局 |
| [系统架构](docs/architecture.md) | 业务事实归谁,调用如何协作 | 模块边界、状态所有权、生命周期和不变量,不重复字段清单 |
| [管理端主题](docs/theme.md) | 界面如何保持一致,主题如何扩展 | 文案与视觉约束、Token 合同、实现入口和验收范围 |
| [插件使用](docs/plugins.md) | 如何安装、授权、配置和使用插件 | 管理员操作与影响,不展开 SDK 调用或宿主事务实现 |
| [插件 SDK](backend/crates/gateway-plugin/sdk/README.md) / [CLI](backend/apps/plugin-cli/README.md) | 如何编写和打包插件 | 作者合同、权限边界、示例与打包验证 |
| [数据库迁移](backend/migrations/README.md) | 如何维护 schema 和准备测试库 | 冻结规则、升级约束和测试环境,不罗列迁移历史 |
| 本文 | 如何协作、审查和交付 | 流程、判断标准与验证要求,任务入口由 [开发技能](.agents/skills/cpr-dev-guide/SKILL.md) 串联 |
- 同一事实只在所属文档完整说明,其他位置按读者任务保留必要摘要和链接
- 改动原因、前后对比、排查经过和验证结果放在 PR、Issue 或任务报告;`release/notes.md` 是发版时使用的发布说明,不作为常规文档的历史章节,也不随普通开发任务自动追加
- 仍影响升级、恢复或兼容使用的内容,写成明确的适用条件与操作,保留必要限制,不叙述演变过程
### 层次与行文
- 按读者任务组织章节,先给入口和操作,再解释限制;长篇参考文档提供导航,独立主题使用小标题
- 快速开始只保留走通流程的必要步骤;API、配置、恢复和权限等容易误用的内容写清输入、默认值、边界与失败处理
- 短说明直接成段,并列信息用列表,可比较的字段或职责用表格,避免一段堆叠多个主题
- 标题、表格单元格、短列表项和独立短段通常不加句号;长段优先拆分,确需区分完整句意时保留句号,不机械替换成逗号或分号
- 命令、URL、版本号、字段名和原始输出保持准确,不为统一标点改写代码示例
审查时逐段核对:是否属于当前状态、是否回答读者问题、是否放在正确文档、是否重复、脱离变更背景能否独立理解
没有文档改动本身不构成验证缺口,判断依据是当前说明是否仍然准确、完整
## 界面验证
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有组件为依据,在任务范围内改进布局、视觉与交互。
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有页面为依据,优先延续原页面的布局、信息层级与交互;设计技能不构成重做页面的理由
开发前查找同类页面及 `components/base` 中的基础组件,优先使用已有主题 Token、交互和数据展示逻辑。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。
开发前查找原页面、同类页面及 `modules/ui/src/components` 中的基础组件,明确参考实现和可复用部分,再确定最小改动。只有原设计不能满足本次操作或存在具体问题时才调整结构,并在 PR 中说明原因;不顺带换风格、重排无关区域或增加重复的模式入口。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题
列表列宽、序号、按钮、分页和弹窗尺寸由实际任务与数据规模决定,以同类页面为参照。新增或调整交互时说明用户需要完成什么操作,不把某个页面的取舍推广成全局禁令。
列表列宽、序号、按钮、分页和弹窗尺寸由实际任务与数据规模决定,以同类页面为参照。新增或调整交互时说明用户需要完成什么操作,不把某个页面的取舍推广成全局禁令
提交者在浏览器中验证受影响路径,提供前后截图、录屏或预览步骤,并标明实际覆盖的状态。按变更选择浅色/深色、窄窗口、空/加载/错误状态、大量数据及键盘操作;共用主题或基础组件变更按主题文档扩大验证范围。模拟数据与预览材料不包含真实凭据。
提交者在浏览器中验证受影响路径,页面变更的 PR 必须提供实际运行截图,包括布局、文案、样式和交互状态的变化:
涉及浏览器能力或访问环境差异时,按项目支持的部署方式核对相关 HTTP/HTTPS、localhost/实际部署地址与浏览器版本,记录实际覆盖的条件;本地预览正常不能替代部署环境中的交互验收。
- 修改现有页面提供变更前后截图,尽量保持相同数据、主题和窗口尺寸;新增页面提供完成态截图。截图保留必要上下文,让审查者能判断布局和信息密度,不只截取孤立控件
- 按影响范围补充浅色/深色、窄窗口及空/加载/错误等受影响状态;共用主题或基础组件变更按主题文档扩大验证范围。弹出层中的内容需展示展开状态,不能只截关闭后的页面
- 在 PR 正文嵌入或链接审查者可访问的图片,注明页面入口、状态和对应提交。录屏、预览地址和复现步骤用于补充,不能替代截图;本机绝对路径不算可供远端审阅的附件
- 视觉或交互继续修改后更新受影响截图,使证据对应待审实现。截图与模拟数据不得包含真实凭据,不用设计稿、生成图片或旧版截图冒充运行结果
- 环境不足时说明缺少的截图和交互验证,不宣称验收通过;页面 PR 在截图补齐前保留 Draft,不标为可审阅或建议合并
截图用于视觉审阅,保存、切换、错误恢复和键盘操作等行为仍需实际验证并记录覆盖范围,不能仅凭静态图片推断交互正确
涉及浏览器能力或访问环境差异时,按项目支持的部署方式核对相关 HTTP/HTTPS、localhost/实际部署地址与浏览器版本,记录实际覆盖的条件;本地预览正常不能替代部署环境中的交互验收
## 验证
选择与变更相关的检查,纯文档修改无需重建前后端。行为修复补充能覆盖触发条件和结果的测试,不增加仅复述实现的断言。
选择与变更相关的检查,纯文档修改无需重建前后端。行为修复补充能覆盖触发条件和结果的测试,不增加仅复述实现的断言
| 范围 | 现有检查 | 仍需提供的证据 |
| --- | --- | --- |
@@ -122,6 +177,10 @@ AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方
| 依赖安全 | Cargo Audit、pnpm Audit | 本次变更的权限、数据与信任边界 |
| 工作流与容器 | Actionlint、相关容器检查 | 改动涉及的运行行为 |
环境准备、依赖安装和服务启动见 [开发与源码联调](docs/development.md),联合修改组件库或插件时使用其中的子模块入口
正式依赖固定到标签或提交,联调只临时替换解析路径,不改写正式依赖或锁文件
前端从仓库根目录执行:
```bash
@@ -130,16 +189,20 @@ pnpm --dir frontend build
git diff --check
```
`build` 已包含类型检查;不维护独立前端测试代码。后端的工具链、命令与线程栈设置统一见 [修改与验收](docs/architecture.md#12-修改与验收)。
`build` 已包含类型检查;不维护独立前端测试代码。后端的工具链、命令与线程栈设置统一见 [修改与验收](docs/architecture.md#12-修改与验收)
依赖 PostgreSQL / Redis 的测试使用 [专用测试环境](backend/migrations/README.md#本地测试库),未配置导致跳过时明确记录。CI 中已有对应服务和检查,见 [质量工作流](.github/workflows/_quality.yml) 与 [安全扫描](.github/workflows/security-scan.yml)。
依赖 PostgreSQL / Redis 的测试使用 [专用测试环境](backend/migrations/README.md#本地测试库),未配置导致跳过时明确记录。CI 中已有对应服务和检查,见 [质量工作流](.github/workflows/_quality.yml) 与 [安全扫描](.github/workflows/security.yml)
集成变更应验证实际业务路径及结果,不能只凭健康检查或探测成功认定全链路正常。协议和网络改动按需核对认证、证书、代理、流式交付及客户端结果;记录影响复现的环境条件。缺少运行条件时说明缺口,不用模拟结果代替真实集成结论。
集成变更应验证实际业务路径及结果,不能只凭健康检查或探测成功认定全链路正常。协议和网络改动按需核对认证、证书、代理、流式交付及客户端结果;记录影响复现的环境条件。缺少运行条件时说明缺口,不用模拟结果代替真实集成结论
## 自动检查与合并条件
[CI](.github/workflows/ci.yml) 已按变更范围调用现有质量检查。新增自动规则优先使用现有工具,要求依据明确、结果稳定、误报可控;无法可靠判定的设计与视觉问题保留审查判断,避免把具体源码写法锁成测试。
[CI](.github/workflows/ci.yml) 按变更范围调用质量检查,Markdown 不触发后端、前端、容器和源码联调任务,工作流语法检查独立执行
AI 自动审查需要在所用工具中单独启用。其支持的检查范围和优先级过滤以工具说明为准;即使能读取 `AGENTS.md`,也不能保证所有可维护性和视觉问题都会被报告。配置参考 [Codex GitHub 审查文档](https://learn.chatgpt.com/docs/third-party/github)。
后端 CI 使用 nextest 执行 `main` 集成测试,默认 Redis 密码模式执行完整测试集,ACL 用户模式复用同一批构建产物执行 Store 的 Redis 测试;本地验证命令见 [架构文档](docs/architecture.md#验证命令)
要由 GitHub 强制执行合并条件,维护者需配置对应分支的必需状态检查、审阅批准和讨论解决要求,并按需要设置新提交后的重新审阅。配置前核对实际检查名称与触发范围,见 [GitHub 分支保护](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches)。规则文档本身不会启用 AI 服务或更改分支设置。
新增自动规则优先使用现有工具,要求依据明确、结果稳定、误报可控;无法可靠判定的设计与视觉问题保留审查判断,避免把具体源码写法锁成测试
AI 自动审查需要在所用工具中单独启用。其支持的检查范围和优先级过滤以工具说明为准;即使能读取 `AGENTS.md`,也不能保证所有可维护性和视觉问题都会被报告。配置参考 [Codex GitHub 审查文档](https://learn.chatgpt.com/docs/third-party/github)
要由 GitHub 强制执行合并条件,维护者需配置对应分支的必需状态检查、审阅批准和讨论解决要求,并按需要设置新提交后的重新审阅。配置前核对实际检查名称与触发范围,见 [GitHub 分支保护](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches)。规则文档本身不会启用 AI 服务或更改分支设置
+76 -58
View File
@@ -12,89 +12,100 @@
[![GHCR](https://img.shields.io/badge/GHCR-codex--proxy--rs-2496ED?logo=docker&logoColor=white&style=flat-square)](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg?style=flat-square)](LICENSE)
[快速开始](#快速开始) · [客户端接入](#客户端接入) · [文档](#文档) · [社区](#社区) · [许可证](#许可证)
[快速预览](#快速预览) · [快速开始](#快速开始) · [客户端接入](#客户端接入) · [文档](#文档) · [社区](#社区) · [许可证](#许可证)
</div>
> [!NOTE]
> 本项目提供 Responses API,不支持 `/v1/chat/completions`。接入前请确认客户端支持 Responses 协议。
> 本项目提供 Responses API,不支持 `/v1/chat/completions`,接入前请确认客户端支持 Responses 协议
## 快速预览
无需部署,打开 [快速预览服务](https://codex-proxy-rs.ainz.cc) 即可体验管理端的系统概览、账号分组、代理管理与用量统计
| 登录信息 | 默认值 |
| --- | --- |
| 地址 | <https://codex-proxy-rs.ainz.cc> |
| 登录身份 | 管理员 |
| 账号 | `admin@cpr.local` |
| 密码 | `039c18de2aeac46d23ead7766bb07bbbe3747233c66a128e` |
预览服务运行已发布版本,展示的账号、代理与使用记录均为模拟数据,每天北京时间 `00:00` 自动生成当天数据
这是公开共享的功能预览环境,不提供真实模型调用;请勿导入真实账号、密钥或其他敏感信息
## 快速开始
使用 Docker Compose 部署版本固定的发布镜像,同时启动 PostgreSQL 和 Redis。
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL。已有部署请先看
[升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置。
使用 Docker Compose 部署版本固定的发布镜像,同时启动 PostgreSQL 和 Redis
### 1. 下载部署文件并配置
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL
已有部署请先看 [升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置
### 一键安装
请确保当前用户能访问 Docker,并可通过 `sudo` 或 root 设置目录权限
```bash
mkdir -p codex-proxy-rs/deploy && cd codex-proxy-rs
# 只解析一次最新正式版本,确保两个文件来自同一 Release。
CPR_RELEASE_URL="$(curl -fsSL -o /dev/null -w '%{url_effective}' https://github.com/zyycn/codex-proxy-rs/releases/latest)"
CPR_RELEASE_TAG="${CPR_RELEASE_URL##*/}"
curl -fsSL "https://github.com/zyycn/codex-proxy-rs/releases/download/${CPR_RELEASE_TAG}/compose.yaml" \
-o deploy/compose.yaml
curl -fsSL "https://github.com/zyycn/codex-proxy-rs/releases/download/${CPR_RELEASE_TAG}/config.example.yaml" \
-o deploy/config.example.yaml
install -d -m 0750 .runtime/postgres .runtime/redis
sudo install -d -m 0770 -o "$(id -u)" -g 10001 .runtime/data .runtime/logs
sudo install -m 0640 -o "$(id -u)" -g 10001 deploy/config.example.yaml deploy/config.yaml
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/install.sh -o install.sh && bash install.sh
```
也可将 `CPR_RELEASE_TAG` 设置为指定的发布标签。Release 附带的 `compose.yaml` 默认使用该版本镜像,
配置模板和部署文件均包含在 `checksums.txt` 中;不要混用 `main` 分支模板与已发布镜像。
[安装脚本](deploy/install.sh) 默认安装到当前目录下的 `codex-proxy-rs/`,下载同一正式 Release 的部署文件,
自动生成密码、设置目录权限并启动服务
分别生成数据库和 Redis 密码:
完成后会显示访问地址和管理员密码,请保存密码,再按下方步骤
[添加账号与客户端密钥](#添加账号与客户端密钥)
可在执行 `bash install.sh` 时传入环境变量:
| 变量 | 用途 | 默认值 |
| --- | --- | --- |
| `INSTALL_DIR` | 安装目录,建议使用绝对路径 | 当前目录下的 `codex-proxy-rs/` |
| `CPR_RELEASE_TAG` | 指定发布标签 | 最新正式版本 |
| `ADMIN_PASSWORD` | 管理员初始密码,至少 12 位,不能包含 `$`,不能使用常见弱口令 | 随机生成 |
例如,自定义安装目录:
```bash
openssl rand -hex 24
openssl rand -hex 24
INSTALL_DIR="$HOME/services/codex-proxy-rs" bash install.sh
```
编辑 `deploy/config.yaml`,填好以下三项:
重复运行时请使用同一安装目录,检测到 `deploy/config.yaml` 后,脚本保留现有配置和部署文件,
忽略传入的管理员密码,也不执行版本升级
| 配置项 | 填写内容 |
| --- | --- |
| `store.database.password` | 第一个生成的 48 位十六进制密码 |
| `store.redis.password` | 第二个生成的 48 位十六进制密码 |
| `admin.default_password` | 管理员初始密码,至少 12 位,不能包含 `$` |
### 手动安装
### 2. 启动服务
自行下载部署文件、配置密码和启动服务的完整步骤见 [部署文档](deploy/README.md#手动安装)
```bash
docker compose -f deploy/compose.yaml config --quiet
docker compose -f deploy/compose.yaml pull
docker compose -f deploy/compose.yaml up -d --no-build --wait
curl -i http://127.0.0.1:8080/healthz
```
### 登录管理端
健康检查返回 `204 No Content` 后,打开 `http://127.0.0.1:8080`,
使用 `admin@cpr.local` 和刚设置的管理员密码登录。
部署完成后,打开 `http://127.0.0.1:8080`,使用 `admin@cpr.local` 和管理员密码登录
默认地址只能在服务器本机访问。从其他设备使用时,需要配置
[HTTPS 反向代理](deploy/README.md#公网访问)。
API Key 持有者可在登录页切换身份,查看自己的用量与额度,并通过「密钥配置」复制 Codex 配置或导入 CCSwitch
### 3. 添加账号与客户端密钥
默认地址只能在服务器本机访问,从其他设备使用时,需要配置
[HTTPS 反向代理](deploy/README.md#公网访问)
1. 在「账号」中添加账号,完成授权或导入。
2. 按需建立账号分组,再创建客户端密钥并选择可用分组。**不选分组表示可使用全部账号**。
3. 打开密钥的「使用密钥」,复制客户端配置。
### 添加账号与客户端密钥
1. 在「账号」中添加账号,完成授权或导入
2. 按需建立账号分组,再创建客户端密钥并选择可用分组,**不选分组表示可使用全部账号**
3. 打开密钥的「使用密钥」,复制客户端配置
## 客户端接入
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,或通过 CCSwitch 导入。
合并到客户端配置后重启 Codex。完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)。
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,或通过 CCSwitch 导入,合并配置后重启 Codex
**其他 Responses API 客户端**:填写以下信息。
完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)
**其他 Responses API 客户端**:填写以下信息:
| 配置 | 值 |
| --- | --- |
| Base URL | `http://127.0.0.1:8080/v1`;远程接入使用服务器的 HTTPS 地址 |
| API Key | 管理端创建的客户端密钥 |
可用模型以该密钥查询到的模型列表为准:
查询该密钥可见的模型目录:
```bash
curl http://127.0.0.1:8080/v1/models \
@@ -103,18 +114,25 @@ curl http://127.0.0.1:8080/v1/models \
## 文档
- [客户端接入与生图](deploy/README.md#客户端配置)
- [部署、备份与恢复](deploy/README.md)
- [API 参考](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) |
| 开发宿主 | [开发与源码联调](docs/development.md) · [系统架构](docs/architecture.md) · [管理端主题](docs/theme.md) · [数据库迁移](backend/migrations/README.md) |
| 参与协作 | [贡献、审查与验证](CONTRIBUTING.md) |
## 社区
感谢 [LINUX DO](https://linux.do) 社区提供开放、友善的技术交流平台。
欢迎到 [Discussions 讨论区](https://github.com/zyycn/codex-proxy-rs/discussions)交流:
[使用问答](https://github.com/zyycn/codex-proxy-rs/discussions/categories/使用问答)、
[想法讨论](https://github.com/zyycn/codex-proxy-rs/discussions/categories/想法讨论)、
[实验反馈](https://github.com/zyycn/codex-proxy-rs/discussions/categories/实验反馈)与
[经验分享](https://github.com/zyycn/codex-proxy-rs/discussions/categories/经验分享)
感谢 [LINUX DO](https://linux.do) 社区提供开放、友善的技术交流平台
## 许可证
本项目基于 [Apache License 2.0](LICENSE) 开源。
本项目基于 [Apache License 2.0](LICENSE) 开源
+801 -134
View File
File diff suppressed because it is too large Load Diff
+17
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",
@@ -32,6 +35,20 @@ needless_collect = { level = "deny", priority = 7 }
[profile.dev]
debug = true
# 测试保留真实密码参数和压缩数据量,优化热点依赖以减少 fixture 的计算开销。
# test 继承 dev;workspace 代码仍保留默认调试与溢出检查。
[profile.dev.package.argon2]
opt-level = 2
[profile.dev.package.blake2]
opt-level = 2
[profile.dev.package."sha2@0.11.0"]
opt-level = 2
[profile.dev.package."miniz_oxide@0.9.1"]
opt-level = 2
[profile.release]
debug = false
strip = "symbols"
+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" }
+363 -40
View File
@@ -7,26 +7,32 @@ use serde::Deserialize;
const CONFIG_SCHEMA_VERSION: u32 = 1;
/// 顶层配置只组合各包拥有的配置段,不解释任何业务字段。
/// 各启动配置忽略未知字段,允许升级时保留旧配置;已知字段仍按类型和业务约束校验。
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GatewayConfig {
schema_version: u32,
host: HostConfig,
store: gateway_store::StoreConfig,
admin: gateway_admin::AdminConfig,
#[serde(default)]
client: gateway_admin::ClientConfig,
api: gateway_api::ApiConfig,
#[serde(default)]
openai: provider_openai::OpenAiConfig,
xai: provider_xai::XaiConfig,
#[serde(default, rename = "services")]
_compose_services: Option<serde::de::IgnoredAny>,
}
impl LoadableConfig for GatewayConfig {
const EXTERNAL_SECTIONS: &'static [&'static str] = &["services"];
fn resolve_and_validate(&mut self, source_dir: &std::path::Path) -> Result<(), ConfigError> {
if self.schema_version != CONFIG_SCHEMA_VERSION {
return Err(ConfigError::InvalidField("schema_version"));
}
self.host.resolve_and_validate(source_dir)?;
self.api
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("api"))?;
self.host
.resolve_and_validate(source_dir, &self.api.asset_directory)?;
let runtime_data_dir = self.host.runtime_data_dir().to_path_buf();
self.store
.resolve_and_validate(&runtime_data_dir)
@@ -34,94 +40,411 @@ impl LoadableConfig for GatewayConfig {
self.admin
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("admin"))?;
self.api
self.client
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("api"))?;
.map_err(|_| ConfigError::InvalidField("client"))?;
self.openai
.resolve_and_validate(&runtime_data_dir)
.map_err(|_| ConfigError::InvalidField("openai"))?;
self.xai
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("xai"))?;
Ok(())
}
}
/// 按冻结顺序初始化全部 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: _,
host,
store,
admin,
client,
api,
openai,
xai,
_compose_services: _,
} = 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(xai, provider_ports).await?;
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?;
host.report_startup_ready("Core");
let mut admin = gateway_admin::initialize(
admin,
store.admin_ports(),
vec![openai.admin_provider(), xai.admin_provider()],
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 plugin_resources = gateway_admin::initialize_plugin_resources(
store.admin_ports().plugin_resources(),
core.snapshot_control(),
);
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_resource_ports(&plugin_resources)
);
let core = or_shutdown!(plugin_runtime, core.activate().await);
let nested_models = core.nested_model_execution_port();
let affinity_lookup = core.affinity_lookup_port();
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup)
);
let settings = gateway_admin::initialize_settings(
store.admin_ports().settings(),
core.snapshot_control(),
admin_providers,
std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
);
let mut services = gateway_admin::service::Registry::new({
let snapshots = core.snapshots();
let middleware = plugin_runtime.middleware_registry();
std::sync::Arc::new(move || {
let snapshot = snapshots.snapshot_for_diagnostics()?;
middleware.resolve(snapshot.extensions()?)
})
});
or_shutdown!(plugin_runtime, services.register_settings(&settings));
let services = std::sync::Arc::new(services);
or_shutdown!(plugin_runtime, plugin_runtime.bind_services(&services));
let commands = or_shutdown!(plugin_runtime, plugin_runtime.prepare_command_line().await);
or_shutdown!(plugin_runtime, store.start_command_line_writes());
let result = commands
.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(),
(
core.account_probe(),
host.proxy_probe(provider_openai::build_reqwest_client_with_custom_ca),
),
host.client_distribution_resolver(),
host.system_operations(),
)
.await?;
);
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 plugin_resources = gateway_admin::initialize_plugin_resources(
store.admin_ports().plugin_resources(),
core.snapshot_control(),
);
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_resource_ports(&plugin_resources)
);
let mut core = or_shutdown!(plugin_runtime, core.activate().await);
let nested_models = core.nested_model_execution_port();
let affinity_lookup = core.affinity_lookup_port();
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup)
);
host.report_startup_ready("Core");
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 {
service_middleware: {
let snapshots = core.snapshots();
let middleware = plugin_runtime.middleware_registry();
std::sync::Arc::new(move || {
let snapshot = snapshots.snapshot_for_diagnostics()?;
middleware.resolve(snapshot.extensions()?)
})
},
providers: admin_providers,
plugin_preparation: plugin_runtime.clone(),
plugin_management: plugin_runtime.clone(),
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(),
)
)
.with_middleware({
let snapshots = core.snapshots();
let middleware = plugin_runtime.middleware_registry();
move |snapshot| {
if let Some(snapshot) = snapshot {
return middleware.resolve(snapshot.extensions()?);
}
// 数据面未就绪时,管理与诊断路由仍可使用发布候选中的插件。
let diagnostic = snapshots.snapshot_for_diagnostics()?;
middleware.resolve(diagnostic.extensions()?)
}
});
let http_dispatcher = api.dispatcher();
or_shutdown!(plugin_runtime, plugin_runtime.bind_http(&http_dispatcher));
host.report_startup_ready("API");
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_services(&admin.services().public_services())
);
let mut plan = store.take_worker_contributions();
plan.push(gateway_host::retention::worker(store.retention())?);
plan.extend(core.take_worker_contributions());
plan.extend(openai.take_worker_contributions());
plan.extend(xai.take_worker_contributions());
plan.extend(admin.take_worker_contributions());
host.start_workers(plan, store.worker_leader_lease())?;
plan.push(plugin_runtime.maintenance_worker(core.snapshots())?);
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()),
}
}
+121 -25
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",
@@ -70,8 +73,15 @@ fn core_value_owners_do_not_depend_on_execution_or_routing() {
"identity.rs" => Some(&["validation"]),
"upstream.rs" => Some(&["validation"]),
"event.rs" => Some(&["metering", "operation", "upstream", "validation"]),
"account/selection.rs" => Some(&["account", "concurrency", "identity", "validation"]),
"concurrency.rs" => Some(&["error"]),
"account/store.rs" => Some(&["account", "error", "identity", "validation"]),
path if path.starts_with("policy/") => Some(&["account", "policy", "validation"]),
path if path.starts_with("policy/") => {
Some(&["account", "identity", "policy", "validation"])
}
"settings/values.rs" | "settings/compiled.rs" => {
Some(&["account", "concurrency", "identity", "metering", "policy"])
}
path if path.starts_with("account/") => Some(&["account", "identity", "validation"]),
_ => None,
};
@@ -147,6 +157,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"]);
@@ -164,25 +181,36 @@ 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-api", &["admin", "openai"]),
("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",
"retention",
"serve",
"system_update",
"workers",
@@ -193,10 +221,7 @@ const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
"crates/providers/openai",
&["config", "credential", "transport"],
),
(
"crates/providers/xai",
&["config", "credential", "transport"],
),
("crates/providers/xai", &["credential", "transport"]),
];
/// 不对应单一生产模块、而是校验 crate/workspace 整体契约的根级测试场景。
@@ -207,10 +232,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 +247,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,17 +381,47 @@ 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 src = member_root.join("src");
assert_module_tree(&src, &["lib.rs", "main.rs"]);
assert_directory_modules_have_children(&src, &["lib.rs", "main.rs"]);
let tests = member_root.join("tests");
if tests.is_dir() {
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<_>>(),
);
}
}
}
fn assert_directory_modules_have_children(root: &Path, crate_roots: &[&str]) {
let files = super::rust_files(root);
for relative in &files {
if crate_roots
.iter()
.any(|candidate| relative == Path::new(candidate))
|| relative.file_name().and_then(|value| value.to_str()) != Some("mod.rs")
{
continue;
}
let directory = relative.parent().expect("mod.rs parent");
assert!(
files
.iter()
.any(|candidate| candidate != relative && candidate.starts_with(directory)),
"{} is a leaf module and must use {}",
root.join(relative).display(),
root.join(directory).with_extension("rs").display(),
);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ModuleLayout {
File,
@@ -368,6 +430,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 +439,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 +475,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()
@@ -440,6 +529,8 @@ fn root_test_scenario_allowed(member: &str, module: &Path) -> bool {
fn assert_module_tree(root: &Path, crate_roots: &[&str]) {
let files = super::rust_files(root);
// 同一父模块的所有子文件复用一次语法解析,计数仍保留重复声明检查。
let mut declarations = BTreeMap::<PathBuf, BTreeMap<String, usize>>::new();
for relative in &files {
if crate_roots
.iter()
@@ -502,7 +593,14 @@ fn assert_module_tree(root: &Path, crate_roots: &[&str]) {
};
let declaration_count = declaration_parents
.iter()
.map(|path| external_module_declaration_count(path, module_name))
.map(|path| {
declarations
.entry(path.clone())
.or_insert_with(|| external_module_declarations(path))
.get(module_name)
.copied()
.unwrap_or_default()
})
.sum::<usize>();
assert_eq!(
declaration_count,
@@ -513,23 +611,21 @@ fn assert_module_tree(root: &Path, crate_roots: &[&str]) {
}
}
fn external_module_declaration_count(path: &Path, module_name: &str) -> usize {
fn external_module_declarations(path: &Path) -> BTreeMap<String, usize> {
if !path.is_file() {
return 0;
return BTreeMap::new();
}
let source = fs::read_to_string(path).expect("read parent module source");
let syntax = syn::parse_file(&source).expect("parse parent module source");
syntax
.items
.iter()
.filter(|item| {
matches!(
item,
Item::Mod(module)
if module.content.is_none() && module.ident == module_name
)
})
.count()
let mut declarations = BTreeMap::new();
for item in syntax.items {
if let Item::Mod(module) = item
&& module.content.is_none()
{
*declarations.entry(module.ident.to_string()).or_default() += 1;
}
}
declarations
}
pub(super) fn backend_root() -> PathBuf {
+249 -50
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)
@@ -168,6 +216,50 @@ fn config_loader_should_resolve_paths_relative_to_config_file() {
assert!(debug.contains("frontend/dist"));
}
#[test]
fn config_loader_should_share_resolved_assets_with_system_update() {
const CHILD_ENV: &str = "CPR_TEST_UPDATE_ASSETS_CHILD";
let Ok(case) = std::env::var(CHILD_ENV) else {
// 使用子进程覆盖无环境变量、Docker 路径和相对路径,避免污染并行测试。
for (case, web_dist) in [
("binary", None),
("docker", Some("/app/web/dist")),
("relative", Some("../custom/dist")),
] {
let mut child = Command::new(std::env::current_exe().expect("test executable"));
child
.args([
"--exact",
"bootstrap::config_loader_should_share_resolved_assets_with_system_update",
])
.env(CHILD_ENV, case)
.env_remove("CPR_WEB_DIST_DIR");
if let Some(web_dist) = web_dist {
child.env("CPR_WEB_DIST_DIR", web_dist);
}
let output = child.output().expect("isolated configuration test");
assert!(
output.status.success(),
"{case}: {}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
return;
};
let mut document = valid_config_document();
document["api"]["asset_directory"] = serde_json::json!("../web/dist");
let (config, directory) = parse_config(&document.to_string()).expect("binary configuration");
let assets = match case.as_str() {
"docker" => std::path::PathBuf::from("/app/web/dist"),
"relative" => directory.path().join("deploy/../custom/dist"),
_ => directory.path().join("deploy/../web/dist"),
};
let debug = format!("{config:?}");
assert!(debug.contains(&format!("asset_directory: {assets:?}")));
assert!(debug.contains(&format!("web_dist_dir: Some({assets:?})")));
}
#[test]
fn config_loader_should_reject_missing_runtime_data_dir() {
let config = valid_config().replace(" runtime_data_dir: '../.runtime/data'\n", "");
@@ -228,16 +320,104 @@ fn bootstrap_config_debug_should_redact_all_passwords() {
}
#[test]
fn config_loader_should_reject_unknown_fields() {
assert_rejected(format!(
"{}\nunknown_terminal_field: true\n",
valid_config()
));
fn config_loader_should_ignore_unknown_fields_in_configuration_sections() {
let mut document = valid_config_document();
document["host"]["system_update"] = serde_json::json!({});
document["store"]["pool"] = serde_json::json!({});
for path in [
"",
"/host",
"/host/listen",
"/host/logging",
"/host/logging/file",
"/host/system_update",
"/store",
"/store/database",
"/store/redis",
"/store/pool",
"/admin",
"/client",
"/api",
"/openai",
] {
let mut extended = document.clone();
extended.pointer_mut(path).expect("configuration section")["unused_setting"] =
serde_json::json!({"nested": [true, null, "ignored"]});
parse_config(&extended.to_string())
.unwrap_or_else(|error| panic!("unknown field in {path}: {error}"));
}
}
#[test]
fn config_loader_should_reject_removed_tls_section() {
assert_rejected(valid_config().replace("openai:\n", "openai:\n tls: {}\n"));
fn config_loader_should_ignore_removed_tls_and_fingerprint_sections() {
let mut document = valid_config_document();
document["openai"]["tls"] = serde_json::json!({});
document["openai"]["fingerprint"] = serde_json::json!({"browser": "removed"});
parse_config(&document.to_string()).expect("removed provider settings are ignored");
}
#[test]
fn config_loader_should_reject_invalid_known_fields_alongside_unknown_fields() {
for port in [serde_json::json!(0), serde_json::json!("not-a-port")] {
let mut document = valid_config_document();
document["host"]["listen"]["unused_setting"] = serde_json::json!(true);
document["host"]["listen"]["port"] = port;
assert_rejected(document.to_string());
}
}
#[test]
fn config_loader_should_report_startup_configuration_diagnostics() {
const CHILD_ENV: &str = "CPR_TEST_CONFIG_DIAGNOSTICS_CHILD";
const UNUSED_SECRET: &str = "unused-value-must-not-appear-in-diagnostics";
if std::env::var_os(CHILD_ENV).is_some() {
gateway_host::load_config::<GatewayConfig>().expect("startup configuration");
return;
}
for case in ["normal", "unused", "missing"] {
let mut document = valid_config_document();
if case == "unused" {
document["openai"]["wire_profile"]["location"] = serde_json::json!(UNUSED_SECRET);
} else if case == "missing" {
document["host"]["listen"]
.as_object_mut()
.unwrap()
.remove("port");
}
let directory = tempfile::tempdir().unwrap();
fs::create_dir(directory.path().join("deploy")).unwrap();
fs::write(
directory.path().join("deploy/config.yaml"),
document.to_string(),
)
.unwrap();
let output = Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"bootstrap::config_loader_should_report_startup_configuration_diagnostics",
"--nocapture",
])
.env(CHILD_ENV, "1")
.current_dir(directory.path())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(
output.status.success(),
case != "missing",
"{case}: {stderr}"
);
match case {
"unused" => assert!(
stderr.contains("openai.wire_profile") && stderr.contains("已忽略"),
"{stderr}"
),
"missing" => assert!(stderr.contains("host.listen.port"), "{stderr}"),
_ => assert!(!stderr.contains("警告"), "{stderr}"),
}
assert!(!stderr.contains(UNUSED_SECRET), "{stderr}");
assert!(!stderr.contains("services"), "{stderr}");
}
}
#[test]
@@ -245,6 +425,26 @@ fn config_loader_should_reject_missing_explicit_fields() {
assert_rejected(valid_config().replace(" request_id_header: 'x-request-id'\n", ""));
}
#[test]
fn config_loader_should_allow_missing_provider_configuration() {
let mut document = valid_config_document();
document.as_object_mut().unwrap().remove("openai");
document.as_object_mut().unwrap().remove("xai");
parse_config(&document.to_string()).expect("provider defaults require no YAML identity");
}
#[test]
fn config_loader_should_ignore_removed_provider_identity_sections() {
let mut document = valid_config_document();
document["openai"]["wire_profile"] = serde_json::json!({"codex_version":"legacy-invalid"});
document["xai"] = serde_json::json!({"wire_profile": {"client_identifier": "legacy-client"}});
let (config, _directory) =
parse_config(&document.to_string()).expect("removed identities ignored");
let debug = format!("{config:?}");
assert!(!debug.contains("legacy-invalid"));
assert!(!debug.contains("legacy-client"));
}
#[test]
fn config_loader_should_reject_unsupported_schema_version() {
assert_rejected(valid_config().replace("schema_version: 1", "schema_version: 2"));
@@ -279,16 +479,34 @@ fn config_loader_should_reject_admin_password_with_compose_interpolation() {
}
#[test]
fn config_loader_should_reject_removed_fingerprint_section() {
assert_rejected(valid_config().replace(
"openai:\n",
"openai:\n fingerprint:\n browser: removed\n",
));
fn config_loader_should_reject_zero_client_session_ttl() {
let mut config = valid_config_document();
*config
.pointer_mut("/client/session_ttl_minutes")
.expect("example client session TTL") = serde_json::json!(0);
assert_rejected(config.to_string());
}
#[test]
fn config_loader_should_reject_invalid_codex_cli_version() {
assert_rejected(valid_config().replace("codex_version: '0.153.4'", "codex_version: 'latest'"));
fn config_loader_should_default_missing_client_section() {
let mut config = valid_config_document();
config
.as_object_mut()
.expect("example config mapping")
.remove("client")
.expect("example client section");
parse_config(&config.to_string()).expect("client defaults when the section is omitted");
}
#[test]
fn config_loader_should_reject_missing_client_session_ttl() {
let mut config = valid_config_document();
config["client"]
.as_object_mut()
.expect("example client mapping")
.remove("session_ttl_minutes")
.expect("example client session TTL");
assert_rejected(config.to_string());
}
#[test]
@@ -306,41 +524,10 @@ fn config_loader_should_reject_zero_server_port() {
}
#[test]
fn config_loader_should_reject_invalid_desktop_profile_fields() {
assert_rejected(valid_config().replace("desktop_build: '8109'", "desktop_build: 'build'"));
}
#[test]
fn config_loader_should_accept_empty_and_custom_request_locations() {
let original = valid_config();
let omitted = original
.lines()
.filter(|line| !line.trim_start().starts_with("location:"))
.collect::<Vec<_>>()
.join("\n");
assert_ne!(original, omitted);
parse_config(&omitted).expect("location passthrough when omitted");
let location_line = original
.lines()
.find(|line| line.trim_start().starts_with("location:"))
.expect("example location");
for empty in [" location:", " location: null", " location: ~"] {
parse_config(&original.replace(location_line, empty)).expect("empty YAML location");
}
let custom = original.replace(
"location: { country: 'US', region: 'Ohio', city: 'Piketon', timezone: 'America/New_York' }",
"location: { country: 'NZ', region: 'Auckland', city: 'Auckland', timezone: 'Pacific/Auckland' }",
);
assert_ne!(original, custom);
parse_config(&custom).expect("custom location from YAML");
}
#[test]
fn config_loader_should_reject_invalid_request_location_timezones() {
let original = valid_config();
let invalid = original.replace("timezone: 'America/New_York'", "timezone: 'Not/A_Timezone'");
assert_ne!(original, invalid);
assert_rejected(invalid);
fn config_loader_should_validate_openai_residency() {
let mut document = valid_config_document();
document["openai"]["residency"] = serde_json::json!("invalid");
assert_rejected(document.to_string());
}
fn assert_rejected(config: String) {
@@ -365,6 +552,18 @@ fn valid_config() -> String {
)
}
fn valid_config_document() -> serde_json::Value {
// 按字段修改样例,避免注释或排版变化让测试输入悄悄失效;JSON 仍可由 YAML 文件入口加载。
config::Config::builder()
.add_source(config::File::from_str(
&valid_config(),
config::FileFormat::Yaml,
))
.build()
.and_then(config::Config::try_deserialize)
.expect("example config document")
}
fn parse_config(config: &str) -> Result<(GatewayConfig, tempfile::TempDir), String> {
let directory = tempfile::tempdir().map_err(|error| error.to_string())?;
let deploy = directory.path().join("deploy");
+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
+53
View File
@@ -0,0 +1,53 @@
# Codex Proxy Plugin CLI
`cpr-plugin` 校验并规范化作者清单、收集声明的资源,生成可安装归档及 SHA-256 校验文件。
输出合同与 [SDK 版本](../../crates/gateway-plugin/sdk/README.md)一致
## 安装
从仓库根目录安装到本地工具目录:
```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` 必须显式声明版本 `3`,
并从 `http`、`websocket`、`service`、`request`、`attempt` 中选择挂载阶段。
CLI 与 SDK 的 `Manifest::from_author_slice`、`PluginBuilder::from_json` 共用规范化入口;归档中的
`plugin.json` 写入补全后的贡献项字段,不保留另一份作者声明。
完整规则见 [SDK 清单](../../crates/gateway-plugin/sdk/docs/manifest.md)
支持 `x86_64-unknown-linux-gnu`、`aarch64-unknown-linux-gnu` 和 `aarch64-apple-darwin`。
完整开发示例在独立仓库 `codex-proxy-plugins` 的 `examples/workbench`,可通过[子模块](../../../docs/development.md#源码联调)检出;本工具不负责源码构建、上传或启用插件
### 图标资源
在作者清单的 `icon` 中指定图标,并在 `resources` 中登记对应路径与 MIME 类型,配置方式见
[SDK 插件图标](../../crates/gateway-plugin/sdk/docs/manifest.md#插件图标)。图标与其他声明资源一样参与打包和摘要计算,
无需额外命令参数;工具保留文件原始内容,宿主安装时验证图标内容与资源限制
## 开发
Rust 包名为 `codex-proxy-plugin-cli`,位于宿主的 `backend` workspace,共享工具链、锁文件和检查规则;内部仅依赖 `gateway-plugin-sdk`
```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":2,
"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":{"version":3,"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]},
"management":{}
},
"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"}
}"#
}
+7 -3
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,8 @@ 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"
tracing = "0.1.44"
@@ -34,9 +36,11 @@ chrono-tz = "0.10"
tokio = { version = "1.52", features = ["rt", "time", "macros", "sync"] }
[dev-dependencies]
sha2 = "0.11"
prettyplease = "0.2"
quote = "1"
syn = { version = "2", features = ["full", "visit-mut"] }
tempfile = "3"
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt"] }
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt", "test-util"] }
[lints]
workspace = true
+20 -11
View File
@@ -16,8 +16,8 @@ use tracing::{info, warn};
use crate::{
model::backup::{
BackupError, BackupObjectMetadata, BackupRecord, BackupStatus, BackupStorageConfig,
BackupTriggerKind, build_backup_seed, code,
BackupError, BackupObjectMetadata, BackupRecord, BackupStatus, BackupStatusTransition,
BackupStorageConfig, BackupTriggerKind, build_backup_seed, code,
},
ports::backup::{
BackupObjectStorePort, BackupRepository, DatabaseDumpPort, DumpRequest,
@@ -224,8 +224,7 @@ impl BackupTask {
.repository
.transition_status(
&record.id,
BackupStatus::Dumping,
BackupStatus::Uploading,
status_transition(BackupStatus::Dumping, BackupStatus::Uploading)?,
update,
now,
)
@@ -274,8 +273,7 @@ impl BackupTask {
self.repository
.transition_status(
&record.id,
BackupStatus::Uploading,
BackupStatus::Completed,
status_transition(BackupStatus::Uploading, BackupStatus::Completed)?,
StatusTransitionUpdate::default(),
now,
)
@@ -402,8 +400,7 @@ impl BackupTask {
.repository
.transition_status(
&record.id,
BackupStatus::Dumping,
BackupStatus::Uploading,
status_transition(BackupStatus::Dumping, BackupStatus::Uploading)?,
update,
now,
)
@@ -471,8 +468,7 @@ impl BackupTask {
.repository
.transition_status(
&record.id,
BackupStatus::Uploading,
BackupStatus::Completed,
status_transition(BackupStatus::Uploading, BackupStatus::Completed)?,
StatusTransitionUpdate::default(),
Utc::now(),
)
@@ -555,7 +551,12 @@ impl BackupTask {
};
let _ = self
.repository
.transition_status(&record.id, record.status, BackupStatus::Failed, update, now)
.transition_status(
&record.id,
status_transition(record.status, BackupStatus::Failed)?,
update,
now,
)
.await
.map_err(repo_error)?;
let _ = self.dump.cleanup_staging(&record.id).await;
@@ -639,6 +640,14 @@ fn classify_dump_error(error: BackupError) -> (&'static str, &'static str) {
}
}
fn status_transition(
from: BackupStatus,
to: BackupStatus,
) -> Result<BackupStatusTransition, WorkerTaskError> {
BackupStatusTransition::try_new(from, to)
.ok_or_else(|| WorkerTaskError::safe("backup status transition is invalid"))
}
fn repo_error(error: crate::ports::store::AdminStoreError) -> WorkerTaskError {
WorkerTaskError::safe(format!("backup store failed: {error}"))
}
@@ -0,0 +1,304 @@
//! 容量熔断冻结的恢复编排:到期探测、失败顺延与自适应并发下调。
//!
//! 冻结事实保存在可丢失的 Redis 冷却中,本服务只组合 Admin 端口:
//! 观测(`AccountRuntimeStore`)、探测(`AccountsService`)与写回
//! (原子降低并发 / 按冻结代次结束探测)。旧观测不会覆盖管理员操作。
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use chrono::{DateTime, Utc};
use futures::StreamExt as _;
use gateway_core::account::{AccountConcurrencyLimit, ProviderAccountId};
use gateway_core::routing::UpstreamModelId;
use gateway_core::task::{ScheduledTask, WorkerCycleContext, WorkerTaskError};
use tracing::warn;
use crate::model::accounts::{
AccountConnectionTestEvent, AccountFreeze, AccountPageItem, AccountRuntimeSnapshot,
};
use crate::model::{MutationActor, MutationContext};
use crate::ports::store::{AccountRuntimeStore, AccountStore, SettingsStore};
use crate::use_case::accounts::AccountsService;
/// 自适应并发下调保留系数:下调到观测在途峰值的 80%。
const ADAPTIVE_CONCURRENCY_FACTOR: f64 = 0.8;
/// 自适应并发下限:过低的并发让账号几乎不可用,宁可保持冻结。
const ADAPTIVE_CONCURRENCY_FLOOR: u32 = 2;
/// worker 周期;冷却观测与探测都依赖该节奏。
pub const FREEZE_RECOVERY_INTERVAL: Duration = Duration::from_secs(30);
pub const FREEZE_RECOVERY_WORKER_OWNER: &str = "account-freeze-recovery";
pub const WORKER_INITIAL_BACKOFF: Duration = Duration::from_secs(1);
pub const WORKER_MAXIMUM_BACKOFF: Duration = Duration::from_secs(60);
pub const WORKER_LEASE_TTL: Duration = Duration::from_secs(15 * 60);
pub const WORKER_LEASE_RENEWAL: Duration = Duration::from_secs(5 * 60);
/// 系统审计上下文的固定请求 ID;账号并发更新审计由此溯源到自动冻结编排。
const SYSTEM_REQUEST_ID: &str = "account-freeze-recovery";
/// 冻结恢复所需的管理端口组合。
#[derive(Clone)]
pub struct FreezeRecoveryDeps {
pub accounts: Arc<dyn AccountsService>,
pub store: Arc<dyn AccountStore>,
pub runtime: Arc<dyn AccountRuntimeStore>,
pub settings: Arc<dyn SettingsStore>,
}
pub struct FreezeRecoveryTask {
deps: FreezeRecoveryDeps,
}
impl FreezeRecoveryTask {
pub fn new(deps: FreezeRecoveryDeps) -> Self {
Self { deps }
}
fn system_context() -> MutationContext {
MutationContext {
actor: MutationActor::System,
request_id: SYSTEM_REQUEST_ID.to_owned(),
}
}
/// 读取冻结策略;读取或校验失败跳过本轮,不能把未知配置当作允许解冻。
async fn freeze_policy(&self) -> Option<gateway_core::provider_ports::ProviderFreezePolicy> {
let settings = self.deps.settings.load_runtime_settings().await.ok()?;
gateway_core::provider_ports::ProviderFreezePolicy::try_new(
settings.account_auto_freeze_enabled,
settings.account_auto_freeze_threshold,
settings.account_auto_freeze_window_seconds,
settings.account_auto_freeze_duration_seconds,
settings.account_auto_freeze_probe_enabled,
settings.account_auto_freeze_probe_model,
settings.account_auto_freeze_adaptive_concurrency,
)
.ok()
}
async fn active_freezes(&self) -> BTreeMap<String, AccountFreeze> {
self.deps.runtime.active_freezes().await.unwrap_or_default()
}
async fn run_cycle_inner(&self) {
let Some(policy) = self.freeze_policy().await else {
return;
};
let freezes = self.active_freezes().await;
if freezes.is_empty() {
return;
}
if policy.enabled() && policy.adaptive_concurrency() {
self.adapt_concurrency_limits(&freezes, &policy).await;
}
self.recover_due_freezes(&freezes, &policy).await;
}
/// 对冻结中的账号执行自适应并发下调:目标为观测在途峰值的 80%(下限 2),
/// 只降不升;未观测到峰值证据的账号保持现状。
async fn adapt_concurrency_limits(
&self,
freezes: &BTreeMap<String, AccountFreeze>,
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
) {
let account_ids = freezes.keys().cloned().collect::<Vec<_>>();
let Ok(peaks) = self.deps.runtime.capacity_peaks(&account_ids).await else {
return;
};
for (account_id, peak) in peaks {
let Some(target) = adaptive_target(peak) else {
continue;
};
let Ok(account) = ProviderAccountId::new(account_id.clone()) else {
continue;
};
let Some(limit) = AccountConcurrencyLimit::new(target) else {
continue;
};
match self
.deps
.accounts
.lower_concurrency_limit(&Self::system_context(), account, limit)
.await
{
Ok(Some(_)) => {
tracing::info!(
account_id,
adapted_limit = target,
freeze_seconds = policy.freeze_duration().as_secs(),
"容量熔断:按观测在途峰值下调账号并发上限",
);
}
Ok(None) => {}
Err(error) => {
warn!(account_id, error = %error, "自适应并发下调失败");
}
}
}
}
async fn load_account(&self, account_id: &str) -> Option<Option<AccountPageItem>> {
let account_id = ProviderAccountId::new(account_id.to_owned()).ok()?;
self.deps
.store
.load_account(account_id.as_str(), AccountRuntimeSnapshot::default())
.await
.ok()
}
/// 到达探测时间后才恢复;关闭自动冻结或探测时,已有冻结仍等待原冷却结束。
async fn recover_due_freezes(
&self,
freezes: &BTreeMap<String, AccountFreeze>,
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
) {
for (account_id, freeze) in freezes {
if freeze.until > Utc::now() {
continue;
}
if policy.enabled() && policy.probe_enabled() && freeze.requires_probe {
self.probe_and_recover(account_id, freeze, policy).await;
} else if let Err(error) = self
.deps
.runtime
.finish_freeze(account_id, freeze, None)
.await
{
warn!(account_id, error = %error, "解除到期冻结失败");
}
}
}
async fn probe_and_recover(
&self,
account_id: &str,
freeze: &AccountFreeze,
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
) {
let Some(Some(item)) = self.load_account(account_id).await else {
return;
};
if !item.account.enabled {
// 停用账号没有自动恢复意义;解冻交给管理员手动恢复。
return;
}
let Ok(account) = ProviderAccountId::new(account_id.to_owned()) else {
return;
};
let Some(model) = self.resolve_probe_model(&account, policy).await else {
// 没有可用探测模型时按失败处理,顺延冻结等待下一轮。
self.postpone(&account, freeze, policy).await;
return;
};
let probe_succeeded = match self
.deps
.accounts
.test_connection(account.clone(), model)
.await
{
Ok(events) => drain_probe(events).await,
Err(error) => {
tracing::debug!(account_id, error = %error, "冻结恢复探测发起失败");
false
}
};
if probe_succeeded {
match self
.deps
.runtime
.finish_freeze(account_id, freeze, None)
.await
{
Ok(true) => tracing::info!(account_id, "冻结恢复探测成功:账号已解冻"),
Ok(false) => {}
Err(error) => warn!(account_id, error = %error, "解除冻结失败"),
}
} else {
self.postpone(&account, freeze, policy).await;
}
}
/// 探测失败只顺延本次仍存在的冻结,旧探测不能重新冻结已手动恢复的账号。
async fn postpone(
&self,
account: &ProviderAccountId,
freeze: &AccountFreeze,
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
) {
let Some(until) = SystemTime::now().checked_add(policy.freeze_duration()) else {
return;
};
let until = DateTime::<Utc>::from(until);
match self
.deps
.runtime
.finish_freeze(account.as_str(), freeze, Some(until))
.await
{
Ok(true) => {
tracing::info!(
account_id = account.as_str(),
postpone_seconds = policy.freeze_duration().as_secs(),
"冻结恢复探测失败:账号冻结顺延",
);
}
Ok(false) => {}
Err(error) => {
warn!(
account_id = account.as_str(),
error = %error,
"冻结顺延失败"
);
}
}
}
async fn resolve_probe_model(
&self,
account: &ProviderAccountId,
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
) -> Option<UpstreamModelId> {
if let Some(model) = policy.probe_model() {
return UpstreamModelId::new(model.to_owned()).ok();
}
let models = self.deps.accounts.models(account, false).await.ok()?;
models.models.into_iter().next().map(|model| model.id)
}
}
impl ScheduledTask for FreezeRecoveryTask {
fn run_cycle(
&self,
context: WorkerCycleContext,
) -> futures::future::BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move {
if context.cancellation().is_cancelled() {
return Ok(());
}
self.run_cycle_inner().await;
Ok(())
})
}
}
/// 自适应目标并发:观测峰值 × 0.8 向下取整,下限 2;无证据(峰值为 0)不调整。
fn adaptive_target(peak_in_flight: u32) -> Option<u32> {
if peak_in_flight == 0 {
return None;
}
let scaled = (f64::from(peak_in_flight) * ADAPTIVE_CONCURRENCY_FACTOR).floor() as u32;
Some(scaled.max(ADAPTIVE_CONCURRENCY_FLOOR))
}
/// 消耗连接测试事件流并返回是否以 `Completed` 终止。
async fn drain_probe(mut events: crate::model::accounts::AccountConnectionTestEventStream) -> bool {
let mut completed = false;
while let Some(event) = events.next().await {
match event {
AccountConnectionTestEvent::Completed => completed = true,
AccountConnectionTestEvent::Failed { .. } => return false,
_ => {}
}
}
completed
}
+318 -80
View File
@@ -5,48 +5,58 @@
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, WorkerRegistration,
WorkerRunnable,
DaemonRestartPolicy, WorkerContribution, WorkerId, WorkerKind, WorkerLeaseRequest,
WorkerRegistration, WorkerRunnable, WorkerSchedule,
},
};
use secrecy::{ExposeSecret as _, SecretString};
use serde::Deserialize;
pub mod backup;
pub mod freeze_recovery;
pub mod model;
pub mod ports;
pub mod service;
mod use_case;
pub use use_case::plugins::{PluginDistributionPorts, PluginManagementService, PluginsService};
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, 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_INITIAL_PASSWORDS: &[&str] = &[
const WEAK_ADMIN_PASSWORDS: &[&str] = &[
"",
"admin",
"123456",
@@ -58,6 +68,7 @@ const WEAK_INITIAL_PASSWORDS: &[&str] = &[
];
const BACKUP_WORKER_OWNER: &str = "backup";
const DEFAULT_CLIENT_SESSION_TTL_MINUTES: u64 = 24 * 60;
/// 只用于首次幂等创建默认管理员的启动密码。
#[derive(Clone, Deserialize)]
@@ -91,13 +102,40 @@ impl fmt::Debug for InitialAdminPassword {
/// 管理控制面的启动配置。
#[derive(Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct AdminConfig {
pub session_ttl_minutes: u64,
pub default_username: String,
pub default_password: InitialAdminPassword,
}
/// Client 登录域的通用启动配置。
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct ClientConfig {
pub session_ttl_minutes: u64,
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
session_ttl_minutes: DEFAULT_CLIENT_SESSION_TTL_MINUTES,
}
}
}
impl ClientConfig {
/// 校验 Client session TTL;当前配置不含相对路径。
///
/// # Errors
///
/// 会话有效期为零或无法安全换算时返回错误。
pub fn resolve_and_validate(&mut self, _source_dir: &Path) -> Result<(), AdminConfigError> {
if self.session_ttl_minutes == 0 || i64::try_from(self.session_ttl_minutes).is_err() {
return Err(AdminConfigError::InvalidField("client.session_ttl_minutes"));
}
Ok(())
}
}
impl AdminConfig {
/// 校验 Admin-owned 字段;当前配置不含相对路径。
///
@@ -116,7 +154,7 @@ impl AdminConfig {
let password = self.default_password.expose().trim();
if password.len() < MINIMUM_INITIAL_PASSWORD_BYTES
|| password.contains('$')
|| WEAK_INITIAL_PASSWORDS.contains(&password.to_ascii_lowercase().as_str())
|| WEAK_ADMIN_PASSWORDS.contains(&password.to_ascii_lowercase().as_str())
{
return Err(AdminConfigError::WeakInitialPassword);
}
@@ -149,8 +187,12 @@ pub enum AdminConfigError {
/// 字段全部私有;调用方经 accessor 直接调用能力,不需要命名内部 `use_case` 模块。
#[derive(Clone)]
pub struct AdminServices {
public_services: Arc<service::Registry>,
plugins: Arc<PluginsService>,
plugin_management: Arc<PluginManagementService>,
proxies: Arc<dyn ProxiesService>,
auth: Arc<dyn AuthService>,
key_usage: Arc<dyn KeyUsageService>,
accounts: Arc<dyn AccountsService>,
account_groups: Arc<dyn AccountGroupService>,
client_keys: Arc<dyn ClientKeyService>,
@@ -158,12 +200,43 @@ 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 {
pub fn public_services(&self) -> Arc<service::Registry> {
self.public_services.clone()
}
#[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()
}
#[must_use]
pub fn key_usage(&self) -> &dyn KeyUsageService {
self.key_usage.as_ref()
}
/// 取得账号服务的共享句柄;后台编排(冻结恢复 worker)需要持有 Arc。
#[must_use]
pub fn accounts_handle(&self) -> Arc<dyn AccountsService> {
Arc::clone(&self.accounts)
}
#[must_use]
pub fn proxies(&self) -> &dyn ProxiesService {
self.proxies.as_ref()
@@ -199,7 +272,6 @@ impl AdminServices {
self.observability.as_ref()
}
#[must_use]
pub fn settings(&self) -> &dyn SettingsService {
self.settings.as_ref()
}
@@ -210,13 +282,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]
@@ -237,42 +310,92 @@ impl AdminBundle {
self.services.clone()
}
/// 取出 Backup Worker 贡献;只能调用一次,与其它 Bundle 的贡献一并交给 Host。
/// 取出 Admin Worker 贡献;只能调用一次,与其它 Bundle 的贡献一并交给 Host。
pub fn take_worker_contributions(&mut self) -> Vec<WorkerContribution> {
std::mem::take(&mut self.worker_contributions)
}
}
/// 校验配置、建立动态 Provider 注册表并完成默认管理员幂等初始化。
/// 组合根提供给控制面的运行能力;与配置和存储端口分别传入。
pub struct AdminRuntimePorts {
pub service_middleware: service::PlanSource,
pub plugin_preparation: Arc<dyn ports::plugins::PluginPreparation>,
pub plugin_management: Arc<dyn ports::plugin_management::PluginManagement>,
pub published_snapshot: gateway_core::runtime::RuntimeSnapshotHandle,
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: ProviderAdminRegistry,
pub snapshot: Arc<dyn SnapshotControl>,
pub account_probe: Arc<dyn AccountProbe>,
pub proxy_probe: Arc<dyn ports::proxy::ProxyProbe>,
pub client_distribution: Arc<dyn ClientDistributionResolver>,
pub system: Arc<dyn SystemOperations>,
pub client_key_verifier: Arc<dyn ClientKeyVerifier>,
}
/// 校验配置、接入已组装的 Provider 注册表并完成默认管理员幂等初始化。
///
/// # Errors
///
/// 配置非法、Provider 注册冲突/缺失或默认管理员初始化失败时返回错误。
/// 配置非法或默认管理员初始化失败时返回错误。
pub async fn initialize(
mut config: AdminConfig,
config: AdminConfig,
client_config: ClientConfig,
store: AdminStorePorts,
providers: Vec<Arc<dyn ProviderAdmin>>,
snapshot: Arc<dyn SnapshotControl>,
probes: (Arc<dyn AccountProbe>, Arc<dyn ports::proxy::ProxyProbe>),
client_distribution: Arc<dyn ClientDistributionResolver>,
system: Arc<dyn SystemOperations>,
runtime: AdminRuntimePorts,
) -> Result<AdminBundle, AdminError> {
let (probe, proxy_probe) = probes;
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 {
service_middleware,
plugin_preparation,
plugin_management,
published_snapshot,
plugin_distribution,
plugin_inspector,
pricing_source,
providers,
snapshot,
account_probe: probe,
proxy_probe,
client_distribution,
system,
client_key_verifier,
} = runtime;
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)?;
client_config
.resolve_and_validate(Path::new("."))
.map_err(|error| AdminError::invalid(error.to_string()))?;
let registry = providers;
let auth = Arc::new(DefaultAuthService::new(
config.default_username,
config.session_ttl_minutes,
client_config.session_ttl_minutes,
store.auth(),
client_key_verifier.clone(),
));
auth.ensure_default_admin(config.default_password.expose())
.await?;
@@ -296,7 +419,55 @@ pub async fn initialize(
backup_ports.dump(),
backup_ports.object_store(),
);
let system_preflight = Arc::new(use_case::plugin_update::PluginSystemUpdatePreflight::new(
store.plugins(),
plugin_inspector.clone(),
snapshot.clone(),
));
let system = Arc::new(DefaultSystemService::new(system, system_preflight));
let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new(
auth.clone(),
client_key_verifier,
store.client_keys(),
store.observability(),
system.clone(),
));
let credentials = Arc::new(CredentialsService::new(
registry.clone(),
store.accounts(),
store.proxies(),
snapshot.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 settings = initialize_settings(
store.settings(),
snapshot.clone(),
registry.clone(),
pricing_source,
);
let mut public_services = service::Registry::new(service_middleware);
public_services.register_settings(&settings)?;
let services = AdminServices {
public_services: Arc::new(public_services),
plugin_management: Arc::new(PluginManagementService::new(
plugin_management,
store.plugins(),
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(),
proxy_probe,
@@ -304,7 +475,7 @@ pub async fn initialize(
registry.clone(),
)),
auth,
accounts,
accounts: accounts.clone(),
account_groups: Arc::new(DefaultAccountGroupService::new(
store.account_groups(),
store.account_runtime(),
@@ -313,40 +484,82 @@ pub async fn initialize(
client_keys: Arc::new(DefaultClientKeyService::new(
store.client_keys(),
snapshot.clone(),
registry.clone(),
)),
client_distribution: Arc::new(DefaultClientDistributionService::new(client_distribution)),
observability: Arc::new(DefaultObservabilityService::new(
store.observability(),
store.accounts(),
store.settings(),
registry,
)),
settings: Arc::new(DefaultSettingsService::new(
store.settings(),
snapshot.clone(),
)),
system: Arc::new(DefaultSystemService::new(system)),
openai: Arc::new(DefaultOpenAiService::new(
openai,
store.accounts(),
store.proxies(),
snapshot.clone(),
)),
xai: Arc::new(DefaultXaiService::new(
xai,
store.accounts(),
store.proxies(),
snapshot.clone(),
registry.clone(),
)),
settings,
system,
credentials,
plugin_accounts,
import_tasks,
backups,
};
let worker_contributions = backup_worker_contribution(backup_task)?;
let freeze_recovery =
freeze_recovery::FreezeRecoveryTask::new(freeze_recovery::FreezeRecoveryDeps {
accounts: Arc::clone(&accounts) as Arc<dyn AccountsService>,
store: store.accounts(),
runtime: store.account_runtime(),
settings: store.settings(),
});
let mut worker_contributions = backup_worker_contribution(backup_task)?;
let id = WorkerId::try_new(WorkerKind::AccountImport, "admin")
.map_err(|_| AdminError::internal("导入 Worker ID 不合法"))?;
let restart = DaemonRestartPolicy::try_new(Duration::from_secs(1), Duration::from_secs(60))
.map_err(|_| AdminError::internal("导入 Worker 重启策略不合法"))?;
let registration = WorkerRegistration::try_new(
id,
WorkerRunnable::Daemon {
restart,
task: Box::new(import_task),
},
)
.map_err(|_| AdminError::internal("导入 Worker 注册信息不合法"))?;
worker_contributions.push(WorkerContribution::Registration(registration));
worker_contributions.extend(freeze_recovery_worker_contribution(freeze_recovery)?);
Ok(AdminBundle {
services,
worker_contributions,
})
}
/// 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))
}
/// 为 Runtime 组合实例自有资源写入;权限和归属在同一存储事务复核。
#[must_use]
pub fn initialize_plugin_resources(
store: Arc<dyn ports::plugin_resources::PluginResourceStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Arc<dyn ports::plugin_resources::PluginResourceAccess> {
Arc::new(use_case::plugin_resources::DefaultPluginResourceAccess { store, snapshot })
}
/// Backup Worker 注册:单个可取消 Daemon,owner 固定为 `backup`。
fn backup_worker_contribution(
task: backup::task::BackupTask,
@@ -366,23 +579,48 @@ fn backup_worker_contribution(
Ok(vec![WorkerContribution::Registration(registration)])
}
fn provider_kind(value: &'static str) -> Result<ProviderKind, AdminError> {
ProviderKind::new(value).map_err(|_| AdminError::internal("内置 Provider 类型不合法"))
/// 冻结恢复 Worker 注册:按固定周期扫描活跃冻结,owner 固定。
fn freeze_recovery_worker_contribution(
task: freeze_recovery::FreezeRecoveryTask,
) -> Result<Vec<WorkerContribution>, AdminError> {
let id = WorkerId::try_new(
WorkerKind::AccountFreezeRecovery,
freeze_recovery::FREEZE_RECOVERY_WORKER_OWNER,
)
.map_err(|_| AdminError::internal("冻结恢复 Worker ID 不合法"))?;
let schedule = WorkerSchedule::try_new(
freeze_recovery::FREEZE_RECOVERY_INTERVAL,
freeze_recovery::WORKER_INITIAL_BACKOFF,
freeze_recovery::WORKER_MAXIMUM_BACKOFF,
freeze_recovery::WORKER_LEASE_TTL,
freeze_recovery::WORKER_LEASE_RENEWAL,
)
.map_err(|_| AdminError::internal("冻结恢复 Worker 调度配置不合法"))?;
let lease = WorkerLeaseRequest::try_new(id.clone(), freeze_recovery::WORKER_LEASE_TTL)
.map_err(|_| AdminError::internal("冻结恢复 Worker 租约配置不合法"))?;
let registration = WorkerRegistration::try_new(
id,
WorkerRunnable::Scheduled {
schedule,
lease: Some(lease),
task: Box::new(task),
},
)
.map_err(|_| AdminError::internal("冻结恢复 Worker 注册信息不合法"))?;
Ok(vec![WorkerContribution::Registration(registration)])
}
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 注册表初始化失败")
/// 设置服务不依赖 Web 管理会话,CLI 与服务器通过同一用例执行事务和发布。
pub fn initialize_settings(
store: Arc<dyn ports::store::SettingsStore>,
snapshot: Arc<dyn SnapshotControl>,
providers: ProviderAdminRegistry,
pricing_source: Arc<dyn ports::pricing::PricingSource>,
) -> Arc<dyn SettingsService> {
Arc::new(DefaultSettingsService::new(
store,
snapshot,
providers,
pricing_source,
))
}
@@ -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.
@@ -79,6 +80,7 @@ pub struct AccountGroupListQuery {
/// Complete account group summary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountGroupRecord {
pub disable_fast: bool,
pub id: AccountGroupId,
pub name: String,
pub description: Option<String>,
@@ -107,6 +109,7 @@ pub struct AccountGroupPage {
/// Create an account group.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateAccountGroup {
pub disable_fast: bool,
pub name: String,
pub description: Option<String>,
pub color: AccountGroupColor,
@@ -115,6 +118,7 @@ pub struct CreateAccountGroup {
/// Store-ready create command with a generated stable ID.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewAccountGroup {
pub disable_fast: bool,
pub id: AccountGroupId,
pub name: String,
pub description: Option<String>,
@@ -124,6 +128,7 @@ pub struct NewAccountGroup {
/// Update an account group's descriptive fields.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAccountGroup {
pub disable_fast: Option<bool>,
pub id: AccountGroupId,
pub name: String,
pub description: Option<String>,
@@ -18,12 +18,26 @@ 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 {
pub notes: Option<String>,
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub model_access: Option<gateway_core::account::AccountModelAccess>,
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
}
@@ -67,11 +81,20 @@ pub struct AccountListQuery {
/// Admin query service 从运行态存储取得的当前账号冷却快照。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AccountRuntimeSnapshot {
pub rate_limited_until: BTreeMap<String, DateTime<Utc>>,
pub cooldown: BTreeMap<String, gateway_core::account::AccountCooldown>,
/// `None` 表示实时 lease 存储不可用;`Some` 中未出现的账号当前使用量为零。
pub in_flight: Option<BTreeMap<String, u64>>,
}
/// 恢复任务读取的冻结快照;generation 将异步结果绑定到本次冻结。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountFreeze {
pub credential_revision: Revision,
pub until: DateTime<Utc>,
pub generation: String,
pub requires_probe: bool,
}
/// Optional account membership filter.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AccountGroupFilter {
@@ -86,6 +109,7 @@ pub struct AccountRecord {
pub provider_kind: ProviderKind,
pub groups: Vec<AccountGroupRef>,
pub name: String,
pub notes: Option<String>,
pub email: Option<String>,
pub upstream_user_id: Option<String>,
pub upstream_account_id: Option<String>,
@@ -98,6 +122,7 @@ pub struct AccountRecord {
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub model_access: gateway_core::account::AccountModelAccess,
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
pub credential_state: CredentialState,
pub credential_observed_at: DateTime<Utc>,
@@ -198,6 +223,16 @@ pub struct AccountPage {
pub struct AccountPageItem {
pub account: AccountRecord,
pub projection: AccountStatusProjection,
pub capacity: AccountCapacity,
}
/// 网关配置的账号并发上限与查询时的占用,不包含排队请求或上游隐藏限制。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccountCapacity {
/// 实时租约读取失败时为 `None`,不能当作空闲。
pub used_slots: Option<u64>,
/// 应用账号覆盖或全局默认值后的上限;`None` 表示不限。
pub total_slots: Option<u64>,
}
/// 统一账号目录的全局状态计数,不受当前筛选和分页影响。
@@ -217,9 +252,12 @@ pub struct AccountSummary {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAccount {
pub account_id: String,
/// 缺省保留备注;空字符串清空备注。
pub notes: Option<String>,
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub model_access: Option<gateway_core::account::AccountModelAccess>,
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
}
@@ -231,14 +269,15 @@ pub struct AccountUpdateResult {
pub account_id: gateway_core::account::ProviderAccountId,
}
/// 一批账号可编辑事实的一次性替换命令。
/// 仅修改显式字段的批量账号设置命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BatchUpdateAccounts {
pub account_ids: Vec<String>,
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
pub enabled: Option<bool>,
pub concurrency_limit: Option<Option<AccountConcurrencyLimit>>,
pub weight: Option<AccountWeight>,
pub model_access: Option<gateway_core::account::AccountModelAccess>,
pub group_ids: Option<Vec<gateway_core::routing::AccountGroupId>>,
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
}
@@ -0,0 +1,153 @@
//! 管理写入的审计分类;适配器只能补充事务实际写入的字段与实体标识。
use super::{
MutationActor, MutationContext,
auth::{AdminAuditEvent, AuditActorKind, admin_session_actor_ref},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MutationAuditOperation {
ModelPricingSync,
ModelPricingUpdate,
RuntimeSettingsReplace,
AdminApiKeyChanged { exists: bool },
AccountGroupCreate,
AccountGroupUpdate,
AccountGroupEnabled { enabled: bool },
AccountGroupDelete,
ClientApiKeyResetBudget,
ProviderAccountUpdate,
BackupStorageUpdate,
BackupScheduleUpdate,
ProviderAccountAdaptConcurrency,
ProviderAccountRecover,
ProviderAccountBatchUpdate,
ProviderAccountDelete,
ProviderAccountExportCredentials,
PluginArtifactInstall,
PluginArtifactAccept,
PluginArtifactDelete,
PluginInstanceConfigure,
PluginInstanceDelete,
PluginSourceCredentialCreate,
PluginSourceCredentialDelete,
ClientApiKeyUpdateBudgetLimits,
ClientApiKeyCreate,
ClientApiKeyUpdate,
ClientApiKeyEnabled { enabled: bool },
ClientApiKeyDelete,
PluginSourceChangeSource,
PluginSourceDelete,
OutboundProxyCreate,
OutboundProxyUpdate,
OutboundProxyDelete,
OutboundProxyTest,
AccountGroupPluginReconcile,
ClientApiKeyPluginReconcile,
ProviderAccountImportDocument,
ProviderAccountRotateCredential,
ProviderAccountRefreshCredential,
ProviderAccountAuthorize,
ProviderAccountReauthorize,
}
impl MutationAuditOperation {
#[must_use]
pub const fn classification(self) -> (&'static str, &'static str) {
match self {
Self::ModelPricingSync => ("pricing.sync", "model_pricing"),
Self::ModelPricingUpdate => ("pricing.update", "model_pricing"),
Self::RuntimeSettingsReplace => ("settings.replace", "runtime_settings"),
Self::AdminApiKeyChanged { exists } => (
if exists {
"admin_api_key.replace"
} else {
"admin_api_key.delete"
},
"runtime_settings",
),
Self::AccountGroupCreate => ("create", "account_group"),
Self::AccountGroupUpdate => ("update", "account_group"),
Self::AccountGroupEnabled { enabled } => {
(if enabled { "enable" } else { "disable" }, "account_group")
}
Self::AccountGroupDelete => ("delete", "account_group"),
Self::ClientApiKeyResetBudget => ("reset_budget", "client_api_key"),
Self::ProviderAccountUpdate => ("update", "provider_account"),
Self::BackupStorageUpdate => ("backup.s3_config_updated", "backup_settings"),
Self::BackupScheduleUpdate => ("backup.schedule_updated", "backup_settings"),
Self::ProviderAccountAdaptConcurrency => ("adapt_concurrency", "provider_account"),
Self::ProviderAccountRecover => ("recover", "provider_account"),
Self::ProviderAccountBatchUpdate => ("batch_update", "provider_account"),
Self::ProviderAccountDelete => ("delete", "provider_account"),
Self::ProviderAccountExportCredentials => ("export_credentials", "provider_account"),
Self::PluginArtifactInstall => ("install", "plugin_artifact"),
Self::PluginArtifactAccept => ("accept", "plugin_artifact"),
Self::PluginArtifactDelete => ("delete", "plugin_artifact"),
Self::PluginInstanceConfigure => ("configure", "plugin_instance"),
Self::PluginInstanceDelete => ("delete", "plugin_instance"),
Self::PluginSourceCredentialCreate => ("create", "plugin_source_credential"),
Self::PluginSourceCredentialDelete => ("delete", "plugin_source_credential"),
Self::ClientApiKeyUpdateBudgetLimits => ("update_budget_limits", "client_api_key"),
Self::ClientApiKeyCreate => ("create", "client_api_key"),
Self::ClientApiKeyUpdate => ("update", "client_api_key"),
Self::ClientApiKeyEnabled { enabled } => {
(if enabled { "enable" } else { "disable" }, "client_api_key")
}
Self::ClientApiKeyDelete => ("delete", "client_api_key"),
Self::PluginSourceChangeSource => ("change_source", "plugin_source"),
Self::PluginSourceDelete => ("delete", "plugin_source"),
Self::OutboundProxyCreate => ("create", "outbound_proxy"),
Self::OutboundProxyUpdate => ("update", "outbound_proxy"),
Self::OutboundProxyDelete => ("delete", "outbound_proxy"),
Self::OutboundProxyTest => ("test", "outbound_proxy"),
Self::AccountGroupPluginReconcile => ("plugin_reconcile", "account_group"),
Self::ClientApiKeyPluginReconcile => ("plugin_reconcile", "client_api_key"),
Self::ProviderAccountImportDocument => ("import_document", "provider_account"),
Self::ProviderAccountRotateCredential => ("rotate_credential", "provider_account"),
Self::ProviderAccountRefreshCredential => ("refresh_credential", "provider_account"),
Self::ProviderAccountAuthorize => ("authorize", "provider_account"),
Self::ProviderAccountReauthorize => ("reauthorize", "provider_account"),
}
}
}
/// 分类与稳定实体标识共同组成审计意图,字段差异由同一提交事务补齐。
#[derive(Debug, Clone, Copy)]
pub struct MutationAuditIntent<'a> {
pub operation: MutationAuditOperation,
pub entity_ref: &'a str,
}
impl MutationAuditIntent<'_> {
#[must_use]
pub fn event(self, context: &MutationContext, changed_fields: Vec<String>) -> AdminAuditEvent {
let (actor_kind, actor_admin_user_id, actor_ref) = match &context.actor {
MutationActor::AdminSession { admin_user_id } => (
AuditActorKind::AdminSession,
Some(admin_user_id.clone()),
admin_session_actor_ref(admin_user_id),
),
MutationActor::AdminApiKey => (
AuditActorKind::AdminApiKey,
None,
"admin_api_key".to_owned(),
),
MutationActor::System => (AuditActorKind::System, None, "system".to_owned()),
};
let (action, entity_kind) = self.operation.classification();
AdminAuditEvent {
id: format!("audit_{}", uuid::Uuid::now_v7().simple()),
actor_kind,
actor_admin_user_id,
actor_ref,
request_id: Some(context.request_id.clone()),
action: action.to_owned(),
entity_kind: entity_kind.to_owned(),
entity_ref: self.entity_ref.to_owned(),
config_revision: None,
changed_fields,
occurred_at: chrono::Utc::now(),
}
}
}
+83 -18
View File
@@ -1,6 +1,7 @@
//! 管理员认证、会话和安全审计事实。
//! 控制面认证、统一会话和管理员安全审计事实。
use chrono::{DateTime, Utc};
use gateway_core::{engine::execution::ClientAuthenticationError, policy::ClientApiKeyId};
use super::{MutationActor, MutationContext, Revision};
@@ -13,8 +14,14 @@ pub fn admin_session_actor_ref(admin_user_id: &str) -> String {
/// 已认证的管理主体。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdminPrincipal {
Session { admin_user_id: String },
Session {
admin_user_id: String,
},
ApiKey,
/// 安装者完整信任的插件;身份仅由内部 HTTP 分派端口签发。
Plugin {
instance_id: String,
},
}
/// 传给管理用例的安全请求上下文。
@@ -32,6 +39,7 @@ impl AdminRequestContext {
admin_user_id: admin_user_id.clone(),
},
AdminPrincipal::ApiKey => MutationActor::AdminApiKey,
AdminPrincipal::Plugin { .. } => MutationActor::System,
};
MutationContext {
actor,
@@ -40,43 +48,100 @@ impl AdminRequestContext {
}
}
/// 管理员登录命令。
/// 登录方式只决定凭据验证流程,不能直接授予会话权限。
#[derive(Clone, PartialEq, Eq)]
pub struct LoginCommand {
pub username: Option<String>,
pub password: String,
pub enum LoginCommand {
Admin {
username: Option<String>,
password: String,
},
Key {
api_key: String,
},
}
impl std::fmt::Debug for LoginCommand {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("LoginCommand")
.field("username", &self.username)
.field("password", &"[REDACTED]")
.finish()
match self {
Self::Admin { username, .. } => formatter
.debug_struct("AdminLogin")
.field("username", username)
.field("password", &"[REDACTED]")
.finish(),
Self::Key { .. } => formatter
.debug_struct("KeyLogin")
.field("api_key", &"[REDACTED]")
.finish(),
}
}
}
/// 登录成功后返回的会话事实。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Clone, PartialEq, Eq)]
pub struct LoginResult {
pub session_id: String,
pub expires_at: DateTime<Utc>,
pub session: AuthSession,
}
impl std::fmt::Debug for LoginResult {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("LoginResult")
.field("session_id", &"[REDACTED]")
.field("session", &self.session)
.finish()
}
}
/// 登录状态机可被 API 精确映射的失败类型。
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum LoginError {
#[error("invalid administrator credentials")]
#[error("invalid login credentials")]
InvalidCredentials,
#[error("administrator authentication is unavailable")]
#[error("too many login attempts")]
TooManyAttempts { retry_after_seconds: u64 },
#[error("authentication is unavailable")]
Unavailable,
}
/// Redis 中可恢复的管理员会话。
impl From<ClientAuthenticationError> for LoginError {
fn from(error: ClientAuthenticationError) -> Self {
match error {
ClientAuthenticationError::InvalidKey => Self::InvalidCredentials,
ClientAuthenticationError::SnapshotUnavailable
| ClientAuthenticationError::ProviderUnavailable => Self::Unavailable,
}
}
}
/// 服务端已验证的身份绑定,不保存密码或原始 API Key。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdminSession {
pub admin_user_id: String,
pub enum SessionSubject {
Admin {
admin_user_id: String,
credential_fingerprint: String,
},
Key {
client_key_id: ClientApiKeyId,
},
}
/// 仅已登录管理员可以修改自己的密码。
pub struct ChangePassword {
pub current_password: String,
pub new_password: String,
}
impl std::fmt::Debug for ChangePassword {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("ChangePassword([REDACTED])")
}
}
/// 两种登录方式共用的固定有效期会话。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthSession {
pub subject: SessionSubject,
pub expires_at: DateTime<Utc>,
}
@@ -124,6 +124,33 @@ impl fmt::Display for BackupStatus {
}
}
/// 已通过领域状态机校验的显式迁移。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BackupStatusTransition {
from: BackupStatus,
to: BackupStatus,
}
impl BackupStatusTransition {
/// 校验并构造显式迁移;非法状态边返回 `None`。
#[must_use]
pub fn try_new(from: BackupStatus, to: BackupStatus) -> Option<Self> {
from.allows_transition_to(to).then_some(Self { from, to })
}
/// 迁移要求的当前状态。
#[must_use]
pub const fn from(self) -> BackupStatus {
self.from
}
/// 迁移完成后的目标状态。
#[must_use]
pub const fn to(self) -> BackupStatus {
self.to
}
}
/// S3 存储配置更新命令;`secret_access_key` 为 `None` 表示保留旧值。
#[derive(Clone)]
pub struct UpdateBackupStorageCommand {
@@ -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};
@@ -10,7 +10,15 @@ use gateway_core::{
routing::{AccountGroupId, ProviderKind},
};
use super::{AdminModelError, Revision, account_groups::AccountGroupRef};
use super::{
AdminModelError, Revision, account_groups::AccountGroupRef,
plugin_resources::PluginResourceOwner,
};
pub type ProviderRequestProfileOverrides =
BTreeMap<ProviderKind, gateway_core::account::OpaqueProviderData>;
pub type ProviderRequestProfileOverrideUpdates =
BTreeMap<ProviderKind, Option<gateway_core::account::OpaqueProviderData>>;
/// Client Key 列表保持旧 HTTP 合同允许的完整非零 `u16` 页大小。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
@@ -86,6 +94,7 @@ pub struct ClientKeyListQuery {
/// 不含完整明文 Key 的管理投影。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientKeyRecord {
pub request_profile_overrides: ProviderRequestProfileOverrides,
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
@@ -109,7 +118,7 @@ pub struct ClientKeyPage {
pub next_cursor: Option<ClientKeyCursor>,
}
/// 仅在创建或显式 reveal 时跨越管理边界的明文 Key。
/// 仅在创建或显式读取密钥配置时返回的明文 Key;Debug 隐去明文。
#[derive(Clone, PartialEq, Eq)]
pub struct ClientKeySecret {
pub record: ClientKeyRecord,
@@ -144,6 +153,7 @@ impl fmt::Debug for ClientKeySecret {
/// API 提交的 Client Key 创建命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateClientKey {
pub request_profile_overrides: ProviderRequestProfileOverrides,
pub custom_key: Option<PlaintextClientApiKey>,
pub name: String,
pub label: Option<String>,
@@ -155,6 +165,7 @@ pub struct CreateClientKey {
/// 管理用例生成 ID 与明文后的持久化命令。
#[derive(Clone, PartialEq, Eq)]
pub struct NewClientKey {
pub request_profile_overrides: ProviderRequestProfileOverrides,
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
@@ -179,6 +190,7 @@ impl fmt::Debug for NewClientKey {
/// 修改 Client Key 的公开策略字段。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateClientKey {
pub request_profile_override_updates: ProviderRequestProfileOverrideUpdates,
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
@@ -195,6 +207,36 @@ pub struct SetClientKeyEnabled {
pub enabled: bool,
}
/// 管理员选择的金额重置范围。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientKeyBudgetPeriod {
Daily,
Weekly,
All,
}
/// 清零所选窗口已用金额,保留限额、到期时间与历史费用。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResetClientKeyBudget {
pub id: ClientApiKeyId,
pub period: ClientKeyBudgetPeriod,
}
/// 仅更新指定金额上限;省略的周期保持不变,不修改已用金额或窗口。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateClientKeyBudgetLimits {
pub id: ClientApiKeyId,
pub daily_limit_usd: Option<gateway_core::metering::Decimal>,
pub weekly_limit_usd: Option<gateway_core::metering::Decimal>,
}
/// 预算变更的调用来源;插件身份由 Runtime 给出,不能从插件请求反序列化。
#[derive(Debug, Clone)]
pub enum ClientKeyBudgetMutationOrigin {
Admin,
Plugin(PluginResourceOwner),
}
/// 删除 Client Key。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteClientKey {
@@ -0,0 +1,84 @@
//! 进程内账号导入任务;只保存执行所需输入与可公开的结果。
use chrono::{DateTime, Utc};
use gateway_core::{account::ProviderAccountId, routing::ProviderKind};
use uuid::Uuid;
use super::{MutationContext, provider_credentials::ImportCredentials};
pub const MAX_IMPORT_TASK_ITEMS: usize = 200;
#[derive(Debug)]
pub struct ImportTaskInput {
pub provider: ProviderKind,
pub command: ImportCredentials,
}
#[derive(Debug)]
pub struct SubmitImportTask {
pub submission_id: Uuid,
/// API 对完整输入计算摘要;用于区分丢失响应后的重试与修改后的新提交。
pub fingerprint: [u8; 32],
pub context: MutationContext,
pub items: Vec<ImportTaskInput>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImportItemStatus {
Pending,
Running,
Succeeded,
Failed,
Unknown,
Skipped,
}
impl ImportItemStatus {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Running => "running",
Self::Succeeded => "succeeded",
Self::Failed => "failed",
Self::Unknown => "unknown",
Self::Skipped => "skipped",
}
}
}
#[derive(Debug, Clone)]
pub struct ImportTaskItem {
pub index: usize,
pub provider: ProviderKind,
pub status: ImportItemStatus,
pub account_ids: Vec<ProviderAccountId>,
pub message: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ImportTaskCounts {
pub pending: usize,
pub running: usize,
pub succeeded: usize,
pub failed: usize,
pub unknown: usize,
pub skipped: usize,
pub imported_accounts: usize,
}
#[derive(Debug, Clone)]
pub struct ImportTaskSummary {
pub task_id: Uuid,
pub created_at: DateTime<Utc>,
pub finished_at: Option<DateTime<Utc>>,
pub stop_requested: bool,
pub total: usize,
pub counts: ImportTaskCounts,
}
#[derive(Debug, Clone)]
pub struct ImportTaskDetail {
pub summary: ImportTaskSummary,
pub items: Vec<ImportTaskItem>,
}
@@ -0,0 +1,41 @@
//! 单页 Key 用量的查询合同;身份范围不由调用方提供。
use super::{
PageSize,
client_keys::ClientKeyRecord,
observability::{
HealthTimeline, OpsErrorPage, RequestMetricPoint, TimeRange, UsageOverview, UsagePage,
},
};
#[derive(Debug, Clone)]
pub struct KeyUsageQuery {
pub range: TimeRange,
pub model: Option<String>,
}
#[derive(Debug, Clone, Copy)]
pub enum KeyUsageRecordKind {
Success,
Error,
}
#[derive(Debug, Clone)]
pub struct KeyUsageRecordsQuery {
pub usage: KeyUsageQuery,
pub kind: KeyUsageRecordKind,
pub current_page: u32,
pub page_size: PageSize,
}
pub struct KeyUsageOverview {
pub key: ClientKeyRecord,
pub overview: UsageOverview,
pub trend: Vec<RequestMetricPoint>,
pub health_timeline: HealthTimeline,
}
pub enum KeyUsageRecords {
Success(UsagePage),
Error(OpsErrorPage),
}
+17 -3
View File
@@ -4,15 +4,23 @@ use std::num::{NonZeroU16, NonZeroU64};
pub mod account_groups;
pub mod accounts;
pub mod audit;
pub mod auth;
pub mod backup;
pub mod client_distribution;
pub mod client_keys;
pub mod import_tasks;
pub mod key_usage;
pub mod observability;
pub mod plugin_client_keys;
pub mod plugin_resources;
pub mod plugins;
pub mod pricing;
pub mod provider_credentials;
pub mod proxies;
pub mod quota_forecast;
pub mod quota_forecast_sampling;
pub mod retention;
pub mod settings;
pub mod system;
@@ -21,6 +29,7 @@ pub mod system;
pub enum AdminErrorKind {
Invalid,
Unauthorized,
Forbidden,
NotFound,
Conflict,
RateLimited,
@@ -100,7 +109,10 @@ impl AdminError {
}
/// PostgreSQL 中所有正整数 revision 的管理层表示。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
)]
#[serde(transparent)]
pub struct Revision(NonZeroU64);
impl Revision {
@@ -149,7 +161,8 @@ impl PageSize {
}
/// 可审计管理写操作的发起者。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum MutationActor {
AdminSession { admin_user_id: String },
AdminApiKey,
@@ -157,7 +170,8 @@ pub enum MutationActor {
}
/// 管理写操作必须携带的审计上下文。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct MutationContext {
pub actor: MutationActor,
pub request_id: String,
@@ -297,6 +297,7 @@ pub struct ProviderBillingInput {
/// 控制面仅保留通用事实,具体 Provider 负责校验已持久化总额并恢复标准费用。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageCalculatedBillingFact {
pub breakdown: Option<CalculatedBillingBreakdown>,
pub bucket_start: DateTime<Utc>,
pub provider_kind: String,
pub upstream_model_id: String,
@@ -311,6 +312,9 @@ pub struct UsageCalculatedBillingFact {
/// Provider 已确认的逐项费用与单价。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CalculatedBillingBreakdown {
pub long_context_billing_applied: bool,
pub image: Option<ImageBillingBreakdown>,
pub custom_multiplier_bps: u32,
pub input_amount: CurrencyCost,
pub output_amount: CurrencyCost,
pub cache_read_amount: CurrencyCost,
@@ -325,6 +329,16 @@ pub struct CalculatedBillingBreakdown {
pub multiplier_percent: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageBillingBreakdown {
pub input_tokens: u64,
pub cached_tokens: u64,
pub input_amount: CurrencyCost,
pub cache_read_amount: CurrencyCost,
pub input_price_per_million: CurrencyCost,
pub cache_read_price_per_million: CurrencyCost,
}
/// 单次请求的费用语义。
///
/// Provider 上报费用或无法恢复逐项价格时保留总额;Provider 验证成功后升级为完整分解。
@@ -533,6 +547,7 @@ pub struct DashboardObservation {
/// 使用记录表格的窄读模型。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageListRecord {
pub client_api_key_name: Option<String>,
pub id: String,
pub endpoint: String,
pub client_transport: String,
@@ -541,9 +556,15 @@ pub struct UsageListRecord {
pub provider_account_ref: Option<String>,
pub provider_account_name: Option<String>,
pub provider_account_email: Option<String>,
/// 账号当前备注,按内部账号 ID 关联,不属于请求历史快照。
pub provider_account_notes: Option<String>,
/// 账号当前套餐,不属于请求历史快照。
pub provider_account_plan_type: Option<String>,
pub provider_account_plan_type_display: Option<String>,
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
pub upstream_response_model: Option<String>,
pub service_tier: Option<String>,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
@@ -602,6 +623,7 @@ pub struct UsageRecord {
pub upstream_transport: Option<String>,
pub http_version: Option<String>,
pub websocket_pool: Option<String>,
pub upstream_response_model: Option<String>,
pub service_tier: Option<String>,
/// Provider 已筛选的专有观测 JSON;管理领域保持不透明。
pub provider_metadata_json: Option<String>,
@@ -738,11 +760,20 @@ pub struct UsageSummary {
pub average_latency_ms: Option<u64>,
}
/// 诊断聚合结果,分母包含截取展示项之前的全部匹配请求。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DiagnosticsObservation {
pub total_request_count: u64,
pub items: Vec<DiagnosticObservation>,
}
/// 单个诊断维度值的聚合结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiagnosticObservation {
pub key: String,
pub name: String,
pub account_provider_kind: Option<String>,
pub account_plan_type: Option<String>,
pub request_count: u64,
pub success_count: u64,
pub failure_count: u64,
@@ -753,6 +784,7 @@ pub struct DiagnosticObservation {
pub first_token_p95_ms: Option<u64>,
pub non_completion_count: u64,
pub retry_count: u64,
pub retried_request_count: u64,
pub cost_coverage: CostCoverage,
pub costs: Vec<CurrencyCost>,
}
@@ -760,6 +792,7 @@ pub struct DiagnosticObservation {
/// 统一运维错误记录。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpsError {
pub client_api_key_name: Option<String>,
pub source: String,
pub event_id: String,
pub request_id: Option<String>,
@@ -972,7 +1005,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>,
}
@@ -1121,6 +1155,8 @@ pub struct UsageInsights {
pub struct DiagnosticsItem {
pub key: String,
pub name: String,
pub account_plan_type: Option<String>,
pub account_plan_type_display: Option<String>,
pub request_count: u64,
pub success_count: u64,
pub error_count: u64,
@@ -1133,7 +1169,6 @@ pub struct DiagnosticsItem {
pub non_completion_rate: f64,
pub retry_count: u64,
pub retry_rate: f64,
pub impact_score: f64,
pub estimated_cost: Option<DecimalAmount>,
pub attempt_count: u64,
pub total_tokens: u64,
@@ -0,0 +1,40 @@
//! 插件可见的 Client Key 非秘密资源投影。
use gateway_core::policy::ClientApiKeyId;
use gateway_core::routing::AccountGroupId;
use super::PageSize;
/// 插件可读取的当前 Key 范围,不含密钥或配置秘密。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginClientKeyFacts {
pub id: ClientApiKeyId,
pub enabled: bool,
pub group_ids: Vec<AccountGroupId>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginClientKeyCursor {
pub name: String,
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,42 @@
//! 插件资源归属由宿主调用上下文给出,不能由插件参数选择实例或代次。
use super::Revision;
#[derive(Debug, Clone)]
pub struct PluginResourceOwner {
pub instance_id: String,
pub artifact_sha256: String,
pub revision: Revision,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManagedResource {
pub id: String,
pub name: String,
pub enabled: bool,
}
pub struct ResourceMutation<T> {
/// 没有事实变化时不递增配置 revision,也不触发发布。
pub revision: Option<Revision>,
pub value: T,
}
pub struct GroupMembersChange {
pub resource_key: String,
pub add: Vec<String>,
pub remove: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GroupMembersChanged {
pub added: u64,
pub removed: u64,
}
/// 插件 Key 的首次创建设置;分组始终从本实例资源键解析。
pub struct ManagedKeyConfig {
pub name: String,
pub limits: gateway_core::policy::RateLimits,
pub budget: gateway_core::engine::budget::ClientBudgetLimits,
}
@@ -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,189 @@
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 = "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, skip_serializing_if = "Option::is_none")]
pub event: Option<String>,
#[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 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 compatibility_warning: Option<String>,
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,91 @@
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 headers: Vec<gateway_core::engine::middleware::MiddlewareHeader>,
pub body: Vec<u8>,
pub request_id: String,
}
pub struct PluginManagementResponse {
pub status: u16,
pub content_type: String,
pub headers: Vec<gateway_core::engine::middleware::MiddlewareHeader>,
pub body: Arc<[u8]>,
}
@@ -0,0 +1,262 @@
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>,
}
impl PluginHostCompatibility {
/// 拒绝空集合、重复项和不受控标识,避免发行清单把“未知”解释为通配。
#[must_use]
pub fn is_valid(&self) -> bool {
if self.schema_version != 2
|| !valid_versions(&self.manifest_schema_versions)
|| !valid_versions(&self.protocol_versions)
|| self.capabilities.is_empty()
|| self.capabilities.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;
}
true
}
#[must_use]
pub fn supports_capability(&self, capability: &str, version: u32) -> bool {
self.capabilities
.iter()
.any(|entry| entry.capability == capability && entry.versions.contains(&version))
}
}
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)>,
}
/// 一次下载使用的非敏感出站代理身份;地址与认证由受管代理记录持有。
#[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")]
pub struct PluginContribution {
pub id: String,
pub version: u32,
#[serde(default)]
pub stages: Vec<String>,
#[serde(default)]
pub input_formats: Vec<String>,
#[serde(default)]
pub output_formats: Vec<String>,
}
/// 制品清单中的展示图标;路径只用于选择已校验的包资源。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PluginArtifactIcon {
Path(String),
Themed(PluginArtifactIconVariants),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
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>,
}
/// 已校验制品的持久化投影;读取时忽略未知字段,可选字段缺省沿用清单语义。
/// 身份与执行所需事实仍为必填,包清单及协议版本由 Runtime 独立校验。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
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>,
#[serde(default = "empty_configuration_schema")]
pub configuration_schema: serde_json::Value,
#[serde(default)]
pub secret_fields: Vec<String>,
#[serde(default)]
pub state_namespaces: Vec<state::PluginStateSchema>,
}
fn empty_configuration_schema() -> serde_json::Value {
serde_json::json!({})
}
/// 由包检查端口产生的不可变制品;不派生 Debug,包内可能含敏感用户数据。
#[derive(Clone)]
pub struct InspectedPluginArtifact {
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")]
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,
}
@@ -0,0 +1,53 @@
//! 全局模型定价的管理合同。
use gateway_core::metering::{ModelPriceOverride, PricingOverrides};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PricingCatalog {
pub defaults: PricingOverrides,
pub overrides: PricingOverrides,
pub synced: PricingOverrides,
pub synced_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StoredPricing {
pub overrides: PricingOverrides,
pub synced: PricingOverrides,
pub synced_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PricingSyncPreview {
pub prices: PricingOverrides,
pub skipped: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[derive(serde::Serialize)]
pub struct SyncPricing {
pub preview: PricingSyncPreview,
pub models: BTreeMap<String, BTreeSet<String>>,
}
pub type PricingSyncChanges = BTreeMap<String, BTreeMap<String, Option<ModelPriceOverride>>>;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum PricingChange {
Replace(ModelPriceOverride),
Multiplier(u32),
Reset,
Delete,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct UpdatePricing {
pub provider: String,
pub models: Vec<String>,
pub change: PricingChange,
}
pub type ProviderPricingCatalog = BTreeMap<String, ModelPriceOverride>;
@@ -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,
},
@@ -99,6 +100,7 @@ impl fmt::Debug for PrepareCredentialImport {
/// Provider 已验证、可由 Store 原子创建的一份 credential。
#[derive(Debug, Clone, PartialEq)]
pub struct PreparedCredentialCreate {
pub model_access: Option<gateway_core::account::AccountModelAccess>,
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
pub account_id: ProviderAccountId,
pub provider_kind: ProviderKind,
@@ -383,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,
@@ -424,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 必须一起返回。
@@ -448,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 并
@@ -493,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) => {
@@ -501,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,
@@ -518,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(())
@@ -584,6 +652,7 @@ pub struct CredentialDeletionResult {
pub struct RotateCredential {
pub mutation: CredentialMutation,
pub provider_material: ProviderDocument,
pub settings: Option<super::accounts::UpdateAccount>,
}
/// Provider 校验手工轮换材料时所需的非事务输入。
@@ -612,6 +681,10 @@ pub struct PreparedCredentialRotationFacts {
pub name: String,
pub email: Option<String>,
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>>,
@@ -633,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
@@ -663,6 +743,7 @@ impl fmt::Debug for PreparedCredentialRotation {
#[derive(Debug, Clone, PartialEq)]
pub struct CredentialRotationCommit {
pub prepared: PreparedCredentialRotationFacts,
pub settings: Option<super::accounts::UpdateAccount>,
}
impl fmt::Debug for RotateCredential {
@@ -671,6 +752,7 @@ impl fmt::Debug for RotateCredential {
.debug_struct("RotateCredential")
.field("mutation", &self.mutation)
.field("provider_material", &self.provider_material)
.field("settings", &self.settings)
.finish()
}
}
@@ -1054,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 {
@@ -1089,6 +1227,8 @@ impl fmt::Debug for ProviderExport {
#[derive(Debug, Clone, PartialEq)]
pub struct AccountDirectoryItem {
pub account: AccountRecord,
pub capacity: super::accounts::AccountCapacity,
pub capabilities: super::accounts::ProviderAccountCapabilities,
/// Provider 提供的套餐展示名称;未识别到套餐时为空。
pub plan_type_display: Option<String>,
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,14 +58,20 @@ 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>,
pub exit_ipv4: Option<std::net::Ipv4Addr>,
pub exit_ipv6: Option<std::net::Ipv6Addr>,
pub message: String,
}
#[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,
pub proxy: OutboundProxy,
@@ -86,12 +93,20 @@ 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,
}
#[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,
pub revision: Revision,
pub name: String,
@@ -103,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(),
}
}
}
@@ -28,9 +28,6 @@ pub struct AccountQuotaForecast {
pub incomplete_tokens: bool,
pub estimated_tokens: Option<u64>,
pub estimated_usd: Option<f64>,
/// 剩余估算始终属于源窗口,不随目标周期折算。
pub remaining_tokens: Option<u64>,
pub remaining_usd: Option<f64>,
}
#[derive(Debug, Clone, PartialEq)]
@@ -43,6 +40,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 +63,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 {
@@ -71,8 +78,6 @@ pub fn account_quota_forecasts(
incomplete_tokens: false,
estimated_tokens: None,
estimated_usd: None,
remaining_tokens: None,
remaining_usd: None,
};
if let Some((window, source_period)) = selected {
forecast.project(
@@ -108,7 +113,7 @@ impl AccountQuotaForecast {
let percent = window
.used_percent
.filter(|p| p.is_finite() && (0.0..=100.0).contains(p));
let usage = sample.map(|sample| &sample.usage);
let usage = sample.map(|sample| &sample.cycle_usage);
let usd = usage
.filter(|usage| usage.known_cost_count > 0)
.map(|usage| usage.usd)
@@ -175,14 +180,20 @@ impl AccountQuotaForecast {
|| (method == QuotaForecastMethod::Incremental && sample.block_count < 2);
// 漏记和个别缺失只影响精度,仍按已记录数值估算,不按请求数补齐未知消耗。
// 预测是近似展示值;不复用为账单金额,也不把月折算当成自然月或额外余额。
let capacity_factor = 100.0 / sample.sampled_percent;
let factor = capacity_factor * self.target_seconds as f64 / seconds as f64;
let tokens = Some(usage.tokens).filter(|tokens| *tokens > 0);
self.estimated_tokens = tokens.and_then(|value| estimate_tokens(value, factor));
self.estimated_usd = usd.and_then(|value| estimate(value, factor));
// 已发生的用量保持本周期累计,只把近期消耗比例用于尚未使用的额度。
let factor = self.target_seconds as f64 / seconds as f64;
let remaining_factor = (100.0 - percent) / sample.sampled_percent;
self.remaining_tokens = tokens.and_then(|value| estimate_tokens(value, remaining_factor));
self.remaining_usd = usd.and_then(|value| estimate(value, remaining_factor));
let remaining_tokens = Some(sample.usage.tokens)
.filter(|tokens| *tokens > 0)
.and_then(|value| estimate(value as f64, remaining_factor));
self.estimated_tokens = remaining_tokens
.and_then(|remaining| estimate_tokens(usage.tokens as f64 + remaining, factor));
let remaining_usd = Some(&sample.usage)
.filter(|usage| usage.known_cost_count > 0 && usage.usd.is_finite() && usage.usd >= 0.0)
.and_then(|usage| estimate(usage.usd, remaining_factor));
self.estimated_usd = usd
.zip(remaining_usd)
.and_then(|(used, remaining)| estimate(used + remaining, factor));
self.unavailable_reason = if self.estimated_tokens.is_none() && self.estimated_usd.is_none()
{
Some("本周期暂无可用于估算的 Token 或费用数据,请积累用量后重试。")
@@ -197,8 +208,8 @@ fn estimate(value: f64, factor: f64) -> Option<f64> {
(estimate.is_finite() && estimate >= 0.0).then_some(estimate)
}
fn estimate_tokens(value: u64, factor: f64) -> Option<u64> {
estimate(value as f64, factor)
fn estimate_tokens(value: f64, factor: f64) -> Option<u64> {
estimate(value, factor)
.filter(|value| value.round() < u64::MAX as f64)
.map(|value| value.round() as u64)
}
@@ -101,6 +101,8 @@ pub struct QuotaForecastSample {
pub observation_count: usize,
pub pending_request_count: u64,
pub discontinuous: bool,
/// 本周期已记录用量;近期采样仅用于预测剩余量,不能覆盖这份累计值。
pub cycle_usage: QuotaForecastUsage,
pub usage: QuotaForecastUsage,
}
@@ -113,12 +115,13 @@ pub fn select_forecast_sample(
current: QuotaForecastPoint,
mut history: Vec<QuotaForecastPoint>,
pending_request_count: u64,
interrupted: bool,
) -> QuotaForecastSample {
history.retain(|point| {
start_at <= point.observed_at
&& point.observed_at < current.observed_at
&& point.used_percent.is_finite()
&& (0.0..100.0).contains(&point.used_percent)
&& (0.0..=100.0).contains(&point.used_percent)
});
history.sort_by_key(|point| point.observed_at);
let mut sample = QuotaForecastSample {
@@ -131,10 +134,13 @@ pub fn select_forecast_sample(
block_count: 0,
observation_count: history.len(),
pending_request_count,
discontinuous: false,
discontinuous: interrupted,
cycle_usage: current.usage.clone(),
usage: current.usage.clone(),
};
history.push(current);
// 重置边界不在查询起点时,从首个新观测重新计数,避免把旧周期累计带入总量。
let mut cycle_baseline = interrupted.then(|| &history[0]);
let mut anchors: Vec<&QuotaForecastPoint> = Vec::new();
let mut high_water: Option<&QuotaForecastPoint> = None;
for point in &history {
@@ -149,6 +155,7 @@ pub fn select_forecast_sample(
}
sample.discontinuous = true;
anchors.clear();
cycle_baseline = Some(point);
}
}
high_water = Some(point);
@@ -162,6 +169,13 @@ pub fn select_forecast_sample(
let Some(current) = history.last() else {
return sample;
};
if let Some(baseline) = cycle_baseline {
let Some(usage) = current.usage.difference(&baseline.usage) else {
sample.discontinuous = true;
return sample;
};
sample.cycle_usage = usage;
}
if anchors.len() < 2 {
return sample;
}
@@ -0,0 +1,41 @@
//! 历史数据保留规则;存储实现只接受已校验的窗口。
use super::AdminError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RetentionTarget {
ModelRequests,
OpsEvents,
AdminAuditEvents,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RetentionPolicy {
usage_days: u32,
ops_days: u32,
audit_days: u32,
}
impl RetentionPolicy {
pub fn try_new(usage_days: u32, ops_days: u32, audit_days: u32) -> Result<Self, AdminError> {
if usage_days < 31 || ops_days == 0 || audit_days == 0 {
return Err(AdminError::invalid(
"请求保留期至少 31 天,事件与审计保留期必须为正数",
));
}
Ok(Self {
usage_days,
ops_days,
audit_days,
})
}
#[must_use]
pub const fn days(self, target: RetentionTarget) -> u32 {
match target {
RetentionTarget::ModelRequests => self.usage_days,
RetentionTarget::OpsEvents => self.ops_days,
RetentionTarget::AdminAuditEvents => self.audit_days,
}
}
}
@@ -4,52 +4,102 @@ 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)]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuntimeSettings {
pub request_profiles: ProviderRequestProfiles,
pub config_revision: Revision,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub model_mappings: ModelMappings,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u32,
pub max_concurrent_per_account: u32,
pub request_interval_ms: u64,
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u32,
pub ops_event_retention_days: u32,
pub audit_retention_days: u32,
pub account_auto_freeze_enabled: bool,
pub account_auto_freeze_threshold: u32,
pub account_auto_freeze_window_seconds: u64,
pub account_auto_freeze_duration_seconds: u64,
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)]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReplaceRuntimeSettings {
/// 读取设置时的版本;与写入在同一事务内比较,防止覆盖并发更新。
pub expected_revision: Revision,
/// 只覆盖提交的 Provider;未提交项保留当前持久值。
pub request_profile_updates: ProviderRequestProfileUpdates,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub model_mappings: ModelMappings,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u32,
pub max_concurrent_per_account: u32,
pub request_interval_ms: u64,
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
pub usage_retention_days: u32,
pub ops_event_retention_days: u32,
pub audit_retention_days: u32,
pub account_auto_freeze_enabled: bool,
pub account_auto_freeze_threshold: u32,
pub account_auto_freeze_window_seconds: u64,
pub account_auto_freeze_duration_seconds: u64,
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 泄漏。
#[derive(Clone, PartialEq, Eq)]
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(transparent)]
pub struct AdminApiKey(String);
impl AdminApiKey {
@@ -77,13 +127,16 @@ impl fmt::Debug for AdminApiKey {
}
/// 管理员 API Key 更新结果。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AdminApiKeyMutation {
pub config_revision: Revision,
pub exists: bool,
}
/// 新管理员 API Key 的一次性返回结果。
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RegeneratedAdminApiKey {
pub mutation: AdminApiKeyMutation,
pub key: AdminApiKey,
@@ -98,3 +151,56 @@ impl fmt::Debug for RegeneratedAdminApiKey {
.finish()
}
}
impl RuntimeSettings {
pub fn client_profile(
&self,
provider: &str,
) -> Option<&gateway_core::account::OpaqueProviderData> {
ProviderKind::new(provider.to_owned())
.ok()
.and_then(|provider| self.request_profiles.get(&provider))
}
}
impl From<RuntimeSettings> for ReplaceRuntimeSettings {
fn from(settings: RuntimeSettings) -> Self {
Self {
expected_revision: settings.config_revision,
request_profile_updates: settings
.request_profiles
.into_iter()
.map(|(provider, value)| (provider, Some(value)))
.collect(),
request_location_enabled: settings.request_location_enabled,
request_location: settings.request_location,
model_mappings: settings.model_mappings,
refresh_margin_seconds: settings.refresh_margin_seconds,
refresh_concurrency: settings.refresh_concurrency,
max_concurrent_per_account: settings.max_concurrent_per_account,
request_interval_ms: settings.request_interval_ms,
max_waiting_per_key: settings.max_waiting_per_key,
max_waiting_per_account: settings.max_waiting_per_account,
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
smart_scheduling: settings.smart_scheduling,
rotation_strategy: settings.rotation_strategy,
min_codex_desktop_version: settings.min_codex_desktop_version,
min_codex_cli_version: settings.min_codex_cli_version,
usage_retention_days: settings.usage_retention_days,
ops_event_retention_days: settings.ops_event_retention_days,
audit_retention_days: settings.audit_retention_days,
account_auto_freeze_enabled: settings.account_auto_freeze_enabled,
account_auto_freeze_threshold: settings.account_auto_freeze_threshold,
account_auto_freeze_window_seconds: settings.account_auto_freeze_window_seconds,
account_auto_freeze_duration_seconds: settings.account_auto_freeze_duration_seconds,
account_auto_freeze_probe_enabled: settings.account_auto_freeze_probe_enabled,
account_auto_freeze_probe_model: settings.account_auto_freeze_probe_model,
account_auto_freeze_adaptive_concurrency: settings
.account_auto_freeze_adaptive_concurrency,
account_warmup_enabled: settings.account_warmup_enabled,
account_warmup_schedule_time: settings.account_warmup_schedule_time,
account_warmup_model: settings.account_warmup_model,
}
}
}
@@ -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,
@@ -71,6 +106,7 @@ pub struct SystemOperationState {
pub struct SystemUpdateStatus {
pub previous_version: Option<String>,
pub current_version: Option<String>,
pub need_restart: bool,
pub operation: SystemOperationState,
}
@@ -103,7 +139,6 @@ pub enum SystemOperationAccepted {
operation_id: String,
deployment_mode: String,
message: String,
need_restart: bool,
target_version: String,
},
Rollback {
@@ -127,3 +162,21 @@ impl SystemOperationAccepted {
}
}
}
/// 重启前的只读确认快照;目标发行与全局配置版本一起绑定用户确认。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SystemRestartPlan {
pub target_version: Option<String>,
pub release_manifest_sha256: Option<String>,
pub config_revision: u64,
pub incompatible_plugins: Vec<SystemIncompatiblePlugin>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SystemIncompatiblePlugin {
pub instance_id: String,
pub name: String,
pub reason: String,
}
@@ -17,8 +17,8 @@ use crate::model::{
MutationContext,
backup::{
BackupError, BackupObjectMetadata, BackupRecord, BackupRecordListQuery, BackupRecordPage,
BackupRecordSeed, BackupSettings, BackupStatus, BackupStorageConfig, ConnectionTestResult,
UpdateBackupScheduleCommand, UpdateBackupStorageCommand,
BackupRecordSeed, BackupSettings, BackupStatusTransition, BackupStorageConfig,
ConnectionTestResult, UpdateBackupScheduleCommand, UpdateBackupStorageCommand,
},
};
use crate::ports::store::AdminStoreResult;
@@ -96,8 +96,7 @@ pub trait BackupRepository: Send + Sync {
async fn transition_status(
&self,
id: &str,
from: BackupStatus,
to: BackupStatus,
transition: BackupStatusTransition,
update: StatusTransitionUpdate,
now: DateTime<Utc>,
) -> AdminStoreResult<Option<BackupRecord>>;
@@ -333,8 +332,7 @@ impl BackupRepository for UnavailableBackupRepository {
async fn transition_status(
&self,
_id: &str,
_from: BackupStatus,
_to: BackupStatus,
_transition: BackupStatusTransition,
_update: StatusTransitionUpdate,
_now: DateTime<Utc>,
) -> AdminStoreResult<Option<BackupRecord>> {
@@ -2,7 +2,15 @@
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 plugin_resources;
pub mod plugins;
pub mod pricing;
pub mod provider;
pub mod proxy;
pub mod retention;
pub mod store;
pub mod system;
@@ -0,0 +1,50 @@
//! 插件宿主访问账号权威所需的窄端口。
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("插件额度事实查询暂不可用"))
}
/// 通过 Provider 原生管理路径刷新额度;不向插件暴露凭据或执行额度重置。
async fn refresh_quota(
&self,
_account_id: &ProviderAccountId,
) -> Result<crate::model::provider_credentials::ProviderQuota, AdminError> {
Err(AdminError::unavailable("插件额度观测刷新暂不可用"))
}
async fn save(
&self,
command: PreparedPluginAccountSave,
context: &MutationContext,
) -> Result<PluginAccountSaveResult, AdminError>;
}
@@ -0,0 +1,37 @@
//! 插件 Client Key 非秘密目录与受控预算管理端口。
use async_trait::async_trait;
use gateway_core::{engine::budget::ClientBudgetStatus, policy::ClientApiKeyId};
use crate::model::{
AdminError, MutationContext,
client_keys::{ResetClientKeyBudget, UpdateClientKeyBudgetLimits},
plugin_client_keys::{PluginClientKeyFacts, PluginClientKeyListQuery, PluginClientKeyPage},
plugin_resources::PluginResourceOwner,
};
#[async_trait]
pub trait PluginClientKeyAccess: Send + Sync {
async fn facts(&self, id: &ClientApiKeyId) -> Result<PluginClientKeyFacts, AdminError>;
async fn budget(&self, id: &ClientApiKeyId) -> Result<ClientBudgetStatus, AdminError>;
async fn update_budget_limits(
&self,
owner: &PluginResourceOwner,
command: UpdateClientKeyBudgetLimits,
context: &MutationContext,
) -> Result<ClientApiKeyId, AdminError>;
async fn reset_budget(
&self,
owner: &PluginResourceOwner,
command: ResetClientKeyBudget,
context: &MutationContext,
) -> Result<ClientApiKeyId, AdminError>;
async fn list(
&self,
query: PluginClientKeyListQuery,
) -> 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 validate_target(
&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>;
}

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