Compare commits

...
46 Commits
Author SHA1 Message Date
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
554 changed files with 19046 additions and 7710 deletions
+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 -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#验证命令)
+96 -106
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",
]
@@ -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",
]
@@ -2125,12 +2122,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 +2481,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 +2509,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 +2641,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 +3100,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 +3131,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 +3154,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 +3185,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 +3198,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 +3629,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 +3868,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 +3878,6 @@ dependencies = [
"html5ever",
"precomputed-hash",
"selectors",
"tendril",
]
[[package]]
@@ -3950,17 +3942,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 +4057,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 +4391,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",
]
@@ -5214,11 +5204,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 +5641,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 +5667,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) {
@@ -740,7 +740,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 +801,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>,
@@ -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 {
@@ -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,
@@ -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(),
@@ -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(),
@@ -1361,7 +1361,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);
}
+1 -1
View File
@@ -37,7 +37,7 @@ tower-http = { version = "0.7.0", features = ["compression-gzip", "cors", "fs",
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]
@@ -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"]
@@ -279,7 +279,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 +376,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,
@@ -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,
@@ -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),
}
}
}
@@ -764,7 +764,6 @@ pub(super) fn usage_record_with_account(
upstream_transport: Some("http_sse".to_owned()),
http_version: Some("h2".to_owned()),
websocket_pool: None,
upstream_response_model: None,
service_tier: None,
provider_metadata_json: None,
attempt_count: 1,
@@ -909,7 +908,6 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
provider_account_authentication_kind: Some("oauth".to_owned()),
upstream_model_id: Some("grok-4.5".to_owned()),
upstream_transport: Some("http_sse".to_owned()),
upstream_response_model: Some("grok-4.6-build".to_owned()),
service_tier: Some("default".to_owned()),
input_tokens: Some(1),
output_tokens: Some(1),
@@ -1059,7 +1057,6 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
"imageOutputTokens": value["data"]["items"][0]["tokenDetails"]["imageOutputTokens"],
"requestedModel": value["data"]["items"][0]["requestedModel"],
"upstreamModel": value["data"]["items"][0]["upstreamModel"],
"upstreamResponseModel": value["data"]["items"][0]["upstreamResponseModel"],
"reasoningEffort": value["data"]["items"][0]["reasoningEffort"],
"reasoningPreset": value["data"]["items"][0]["reasoningPreset"],
"subagentKind": value["data"]["items"][0]["subagentKind"],
@@ -1083,7 +1080,6 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
"imageOutputTokens": 9,
"requestedModel": "grok-4.5",
"upstreamModel": "grok-4.5",
"upstreamResponseModel": "grok-4.6-build",
"reasoningEffort": "max",
"reasoningPreset": "ultra",
"subagentKind": "worker",
@@ -322,6 +322,17 @@ async fn instance_list_serializes_safe_runtime_diagnostics() {
assert_eq!(instance["running"], false);
assert_eq!(instance["runtime"]["status"], "preparation_failed");
assert_eq!(instance["runtime"]["actualRevision"], 6);
assert_eq!(
instance["runtime"]["load"],
serde_json::json!({
"activeCalls": 3,
"executingCalls": 1,
"queuedCalls": 1,
"resumingCalls": 1,
"pendingCallbacks": 2,
"activeStreams": 0,
})
);
assert_eq!(
instance["runtime"]["drainingRevisions"],
serde_json::json!([5])
@@ -665,6 +676,14 @@ impl gateway_admin::ports::plugins::PluginRuntimeDiagnostics for TestPluginPorts
message: "插件候选准备失败".into(),
}),
draining_revisions: vec![5],
load: Some(gateway_admin::model::plugins::instances::PluginInstanceRuntimeLoad {
active_calls: 3,
executing_calls: 1,
queued_calls: 1,
resuming_calls: 1,
pending_callbacks: 2,
active_streams: 0,
}),
},
)
})
@@ -110,7 +110,6 @@ fn usage_record() -> UsageListRecord {
provider_account_authentication_kind: Some("private-sentinel".to_owned()),
upstream_model_id: Some("private-sentinel".to_owned()),
upstream_transport: Some("websocket".to_owned()),
upstream_response_model: Some("private-sentinel".to_owned()),
service_tier: Some("private-sentinel".to_owned()),
input_tokens: Some(1000),
output_tokens: Some(100),
@@ -2164,109 +2164,132 @@ async fn streaming_upstream_wire_failure_should_not_be_rewritten_as_a_gateway_er
#[tokio::test]
async fn streaming_upstream_error_event_should_be_translated_to_response_failed_for_sse() {
let trace = Arc::new(Trace::default());
let error_data = json!({
"type": "error",
"error": {
"type": "service_unavailable_error",
"code": "server_is_overloaded",
"message": "Our servers are currently overloaded. Please try again later.",
"param": null,
"future_error_field": {"keep": true}
},
"sequence_number": 2,
"future_event_field": {"keep": true}
});
let projected_error_frame = Bytes::from(encode_sse_event("error", &error_data.to_string()));
let error_wire = ProviderEvent::wire(
ProtocolWireEvent::json_with_raw_sse_metadata(
"openai",
Some("error".to_owned()),
error_data,
projected_error_frame,
None,
None,
)
.expect("valid upstream error wire"),
);
let started_wire = ProviderEvent::canonical_with_wire(
vec![started()],
ProtocolWireEvent::json(
"openai",
Some("response.created".to_owned()),
json!({
"type": "response.created",
"response": {
"id": "resp_test",
"model": "public-model",
"status": "in_progress",
"future_response_field": {"keep": true}
}
}),
)
.expect("valid upstream started wire"),
);
let session = FakeSession::streaming(
Arc::clone(&trace),
vec![
NextStep::Event(CoordinatedEvent::single(
started_wire,
CommitRequirement::CommitBeforeDelivery,
)),
NextStep::Event(CoordinatedEvent::single(
error_wire,
CommitRequirement::AlreadyCommitted,
)),
NextStep::Error(EngineError::Provider(ProviderError::new(
ProviderErrorKind::UpstreamCapacityUnavailable,
UpstreamSendState::Sent,
))),
],
);
let response = stream_execution_response(Box::new(session), None).await;
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read SSE body");
let body = String::from_utf8(body.to_vec()).expect("SSE is UTF-8");
let events = parse_sse_events(&body).expect("translated SSE should parse");
let failed = events
.iter()
.find(|event| event.event.as_deref() == Some("response.failed"))
.expect("translated response.failed event");
let failed: Value = serde_json::from_str(&failed.data).expect("response.failed JSON");
assert_eq!(
for (error_type, code, client_code, kind, client_status) in [
(
failed,
body.matches("event: response.failed").count(),
body.contains("event: error\n"),
body.ends_with("data: [DONE]\n\n"),
"service_unavailable_error",
"server_is_overloaded",
"server_error",
ProviderErrorKind::UpstreamCapacityUnavailable,
503,
),
(
json!({
"type": "response.failed",
"response": {
"id": "resp_test",
"model": "public-model",
"status": "failed",
"error": {
"type": "service_unavailable_error",
"code": "server_error",
"message": "Our servers are currently overloaded. Please try again later.",
"param": null,
"future_error_field": {"keep": true}
"rate_limit_error",
"turn_rate_limit_exceeded",
"turn_rate_limit_exceeded",
ProviderErrorKind::RateLimited,
429,
),
] {
let trace = Arc::new(Trace::default());
let error_data = json!({
"type": "error",
"status": 429,
"headers": {"Retry-After": "24"},
"error": {
"type": error_type,
"code": code,
"message": "Our servers are currently overloaded. Please try again later.",
"param": null,
"future_error_field": {"keep": true}
},
"sequence_number": 2,
"future_event_field": {"keep": true}
});
let projected_error_frame = Bytes::from(encode_sse_event("error", &error_data.to_string()));
let error_wire = ProviderEvent::wire(
ProtocolWireEvent::json_with_raw_sse_metadata(
"openai",
Some("error".to_owned()),
error_data,
projected_error_frame,
None,
None,
)
.expect("valid upstream error wire"),
);
let started_wire = ProviderEvent::canonical_with_wire(
vec![started()],
ProtocolWireEvent::json(
"openai",
Some("response.created".to_owned()),
json!({
"type": "response.created",
"response": {
"id": "resp_test",
"model": "public-model",
"status": "in_progress",
"future_response_field": {"keep": true}
}
}),
)
.expect("valid upstream started wire"),
);
let session = FakeSession::streaming(
Arc::clone(&trace),
vec![
NextStep::Event(CoordinatedEvent::single(
started_wire,
CommitRequirement::CommitBeforeDelivery,
)),
NextStep::Event(CoordinatedEvent::single(
error_wire,
CommitRequirement::AlreadyCommitted,
)),
NextStep::Error(EngineError::Provider(ProviderError::new(
kind,
UpstreamSendState::Sent,
))),
],
);
let response = stream_execution_response(Box::new(session), None).await;
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read SSE body");
let body = String::from_utf8(body.to_vec()).expect("SSE is UTF-8");
let events = parse_sse_events(&body).expect("translated SSE should parse");
let failed = events
.iter()
.find(|event| event.event.as_deref() == Some("response.failed"))
.expect("translated response.failed event");
let failed: Value = serde_json::from_str(&failed.data).expect("response.failed JSON");
assert_eq!(
(
failed,
body.matches("event: response.failed").count(),
body.contains("event: error\n"),
body.ends_with("data: [DONE]\n\n"),
),
(
json!({
"type": "response.failed",
"status": client_status,
"headers": {"Retry-After": "24"},
"response": {
"id": "resp_test",
"model": "public-model",
"status": "failed",
"error": {
"type": error_type,
"code": client_code,
"message": "Our servers are currently overloaded. Please try again later.",
"param": null,
"future_error_field": {"keep": true},
"headers": {"retry-after": "24"}
},
"future_response_field": {"keep": true}
},
"future_response_field": {"keep": true}
},
"sequence_number": 2,
"future_event_field": {"keep": true}
}),
1,
false,
true,
)
);
"sequence_number": 2,
"future_event_field": {"keep": true}
}),
1,
false,
true,
)
);
}
}
#[tokio::test]
@@ -948,8 +948,8 @@ fn transparent_encoder_should_translate_error_wire_to_response_failed_for_websoc
json!({
"type": "error",
"status": 429,
"headers": {"x-request-id": "req_raw"},
"error": {"type": "custom_type", "code": "custom_code", "message": "raw marker"},
"headers": {"x-request-id": "req_raw", "Retry-After": "24"},
"error": {"type": "rate_limit_error", "code": "turn_rate_limit_exceeded", "message": "raw marker"},
"future": {"keep": true}
}),
);
@@ -9,15 +9,38 @@ use std::{
use futures::future::BoxFuture;
use crate::{
error::GatewayError,
policy::{ClientApiKeyId, PlaintextClientApiKey},
routing::extensions::{ExtensionSetId, ExtensionSetReference},
routing::{
extensions::{ExtensionSetId, ExtensionSetReference},
request_settings::RequestSettings,
},
};
use super::execution::AuthenticatedClient;
/// 身份解析与认证只返回冻结权限,不提供模型执行入口
pub trait ClientIdentityPort: Send + Sync {
/// 只解析身份与冻结权限,不建立模型执行图或记录使用事实
fn resolve_client(
&self,
client_key_id: ClientApiKeyId,
settings: Option<RequestSettings>,
) -> BoxFuture<'_, Result<AuthenticatedClient, GatewayError>>;
/// 复用入口认证计划及 principal 映射,不进行模型准入
fn verify_client(
&self,
request: ClientAuthenticationRequest,
) -> BoxFuture<'_, Result<AuthenticatedClient, GatewayError>>;
}
pub const MAXIMUM_AUTHORIZATION_BYTES: usize = 8 * 1024;
/// API 从数据面 Authorization 头构造的有界认证信封
#[derive(Clone, PartialEq, Eq)]
#[derive(Clone)]
pub struct ClientAuthenticationRequest {
invocation: Option<Arc<super::extensions::InvocationOrigin>>,
request_context: Option<Arc<super::extensions::RequestContext>>,
settings: Option<crate::routing::request_settings::RequestSettings>,
authorization: Arc<str>,
@@ -25,6 +48,18 @@ pub struct ClientAuthenticationRequest {
}
impl ClientAuthenticationRequest {
/// 子认证继承受信调用的来源、取消和副作用归属,不据此跳过认证器
#[must_use]
pub fn with_invocation(mut self, origin: super::extensions::InvocationOrigin) -> Self {
self.invocation = Some(Arc::new(origin));
self
}
#[must_use]
pub fn invocation(&self) -> Option<&super::extensions::InvocationOrigin> {
self.invocation.as_deref()
}
#[must_use]
pub fn with_request_context(
mut self,
@@ -35,12 +70,18 @@ impl ClientAuthenticationRequest {
}
#[must_use]
pub fn request_context(&self) -> Option<&super::extensions::RequestContext> {
self.request_context.as_deref()
self.invocation
.as_ref()
.and_then(|origin| origin.extensions.request())
.or(self.request_context.as_deref())
}
#[must_use]
pub fn shared_request_context(&self) -> Option<Arc<super::extensions::RequestContext>> {
self.request_context.clone()
self.invocation
.as_ref()
.and_then(|origin| origin.extensions.shared_request())
.or_else(|| self.request_context.clone())
}
#[must_use]
@@ -79,6 +120,7 @@ impl ClientAuthenticationRequest {
.filter(|value| !value.is_empty())
.and_then(|value| PlaintextClientApiKey::new(value.to_owned()).ok());
Ok(Self {
invocation: None,
settings: None,
request_context: None,
authorization: authorization.into(),
@@ -1110,7 +1110,7 @@ where
pinned_account.clone(),
self.account_state_owner.clone(),
)
.with_account_scope(Arc::clone(self.plan.account_scope())),
.with_account_scope(Arc::clone(candidate.account_scope())),
}
.with_credential_recovery_attempted(pinned_account.as_ref().is_some_and(|account| {
self.credential_recovery_attempted_accounts
@@ -2302,12 +2302,6 @@ where
http_version,
websocket_pool,
service_tier,
upstream_response_model: self
.current
.as_ref()
.and_then(|current| current.response_observation.as_ref())
.and_then(ProviderResponseObservation::upstream_response_model)
.map(str::to_owned),
provider_metadata_json,
diagnostic_trace_json: self.trace.snapshot().map(|value| value.to_string()),
error: None,
@@ -2490,12 +2484,6 @@ where
http_version,
websocket_pool,
service_tier,
upstream_response_model: self
.current
.as_ref()
.and_then(|current| current.response_observation.as_ref())
.and_then(ProviderResponseObservation::upstream_response_model)
.map(str::to_owned),
provider_metadata_json,
diagnostic_trace_json: self.trace.snapshot().map(|value| value.to_string()),
error: Some(finalization.error),
@@ -12,7 +12,6 @@ pub use contract::{
StartedExecution,
};
mod scope;
pub use scope::BoundModelExecutionContext;
mod service;
pub use failure::gateway_error_from_engine;
pub use service::DefaultExecutionService;
@@ -15,13 +15,10 @@ use crate::{
lifecycle::{CancellationToken, Deadline},
operation::Operation,
policy::ClientApiKeyId,
routing::{
FrozenAccountScope, PublicModelId, UpstreamModelId, request_settings::RequestSettings,
},
routing::{FrozenAccountScope, PublicModelId, UpstreamModelId},
};
use std::{
collections::BTreeMap,
fmt,
sync::{
Arc, Mutex, Weak,
atomic::{AtomicUsize, Ordering},
@@ -188,57 +185,6 @@ pub(super) struct ActiveRequestLease {
pub(super) authority: Arc<ActiveRequestAuthority>,
}
/// 管理页或 CLI 一次调用持有的显式模型执行身份
///
/// 结构不暴露 Key 明文;Runtime 只能把它原样交回 Core 发起模型请求
#[derive(Clone)]
pub struct BoundModelExecutionContext {
pub(super) authority: Arc<BoundModelExecutionAuthority>,
}
impl BoundModelExecutionContext {
/// 与实际执行共用绑定时的快照,不能回退到后续发布的配置
#[must_use]
pub fn request_body_limit(&self) -> usize {
self.authority
.client
.snapshot
.responses_max_decompressed_body_bytes()
}
#[must_use]
pub fn request_settings(&self) -> Option<&RequestSettings> {
self.authority.client.request_settings()
}
}
pub(super) struct BoundModelExecutionAuthority {
pub(super) client: AuthenticatedClient,
pub(super) account_scope: Arc<FrozenAccountScope>,
pub(super) cancellation: CancellationToken,
pub(super) extension_scope: ExtensionCallScope,
pub(super) initiating_plugin_instance_id: String,
}
impl fmt::Debug for BoundModelExecutionContext {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("BoundModelExecutionContext")
.field("key_id", &self.authority.client.policy.key_id())
.field(
"initiating_plugin_instance_id",
&self.authority.initiating_plugin_instance_id,
)
.finish_non_exhaustive()
}
}
impl Drop for BoundModelExecutionAuthority {
fn drop(&mut self) {
self.cancellation.cancel();
}
}
impl ActiveRequestLease {
pub(super) fn release(self) {
drop(self);
@@ -12,9 +12,8 @@ use super::contract::{
use super::probe_store::{ProbeObservation, TransientExecutionStore};
use super::scope::{
ActiveRequestAuthority, ActiveRequestLease, ActiveRequestRegistry, AuthorizedExecution,
BoundExecutionFacts, BoundModelExecutionAuthority, BoundModelExecutionContext,
ExecutionStartGuard, ExecutionTarget, NestedExecutionFacts, NestedExecutionGraph,
PendingStartExecution, PreparedExecutionStart,
BoundExecutionFacts, ExecutionStartGuard, ExecutionTarget, NestedExecutionFacts,
NestedExecutionGraph, PendingStartExecution, PreparedExecutionStart,
};
use super::{COORDINATION_TIMEOUT, DIAGNOSTIC_TIMEOUT};
use super::{scope::ExecutionAdmission, session::DefaultExecutionSession};
@@ -28,7 +27,7 @@ use crate::{
ClientAdmissionRequest,
},
authentication::{
ClientAuthenticationRequest, FrontendAuthenticationDecision,
ClientAuthenticationRequest, ClientIdentityPort, FrontendAuthenticationDecision,
FrontendAuthenticationExtensionIndex,
},
budget::{ClientBudgetCharge, ClientBudgetPort},
@@ -41,8 +40,8 @@ use crate::{
middleware::FrozenMiddlewarePlan,
nested::{
AffinityLookupPort, AffinityLookupRequest, AffinityLookupResult,
BoundModelExecutionBinding, BoundModelExecutionRequest, ExecutionEffects,
NestedModelExecutionPort, NestedModelExecutionRequest,
BoundModelExecutionRequest, ExecutionEffects, NestedModelExecutionPort,
NestedModelExecutionRequest,
},
observation::{
FrozenRequestObservationContext, RequestObservationDispatch, RequestObservationScope,
@@ -1081,72 +1080,54 @@ impl DefaultExecutionService {
})
}
async fn bind_bound_model_inner(
&self,
binding: BoundModelExecutionBinding,
) -> Result<BoundModelExecutionContext, GatewayError> {
let client = self
.client_for_key_id(&binding.client_key_id, binding.settings)
.await?;
if binding.extension_scope.len() >= ExtensionCallScope::MAXIMUM_DEPTH {
return Err(GatewayError::new(
GatewayErrorKind::PolicyDenied,
"plugin model execution depth exceeded",
));
}
let extension_scope = binding
.extension_scope
.extending(binding.initiating_plugin_instance_id.clone())
.ok_or_else(|| {
GatewayError::new(
GatewayErrorKind::Internal,
"bound plugin execution scope is invalid",
)
})?;
let account_scope = Arc::clone(client.policy.account_scope());
Ok(BoundModelExecutionContext {
authority: Arc::new(BoundModelExecutionAuthority {
client,
account_scope,
cancellation: binding.cancellation,
extension_scope,
initiating_plugin_instance_id: binding.initiating_plugin_instance_id,
}),
})
}
async fn start_bound_model_inner(
&self,
request: BoundModelExecutionRequest,
) -> Result<StartedExecution, GatewayError> {
let BoundModelExecutionRequest {
context,
client,
initiating_plugin_instance_id,
cancellation,
extension_scope,
public_model,
operation,
mut metadata,
provider,
account,
} = request;
let authority = &context.authority;
if authority.cancellation.is_cancelled() {
if cancellation.is_cancelled() {
return Err(GatewayError::new(
GatewayErrorKind::Cancelled,
"bound plugin invocation was cancelled",
));
}
let now = SystemTime::now();
let deadline_at = Deadline::from_timeout(now, authority.client.execution_timeout())
if extension_scope.len() >= ExtensionCallScope::MAXIMUM_DEPTH {
return Err(GatewayError::new(
GatewayErrorKind::PolicyDenied,
"plugin model execution depth exceeded",
));
}
let extension_scope = extension_scope
.extending(initiating_plugin_instance_id.clone())
.ok_or_else(|| {
GatewayError::new(
GatewayErrorKind::PolicyDenied,
"plugin model execution scope is recursive",
)
})?;
let now = SystemTime::now();
let deadline_at =
Deadline::from_timeout(now, client.execution_timeout()).ok_or_else(|| {
GatewayError::new(GatewayErrorKind::Internal, "system clock is invalid")
})?;
let (account_scope, required_provider) = restrict_model_execution_scope(
authority.account_scope.as_ref(),
client.policy.account_scope().as_ref(),
provider,
account.as_ref(),
None,
)?;
// 连接承载多个独立执行;递归预算和副作用只在本次执行的子调用图内共享
// 父连接的取消仍通过 authority 传播,不能因新建执行丢失扩展调用链
// 父调用的取消继续向子执行传播,不能因新建执行丢失扩展调用链
let graph = Arc::new(NestedExecutionGraph::new(Arc::new(
ExecutionEffects::default(),
)));
@@ -1154,12 +1135,12 @@ impl DefaultExecutionService {
let execution_effects_baseline = graph.effects.epoch();
metadata.endpoint = "host.model".to_owned();
metadata.transport = ClientTransport::InternalPlugin;
metadata.inherit_request_origin(&authority.extension_scope);
metadata.inherit_request_origin(&extension_scope);
self.client_api_key_usage
.record_used(authority.client.policy.key_id());
.record_used(client.policy.key_id());
self.start_authorized(
PendingStartExecution {
client: authority.client.clone(),
client,
target: ExecutionTarget::Model(public_model),
operation,
metadata,
@@ -1168,13 +1149,13 @@ impl DefaultExecutionService {
account_scope,
response_control: None,
deadline_at,
cancellation: authority.cancellation.child_token(),
extension_scope: authority.extension_scope.clone(),
cancellation: cancellation.child_token(),
extension_scope,
required_provider,
required_account: account,
nested: None,
bound: Some(BoundExecutionFacts {
initiating_plugin_instance_id: authority.initiating_plugin_instance_id.clone(),
initiating_plugin_instance_id,
}),
graph: Some(graph),
execution_effects_baseline,
@@ -1570,14 +1551,7 @@ impl ExecutionService for DefaultExecutionService {
.query_client_model_catalog(scope, protocol, client_version)
.await?
else {
for profile in client.snapshot.public_model_profiles_for_provider(kind) {
if !client.snapshot.catalog_model_allowed_for_scope(
kind,
profile.model(),
scope,
) {
continue;
}
for profile in client.snapshot.profiles_for_provider_scope(kind, scope) {
if seen.insert(profile.model().clone()) {
result.push(PublicModelDescriptor::Adapted(profile));
}
@@ -1629,6 +1603,12 @@ impl ExecutionService for DefaultExecutionService {
result.push(source.content.for_public_model(model));
}
}
// 原生完整对象优先;只补入当前授权账号实际提供的独立适配画像
for profile in client.snapshot.contributed_profiles_for_scope(kind, scope) {
if seen.insert(profile.model().clone()) {
result.push(PublicModelDescriptor::Adapted(profile));
}
}
}
Ok(result)
})
@@ -1724,23 +1704,57 @@ impl ExecutionService for DefaultExecutionService {
}
}
impl ClientIdentityPort for DefaultExecutionService {
fn resolve_client(
&self,
client_key_id: ClientApiKeyId,
settings: Option<RequestSettings>,
) -> BoxFuture<'_, Result<AuthenticatedClient, GatewayError>> {
Box::pin(async move { self.client_for_key_id(&client_key_id, settings).await })
}
fn verify_client(
&self,
request: ClientAuthenticationRequest,
) -> BoxFuture<'_, Result<AuthenticatedClient, GatewayError>> {
Box::pin(async move {
let cancellation = request
.invocation()
.map(|origin| origin.cancellation.clone());
let verification = self.verify_request(request).fuse();
pin_mut!(verification);
match cancellation {
Some(cancellation) => {
let cancelled = cancellation.cancelled().fuse();
pin_mut!(cancelled);
select_biased! {
() = cancelled => Err(GatewayError::new(GatewayErrorKind::Cancelled, "client verification was cancelled")),
result = verification => result.map_err(authentication_gateway_error),
}
}
None => verification.await.map_err(authentication_gateway_error),
}
})
}
}
impl NestedModelExecutionPort for DefaultExecutionService {
fn models(
&self,
context: BoundModelExecutionContext,
client: AuthenticatedClient,
cancellation: CancellationToken,
protocol: String,
client_version: String,
) -> BoxFuture<'_, Result<Vec<PublicModelId>, GatewayError>> {
Box::pin(async move {
let authority = &context.authority;
let cancellation = authority.cancellation.cancelled().fuse();
let cancelled = cancellation.cancelled().fuse();
let timeout = Delay::new(DIAGNOSTIC_TIMEOUT).fuse();
let catalog = self
.client_model_catalog(&authority.client, &protocol, &client_version)
.client_model_catalog(&client, &protocol, &client_version)
.fuse();
pin_mut!(cancellation, timeout, catalog);
pin_mut!(cancelled, timeout, catalog);
let models = select_biased! {
() = cancellation => return Err(GatewayError::new(GatewayErrorKind::Cancelled, "model catalog call was cancelled")),
() = cancelled => return Err(GatewayError::new(GatewayErrorKind::Cancelled, "model catalog call was cancelled")),
() = timeout => return Err(GatewayError::new(GatewayErrorKind::Timeout, "model catalog deadline elapsed")),
result = catalog => result.map_err(|_| GatewayError::new(GatewayErrorKind::Internal, "model catalog is unavailable"))?,
};
@@ -1754,13 +1768,6 @@ impl NestedModelExecutionPort for DefaultExecutionService {
})
}
fn bind(
&self,
binding: BoundModelExecutionBinding,
) -> BoxFuture<'_, Result<BoundModelExecutionContext, GatewayError>> {
Box::pin(async move { self.bind_bound_model_inner(binding).await })
}
fn start(
&self,
request: NestedModelExecutionRequest,
@@ -62,6 +62,17 @@ impl ExtensionCallScope {
}
}
/// 宿主签发的父调用来源;身份裁决、执行链和实际资源仍由各自 owner 持有
#[derive(Clone, Debug)]
pub struct InvocationOrigin {
pub request_id: String,
pub call_id: String,
pub cancellation: crate::lifecycle::CancellationToken,
pub extensions: ExtensionCallScope,
pub plan: Option<super::middleware::FrozenMiddlewarePlan>,
pub effects: Option<Arc<super::nested::ExecutionEffects>>,
}
/// 同一代次的具体执行计划;不拥有发布状态,运行资源由对应发布集合保活
#[derive(Debug, Default)]
pub struct ExecutionExtensionPlans {
@@ -104,7 +104,6 @@ impl fmt::Debug for MiddlewareHeader {
#[derive(Clone, PartialEq, Eq)]
pub struct MiddlewareRequest {
settings: Option<RequestSettings>,
provider_parameters: Option<serde_json::Map<String, serde_json::Value>>,
protocol: String,
headers: Vec<MiddlewareHeader>,
body: Bytes,
@@ -126,7 +125,6 @@ impl MiddlewareRequest {
let protocol = protocol.into();
Self {
settings: None,
provider_parameters: None,
original_protocol: protocol.clone(),
original_body: body.clone(),
capabilities: None,
@@ -147,27 +145,6 @@ impl MiddlewareRequest {
self.settings.as_ref()
}
/// Provider 提供的本次构造参数;Core 不解释其中的业务字段
#[must_use]
pub fn provider_parameters(&self) -> Option<&serde_json::Map<String, serde_json::Value>> {
self.provider_parameters.as_ref()
}
#[must_use]
pub fn with_provider_parameters(
mut self,
parameters: serde_json::Map<String, serde_json::Value>,
) -> Self {
self.provider_parameters = Some(parameters);
self
}
pub fn take_provider_parameters(
&mut self,
) -> Option<serde_json::Map<String, serde_json::Value>> {
self.provider_parameters.take()
}
#[must_use]
pub fn protocol(&self) -> &str {
&self.protocol
@@ -167,7 +167,7 @@ impl AccountAttemptContext {
}
}
/// 附着普通请求认证时冻结的账号范围
/// 附着本次尝试的冻结账号范围,包含路由候选的能力限制
#[must_use]
pub fn with_account_scope(
mut self,
@@ -755,7 +755,6 @@ impl AttemptContext {
provider: ProviderKind,
model: Option<String>,
account_id: ProviderAccountId,
parameters: Option<serde_json::Map<String, serde_json::Value>>,
terminal: provider::ProviderMiddlewareTerminal,
) -> Result<provider::ProviderStream, ProviderError> {
// 入口已校验原请求,此处复查中间件最终提交的 operation,每次重试均生效
@@ -763,7 +762,7 @@ impl AttemptContext {
.account_scope()
.is_some_and(|scope| !scope.allows_image_generation())
{
Box::new(move |operation, headers, parameters| {
Box::new(move |operation, headers| {
Box::pin(async move {
image_generation::check_image_generation(false, &operation).map_err(|_| {
ProviderError::new(
@@ -772,7 +771,7 @@ impl AttemptContext {
)
.with_retry_prohibited()
})?;
terminal(operation, headers, parameters).await
terminal(operation, headers).await
})
})
} else {
@@ -790,7 +789,6 @@ impl AttemptContext {
context,
operation,
self.request.client_transport,
parameters,
terminal,
)
.await
@@ -897,7 +895,7 @@ impl AttemptContext {
self.account.is_diagnostic_required_account()
}
/// 普通请求认证时冻结的账号范围;管理端诊断为 `None`
/// 当前路由候选的冻结账号范围;管理端诊断为 `None`
#[must_use]
pub const fn account_scope(&self) -> Option<&Arc<crate::account::scope::FrozenAccountScope>> {
self.account.account_scope()
@@ -1072,7 +1070,6 @@ pub struct ModelRequestFinalization {
pub http_version: Option<String>,
pub websocket_pool: Option<String>,
pub service_tier: Option<String>,
pub upstream_response_model: Option<String>,
/// Provider 已筛选的专有观测 JSON;Core 不解释字段
pub provider_metadata_json: Option<String>,
/// 请求全程的有界诊断快照,跨 Provider 与重试保留
@@ -13,13 +13,13 @@ use futures::future::BoxFuture;
use crate::{
account::ProviderAccountId, error::GatewayError, identity::ProviderKind,
lifecycle::CancellationToken, operation::Operation, policy::ClientApiKeyId,
provider_ports::ProviderSessionAffinityKey, routing::PublicModelId,
lifecycle::CancellationToken, operation::Operation, provider_ports::ProviderSessionAffinityKey,
routing::PublicModelId,
};
use super::{
ModelRequestId,
execution::{BoundModelExecutionContext, ExecutionRequestMetadata, StartedExecution},
execution::{AuthenticatedClient, ExecutionRequestMetadata, StartedExecution},
};
/// 同一请求图内不可逆外部副作用的单调水位
@@ -59,37 +59,14 @@ pub struct NestedModelExecutionRequest {
pub parent_account: Option<ProviderAccountId>,
}
/// 插件为当前调用或连接显式绑定的模型执行身份
///
/// Key ID 由插件选择,发起实例由宿主确定
/// Core 在绑定时冻结当前
/// Key 策略;每个实际模型请求仍独立经过准入、预算、账本与计费
pub struct BoundModelExecutionBinding {
pub settings: Option<crate::routing::request_settings::RequestSettings>,
pub client_key_id: ClientApiKeyId,
pub initiating_plugin_instance_id: String,
/// 身份随父调用或连接回收;每个模型请求使用独立的执行期限
pub cancellation: CancellationToken,
/// 宿主保留的扩展调用链,异步观察不能通过新建模型请求清空防递归事实
pub extension_scope: super::extensions::ExtensionCallScope,
}
impl fmt::Debug for BoundModelExecutionBinding {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("BoundModelExecutionBinding")
.field("client_key_id", &self.client_key_id)
.field(
"initiating_plugin_instance_id",
&self.initiating_plugin_instance_id,
)
.finish_non_exhaustive()
}
}
/// 显式身份作用域内的一次模型请求
///
/// 身份与权限可先用于查询;执行链只在真正启动模型请求时建立
pub struct BoundModelExecutionRequest {
pub context: BoundModelExecutionContext,
pub client: AuthenticatedClient,
pub initiating_plugin_instance_id: String,
pub cancellation: CancellationToken,
pub extension_scope: super::extensions::ExtensionCallScope,
pub public_model: PublicModelId,
pub operation: Operation,
pub metadata: ExecutionRequestMetadata,
@@ -101,7 +78,11 @@ impl fmt::Debug for BoundModelExecutionRequest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("BoundModelExecutionRequest")
.field("context", &self.context)
.field("client", &self.client)
.field(
"initiating_plugin_instance_id",
&self.initiating_plugin_instance_id,
)
.field("public_model", &self.public_model)
.field("operation", &self.operation.kind())
.field("provider", &self.provider)
@@ -132,17 +113,12 @@ impl fmt::Debug for NestedModelExecutionRequest {
pub trait NestedModelExecutionPort: Send + Sync {
fn models(
&self,
context: BoundModelExecutionContext,
client: AuthenticatedClient,
cancellation: CancellationToken,
protocol: String,
client_version: String,
) -> BoxFuture<'_, Result<Vec<PublicModelId>, GatewayError>>;
/// 为插件调用冻结显式执行身份;取消父调用会取消其所有模型执行
fn bind(
&self,
binding: BoundModelExecutionBinding,
) -> BoxFuture<'_, Result<BoundModelExecutionContext, GatewayError>>;
fn start(
&self,
request: NestedModelExecutionRequest,
@@ -249,7 +249,6 @@ pub struct RequestObservation {
provider: Option<ProviderKind>,
account_id: Option<ProviderAccountId>,
upstream_model: Option<crate::routing::UpstreamModelId>,
response_model: Option<String>,
service_tier: Option<String>,
outcome: RequestObservationOutcome,
send_state: UpstreamSendState,
@@ -287,7 +286,6 @@ impl RequestObservation {
provider: None,
account_id: None,
upstream_model: None,
response_model: None,
service_tier: None,
outcome,
send_state,
@@ -341,11 +339,6 @@ impl RequestObservation {
self.upstream_model.as_ref()
}
#[must_use]
pub fn response_model(&self) -> Option<&str> {
self.response_model.as_deref()
}
#[must_use]
pub fn service_tier(&self) -> Option<&str> {
self.service_tier.as_deref()
@@ -626,7 +619,6 @@ impl RequestObservationDispatch {
outcome: RequestObservationOutcome::Rejected,
account_id: None,
upstream_model: None,
response_model: None,
service_tier: None,
send_state: UpstreamSendState::NotSent,
attempt_count: 0,
@@ -699,7 +691,6 @@ impl RequestObservationDispatch {
provider,
account_id: attempt.map(|attempt| attempt.provider_account_id().clone()),
upstream_model: attempt.and_then(|attempt| attempt.upstream_model().cloned()),
response_model: finalization.upstream_response_model.clone(),
service_tier: finalization.service_tier.clone(),
outcome,
send_state: finalization.send_state,
@@ -471,7 +471,6 @@ pub type ProviderMiddlewareTerminal = Box<
dyn FnOnce(
Operation,
Vec<MiddlewareHeader>,
Option<serde_json::Map<String, serde_json::Value>>,
) -> BoxFuture<'static, Result<ProviderStream, ProviderError>>
+ Send
+ 'static,
@@ -490,19 +489,18 @@ fn provider_middleware_next(
activate: oneshot::Receiver<()>,
observation: Arc<Mutex<MiddlewareSendObservation>>,
) -> MiddlewareNext {
crate::middleware::compose(Vec::new(), move |mut request| {
crate::middleware::compose(Vec::new(), move |request| {
Box::pin(async move {
let fallback_protocol = operation.protocol().to_owned();
if request.has_capability_declaration() {
return Err(MiddlewareError::InvalidState);
}
let parameters = request.take_provider_parameters();
let (protocol, headers, body) = request.into_parts();
validate_attempt_headers(&headers)?;
let operation = operation
.replace_middleware_wire(protocol, body)
.map_err(|_| MiddlewareError::InvalidState)?;
let stream = terminal(operation, headers, parameters)
let stream = terminal(operation, headers)
.await
.map_err(MiddlewareError::Provider)?;
let metadata = stream.metadata().clone();
@@ -736,22 +734,18 @@ pub async fn execute_attempt_middleware(
context: MiddlewareContext,
operation: Operation,
transport: ClientTransport,
parameters: Option<serde_json::Map<String, serde_json::Value>>,
terminal: ProviderMiddlewareTerminal,
) -> Result<ProviderStream, ProviderError> {
let Some(plan) = plan else {
return terminal(operation, Vec::new(), parameters).await;
return terminal(operation, Vec::new()).await;
};
let mut request = MiddlewareRequest::new(
let request = MiddlewareRequest::new(
operation.protocol(),
Vec::<MiddlewareHeader>::new(),
operation
.middleware_body()
.map_err(|_| middleware_protocol_error(UpstreamSendState::NotSent))?,
);
if let Some(parameters) = parameters {
request = request.with_provider_parameters(parameters);
}
let (prepared, metadata) = oneshot::channel();
let (activate, activation) = oneshot::channel();
let observation = Arc::new(Mutex::new(MiddlewareSendObservation {
-17
View File
@@ -624,7 +624,6 @@ pub struct ProviderResponseObservation {
status_code: Option<u16>,
request_id: Option<OpaqueUpstreamValue>,
service_tier: Option<String>,
upstream_response_model: Option<String>,
timings: ProviderResponseTimings,
// 流式事件分别持有快照,共享不可变头集合,避免逐事件复制头名和集合
client_headers: Arc<[ProviderResponseHeader]>,
@@ -641,7 +640,6 @@ impl ProviderResponseObservation {
status_code: None,
request_id: None,
service_tier: None,
upstream_response_model: None,
timings: ProviderResponseTimings::default(),
client_headers: Arc::default(),
provider_metadata: None,
@@ -697,21 +695,6 @@ impl ProviderResponseObservation {
self
}
/// 记录 Provider 从上游响应明确取得的模型,不改写发送模型或计费合同
#[must_use]
pub fn with_upstream_response_model_if_valid(mut self, model: &str) -> Self {
let model = model.trim();
if validate_text(model, 256, false, None).is_ok() {
self.upstream_response_model = Some(model.to_owned());
}
self
}
#[must_use]
pub fn upstream_response_model(&self) -> Option<&str> {
self.upstream_response_model.as_deref()
}
#[must_use]
pub const fn with_timings(mut self, timings: ProviderResponseTimings) -> Self {
self.timings = timings;
+17
View File
@@ -34,6 +34,7 @@ use engine::ExecutionStore;
use engine::admission::{
ClientAdmissionPort, ClientAdmissionRecoveryPort, restore_client_admission_startup,
};
use engine::authentication::ClientIdentityPort;
use engine::continuation::NativeContinuationPort;
use engine::execution::{
ClientApiKeyUsageSink, ClientKeyVerifier, DefaultExecutionService, ExecutionService,
@@ -127,6 +128,7 @@ pub struct CoreBundle {
client_key_verifier: Arc<dyn ClientKeyVerifier>,
snapshot_control: Arc<dyn SnapshotControl>,
account_probe: Arc<dyn AccountProbe>,
client_identity: Arc<dyn ClientIdentityPort>,
nested_model_execution: Arc<dyn NestedModelExecutionPort>,
affinity_lookup: Arc<dyn AffinityLookupPort>,
health_probes: Vec<Arc<dyn HealthProbe>>,
@@ -181,6 +183,7 @@ impl CoreStartup {
let execution: Arc<dyn ExecutionService> = service.clone();
let client_key_verifier: Arc<dyn ClientKeyVerifier> = service.clone();
let account_probe: Arc<dyn AccountProbe> = service.clone();
let client_identity: Arc<dyn ClientIdentityPort> = service.clone();
let nested_model_execution: Arc<dyn NestedModelExecutionPort> = service.clone();
let affinity_lookup: Arc<dyn AffinityLookupPort> = service;
let snapshot_control: Arc<dyn SnapshotControl> = publisher;
@@ -191,6 +194,7 @@ impl CoreStartup {
client_key_verifier,
snapshot_control,
account_probe,
client_identity,
nested_model_execution,
affinity_lookup,
health_probes,
@@ -266,6 +270,11 @@ impl CoreBundle {
Arc::clone(&self.account_probe)
}
#[must_use]
pub fn client_identity_port(&self) -> Arc<dyn ClientIdentityPort> {
Arc::clone(&self.client_identity)
}
#[must_use]
pub fn nested_model_execution_port(&self) -> Arc<dyn NestedModelExecutionPort> {
Arc::clone(&self.nested_model_execution)
@@ -395,6 +404,7 @@ pub struct CoreCommandPlaneStartup {
pub struct CoreCommandPlaneBundle {
control: CoreControlPlaneBundle,
account_probe: Arc<dyn AccountProbe>,
client_identity: Arc<dyn ClientIdentityPort>,
nested_model_execution: Arc<dyn NestedModelExecutionPort>,
affinity_lookup: Arc<dyn AffinityLookupPort>,
}
@@ -425,11 +435,13 @@ impl CoreCommandPlaneStartup {
.with_snapshot_refresh(Arc::clone(&control.publisher)),
);
let account_probe: Arc<dyn AccountProbe> = service.clone();
let client_identity: Arc<dyn ClientIdentityPort> = service.clone();
let nested_model_execution: Arc<dyn NestedModelExecutionPort> = service.clone();
let affinity_lookup: Arc<dyn AffinityLookupPort> = service;
Ok(CoreCommandPlaneBundle {
control,
account_probe,
client_identity,
nested_model_execution,
affinity_lookup,
})
@@ -452,6 +464,11 @@ impl CoreCommandPlaneBundle {
self.control.snapshot_control()
}
#[must_use]
pub fn client_identity_port(&self) -> Arc<dyn ClientIdentityPort> {
Arc::clone(&self.client_identity)
}
#[must_use]
pub fn nested_model_execution_port(&self) -> Arc<dyn NestedModelExecutionPort> {
Arc::clone(&self.nested_model_execution)
@@ -21,6 +21,17 @@ pub struct ContributedModelAlias {
pub target: UpstreamModelId,
}
/// 适配模型的冻结账号支持事实;没有选择账号或改写请求的能力
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContributedModel {
pub owner: String,
pub id: PublicModelId,
pub provider: ProviderKind,
pub accounts: BTreeSet<ProviderAccountId>,
pub capabilities: ModelCapabilities,
pub presentation: ModelPresentation,
}
/// Provider 客户端目录条目;缺少原生协议正文时使用已编译的通用画像
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderModelDescriptor {
@@ -4,7 +4,7 @@ use std::{fmt, sync::Arc};
use futures::future::BoxFuture;
use crate::routing::{ConfigRevision, ContributedModelAlias};
use crate::routing::{ConfigRevision, ContributedModel, ContributedModelAlias};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ExtensionSetId(String);
@@ -37,6 +37,10 @@ pub trait ExtensionSetLease: Send + Sync {
&[]
}
fn models(&self) -> &[ContributedModel] {
&[]
}
/// 局部故障已有请求级处理计划时,集合仍可提供服务
fn can_serve(&self) -> bool {
self.is_ready()
@@ -71,6 +75,11 @@ impl ExtensionSetReference {
pub fn model_aliases(&self) -> &[ContributedModelAlias] {
self.lease.model_aliases()
}
#[must_use]
pub fn models(&self) -> &[ContributedModel] {
self.lease.models()
}
}
impl fmt::Debug for ExtensionSetReference {
@@ -11,7 +11,7 @@ pub use crate::account::scope::{
};
pub use crate::identity::ProviderKind;
pub use catalog::{
ContributedModelAlias, ProviderCatalogGeneration, ProviderCatalogPort,
ContributedModel, ContributedModelAlias, ProviderCatalogGeneration, ProviderCatalogPort,
ProviderCatalogUnavailable, ProviderModelCapabilities, ProviderModelContent,
ProviderModelDescriptor, PublicModelDescriptor,
};
@@ -652,6 +652,30 @@ impl RuntimeSnapshot {
.map_or(&[], |set| set.model_aliases())
}
fn contributed_models(&self) -> &[super::ContributedModel] {
self.extensions.as_ref().map_or(&[], |set| set.models())
}
pub(crate) fn contributed_profiles_for_scope(
&self,
provider: &ProviderKind,
scope: &FrozenAccountScope,
) -> Vec<super::PublicModelProfile> {
self.contributed_models()
.iter()
.filter(|model| {
&model.provider == provider
&& model.accounts.iter().any(|account| {
scope.account_provider(account) == Some(provider)
&& scope.allows_model(account, &self.mapped_model(model.id.as_str()))
})
})
.map(|model| {
super::PublicModelProfile::new(model.id.clone(), model.presentation.clone())
})
.collect()
}
fn model_alias(&self, model: &str) -> Option<&super::ContributedModelAlias> {
self.model_aliases()
.iter()
@@ -844,6 +868,12 @@ impl RuntimeSnapshot {
.filter(|alias| &alias.provider == provider)
.map(|alias| alias.id.clone()),
);
models.extend(
self.contributed_models()
.iter()
.filter(|model| &model.provider == provider)
.map(|model| model.id.clone()),
);
models.into_iter().collect()
}
@@ -922,10 +952,7 @@ impl RuntimeSnapshot {
) -> Vec<super::PublicModelProfile> {
let mut profiles = BTreeMap::new();
for provider in scope.provider_kinds() {
for profile in self.public_model_profiles_for_provider(provider) {
if !self.catalog_model_allowed_for_scope(provider, profile.model(), scope) {
continue;
}
for profile in self.profiles_for_provider_scope(provider, scope) {
profiles
.entry(profile.model().clone())
.or_insert_with(|| profile.presentation().clone());
@@ -937,6 +964,20 @@ impl RuntimeSnapshot {
.collect()
}
pub(crate) fn profiles_for_provider_scope(
&self,
provider: &ProviderKind,
scope: &FrozenAccountScope,
) -> Vec<super::PublicModelProfile> {
self.public_model_profiles_for_provider(provider)
.into_iter()
.filter(|profile| {
self.native_catalog_model_allowed_for_scope(provider, profile.model(), scope)
})
.chain(self.contributed_profiles_for_scope(provider, scope))
.collect()
}
/// 已知模型只属于明确声明它的 Provider;未知模型仍可由非完整目录透传
#[must_use]
pub fn contains_public_model_for_provider(
@@ -961,7 +1002,15 @@ impl RuntimeSnapshot {
self.provider_models
.get(provider)
.is_some_and(|models| models.contains_key(&upstream_model))
|| self
.contributed_models()
.iter()
.any(|model| &model.provider == provider && &model.id == public_model)
|| (!self.exhaustive_provider_catalogs.contains(provider)
&& !self
.contributed_models()
.iter()
.any(|model| &model.id == public_model)
&& !self.is_known_upstream_model(&upstream_model))
}
@@ -969,6 +1018,10 @@ impl RuntimeSnapshot {
self.provider_models
.values()
.any(|models| models.contains_key(upstream_model))
|| self
.contributed_models()
.iter()
.any(|model| model.id.as_str() == upstream_model.as_str())
}
#[must_use]
@@ -988,6 +1041,24 @@ impl RuntimeSnapshot {
provider: &ProviderKind,
public_model: &PublicModelId,
scope: &FrozenAccountScope,
) -> bool {
self.native_catalog_model_allowed_for_scope(provider, public_model, scope)
|| self.contributed_models().iter().any(|model| {
&model.provider == provider
&& &model.id == public_model
&& model.accounts.iter().any(|account| {
scope.account_provider(account) == Some(provider)
&& scope
.allows_model(account, &self.mapped_model(public_model.as_str()))
})
})
}
fn native_catalog_model_allowed_for_scope(
&self,
provider: &ProviderKind,
public_model: &PublicModelId,
scope: &FrozenAccountScope,
) -> bool {
let upstream_model = self.mapped_model(public_model.as_str());
match self
@@ -999,7 +1070,17 @@ impl RuntimeSnapshot {
scope.account_provider(account) == Some(provider)
&& scope.allows_model(account, &upstream_model)
}),
None => scope.allows_provider_model(provider, &upstream_model),
None => {
// 只有插件声明的模型不能借非完整原生目录扩大到其他账号
let native = self.provider_models.get(provider).is_some_and(|models| {
models.keys().any(|model| model.as_str() == upstream_model)
});
let contributed = self
.contributed_models()
.iter()
.any(|model| &model.provider == provider && &model.id == public_model);
(native || !contributed) && scope.allows_provider_model(provider, &upstream_model)
}
}
}
@@ -1121,7 +1202,11 @@ impl RuntimeSnapshot {
}
.map_err(|_| RoutingError::InvalidIdentifier)?;
// 归属先于账号范围、能力与路由约束判定,原 Provider 被排除不能变成跨平台透传
let known_model = self.is_known_upstream_model(&upstream_model);
let known_model = self.is_known_upstream_model(&upstream_model)
|| self
.contributed_models()
.iter()
.any(|model| &model.id == public_model);
let providers = context.required_provider.as_ref().map_or_else(
|| account_scope.provider_kinds().clone(),
@@ -1155,17 +1240,64 @@ impl RuntimeSnapshot {
.provider_models
.get(provider)
.and_then(|models| models.get(&upstream_model));
let emulated_features = match capabilities {
Some(capabilities) => {
let Some(emulated) = capabilities.match_requirements(&requirements) else {
let contributions: Vec<_> = self
.contributed_models()
.iter()
.filter(|model| &model.provider == provider && &model.id == public_model)
.collect();
let mut candidate_scope = Arc::clone(&account_scope);
let emulated_features = if contributions.is_empty() {
match capabilities {
Some(capabilities) => {
let Some(emulated) = capabilities.match_requirements(&requirements) else {
continue;
};
emulated
}
None if known_model || self.exhaustive_provider_catalogs.contains(provider) => {
continue;
};
emulated
}
None => BTreeSet::new(),
}
None if known_model || self.exhaustive_provider_catalogs.contains(provider) => {
} else {
let native = capabilities.and_then(|value| value.match_requirements(&requirements));
let capable_accounts: BTreeSet<_> = contributions
.iter()
.filter(|model| {
model
.capabilities
.match_requirements(&requirements)
.is_some()
})
.flat_map(|model| model.accounts.iter())
.filter(|account| {
account_scope.account_provider(account) == Some(provider)
&& account_scope.allows_model(account, upstream_model.as_str())
})
.cloned()
.collect();
if native.is_some() {
// 这些账号实际由适配器执行,不能借用同名原生模型的能力
let mut restricted = account_scope.as_ref().clone();
for account in contributions.iter().flat_map(|model| &model.accounts) {
if !capable_accounts.contains(account) {
restricted = restricted.excluding_account(account.clone());
}
}
candidate_scope = Arc::new(restricted);
} else {
// restricted_to 的空集合表示不收窄,因此必须先拒绝空候选
if capable_accounts.is_empty() {
continue;
}
candidate_scope = Arc::new(
account_scope.restricted_to(&Default::default(), &capable_accounts),
);
}
if !candidate_scope.allows_provider_model(provider, upstream_model.as_str()) {
continue;
}
None => BTreeSet::new(),
native.unwrap_or_default()
};
candidates.push(ProviderCandidate {
provider: provider.clone(),
@@ -1173,10 +1305,15 @@ impl RuntimeSnapshot {
.provider_model_presentations
.get(provider)
.and_then(|models| models.get(&upstream_model))
.cloned(),
.cloned()
.or_else(|| {
contributions
.first()
.map(|model| model.presentation.clone())
}),
upstream_model: Some(upstream_model.clone()),
emulated_features,
account_scope: Arc::clone(&account_scope),
account_scope: candidate_scope,
});
}
@@ -491,7 +491,6 @@ struct FinalState {
http_version: Option<String>,
websocket_pool: Option<String>,
service_tier: Option<String>,
upstream_response_model: Option<String>,
upstream_request_id: Option<String>,
upstream_status_code: Option<u16>,
transport_decision_wait_ms: Option<u64>,
@@ -682,7 +681,6 @@ impl ExecutionStore for FakeStore {
http_version: finalization.http_version,
websocket_pool: finalization.websocket_pool,
service_tier: finalization.service_tier,
upstream_response_model: finalization.upstream_response_model,
upstream_request_id: finalization.upstream_request_id,
upstream_status_code: finalization.upstream_status_code,
transport_decision_wait_ms: finalization.timings.transport_decision_wait_ms,
@@ -1764,7 +1762,6 @@ fn response_observation_is_persisted_but_never_delivered() {
.with_websocket_pool(WebSocketPoolKind::New)
.with_status_code(200)
.with_request_id(OpaqueUpstreamValue::new("upstream-observed"))
.with_upstream_response_model_if_valid("gpt-returned")
.try_with_service_tier("priority")
.expect("service tier")
.with_timings(ProviderResponseTimings {
@@ -1804,10 +1801,6 @@ fn response_observation_is_persisted_but_never_delivered() {
assert_eq!(finalization.http_version.as_deref(), Some("HTTP/2"));
assert_eq!(finalization.websocket_pool.as_deref(), Some("new"));
assert_eq!(finalization.service_tier.as_deref(), Some("priority"));
assert_eq!(
finalization.upstream_response_model.as_deref(),
Some("gpt-returned")
);
assert_eq!(
finalization.upstream_request_id.as_deref(),
Some("upstream-observed")
@@ -2083,7 +2076,6 @@ fn discarded_attempt_observation_does_not_leak_into_retry_result() {
.with_http_version(UpstreamHttpVersion::Http11)
.with_status_code(503)
.with_request_id(OpaqueUpstreamValue::new("discarded-request"))
.with_upstream_response_model_if_valid("discarded-model")
.with_timings(ProviderResponseTimings {
transport_decision_wait_ms: Some(7),
connect_ms: Some(11),
@@ -2146,7 +2138,6 @@ fn discarded_attempt_observation_does_not_leak_into_retry_result() {
let state = store.state.lock().expect("store lock");
let finalization = &state.finalizations[0];
assert_eq!(finalization.upstream_response_model, None);
let trace: Value =
serde_json::from_str(finalization.diagnostic_trace_json.as_deref().unwrap()).unwrap();
let events = trace["events"].as_array().unwrap();
@@ -0,0 +1,270 @@
//! 身份、冻结权限与查询不建立模型执行图;实际执行才检查调用链并占用执行资源
use super::*;
use gateway_core::engine::{
extensions::{ExtensionCallScope, InvocationOrigin},
nested::{BoundModelExecutionRequest, NestedModelExecutionPort},
};
use gateway_core::lifecycle::CancellationToken;
#[test]
fn identity_queries_keep_frozen_permissions_without_using_execution_resources() {
block_on(async {
let snapshots = RuntimeSnapshotHandle::new(start_snapshot());
let store = Arc::new(TrackingExecutionStore::default());
let usage = Arc::new(RecordingClientApiKeyUsage::default());
let service = DefaultExecutionService::new(
snapshots.clone(),
store.clone(),
ProviderRegistry::new([Arc::new(NativeCatalogProvider {
fail: false,
empty: false,
}) as Arc<dyn Provider>])
.unwrap(),
Arc::new(UnusedAdmissions),
Arc::new(UnusedContinuation),
usage.clone(),
Arc::new(crate::RecordingDiagnostics::default()),
);
let settings = service.request_settings().unwrap();
let key = ClientApiKeyId::new("key_start_test").unwrap();
let client =
ClientIdentityPort::resolve_client(&service, key.clone(), Some(settings.clone()))
.await
.unwrap();
snapshots.publish(start_snapshot_with_policy(
2,
false,
RateLimits::unlimited(),
FastMode::Disabled,
));
assert!(
client
.policy()
.account_scope()
.allows(&ProviderAccountId::new("acct_start").unwrap(),)
);
assert!(
!client
.policy()
.account_scope()
.allows(&ProviderAccountId::new("acct_missing").unwrap(),)
);
let models = NestedModelExecutionPort::models(
&service,
client.clone(),
CancellationToken::default(),
"codex".into(),
"0.154.0".into(),
)
.await
.unwrap();
assert_eq!(models, vec![PublicModelId::new("gpt-start").unwrap()]);
let frozen = ClientIdentityPort::resolve_client(&service, key.clone(), Some(settings))
.await
.unwrap();
assert_eq!(frozen.snapshot().revision().get(), 1);
let current = ClientIdentityPort::resolve_client(&service, key, None).await;
assert!(matches!(current, Err(error) if error.kind() == GatewayErrorKind::Unauthorized));
let cancellation = CancellationToken::default();
cancellation.cancel();
let cancelled = NestedModelExecutionPort::models(
&service,
client,
cancellation,
"codex".into(),
"0.154.0".into(),
)
.await;
assert!(matches!(cancelled, Err(error) if error.kind() == GatewayErrorKind::Cancelled));
assert!(usage.recorded().is_empty());
assert!(!store.touched.load(Ordering::SeqCst));
});
}
#[test]
fn execution_scope_limits_apply_only_when_starting_a_model() {
block_on(async {
let store = Arc::new(TrackingExecutionStore::default());
let usage = Arc::new(RecordingClientApiKeyUsage::default());
let service = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(start_snapshot()),
store.clone(),
ProviderRegistry::new([Arc::new(NativeCatalogProvider {
fail: false,
empty: false,
}) as Arc<dyn Provider>])
.unwrap(),
Arc::new(UnusedAdmissions),
Arc::new(UnusedContinuation),
usage.clone(),
Arc::new(crate::RecordingDiagnostics::default()),
);
let repeated = ExtensionCallScope::default()
.extending("entry".into())
.unwrap();
let exhausted = (0..ExtensionCallScope::MAXIMUM_DEPTH)
.fold(ExtensionCallScope::default(), |scope, index| {
scope.extending(format!("ancestor-{index}")).unwrap()
});
for extension_scope in [repeated, exhausted] {
let client = ClientIdentityPort::resolve_client(
&service,
ClientApiKeyId::new("key_start_test").unwrap(),
None,
)
.await
.unwrap();
let result = NestedModelExecutionPort::start_bound(
&service,
BoundModelExecutionRequest {
client: client.clone(),
initiating_plugin_instance_id: "entry".into(),
cancellation: CancellationToken::default(),
extension_scope,
public_model: PublicModelId::new("gpt-start").unwrap(),
operation: start_operation(),
metadata: ExecutionRequestMetadata {
protocol: "openai".into(),
endpoint: "host.model".into(),
transport: ClientTransport::InternalPlugin,
stream: false,
client_ip: None,
user_agent: None,
previous_response_id: None,
},
provider: None,
account: None,
},
)
.await;
assert!(matches!(result, Err(error) if error.kind() == GatewayErrorKind::PolicyDenied));
let models = NestedModelExecutionPort::models(
&service,
client,
CancellationToken::default(),
"codex".into(),
"0.154.0".into(),
)
.await
.unwrap();
assert_eq!(models, vec![PublicModelId::new("gpt-start").unwrap()]);
}
assert!(usage.recorded().is_empty());
assert!(!store.touched.load(Ordering::SeqCst));
});
}
struct ParentAwareAuthentication {
calls: AtomicUsize,
mapped: bool,
}
impl FrontendAuthenticationPlan for ParentAwareAuthentication {
fn authenticate<'a>(
&'a self,
request: &'a ClientAuthenticationRequest,
) -> BoxFuture<'a, Result<FrontendAuthenticationDecision, FrontendAuthenticationError>> {
Box::pin(async move {
self.calls.fetch_add(1, Ordering::SeqCst);
let origin = request.invocation().expect("parent invocation");
assert_eq!(origin.request_id, "http-request");
assert_eq!(origin.call_id, "http-call");
assert!(origin.extensions.contains("authentication-plugin"));
assert!(origin.effects.is_some());
assert!(!origin.cancellation.is_cancelled());
assert_eq!(request.request_context().unwrap().uri, "/plugin/proxy");
assert!(request.settings().is_some());
Ok(FrontendAuthenticationDecision::Authenticated {
principal: "external-principal".into(),
})
})
}
fn client_key_id(&self, principal: &str) -> Option<ClientApiKeyId> {
assert_eq!(principal, "external-principal");
self.mapped
.then(|| ClientApiKeyId::new("key_start_test").unwrap())
}
fn exclusive(&self) -> bool {
true
}
}
#[test]
fn verification_keeps_parent_origin_and_requires_the_frontend_principal_mapping() {
block_on(async {
for mapped in [true, false] {
let authentication = FrontendAuthenticationExtensionIndex::default();
let plan = Arc::new(ParentAwareAuthentication {
calls: AtomicUsize::new(0),
mapped,
});
let snapshot = start_snapshot().with_extensions(Some(authentication_generation(
&authentication,
"identity-verification",
plan.clone(),
)));
let store = Arc::new(TrackingExecutionStore::default());
let usage = Arc::new(RecordingClientApiKeyUsage::default());
let service = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(snapshot),
store.clone(),
ProviderRegistry::default(),
Arc::new(UnusedAdmissions),
Arc::new(UnusedContinuation),
usage.clone(),
Arc::new(crate::RecordingDiagnostics::default()),
)
.with_frontend_authentication(authentication);
let settings = service.request_settings().unwrap();
let origin = InvocationOrigin {
request_id: "http-request".into(),
call_id: "http-call".into(),
cancellation: CancellationToken::default(),
extensions: plugin_origin_scope()
.extending("authentication-plugin".into())
.unwrap(),
plan: None,
effects: Some(Arc::new(
gateway_core::engine::nested::ExecutionEffects::default(),
)),
};
let mut cancelled_origin = origin.clone();
cancelled_origin.cancellation = CancellationToken::default();
cancelled_origin.cancellation.cancel();
let cancelled = ClientIdentityPort::verify_client(
&service,
ClientAuthenticationRequest::new("External fixture")
.unwrap()
.with_invocation(cancelled_origin)
.with_settings(settings.clone()),
)
.await;
assert!(matches!(cancelled, Err(error) if error.kind() == GatewayErrorKind::Cancelled));
assert_eq!(plan.calls.load(Ordering::SeqCst), 0);
let result = ClientIdentityPort::verify_client(
&service,
ClientAuthenticationRequest::new("External fixture")
.unwrap()
.with_invocation(origin)
.with_settings(settings.clone()),
)
.await;
if mapped {
let client = result.unwrap();
assert_eq!(client.policy().key_id().as_str(), "key_start_test");
assert_eq!(client.request_settings(), Some(&settings));
} else {
assert!(
matches!(result, Err(error) if error.kind() == GatewayErrorKind::Unauthorized)
);
}
assert_eq!(plan.calls.load(Ordering::SeqCst), 1);
assert!(usage.recorded().is_empty());
assert!(!store.touched.load(Ordering::SeqCst));
}
});
}
@@ -1,5 +1,6 @@
//! 执行服务的准入、预算、请求记录与终结行为测试
mod identity;
mod outbound;
use gateway_core::routing::extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference};
@@ -12,6 +13,7 @@ use std::{
time::{Duration, Instant, SystemTime},
};
use gateway_core::engine::authentication::ClientIdentityPort;
use gateway_core::engine::budget::{ClientBudgetCharge, ClientBudgetError, ClientBudgetPort};
use gateway_core::error::GatewayError;
@@ -1215,9 +1217,7 @@ fn plugin_origin_scope() -> gateway_core::engine::extensions::ExtensionCallScope
#[test]
fn explicit_key_model_execution_preserves_http_origin_and_leaves_background_origin_unknown() {
use gateway_core::engine::nested::{
BoundModelExecutionBinding, BoundModelExecutionRequest, NestedModelExecutionPort,
};
use gateway_core::engine::nested::{BoundModelExecutionRequest, NestedModelExecutionPort};
block_on(async {
for has_origin in [true, false] {
let store = Arc::new(TrackingExecutionStore::default());
@@ -1234,11 +1234,17 @@ fn explicit_key_model_execution_preserves_http_origin_and_leaves_background_orig
Arc::new(crate::RecordingDiagnostics::default()),
);
let input = request(&service, ClientTransport::InternalPlugin);
let context = NestedModelExecutionPort::bind(
let client = ClientIdentityPort::resolve_client(
&service,
BoundModelExecutionBinding {
settings: None,
client_key_id: ClientApiKeyId::new("key_start_test").unwrap(),
ClientApiKeyId::new("key_start_test").unwrap(),
None,
)
.await
.unwrap();
let mut started = NestedModelExecutionPort::start_bound(
&service,
BoundModelExecutionRequest {
client,
initiating_plugin_instance_id: "origin-fixture".into(),
cancellation: Default::default(),
extension_scope: if has_origin {
@@ -1246,14 +1252,6 @@ fn explicit_key_model_execution_preserves_http_origin_and_leaves_background_orig
} else {
Default::default()
},
},
)
.await
.unwrap();
let mut started = NestedModelExecutionPort::start_bound(
&service,
BoundModelExecutionRequest {
context: context.clone(),
public_model: input.public_model,
operation: input.operation,
metadata: input.metadata,
@@ -2282,7 +2280,6 @@ impl Provider for ObservationProvider {
gateway_core::event::ProviderResponseObservation::new(
UpstreamTransport::new("http_sse").unwrap(),
)
.with_upstream_response_model_if_valid("gpt-reported")
.with_service_tier_if_valid("priority"),
);
Ok(ProviderStream::new(
@@ -2615,7 +2612,6 @@ fn final_observation_is_emitted_once_for_success_rejection_and_detached_cancella
.map(UpstreamModelId::as_str),
Some("gpt-start")
);
assert_eq!(observations[0].response_model(), Some("gpt-reported"));
assert_eq!(observations[0].service_tier(), Some("priority"));
assert_eq!(
observations[0].outcome(),
@@ -3306,6 +3302,7 @@ struct FailingProvider;
struct NativeCatalogProvider {
fail: bool,
empty: bool,
}
#[async_trait]
@@ -3336,6 +3333,9 @@ impl Provider for NativeCatalogProvider {
if self.fail {
return Err(gateway_core::routing::ProviderCatalogUnavailable);
}
if self.empty {
return Ok(Some(Vec::new()));
}
Ok(Some(
["gpt-new", "gpt-start"]
.into_iter()
@@ -3366,7 +3366,10 @@ impl Provider for NativeCatalogProvider {
#[test]
fn client_catalog_forwards_scope_maps_whole_objects_and_omits_unroutable_models() {
#[derive(Debug)]
struct Aliases(Vec<gateway_core::routing::ContributedModelAlias>);
struct Aliases(
Vec<gateway_core::routing::ContributedModelAlias>,
Vec<gateway_core::routing::ContributedModel>,
);
impl gateway_core::routing::extensions::ExtensionSetLease for Aliases {
fn is_ready(&self) -> bool {
true
@@ -3374,8 +3377,29 @@ fn client_catalog_forwards_scope_maps_whole_objects_and_omits_unroutable_models(
fn model_aliases(&self) -> &[gateway_core::routing::ContributedModelAlias] {
&self.0
}
fn models(&self) -> &[gateway_core::routing::ContributedModel] {
&self.1
}
}
for fail in [false, true] {
for (fail, empty) in [(false, false), (false, true), (true, false)] {
let contributed = [
("gpt-start", "acct_start"),
("adapted", "acct_start"),
("private-model", "acct_other"),
]
.into_iter()
.map(|(id, account)| gateway_core::routing::ContributedModel {
owner: "catalog-plugin".into(),
id: PublicModelId::new(id).unwrap(),
provider: ProviderKind::new("openai").unwrap(),
accounts: [ProviderAccountId::new(account).unwrap()].into(),
capabilities: ModelCapabilities::new([OperationKind::Generate].into(), None),
presentation: gateway_core::routing::ModelPresentation::new(
Some(format!("Plugin {id}")),
None,
),
})
.collect();
let snapshot = start_snapshot();
let settings = snapshot
.settings()
@@ -3390,20 +3414,23 @@ fn client_catalog_forwards_scope_maps_whole_objects_and_omits_unroutable_models(
.with_extensions(Some(ExtensionSetReference::new(
gateway_core::routing::extensions::ExtensionSetId::new("catalog-aliases".into())
.unwrap(),
Arc::new(Aliases(vec![
gateway_core::routing::ContributedModelAlias {
Arc::new(Aliases(
vec![gateway_core::routing::ContributedModelAlias {
owner: "catalog-plugin".into(),
id: PublicModelId::new("plugin-alias").unwrap(),
provider: ProviderKind::new("openai").unwrap(),
target: UpstreamModelId::new("gpt-start").unwrap(),
},
])),
}],
contributed,
)),
)));
let service = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(snapshot),
Arc::new(TrackingExecutionStore::default()),
ProviderRegistry::new([Arc::new(NativeCatalogProvider { fail }) as Arc<dyn Provider>])
.expect("registry"),
ProviderRegistry::new([
Arc::new(NativeCatalogProvider { fail, empty }) as Arc<dyn Provider>
])
.expect("registry"),
Arc::new(UnusedAdmissions),
Arc::new(UnusedContinuation),
Arc::new(RecordingClientApiKeyUsage::default()),
@@ -3419,8 +3446,35 @@ fn client_catalog_forwards_scope_maps_whole_objects_and_omits_unroutable_models(
continue;
}
let models = result.expect("models");
let adapted = models
.iter()
.filter_map(|entry| match entry {
gateway_core::routing::PublicModelDescriptor::Adapted(profile) => {
Some(profile.model().as_str())
}
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
adapted,
if empty {
vec!["gpt-start", "adapted"]
} else {
vec!["adapted"]
}
);
if empty {
assert_eq!(models.len(), 2);
continue;
}
let pairs = models
.iter()
.filter(|entry| {
matches!(
entry,
gateway_core::routing::PublicModelDescriptor::Native { .. }
)
})
.map(|entry| {
let gateway_core::routing::PublicModelDescriptor::Native { model, payload } = entry
else {
@@ -103,8 +103,7 @@ impl Provider for PreparingProvider {
provider,
model,
account,
None,
Box::new(move |_, _, _| {
Box::new(move |_, _| {
Box::pin(async move {
let events = futures::stream::once(async move {
Err::<ProviderEvent, _>(
@@ -88,7 +88,7 @@ fn plan(action: Action) -> FrozenMiddlewarePlan {
}
fn terminal(polls: Arc<AtomicUsize>, released: Arc<AtomicUsize>) -> ProviderMiddlewareTerminal {
Box::new(move |_, _, _| {
Box::new(move |_, _| {
Box::pin(async move {
let metadata = ProviderCallMetadata::new(
ProviderKind::new("openai").unwrap(),
@@ -127,7 +127,6 @@ fn inspecting_a_response_waits_for_the_outer_stream_and_keeps_send_facts() {
context(),
operation(),
ClientTransport::HttpJson,
None,
terminal(polls.clone(), released.clone()),
)
.await
@@ -166,7 +165,6 @@ fn rejecting_metadata_drops_the_unactivated_handler_and_account_lease() {
context(),
operation(),
ClientTransport::HttpJson,
None,
terminal(polls.clone(), released.clone()),
)
.await
@@ -196,8 +194,7 @@ fn middleware_rejection_before_next_does_not_wait_for_activation() {
context(),
operation(),
ClientTransport::HttpJson,
None,
Box::new(|_, _, _| panic!("rejected middleware cannot prepare upstream")),
Box::new(|_, _| panic!("rejected middleware cannot prepare upstream")),
)
.await
.err()
@@ -340,7 +340,7 @@ fn attempt_middleware_consumes_owned_next_once_and_preserves_host_envelopes() {
let terminal: ProviderMiddlewareTerminal = {
let terminal_calls = Arc::clone(&terminal_calls);
Box::new(move |operation, headers, _| {
Box::new(move |operation, headers| {
terminal_calls.fetch_add(1, Ordering::SeqCst);
Box::pin(async move {
assert!(headers.is_empty());
@@ -392,7 +392,6 @@ fn attempt_middleware_consumes_owned_next_once_and_preserves_host_envelopes() {
context(),
operation(),
ClientTransport::HttpJson,
None,
terminal,
)
.await
@@ -487,7 +486,7 @@ fn attempt_passthrough_preserves_parsed_wire_for_every_client_transport() {
payload.clone(),
)
.unwrap();
let terminal: ProviderMiddlewareTerminal = Box::new(move |_, _, _| {
let terminal: ProviderMiddlewareTerminal = Box::new(move |_, _| {
Box::pin(async move {
Ok(ProviderStream::new(
empty_provider_stream().metadata().clone(),
@@ -501,7 +500,6 @@ fn attempt_passthrough_preserves_parsed_wire_for_every_client_transport() {
context(),
operation(),
transport,
None,
terminal,
)
.await
@@ -569,7 +567,7 @@ fn attempt_sse_encoding_keeps_opaque_metadata_from_creating_extra_frames() {
Some(10),
)
.unwrap();
let terminal: ProviderMiddlewareTerminal = Box::new(move |_, _, _| {
let terminal: ProviderMiddlewareTerminal = Box::new(move |_, _| {
Box::pin(async move {
Ok(ProviderStream::new(
empty_provider_stream().metadata().clone(),
@@ -584,7 +582,6 @@ fn attempt_sse_encoding_keeps_opaque_metadata_from_creating_extra_frames() {
context(),
operation(),
ClientTransport::HttpSse,
None,
terminal,
)
.await
@@ -599,7 +596,7 @@ fn attempt_middleware_preserves_multi_value_and_authentication_headers() {
let allowed_calls = Arc::new(AtomicUsize::new(0));
let allowed_terminal: ProviderMiddlewareTerminal = {
let allowed_calls = Arc::clone(&allowed_calls);
Box::new(move |_operation, headers, _| {
Box::new(move |_operation, headers| {
allowed_calls.fetch_add(1, Ordering::SeqCst);
Box::pin(async move {
assert_eq!(headers.len(), 2);
@@ -627,7 +624,6 @@ fn attempt_middleware_preserves_multi_value_and_authentication_headers() {
context(),
operation(),
ClientTransport::HttpJson,
None,
allowed_terminal,
)
.await
@@ -637,7 +633,7 @@ fn attempt_middleware_preserves_multi_value_and_authentication_headers() {
let calls = Arc::new(AtomicUsize::new(0));
let terminal: ProviderMiddlewareTerminal = {
let calls = calls.clone();
Box::new(move |_, headers, _| {
Box::new(move |_, headers| {
calls.fetch_add(1, Ordering::SeqCst);
Box::pin(async move {
assert_eq!(headers[0].name(), "authorization");
@@ -661,7 +657,6 @@ fn attempt_middleware_preserves_multi_value_and_authentication_headers() {
context(),
operation(),
ClientTransport::HttpJson,
None,
terminal,
)
.await
@@ -741,8 +736,7 @@ fn attempt_middleware_cannot_add_image_generation_to_a_denied_scope() {
ProviderKind::new("openai").unwrap(),
Some("gpt-test".into()),
ProviderAccountId::new("acct_middleware").unwrap(),
None,
Box::new(move |operation, _, _| {
Box::new(move |operation, _| {
Box::pin(async move {
assert!(operation.image_generation_requested());
count.fetch_add(1, Ordering::SeqCst);
@@ -99,6 +99,247 @@ struct CountingExtensionPreparation {
struct AliasLease(Vec<ContributedModelAlias>);
struct ModelLease(Vec<gateway_core::routing::ContributedModel>);
#[test]
fn same_name_native_and_adapted_sources_keep_account_capabilities_separate() {
use gateway_core::{
account::{AccountModelAccess, AccountModelAccessMode, ProviderAccountId},
operation::{Feature, GenerateRequest, Operation, ProtocolPayload},
routing::{ContributedModel, SupportLevel},
};
struct Catalog;
impl ProviderCatalogPort for Catalog {
fn catalog_generations(&self) -> BTreeMap<ProviderKind, ProviderCatalogGeneration> {
[(
ProviderKind::new("alpha").unwrap(),
ProviderCatalogGeneration::new(1),
)]
.into()
}
fn query_model_capabilities(
&self,
_: &ProviderKind,
) -> BoxFuture<'_, Result<Vec<ProviderModelCapabilities>, ProviderCatalogUnavailable>>
{
Box::pin(async {
Ok(vec![
ProviderModelCapabilities::new(
UpstreamModelId::new("shared-model").unwrap(),
super::capabilities().with_feature(Feature::Tools, SupportLevel::Native),
)
.with_catalog_accounts([ProviderAccountId::new("acct_a").unwrap()].into())
.with_presentation(ModelPresentation::new(Some("Native".into()), None)),
])
})
}
}
let id = PublicModelId::new("shared-model").unwrap();
let b = ProviderAccountId::new("acct_b").unwrap();
let mut contribution = ContributedModel {
owner: "fixture".into(),
id: id.clone(),
provider: ProviderKind::new("alpha").unwrap(),
accounts: [b.clone()].into(),
capabilities: super::capabilities(),
presentation: ModelPresentation::new(Some("Adapted".into()), None),
};
let facts = SnapshotFacts::new(
revision(1),
revision(1),
super::settings(),
vec![],
vec![],
["acct_a", "acct_b", "acct_c"]
.into_iter()
.map(|id| {
SnapshotProviderAccountFacts::new(ProviderAccountId::new(id).unwrap(), "alpha")
.with_model_access(
AccountModelAccess::new(
AccountModelAccessMode::Allowlist,
vec![
if id == "acct_c" {
"other"
} else {
"shared-model"
}
.into(),
],
)
.unwrap(),
)
})
.collect(),
vec![],
);
let native = block_on(
RuntimeSnapshotCompiler::new(
Arc::new(TestSnapshotStore::new(Ok(facts))),
Arc::new(Catalog),
)
.compile(),
)
.unwrap();
let scope = native.all_account_scope();
let b_only = Arc::new(scope.restricted_to(&Default::default(), &[b.clone()].into()));
assert!(native.public_models_for_scope(&b_only).is_empty());
let snapshot = native.with_extensions(Some(ExtensionSetReference::new(
ExtensionSetId::new("adapted".into()).unwrap(),
Arc::new(ModelLease(vec![contribution.clone()])),
)));
assert_eq!(snapshot.public_models_for_scope(&b_only), vec![id.clone()]);
assert_eq!(
snapshot.public_model_profiles_for_scope(&b_only)[0].presentation(),
&contribution.presentation
);
let plan = snapshot
.plan(
&id,
&super::operation(),
b_only.clone(),
&Default::default(),
)
.unwrap();
assert!(plan.candidates()[0].account_scope().allows(&b));
let tools = Operation::Generate(GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object(
"openai",
serde_json::json!({"model":"shared-model","tools":[{"type":"function","name":"test"}]})
.as_object()
.unwrap()
.clone(),
)
.unwrap(),
));
assert!(
snapshot
.plan(&id, &tools, b_only, &Default::default())
.is_err()
);
let plan = snapshot
.plan(&id, &tools, scope.clone(), &Default::default())
.unwrap();
assert!(
plan.candidates()[0]
.account_scope()
.allows(&ProviderAccountId::new("acct_a").unwrap())
);
assert!(!plan.candidates()[0].account_scope().allows(&b));
contribution.accounts = [ProviderAccountId::new("acct_c").unwrap()].into();
let denied = snapshot.with_extensions(Some(ExtensionSetReference::new(
ExtensionSetId::new("denied".into()).unwrap(),
Arc::new(ModelLease(vec![contribution])),
)));
let c_only = Arc::new(scope.restricted_to(
&Default::default(),
&[ProviderAccountId::new("acct_c").unwrap()].into(),
));
assert!(denied.public_models_for_scope(&c_only).is_empty());
assert!(
denied
.plan(&id, &super::operation(), c_only, &Default::default())
.is_err()
);
}
impl ExtensionSetLease for ModelLease {
fn is_ready(&self) -> bool {
true
}
fn models(&self) -> &[gateway_core::routing::ContributedModel] {
&self.0
}
}
#[test]
fn native_model_without_presentation_cannot_borrow_an_unauthorized_plugin_profile() {
let snapshot = super::snapshot().with_extensions(Some(ExtensionSetReference::new(
ExtensionSetId::new("private-profile".into()).unwrap(),
Arc::new(ModelLease(vec![gateway_core::routing::ContributedModel {
owner: "fixture".into(),
id: PublicModelId::new("gpt-5.5").unwrap(),
provider: ProviderKind::new("openai").unwrap(),
accounts: [gateway_core::account::ProviderAccountId::new("acct_private").unwrap()]
.into(),
capabilities: super::capabilities(),
presentation: ModelPresentation::new(Some("Private model".into()), None),
}])),
)));
assert!(
snapshot
.public_model_profiles_for_scope(&super::account_scope())
.is_empty()
);
}
#[test]
fn adapted_models_limit_visibility_and_routing_to_capable_authorized_accounts() {
use gateway_core::{
account::ProviderAccountId,
operation::{Feature, GenerateRequest, Operation, ProtocolPayload},
routing::{ContributedModel, SupportLevel},
};
let account = ProviderAccountId::new("acct_openai").unwrap();
let provider = ProviderKind::new("openai").unwrap();
let id = PublicModelId::new("adapted-model").unwrap();
let contribution = ContributedModel {
owner: "fixture".into(),
id: id.clone(),
provider,
accounts: [account.clone()].into(),
capabilities: super::capabilities(),
presentation: ModelPresentation::new(Some("Adapted".into()), None),
};
let snapshot = super::snapshot().with_extensions(Some(ExtensionSetReference::new(
ExtensionSetId::new("models".into()).unwrap(),
Arc::new(ModelLease(vec![contribution.clone()])),
)));
let scope = super::account_scope();
assert!(snapshot.public_models_for_scope(&scope).contains(&id));
assert!(
snapshot
.public_model_profiles_for_scope(&scope)
.iter()
.any(|p| p.model() == &id)
);
let plan = snapshot
.plan(&id, &super::operation(), scope.clone(), &Default::default())
.unwrap();
assert_eq!(plan.candidates().len(), 1);
assert!(plan.candidates()[0].account_scope().allows(&account));
let other = Arc::new(scope.restricted_to(
&Default::default(),
&[ProviderAccountId::new("acct_xai").unwrap()].into(),
));
assert!(!snapshot.public_models_for_scope(&other).contains(&id));
assert!(
snapshot
.plan(&id, &super::operation(), other, &Default::default())
.is_err()
);
let tools = Operation::Generate(GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object("openai", serde_json::json!({"model":"adapted-model","input":[],"tools":[{"type":"function","name":"test","parameters":{}}]}).as_object().unwrap().clone()).unwrap(),
));
assert!(
snapshot
.plan(&id, &tools, scope.clone(), &Default::default())
.is_err()
);
let mut supported = contribution;
supported.capabilities = supported
.capabilities
.with_feature(Feature::Tools, SupportLevel::Native);
let capable = snapshot.with_extensions(Some(ExtensionSetReference::new(
ExtensionSetId::new("capable".into()).unwrap(),
Arc::new(ModelLease(vec![supported])),
)));
assert!(
capable
.plan(&id, &tools, scope, &Default::default())
.is_ok()
);
}
impl ExtensionSetLease for AliasLease {
fn is_ready(&self) -> bool {
true
+1 -1
View File
@@ -29,7 +29,7 @@ percent-encoding = "2.3.2"
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "rustls-tls-native-roots", "socks"] }
rustls = { version = "=0.23.46", default-features = false, features = ["aws_lc_rs", "std", "tls12"] }
rustls-native-certs = "=0.8.3"
scraper = "0.27.0"
scraper = "0.29.0"
semver = "1.0.27"
secrecy = "0.10.3"
serde = { version = "1.0.228", features = ["derive"] }
@@ -24,7 +24,7 @@ gateway-plugin-sdk = { path = "../sdk", features = ["io"] }
gateway-protocol = { path = "../../gateway-protocol" }
hex = "0.4.3"
image = { version = "0.25.8", default-features = false, features = ["bmp", "gif", "ico", "jpeg", "png", "webp"] }
jsonschema = { version = "0.58.4", default-features = false }
jsonschema = { version = "0.60.0", default-features = false }
roxmltree = "0.21.1"
semver = "1.0.27"
secrecy = "0.10.3"
@@ -1,12 +1,12 @@
{
"schema_version": 2,
"manifest_schema_versions": [2],
"protocol_versions": [5],
"protocol_versions": [7],
"capabilities": [
{ "capability": "frontend_authentication", "versions": [2] },
{ "capability": "scheduler", "versions": [3] },
{ "capability": "model_router", "versions": [2] },
{ "capability": "model_catalog", "versions": [1] },
{ "capability": "model_catalog", "versions": [1, 2] },
{ "capability": "retry_policy", "versions": [2] },
{ "capability": "middleware", "versions": [6] },
{ "capability": "upstream_adapter", "versions": [5] },
@@ -1,29 +1,49 @@
//! 编译插件模型目录贡献并校验能力绑定
use std::time::Duration;
use gateway_admin::model::{
AdminError,
plugins::instances::{PluginCapabilityBinding, PluginInstance},
};
use gateway_core::routing::{ContributedModelAlias, ProviderKind, PublicModelId, UpstreamModelId};
use gateway_plugin_sdk::{Capability, Manifest, Stage, call::catalog::ModelCatalogRegistration};
use gateway_core::{
account::ProviderAccountId,
operation::{Feature, OperationKind},
routing::{
ContributedModel, ContributedModelAlias, ModelCapabilities, ModelPresentation,
ProviderKind, PublicModelId, SupportLevel, UpstreamModelId,
},
};
use gateway_plugin_sdk::{
Capability, Manifest, Stage,
call::{
catalog::{ModelCatalogRegistration, ModelCatalogRegistrationV2},
middleware::RequestFeature,
},
};
use crate::RpcSession;
pub(crate) struct PreparedCatalog {
pub(crate) aliases: Vec<ContributedModelAlias>,
pub(crate) models: Vec<ContributedModel>,
}
pub(crate) async fn prepare(
manifest: &Manifest,
instance: &PluginInstance,
session: &RpcSession,
) -> Result<Vec<ContributedModelAlias>, AdminError> {
adapters: &[super::upstream_adapter::AdapterEntry],
) -> Result<PreparedCatalog, AdminError> {
if !manifest.contributes.contains_key(&Capability::ModelCatalog) {
return Ok(Vec::new());
return Ok(PreparedCatalog {
aliases: Vec::new(),
models: Vec::new(),
});
}
validate_bindings(manifest, &instance.bindings)?;
let reply = session
.call(
"model_catalog.register",
session.context(Stage::Registration, Duration::from_secs(5)),
session.context(Stage::Registration, session.budget(Stage::Registration)),
serde_json::json!({}),
Vec::new(),
)
@@ -32,13 +52,19 @@ pub(crate) async fn prepare(
if reply.payload.len() > 64 * 1024 {
return Err(AdminError::invalid("插件模型目录信封无效或过大"));
}
let registration: ModelCatalogRegistration = serde_json::from_slice(&reply.payload)
.map_err(|_| AdminError::invalid("插件模型目录声明无效"))?;
if registration.models.len() > 256 {
return Err(AdminError::invalid("单个插件最多贡献 256 个模型别名"));
let (aliases, models) = if manifest.contributes[&Capability::ModelCatalog].version == 2 {
let registration: ModelCatalogRegistrationV2 = serde_json::from_slice(&reply.payload)
.map_err(|_| AdminError::invalid("插件模型目录声明无效"))?;
(registration.aliases, registration.models)
} else {
let registration: ModelCatalogRegistration = serde_json::from_slice(&reply.payload)
.map_err(|_| AdminError::invalid("插件模型目录声明无效"))?;
(registration.models, Vec::new())
};
if aliases.len().saturating_add(models.len()) > 256 {
return Err(AdminError::invalid("单个插件最多贡献 256 个模型条目"));
}
registration
.models
let aliases = aliases
.into_iter()
.map(|model| {
let id = PublicModelId::new(model.id)
@@ -52,7 +78,79 @@ pub(crate) async fn prepare(
.map_err(|_| AdminError::invalid("模型别名目标无效"))?,
})
})
.collect()
.collect::<Result<_, AdminError>>()?;
let models = models
.into_iter()
.map(|model| {
let mut targets = adapters
.iter()
.filter_map(|entry| entry.catalog_target(&model.adapter_id, &model.id));
let target = targets
.next()
.filter(|_| targets.next().is_none())
.ok_or_else(|| AdminError::invalid("适配模型必须关联同实例的静态账号适配器"))?;
let mut capabilities =
ModelCapabilities::new([OperationKind::Generate].into(), model.max_output_tokens);
for feature in model.features {
let feature = match feature {
RequestFeature::Tools => Feature::Tools,
RequestFeature::Vision => Feature::Vision,
RequestFeature::Reasoning => Feature::Reasoning,
RequestFeature::JsonSchema => Feature::JsonSchema,
};
capabilities = capabilities.with_feature(feature, SupportLevel::Native);
}
let p = model.presentation;
Ok(ContributedModel {
owner: instance.id.clone(),
id: PublicModelId::new(model.id)
.map_err(|_| AdminError::invalid("适配模型 ID 无效"))?,
provider: ProviderKind::new(target.provider.as_str())
.map_err(|_| AdminError::invalid("适配模型 Provider 无效"))?,
accounts: target
.account_ids
.iter()
.map(|id| {
ProviderAccountId::new(id.clone())
.map_err(|_| AdminError::invalid("适配模型账号无效"))
})
.collect::<Result<_, _>>()?,
capabilities,
presentation: ModelPresentation::new(p.display_name, p.description)
.with_reasoning(p.default_reasoning_effort, p.supported_reasoning_efforts)
.with_context_window_tokens(p.context_window_tokens)
.with_max_context_window_tokens(p.max_context_window_tokens)
.with_image_input(p.image_input)
.with_agent_tools(p.agent_tools, p.parallel_tool_calls)
.with_search_tool(p.search_tool)
.with_image_detail_original(p.image_detail_original)
.with_verbosity(p.verbosity)
.with_hidden(p.hidden),
})
})
.collect::<Result<_, AdminError>>()?;
Ok(PreparedCatalog { aliases, models })
}
/// 新目录的同名画像必须一致,不由注册顺序拼接能力或改变别名目标
pub(crate) fn validate_models(
aliases: &[ContributedModelAlias],
models: &[ContributedModel],
) -> Result<(), AdminError> {
for (index, model) in models.iter().enumerate() {
if aliases.iter().any(|alias| alias.id == model.id)
|| models[..index].iter().any(|other| {
other.id == model.id
&& (other.provider != model.provider
|| other.presentation != model.presentation
|| other.capabilities != model.capabilities
|| !other.accounts.is_disjoint(&model.accounts))
})
{
return Err(AdminError::invalid("适配模型目录声明冲突"));
}
}
Ok(())
}
pub(crate) fn validate_bindings(
@@ -48,12 +48,13 @@ impl PluginCommandSession {
let payload = serde_json::to_vec(&invocation)
.map_err(|_| AdminError::invalid("插件命令参数无法编码"))?;
let mut saved_accounts = 0;
let deadline = tokio::time::Instant::now() + command.session.budget(Stage::CommandLine);
let result = tokio::select! {
biased;
() = cancellation.cancelled() => Ok(Err("调用已取消")),
result = tokio::time::timeout(
self.limits.maximum_call_timeout,
command.execute(payload, self.limits, &mut saved_accounts),
result = tokio::time::timeout_at(
deadline,
command.execute(payload, self.limits, deadline, &mut saved_accounts),
) => result,
};
match result {
@@ -76,11 +77,14 @@ impl PluginCommand {
&self,
payload: Vec<u8>,
limits: RpcLimits,
deadline: tokio::time::Instant,
saved_accounts: &mut usize,
) -> Result<PluginCommandOutput, &'static str> {
let mut context = self
.session
.context(Stage::CommandLine, limits.maximum_call_timeout);
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Err("超过命令总期限");
}
let mut context = self.session.context(Stage::CommandLine, remaining);
context.request_id = Some(uuid::Uuid::new_v4().to_string());
let scope = self
.callbacks
@@ -135,6 +139,7 @@ fn rpc_failure(error: RpcError) -> &'static str {
}
RpcError::Capacity | RpcError::Overloaded => "插件调用容量不足",
RpcError::Context(_) => "调用上下文无效",
RpcError::Budget(_) => "调用预算冲突",
RpcError::Remote(_) => "插件拒绝命令",
}
}
@@ -4,7 +4,7 @@ use super::PluginCommand;
use crate::{RpcSession, callback::PluginCallbacks};
use gateway_admin::model::{AdminError, plugins::instances::PluginInstance};
use gateway_plugin_sdk::{Capability, Manifest, Stage, call::management::CommandRegistration};
use std::{sync::Arc, time::Duration};
use std::sync::Arc;
const MAXIMUM_REGISTRATION_BYTES: usize = 64 * 1024;
@@ -23,7 +23,7 @@ pub(crate) async fn prepare(
let reply = session
.call(
"command_line.register",
session.context(Stage::Registration, Duration::from_secs(5)),
session.context(Stage::Registration, session.budget(Stage::Registration)),
serde_json::json!({}),
Vec::new(),
)
@@ -1,6 +1,6 @@
//! 编译插件客户端认证绑定,并将认证结果映射为宿主身份
use std::{collections::BTreeMap, sync::Arc, time::Duration};
use std::{collections::BTreeMap, sync::Arc};
use futures::future::BoxFuture;
use gateway_admin::model::{
@@ -26,8 +26,6 @@ use gateway_plugin_sdk::{
use crate::{RpcSession, callback::PluginCallbacks};
const IDENTIFIER_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Clone)]
pub(crate) struct FrontendAuthenticationEntry {
identities: BTreeMap<String, ClientApiKeyId>,
@@ -47,7 +45,7 @@ pub(crate) async fn prepare_entry(
let reply = session
.call(
"frontend_auth.identifier",
session.context(Stage::Registration, IDENTIFIER_TIMEOUT),
session.context(Stage::Registration, session.budget(Stage::Registration)),
serde_json::json!({}),
Vec::new(),
)
@@ -167,19 +165,16 @@ fn valid_identifier(value: &str) -> bool {
pub(crate) struct PluginFrontendAuthenticationPlan {
entry: FrontendAuthenticationEntry,
timeout: Duration,
}
impl PluginFrontendAuthenticationPlan {
pub(crate) fn compile(
mut entries: Vec<FrontendAuthenticationEntry>,
maximum_call_timeout: Duration,
) -> Result<Option<Arc<Self>>, AdminError> {
match entries.len() {
0 => Ok(None),
1 => Ok(Some(Arc::new(Self {
entry: entries.pop().expect("one entry was checked"),
timeout: maximum_call_timeout.min(Duration::from_secs(30)),
}))),
_ => Err(AdminError::invalid("同一发布集合只能启用一个入口认证插件")),
}
@@ -197,23 +192,32 @@ impl FrontendAuthenticationPlan for PluginFrontendAuthenticationPlan {
.invocation
.as_ref()
.ok_or(FrontendAuthenticationError)?;
let mut context = session.context(Stage::Authentication, self.timeout);
let mut context =
session.context(Stage::Authentication, session.budget(Stage::Authentication));
context.request = crate::adapter::request_context(request.request_context());
context.request_id = request.invocation().map(|origin| origin.request_id.clone());
let _scope = callbacks
.prepare_frontend_authentication(&context, request.shared_request_context())
.prepare_frontend_authentication(&context, request)
.map_err(|_| FrontendAuthenticationError)?;
let input = FrontendAuthenticationRequest {
authorization: request.authorization().to_owned(),
};
let reply = session
.call(
"frontend_auth.authenticate",
context,
serde_json::json!({}),
serde_json::to_vec(&input).map_err(|_| FrontendAuthenticationError)?,
)
.await
.map_err(|_| FrontendAuthenticationError)?;
let invocation = session.call(
"frontend_auth.authenticate",
context,
serde_json::json!({}),
serde_json::to_vec(&input).map_err(|_| FrontendAuthenticationError)?,
);
let reply = if let Some(origin) = request.invocation() {
tokio::select! {
biased;
() = origin.cancellation.cancelled() => return Err(FrontendAuthenticationError),
reply = invocation => reply,
}
} else {
invocation.await
}
.map_err(|_| FrontendAuthenticationError)?;
let result: FrontendAuthenticationResult =
serde_json::from_slice(&reply.payload).map_err(|_| FrontendAuthenticationError)?;
match result {
@@ -165,9 +165,10 @@ impl ManagementEntry {
{
return Err(invalid_state());
}
let mut context = self
.session
.context(Stage::PublicManagement, limits.maximum_call_timeout);
let mut context = self.session.context(
Stage::PublicManagement,
self.session.budget(Stage::PublicManagement),
);
context.request = crate::adapter::request_context(request.request_context.as_deref());
context.request_id = Some(request.request_id);
let _scope = self
@@ -98,7 +98,7 @@ impl ManagementEntry {
.map_err(|_| AdminError::invalid("插件管理请求无法编码"))?;
let mut context = self
.session
.context(Stage::Management, limits.maximum_call_timeout);
.context(Stage::Management, self.session.budget(Stage::Management));
context.request = crate::adapter::request_context(request.request_context.as_deref());
context.request_id = Some(request.request_id);
let _scope = self
@@ -1,6 +1,6 @@
//! 将插件管理声明编译为页面、路由与资源入口
use std::{collections::BTreeMap, sync::Arc, time::Duration};
use std::{collections::BTreeMap, sync::Arc};
use gateway_admin::model::{
AdminError,
@@ -34,7 +34,7 @@ pub(crate) async fn prepare(
let reply = session
.call(
"management.register",
session.context(Stage::Registration, Duration::from_secs(5)),
session.context(Stage::Registration, session.budget(Stage::Registration)),
serde_json::json!({}),
Vec::new(),
)
@@ -7,7 +7,7 @@ use gateway_core::engine::middleware::{
MiddlewareRequest, MiddlewareResponse,
};
use gateway_core::middleware::{Middleware, Next};
use std::{sync::Arc, time::Duration};
use std::sync::Arc;
// 协议适配只提供选择条件与单层调用;顺序、上下文捕获和续体拼接共用这一个入口
type Invoke<Context, Input, Output, Error> = fn(
@@ -15,7 +15,6 @@ type Invoke<Context, Input, Output, Error> = fn(
Context,
Input,
Next<Input, Output, Error>,
Duration,
) -> BoxFuture<'static, Result<Output, Error>>;
impl PluginMiddlewarePlan {
@@ -42,8 +41,7 @@ impl PluginMiddlewarePlan {
.map(|entry| -> Middleware<Input, Output, Error> {
let entry = Arc::clone(entry);
let context = context.clone();
let timeout = self.middleware_timeout;
Box::new(move |input, next| invoke(entry, context, input, next, timeout))
Box::new(move |input, next| invoke(entry, context, input, next))
})
.collect();
gateway_core::middleware::compose(layers, move |input| next.run(input))
@@ -70,10 +68,8 @@ impl MiddlewarePlan for PluginMiddlewarePlan {
MiddlewareMount::Outbound,
context,
outbound_matches,
|entry, context, request, next, timeout| {
Box::pin(super::outbound::invoke(
entry, context, request, next, timeout,
))
|entry, context, request, next| {
Box::pin(super::outbound::invoke(entry, context, request, next))
},
next,
)
@@ -99,8 +95,8 @@ impl MiddlewarePlan for PluginMiddlewarePlan {
MiddlewareMount::Service,
context,
|entry, context| !context.extensions.contains(&entry.instance_id),
|entry, context, input, next, timeout| {
Box::pin(super::service::invoke(entry, context, input, next, timeout))
|entry, context, input, next| {
Box::pin(super::service::invoke(entry, context, input, next))
},
next,
)
@@ -123,10 +119,8 @@ impl MiddlewarePlan for PluginMiddlewarePlan {
MiddlewareMount::WebSocket,
context,
|_entry, _context| true,
|entry, context, message, next, timeout| {
Box::pin(super::websocket::invoke(
entry, context, message, next, timeout,
))
|entry, context, message, next| {
Box::pin(super::websocket::invoke(entry, context, message, next))
},
next,
)
@@ -147,8 +141,8 @@ impl MiddlewarePlan for PluginMiddlewarePlan {
MiddlewareMount::Http,
context,
|entry, context| !context.extensions.contains(&entry.instance_id),
|entry, context, request, next, timeout| {
Box::pin(super::http::invoke(entry, context, request, next, timeout))
|entry, context, request, next| {
Box::pin(super::http::invoke(entry, context, request, next))
},
next,
)
@@ -173,10 +167,8 @@ impl MiddlewarePlan for PluginMiddlewarePlan {
context.model(),
)
},
|entry, context, request, next, timeout| {
Box::pin(super::request::invoke(
entry, context, request, next, timeout,
))
|entry, context, request, next| {
Box::pin(super::request::invoke(entry, context, request, next))
},
next,
)
@@ -20,7 +20,6 @@ pub(super) async fn invoke(
context: core::Context,
request: core::Request,
next: core::Next,
timeout: std::time::Duration,
) -> Result<core::Response, MiddlewareError> {
let Some(ports) = &entry.invocation else {
return if entry.failure_policy == PluginFailurePolicy::Delegate {
@@ -35,7 +34,9 @@ pub(super) async fn invoke(
.and_then(|settings| settings.runtime.as_ref())
.map_or(serde_json::Value::Null, |settings| settings.inspect());
let (invocation, request) = Invocation::new(request, next, entry.instance_id.clone())?;
let mut call = ports.session.context(Stage::Http, timeout);
let mut call = ports
.session
.context(Stage::Http, ports.session.budget(Stage::Http));
call.request = crate::adapter::request_context(context.extensions.request());
call.request_id = Some(context.request_id.clone());
call.resource_stream = true;
@@ -50,7 +51,9 @@ pub(super) async fn invoke(
request_id: context.request_id.clone(),
call_id: context.call_id.clone(),
cancellation: context.cancellation.clone(),
plan,
plan: Some(plan),
extensions: context.extensions.clone(),
effects: None,
}),
context.extensions.clone(),
invocation.clone(),
@@ -18,7 +18,6 @@ use std::{
collections::{BTreeMap, BTreeSet},
fmt,
sync::Arc,
time::Duration,
};
#[derive(Clone)]
@@ -168,7 +167,6 @@ fn compile_entry(
pub(crate) struct PluginMiddlewarePlan {
middleware: BTreeMap<MiddlewareMount, Box<[Arc<MiddlewareEntry>]>>,
middleware_timeout: Duration,
}
impl fmt::Debug for PluginMiddlewarePlan {
@@ -181,10 +179,7 @@ impl fmt::Debug for PluginMiddlewarePlan {
}
impl PluginMiddlewarePlan {
pub(crate) fn compile(
mut entries: Vec<MiddlewareEntry>,
maximum_call_timeout: Duration,
) -> Option<Arc<Self>> {
pub(crate) fn compile(mut entries: Vec<MiddlewareEntry>) -> Option<Arc<Self>> {
if entries.is_empty() {
return None;
}
@@ -208,7 +203,6 @@ impl PluginMiddlewarePlan {
.into_iter()
.map(|(mount, entries)| (mount, entries.into_boxed_slice()))
.collect(),
middleware_timeout: maximum_call_timeout,
}))
}
}
@@ -1,6 +1,6 @@
//! 在 Provider 发送前组合完整出站请求,并沿用模型数据面的调用资源
use std::{sync::Arc, time::Duration};
use std::sync::Arc;
use gateway_admin::model::plugins::instances::PluginFailurePolicy;
use gateway_core::engine::middleware::{MiddlewareContext, MiddlewareError, outbound as core};
@@ -17,7 +17,6 @@ pub(super) async fn invoke(
context: MiddlewareContext,
request: core::Request,
next: core::Next,
timeout: Duration,
) -> Result<core::Request, MiddlewareError> {
let Some(ports) = &entry.invocation else {
return if entry.failure_policy == PluginFailurePolicy::Delegate {
@@ -54,7 +53,12 @@ pub(super) async fn invoke(
.get(),
},
};
let mut call = ports.session.context(Stage::Outbound, timeout);
let mut call = ports.session.context(
Stage::Outbound,
context
.deadline()
.bounded(ports.session.budget(Stage::Outbound)),
);
call.request = crate::adapter::request_context(context.extension_scope().request());
call.request_id = Some(input.metadata.request_id.clone());
call.attempt_id = Some(format!(
@@ -1,9 +1,6 @@
//! 请求与尝试中间件的 RPC 调用、失败回退和响应正文生命周期
use std::{
sync::{Arc, Mutex},
time::Duration,
};
use std::sync::{Arc, Mutex};
use bytes::Bytes;
use futures::future::BoxFuture;
@@ -37,7 +34,6 @@ pub(super) async fn invoke(
context: MiddlewareContext,
request: MiddlewareRequest,
next: MiddlewareNext,
maximum_timeout: Duration,
) -> Result<MiddlewareResponse, MiddlewareError> {
let Some(invocation_ports) = &entry.invocation else {
return if entry.failure_policy
@@ -48,10 +44,7 @@ pub(super) async fn invoke(
Err(MiddlewareError::Fault)
};
};
if maximum_timeout.is_zero()
|| context.deadline().is_elapsed()
|| context.cancellation().is_cancelled()
{
if context.deadline().is_elapsed() || context.cancellation().is_cancelled() {
return Err(MiddlewareError::Fault);
}
let invocation = MiddlewareInvocation::new(&context, request, next, entry.instance_id.clone());
@@ -59,18 +52,21 @@ pub(super) async fn invoke(
Ok(projection) => projection,
Err(_) => return recover_rpc(&entry, &invocation, RpcError::Context(None)).await,
};
let stage = match entry.mount {
MiddlewareMount::Http
| MiddlewareMount::WebSocket
| MiddlewareMount::Service
| MiddlewareMount::Outbound => {
return Err(MiddlewareError::InvalidState);
}
MiddlewareMount::Request => Stage::Request,
MiddlewareMount::Attempt => Stage::Attempt,
};
let mut call_context = invocation_ports.session.context(
match entry.mount {
MiddlewareMount::Http
| MiddlewareMount::WebSocket
| MiddlewareMount::Service
| MiddlewareMount::Outbound => {
return Err(MiddlewareError::InvalidState);
}
MiddlewareMount::Request => Stage::Request,
MiddlewareMount::Attempt => Stage::Attempt,
},
maximum_timeout,
stage,
context
.deadline()
.bounded(invocation_ports.session.budget(stage)),
);
call_context.resource_stream = true;
call_context.request = crate::adapter::request_context(context.extension_scope().request());
@@ -98,7 +94,6 @@ pub(super) async fn invoke(
scope,
)?;
let head = MiddlewareRequestHead {
provider_parameters: invocation.provider_parameters(),
settings_sources: invocation.settings_sources(),
settings: invocation
.settings()
@@ -16,7 +16,6 @@ pub(super) async fn invoke(
context: core::Context,
input: core::Value,
next: core::Next,
timeout: std::time::Duration,
) -> Result<core::Value, core::Error> {
let Some(ports) = &entry.invocation else {
return if entry.failure_policy == PluginFailurePolicy::Delegate {
@@ -26,7 +25,9 @@ pub(super) async fn invoke(
};
};
let invocation = Invocation::new(next);
let mut call = ports.session.context(Stage::Service, timeout);
let mut call = ports
.session
.context(Stage::Service, ports.session.budget(Stage::Service));
call.request = crate::adapter::request_context(context.extensions.request());
call.request_id = Some(context.request_id.clone());
let _binding = ports
@@ -37,7 +38,9 @@ pub(super) async fn invoke(
request_id: context.request_id.clone(),
call_id: context.call_id.clone(),
cancellation: context.cancellation.clone(),
plan: context.plan.clone(),
plan: Some(context.plan.clone()),
extensions: context.extensions.clone(),
effects: None,
}),
context.extensions.clone(),
invocation,
@@ -16,7 +16,6 @@ pub(super) async fn invoke(
context: core::Context,
message: core::Message,
next: core::Next,
timeout: std::time::Duration,
) -> Result<Option<core::Message>, MiddlewareError> {
let Some(ports) = &entry.invocation else {
return if entry.failure_policy == PluginFailurePolicy::Delegate {
@@ -44,7 +43,9 @@ pub(super) async fn invoke(
.encode(message)
.map_err(|_| MiddlewareError::Fault)?,
};
let mut call = ports.session.context(Stage::WebSocket, timeout);
let mut call = ports
.session
.context(Stage::WebSocket, ports.session.budget(Stage::WebSocket));
call.request = crate::adapter::request_context(context.extensions.request());
call.request_id = Some(context.connection_id.clone());
let _binding = ports
@@ -55,7 +56,9 @@ pub(super) async fn invoke(
request_id: context.connection_id.clone(),
call_id: call.resource_scope_id.clone(),
cancellation: context.cancellation.clone(),
plan,
plan: Some(plan),
extensions: context.extensions.clone(),
effects: None,
}),
context.extensions,
invocation.clone(),
@@ -3,7 +3,7 @@
use std::{
collections::BTreeMap,
sync::{Arc, Mutex},
time::{Duration, SystemTime, UNIX_EPOCH},
time::{SystemTime, UNIX_EPOCH},
};
use gateway_admin::model::{
@@ -31,7 +31,6 @@ use tokio::sync::{OwnedSemaphorePermit, Semaphore, mpsc};
use crate::{RpcSession, adapter::scope::BindingScope};
const OBSERVATION_TIMEOUT: Duration = Duration::from_secs(2);
const OBSERVATION_ENVELOPE_RESERVE: usize = 16 * 1024;
#[derive(Clone)]
@@ -57,10 +56,11 @@ impl ObserverEntry {
event: Event,
payload: Vec<u8>,
extensions: gateway_core::engine::extensions::ExtensionCallScope,
timeout: Duration,
) {
let request_id = event.request_id();
let mut context = self.session.context(Stage::Observation, timeout);
let mut context = self
.session
.context(Stage::Observation, self.session.budget(Stage::Observation));
context.request = crate::adapter::request_context(extensions.request());
context.request_id = Some(request_id.to_owned());
let Ok(_scope) = self.callbacks.prepare_extensions(&context, extensions) else {
@@ -166,7 +166,6 @@ pub(crate) struct PluginObserverPlan {
websocket_queues: Arc<Mutex<BTreeMap<String, mpsc::Sender<QueuedWebSocketObservation>>>>,
websocket_queue_capacity: usize,
maximum_websocket_payload_bytes: usize,
timeout: Duration,
}
struct QueuedWebSocketObservation {
@@ -180,7 +179,6 @@ impl PluginObserverPlan {
pub(crate) fn compile(
mut entries: Vec<ObserverEntry>,
maximum_calls: usize,
maximum_call_timeout: Duration,
maximum_buffered_body_bytes: usize,
) -> Option<Arc<Self>> {
if entries.is_empty() {
@@ -205,7 +203,6 @@ impl PluginObserverPlan {
websocket_queues: Arc::new(Mutex::new(BTreeMap::new())),
websocket_queue_capacity: maximum_calls.clamp(1, 256),
maximum_websocket_payload_bytes,
timeout: maximum_call_timeout.min(OBSERVATION_TIMEOUT),
}))
}
@@ -326,7 +323,6 @@ impl RequestObserverPlan for PluginObserverPlan {
return;
};
let entries = Arc::clone(&self.entries);
let timeout = self.timeout;
handle.spawn(async move {
let _generation = generation;
let _slot = slot;
@@ -342,7 +338,6 @@ impl RequestObserverPlan for PluginObserverPlan {
Event::RequestCompleted(Box::new(wire_observation(&observation))),
Vec::new(),
observation.extension_scope().clone(),
timeout,
)
.await;
}
@@ -372,10 +367,8 @@ impl PluginObserverPlan {
let (sender, receiver) = mpsc::channel(self.websocket_queue_capacity);
queues.insert(request_id.to_owned(), sender.clone());
let entries = Arc::clone(&self.entries);
let timeout = self.timeout;
handle.spawn(dispatch_websocket_queue(
entries,
timeout,
generation,
worker_slot,
receiver,
@@ -386,7 +379,6 @@ impl PluginObserverPlan {
async fn dispatch_websocket_queue(
entries: Arc<[ObserverEntry]>,
timeout: Duration,
generation: ExtensionSetReference,
worker_slot: OwnedSemaphorePermit,
mut receiver: mpsc::Receiver<QueuedWebSocketObservation>,
@@ -422,7 +414,6 @@ async fn dispatch_websocket_queue(
Event::WebSocketResponse(Box::new(request)),
queued.payload.to_vec(),
queued.observation.extension_scope().clone(),
timeout,
)
.await;
}
@@ -455,7 +446,6 @@ fn wire_observation(observation: &RequestObservation) -> RequestCompleted {
upstream_model: observation
.upstream_model()
.map(|model| model.as_str().to_owned()),
response_model: observation.response_model().map(str::to_owned),
service_tier: observation.service_tier().map(str::to_owned),
requested_model: observation
.requested_model()
@@ -3,7 +3,7 @@
mod retry;
mod route_schedule;
use std::{fmt, sync::Arc, time::Duration};
use std::{fmt, sync::Arc};
use gateway_admin::model::{
AdminError,
@@ -13,8 +13,6 @@ use gateway_plugin_sdk::{Capability, Manifest, Stage};
use crate::{RpcSession, adapter::scope::BindingScope, callback::PluginCallbacks};
const POLICY_TIMEOUT: Duration = Duration::from_secs(2);
#[derive(Clone)]
pub(crate) enum PolicyEntry {
Router(ModelRouterEntry),
@@ -219,7 +217,6 @@ pub(crate) struct PluginRequestPolicyPlan {
routers: Arc<[ModelRouterEntry]>,
schedulers: Arc<[AccountSchedulerEntry]>,
retries: Arc<[RetryEntry]>,
policy_timeout: Duration,
}
impl fmt::Debug for PluginRequestPolicyPlan {
@@ -234,10 +231,7 @@ impl fmt::Debug for PluginRequestPolicyPlan {
}
impl PluginRequestPolicyPlan {
pub(crate) fn compile(
entries: Vec<PolicyEntry>,
maximum_call_timeout: Duration,
) -> Result<Option<Arc<Self>>, AdminError> {
pub(crate) fn compile(entries: Vec<PolicyEntry>) -> Result<Option<Arc<Self>>, AdminError> {
let mut routers = Vec::new();
let mut schedulers = Vec::new();
let mut retries = Vec::new();
@@ -277,7 +271,6 @@ impl PluginRequestPolicyPlan {
routers: routers.into(),
schedulers: schedulers.into(),
retries: retries.into(),
policy_timeout: maximum_call_timeout.min(POLICY_TIMEOUT),
})))
}
}
@@ -17,11 +17,10 @@ use super::RetryEntry;
pub(super) fn decide(
entries: Arc<[RetryEntry]>,
input: RetryInput,
timeout: Duration,
) -> BoxFuture<'static, Result<RetryDecision, RequestPolicyFault>> {
Box::pin(async move {
let started = std::time::Instant::now();
for entry in entries.iter().filter(|entry| {
let started = tokio::time::Instant::now();
let matched = entries.iter().filter(|entry| {
!input.extension_scope.contains(&entry.instance_id)
&& entry.scope.matches_processing(
&input.client_key_id,
@@ -29,23 +28,32 @@ pub(super) fn decide(
Some(&input.facts.provider),
input.facts.model.as_deref(),
)
}) {
});
let timeout = matched
.clone()
.filter_map(|entry| entry.invocation.as_ref())
.map(|invocation| invocation.session.budget(Stage::Retry))
.max()
.unwrap_or_default()
.min(input.facts.remaining_deadline);
for entry in matched {
// 整条委托链共享预算,不能每个插件重新取得完整等待时间
let remaining = timeout
.min(input.facts.remaining_deadline)
.saturating_sub(started.elapsed());
let remaining = timeout.saturating_sub(started.elapsed());
if remaining.is_zero() {
break;
}
match call(entry, &input, remaining).await {
Ok(RetryDecision::Delegate) => {}
Ok(decision) => return Ok(decision),
Err(()) => tracing::warn!(
plugin_id = entry.plugin_id,
instance_id = entry.instance_id,
request_id = input.request_id.as_str(),
"插件重试策略无效或调用失败,委托后续策略"
),
match tokio::time::timeout(remaining, call(entry, &input, remaining)).await {
Err(_) => break,
Ok(result) => match result {
Ok(RetryDecision::Delegate) => {}
Ok(decision) => return Ok(decision),
Err(()) => tracing::warn!(
plugin_id = entry.plugin_id,
instance_id = entry.instance_id,
request_id = input.request_id.as_str(),
"插件重试策略无效或调用失败,委托后续策略"
),
},
}
}
Ok(RetryDecision::Delegate)
@@ -79,7 +87,10 @@ async fn call(
.unwrap_or(u64::MAX),
allowed_actions,
};
let mut context = invocation.session.context(Stage::Retry, timeout);
let mut context = invocation.session.context(
Stage::Retry,
timeout.min(invocation.session.budget(Stage::Retry)),
);
context.request = crate::adapter::request_context(input.extension_scope.request());
context.request_id = Some(request.request_id.clone());
context.attempt_id = Some(format!("{}:{}", request.request_id, request.attempt_index));
@@ -1,6 +1,6 @@
//! 插件模型路由与账号调度调用,以及请求事实的线协议投影
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use std::time::{SystemTime, UNIX_EPOCH};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use futures::future::BoxFuture;
@@ -38,7 +38,7 @@ impl RequestPolicyPlan for PluginRequestPolicyPlan {
input: gateway_core::engine::policy::RetryInput,
) -> BoxFuture<'static, Result<gateway_core::engine::policy::RetryDecision, RequestPolicyFault>>
{
super::retry::decide(self.retries.clone(), input, self.policy_timeout)
super::retry::decide(self.retries.clone(), input)
}
fn route_model(
@@ -46,12 +46,11 @@ impl RequestPolicyPlan for PluginRequestPolicyPlan {
input: ModelRouteInput,
) -> BoxFuture<'static, Result<ModelRouteDecision, RequestPolicyFault>> {
let routers = std::sync::Arc::clone(&self.routers);
let timeout = self.policy_timeout;
Box::pin(async move {
for entry in routers.iter().filter(|entry| {
!input.suppresses_plugin(&entry.instance_id) && entry.scope.matches_route(&input)
}) {
let result = route_with_entry(entry, &input, timeout).await;
let result = route_with_entry(entry, &input).await;
match result {
Ok(ModelRouteDecision::Unhandled) => continue,
Ok(decision) => return Ok(decision),
@@ -78,14 +77,13 @@ impl RequestPolicyPlan for PluginRequestPolicyPlan {
input: AccountScheduleInput,
) -> BoxFuture<'static, Result<AccountScheduleDecision, RequestPolicyFault>> {
let schedulers = std::sync::Arc::clone(&self.schedulers);
let timeout = self.policy_timeout;
Box::pin(async move {
let Some(entry) = schedulers.iter().find(|entry| {
!input.suppresses_plugin(&entry.instance_id) && entry.scope.matches_schedule(&input)
}) else {
return Ok(AccountScheduleDecision::Delegate);
};
match schedule_with_entry(entry, &input, timeout).await {
match schedule_with_entry(entry, &input).await {
Ok(decision) => Ok(decision),
Err(())
if entry.failure_policy
@@ -108,7 +106,6 @@ impl RequestPolicyPlan for PluginRequestPolicyPlan {
async fn route_with_entry(
entry: &ModelRouterEntry,
input: &ModelRouteInput,
timeout: Duration,
) -> Result<ModelRouteDecision, ()> {
let invocation = entry.invocation.as_ref().ok_or(())?;
let projection = operation_projection(input.operation())?;
@@ -126,7 +123,9 @@ async fn route_with_entry(
.collect(),
headers,
};
let mut context = invocation.session.context(Stage::Routing, timeout);
let mut context = invocation
.session
.context(Stage::Routing, invocation.session.budget(Stage::Routing));
context.request = crate::adapter::request_context(input.extension_scope().request());
context.request_id = Some(input.request_id().as_str().to_owned());
let _network_scope = invocation
@@ -172,7 +171,6 @@ async fn route_with_entry(
async fn schedule_with_entry(
entry: &AccountSchedulerEntry,
input: &AccountScheduleInput,
timeout: Duration,
) -> Result<AccountScheduleDecision, ()> {
let invocation = entry.invocation.as_ref().ok_or(())?;
let candidates = input.candidates();
@@ -197,7 +195,10 @@ async fn schedule_with_entry(
})
.collect(),
};
let mut context = invocation.session.context(Stage::Scheduling, timeout);
let mut context = invocation.session.context(
Stage::Scheduling,
invocation.session.budget(Stage::Scheduling),
);
context.request = crate::adapter::request_context(input.extension_scope().request());
context.request_id = Some(input.request_id().as_str().to_owned());
context.attempt_id = Some(format!(
@@ -86,6 +86,14 @@ impl BindingScope {
!self.providers.is_empty()
}
/// 静态目录只能继承账号范围,不能丢失请求级 Key 或组条件
pub(crate) fn matches_catalog(&self, provider: &ProviderKind, model: &str) -> bool {
self.client_keys.is_empty()
&& self.account_groups.is_empty()
&& self.matches_provider(Some(provider))
&& self.matches_model(Some(model))
}
pub(crate) fn matches_observation(&self, observation: &RequestObservation) -> bool {
self.matches_identity(observation.client_key_id(), observation.account_group_ids())
&& self.matches_provider(observation.provider())
@@ -28,6 +28,7 @@ pub(super) struct EventDecoder {
usage: Usage,
contents: usize,
pub(super) completed: bool,
pub(super) replay_blocked: bool,
}
pub(super) struct DecodedEvent {
@@ -50,6 +51,10 @@ impl EventDecoder {
if self.completed || message.event.facts.len() > 64 {
return Err(invalid(sent));
}
// 即使与失败同批携带,也不能把 wire、工具或其他业务事实丢弃后重放
self.replay_blocked |= !message.event.facts.is_empty()
|| message.event.wire.is_some()
|| message.continuation.is_some();
let wire = message
.event
.wire
@@ -75,12 +75,34 @@ pub(super) fn execute(
let result = state.next().await;
match result {
Ok(event) => Ok(Some((event, state))),
Err(error) => Err(state.invocation.account.record_failure(error).await),
Err(error) => {
let error = state.finish_error(error);
Err(state.invocation.account.record_failure(error).await)
}
}
}))
}
impl Execution {
fn finish_error(&mut self, mut error: ProviderError) -> ProviderError {
if let Some(active) = self.active.take() {
let managed = Arc::clone(&active.managed);
// RpcStream 析构同步撤销回调并封存未完成发送,证明不能早于这一步
drop(active);
if error.kind() == ProviderErrorKind::RateLimited
&& error.upstream_status() == Some(429)
&& error.send_state() == UpstreamSendState::Sent
&& managed.send_state.get() == UpstreamSendState::Sent
&& managed.replay.rejected_once()
&& !self.decoder.replay_blocked
&& !self.invocation.context.cancellation().is_cancelled()
{
error = error.with_replay_safe();
}
}
error
}
async fn next(&mut self) -> Result<gateway_core::event::ProviderEvent, ProviderError> {
if self.active.is_none() {
self.active = Some(self.begin().await?);
@@ -167,7 +189,7 @@ impl Execution {
let timeout = invocation
.context
.deadline()
.bounded(self.adapter.session.maximum_call_timeout());
.bounded(self.adapter.session.budget(Stage::Upstream));
if timeout.is_zero() || invocation.context.cancellation().is_cancelled() {
return Err(ProviderError::new(
ProviderErrorKind::Cancelled,
@@ -37,6 +37,24 @@ pub(crate) struct AdapterEntry {
adapter: Option<Arc<PluginUpstreamAdapter>>,
}
impl AdapterEntry {
pub(crate) fn catalog_target(
&self,
id: &str,
model: &str,
) -> Option<&UpstreamAdapterDeclaration> {
let adapter = self.adapter.as_ref()?;
let declaration = &adapter.declaration;
let provider = ProviderKind::new(declaration.provider.as_str()).ok()?;
(declaration.id == id
&& !declaration.account_ids.is_empty()
&& (declaration.models.is_empty()
|| declaration.models.iter().any(|value| value == model))
&& self.scope.matches_catalog(&provider, model))
.then_some(declaration)
}
}
pub(crate) struct PluginUpstreamAdapter {
instance_id: String,
contribution_id: String,
@@ -47,12 +47,6 @@ pub(super) fn merge(
.try_with_service_tier(value)
.map_err(|source| invalid(sent).with_source(source))?;
}
if let Some(value) = update.upstream_response_model {
current = current.with_upstream_response_model_if_valid(&value);
if current.upstream_response_model() != Some(value.trim()) {
return Err(invalid(sent));
}
}
if !update.client_headers.is_empty() {
current = current.with_client_headers(
update
@@ -1,6 +1,6 @@
//! 校验并准备插件上游适配声明与能力绑定
use std::{sync::Arc, time::Duration};
use std::sync::Arc;
use gateway_admin::model::{
AdminError,
@@ -56,7 +56,7 @@ pub(crate) async fn prepare(
let reply = session
.call(
REGISTER_METHOD,
session.context(Stage::Registration, Duration::from_secs(5)),
session.context(Stage::Registration, session.budget(Stage::Registration)),
serde_json::json!({}),
vec![],
)
@@ -20,8 +20,8 @@ use gateway_core::{
use gateway_plugin_sdk::{
CallContext, ErrorCode, PluginFault,
call::host::{
AuthCredential, AuthGetRequest, AuthListRequest, AuthListResult, AuthRuntimeAccount,
AuthSaveRequest, AuthSaveResult, CredentialFacts,
self as wire, AuthCredential, AuthGetRequest, AuthListRequest, AuthListResult,
AuthRuntimeAccount, AuthSaveRequest, AuthSaveResult, CredentialFacts,
},
};
@@ -66,6 +66,32 @@ impl PluginAccounts {
Self { slot }
}
pub(super) async fn http_proxy(
&self,
account_id: String,
credential_revision: u64,
) -> Result<Option<gateway_core::account::OutboundProxy>, PluginFault> {
let id = ProviderAccountId::new(account_id).map_err(|_| invalid())?;
let expected = Revision::new(credential_revision).map_err(|_| invalid())?;
let account = self
.slot
.upgrade()
.map_err(map_admin_error)?
.get_runtime(&id)
.await
.map_err(map_admin_error)?;
if !account.enabled {
return Err(denied());
}
if account.credential_revision != expected {
return Err(PluginFault::new(
ErrorCode::Conflict,
"account credential revision has changed",
));
}
Ok(account.effective_outbound_proxy().cloned())
}
pub(super) async fn call(
&self,
context: &CallContext,
@@ -79,7 +105,7 @@ impl PluginAccounts {
}
let access = self.slot.upgrade().map_err(map_admin_error)?;
match method {
"host.auth.list" => {
wire::AUTH_LIST => {
let request: AuthListRequest = decode(payload)?;
let limit = PageSize::new(request.limit).map_err(|_| invalid())?;
let cursor = request
@@ -105,7 +131,7 @@ impl PluginAccounts {
next_cursor: page.next_cursor.map(|cursor| cursor.as_str().to_owned()),
})
}
"host.auth.get_runtime" => {
wire::AUTH_GET_RUNTIME => {
let request: AuthGetRequest = decode(payload)?;
let account_id =
ProviderAccountId::new(request.account_id).map_err(|_| invalid())?;
@@ -115,7 +141,7 @@ impl PluginAccounts {
.map_err(map_admin_error)?;
encode(&runtime_account(&account))
}
"host.auth.get" => {
wire::AUTH_GET => {
let request: AuthGetRequest = decode(payload)?;
let account_id =
ProviderAccountId::new(request.account_id).map_err(|_| invalid())?;
@@ -131,7 +157,7 @@ impl PluginAccounts {
facts: credential_facts(account, credential.provider_material),
})
}
"host.auth.save" => {
wire::AUTH_SAVE => {
let request: AuthSaveRequest = decode(payload)?;
encode(&self.save(context, scope, request).await?)
}
@@ -16,9 +16,8 @@ pub(super) struct CallResources {
pub(super) cancellation: CancellationToken,
pub(super) scope: Arc<CallbackScope>,
pub(super) http_resources: Arc<http_middleware::resources::Resources>,
pub(super) model_bindings: tokio::sync::Mutex<
BTreeMap<String, gateway_core::engine::execution::BoundModelExecutionContext>,
>,
pub(super) client_identities:
tokio::sync::Mutex<BTreeMap<String, gateway_core::engine::execution::AuthenticatedClient>>,
pub(super) state: Mutex<CallState>,
}
@@ -32,13 +31,18 @@ impl CallResources {
Self {
deadline: deadline.clone(),
operation_timeout: Duration::from_millis(context.timeout_ms),
cancellation: CancellationToken::new(),
cancellation: scope
.origin
.as_ref()
.map_or_else(CancellationToken::new, |origin| {
origin.cancellation.child_token()
}),
scope,
http_resources: middleware
.as_ref()
.and_then(|middleware| middleware.http_resources())
.unwrap_or_default(),
model_bindings: tokio::sync::Mutex::new(BTreeMap::new()),
client_identities: tokio::sync::Mutex::new(BTreeMap::new()),
state: Mutex::new(CallState {
middleware,
..CallState::default()
@@ -46,6 +50,40 @@ impl CallResources {
}
}
/// 子调用共享受信来源;身份解析与执行准入仍留在各能力 owner
pub(super) fn child_origin(
&self,
context: &CallContext,
) -> Result<gateway_core::engine::extensions::InvocationOrigin, PluginFault> {
let parent = self.scope.origin.as_ref();
Ok(gateway_core::engine::extensions::InvocationOrigin {
request_id: parent.map_or_else(
|| {
context
.request_id
.clone()
.unwrap_or_else(|| context.resource_scope_id.clone())
},
|origin| origin.request_id.clone(),
),
call_id: parent.map_or_else(
|| format!("{}:{}", context.resource_scope_id, context.call_id),
|origin| origin.call_id.clone(),
),
cancellation: self.cancellation.child_token(),
extensions: if self.scope.extension_scope.contains(&context.instance_id) {
self.scope.extension_scope.clone()
} else {
self.scope
.extension_scope
.extending(context.instance_id.clone())
.ok_or_else(super::invalid)?
},
plan: parent.and_then(|origin| origin.plan.clone()),
effects: self.scope.execution_effects.clone(),
})
}
pub(super) fn close(&self) {
self.cancellation.cancel();
let mut state = self
@@ -114,7 +152,9 @@ impl CallResources {
.unwrap_or_else(std::sync::PoisonError::into_inner)
.middleware
.clone();
middleware.and_then(|middleware| middleware.request_settings())
middleware
.and_then(|middleware| middleware.request_settings())
.or_else(|| self.scope.settings.clone())
}
/// 建立受管流后,网络操作各自计时;连接空闲时间不消耗后续操作的预算
@@ -0,0 +1,154 @@
//! 客户端认证、冻结权限查询与调用内身份复用
use std::sync::{Arc, OnceLock, Weak};
use gateway_admin::model::AdminError;
use gateway_core::{
account::ProviderAccountId,
engine::{
authentication::{ClientAuthenticationRequest, ClientIdentityPort},
execution::AuthenticatedClient,
},
policy::ClientApiKeyId,
routing::UpstreamModelId,
};
use gateway_plugin_sdk::{CallContext, ErrorCode, PluginFault, call::data};
use super::{CallResources, denied, invalid};
use crate::RpcReply;
const MAX_CLIENT_IDENTITIES_PER_CALL: usize = 16;
pub(crate) struct PluginClientIdentityPortSlot {
port: OnceLock<Weak<dyn ClientIdentityPort>>,
}
impl PluginClientIdentityPortSlot {
pub(crate) const fn new() -> Self {
Self {
port: OnceLock::new(),
}
}
pub(crate) fn bind(&self, port: &Arc<dyn ClientIdentityPort>) -> Result<(), AdminError> {
self.port
.set(Arc::downgrade(port))
.map_err(|_| AdminError::conflict("插件身份端口已经绑定"))
}
fn upgrade(&self) -> Result<Arc<dyn ClientIdentityPort>, PluginFault> {
self.port.get().and_then(Weak::upgrade).ok_or_else(denied)
}
}
pub(super) struct PluginClients {
slot: Arc<PluginClientIdentityPortSlot>,
}
impl PluginClients {
pub(super) fn new(slot: Arc<PluginClientIdentityPortSlot>) -> Self {
Self { slot }
}
pub(super) async fn call(
&self,
context: &CallContext,
call: &Arc<CallResources>,
method: &str,
params: serde_json::Value,
payload: Vec<u8>,
) -> Result<RpcReply, PluginFault> {
match method {
data::CLIENT_VERIFY => {
if params != serde_json::json!({}) {
return Err(invalid());
}
let request: data::VerifyClientRequest =
serde_json::from_slice(&payload).map_err(|_| invalid())?;
let mut authentication = ClientAuthenticationRequest::new(request.authorization)
.map_err(|_| invalid())?
.with_invocation(call.child_origin(context)?);
if let Some(settings) = call.request_settings() {
authentication = authentication.with_settings(settings);
}
let port = self.slot.upgrade()?;
call.observe_dispatch()?;
let client = port
.verify_client(authentication)
.await
.map_err(super::error::gateway)?;
super::admin::encode(&data::VerifiedClient {
client_key_id: client.policy().key_id().as_str().to_owned(),
})
}
data::ACCESS_CHECK => {
if params != serde_json::json!({}) {
return Err(invalid());
}
let request: data::AccessQuery =
serde_json::from_slice(&payload).map_err(|_| invalid())?;
let client = self.identity(call, request.client_key_id).await?;
let scope = client.policy().account_scope();
let model = request
.upstream_model
.map(UpstreamModelId::new)
.transpose()
.map_err(|_| invalid())?;
let accounts = request
.account_ids
.into_iter()
.map(|account_id| {
let account =
ProviderAccountId::new(account_id.clone()).map_err(|_| invalid())?;
let allowed = model.as_ref().map_or_else(
|| scope.allows(&account),
|model| scope.allows_model(&account, model.as_str()),
);
Ok(data::AccountAccessFacts {
account_id,
allowed,
})
})
.collect::<Result<Vec<_>, PluginFault>>()?;
super::admin::encode(&data::AccessFacts {
schema_version: 1,
client_key_id: client.policy().key_id().as_str().to_owned(),
accounts,
})
}
_ => Err(denied()),
}
}
pub(super) async fn identity(
&self,
call: &CallResources,
client_key_id: String,
) -> Result<AuthenticatedClient, PluginFault> {
let port = self.slot.upgrade()?;
let key = ClientApiKeyId::new(client_key_id.clone()).map_err(|_| invalid())?;
let settings = call.request_settings();
let mut identities = call.client_identities.lock().await;
if let Some(client) = identities
.get(&client_key_id)
.filter(|client| client.request_settings() == settings.as_ref())
{
return Ok(client.clone());
}
if !identities.contains_key(&client_key_id)
&& identities.len() >= MAX_CLIENT_IDENTITIES_PER_CALL
{
return Err(PluginFault::new(
ErrorCode::Capacity,
"client identity capacity is exhausted",
));
}
let client = port
.resolve_client(key, settings)
.await
.map_err(super::error::gateway)?;
// 查询与执行共用调用内快照,执行链和子执行取消只在真正启动时建立
identities.insert(client_key_id, client.clone());
Ok(client)
}
}
@@ -98,7 +98,7 @@ impl PluginData {
next_cursor: page.next_cursor.map(|id| id.as_str().to_owned()),
})
}
data::QUOTA_GET | data::QUOTA_REFRESH => {
data::QUOTA_GET | data::QUOTA_REFRESH | data::QUOTA_DETAILS => {
let accounts = self.accounts.upgrade().map_err(map_admin_error)?;
let query: data::QuotaFactsQuery =
serde_json::from_slice(payload).map_err(|_| invalid())?;
@@ -109,6 +109,35 @@ impl PluginData {
accounts.get_quota(&account_id).await
}
.map_err(map_admin_error)?;
if method == data::QUOTA_DETAILS {
return super::admin::encode(&data::QuotaDetails {
schema_version: 1,
account_id: account_id.as_str().to_owned(),
observed_at_ms: quota.observed_at.map(|time| time.timestamp_millis()),
limit_reached: quota.limit_reached,
windows: quota
.windows
.into_iter()
.map(|window| data::QuotaWindowDetails {
key: window.key,
group: window.group,
label: window.label,
limit_id: window.limit_id,
limit_name: window.limit_name,
role: window.role.map(|role| role.as_str().to_owned()),
window_seconds: window.window_seconds,
used_percent: window.used_percent,
reset_at_ms: window.reset_at.map(|time| time.timestamp_millis()),
limit_reached: window.limit_reached,
})
.collect(),
credits: quota.credits.map(|credits| data::QuotaCredits {
has_credits: credits.has_credits,
unlimited: credits.unlimited,
balance: credits.balance,
}),
});
}
serde_json::to_vec(&data::QuotaFacts {
schema_version: 1,
account_id: account_id.as_str().to_owned(),
@@ -0,0 +1,263 @@
//! 将有效父调用的宿主回调分派到所属原生能力,并随调用撤销资源
use std::sync::{Arc, Weak};
use futures::future::BoxFuture;
use gateway_plugin_sdk::{
CallContext, ErrorCode, PluginFault,
call::{host, middleware as middleware_wire, upstream_adapter as upstream_wire},
client::CallDeadline,
};
use super::{CallResources, CallbackScope, PluginCallbacks, denied, http, http_dispatch, upstream};
use crate::{CallbackHandler, RpcReply};
impl CallbackHandler for PluginCallbacks {
fn begin(&self, context: &CallContext, deadline: &CallDeadline) {
let scope = self
.scopes
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&context.resource_scope_id)
.and_then(Weak::upgrade)
.unwrap_or_else(|| {
let mut scope = CallbackScope::for_call(context);
if context.stage == gateway_plugin_sdk::Stage::Registration {
scope.request_body_limit = self.registration_body_limit;
}
Arc::new(scope)
});
let middleware = self
.pending_middleware
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&context.resource_scope_id);
self.calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(
context.call_id,
Arc::new(CallResources::new(context, deadline, scope, middleware)),
);
}
fn finish(&self, context: &CallContext) {
if let Some(call) = self
.calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&context.call_id)
{
call.close();
}
}
fn call(
&self,
context: CallContext,
method: String,
params: serde_json::Value,
payload: Vec<u8>,
) -> BoxFuture<'static, Result<RpcReply, PluginFault>> {
let call = self
.calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&context.call_id)
.cloned();
let http = self.http.clone();
let network = self.network.clone();
let maximum_payload = self.maximum_payload;
let log = self.log.clone();
let private_state = self.private_state.clone();
let accounts = self.accounts.clone();
let resources = self.resources.clone();
let data = self.data.clone();
let keys = self.keys.clone();
let clients = self.clients.clone();
let models = self.models.clone();
let request_limits = self.request_limits.clone();
let affinity = self.affinity.clone();
let services = self.services.clone();
let service_actor = self.service_actor.clone();
Box::pin(async move {
let call = call.ok_or_else(denied)?;
let scope = &call.scope;
let (middleware, is_private_state) = {
let state = call
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if state.closed || call.deadline.is_elapsed() {
return Err(denied());
}
if method == host::LOG {
return log.record(&context, params, &payload);
}
// 只证明受管 HTTP 拒绝;其他能力即使只读,也不推测其业务副作用
if let Some(managed) = &scope.upstream
&& !matches!(
method.as_str(),
upstream_wire::HTTP_DO
| upstream_wire::HTTP_DO_STREAM
| upstream_wire::HTTP_STREAM_READ
| upstream_wire::HTTP_STREAM_CLOSE
)
{
managed.replay.invalidate();
}
let middleware = if matches!(
method.as_str(),
middleware_wire::NEXT_METHOD
| middleware_wire::BODY_READ_METHOD
| middleware_wire::BODY_FACTS_METHOD
| middleware_wire::BODY_CLOSE_METHOD
| middleware_wire::http::UPGRADE_METHOD
| middleware_wire::http::RECEIVE_METHOD
| middleware_wire::websocket::SEND_METHOD
) {
Some(state.middleware.clone().ok_or_else(denied)?)
} else {
None
};
let is_private_state = matches!(
method.as_str(),
host::STATE_GET | host::STATE_PUT | host::STATE_DELETE
);
(middleware, is_private_state)
};
if matches!(
method.as_str(),
middleware_wire::http::DISPATCH_METHOD
| middleware_wire::http::BODY_READ_METHOD
| middleware_wire::http::BODY_CLOSE_METHOD
| middleware_wire::http::BODY_CREATE_METHOD
| middleware_wire::http::BODY_WRITE_METHOD
) {
return tokio::select! {
biased;
() = call.cancellation.cancelled() => Err(PluginFault::new(ErrorCode::Cancelled, "HTTP callback cancelled")),
result = http_dispatch::call(&services, &context, &call, &method, params, payload, maximum_payload) => result,
};
}
if let Some(middleware) = middleware {
return middleware
.invoke(method, params, payload, maximum_payload)
.await;
}
if method == gateway_plugin_sdk::call::services::CALL_METHOD {
return services
.call(&context, &call, service_actor, params, &payload)
.await;
}
if is_private_state {
return private_state.call(&method, params, &payload).await;
}
if matches!(
method.as_str(),
gateway_plugin_sdk::call::resources::GROUP_ENSURE
| gateway_plugin_sdk::call::resources::GROUP_MEMBERS
| gateway_plugin_sdk::call::resources::KEY_ENSURE
) {
return resources.call(&context, &method, params, &payload).await;
}
if matches!(
method.as_str(),
host::KEYS_LIST
| gateway_plugin_sdk::call::key_budgets::RESET
| gateway_plugin_sdk::call::key_budgets::GET
| gateway_plugin_sdk::call::key_budgets::UPDATE_LIMITS
) {
return keys.call(&context, &method, params, &payload).await;
}
if matches!(
method.as_str(),
gateway_plugin_sdk::call::data::CLIENT_VERIFY
| gateway_plugin_sdk::call::data::ACCESS_CHECK
) {
return clients
.call(&context, &call, &method, params, payload)
.await;
}
if method == host::MODELS_LIST {
return models.call(&context, &call, &method, params, payload).await;
}
if matches!(
method.as_str(),
host::MODEL_EXECUTE
| host::MODEL_EXECUTE_STREAM
| host::MODEL_STREAM_READ
| host::MODEL_STREAM_CLOSE
) {
return models.call(&context, &call, &method, params, payload).await;
}
if method == host::AFFINITY_LOOKUP {
return affinity.call(&context, params, &payload).await;
}
let scope = Some(scope)
.filter(|scope| scope.authorizes(&context))
.ok_or_else(denied)?;
if matches!(
method.as_str(),
gateway_plugin_sdk::call::data::ACCOUNTS_LIST
| gateway_plugin_sdk::call::data::KEYS_GET
| gateway_plugin_sdk::call::data::QUOTA_GET
| gateway_plugin_sdk::call::data::QUOTA_REFRESH
| gateway_plugin_sdk::call::data::QUOTA_DETAILS
) {
return data.call(&method, params, &payload).await;
}
if matches!(
method.as_str(),
host::AUTH_LIST | host::AUTH_GET | host::AUTH_GET_RUNTIME | host::AUTH_SAVE
) {
return accounts
.call(&context, scope, &method, params, &payload)
.await;
}
let http = http::HttpCallbacks {
client: &http,
network: &network,
scope,
call: &call,
maximum_payload,
request_limits: &request_limits,
};
if method == host::ACCOUNT_HTTP {
return http.account(&accounts, params, payload).await;
}
if matches!(
method.as_str(),
upstream_wire::HTTP_DO
| upstream_wire::HTTP_DO_STREAM
| upstream_wire::HTTP_STREAM_READ
| upstream_wire::HTTP_STREAM_CLOSE
| upstream_wire::WEBSOCKET_OPEN
| upstream_wire::WEBSOCKET_SEND
| upstream_wire::WEBSOCKET_READ
| upstream_wire::WEBSOCKET_CLOSE
) {
return upstream::dispatch(&http, &method, params, payload)
.await
.inspect_err(|_| {
if let Some(managed) = &scope.upstream {
managed.replay.invalidate();
}
});
}
if matches!(
method.as_str(),
host::HTTP_DO
| host::HTTP_DO_STREAM
| host::HTTP_STREAM_READ
| host::HTTP_STREAM_CLOSE
) {
return http.dispatch(&method, params, payload).await;
}
Err(PluginFault::new(
ErrorCode::Unsupported,
"host callback method is not supported",
))
})
}
}
@@ -119,7 +119,6 @@ fn observation(value: &ProviderResponseObservation) -> facts::ResponseObservatio
status_code: value.status_code(),
request_id: value.request_id().map(|value| value.as_str().to_owned()),
service_tier: value.service_tier().map(str::to_owned),
upstream_response_model: value.upstream_response_model().map(str::to_owned),
timings: facts::ResponseTimings {
transport_decision_wait_ms: timings.transport_decision_wait_ms,
connect_ms: timings.connect_ms,
@@ -29,16 +29,42 @@ impl HttpCallbacks<'_> {
self.request_limits.body_bytes(self.call)
}
pub(super) async fn account(
&self,
accounts: &super::accounts::PluginAccounts,
params: serde_json::Value,
payload: Vec<u8>,
) -> Result<RpcReply, PluginFault> {
let request: wire::AccountHttpRequest = decode(params)?;
let maximum_body_bytes = self.body_limit(&payload)?;
let proxy = accounts
.http_proxy(request.account_id, request.credential_revision)
.await?;
self.send(
HttpRequest {
method: request.request.method,
url: request.request.url,
headers: request.request.headers,
body: payload,
maximum_body_bytes,
},
true,
None,
proxy.as_ref(),
)
.await
}
pub(super) async fn send(
&self,
request: HttpRequest,
streaming: bool,
upstream_purpose: Option<gateway_plugin_sdk::call::upstream_adapter::UpstreamPathPurpose>,
proxy: Option<&gateway_core::account::OutboundProxy>,
) -> Result<RpcReply, PluginFault> {
let Self {
client,
network,
scope,
call,
maximum_payload,
..
@@ -48,13 +74,12 @@ impl HttpCallbacks<'_> {
}
let timeout = call.timeout()?;
let attempt = call.start_upstream(upstream_purpose)?;
let response = client
.open(request, scope.proxy.as_ref(), network, timeout)
.await;
attempt.finish(
let response = client.open(request, proxy, network, timeout).await;
attempt.finish_http(
response
.as_ref()
.map_or_else(|error| error.send_state, |_| UpstreamSendState::Sent),
response.as_ref().ok().map(|response| response.status),
);
let response = response.map_err(http_error)?;
let mut body = response.body;
@@ -115,7 +140,7 @@ impl HttpCallbacks<'_> {
) -> Result<RpcReply, PluginFault> {
let call = self.call;
match method {
"host.http.do" | "host.http.do_stream" => {
wire::HTTP_DO | wire::HTTP_DO_STREAM => {
let request: wire::HttpRequest = decode(params)?;
self.send(
HttpRequest {
@@ -125,12 +150,13 @@ impl HttpCallbacks<'_> {
maximum_body_bytes: self.body_limit(&payload)?,
body: payload,
},
method == "host.http.do_stream",
method == wire::HTTP_DO_STREAM,
None,
self.scope.proxy.as_ref(),
)
.await
}
"host.http.stream_read" => {
wire::HTTP_STREAM_READ => {
if !payload.is_empty() {
return Err(invalid());
}
@@ -174,7 +200,7 @@ impl HttpCallbacks<'_> {
payload: chunk.map_or_else(Vec::new, |chunk| chunk.to_vec()),
})
}
"host.http.stream_close" => {
wire::HTTP_STREAM_CLOSE => {
if !payload.is_empty() {
return Err(invalid());
}
@@ -36,25 +36,23 @@ pub(super) async fn call(
request.timeout_ms,
&context.instance_id,
)?;
let parent = call.scope.origin.as_ref();
call.scope.check_child_depth(&context.instance_id)?;
let origin = call.child_origin(context)?;
let child = core::Context {
plugin_instance_id: Some(context.instance_id.clone()),
request_id: context
.request_id
.clone()
.unwrap_or_else(|| context.resource_scope_id.clone()),
request_id: origin.request_id,
call_id: uuid::Uuid::now_v7().to_string(),
parent_call_id: Some(parent.map_or_else(
|| format!("{}:{}", context.resource_scope_id, context.call_id),
|parent| parent.call_id.clone(),
)),
extensions: call.scope.child_extensions(&context.instance_id)?,
// 句柄可以在父 RPC End 后交给 HTTP 发送端;生命周期属于父调用和响应正文,不能在 RPC End 提前取消
cancellation: parent.map_or_else(
|| call.cancellation.child_token(),
|parent| parent.cancellation.child_token(),
),
plan: parent.map(|parent| parent.plan.clone()),
parent_call_id: Some(origin.call_id),
extensions: origin.extensions,
// 转交正文后由消费端持有原请求寿命,父 RPC End 只撤销新业务分派
cancellation: call
.scope
.origin
.as_ref()
.map_or(origin.cancellation, |parent| {
parent.cancellation.child_token()
}),
plan: origin.plan,
};
let body = call.http_resources.body(request.body, payload).await?;
let mut http = core::Request::new(body);
@@ -0,0 +1,243 @@
//! 为具体入口登记回调作用域和一次性中间件绑定,不持有 RPC 调度状态
use std::sync::{Arc, Weak};
use gateway_core::{account::OutboundProxy, lifecycle::CancellationToken};
use gateway_plugin_sdk::{CallContext, PluginFault};
use super::{
CallbackScope, InvocationContext, MiddlewareBinding, MiddlewareCallback, PluginCallbacks,
denied, upstream,
};
impl PluginCallbacks {
pub(crate) fn bind_middleware(
self: &Arc<Self>,
resource_scope_id: String,
invocation: Arc<dyn MiddlewareCallback>,
scope: Arc<CallbackScope>,
) -> Result<MiddlewareBinding, gateway_core::engine::middleware::MiddlewareError> {
let mut pending = self
.pending_middleware
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if pending
.insert(resource_scope_id.clone(), Arc::clone(&invocation))
.is_some()
{
return Err(gateway_core::engine::middleware::MiddlewareError::InvalidState);
}
Ok(MiddlewareBinding::new(
Arc::clone(self),
resource_scope_id,
invocation,
scope,
))
}
pub(super) fn unbind_middleware(
&self,
resource_scope_id: &str,
invocation: &Arc<dyn MiddlewareCallback>,
) {
let mut pending = self
.pending_middleware
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if pending
.get(resource_scope_id)
.is_some_and(|pending| Arc::ptr_eq(pending, invocation))
{
pending.remove(resource_scope_id);
}
}
pub(crate) fn prepare_data_plane(
&self,
context: &CallContext,
effects: Option<Arc<gateway_core::engine::nested::ExecutionEffects>>,
extensions: gateway_core::engine::extensions::ExtensionCallScope,
plan: Option<gateway_core::engine::middleware::FrozenMiddlewarePlan>,
cancellation: CancellationToken,
request_body_limit: Option<usize>,
) -> Result<Arc<CallbackScope>, PluginFault> {
if !matches!(
context.stage,
gateway_plugin_sdk::Stage::Request
| gateway_plugin_sdk::Stage::Attempt
| gateway_plugin_sdk::Stage::Outbound
| gateway_plugin_sdk::Stage::Routing
| gateway_plugin_sdk::Stage::Scheduling
| gateway_plugin_sdk::Stage::Retry
) {
return Err(denied());
}
let mut scope = CallbackScope::for_call(context);
if let Some(effects) = effects {
scope = scope.with_execution_effects(effects);
}
scope.extension_scope = extensions;
scope.request_body_limit = request_body_limit;
scope.origin = plan.map(|plan| InvocationContext {
request_id: context
.request_id
.clone()
.unwrap_or_else(|| context.resource_scope_id.clone()),
call_id: context
.request_id
.clone()
.unwrap_or_else(|| context.resource_scope_id.clone()),
cancellation,
extensions: scope.extension_scope.clone(),
effects: scope.execution_effects.clone(),
plan: Some(plan),
});
self.register_scope(context, scope)
}
pub(crate) fn request_body_limit(&self) -> Result<usize, PluginFault> {
self.request_limits.current_body_bytes()
}
pub(crate) fn prepare_upstream(
&self,
context: &CallContext,
managed: Arc<upstream::ManagedUpstream>,
extension_scope: gateway_core::engine::extensions::ExtensionCallScope,
request_body_limit: usize,
) -> Result<Arc<CallbackScope>, PluginFault> {
if context.stage != gateway_plugin_sdk::Stage::Upstream
|| context.account_id.as_deref() != Some(managed.account.account_id().as_str())
|| context.credential_revision != Some(managed.account.credential_revision().get())
{
return Err(denied());
}
let mut scope = CallbackScope::new(context, managed.account.outbound_proxy().cloned())
.with_upstream(managed);
scope.extension_scope = extension_scope;
scope.request_body_limit = Some(request_body_limit);
self.register_scope(context, scope)
}
pub(crate) fn prepare_management(
&self,
context: &CallContext,
proxy: Option<OutboundProxy>,
request: Option<Arc<gateway_core::engine::extensions::RequestContext>>,
) -> Result<Arc<CallbackScope>, PluginFault> {
if !matches!(
context.stage,
gateway_plugin_sdk::Stage::Management | gateway_plugin_sdk::Stage::PublicManagement
) {
return Err(denied());
}
self.prepare_scope(context, proxy, request)
}
pub(crate) fn prepare_command_line(
&self,
context: &CallContext,
) -> Result<Arc<CallbackScope>, PluginFault> {
if context.stage != gateway_plugin_sdk::Stage::CommandLine {
return Err(denied());
}
self.prepare_scope(context, None, None)
}
pub(crate) fn prepare_frontend_authentication(
&self,
context: &CallContext,
request: &gateway_core::engine::authentication::ClientAuthenticationRequest,
) -> Result<Arc<CallbackScope>, PluginFault> {
if context.stage != gateway_plugin_sdk::Stage::Authentication
|| context.account_id.is_some()
|| context.credential_revision.is_some()
|| context.attempt_id.is_some()
{
return Err(denied());
}
let mut scope = CallbackScope::for_call(context);
scope.settings = request.settings().cloned();
if let Some(origin) = request.invocation() {
scope.extension_scope = origin.extensions.clone();
scope.execution_effects = origin.effects.clone();
scope.origin = Some(origin.clone());
} else if let Some(request) = request.shared_request_context() {
scope.extension_scope = scope.extension_scope.with_request(request);
}
self.register_scope(context, scope)
}
pub(crate) async fn save_command_account(
&self,
context: &CallContext,
scope: &CallbackScope,
request: gateway_plugin_sdk::call::host::AuthSaveRequest,
) -> Result<gateway_plugin_sdk::call::host::AuthSaveResult, PluginFault> {
if context.stage != gateway_plugin_sdk::Stage::CommandLine || !scope.authorizes(context) {
return Err(denied());
}
self.accounts.save(context, scope, request).await
}
fn prepare_scope(
&self,
context: &CallContext,
proxy: Option<OutboundProxy>,
request: Option<Arc<gateway_core::engine::extensions::RequestContext>>,
) -> Result<Arc<CallbackScope>, PluginFault> {
let mut scope = CallbackScope::new(context, proxy);
if let Some(request) = request {
scope.extension_scope = scope.extension_scope.with_request(request);
}
self.register_scope(context, scope)
}
pub(crate) fn prepare_extensions(
&self,
context: &CallContext,
extension_scope: gateway_core::engine::extensions::ExtensionCallScope,
) -> Result<Arc<CallbackScope>, PluginFault> {
let mut scope = CallbackScope::for_call(context);
scope.extension_scope = extension_scope;
self.register_scope(context, scope)
}
pub(crate) fn bind_invocation(
self: &Arc<Self>,
call: &CallContext,
origin: Option<InvocationContext>,
extensions: gateway_core::engine::extensions::ExtensionCallScope,
invocation: Arc<dyn MiddlewareCallback>,
) -> Result<MiddlewareBinding, gateway_core::engine::middleware::MiddlewareError> {
let mut scope = CallbackScope::for_call(call);
scope.extension_scope = extensions;
scope.origin = origin;
let scope = self
.register_scope(call, scope)
.map_err(|_| gateway_core::engine::middleware::MiddlewareError::Fault)?;
self.bind_middleware(call.resource_scope_id.clone(), invocation, scope)
}
fn register_scope(
&self,
context: &CallContext,
scope: CallbackScope,
) -> Result<Arc<CallbackScope>, PluginFault> {
let scope = Arc::new(scope);
let mut scopes = self
.scopes
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
scopes.retain(|_, scope| scope.strong_count() != 0);
if scopes
.get(&context.resource_scope_id)
.and_then(Weak::upgrade)
.is_some()
{
return Err(denied());
}
scopes.insert(context.resource_scope_id.clone(), Arc::downgrade(&scope));
Ok(scope)
}
}
@@ -52,7 +52,7 @@ impl PluginClientKeys {
params: serde_json::Value,
payload: &[u8],
) -> Result<RpcReply, PluginFault> {
if method == "host.keys.list" {
if method == gateway_plugin_sdk::call::host::KEYS_LIST {
return self.ports.list(params, payload).await;
}
if params != serde_json::json!({}) {

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