Compare commits

...
84 Commits
Author SHA1 Message Date
ZyyandCodex d89db84a77 chore(release): v3.35.1
Co-authored-by: Codex <noreply@openai.com>
2026-10-29 13:09:42 +08:00
ZyyandCodex 6504761ce4 fix(responses): keep slow websocket uploads alive
Track inbound byte progress before WebSocket decoding so active uploads do not trip the heartbeat timeout. Reuse Axum handshake validation and report actual receive idle time.

Cover successive slow uploads and stalled partial frames, and document the downstream liveness contract.

Validation: 652 API and gateway tests passed; production API check, strict workspace Clippy, and formatting passed.

Co-authored-by: Codex <noreply@openai.com>
2026-10-29 13:03:06 +08:00
ZyyandCodex 5877b0cb2f fix(openai): tolerate session affinity storage jitter
Raise the session affinity storage budget to 500ms while keeping cyber policy operations on a separate 100ms budget. Preserve fail-closed binding behavior and cover delayed admission and timeout rejection before upstream sending.

Refs #549

Co-authored-by: Codex <noreply@openai.com>
2026-10-29 12:04:09 +08:00
ZyyandCodex 958e13c370 fix(errors): standardize client messages and correct error mappings
Return non-retryable client version errors and correct provider error types.
Keep safe user messages separate from operational diagnostics.
Unify error wording and document the conventions in the development skill.

Co-authored-by: Codex <noreply@openai.com>
2026-10-29 01:57:31 +08:00
zyycnandCodex 80f7d53085 chore(release): v3.35.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-29 00:14:10 +08:00
2f2c1787be fix(responses): align heartbeat and websocket connection lifecycles (#548)
Use passive upstream heartbeats and independent downstream liveness checks.
Reclaim unbound sockets after five idle minutes and release client-owned
resources on disconnect, with safe continuation ownership transfer.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-29 00:08:40 +08:00
ZyyandCodex d679d5e708 fix(i18n): default to Simplified Chinese and simplify login labels
Co-authored-by: Codex <noreply@openai.com>
2026-10-28 21:50:52 +08:00
ZyyandCodex 0f214c3e25 docs: align guides and workflows with current behavior
Co-authored-by: Codex <noreply@openai.com>
2026-10-28 21:50:51 +08:00
736eca6593 feat(accounts): support configurable error protection rules (#547)
* feat(accounts): support configurable error protection rules

Replace capacity-specific freezing with bounded, configurable account error rules and shared attempt feedback.

Drop legacy settings without conversion and expose rule editing and freeze reasons in the admin UI.

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

* fix(accounts): preserve error protection scheduling boundaries

Keep xAI accounts excluded while recovery probes are pending, and cover
selection before and after recovery.

Separate scheduling cooldown reads from admin display details, share the
atomic Redis migration and selection logic, and use one policy revision.
Remove the unused adaptive concurrency reduction path and its tests.

Validation: 393 targeted backend tests, workspace Clippy with -D warnings,
Rustfmt, frontend type checking and build, and local service health checks.

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-28 21:50:51 +08:00
ZyyandCodex be5b6d199e fix(api): enforce version gates for official Codex clients
Recognize official TUI, exec, IDE, SDK and App Server/MCP product names using the Core version in the User-Agent. Keep Desktop app version handling and unknown-client compatibility intact.

Compare SemVer precedence without build metadata, cover HTTP/SSE and real WebSocket admission, and align API documentation and localized settings help.

Validated 54 targeted and architecture tests, strict Clippy, Rust formatting, frontend lint/build, and localized component previews.

Co-authored-by: Codex <noreply@openai.com>
2026-10-28 19:31:20 +08:00
ZyyandCodex 53e1e9cfb8 fix(ui): show loading failures in notifications
Remove inline loading failure placeholders and dedicated retry actions from
settings, accounts, plugins and usage views. Route foreground failures through
existing notifications while preserving disabled controls and available data.
Clean up obsolete error props, state and translations.

Co-authored-by: Codex <noreply@openai.com>
2026-10-28 19:11:55 +08:00
蓝芷怡andCodex e03851e23f fix(dashboard): show notes below API key accounts (#545)
* fix(dashboard): show notes below API key accounts

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

* fix(frontend): complete dashboard theme preview account notes

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

---------

Co-authored-by: Codex <noreply@openai.com>
2026-10-28 18:55:57 +08:00
7cd17aff42 feat(usage): add request concurrency trends (#537)
* feat(usage): add request concurrency trends

* perf(usage)!: unify load insights and optimize concurrency

Aggregate concurrency on a single event timeline and reuse lifecycle
indexes instead of joining every request against every time bucket.

Align load summaries and trend points with the four chart metrics,
combine concurrency and scheduling in one view, and right-align the
statistics card selectors. Update the API contract and regression tests.

BREAKING CHANGE: Usage insights concurrency and scheduling P95 fields
move under performance.load and performance.points[].load. Unused
scheduling P50, capacity, and performance coverage fields are removed.

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-28 17:22:28 +08:00
005466bcb0 feat(api): support Chat Completions through Responses (#542)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-28 15:15:31 +08:00
ZyyandCodex a6031310f1 fix(openai): exclude free accounts from live selection
Centralize account authentication, plan and model eligibility for Images and Live. Reuse the same checks before leases, affinity and retries, while preserving unknown plans and Free text/search access.

Co-authored-by: Codex <noreply@openai.com>
2026-10-28 13:12:01 +08:00
niceSprite 1ac2a4eac3 fix(logging): preserve warnings after disabled target probes (#539)
* fix(logging): preserve warnings after disabled target probes

* docs: keep logging fix focused on runtime behavior
2026-10-28 12:52:25 +08:00
ZyyandCodex 790a643020 fix(frontend): hide general quota heading in account details
Co-authored-by: Codex <noreply@openai.com>
2026-10-28 11:22:30 +08:00
ZyyandCodex 1fa37ac92f fix(openai): observe reasoning effort updates across continuations
Resolve reasoning effort from configuration updates and preserve overrides in response-scoped session metadata without rewriting upstream requests. Cover HTTP and WebSocket continuations, new-chain resets, and baseline changes.

Co-authored-by: Codex <noreply@openai.com>
2026-10-28 11:04:29 +08:00
ZyyandCodex 0ceac908f4 fix(ui): route Codex voice control through configured gateway
Include the realtime WebSocket base URL in generated Codex configuration and document voice setup. Simplify configuration comments.

Track key reveal actions separately so opening client configuration and copying a key show loading only on the initiating button. Preserve duplicate request protection and stale request cancellation.

Co-authored-by: Codex <noreply@openai.com>
2026-10-28 00:19:13 +08:00
ZyyandCodex 86a27bed94 fix(ui): include model catalog URL in Codex config
Enable remote model discovery for custom Codex providers so upstream catalog updates appear in the model picker.

Co-authored-by: Codex <noreply@openai.com>
2026-10-27 18:22:53 +08:00
ZyyandCodex 60f8f1ee80 refactor(plugins)!: remove model catalog v1 support
Keep direct aliases in v2 and update the SDK builder, runtime compatibility declaration, tests and migration guidance.

BREAKING CHANGE: model_catalog plugins must declare version 2, move their alias list to aliases and return models for adapted models.

Co-authored-by: Codex <noreply@openai.com>
2026-10-27 17:40:13 +08:00
ZyyandCodex 7b6a204889 docs(release): restore account routing advisory
Co-authored-by: Codex <noreply@openai.com>
2026-10-27 17:13:54 +08:00
ZyyandCodex 47d30474af chore(release): v3.34.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-27 16:48:35 +08:00
ZyyandCodex f1d5b73255 fix(ui): refine admin filters, quota colors and batch actions
Simplify group filters and keep date and platform controls aligned. Apply quota warning thresholds independently to each window and move account batch actions into a compact menu.

Co-authored-by: Codex <noreply@openai.com>
2026-10-27 16:20:15 +08:00
ZyyandCodex 18c75132c3 fix(openai): add built-in GPT-6.1 Terra pricing
Include published standard, flex, priority, and long-context prices in the built-in catalog and classify Terra as a reasoning model for preview web search billing.

Co-authored-by: Codex <noreply@openai.com>
2026-10-27 16:20:15 +08:00
ZyyandCodex a48139b4c2 fix(store): support literal substring search in admin lists
Match account names and emails, client key names and labels, and observability search fields by literal substrings. Preserve wildcard escaping and existing case rules.

Co-authored-by: Codex <noreply@openai.com>
2026-10-27 16:20:15 +08:00
蓝芷怡andCodex dd529d07ec fix(store): support pg_dump staging files on Windows (#475)
Co-authored-by: Codex <noreply@openai.com>
2026-10-27 15:13:13 +08:00
19fbb5da75 feat(plugin): support account-scoped adapted model catalogs (#533)
* feat(plugin): publish account-scoped adapted model catalogs

* fix(core): preserve candidate account scope during execution

Apply the narrowed candidate scope before provider account selection, including affinity and account rotation. Cover adapted-only models and same-name models with incompatible tool capabilities through the real OpenAI selector and local HTTP fixtures.

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-27 13:37:57 +08:00
ZyyandCodex 9296885cce fix(tests): align middleware fixture with startup compatibility
Co-authored-by: Codex <noreply@openai.com>
2026-10-27 13:17:38 +08:00
Ke WangandCodex af9790ac37 fix(plugin): prove replay safety for managed HTTP 429 rejections (#532)
Seal managed callback observations before Core retry decisions and synchronize RPC resource revocation with stream teardown.

Co-authored-by: Codex <noreply@openai.com>
2026-10-27 13:12:42 +08:00
Ke Wang c58e280739 fix(deps): update zstd safety wrappers (#534)
Preserve Zstd 1.5.7 and compression level 3 while updating the Rust wrappers to resolve security advisories.
2026-10-27 12:53:00 +08:00
ZyyandCodex e33f50ec09 fix(plugins): gate incompatible plugin startup
Keep incompatible plugins out of execution sets during automatic startup.
Require scoped confirmation for manual compatibility overrides and show
the stopped state and upgrade effects in the management UI.

Co-authored-by: Codex <noreply@openai.com>
2026-10-27 12:37:28 +08:00
ZyyandCodex 1db8b0e319 chore(deps): update plugins to v0.8.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-26 19:19:56 +08:00
ZyyandCodex 8c8618aa0f chore(release): v3.33.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-26 18:44:00 +08:00
c43a8872db fix(frontend): highlight selected models missing upstream (#528)
* fix(frontend): highlight unavailable selected models

* fix(frontend): refine model catalog warnings and refresh control

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-26 18:39:08 +08:00
ZyyandCodex 51c5caed0e fix(deploy): disable account routing override in example config
Co-authored-by: Codex <noreply@openai.com>
2026-10-26 18:00:53 +08:00
a7f95c3a78 fix(openai): preserve Retry-After when converting WS errors to SSE (#531)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-26 17:08:57 +08:00
ZyyandCodex 421e37896a fix(tests): avoid RPC admission callback deadlock
Drive the ordinary callback call to completion before consuming its notification so delayed suspension cannot strand an unpolled admission future. Bound next callback startup and resumption waits.

Co-authored-by: Codex <noreply@openai.com>
2026-10-26 17:06:45 +08:00
1d1ccda42a refactor(plugins): consolidate host lifecycle and diagnostics (#529)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-26 17:06:44 +08:00
e38cf31323 refactor(plugins): separate client identity from model execution ports (#527)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-26 13:21:31 +08:00
614c81c375 refactor(plugins)!: separate identity queries and cooperative scheduling (#525)
Resolve identity and frozen access facts without constructing model execution
contexts. Coordinate parent task suspension, resumption and retained callbacks
at the SDK and Runtime boundary, with bounded admission and cancellation.
Expose account HTTP, client verification, access facts and quota details through
the existing host owners.

BREAKING CHANGE: Plugin sessions require RPC v7; rebuild and repackage plugins
with the matching SDK. No legacy protocol negotiation or fallback is provided.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-26 13:05:08 +08:00
f0484c4e11 chore(frontend): upgrade ESLint config to v10 and migrate formatting (#522)
* chore(deps-dev): bump @antfu/eslint-config in /frontend

Bumps [@antfu/eslint-config](https://github.com/antfu/eslint-config) from 9.5.1 to 10.0.0.
- [Release notes](https://github.com/antfu/eslint-config/releases)
- [Commits](https://github.com/antfu/eslint-config/compare/v9.5.1...v10.0.0)

---
updated-dependencies:
- dependency-name: "@antfu/eslint-config"
  dependency-version: 10.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(frontend): migrate formatting to ESLint v10

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-26 11:34:32 +08:00
dependabot[bot] dffc417b9f chore(deps): bump rust from e12566d to 48b7f43 in /deploy (#524)
Bumps rust from `e12566d` to `48b7f43`.

---
updated-dependencies:
- dependency-name: rust
  dependency-version: 1.99.1-bookworm
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 11:04:28 +08:00
dependabot[bot] 2d4294c3c9 chore(deps): bump debian from 7c7b2c9 to 7ff6ba3 in /deploy (#523)
Bumps debian from `7c7b2c9` to `7ff6ba3`.

---
updated-dependencies:
- dependency-name: debian
  dependency-version: 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-10-26 11:04:23 +08:00
dependabot[bot] 3d9906facf chore(deps): bump the minor-and-patch group in /frontend with 5 updates (#520)
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.59.0` | `1.61.0` |
| [es-toolkit](https://github.com/toss/es-toolkit) | `1.52.0` | `1.53.0` |
| [eslint-plugin-vuejs-accessibility](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility) | `2.6.2` | `2.6.3` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.3.4` | `8.3.5` |
| [vue-tsc](https://github.com/vuejs/language-tools/tree/HEAD/packages/tsc) | `3.3.11` | `3.3.13` |


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

Updates `es-toolkit` from 1.52.0 to 1.53.0
- [Release notes](https://github.com/toss/es-toolkit/releases)
- [Changelog](https://github.com/toss/es-toolkit/blob/main/CHANGELOG.md)
- [Commits](https://github.com/toss/es-toolkit/compare/v1.52.0...v1.53.0)

Updates `eslint-plugin-vuejs-accessibility` from 2.6.2 to 2.6.3
- [Release notes](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/releases)
- [Changelog](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/compare/v2.6.2...v2.6.3)

Updates `vite` from 8.3.4 to 8.3.5
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.5/packages/vite)

Updates `vue-tsc` from 3.3.11 to 3.3.13
- [Release notes](https://github.com/vuejs/language-tools/releases)
- [Changelog](https://github.com/vuejs/language-tools/blob/master/CHANGELOG.md)
- [Commits](https://github.com/vuejs/language-tools/commits/v3.3.13/packages/tsc)

---
updated-dependencies:
- dependency-name: "@lucide/vue"
  dependency-version: 1.61.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: es-toolkit
  dependency-version: 1.53.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: eslint-plugin-vuejs-accessibility
  dependency-version: 2.6.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vite
  dependency-version: 8.3.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vue-tsc
  dependency-version: 3.3.13
  dependency-type: direct:development
  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-10-26 11:04:18 +08:00
dependabot[bot] da9c2b92b0 chore(deps): bump jsonschema from 0.58.6 to 0.60.0 in /backend (#519)
Bumps [jsonschema](https://github.com/Stranger6667/jsonschema) from 0.58.6 to 0.60.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.58.6...ruby-v0.60.0)

---
updated-dependencies:
- dependency-name: jsonschema
  dependency-version: 0.60.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-10-26 11:02:43 +08:00
dependabot[bot] de5c10c4e4 chore(deps): bump scraper from 0.27.0 to 0.29.0 in /backend (#518)
Bumps [scraper](https://github.com/rust-scraper/scraper) from 0.27.0 to 0.29.0.
- [Release notes](https://github.com/rust-scraper/scraper/releases)
- [Commits](https://github.com/rust-scraper/scraper/compare/v0.27.0...v0.29.0)

---
updated-dependencies:
- dependency-name: scraper
  dependency-version: 0.29.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-10-26 11:02:33 +08:00
dependabot[bot] 939cb99985 chore(deps): bump the minor-and-patch group in /backend with 7 updates (#517)
Bumps the minor-and-patch group in /backend with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [zip](https://github.com/zip-rs/zip2) | `9.0.4` | `9.0.5` |
| [async-compression](https://github.com/Nullus157/async-compression) | `0.4.50` | `0.4.51` |
| [aws-credential-types](https://github.com/smithy-lang/smithy-rs) | `1.3.0` | `1.3.1` |
| [aws-sdk-s3](https://github.com/awslabs/aws-sdk-rust) | `1.152.0` | `1.155.0` |
| [aws-smithy-runtime-api](https://github.com/smithy-lang/smithy-rs) | `1.19.0` | `1.19.1` |
| [aws-smithy-types](https://github.com/smithy-lang/smithy-rs) | `1.8.1` | `1.9.0` |
| [crc32fast](https://github.com/srijs/rust-crc32fast) | `1.5.2` | `1.5.3` |


Updates `zip` from 9.0.4 to 9.0.5
- [Release notes](https://github.com/zip-rs/zip2/releases)
- [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zip-rs/zip2/compare/v9.0.4...v9.0.5)

Updates `async-compression` from 0.4.50 to 0.4.51
- [Release notes](https://github.com/Nullus157/async-compression/releases)
- [Commits](https://github.com/Nullus157/async-compression/compare/async-compression-v0.4.50...async-compression-v0.4.51)

Updates `aws-credential-types` from 1.3.0 to 1.3.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)

Updates `aws-sdk-s3` from 1.152.0 to 1.155.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.19.0 to 1.19.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)

Updates `aws-smithy-types` from 1.8.1 to 1.9.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 `crc32fast` from 1.5.2 to 1.5.3
- [Commits](https://github.com/srijs/rust-crc32fast/compare/v1.5.2...v1.5.3)

---
updated-dependencies:
- dependency-name: zip
  dependency-version: 9.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: async-compression
  dependency-version: 0.4.51
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-credential-types
  dependency-version: 1.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-sdk-s3
  dependency-version: 1.155.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.19.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-smithy-types
  dependency-version: 1.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: crc32fast
  dependency-version: 1.5.3
  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-10-26 11:02:21 +08:00
dependabot[bot] 2bb8ebc01c chore(deps): bump the minor-and-patch group with 3 updates (#516)
Bumps the minor-and-patch group with 3 updates: [Swatinem/rust-cache](https://github.com/swatinem/rust-cache), [taiki-e/install-action](https://github.com/taiki-e/install-action) and [docker/login-action](https://github.com/docker/login-action).


Updates `Swatinem/rust-cache` from 2.9.2 to 2.9.3
- [Release notes](https://github.com/swatinem/rust-cache/releases)
- [Changelog](https://github.com/Swatinem/rust-cache/blob/master/CHANGELOG.md)
- [Commits](https://github.com/swatinem/rust-cache/compare/6323deb102c322ba6fcbdcafc7e3dddab59af2b6...d1527c8f67ef9af357290475d4af4b0fab8f12e0)

Updates `taiki-e/install-action` from 2.87.29 to 2.87.31
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/8cca0918f8471c33515202b73f56ab27f2bed283...1e29709c0ec63babbe8f266397a465f29a825f91)

Updates `docker/login-action` from 4.6.0 to 4.7.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/dbcb813823bdd20940b903addbd779551569679f...81c6f93d4bc991e510b342a00b419b3bb4efc5c6)

---
updated-dependencies:
- dependency-name: Swatinem/rust-cache
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: taiki-e/install-action
  dependency-version: 2.87.31
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: docker/login-action
  dependency-version: 4.7.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-10-26 11:01:07 +08:00
ZyyandCodex 6a5300a7b5 chore(release): v3.32.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 23:34:39 +08:00
2c5b8b5c1a feat(openai): configure residency and account routing headers (#515)
Accept optional deployment strings for residency and account routing,
discard downstream values when unset, and isolate WebSocket connections
by the configured handshake headers.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 23:19:11 +08:00
0a87ac7f76 fix(plugins)!: honor declared stage call budgets (#513)
Resolve scenario defaults and plugin declarations consistently across the SDK
and runtime. Preserve maintenance across unrelated publications and cancel
long preparation when the runtime shuts down.

BREAKING CHANGE: Plugin RPC requires protocol v6. Rebuild plugins with the
updated SDK; previous protocol versions are not supported.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 22:39:23 +08:00
ZyyandCodex 3ac9d5f6f8 ci: keep repository checks independent
Remove automatic cross-repository source integration from host CI while
preserving host checks against locked dependencies. Keep source integration
available for explicit local version combinations and align the documentation.

Co-authored-by: Codex <noreply@openai.com>
2026-10-25 22:34:48 +08:00
ZyyandCodex e7e5b659cd chore(release): v3.31.3
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 17:31:19 +08:00
ZyyandCodex b225b3732d fix(accounts): unify Codex labels and tighten plan badge spacing
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 17:28:06 +08:00
ZyyandCodex 9dca763ea4 fix(ui): polish plugin management and API key labels
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 16:57:42 +08:00
ZyyandCodex d26bc36db5 chore(release): v3.31.2
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 15:59:42 +08:00
ZyyandCodex 96d5ab674a feat(ui): refine forms, navigation and theme loading
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 15:59:34 +08:00
ZyyandCodex 84c0db2613 fix(ui): simplify token label and center reasoning effort
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 04:58:06 +08:00
ZyyandCodex d819315d8b fix(ci): allow independent UI dependency versions
Keep frozen installs, source compatibility builds, and lockfile checks
without requiring every consumer to pin the UI submodule revision.

Co-authored-by: Codex <noreply@openai.com>
2026-10-25 04:48:28 +08:00
ZyyandCodex 06e1d8f0ac chore(release): v3.31.1
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 04:40:32 +08:00
ZyyandCodex bdc8274b36 chore(ui): upgrade component library to v0.6.1
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 04:38:49 +08:00
ZyyandCodex 3f6607209b refactor(usage): remove reported model tracking and polish UI
Remove upstream response model tracking across providers, storage, APIs,
and plugin facts without migrating historical records.

Refine sidebar hints, locale loading indicators, model columns, and usage
page labels and filters.

Co-authored-by: Codex <noreply@openai.com>
2026-10-25 04:03:27 +08:00
ZyyandCodex 3c05bc8f07 refactor(plugins): remove provider-specific request parameters
Use request, attempt, and outbound middleware for plugin-owned identity
selection and complete request rewriting. Preserve native account identity
and partition WebSocket connections through existing outbound opening changes.

Remove the construction parameter channel and its obsolete tests and docs.
Keep middleware at v6 without a compatibility shim.

Co-authored-by: Codex <noreply@openai.com>
2026-10-25 03:30:52 +08:00
ZyyandCodex 9ccd84f1a0 test(store): fix pg_dump lifecycle probe on Ubuntu 26.04
Redirect the mock process stdout to /dev/null explicitly so the wait-stage probe works when sleep reopens closed standard descriptors. Preserve cancellation, process reaping, and partial archive cleanup assertions.

Co-authored-by: Codex <noreply@openai.com>
2026-10-25 01:35:59 +08:00
ZyyandCodex a8f183707e chore(build): refresh base images and pin Ubuntu runners
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 01:02:46 +08:00
ZyyandCodex 3f20af61ff chore(plugins): update workbench to v0.7.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 00:23:55 +08:00
ZyyandCodex efaed4c3a6 fix(web): refine health loading and quota labels
Hide request health granularity until analytics are loaded and remove the placeholder daily granularity. Use compact uppercase quota badges and localized five-hour labels.

Co-authored-by: Codex <noreply@openai.com>
2026-10-25 00:08:42 +08:00
ZyyandCodex a36137bafb chore(release): v3.31.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-24 23:22:06 +08:00
2e7a39e1e8 feat(plugins): add provider request composition middleware (#511)
* feat(plugins): add provider request composition middleware

Expose per-attempt provider parameters and a final outbound preparation chain using middleware v6. Preserve native transport behavior when no outbound scope matches.

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

* fix(plugins): sync workbench with middleware v6

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-24 22:58:06 +08:00
ZyyandCodex 6648198df4 perf(responses): reduce WebSocket copies and trace completion latency
Move owned request bodies back into WebSocket requests and forward validated
UTF-8 frames without copying their payloads. Record quota, account feedback,
and cyber policy durations while preserving terminal delivery ordering.

Co-authored-by: Codex <noreply@openai.com>
2026-10-24 21:05:22 +08:00
ZyyandCodex 2eed9b20ed fix(web): persist table sorting preferences
Co-authored-by: Codex <noreply@openai.com>
2026-10-24 20:54:37 +08:00
16aa08091c feat(plugins): select upstream adapters by account (#509)
* feat(plugins): select upstream adapters within account scopes

* fix(plugins): defer adapter decisions until account selection

Keep scoped adapters pending until an account is known so unavailable
fallbacks cannot mask a healthy earlier match. Filter native xAI accounts
when only an adapter can accept the request, preserving normal selection
and cold-stream behavior.

Cover fallback precedence and mixed-pool prewarm eligibility regressions.

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-24 20:20:47 +08:00
ZyyandCodex abae4a631b fix(skills): enforce required domain guidance
Co-authored-by: Codex <noreply@openai.com>
2026-10-24 19:53:37 +08:00
8983eb4ce9 fix(openai): preserve session affinity for managed upstream adapters (#508)
* fix(openai): preserve session affinity for managed upstream adapters

* test(openai): avoid coupling wire invariance to adapter lookup count

* fix(openai): preserve plugin control over adapter request identity

Keep affinity and turn aliases tied to the admitted request before attempt middleware. Pass plugin rewrites to the adapter without native identity revalidation.

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-24 19:51:48 +08:00
ZyyandCodex cd605b850e chore(deps): pin UI dependency to v0.6.0 release tag
Reference the v0.6.0 release tag and synchronize the pnpm lockfile.
The verified remote tag resolves to the existing UI commit.

Co-authored-by: Codex <noreply@openai.com>
2026-10-24 19:02:45 +08:00
ZyyandCodex 7beebb2340 fix(deps): patch crypto-bigint and tokio security advisories
Update crypto-bigint to 0.7.6 for RUSTSEC-2026-0387 and Tokio to
1.53.3 for RUSTSEC-2026-0386. Preserve the exact Tokio constraint
and verify existing transport fingerprint and cancellation contracts.

Co-authored-by: Codex <noreply@openai.com>
2026-10-24 19:02:41 +08:00
ZyyandCodex 31de0bd83c perf(store): move client budget settlement off request path
Track pending charges in memory and settle them with a supervised worker.
Include pending costs in budget checks without retrying historical writes
at admission, and reject new admissions when the backlog reaches its limit.

Use idempotent per-key batches with write deadlines, adaptive batch sizes,
and bounded shutdown draining. Keep load tests outside the regular suite.

Co-authored-by: Codex <noreply@openai.com>
2026-10-24 18:45:12 +08:00
ZyyandCodex 5cfc6dee10 fix(openai): abort stalled websocket pumps during error cleanup
Stop the send pump before returning terminal errors or discarding an
exchange. Preserve socket cleanup ordering and cover stalled data and
control writes.

Co-authored-by: Codex <noreply@openai.com>
2026-10-24 18:45:04 +08:00
ZyyandCodex ef5b4d7dbd fix(openai): reuse reasoning content registrations
Honor reasoning summary indexes and reuse registered content parts while
preserving upstream wire events and type conflict diagnostics.

Co-authored-by: Codex <noreply@openai.com>
2026-10-24 18:45:00 +08:00
ZyyandCodex 31974bc289 fix(diagnostics): align trace snapshot persistence limits
Co-authored-by: Codex <noreply@openai.com>
2026-10-24 14:19:22 +08:00
ZyyandCodex e033e21994 fix(frontend): remove default concurrency input icon
Co-authored-by: Codex <noreply@openai.com>
2026-10-24 00:05:43 +08:00
ZyyandCodex 2818edb9fc perf(i18n): load language resources on demand
Load and cache the selected language with synchronized UI messages. Keep the latest selection when loads overlap and render missing messages as empty text without language fallback.

Co-authored-by: Codex <noreply@openai.com>
2026-10-24 00:05:38 +08:00
ZyyandCodex 1d1f34ba7e fix(frontend): refine admin copy and responsive layouts
Shorten contextual actions and loading messages while preserving explanatory text. Improve quota metadata, profile metrics, theme controls and usage layouts, and add collapsed navigation hints.

Use spaced lowercase time units and remove chart tooltip dividers.

Co-authored-by: Codex <noreply@openai.com>
2026-10-23 23:10:15 +08:00
866 changed files with 39460 additions and 12839 deletions
+37 -12
View File
@@ -1,28 +1,33 @@
---
name: cpr-dev-guide
description: Codex Proxy RS 前后端开发、排障、代码审查和文档修改入口;改页面、弹窗、样式、文案,修接口、调度、数据库、Rust、宿主 SDK 或 Runtime 时均使用;PR、Issue、独立网关插件与发版另配对应技能
description: Codex Proxy RS 开发、修复、排障、代码审查和文档修改入口;页面、样式、文案、接口、调度、数据库及宿主 SDK、Runtime、插件接入均适用;PR、Issue、独立网关插件与发版叠加对应技能
---
# 开发指南
## 先匹配任务与文件
## 先确定领域与必读规范
按用户要改的行为初判,定位后按实际修改或审查的文件复核,不要求用户说出“前端”“后端”或技能名。**命中多行就同时执行,先读取对应规范再修改或给出审查结论**;任务范围变化时补齐新命中项,已有上下文直接复用
按用户意图、受影响行为与对象初判,定位后按实际文件复核,不要求用户说出“前端”“后端”或技能名。**所有命中行共同构成本次必读规范,先读完再确定方案、修改或给出审查结论**;跨领域叠加,不能只选最像当前步骤的一行
| 命中条件,操作或文件满足其一即可 | 必须执行 |
职责边界决定问题由谁修复;即使代码位于账号、调度或 API 模块,只要影响插件接入或为插件增加处理,也命中插件边界。确定方案时说明相关业务事实的归属及已有实现的复用依据,不能以功能恢复或测试通过替代职责判断
| 领域或操作,行为影响与文件路径满足其一即可 | 确定方案前读取与执行 |
| --- | --- |
| 调整页面、弹窗、表单、按钮、文案、样式或交互;目标在 `frontend/`、`modules/ui/`、插件前端,或是 `.vue` 文件 | [前端流程](references/frontend.md),覆盖组件复用、文案、Token、样式、视觉状态和运行证据;局部修改同样执行 |
| 修改服务端接口、路由、调度、账号与用量规则、持久化、后台任务或协议行为;目标是 `backend/` 中的实现、配置、迁移,或 Rust 源码 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)、所属模块、[后端自审](../../../docs/architecture.md#后端自审);涉及 Rust 时使用 `$rust-best-practices` 和下方后端注释要求,验证时读[命令](../../../docs/architecture.md#验证命令) |
| 开发、排查或审查插件接入、宿主 SDK、Runtime、扩展接口或独立网关插件;目标在 `backend/crates/gateway-plugin/`、`modules/plugins/` | 完整读取[插件职责边界](references/plugin-boundaries.md)及所用 [SDK](../../../backend/crates/gateway-plugin/sdk/README.md) 类型与能力合同;宿主维护自身合同、资源与稳定性,插件业务错误在插件侧修复,不在宿主、SDK 或 Runtime 中补偿;独立插件开发、排查、审查另用 [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md) |
| 开发、排查或审查服务端接口、路由、调度、账号与用量规则、持久化、后台任务或协议行为;目标是 `backend/` 中的实现、配置、迁移,或 Rust 源码 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)的模块与依赖规则、所属模块及[后端自审](../../../docs/architecture.md#后端自审);Rust 使用 `$rust-best-practices` 和下方后端注释要求,宿主 Rust 同时读取 [Rust 模块组织约定](../../../docs/architecture.md#32-rust-模块组织约定) |
| 修改页面接口调用,或开发、排查、审查面向管理端或模型客户端的 HTTP/WS API 合同 | [API](../../../docs/api.md)对应章节;优先复用已有接口,核对调用方 → 服务端解析 → 实际查询或执行,不能只凭前端类型认定参数生效;按实际改动叠加前后端规范 |
| 新增、修改或审查客户端可见错误、失败提示、后台任务状态文案 | 下方[错误文案](#错误文案),并按实际出口核对[错误与诊断三层边界](../../../docs/architecture.md#错误与诊断三层边界) |
| 模块或调用链变更 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)及所属模块章节,不通读整份架构 |
| 新增、调整、清理或审查测试 | 下方[测试维护](#测试维护),按当前行为、职责归属与已有覆盖判断保留、合并或删除 |
| 修改部署行为、`deploy/` 配置或数据库迁移 | 按范围读取[部署](../../../deploy/README.md)、[迁移](../../../backend/migrations/README.md)对应章节 |
| 开发、排查或审查宿主 SDK、Runtime、扩展接口或独立插件;目标在 `backend/crates/gateway-plugin/` | [插件职责边界](references/plugin-boundaries.md)及所用 [SDK](../../../backend/crates/gateway-plugin/sdk/README.md) 类型与能力合同;宿主守住自身合同、资源与稳定性,不猜测或补偿插件业务错误;仅独立插件创作、排查另用 [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md) |
| 修改或审查文档 | [文档检查](references/documentation.md)及目标章节,不默认读取代码开发、页面验收或发布流程 |
| 普通代码审查 | [审查标准](../../../CONTRIBUTING.md#审查标准);审查默认只读 |
| 修改或审查文档;实现或审查用法、公开合同、职责、约束或运维操作的变化 | [文档检查](references/documentation.md)及相关章节,尚无文档差异时也检查是否漏更;纯文档任务不默认读取代码开发、页面验收或发布流程 |
| 代码审查,包括 PR 审查 | [审查标准](../../../CONTRIBUTING.md#审查标准),叠加被审查行为所属领域的规范;审查默认只读 |
| PR / Issue / 发版 | 按当前意图选 [PR](../cpr-github-pr/SKILL.md)、[Issue](../cpr-github-issue/SKILL.md)或[发版](../cpr-release/SKILL.md),普通提交不创建 PR |
纯文档任务按文档分支执行,不因文档所在目录或提及的路径、语言、功能加载开发流程。每次补读只解决当前未知项;文档链接带 `#章节` 不代表工具自动截取,先用 `rg -n '^#{1,4} ' 文件` 找边界,只读命中章节,不预读整份 CONTRIBUTING、架构、API、主题或全部 references,全文审查除外
纯文档任务按文档分支执行;核实文中的合同与职责时读对应领域条款,不因提及代码路径或语言加载实现、构建与页面验收流程
命中的流程引用完整读取,并落实其中指定的必读章节;长文档先用 `rg -n '^#{1,4} ' 文件` 定位,读取指定章节的完整内容。`#章节` 不会自动截取,“按需”仅用于未命中的领域和后续操作资料,不能省略必读规范;已有内容只在当前上下文可用且仍适用时复用,不循环重读技能
## 参考仓库的优先级
@@ -31,9 +36,9 @@ description: Codex Proxy RS 前后端开发、排障、代码审查和文档修
## 执行与收尾
1. 遵守已提供的 [AGENTS.md](../../../AGENTS.md),按其 CodeGraph 约定定位代码;确认工作区、任务范围与已有改动,找到事实归属、同类实现和调用链,补齐上表命中规范及[项目约定](../../../CONTRIBUTING.md#项目约定)对应条目后再修改
2. 自审完整差异,处理本次引入的职责越界、重复规则、冗余状态与无依据分支;删除或替换功能时一并清理失效的配置、类型、调用、测试与文档,已部署数据和公开合同仍遵守所属迁移及兼容规则;仅审查时报告,不自行修复
3. 常规文档只写当前状态;有文档差异时执行[文档检查](references/documentation.md),没有时只确认现有说明是否失真,普通开发不自动修改 `release/notes.md`
4. 按变更范围执行[验证](../../../CONTRIBUTING.md#验证)及其停止条件,沿用本轮有效证据与用户已确定的验收分工,区分通过、失败、跳过与未执行;提交时补读[提交约定](../../../CONTRIBUTING.md#项目约定)
2. 以最终差异重新匹配上表,补齐新增领域的规范并检查实际实现,再处理本次引入的职责越界、重复规则、冗余状态与无依据分支;删除或替换功能时一并清理失效的配置、类型、调用、测试与文档,已部署数据和公开合同仍遵守所属迁移及兼容规则;仅审查时报告,不自行修复
3. 按最终行为执行[文档检查](references/documentation.md),核对所属正文及相关概述、示例和引用,在本任务内完成必要同步;不以尚无文档差异跳过检查。内部重构等未改变文档所述事实时无需改文档;常规文档只写当前状态,普通开发不自动修改 `release/notes.md`
4. 按变更范围执行[验证](../../../CONTRIBUTING.md#验证)及其停止条件,后端命令见[验证命令](../../../docs/architecture.md#验证命令);沿用本轮有效证据与用户已确定的验收分工,区分通过、失败、跳过与未执行;提交时补读[提交约定](../../../CONTRIBUTING.md#项目约定)
5. 交付实际结果与验证缺口;前端按[前端流程](references/frontend.md)核对最终差异和运行证据,后端按上表检查生命周期,不把构建通过当作行为验收
## 测试维护
@@ -51,3 +56,23 @@ description: Codex Proxy RS 前后端开发、排障、代码审查和文档修
- 每个 Rust 文件顶层使用简短的中文 `//!` 注释说明文件职责,覆盖生产源码、测试与构建脚本;测试文件说明测试范围或辅助用途,生成文件同步维护生成器
- 注释不使用中文或英文句号,多句说明按语义分行;保留 URL、版本号、标识符和代码示例中有语义的点号
- 文件说明聚焦当前功能与职责边界,局部注释解释原因和约束,避免复述代码或记录修改经过
## 错误文案
适用于项目自定义的客户端提示,包括 HTTP、WS、SSE 和任务失败状态;原始上游错误与内部诊断遵守[三层边界](../../../docs/architecture.md#错误与诊断三层边界),不为统一外观翻译、截短或改写
- 管理端自定义提示统一中文,模型 API 自定义提示统一英文;英文句首大写、句末使用句号,API、Codex 等缩写及专有名词保持正确大小写,中文短提示不加句末句号
- 默认一句说明具体问题,确有必要才补第二句处理办法,不设机械字数限制;明确字段、对象或限制,避免只说「请求不合法」或堆叠内部术语
- 处理办法必须明确且适用;不把「请稍后重试」「联系管理员」作为统一后缀,不罗列尚未确认的网络、权限、磁盘等可能原因,不对执行结果未知的操作笼统建议重试
- 机器码、状态与结构化字段负责程序判断,文案供人阅读;只改措辞时保留分类、重试和账号处置语义,先核对现有调用方是否依赖消息文本
- 在拥有错误事实的模块提供安全提示,保留独立的内部原因链;不在响应出口批量首字母大写、翻译或裁剪任意消息,以免改写上游原文
| 场景 | 文案示例 |
| --- | --- |
| 日费用额度耗尽 | `Daily spending limit reached.` |
| 请求过大 | `Request too large. Reduce the input size.` |
| 客户端过旧 | `Codex CLI is outdated. Upgrade to 0.152.0 or later.` |
| 名称为空 | `请输入 API Key 名称` |
| 更新包校验失败 | `更新包校验失败,请重新下载` |
审查时沿实际出口检查语言、大小写、标点、长度和处理建议;复用现有行为测试核对错误码及敏感信息边界,不为逐条文案建立重复的快照或字符串清单测试
@@ -1,6 +1,6 @@
interface:
display_name: "cpr-dev-guide"
short_description: "前后端开发、排障与审查入口,按任务和文件匹配规范并验证"
default_prompt: "使用 $cpr-dev-guide 按请求和实际文件匹配前后端规范,读取命中项后完成已授权的开发或审查,并自审、验证和报告结果。"
short_description: "开发、排障与审查入口,按行为影响匹配领域边界并验证实现"
default_prompt: "使用 $cpr-dev-guide 按请求、行为影响和实际文件匹配领域必读规范,读完职责边界再确定方案,完成已授权的开发或审查、自审与验证。"
policy:
allow_implicit_invocation: true
@@ -1,14 +1,22 @@
# 文档检查
修改或审查文档时读取,职责与行文统一按 [文档职责与更新条件](../../../../CONTRIBUTING.md#文档职责与更新条件),只读该节
修改或审查文档,以及实现或审查涉及文档更新条件的行为变化时,完整读取本流程和 [文档职责与更新条件](../../../../CONTRIBUTING.md#文档职责与更新条件),覆盖内容归属与层次行文
## 核实与同步范围
- 以当前目标分支及任务内工作区的实现、配置和有效运行证据核实行为;提交记录用于发现待核实的主题,不能直接当作现行事实或按提交顺序组织正文。按 [CodeGraph 约定](../../../../AGENTS.md#codegraph)同步索引后定位,证据不足时报告缺口,不把计划或推测写成已支持能力
- 开发任务按最终行为检查哪些用法、合同、职责、约束或运维操作需要同步,即使尚无 Markdown 差异也要检查;独立文档更新按用户范围核对现行说明,不等到发版时再补普通开发所需的文档
- 沿同一事实查找所属正文及相关概述、示例和引用,重点核对“支持/不支持”等能力声明、默认值、配置模板和命令。概述保留必要摘要并链接详细合同;不能只更新详细章节,留下 README 或示例中的矛盾说明,也不扩展为无关全仓改写
## 写之前
先确定读者、必须说明的当前事实与所属章节,再决定是否需要修改及详略;文档职责与更新条件按上节核对
先确定读者、必须说明的当前事实与所属章节,再决定是否需要修改及详略;文档职责与更新条件按上节核对。涉及合同或职责说明时,补读所属架构、API 或 SDK 的对应条款,核实事实;不因文档提及实现而执行无关的开发与构建流程
在所属章节就地替换失效说明,先组织读者现在需要的操作、结果和限制,不从变更摘要改写正文。仅介绍被替代实现、修复动机或修改经过的段落直接删除;删掉“新增”“此前”“不再”等词后仍在回答变更经过的内容,也不保留
## 逐段验收
检查工作区和暂存区中本任务的最终文档差异,按内容用途处理,不只搜索关键词:
检查工作区和暂存区中本任务的最终文档差异,并连同受影响章节及相关概述、示例通读,按内容用途处理,不只搜索关键词:
| 段落回答的问题 | 处理 |
| --- | --- |
@@ -26,6 +34,6 @@
- 完成上述逐段验收及[行文检查](../../../../CONTRIBUTING.md#层次与行文),发现问题先处理,未完成内容审查不能称文档已检查
- 检查相对链接、章节锚点、表格与命令示例,`git diff --check` 或构建通过不能代替内容审查
- 报告实际覆盖和缺口即可,不把检查记录写进常规文档
- 交付时简述已同步的文档范围;涉及更新条件却未改文档时,说明现有内容为何仍准确或还有什么缺口。仅报告实际核对范围,不把链接检查通过称为事实核实,也不把检查记录写进常规文档
只有明确的发布说明任务才进入 `cpr-release`;发布说明与常规文档的边界按[内容归属](../../../../CONTRIBUTING.md#内容归属)执行
@@ -2,9 +2,11 @@
适用于页面、组件、弹窗、表单及局部文案和样式修改,不以是否创建 PR 为触发条件;仅审查时核对和报告,不自行修改
## 开始前
## 领域必读
读取[前端职责](../../../../docs/architecture.md#34-前端模块职责)、[文案与信息层级](../../../../docs/theme.md#界面文案与信息层级)和[界面验证](../../../../CONTRIBUTING.md#界面验证),明确本次用户操作、原页面、最接近的同类实现及可复用基础组件;按本轮验收分工留存所需截图
确定页面方案或审查结论前,读取[前端职责](../../../../docs/architecture.md#34-前端模块职责)、[文案与信息层级](../../../../docs/theme.md#界面文案与信息层级)和[界面验证](../../../../CONTRIBUTING.md#界面验证)的完整章节;只改文案或局部样式也执行。明确本次用户操作、原页面、最接近的同类实现及可复用基础组件,沿用既有设计与请求层,按本轮验收分工留存所需截图
## 按改动补齐规范
用户要求看效果或自行验收时,按[源码联调](../../../../docs/development.md#源码联调)提供当前工作区的可访问预览与页面入口,确认热更新和依赖来源;需要模拟数据时用于实际页面并标明,不把静态稿、旧构建或仅有本机文件路径当作可用预览
@@ -12,7 +14,7 @@
| 改动 | 规范入口 |
| --- | --- |
| Vue 逻辑、请求或状态 | [项目约定](../../../../CONTRIBUTING.md#项目约定)的前端条目及上述前端职责 |
| Vue 组件、逻辑、请求或状态 | 使用 `$vue-best-practices`,读取[项目约定](../../../../CONTRIBUTING.md#项目约定)的前端条目;请求与接口合同同时执行开发入口的 API 分支 |
| 颜色、间距、字体、边框、阴影、局部 CSS 或交互样式 | [通用颜色消费](../../../../docs/theme.md#通用颜色消费)、[样式与命名约定](../../../../docs/theme.md#样式与命名约定),核对无边常态及 Hover、Focus 等视觉状态 |
| 主题算法、编辑器或共用组件视觉行为 | 所改主题章节及[扩大验证范围](../../../../docs/theme.md#验证) |
| 明确指定设计技能,或建立、重塑视觉方向 | 使用指定技能或 `$frontend-design`,设计取舍仍以项目主题、用户任务和已有页面为依据 |
@@ -1,7 +1,7 @@
# 插件职责边界
仅在开发或审查 SDK、Runtime、宿主扩展接口及独立网关插件时读取
先看[插件扩展](../../../../docs/architecture.md#31-插件扩展)中的职责与执行边界,不通读整份架构
开发、排查或审查插件接入、SDK、Runtime、宿主扩展接口及独立网关插件时完整读取,不以文件位于插件目录或正在选择能力为条件
确定方案前读取[职责与源码入口](../../../../docs/architecture.md#职责与源码入口)、[发布与执行边界](../../../../docs/architecture.md#发布与执行边界)及[信任与资源边界](../../../../docs/architecture.md#信任与资源边界),明确问题归属、必要宿主保护与插件责任
**宿主提供可组合能力,守住自身合同、资源与稳定性;插件业务正确性和兼容修复由插件负责**
+5 -3
View File
@@ -13,13 +13,15 @@ description: 草拟、创建、补充或回复 GitHub Issue,包括缺陷、功
补充已有 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 时,读取本仓库的[提交 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),提交症状反馈不以找到根因为前提
- 需要技术排查、判断实现方案或修改时执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)的领域必读,不能仅凭症状建议跨职责补偿;提交症状反馈不以找到根因为前提
- 引用上游行为或提出方案时执行[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据),注明适用版本、来源与缺口
## 正文与证据
+2 -2
View File
@@ -19,7 +19,7 @@ description: 准备、创建、更新或审查 GitHub PR,含回复讨论与处
## 按模式读取
只加载当前模式,已有上下文不重读;长文档先定位标题再截取对应章节,不因链接存在而加载全文或所有 references
先完成上述范围判断,再按当前动作与差异涉及的领域叠加下表。命中的流程引用完整读取,长文档读取指定章节;只复用已实际读取且仍适用的内容,单纯整理正文或回复不扩展为完整实现审查
| 当前动作 | 读取与执行 |
| --- | --- |
@@ -27,7 +27,7 @@ description: 准备、创建、更新或审查 GitHub PR,含回复讨论与处
| 创建或更新 PR | [创建流程](references/create.md)、[PR 流程](../../../CONTRIBUTING.md#pr-流程)与[规则文件保护](../../../CONTRIBUTING.md#规则文件保护) |
| 审查 PR | [审查流程](references/review.md)、[审查标准](../../../CONTRIBUTING.md#审查标准) |
| 回复讨论或审查意见 | 目标评论、相关上下文与实际处理结果,按下方回复口吻答复;仅回复不加载创建或完整审查流程 |
| 需要修改代码或文档 | 使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)的对应分支,完成自审与验证 |
| 实现自审、审查或修改代码与文档 | 执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)的领域必读与对应检查,包括后端、插件、测试和 API 等命中项;只读审查同样执行,不能仅核对 PR 正文与 CI |
| 含前端改动 | [前端流程](../cpr-dev-guide/references/frontend.md),核对适用规范与当前实现的运行证据 |
| 含文档改动 | [文档检查](../cpr-dev-guide/references/documentation.md) |
@@ -1,6 +1,6 @@
interface:
display_name: "cpr-github-pr"
short_description: "提交、更新与审查 PR,核对问题、自审、页面截图和 AI 模型披露"
default_prompt: "使用 $cpr-github-pr 按当前请求准备、提交、更新或审查 PR,核实问题与方案,检查自审、验证、页面截图和 AI 模型披露。"
default_prompt: "使用 $cpr-github-pr 按当前请求准备、提交、更新或审查 PR,实现自审与审查叠加开发指南的领域规范,核实问题、方案、验证、页面截图和 AI 模型披露。"
policy:
allow_implicit_invocation: true
@@ -4,6 +4,7 @@
1. 获取 PR 的 base/head SHA、描述、文件差异、已有讨论及 CI 状态。记录实际审查的 head,使用 merge-base 差异。
需要源码上下文时先遵守目标仓库的定位规则;需检出时使用隔离工作区,保留用户当前分支和运行环境。
按实际行为与文件执行 [cpr-dev-guide](../../cpr-dev-guide/SKILL.md)的领域必读,再判断方案与实现;只读审查也核对职责边界与生命周期。
2. 按入口公共核对在目标分支基线核实问题,区分原有问题、环境或配置差异与 PR 新引入的问题,说明主分支已覆盖的部分。
3. 按[问题与方案依据](../../../../CONTRIBUTING.md#问题与方案依据)评估解决方向,涉及上游时说明官方处理方式及本次方案关系;缺少必要性依据的偏离明确指出,不因测试通过就认定方案合理。
4. 按[审查标准](../../../../CONTRIBUTING.md#审查标准)追踪完整调用链,找到共享业务事实、同类实现及已有验证。
+11 -7
View File
@@ -1,13 +1,17 @@
---
name: cpr-plugin-dev
description: 开发、排查或打包 Codex Proxy RS 独立网关插件;仅改宿主 Runtime、安装器或管理端时使用 cpr-dev-guide,不适用于 Codex 插件或 Vue 插件
description: 开发、修复、排查、审查或打包 Codex Proxy RS 独立网关插件,含处理器、清单与插件页面;仅改宿主能力时使用 cpr-dev-guide 的插件边界,不适用于 Codex 插件或 Vue 插件
---
# 网关插件开发
插件在独立项目中通过公开 SDK 实现业务,不向宿主业务模块添加专属流程
## 先读职责边界
同时使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)的对应任务分支,已加载则复用,不递归重读
插件在独立项目中通过公开 SDK 实现业务,插件业务错误在插件侧修复;宿主维护自身合同、资源与稳定性,不猜测插件意图、不补造业务数据,不在宿主、SDK 或 Runtime 中增加插件专属补偿
开发、排查和审查先完整读取[插件职责边界](../cpr-dev-guide/references/plugin-boundaries.md),再选择实现步骤;已有处理器修复和页面改动也执行,不以是否新增能力为条件。需要新增宿主能力时,先界定可独立使用的基础接口及其职责;必要的协议保护与资源回收仍由宿主负责
同时执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)的领域必读与收尾要求;已实际读取且仍适用的内容复用,不递归重读入口。仅构建、打包或分发已有插件时按对应操作读取,若需诊断或修改行为,再补齐开发分支
## 范围与合同
@@ -18,13 +22,13 @@ description: 开发、排查或打包 Codex Proxy RS 独立网关插件;仅改
## 按当前步骤读取
只读取命中行与链接的指定章节,长文档先列标题再截取;不预读全部 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),不需要页面的插件不创建前端 |
| 选择、使用或审查能力 | [能力与方法](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.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#验证与交付),按实现范围验证 |
@@ -1,6 +1,6 @@
interface:
display_name: "cpr-plugin-dev"
short_description: "网关插件开发、接入排障、SDK 实现、页面与打包验证"
default_prompt: "使用 $cpr-plugin-dev 按当前请求开发、排查或打包 Codex Proxy RS 网关插件,复用公开 SDK 与已有页面并验证实际行为。"
short_description: "独立网关插件开发、排障与审查,先核对宿主边界再实现和验证"
default_prompt: "使用 $cpr-plugin-dev 按当前请求开发、排查、审查或打包独立网关插件,先读取适用职责边界,插件业务错误在插件侧修复,再按步骤读取实现与验证资料。"
policy:
allow_implicit_invocation: true
@@ -1,6 +1,6 @@
# 插件页面
仅在创建或修改插件页面时读取,先执行[前端流程](../../cpr-dev-guide/references/frontend.md),再核对以下插件特有合同
创建、修改或审查插件页面时完整读取,先完成[插件入口](../SKILL.md#先读职责边界)的边界判断并执行[前端流程](../../cpr-dev-guide/references/frontend.md),再核对以下插件特有合同
- 优先参考独立示例仓库的 `examples/workbench/frontend/src/api/`:业务路由放在 `modules/`,`request.ts` 封装公开宿主桥;按需使用 `@codex-proxy/ui` 包出口,不跨仓导入宿主或 UI 的内部源码
- Vue 页面保留 SFC,逻辑、脚本和配置使用 TypeScript;只实现业务内容,标题和副标题由 `ManagementPage` 交给宿主显示
@@ -1,7 +1,7 @@
# 清单与处理器实现
仅在编写或排查插件清单、Rust 处理器时读取,涉及 Rust 时使用[开发入口](../../cpr-dev-guide/SKILL.md)的 Rust 分支
选择能力时只读 SDK 的[能力与方法](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#能力与方法)及所需能力章节
开发、排查或审查插件清单、Rust 处理器时完整读取,先完成[插件入口](../SKILL.md#先读职责边界)的边界判断,涉及 Rust 时执行[开发入口](../../cpr-dev-guide/SKILL.md)的 Rust 分支
使用能力前读取 SDK 的[能力与方法](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#能力与方法)及所用能力章节,已有实现的修复与审查也核对合同
## 清单与处理器
+14 -14
View File
@@ -15,20 +15,6 @@ description: Codex Proxy RS 的版本规划、发布说明、正式或预发行
其他仓库使用其发布入口与规则,不套用本仓库路径;规则已在上下文中时不重复读取
## 按任务读取
只读当前阶段和指定章节,先定位标题再截取,不预读整份 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) |
## 必须保持的边界
- Codex 兼容性变化按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核对已有依据与验证范围,缺少依据列为发行风险,不以 CI 通过代替
@@ -37,3 +23,17 @@ description: Codex Proxy RS 的版本规划、发布说明、正式或预发行
- 预发行不得覆盖 GitHub Latest 或镜像 `latest`,完成后回读核对
- `release/notes.md` 只承载当前发行的说明;常规文档修改另按开发入口执行[文档检查](../cpr-dev-guide/references/documentation.md)
- 交付版本、提交、Release/工作流链接与实际结果,未完成构建或缺失产物时不称发布成功
## 按任务读取
先落实上述范围与边界,再读取当前阶段的完整指定章节;实际发布包含准备与验收,不能只读执行命令。阶段变化时补齐读取,准备文案不加载执行流程,实际发布前须核对发布入口的完整副作用
| 任务 | 读取范围 |
| --- | --- |
| 推荐版本号 | [基线与版本](references/preparation.md#基线与版本) |
| 编写或翻译发布说明 | [基线与版本](references/preparation.md#基线与版本)、[发布说明](references/preparation.md#发布说明) |
| 检查发行条件 | [发行准备检查](references/preparation.md#发行准备检查) |
| 实际发布 | 先完成[基线与版本](references/preparation.md#基线与版本)、[发布说明](references/preparation.md#发布说明)、[发行准备检查](references/preparation.md#发行准备检查),再执行[通道核对](references/operations.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) |
+1 -1
View File
@@ -14,7 +14,7 @@ permissions:
jobs:
container:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 45
env:
CPR_IMAGE: codex-proxy-rs:ci-${{ inputs.ref }}
+10 -7
View File
@@ -24,7 +24,7 @@ permissions:
jobs:
backend:
if: inputs.run_backend
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 30
env:
CARGO_TERM_COLOR: always
@@ -34,7 +34,7 @@ jobs:
CPR_TEST_REDIS_URL: redis://127.0.0.1:6379
services:
postgres:
image: postgres:18-bookworm@sha256:d9c83446333daec3f0588cc709adb80c26090b7f9f0f7ec8d43c243385d79818
image: postgres:18-bookworm@sha256:bbf83594798e144bff365a9c191c5d779f181fb3135902064ab8ff11918020fc
env:
POSTGRES_DB: codex_proxy
POSTGRES_USER: codex_proxy
@@ -48,7 +48,7 @@ jobs:
--health-timeout 5s
--health-retries 10
redis:
image: redis:8-alpine@sha256:9d317178eceac8454a2284a9e6df2466b93c745529947f0cd42a0fa9609d7005
image: redis:8-alpine@sha256:0bf6577d7774e592b29d093e3ac9390eb91e13d72c21e506afce736de5e0eeb0
ports:
- 6379:6379
options: >-
@@ -108,7 +108,7 @@ jobs:
components: rustfmt,clippy
- name: Cache Rust
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
workspaces: backend -> target
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
@@ -122,7 +122,7 @@ jobs:
run: cargo clippy --all-targets --all-features --locked -- -D warnings
- name: Setup nextest
uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29
uses: taiki-e/install-action@1e29709c0ec63babbe8f266397a465f29a825f91 # v2.87.31
with:
tool: cargo-nextest@0.9.143
fallback: none
@@ -199,7 +199,7 @@ jobs:
frontend:
if: inputs.run_frontend
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 15
env:
CI: 'true'
@@ -213,7 +213,7 @@ jobs:
uses: ./.github/actions/build-frontend
workflow-lint:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 5
steps:
- name: Checkout
@@ -223,6 +223,8 @@ jobs:
# GitHub 已支持 queue: max,actionlint 尚未支持该字段
# https://github.com/rhysd/actionlint/issues/657
# GitHub 已提供 ubuntu-26.04,actionlint 尚未收录该标签
# https://github.com/actions/runner-images#available-images
- name: Lint GitHub Actions workflows
run: >-
docker run --rm
@@ -231,6 +233,7 @@ jobs:
rhysd/actionlint:1.7.7@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9
-color
-ignore '^unexpected key "queue" for "concurrency" section\. expected one of "cancel-in-progress", "group"$'
-ignore '^label "ubuntu-26\.04" is unknown\.'
.github/workflows/*.yml
- name: Check workflow script syntax
+2 -77
View File
@@ -20,7 +20,7 @@ concurrency:
jobs:
changes:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 5
permissions:
contents: read
@@ -29,7 +29,6 @@ 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
@@ -41,7 +40,7 @@ jobs:
id: filter
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
with:
# 目录规则取并集,再统一排除说明文档,保留隐藏配置与子模块指针
# 目录规则取并集,再统一排除说明文档,保留隐藏配置
predicate-quantifier: some-with-excludes
filters: |
backend:
@@ -61,17 +60,6 @@ jobs:
- '.github/workflows/ci.yml'
- '.github/workflows/_quality.yml'
- '!**/*.md'
source:
- '.github/scripts/toolchains.rb'
- 'backend/rust-toolchain.toml'
- 'deploy/Dockerfile'
- '.gitmodules'
- 'modules/ui'
- 'modules/plugins'
- 'frontend/**'
- 'backend/crates/gateway-plugin/sdk/**'
- '.github/workflows/ci.yml'
- '!**/*.md'
container:
- 'backend/**'
- 'frontend/**'
@@ -101,66 +89,3 @@ 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: Read build toolchains
id: toolchains
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
- name: Setup Node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: ${{ steps.toolchains.outputs.node }}
- name: Setup Rust
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
with:
toolchain: ${{ steps.toolchains.outputs.rust }}
- name: Check dependency pins
run: |
ui_revision="$(git -C modules/ui rev-parse HEAD)"
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
+6 -6
View File
@@ -21,7 +21,7 @@ env:
jobs:
release-info:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 10
outputs:
tag: ${{ steps.version.outputs.tag }}
@@ -73,7 +73,7 @@ jobs:
frontend:
needs: release-info
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 15
env:
CI: 'true'
@@ -205,7 +205,7 @@ jobs:
package-assets:
needs: [release-info, frontend, build-docker-binary, build-binary]
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 20
permissions:
contents: read
@@ -312,7 +312,7 @@ jobs:
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -375,7 +375,7 @@ jobs:
queue: max
# 产物准备可与测试重叠,版本标签和 GitHub Release 必须等全部质量与镜像验证通过
needs: [release-info, quality, package-assets, build-docker-image]
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 20
permissions:
contents: write
@@ -409,7 +409,7 @@ jobs:
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Login to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
+1 -1
View File
@@ -18,7 +18,7 @@ concurrency:
jobs:
guard:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 5
steps:
# 只查询 PR 元数据,禁止检出或执行贡献分支中的代码。
+3 -3
View File
@@ -19,7 +19,7 @@ concurrency:
jobs:
backend-security:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 20
steps:
- name: Checkout
@@ -28,7 +28,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Install cargo-audit
uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29
uses: taiki-e/install-action@1e29709c0ec63babbe8f266397a465f29a825f91 # v2.87.31
with:
tool: cargo-audit@0.22.2
fallback: none
@@ -38,7 +38,7 @@ jobs:
cargo-audit audit --file backend/Cargo.lock
frontend-security:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 15
steps:
- name: Checkout
+1
View File
@@ -34,6 +34,7 @@ Thumbs.db
# Local tools and worktrees
/.codegraph/
/.playwright-cli/
/.codex-ws-audit/
/.reasonix/
/.claude
+6 -3
View File
@@ -5,14 +5,16 @@
| 任务意图与常见说法 | 必用技能 |
| --- | --- |
| 开发、修复、排障、代码审查、改文档;包括“改弹窗/样式/文案”“修接口/调度/数据库”等省略请求 | [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 |
| 提交/提个 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 |
| 写、修或审查独立网关插件,排查插件接入、打包插件 | [cpr-plugin-dev](.agents/skills/cpr-plugin-dev/SKILL.md),同时执行 cpr-dev-guide;仅改宿主能力时由 cpr-dev-guide 加载插件边界 |
| 发版、准备版本、发布 beta/实验版、整理发布说明、打发行 tag | [cpr-release](.agents/skills/cpr-release/SKILL.md) |
结合上下文识别“提交吧”“看看这个问题”等省略说法:普通 Git 提交不自动变成 PR,代码排查不自动变成 Issue;审查与发版准备不代表获准远程写入
只加载当前任务命中的技能、模式和文档章节,复用已有上下文,进入新阶段再补读;链接是按需入口,不是全文或全部参考文件的必读清单
先完整读取命中技能的 `SKILL.md`,再读取其领域必读规范,之后按当前步骤补充实现、命令和示例。技能名称、描述或入口摘要不能代替正文与必读引用;引用中明确要求“先读”“必须读取”或“执行”的规范也要落实,其他资料按需读取
按用户意图、行为影响和实际文件共同匹配,跨领域叠加;确定方案或给出审查结论前完成对应领域必读,不能因改动小、路径未落在该模块或自认为熟悉而跳过。只复用当前上下文中已实际读取且仍适用的内容;输出截断、上下文压缩后缺失的必要内容须补读,范围或阶段变化时重新匹配
执行所选流程中的问题核实、自审、验证与交付要求,协作流程和审查标准见 [CONTRIBUTING.md](CONTRIBUTING.md)
@@ -26,6 +28,7 @@ Issue 和 PR 回复统一按 [GitHub 回复口吻](CONTRIBUTING.md#github-回复
In repositories indexed by CodeGraph (a `.codegraph/` directory exists at the repo root), reach for it BEFORE grep/find or reading files when you need to understand or locate code:
- **Index freshness**: Run `codegraph sync` from the repository root before the first code query in a task. After pulling updates, switching branches, or changing code, run it again before the next query so symbols and call paths match the working tree. If synchronization fails, report the failure and verify against current source rather than treating the index as current.
- **MCP tools** (when available): `codegraph_explore` answers most code questions in one call — the relevant symbols' verbatim source plus the call paths between them. `codegraph_node` returns one symbol's source + callers, or reads a whole file with line numbers. If the tools are listed but deferred, load them by name via tool search.
- **Shell** (always works): `codegraph explore "<symbol names or question>"` and `codegraph node <symbol-or-file>` print the same output.
+1 -1
View File
@@ -228,7 +228,7 @@ git diff --check
## 自动检查与合并条件
[CI](.github/workflows/ci.yml) 按变更范围调用质量检查,Markdown 不触发后端、前端、容器和源码联调任务,工作流语法检查独立执行
[CI](.github/workflows/ci.yml) 按变更范围调用质量检查,Markdown 不触发后端、前端和容器任务,工作流语法检查独立执行
后端 CI 使用 nextest 执行 `main` 集成测试,默认 Redis 密码模式执行完整测试集,ACL 用户模式复用同一批构建产物执行 Store 的 Redis 测试;本地验证命令见 [架构文档](docs/architecture.md#验证命令)
+2 -2
View File
@@ -17,7 +17,7 @@
</div>
> [!NOTE]
> 本项目提供 Responses API,不支持 `/v1/chat/completions`,接入前请确认客户端支持 Responses 协议
> 本项目提供 Responses API 和 Chat Completions API;Chat Completions 请求通过 Responses 链路执行,支持范围见 [API 参考](docs/api.md#chat-completions-请求)
## 快速预览
@@ -97,7 +97,7 @@ API Key 持有者可在登录页切换身份,查看自己的用量与额度,
完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)
**其他 Responses API 客户端**:填写以下信息:
**其他 Responses / Chat Completions API 客户端**:填写以下信息:
| 配置 | 值 |
| --- | --- |
+102 -110
View File
@@ -147,9 +147,9 @@ dependencies = [
[[package]]
name = "async-compression"
version = "0.4.50"
version = "0.4.51"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1"
checksum = "9d5755640120b24961f8231e05892e4a4f7e0d04e8aeca161508c9c397946970"
dependencies = [
"compression-codecs",
"compression-core",
@@ -224,9 +224,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-credential-types"
version = "1.3.0"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e"
checksum = "576e8da35751dc85409ec1cd15999f692c277a1fbe9336780fe6f15e49dd637d"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
@@ -260,9 +260,9 @@ dependencies = [
[[package]]
name = "aws-runtime"
version = "1.10.0"
version = "1.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b8a9911551b4ea6ca13805ef52ed96f7d2bbb43cc3b4a14cb0776a71f33cfaa"
checksum = "9a0cc2144962cc5e358467b16e3d82981b1d424a7c3d34602be0036103814a1a"
dependencies = [
"aws-credential-types",
"aws-sigv4",
@@ -286,9 +286,9 @@ dependencies = [
[[package]]
name = "aws-sdk-s3"
version = "1.152.0"
version = "1.155.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "004d5acbd6850ab58789a43822e0a2ef23927a46b012c17f8fa51253103db9f8"
checksum = "5a542f0e9261b9ce56708d591a62062079fa3c58a362ffbf49a2e48a4f6ced16"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -322,9 +322,9 @@ dependencies = [
[[package]]
name = "aws-sigv4"
version = "1.6.0"
version = "1.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2312577f088c9fbf4206dfdb884cf1de9407b43e1a923cbed5237775116fc24b"
checksum = "9d3d48355cb86b15ccbb38b8bc6b87500c824ee4ed8cbc10a961b435242b5e16"
dependencies = [
"aws-credential-types",
"aws-smithy-eventstream",
@@ -344,9 +344,9 @@ dependencies = [
[[package]]
name = "aws-smithy-async"
version = "1.3.0"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316"
checksum = "de681a9df7ff79823b759ee17061076ec049cd61a8fa28ece4293d89c9f6964e"
dependencies = [
"futures-util",
"pin-project-lite",
@@ -376,9 +376,9 @@ dependencies = [
[[package]]
name = "aws-smithy-eventstream"
version = "0.61.4"
version = "0.61.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80c2051c2f1016fb8e6548dd07b8bc2ac9c3fe583721444b92f515e856d31609"
checksum = "14c8a643d0e114cae39e45415e3225793a6396698b7779a0c9e3ede731d8fc71"
dependencies = [
"aws-smithy-types",
"bytes",
@@ -387,9 +387,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http"
version = "0.64.1"
version = "0.64.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "639b4d8f8555f24a9be649811c3eb0b4d4616f4d61daf0c32e28873bc1ea9af1"
checksum = "ba8960956e0ee0ca3f3161e62913a61bc65a7ef638ca3ef1d4c7159f8443e398"
dependencies = [
"aws-smithy-eventstream",
"aws-smithy-runtime-api",
@@ -403,15 +403,14 @@ dependencies = [
"http-body-util",
"percent-encoding",
"pin-project-lite",
"pin-utils",
"tracing",
]
[[package]]
name = "aws-smithy-http-client"
version = "1.5.0"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51c89cc3f1f281d659a67a519a1b5c6d445b5ce09fa7e5aee40c2c2707e9509d"
checksum = "aeca69f65362f7375891008ea525fd440ad6db1836458ac188c87b126804114e"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
@@ -433,9 +432,9 @@ dependencies = [
[[package]]
name = "aws-smithy-json"
version = "0.63.1"
version = "0.65.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3385d469edbe8b60cc72002784652b5efca39178192aa9cc4b44c9875c6bdc18"
checksum = "9f6835fb38bc6fc8c91e584b0366eb4ff595e5c485134dbf4f0bdeaa18856666"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-schema",
@@ -453,9 +452,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime"
version = "1.16.0"
version = "1.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6e302ac1d88b99652489df31abdec6ac42a2ab2ac3982ad0ac49f64dfaf28ba"
checksum = "79cef0c41e8ca40f74f68ed8ee4d1ddbde12bb847b95ef9b3c025681ddfae58a"
dependencies = [
"aws-smithy-async",
"aws-smithy-http",
@@ -470,16 +469,15 @@ dependencies = [
"http-body",
"http-body-util",
"pin-project-lite",
"pin-utils",
"tokio",
"tracing",
]
[[package]]
name = "aws-smithy-runtime-api"
version = "1.19.0"
version = "1.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0730c16f91124c6a2abb4932c77e299288b3dd9f967ea2e9ec48cc6731e87a4"
checksum = "06788004dea79018413f381891da6dcca23196acd79c4d51b767fbdacc76eae4"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api-macros",
@@ -505,9 +503,9 @@ dependencies = [
[[package]]
name = "aws-smithy-schema"
version = "0.2.1"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8f395d93304280b64b7632fea798d177e74897fe7f063416ce627cd6fa24829"
checksum = "bec8dae1015a6d959a5faf86fbdf77486cfe19e4ccb6ee009d8a46efc45028a4"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
@@ -516,9 +514,9 @@ dependencies = [
[[package]]
name = "aws-smithy-types"
version = "1.8.1"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69bb407740a197147da48238ecc94498493c9e85445732360cec180296ca45f1"
checksum = "55752025c39c0e094962fd73f1c57014b191f678612e517f219468e575af9822"
dependencies = [
"base64-simd",
"bytes",
@@ -530,7 +528,6 @@ dependencies = [
"itoa",
"num-integer",
"pin-project-lite",
"pin-utils",
"ryu",
"serde",
"time",
@@ -540,9 +537,9 @@ dependencies = [
[[package]]
name = "aws-smithy-xml"
version = "0.62.1"
version = "0.64.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b932c8d6dc127fc980eecd78f8694ae9b9551b69a93a7def2a199c1c0033daf"
checksum = "a6581618a2fd006159016d1c36881d94d61b4a017f4a8d631310875e18252413"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-schema",
@@ -552,9 +549,9 @@ dependencies = [
[[package]]
name = "aws-types"
version = "1.6.0"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "209f3a6d82a6e9e5f94abbed94c7a26e1c052341002bf57a5fb5481f625896fc"
checksum = "6ba0d970d3947bb064d2ae1d823efa186c1dbde88f88afa8f6549946e5d8deee"
dependencies = [
"aws-credential-types",
"aws-smithy-async",
@@ -945,9 +942,9 @@ dependencies = [
[[package]]
name = "compression-codecs"
version = "0.4.45"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a"
checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe"
dependencies = [
"compression-core",
"flate2",
@@ -956,9 +953,9 @@ dependencies = [
[[package]]
name = "compression-core"
version = "0.4.33"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a"
[[package]]
name = "config"
@@ -1084,9 +1081,9 @@ dependencies = [
[[package]]
name = "crc32fast"
version = "1.5.2"
version = "1.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
dependencies = [
"cfg-if",
]
@@ -1129,9 +1126,9 @@ checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
name = "crypto-bigint"
version = "0.7.5"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271"
checksum = "6bb08299a6046833a879ace115697f12fd2bd6c8d3e93c909ffc9e0738e7cae0"
dependencies = [
"cpubits",
"ctutils",
@@ -1166,14 +1163,14 @@ dependencies = [
[[package]]
name = "cssparser"
version = "0.37.0"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
checksum = "11119743ad110e8c1bdccd930d7f5c30c99e5fc76a7b63ec9807e84eef0c5f59"
dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
"phf 0.13.1",
"phf 0.14.0",
"smallvec",
]
@@ -1746,6 +1743,8 @@ dependencies = [
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"regex",
"secrecy",
"serde",
@@ -2125,12 +2124,13 @@ dependencies = [
[[package]]
name = "html5ever"
version = "0.39.0"
version = "0.40.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
checksum = "456a1a377e608e555d22ddab27ac0114bc7a7b4199078108e34c2aeae6c9b130"
dependencies = [
"log",
"markup5ever",
"memchr",
]
[[package]]
@@ -2483,9 +2483,9 @@ dependencies = [
[[package]]
name = "jsonschema"
version = "0.58.6"
version = "0.60.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f622c574a29fb3034a0a90645afee194dfedc75fc124228801b6e77041075bd1"
checksum = "7e7305938cac5ee144352ceb71583ffc6cfeac7d20fcb20ef865a4d5c314e499"
dependencies = [
"ahash",
"bytecount",
@@ -2511,23 +2511,24 @@ dependencies = [
[[package]]
name = "jsonschema-regex"
version = "0.58.6"
version = "0.60.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a968fffd387b2ab005bf00c1851f5d26e90df05b6f7ad609ed0d620033cca5e7"
checksum = "6f8a848331614144c03e815fb931f238c9e342bd7ab501b29e35d647e7cd5688"
dependencies = [
"regex-syntax",
]
[[package]]
name = "jsonschema-value"
version = "0.58.6"
version = "0.60.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39b7670345e5ce87371339d13705dbb9276b7e3e934bbe1efa2c6734a1f65a96"
checksum = "5a5a947ef798eeacacb43edf29b1d0ab64bdf5e2c9fbcaad10ee8663d922b544"
dependencies = [
"ahash",
"bytecount",
"fraction",
"getrandom 0.3.4",
"memchr",
"num-cmp",
"num-traits",
"serde_json",
@@ -2642,9 +2643,9 @@ dependencies = [
[[package]]
name = "markup5ever"
version = "0.39.0"
version = "0.40.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
checksum = "0ab3dc68ac4a0f5719e560136778c1ee716e296030d75dbd4484e37e39e3a842"
dependencies = [
"log",
"tendril",
@@ -3101,23 +3102,23 @@ dependencies = [
[[package]]
name = "phf"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
dependencies = [
"phf_macros 0.13.1",
"phf_shared 0.13.1",
"phf_macros 0.14.0",
"phf_shared 0.14.0",
"serde",
]
[[package]]
name = "phf_codegen"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"phf_generator 0.14.0",
"phf_shared 0.14.0",
]
[[package]]
@@ -3132,12 +3133,12 @@ dependencies = [
[[package]]
name = "phf_generator"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
dependencies = [
"fastrand",
"phf_shared 0.13.1",
"phf_shared 0.14.0",
]
[[package]]
@@ -3155,12 +3156,12 @@ dependencies = [
[[package]]
name = "phf_macros"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"phf_generator 0.14.0",
"phf_shared 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.119",
@@ -3186,9 +3187,9 @@ dependencies = [
[[package]]
name = "phf_shared"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
dependencies = [
"siphasher",
]
@@ -3199,12 +3200,6 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pkcs8"
version = "0.11.0"
@@ -3636,9 +3631,9 @@ dependencies = [
[[package]]
name = "referencing"
version = "0.58.6"
version = "0.60.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "558f3855529c0a7e78a48e409fa653456e55f1b167eab4109c16a255b7727e3f"
checksum = "922268812b6f9e20b4ab0f1efe8e07036958eed7346ceddb31c777b5531fd11f"
dependencies = [
"ahash",
"fluent-uri",
@@ -3875,9 +3870,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scraper"
version = "0.27.0"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
checksum = "6844b5f49a5579f12ba4f89d9f7eec6d71889e4aa0bc7181821040ce66f08fff"
dependencies = [
"cssparser",
"ego-tree",
@@ -3885,7 +3880,6 @@ dependencies = [
"html5ever",
"precomputed-hash",
"selectors",
"tendril",
]
[[package]]
@@ -3950,17 +3944,15 @@ dependencies = [
[[package]]
name = "selectors"
version = "0.38.0"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
checksum = "8322aebea94a6cc288ff884e8a49bdbceef60206c400c6c46d22e62fc4fd91a6"
dependencies = [
"bitflags",
"cssparser",
"derive_more",
"log",
"new_debug_unreachable",
"phf 0.13.1",
"phf_codegen",
"precomputed-hash",
"rustc-hash",
"servo_arc",
@@ -4067,9 +4059,9 @@ dependencies = [
[[package]]
name = "servo_arc"
version = "0.4.3"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
checksum = "b4eceb519ec34d8290aaf6e46f435102f2bfa63f22b05a144fb16ab266d3b198"
dependencies = [
"stable_deref_trait",
]
@@ -4401,24 +4393,24 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "string_cache"
version = "0.9.0"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
checksum = "ffa8a5dbe8b3f0bbe29d4c3225daafaeead63afdc1b65fc4c01a1384166038e6"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.13.1",
"phf_shared 0.14.0",
"precomputed-hash",
]
[[package]]
name = "string_cache_codegen"
version = "0.6.1"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
checksum = "928dcdf75e47626b3617a976ec205d9f057584c371c1f23b782129268d0e6edc"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"phf_generator 0.14.0",
"phf_shared 0.14.0",
"proc-macro2",
"quote",
]
@@ -4655,9 +4647,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.52.3"
version = "1.53.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
dependencies = [
"bytes",
"libc",
@@ -5214,11 +5206,11 @@ dependencies = [
[[package]]
name = "web_atoms"
version = "0.2.6"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
checksum = "7572660c8890448ba236b7376f27e389c6a7e1c70195622faced601f855c0ada"
dependencies = [
"phf 0.13.1",
"phf 0.14.0",
"phf_codegen",
"string_cache",
"string_cache_codegen",
@@ -5651,9 +5643,9 @@ dependencies = [
[[package]]
name = "zip"
version = "9.0.4"
version = "9.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22"
checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e"
dependencies = [
"crc32fast",
"flate2",
@@ -5677,27 +5669,27 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zstd"
version = "0.13.3"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.2.4"
version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.16+zstd.1.5.7"
version = "2.1.2+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3"
dependencies = [
"cc",
"pkg-config",
@@ -103,6 +103,10 @@ async fn execute(
let management = ManagementPorts::new(&store, providers.admin.clone(), core.snapshot_control());
or_shutdown!(plugin_runtime, management.bind(&plugin_runtime));
let core = or_shutdown!(plugin_runtime, core.activate().await);
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_client_identity_port(&core.client_identity_port())
);
let nested_models = core.nested_model_execution_port();
let affinity_lookup = core.affinity_lookup_port();
or_shutdown!(
@@ -38,6 +38,10 @@ pub async fn run() -> Result<(), BootstrapError> {
let management = ManagementPorts::new(&store, providers.admin.clone(), core.snapshot_control());
or_shutdown!(plugin_runtime, management.bind(&plugin_runtime));
let mut core = or_shutdown!(plugin_runtime, core.activate().await);
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_client_identity_port(&core.client_identity_port())
);
let nested_models = core.nested_model_execution_port();
let affinity_lookup = core.affinity_lookup_port();
or_shutdown!(
+21 -3
View File
@@ -370,10 +370,28 @@ fn config_loader_should_reject_zero_server_port() {
}
#[test]
fn config_loader_should_validate_openai_residency() {
fn config_loader_should_accept_optional_openai_routing_strings() {
for value in [
serde_json::Value::Null,
serde_json::json!("us"),
serde_json::json!("custom-value"),
serde_json::json!(""),
] {
let mut document = valid_config_document();
document["openai"]["residency"] = value.clone();
document["openai"]["account_routing_override"] = value;
parse_config(&document.to_string()).expect("optional routing strings");
}
let mut document = valid_config_document();
document["openai"]["residency"] = serde_json::json!("invalid");
assert_rejected(document.to_string());
document["openai"]
.as_object_mut()
.unwrap()
.remove("residency");
document["openai"]
.as_object_mut()
.unwrap()
.remove("account_routing_override");
parse_config(&document.to_string()).expect("omitted routing strings");
}
fn assert_rejected(config: String) {
+1 -1
View File
@@ -250,7 +250,7 @@ fn source_manifest() -> &'static [u8] {
"main":"bin/plugin",
"runtime":"trustedProcess",
"contributes":{
"middleware":{"version":5,"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]},
"middleware":{"version":6,"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]},
"management":{"version":2}
},
@@ -1,9 +1,4 @@
//! 容量熔断冻结的恢复编排:到期探测、失败顺延与自适应并发下调
//!
//! 冻结事实保存在可丢失的 Redis 冷却中,本服务只组合 Admin 端口:
//! 观测(`AccountRuntimeStore`)、探测(`AccountsService`)与写回
//! (原子降低并发 / 按冻结代次结束探测)
//! 旧观测不会覆盖管理员操作
//! 账号错误保护的到期探测与按冻结快照恢复
use std::collections::BTreeMap;
use std::sync::Arc;
@@ -11,7 +6,7 @@ use std::time::{Duration, SystemTime};
use chrono::{DateTime, Utc};
use futures::StreamExt as _;
use gateway_core::account::{AccountConcurrencyLimit, ProviderAccountId};
use gateway_core::account::{AccountErrorProtection, ProviderAccountId};
use gateway_core::routing::UpstreamModelId;
use gateway_core::task::{ScheduledTask, WorkerCycleContext, WorkerTaskError};
use tracing::warn;
@@ -19,14 +14,9 @@ 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";
@@ -34,9 +24,6 @@ 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 {
@@ -55,26 +42,12 @@ impl FreezeRecoveryTask {
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> {
/// 无法读取或校验配置时跳过本轮,不能把未知状态当作允许解冻
async fn freeze_policy(&self) -> Option<AccountErrorProtection> {
let settings = self.deps.settings.load_runtime_settings().await.ok()?;
gateway_core::provider_ports::ProviderFreezePolicy::try_new(
settings.values.account_auto_freeze_enabled,
settings.values.account_auto_freeze_threshold,
settings.values.account_auto_freeze_window_seconds,
settings.values.account_auto_freeze_duration_seconds,
settings.values.account_auto_freeze_probe_enabled,
settings.values.account_auto_freeze_probe_model,
settings.values.account_auto_freeze_adaptive_concurrency,
)
.ok()
let policy = settings.values.account_error_protection;
policy.validate().ok()?;
Some(policy)
}
async fn active_freezes(&self) -> BTreeMap<String, AccountFreeze> {
@@ -89,55 +62,9 @@ impl FreezeRecoveryTask {
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
@@ -151,13 +78,13 @@ impl FreezeRecoveryTask {
async fn recover_due_freezes(
&self,
freezes: &BTreeMap<String, AccountFreeze>,
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
policy: &AccountErrorProtection,
) {
for (account_id, freeze) in freezes {
if freeze.until > Utc::now() {
continue;
}
if policy.enabled() && policy.probe_enabled() && freeze.requires_probe {
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
@@ -174,11 +101,19 @@ impl FreezeRecoveryTask {
&self,
account_id: &str,
freeze: &AccountFreeze,
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
policy: &AccountErrorProtection,
) {
let Some(Some(item)) = self.load_account(account_id).await else {
return;
};
if item.account.credential_revision != freeze.credential_revision {
let _ = self
.deps
.runtime
.finish_freeze(account_id, freeze, None)
.await;
return;
}
if !item.account.enabled {
// 停用账号没有自动恢复意义;解冻交给管理员手动恢复
return;
@@ -186,7 +121,7 @@ impl FreezeRecoveryTask {
let Ok(account) = ProviderAccountId::new(account_id.to_owned()) else {
return;
};
let Some(model) = self.resolve_probe_model(&account, policy).await else {
let Some(model) = self.resolve_probe_model(&account, freeze, policy).await else {
// 没有可用探测模型时按失败处理,顺延冻结等待下一轮
self.postpone(&account, freeze, policy).await;
return;
@@ -224,9 +159,23 @@ impl FreezeRecoveryTask {
&self,
account: &ProviderAccountId,
freeze: &AccountFreeze,
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
policy: &AccountErrorProtection,
) {
let Some(until) = SystemTime::now().checked_add(policy.freeze_duration()) else {
// 探测期间关闭保护或探测时,不再把已经到期的冻结向后延长
let Some(current) = self.freeze_policy().await else {
return;
};
if !current.enabled || !current.probe_enabled {
let _ = self
.deps
.runtime
.finish_freeze(account.as_str(), freeze, None)
.await;
return;
}
let Some(until) = SystemTime::now().checked_add(Duration::from_secs(
freeze.duration_seconds.unwrap_or(policy.duration_seconds),
)) else {
return;
};
let until = DateTime::<Utc>::from(until);
@@ -239,7 +188,10 @@ impl FreezeRecoveryTask {
Ok(true) => {
tracing::info!(
account_id = account.as_str(),
postpone_seconds = policy.freeze_duration().as_secs(),
postpone_seconds = Duration::from_secs(
freeze.duration_seconds.unwrap_or(policy.duration_seconds)
)
.as_secs(),
"冻结恢复探测失败:账号冻结顺延",
);
}
@@ -257,9 +209,14 @@ impl FreezeRecoveryTask {
async fn resolve_probe_model(
&self,
account: &ProviderAccountId,
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
freeze: &AccountFreeze,
policy: &AccountErrorProtection,
) -> Option<UpstreamModelId> {
if let Some(model) = policy.probe_model() {
if let Some(model) = if freeze.duration_seconds.is_some() {
freeze.probe_model.as_deref()
} else {
policy.probe_model.as_deref()
} {
return UpstreamModelId::new(model.to_owned()).ok();
}
let models = self.deps.accounts.models(account, false).await.ok()?;
@@ -282,15 +239,6 @@ impl ScheduledTask for FreezeRecoveryTask {
}
}
/// 自适应目标并发:观测峰值 × 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;
@@ -81,6 +81,7 @@ pub struct AccountListQuery {
/// Admin query service 从运行态存储取得的当前账号冷却快照
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AccountRuntimeSnapshot {
pub protection_rules: BTreeMap<String, Vec<gateway_core::account::AccountProtectionMatch>>,
pub cooldown: BTreeMap<String, gateway_core::account::AccountCooldown>,
/// `None` 表示实时 lease 存储不可用;`Some` 中未出现的账号当前使用量为零
pub in_flight: Option<BTreeMap<String, u64>>,
@@ -93,6 +94,9 @@ pub struct AccountFreeze {
pub until: DateTime<Utc>,
pub generation: String,
pub requires_probe: bool,
pub duration_seconds: Option<u64>,
pub probe_model: Option<String>,
pub protection_rules: Vec<gateway_core::account::AccountProtectionMatch>,
}
/// 可选的账号分组成员筛选条件
@@ -203,6 +207,7 @@ pub struct AccountPage {
/// 同一状态快照下的账号事实与唯一状态投影
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountPageItem {
pub protection_rules: Vec<gateway_core::account::AccountProtectionMatch>,
pub account: AccountRecord,
pub projection: AccountStatusProjection,
pub capacity: AccountCapacity,
@@ -2,7 +2,9 @@
use chrono::{DateTime, Utc};
use super::observability::{DecimalAmount, RequestMetricPoint, UsageOverview};
use super::observability::{
DecimalAmount, RequestMetricPoint, UsageConcurrencyPoint, UsageOverview,
};
/// 数据可见性状态,不以缺失投影冒充零统计
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
@@ -57,9 +59,10 @@ pub struct UsageProjectedCostPoint {
}
/// 同一数据库快照内的完整洞察输入
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq)]
pub struct UsageInsightsObservation {
pub overview: UsageOverview,
pub trend: Vec<RequestMetricPoint>,
pub costs: Vec<UsageProjectedCostPoint>,
pub concurrency: Vec<UsageConcurrencyPoint>,
}
@@ -23,7 +23,6 @@ pub enum MutationAuditOperation {
BackupDelete,
BackupConnectionTest,
BackupDownloadUrl,
ProviderAccountAdaptConcurrency,
ProviderAccountRecover,
ProviderAccountBatchUpdate,
ProviderAccountDelete,
@@ -84,7 +83,6 @@ impl MutationAuditOperation {
Self::BackupDelete => ("backup.delete_requested", "backup"),
Self::BackupConnectionTest => ("backup.s3_connection_tested", "backup_settings"),
Self::BackupDownloadUrl => ("backup.download_url_created", "backup"),
Self::ProviderAccountAdaptConcurrency => ("adapt_concurrency", "provider_account"),
Self::ProviderAccountRecover => ("recover", "provider_account"),
Self::ProviderAccountBatchUpdate => ("batch_update", "provider_account"),
Self::ProviderAccountDelete => ("delete", "provider_account"),
+13 -1
View File
@@ -68,6 +68,7 @@ pub struct AdminError {
kind: AdminErrorKind,
message: String,
source: Option<gateway_core::error::ErrorSource>,
sensitive_context_redacted: bool,
}
impl AdminError {
@@ -77,6 +78,7 @@ impl AdminError {
kind,
message: message.into(),
source: None,
sensitive_context_redacted: false,
}
}
@@ -86,9 +88,19 @@ impl AdminError {
self
}
#[must_use]
pub fn redact_sensitive_context(mut self) -> Self {
self.sensitive_context_redacted = true;
self
}
#[must_use]
pub fn error_details(&self) -> Option<gateway_core::error::ErrorDetails> {
gateway_core::error::ErrorDetails::capture(self.source.as_ref(), None, false)
gateway_core::error::ErrorDetails::capture(
self.source.as_ref(),
None,
self.sensitive_context_redacted,
)
}
#[must_use]
@@ -653,6 +653,7 @@ pub struct DashboardAccountUsage {
pub authentication_kind: String,
pub name: String,
pub email: Option<String>,
pub notes: Option<String>,
pub plan_type: Option<String>,
/// Admin 按 Provider 补齐的套餐展示名称
pub plan_type_display: Option<String>,
@@ -740,7 +741,6 @@ pub struct UsageListRecord {
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>,
@@ -802,7 +802,6 @@ 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>,
@@ -1262,38 +1261,44 @@ pub struct UsageInsightsHealth {
pub points: Vec<UsageInsightsHealthPoint>,
}
/// 一个时间桶内按重叠时长加权的逻辑请求并发
#[derive(Debug, Clone, PartialEq)]
pub struct UsageConcurrencyPoint {
pub bucket_start: DateTime<Utc>,
pub average_concurrency: f64,
pub peak_concurrency: u64,
}
/// 请求并发与调度等待的负载指标
#[derive(Debug, Clone, PartialEq)]
pub struct UsageInsightsLoad {
pub average_concurrency: f64,
pub peak_concurrency: u64,
pub admission_decision_p95_ms: Option<PercentileMilliseconds>,
pub account_selection_wait_p95_ms: Option<PercentileMilliseconds>,
}
/// 洞察性能时间点
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq)]
pub struct UsageInsightsPerformancePoint {
pub bucket_start: DateTime<Utc>,
pub latency_percentiles: LatencyPercentiles,
pub first_token_latency_percentiles: LatencyPercentiles,
pub admission_decision_percentiles: LatencyPercentiles,
pub account_selection_wait_percentiles: LatencyPercentiles,
pub output_throughput_p10: Option<u64>,
pub output_throughput_p50: Option<u64>,
pub output_throughput_p90: Option<u64>,
pub capacity_utilization_avg_basis_points: Option<u64>,
pub capacity_utilization_p95_basis_points: Option<u64>,
pub load: UsageInsightsLoad,
}
/// 洞察性能汇总与可观测覆盖率
/// 洞察性能汇总
#[derive(Debug, Clone, PartialEq)]
pub struct UsageInsightsPerformance {
pub latency_percentiles: LatencyPercentiles,
pub first_token_latency_percentiles: LatencyPercentiles,
pub latency_coverage: f64,
pub first_token_coverage: f64,
pub admission_decision_percentiles: LatencyPercentiles,
pub account_selection_wait_percentiles: LatencyPercentiles,
pub admission_decision_coverage: f64,
pub account_selection_wait_coverage: f64,
pub output_throughput_p10: Option<u64>,
pub output_throughput_p50: Option<u64>,
pub output_throughput_p90: Option<u64>,
pub capacity_utilization_avg_basis_points: Option<u64>,
pub capacity_utilization_p95_basis_points: Option<u64>,
pub capacity_coverage: f64,
pub load: UsageInsightsLoad,
pub points: Vec<UsageInsightsPerformancePoint>,
}
@@ -84,6 +84,7 @@ pub struct PluginInstanceMutation {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginInstanceRuntimeStatus {
Disabled,
Unknown,
AwaitingPublication,
Preparing,
Running,
@@ -108,6 +109,21 @@ pub struct PluginInstanceRuntime {
pub actual_artifact_sha256: Option<String>,
pub failure: Option<PluginInstanceRuntimeFailure>,
pub draining_revisions: Vec<u64>,
pub load: Option<PluginInstanceRuntimeLoad>,
}
/// 当前发布会话的资源占用采样,计数可以重叠且不构成原子快照
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginInstanceRuntimeLoad {
/// 总在途名额,包含排队、取消确认和仍由响应流持有的调用
pub active_calls: usize,
pub executing_calls: usize,
pub queued_calls: usize,
pub resuming_calls: usize,
/// 包含已返回但尚未由插件消费的回调结果
pub pending_callbacks: usize,
/// 已交付首结果但尚未收到 End 的流
pub active_streams: usize,
}
/// 可继续运行的旧接口提示,与无法启用的兼容性错误分开
@@ -136,6 +152,8 @@ pub struct PluginInstanceView {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ConfigurePluginInstance {
/// 只随本次启动请求传递,不写入实例配置
pub compatibility_confirmation: Option<PluginCompatibilityConfirmation>,
/// 创建草稿固定一个 UUID,重试复用同一配置,不能作为编辑已有配置的入口
pub creation_id: Option<String>,
/// 编辑时检查用户看到的配置版本,避免覆盖另一窗口已经保存的内容
@@ -159,6 +177,16 @@ pub struct PluginInstanceReplacement {
pub expected_revision: u64,
}
/// 管理员确认的宿主、实例配置与目标包;不构成永久兼容豁免
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PluginCompatibilityConfirmation {
pub host_fingerprint: String,
pub instance_id: String,
pub instance_revision: u64,
pub artifact_sha256: String,
}
/// 恢复已安装旧版本的配置快照,并重新通过目标版本校验
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -196,6 +224,9 @@ pub struct PluginVersionConfiguration {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginVersionPlan {
pub compatibility_warning: Option<String>,
pub compatibility_confirmation: Option<PluginCompatibilityConfirmation>,
pub load_error: Option<String>,
pub instance_revision: u64,
pub artifact_sha256: String,
pub configuration: serde_json::Value,
@@ -62,6 +62,48 @@ impl PluginHostCompatibility {
.iter()
.any(|entry| entry.capability == capability && entry.versions.contains(&version))
}
/// 启动和升级预检共用比较规则,只检查插件声明的能力
#[must_use]
pub fn incompatibilities(
&self,
requirements: &PluginCompatibilityRequirements,
host: &semver::Version,
) -> Vec<String> {
let mut reasons = Vec::new();
if !self
.manifest_schema_versions
.contains(&requirements.manifest_schema_version)
{
reasons.push(format!(
"清单 v{} 不在宿主支持范围内",
requirements.manifest_schema_version
));
}
if !self
.protocol_versions
.contains(&requirements.protocol_version)
{
reasons.push(format!(
"RPC v{} 不在宿主支持范围内",
requirements.protocol_version
));
}
if !semver::VersionReq::parse(&requirements.host_version)
.is_ok_and(|range| range.matches(host))
{
reasons.push(format!(
"插件声明的宿主范围为 {},当前为 {host}",
requirements.host_version
));
}
for (capability, version) in &requirements.capabilities {
if !self.supports_capability(capability, *version) {
reasons.push(format!("{capability} v{version} 不在宿主支持范围内"));
}
}
reasons
}
}
fn valid_versions(versions: &[u32]) -> bool {
@@ -1246,6 +1246,7 @@ impl fmt::Debug for ProviderExport {
/// 统一账号目录的一行完整结果
#[derive(Debug, Clone, PartialEq)]
pub struct AccountDirectoryItem {
pub protection_rules: Vec<gateway_core::account::AccountProtectionMatch>,
pub account: AccountRecord,
pub capacity: super::accounts::AccountCapacity,
pub capabilities: super::accounts::ProviderAccountCapabilities,
@@ -72,7 +72,7 @@ pub fn account_quota_forecasts(
},
extrapolated: false,
source: None,
unavailable_reason: Some("没有可统计的周/月额度窗口,请先刷新账号额度。"),
unavailable_reason: Some("缺少周/月额度窗口,请刷新账号额度"),
low_sample: false,
incomplete_cost: false,
incomplete_tokens: false,
@@ -138,30 +138,27 @@ impl AccountQuotaForecast {
self.incomplete_tokens = usage.is_some_and(|usage| usage.missing_token_count > 0);
let method = sample.map_or(QuotaForecastMethod::Cumulative, |sample| sample.method);
let Some(start) = start.filter(|start| *start <= now && now < reset_at) else {
self.unavailable_reason = Some("额度窗口已过期或边界无效,请刷新账号额度后重试。");
self.unavailable_reason = Some("额度窗口已过期或无效,请刷新账号额度");
return;
};
if !observed_at.is_some_and(|observed| start <= observed && observed <= now) {
self.unavailable_reason = Some("缺少本周期的额度快照,请先刷新账号额度。");
self.unavailable_reason = Some("缺少本周期的额度快照,请刷新账号额度");
return;
}
if sample.is_some_and(|sample| sample.discontinuous) {
self.unavailable_reason =
Some("额度观测出现回落或累计记录不连续,正在重新积累配对样本。");
self.unavailable_reason = Some("额度回落或记录不连续,正在重新积累配对样本");
return;
}
if account_added_at > start && method == QuotaForecastMethod::Cumulative {
self.unavailable_reason = Some(
"本周期开始时的记录不完整,正在积累至少 5 个百分点的配对观测,无需等待下次重置。",
);
self.unavailable_reason = Some("周期初记录不完整,正在积累至少 5 个百分点的配对观测");
return;
}
let Some(percent) = percent else {
self.unavailable_reason = Some("已用比例未知,请刷新额度后查看预测。");
self.unavailable_reason = Some("已用比例未知,请刷新账号额度");
return;
};
let Some(usage) = usage.filter(|usage| usage.request_count > 0) else {
self.unavailable_reason = Some("本周期没有网关用量记录,暂时无法预测额度。");
self.unavailable_reason = Some("本周期没有网关用量记录,无法预测额度");
return;
};
let Some(sample) = sample.filter(|sample| {
@@ -172,8 +169,7 @@ impl AccountQuotaForecast {
&& sample.sampled_percent >= MIN_USED_PERCENT
&& sample.sampled_percent <= percent
}) else {
self.unavailable_reason =
Some("有效额度进度不足 5 个百分点或采样边界无效,请继续积累用量。");
self.unavailable_reason = Some("有效额度进度不足 5 个百分点或采样边界无效");
return;
};
self.low_sample = sample.sampled_percent < LOW_SAMPLE_PERCENT
@@ -196,7 +192,7 @@ impl AccountQuotaForecast {
.and_then(|(used, remaining)| estimate(used + remaining, factor));
self.unavailable_reason = if self.estimated_tokens.is_none() && self.estimated_usd.is_none()
{
Some("本周期暂无可用于估算的 Token 或费用数据,请积累用量后重试。")
Some("本周期缺少可用于估算的 Token 或费用数据")
} else {
None
};
@@ -65,13 +65,7 @@ pub struct RuntimeSettingsValues {
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_error_protection: gateway_core::account::AccountErrorProtection,
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
@@ -232,12 +226,7 @@ impl RuntimeSettingsValues {
self.ops_event_retention_days,
self.audit_retention_days,
)?;
gateway_core::provider_ports::ProviderFreezePolicy::validate_values(
self.account_auto_freeze_threshold,
self.account_auto_freeze_window_seconds,
self.account_auto_freeze_duration_seconds,
self.account_auto_freeze_probe_model.as_deref(),
)?;
self.account_error_protection.validate()?;
gateway_core::provider_ports::ProviderWarmupPolicy::validate_values(
self.account_warmup_enabled,
&self.account_warmup_schedule_time,
@@ -46,6 +46,15 @@ pub trait PluginPreparation: PluginRuntimeDiagnostics + PluginStateLifecycle {
&self,
snapshot: PluginInstanceSnapshot,
) -> Result<ExtensionSetReference, AdminError>;
/// 确认由 Admin 校验配置版本,Runtime 复核宿主并仅放行目标实例
async fn prepare_confirmed(
&self,
_snapshot: PluginInstanceSnapshot,
_confirmation: crate::model::plugins::instances::PluginCompatibilityConfirmation,
) -> Result<ExtensionSetReference, AdminError> {
Err(AdminError::invalid("插件运行时不支持确认启动"))
}
}
/// 运行诊断独立于发布准备;没有进程事实时显式返回 None
@@ -175,6 +184,10 @@ pub trait PluginDistribution: Send + Sync {
/// Runtime 只解释插件包格式;安装事务与来源选择归 Admin
#[async_trait]
pub trait PluginPackageInspector: Send + Sync {
/// 当前宿主版本、兼容声明与平台的稳定身份,不含敏感信息
fn compatibility_host_fingerprint(&self) -> Result<String, AdminError> {
Err(AdminError::internal("宿主兼容声明不可用"))
}
/// 完整校验包并返回版本范围诊断;校验失败与可加载但未经宿主承诺的提醒分开
async fn compatibility_warning(
&self,
@@ -148,7 +148,7 @@ pub trait ProviderAdmin: Send + Sync {
> {
Err(gateway_core::settings::privacy::PrivacyError {
rule_index: 0,
reason: "Provider 不支持隐私策略",
reason: "该服务商不支持隐私策略",
})
}
@@ -161,7 +161,7 @@ pub trait ProviderAdmin: Send + Sync {
> {
Err(gateway_core::settings::privacy::PrivacyError {
rule_index: 0,
reason: "Provider 不支持隐私策略",
reason: "该服务商不支持隐私策略",
})
}
@@ -209,14 +209,6 @@ pub trait AccountStore:
context: &MutationContext,
) -> AdminStoreResult<AccountUpdateResult>;
/// 在事务内按最新启用状态、账号上限和全局默认值判断,只降低并发上限
async fn lower_concurrency_limit(
&self,
account_id: &gateway_core::account::ProviderAccountId,
limit: gateway_core::account::AccountConcurrencyLimit,
context: &MutationContext,
) -> AdminStoreResult<Option<AccountUpdateResult>>;
async fn recover_account(
&self,
account_id: &gateway_core::account::ProviderAccountId,
@@ -259,17 +251,11 @@ pub trait AccountRuntimeStore: Send + Sync {
account_ids: &[String],
) -> AdminStoreResult<AccountRuntimeSnapshot>;
/// 容量熔断自动冻结中的账号与其冻结截止时间;429 临时限流不包含在内
/// 账号错误保护中的账号与其冻结快照;临时限流不包含在内
async fn active_freezes(
&self,
) -> AdminStoreResult<BTreeMap<String, crate::model::accounts::AccountFreeze>>;
/// 读取容量失败窗口内观测到的在途并发峰值(自适应并发下调的证据)
async fn capacity_peaks(
&self,
account_ids: &[String],
) -> AdminStoreResult<BTreeMap<String, u32>>;
/// 仅当冻结快照仍匹配时解除或顺延;旧探测不得覆盖手动恢复或新一轮冻结
async fn finish_freeze(
&self,
@@ -38,11 +38,13 @@ pub enum SystemOperationErrorKind {
}
/// 不泄漏路径、命令行或发布凭据的系统操作错误
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("system operation failed: {message}")]
#[derive(Debug, Clone, thiserror::Error)]
#[error("{message}")]
pub struct SystemOperationError {
kind: SystemOperationErrorKind,
message: String,
source: Option<gateway_core::error::ErrorSource>,
sensitive_context_redacted: bool,
}
impl SystemOperationError {
@@ -51,9 +53,37 @@ impl SystemOperationError {
Self {
kind,
message: message.into(),
source: None,
sensitive_context_redacted: false,
}
}
#[must_use]
pub fn with_source(mut self, source: impl Into<gateway_core::error::ErrorSource>) -> Self {
self.source = Some(source.into());
self
}
#[must_use]
pub fn redact_sensitive_context(mut self) -> Self {
self.sensitive_context_redacted = true;
self
}
#[must_use]
pub const fn sensitive_context_redacted(&self) -> bool {
self.sensitive_context_redacted
}
#[must_use]
pub fn error_details(&self) -> Option<gateway_core::error::ErrorDetails> {
gateway_core::error::ErrorDetails::capture(
self.source.as_ref(),
None,
self.sensitive_context_redacted,
)
}
#[must_use]
pub const fn kind(&self) -> SystemOperationErrorKind {
self.kind
@@ -88,13 +88,6 @@ pub trait AccountsService: Send + Sync {
command: UpdateAccount,
) -> Result<AccountUpdateResult, AdminError>;
async fn lower_concurrency_limit(
&self,
context: &MutationContext,
account_id: ProviderAccountId,
limit: gateway_core::account::AccountConcurrencyLimit,
) -> Result<Option<AccountUpdateResult>, AdminError>;
async fn batch_update(
&self,
context: &MutationContext,
@@ -123,14 +116,14 @@ pub trait AccountsService: Send + Sync {
&self,
_account_id: &ProviderAccountId,
) -> Result<AccountPersonalInfo, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持个人信息"))
Err(AdminError::invalid("该服务商不支持个人信息查询"))
}
async fn profile_avatar(
&self,
_account_id: &ProviderAccountId,
) -> Result<ProviderProfileAvatar, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持账号头像"))
Err(AdminError::invalid("该服务商不支持账号头像"))
}
async fn reset_credit_automation_status(
@@ -161,7 +154,7 @@ pub trait AccountsService: Send + Sync {
_context: &MutationContext,
_account_id: ProviderAccountId,
) -> Result<ProviderResetCredits, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持重置额度"))
Err(AdminError::invalid("该服务商不支持额度重置"))
}
async fn consume_reset_credit(
@@ -169,7 +162,7 @@ pub trait AccountsService: Send + Sync {
_context: &MutationContext,
_command: ConsumeProviderResetCredit,
) -> Result<ProviderResetCreditResult, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持重置额度"))
Err(AdminError::invalid("该服务商不支持额度重置"))
}
async fn models(
@@ -322,7 +315,7 @@ impl DefaultAccountsService {
.load_account(account_id.as_str(), runtime)
.await
.map_err(|error| map_store_error(error, "provider account"))?
.ok_or_else(|| AdminError::not_found("Provider 账号不存在"))
.ok_or_else(|| AdminError::not_found("服务商账号不存在"))
}
async fn provider_for_account(
@@ -474,6 +467,7 @@ impl DefaultAccountsService {
.and_then(|(window, _)| window.local_usage.clone())
});
Ok(AccountDirectoryItem {
protection_rules: stored.protection_rules,
capacity: stored.capacity,
capabilities: provider
.account_capabilities(account_id, &stored.account.authentication_kind),
@@ -570,7 +564,7 @@ impl AccountsService for DefaultAccountsService {
.zip(quotas)
.map(|(mut item, quota)| {
let id = ProviderAccountId::new(item.account.id.clone())
.map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))?;
.map_err(|_| AdminError::invalid("服务商账号 ID 不合法"))?;
let capabilities = providers
.require(&item.account.provider_kind)
.map(|provider| {
@@ -583,6 +577,7 @@ impl AccountsService for DefaultAccountsService {
.and_then(|(window, _)| window.local_usage.clone())
});
Ok(AccountDirectoryItem {
protection_rules: item.protection_rules,
capacity: crate::model::accounts::AccountCapacity {
used_slots: in_flight
.as_ref()
@@ -748,7 +743,7 @@ impl AccountsService for DefaultAccountsService {
command: UpdateAccount,
) -> Result<AccountUpdateResult, AdminError> {
let account_id = ProviderAccountId::new(command.account_id.clone())
.map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))?;
.map_err(|_| AdminError::invalid("服务商账号 ID 不合法"))?;
let (_, provider) = self.provider_for_account(&account_id).await?;
let enabled = command.enabled;
let result = self
@@ -766,27 +761,6 @@ impl AccountsService for DefaultAccountsService {
Ok(result)
}
async fn lower_concurrency_limit(
&self,
context: &MutationContext,
account_id: ProviderAccountId,
limit: gateway_core::account::AccountConcurrencyLimit,
) -> Result<Option<AccountUpdateResult>, AdminError> {
let (_, provider) = self.provider_for_account(&account_id).await?;
let result = self
.accounts
.lower_concurrency_limit(&account_id, limit, context)
.await
.map_err(|error| map_store_error(error, "provider account"))?;
if let Some(result) = &result {
provider
.account_facts_changed(std::slice::from_ref(&account_id))
.await;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
}
Ok(result)
}
async fn batch_update(
&self,
context: &MutationContext,
@@ -797,7 +771,7 @@ impl AccountsService for DefaultAccountsService {
.iter()
.map(|id| {
ProviderAccountId::new(id.clone())
.map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))
.map_err(|_| AdminError::invalid("服务商账号 ID 不合法"))
})
.collect::<Result<Vec<_>, _>>()?;
let mut providers = BTreeMap::<
@@ -326,17 +326,15 @@ impl DefaultBackupService {
/// 校验存储配置;空串视为非法,Secret 缺省表示保留旧值
fn validate_storage(command: &UpdateBackupStorageCommand) -> Result<(), AdminError> {
validate_endpoint(&command.endpoint)?;
validate_nonempty("region", &command.region)?;
validate_nonempty("bucket", &command.bucket)?;
validate_nonempty("accessKeyId", &command.access_key_id)?;
validate_nonempty("存储区域", &command.region)?;
validate_nonempty("存储桶名称", &command.bucket)?;
validate_nonempty("访问密钥 ID", &command.access_key_id)?;
if let Some(secret) = command.secret_access_key.as_ref() {
let value = secret.expose_secret();
if value.is_empty() {
return Err(AdminError::bad_request(
"secretAccessKey 不能为空字符串;缺省表示保留旧值",
));
return Err(AdminError::bad_request("访问密钥不能为空"));
}
validate_length("secretAccessKey", value, 512)?;
validate_length("访问密钥", value, 512)?;
}
crate::model::backup::normalize_prefix(&command.prefix).map_err(map_backup_error)?;
Ok(())
@@ -344,17 +342,15 @@ fn validate_storage(command: &UpdateBackupStorageCommand) -> Result<(), AdminErr
fn validate_endpoint(value: &str) -> Result<(), AdminError> {
if value.is_empty() {
return Err(AdminError::bad_request("endpoint 不能为空"));
return Err(AdminError::bad_request("备份存储地址不能为空"));
}
if !value.starts_with("https://") {
return Err(AdminError::bad_request(
"endpoint 必须使用 HTTPS;仅测试或显式开发配置允许 HTTP",
));
return Err(AdminError::bad_request("备份存储地址必须使用 HTTPS"));
}
if value.contains('@') || value.bytes().any(|byte| byte.is_ascii_control()) {
return Err(AdminError::bad_request("endpoint 格式非法"));
return Err(AdminError::bad_request("备份存储地址格式不合法"));
}
validate_length("endpoint", value, 2048)
validate_length("备份存储地址", value, 2048)
}
fn validate_nonempty(field: &str, value: &str) -> Result<(), AdminError> {
@@ -94,7 +94,7 @@ impl ClientKeyService for DefaultClientKeyService {
.get_client_key(id)
.await
.map_err(|error| map_store_error(error, "client API key"))?
.ok_or_else(|| AdminError::not_found("Client API Key 不存在"))
.ok_or_else(|| AdminError::not_found("API Key 不存在"))
}
async fn budget(&self, id: &ClientApiKeyId) -> Result<ClientBudgetStatus, AdminError> {
@@ -149,7 +149,7 @@ impl ClientKeyService for DefaultClientKeyService {
.reveal_client_key(id)
.await
.map_err(|error| map_store_error(error, "client API key"))?
.ok_or_else(|| AdminError::not_found("Client API Key 不存在"))
.ok_or_else(|| AdminError::not_found("API Key 不存在"))
}
async fn create(
@@ -158,7 +158,7 @@ impl ClientKeyService for DefaultClientKeyService {
command: CreateClientKey,
) -> Result<CreatedClientKey, AdminError> {
crate::model::client_keys::validate_group_ids(&command.group_ids)
.map_err(|_| AdminError::invalid("Client API Key 分组不合法"))?;
.map_err(|_| AdminError::invalid("API Key 分组不合法"))?;
for (provider, profile) in &command.request_profile_overrides {
self.providers
.require(provider)
@@ -202,7 +202,7 @@ impl ClientKeyService for DefaultClientKeyService {
command: UpdateClientKey,
) -> Result<ClientKeyMutation, AdminError> {
crate::model::client_keys::validate_group_ids(&command.group_ids)
.map_err(|_| AdminError::invalid("Client API Key 分组不合法"))?;
.map_err(|_| AdminError::invalid("API Key 分组不合法"))?;
for (provider, profile) in &command.request_profile_override_updates {
if let Some(profile) = profile {
self.providers
@@ -277,9 +277,7 @@ fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> {
return Ok(());
};
if cursor.sort != query.sort {
return Err(AdminError::invalid(
"Client API Key 游标排序与查询条件不一致",
));
return Err(AdminError::invalid("分页游标与排序条件不一致"));
}
let matches = matches!(
(cursor.sort.field, &cursor.value),
@@ -300,7 +298,7 @@ fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> {
if matches {
Ok(())
} else {
Err(AdminError::invalid("Client API Key 游标不合法"))
Err(AdminError::invalid("API Key 分页游标不合法"))
}
}
@@ -65,7 +65,7 @@ pub(super) async fn required_credential(
.credential_details(provider_kind, account_id)
.await
.map_err(|error| map_store_error(error, resource))?
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
.ok_or_else(|| AdminError::not_found("服务商凭据不存在"))
}
pub(super) async fn required_plugin_credential(
@@ -77,7 +77,7 @@ pub(super) async fn required_plugin_credential(
.credential_details_by_id(account_id)
.await
.map_err(|error| map_store_error(error, resource))?
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
.ok_or_else(|| AdminError::not_found("服务商凭据不存在"))
}
pub(super) async fn import_proxy_binding(
@@ -156,7 +156,7 @@ pub(super) fn validate_prepared_import(
.iter()
.any(|credential| credential.provider_kind != *provider_kind)
{
return Err(AdminError::conflict("Provider 准备结果与请求范围不一致"));
return Err(AdminError::conflict("服务商返回的凭据与请求范围不符"));
}
Ok(())
}
@@ -176,7 +176,7 @@ pub(super) fn validate_prepared_rotation_facts(
if facts.account_id.as_str() != account.id.as_str()
|| facts.provider_kind != account.provider_kind
{
return Err(AdminError::conflict("Provider 准备结果与当前凭据不一致"));
return Err(AdminError::conflict("凭据处理结果与当前凭据不符"));
}
Ok(())
}
@@ -42,15 +42,17 @@ pub(super) fn map_provider_error(
ProviderAdminErrorKind::BadGateway => AdminErrorKind::BadGateway,
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
};
let message = match kind {
AdminErrorKind::Invalid => "Provider 请求不合法",
AdminErrorKind::NotFound => "Provider 资源不存在",
AdminErrorKind::Conflict => "Provider 资源状态冲突,请刷新后重试",
AdminErrorKind::BadGateway => "上游服务请求失败",
AdminErrorKind::UpstreamResultUnknown => "上游执行结果未知,请刷新状态后再决定是否重试",
AdminErrorKind::Unavailable => "Provider 服务暂不可用",
AdminErrorKind::Internal => "服务内部错误",
_ => "Provider 操作失败",
let message = match error.kind() {
ProviderAdminErrorKind::Invalid => "服务商请求参数不合法",
ProviderAdminErrorKind::Unsupported => "该服务商不支持此操作",
ProviderAdminErrorKind::NotFound => "服务商资源不存在",
ProviderAdminErrorKind::Conflict => "服务商资源状态冲突,请刷新后重试",
ProviderAdminErrorKind::BadGateway => "上游服务请求失败",
ProviderAdminErrorKind::Ambiguous => "上游执行结果未知,请刷新状态后再决定是否重试",
ProviderAdminErrorKind::Unavailable | ProviderAdminErrorKind::CredentialRefreshRequired => {
"服务商暂不可用"
}
ProviderAdminErrorKind::Internal => "服务内部错误",
};
AdminError::new(kind, error.public_message().unwrap_or(message)).with_source(OperationFailure {
operation: resource,
@@ -20,9 +20,10 @@ use crate::{
HealthTimeline, HealthTimelinePoint, OpsErrorPage, OpsErrorQuery, ProviderBillingInput,
RequestMetricPoint, RequestMetrics, TimeRange, Trend, TrendKind, TrendPoint,
TrendSummary, UsageBilling, UsageBillingProjection, UsageCalculatedBillingFact,
UsageDetail, UsageFilter, UsageInsights, UsageInsightsCost, UsageInsightsCostPoint,
UsageInsightsHealth, UsageInsightsHealthPoint, UsageInsightsPerformance,
UsageInsightsPerformancePoint, UsageOverview, UsagePage, UsageQuery, UsageSummary,
UsageConcurrencyPoint, UsageDetail, UsageFilter, UsageInsights, UsageInsightsCost,
UsageInsightsCostPoint, UsageInsightsHealth, UsageInsightsHealthPoint,
UsageInsightsLoad, UsageInsightsPerformance, UsageInsightsPerformancePoint,
UsageOverview, UsagePage, UsageQuery, UsageSummary,
},
provider_credentials::ProviderQuotaRequest,
},
@@ -304,7 +305,7 @@ impl ObservabilityService for DefaultObservabilityService {
}
let overview = observation.overview;
let trend = observation.trend;
build_usage_insights(overview, trend, standard_costs)
build_usage_insights(overview, trend, standard_costs, observation.concurrency)
}
async fn diagnostics(
@@ -385,6 +386,7 @@ fn build_usage_insights(
overview: UsageOverview,
trend: Vec<RequestMetricPoint>,
standard_costs: UsageCostScenarios,
concurrency: Vec<UsageConcurrencyPoint>,
) -> Result<UsageInsights, AdminError> {
let granularity = trend.first().map_or(
crate::model::observability::Granularity::FifteenMinutes,
@@ -432,49 +434,69 @@ fn build_usage_insights(
})
.collect(),
};
let concurrency_by_bucket = concurrency
.iter()
.map(|point| (point.bucket_start, point))
.collect::<BTreeMap<_, _>>();
let mut weighted_concurrency_micros = 0.0;
let mut effective_micros = 0_i64;
for (index, point) in concurrency.iter().enumerate() {
let start = point.bucket_start.max(overview.range.start);
let end = concurrency
.get(index + 1)
.map_or(overview.range.end, |next| next.bucket_start)
.min(overview.range.end);
let micros = (end - start).num_microseconds().unwrap_or(0).max(0);
effective_micros = effective_micros.saturating_add(micros);
weighted_concurrency_micros += point.average_concurrency * micros as f64;
}
let average_concurrency = if effective_micros > 0 {
weighted_concurrency_micros / effective_micros as f64
} else {
0.0
};
let peak_concurrency = concurrency
.iter()
.map(|point| point.peak_concurrency)
.max()
.unwrap_or(0);
let performance = UsageInsightsPerformance {
latency_percentiles: requests.latency_percentiles,
first_token_latency_percentiles: requests.first_token_latency_percentiles,
latency_coverage: rate_or_zero(requests.latency_count, requests.request_count),
first_token_coverage: rate_or_zero(
requests.first_token_latency_count,
requests.request_count,
),
admission_decision_percentiles: requests.admission_decision_percentiles,
account_selection_wait_percentiles: requests.account_selection_wait_percentiles,
admission_decision_coverage: rate_or_zero(
requests.admission_decision_count,
requests.request_count,
),
account_selection_wait_coverage: rate_or_zero(
requests.account_selection_wait_count,
requests.request_count,
),
output_throughput_p10: requests.output_throughput_p10,
output_throughput_p50: requests.output_throughput_p50,
output_throughput_p90: requests.output_throughput_p90,
capacity_utilization_avg_basis_points: requests.capacity_utilization_avg_basis_points,
capacity_utilization_p95_basis_points: requests.capacity_utilization_p95_basis_points,
capacity_coverage: rate_or_zero(requests.capacity_sample_count, requests.request_count),
load: UsageInsightsLoad {
average_concurrency,
peak_concurrency,
admission_decision_p95_ms: requests.admission_decision_percentiles.p95_ms,
account_selection_wait_p95_ms: requests.account_selection_wait_percentiles.p95_ms,
},
points: trend
.iter()
.map(|point| UsageInsightsPerformancePoint {
bucket_start: point.bucket_start,
latency_percentiles: point.metrics.latency_percentiles,
first_token_latency_percentiles: point.metrics.first_token_latency_percentiles,
admission_decision_percentiles: point.metrics.admission_decision_percentiles,
account_selection_wait_percentiles: point
.metrics
.account_selection_wait_percentiles,
output_throughput_p10: point.metrics.output_throughput_p10,
output_throughput_p50: point.metrics.output_throughput_p50,
output_throughput_p90: point.metrics.output_throughput_p90,
capacity_utilization_avg_basis_points: point
.metrics
.capacity_utilization_avg_basis_points,
capacity_utilization_p95_basis_points: point
.metrics
.capacity_utilization_p95_basis_points,
.map(|point| {
let concurrency = concurrency_by_bucket.get(&point.bucket_start);
UsageInsightsPerformancePoint {
bucket_start: point.bucket_start,
latency_percentiles: point.metrics.latency_percentiles,
first_token_latency_percentiles: point.metrics.first_token_latency_percentiles,
output_throughput_p10: point.metrics.output_throughput_p10,
output_throughput_p50: point.metrics.output_throughput_p50,
output_throughput_p90: point.metrics.output_throughput_p90,
load: UsageInsightsLoad {
average_concurrency: concurrency
.map_or(0.0, |point| point.average_concurrency),
peak_concurrency: concurrency.map_or(0, |point| point.peak_concurrency),
admission_decision_p95_ms: point
.metrics
.admission_decision_percentiles
.p95_ms,
account_selection_wait_p95_ms: point
.metrics
.account_selection_wait_percentiles
.p95_ms,
},
}
})
.collect(),
};
@@ -104,7 +104,7 @@ impl PluginAccountAccess for DefaultPluginAccountAccess {
.load_credential_for_plugin(account_id)
.await
.map_err(|error| map_store_error(error, "plugin credential"))?
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))?;
.ok_or_else(|| AdminError::not_found("服务商凭据不存在"))?;
Ok(PluginAccountCredential {
account: credential.account,
provider_material: credential.provider_material,
@@ -161,53 +161,24 @@ impl PluginSystemUpdatePreflight {
.await,
) {
(_, Err(error)) if error.kind() == AdminErrorKind::Invalid => {
Some("插件包或协议与目标版本不兼容".to_owned())
Some(error.message().to_owned())
}
(_, Err(error)) => return Err(map_inspection_error(error)),
(None, _) => Some("无法确认插件与目标版本兼容".to_owned()),
(Some(host), Ok(requirements)) => {
if !semver::VersionReq::parse(&requirements.host_version)
.is_ok_and(|versions| versions.matches(target))
{
Some(format!("要求网关版本 {}", requirements.host_version))
} else if !host
.manifest_schema_versions
.contains(&requirements.manifest_schema_version)
|| !host
.protocol_versions
.contains(&requirements.protocol_version)
|| requirements
.capabilities
.iter()
.any(|(capability, version)| {
!host.supports_capability(capability, *version)
})
{
Some("插件协议或能力与目标版本不兼容".to_owned())
} else {
None
}
let reasons = host.incompatibilities(&requirements, target);
(!reasons.is_empty()).then(|| reasons.join(","))
}
}
} else {
match self
.inspector
.inspect(
artifact.archive.clone(),
Some(instance.artifact_sha256.clone()),
)
.compatibility_warning(artifact.archive, instance.artifact_sha256.clone())
.await
{
Ok(_) => self
.inspector
.compatibility_warning(
artifact.archive,
instance.artifact_sha256.clone(),
)
.await
.map_err(map_inspection_error)?,
Ok(warning) => warning,
Err(error) if error.kind() == AdminErrorKind::Invalid => {
Some("插件与当前宿主不兼容".to_owned())
Some(error.message().to_owned())
}
Err(error) => return Err(map_inspection_error(error)),
}
@@ -186,6 +186,8 @@ impl PluginsService {
.configuration_ready(probe, &metadata)
.await?;
let status = self.package_status(&metadata.sha256).await?;
let compatible = status.warning.is_none() && status.load_error.is_none();
// 其它管理员写入或同插件的并发安装可能推进全局 revision;稳定 creation ID
// 配合每轮重新读取,既不会复制默认实例,也不会覆盖先完成的另一个版本
for _ in 0..3 {
@@ -207,12 +209,13 @@ impl PluginsService {
}
let input = ConfigurePluginInstance {
compatibility_confirmation: None,
replace_instances: Vec::new(),
creation_id: Some(creation_id.clone()),
expected_revision: None,
name: metadata.display_name.clone(),
artifact_sha256: metadata.sha256.clone(),
enabled: !configuration_required,
enabled: !configuration_required && compatible,
configuration: configuration.clone(),
secrets: None,
bindings: bindings.clone(),
@@ -71,7 +71,7 @@ impl PluginsService {
validate_update_policy(&binding)?;
let PluginUpdateSource::Github { repository } = &binding.source else {
return Err(AdminError::invalid(
"该来源不提供 Release 更新查询,请显式选择并安装插件包",
"该来源不支持更新查询,请手动安装插件包",
));
};
let (tag, allow_prerelease) = match &binding.policy {
@@ -356,9 +356,7 @@ fn validate_update_policy(binding: &PluginSourceBinding) -> Result<(), AdminErro
if !matches!(binding.source, PluginUpdateSource::Github { .. })
&& !matches!(binding.policy, PluginUpdatePolicy::Manual {})
{
return Err(AdminError::invalid(
"只有 GitHub 来源支持 Release 更新检查策略",
));
return Err(AdminError::invalid("仅 GitHub 来源支持发行版本更新检查"));
}
if let PluginUpdatePolicy::Pinned { tag, .. } = &binding.policy
&& (tag.is_empty()
@@ -366,7 +364,7 @@ fn validate_update_policy(binding: &PluginSourceBinding) -> Result<(), AdminErro
|| tag.chars().any(char::is_control)
|| tag.trim() != tag)
{
return Err(AdminError::invalid("固定 Release tag 不合法"));
return Err(AdminError::invalid("固定版本标签不合法"));
}
Ok(())
}
@@ -1,773 +0,0 @@
//! 插件实例的配置、停用、删除与私有状态迁移编排
use std::collections::{BTreeMap, BTreeSet};
use gateway_core::{
policy::ClientApiKeyId,
routing::{AccountGroupId, extensions::ExtensionSetReference},
};
use secrecy::ExposeSecret;
use super::PluginsService;
use crate::{
model::{
AdminError, AdminErrorKind, MutationContext, Revision,
plugins::instances::{
ConfigurePluginInstance, PluginInstance, PluginInstanceMutation,
PluginInstanceReplacement, PluginInstanceRuntime, PluginInstanceRuntimeStatus,
PluginInstanceSnapshot, PluginInstanceView,
},
plugins::state::{PluginStateCommit, PluginStateConfiguration},
},
use_case::{map_store_error, publish_committed},
};
#[derive(Clone, Default)]
pub(super) struct PackageStatus {
warning: Option<String>,
load_error: Option<String>,
}
impl PluginsService {
/// 只校验固定包体,不启动插件;停用实例也需区分兼容性提醒与加载错误
async fn package_status(&self, digest: &str) -> Result<PackageStatus, AdminError> {
// 运行宿主和摘要固定,缓存纯静态结论,避免管理页轮询反复解包
// 只缓存有限数量的确定结果,暂时性错误仍可重试
let mut cache = self.compatibility.lock().await;
if let Some(warning) = cache.get(digest) {
return Ok(warning.clone());
}
let artifact = self
.store
.load_artifact(digest)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
let warning = match self
.inspector
.compatibility_warning(artifact.archive, digest.to_owned())
.await
{
Ok(warning) => PackageStatus {
warning,
load_error: None,
},
Err(error) if error.kind() == AdminErrorKind::Invalid => PackageStatus {
warning: None,
load_error: Some(error.message().to_owned()),
},
Err(error) => return Err(error),
};
if cache.len() < 128 {
cache.insert(digest.to_owned(), warning.clone());
}
Ok(warning)
}
pub async fn instances(&self) -> Result<Vec<PluginInstanceView>, AdminError> {
let snapshot = self
.store
.load_instances()
.await
.map_err(|e| map_store_error(e, "plugin"))?;
let published = self.published.snapshot_for_diagnostics();
let published_revision = published.as_ref().map(|snapshot| snapshot.revision().get());
let published_ready = published
.as_ref()
.is_some_and(|snapshot| snapshot.extensions().is_none_or(|set| set.can_serve()));
let mut diagnostics = self
.preparation
.runtime_diagnostics(
&snapshot,
published_revision,
published
.as_ref()
.and_then(|snapshot| snapshot.extensions()),
)
.await
.unwrap_or_default();
let target_revision = snapshot.config_revision.get();
let artifacts = self
.list()
.await?
.into_iter()
.map(|artifact| (artifact.metadata.sha256.clone(), artifact.metadata))
.collect::<BTreeMap<_, _>>();
let mut views = Vec::with_capacity(snapshot.instances.len());
for instance in snapshot.instances {
let metadata = artifacts
.get(&instance.artifact_sha256)
.ok_or_else(|| AdminError::not_found("插件制品不存在"))?;
let package_status = self.package_status(&instance.artifact_sha256).await?;
let configuration_required = package_status.load_error.is_none()
&& !self
.preparation
.configuration_ready(instance.clone(), metadata)
.await?;
let runtime = diagnostics.remove(&instance.id).unwrap_or_else(|| {
let running = instance.enabled
&& published_ready
&& published_revision == Some(target_revision);
PluginInstanceRuntime {
status: if !instance.enabled {
PluginInstanceRuntimeStatus::Disabled
} else if running {
PluginInstanceRuntimeStatus::Running
} else {
PluginInstanceRuntimeStatus::AwaitingPublication
},
actual_revision: running.then_some(instance.revision.get()),
actual_artifact_sha256: running.then(|| instance.artifact_sha256.clone()),
failure: None,
draining_revisions: Vec::new(),
}
});
views.push(PluginInstanceView {
api_deprecations: if package_status.load_error.is_none() {
self.inspector.api_deprecations(metadata)?
} else {
Vec::new()
},
configuration_required,
compatibility_warning: package_status.warning,
load_error: package_status.load_error,
running: runtime.status == PluginInstanceRuntimeStatus::Running,
published_revision,
runtime,
instance,
});
}
Ok(views)
}
pub async fn configure_instance(
&self,
id: Option<&str>,
input: ConfigurePluginInstance,
context: &MutationContext,
) -> Result<PluginInstanceMutation, AdminError> {
if id.is_some() && input.creation_id.is_some() {
return Err(AdminError::invalid("编辑配置不能使用创建标识"));
}
if id.is_none() && input.expected_revision.is_some() {
return Err(AdminError::invalid("创建配置不能指定已有配置版本"));
}
if let Some(creation_id) = &input.creation_id {
let parsed = uuid::Uuid::parse_str(creation_id)
.map_err(|_| AdminError::invalid("配置创建标识必须是 UUID"))?;
if parsed.to_string() != *creation_id {
return Err(AdminError::invalid("配置创建标识必须使用标准 UUID 格式"));
}
}
let snapshot = self
.store
.load_instances()
.await
.map_err(|e| map_store_error(e, "plugin"))?;
let existing = match id {
Some(id) => Some(
snapshot
.instances
.iter()
.find(|instance| instance.id == id)
.cloned()
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?,
),
None => input.creation_id.as_ref().and_then(|creation_id| {
snapshot
.instances
.iter()
.find(|instance| instance.id == *creation_id)
.cloned()
}),
};
if let Some(existing) = &existing {
if id.is_none() && !same_creation_request(existing, &input) {
return Err(AdminError::conflict("此配置已创建,请加载最新配置后编辑"));
}
if input
.expected_revision
.is_some_and(|revision| revision != existing.revision.get())
{
return Err(AdminError::conflict("插件配置已变更,请加载最新配置后重试"));
}
}
self.configure_from_snapshot(snapshot, existing, input, context)
.await
}
pub(super) async fn configure_from_snapshot(
&self,
mut snapshot: PluginInstanceSnapshot,
existing: Option<PluginInstance>,
input: ConfigurePluginInstance,
context: &MutationContext,
) -> Result<PluginInstanceMutation, AdminError> {
let expected_revision = snapshot.config_revision;
let candidate_revision = next_revision(expected_revision)?;
let artifacts = self
.store
.list_artifacts()
.await
.map_err(|error| map_store_error(error, "plugin"))?;
let artifact = artifacts
.iter()
.find(|artifact| artifact.metadata.sha256 == input.artifact_sha256)
.ok_or_else(|| AdminError::not_found("插件制品不存在"))?;
if artifact.accepted_at.is_none() {
return Err(AdminError::invalid("请先安装并接受该版本声明的权限"));
}
if input.enabled
&& let Some(warning) = self
.package_status(&input.artifact_sha256)
.await?
.load_error
{
return Err(AdminError::invalid(format!(
"{warning},无法启动,请安装兼容版本"
)));
}
let same_plugin = |digest: &str| {
artifacts.iter().any(|item| {
item.metadata.sha256 == digest
&& item.metadata.plugin_id == artifact.metadata.plugin_id
})
};
if existing
.as_ref()
.is_some_and(|previous| !same_plugin(&previous.artifact_sha256))
{
return Err(AdminError::invalid("不能将已有配置切换为另一个插件"));
}
if input.enabled
&& snapshot.instances.iter().any(|other| {
other.enabled
&& existing
.as_ref()
.is_none_or(|current| current.id != other.id)
&& same_plugin(&other.artifact_sha256)
&& !input
.replace_instances
.iter()
.any(|replacement| replacement.id == other.id)
})
{
return Err(AdminError::conflict(
"此插件已有启用配置,请先切换或停用旧配置",
));
}
let saved = if let Some(previous) = &existing
&& previous.artifact_sha256 != input.artifact_sha256
&& input.secrets.is_none()
{
self.store
.load_version_configuration(&previous.id, &input.artifact_sha256)
.await
.map_err(|error| map_store_error(error, "plugin"))?
} else {
None
};
let instance = PluginInstance {
id: existing.as_ref().map_or_else(
|| {
input
.creation_id
.clone()
.unwrap_or_else(|| uuid::Uuid::now_v7().to_string())
},
|instance| instance.id.clone(),
),
name: input.name.trim().to_owned(),
artifact_sha256: input.artifact_sha256,
enabled: input.enabled,
trusted_process: true,
configuration: input.configuration,
secrets: input
.secrets
.or_else(|| saved.map(|saved| saved.secrets))
.unwrap_or_else(|| {
existing
.as_ref()
.map_or_else(Default::default, |instance| instance.secrets.clone())
}),
bindings: input.bindings,
revision: candidate_revision,
};
validate_input(&instance)?;
if existing
.as_ref()
.is_some_and(|previous| previous.artifact_sha256 != instance.artifact_sha256)
&& !self
.preparation
.configuration_ready(instance.clone(), &artifact.metadata)
.await?
{
return Err(AdminError::invalid(
"目标版本仍缺少必填设置,请补充后再切换",
));
}
let replacements = input.replace_instances;
if !replacements.is_empty() {
if !instance.enabled || replacements.len() > 256 {
return Err(AdminError::invalid(
"仅启用配置时可以切换其他配置,单次最多 256 项",
));
}
let mut ids = BTreeSet::new();
for replacement in &replacements {
if replacement.id == instance.id || !ids.insert(&replacement.id) {
return Err(AdminError::invalid("停用列表不能重复或包含当前配置"));
}
let previous = snapshot
.instances
.iter()
.find(|item| item.id == replacement.id)
.ok_or_else(|| AdminError::conflict("待停用配置已变更,请重新确认"))?;
if !previous.enabled || previous.revision.get() != replacement.expected_revision {
return Err(AdminError::conflict("待停用配置已变更,请重新确认"));
}
if !artifacts.iter().any(|item| {
item.metadata.sha256 == previous.artifact_sha256
&& item.metadata.plugin_id == artifact.metadata.plugin_id
}) {
return Err(AdminError::invalid("只能切换同一插件的配置"));
}
}
}
let state = self.preparation.validate(instance.clone()).await?;
if let Some(existing) = &existing
&& self.state.transition_required(&existing.id, &state).await?
{
return self
.configure_instance_with_state_transition(
snapshot,
existing.clone(),
instance,
state,
&replacements,
context,
)
.await;
}
snapshot.instances.retain(|item| item.id != instance.id);
snapshot.instances.push(instance.clone());
snapshot.config_revision = candidate_revision;
apply_replacements(&mut snapshot, &replacements);
let prepared = self.preparation.prepare(snapshot.clone()).await?;
let result = self
.store
.save_instance_replacing(
instance,
expected_revision,
PluginStateCommit {
configuration: state,
transition_id: None,
},
&replacements,
context,
)
.await
.map_err(|e| map_store_error(e, "plugin"))?;
self.publish_instance(snapshot, prepared, &result, &replacements)
.await?;
Ok(result)
}
async fn configure_instance_with_state_transition(
&self,
mut snapshot: PluginInstanceSnapshot,
previous: PluginInstance,
mut target: PluginInstance,
target_state: PluginStateConfiguration,
replacements: &[PluginInstanceReplacement],
context: &MutationContext,
) -> Result<PluginInstanceMutation, AdminError> {
let previous_state = self.preparation.validate(previous.clone()).await?;
let original_revision = snapshot.config_revision;
// 即使最终保持停用,迁移也必须由目标制品的受控候选执行
let mut migration_instance = target.clone();
migration_instance.enabled = true;
snapshot.config_revision = migration_instance.revision;
snapshot
.instances
.retain(|instance| instance.id != migration_instance.id);
snapshot.instances.push(migration_instance);
apply_replacements(&mut snapshot, replacements);
let migration_prepared = self.preparation.prepare(snapshot).await?;
let mut disabled_revision = None;
let disabled = if previous.enabled {
let mut disabled = previous.clone();
disabled.enabled = false;
let mutation = self
.store
.save_instance_with_state(
disabled,
original_revision,
PluginStateCommit {
configuration: previous_state.clone(),
transition_id: None,
},
context,
)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?;
self.preparation
.quiesce_instance(&previous.id, &previous.artifact_sha256, previous.revision)
.await;
disabled_revision = Some(mutation.config_revision);
mutation.instance
} else {
previous.clone()
};
let expected_revision = disabled_revision.unwrap_or(original_revision);
let transition = match self
.state
.begin_transition(
&disabled.id,
disabled.revision,
&target.artifact_sha256,
target_state.clone(),
)
.await
{
Ok(transition) => transition,
Err(error) => {
return Err(self
.recover_failed_state_transition(
None,
&previous,
&previous_state,
disabled_revision,
context,
error,
)
.await);
}
};
// 迁移开始后的所有提交前失败都走同一恢复出口;提交成功后不再回滚旧配置
let committed = async {
self.preparation
.migrate_state(&migration_prepared, transition.clone())
.await?;
let mut current = self
.store
.load_instances()
.await
.map_err(|error| map_store_error(error, "plugin"))?;
if current.config_revision != expected_revision {
return Err(AdminError::conflict("迁移期间插件配置已发生变化"));
}
let candidate_revision = next_revision(expected_revision)?;
target.revision = candidate_revision;
current.instances.retain(|item| item.id != target.id);
current.instances.push(target.clone());
current.config_revision = candidate_revision;
apply_replacements(&mut current, replacements);
let prepared = self.preparation.prepare(current.clone()).await?;
let result = self
.store
.save_instance_replacing(
target,
expected_revision,
PluginStateCommit {
configuration: target_state,
transition_id: Some(transition.id.clone()),
},
replacements,
context,
)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
Ok((current, prepared, result))
}
.await;
let (current, prepared, result) = match committed {
Ok(committed) => committed,
Err(error) => {
return Err(self
.recover_failed_state_transition(
Some(&transition.id),
&previous,
&previous_state,
disabled_revision,
context,
error,
)
.await);
}
};
self.publish_instance(current, prepared, &result, replacements)
.await?;
drop(migration_prepared);
Ok(result)
}
async fn recover_failed_state_transition(
&self,
transition_id: Option<&str>,
previous: &PluginInstance,
previous_state: &PluginStateConfiguration,
disabled_revision: Option<Revision>,
context: &MutationContext,
original_error: AdminError,
) -> AdminError {
if let Some(transition_id) = transition_id {
self.state.abort_transition(transition_id).await;
}
let Some(disabled_revision) = disabled_revision else {
return original_error;
};
let restored = async {
let mut snapshot = self
.store
.load_instances()
.await
.map_err(|error| map_store_error(error, "plugin"))?;
if snapshot.config_revision != disabled_revision {
return Err(AdminError::conflict(
"状态迁移失败;实例已保持停用,且配置已被其他操作修改",
));
}
let candidate_revision = next_revision(disabled_revision)?;
let mut restore = previous.clone();
restore.enabled = true;
restore.revision = candidate_revision;
snapshot.instances.retain(|item| item.id != restore.id);
snapshot.instances.push(restore.clone());
snapshot.config_revision = candidate_revision;
let prepared = self.preparation.prepare(snapshot.clone()).await?;
let mutation = self
.store
.save_instance_with_state(
restore,
disabled_revision,
PluginStateCommit {
configuration: previous_state.clone(),
transition_id: None,
},
context,
)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
self.publish_instance(snapshot, prepared, &mutation, &[])
.await?;
Ok::<(), AdminError>(())
}
.await;
match restored {
Ok(()) => original_error,
Err(error) => {
tracing::warn!(
error_kind = ?error.kind(),
"plugin state migration failed and the previous instance could not be restored"
);
AdminError::conflict("状态迁移失败;为避免覆盖并发配置,插件实例已保持停用")
}
}
}
/// 以事务返回的版本激活并发布,候选强引用必须覆盖整个提交后阶段
async fn publish_instance(
&self,
mut snapshot: PluginInstanceSnapshot,
prepared: ExtensionSetReference,
mutation: &PluginInstanceMutation,
replacements: &[PluginInstanceReplacement],
) -> Result<(), AdminError> {
let prepared = if mutation.config_revision == snapshot.config_revision
&& mutation.instance.revision == snapshot.config_revision
{
prepared
} else {
snapshot.config_revision = mutation.config_revision;
snapshot
.instances
.retain(|item| item.id != mutation.instance.id);
snapshot.instances.push(mutation.instance.clone());
apply_replacements(&mut snapshot, replacements);
self.preparation.prepare(snapshot).await?
};
self.preparation
.activate_state(&prepared, &mutation.instance)
.await?;
publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?;
drop(prepared);
Ok(())
}
pub async fn delete_instance(
&self,
id: &str,
context: &MutationContext,
) -> Result<Revision, AdminError> {
let snapshot = self
.store
.load_instances()
.await
.map_err(|e| map_store_error(e, "plugin"))?;
let instance = snapshot
.instances
.iter()
.find(|instance| instance.id == id)
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
if instance.enabled {
return Err(AdminError::conflict("请先停用插件实例"));
}
let revision = self
.store
.delete_instance(id, snapshot.config_revision, context)
.await
.map_err(|e| map_store_error(e, "plugin"))?;
publish_committed(self.snapshots.as_ref(), revision).await?;
Ok(revision)
}
/// 紧急管理修复不要求损坏插件成功准备;提交后沿用现有发布与暂停合同
pub async fn disable_instance(
&self,
id: &str,
context: &MutationContext,
) -> Result<PluginInstanceMutation, AdminError> {
let PluginInstanceSnapshot {
config_revision,
instances,
} = self
.store
.load_instances()
.await
.map_err(|e| map_store_error(e, "plugin"))?;
let mut instance = instances
.into_iter()
.find(|instance| instance.id == id)
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
instance.enabled = false;
let result = self
.store
.save_instance(instance, config_revision, context)
.await
.map_err(|e| map_store_error(e, "plugin"))?;
publish_committed(self.snapshots.as_ref(), result.config_revision).await?;
Ok(result)
}
}
fn apply_replacements(
snapshot: &mut PluginInstanceSnapshot,
replacements: &[PluginInstanceReplacement],
) {
for instance in &mut snapshot.instances {
if replacements.iter().any(|item| item.id == instance.id) {
instance.enabled = false;
instance.revision = snapshot.config_revision;
}
}
}
fn same_creation_request(existing: &PluginInstance, input: &ConfigurePluginInstance) -> bool {
let same_secrets = input
.secrets
.as_ref()
.map_or(existing.secrets.is_empty(), |secrets| {
secrets.len() == existing.secrets.len()
&& secrets.iter().all(|(key, value)| {
existing
.secrets
.get(key)
.is_some_and(|current| current.expose_secret() == value.expose_secret())
})
});
existing.name == input.name.trim()
&& existing.artifact_sha256 == input.artifact_sha256
&& existing.enabled == input.enabled
&& existing.configuration == input.configuration
&& existing.bindings == input.bindings
&& same_secrets
}
fn validate_input(instance: &PluginInstance) -> Result<(), AdminError> {
if instance.name.is_empty()
|| instance.name.len() > 128
|| instance.name.chars().any(char::is_control)
|| instance.artifact_sha256.len() != 64
|| !instance
.artifact_sha256
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|| !instance.configuration.is_object()
|| serde_json::to_vec(&instance.configuration).map_or(true, |bytes| bytes.len() > 48 * 1024)
|| instance.secrets.len() > 64
|| instance.bindings.len() > 64
{
return Err(AdminError::invalid("插件实例配置不合法或超过大小限制"));
}
use secrecy::ExposeSecret as _;
if instance
.secrets
.iter()
.any(|(key, value)| key.len() > 64 || value.expose_secret().len() > 8192)
|| instance
.secrets
.values()
.map(|value| value.expose_secret().len())
.sum::<usize>()
> 48 * 1024
{
return Err(AdminError::invalid("插件敏感配置超过大小限制"));
}
if instance.bindings.iter().any(|binding| {
binding.contribution.is_empty()
|| binding.contribution.len() > 128
|| !binding
.contribution
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-'))
|| binding.client_key_ids.len() > 256
|| binding.account_group_ids.len() > 256
|| binding.client_key_ids.iter().collect::<BTreeSet<_>>().len()
!= binding.client_key_ids.len()
|| binding
.account_group_ids
.iter()
.collect::<BTreeSet<_>>()
.len()
!= binding.account_group_ids.len()
|| binding
.client_key_ids
.iter()
.any(|id| ClientApiKeyId::new(id.clone()).is_err())
|| binding
.account_group_ids
.iter()
.any(|id| AccountGroupId::new(id.clone()).is_err())
|| binding.identity_bindings.len() > 256
|| binding
.identity_bindings
.iter()
.map(|identity| &identity.principal)
.collect::<BTreeSet<_>>()
.len()
!= binding.identity_bindings.len()
|| binding.identity_bindings.iter().any(|identity| {
identity.principal.trim() != identity.principal
|| identity.principal.is_empty()
|| identity.principal.len() > 256
|| identity.principal.chars().any(char::is_control)
|| ClientApiKeyId::new(identity.client_key_id.clone()).is_err()
})
}) {
return Err(AdminError::invalid("插件绑定的范围或入口认证身份不合法"));
}
Ok(())
}
fn next_revision(revision: Revision) -> Result<Revision, AdminError> {
Revision::new(
revision
.get()
.checked_add(1)
.ok_or_else(|| AdminError::internal("配置版本已耗尽"))?,
)
.map_err(|_| AdminError::internal("配置版本不合法"))
}
@@ -0,0 +1,135 @@
//! 插件配置的输入约束、创建幂等比较与候选快照变换
use std::collections::BTreeSet;
use gateway_core::{policy::ClientApiKeyId, routing::AccountGroupId};
use secrecy::ExposeSecret;
use crate::model::{
AdminError, Revision,
plugins::instances::{
ConfigurePluginInstance, PluginInstance, PluginInstanceReplacement, PluginInstanceSnapshot,
},
};
pub(super) fn apply_replacements(
snapshot: &mut PluginInstanceSnapshot,
replacements: &[PluginInstanceReplacement],
) {
for instance in &mut snapshot.instances {
if replacements.iter().any(|item| item.id == instance.id) {
instance.enabled = false;
instance.revision = snapshot.config_revision;
}
}
}
pub(super) fn same_creation_request(
existing: &PluginInstance,
input: &ConfigurePluginInstance,
) -> bool {
let same_secrets = input
.secrets
.as_ref()
.map_or(existing.secrets.is_empty(), |secrets| {
secrets.len() == existing.secrets.len()
&& secrets.iter().all(|(key, value)| {
existing
.secrets
.get(key)
.is_some_and(|current| current.expose_secret() == value.expose_secret())
})
});
existing.name == input.name.trim()
&& existing.artifact_sha256 == input.artifact_sha256
&& existing.enabled == input.enabled
&& existing.configuration == input.configuration
&& existing.bindings == input.bindings
&& same_secrets
}
pub(super) fn validate_input(instance: &PluginInstance) -> Result<(), AdminError> {
if instance.name.is_empty()
|| instance.name.len() > 128
|| instance.name.chars().any(char::is_control)
|| instance.artifact_sha256.len() != 64
|| !instance
.artifact_sha256
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|| !instance.configuration.is_object()
|| serde_json::to_vec(&instance.configuration).map_or(true, |bytes| bytes.len() > 48 * 1024)
|| instance.secrets.len() > 64
|| instance.bindings.len() > 64
{
return Err(AdminError::invalid("插件实例配置不合法或超过大小限制"));
}
use secrecy::ExposeSecret as _;
if instance
.secrets
.iter()
.any(|(key, value)| key.len() > 64 || value.expose_secret().len() > 8192)
|| instance
.secrets
.values()
.map(|value| value.expose_secret().len())
.sum::<usize>()
> 48 * 1024
{
return Err(AdminError::invalid("插件敏感配置超过大小限制"));
}
if instance.bindings.iter().any(|binding| {
binding.contribution.is_empty()
|| binding.contribution.len() > 128
|| !binding
.contribution
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-'))
|| binding.client_key_ids.len() > 256
|| binding.account_group_ids.len() > 256
|| binding.client_key_ids.iter().collect::<BTreeSet<_>>().len()
!= binding.client_key_ids.len()
|| binding
.account_group_ids
.iter()
.collect::<BTreeSet<_>>()
.len()
!= binding.account_group_ids.len()
|| binding
.client_key_ids
.iter()
.any(|id| ClientApiKeyId::new(id.clone()).is_err())
|| binding
.account_group_ids
.iter()
.any(|id| AccountGroupId::new(id.clone()).is_err())
|| binding.identity_bindings.len() > 256
|| binding
.identity_bindings
.iter()
.map(|identity| &identity.principal)
.collect::<BTreeSet<_>>()
.len()
!= binding.identity_bindings.len()
|| binding.identity_bindings.iter().any(|identity| {
identity.principal.trim() != identity.principal
|| identity.principal.is_empty()
|| identity.principal.len() > 256
|| identity.principal.chars().any(char::is_control)
|| ClientApiKeyId::new(identity.client_key_id.clone()).is_err()
})
}) {
return Err(AdminError::invalid("插件绑定的范围或入口认证身份不合法"));
}
Ok(())
}
pub(super) fn next_revision(revision: Revision) -> Result<Revision, AdminError> {
Revision::new(
revision
.get()
.checked_add(1)
.ok_or_else(|| AdminError::internal("配置版本已耗尽"))?,
)
.map_err(|_| AdminError::internal("配置版本不合法"))
}
@@ -0,0 +1,226 @@
//! 插件私有状态迁移的排空、暂存提交与失败恢复编排
use super::{
PluginsService,
configuration::{apply_replacements, next_revision},
};
use crate::{
model::{
AdminError, MutationContext, Revision,
plugins::{
instances::{
PluginCompatibilityConfirmation, PluginInstance, PluginInstanceMutation,
PluginInstanceReplacement, PluginInstanceSnapshot,
},
state::{PluginStateCommit, PluginStateConfiguration},
},
},
use_case::{map_store_error, publish_committed},
};
impl PluginsService {
pub(super) async fn configure_instance_with_state_transition(
&self,
mut snapshot: PluginInstanceSnapshot,
mut target: PluginInstance,
target_state: PluginStateConfiguration,
replacements: &[PluginInstanceReplacement],
confirmation: Option<&PluginCompatibilityConfirmation>,
context: &MutationContext,
) -> Result<PluginInstanceMutation, AdminError> {
let previous = snapshot
.instances
.iter()
.find(|instance| instance.id == target.id)
.cloned()
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
let previous_state = self.preparation.validate(previous.clone()).await?;
let original_revision = snapshot.config_revision;
// 即使最终保持停用,迁移也必须由目标制品的受控候选执行
let mut migration_instance = target.clone();
migration_instance.enabled = true;
snapshot.config_revision = migration_instance.revision;
snapshot
.instances
.retain(|instance| instance.id != migration_instance.id);
snapshot.instances.push(migration_instance);
apply_replacements(&mut snapshot, replacements);
let migration_prepared = self
.prepare_instance_candidate(snapshot, confirmation)
.await?;
let mut disabled_revision = None;
let disabled = if previous.enabled {
let mut disabled = previous.clone();
disabled.enabled = false;
let mutation = self
.store
.save_instance_with_state(
disabled,
original_revision,
PluginStateCommit {
configuration: previous_state.clone(),
transition_id: None,
},
context,
)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?;
self.preparation
.quiesce_instance(&previous.id, &previous.artifact_sha256, previous.revision)
.await;
disabled_revision = Some(mutation.config_revision);
mutation.instance
} else {
previous.clone()
};
let expected_revision = disabled_revision.unwrap_or(original_revision);
let transition = match self
.state
.begin_transition(
&disabled.id,
disabled.revision,
&target.artifact_sha256,
target_state.clone(),
)
.await
{
Ok(transition) => transition,
Err(error) => {
return Err(self
.recover_failed_state_transition(
None,
&previous,
&previous_state,
disabled_revision,
context,
error,
)
.await);
}
};
// 迁移开始后的所有提交前失败都走同一恢复出口;提交成功后不再回滚旧配置
let committed = async {
self.preparation
.migrate_state(&migration_prepared, transition.clone())
.await?;
let mut current = self
.store
.load_instances()
.await
.map_err(|error| map_store_error(error, "plugin"))?;
if current.config_revision != expected_revision {
return Err(AdminError::conflict("迁移期间插件配置已发生变化"));
}
let candidate_revision = next_revision(expected_revision)?;
target.revision = candidate_revision;
current.instances.retain(|item| item.id != target.id);
current.instances.push(target.clone());
current.config_revision = candidate_revision;
apply_replacements(&mut current, replacements);
let prepared = self
.prepare_instance_candidate(current.clone(), confirmation)
.await?;
let result = self
.store
.save_instance_replacing(
target,
expected_revision,
PluginStateCommit {
configuration: target_state,
transition_id: Some(transition.id.clone()),
},
replacements,
context,
)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
Ok((current, prepared, result))
}
.await;
let (current, prepared, result) = match committed {
Ok(committed) => committed,
Err(error) => {
return Err(self
.recover_failed_state_transition(
Some(&transition.id),
&previous,
&previous_state,
disabled_revision,
context,
error,
)
.await);
}
};
self.publish_instance(current, prepared, &result, replacements, confirmation)
.await?;
drop(migration_prepared);
Ok(result)
}
async fn recover_failed_state_transition(
&self,
transition_id: Option<&str>,
previous: &PluginInstance,
previous_state: &PluginStateConfiguration,
disabled_revision: Option<Revision>,
context: &MutationContext,
original_error: AdminError,
) -> AdminError {
if let Some(transition_id) = transition_id {
self.state.abort_transition(transition_id).await;
}
let Some(disabled_revision) = disabled_revision else {
return original_error;
};
let restored = async {
let mut snapshot = self
.store
.load_instances()
.await
.map_err(|error| map_store_error(error, "plugin"))?;
if snapshot.config_revision != disabled_revision {
return Err(AdminError::conflict(
"状态迁移失败;实例已保持停用,且配置已被其他操作修改",
));
}
let candidate_revision = next_revision(disabled_revision)?;
let mut restore = previous.clone();
restore.enabled = true;
restore.revision = candidate_revision;
snapshot.instances.retain(|item| item.id != restore.id);
snapshot.instances.push(restore.clone());
snapshot.config_revision = candidate_revision;
let prepared = self.preparation.prepare(snapshot.clone()).await?;
let mutation = self
.store
.save_instance_with_state(
restore,
disabled_revision,
PluginStateCommit {
configuration: previous_state.clone(),
transition_id: None,
},
context,
)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
self.publish_instance(snapshot, prepared, &mutation, &[], None)
.await?;
Ok::<(), AdminError>(())
}
.await;
match restored {
Ok(()) => original_error,
Err(error) => {
tracing::warn!(
error_kind = ?error.kind(),
"plugin state migration failed and the previous instance could not be restored"
);
AdminError::conflict("状态迁移失败;为避免覆盖并发配置,插件实例已保持停用")
}
}
}
}
@@ -0,0 +1,15 @@
//! 插件实例用例的职责边界与静态检查结果
mod configuration;
mod migration;
mod mutations;
mod publication;
mod queries;
use super::PluginsService;
#[derive(Clone, Default)]
pub(super) struct PackageStatus {
pub(super) warning: Option<String>,
pub(super) load_error: Option<String>,
}
@@ -0,0 +1,333 @@
//! 插件实例的配置提交、发布、停用与删除用例
use std::collections::BTreeSet;
use super::{
PluginsService,
configuration::{apply_replacements, next_revision, same_creation_request, validate_input},
};
use crate::{
model::{
AdminError, MutationContext, Revision,
plugins::{
instances::{
ConfigurePluginInstance, PluginInstance, PluginInstanceMutation,
PluginInstanceSnapshot,
},
state::PluginStateCommit,
},
},
use_case::{map_store_error, publish_committed},
};
impl PluginsService {
pub async fn configure_instance(
&self,
id: Option<&str>,
input: ConfigurePluginInstance,
context: &MutationContext,
) -> Result<PluginInstanceMutation, AdminError> {
if id.is_some() && input.creation_id.is_some() {
return Err(AdminError::invalid("编辑配置不能使用创建标识"));
}
if id.is_none() && input.expected_revision.is_some() {
return Err(AdminError::invalid("创建配置不能指定已有配置版本"));
}
if let Some(creation_id) = &input.creation_id {
let parsed = uuid::Uuid::parse_str(creation_id)
.map_err(|_| AdminError::invalid("配置创建标识必须是 UUID"))?;
if parsed.to_string() != *creation_id {
return Err(AdminError::invalid("配置创建标识必须使用标准 UUID 格式"));
}
}
let snapshot = self
.store
.load_instances()
.await
.map_err(|e| map_store_error(e, "plugin"))?;
let existing = match id {
Some(id) => Some(
snapshot
.instances
.iter()
.find(|instance| instance.id == id)
.cloned()
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?,
),
None => input.creation_id.as_ref().and_then(|creation_id| {
snapshot
.instances
.iter()
.find(|instance| instance.id == *creation_id)
.cloned()
}),
};
if let Some(existing) = &existing {
if id.is_none() && !same_creation_request(existing, &input) {
return Err(AdminError::conflict("此配置已创建,请加载最新配置后编辑"));
}
if input
.expected_revision
.is_some_and(|revision| revision != existing.revision.get())
{
return Err(AdminError::conflict("插件配置已变更,请加载最新配置后重试"));
}
}
self.configure_from_snapshot(snapshot, existing, input, context)
.await
}
pub(in crate::use_case::plugins) async fn configure_from_snapshot(
&self,
mut snapshot: PluginInstanceSnapshot,
existing: Option<PluginInstance>,
input: ConfigurePluginInstance,
context: &MutationContext,
) -> Result<PluginInstanceMutation, AdminError> {
let expected_revision = snapshot.config_revision;
let candidate_revision = next_revision(expected_revision)?;
let artifacts = self
.store
.list_artifacts()
.await
.map_err(|error| map_store_error(error, "plugin"))?;
let artifact = artifacts
.iter()
.find(|artifact| artifact.metadata.sha256 == input.artifact_sha256)
.ok_or_else(|| AdminError::not_found("插件制品不存在"))?;
if artifact.accepted_at.is_none() {
return Err(AdminError::invalid("请先安装并确认完整信任该版本"));
}
let confirmation = input.compatibility_confirmation.as_ref();
if let Some(confirmed) = confirmation
&& (!input.enabled
|| existing.as_ref().is_none_or(|instance| {
instance.id != confirmed.instance_id
|| instance.revision.get() != confirmed.instance_revision
})
|| input.artifact_sha256 != confirmed.artifact_sha256
|| self.inspector.compatibility_host_fingerprint()? != confirmed.host_fingerprint)
{
return Err(AdminError::conflict(
"宿主、插件包或配置已变化,请重新确认启动风险",
));
}
if input.enabled {
let status = self.package_status(&input.artifact_sha256).await?;
if let Some(reason) = status.load_error {
return Err(AdminError::invalid(format!(
"{reason},无法启动,请安装兼容版本"
)));
}
if let Some(reason) = status.warning
&& confirmation.is_none()
{
return Err(AdminError::conflict(format!(
"插件版本不兼容:{reason};请先确认启动风险"
)));
}
}
let same_plugin = |digest: &str| {
artifacts.iter().any(|item| {
item.metadata.sha256 == digest
&& item.metadata.plugin_id == artifact.metadata.plugin_id
})
};
if existing
.as_ref()
.is_some_and(|previous| !same_plugin(&previous.artifact_sha256))
{
return Err(AdminError::invalid("不能将已有配置切换为另一个插件"));
}
if input.enabled
&& snapshot.instances.iter().any(|other| {
other.enabled
&& existing
.as_ref()
.is_none_or(|current| current.id != other.id)
&& same_plugin(&other.artifact_sha256)
&& !input
.replace_instances
.iter()
.any(|replacement| replacement.id == other.id)
})
{
return Err(AdminError::conflict(
"此插件已有启用配置,请先切换或停用旧配置",
));
}
let saved = if let Some(previous) = &existing
&& previous.artifact_sha256 != input.artifact_sha256
&& input.secrets.is_none()
{
self.store
.load_version_configuration(&previous.id, &input.artifact_sha256)
.await
.map_err(|error| map_store_error(error, "plugin"))?
} else {
None
};
let instance = PluginInstance {
id: existing.as_ref().map_or_else(
|| {
input
.creation_id
.clone()
.unwrap_or_else(|| uuid::Uuid::now_v7().to_string())
},
|instance| instance.id.clone(),
),
name: input.name.trim().to_owned(),
artifact_sha256: input.artifact_sha256,
enabled: input.enabled,
trusted_process: true,
configuration: input.configuration,
secrets: input
.secrets
.or_else(|| saved.map(|saved| saved.secrets))
.unwrap_or_else(|| {
existing
.as_ref()
.map_or_else(Default::default, |instance| instance.secrets.clone())
}),
bindings: input.bindings,
revision: candidate_revision,
};
validate_input(&instance)?;
if existing
.as_ref()
.is_some_and(|previous| previous.artifact_sha256 != instance.artifact_sha256)
&& !self
.preparation
.configuration_ready(instance.clone(), &artifact.metadata)
.await?
{
return Err(AdminError::invalid(
"目标版本仍缺少必填设置,请补充后再切换",
));
}
let replacements = input.replace_instances;
if !replacements.is_empty() {
if !instance.enabled || replacements.len() > 256 {
return Err(AdminError::invalid(
"仅启用配置时可以切换其他配置,单次最多 256 项",
));
}
let mut ids = BTreeSet::new();
for replacement in &replacements {
if replacement.id == instance.id || !ids.insert(&replacement.id) {
return Err(AdminError::invalid("停用列表不能重复或包含当前配置"));
}
let previous = snapshot
.instances
.iter()
.find(|item| item.id == replacement.id)
.ok_or_else(|| AdminError::conflict("待停用配置已变更,请重新确认"))?;
if !previous.enabled || previous.revision.get() != replacement.expected_revision {
return Err(AdminError::conflict("待停用配置已变更,请重新确认"));
}
if !artifacts.iter().any(|item| {
item.metadata.sha256 == previous.artifact_sha256
&& item.metadata.plugin_id == artifact.metadata.plugin_id
}) {
return Err(AdminError::invalid("只能切换同一插件的配置"));
}
}
}
let state = self.preparation.validate(instance.clone()).await?;
if let Some(existing) = &existing
&& self.state.transition_required(&existing.id, &state).await?
{
return self
.configure_instance_with_state_transition(
snapshot,
instance,
state,
&replacements,
confirmation,
context,
)
.await;
}
snapshot.instances.retain(|item| item.id != instance.id);
snapshot.instances.push(instance.clone());
snapshot.config_revision = candidate_revision;
apply_replacements(&mut snapshot, &replacements);
let prepared = self
.prepare_instance_candidate(snapshot.clone(), confirmation)
.await?;
let result = self
.store
.save_instance_replacing(
instance,
expected_revision,
PluginStateCommit {
configuration: state,
transition_id: None,
},
&replacements,
context,
)
.await
.map_err(|e| map_store_error(e, "plugin"))?;
self.publish_instance(snapshot, prepared, &result, &replacements, confirmation)
.await?;
Ok(result)
}
pub async fn delete_instance(
&self,
id: &str,
context: &MutationContext,
) -> Result<Revision, AdminError> {
let snapshot = self
.store
.load_instances()
.await
.map_err(|e| map_store_error(e, "plugin"))?;
let instance = snapshot
.instances
.iter()
.find(|instance| instance.id == id)
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
if instance.enabled {
return Err(AdminError::conflict("请先停用插件实例"));
}
let revision = self
.store
.delete_instance(id, snapshot.config_revision, context)
.await
.map_err(|e| map_store_error(e, "plugin"))?;
publish_committed(self.snapshots.as_ref(), revision).await?;
Ok(revision)
}
/// 紧急管理修复不要求损坏插件成功准备;提交后沿用现有发布与暂停合同
pub async fn disable_instance(
&self,
id: &str,
context: &MutationContext,
) -> Result<PluginInstanceMutation, AdminError> {
let PluginInstanceSnapshot {
config_revision,
instances,
} = self
.store
.load_instances()
.await
.map_err(|e| map_store_error(e, "plugin"))?;
let mut instance = instances
.into_iter()
.find(|instance| instance.id == id)
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
instance.enabled = false;
let result = self
.store
.save_instance(instance, config_revision, context)
.await
.map_err(|e| map_store_error(e, "plugin"))?;
publish_committed(self.snapshots.as_ref(), result.config_revision).await?;
Ok(result)
}
}
@@ -0,0 +1,63 @@
//! 配置提交与迁移恢复共用的状态激活和发布收尾
use gateway_core::routing::extensions::ExtensionSetReference;
use super::{PluginsService, configuration::apply_replacements};
use crate::{
model::{
AdminError,
plugins::instances::{
PluginCompatibilityConfirmation, PluginInstanceMutation, PluginInstanceReplacement,
PluginInstanceSnapshot,
},
},
use_case::publish_committed,
};
impl PluginsService {
pub(super) async fn prepare_instance_candidate(
&self,
snapshot: PluginInstanceSnapshot,
confirmation: Option<&PluginCompatibilityConfirmation>,
) -> Result<ExtensionSetReference, AdminError> {
match confirmation {
Some(confirmation) => {
self.preparation
.prepare_confirmed(snapshot, confirmation.clone())
.await
}
None => self.preparation.prepare(snapshot).await,
}
}
/// 以事务返回的版本激活并发布,候选强引用必须覆盖整个提交后阶段
pub(super) async fn publish_instance(
&self,
mut snapshot: PluginInstanceSnapshot,
prepared: ExtensionSetReference,
mutation: &PluginInstanceMutation,
replacements: &[PluginInstanceReplacement],
confirmation: Option<&PluginCompatibilityConfirmation>,
) -> Result<(), AdminError> {
let prepared = if mutation.config_revision == snapshot.config_revision
&& mutation.instance.revision == snapshot.config_revision
{
prepared
} else {
snapshot.config_revision = mutation.config_revision;
snapshot
.instances
.retain(|item| item.id != mutation.instance.id);
snapshot.instances.push(mutation.instance.clone());
apply_replacements(&mut snapshot, replacements);
self.prepare_instance_candidate(snapshot, confirmation)
.await?
};
self.preparation
.activate_state(&prepared, &mutation.instance)
.await?;
publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?;
drop(prepared);
Ok(())
}
}
@@ -0,0 +1,122 @@
//! 插件实例列表与静态包检查的只读投影,不从发布事实推断进程状态
use std::collections::BTreeMap;
use super::{PackageStatus, PluginsService};
use crate::{
model::{
AdminError, AdminErrorKind,
plugins::instances::{
PluginInstanceRuntime, PluginInstanceRuntimeStatus, PluginInstanceView,
},
},
use_case::map_store_error,
};
impl PluginsService {
/// 只校验固定包体,不启动插件;停用实例也需区分兼容性提醒与加载错误
pub(in crate::use_case::plugins) async fn package_status(
&self,
digest: &str,
) -> Result<PackageStatus, AdminError> {
// 运行宿主和摘要固定,缓存纯静态结论,避免管理页轮询反复解包
// 只缓存有限数量的确定结果,暂时性错误仍可重试
if let Some(status) = self.compatibility.lock().await.get(digest).cloned() {
return Ok(status);
}
let artifact = self
.store
.load_artifact(digest)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
let status = match self
.inspector
.compatibility_warning(artifact.archive, digest.to_owned())
.await
{
Ok(warning) => PackageStatus {
warning,
load_error: None,
},
Err(error) if error.kind() == AdminErrorKind::Invalid => PackageStatus {
warning: None,
load_error: Some(error.message().to_owned()),
},
Err(error) => return Err(error),
};
let mut cache = self.compatibility.lock().await;
if cache.len() < 128 {
cache.insert(digest.to_owned(), status.clone());
}
Ok(status)
}
pub async fn instances(&self) -> Result<Vec<PluginInstanceView>, AdminError> {
let snapshot = self
.store
.load_instances()
.await
.map_err(|e| map_store_error(e, "plugin"))?;
let published = self.published.snapshot_for_diagnostics();
let published_revision = published.as_ref().map(|snapshot| snapshot.revision().get());
let mut diagnostics = self
.preparation
.runtime_diagnostics(
&snapshot,
published_revision,
published
.as_ref()
.and_then(|snapshot| snapshot.extensions()),
)
.await
.unwrap_or_default();
let artifacts = self
.list()
.await?
.into_iter()
.map(|artifact| (artifact.metadata.sha256.clone(), artifact.metadata))
.collect::<BTreeMap<_, _>>();
let mut views = Vec::with_capacity(snapshot.instances.len());
for instance in snapshot.instances {
let metadata = artifacts
.get(&instance.artifact_sha256)
.ok_or_else(|| AdminError::not_found("插件制品不存在"))?;
let package_status = self.package_status(&instance.artifact_sha256).await?;
let configuration_required = package_status.load_error.is_none()
&& !self
.preparation
.configuration_ready(instance.clone(), metadata)
.await?;
let runtime =
diagnostics
.remove(&instance.id)
.unwrap_or_else(|| PluginInstanceRuntime {
status: if !instance.enabled {
PluginInstanceRuntimeStatus::Disabled
} else {
PluginInstanceRuntimeStatus::Unknown
},
actual_revision: None,
actual_artifact_sha256: None,
failure: None,
draining_revisions: Vec::new(),
load: None,
});
views.push(PluginInstanceView {
api_deprecations: if package_status.load_error.is_none() {
self.inspector.api_deprecations(metadata)?
} else {
Vec::new()
},
configuration_required,
compatibility_warning: package_status.warning,
load_error: package_status.load_error,
running: runtime.status == PluginInstanceRuntimeStatus::Running,
published_revision,
runtime,
instance,
});
}
Ok(views)
}
}
@@ -10,9 +10,9 @@ use crate::{
plugins::{
PluginArtifactMetadata,
instances::{
ConfigurePluginInstance, PluginInstance, PluginInstanceMutation,
PluginRollbackPlan, PluginRollbackTarget, PluginVersionConfiguration,
PluginVersionPlan, RollbackPluginInstance,
ConfigurePluginInstance, PluginCompatibilityConfirmation, PluginInstance,
PluginInstanceMutation, PluginRollbackPlan, PluginRollbackTarget,
PluginVersionConfiguration, PluginVersionPlan, RollbackPluginInstance,
},
},
},
@@ -61,6 +61,7 @@ impl PluginsService {
AdminError::invalid("该版本没有可恢复的配置,请通过版本切换检查当前设置")
})?;
let configuration = ConfigurePluginInstance {
compatibility_confirmation: None,
creation_id: None,
expected_revision: None,
replace_instances: Vec::new(),
@@ -142,7 +143,21 @@ impl PluginsService {
.find(|instance| instance.id == id)
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
let (configuration, restored) = self.version_configuration(instance, digest).await?;
let status = self.package_status(digest).await?;
let compatibility_confirmation = if status.warning.is_some() {
Some(PluginCompatibilityConfirmation {
host_fingerprint: self.inspector.compatibility_host_fingerprint()?,
instance_id: instance.id.clone(),
instance_revision: instance.revision.get(),
artifact_sha256: digest.to_owned(),
})
} else {
None
};
Ok(PluginVersionPlan {
compatibility_warning: status.warning,
compatibility_confirmation,
load_error: status.load_error,
instance_revision: instance.revision.get(),
artifact_sha256: digest.to_owned(),
configuration: configuration.configuration,
@@ -179,6 +194,7 @@ impl PluginsService {
.version_configuration(&existing, &input.artifact_sha256)
.await?;
let request = ConfigurePluginInstance {
compatibility_confirmation: None,
creation_id: None,
expected_revision: Some(existing.revision.get()),
replace_instances: Vec::new(),
@@ -47,14 +47,14 @@ pub trait SettingsService: Send + Sync {
&self,
_provider: &str,
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持客户端身份配置"))
Err(AdminError::invalid("该服务商不支持客户端身份配置"))
}
async fn preview_client_profile(
&self,
_provider: &str,
_configuration: Option<&gateway_core::account::OpaqueProviderData>,
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持客户端身份配置"))
Err(AdminError::invalid("该服务商不支持客户端身份配置"))
}
async fn load(&self) -> Result<RuntimeSettings, AdminError>;
async fn replace(
@@ -86,7 +86,7 @@ impl DefaultSettingsService {
provider: &str,
) -> Result<std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>, AdminError> {
let kind = gateway_core::routing::ProviderKind::new(provider)
.map_err(|_| AdminError::invalid("Provider 不合法"))?;
.map_err(|_| AdminError::invalid("服务商无效"))?;
self.providers
.require(&kind)
.map_err(|error| super::map_provider_error(error, "client profile"))
@@ -170,7 +170,7 @@ impl SettingsService for DefaultSettingsService {
.keys()
.any(|provider| !providers.contains_key(provider))
{
return Err(AdminError::invalid("Provider 不支持价目管理"));
return Err(AdminError::invalid("该服务商不支持价目管理"));
}
let mut current = self.pricing_source.fetch().await?;
if current != command.preview {
@@ -242,7 +242,7 @@ impl SettingsService for DefaultSettingsService {
|| model.chars().any(char::is_control)
})
{
return Err(AdminError::invalid("Provider、模型 ID 或批量数量不合法"));
return Err(AdminError::invalid("服务商、模型 ID 或批量数量不合法"));
}
if command.change == PricingChange::Delete
&& command.models.iter().any(|model| {
@@ -146,12 +146,11 @@ fn map_system_error(error: SystemOperationError) -> AdminError {
SystemOperationErrorKind::Upstream => AdminErrorKind::BadGateway,
SystemOperationErrorKind::Internal => AdminErrorKind::Internal,
};
let message = match kind {
AdminErrorKind::Invalid => "系统操作请求不合法",
AdminErrorKind::Conflict => error.message(),
AdminErrorKind::BadGateway => "系统更新服务请求失败",
AdminErrorKind::Internal => "系统操作失败",
_ => "系统操作失败",
let failure = AdminError::new(kind, error.message());
let failure = if error.sensitive_context_redacted() {
failure.redact_sensitive_context()
} else {
failure
};
AdminError::new(kind, message)
failure.with_source(error)
}
@@ -24,9 +24,9 @@ impl DefaultUsageDataService {
reason: reason.into(),
};
let kind = ProviderKind::new(input.provider.clone())
.map_err(|_| UsageDataError::invalid("Provider 无效"))?;
.map_err(|_| UsageDataError::invalid("服务商无效"))?;
let Ok(provider) = self.providers.require(&kind) else {
return Ok(unavailable("Provider 没有可用的计价规则"));
return Ok(unavailable("服务商没有可用的计价规则"));
};
let tokens = &input.tokens;
let price = match &input.price {
@@ -61,9 +61,9 @@ impl DefaultUsageDataService {
ProviderAdminErrorKind::Unsupported | ProviderAdminErrorKind::Invalid
) =>
{
return Ok(unavailable("Provider 不支持该计价输入"));
return Ok(unavailable("服务商不支持该计价输入"));
}
Err(_) => return Err(UsageDataError::unavailable("Provider 计价失败")),
Err(_) => return Err(UsageDataError::unavailable("服务商计价失败")),
};
Ok(UsagePricingResult::Estimated {
id: input.id,
@@ -315,6 +315,7 @@ impl AccountRuntimeStore for FakeRuntimeStore {
) -> AdminStoreResult<AccountRuntimeSnapshot> {
*self.requested_accounts.lock().expect("requested accounts") = account_ids.to_vec();
Ok(AccountRuntimeSnapshot {
protection_rules: std::collections::BTreeMap::new(),
cooldown: BTreeMap::from([(
"acct_limited".to_owned(),
std::time::SystemTime::from(Utc::now() + Duration::minutes(5)).into(),
@@ -329,13 +330,6 @@ impl AccountRuntimeStore for FakeRuntimeStore {
Ok(BTreeMap::new())
}
async fn capacity_peaks(
&self,
_account_ids: &[String],
) -> AdminStoreResult<BTreeMap<String, u32>> {
Ok(BTreeMap::new())
}
async fn finish_freeze(
&self,
_account_id: &str,
@@ -617,7 +617,6 @@ pub(super) struct FakeAccountStore {
automations: Mutex<
BTreeMap<String, gateway_admin::model::reset_credit_automation::ResetCreditAutomation>,
>,
pub(super) lowered_limits: Mutex<Vec<(String, u32)>>,
}
impl FakeAccountStore {
@@ -638,7 +637,6 @@ impl FakeAccountStore {
quota_window_queries: Mutex::new(Vec::new()),
quota_forecast_history: Mutex::new(QuotaForecastHistory::default()),
update_commands: Mutex::new(Vec::new()),
lowered_limits: Mutex::new(Vec::new()),
automations: Mutex::default(),
})
}
@@ -720,6 +718,7 @@ impl FakeAccountStore {
last_error_message: account.last_error_message.clone(),
};
AccountPageItem {
protection_rules: Vec::new(),
capacity: gateway_admin::model::accounts::AccountCapacity {
used_slots: None,
total_slots: account
@@ -1036,35 +1035,6 @@ impl AccountStore for FakeAccountStore {
})
}
async fn lower_concurrency_limit(
&self,
account_id: &ProviderAccountId,
limit: gateway_core::account::AccountConcurrencyLimit,
_: &MutationContext,
) -> AdminStoreResult<Option<AccountUpdateResult>> {
let mut accounts = self.accounts.lock().expect("accounts");
let Some(account) = accounts
.iter_mut()
.find(|account| account.id == account_id.as_str())
else {
return Ok(None);
};
if !account.enabled
|| account.concurrency_limit.map_or(5, |value| value.get()) <= limit.get()
{
return Ok(None);
}
account.concurrency_limit = Some(limit);
self.lowered_limits
.lock()
.expect("lowered limits")
.push((account.id.clone(), limit.get()));
Ok(Some(AccountUpdateResult {
config_revision: revision(2),
account_id: account_id.clone(),
}))
}
async fn recover_account(
&self,
account_id: &ProviderAccountId,
@@ -1189,13 +1159,7 @@ impl SettingsStore for StaticSettingsStore {
usage_retention_days: 30,
ops_event_retention_days: 30,
audit_retention_days: 30,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
account_error_protection: gateway_core::account::AccountErrorProtection::default(),
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
@@ -3124,7 +3088,7 @@ async fn accounts_refresh_should_not_expose_the_provider_failure_message() {
.expect_err("Provider preparation must fail");
assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Conflict);
assert_eq!(error.to_string(), "Provider 资源状态冲突,请刷新后重试");
assert_eq!(error.to_string(), "服务商资源状态冲突,请刷新后重试");
assert!(!error.to_string().contains(upstream_message));
assert_eq!(
recorded(&events),
@@ -3680,6 +3644,7 @@ impl AccountRuntimeStore for CapacityRuntime {
return Err(store_unavailable());
}
Ok(AccountRuntimeSnapshot {
protection_rules: std::collections::BTreeMap::new(),
in_flight: self.counts.clone(),
..Default::default()
})
@@ -3691,10 +3656,6 @@ impl AccountRuntimeStore for CapacityRuntime {
Ok(BTreeMap::new())
}
async fn capacity_peaks(&self, _: &[String]) -> AdminStoreResult<BTreeMap<String, u32>> {
Ok(BTreeMap::new())
}
async fn finish_freeze(
&self,
_: &str,
@@ -1,4 +1,4 @@
//! 容量熔断冻结恢复编排回归:到期探测、失败顺延与自适应并发下调
//! 账号保护恢复编排:到期探测、快照参数与失败顺延
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
@@ -24,7 +24,7 @@ use gateway_core::task::{
use super::AdminHarness;
use super::accounts::{FakeAccountStore, FakeProviderAdmin, account_record, events, revision};
fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> RuntimeSettings {
fn runtime_settings(enabled: bool, probe_enabled: bool) -> RuntimeSettings {
RuntimeSettings {
request_profiles: Default::default(),
config_revision: revision(1),
@@ -52,13 +52,22 @@ fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> Runti
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 90,
account_auto_freeze_enabled: enabled,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: probe_enabled,
account_auto_freeze_probe_model: Some("gpt-5.5".to_owned()),
account_auto_freeze_adaptive_concurrency: adaptive,
account_error_protection: gateway_core::account::AccountErrorProtection {
enabled,
probe_enabled,
probe_model: Some("gpt-5.5".to_owned()),
rules: vec![gateway_core::account::AccountErrorRule {
id: "sample".into(),
name: "示例".into(),
conditions: vec![gateway_core::account::AccountErrorCondition {
field: gateway_core::account::AccountErrorField::Code,
operator: gateway_core::account::AccountErrorOperator::Equals,
values: vec![Some("custom_failure".into())],
ignore_case: false,
}],
}],
..Default::default()
},
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
@@ -131,19 +140,17 @@ fn store_unavailable() -> AdminStoreError {
)
}
/// 记录解冻/顺延调用的运行态 fake;冻结与峰值证据由测试预置
/// 记录解冻与顺延调用,冻结快照由测试预置
struct FreezeRuntimeStore {
freezes: BTreeMap<String, AccountFreeze>,
peaks: BTreeMap<String, u32>,
cleared: Mutex<Vec<String>>,
extended: Mutex<Vec<(String, DateTime<Utc>)>>,
}
impl FreezeRuntimeStore {
fn new(freezes: BTreeMap<String, AccountFreeze>, peaks: BTreeMap<String, u32>) -> Arc<Self> {
fn new(freezes: BTreeMap<String, AccountFreeze>) -> Arc<Self> {
Arc::new(Self {
freezes,
peaks,
cleared: Mutex::new(Vec::new()),
extended: Mutex::new(Vec::new()),
})
@@ -188,16 +195,6 @@ impl AccountRuntimeStore for FreezeRuntimeStore {
Ok(self.freezes.clone())
}
async fn capacity_peaks(
&self,
account_ids: &[String],
) -> AdminStoreResult<BTreeMap<String, u32>> {
Ok(account_ids
.iter()
.filter_map(|id| self.peaks.get(id).map(|peak| (id.clone(), *peak)))
.collect())
}
async fn finish_freeze(
&self,
account_id: &str,
@@ -241,9 +238,10 @@ struct FailingProbe;
impl AccountProbe for FailingProbe {
fn probe(
&self,
_: AccountProbeRequest,
request: AccountProbeRequest,
_: Option<Arc<gateway_core::routing::RuntimeSnapshot>>,
) -> futures::future::BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
assert_eq!(request.upstream_model.as_str(), "snapshot-model");
Box::pin(async {
Err(AccountProbeError::new(
gateway_core::error::GatewayError::new(
@@ -317,6 +315,9 @@ fn freeze_until(until: DateTime<Utc>) -> BTreeMap<String, AccountFreeze> {
BTreeMap::from([(
"acct_test".to_owned(),
AccountFreeze {
duration_seconds: None,
probe_model: None,
protection_rules: Vec::new(),
credential_revision: revision(1),
until,
generation: "freeze-generation".to_owned(),
@@ -331,9 +332,9 @@ fn freeze_due() -> BTreeMap<String, AccountFreeze> {
#[tokio::test]
async fn probe_success_clears_freeze() {
let runtime = FreezeRuntimeStore::new(freeze_due(), BTreeMap::new());
let runtime = FreezeRuntimeStore::new(freeze_due());
let (task, _store) = recovery_task(
runtime_settings(true, true, false),
runtime_settings(true, true),
Arc::clone(&runtime),
Arc::new(SuccessfulProbe),
)
@@ -346,10 +347,13 @@ async fn probe_success_clears_freeze() {
}
#[tokio::test]
async fn probe_failure_postpones_freeze() {
let runtime = FreezeRuntimeStore::new(freeze_due(), BTreeMap::new());
async fn probe_failure_postpones_using_the_freeze_snapshot() {
let mut freezes = freeze_due();
freezes.get_mut("acct_test").unwrap().duration_seconds = Some(300);
freezes.get_mut("acct_test").unwrap().probe_model = Some("snapshot-model".into());
let runtime = FreezeRuntimeStore::new(freezes);
let (task, _store) = recovery_task(
runtime_settings(true, true, false),
runtime_settings(true, true),
Arc::clone(&runtime),
Arc::new(FailingProbe),
)
@@ -364,16 +368,16 @@ async fn probe_failure_postpones_freeze() {
assert_eq!(account_id, "acct_test");
let remaining = until.timestamp_millis() - Utc::now().timestamp_millis();
assert!(
remaining > 7_000_000 && remaining <= 7_200_000,
"postponed freeze should be about 2 hours, got {remaining}ms"
remaining > 290_000 && remaining <= 300_000,
"postponed freeze should use the frozen 5-minute duration, got {remaining}ms"
);
}
#[tokio::test]
async fn disabled_policy_releases_due_freeze_without_probing() {
let runtime = FreezeRuntimeStore::new(freeze_due(), BTreeMap::new());
let runtime = FreezeRuntimeStore::new(freeze_due());
let (task, _store) = recovery_task(
runtime_settings(false, true, false),
runtime_settings(false, true),
Arc::clone(&runtime),
Arc::new(SuccessfulProbe),
)
@@ -385,59 +389,12 @@ async fn disabled_policy_releases_due_freeze_without_probing() {
assert!(runtime.extended().is_empty());
}
#[tokio::test]
async fn adaptive_concurrency_lowers_limit_to_observed_peak() {
let runtime = FreezeRuntimeStore::new(
freeze_until(Utc::now() + TimeDelta::hours(1)),
BTreeMap::from([("acct_test".to_owned(), 4_u32)]),
);
let (task, store) = recovery_task(
runtime_settings(true, false, true),
Arc::clone(&runtime),
Arc::new(SuccessfulProbe),
)
.await;
run_cycle(task.as_ref()).await;
// 峰值 4 × 0.8 = 3(不低于下限 2),低于全局默认 5,应下调到 3
assert_eq!(
*store.lowered_limits.lock().expect("lowered limits"),
vec![("acct_test".to_owned(), 3)]
);
assert!(store.update_commands().is_empty());
assert!(runtime.extended().is_empty());
assert!(runtime.cleared().is_empty());
}
#[tokio::test]
async fn adaptive_concurrency_never_raises_limit() {
let runtime = FreezeRuntimeStore::new(
freeze_until(Utc::now() + TimeDelta::hours(1)),
BTreeMap::from([("acct_test".to_owned(), 40_u32)]),
);
let (task, store) = recovery_task(
runtime_settings(true, false, true),
Arc::clone(&runtime),
Arc::new(SuccessfulProbe),
)
.await;
run_cycle(task.as_ref()).await;
// 峰值 40 的目标 32 高于全局默认 5;只降不升,不下发任何更新
assert!(store.update_commands().is_empty());
}
/// 到期时间还早(超过探测提前量)的冻结不发起探测
#[tokio::test]
async fn probe_skips_freezes_far_from_expiry() {
let runtime = FreezeRuntimeStore::new(
freeze_until(Utc::now() + TimeDelta::hours(1)),
BTreeMap::new(),
);
let runtime = FreezeRuntimeStore::new(freeze_until(Utc::now() + TimeDelta::hours(1)));
let (task, _store) = recovery_task(
runtime_settings(true, true, false),
runtime_settings(true, true),
Arc::clone(&runtime),
Arc::new(SuccessfulProbe),
)
@@ -449,13 +406,10 @@ async fn probe_skips_freezes_far_from_expiry() {
assert!(runtime.extended().is_empty());
}
/// 停用账号不参与探测恢复;并发下调同样跳过
/// 停用账号不参与探测恢复
#[tokio::test]
async fn disabled_accounts_are_skipped() {
let runtime = FreezeRuntimeStore::new(
freeze_due(),
BTreeMap::from([("acct_test".to_owned(), 4_u32)]),
);
let runtime = FreezeRuntimeStore::new(freeze_due());
let mut record = account_record("openai");
record.enabled = false;
let store = FakeAccountStore::with_account(record, events());
@@ -463,7 +417,7 @@ async fn disabled_accounts_are_skipped() {
.accounts(Arc::clone(&store) as Arc<dyn AccountStore>)
.account_runtime(Arc::clone(&runtime) as Arc<dyn AccountRuntimeStore>)
.settings(Arc::new(FreezeSettingsStore {
settings: runtime_settings(true, true, true),
settings: runtime_settings(true, true),
}))
.provider(FakeProviderAdmin::new("openai", events()))
.probe(Arc::new(SuccessfulProbe))
@@ -614,15 +614,6 @@ impl AccountStore for UnavailableStore {
Err(unavailable("account enabled"))
}
async fn lower_concurrency_limit(
&self,
_: &gateway_core::account::ProviderAccountId,
_: gateway_core::account::AccountConcurrencyLimit,
_: &MutationContext,
) -> AdminStoreResult<Option<gateway_admin::model::accounts::AccountUpdateResult>> {
Ok(None)
}
async fn recover_account(
&self,
_: &ProviderAccountId,
@@ -685,13 +676,6 @@ impl AccountRuntimeStore for UnavailableStore {
Ok(BTreeMap::new())
}
async fn capacity_peaks(
&self,
_account_ids: &[String],
) -> AdminStoreResult<BTreeMap<String, u32>> {
Ok(BTreeMap::new())
}
async fn finish_freeze(
&self,
_account_id: &str,
@@ -22,8 +22,9 @@ use gateway_admin::{
DiagnosticDimension, DiagnosticObservation, DiagnosticsObservation, Granularity,
HealthStatus, LatencyPercentiles, ObservabilityPageSize, OpsErrorPage, OpsErrorQuery,
PercentileMilliseconds, RequestMetricPoint, RequestMetrics, TimeRange, TrendKind,
UsageBilling, UsageDetail, UsageFilter, UsageInsightsObservation, UsageListRecord,
UsageOverview, UsagePage, UsageProjectedCostPoint, UsageQuery, UsageSummaryObservation,
UsageBilling, UsageConcurrencyPoint, UsageDetail, UsageFilter,
UsageInsightsObservation, UsageListRecord, UsageOverview, UsagePage,
UsageProjectedCostPoint, UsageQuery, UsageSummaryObservation,
},
settings::{
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
@@ -678,10 +679,26 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
assert_eq!(insights.health.success_rate, 0.75);
assert_eq!(insights.health.completion_rate, 1.0);
assert_eq!(insights.granularity, Granularity::FifteenMinutes);
assert_eq!(insights.performance.latency_coverage, 0.5);
assert_eq!(insights.performance.admission_decision_coverage, 1.0);
assert_eq!(insights.performance.account_selection_wait_coverage, 0.8);
assert_eq!(insights.performance.capacity_coverage, 0.8);
assert_eq!(
insights
.performance
.load
.admission_decision_p95_ms
.map(|value| value.as_f64()),
Some(5.0)
);
assert_eq!(
insights
.performance
.load
.account_selection_wait_p95_ms
.map(|value| value.as_f64()),
Some(40.0)
);
assert_eq!(
insights.performance.points[0].load,
insights.performance.load
);
assert_eq!(insights.performance.output_throughput_p50, Some(50));
assert_eq!(insights.cost.tokens_per_request, 100.0);
assert_eq!(
@@ -742,6 +759,52 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
assert_eq!(diagnostics.items[1].request_share, 0.25);
}
#[tokio::test]
async fn usage_insights_should_weight_concurrency_by_effective_bucket_duration() {
let bucket_start = DateTime::parse_from_rfc3339("2026-09-01T10:00:00Z")
.unwrap()
.with_timezone(&Utc);
let range = TimeRange::new(
bucket_start + Duration::minutes(5),
bucket_start + Duration::minutes(20),
)
.unwrap();
let store = Arc::new(FixtureObservabilityStore::new(range));
store.replace_trend(vec![
health_metric_point(bucket_start, RequestMetrics::default()),
health_metric_point(
bucket_start + Duration::minutes(15),
RequestMetrics::default(),
),
]);
*store.concurrency.lock().unwrap() = vec![
UsageConcurrencyPoint {
bucket_start,
average_concurrency: 1.0,
peak_concurrency: 1,
},
UsageConcurrencyPoint {
bucket_start: bucket_start + Duration::minutes(15),
average_concurrency: 3.0,
peak_concurrency: 4,
},
];
let insights = observability_services(store)
.await
.observability()
.usage_insights(range, UsageFilter::default())
.await
.unwrap();
assert!((insights.performance.load.average_concurrency - 5.0 / 3.0).abs() < 1e-10);
assert_eq!(insights.performance.load.peak_concurrency, 4);
assert_eq!(insights.performance.points[0].load.average_concurrency, 1.0);
assert_eq!(insights.performance.points[0].load.peak_concurrency, 1);
assert_eq!(insights.performance.points[1].load.average_concurrency, 3.0);
assert_eq!(insights.performance.points[1].load.peak_concurrency, 4);
}
#[tokio::test]
async fn diagnostics_should_preserve_request_order_when_a_small_sample_fails() {
let now = Utc::now();
@@ -937,6 +1000,7 @@ async fn dashboard_should_preserve_provider_plan_names() {
authentication_kind: "oauth".to_owned(),
name: (*plan).to_owned(),
email: None,
notes: None,
plan_type: Some((*plan).to_owned()),
plan_type_display: None,
request_count: 0,
@@ -987,6 +1051,7 @@ struct FixtureObservabilityStore {
trend: Mutex<Vec<RequestMetricPoint>>,
overview: Mutex<UsageOverview>,
projected_costs: Mutex<Vec<UsageProjectedCostPoint>>,
concurrency: Mutex<Vec<UsageConcurrencyPoint>>,
insights_fail: AtomicBool,
summary_fail: AtomicBool,
diagnostics: Mutex<DiagnosticsObservation>,
@@ -1014,6 +1079,7 @@ impl FixtureObservabilityStore {
providers: Vec::new(),
}),
projected_costs: Mutex::new(Vec::new()),
concurrency: Mutex::new(Vec::new()),
insights_fail: AtomicBool::new(false),
summary_fail: AtomicBool::new(false),
diagnostics: Mutex::new(DiagnosticsObservation::default()),
@@ -1144,6 +1210,7 @@ impl ObservabilityStore for FixtureObservabilityStore {
.lock()
.expect("projected costs")
.clone(),
concurrency: self.concurrency.lock().expect("concurrency").clone(),
})
}
@@ -1253,13 +1320,7 @@ impl SettingsStore for FixtureSettingsStore {
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 30,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
account_error_protection: gateway_core::account::AccountErrorProtection::default(),
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
@@ -1361,7 +1422,6 @@ fn total_record(
provider_account_authentication_kind: None,
upstream_model_id: Some("gpt-5.5".to_owned()),
upstream_transport: None,
upstream_response_model: None,
service_tier: None,
input_tokens: Some(800),
output_tokens: Some(200),
@@ -39,6 +39,7 @@ struct FixtureData {
artifact: InstalledPluginArtifact,
snapshot: PluginInstanceSnapshot,
saves: usize,
compatibility_warning: Option<String>,
publications: Vec<u64>,
}
@@ -61,6 +62,7 @@ impl Fixture {
instances: existing.into_iter().collect(),
},
saves: 0,
compatibility_warning: None,
publications: Vec::new(),
}),
})
@@ -89,6 +91,14 @@ impl SnapshotControl for Fixture {
#[async_trait]
impl gateway_admin::ports::plugins::PluginPackageInspector for Fixture {
async fn compatibility_warning(
&self,
_: Arc<[u8]>,
_: String,
) -> Result<Option<String>, AdminError> {
Ok(self.data.lock().unwrap().compatibility_warning.clone())
}
async fn inspect(
&self,
archive: Arc<[u8]>,
@@ -488,3 +498,20 @@ fn context() -> MutationContext {
fn store_error(kind: AdminStoreErrorKind) -> AdminStoreError {
AdminStoreError::new(kind, "plugin", "install fixture")
}
#[tokio::test]
async fn installing_an_incompatible_package_keeps_the_default_instance_stopped() {
let fixture = Fixture::new(metadata(true), None);
fixture.data.lock().unwrap().compatibility_warning = Some("宿主范围不匹配".into());
let result = fixture
.service()
.accept_artifact(DIGEST, &context())
.await
.unwrap();
assert!(!result.configuration_required);
let data = fixture.data.lock().unwrap();
assert_eq!(data.snapshot.instances.len(), 1);
assert!(!data.snapshot.instances[0].enabled);
assert!(!data.snapshot.instances[0].bindings.is_empty());
assert!(data.artifact.accepted_at.is_some());
}
@@ -49,6 +49,8 @@ use serde_json::json;
use super::TestPluginPorts;
mod queries;
const OLD_ARTIFACT: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const NEW_ARTIFACT: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
@@ -61,6 +63,10 @@ enum MigrationBehavior {
#[async_trait]
impl gateway_admin::ports::plugins::PluginPackageInspector for LifecycleFixture {
fn compatibility_host_fingerprint(&self) -> Result<String, AdminError> {
Ok("test-host".into())
}
fn api_deprecations(
&self,
metadata: &PluginArtifactMetadata,
@@ -88,6 +94,11 @@ impl gateway_admin::ports::plugins::PluginPackageInspector for LifecycleFixture
archive: Arc<[u8]>,
digest: String,
) -> Result<Option<String>, AdminError> {
let gate = self.inspection_gate.lock().unwrap().clone();
if let Some(gate) = gate.filter(|gate| gate.digest == digest) {
gate.started.notify_one();
gate.release.notified().await;
}
self.inspect(archive, Some(digest)).await?;
Ok(self.data.lock().unwrap().compatibility_warning.clone())
}
@@ -143,11 +154,19 @@ struct LifecycleFixture {
behavior: MigrationBehavior,
data: Mutex<FixtureData>,
next_prepared: AtomicU64,
inspection_gate: Mutex<Option<Arc<InspectionGate>>>,
}
struct InspectionGate {
digest: String,
started: tokio::sync::Notify,
release: tokio::sync::Notify,
}
impl LifecycleFixture {
fn new(behavior: MigrationBehavior) -> Arc<Self> {
Arc::new(Self {
inspection_gate: Mutex::new(None),
behavior,
data: Mutex::new(FixtureData {
snapshot: PluginInstanceSnapshot {
@@ -267,6 +286,22 @@ impl ExtensionSetLease for Lease {
#[async_trait]
impl PluginPreparation for LifecycleFixture {
async fn prepare_confirmed(
&self,
snapshot: PluginInstanceSnapshot,
confirmation: gateway_admin::model::plugins::instances::PluginCompatibilityConfirmation,
) -> Result<ExtensionSetReference, AdminError> {
assert_eq!(confirmation.host_fingerprint, "test-host");
assert!(
snapshot
.instances
.iter()
.any(|instance| instance.id == confirmation.instance_id
&& instance.artifact_sha256 == confirmation.artifact_sha256)
);
self.prepare(snapshot).await
}
async fn configuration_ready(
&self,
instance: PluginInstance,
@@ -716,6 +751,7 @@ fn rollback_input() -> RollbackPluginInstance {
fn input() -> ConfigurePluginInstance {
ConfigurePluginInstance {
compatibility_confirmation: None,
replace_instances: Vec::new(),
creation_id: None,
expected_revision: None,
@@ -970,49 +1006,6 @@ fn state_unavailable<T>() -> PluginStateStoreResult<T> {
))
}
#[tokio::test]
async fn instance_list_uses_runtime_diagnostics_and_keeps_running_compatibility_derived() {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
let expected = PluginInstanceRuntime {
status: PluginInstanceRuntimeStatus::PreparationFailed,
actual_revision: Some(9),
actual_artifact_sha256: Some(OLD_ARTIFACT.into()),
failure: Some(PluginInstanceRuntimeFailure {
code: "unavailable".into(),
message: "插件候选准备失败".into(),
}),
draining_revisions: vec![8],
};
fixture.data.lock().unwrap().diagnostics = Some(BTreeMap::from([(
"00000000-0000-7000-8000-000000000001".into(),
expected.clone(),
)]));
let views = service(fixture, Arc::new(Published::default()))
.instances()
.await
.unwrap();
assert_eq!(views.len(), 1);
assert!(!views[0].running);
assert_eq!(views[0].runtime, expected);
}
#[tokio::test]
async fn instance_list_falls_back_without_inventing_runtime_failures() {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
let views = service(fixture, Arc::new(Published::default()))
.instances()
.await
.unwrap();
assert_eq!(views.len(), 1);
assert_eq!(
views[0].runtime.status,
PluginInstanceRuntimeStatus::AwaitingPublication
);
assert!(!views[0].running);
assert!(views[0].runtime.failure.is_none());
}
#[tokio::test]
async fn incompatible_state_upgrade_quiesces_migrates_and_promotes_the_target() {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
@@ -1644,7 +1637,7 @@ async fn transient_compatibility_check_failure_can_be_retried() {
}
#[tokio::test]
async fn compatibility_warning_allows_enabling_and_persists_after_success() {
async fn incompatible_start_requires_confirmation_and_preserves_the_warning() {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
{
let mut data = fixture.data.lock().unwrap();
@@ -1657,6 +1650,21 @@ async fn compatibility_warning_allows_enabling_and_persists_after_success() {
assert!(view.load_error.is_none());
let mut request = input();
request.artifact_sha256 = OLD_ARTIFACT.into();
let error = service
.configure_instance(Some(&view.instance.id), request, &context())
.await
.err()
.unwrap();
assert_eq!(error.kind(), AdminErrorKind::Conflict);
assert!(fixture.data.lock().unwrap().saves.is_empty());
assert!(fixture.data.lock().unwrap().prepared_snapshots.is_empty());
let plan = service
.version_plan(&view.instance.id, OLD_ARTIFACT)
.await
.unwrap();
let mut request = input();
request.artifact_sha256 = OLD_ARTIFACT.into();
request.compatibility_confirmation = plan.compatibility_confirmation;
let result = service
.configure_instance(Some(&view.instance.id), request, &context())
.await
@@ -1667,3 +1675,54 @@ async fn compatibility_warning_allows_enabling_and_persists_after_success() {
assert!(view.compatibility_warning.is_some());
assert!(view.load_error.is_none());
}
#[tokio::test]
async fn incompatible_start_rejects_stale_confirmations_and_unloadable_packages() {
for mismatch in ["host", "instance", "revision", "artifact", "unloadable"] {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
{
let mut data = fixture.data.lock().unwrap();
data.snapshot.instances[0].enabled = false;
data.compatibility_warning = Some("middleware v2 未声明兼容".into());
if mismatch == "unloadable" {
data.inspection_error = Some(AdminErrorKind::Invalid);
}
}
let service = service(fixture.clone(), Arc::new(Published::default()));
let previous = fixture.data.lock().unwrap().snapshot.instances[0].clone();
let mut confirmation =
gateway_admin::model::plugins::instances::PluginCompatibilityConfirmation {
host_fingerprint: "test-host".into(),
instance_id: previous.id.clone(),
instance_revision: previous.revision.get(),
artifact_sha256: OLD_ARTIFACT.into(),
};
match mismatch {
"host" => confirmation.host_fingerprint = "another-host".into(),
"instance" => confirmation.instance_id = "another-instance".into(),
"revision" => confirmation.instance_revision += 1,
"artifact" => confirmation.artifact_sha256 = NEW_ARTIFACT.into(),
_ => {}
}
let mut request = input();
request.artifact_sha256 = OLD_ARTIFACT.into();
request.compatibility_confirmation = Some(confirmation);
let error = service
.configure_instance(Some(&previous.id), request, &context())
.await
.err()
.unwrap();
assert_eq!(
error.kind(),
if mismatch == "unloadable" {
AdminErrorKind::Invalid
} else {
AdminErrorKind::Conflict
}
);
let data = fixture.data.lock().unwrap();
assert!(data.saves.is_empty(), "{mismatch}");
assert!(data.prepared_snapshots.is_empty(), "{mismatch}");
assert!(!data.snapshot.instances[0].enabled);
}
}
@@ -0,0 +1,151 @@
//! 实例运行诊断与包检查缓存的查询回归测试
use super::*;
use gateway_admin::model::plugins::instances::PluginInstanceRuntimeLoad;
#[tokio::test]
async fn instance_list_uses_runtime_diagnostics_and_keeps_running_compatibility_derived() {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
let expected = PluginInstanceRuntime {
status: PluginInstanceRuntimeStatus::PreparationFailed,
actual_revision: Some(9),
actual_artifact_sha256: Some(OLD_ARTIFACT.into()),
failure: Some(PluginInstanceRuntimeFailure {
code: "unavailable".into(),
message: "插件候选准备失败".into(),
}),
draining_revisions: vec![8],
load: Some(PluginInstanceRuntimeLoad {
active_calls: 3,
executing_calls: 1,
queued_calls: 1,
resuming_calls: 1,
pending_callbacks: 2,
active_streams: 0,
}),
};
fixture.data.lock().unwrap().diagnostics = Some(BTreeMap::from([(
"00000000-0000-7000-8000-000000000001".into(),
expected.clone(),
)]));
let views = service(fixture, Arc::new(Published::default()))
.instances()
.await
.unwrap();
assert_eq!(views.len(), 1);
assert!(!views[0].running);
assert_eq!(views[0].runtime, expected);
}
#[tokio::test]
async fn instance_list_falls_back_without_inventing_runtime_failures() {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
let views = service(fixture, Arc::new(Published::default()))
.instances()
.await
.unwrap();
assert_eq!(views.len(), 1);
assert_eq!(
views[0].runtime.status,
PluginInstanceRuntimeStatus::Unknown
);
assert!(!views[0].running);
assert!(views[0].runtime.failure.is_none());
assert!(views[0].runtime.actual_revision.is_none());
assert!(views[0].runtime.load.is_none());
}
#[tokio::test]
async fn disabled_instance_without_runtime_diagnostics_has_a_known_disabled_status() {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
fixture.data.lock().unwrap().snapshot.instances[0].enabled = false;
let views = service(fixture, Arc::new(Published::default()))
.instances()
.await
.unwrap();
assert_eq!(
views[0].runtime.status,
PluginInstanceRuntimeStatus::Disabled
);
assert!(!views[0].running);
assert!(views[0].runtime.failure.is_none());
assert!(views[0].runtime.load.is_none());
}
#[tokio::test]
async fn matching_publication_without_process_diagnostics_does_not_imply_running() {
for diagnostics in [None, Some(BTreeMap::new())] {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
fixture.data.lock().unwrap().diagnostics = diagnostics;
let revision = fixture.snapshot().config_revision.get();
let published = RuntimeSnapshotHandle::new(
gateway_core::routing::RuntimeSnapshot::new(
ConfigRevision::new(revision).unwrap(),
gateway_core::settings::SettingsValues::new(
1,
"round_robin",
BTreeMap::new(),
None,
None,
),
Vec::new(),
Vec::new(),
Vec::new(),
)
.unwrap(),
);
let service = PluginsService::new(
fixture.clone(),
fixture.clone(),
PluginDistributionPorts::new(Arc::new(TestPluginPorts), Arc::new(TestPluginPorts)),
Arc::new(Published::default()),
fixture.clone(),
published,
fixture,
);
let view = service.instances().await.unwrap().remove(0);
assert_eq!(view.published_revision, Some(revision));
assert_eq!(view.runtime.status, PluginInstanceRuntimeStatus::Unknown);
assert!(!view.running);
assert!(view.runtime.actual_revision.is_none());
assert!(view.runtime.actual_artifact_sha256.is_none());
assert!(view.runtime.failure.is_none());
}
}
#[tokio::test]
async fn cached_package_query_is_not_blocked_by_another_package_inspection() {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
let service = Arc::new(service(fixture.clone(), Arc::new(Published::default())));
service.instances().await.unwrap();
let gate = Arc::new(InspectionGate {
digest: NEW_ARTIFACT.into(),
started: tokio::sync::Notify::new(),
release: tokio::sync::Notify::new(),
});
*fixture.inspection_gate.lock().unwrap() = Some(gate.clone());
fixture.data.lock().unwrap().snapshot.instances[0].artifact_sha256 = NEW_ARTIFACT.into();
let inspecting = {
let service = service.clone();
tokio::spawn(async move { service.instances().await })
};
tokio::time::timeout(std::time::Duration::from_secs(1), gate.started.notified())
.await
.expect("another package inspection started");
fixture.data.lock().unwrap().snapshot.instances[0].artifact_sha256 = OLD_ARTIFACT.into();
let cached =
tokio::time::timeout(std::time::Duration::from_millis(100), service.instances()).await;
gate.release.notify_one();
inspecting.await.unwrap().unwrap();
assert_eq!(
cached
.expect("cached query must not wait for inspection")
.unwrap()[0]
.instance
.artifact_sha256,
OLD_ARTIFACT
);
assert_eq!(fixture.data.lock().unwrap().archive_reads, 2);
}
@@ -213,6 +213,16 @@ fn sdk_settings_contract_matches_host_declarations() {
let mut types = Vec::new();
let mut replacement_fields = Vec::new();
for (path, names) in [
(
"../gateway-core/src/account/error_protection.rs",
&[
"AccountErrorProtection",
"AccountErrorRule",
"AccountErrorCondition",
"AccountErrorField",
"AccountErrorOperator",
][..],
),
(
"../gateway-core/src/settings/privacy.rs",
&[
@@ -345,13 +345,8 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
usage_retention_days: 31,
ops_event_retention_days: 30,
audit_retention_days: 30,
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
account_error_protection:
gateway_core::account::AccountErrorProtection::default(),
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
@@ -16,11 +16,15 @@ use gateway_admin::{
struct RecordingSystemOperations {
target: Mutex<Option<Option<String>>>,
channel: Mutex<Option<gateway_admin::model::system::SystemUpdateChannel>>,
failure: Option<SystemOperationError>,
}
#[async_trait]
impl SystemOperations for RecordingSystemOperations {
async fn version(&self) -> Result<SystemVersion, SystemOperationError> {
if let Some(error) = &self.failure {
return Err(error.clone());
}
Ok(SystemVersion {
version: "1.0.0".to_owned(),
git_sha: "unknown".to_owned(),
@@ -119,6 +123,33 @@ impl SystemOperations for RecordingSystemOperations {
}
}
#[tokio::test]
async fn system_failure_should_preserve_safe_message_source_and_redaction_marker() {
let operations = std::sync::Arc::new(RecordingSystemOperations {
failure: Some(
SystemOperationError::new(
gateway_admin::ports::system::SystemOperationErrorKind::Upstream,
"更新包下载失败",
)
.with_source(std::io::Error::other("native transport diagnostic"))
.redact_sensitive_context(),
),
..RecordingSystemOperations::default()
});
let services = super::AdminHarness::new().system(operations).build().await;
let error = services.system().version().await.expect_err("host failure");
assert_eq!(
error.kind(),
gateway_admin::model::AdminErrorKind::BadGateway
);
assert_eq!(error.message(), "更新包下载失败");
assert!(!format!("{error} {error:?}").contains("native transport diagnostic"));
let details = error.error_details().expect("host error details");
assert!(details.as_str().contains("native transport diagnostic"));
let details: serde_json::Value = serde_json::from_str(details.as_str()).expect("details JSON");
assert_eq!(details["redacted"], true);
}
#[tokio::test]
async fn system_update_should_normalize_blank_target_to_latest() {
let operations = std::sync::Arc::new(RecordingSystemOperations::default());
+5 -2
View File
@@ -25,19 +25,22 @@ gateway-admin = { path = "../gateway-admin" }
gateway-core = { path = "../gateway-core" }
gateway-protocol = { path = "../gateway-protocol" }
headers = "0.4.1"
hyper = { version = "=1.11.1", default-features = false }
hyper-util = { version = "=0.1.21", default-features = false, features = ["tokio"] }
regex = "1.13.1"
secrecy = "0.10"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
sha2 = "0.11.0"
thiserror = "2.0.18"
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt", "sync", "time"] }
tokio = { version = "1.52.3", default-features = false, features = ["io-util", "macros", "rt", "sync", "time"] }
tokio-tungstenite = { version = "0.29.0", default-features = false, features = ["handshake"] }
tower = { version = "0.5.3", features = ["util"] }
tower-http = { version = "0.7.0", features = ["compression-gzip", "cors", "fs", "request-id", "timeout", "trace"] }
tracing = "0.1.44"
url = "2.5.8"
uuid = { version = "1.23.3", features = ["v7"] }
zstd = "=0.13.3"
zstd = "=0.14.3"
zip = { version = "9.0.4", default-features = false, features = ["deflate-flate2"] }
[dev-dependencies]
@@ -72,6 +72,7 @@ pub(super) fn account_view(
projection,
usage,
quota,
protection_rules,
} = item;
let status = projection.status.as_str().to_owned();
let cooldown = projection.cooldown;
@@ -86,7 +87,8 @@ pub(super) fn account_view(
if account.authentication_kind == "api_key" {
usage.window_kind = AccountUsageWindowKind::General;
}
let (quota, refresh_token_expires_at) = account_quota_view(quota, cooldown, now, time);
let (mut quota, refresh_token_expires_at) = account_quota_view(quota, cooldown, now, time);
quota.protection_rules = protection_rules;
AccountView {
capabilities: capabilities.into(),
id: account.id.clone(),
@@ -232,8 +234,8 @@ pub(super) fn account_quota_view(
}
});
let rate_limit_reason = cooldown.map(|value| {
if value.kind.is_capacity_freeze() {
"capacity_freeze".to_owned()
if value.kind.is_error_protection() {
"error_protection".to_owned()
} else {
"upstream_rate_limit".to_owned()
}
@@ -251,6 +253,7 @@ pub(super) fn account_quota_view(
rate_limit_recovery_display,
rate_limit_reason,
recovery_probe_required,
protection_rules: Vec::new(),
windows,
credits: quota.credits.map(|credits| AccountQuotaCreditsView {
has_credits: credits.has_credits,
@@ -394,6 +394,7 @@ pub struct AccountQuotaView {
pub rate_limit_recovery_display: Option<String>,
pub rate_limit_reason: Option<String>,
pub recovery_probe_required: bool,
pub protection_rules: Vec<gateway_core::account::AccountProtectionMatch>,
pub windows: Vec<AccountQuotaWindowView>,
pub credits: Option<AccountQuotaCreditsView>,
}
@@ -1066,13 +1066,28 @@ fn mutation_response(
}
fn map_wire_error(error: WireValidationError) -> AdminError {
match error.field() {
"cursor" => AdminError::bad_request("Client API Key 游标不合法"),
let message = match error.field() {
"cursor" => "API Key 分页游标不合法",
"clientKeyRevealNotFound" | "clientKeyMutationNotFound" => {
AdminError::not_found("Client API Key 不存在")
return AdminError::not_found("API Key 不存在");
}
_ => AdminError::bad_request("Client API Key 请求不合法"),
}
"id" => "API Key ID 不能为空",
"name" => "API Key 名称不能为空",
"label" => "API Key 标签不能为空",
"customKey" => "自定义 API Key 仅支持英文字母、数字和英文符号",
"groupIds" => "账号分组无效、重复或数量超限",
"maxConcurrency" => "并发请求上限超出支持范围",
"requestsPerMinute" => "每分钟请求上限超出支持范围",
"dailyLimitUsd" => "每日美元限额须非负,整数和小数各最多 10 位",
"weeklyLimitUsd" => "每周美元限额须非负,整数和小数各最多 10 位",
"providerRequestProfileOverrides" => "API Key 客户端身份配置无效或字段冲突",
"limit" => "每页数量须为 1 至 65535",
"search" => "搜索内容过长或包含控制字符",
"sortBy" => "排序字段不受支持",
"sortDirection" => "排序方向必须为 asc(升序)或 desc(降序)",
_ => "API Key 请求参数不合法",
};
AdminError::bad_request(message)
}
fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError {
@@ -28,13 +28,12 @@ pub(super) fn build(document: &Value) -> io::Result<Vec<u8>> {
let detail = &evidence["detail"]["request"];
writeln!(
archive,
"\n结果:{};状态码:{};尝试数:{}\n模型:请求={},发送={},上游报告={}\n传输:{} → {}\n时延:{}",
"\n结果:{};状态码:{};尝试数:{}\n模型:请求={},发送={}\n传输:{} → {}\n时延:{}",
detail["logicalOutcome"],
detail["statusCode"],
detail["attemptCount"],
detail["requestedModel"],
detail["upstreamModel"],
detail["upstreamResponseModel"],
detail["clientTransport"],
detail["upstreamTransport"],
detail["latencyDetails"]
@@ -41,10 +41,6 @@ pub(crate) fn display_rate(value: f64) -> String {
}
}
fn basis_points_rate(value: u64) -> f64 {
value as f64 / 10_000.0
}
pub(crate) fn outcome_name(outcome: &domain::RequestOutcome) -> &str {
outcome.as_str()
}
@@ -279,7 +275,6 @@ pub(crate) fn usage_list_record_view(
model,
requested_model: record.requested_model_id,
upstream_model: record.upstream_model_id,
upstream_response_model: record.upstream_response_model,
service_tier: record.service_tier,
client_transport: record.client_transport,
upstream_transport: record.upstream_transport,
@@ -377,7 +372,6 @@ pub(crate) fn usage_record_view(
model,
requested_model: record.requested_model_id,
upstream_model: record.upstream_model_id,
upstream_response_model: record.upstream_response_model,
service_tier: record.service_tier,
status_code,
client_transport: record.client_transport,
@@ -932,6 +926,7 @@ pub(crate) fn dashboard_view(
.email
.clone()
.unwrap_or_else(|| credential.name.clone()),
notes: credential.notes,
plan_type: credential.plan_type.clone(),
plan_type_display: credential
.plan_type_display
@@ -1052,6 +1047,17 @@ pub(crate) fn usage_summary_view(
}
}
fn overview_load_view(load: &domain::UsageInsightsLoad) -> OverviewLoadView {
OverviewLoadView {
average_concurrency: load.average_concurrency,
peak_concurrency: load.peak_concurrency,
admission_decision_p95_ms: load.admission_decision_p95_ms.map(|value| value.as_f64()),
account_selection_wait_p95_ms: load
.account_selection_wait_p95_ms
.map(|value| value.as_f64()),
}
}
pub(crate) fn usage_insights_view(
insights: domain::UsageInsights,
time: crate::time::TimePresenter,
@@ -1094,31 +1100,10 @@ pub(crate) fn usage_insights_view(
.first_token_latency_percentiles
.p99_ms
.map(|value| value.as_f64()),
admission_decision_p50_ms: point
.admission_decision_percentiles
.p50_ms
.map(|value| value.as_f64()),
admission_decision_p95_ms: point
.admission_decision_percentiles
.p95_ms
.map(|value| value.as_f64()),
account_selection_wait_p50_ms: point
.account_selection_wait_percentiles
.p50_ms
.map(|value| value.as_f64()),
account_selection_wait_p95_ms: point
.account_selection_wait_percentiles
.p95_ms
.map(|value| value.as_f64()),
output_throughput_p10: point.output_throughput_p10,
output_throughput_p50: point.output_throughput_p50,
output_throughput_p90: point.output_throughput_p90,
capacity_utilization: point
.capacity_utilization_avg_basis_points
.map(basis_points_rate),
capacity_utilization_p95: point
.capacity_utilization_p95_basis_points
.map(basis_points_rate),
load: overview_load_view(&point.load),
})
.collect();
let cost_points = insights
@@ -1187,42 +1172,10 @@ pub(crate) fn usage_insights_view(
.first_token_latency_percentiles
.p99_ms
.map(|value| value.as_f64()),
admission_decision_p50_ms: insights
.performance
.admission_decision_percentiles
.p50_ms
.map(|value| value.as_f64()),
admission_decision_p95_ms: insights
.performance
.admission_decision_percentiles
.p95_ms
.map(|value| value.as_f64()),
account_selection_wait_p50_ms: insights
.performance
.account_selection_wait_percentiles
.p50_ms
.map(|value| value.as_f64()),
account_selection_wait_p95_ms: insights
.performance
.account_selection_wait_percentiles
.p95_ms
.map(|value| value.as_f64()),
output_throughput_p10: insights.performance.output_throughput_p10,
output_throughput_p50: insights.performance.output_throughput_p50,
output_throughput_p90: insights.performance.output_throughput_p90,
capacity_utilization: insights
.performance
.capacity_utilization_avg_basis_points
.map(basis_points_rate),
capacity_utilization_p95: insights
.performance
.capacity_utilization_p95_basis_points
.map(basis_points_rate),
latency_coverage: insights.performance.latency_coverage,
first_token_coverage: insights.performance.first_token_coverage,
admission_decision_coverage: insights.performance.admission_decision_coverage,
account_selection_wait_coverage: insights.performance.account_selection_wait_coverage,
capacity_coverage: insights.performance.capacity_coverage,
load: overview_load_view(&insights.performance.load),
points: performance_points,
},
cost: OverviewCostView {
@@ -103,7 +103,6 @@ pub struct UsageListRecordView {
pub model: Option<String>,
pub requested_model: Option<String>,
pub upstream_model: Option<String>,
pub upstream_response_model: Option<String>,
pub service_tier: Option<String>,
pub client_transport: String,
pub upstream_transport: Option<String>,
@@ -142,7 +141,6 @@ pub struct UsageRecordView {
pub model: Option<String>,
pub requested_model: Option<String>,
pub upstream_model: Option<String>,
pub upstream_response_model: Option<String>,
pub service_tier: Option<String>,
pub status_code: Option<i64>,
pub client_transport: String,
@@ -408,6 +406,7 @@ pub struct DashboardAccountUsageView {
pub provider: String,
pub authentication_kind: String,
pub email: String,
pub notes: Option<String>,
pub plan_type: Option<String>,
pub plan_type_display: String,
pub tokens: String,
@@ -650,6 +649,16 @@ pub struct OverviewHealthView {
pub points: Vec<OverviewHealthPointView>,
}
/// 并发与调度等待的负载指标
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OverviewLoadView {
pub average_concurrency: f64,
pub peak_concurrency: u64,
pub admission_decision_p95_ms: Option<f64>,
pub account_selection_wait_p95_ms: Option<f64>,
}
/// 洞察性能趋势点
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -662,15 +671,10 @@ pub struct OverviewPerformancePointView {
pub first_token_p50_ms: Option<f64>,
pub first_token_p95_ms: Option<f64>,
pub first_token_p99_ms: Option<f64>,
pub admission_decision_p50_ms: Option<f64>,
pub admission_decision_p95_ms: Option<f64>,
pub account_selection_wait_p50_ms: Option<f64>,
pub account_selection_wait_p95_ms: Option<f64>,
pub output_throughput_p10: Option<u64>,
pub output_throughput_p50: Option<u64>,
pub output_throughput_p90: Option<u64>,
pub capacity_utilization: Option<f64>,
pub capacity_utilization_p95: Option<f64>,
pub load: OverviewLoadView,
}
/// 洞察性能摘要
@@ -683,20 +687,10 @@ pub struct OverviewPerformanceView {
pub first_token_p50_ms: Option<f64>,
pub first_token_p95_ms: Option<f64>,
pub first_token_p99_ms: Option<f64>,
pub admission_decision_p50_ms: Option<f64>,
pub admission_decision_p95_ms: Option<f64>,
pub account_selection_wait_p50_ms: Option<f64>,
pub account_selection_wait_p95_ms: Option<f64>,
pub output_throughput_p10: Option<u64>,
pub output_throughput_p50: Option<u64>,
pub output_throughput_p90: Option<u64>,
pub capacity_utilization: Option<f64>,
pub capacity_utilization_p95: Option<f64>,
pub latency_coverage: f64,
pub first_token_coverage: f64,
pub admission_decision_coverage: f64,
pub account_selection_wait_coverage: f64,
pub capacity_coverage: f64,
pub load: OverviewLoadView,
pub points: Vec<OverviewPerformancePointView>,
}
@@ -9,8 +9,8 @@ use axum::{
};
use gateway_admin::model::plugins::instances::{
ConfigurePluginInstance, PluginCapabilityBinding, PluginInstance, PluginInstanceRuntime,
PluginInstanceRuntimeFailure, PluginInstanceRuntimeStatus, PluginInstanceView,
RollbackPluginInstance,
PluginInstanceRuntimeFailure, PluginInstanceRuntimeLoad, PluginInstanceRuntimeStatus,
PluginInstanceView, RollbackPluginInstance,
};
use serde::{Deserialize, Serialize};
@@ -70,6 +70,7 @@ struct InstanceView {
#[serde(rename_all = "snake_case")]
enum InstanceRuntimeStatus {
Disabled,
Unknown,
AwaitingPublication,
Preparing,
Running,
@@ -83,6 +84,7 @@ impl From<PluginInstanceRuntimeStatus> for InstanceRuntimeStatus {
fn from(value: PluginInstanceRuntimeStatus) -> Self {
match value {
PluginInstanceRuntimeStatus::Disabled => Self::Disabled,
PluginInstanceRuntimeStatus::Unknown => Self::Unknown,
PluginInstanceRuntimeStatus::AwaitingPublication => Self::AwaitingPublication,
PluginInstanceRuntimeStatus::Preparing => Self::Preparing,
PluginInstanceRuntimeStatus::Running => Self::Running,
@@ -118,6 +120,31 @@ struct InstanceRuntimeView {
actual_artifact_sha256: Option<String>,
failure: Option<InstanceRuntimeFailure>,
draining_revisions: Vec<u64>,
load: Option<InstanceRuntimeLoad>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct InstanceRuntimeLoad {
active_calls: usize,
executing_calls: usize,
queued_calls: usize,
resuming_calls: usize,
pending_callbacks: usize,
active_streams: usize,
}
impl From<PluginInstanceRuntimeLoad> for InstanceRuntimeLoad {
fn from(value: PluginInstanceRuntimeLoad) -> Self {
Self {
active_calls: value.active_calls,
executing_calls: value.executing_calls,
queued_calls: value.queued_calls,
resuming_calls: value.resuming_calls,
pending_callbacks: value.pending_callbacks,
active_streams: value.active_streams,
}
}
}
impl From<PluginInstanceRuntime> for InstanceRuntimeView {
@@ -128,6 +155,7 @@ impl From<PluginInstanceRuntime> for InstanceRuntimeView {
actual_artifact_sha256: value.actual_artifact_sha256,
failure: value.failure.map(Into::into),
draining_revisions: value.draining_revisions,
load: value.load.map(Into::into),
}
}
}
@@ -56,13 +56,7 @@ pub struct RuntimeSettingsFields {
pub usage_retention_days: u64,
pub ops_event_retention_days: u64,
pub audit_retention_days: u64,
pub account_auto_freeze_enabled: bool,
pub account_auto_freeze_threshold: u64,
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_error_protection: gateway_core::account::AccountErrorProtection,
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
@@ -587,18 +581,13 @@ fn wire_model_mappings(mappings: DomainModelMappings) -> ModelMappings {
fn map_wire_error(error: WireValidationError) -> AdminError {
let message = match error.field() {
"settingsRefreshConcurrencyOverflow" => "refreshConcurrency 不合法".to_owned(),
"settingsMaxConcurrencyOverflow" => "maxConcurrentPerAccount 不合法".to_owned(),
"settingsUsageRetentionOverflow" => "usageRetentionDays 不合法".to_owned(),
"settingsOpsRetentionOverflow" => "opsEventRetentionDays 不合法".to_owned(),
"settingsAuditRetentionOverflow" => "auditRetentionDays 不合法".to_owned(),
"requestLocation" => "请求位置不合法,请检查国家代码、地区和城市".to_owned(),
"settingsFreezeThresholdOverflow" => "accountAutoFreezeThreshold 不合法".to_owned(),
"accountAutoFreezeThreshold" => "账号自动冻结阈值应为 2~1000 的整数".to_owned(),
"accountAutoFreezeWindowSeconds" => "账号自动冻结统计窗口应为 60~3600 秒".to_owned(),
"accountAutoFreezeDurationSeconds" => "账号自动冻结时长应为 300~604800 秒".to_owned(),
"accountAutoFreezeProbeModel" => "探测模型格式不合法".to_owned(),
"providerRequestProfiles" => "Provider 请求画像格式不合法或字段冲突".to_owned(),
"settingsRefreshConcurrencyOverflow" => "刷新并发数不合法".to_owned(),
"settingsMaxConcurrencyOverflow" => "单账号并发上限不合法".to_owned(),
"settingsUsageRetentionOverflow" => "用量记录保留天数不合法".to_owned(),
"settingsOpsRetentionOverflow" => "运维事件保留天数不合法".to_owned(),
"settingsAuditRetentionOverflow" => "审计记录保留天数不合法".to_owned(),
"requestLocation" => "请求位置的国家代码、地区或城市不合法".to_owned(),
"providerRequestProfiles" => "服务商客户端身份配置无效或字段冲突".to_owned(),
"minCodexDesktopVersion" => "Codex Desktop 最低版本格式不合法".to_owned(),
"minCodexCliVersion" => "Codex CLI 最低版本格式不合法".to_owned(),
field => format!("{field} 字段不合法"),
@@ -776,14 +765,7 @@ impl RuntimeSettingsFields {
.map_err(|_| WireValidationError::new("settingsOpsRetentionOverflow"))?,
audit_retention_days: u32::try_from(self.audit_retention_days)
.map_err(|_| WireValidationError::new("settingsAuditRetentionOverflow"))?,
account_auto_freeze_enabled: self.account_auto_freeze_enabled,
account_auto_freeze_threshold: u32::try_from(self.account_auto_freeze_threshold)
.map_err(|_| WireValidationError::new("settingsFreezeThresholdOverflow"))?,
account_auto_freeze_window_seconds: self.account_auto_freeze_window_seconds,
account_auto_freeze_duration_seconds: self.account_auto_freeze_duration_seconds,
account_auto_freeze_probe_enabled: self.account_auto_freeze_probe_enabled,
account_auto_freeze_probe_model: self.account_auto_freeze_probe_model,
account_auto_freeze_adaptive_concurrency: self.account_auto_freeze_adaptive_concurrency,
account_error_protection: self.account_error_protection,
account_warmup_enabled: self.account_warmup_enabled,
account_warmup_schedule_time: self.account_warmup_schedule_time,
account_warmup_model: self.account_warmup_model,
@@ -819,14 +801,7 @@ impl From<gateway_admin::model::settings::RuntimeSettingsValues> for RuntimeSett
usage_retention_days: u64::from(settings.usage_retention_days),
ops_event_retention_days: u64::from(settings.ops_event_retention_days),
audit_retention_days: u64::from(settings.audit_retention_days),
account_auto_freeze_enabled: settings.account_auto_freeze_enabled,
account_auto_freeze_threshold: u64::from(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_error_protection: settings.account_error_protection,
account_warmup_enabled: settings.account_warmup_enabled,
account_warmup_schedule_time: settings.account_warmup_schedule_time,
account_warmup_model: settings.account_warmup_model,
@@ -839,13 +814,7 @@ impl From<gateway_admin::model::settings::RuntimeSettingsValues> for RuntimeSett
fn settings_validation_error(field: &'static str) -> WireValidationError {
WireValidationError::new(match field {
"account_auto_freeze_adaptive_concurrency" => "accountAutoFreezeAdaptiveConcurrency",
"account_auto_freeze_duration_seconds" => "accountAutoFreezeDurationSeconds",
"account_auto_freeze_enabled" => "accountAutoFreezeEnabled",
"account_auto_freeze_probe_enabled" => "accountAutoFreezeProbeEnabled",
"account_auto_freeze_probe_model" => "accountAutoFreezeProbeModel",
"account_auto_freeze_threshold" => "accountAutoFreezeThreshold",
"account_auto_freeze_window_seconds" => "accountAutoFreezeWindowSeconds",
"account_error_protection" => "accountErrorProtection",
"account_warmup_enabled" => "accountWarmupEnabled",
"account_warmup_model" => "accountWarmupModel",
"reset_credit_lead_minutes" => "resetCreditLeadMinutes",
+24 -16
View File
@@ -153,6 +153,19 @@ pub(crate) fn client_access_error_response(error: ClientAccessError) -> Response
#[must_use]
pub fn identify_codex_client(headers: &HeaderMap) -> Option<IdentifiedCodexClient> {
const MAXIMUM_HEADER_LENGTH: usize = 4096;
// 官方 CLI、IDE、SDK 与 App Server 的 UA 产品版本均来自所运行的 Codex Core
const CORE_CLIENT_PRODUCTS: &[&str] = &[
"codex_cli_rs",
"codex-cli",
"codex-tui",
"codex_exec",
"codex_vscode",
"codex_sdk_ts",
"codex_python_sdk",
"codex-app-server",
"codex-app-server-sdk",
"codex_mcp_server",
];
let originator = bounded_ascii_header(headers, "originator", MAXIMUM_HEADER_LENGTH);
let user_agent = bounded_ascii_header(headers, "user-agent", MAXIMUM_HEADER_LENGTH);
@@ -186,22 +199,17 @@ pub fn identify_codex_client(headers: &HeaderMap) -> Option<IdentifiedCodexClien
for product in user_agent.split(|character: char| {
character.is_ascii_whitespace() || matches!(character, '(' | ')' | ';' | ',' | '[' | ']')
}) {
for prefix in ["codex_cli_rs", "codex-cli"] {
if product.eq_ignore_ascii_case(prefix) {
return Some(IdentifiedCodexClient {
kind: CodexClientKind::Cli,
version: None,
});
}
let Some((name, version)) = product.split_once('/') else {
continue;
};
if name.eq_ignore_ascii_case(prefix) {
return Some(IdentifiedCodexClient {
kind: CodexClientKind::Cli,
version: CodexClientVersion::parse(version).ok(),
});
}
let (name, version) = product
.split_once('/')
.map_or((product, None), |(name, version)| (name, Some(version)));
if CORE_CLIENT_PRODUCTS
.iter()
.any(|known| name.eq_ignore_ascii_case(known))
{
return Some(IdentifiedCodexClient {
kind: CodexClientKind::Cli,
version: version.and_then(|value| CodexClientVersion::parse(value).ok()),
});
}
}
None
@@ -0,0 +1,319 @@
//! Chat 出口持有协议投影缓冲 提交与资源回收始终委托原受管正文
use std::collections::VecDeque;
use axum::body::to_bytes;
use bytes::Bytes;
use futures::future::BoxFuture;
use gateway_core::{
engine::middleware::{
MiddlewareBody, MiddlewareError, MiddlewareFrame, MiddlewareFraming, MiddlewareHeader,
MiddlewareResponse,
},
error::{GatewayError, GatewayErrorKind},
};
use gateway_protocol::openai::sse::{DONE_SSE_FRAME, SseEvent, SseEventDecoder, sse_frame_is_done};
use serde_json::Value;
use crate::openai::{middleware::error_response, responses::ProtocolErrorBody};
use super::response::{ChatIdentity, ChatStreamEncoder, project_response};
pub(super) fn project_middleware_response(
response: MiddlewareResponse,
identity: ChatIdentity,
streaming: bool,
include_usage: bool,
) -> MiddlewareResponse {
if !(200..300).contains(&response.status_code()) {
return response;
}
let (protocol, status, mut headers, body, envelope) = response.into_parts();
// 投影改变正文长度和表示形式 不继承 Responses 的实体长度或编码
headers.retain(|header| {
!["content-length", "content-type", "content-encoding"]
.iter()
.any(|name| header.name().eq_ignore_ascii_case(name))
});
headers.push(MiddlewareHeader::new(
"content-type",
Bytes::from_static(if streaming {
b"text/event-stream"
} else {
b"application/json"
}),
));
let projection = if streaming {
Projection::Stream(Box::new(StreamProjection {
encoder: ChatStreamEncoder::new(identity, include_usage),
decoder: SseEventDecoder::default(),
done_received: false,
}))
} else {
Projection::Json(identity)
};
let mut response = MiddlewareResponse::new(
protocol,
status,
headers,
Box::new(ChatBody {
inner: Some(body),
projection,
pending: VecDeque::new(),
committed: false,
finished: false,
}),
);
if let Some(envelope) = envelope {
response = response.with_envelope(envelope);
}
response
}
pub(super) fn chat_error_frame(data: &Value) -> Bytes {
let error = data
.pointer("/response/error")
.or_else(|| data.get("error"));
Bytes::from(format!("data: {}\n\n", serde_json::json!({"error": error})))
}
enum Projection {
Json(ChatIdentity),
Stream(Box<StreamProjection>),
}
impl Projection {
fn terminal_seen(&self) -> bool {
matches!(self, Self::Stream(stream) if stream.terminal_seen())
}
}
struct StreamProjection {
encoder: ChatStreamEncoder,
decoder: SseEventDecoder,
done_received: bool,
}
impl StreamProjection {
fn terminal_seen(&self) -> bool {
self.encoder.is_completed() || self.encoder.has_wire_failure()
}
fn project(&mut self, events: Vec<SseEvent>) -> Result<Vec<Bytes>, MiddlewareError> {
let mut output = Vec::new();
for event in events {
if self.done_received {
return Err(invalid_response());
}
let data = serde_json::from_str(&event.data).map_err(|_| invalid_response())?;
// codec 区分辅助事件与终态后的业务输出 交付层不再维护一份事件分类
output.extend(
self.encoder
.push_event(event.event.as_deref(), &data)
.map_err(projection_error)?,
);
}
Ok(output)
}
}
struct ChatBody {
inner: Option<Box<dyn MiddlewareBody>>,
projection: Projection,
pending: VecDeque<MiddlewareFrame>,
committed: bool,
finished: bool,
}
impl ChatBody {
async fn read_one(&mut self) -> Result<(), MiddlewareError> {
let inner = self.inner.as_mut().ok_or(MiddlewareError::InvalidState)?;
let Some(frame) = inner.next_frame().await? else {
if let Projection::Stream(stream) = &mut self.projection {
let events = stream.decoder.finish().map_err(|_| invalid_response())?;
let frames = stream.project(events)?;
if !frames.is_empty() || !stream.done_received || !inner.is_finalized() {
return Err(invalid_response());
}
self.pending.push_back(done_frame());
}
self.finished = true;
return Ok(());
};
let transformed = frame.transformed();
let (bytes, framing, terminal, envelope) = frame.into_parts();
let output = match &mut self.projection {
Projection::Json(identity) => {
if framing != MiddlewareFraming::JsonDocument || !terminal {
return Err(invalid_response());
}
let value = serde_json::from_slice(&bytes).map_err(|_| invalid_response())?;
let value = project_response(&value, identity).map_err(projection_error)?;
vec![MiddlewareFrame::new(
Bytes::from(serde_json::to_vec(&value).map_err(|_| invalid_response())?),
MiddlewareFraming::JsonDocument,
true,
)]
}
Projection::Stream(stream) => {
if framing != MiddlewareFraming::SseEvent {
return Err(invalid_response());
}
let events = stream
.decoder
.push(&bytes)
.map_err(|_| invalid_response())?;
let output = stream.project(events)?;
// 共用 SSE 解码器只返回业务事件 DONE 由受管正文的控制帧单独确认
if std::str::from_utf8(&bytes).is_ok_and(sse_frame_is_done) {
if stream.done_received || !stream.terminal_seen() {
return Err(invalid_response());
}
stream.done_received = true;
}
if terminal && !stream.done_received {
return Err(invalid_response());
}
output
.into_iter()
.map(|bytes| MiddlewareFrame::new(bytes, MiddlewareFraming::SseEvent, false))
.collect()
}
};
let mut output: VecDeque<_> = output
.into_iter()
.map(|frame| frame.with_transformed(transformed))
.collect();
if let Some(envelope) = envelope
&& let Some(last) = output.pop_back()
{
output.push_back(last.with_envelope(envelope));
}
self.pending.extend(output);
Ok(())
}
async fn verify_terminal(&mut self) -> Result<(), MiddlewareError> {
while !self.finished {
self.read_one().await?;
}
Ok(())
}
async fn fail_stream(&mut self, error: MiddlewareError) {
self.pending.clear();
if let Some(inner) = self.inner.take() {
inner.close().await;
}
let response = error_response(error);
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.unwrap_or_default();
let value = serde_json::from_slice(&bytes).unwrap_or_else(|_| {
serde_json::json!({"error": {
"type": "server_error", "code": "invalid_upstream_response",
"message": "The gateway could not deliver the Chat response."
}})
});
self.pending.push_back(MiddlewareFrame::new(
chat_error_frame(&value),
MiddlewareFraming::SseEvent,
false,
));
self.pending.push_back(done_frame());
self.finished = true;
}
}
impl MiddlewareBody for ChatBody {
fn next_frame(&mut self) -> BoxFuture<'_, Result<Option<MiddlewareFrame>, MiddlewareError>> {
Box::pin(async move {
while self.pending.is_empty() && !self.finished {
let result = match self.read_one().await {
Ok(()) if self.committed && self.projection.terminal_seen() => {
self.verify_terminal().await
}
result => result,
};
if let Err(error) = result {
if !self.committed {
return Err(error);
}
self.fail_stream(error).await;
}
}
Ok(self.pending.pop_front())
})
}
fn commit_downstream(
&mut self,
client_status_code: Option<u16>,
) -> BoxFuture<'_, Result<(), MiddlewareError>> {
Box::pin(async move {
self.inner
.as_mut()
.ok_or(MiddlewareError::InvalidState)?
.commit_downstream(client_status_code)
.await?;
self.committed = true;
// HTTP 尚未发送首帧 即使首批已经包含 finish 或 usage 也先确认原会话完成结算
if self.projection.terminal_seen()
&& let Err(error) = self.verify_terminal().await
{
// 原会话已提交 不能把已记录的 200 改成另一 HTTP 状态
// 首个 Chat 帧为 role 或 error 成功终态仍在 pending 内可安全撤回
self.fail_stream(error).await;
}
Ok(())
})
}
fn record_client_status(&mut self, status: u16) -> BoxFuture<'_, Result<(), MiddlewareError>> {
match self.inner.as_mut() {
Some(inner) => inner.record_client_status(status),
None => Box::pin(async { Ok(()) }),
}
}
fn is_finalized(&self) -> bool {
self.inner.as_ref().is_none_or(|inner| inner.is_finalized())
}
fn close(mut self: Box<Self>) -> BoxFuture<'static, ()> {
let inner = self.inner.take();
Box::pin(async move {
if let Some(inner) = inner {
inner.close().await;
}
})
}
}
fn projection_error(error: ProtocolErrorBody) -> MiddlewareError {
MiddlewareError::Gateway(
GatewayError::new(
GatewayErrorKind::UpstreamUnavailable,
"The gateway could not project the upstream response to Chat Completions.",
)
.with_client_code(error.error.code),
)
}
fn invalid_response() -> MiddlewareError {
MiddlewareError::Gateway(
GatewayError::new(
GatewayErrorKind::UpstreamUnavailable,
"The gateway could not deliver the Chat response.",
)
.with_client_code("invalid_upstream_response"),
)
}
fn done_frame() -> MiddlewareFrame {
MiddlewareFrame::new(
Bytes::from_static(DONE_SSE_FRAME.as_bytes()),
MiddlewareFraming::SseEvent,
true,
)
}
@@ -0,0 +1,100 @@
//! Chat HTTP 入口只规范化协议 共用 Responses 的执行与交付链路
use std::{net::SocketAddr, time::SystemTime};
use axum::{
body::Bytes,
extract::{Extension, State, connect_info::ConnectInfo},
http::{HeaderMap, StatusCode, header::CONTENT_ENCODING},
response::Response,
};
use crate::ApiState;
use crate::openai::{
auth::{authenticate_client, client_access_error_response},
error::{gateway_error_response, protocol_error_response},
responses::{
DecodedResponsesRequest, HttpResponseAdapter, RequestDecodeError, ResponsesHttpRequest,
decode_request_with_headers, decompress_request_body, execute_prepared_response,
},
router::CHAT_COMPLETIONS_PATH,
};
use super::{
delivery::{chat_error_frame, project_middleware_response},
request::convert_request,
response::ChatIdentity,
};
pub(in crate::openai) async fn chat_completions(
State(state): State<ApiState>,
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
ingress_id: Option<Extension<tower_http::request_id::RequestId>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let service = state.openai().clone();
let client = match authenticate_client(&service, &headers).await {
Ok(client) => client,
Err(error) => return client_access_error_response(error),
};
let (decoded, middleware_body, include_usage) = match decode_request(
&body,
&headers,
client.snapshot().responses_max_decompressed_body_bytes(),
) {
Ok(request) => request,
Err(error) => {
return protocol_error_response(StatusCode::BAD_REQUEST, error.protocol_body());
}
};
let prepared = match service.execution().prepare_execution(client).await {
Ok(prepared) => prepared,
Err(error) => return gateway_error_response(&error),
};
let identity = ChatIdentity {
id: format!("chatcmpl-{}", prepared.request_id().as_str()),
created: SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
model: decoded.metadata().requested_model().to_owned(),
};
let streaming = decoded.metadata().stream();
let adapter = HttpResponseAdapter {
endpoint: CHAT_COMPLETIONS_PATH,
wrap: Box::new(move |response| {
project_middleware_response(response, identity, streaming, include_usage)
}),
stream_error: chat_error_frame,
};
execute_prepared_response(
service,
prepared,
ResponsesHttpRequest {
peer_address: connect_info.map(|Extension(ConnectInfo(address))| address),
ingress_id,
headers,
decoded,
middleware_body,
},
None,
Some(adapter),
)
.await
}
fn decode_request(
body: &[u8],
headers: &HeaderMap,
maximum_body_bytes: usize,
) -> Result<(DecodedResponsesRequest, Bytes, bool), RequestDecodeError> {
let body = decompress_request_body(body, headers, maximum_body_bytes)?;
let converted = convert_request(&body)?;
let body =
serde_json::to_vec(&converted.body).map_err(|_| RequestDecodeError::MalformedJson)?;
let mut decoded_headers = headers.clone();
decoded_headers.remove(CONTENT_ENCODING);
let decoded = decode_request_with_headers(&body, &decoded_headers, maximum_body_bytes)?;
Ok((decoded, Bytes::from(body), converted.include_usage))
}
@@ -0,0 +1,8 @@
//! Chat Completions 与 Responses 之间的 API 协议适配
mod delivery;
mod http;
mod request;
mod response;
pub(super) use http::chat_completions;
@@ -0,0 +1,550 @@
//! 将 Chat 请求转换为 Responses 正文,协议选项留在 API,业务 JSON 与上游兼容规则保持原有归属
use serde_json::{Map, Value, json};
use crate::openai::responses::RequestDecodeError;
pub(in crate::openai) struct ConvertedChatRequest {
pub(in crate::openai) body: Map<String, Value>,
pub(in crate::openai) include_usage: bool,
}
pub(in crate::openai) fn convert_request(
body: &[u8],
) -> Result<ConvertedChatRequest, RequestDecodeError> {
let value = serde_json::from_slice(body).map_err(|_| RequestDecodeError::MalformedJson)?;
let Value::Object(mut chat) = value else {
return Err(RequestDecodeError::ExpectedObject);
};
validate_options(&chat)?;
let model = required_string(&mut chat, "model", "model", false)?;
let stream = optional_bool(&mut chat, "stream", "stream")?.unwrap_or(false);
let include_usage = stream_options(optional(&mut chat, "stream_options"), stream)?;
let input = messages(required(&mut chat, "messages", "messages")?)?;
let mut body = Map::from_iter([
("model".into(), model.into()),
("input".into(), input.into()),
("stream".into(), stream.into()),
("store".into(), false.into()),
]);
if let Some(value) = optional(&mut chat, "tools") {
body.insert("tools".into(), function_tools(value)?.into());
}
if let Some(value) = optional(&mut chat, "tool_choice") {
let choice = tool_choice(value, body.get("tools"))?;
body.insert("tool_choice".into(), choice);
}
if let Some(value) = optional_bool(&mut chat, "parallel_tool_calls", "parallel_tool_calls")? {
body.insert("parallel_tool_calls".into(), value.into());
}
let mut text = Map::new();
if let Some(value) = optional(&mut chat, "response_format") {
text.insert("format".into(), response_format(value)?);
}
if let Some(value) = optional(&mut chat, "verbosity") {
string(&value, "verbosity", false)?;
text.insert("verbosity".into(), value);
}
if !text.is_empty() {
body.insert("text".into(), text.into());
}
if let Some(value) = optional(&mut chat, "reasoning_effort") {
string(&value, "reasoning_effort", false)?;
body.insert("reasoning".into(), json!({"effort": value}));
}
for field in ["max_tokens", "max_completion_tokens"] {
if let Some(value) = optional(&mut chat, field) {
if !value.as_u64().is_some_and(|tokens| tokens > 0) {
return Err(invalid(field));
}
// 新字段优先,具体上游是否接受长度控制仍由现有 Provider 决定
body.insert("max_output_tokens".into(), value);
}
}
for (field, maximum) in [("temperature", 2.0), ("top_p", 1.0)] {
if let Some(value) = optional(&mut chat, field) {
if !value
.as_f64()
.is_some_and(|number| (0.0..=maximum).contains(&number))
{
return Err(invalid(field));
}
body.insert(field.into(), value);
}
}
for field in [
"user",
"safety_identifier",
"service_tier",
"prompt_cache_key",
"prompt_cache_retention",
] {
if let Some(value) = optional(&mut chat, field) {
string(&value, field, true)?;
body.insert(field.into(), value);
}
}
for field in ["metadata", "prompt_cache_options", "moderation"] {
if let Some(value) = optional(&mut chat, field) {
let object = object(value, field)?;
body.insert(field.into(), object.into());
}
}
// 未识别的客户端顶层扩展不等于 Responses 参数,不进入上游正文
Ok(ConvertedChatRequest {
body,
include_usage,
})
}
fn validate_options(chat: &Map<String, Value>) -> Result<(), RequestDecodeError> {
for (field, value) in chat.iter().filter(|(_, value)| !value.is_null()) {
let supported = match field.as_str() {
"n" => value.as_u64() == Some(1),
"store" | "logprobs" => value == false,
"frequency_penalty" | "presence_penalty" => value.as_f64() == Some(0.0),
"top_logprobs" => value.as_u64() == Some(0),
"logit_bias" => value.as_object().is_some_and(Map::is_empty),
"functions" | "stop" => value.as_array().is_some_and(Vec::is_empty),
"modalities" => value == &json!(["text"]),
"audio"
| "prediction"
| "seed"
| "function_call"
| "web_search_options"
| "reasoning_content"
| "input"
| "previous_response_id"
| "conversation"
| "instructions"
| "reasoning"
| "text"
| "include"
| "background"
| "max_output_tokens" => false,
_ => true,
};
if !supported {
return Err(unsupported(field));
}
}
Ok(())
}
fn stream_options(value: Option<Value>, stream: bool) -> Result<bool, RequestDecodeError> {
let Some(value) = value else {
return Ok(false);
};
if !stream {
return Err(invalid("stream_options"));
}
let mut options = object(value, "stream_options")?;
let include_usage = optional_bool(
&mut options,
"include_usage",
"stream_options.include_usage",
)?
.unwrap_or(false);
if optional_bool(
&mut options,
"include_obfuscation",
"stream_options.include_obfuscation",
)? == Some(true)
{
// Chat 重编码会改变帧长度,不能声称继承上游流的长度混淆保证
return Err(unsupported("stream_options.include_obfuscation"));
}
Ok(include_usage)
}
fn messages(value: Value) -> Result<Vec<Value>, RequestDecodeError> {
let messages = array(value, "messages")?;
if messages.is_empty() {
return Err(RequestDecodeError::EmptyField {
field: "messages".into(),
});
}
let mut input = Vec::with_capacity(messages.len());
for (index, value) in messages.into_iter().enumerate() {
let path = format!("messages[{index}]");
let mut message = object(value, &path)?;
let role = required_string(&mut message, "role", &format!("{path}.role"), false)?;
if !matches!(
role.as_str(),
"system" | "developer" | "user" | "assistant" | "tool"
) {
return Err(unsupported(&format!("{path}.role")));
}
if let Some(name) = optional(&mut message, "name")
&& !string(&name, &format!("{path}.name"), true)?.is_empty()
{
return Err(unsupported(&format!("{path}.name")));
}
for field in ["function_call", "reasoning_content", "reasoning", "audio"] {
if optional(&mut message, field).is_some() {
return Err(unsupported(&format!("{path}.{field}")));
}
}
for (field, allowed_role) in [
("tool_calls", "assistant"),
("refusal", "assistant"),
("tool_call_id", "tool"),
] {
if role != allowed_role && message.get(field).is_some_and(|value| !value.is_null()) {
return Err(invalid(&format!("{path}.{field}")));
}
}
if role == "tool" {
let call_id = required_string(
&mut message,
"tool_call_id",
&format!("{path}.tool_call_id"),
false,
)?;
let content_path = format!("{path}.content");
let content = required(&mut message, "content", &content_path)?;
let output = if content.is_string() {
content
} else {
content_parts(content, &role, &content_path, false)?.into()
};
input.push(
json!({"type": "function_call_output", "call_id": call_id, "output": output}),
);
continue;
}
let calls = optional(&mut message, "tool_calls")
.map(|value| tool_calls(value, &format!("{path}.tool_calls")))
.transpose()?
.unwrap_or_default();
let refusal = optional(&mut message, "refusal");
if let Some(value) = &refusal {
string(value, &format!("{path}.refusal"), true)?;
}
let content = optional(&mut message, "content");
if content.is_none() && refusal.is_none() && calls.is_empty() {
return Err(invalid(&format!("{path}.content")));
}
let is_assistant = role == "assistant";
let mut parts = content
.map(|value| content_parts(value, &role, &format!("{path}.content"), is_assistant))
.transpose()?;
if let Some(refusal) = refusal {
parts
.get_or_insert_with(Vec::new)
.push(json!({"type": "refusal", "refusal": refusal}));
}
if let Some(parts) = parts {
let mut item = json!({"type": "message", "role": role, "content": parts});
if is_assistant {
// assistant 历史采用输出消息合同,普通文本和 refusal 都不能编码成 input_text
item["id"] = format!("msg_chat_{index}").into();
item["status"] = "completed".into();
}
input.push(item);
}
input.extend(calls);
}
Ok(input)
}
fn content_parts(
value: Value,
role: &str,
path: &str,
is_assistant: bool,
) -> Result<Vec<Value>, RequestDecodeError> {
let parts = match value {
Value::String(text) => return Ok(vec![text_part(text, is_assistant)]),
Value::Array(parts) => parts,
_ => return Err(invalid_type(path, "a string or an array")),
};
let mut result = Vec::with_capacity(parts.len());
for (index, value) in parts.into_iter().enumerate() {
let path = format!("{path}[{index}]");
let mut part = object(value, &path)?;
let kind = required_string(&mut part, "type", &format!("{path}.type"), false)?;
let mut converted = match kind.as_str() {
"text" => text_part(
required_string(&mut part, "text", &format!("{path}.text"), true)?,
is_assistant,
),
"image_url" if role == "user" => image_part(
required(&mut part, "image_url", &format!("{path}.image_url"))?,
&format!("{path}.image_url"),
)?,
"refusal" if role == "assistant" => json!({
"type": "refusal",
"refusal": required_string(&mut part, "refusal", &format!("{path}.refusal"), true)?,
}),
_ => return Err(unsupported(&format!("{path}.type"))),
};
if let Some(value) = optional(&mut part, "prompt_cache_breakpoint") {
if is_assistant {
return Err(unsupported(&format!("{path}.prompt_cache_breakpoint")));
}
converted["prompt_cache_breakpoint"] =
object(value, &format!("{path}.prompt_cache_breakpoint"))?.into();
}
result.push(converted);
}
Ok(result)
}
fn text_part(text: String, is_assistant: bool) -> Value {
if is_assistant {
json!({"type": "output_text", "text": text, "annotations": []})
} else {
json!({"type": "input_text", "text": text})
}
}
fn image_part(value: Value, path: &str) -> Result<Value, RequestDecodeError> {
let mut image = object(value, path)?;
let url = required_string(&mut image, "url", &format!("{path}.url"), false)?;
let valid = url::Url::parse(&url).is_ok_and(|parsed| {
matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some()
|| parsed.scheme() == "data"
&& url.starts_with("data:image/")
&& url
.split_once(";base64,")
.is_some_and(|(_, data)| !data.trim().is_empty())
});
if !valid {
return Err(invalid(&format!("{path}.url")));
}
let mut converted = json!({"type": "input_image", "image_url": url});
if let Some(detail) = optional(&mut image, "detail") {
string(&detail, &format!("{path}.detail"), false)?;
converted["detail"] = detail;
}
Ok(converted)
}
fn tool_calls(value: Value, path: &str) -> Result<Vec<Value>, RequestDecodeError> {
let calls = array(value, path)?;
let mut result = Vec::with_capacity(calls.len());
for (index, value) in calls.into_iter().enumerate() {
let path = format!("{path}[{index}]");
let mut call = object(value, &path)?;
let id = required_string(&mut call, "id", &format!("{path}.id"), false)?;
if required_string(&mut call, "type", &format!("{path}.type"), false)? != "function" {
return Err(unsupported(&format!("{path}.type")));
}
let function_path = format!("{path}.function");
let mut function = object(
required(&mut call, "function", &function_path)?,
&function_path,
)?;
let name = required_string(
&mut function,
"name",
&format!("{function_path}.name"),
false,
)?;
let arguments = required_string(
&mut function,
"arguments",
&format!("{function_path}.arguments"),
true,
)?;
result.push(
json!({"type": "function_call", "call_id": id, "name": name, "arguments": arguments}),
);
}
Ok(result)
}
fn function_tools(value: Value) -> Result<Vec<Value>, RequestDecodeError> {
array(value, "tools")?
.into_iter()
.enumerate()
.map(|(index, value)| {
let path = format!("tools[{index}]");
let mut tool = object(value, &path)?;
if required_string(&mut tool, "type", &format!("{path}.type"), false)? != "function" {
return Err(unsupported(&format!("{path}.type")));
}
let function_path = format!("{path}.function");
let mut function = object(
required(&mut tool, "function", &function_path)?,
&function_path,
)?;
let name = required_string(
&mut function,
"name",
&format!("{function_path}.name"),
false,
)?;
// Chat 的缺省是宽松函数 schema,不能让 Responses 的缺省收紧原始参数合同
let strict =
optional_bool(&mut function, "strict", &format!("{function_path}.strict"))?
.unwrap_or(false);
let mut converted = json!({"type": "function", "name": name, "strict": strict});
if let Some(description) = optional(&mut function, "description") {
string(&description, &format!("{function_path}.description"), true)?;
converted["description"] = description;
}
if let Some(parameters) = optional(&mut function, "parameters") {
converted["parameters"] = parameters;
}
Ok(converted)
})
.collect()
}
fn tool_choice(value: Value, tools: Option<&Value>) -> Result<Value, RequestDecodeError> {
let tools = tools.and_then(Value::as_array);
if let Value::String(choice) = &value {
return match choice.as_str() {
"auto" | "none" => Ok(value),
"required" if tools.is_some_and(|tools| !tools.is_empty()) => Ok(value),
"required" => Err(invalid("tool_choice")),
_ => Err(unsupported("tool_choice")),
};
}
let mut choice = object(value, "tool_choice")?;
if required_string(&mut choice, "type", "tool_choice.type", false)? != "function" {
return Err(unsupported("tool_choice.type"));
}
let mut function = object(
required(&mut choice, "function", "tool_choice.function")?,
"tool_choice.function",
)?;
let name = required_string(&mut function, "name", "tool_choice.function.name", false)?;
if !tools.is_some_and(|tools| {
tools
.iter()
.any(|tool| tool.get("name").and_then(Value::as_str) == Some(name.as_str()))
}) {
return Err(invalid("tool_choice.function.name"));
}
Ok(json!({"type": "function", "name": name}))
}
fn response_format(value: Value) -> Result<Value, RequestDecodeError> {
let mut format = object(value, "response_format")?;
let kind = required_string(&mut format, "type", "response_format.type", false)?;
match kind.as_str() {
"text" | "json_object" => Ok(json!({"type": kind})),
"json_schema" => {
let mut schema = object(
required(&mut format, "json_schema", "response_format.json_schema")?,
"response_format.json_schema",
)?;
let name = required_string(
&mut schema,
"name",
"response_format.json_schema.name",
false,
)?;
let definition = required(&mut schema, "schema", "response_format.json_schema.schema")?;
if definition.is_null() {
return Err(invalid("response_format.json_schema.schema"));
}
let mut converted = json!({"type": "json_schema", "name": name, "schema": definition});
if let Some(strict) =
optional_bool(&mut schema, "strict", "response_format.json_schema.strict")?
{
converted["strict"] = strict.into();
}
if let Some(description) = optional(&mut schema, "description") {
string(
&description,
"response_format.json_schema.description",
true,
)?;
converted["description"] = description;
}
Ok(converted)
}
_ => Err(unsupported("response_format.type")),
}
}
fn optional(object: &mut Map<String, Value>, key: &str) -> Option<Value> {
object.remove(key).filter(|value| !value.is_null())
}
fn required(
object: &mut Map<String, Value>,
key: &str,
path: &str,
) -> Result<Value, RequestDecodeError> {
object
.remove(key)
.ok_or_else(|| RequestDecodeError::MissingField { field: path.into() })
}
fn object(value: Value, path: &str) -> Result<Map<String, Value>, RequestDecodeError> {
match value {
Value::Object(object) => Ok(object),
_ => Err(invalid_type(path, "an object")),
}
}
fn array(value: Value, path: &str) -> Result<Vec<Value>, RequestDecodeError> {
match value {
Value::Array(array) => Ok(array),
_ => Err(invalid_type(path, "an array")),
}
}
fn string<'a>(
value: &'a Value,
path: &str,
allow_empty: bool,
) -> Result<&'a str, RequestDecodeError> {
let value = value
.as_str()
.ok_or_else(|| invalid_type(path, "a string"))?;
if !allow_empty && value.trim().is_empty() {
return Err(RequestDecodeError::EmptyField { field: path.into() });
}
Ok(value)
}
fn required_string(
object: &mut Map<String, Value>,
key: &str,
path: &str,
allow_empty: bool,
) -> Result<String, RequestDecodeError> {
let value = required(object, key, path)?;
string(&value, path, allow_empty)?;
match value {
Value::String(value) => Ok(value),
_ => Err(invalid_type(path, "a string")),
}
}
fn optional_bool(
object: &mut Map<String, Value>,
key: &str,
path: &str,
) -> Result<Option<bool>, RequestDecodeError> {
optional(object, key)
.map(|value| {
value
.as_bool()
.ok_or_else(|| invalid_type(path, "a boolean"))
})
.transpose()
}
fn invalid_type(path: &str, expected: &'static str) -> RequestDecodeError {
RequestDecodeError::InvalidType {
field: path.into(),
expected,
}
}
fn invalid(path: &str) -> RequestDecodeError {
RequestDecodeError::InvalidValue { field: path.into() }
}
fn unsupported(path: &str) -> RequestDecodeError {
RequestDecodeError::UnsupportedField { field: path.into() }
}
@@ -0,0 +1,664 @@
//! 将实际 Responses wire 投影为 Chat JSON 和 SSE,不拥有执行、计费或终结生命周期
use std::collections::{BTreeMap, BTreeSet};
use bytes::Bytes;
use gateway_protocol::openai::events::{billable_usage_is_complete, extract_usage};
use gateway_protocol::openai::sse::encode_sse_event;
use serde::Serialize;
use serde_json::{Value, json};
use crate::openai::responses::{ProtocolError, ProtocolErrorBody};
#[derive(Debug, Clone, Serialize)]
pub(in crate::openai) struct ChatIdentity {
pub id: String,
pub created: u64,
pub model: String,
}
#[derive(Serialize)]
struct Envelope<'a> {
#[serde(flatten)]
identity: &'a ChatIdentity,
object: &'static str,
choices: Value,
#[serde(skip_serializing_if = "Option::is_none")]
usage: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
metadata: Option<&'a Value>,
#[serde(skip_serializing_if = "Option::is_none")]
service_tier: Option<&'a Value>,
#[serde(skip_serializing_if = "Option::is_none")]
moderation: Option<Value>,
}
/// 输入已由现有 Responses collector 聚合,投影不再恢复或重建缺失的 output
pub(in crate::openai) fn project_response(
response: &Value,
identity: &ChatIdentity,
) -> Result<Value, ProtocolErrorBody> {
let output = response
.get("output")
.and_then(Value::as_array)
.ok_or_else(invalid_response)?;
validate_output_identities(output)?;
let mut content = None;
let mut refusal = None;
let mut tools = Vec::new();
for item in output {
match required_str(item, "type")? {
"message" => {
for part in message_parts(item)? {
let (kind, text) = part_text(part)?;
let target = match kind {
PartKind::Text => &mut content,
PartKind::Refusal => &mut refusal,
};
target.get_or_insert_with(String::new).push_str(text);
}
}
"function_call" => {
let (id, name, arguments) = function_fields(item)?;
tools.push(json!({
"id": id, "type": "function",
"function": {"name": name, "arguments": arguments}
}));
}
"reasoning" => {}
_ => return Err(invalid_response()),
}
}
let finish_reason = finish_reason(response, !tools.is_empty())?;
let mut message = json!({"role": "assistant", "content": content, "refusal": refusal});
if !tools.is_empty() {
message["tool_calls"] = Value::Array(tools);
}
serde_json::to_value(Envelope {
identity,
object: "chat.completion",
choices: json!([{"index": 0, "message": message, "finish_reason": finish_reason, "logprobs": null}]),
usage: chat_usage(response),
metadata: response.get("metadata"),
service_tier: response.get("service_tier"),
moderation: project_moderation(response)?,
})
.map_err(|_| invalid_response())
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum PartKind {
Text,
Refusal,
}
impl PartKind {
const fn field(self) -> &'static str {
match self {
Self::Text => "content",
Self::Refusal => "refusal",
}
}
}
struct Part {
kind: PartKind,
text: String,
}
struct Tool {
index: usize,
call_id: String,
name: String,
arguments: String,
}
enum Output {
Message(BTreeMap<u64, Part>),
Function(Tool),
Reasoning,
}
struct OutputItem {
id: Option<String>,
output: Output,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Terminal {
Completed,
Failed,
}
/// 只保存身份关联与快照去重所需内容;终态帧由外层受管 body 决定何时交付
pub(in crate::openai) struct ChatStreamEncoder {
identity: ChatIdentity,
include_usage: bool,
role_sent: bool,
output: BTreeMap<u64, OutputItem>,
terminal: Option<Terminal>,
}
impl ChatStreamEncoder {
pub(in crate::openai) fn new(identity: ChatIdentity, include_usage: bool) -> Self {
Self {
identity,
include_usage,
role_sent: false,
output: BTreeMap::new(),
terminal: None,
}
}
pub(in crate::openai) fn is_completed(&self) -> bool {
self.terminal == Some(Terminal::Completed)
}
pub(in crate::openai) fn has_wire_failure(&self) -> bool {
self.terminal == Some(Terminal::Failed)
}
pub(in crate::openai) fn push_event(
&mut self,
event_type: Option<&str>,
data: &Value,
) -> Result<Vec<Bytes>, ProtocolErrorBody> {
let event_type = event_type.or_else(|| data.get("type").and_then(Value::as_str));
if self.has_wire_failure() {
return Ok(Vec::new());
}
let mut frames = Vec::new();
match event_type {
Some("response.created") => {
self.require_open()?;
if !self.role_sent {
frames.push(self.delta(json!({"role": "assistant"}))?);
self.role_sent = true;
}
}
Some("response.output_text.delta" | "response.refusal.delta") => {
self.require_open()?;
let kind = if event_type == Some("response.refusal.delta") {
PartKind::Refusal
} else {
PartKind::Text
};
let delta = required_str(data, "delta")?;
let part = self.event_part(data, kind)?;
part.text.push_str(delta);
if !delta.is_empty() {
frames.push(self.delta(json!({kind.field(): delta}))?);
}
}
Some("response.output_text.done" | "response.refusal.done") => {
self.require_open()?;
let (kind, field) = if event_type == Some("response.refusal.done") {
(PartKind::Refusal, "refusal")
} else {
(PartKind::Text, "text")
};
let text = required_str(data, field)?;
let part = self.event_part(data, kind)?;
let suffix = append_snapshot(&mut part.text, text)?;
if !suffix.is_empty() {
frames.push(self.delta(json!({kind.field(): suffix}))?);
}
}
Some("response.content_part.added" | "response.content_part.done") => {
self.require_open()?;
let part = data.get("part").ok_or_else(invalid_response)?;
let (kind, text) = part_text(part)?;
let part = self.event_part(data, kind)?;
let suffix = append_snapshot(&mut part.text, text)?;
if !suffix.is_empty() {
frames.push(self.delta(json!({kind.field(): suffix}))?);
}
}
Some("response.output_item.added" | "response.output_item.done") => {
self.require_open()?;
self.item_snapshot(
required_index(data, "output_index")?,
data.get("item").ok_or_else(invalid_response)?,
false,
&mut frames,
)?;
}
Some(
"response.function_call_arguments.delta" | "response.function_call_arguments.done",
) => {
self.require_open()?;
let index = required_index(data, "output_index")?;
let item = self.output.get_mut(&index).ok_or_else(invalid_response)?;
bind_id(item, optional_id(data, "item_id")?)?;
let Output::Function(tool) = &mut item.output else {
return Err(invalid_response());
};
let delta = if event_type == Some("response.function_call_arguments.delta") {
let delta = required_str(data, "delta")?;
tool.arguments.push_str(delta);
delta
} else {
append_snapshot(&mut tool.arguments, required_str(data, "arguments")?)?
};
let tool_index = tool.index;
if !delta.is_empty() {
frames.push(self.delta(json!({"tool_calls": [{"index": tool_index, "function": {"arguments": delta}}]}))?);
}
}
Some("response.completed" | "response.incomplete") => {
self.require_open()?;
let response = data.get("response").ok_or_else(invalid_response)?;
let status = required_str(response, "status")?;
if (event_type == Some("response.completed") && status != "completed")
|| (event_type == Some("response.incomplete") && status != "incomplete")
{
return Err(invalid_response());
}
if let Some(output) = response.get("output") {
let output = output.as_array().ok_or_else(invalid_response)?;
validate_output_identities(output)?;
for (index, item) in output.iter().enumerate() {
self.item_snapshot(index as u64, item, true, &mut frames)?;
}
} else if self.output.is_empty() {
return Err(invalid_response());
}
let finish_reason = finish_reason(response, self.tool_count() > 0)?;
frames.push(self.chunk(
json!([{"index": 0, "delta": {}, "finish_reason": finish_reason, "logprobs": null}]),
self.include_usage.then_some(Value::Null),
Some(response),
)?);
if self.include_usage
&& let Some(usage) = chat_usage(response)
{
frames.push(self.chunk(json!([]), Some(usage), None)?);
}
self.terminal = Some(Terminal::Completed);
}
Some("response.failed" | "error") => {
self.require_open()?;
let error = data
.pointer("/response/error")
.or_else(|| data.get("error"))
.unwrap_or(data);
required_str(error, "message")?;
// 错误恢复信号和扩展由既有出口拥有,Chat 仅转换外层包装
let mut error = error.clone();
if error
.get("type")
.and_then(Value::as_str)
.is_none_or(|kind| kind == "error")
{
error["type"] = json!("server_error");
}
frames.push(sse_frame(&json!({"error": error})));
self.terminal = Some(Terminal::Failed);
}
// 辅助事件可随上游演进;实际 output item 和 content part 仍逐项检查可表达性
_ => {}
}
// done-only 流也要提供完整 Chat 消息身份,辅助事件或私有推理不触发角色帧
if !self.role_sent && !frames.is_empty() && !self.has_wire_failure() {
frames.insert(0, self.delta(json!({"role": "assistant"}))?);
self.role_sent = true;
}
Ok(frames)
}
fn require_open(&self) -> Result<(), ProtocolErrorBody> {
if self.terminal.is_some() {
Err(invalid_response())
} else {
Ok(())
}
}
fn tool_count(&self) -> usize {
self.output
.values()
.filter(|item| matches!(item.output, Output::Function(_)))
.count()
}
fn event_part(&mut self, data: &Value, kind: PartKind) -> Result<&mut Part, ProtocolErrorBody> {
let index = required_index(data, "output_index")?;
let content_index = required_index(data, "content_index")?;
let id = optional_id(data, "item_id")?;
let item = self.output.entry(index).or_insert_with(|| OutputItem {
id: None,
output: Output::Message(BTreeMap::new()),
});
bind_id(item, id)?;
message_part(item, content_index, kind)
}
fn item_snapshot(
&mut self,
index: u64,
snapshot: &Value,
terminal: bool,
frames: &mut Vec<Bytes>,
) -> Result<(), ProtocolErrorBody> {
let id = optional_id(snapshot, "id")?;
let kind = required_str(snapshot, "type")?;
let call_id = if kind == "function_call" {
Some(required_nonempty_str(snapshot, "call_id")?)
} else {
None
};
let known_index = self.find_item(id, call_id)?;
let index = match known_index {
Some(known) if terminal || known == index => known,
Some(_) => return Err(invalid_response()),
None => index,
};
if !self.output.contains_key(&index) {
let output = match kind {
"message" => Output::Message(BTreeMap::new()),
"reasoning" => Output::Reasoning,
"function_call" => {
let (call_id, name, _) = function_fields(snapshot)?;
let tool_index = self.tool_count();
frames.push(self.delta(json!({"tool_calls": [{"index": tool_index, "id": call_id, "type": "function", "function": {"name": name, "arguments": ""}}]}))?);
Output::Function(Tool {
index: tool_index,
call_id: call_id.to_owned(),
name: name.to_owned(),
arguments: String::new(),
})
}
_ => return Err(invalid_response()),
};
self.output.insert(index, OutputItem { id: None, output });
}
let item = self.output.get_mut(&index).ok_or_else(invalid_response)?;
bind_id(item, id)?;
match (&mut item.output, kind) {
(Output::Message(parts), "message") => {
for (index, part) in message_parts(snapshot)?.iter().enumerate() {
let (kind, text) = part_text(part)?;
let part = part_entry(parts, index as u64, kind)?;
let suffix = append_snapshot(&mut part.text, text)?;
if !suffix.is_empty() {
frames.push(delta_frame(
&self.identity,
self.include_usage,
json!({kind.field(): suffix}),
)?);
}
}
}
(Output::Function(tool), "function_call") => {
let (id, name, arguments) = function_fields(snapshot)?;
if tool.call_id != id || tool.name != name {
return Err(invalid_response());
}
let suffix = append_snapshot(&mut tool.arguments, arguments)?;
if !suffix.is_empty() {
frames.push(delta_frame(&self.identity, self.include_usage, json!({"tool_calls": [{"index": tool.index, "function": {"arguments": suffix}}]}))?);
}
}
(Output::Reasoning, "reasoning") => {}
_ => return Err(invalid_response()),
}
Ok(())
}
fn find_item(
&self,
id: Option<&str>,
call_id: Option<&str>,
) -> Result<Option<u64>, ProtocolErrorBody> {
let mut found = None;
for (&index, item) in &self.output {
let same_id = id.is_some() && item.id.as_deref() == id;
let same_call = matches!(&item.output, Output::Function(tool) if call_id == Some(tool.call_id.as_str()));
if same_id || same_call {
if found.is_some_and(|previous| previous != index) {
return Err(invalid_response());
}
found = Some(index);
}
}
Ok(found)
}
fn delta(&self, delta: Value) -> Result<Bytes, ProtocolErrorBody> {
delta_frame(&self.identity, self.include_usage, delta)
}
fn chunk(
&self,
choices: Value,
usage: Option<Value>,
response: Option<&Value>,
) -> Result<Bytes, ProtocolErrorBody> {
chunk_frame(&self.identity, choices, usage, response)
}
}
fn message_part(
item: &mut OutputItem,
index: u64,
kind: PartKind,
) -> Result<&mut Part, ProtocolErrorBody> {
let Output::Message(parts) = &mut item.output else {
return Err(invalid_response());
};
part_entry(parts, index, kind)
}
fn part_entry(
parts: &mut BTreeMap<u64, Part>,
index: u64,
kind: PartKind,
) -> Result<&mut Part, ProtocolErrorBody> {
let part = parts.entry(index).or_insert_with(|| Part {
kind,
text: String::new(),
});
if part.kind != kind {
return Err(invalid_response());
}
Ok(part)
}
fn bind_id(item: &mut OutputItem, id: Option<&str>) -> Result<(), ProtocolErrorBody> {
if let Some(id) = id {
if item.id.as_deref().is_some_and(|previous| previous != id) {
return Err(invalid_response());
}
if item.id.is_none() {
item.id = Some(id.to_owned());
}
}
Ok(())
}
fn append_snapshot<'a>(text: &mut String, snapshot: &'a str) -> Result<&'a str, ProtocolErrorBody> {
let suffix = snapshot
.strip_prefix(text.as_str())
.ok_or_else(invalid_response)?;
text.push_str(suffix);
Ok(suffix)
}
fn message_parts(item: &Value) -> Result<&[Value], ProtocolErrorBody> {
if required_str(item, "role")? != "assistant" {
return Err(invalid_response());
}
item.get("content")
.and_then(Value::as_array)
.map(Vec::as_slice)
.ok_or_else(invalid_response)
}
fn part_text(part: &Value) -> Result<(PartKind, &str), ProtocolErrorBody> {
match required_str(part, "type")? {
"output_text" => Ok((PartKind::Text, required_str(part, "text")?)),
"refusal" => Ok((PartKind::Refusal, required_str(part, "refusal")?)),
_ => Err(invalid_response()),
}
}
fn function_fields(item: &Value) -> Result<(&str, &str, &str), ProtocolErrorBody> {
Ok((
required_nonempty_str(item, "call_id")?,
required_nonempty_str(item, "name")?,
required_str(item, "arguments")?,
))
}
fn validate_output_identities(output: &[Value]) -> Result<(), ProtocolErrorBody> {
let mut item_ids = BTreeSet::new();
let mut call_ids = BTreeSet::new();
for item in output {
if let Some(id) = optional_id(item, "id")?
&& !item_ids.insert(id)
{
return Err(invalid_response());
}
if item.get("type").and_then(Value::as_str) == Some("function_call")
&& !call_ids.insert(required_nonempty_str(item, "call_id")?)
{
return Err(invalid_response());
}
}
Ok(())
}
fn finish_reason(response: &Value, has_tools: bool) -> Result<&'static str, ProtocolErrorBody> {
if response.get("error").is_some_and(|value| !value.is_null()) {
return Err(invalid_response());
}
match required_str(response, "status")? {
"completed" => Ok(if has_tools { "tool_calls" } else { "stop" }),
"incomplete" => match response
.pointer("/incomplete_details/reason")
.and_then(Value::as_str)
{
Some("max_output_tokens") => Ok("length"),
Some("content_filter") => Ok("content_filter"),
_ => Err(invalid_response()),
},
_ => Err(invalid_response()),
}
}
fn chat_usage(response: &Value) -> Option<Value> {
let usage = extract_usage(response)?;
if !billable_usage_is_complete(response, usage) {
return None;
}
let mut result = json!({"prompt_tokens": usage.input_tokens, "completion_tokens": usage.output_tokens, "total_tokens": usage.total_tokens});
if response
.pointer("/usage/input_tokens_details/cached_tokens")
.is_some()
{
result["prompt_tokens_details"] = json!({"cached_tokens": usage.cached_input_tokens});
}
if response
.pointer("/usage/output_tokens_details/reasoning_tokens")
.and_then(Value::as_u64)
.is_some_and(|tokens| tokens <= usage.output_tokens)
{
result["completion_tokens_details"] =
json!({"reasoning_tokens": usage.reasoning_output_tokens});
}
Some(result)
}
fn project_moderation(response: &Value) -> Result<Option<Value>, ProtocolErrorBody> {
let Some(moderation) = response.get("moderation").filter(|value| !value.is_null()) else {
return Ok(None);
};
let mut result = json!({});
for side in ["input", "output"] {
let value = moderation.get(side).ok_or_else(invalid_response)?;
result[side] = match required_str(value, "type")? {
"moderation_result" => {
json!({"type": "moderation_results", "model": required_str(value, "model")?, "results": [value]})
}
"error" => value.clone(),
_ => return Err(invalid_response()),
};
}
Ok(Some(result))
}
fn delta_frame(
identity: &ChatIdentity,
include_usage: bool,
delta: Value,
) -> Result<Bytes, ProtocolErrorBody> {
chunk_frame(
identity,
json!([{"index": 0, "delta": delta, "finish_reason": null, "logprobs": null}]),
include_usage.then_some(Value::Null),
None,
)
}
fn chunk_frame(
identity: &ChatIdentity,
choices: Value,
usage: Option<Value>,
response: Option<&Value>,
) -> Result<Bytes, ProtocolErrorBody> {
let value = serde_json::to_value(Envelope {
identity,
object: "chat.completion.chunk",
choices,
usage,
metadata: None,
service_tier: response.and_then(|value| value.get("service_tier")),
moderation: response.map(project_moderation).transpose()?.flatten(),
})
.map_err(|_| invalid_response())?;
Ok(sse_frame(&value))
}
fn sse_frame(value: &Value) -> Bytes {
Bytes::from(encode_sse_event("", &value.to_string()))
}
fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, ProtocolErrorBody> {
value
.get(field)
.and_then(Value::as_str)
.ok_or_else(invalid_response)
}
fn required_nonempty_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, ProtocolErrorBody> {
let value = required_str(value, field)?;
if value.is_empty() {
return Err(invalid_response());
}
Ok(value)
}
fn optional_id<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, ProtocolErrorBody> {
value
.get(field)
.map(|_| required_nonempty_str(value, field))
.transpose()
}
fn required_index(value: &Value, field: &str) -> Result<u64, ProtocolErrorBody> {
value
.get(field)
.and_then(Value::as_u64)
.ok_or_else(invalid_response)
}
fn invalid_response() -> ProtocolErrorBody {
ProtocolErrorBody {
error: ProtocolError {
kind: "server_error",
code: "invalid_upstream_response",
message: "The upstream response cannot be represented as a Chat completion.".to_owned(),
param: None,
},
}
}
@@ -33,7 +33,8 @@ pub fn openai_error_response(
}
}
/// Codex 将这两个容量码视为不可重试;仅在交付边界转换,内部仍保留真实上游事实
/// 无重试提示的容量拒绝及旧版 Codex 限流错误需要可重试投影
/// 仅在交付边界转换,内部仍保留真实上游事实
pub(super) fn client_error_code(code: &str) -> &str {
match code {
"server_is_overloaded" | "slow_down" => "server_error",
@@ -83,7 +84,7 @@ pub fn protocol_error_response(status: StatusCode, body: ProtocolErrorBody) -> R
pub fn missing_client_api_key_response() -> (StatusCode, Json<Value>) {
openai_error_response(
StatusCode::UNAUTHORIZED,
"Missing or invalid API key",
"Missing or invalid API key.",
"invalid_request_error",
"invalid_api_key",
)
@@ -93,7 +94,7 @@ pub fn missing_client_api_key_response() -> (StatusCode, Json<Value>) {
pub fn runtime_unavailable_response() -> (StatusCode, Json<Value>) {
openai_error_response(
StatusCode::SERVICE_UNAVAILABLE,
"Gateway runtime configuration is temporarily unavailable",
"Gateway configuration is temporarily unavailable.",
"server_error",
"runtime_configuration_unavailable",
)
@@ -110,7 +111,7 @@ pub fn client_version_rejection_response(
let (message, code, current_version) = match rejection.current() {
Some(current) => (
format!(
"{client_label} {current} is below the minimum required version {}. Upgrade {client_label} and retry.",
"{client_label} {current} is outdated. Upgrade to {} or later.",
rejection.min()
),
"client_version_too_old",
@@ -118,15 +119,15 @@ pub fn client_version_rejection_response(
),
None => (
format!(
"A valid {client_label} version is required. Upgrade to version {} or newer and retry.",
rejection.min()
"Cannot read the {client_label} version. Check the User-Agent and version headers."
),
"client_version_unavailable",
None,
),
};
// Codex 将 400 视为不可重试的请求错误,426 则会触发 WS 回退和 HTTP 重试
(
StatusCode::UPGRADE_REQUIRED,
StatusCode::BAD_REQUEST,
Json(json!({
"error": {
"message": message,
@@ -144,7 +145,7 @@ pub fn client_version_rejection_response(
pub fn model_not_found_response() -> (StatusCode, Json<Value>) {
openai_error_response(
StatusCode::NOT_FOUND,
"The requested model does not exist or is not available",
"The requested model does not exist or is not available.",
"invalid_request_error",
"model_not_found",
)
@@ -124,7 +124,7 @@ async fn image_operation(
.map_err(|_| {
GatewayError::new(
GatewayErrorKind::Internal,
"OpenAI protocol identifier is invalid",
"OpenAI protocol identifier is invalid.",
)
})?
.with_context(context);
@@ -26,7 +26,7 @@ pub(super) async fn normalize_edit_body(
return Ok(body);
}
if headers.get_all(CONTENT_TYPE).iter().count() != 1 {
return Err(invalid("Image edit requires a single Content-Type header"));
return Err(invalid("Image edit requires a single Content-Type header."));
}
let mut request = Request::new(Body::from(body));
@@ -35,7 +35,7 @@ pub(super) async fn normalize_edit_body(
DefaultBodyLimit::disable().apply(&mut request);
let mut multipart = Multipart::from_request(request, &())
.await
.map_err(|_| invalid("Image edit multipart boundary is invalid"))?;
.map_err(|_| invalid("Image edit multipart boundary is invalid."))?;
let mut body = Vec::new();
let mut serializer = serde_json::Serializer::new(&mut body);
// 直接序列化字段,避免经过 Map 后覆盖同名参数
@@ -44,17 +44,17 @@ pub(super) async fn normalize_edit_body(
while let Some(field) = multipart
.next_field()
.await
.map_err(|_| invalid("Image edit multipart body is invalid"))?
.map_err(|_| invalid("Image edit multipart body is invalid."))?
{
let name = field
.name()
.ok_or_else(|| invalid("Image edit multipart fields must have names"))?
.ok_or_else(|| invalid("Image edit multipart fields must have names."))?
.to_owned();
let is_file = field.file_name().is_some();
let bytes = field
.bytes()
.await
.map_err(|_| invalid("Image edit multipart field is invalid"))?;
.map_err(|_| invalid("Image edit multipart field is invalid."))?;
if matches!(name.as_str(), "image" | "image[]") {
// 按表单中的出现顺序追加,多图顺序参与编辑语义
images.push(image_reference(&bytes)?);
@@ -65,7 +65,7 @@ pub(super) async fn normalize_edit_body(
} else {
if is_file {
return Err(invalid(
"Only image, image[] and mask may contain image files",
"Only image, image[] and mask may contain image files.",
));
}
scalar_field(&name, &bytes)?
@@ -76,7 +76,7 @@ pub(super) async fn normalize_edit_body(
}
if images.is_empty() {
return Err(invalid(
"Image edit multipart requires an image or image[] upload",
"Image edit multipart requires an image or image[] upload.",
));
}
fields
@@ -92,14 +92,14 @@ fn scalar_field(name: &str, bytes: &[u8]) -> Result<Value, GatewayError> {
match name {
"n" | "output_compression" | "partial_images" => serde_json::from_slice::<Number>(bytes)
.map(Value::Number)
.map_err(|_| invalid("Image edit numeric fields must contain JSON numbers")),
.map_err(|_| invalid("Image edit numeric fields must contain JSON numbers.")),
"stream" => serde_json::from_slice::<bool>(bytes)
.map(Value::Bool)
.map_err(|_| invalid("Image edit stream field must contain a JSON boolean")),
.map_err(|_| invalid("Image edit stream field must contain a JSON boolean.")),
// 其余表单字段保留名称与文本值,不因字段尚未识别而丢弃或拒绝
_ => {
let text = std::str::from_utf8(bytes)
.map_err(|_| invalid("Image edit text fields must be valid UTF-8"))?;
.map_err(|_| invalid("Image edit text fields must be valid UTF-8."))?;
Ok(Value::String(text.to_owned()))
}
}
@@ -114,7 +114,7 @@ fn image_reference(bytes: &[u8]) -> Result<Value, GatewayError> {
} else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
"image/webp"
} else {
return Err(invalid("Image edit uploads must be PNG, JPEG or WebP"));
return Err(invalid("Image edit uploads must be PNG, JPEG or WebP."));
};
Ok(json!({ "image_url": format!("data:{mime};base64,{}", STANDARD.encode(bytes)) }))
}
@@ -126,6 +126,6 @@ fn invalid(message: &'static str) -> GatewayError {
fn encode_error(_: serde_json::Error) -> GatewayError {
GatewayError::new(
GatewayErrorKind::Internal,
"Could not encode image edit JSON",
"Could not encode image edit JSON.",
)
}
@@ -70,7 +70,7 @@ pub(crate) async fn live_call(
return live_error_response(
LiveErrorShape::Live,
StatusCode::BAD_REQUEST,
"Codex live call request has an invalid model",
"Codex live call request has an invalid model.",
"invalid_request",
);
}
@@ -184,7 +184,7 @@ async fn read_call_body(
Box::new(live_error_response(
LiveErrorShape::Live,
StatusCode::PAYLOAD_TOO_LARGE,
"Codex live request body too large",
"Codex live request body too large.",
"invalid_request",
))
})?;
@@ -194,7 +194,7 @@ async fn read_call_body(
Box::new(live_error_response(
LiveErrorShape::Live,
StatusCode::BAD_REQUEST,
"Codex live multipart body is invalid",
"Codex live multipart body is invalid.",
"invalid_request",
))
})?;
@@ -205,7 +205,7 @@ async fn read_call_body(
Box::new(live_error_response(
LiveErrorShape::Live,
StatusCode::BAD_REQUEST,
"Codex live multipart body is invalid",
"Codex live multipart body is invalid.",
"invalid_request",
))
})?
@@ -218,7 +218,7 @@ async fn read_call_body(
Box::new(live_error_response(
LiveErrorShape::Live,
StatusCode::BAD_REQUEST,
"Codex live multipart body is invalid",
"Codex live multipart body is invalid.",
"invalid_request",
))
})?;
@@ -229,7 +229,7 @@ async fn read_call_body(
Box::new(live_error_response(
LiveErrorShape::Live,
StatusCode::BAD_REQUEST,
"Codex live multipart body is invalid",
"Codex live multipart body is invalid.",
"invalid_request",
))
})?;
@@ -237,7 +237,7 @@ async fn read_call_body(
Box::new(live_error_response(
LiveErrorShape::Live,
StatusCode::BAD_REQUEST,
"Codex live session field must contain valid JSON",
"Codex live session field must contain valid JSON.",
"invalid_request",
))
})?;
@@ -251,7 +251,7 @@ async fn read_call_body(
None => Err(Box::new(live_error_response(
LiveErrorShape::Live,
StatusCode::BAD_REQUEST,
"Codex live multipart body requires an sdp field",
"Codex live multipart body requires an sdp field.",
"invalid_request",
))),
}
@@ -121,7 +121,7 @@ async fn sip_unsupported() -> Response {
pub(crate) fn realtime_unsupported_response(capability: &str) -> Response {
openai_error_response(
StatusCode::NOT_IMPLEMENTED,
&format!("{capability} are not supported by the Codex OAuth upstream"),
&format!("Unsupported by Codex OAuth: {capability}."),
"not_supported_error",
"realtime_capability_not_supported",
)
@@ -168,7 +168,7 @@ pub(crate) fn live_call_operation(
Box::new(live_error_response(
LiveErrorShape::Live,
StatusCode::BAD_REQUEST,
"Codex live call request is invalid",
"Codex live call request is invalid.",
"invalid_request",
))
};
@@ -251,7 +251,7 @@ pub(crate) fn encode_call_request(
))
};
if sdp.trim().is_empty() {
return Err(invalid("Codex live call request requires an SDP offer"));
return Err(invalid("Codex live call request requires an SDP offer."));
}
let mut payload = serde_json::Map::new();
payload.insert("sdp".to_owned(), Value::String(sdp.to_owned()));
@@ -260,7 +260,7 @@ pub(crate) fn encode_call_request(
}
serde_json::to_vec(&payload)
.map(Bytes::from)
.map_err(|_| invalid("Codex live call request is invalid"))
.map_err(|_| invalid("Codex live call request is invalid."))
}
/// 共用响应身份与传输边界;Live 原始正文另保留上游 Content-Type
@@ -310,7 +310,7 @@ pub(crate) fn normalize_call_request(
Box::new(live_error_response(
LiveErrorShape::Live,
StatusCode::BAD_REQUEST,
"Codex live call request requires a UTF-8 SDP offer",
"Codex live call request requires a UTF-8 SDP offer.",
"invalid_request",
))
})?;
@@ -149,7 +149,7 @@ fn upgrade_required_response() -> Response {
let mut response = live_error_response(
LiveErrorShape::Realtime,
StatusCode::UPGRADE_REQUIRED,
"WebSocket upgrade required",
"WebSocket upgrade required.",
"websocket_upgrade_required",
);
response.headers_mut().insert(
@@ -246,7 +246,7 @@ pub(crate) fn error_response(error: MiddlewareError) -> Response {
MiddlewareError::Rejected | MiddlewareError::Remote { rejected: true, .. } => {
openai_error_response(
StatusCode::FORBIDDEN,
"Request rejected by middleware",
"Request rejected by middleware.",
"invalid_request_error",
"middleware_rejected",
)
@@ -255,7 +255,7 @@ pub(crate) fn error_response(error: MiddlewareError) -> Response {
MiddlewareError::Fault | MiddlewareError::Remote { .. } | MiddlewareError::InvalidState => {
openai_error_response(
StatusCode::BAD_GATEWAY,
"Request middleware failed",
"Request middleware failed.",
"server_error",
"middleware_failed",
)
@@ -1,6 +1,7 @@
//! OpenAI 客户端协议 adapter
pub mod auth;
mod chat_completions;
mod endpoint;
pub mod error;
pub mod images;
@@ -72,7 +72,7 @@ async fn models_response(
{
return openai_error_response(
StatusCode::BAD_REQUEST,
"Invalid client_version",
"Invalid client_version.",
"invalid_request_error",
"invalid_client_version",
)
@@ -134,7 +134,7 @@ async fn models_response(
fn catalog_unavailable_response() -> Response {
openai_error_response(
StatusCode::SERVICE_UNAVAILABLE,
"Model catalog is temporarily unavailable",
"Model catalog is temporarily unavailable.",
"server_error",
"model_catalog_unavailable",
)

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