Compare commits

..
76 Commits
Author SHA1 Message Date
ZyyandCodex 3a0552df73 chore: bump version to 3.45.0
Co-authored-by: Codex <noreply@openai.com>
2026-11-11 01:48:06 +08:00
ZyyandCodex c72136c689 refactor(store): consolidate unreleased database migrations
Co-authored-by: Codex <noreply@openai.com>
2026-11-11 01:47:46 +08:00
ZyyandCodex a2972d6455 fix(usage): flag incomplete attempt history in error details
Co-authored-by: Codex <noreply@openai.com>
2026-11-11 01:38:23 +08:00
Zyy b5365b5c38 test(host): prevent update discovery mock teardown from hanging 2026-11-11 01:34:41 +08:00
ZyyandCodex a4e60c3d57 docs: clarify Anthropic Messages support and client setup
Co-authored-by: Codex <noreply@openai.com>
2026-11-11 01:33:14 +08:00
e3befd862a feat(accounts): pause scheduling at general quota window limits (#670)
* feat(accounts): pause scheduling at general quota window limits

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

* fix(store): preserve quota pauses across proxy changes

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

* fix(openai): retry quota merges against the observed snapshot

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-11 01:24:37 +08:00
ZyyandZyy 2fc347508a fix(peer): allow grouped API accounts and reject forwarding loops (#671)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-11-11 01:04:20 +08:00
e079ba6e4b feat(accounts): redeem reset credits when weekly quota runs low (#669)
* feat(accounts): redeem reset credits when weekly quota runs low

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

* fix(accounts): observe weekly quota recovery after the last reset credit

Keep checking the local quota snapshot while recovery is unconfirmed. Once recovery is observed with no cards left, return to daily discovery.

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-10 23:50:19 +08:00
ZyyandCodex 13edec0b07 test(openai): stabilize native TLS and WebSocket transport checks
Keep the Linux ClientHello fixture and compare system-native TLS on other platforms. Let the loopback WebSocket handshake finish before advancing idle time, and accept a peer reset on cancellation with unread frames.

Co-authored-by: Codex <noreply@openai.com>
2026-11-10 22:52:56 +08:00
ZyyandCodex 725de6ead6 chore(deps): replace the yanked crypto-bigint release
Update crypto-bigint to 0.7.11 and ctutils to its required 0.4.8 patch.

Co-authored-by: Codex <noreply@openai.com>
2026-11-10 22:52:16 +08:00
758bd24b32 fix(openai): merge model catalogs across scoped OAuth accounts (#667)
Discover models per account so the Codex picker includes models unique to an eligible group member. Use account-specific catalog evidence when selecting a credential for native Responses requests.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-10 22:40:14 +08:00
114baf669b fix(usage): clarify error origins and response diagnostics (#657)
Use saved request and upstream facts to show the same error origin in the usage list and detail. Distinguish WebSocket error statuses from HTTP responses and preserve unknown delivery state for intermediate attempts.

Update the Linux OpenSSL bindings to 0.10.80 to address the dependency audit findings.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-10 22:39:52 +08:00
ZyyandCodex 628533216a feat(keys): enable model discovery in the Codex config template
Co-authored-by: Codex <noreply@openai.com>
2026-11-10 22:07:46 +08:00
ZyyandCodex dd65665128 feat(protocol): support Messages ingress and API upstreams
Support Responses, Chat Completions and Messages ingress with Responses
or Messages API upstreams in the existing OpenAI provider.

Share generation admission and delivery, preserve native protocol payloads,
and translate cross-protocol tools, reasoning, streams and usage. Add
Messages token counting and editable upstream protocol settings.

Co-authored-by: Codex <noreply@openai.com>
2026-11-10 22:07:46 +08:00
ZyyandCodex 63db306ba2 fix(accounts): let concurrency details grow with request rows
Co-authored-by: Codex <noreply@openai.com>
2026-11-10 15:00:28 +08:00
de58b436b1 feat(keys): add explicit budget windows with safe retries (#660)
* feat(keys): add explicit budget windows with safe retries

Add revision-checked daily and weekly windows across admission, settlement
and queries. Reuse the existing budget ledger for atomic window updates,
latest-operation replay and audit records.

Expose typed SDK callbacks while keeping account associations and cycle
decisions in plugins.

Refs #658

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

* fix(keys): preserve budget accounting across window changes

Keep restored balances when historical charges are unavailable and reject window changes that would require those charges. Honor future start times after release and report actual spending windows to peers.

Require an explicit null to release a window through the plugin SDK.

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

* fix(plugins): preserve budget window precision across SDK calls

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-10 12:37:02 +08:00
bec4c2d2a6 fix(deps): update openssl for RUSTSEC-2026-0463 (#662)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-10 12:15:29 +08:00
ZyyandCodex 092789f5ff fix(deps): update SQLx pin for SQLite security advisory
Pin SQLx to the upstream commit supporting libsqlite3-sys 0.39.x and lock libsqlite3-sys to 0.39.1 to resolve RUSTSEC-2026-0459. Preserve the PostgreSQL transaction cancellation fix.

Co-authored-by: Codex <noreply@openai.com>
2026-11-09 20:19:18 +08:00
ZyyandCodex c6350fd234 fix(usage): display explicit incomplete failures as failed
Use recorded upstream error codes and timeout classifications to show
failed requests consistently in usage lists and attempt details.
Keep interrupted or uncertain requests incomplete and preserve raw
outcomes, error statistics, HTTP status codes, and retry behavior.

Refs #656

Co-authored-by: Codex <noreply@openai.com>
2026-11-09 19:04:36 +08:00
ZyyandCodex 620db1b877 feat(openai): simplify account warmup scheduling
Use a single five-field Cron expression and allow lightweight warmup when
quota windows lack active evidence, without extra quota refreshes.
Reset saved schedules and disable warmup in migration 0040 while keeping
the configured model.

Co-authored-by: Codex <noreply@openai.com>
2026-11-09 16:15:35 +08:00
4bf1208024 fix(openai): verify idle quota windows before warmup (#653)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-09 15:32:20 +08:00
github-actions[bot] cf7a9bd501 chore(release): update release channels to 3.44.0 2026-11-09 04:32:56 +00:00
ZyyandCodex c622f08574 chore: bump version to 3.44.0
Co-authored-by: Codex <noreply@openai.com>
2026-11-09 12:17:06 +08:00
ZyyandCodex 5741683ea1 chore(deps): upgrade UI to v0.9.1
Co-authored-by: Codex <noreply@openai.com>
2026-11-09 12:14:33 +08:00
Zyy 16b3671733 fix(accounts): show proxy failures in connection tests 2026-11-09 12:05:11 +08:00
dependabot[bot] a708301cb4 chore(deps): bump the minor-and-patch group in /frontend with 6 updates (#648)
Bumps the minor-and-patch group in /frontend with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@lucide/vue](https://github.com/lucide-icons/lucide/tree/HEAD/packages/vue) | `1.67.0` | `1.70.0` |
| [es-toolkit](https://github.com/toss/es-toolkit) | `1.53.1` | `1.53.2` |
| [vue](https://github.com/vuejs/core) | `3.5.44` | `3.5.45` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.4` | `4.3.5` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.4` | `4.3.5` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.3.5` | `8.3.6` |


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

Updates `es-toolkit` from 1.53.1 to 1.53.2
- [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.53.1...v1.53.2)

Updates `vue` from 3.5.44 to 3.5.45
- [Release notes](https://github.com/vuejs/core/releases)
- [Changelog](https://github.com/vuejs/core/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vuejs/core/compare/v3.5.44...v3.5.45)

Updates `@tailwindcss/vite` from 4.3.4 to 4.3.5
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.5/packages/@tailwindcss-vite)

Updates `tailwindcss` from 4.3.4 to 4.3.5
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.5/packages/tailwindcss)

Updates `vite` from 8.3.5 to 8.3.6
- [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.6/packages/vite)

---
updated-dependencies:
- dependency-name: "@lucide/vue"
  dependency-version: 1.70.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: es-toolkit
  dependency-version: 1.53.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vue
  dependency-version: 3.5.45
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: tailwindcss
  dependency-version: 4.3.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vite
  dependency-version: 8.3.6
  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-11-09 11:22:27 +08:00
dependabot[bot] 65fd34991b chore(deps): bump node from bc0a2af to add5c17 in /deploy (#650)
Bumps node from `bc0a2af` to `add5c17`.

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26.13.0-bookworm-slim
  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-11-09 11:21:59 +08:00
dependabot[bot] 03186b5411 chore(deps): bump debian from 99c0b25 to 734a72b in /deploy (#649)
Bumps debian from `99c0b25` to `734a72b`.

---
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-11-09 11:21:47 +08:00
dependabot[bot] 47ce36f4bf chore(deps): bump yaml-rust2 from 0.11.0 to 0.13.0 in /backend (#647)
Bumps [yaml-rust2](https://github.com/Ethiraric/yaml-rust2) from 0.11.0 to 0.13.0.
- [Release notes](https://github.com/Ethiraric/yaml-rust2/releases)
- [Changelog](https://github.com/Ethiraric/yaml-rust2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Ethiraric/yaml-rust2/compare/v0.11.0...v0.13.0)

---
updated-dependencies:
- dependency-name: yaml-rust2
  dependency-version: 0.13.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-11-09 11:21:33 +08:00
dependabot[bot] 462cef5dd5 chore(deps): bump the minor-and-patch group in /backend with 2 updates (#646)
Bumps the minor-and-patch group in /backend with 2 updates: [zip](https://github.com/zip-rs/zip2) and [async-compression](https://github.com/Nullus157/async-compression).


Updates `zip` from 9.2.2 to 9.4.0
- [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.2.2...v9.4.0)

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

---
updated-dependencies:
- dependency-name: zip
  dependency-version: 9.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: async-compression
  dependency-version: 0.4.52
  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-11-09 11:21:16 +08:00
dependabot[bot] 5647dc8dcb chore(deps): bump the minor-and-patch group with 5 updates (#645)
Bumps the minor-and-patch group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [taiki-e/install-action](https://github.com/taiki-e/install-action) | `2.89.1` | `2.90.2` |
| [docker/login-action](https://github.com/docker/login-action) | `4.7.0` | `4.8.0` |
| [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) | `4.1.2` | `4.1.3` |
| [softprops/action-gh-release](https://github.com/softprops/action-gh-release) | `3.0.3` | `3.0.4` |
| [pnpm/action-setup](https://github.com/pnpm/action-setup) | `6.1.0` | `6.1.1` |


Updates `taiki-e/install-action` from 2.89.1 to 2.90.2
- [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/682ce6c3c696a4cd5168a05f98e51aa02b877941...3a3daafd3b7f23b8f65d9f5ba2db203fb59b79e2)

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

Updates `sigstore/cosign-installer` from 4.1.2 to 4.1.3
- [Release notes](https://github.com/sigstore/cosign-installer/releases)
- [Commits](https://github.com/sigstore/cosign-installer/compare/6f9f17788090df1f26f669e9d70d6ae9567deba6...756fe2d284c7292c4e9b62c8a28f1e5629c59532)

Updates `softprops/action-gh-release` from 3.0.3 to 3.0.4
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/efb35369e0ad2afab669f228072c1b0d510eae64...8a0cd651e5b2365bd640854a3fa5dfc70e4875f3)

Updates `pnpm/action-setup` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/ea17c68df8912ef543352723c149a84f56e3d413...73e46992e05b19abd31f2f3c20e0e7370d762115)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.90.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: docker/login-action
  dependency-version: 4.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: sigstore/cosign-installer
  dependency-version: 4.1.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: pnpm/action-setup
  dependency-version: 6.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-09 11:20:09 +08:00
ZyyandCodex 0d44492309 fix(openai): scope active quota window reassignment to image endpoints
Co-authored-by: Codex <noreply@openai.com>
2026-11-09 11:16:15 +08:00
ZyyandZyy 31f471b051 feat(plugin): support HTTP requests by proxy ID (#644)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-11-09 03:29:52 +08:00
github-actions[bot] e7ef31d882 chore(release): update release channels to 3.43.1 2026-11-08 10:55:22 +00:00
ZyyandCodex 197e9e517a chore: bump version to 3.43.1
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 18:12:22 +08:00
Zyy e770c94a9e fix(openai): preserve Codex quota after image generation 2026-11-08 18:04:45 +08:00
ce620f1b53 feat(release): add Intel macOS downloads (#641)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 17:30:22 +08:00
ZyyandCodex 290aecb7a4 fix(admin): keep page scrolling on the compositor
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 14:05:10 +08:00
ZyyandCodex 1a2a4a56ae fix(openai): update account identity on OAuth reauthorization
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 03:55:30 +08:00
github-actions[bot] 6dd0da1fbd chore(release): update release channels to 3.43.0 2026-11-07 19:38:40 +00:00
ZyyandCodex 402bc37fbb chore: bump version to 3.43.0
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 03:25:42 +08:00
ZyyandCodex 8b9aacd8af chore(release): distinguish version bumps from channel updates
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 03:24:05 +08:00
ZyyandCodex d9f9eca7e8 feat(admin): adopt UI v0.9.0 scene presets and shared sidebar
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 03:15:33 +08:00
ff1ea92155 fix(store): release account concurrency leases on shutdown (#638)
Supervise scheduling lease release tasks and drain them within a bounded shutdown budget. Preserve other instances and the existing TTL fallback.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 01:01:11 +08:00
github-actions[bot] f772bda57b chore(release): publish v3.42.1 2026-11-07 07:14:34 +00:00
ZyyandCodex e0a3dc20dd chore(release): prepare v3.42.1
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 15:04:23 +08:00
39c8a6352d feat(plugins): confirm external links from management pages (#635)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 15:01:44 +08:00
Ke WangandCodex db779a4a21 fix(openai): hide untyped models from agent picker (#632)
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 14:43:05 +08:00
ZyyandCodex 750dd32200 fix(scheduling): log queue fallback binding migrations
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 14:28:56 +08:00
ZyyandCodex 6230d95729 fix(admin): adopt UI v0.8.1 overlay fixes
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 14:23:49 +08:00
ZyyandCodex 11b64b3026 fix(update): publish verified channels on main
Read update candidates from the published channel table and advance it only after the release assets are public. Keep the tagged preparation commit separate from the publication commit.

Co-authored-by: Codex <noreply@openai.com>
2026-11-07 13:47:32 +08:00
ca5901ba56 fix(admin): preserve proxy capacity in OAuth pending state (#631)
* fix(admin): preserve proxy capacity in OAuth pending state

Persist the saved proxy's WebSocket capacity in the shared pending
authorization codec and restore it before the Store checks the snapshot.
Keep genuine proxy changes and disabled proxies rejected.

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

* docs(deploy): clarify OAuth proxy upgrade guidance

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

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-11-07 13:24:15 +08:00
ZyyandCodex 81de0f5331 build(release): keep the plugin manifest inside distribution packages
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 03:00:01 +08:00
ZyyandCodex 69c1ee15bb docs(release): clarify same-version retries and simplify release notes
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 02:56:49 +08:00
ZyyandCodex ba87cfde6a chore(ci): remove the release script test suite
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 02:41:37 +08:00
ZyyandCodex d0a588fbc3 test(plugin): signal crashes after host registration
Drive each worker exit from the prepared generation so scheduling delays cannot turn a post-registration crash into a registration failure.

Co-authored-by: Codex <noreply@openai.com>
2026-11-07 02:28:00 +08:00
ZyyandCodex 7cee430947 chore(release): v3.42.0
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 01:59:00 +08:00
ZyyandCodex ac98a764ff fix(update): discover channels from verified release assets
Publish an immutable channel snapshot with each release and advance a fixed release pointer only after public assets pass verification. Check the exact platform package before notifying clients.

Co-authored-by: Codex <noreply@openai.com>
2026-11-07 01:59:00 +08:00
ZyyandCodex 56ef1bffa2 test(openai): align service tier fixture with OAuth normalization
Use client_metadata to verify nested service tier fields survive fast-mode handling while preserving outbound tier and billing assertions.

Co-authored-by: Codex <noreply@openai.com>
2026-11-07 01:46:54 +08:00
ZyyandCodex e275a6dada chore(release): v3.42.0
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 01:21:29 +08:00
555269739c fix(openai): normalize unsupported Codex OAuth request options (#630)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 01:16:25 +08:00
ZyyandCodex d03d8408fc docs: clarify current configuration and update behavior
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 00:55:33 +08:00
ZyyandCodex 228eea90c1 chore(db): consolidate unreleased migrations
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 00:48:32 +08:00
ZyyandCodex 0d25061ef0 docs: bound UI verification and generalize AI attribution
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 00:37:46 +08:00
ZyyandZyy e97b1faf0d feat(openai): derive installation identities from key seeds (#629)
* feat(openai): derive installation identities from key seeds

* docs: explain installation seed migration effects

* docs: fix the key configuration reference

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-11-07 00:33:17 +08:00
ZyyandZyy 90ecac978a feat(accounts): make quota credit use opt-in per account (#628)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-11-07 00:20:43 +08:00
ZyyandZyy 4326eb1e4b fix(update): bound GitHub lookups with a published channel index (#627)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-11-07 00:20:19 +08:00
ZyyandCodex 2a8d5d8643 ci(release): publish the channel index after verifying release assets
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 00:13:47 +08:00
ZyyandCodex b7fa5872a3 refactor(ui): reuse shared button components
Co-authored-by: Codex <noreply@openai.com>
2026-11-06 23:41:36 +08:00
ZyyandCodex e907f419ab fix(ui): show account group names in popovers
Co-authored-by: Codex <noreply@openai.com>
2026-11-06 23:20:13 +08:00
ZyyandZyy ee7e837d1f feat(scheduling): split queue timeouts and allow account fallback (#625)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-11-06 23:09:29 +08:00
ZyyandCodex 1240f22580 fix(openai): honor affinity mode for sessionless requests
Use the configured affinity mode for content and cache-key bindings, including strict waiting and binding store failures. Cover queue rejection, cancellation, migration, and preferred spillover, and document the scheduling boundaries.

Refs #623

Co-authored-by: Codex <noreply@openai.com>
2026-11-06 22:05:15 +08:00
ZyyandCodex e390417821 fix(openai): prefer the same account for sessionless requests
Use the client cache key or the opening messages as a tenant-scoped scheduling preference. Keep explicit sessions and continuation ownership separate, and allow spillover when the preferred account is busy.

Refs #622

Co-authored-by: Codex <noreply@openai.com>
2026-11-06 20:01:01 +08:00
ZyyandCodex e3f1299a0c fix(ui): retain custom model choices until save
Keep deselected model options in the editing session and remove the nested model list scrollbar.

Co-authored-by: Codex <noreply@openai.com>
2026-11-06 15:53:42 +08:00
Zyy 4e4cd96478 fix(openai): recheck cross-account capacity while queued 2026-11-06 15:02:16 +08:00
ZyyandZyy 6de1beb1df perf(openai): reduce scheduling reads and stabilize session affinity (#618)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-11-06 14:44:42 +08:00
530 changed files with 25328 additions and 7074 deletions
+3 -2
View File
@@ -20,7 +20,7 @@ description: Codex Proxy RS 开发、修复、排障、代码审查和文档修
| 领域或操作,行为影响与文件路径满足其一即可 | 确定方案前读取与执行 |
| --- | --- |
| 调整页面、弹窗、表单、按钮、文案、样式或交互;目标在 `frontend/`、`modules/ui/`、插件前端,或是 `.vue` 文件 | [前端流程](references/frontend.md),覆盖组件复用、文案、Token、样式、视觉状态和运行证据;局部修改同样执行 |
| 调整页面、弹窗、表单、按钮、文案、样式或交互;目标在 `frontend/`、`modules/ui/`、插件前端,或是 `.vue` 文件 | [前端流程](references/frontend.md),局部修改同样按影响范围检查组件复用、文案、Token、样式、视觉状态和运行证据 |
| 开发、排查或审查插件接入、宿主 SDK、Runtime、扩展接口或独立网关插件;目标在 `backend/crates/gateway-plugin/`、`modules/plugins/` | 完整读取[插件职责边界](references/plugin-boundaries.md)及所用 [SDK](../../../backend/crates/gateway-plugin/sdk/README.md) 类型与能力合同;宿主维护自身合同、资源与稳定性,插件业务错误在插件侧修复,不在宿主、SDK 或 Runtime 中补偿;独立插件开发、排查、审查另用 [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md) |
| 开发、排查或审查服务端接口、路由、调度、账号与用量规则、持久化、后台任务或协议行为;目标是 `backend/` 中的实现、配置、迁移,或 Rust 源码 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)的模块与依赖规则、所属模块及[后端自审](../../../docs/architecture.md#后端自审);Rust 使用 `$rust-best-practices` 和下方后端注释要求,宿主 Rust 同时读取 [Rust 模块组织约定](../../../docs/architecture.md#32-rust-模块组织约定) |
| 修改页面接口调用,或开发、排查、审查面向管理端或模型客户端的 HTTP/WS API 合同 | [API](../../../docs/api.md)对应章节;优先复用已有接口,核对调用方 → 服务端解析 → 实际查询或执行,不能只凭前端类型认定参数生效;按实际改动叠加前后端规范 |
@@ -45,8 +45,9 @@ description: Codex Proxy RS 开发、修复、排障、代码审查和文档修
1. 遵守已提供的 [AGENTS.md](../../../AGENTS.md),按其 CodeGraph 约定定位代码;确认工作区、任务范围与已有改动,找到事实归属、同类实现和调用链,补齐上表命中规范及[项目约定](../../../CONTRIBUTING.md#项目约定)对应条目后再修改
2. 以最终差异重新匹配上表,补齐新增领域的规范并检查实际实现,再处理本次引入的职责越界、重复规则、冗余状态与无依据分支;删除或替换功能时一并清理失效的配置、类型、调用、测试与文档,已部署数据和公开合同仍遵守所属迁移及兼容规则;仅审查时报告,不自行修复
3. 按最终行为执行[文档检查](references/documentation.md),核对所属正文及相关概述、示例和引用,在本任务内完成必要同步;不以尚无文档差异跳过检查。内部重构等未改变文档所述事实时无需改文档;常规文档只写当前状态,普通开发不自动修改 `release/notes.md`
4. 按变更范围执行[验证](../../../CONTRIBUTING.md#验证)及其停止条件,后端命令见[验证命令](../../../docs/architecture.md#验证命令);复用仍适用的验证证据与用户已确定的验收分工,区分通过、失败、跳过与未执行;提交前确认已覆盖[提交约定](../../../CONTRIBUTING.md#项目约定)
4. 按变更范围执行[验证](../../../CONTRIBUTING.md#验证)及其停止条件,后端命令见[验证命令](../../../docs/architecture.md#验证命令);复用仍适用的验证证据与用户已确定的验收分工,区分通过、失败、跳过与未执行
5. 交付前按[首要写作要求](#首要写作要求言简意赅)通读本次新增或修改的文字。交代实际结果与验证缺口;前端按[前端流程](references/frontend.md)核对运行证据,后端按上表检查生命周期,不把构建通过当作行为验收
6. 按授权完成提交与交付;提交前按[提交约定](../../../CONTRIBUTING.md#项目约定)准备提交信息与实际共同作者署名,提交后核对实际提交消息,PR 的 AI 披露不能替代提交署名
## 测试维护
@@ -4,7 +4,7 @@
## 领域必读
确定页面方案或审查结论前,读取[前端职责](../../../../docs/architecture.md#34-前端模块职责)、[文案与信息层级](../../../../docs/theme.md#界面文案与信息层级)和[界面验证](../../../../CONTRIBUTING.md#界面验证)的完整章节;只改文案或局部样式也执行。明确本次用户操作、原页面、最接近的同类实现及可复用基础组件,沿用既有设计与请求层,按本轮验收分工留存所需截图
确定页面方案或审查结论前,读取[前端职责](../../../../docs/architecture.md#34-前端模块职责)、[文案与信息层级](../../../../docs/theme.md#界面文案与信息层级)和[界面验证](../../../../CONTRIBUTING.md#界面验证)的完整章节,局部修改同样读取。明确本次用户操作、原页面、最接近的同类实现及可复用基础组件,沿用既有设计与请求层,按本轮验收分工留存所需截图
## 按改动补齐规范
@@ -21,9 +21,15 @@
普通维护和新增业务页面沿用现有设计;设计技能中的配色、字体、布局探索不覆盖项目规范,主题算法只在涉及时读取
## 验收范围与收尾
验证前按[界面验证](../../../../CONTRIBUTING.md#界面验证)明确本次受影响的用户路径、状态和通过条件。局部修改围绕受影响区域及相关操作验收;主题、窗口尺寸和空/加载/错误状态仅在受影响时补充,共用主题或基础组件变更按主题规范扩大范围
通过后按[验证停止条件](../../../../CONTRIBUTING.md#验证)收尾;只有新改动、失败或未解疑点时补验相关部分。补交截图或 PR 材料时复用仍有效的运行证据
## 交付前
对照最终差异和实际页面逐项核对,不以读取过规范代替检查:
对照最终差异和实际页面核对适用项,不以读取过规范代替检查:
- **复用与范围**:是否沿用参考页面和基础组件,已有组件能承载时不手写替代控件;共用缺陷按职责修复,修复后清理本次已失效的页面补丁,不顺带重排无关区域或新增重复入口
- **文案与层级**:标点是否合规,标题、状态和帮助是否重复,详细说明与当前决策必需提示的位置是否合适
+2 -1
View File
@@ -19,7 +19,8 @@ description: Codex Proxy RS 的版本规划、发布说明、正式或预发行
- Codex 兼容性变化按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核对已有依据与验证范围,缺少依据列为发行风险,不以 CI 通过代替
- `release/publish --check <version>` 只读检查发行准备条件;不带 `--check` 会提交、打 tag、推送并触发工作流,实际发布仍需相应授权
- 同名版本、超时或中断先查本地与远端状态,不删已发布 tag、不移动 tag、不强推掩盖冲突
- 同版本失败先区分[续跑原任务与修复源码后重发](references/operations.md#恢复与验收):前者沿用原提交;后者仅在版本尚未公开时清理失败的版本提交,再生成唯一的版本提交。已发布的 tag 和产物保持不变
- 备版只更新构建版本;发布后回读 `main:release/version.yaml` 的 `published`,核对已公开的通道候选
- 预发行不得覆盖 GitHub Latest 或镜像 `latest`,完成后回读核对
- `release/notes.md` 只承载当前发行的说明;常规文档修改另按开发入口执行[文档检查](../cpr-dev-guide/references/documentation.md)
- 交付版本、提交、Release/工作流链接与实际结果,未完成构建或缺失产物时不称发布成功
@@ -27,7 +27,7 @@
应用内更新仅允许同一轮实验,规则见[版本命名与升级规则](../../../../deploy/README.md#版本命名与升级规则)。
发版前核对上一实验版是否已包含该更新器;仍禁用在线更新的旧构建需要先手动升级。
- GitHub Release 标为 Pre-release,且不设为 Latest;Docker 发布版本标签,不覆盖稳定版 `latest`。
正式版和 alpha、beta、rc 均不接收实验版更新;环境变量不能改变发行线。
发布任务应将该基线的最高公开实验版写入`main:release/version.yaml` 的 `published[大版本].exp[基线]`,不推进普通通道。
- 复用正式版平台清单、质量检查、完整安装包与多架构镜像,以及部署文件、校验和和现有产物证明。
对照本次清单确认平台,不以“实验版”为由仅发布单架构镜像。
- 部署说明交代与稳定实例并存的目录、配置、端口和数据库要求;需要 Compose 隔离配置时,
@@ -20,7 +20,7 @@
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
脚本会更新 `release/version.yaml`,按[接口弃用规则](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#接口弃用)校验并维护 `plugin-api-deprecations.json`,将这些差异与最终工作区中的发布说明一起提交为 `chore(release): vX.Y.Z`。
脚本只改 `release/version.yaml` 的顶层 `version`,按[接口弃用规则](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#接口弃用)校验并维护 `plugin-api-deprecations.json`,将这些差异与最终工作区中的发布说明一起提交为 `chore: bump version to <version>`。
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
本任务需要的提交按[项目约定](../../../../CONTRIBUTING.md#项目约定)保留英文 Conventional Commits 与相应署名。
@@ -38,13 +38,29 @@ release/publish "$release_version"
## 恢复与验收
- 脚本中断或网络超时时,先核对本地提交与 tag、远端分支与 tag、对应工作流 run 及 Release,确定已完成到哪一步。
若本地已创建版本提交或 tag,不直接重跑入口,也不通过删 tag、回退版本或强推来消除报错。
- 提交和 tag 已推送但工作流未触发时,核对目标后使用脚本提示的 dispatch 命令补触发;
工作流已存在则跟踪或恢复对应 run,避免重复发布。发现 tag 指向不同提交时报告冲突,不移动已发布 tag。
- 跟踪与本次 tag 对应的 run ID,核对工作流解析的目标提交。触发工作流成功只表示已开始,不能当作发布完成。
- 构建与打包中间产物保留 7 天,在有效期内可重跑失败任务;产物过期后重跑完整工作流,不移动原 tag。
先核对本地与远端的分支头、版本提交、tag,以及对应的工作流、GitHub Release 和版本镜像。超时或任务失败不等于版本未公开;即使 Release 还是草稿,版本镜像也可能已经推送
### 续跑原任务
源码无需修改时保留版本提交和 tag,核对两者指向后从断点继续:
- 尚未推送:补齐缺失的步骤,按入口原子推送分支和 tag
- 已推送但未触发:使用入口提示的 dispatch 命令
- 已有 run:跟踪对应提交的任务,失败时重跑失败任务。构建产物保留 7 天,过期后从原 tag 重跑完整工作流
### 修复源码后同版本重发
1. 确认旧工作流已停止、该版本没有公开 Release 或版本镜像,且待清理提交不在其他已发布 tag 的历史中。已有公开产物时保留原提交和 tag,代码修复另发新版本;发布状态不明时先查清
2. 从失败版本提交的父提交重新接上后续功能和修复,跳过已核实的失败版本提交。保留最终说明为未提交改动,版本号和插件弃用清单交给发行入口。核对差异,确保修复未丢失、失败提交已不在待发历史中
3. 只清理本次失败的 tag 和草稿。涉及远端改写时,先核对授权,以事先记录的分支头和 tag 对象 SHA 作显式租约,原子更新分支并删除未发行 tag。租约不匹配就停止,不合并旧线
4. 修复验证通过后执行 `release/publish --check <version>`,再按[执行发版](#执行发版)重发。核对上一发行至新 tag 的历史中只有一个本版本的发行提交,且与工作流构建的提交一致
### 验收
- 发布完成后核对 GitHub Release 的版本、正式版/预发行状态和中文变更正文;对照平台清单与工作流确认归档、校验和及镜像产物。
工作流附加的安装说明和自动提交列表按其当前配置处理,不用它们替代手写的中文变更说明。
- 回读 `main:release/version.yaml` 的 `published`。正式版参与同一大版本四个普通通道的比较;RC 参与 RC、Beta、Alpha,Beta 参与 Beta、Alpha,Alpha 只参与自身。实验版只推进 `exp["X.Y.Z"]`。有通道推进时应有 `chore(release): update release channels to <version>` 提交;重复执行或较旧版本未推进任何通道时无提交
- 通道表写入失败时,确认该版本已公开、工作流能写入 `main`,再按已有授权执行 `gh workflow run release.yml --ref main -f tag="v$release_version" -f index_only=true`。恢复任务重新校验公开附件、读取并合并当前清单,不重新打包或改 tag
- v3.42.0 在线升级:首个包含新更新器的 3.x 正式版公开并通过校验后,从该次通道提交取出 `release/version.yaml`,将顶层 `version` 设为该正式版,作为同名附件上传到该 Release。回读附件,确认 3.x 稳定通道指向该版本且安装包可用,再手动将已有 `update-index` 正文改为 `{"current":"<该正式版>"}`。此后保留该页与附件,无需再编辑
- 返回版本、tag 对应提交、Release/工作流链接及实际结果。失败、仍在构建或缺失产物时明确说明,不能称为发布成功;
Release 和镜像已生成也不表示用户的运行实例已经升级。
@@ -5,7 +5,7 @@
## 基线与版本
1. 确认类型、分支、upstream、目标仓库、工作区与完整待发布范围,保留无关改动;未跟踪文件也需核对
2. 读取 [version.yaml](../../../../release/version.yaml)、相关 tag 和实际 Release,确定上一发布基线与本次目标提交,再核对二者间的最终 diff,不只看最近几条提交
2. 读取 [version.yaml](../../../../release/version.yaml)、相关 tag 和实际 Release,区分顶层构建版本与 `main` 上的 `published` 通道表,结合目标通道确定上一发布基线与本次目标提交,再核对二者间的最终 diff,不只看最近几条提交
3. 使用用户确定的目标版本,建议版本时说明兼容性、能力或修复依据;同名 tag 先区分重试与新发行,不递增版本掩盖冲突
4. 命名与晋级按[版本命名与升级规则](../../../../deploy/README.md#版本命名与升级规则),实验版另读[实验流程](experimental.md#分支与版本)
@@ -14,7 +14,9 @@
读取当前 [notes.md](../../../../release/notes.md)与[项目约定](../../../../CONTRIBUTING.md#项目约定)中的发布说明条目
- 只在发布说明任务中修改 `release/notes.md`,替换为本次版本内容,首行为目标 tag,正文使用中文
- 按用户可感知的最终变化归并,省略空章节,不逐条翻译提交,不写排查过程或未经验证的承诺
- 常规版本按「新增功能」「优化改进」「问题修复」「升级须知」归类,省略空章节;单一改动可直接写一句。安装命令和文档入口由工作流附加
- 每条用一句短语讲清一个用户可感知的结果,不加句号,例如「系统更新:确认当前平台有安装包后才提示升级」
- 同一变化只写一次,保留会影响使用或升级的默认值和限制,不展开实现过程、逐条翻译提交或写未经验证的承诺
- 剔除回退、重复及未进入发行范围的内容,升级事项须能追溯到实际迁移、配置或兼容变化
- 只翻译已有准确说明时保持事实与范围,不混入其他变更
- 常规文档仍只描述当前状态;必要修订执行[文档检查](../../cpr-dev-guide/references/documentation.md),不复制发布说明中的版本变化列表
@@ -26,6 +28,8 @@
进入实际发行准备后,读取目标分支的 [release/publish](../../../../release/publish)、[工作流](../../../../.github/workflows/release.yml)与[平台清单](../../../../release/platforms.yaml),按当前入口执行,不另写脚本或绕过检查
在上一发行基线到 HEAD 的提交中检查是否已有目标版本的备版提交(`chore: bump version to <version>`;查历史时也匹配 `chore(release): prepare v<version>` 和 `chore(release): v<version>`),即使同名 tag 已不存在也要查。命中时先核对该提交对应的发行状态,按[失败恢复](operations.md#恢复与验收)处理,不在旧提交上再叠一个同名提交
发布说明准备好、其他代码已提交后,执行 `release/publish --check <version>`,检查版本、说明、工作区、远端分支与 tag、工具链和插件兼容窗口。
预检不修改文件、Git 引用或远端状态;远端存在本地未知提交时先同步再检查。它不替代 CI、发行物验证或实际发布授权
+3 -4
View File
@@ -1,10 +1,10 @@
require 'digest'
require 'fileutils'
require 'json'
require_relative 'release-metadata'
# 归档与镜像消费相同二进制和封口清单,不在打包时重新解释插件分发策略
module ReleasePackage
APP_NAME = 'codex-proxy-rs'
APP_NAME = ReleaseMetadata::APP_NAME
MANIFEST = 'dist/plugin-release-assets/plugin-release-manifest.json'
def self.install_manifest(directory)
@@ -34,7 +34,6 @@ module ReleasePackage
default_image = "${CPR_IMAGE:-#{image}:latest}"
raise 'Expected exactly one default application image in deploy/compose.yaml' unless compose.scan(default_image).length == 1
File.write("#{release_dir}/compose.yaml", compose.sub(default_image, "${CPR_IMAGE:-#{image}:#{ENV.fetch('DOCKER_VERSION')}}"))
FileUtils.cp(MANIFEST, release_dir)
platforms.each do |platform|
raise "Unsupported archive format: #{platform['archive']}" unless platform['archive'] == 'tar.gz'
@@ -47,7 +46,7 @@ module ReleasePackage
FileUtils.cp_r('dist/frontend/.', "#{root}/web/dist")
FileUtils.cp(config, "#{root}/deploy")
install_manifest("#{root}/plugins/official")
archive = "#{release_dir}/#{APP_NAME}_#{ENV.fetch('VERSION')}_#{platform['os']}_#{platform['arch']}.tar.gz"
archive = File.join(release_dir, ReleaseMetadata.archive_name(ENV.fetch('VERSION'), platform))
raise "Failed to create #{archive}" unless system('tar', '-czf', archive, '-C', File.dirname(root), APP_NAME)
end
+105
View File
@@ -0,0 +1,105 @@
require 'base64'
require 'uri'
require_relative 'release-metadata'
# 各发行线共用 main 通道表;只合并本次已核实的版本
module PublishedChannels
PATH = 'release/version.yaml'
LIMIT = 128 * 1024
def self.advance(document, version)
version = ReleaseMetadata.version(version)
major, = version.split('.', 2)
core, stage = version.split('+', 2).first.split('-', 2)
channels = (document.fetch('published')[major] ||= {})
if stage&.start_with?('exp.')
channels = (channels['exp'] ||= {})
keys = [core]
else
stages = %w[stable rc beta alpha]
keys = stages.drop(stages.index(stage ? stage.split('.').first : 'stable'))
end
incoming = ReleaseMetadata.sort_key(version)
changed = false
keys.each do |key|
current = channels[key]
if current
comparison = incoming <=> ReleaseMetadata.sort_key(current)
raise "Conflicting release identities: #{current}, #{version}" if comparison.zero? && current != version
next unless comparison.positive?
end
channels[key] = version
changed = true
end
changed
end
class Publisher
def api(endpoint, method: 'GET', data: nil, optional: false)
args = ['gh', 'api', endpoint, '--method', method]
args.concat(%w[--input -]) if data
out, error, status = Open3.capture3(*args, stdin_data: data ? JSON.generate(data) : '')
return nil if optional && !status.success? && error.include?('(HTTP 404)')
raise "Cannot access #{endpoint}: #{error.strip}" unless status.success?
JSON.parse(out)
end
def initialize(repository)
@base = "repos/#{repository}"
end
def find_release(version)
api("#{@base}/releases/tags/v#{ReleaseMetadata.version(version)}", optional: true)
end
def verify_assets(version)
release = find_release(version)
tag = "v#{version}"
raise "Release is not public or tag differs: #{tag}" unless release && release.fetch('tag_name') == tag &&
!release.fetch('draft') && release.fetch('prerelease') == ReleaseMetadata.prerelease?(version)
data = api("#{@base}/contents/release/platforms.yaml?ref=#{URI.encode_www_form_component(tag)}")
platforms = YAML.safe_load(Base64.decode64(data.fetch('content'))).fetch('platforms')
raise "Missing platforms for #{tag}" if platforms.empty?
expected = platforms.map { |p| ReleaseMetadata.archive_name(version, p) }
expected.concat(%w[checksums.txt compose.yaml config.example.yaml])
uploaded = release.fetch('assets').filter_map do |asset|
asset.fetch('name') if asset.fetch('state') == 'uploaded' && asset.fetch('size').positive?
end
missing = expected - uploaded
raise "Incomplete release #{tag}: #{missing.join(', ')}" unless missing.empty?
end
def check_unpublished(version)
existing = find_release(version)
raise "Release v#{version} already public; use index-only recovery" if existing && !existing.fetch('draft')
end
def publish(version)
verify_assets(version)
# 读取 main 的当前文件,实验分支和较晚结束的旧构建都不能覆盖其他通道或构建版本
endpoint = "#{@base}/contents/#{PATH}"
file = api("#{endpoint}?ref=main")
document = YAML.safe_load(Base64.decode64(file.fetch('content')))
return unless PublishedChannels.advance(document, version)
content = YAML.dump(document).delete_prefix("---\n")
raise 'Published index exceeds the client size limit' if content.bytesize > LIMIT
# 按读取的文件 SHA 写入;并发备版改过同一文件时交给恢复任务重新合并
api(endpoint, method: 'PUT', data: {
branch: 'main', sha: file.fetch('sha'), content: Base64.strict_encode64(content),
message: "chore(release): update release channels to #{version}"
})
end
end
end
if $PROGRAM_NAME == __FILE__
mode, raw = ARGV
version = ReleaseMetadata.version(raw || '')
publisher = PublishedChannels::Publisher.new(ENV.fetch('GITHUB_REPOSITORY'))
case mode
when 'check' then publisher.check_unpublished(version)
when 'publish' then publisher.publish(version)
else abort 'usage: published-channels.rb check|publish VERSION'
end
end
-193
View File
@@ -1,193 +0,0 @@
require 'fileutils'
require 'json'
require 'open3'
require 'tmpdir'
require_relative 'release-quality'
# 使用临时 Git 仓库验证远端状态和只读边界,不访问真实 GitHub 或修改当前仓库
ROOT = File.expand_path('../..', __dir__)
def assert(condition, message)
raise message unless condition
end
def git(*arguments)
output, error, status = Open3.capture3('git', *arguments)
raise "git #{arguments.first}: #{error}" unless status.success?
output.strip
end
def commit(message)
git('add', '.')
git('commit', '-qm', message)
end
Dir.mktmpdir('release-checks-') do |directory|
remote = File.join(directory, 'remote.git')
system('git', 'init', '--bare', '-q', remote, exception: true)
checkout = File.join(directory, 'checkout')
FileUtils.mkdir_p(checkout)
Dir.chdir(checkout) do
git('init', '-q', '-b', 'main')
git('config', 'user.email', 'test@example.invalid')
git('config', 'user.name', 'Release test')
files = %w[
release/publish release/version.yaml release/notes.md release/platforms.yaml
.github/scripts/toolchains.rb .github/scripts/release-metadata.rb
backend/rust-toolchain.toml deploy/Dockerfile
backend/crates/gateway-plugin/runtime/plugin-api-lifecycle.rb
backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json
backend/crates/gateway-plugin/runtime/plugin-api-deprecations.json
]
files.each do |path|
FileUtils.mkdir_p(File.dirname(path))
FileUtils.cp(File.join(ROOT, path), path)
end
commit('initial source')
assert(ReleaseQuality.evidence('owner/repo').nil?, 'root commit should require full checks')
git('remote', 'add', 'origin', remote)
git('push', '-qu', 'origin', 'main')
File.write('release/notes.md', "# v99.0.0\n\nFixture notes\n")
bin = File.join(directory, 'bin')
FileUtils.mkdir_p(bin)
File.write(File.join(bin, 'gh'), <<~RUBY)
#!/usr/bin/env ruby
case ARGV
when ['auth', 'status'] then exit 0
when ['repo', 'view', #{remote.inspect}, '--json', 'nameWithOwner', '--jq', '.nameWithOwner']
puts 'owner/repo'
else
abort "unexpected gh mutation: \#{ARGV.inspect}"
end
RUBY
File.chmod(0o755, File.join(bin, 'gh'))
environment = { 'PATH' => "#{bin}:#{ENV.fetch('PATH')}" }
snapshot = lambda do
[git('show-ref'), git('status', '--porcelain'), File.binread('.git/index'),
files.to_h { |path| [path, File.binread(path)] },
git('ls-remote', 'origin'), File.exist?('.git/FETCH_HEAD')]
end
check = lambda do |success, message = nil|
before = snapshot.call
output, error, status = Open3.capture3(environment, 'bash', 'release/publish', '--check', '99.0.0')
assert(status.success? == success, "preflight outcome: #{output}#{error}")
assert("#{output}#{error}".include?(message), "missing error: #{message}; #{error}") if message
assert(snapshot.call == before, 'preflight modified files, refs, index, or FETCH_HEAD')
end
check.call(true, 'Preflight passed')
File.write('deploy/Dockerfile', File.read('deploy/Dockerfile') + "\n# fixture\n")
check.call(false, 'unstaged changes')
git('checkout', '--', 'deploy/Dockerfile')
git('tag', 'v99.0.0')
check.call(false, 'local tag already exists')
git('push', '-q', 'origin', 'refs/tags/v99.0.0')
git('tag', '-d', 'v99.0.0')
check.call(false, 'remote tag already exists')
git('push', '-q', 'origin', ':refs/tags/v99.0.0')
# 未履行的兼容窗口必须在只读预检中失败,不能等待提交版本后再发现
deprecations = 'backend/crates/gateway-plugin/runtime/plugin-api-deprecations.json'
previous = File.read(deprecations)
File.write(deprecations, JSON.generate([{
capability: 'removed-fixture', version: 1, replacement_version: 2, remaining_releases: 7
}]))
git('add', deprecations)
git('commit', '-qm', 'invalid lifecycle fixture')
check.call(false, 'replacement v2 must be available')
File.write(deprecations, previous)
commit('restore lifecycle and notes')
File.write('release/notes.md', "# v99.0.0\n\nUpdated fixture notes\n")
# 远端前进但没有本地对象时,提示同步而不隐式 fetch
other = File.join(directory, 'other')
git('clone', '-q', '-b', 'main', remote, other)
Dir.chdir(other) do
git('config', 'user.email', 'test@example.invalid')
git('config', 'user.name', 'Release test')
File.write('remote-only', 'change')
commit('remote advance')
git('push', '-q')
end
check.call(false, 'unseen commits')
git('fetch', '-q', 'origin')
check.call(false, 'diverged')
FileUtils.mv(remote, "#{remote}.hidden")
# 读取远端失败不能当作 tag 不存在,仍须保持本地不变
before = [git('show-ref'), git('status', '--porcelain'), File.binread('.git/index')]
_, error, status = Open3.capture3(environment, 'bash', 'release/publish', '--check', '99.0.0')
assert(!status.success? && error.include?('failed to read remote refs'), 'remote failure was ignored')
assert(before == [git('show-ref'), git('status', '--porcelain'), File.binread('.git/index')], 'remote failure changed local state')
FileUtils.mv("#{remote}.hidden", remote)
File.write('release/version.yaml', "version: \"99.0.0\"\n")
commit('release metadata')
parent = git('rev-parse', 'HEAD^')
run = { 'head_sha' => parent, 'conclusion' => 'success', 'head_repository' => { 'full_name' => 'owner/repo' }, 'id' => 42 }
job = { 'name' => 'quality / backend', 'conclusion' => 'success', 'html_url' => 'https://example.invalid/42' }
runs = [run]
jobs = [job]
original_capture = ReleaseQuality.method(:capture)
ReleaseQuality.define_singleton_method(:capture) do |*arguments|
if arguments.first == 'gh'
endpoint = arguments.find { |argument| argument.start_with?('repos/') }
if endpoint.include?('/workflows/ci.yml/runs?')
assert(endpoint.include?("event=push&branch=main&head_sha=#{parent}"), 'wrong CI evidence scope')
JSON.generate('workflow_runs' => runs)
else
assert(endpoint == 'repos/owner/repo/actions/runs/42/jobs?per_page=100', 'wrong run jobs')
jobs.map { |entry| JSON.generate(entry) }.join("\n")
end
else
original_capture.call(*arguments)
end
end
assert(ReleaseQuality.evidence('owner/repo') == job['html_url'], 'valid CI evidence not reused')
%w[skipped failure cancelled].each do |conclusion|
job['conclusion'] = conclusion
assert(ReleaseQuality.evidence('owner/repo').nil?, "reused #{conclusion} backend")
end
job['conclusion'] = 'success'
jobs = []
assert(ReleaseQuality.evidence('owner/repo').nil?, 'reused missing backend job')
jobs = [job]
run['conclusion'] = 'failure'
assert(ReleaseQuality.evidence('owner/repo').nil?, 'reused failed CI run')
run['conclusion'] = nil
assert(ReleaseQuality.evidence('owner/repo').nil?, 'reused unfinished CI run')
run['conclusion'] = 'success'
run['head_repository']['full_name'] = 'fork/repo'
assert(ReleaseQuality.evidence('owner/repo').nil?, 'reused foreign repository CI')
run['head_repository']['full_name'] = 'owner/repo'
runs = []
assert(ReleaseQuality.evidence('owner/repo').nil?, 'reused absent CI run')
runs = [run]
output, _, status = Open3.capture3(environment.merge('GITHUB_REPOSITORY' => 'owner/repo'), 'ruby', File.join(ROOT, '.github/scripts/release-quality.rb'))
assert(status.success? && output.strip == 'run_backend=true', 'API failure did not fall back to full checks')
File.write('backend/source.rs', '// changed source')
commit('source changed')
assert(ReleaseQuality.evidence('owner/repo').nil?, 'reused CI across source changes')
merge = git('commit-tree', 'HEAD^{tree}', '-p', 'HEAD', '-p', 'HEAD^', '-m', 'merge fixture')
git('update-ref', 'HEAD', merge)
assert(ReleaseQuality.evidence('owner/repo').nil?, 'merge commit should require full checks')
# 在同一个隔离仓库走实际提交与原子推送路径,dispatch 仍由本地替身接收
git('merge', '--no-edit', 'origin/main')
File.write('release/notes.md', "# v100.0.0\n\nPublish fixture notes\n")
dispatch = File.join(directory, 'dispatch.json')
mock = File.join(bin, 'gh')
File.write(mock, File.read(mock).sub("else\n", <<~RUBY))
when ['workflow', 'run', 'release.yml', '--repo', 'owner/repo', '--ref', 'main', '--raw-field', 'tag=v100.0.0']
File.write(#{dispatch.inspect}, ARGV.join(' '))
else
RUBY
output, error, status = Open3.capture3(environment, 'bash', 'release/publish', '100.0.0')
assert(status.success?, "publish failed: #{output}#{error}")
assert(File.exist?(dispatch), 'workflow was not dispatched')
assert(git('log', '-1', '--format=%s') == 'chore(release): v100.0.0', 'wrong release commit')
assert(git('status', '--porcelain').empty?, 'release left tracked changes')
assert(git('rev-parse', 'HEAD') == git('rev-parse', 'v100.0.0^{commit}'), 'tag differs from release commit')
assert(git('ls-remote', 'origin', 'refs/tags/v100.0.0').split.first == git('rev-parse', 'v100.0.0'), 'tag not pushed')
end
end
puts 'Release preflight and CI evidence checks passed'
+4
View File
@@ -62,6 +62,10 @@ module ReleaseMetadata
entries
end
def self.archive_name(version, platform)
"#{APP_NAME}_#{version}_#{platform.fetch('os')}_#{platform.fetch('arch')}.#{platform.fetch('archive')}"
end
def self.outputs(tag)
value = version(YAML.load_file('release/version.yaml').fetch('version'))
raise "Release tag #{tag} does not match v#{value}" unless tag == "v#{value}"
+1 -4
View File
@@ -125,7 +125,7 @@ jobs:
run: cargo clippy --all-targets --all-features --locked -- -D warnings
- name: Setup nextest
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
uses: taiki-e/install-action@3a3daafd3b7f23b8f65d9f5ba2db203fb59b79e2 # v2.90.2
with:
tool: cargo-nextest@0.9.143
fallback: none
@@ -259,6 +259,3 @@ jobs:
for script in .github/scripts/*.rb; do ruby -c "$script"; done
ruby .github/scripts/toolchains.rb
ruby -c backend/crates/gateway-plugin/runtime/plugin-api-lifecycle.rb
- name: Verify release preflight and CI evidence
run: ruby .github/scripts/release-checks-test.rb
+45 -26
View File
@@ -7,6 +7,10 @@ on:
description: 'Tag to release, for example v0.1.0'
required: true
type: string
index_only:
description: 'Recover the index for an already public release without rebuilding'
type: boolean
default: false
permissions:
contents: read
@@ -21,6 +25,7 @@ env:
jobs:
release-info:
if: ${{ !inputs.index_only }}
runs-on: ubuntu-26.04
timeout-minutes: 10
permissions:
@@ -110,7 +115,7 @@ jobs:
cache-bin: false
save-if: ${{ github.event_name != 'pull_request' }}
- name: Setup nextest
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
uses: taiki-e/install-action@3a3daafd3b7f23b8f65d9f5ba2db203fb59b79e2 # v2.90.2
with:
tool: cargo-nextest@0.9.143
fallback: none
@@ -239,8 +244,6 @@ jobs:
timeout-minutes: 20
permissions:
contents: read
id-token: write
attestations: write
steps:
- name: Checkout
@@ -274,11 +277,6 @@ jobs:
PLATFORM_MATRIX: ${{ needs.release-info.outputs.platform_matrix }}
run: ruby .github/scripts/package-release.rb archives
- name: Generate release asset provenance
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-path: dist/release/*
- name: Upload release assets
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
@@ -342,7 +340,7 @@ jobs:
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to GHCR
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -436,10 +434,10 @@ jobs:
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
uses: sigstore/cosign-installer@756fe2d284c7292c4e9b62c8a28f1e5629c59532 # v4.1.3
- name: Login to GHCR
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -510,24 +508,12 @@ jobs:
shell: bash
env:
INSTALLATION_NOTES: |
## 📥 安装
## Linux 一键安装(Docker Compose)
**Docker 部署:**
```bash
# 从 GitHub 容器镜像仓库拉取
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-info.outputs.docker_version }}
curl -fsSLO https://raw.githubusercontent.com/${{ github.repository }}/${{ needs.release-info.outputs.tag }}/deploy/install.sh && CPR_RELEASE_TAG=${{ needs.release-info.outputs.tag }} bash install.sh
```
请从本版本附件下载 `compose.yaml` 和 `config.example.yaml`。Compose 默认固定到本版本镜像;升级前请对比模板与已有配置。
**手动下载:**
请从下方附件中下载适合当前平台的归档包。
附件包含与当前宿主版本及提交绑定的 `plugin-release-manifest.json`,当前没有默认内置插件。
教学示例由独立的 `codex-proxy-plugins` 仓库维护和发布,不随宿主发行物分发。
发布归档提供 GitHub 构建产物证明。Docker 镜像附带软件物料清单(SBOM)、构建来源证明和 cosign 无密钥签名。
## 📚 文档
- [GitHub 仓库](https://github.com/${{ github.repository }})
@@ -540,9 +526,20 @@ jobs:
File.write(output, "#{notes}\n\n#{installation}\n")
RUBY
- name: Check that the release is unpublished
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.release-info.outputs.version }}
run: ruby .github/scripts/published-channels.rb check "$VERSION"
- name: Generate release asset provenance
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-path: dist/release/*
- name: Upload GitHub Release assets
id: release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
uses: softprops/action-gh-release@8a0cd651e5b2365bd640854a3fa5dfc70e4875f3 # v3.0.4
with:
tag_name: ${{ needs.release-info.outputs.tag }}
generate_release_notes: true
@@ -563,3 +560,25 @@ jobs:
DIGEST: ${{ steps.manifest.outputs.digest }}
RELEASE_ID: ${{ steps.release.outputs.id }}
run: ruby .github/scripts/publish-release.rb
- name: Publish verified channels to main
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.release-info.outputs.version }}
run: ruby .github/scripts/published-channels.rb publish "$VERSION"
recover-index:
if: inputs.index_only
runs-on: ubuntu-26.04
concurrency:
group: release-publication
queue: max
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Verify public assets and publish channels to main
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
run: ruby .github/scripts/published-channels.rb publish "$TAG"
+2 -2
View File
@@ -34,7 +34,7 @@ jobs:
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.sha }}
- name: Install cargo-audit
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
uses: taiki-e/install-action@3a3daafd3b7f23b8f65d9f5ba2db203fb59b79e2 # v2.90.2
with:
tool: cargo-audit@0.22.2
fallback: none
@@ -53,7 +53,7 @@ jobs:
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.sha }}
- name: Setup pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1
with:
package_json_file: frontend/package.json
+3
View File
@@ -17,6 +17,9 @@
/.runtime/
*.log
# Local planning documents
/docs/plans/
# Local configuration and secrets
/deploy/config.yaml
.env
+2 -1
View File
@@ -126,7 +126,8 @@ PR 不接受以下协作规则文件的新增、修改、删除或重命名:
- AI 进行 Rust 开发时,加载并使用 `$rust-best-practices` 技能。模块、可见性、依赖方向和测试目录按架构文档执行,使用现有 Rustfmt 与 Clippy 配置
- Vue 使用 TypeScript、`<script setup>` 和现有 ESLint 配置;API 请求通过已有模块及 `request` 封装,沿用逐行请求配置格式
- 业务数据的解析、校验和展示优先复用已有逻辑。新增抽象放在拥有该职责的模块,避免重复事实源或无实际需求的通用层
- 新增代码注释使用中文。提交信息使用英文 Conventional Commits,参考近期历史,按完整功能归并;确由 Codex 参与的提交保留 `Co-authored-by: Codex <noreply@openai.com>`
- 新增代码注释使用中文。提交信息使用英文 Conventional Commits,参考近期历史,按完整功能归并
- AI 参与的提交按实际助手添加 `Co-authored-by: 名称 <邮箱>`,名称与邮箱采用该助手已知的署名身份,并保留其他实际共同作者署名;PR 的 AI 使用披露不替代提交署名
- 发布说明写入 `release/notes.md`,标题首行为目标版本的 `# vX.Y.Z`,章节标题和正文使用中文;命令、配置键、协议名等保留原文。只说明本次版本的实际变化,按影响交代升级事项
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理
+7 -6
View File
@@ -17,7 +17,7 @@
</div>
> [!NOTE]
> 本项目提供 Responses API 和 Chat Completions API;Chat Completions 请求通过 Responses 链路执行,支持范围见 [API 参考](docs/api.md#chat-completions-请求)
> 生成请求支持 [OpenAI Responses](docs/api.md#responses-请求与传输)、[Chat Completions](docs/api.md#chat-completions-请求) 和 [Anthropic Messages](docs/api.md#messages-请求) 三种协议
## 快速预览
@@ -40,7 +40,8 @@
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL
Windows x64 / ARM64 使用发行包部署,步骤见 [Windows 二进制部署](deploy/README.md#windows-二进制部署)
macOS 从[发行页](https://github.com/zyycn/codex-proxy-rs/releases)下载对应的归档:Intel 机型选 `darwin_amd64`,Apple Silicon 机型选 `darwin_arm64`。
Windows x64 / ARM64 的安装步骤见 [Windows 二进制部署](deploy/README.md#windows-二进制部署)
已有部署请先看 [升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置
@@ -99,12 +100,12 @@ API Key 持有者可在登录页切换身份,查看自己的用量与额度,
完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)
**其他 Responses / Chat Completions API 客户端**:填写以下信息:
**其他 API 客户端**:API Key 填写管理端创建的客户端密钥。按客户端自动追加的请求路径填写 Base URL;远程接入时将本机地址换成服务器的 HTTPS 地址
| 配置 | 值 |
| 客户端自动追加的路径 | Base URL(本机部署) |
| --- | --- |
| Base URL | `http://127.0.0.1:8080/v1`;远程接入使用服务器的 HTTPS 地址 |
| API Key | 管理端创建的客户端密钥 |
| OpenAI Responses:`/responses`;Chat Completions:`/chat/completions` | `http://127.0.0.1:8080/v1` |
| Anthropic Messages:`/v1/messages` | `http://127.0.0.1:8080` |
查询该密钥可见的模型目录:
+84 -29
View File
@@ -102,6 +102,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arc-swap"
version = "1.9.2"
@@ -147,9 +153,9 @@ dependencies = [
[[package]]
name = "async-compression"
version = "0.4.51"
version = "0.4.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d5755640120b24961f8231e05892e4a4f7e0d04e8aeca161508c9c397946970"
checksum = "6ad47a740fb906fffdd891e8b6d5fde2511600f7bfaebd68939fa08a14999459"
dependencies = [
"compression-codecs",
"compression-core",
@@ -716,6 +722,17 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
[[package]]
name = "bstr"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f"
dependencies = [
"memchr",
"regex-automata",
"serde_core",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -942,9 +959,9 @@ dependencies = [
[[package]]
name = "compression-codecs"
version = "0.4.46"
version = "0.4.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe"
checksum = "84f840cff84d10085260e460023934d0af699e71212b09b2a41452e8bd9cda43"
dependencies = [
"compression-core",
"flate2",
@@ -966,7 +983,7 @@ dependencies = [
"pathdiff",
"serde_core",
"winnow 1.0.3",
"yaml-rust2",
"yaml-rust2 0.11.1",
]
[[package]]
@@ -1126,9 +1143,9 @@ checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
name = "crypto-bigint"
version = "0.7.6"
version = "0.7.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bb08299a6046833a879ace115697f12fd2bd6c8d3e93c909ffc9e0738e7cae0"
checksum = "914c195c4d7fbc5ed7f88d14cbf85914d5c664967658d43404b263913a477e82"
dependencies = [
"cpubits",
"ctutils",
@@ -1186,9 +1203,9 @@ dependencies = [
[[package]]
name = "ctutils"
version = "0.4.5"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0"
checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f"
dependencies = [
"cmov",
"subtle",
@@ -1773,9 +1790,11 @@ dependencies = [
"bytes",
"chrono",
"chrono-tz",
"cron",
"event-listener",
"futures",
"futures-timer",
"gateway-protocol",
"http",
"http-body-util",
"semver",
@@ -1834,6 +1853,7 @@ dependencies = [
"tracing-appender",
"tracing-subscriber",
"wiremock",
"yaml-rust2 0.13.0",
]
[[package]]
@@ -2064,6 +2084,15 @@ dependencies = [
"hashbrown 0.16.1",
]
[[package]]
name = "hashlink"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "headers"
version = "0.4.2"
@@ -2576,9 +2605,9 @@ dependencies = [
[[package]]
name = "libsqlite3-sys"
version = "0.37.0"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3"
dependencies = [
"pkg-config",
"vcpkg",
@@ -2935,15 +2964,14 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "openssl"
version = "0.10.75"
version = "0.10.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
dependencies = [
"bitflags",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
@@ -2982,9 +3010,9 @@ dependencies = [
[[package]]
name = "openssl-sys"
version = "0.9.111"
version = "0.9.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
dependencies = [
"cc",
"libc",
@@ -3333,6 +3361,7 @@ dependencies = [
"hyper",
"hyper-rustls",
"hyper-util",
"lru",
"native-tls",
"openssl",
"openssl-sys",
@@ -3351,6 +3380,7 @@ dependencies = [
"tar",
"tempfile",
"thiserror",
"tiktoken-rs",
"tokio",
"tokio-rustls",
"tokio-tungstenite 0.28.0",
@@ -4217,7 +4247,7 @@ dependencies = [
[[package]]
name = "sqlx"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"sqlx-core",
"sqlx-macros",
@@ -4229,7 +4259,7 @@ dependencies = [
[[package]]
name = "sqlx-core"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -4244,7 +4274,7 @@ dependencies = [
"futures-io",
"futures-util",
"hashbrown 0.16.1",
"hashlink",
"hashlink 0.11.1",
"indexmap",
"log",
"memchr",
@@ -4266,7 +4296,7 @@ dependencies = [
[[package]]
name = "sqlx-macros"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"proc-macro2",
"quote",
@@ -4278,7 +4308,7 @@ dependencies = [
[[package]]
name = "sqlx-macros-core"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"cfg-if",
"dotenvy",
@@ -4303,7 +4333,7 @@ dependencies = [
[[package]]
name = "sqlx-mysql"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"bitflags",
"byteorder",
@@ -4330,7 +4360,7 @@ dependencies = [
[[package]]
name = "sqlx-postgres"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"atoi",
"base64 0.22.1",
@@ -4366,7 +4396,7 @@ dependencies = [
[[package]]
name = "sqlx-sqlite"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"atoi",
"chrono",
@@ -4593,6 +4623,20 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "tiktoken-rs"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2aeff724640cfe13037336ddf35befdffd2909cbdb65cf041cc8a4cf8c584cfa"
dependencies = [
"anyhow",
"base64 0.22.1",
"bstr",
"fancy-regex",
"regex",
"rustc-hash",
]
[[package]]
name = "time"
version = "0.3.53"
@@ -5518,13 +5562,24 @@ dependencies = [
[[package]]
name = "yaml-rust2"
version = "0.11.0"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e"
checksum = "b36710ce3a279cfce8465dbab826f161675a262950b922cb2c3663852dfe9eb0"
dependencies = [
"arraydeque",
"encoding_rs",
"hashlink",
"hashlink 0.11.1",
]
[[package]]
name = "yaml-rust2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57e5b818a27a4cd30884ea380857a5e56f7ec3ba24a3990a3cc0b95af3238e18"
dependencies = [
"arraydeque",
"encoding_rs",
"hashlink 0.12.2",
]
[[package]]
@@ -5646,9 +5701,9 @@ dependencies = [
[[package]]
name = "zip"
version = "9.2.2"
version = "9.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e408c6cc4699dd017d53dc080cd51c2cea2b5354af57a1479bd265ed42911487"
checksum = "3cec733c9820347a592f6375c3c0089b479ba0a0b1cbf3dd1958b02959b7c033"
dependencies = [
"crc32fast",
"flate2",
+2 -2
View File
@@ -61,5 +61,5 @@ codegen-units = 1
panic = "abort"
[patch.crates-io]
# 固定上游 BEGIN 取消回滚修复,避免未结束事务复用;见 transact-rs/sqlx#4394
sqlx = { git = "https://github.com/transact-rs/sqlx", rev = "f1e94ec34dd5f11ad6488a3a8ac7988439a3f757" }
# 固定包含 BEGIN 取消回滚及 SQLite 安全版本支持的上游提交;见 transact-rs/sqlx#4394、#4502
sqlx = { git = "https://github.com/transact-rs/sqlx", rev = "10462fa4414c6c7f1ed1d39bb31678e3b726878c" }
@@ -7,6 +7,7 @@ use gateway_admin::{
ports::{
plugin_accounts::PluginAccountAccess, plugin_client_keys::PluginClientKeyAccess,
plugin_resources::PluginResourceAccess, provider::ProviderAdminRegistry,
proxy::PluginProxyAccess,
},
};
use gateway_core::runtime::{RuntimeSnapshotHandle, SnapshotControl};
@@ -35,6 +36,7 @@ pub(super) struct ManagementPorts {
pub(super) accounts: Arc<dyn PluginAccountAccess>,
keys: Arc<dyn PluginClientKeyAccess>,
resources: Arc<dyn PluginResourceAccess>,
proxies: Arc<dyn PluginProxyAccess>,
}
impl ManagementPorts {
@@ -46,6 +48,7 @@ impl ManagementPorts {
) -> Self {
let ports = store.admin_ports();
Self {
proxies: gateway_admin::initialize_plugin_proxies(ports.proxies()),
accounts: gateway_admin::initialize_plugin_accounts(
providers.clone(),
ports.accounts(),
@@ -66,6 +69,7 @@ impl ManagementPorts {
pub(super) fn bind(&self, runtime: &PluginRuntime) -> Result<(), AdminError> {
runtime.bind_account_ports(&self.accounts)?;
runtime.bind_proxy_ports(&self.proxies)?;
runtime.bind_client_key_ports(&self.keys)?;
runtime.bind_resource_ports(&self.resources)
}
@@ -128,9 +128,11 @@ pub async fn run() -> Result<(), BootstrapError> {
let mut probes = store.health_probes();
probes.extend(core.health_probes());
probes.push(host.logging_health_probe());
let peer_node = or_shutdown!(plugin_runtime, store.peer_routing_id().await);
let api = or_shutdown!(
plugin_runtime,
gateway_api::initialize(
peer_node,
config.api,
core.execution_service(),
admin.services(),
@@ -509,6 +509,7 @@ const ALLOWED_INTERNAL_EDGES: &[(&str, &str)] = &[
("gateway-api", "gateway-admin"),
("gateway-api", "gateway-core"),
("gateway-api", "gateway-protocol"),
("gateway-core", "gateway-protocol"),
("gateway-host", "gateway-admin"),
("gateway-host", "gateway-core"),
("gateway-host", "gateway-protocol"),
@@ -264,6 +264,14 @@ pub fn initialize_plugin_accounts(
))
}
/// 为 Runtime 创建代理目录与当前出口解析的窄端口
#[must_use]
pub fn initialize_plugin_proxies(
store: Arc<dyn ports::proxy::ProxyStore>,
) -> Arc<dyn ports::proxy::PluginProxyAccess> {
Arc::new(use_case::proxies::DefaultPluginProxyAccess { store })
}
/// 为 Runtime 创建非秘密 Client Key 目录与预算重置的窄端口
#[must_use]
pub fn initialize_plugin_client_keys(
+2 -2
View File
@@ -42,8 +42,8 @@ pub use use_case::{
pub use bootstrap::{
initialize, initialize_accounts, initialize_plugin_accounts, initialize_plugin_client_keys,
initialize_plugin_resources, initialize_settings, initialize_usage_data,
initialize_with_plugin_accounts,
initialize_plugin_proxies, initialize_plugin_resources, initialize_settings,
initialize_usage_data, initialize_with_plugin_accounts,
};
use ports::{
@@ -21,6 +21,8 @@ pub use gateway_core::account::{
/// 账号可用的管理操作
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct ProviderAccountCapabilities {
pub quota_limits_supported: bool,
pub quota_credits: bool,
pub quota: bool,
pub quota_refresh: bool,
pub profile: bool,
@@ -110,6 +112,9 @@ pub enum AccountGroupFilter {
/// 账号公共存储投影;Provider 专属字段不进入此结构
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountRecord {
pub quota_paused: bool,
pub quota_limits: gateway_core::account::AccountQuotaLimits,
pub auto_use_quota_credits: bool,
pub id: String,
pub provider_kind: ProviderKind,
pub groups: Vec<AccountGroupRef>,
@@ -249,6 +254,8 @@ pub struct AccountUpdateResult {
/// 仅修改显式字段的批量账号设置命令
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BatchUpdateAccounts {
pub quota_limits: Option<gateway_core::account::AccountQuotaLimits>,
pub auto_use_quota_credits: Option<bool>,
pub account_ids: Vec<String>,
pub enabled: Option<bool>,
pub concurrency_limit: Option<Option<AccountConcurrencyLimit>>,
@@ -258,6 +265,16 @@ pub struct BatchUpdateAccounts {
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
}
/// 已按拟提交策略重算的账号暂停结果,以快照版本防止旧判断覆盖新观测
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreparedQuotaPause {
pub observed_at: Option<DateTime<Utc>>,
pub revision: Revision,
pub paused: bool,
}
pub type PreparedQuotaPauses = std::collections::BTreeMap<String, PreparedQuotaPause>;
/// 批量账号更新结果
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountsUpdateResult {
@@ -290,6 +307,7 @@ pub enum AccountConnectionTestEvent {
Failed {
source: AccountProbeErrorSource,
gateway_error_code: GatewayErrorKind,
proxy_hint: Option<gateway_core::error::ProxyConnectionHint>,
send_state: Option<UpstreamSendState>,
message: String,
provider_error_code: Option<String>,
@@ -16,6 +16,7 @@ pub enum MutationAuditOperation {
AccountGroupEnabled { enabled: bool },
AccountGroupDelete,
ClientApiKeyResetBudget,
ClientApiKeySetBudgetWindow,
ProviderAccountUpdate,
BackupStorageUpdate,
BackupScheduleUpdate,
@@ -76,6 +77,7 @@ impl MutationAuditOperation {
}
Self::AccountGroupDelete => ("delete", "account_group"),
Self::ClientApiKeyResetBudget => ("reset_budget", "client_api_key"),
Self::ClientApiKeySetBudgetWindow => ("set_budget_window", "client_api_key"),
Self::ProviderAccountUpdate => ("update", "provider_account"),
Self::BackupStorageUpdate => ("backup.s3_config_updated", "backup_settings"),
Self::BackupScheduleUpdate => ("backup.schedule_updated", "backup_settings"),
@@ -233,13 +233,56 @@ pub enum ClientKeyBudgetPeriod {
All,
}
/// 清零所选窗口已用金额,保留限额、到期时间与历史费用
/// 清零所选窗口并恢复日历规则,保留限额与历史费用
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResetClientKeyBudget {
pub id: ClientApiKeyId,
pub period: ClientKeyBudgetPeriod,
}
/// 显式窗口复用已有日/周金额上限,外部周期的选择归调用方
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientKeyBudgetWindowPeriod {
Daily,
Weekly,
}
impl ClientKeyBudgetWindowPeriod {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Daily => "daily",
Self::Weekly => "weekly",
}
}
}
/// 按请求完成时间归属的左闭右开预算窗口
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExplicitClientKeyBudgetWindow {
pub starts_at: DateTime<Utc>,
pub ends_at: DateTime<Utc>,
}
/// 窗口配置版本独立于费用累计与全局配置版本
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientKeyBudgetWindow {
pub revision: u64,
pub explicit: bool,
pub starts_at: Option<DateTime<Utc>>,
pub ends_at: Option<DateTime<Utc>>,
pub used_usd: gateway_core::metering::Decimal,
}
/// 当前窗口版本标识一次操作,同版本同参数可重试;None 释放显式控制
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetClientKeyBudgetWindow {
pub id: ClientApiKeyId,
pub period: ClientKeyBudgetWindowPeriod,
pub expected_revision: u64,
pub window: Option<ExplicitClientKeyBudgetWindow>,
}
/// 仅更新指定金额上限;省略的周期保持不变,不修改已用金额或窗口
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateClientKeyBudgetLimits {
@@ -690,11 +690,12 @@ pub struct RequestMetricPoint {
pub costs: Vec<CurrencyCost>,
}
/// 账号池的统一五态统计
/// 账号池的状态统计
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct AccountPoolMetrics {
pub total: u64,
pub normal: u64,
pub quota_paused: u64,
pub quota_exhausted: u64,
pub rate_limited: u64,
pub disabled: u64,
@@ -804,10 +805,33 @@ pub struct DashboardObservation {
pub recent_requests: Vec<UsageListRecord>,
}
/// 根据已记录事实推导的错误位置,不表示责任归属
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UsageErrorOrigin {
Proxy,
Upstream,
Connection,
Unknown,
}
impl UsageErrorOrigin {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Proxy => "proxy",
Self::Upstream => "upstream",
Self::Connection => "connection",
Self::Unknown => "unknown",
}
}
}
/// 使用记录表格的窄读模型
#[derive(Debug, Clone, PartialEq)]
pub struct UsageListRecord {
pub outcome: RequestOutcome,
/// 已保存的上游错误对象含非空字符串 code、type 或 message
pub has_upstream_error: bool,
pub error_code: Option<String>,
pub error_message: Option<String>,
pub error_kind: Option<String>,
@@ -816,6 +840,7 @@ pub struct UsageListRecord {
pub upstream_connection_exit_reason: Option<String>,
pub recovered: bool,
pub client_status_code: Option<u16>,
pub upstream_status_code: Option<u16>,
pub client_api_key_name: Option<String>,
pub id: String,
pub endpoint: String,
@@ -1002,7 +1027,8 @@ pub struct UsageAttempt {
pub upstream_transport: Option<String>,
pub upstream_send_state: Option<String>,
pub outcome: RequestOutcome,
pub downstream_committed: bool,
/// 未记录尝试交付边界时保持未知,不以 false 代替
pub downstream_committed: Option<bool>,
pub status_code: Option<u16>,
pub provider_error_code: Option<String>,
pub failure_kind: Option<String>,
@@ -304,6 +304,12 @@ impl PendingAuthorizationMutation {
"outbound_proxy_url".to_owned(),
Value::String(proxy.expose_url().to_owned()),
);
if proxy.max_websocket_connections() != 0 {
document.insert(
"outbound_proxy_max_websocket_connections".to_owned(),
Value::from(proxy.max_websocket_connections()),
);
}
}
if let Some(id) = &self.outbound_proxy_id {
document.insert("outbound_proxy_id".to_owned(), Value::String(id.clone()));
@@ -344,6 +350,11 @@ impl PendingAuthorizationMutation {
.map(gateway_core::account::OutboundProxy::parse)
.transpose()
.map_err(|_| invalid())?;
let proxy = match (proxy, document.outbound_proxy_max_websocket_connections) {
(Some(proxy), Some(limit)) => Some(proxy.with_max_websocket_connections(limit)),
(None, Some(_)) => return Err(invalid()),
(proxy, None) => proxy,
};
Ok(Self::new(
provider_kind,
target,
@@ -367,6 +378,8 @@ struct StoredAuthorizationMutationV1 {
#[serde(default)]
outbound_proxy_url: Option<String>,
#[serde(default)]
outbound_proxy_max_websocket_connections: Option<u32>,
#[serde(default)]
outbound_proxy_id: Option<String>,
}
@@ -832,6 +845,7 @@ pub struct ProviderQuotaReadRequest<'a> {
/// Provider 已解析的 quota 结果及其不透明差异字段
#[derive(Debug, Default, Clone, PartialEq)]
pub struct ProviderQuota {
pub paused_windows: Vec<String>,
pub upstream: Option<super::upstream_info::ProviderUpstreamInfo>,
/// 上游额度响应明确提供的套餐,可用于补全账号展示
pub plan_type: Option<String>,
@@ -839,7 +853,7 @@ pub struct ProviderQuota {
pub refresh_token_expires_at: Option<DateTime<Utc>>,
pub windows: Vec<ProviderQuotaWindow>,
pub credits: Option<ProviderQuotaCredits>,
/// 展示用快照级触顶事实(顶层或任一窗口触顶);不参与账号五态派生
/// 展示用快照级触顶事实(顶层或任一窗口触顶);不参与账号状态派生
pub limit_reached: bool,
pub provider_data: Option<ProviderDocument>,
}
@@ -19,6 +19,9 @@ pub struct PendingResetCredit {
pub struct ResetCreditAutomationState {
pub next_discovery_at: DateTime<Utc>,
pub credits: Vec<PlannedResetCredit>,
pub next_weekly_check_at: Option<DateTime<Utc>>,
/// 同一次低额度只消费一次,等待消费后的恢复观测再允许提前用卡
pub awaiting_weekly_recovery_since: Option<DateTime<Utc>>,
pub pending: Option<PendingResetCredit>,
pub retry_at: Option<DateTime<Utc>>,
pub failures: u32,
@@ -39,6 +42,7 @@ pub struct ResetCreditAutomation {
pub struct ResetCreditAutomationStatus {
pub enabled: bool,
pub lead_minutes: u32,
pub weekly_trigger_remaining_percent: Option<u32>,
pub next_discovery_at: Option<DateTime<Utc>>,
pub next_consume_at: Option<DateTime<Utc>>,
pub last_result: Option<String>,
@@ -53,7 +53,9 @@ pub struct RuntimeSettingsValues {
pub max_concurrent_per_account: u32,
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub key_queue_timeout_seconds: u32,
pub account_queue_timeout_seconds: u32,
pub switch_account_on_queue_failure: bool,
pub max_retry_after_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub openai_account_affinity: gateway_core::account::AccountAffinity,
@@ -68,8 +70,10 @@ pub struct RuntimeSettingsValues {
pub audit_retention_days: u32,
pub account_error_protection: gateway_core::account::AccountErrorProtection,
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_cron: String,
pub account_warmup_model: Option<String>,
#[serde(default)]
pub reset_credit_on_low_weekly_quota: bool,
pub reset_credit_lead_minutes: u32,
pub reset_credit_max_weekly_remaining_percent: Option<u32>,
}
@@ -201,7 +205,8 @@ impl RuntimeSettingsValues {
gateway_core::settings::validate_request_limits(
self.max_waiting_per_key,
self.max_waiting_per_account,
self.concurrency_wait_timeout_seconds,
self.key_queue_timeout_seconds,
self.account_queue_timeout_seconds,
self.max_account_rotations,
self.openai_session_affinity_ttl_hours,
)?;
@@ -230,7 +235,7 @@ impl RuntimeSettingsValues {
self.account_error_protection.validate()?;
gateway_core::provider_ports::ProviderWarmupPolicy::validate_values(
self.account_warmup_enabled,
&self.account_warmup_schedule_time,
&self.account_warmup_cron,
self.account_warmup_model.as_deref(),
)?;
Ok(())
@@ -3,6 +3,14 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// 摘要只读取发布索引,完整详情可按请求刷新
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SystemUpdateCheck {
Summary,
Detail,
Refresh,
}
/// 本次检查或安装选择的更新稳定性范围,具体版本准入由 Host 判定
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
@@ -69,6 +77,7 @@ pub struct SystemUpdateDetail {
pub update_supported: bool,
pub unsupported_reason: Option<String>,
pub warning: Option<String>,
pub retry_at: Option<DateTime<Utc>>,
}
/// 系统操作类型
@@ -5,13 +5,28 @@ use gateway_core::{engine::budget::ClientBudgetStatus, policy::ClientApiKeyId};
use crate::model::{
AdminError, MutationContext,
client_keys::{ResetClientKeyBudget, UpdateClientKeyBudgetLimits},
client_keys::{
ClientKeyBudgetWindow, ClientKeyBudgetWindowPeriod, ResetClientKeyBudget,
SetClientKeyBudgetWindow, UpdateClientKeyBudgetLimits,
},
plugin_client_keys::{PluginClientKeyFacts, PluginClientKeyListQuery, PluginClientKeyPage},
plugin_resources::PluginResourceOwner,
};
#[async_trait]
pub trait PluginClientKeyAccess: Send + Sync {
async fn budget_window(
&self,
id: &ClientApiKeyId,
period: ClientKeyBudgetWindowPeriod,
) -> Result<ClientKeyBudgetWindow, AdminError>;
async fn set_budget_window(
&self,
owner: &PluginResourceOwner,
command: SetClientKeyBudgetWindow,
context: &MutationContext,
) -> Result<u64, AdminError>;
async fn facts(&self, id: &ClientApiKeyId) -> Result<PluginClientKeyFacts, AdminError>;
async fn budget(&self, id: &ClientApiKeyId) -> Result<ClientBudgetStatus, AdminError>;
@@ -5,13 +5,20 @@ use gateway_core::account::{OutboundProxy, ProviderAccountId};
use super::store::AdminStoreResult;
use crate::model::{
MutationContext, Revision,
AdminError, MutationContext, Revision,
proxies::{
ImportProxyBinding, NewProxy, ProxyAccountListQuery, ProxyAccountPage, ProxyListQuery,
ProxyMutation, ProxyPage, ProxyRecord, ProxyTestResult, UpdateProxy,
},
};
/// 插件受管网络读取代理的窄端口,连接配置仅供宿主发送请求
#[async_trait]
pub trait PluginProxyAccess: Send + Sync {
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError>;
async fn resolve(&self, id: &str) -> Result<OutboundProxy, AdminError>;
}
#[async_trait]
pub trait ProxyStore: Send + Sync {
/// 在凭据交换到提交期间保护选定代理的连接配置和测试结果
@@ -22,8 +22,9 @@ use crate::model::{
},
auth::{AdminAuditEvent, AuthSession},
client_keys::{
ClientKeyBudgetMutationOrigin, ClientKeyListQuery, ClientKeyPage, ClientKeyRecord,
ClientKeySecret, DeleteClientKey, NewClientKey, ResetClientKeyBudget, SetClientKeyEnabled,
ClientKeyBudgetMutationOrigin, ClientKeyBudgetWindow, ClientKeyBudgetWindowPeriod,
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
NewClientKey, ResetClientKeyBudget, SetClientKeyBudgetWindow, SetClientKeyEnabled,
UpdateClientKey, UpdateClientKeyBudgetLimits,
},
observability::{
@@ -254,6 +255,7 @@ pub trait AccountStore:
async fn batch_update_accounts(
&self,
command: BatchUpdateAccounts,
pauses: crate::model::accounts::PreparedQuotaPauses,
context: &MutationContext,
) -> AdminStoreResult<AccountsUpdateResult>;
@@ -365,6 +367,19 @@ pub trait AuthStore: Send + Sync {
/// Client API Key 资料读取与管理写入
#[async_trait]
pub trait ClientKeyStore: Send + Sync {
async fn client_key_budget_window(
&self,
id: &gateway_core::policy::ClientApiKeyId,
period: ClientKeyBudgetWindowPeriod,
) -> AdminStoreResult<ClientKeyBudgetWindow>;
async fn set_client_key_budget_window(
&self,
command: SetClientKeyBudgetWindow,
origin: ClientKeyBudgetMutationOrigin,
context: &MutationContext,
) -> AdminStoreResult<u64>;
/// 按已验证的 ID 读取资料,不读取完整明文 Key
async fn get_client_key(
&self,
@@ -3,12 +3,13 @@
use std::{pin::Pin, sync::Arc};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use futures::Stream;
use crate::model::Revision;
use crate::model::system::{
SystemOperationAccepted, SystemUpdateChannel, SystemUpdateDetail, SystemUpdateEvent,
SystemUpdateStatus, SystemVersion,
SystemOperationAccepted, SystemUpdateChannel, SystemUpdateCheck, SystemUpdateDetail,
SystemUpdateEvent, SystemUpdateStatus, SystemVersion,
};
/// Host 已校验并解包的同一更新候选;字节只含发行清单,不含运行配置或凭据
@@ -43,6 +44,7 @@ pub enum SystemOperationErrorKind {
pub struct SystemOperationError {
kind: SystemOperationErrorKind,
message: String,
retry_at: Option<DateTime<Utc>>,
source: Option<gateway_core::error::ErrorSource>,
sensitive_context_redacted: bool,
}
@@ -53,11 +55,23 @@ impl SystemOperationError {
Self {
kind,
message: message.into(),
retry_at: None,
source: None,
sensitive_context_redacted: false,
}
}
#[must_use]
pub fn with_retry_at(mut self, retry_at: DateTime<Utc>) -> Self {
self.retry_at = Some(retry_at);
self
}
#[must_use]
pub const fn retry_at(&self) -> Option<DateTime<Utc>> {
self.retry_at
}
#[must_use]
pub fn with_source(mut self, source: impl Into<gateway_core::error::ErrorSource>) -> Self {
self.source = Some(source.into());
@@ -135,7 +149,7 @@ pub trait SystemOperations: Send + Sync {
async fn update_detail(
&self,
refresh: bool,
check: SystemUpdateCheck,
channel: Option<SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, SystemOperationError>;
@@ -759,6 +759,8 @@ impl AccountsService for DefaultAccountsService {
self.accounts
.batch_update_accounts(
BatchUpdateAccounts {
quota_limits: None,
auto_use_quota_credits: None,
account_ids: vec![account_id.to_string()],
enabled: Some(true),
concurrency_limit: None,
@@ -767,6 +769,7 @@ impl AccountsService for DefaultAccountsService {
group_ids: None,
outbound_proxy: None,
},
Default::default(),
context,
)
.await
@@ -837,23 +840,80 @@ impl AccountsService for DefaultAccountsService {
(
std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>,
Vec<ProviderAccountId>,
Vec<ProviderAccountId>,
),
>::new();
let mut pauses = BTreeMap::new();
for account_id in &account_ids {
let (item, provider) = self.provider_for_account(account_id).await?;
providers
let (mut item, provider) = self.provider_for_account(account_id).await?;
if let Some(limits) = &command.quota_limits {
if !provider
.account_capabilities(account_id, &item.account.authentication_kind)
.quota_limits_supported
{
return Err(AdminError::invalid("该账号不支持通用额度窗口上限"));
}
let new_keys = limits
.keys()
.filter(|key| !item.account.quota_limits.contains(key))
.map(str::to_owned)
.collect::<Vec<_>>();
if !limits.is_empty() {
item.account.quota_limits = limits.clone();
let quota = provider
.read_quotas(&[ProviderQuotaReadRequest {
account: &item.account,
rolling_usage: None,
}])
.await
.into_iter()
.next()
.ok_or_else(|| AdminError::invalid("缺少账号额度观测结果"))?
.map_err(|error| map_provider_error(error, "provider quota"))?;
if new_keys.iter().any(|key| {
!quota.windows.iter().any(|window| {
window.key == *key
&& window.local_usage_attribution
== QuotaLocalUsageAttribution::AccountWide
})
}) {
return Err(AdminError::invalid("只能设置已观测的通用额度窗口"));
}
pauses.insert(
item.account.id.clone(),
crate::model::accounts::PreparedQuotaPause {
revision: item.account.credential_revision,
observed_at: quota.observed_at,
paused: !quota.paused_windows.is_empty(),
},
);
}
}
if command.auto_use_quota_credits.is_some()
&& !provider
.account_capabilities(account_id, &item.account.authentication_kind)
.quota_credits
{
return Err(AdminError::invalid("该账号不支持使用额度点数"));
}
let (_, selected, exhausted) = providers
.entry(item.account.provider_kind)
.or_insert_with(|| (provider, Vec::new()))
.1
.push(account_id.clone());
.or_insert_with(|| (provider, Vec::new(), Vec::new()));
selected.push(account_id.clone());
if command.auto_use_quota_credits == Some(true)
&& !item.account.auto_use_quota_credits
&& item.account.quota.is_exhausted()
{
exhausted.push(account_id.clone());
}
}
let enabled = command.enabled;
let result = self
.accounts
.batch_update_accounts(command, context)
.batch_update_accounts(command, pauses, context)
.await
.map_err(|error| map_store_error(error, "provider accounts"))?;
for (provider, provider_ids) in providers.values() {
for (provider, provider_ids, _) in providers.values() {
if enabled == Some(false) {
for account_id in provider_ids {
super::notify_account_unavailable(
@@ -872,6 +932,18 @@ impl AccountsService for DefaultAccountsService {
.await;
}
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
for (provider, _, exhausted) in providers.into_values() {
if exhausted.is_empty() {
continue;
}
super::credential_mutation::observe_account_quotas(
self.upstream_info.as_ref(),
&provider,
&self.accounts,
&exhausted,
context.request_id.as_str(),
);
}
Ok(result)
}
@@ -1188,6 +1260,7 @@ impl AccountsService for DefaultAccountsService {
vec![AccountConnectionTestEvent::Failed {
source: error.source(),
gateway_error_code: error.kind(),
proxy_hint: error.proxy_hint(),
send_state: error.send_state(),
message,
provider_error_code: error.client_error_code().map(ToOwned::to_owned),
@@ -2,12 +2,66 @@
use super::*;
use crate::model::{MutationActor, reset_credit_automation::*};
use crate::model::{MutationActor, reset_credit_automation::*, settings::RuntimeSettingsValues};
use chrono::{DateTime, Utc};
use gateway_core::time::DeploymentTimeZone;
use rand_core::{OsRng, RngCore as _};
use sha2::{Digest as _, Sha256};
#[derive(Clone, Copy)]
enum Trigger {
Expiry {
lead: u32,
max_remaining: Option<u32>,
},
Weekly {
max_remaining: u32,
},
}
impl Trigger {
fn from_settings(settings: &RuntimeSettingsValues) -> Self {
if settings.reset_credit_on_low_weekly_quota {
Self::Weekly {
max_remaining: settings
.reset_credit_max_weekly_remaining_percent
.unwrap_or(0),
}
} else {
Self::Expiry {
lead: settings.reset_credit_lead_minutes,
max_remaining: settings.reset_credit_max_weekly_remaining_percent,
}
}
}
fn weekly_trigger(self) -> Option<u32> {
match self {
Self::Expiry { .. } => None,
Self::Weekly { max_remaining } => Some(max_remaining),
}
}
fn quota_limit(self) -> Option<u32> {
match self {
Self::Expiry { max_remaining, .. } => max_remaining,
Self::Weekly { max_remaining } => Some(max_remaining),
}
}
fn eligible(self, remaining: f64, limit: u32) -> bool {
// 周额度模式即使配置 100% 也不消耗卡片重置满额窗口
match self {
Self::Expiry { .. } => remaining <= f64::from(limit),
Self::Weekly { .. } => weekly_low(remaining, limit),
}
}
fn expiry_due(self, account_id: &str, credit: &PlannedResetCredit, now: DateTime<Utc>) -> bool {
matches!(self, Self::Expiry { lead, .. } if consume_at(account_id, credit, lead) <= now)
}
}
impl DefaultAccountsService {
pub(super) async fn reset_credits_upstream(
&self,
@@ -74,7 +128,9 @@ impl DefaultAccountsService {
account_id: &ProviderAccountId,
) -> Result<ResetCreditAutomationStatus, AdminError> {
self.require_reset_credit_automation(account_id).await?;
let lead_minutes = self.automation_settings().await?.reset_credit_lead_minutes;
let settings = self.automation_settings().await?;
let lead_minutes = settings.reset_credit_lead_minutes;
let weekly_trigger_remaining_percent = Trigger::from_settings(&settings).weekly_trigger();
let record = self
.accounts
.reset_credit_automation(account_id.as_str())
@@ -83,20 +139,23 @@ impl DefaultAccountsService {
Ok(ResetCreditAutomationStatus {
enabled: record.as_ref().is_some_and(|r| r.enabled),
lead_minutes,
weekly_trigger_remaining_percent,
next_discovery_at: record
.as_ref()
.filter(|r| r.enabled)
.map(|r| r.state.next_discovery_at),
next_consume_at: record.as_ref().filter(|r| r.enabled).and_then(|r| {
next_consumption(r, lead_minutes)
.or_else(|| {
r.state
.pending
.as_ref()
.map(|pending| consume_at(&r.account_id, &pending.credit, lead_minutes))
})
.map(|due| r.state.retry_at.map_or(due, |retry| due.max(retry)))
}),
next_consume_at: record
.as_ref()
.filter(|r| r.enabled && weekly_trigger_remaining_percent.is_none())
.and_then(|r| {
next_consumption(r, lead_minutes)
.or_else(|| {
r.state.pending.as_ref().map(|pending| {
consume_at(&r.account_id, &pending.credit, lead_minutes)
})
})
.map(|due| r.state.retry_at.map_or(due, |retry| due.max(retry)))
}),
last_result: record.and_then(|r| {
if r.state
.pending
@@ -104,6 +163,15 @@ impl DefaultAccountsService {
.is_some_and(|pending| pending.credit.expires_at <= Utc::now())
{
Some("expired_unknown".to_owned())
} else if r.enabled
&& weekly_trigger_remaining_percent.is_some()
&& r.state.awaiting_weekly_recovery_since.is_some()
&& matches!(
r.state.last_result.as_deref(),
Some("reset" | "already_redeemed")
)
{
Some("awaiting_weekly_recovery".to_owned())
} else {
r.state.last_result
}
@@ -145,6 +213,8 @@ impl DefaultAccountsService {
revision: 0,
next_run_at: now,
state: ResetCreditAutomationState {
next_weekly_check_at: None,
awaiting_weekly_recovery_since: None,
next_discovery_at: now,
credits: vec![],
pending: None,
@@ -160,8 +230,8 @@ impl DefaultAccountsService {
record.state.next_discovery_at =
now + chrono::Duration::seconds(i64::from(OsRng.next_u32() % 60));
}
let lead = self.automation_settings().await?.reset_credit_lead_minutes;
reschedule(&mut record, lead, now);
let trigger = Trigger::from_settings(&self.automation_settings().await?);
reschedule(&mut record, trigger, now);
self.save_automation(&mut record, Some(context)).await?;
let settings = self
.settings
@@ -246,7 +316,7 @@ impl DefaultAccountsService {
self.observe_reset_credits(&mut record, credits, now)?;
reschedule(
&mut record,
self.automation_settings().await?.reset_credit_lead_minutes,
Trigger::from_settings(&self.automation_settings().await?),
now,
);
self.save_automation(&mut record, None).await
@@ -255,18 +325,16 @@ impl DefaultAccountsService {
async fn automation_failed(
&self,
record: &mut ResetCreditAutomation,
lead: u32,
trigger: Trigger,
now: DateTime<Utc>,
) -> Result<(), AdminError> {
if record.state.pending.is_none()
&& !next_consumption(record, lead).is_some_and(|due| due <= now)
{
if record.state.pending.is_none() && record.state.retry_at.is_none() {
record.state.next_discovery_at =
next_discovery(now, self.timezone, OsRng.next_u64())
.ok_or_else(|| AdminError::internal("无法计算重置卡发现时间"))?;
record.state.retry_at = None;
record.state.last_result = Some("discovery_failed".to_owned());
reschedule(record, lead, now);
reschedule(record, trigger, now);
return self.save_automation(record, None).await;
}
record.state.failures = record.state.failures.saturating_add(1);
@@ -281,7 +349,7 @@ impl DefaultAccountsService {
}
.to_owned(),
);
reschedule(record, lead, now);
reschedule(record, trigger, now);
self.save_automation(record, None).await
}
@@ -289,7 +357,7 @@ impl DefaultAccountsService {
async fn defer_automation(
&self,
record: &mut ResetCreditAutomation,
lead: u32,
trigger: Trigger,
reason: &str,
) -> Result<(), AdminError> {
let now = Utc::now();
@@ -305,38 +373,24 @@ impl DefaultAccountsService {
);
record.state.last_result = Some(reason.to_owned());
record.state.failures = 0;
reschedule(record, lead, now);
reschedule(record, trigger, now);
self.save_automation(record, None).await
}
async fn weekly_reset_credit_gate(
async fn reset_credit_quota(
&self,
account_id: &ProviderAccountId,
max_remaining: u32,
) -> Result<Option<&'static str>, AdminError> {
refresh: bool,
) -> Result<ProviderQuota, AdminError> {
let (_, provider) = self.provider_for_account(account_id).await?;
let quota = provider
provider
.quota(ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: true,
refresh,
rolling_usage: None,
})
.await
.map_err(|error| map_provider_error(error, "reset-credit weekly quota"))?;
// 复用账号周窗口归属,不把短期、月度或模型专属桶当作剩余周额度
let remaining = quota
.usage_windows()
.find(|(_, period)| *period == AccountUsagePeriod::Weekly)
.and_then(|(window, _)| window.used_percent)
.filter(|used| used.is_finite())
.map(|used| 100.0 - used.clamp(0.0, 100.0));
Ok(match remaining {
Some(remaining) if remaining > f64::from(max_remaining) => {
Some("weekly_quota_above_threshold")
}
Some(_) => None,
None => Some("weekly_quota_unavailable"),
})
.map_err(|error| map_provider_error(error, "reset-credit weekly quota"))
}
pub(super) async fn reconcile_automation(
@@ -368,7 +422,7 @@ impl DefaultAccountsService {
}
let now = Utc::now();
let settings = self.automation_settings().await?;
let lead = settings.reset_credit_lead_minutes;
let trigger = Trigger::from_settings(&settings);
record
.state
.credits
@@ -382,18 +436,51 @@ impl DefaultAccountsService {
record.state.pending = None;
record.state.retry_at = None;
record.state.last_result = Some("expired_unknown".to_owned());
record.state.awaiting_weekly_recovery_since = Some(now);
}
if record.state.retry_at.is_some_and(|retry| retry > now) {
reschedule(&mut record, lead, now);
reschedule(&mut record, trigger, now);
return self.save_automation(&mut record, None).await;
}
let context = MutationContext {
actor: MutationActor::System,
request_id: "reset-credit-automation".to_owned(),
};
let due_credit = next_consumption(&record, lead).is_some_and(|due| due <= now);
let due_credit = record
.state
.credits
.iter()
.any(|credit| trigger.expiry_due(account_id.as_str(), credit, now));
let early_limit = trigger.weekly_trigger();
// 错峰读取本地快照,等待恢复期间不因卡片用完而漏过下一轮额度
record.state.next_weekly_check_at = early_limit
.map(|_| now + chrono::Duration::seconds(60 + i64::from(OsRng.next_u32() % 30)));
let mut early_due = false;
if let Some(limit) = early_limit
&& record.state.pending.is_none()
&& needs_weekly_observation(&record.state)
{
match self.reset_credit_quota(account_id, false).await {
Ok(quota) => {
observe_weekly_recovery(&mut record.state, &quota, limit);
early_due = !record.state.credits.is_empty()
&& record.state.awaiting_weekly_recovery_since.is_none()
&& weekly_remaining(&quota)
.is_some_and(|remaining| trigger.eligible(remaining, limit));
}
Err(error) => {
// 额度读取失败不能阻塞独立的卡片发现计划
tracing::warn!(
account_id = account_id.as_str(),
error_kind = error.kind().as_str(),
"重置卡周额度快照读取失败"
);
}
}
}
if record.state.pending.is_none()
&& (due_credit
|| early_due
|| record.state.next_discovery_at <= now
|| record.state.retry_at.is_some())
{
@@ -403,10 +490,10 @@ impl DefaultAccountsService {
next_discovery(now, self.timezone, OsRng.next_u64())
.ok_or_else(|| AdminError::internal("无法计算重置卡发现时间"))?;
}
if due_credit || record.state.retry_at.is_some() {
if due_credit || early_due || record.state.retry_at.is_some() {
record.state.retry_at = Some(now + chrono::Duration::seconds(30));
}
reschedule(&mut record, lead, now);
reschedule(&mut record, trigger, now);
self.save_automation(&mut record, None).await?;
// 真正消费前重新核对卡片;每日发现结果也可直接承担本次核对
match self
@@ -415,7 +502,7 @@ impl DefaultAccountsService {
{
Ok(credits) => self.observe_reset_credits(&mut record, &credits, Utc::now())?,
Err(error) => {
self.automation_failed(&mut record, lead, Utc::now())
self.automation_failed(&mut record, trigger, Utc::now())
.await?;
return Err(error);
}
@@ -424,24 +511,28 @@ impl DefaultAccountsService {
.state
.credits
.iter()
.find(|credit| consume_at(account_id.as_str(), credit, lead) <= Utc::now())
.find(|credit| {
early_due || trigger.expiry_due(account_id.as_str(), credit, Utc::now())
})
.cloned()
{
if let Some(max_remaining) = settings.reset_credit_max_weekly_remaining_percent {
// 已发出的未决请求跳过此检查,继续沿原幂等键确认结果
match self
.weekly_reset_credit_gate(account_id, max_remaining)
.await
{
Ok(Some(reason)) => {
return self.defer_automation(&mut record, lead, reason).await;
}
Ok(None) => {}
if let Some(limit) = trigger.quota_limit() {
// 缓存只能唤醒检查,实际选中的卡片与额度都要重新核实
let quota = match self.reset_credit_quota(account_id, true).await {
Ok(quota) => quota,
Err(error) => {
self.defer_automation(&mut record, lead, "weekly_quota_unavailable")
self.defer_automation(&mut record, trigger, "weekly_quota_unavailable")
.await?;
return Err(error);
}
};
let reason = match weekly_remaining(&quota) {
Some(remaining) if trigger.eligible(remaining, limit) => None,
Some(_) => Some("weekly_quota_above_threshold"),
None => Some("weekly_quota_unavailable"),
};
if let Some(reason) = reason {
return self.defer_automation(&mut record, trigger, reason).await;
}
}
// 刷新额度期间卡片可能到期,不能把已过期卡片持久化为待消费
@@ -450,7 +541,7 @@ impl DefaultAccountsService {
.state
.credits
.retain(|credit| credit.expires_at > Utc::now());
reschedule(&mut record, lead, Utc::now());
reschedule(&mut record, trigger, Utc::now());
return self.save_automation(&mut record, None).await;
}
record.state.pending = Some(PendingResetCredit {
@@ -459,13 +550,13 @@ impl DefaultAccountsService {
});
record.state.last_result = Some("pending".to_owned());
record.state.retry_at = Some(Utc::now() + chrono::Duration::seconds(30));
reschedule(&mut record, lead, Utc::now());
reschedule(&mut record, trigger, Utc::now());
// 必须在发送消费请求前落盘,进程退出或响应丢失继续使用相同幂等键
self.save_automation(&mut record, None).await?;
}
}
let Some(pending) = record.state.pending.clone() else {
reschedule(&mut record, lead, Utc::now());
reschedule(&mut record, trigger, Utc::now());
return self.save_automation(&mut record, None).await;
};
let command = ConsumeProviderResetCredit {
@@ -478,9 +569,12 @@ impl DefaultAccountsService {
Ok(result) => {
let confirmed = matches!(result.code.as_str(), "reset" | "already_redeemed");
record.state.pending = None;
if confirmed {
record.state.awaiting_weekly_recovery_since = Some(Utc::now());
}
if result.code == "nothing_to_reset" {
return self
.defer_automation(&mut record, lead, "nothing_to_reset")
.defer_automation(&mut record, trigger, "nothing_to_reset")
.await;
}
record
@@ -494,15 +588,20 @@ impl DefaultAccountsService {
} else {
"not_redeemed".to_owned()
});
reschedule(&mut record, lead, Utc::now());
reschedule(&mut record, trigger, Utc::now());
// 已确认的结果先落盘,再刷新额度;观测失败不能重放已完成的消费
self.save_automation(&mut record, None).await?;
if confirmed {
let _ = self.quota(account_id, true).await;
if confirmed
&& let Ok(account) = self.quota(account_id, true).await
&& let Some(limit) = early_limit
&& observe_weekly_recovery(&mut record.state, &account.quota, limit)
{
reschedule(&mut record, trigger, Utc::now());
self.save_automation(&mut record, None).await?;
}
}
Err(error) => {
self.automation_failed(&mut record, lead, Utc::now())
self.automation_failed(&mut record, trigger, Utc::now())
.await?;
return Err(error);
}
@@ -511,6 +610,42 @@ impl DefaultAccountsService {
}
}
/// 复用账号周窗口归属,忽略短期、模型专属和过期窗口
fn weekly_remaining(quota: &ProviderQuota) -> Option<f64> {
quota
.usage_windows()
.find(|(window, period)| {
*period == AccountUsagePeriod::Weekly
&& window.reset_at.is_some_and(|end| end > Utc::now())
})
.and_then(|(window, _)| window.used_percent)
.filter(|used| used.is_finite())
.map(|used| 100.0 - used.clamp(0.0, 100.0))
}
fn weekly_low(remaining: f64, limit: u32) -> bool {
remaining < 100.0 && remaining <= f64::from(limit)
}
fn needs_weekly_observation(state: &ResetCreditAutomationState) -> bool {
!state.credits.is_empty() || state.awaiting_weekly_recovery_since.is_some()
}
fn observe_weekly_recovery(
state: &mut ResetCreditAutomationState,
quota: &ProviderQuota,
limit: u32,
) -> bool {
if let Some(since) = state.awaiting_weekly_recovery_since
&& quota.observed_at.is_some_and(|at| at > since)
&& weekly_remaining(quota).is_some_and(|remaining| !weekly_low(remaining, limit))
{
state.awaiting_weekly_recovery_since = None;
return true;
}
false
}
/// 当地次日随机发现时间;随机数由调用者提供,结果持久化后不会因重启重抽
fn next_discovery(
now: DateTime<Utc>,
@@ -550,14 +685,21 @@ fn next_consumption(record: &ResetCreditAutomation, lead_minutes: u32) -> Option
.min()
}
fn reschedule(record: &mut ResetCreditAutomation, lead_minutes: u32, now: DateTime<Utc>) {
fn reschedule(record: &mut ResetCreditAutomation, trigger: Trigger, now: DateTime<Utc>) {
record.next_run_at = if let Some(retry) = record.state.retry_at {
retry
} else if record.state.pending.is_some() {
now
} else {
next_consumption(record, lead_minutes).map_or(record.state.next_discovery_at, |due| {
due.min(record.state.next_discovery_at)
let scheduled = match trigger {
Trigger::Expiry { lead, .. } => next_consumption(record, lead),
Trigger::Weekly { .. } if needs_weekly_observation(&record.state) => {
Some(record.state.next_weekly_check_at.unwrap_or(now))
}
Trigger::Weekly { .. } => None,
};
scheduled.map_or(record.state.next_discovery_at, |at| {
at.min(record.state.next_discovery_at)
})
};
}
@@ -14,10 +14,12 @@ use crate::{
model::{
AdminError, MutationContext,
client_keys::{
ClientKeyBudgetMutationOrigin, ClientKeyCursorValue, ClientKeyListQuery,
ClientKeyMutation, ClientKeyPage, ClientKeyRecord, ClientKeySecret, ClientKeySortField,
CreateClientKey, CreatedClientKey, DeleteClientKey, NewClientKey, ResetClientKeyBudget,
SetClientKeyEnabled, UpdateClientKey, UpdateClientKeyBudgetLimits,
ClientKeyBudgetMutationOrigin, ClientKeyBudgetWindow, ClientKeyBudgetWindowPeriod,
ClientKeyCursorValue, ClientKeyListQuery, ClientKeyMutation, ClientKeyPage,
ClientKeyRecord, ClientKeySecret, ClientKeySortField, CreateClientKey,
CreatedClientKey, DeleteClientKey, NewClientKey, ResetClientKeyBudget,
SetClientKeyBudgetWindow, SetClientKeyEnabled, UpdateClientKey,
UpdateClientKeyBudgetLimits,
},
},
ports::store::{AdminStoreError, AdminStoreErrorKind, ClientKeyStore},
@@ -28,6 +30,18 @@ use super::{map_store_error, publish_committed};
/// API 消费的 Client Key 管理服务
#[async_trait]
pub trait ClientKeyService: Send + Sync {
async fn budget_window(
&self,
id: &ClientApiKeyId,
period: ClientKeyBudgetWindowPeriod,
) -> Result<ClientKeyBudgetWindow, AdminError>;
async fn set_budget_window(
&self,
context: &MutationContext,
command: SetClientKeyBudgetWindow,
origin: ClientKeyBudgetMutationOrigin,
) -> Result<u64, AdminError>;
async fn get(&self, id: &ClientApiKeyId) -> Result<ClientKeyRecord, AdminError>;
async fn list(&self, query: ClientKeyListQuery) -> Result<ClientKeyPage, AdminError>;
async fn reveal(&self, id: &ClientApiKeyId) -> Result<ClientKeySecret, AdminError>;
@@ -89,6 +103,35 @@ impl DefaultClientKeyService {
#[async_trait]
impl ClientKeyService for DefaultClientKeyService {
async fn budget_window(
&self,
id: &ClientApiKeyId,
period: ClientKeyBudgetWindowPeriod,
) -> Result<ClientKeyBudgetWindow, AdminError> {
self.store
.client_key_budget_window(id, period)
.await
.map_err(|error| map_store_error(error, "client API key"))
}
async fn set_budget_window(
&self,
context: &MutationContext,
command: SetClientKeyBudgetWindow,
origin: ClientKeyBudgetMutationOrigin,
) -> Result<u64, AdminError> {
if command
.window
.is_some_and(|window| window.starts_at >= window.ends_at)
{
return Err(AdminError::invalid("预算窗口结束时间必须晚于开始时间"));
}
self.store
.set_client_key_budget_window(command, origin, context)
.await
.map_err(|error| map_store_error(error, "client API key"))
}
async fn get(&self, id: &ClientApiKeyId) -> Result<ClientKeyRecord, AdminError> {
self.store
.get_client_key(id)
@@ -30,7 +30,18 @@ pub(super) async fn publish_credentials_and_observe_quota(
notify_account_facts_changed(peer.map(AsRef::as_ref), provider.as_ref(), account_ids).await;
publish_committed(snapshot, revision).await?;
// 凭据已经提交;额度只是可重建的观察,不能拖住管理请求或回滚提交结果
observe_account_quotas(peer, provider, accounts, account_ids, request_id);
Ok(())
}
pub(super) fn observe_account_quotas(
peer: Option<&std::sync::Arc<dyn crate::ports::peer::PeerUpstreamService>>,
provider: &std::sync::Arc<dyn ProviderAdmin>,
accounts: &std::sync::Arc<dyn AccountStore>,
account_ids: &[ProviderAccountId],
request_id: &str,
) {
// 配置已提交,额度查询失败不回滚配置
let provider = provider.clone();
let peer = peer.cloned();
let accounts = accounts.clone();
@@ -48,14 +59,14 @@ pub(super) async fn publish_credentials_and_observe_quota(
Ok(Some(account)) => account,
Ok(None) => continue,
Err(error) => {
tracing::warn!(request_id, account_id = %account_id, error = %error, "initial quota account lookup failed");
tracing::warn!(request_id, account_id = %account_id, error = %error, "quota observation account lookup failed");
continue;
}
};
if !account.enabled || &account.provider_kind != provider.provider_kind() {
continue;
}
// 首查与手动刷新共用认证能力分派,互联协议由 Admin 解释
// 后台查询与手动刷新共用认证能力分派,互联协议由 Admin 解释
super::peer_link::upstream::read_quota(
Some(peer),
provider.as_ref(),
@@ -72,12 +83,11 @@ pub(super) async fn publish_credentials_and_observe_quota(
provider = %provider.provider_kind().as_str(),
account_id = %account_id.as_str(),
quota_error = ?error.kind(),
"initial quota observation failed"
"account quota observation failed"
);
}
}
});
Ok(())
}
pub(super) async fn required_credential(
@@ -0,0 +1,153 @@
//! 从已保存的错误事实投影管理端来源,不参与执行错误分类或重试
use crate::model::observability::{UsageErrorOrigin, UsageListRecord, UsageRecord};
impl UsageListRecord {
#[must_use]
pub fn error_origin(&self) -> UsageErrorOrigin {
error_origin(
self.has_upstream_error,
self.error_code.as_deref(),
self.websocket_close_code,
self.upstream_status_code,
self.error_kind.as_deref(),
self.continuation_unavailable_reason.as_deref(),
self.upstream_connection_exit_reason.as_deref(),
)
}
}
impl UsageRecord {
#[must_use]
pub fn error_origin(&self) -> UsageErrorOrigin {
let has_upstream_error = self.upstream_error.as_ref().is_some_and(|error| {
[&error.message, &error.code, &error.error_type]
.into_iter()
.any(|value| value.as_ref().is_some_and(|value| !value.is_empty()))
});
error_origin(
has_upstream_error,
self.provider_error_code.as_deref(),
self.websocket_close_code,
self.upstream_status_code,
self.error_kind.as_deref(),
self.continuation_unavailable_reason.as_deref(),
self.upstream_connection_exit_reason.as_deref(),
)
}
}
// 上游原始事实优先;客户端 HTTP 状态和任意错误文案不能证明错误来源
fn error_origin(
has_upstream_error: bool,
upstream_code: Option<&str>,
websocket_close_code: Option<u16>,
upstream_status_code: Option<u16>,
error_kind: Option<&str>,
continuation_reason: Option<&str>,
connection_exit_reason: Option<&str>,
) -> UsageErrorOrigin {
if has_upstream_error
|| upstream_code.is_some_and(|code| !code.is_empty())
|| websocket_close_code.is_some()
|| upstream_status_code.is_some_and(|status| status >= 400)
|| matches!(
continuation_reason,
Some("upstream_rejected" | "quota_exhausted")
)
|| matches!(
error_kind,
// 这里匹配内部 errorKind;message_too_big 只由收到的上游 close 1009 产生
Some("permission_denied" | "message_too_big" | "upstream_capacity_unavailable")
)
{
return UsageErrorOrigin::Upstream;
}
if matches!(
continuation_reason,
Some(
"scope_unavailable"
| "pool_unavailable"
| "fresh_connection_required"
| "connection_busy"
| "latest_response_mismatch"
| "reused_connection_lost"
| "transport_payload_too_large"
)
) || matches!(
error_kind,
Some(
"process_interrupted"
| "process_terminated"
| "internal_error"
| "model_not_found"
| "no_available_provider"
| "no_eligible_account"
| "account_capacity_unavailable"
| "middleware_capacity_unavailable"
| "concurrency_queue_full"
| "concurrency_queue_timeout"
| "provider_infrastructure_unavailable"
| "session_policy_restricted"
| "request_policy_denied"
| "policy_denied"
| "budget_exhausted"
)
) || matches!(
connection_exit_reason,
Some(
"command_channel_closed"
| "local_close"
| "message_receiver_closed"
| "client_cancelled"
| "downstream_send_failed"
| "downstream_connection_closed"
| "downstream_rebound"
| "pool_shutdown"
| "max_age_expired"
| "idle_timeout_expired"
| "proxy_capacity_evicted"
| "search_activation_required"
)
) {
return UsageErrorOrigin::Proxy;
}
if matches!(
connection_exit_reason,
Some(
"upstream_close"
| "normal_close"
| "unexpected_binary_event"
| "incomplete_response"
| "failed_response"
)
) {
return UsageErrorOrigin::Upstream;
}
if error_kind == Some("transport")
|| matches!(
connection_exit_reason,
Some(
"eof"
| "unexpected_eof"
| "transport_error"
| "connection_reset"
| "connection_aborted"
| "broken_pipe"
| "tcp_reset"
| "reset_without_closing_handshake"
| "transport_timeout"
| "outbound_transport_error"
| "receive_idle_timeout"
| "pong_timeout"
| "ping_write_timeout"
| "pong_write_timeout"
)
)
{
return UsageErrorOrigin::Connection;
}
// invalid_request、protocol、rate_limited 等分类同时用于本地和上游失败
// 发送状态与笼统的连接退出摘要也不能替代缺失的来源事实
UsageErrorOrigin::Unknown
}
@@ -43,6 +43,7 @@ const HEALTH_TIMELINE_SLOT_MINUTES: i64 = 15;
const HEALTH_TIMELINE_MIN_SAMPLE_SIZE: u64 = 10;
const HEALTH_TIMELINE_UNAVAILABLE_FAILURE_THRESHOLD: u64 = 3;
const HEALTH_TIMELINE_STABLE_RELIABILITY: f64 = 95.0;
mod errors;
mod export;
mod store_cache;
pub(crate) use store_cache::CachedObservabilityStore;
@@ -8,7 +8,9 @@ use std::{collections::BTreeMap, sync::Arc};
use async_trait::async_trait;
use chrono::{TimeDelta, Utc};
use gateway_core::{engine::execution::AuthenticatedClient, policy::ClientPeerSharing};
use gateway_core::{
account::ProviderAccountId, engine::execution::AuthenticatedClient, policy::ClientPeerSharing,
};
use tokio::sync::Semaphore;
use crate::{
@@ -113,10 +115,7 @@ impl PeerLinkService for DefaultPeerLinkService {
let mut result = PeerLinkExport {
observed_at: now,
expires_at: now + TimeDelta::minutes(5),
budget_access: key
.budget
.limits
.access(key.budget.daily_used_usd, key.budget.weekly_used_usd),
budget_access: key.budget.access_at(budget_observed_at.into()),
key_limits: key.limits,
budget: key.budget,
budget_observed_at,
@@ -154,16 +153,37 @@ impl PeerLinkService for DefaultPeerLinkService {
.filter(|group| group.enabled)
.map(|group| group.id.clone())
.collect::<Vec<_>>();
let scope = client.policy().account_scope();
let direct_ids = ids
.iter()
.filter(|id| {
scope
.directory()
.account(id)
.is_some_and(|account| account.is_direct_account())
})
.cloned()
.collect::<Vec<_>>();
let accounts = self
.accounts
.load_accounts_in_enabled_groups(&ids, &groups)
.load_accounts_in_enabled_groups(&direct_ids, &groups)
.await
.map_err(|error| map_store_error(error, "peer accounts"))?
.into_iter()
.filter(|account| {
self.providers
.require(&account.provider_kind)
.is_ok_and(|provider| provider.is_direct_account(&account.authentication_kind))
ProviderAccountId::new(account.id.clone())
.ok()
.and_then(|id| scope.directory().account(&id))
.is_some_and(|frozen| {
frozen.direct_authentication_kind()
== Some(account.authentication_kind.as_str())
})
&& self
.providers
.require(&account.provider_kind)
.is_ok_and(|provider| {
provider.is_direct_account(&account.authentication_kind)
})
})
.collect::<Vec<_>>();
let account_ids = accounts
@@ -232,26 +252,18 @@ impl PeerLinkService for DefaultPeerLinkService {
));
}
if sharing.share_quota {
let (quota, mut models) = projection::quota_and_models(
let (quota, models) = projection::quota_and_models(
client,
&accounts,
&quotas,
&self.providers,
&runtime,
&public_models,
&ids,
now,
limits.max_response_bytes,
)
.await?;
// 发布窗口内缺失或已变更的账号不能被静默剔除后推导整个模型池耗尽
if accounts.len() != ids.len() {
for model in &mut models {
model.complete = false;
if model.state == crate::model::peer_link::PeerModelState::Exhausted {
model.state = crate::model::peer_link::PeerModelState::Unknown;
}
}
}
result.quota = Some(quota);
result.models = models;
}
@@ -1,6 +1,6 @@
//! 从已授权的本地账号事实派生公开汇总,保留原始观测时间
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use chrono::{DateTime, TimeDelta, Utc};
use gateway_core::{
@@ -38,7 +38,7 @@ pub(super) fn pool_summary(
for account in accounts {
match status(account, runtime, now) {
AccountStatus::Normal => summary.ready += 1,
AccountStatus::RateLimited => summary.cooldown += 1,
AccountStatus::QuotaPaused | AccountStatus::RateLimited => summary.cooldown += 1,
AccountStatus::QuotaExhausted => summary.exhausted += 1,
AccountStatus::Disabled => summary.disabled += 1,
AccountStatus::Error => summary.error += 1,
@@ -67,6 +67,7 @@ pub(super) async fn quota_and_models(
providers: &ProviderAdminRegistry,
runtime: &AccountRuntimeSnapshot,
models: &[gateway_core::routing::PublicModelId],
authorized: &[ProviderAccountId],
now: DateTime<Utc>,
max_response_bytes: usize,
) -> Result<(Vec<PeerAccountQuota>, Vec<PeerModelSummary>), super::PeerLinkError> {
@@ -151,6 +152,28 @@ pub(super) async fn quota_and_models(
.collect(),
});
}
// 转供账号及发布窗口内消失的账号没有本地观测,只影响它们获准执行的模型
if accounts.len() != authorized.len() {
let exported = accounts
.iter()
.map(|account| account.id.as_str())
.collect::<BTreeSet<_>>();
let missing = authorized
.iter()
.filter(|id| !exported.contains(id.as_str()))
.collect::<Vec<_>>();
for (index, model) in models.iter().enumerate() {
if index % 16 == 0 {
tokio::task::yield_now().await;
}
if missing
.iter()
.any(|id| snapshot.allows_public_model_for_account(model, scope, id))
{
observations.entry(model.as_str()).or_default().incomplete = true;
}
}
}
let summaries = models
.iter()
.map(|model| {
@@ -248,6 +271,7 @@ fn status(
) -> AccountStatus {
resolve_account_status(
&AccountStatusFacts {
quota_paused: account.quota_paused,
enabled: account.enabled,
credential_state: account.credential_state,
access_token_expires_at: account.access_token_expires_at.map(Into::into),
@@ -214,6 +214,7 @@ impl PeerInfoService {
}
}
ProviderQuota {
paused_windows: Vec::new(),
upstream: Some(info),
plan_type: None,
observed_at,
@@ -170,6 +170,7 @@ impl PeerInfoService {
.and_then(|peer| quota::observe(&peer, started_at, now))
.unwrap_or(QuotaState::unknown());
let observation = QuotaObservation {
quota_pause: None,
expected_connection: Some(connection.clone()),
account_id: id.clone(),
expected_revision: connection.credential_revision,
@@ -9,9 +9,10 @@ use crate::{
model::{
AdminError, MutationContext,
client_keys::{
ClientKeyBudgetMutationOrigin, ClientKeyCursor, ClientKeyCursorValue,
ClientKeyListQuery, ClientKeyPageSize, ClientKeySort, ClientKeySortField,
ResetClientKeyBudget, SortDirection, UpdateClientKeyBudgetLimits,
ClientKeyBudgetMutationOrigin, ClientKeyBudgetWindow, ClientKeyBudgetWindowPeriod,
ClientKeyCursor, ClientKeyCursorValue, ClientKeyListQuery, ClientKeyPageSize,
ClientKeySort, ClientKeySortField, ResetClientKeyBudget, SetClientKeyBudgetWindow,
SortDirection, UpdateClientKeyBudgetLimits,
},
plugin_client_keys::{
PluginClientKey, PluginClientKeyCursor, PluginClientKeyFacts, PluginClientKeyListQuery,
@@ -35,6 +36,29 @@ impl DefaultPluginClientKeyAccess {
#[async_trait]
impl PluginClientKeyAccess for DefaultPluginClientKeyAccess {
async fn budget_window(
&self,
id: &ClientApiKeyId,
period: ClientKeyBudgetWindowPeriod,
) -> Result<ClientKeyBudgetWindow, AdminError> {
self.service.budget_window(id, period).await
}
async fn set_budget_window(
&self,
owner: &PluginResourceOwner,
command: SetClientKeyBudgetWindow,
context: &MutationContext,
) -> Result<u64, AdminError> {
self.service
.set_budget_window(
context,
command,
ClientKeyBudgetMutationOrigin::Plugin(owner.clone()),
)
.await
}
async fn facts(&self, id: &ClientApiKeyId) -> Result<PluginClientKeyFacts, AdminError> {
let key = self.service.get(id).await?;
Ok(PluginClientKeyFacts {
@@ -365,7 +365,7 @@ fn current_platform() -> Option<Platform> {
fn target_environment_matches(target: &str) -> bool {
match target {
"x86_64-unknown-linux-gnu" | "aarch64-unknown-linux-gnu" => cfg!(target_env = "gnu"),
"aarch64-apple-darwin" => cfg!(target_os = "macos"),
"x86_64-apple-darwin" | "aarch64-apple-darwin" => cfg!(target_os = "macos"),
"x86_64-pc-windows-msvc" | "aarch64-pc-windows-msvc" => cfg!(target_env = "msvc"),
_ => false,
}
@@ -386,6 +386,13 @@ const SUPPORTED_PLATFORMS: &[Platform] = &[
runtime_os: "linux",
runtime_architecture: "aarch64",
},
Platform {
release_os: "darwin",
release_architecture: "amd64",
target: "x86_64-apple-darwin",
runtime_os: "macos",
runtime_architecture: "x86_64",
},
Platform {
release_os: "darwin",
release_architecture: "arm64",
@@ -23,11 +23,51 @@ use crate::{
},
ports::{
provider::ProviderAdminRegistry,
proxy::{ProxyProbe, ProxyStore},
proxy::{PluginProxyAccess, ProxyProbe, ProxyStore},
store::AdminStoreErrorKind,
},
};
pub(crate) struct DefaultPluginProxyAccess {
pub(crate) store: Arc<dyn ProxyStore>,
}
#[async_trait]
impl PluginProxyAccess for DefaultPluginProxyAccess {
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError> {
list_proxies(self.store.as_ref(), query).await
}
async fn resolve(&self, id: &str) -> Result<OutboundProxy, AdminError> {
if id.is_empty() || id.len() > 128 || id.chars().any(char::is_control) {
return Err(AdminError::invalid("代理 ID 不合法"));
}
let record = self
.store
.get(id)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
// 显式选择代理时必须有可用配置,不能沿用账号停用代理后直连的语义
if !record.enabled {
return Err(AdminError::invalid("代理已禁用,请启用后再选择"));
}
Ok(record.proxy)
}
}
async fn list_proxies(
store: &dyn ProxyStore,
query: ProxyListQuery,
) -> Result<ProxyPage, AdminError> {
if query.page == 0 || query.search.len() > 256 || query.search.chars().any(char::is_control) {
return Err(AdminError::invalid("代理查询参数不合法"));
}
store
.list(query)
.await
.map_err(|error| map_store_error(error, "proxy"))
}
#[async_trait]
pub trait ProxiesService: Send + Sync {
async fn create_batch(
@@ -268,14 +308,7 @@ impl ProxiesService for DefaultProxiesService {
}
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError> {
if query.page == 0 || query.search.len() > 256 || query.search.chars().any(char::is_control)
{
return Err(AdminError::invalid("代理查询参数不合法"));
}
self.store
.list(query)
.await
.map_err(|error| map_store_error(error, "proxy"))
list_proxies(self.store.as_ref(), query).await
}
async fn create(
@@ -10,8 +10,8 @@ use crate::{
model::{
AdminError, AdminErrorKind,
system::{
SystemOperationAccepted, SystemUpdateChannel, SystemUpdateDetail, SystemUpdateStatus,
SystemVersion,
SystemOperationAccepted, SystemUpdateChannel, SystemUpdateCheck, SystemUpdateDetail,
SystemUpdateStatus, SystemVersion,
},
},
ports::system::{
@@ -25,7 +25,7 @@ pub trait SystemService: Send + Sync {
async fn version(&self) -> Result<SystemVersion, AdminError>;
async fn update_detail(
&self,
refresh: bool,
check: SystemUpdateCheck,
channel: Option<SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, AdminError>;
fn update_events(&self) -> SystemUpdateEventStream;
@@ -71,11 +71,11 @@ impl SystemService for DefaultSystemService {
async fn update_detail(
&self,
refresh: bool,
check: SystemUpdateCheck,
channel: Option<SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, AdminError> {
self.operations
.update_detail(refresh, channel)
.update_detail(check, channel)
.await
.map_err(map_system_error)
}
@@ -106,6 +106,7 @@ fn provider_document_debug_should_not_expose_opaque_material() {
#[test]
fn representative_quota_should_prefer_short_window_and_highest_usage() {
let quota = ProviderQuota {
paused_windows: Vec::new(),
upstream: None,
credits: None,
plan_type: None,
@@ -129,6 +130,7 @@ fn representative_quota_should_prefer_account_wide_window_over_model_specific_wi
let mut model_specific = quota_window("shortTerm", Some(18_000), Some(64.0));
model_specific.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
let quota = ProviderQuota {
paused_windows: Vec::new(),
upstream: None,
credits: None,
plan_type: None,
@@ -148,6 +150,7 @@ fn representative_quota_should_prefer_account_wide_window_over_model_specific_wi
#[test]
fn exhausted_quota_should_project_full_usage_to_only_the_representative_window() {
let mut quota = ProviderQuota {
paused_windows: Vec::new(),
upstream: None,
credits: None,
plan_type: None,
@@ -178,6 +181,7 @@ fn exhausted_quota_should_preserve_the_provider_identified_reached_window() {
let mut reached = quota_window("monthly", Some(2_592_000), Some(98.0));
reached.limit_reached = true;
let mut quota = ProviderQuota {
paused_windows: Vec::new(),
upstream: None,
credits: None,
plan_type: None,
@@ -116,6 +116,7 @@ fn selected_usage_window(quota: &ProviderQuota) -> Option<(&str, AccountUsagePer
fn usage_quota(windows: Vec<ProviderQuotaWindow>) -> ProviderQuota {
ProviderQuota {
paused_windows: Vec::new(),
upstream: None,
credits: None,
plan_type: None,
@@ -168,6 +169,7 @@ fn usage_window(key: &str, group: &str, seconds: u64) -> ProviderQuotaWindow {
#[test]
fn dashboard_quota_should_preserve_unknown_and_actual_window_facts() {
let mut quota = ProviderQuota {
paused_windows: Vec::new(),
upstream: None,
credits: None,
plan_type: None,
@@ -267,7 +269,8 @@ fn pending_mutation_v1_should_round_trip_all_targets_and_owners() {
gateway_core::account::OutboundProxy::parse(
"http://user:secret@proxy.example:8080",
)
.unwrap(),
.unwrap()
.with_max_websocket_connections(16),
))
.with_outbound_proxy_id(Some("proxy_saved".to_owned()));
assert_eq!(
@@ -293,6 +296,27 @@ fn pending_mutation_v1_should_keep_existing_storage_fields_and_reject_invalid_do
let restored =
PendingAuthorizationMutation::from_storage_v1(legacy.clone()).expect("v1 document");
assert_eq!(Value::Object(restored.to_storage_v1()), legacy);
let mut legacy_proxy = legacy.clone();
legacy_proxy["outbound_proxy_url"] = json!("socks5h://proxy.example.invalid:1080");
legacy_proxy["outbound_proxy_id"] = json!("proxy_saved");
let restored = PendingAuthorizationMutation::from_storage_v1(legacy_proxy.clone())
.expect("legacy proxy without a capacity field remains readable");
assert_eq!(
restored
.outbound_proxy()
.unwrap()
.max_websocket_connections(),
0
);
assert_eq!(Value::Object(restored.to_storage_v1()), legacy_proxy);
for invalid in [json!(-1), json!(4294967296_u64), json!("16")] {
let mut document = legacy_proxy.clone();
document["outbound_proxy_max_websocket_connections"] = invalid;
assert!(PendingAuthorizationMutation::from_storage_v1(document).is_err());
}
let mut missing_proxy = legacy.clone();
missing_proxy["outbound_proxy_max_websocket_connections"] = json!(16);
assert!(PendingAuthorizationMutation::from_storage_v1(missing_proxy).is_err());
for (pointer, invalid) in [
("/provider_kind", json!("")),
("/target/account_id", json!("")),
@@ -59,6 +59,7 @@ fn window(key: &str, days: u64) -> ProviderQuotaWindow {
fn quota(windows: Vec<ProviderQuotaWindow>) -> ProviderQuota {
ProviderQuota {
paused_windows: Vec::new(),
upstream: None,
credits: None,
plan_type: None,
@@ -377,6 +377,7 @@ fn member(account_id: &str, total_slots: u64) -> AccountGroupMemberFact {
group_id: group_id(),
account_id: account_id.to_owned(),
status: AccountStatusFacts {
quota_paused: false,
enabled: true,
credential_state: CredentialState::Ready,
access_token_expires_at: None,
@@ -714,6 +714,7 @@ impl FakeAccountStore {
fn page_item(account: AccountRecord) -> AccountPageItem {
let facts = AccountStatusFacts {
quota_paused: false,
enabled: account.enabled,
credential_state: account.credential_state,
access_token_expires_at: account.access_token_expires_at.map(Into::into),
@@ -788,6 +789,7 @@ impl AccountStore for FakeAccountStore {
summary: AccountSummary {
total,
normal: 1,
quota_paused: 0,
quota_exhausted: 0,
rate_limited: 0,
disabled: 0,
@@ -1082,6 +1084,7 @@ impl AccountStore for FakeAccountStore {
async fn batch_update_accounts(
&self,
command: BatchUpdateAccounts,
_: gateway_admin::model::accounts::PreparedQuotaPauses,
context: &MutationContext,
) -> AdminStoreResult<AccountsUpdateResult> {
self.record("store.batch_update_accounts");
@@ -1128,6 +1131,7 @@ impl AccountStore for FakeAccountStore {
#[derive(Default)]
struct StaticSettingsStore {
reset_credit_on_low_weekly_quota: bool,
reset_credit_max_weekly_remaining_percent: Option<u32>,
}
@@ -1166,7 +1170,9 @@ impl SettingsStore for StaticSettingsStore {
max_concurrent_per_account: 1,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
key_queue_timeout_seconds: 30,
account_queue_timeout_seconds: 30,
switch_account_on_queue_failure: false,
max_retry_after_seconds: 240,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
@@ -1181,9 +1187,10 @@ impl SettingsStore for StaticSettingsStore {
audit_retention_days: 30,
account_error_protection: gateway_core::account::AccountErrorProtection::default(),
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_cron: "0 8 * * *".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
reset_credit_on_low_weekly_quota: self.reset_credit_on_low_weekly_quota,
reset_credit_max_weekly_remaining_percent: self
.reset_credit_max_weekly_remaining_percent,
},
@@ -1906,6 +1913,8 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() {
.batch_update(
&context("batch-update-request"),
BatchUpdateAccounts {
quota_limits: None,
auto_use_quota_credits: None,
model_access: Default::default(),
outbound_proxy: None,
account_ids: vec!["acct_openai".to_owned(), "acct_xai".to_owned()],
@@ -2073,6 +2082,7 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop
let events = events();
let openai = FakeProviderAdmin::new("openai", events.clone());
openai.set_quota(ProviderQuota {
paused_windows: Vec::new(),
upstream: None,
credits: None,
plan_type: None,
@@ -2233,6 +2243,7 @@ async fn accounts_list_should_map_unknown_credential_to_error_not_normal() {
async fn accounts_list_should_not_derive_rate_limited_from_provider_quota_view() {
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
paused_windows: Vec::new(),
upstream: None,
credits: None,
plan_type: None,
@@ -2284,6 +2295,7 @@ async fn accounts_list_should_not_derive_rate_limited_from_provider_quota_view()
async fn accounts_list_should_not_derive_exhaustion_from_provider_quota_view() {
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
paused_windows: Vec::new(),
upstream: None,
credits: None,
plan_type: None,
@@ -2890,6 +2902,7 @@ async fn accounts_list_should_attach_local_usage_to_quota_windows() {
let provider = FakeProviderAdmin::new("openai", events());
let reset_at = Utc::now() + TimeDelta::hours(1);
provider.set_quota(ProviderQuota {
paused_windows: Vec::new(),
upstream: None,
credits: None,
plan_type: None,
@@ -3054,6 +3067,7 @@ async fn accounts_list_should_not_attach_account_usage_to_model_specific_quota_w
let provider = FakeProviderAdmin::new("openai", events());
let reset_at = Utc::now() + TimeDelta::days(7);
provider.set_quota(ProviderQuota {
paused_windows: Vec::new(),
upstream: None,
credits: None,
plan_type: None,
@@ -3359,6 +3373,7 @@ pub(super) fn document() -> ProviderDocument {
fn empty_quota() -> ProviderQuota {
ProviderQuota {
paused_windows: Vec::new(),
upstream: None,
credits: None,
plan_type: None,
@@ -3396,6 +3411,9 @@ fn quota_local_usage(account_id: &str, total_tokens: u64) -> AccountUsage {
pub(super) fn account_record(kind: &str) -> AccountRecord {
let now = Utc::now();
AccountRecord {
quota_paused: false,
quota_limits: Default::default(),
auto_use_quota_credits: false,
outbound_proxy_id: None,
outbound_proxy_enabled: true,
notes: None,
@@ -231,6 +231,8 @@ fn plan(expires_at: chrono::DateTime<Utc>) -> ResetCreditAutomation {
revision: 1,
next_run_at: Utc::now(),
state: ResetCreditAutomationState {
next_weekly_check_at: None,
awaiting_weekly_recovery_since: None,
next_discovery_at: Utc::now() + TimeDelta::days(1),
credits: vec![PlannedResetCredit {
id: "card_1".into(),
@@ -309,7 +311,7 @@ async fn automation_restores_pending_idempotency_after_service_restart() {
.state
.retry_at = Some(Utc::now() - TimeDelta::seconds(1));
drop(services);
let restarted = threshold_services(provider.clone(), store.clone(), Some(0)).await;
let restarted = threshold_services(provider.clone(), store.clone(), Some(0), true).await;
restarted
.accounts()
.reconcile_reset_credit_automation(account_id)
@@ -712,10 +714,12 @@ async fn threshold_services(
provider: Arc<FakeProviderAdmin>,
store: Arc<FakeAccountStore>,
max_remaining: Option<u32>,
early: bool,
) -> AdminServices {
super::super::AdminHarness::new()
.accounts(store)
.settings(Arc::new(StaticSettingsStore {
reset_credit_on_low_weekly_quota: early,
reset_credit_max_weekly_remaining_percent: max_remaining,
}))
.provider(provider)
@@ -783,7 +787,7 @@ async fn automation_weekly_threshold_is_optional_inclusive_and_uses_remaining_qu
let store = FakeAccountStore::new("openai", events());
schedule_due_credit(&provider, &store);
provider.set_quota(weekly_quota(used));
let services = threshold_services(provider.clone(), store.clone(), threshold).await;
let services = threshold_services(provider.clone(), store.clone(), threshold, false).await;
services
.accounts()
.reconcile_reset_credit_automation(ProviderAccountId::new("acct_test").unwrap())
@@ -825,7 +829,7 @@ async fn automation_retains_skipped_credit_and_refreshes_quota_on_next_attempt()
let store = FakeAccountStore::new("openai", events());
schedule_due_credit(&provider, &store);
provider.set_quota(weekly_quota(10.0));
let services = threshold_services(provider.clone(), store.clone(), Some(70)).await;
let services = threshold_services(provider.clone(), store.clone(), Some(70), false).await;
let id = ProviderAccountId::new("acct_test").unwrap();
services
.accounts()
@@ -897,7 +901,7 @@ async fn automation_defers_missing_invalid_or_failed_weekly_quota() {
schedule_due_credit(&provider, &store);
provider.set_quota(quota);
*provider.quota_failure.lock().unwrap() = failure;
let services = threshold_services(provider.clone(), store.clone(), Some(70)).await;
let services = threshold_services(provider.clone(), store.clone(), Some(70), false).await;
let result = services
.accounts()
.reconcile_reset_credit_automation(ProviderAccountId::new("acct_test").unwrap())
@@ -971,3 +975,256 @@ async fn automation_nothing_to_reset_retains_credit_and_retries_with_a_new_key()
.is_empty()
);
}
#[tokio::test]
async fn early_use_checks_only_known_cards_and_confirms_low_quota_before_spending() {
for (enabled, threshold, used, expiring, expected) in [
(false, Some(10), 100.0, false, false),
(true, None, 99.9, true, false),
(true, None, 100.0, false, true),
(true, Some(10), 90.0, false, true),
(true, Some(100), 0.0, false, false),
] {
let provider = FakeProviderAdmin::new("openai", events());
let store = FakeAccountStore::new("openai", events());
enable_capability(&provider);
let expiry = Utc::now()
+ if expiring {
TimeDelta::minutes(5)
} else {
TimeDelta::days(3)
};
provider.reset_credits.lock().unwrap().push(card(expiry));
let mut record = plan(expiry);
let credits = std::mem::take(&mut record.state.credits);
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), record);
let services =
threshold_services(provider.clone(), store.clone(), threshold, enabled).await;
let id = ProviderAccountId::new("acct_test").unwrap();
provider.set_quota(weekly_quota(used));
services
.accounts()
.reconcile_reset_credit_automation(id.clone())
.await
.unwrap();
assert!(provider.quota_requests().is_empty());
let no_card = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert_eq!(no_card.next_run_at, no_card.state.next_discovery_at);
store
.automations
.lock()
.unwrap()
.get_mut("acct_test")
.unwrap()
.state
.credits = credits;
services
.accounts()
.reconcile_reset_credit_automation(id)
.await
.unwrap();
assert_eq!(
provider.reset_credit_commands().len(),
usize::from(expected)
);
let status = services
.accounts()
.reset_credit_automation_status(ProviderAccountId::new("acct_test").unwrap())
.await
.unwrap();
assert_eq!(
status.weekly_trigger_remaining_percent,
enabled.then_some(threshold.unwrap_or(0))
);
if enabled {
assert!(status.next_consume_at.is_none());
}
let requests = provider.quota_requests();
assert_eq!(
requests.iter().filter(|r| !r.refresh).count(),
usize::from(enabled)
);
assert_eq!(requests.iter().any(|r| r.refresh), expected);
if enabled && !expected {
assert!(
store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap()
.next_run_at
<= Utc::now() + TimeDelta::seconds(90)
);
}
}
}
#[tokio::test]
async fn early_use_rechecks_live_quota_and_waits_for_a_new_recovery_after_restart() {
let provider = FakeProviderAdmin::new("openai", events());
let store = FakeAccountStore::new("openai", events());
enable_capability(&provider);
let expiry = Utc::now() + TimeDelta::days(3);
let mut later = card(expiry + TimeDelta::days(1));
later.id = "later".into();
*provider.reset_credits.lock().unwrap() = vec![later, card(expiry)];
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), plan(expiry));
let id = ProviderAccountId::new("acct_test").unwrap();
let services = threshold_services(provider.clone(), store.clone(), Some(10), true).await;
let high = ProviderQuota {
observed_at: Some(Utc::now()),
..weekly_quota(15.0)
};
provider.set_quota(weekly_quota(90.0));
let (release, gate) = tokio::sync::oneshot::channel();
*provider.reset_credit_query_gate.lock().unwrap() = Some(gate);
let (result, ()) = tokio::join!(
services
.accounts()
.reconcile_reset_credit_automation(id.clone()),
async {
tokio::time::timeout(
std::time::Duration::from_secs(2),
provider.reset_credit_query_started.notified(),
)
.await
.unwrap();
provider.set_quota(high.clone());
release.send(()).unwrap();
}
);
result.unwrap();
assert!(provider.reset_credit_commands().is_empty());
advance_retry(&store);
provider.set_quota(weekly_quota(95.0));
services
.accounts()
.reconcile_reset_credit_automation(id.clone())
.await
.unwrap();
let commands = provider.reset_credit_commands();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0].credit_id.as_deref(), Some("card_1"));
provider
.reset_credits
.lock()
.unwrap()
.retain(|credit| credit.id != "card_1");
let services = threshold_services(provider.clone(), store.clone(), Some(10), true).await;
for quota in [weekly_quota(95.0), high, weekly_quota(95.0)] {
provider.set_quota(quota);
services
.accounts()
.reconcile_reset_credit_automation(id.clone())
.await
.unwrap();
}
assert_eq!(provider.reset_credit_commands().len(), 1);
provider.set_quota(ProviderQuota {
observed_at: Some(Utc::now()),
..weekly_quota(20.0)
});
services
.accounts()
.reconcile_reset_credit_automation(id.clone())
.await
.unwrap();
provider.set_quota(weekly_quota(100.0));
services
.accounts()
.reconcile_reset_credit_automation(id)
.await
.unwrap();
let commands = provider.reset_credit_commands();
assert_eq!(commands.len(), 2);
assert_eq!(commands[1].credit_id.as_deref(), Some("later"));
}
#[tokio::test]
async fn weekly_recovery_is_observed_without_cards_after_a_failed_quota_readback() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
let store = FakeAccountStore::new("openai", events.clone());
let expiry = Utc::now() + TimeDelta::days(3);
let mut record = plan(expiry);
record.state.pending = Some(PendingResetCredit {
credit: record.state.credits[0].clone(),
redeem_request_id: uuid::Uuid::new_v4().to_string(),
});
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), record);
let id = ProviderAccountId::new("acct_test").unwrap();
let services = threshold_services(provider.clone(), store.clone(), Some(10), true).await;
let accounts = services.accounts();
*provider.quota_failure.lock().unwrap() = Some(ProviderAdminErrorKind::Unavailable);
accounts
.reconcile_reset_credit_automation(id.clone())
.await
.unwrap();
let waiting = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert!(waiting.state.credits.is_empty());
assert_eq!(waiting.state.last_result.as_deref(), Some("reset"));
assert!(waiting.next_run_at <= Utc::now() + TimeDelta::seconds(90));
provider.set_quota(ProviderQuota {
observed_at: Some(Utc::now()),
..weekly_quota(20.0)
});
accounts
.reconcile_reset_credit_automation(id.clone())
.await
.unwrap();
let recovered = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert_eq!(recovered.next_run_at, recovered.state.next_discovery_at);
assert!(!recorded(&events).contains(&"provider.reset_credits"));
assert_eq!(
provider
.quota_requests()
.iter()
.map(|r| r.refresh)
.collect::<Vec<_>>(),
[true, false]
);
provider.set_quota(weekly_quota(95.0));
let mut new_card = card(expiry);
new_card.id = "next".into();
provider.reset_credits.lock().unwrap().push(new_card);
accounts
.reset_credits(&context("new-card"), id.clone())
.await
.unwrap();
accounts
.reconcile_reset_credit_automation(id)
.await
.unwrap();
let commands = provider.reset_credit_commands();
assert_eq!(commands.len(), 2);
assert_eq!(commands[1].credit_id.as_deref(), Some("next"));
}
@@ -35,6 +35,32 @@ struct TestClientKeyStore {
#[async_trait]
impl ClientKeyStore for TestClientKeyStore {
async fn client_key_budget_window(
&self,
_: &gateway_core::policy::ClientApiKeyId,
_: gateway_admin::model::client_keys::ClientKeyBudgetWindowPeriod,
) -> gateway_admin::ports::store::AdminStoreResult<
gateway_admin::model::client_keys::ClientKeyBudgetWindow,
> {
Err(gateway_admin::ports::store::AdminStoreError::new(
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
"client key",
"unused budget window",
))
}
async fn set_client_key_budget_window(
&self,
_: gateway_admin::model::client_keys::SetClientKeyBudgetWindow,
_: gateway_admin::model::client_keys::ClientKeyBudgetMutationOrigin,
_: &MutationContext,
) -> gateway_admin::ports::store::AdminStoreResult<u64> {
Err(gateway_admin::ports::store::AdminStoreError::new(
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
"client key",
"unused budget window",
))
}
async fn update_client_key_budget_limits(
&self,
_: gateway_admin::model::client_keys::UpdateClientKeyBudgetLimits,
@@ -41,7 +41,9 @@ fn runtime_settings(enabled: bool, probe_enabled: bool) -> RuntimeSettings {
max_concurrent_per_account: 5,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
key_queue_timeout_seconds: 30,
account_queue_timeout_seconds: 30,
switch_account_on_queue_failure: false,
max_retry_after_seconds: 240,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
@@ -71,9 +73,10 @@ fn runtime_settings(enabled: bool, probe_enabled: bool) -> RuntimeSettings {
..Default::default()
},
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_cron: "0 8 * * *".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
reset_credit_on_low_weekly_quota: false,
reset_credit_max_weekly_remaining_percent: None,
},
}
@@ -644,6 +644,7 @@ impl AccountStore for UnavailableStore {
async fn batch_update_accounts(
&self,
_: BatchUpdateAccounts,
_: gateway_admin::model::accounts::PreparedQuotaPauses,
_: &MutationContext,
) -> AdminStoreResult<AccountsUpdateResult> {
Err(unavailable("account batch update"))
@@ -707,6 +708,32 @@ impl AccountRuntimeStore for UnavailableStore {
#[async_trait]
impl ClientKeyStore for UnavailableStore {
async fn client_key_budget_window(
&self,
_: &gateway_core::policy::ClientApiKeyId,
_: gateway_admin::model::client_keys::ClientKeyBudgetWindowPeriod,
) -> gateway_admin::ports::store::AdminStoreResult<
gateway_admin::model::client_keys::ClientKeyBudgetWindow,
> {
Err(gateway_admin::ports::store::AdminStoreError::new(
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
"client key",
"unused budget window",
))
}
async fn set_client_key_budget_window(
&self,
_: gateway_admin::model::client_keys::SetClientKeyBudgetWindow,
_: gateway_admin::model::client_keys::ClientKeyBudgetMutationOrigin,
_: &MutationContext,
) -> gateway_admin::ports::store::AdminStoreResult<u64> {
Err(gateway_admin::ports::store::AdminStoreError::new(
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
"client key",
"unused budget window",
))
}
async fn update_client_key_budget_limits(
&self,
_: gateway_admin::model::client_keys::UpdateClientKeyBudgetLimits,
@@ -1179,7 +1206,7 @@ impl SystemOperations for UnavailableSystem {
async fn update_detail(
&self,
_: bool,
_: gateway_admin::model::system::SystemUpdateCheck,
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, SystemOperationError> {
Err(unavailable_system())
@@ -0,0 +1,174 @@
//! 验证管理端错误位置的证据优先级与未知边界
use gateway_admin::model::observability::{RequestOutcome, UsageErrorOrigin};
use super::{Utc, total_record};
#[test]
fn usage_error_origin_does_not_infer_from_client_status_or_message() {
let mut record = total_record("unknown", Some("openai"), "unavailable", Utc::now());
record.client_status_code = Some(502);
record.upstream_status_code = Some(200);
record.error_message = Some("Upstream error: connection reset".to_owned());
// 这些分类既可能来自本地校验、选凭据、RPC、准入或取消,也可能来自上游
for kind in [
"invalid_request",
"unsupported",
"unauthorized",
"rate_limited",
"quota_exhausted",
"timeout",
"cancelled",
"protocol",
"unavailable",
"upstream_unavailable",
"continuation_recovery_required",
"future_error_kind",
] {
record.error_kind = Some(kind.to_owned());
assert_eq!(record.error_origin(), UsageErrorOrigin::Unknown, "{kind}");
}
}
#[test]
fn usage_error_origin_recognizes_unambiguous_failure_kinds() {
use UsageErrorOrigin::{Connection, Proxy, Upstream};
let mut record = total_record("classified", Some("openai"), "unavailable", Utc::now());
record.outcome = RequestOutcome::Failed;
for (kind, expected) in [
// 本地执行、选路、账号与准入策略已经确定了错误位置
("internal_error", Proxy),
("model_not_found", Proxy),
("no_available_provider", Proxy),
("no_eligible_account", Proxy),
("account_capacity_unavailable", Proxy),
("middleware_capacity_unavailable", Proxy),
("concurrency_queue_full", Proxy),
("concurrency_queue_timeout", Proxy),
("provider_infrastructure_unavailable", Proxy),
("session_policy_restricted", Proxy),
("request_policy_denied", Proxy),
("policy_denied", Proxy),
("budget_exhausted", Proxy),
("process_interrupted", Proxy),
("process_terminated", Proxy),
// Provider 的权限拒绝、消息过大和模型容量拒绝来自明确上游拒绝
("permission_denied", Upstream),
("message_too_big", Upstream),
("upstream_capacity_unavailable", Upstream),
("transport", Connection),
] {
record.error_kind = Some(kind.to_owned());
assert_eq!(record.error_origin(), expected, "{kind}");
}
}
#[test]
fn usage_error_origin_distinguishes_upstream_and_local_size_rejections() {
let mut record = total_record("size", Some("openai"), "unavailable", Utc::now());
record.outcome = RequestOutcome::Failed;
// 收到上游 close 1009 才产生 MessageTooBig;本地发送前超限保存续接拒绝原因
record.error_kind = Some("message_too_big".to_owned());
assert_eq!(record.error_origin(), UsageErrorOrigin::Upstream);
record.error_kind = Some("continuation_recovery_required".to_owned());
record.continuation_unavailable_reason = Some("transport_payload_too_large".to_owned());
assert_eq!(record.error_origin(), UsageErrorOrigin::Proxy);
record.continuation_unavailable_reason = None;
record.error_message = Some("message too big".to_owned());
assert_eq!(record.error_origin(), UsageErrorOrigin::Unknown);
}
#[test]
fn usage_error_origin_prefers_upstream_facts_to_local_classification() {
let mut record = total_record("upstream", Some("openai"), "unavailable", Utc::now());
record.error_kind = Some("internal_error".to_owned());
record.continuation_unavailable_reason = Some("reused_connection_lost".to_owned());
record.client_status_code = Some(503);
record.upstream_status_code = Some(429);
assert_eq!(record.error_origin(), UsageErrorOrigin::Upstream);
record.upstream_status_code = Some(200);
record.error_code = Some("previous_response_not_found".to_owned());
assert_eq!(record.error_origin(), UsageErrorOrigin::Upstream);
record.error_code = None;
record.websocket_close_code = Some(1011);
assert_eq!(record.error_origin(), UsageErrorOrigin::Upstream);
record.websocket_close_code = None;
record.has_upstream_error = true;
assert_eq!(record.error_origin(), UsageErrorOrigin::Upstream);
}
#[test]
fn usage_error_origin_uses_connection_exit_boundaries() {
use UsageErrorOrigin::{Connection, Proxy, Unknown, Upstream};
let mut record = total_record("connection", Some("openai"), "unavailable", Utc::now());
record.outcome = RequestOutcome::Failed;
// Provider 的传输和协议错误会统一保存为 upstream_unavailable,具体退出原因仍独立保存
record.error_kind = Some("upstream_unavailable".to_owned());
record.upstream_status_code = Some(200);
for (reason, expected) in [
// 本地通道、下游交付与连接池回收,不表示客户端取消应由代理承担责任
("command_channel_closed", Proxy),
("local_close", Proxy),
("message_receiver_closed", Proxy),
("client_cancelled", Proxy),
("downstream_send_failed", Proxy),
("downstream_connection_closed", Proxy),
("downstream_rebound", Proxy),
("pool_shutdown", Proxy),
("max_age_expired", Proxy),
("idle_timeout_expired", Proxy),
("proxy_capacity_evicted", Proxy),
("search_activation_required", Proxy),
// 已收到明确的上游帧或终态,与无关闭帧的 EOF 区分
("upstream_close", Upstream),
("normal_close", Upstream),
("unexpected_binary_event", Upstream),
("incomplete_response", Upstream),
("failed_response", Upstream),
("eof", Connection),
("unexpected_eof", Connection),
("transport_error", Connection),
("connection_reset", Connection),
("connection_aborted", Connection),
("broken_pipe", Connection),
("tcp_reset", Connection),
("reset_without_closing_handshake", Connection),
("transport_timeout", Connection),
("outbound_transport_error", Connection),
("receive_idle_timeout", Connection),
("pong_timeout", Connection),
("ping_write_timeout", Connection),
("pong_write_timeout", Connection),
// stream_eof 也可能是本地收包任务退出,笼统退出摘要合并了多种底层失败
("stream_eof", Unknown),
("upstream_closed", Unknown),
("upstream_receive_failed", Unknown),
("exchange_failure", Unknown),
("running", Unknown),
("future_exit_reason", Unknown),
] {
record.upstream_connection_exit_reason = Some(reason.to_owned());
assert_eq!(record.error_origin(), expected, "{reason}");
}
}
#[test]
fn usage_error_origin_distinguishes_local_policy_from_connection_failure() {
let mut record = total_record("local", Some("openai"), "unavailable", Utc::now());
record.client_status_code = Some(503);
record.upstream_status_code = Some(200);
record.error_kind = Some("budget_exhausted".to_owned());
assert_eq!(record.error_origin(), UsageErrorOrigin::Proxy);
record.error_kind = Some("transport".to_owned());
record.upstream_connection_exit_reason = Some("unexpected_eof".to_owned());
assert_eq!(record.error_origin(), UsageErrorOrigin::Connection);
record.continuation_unavailable_reason = Some("latest_response_mismatch".to_owned());
assert_eq!(record.error_origin(), UsageErrorOrigin::Proxy);
record.continuation_unavailable_reason = Some("upstream_rejected".to_owned());
assert_eq!(record.error_origin(), UsageErrorOrigin::Upstream);
record.continuation_unavailable_reason = None;
record.upstream_connection_exit_reason = Some("upstream_close".to_owned());
assert_eq!(record.error_origin(), UsageErrorOrigin::Upstream);
}
@@ -34,6 +34,8 @@ use gateway_admin::{
ports::store::{AdminStoreResult, ObservabilityStore, SettingsStore},
};
mod errors;
#[test]
fn external_observability_range_accepts_exactly_366_days() {
let end = Utc::now();
@@ -1420,7 +1422,9 @@ impl SettingsStore for FixtureSettingsStore {
max_concurrent_per_account: self.max_concurrent_per_account,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
key_queue_timeout_seconds: 30,
account_queue_timeout_seconds: 30,
switch_account_on_queue_failure: false,
max_retry_after_seconds: 240,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
@@ -1435,9 +1439,10 @@ impl SettingsStore for FixtureSettingsStore {
audit_retention_days: 30,
account_error_protection: gateway_core::account::AccountErrorProtection::default(),
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_cron: "0 8 * * *".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
reset_credit_on_low_weekly_quota: false,
reset_credit_max_weekly_remaining_percent: None,
},
})
@@ -1517,6 +1522,7 @@ fn total_record(
) -> UsageListRecord {
UsageListRecord {
provider_account_upstream_peer: None,
has_upstream_error: false,
error_code: None,
error_message: None,
error_kind: None,
@@ -1525,6 +1531,7 @@ fn total_record(
upstream_connection_exit_reason: None,
recovered: false,
client_status_code: None,
upstream_status_code: None,
outcome: gateway_admin::model::observability::RequestOutcome::Succeeded,
client_api_key_name: Some("Production".to_owned()),
id: id.to_owned(),
@@ -456,6 +456,31 @@ impl ProviderAccountStore for TestAccounts {
.cloned()
.collect())
}
async fn list_for_provider_accounts(
&self,
provider: &ProviderKind,
account_ids: &[ProviderAccountId],
) -> Result<Vec<ProviderAccount>, StoreError> {
if account_ids.is_empty() {
return Ok(Vec::new());
}
if Some(provider.as_str()) == self.failed_provider {
return Err(StoreError::new(StoreErrorKind::Unavailable));
}
Ok(self
.accounts
.lock()
.unwrap()
.iter()
.filter(|account| {
account.provider() == provider
&& account.enabled()
&& account_ids.contains(account.id())
})
.cloned()
.collect())
}
async fn list_refresh_candidates(
&self,
_query: ProviderRefreshQuery,
@@ -423,31 +423,37 @@ fn system_context() -> MutationContext {
}
fn plugin_entry(id: &str, marker: u8) -> serde_json::Value {
let (release_os, release_architecture, target) = release_platform();
// 清单列出所有平台,夹具只提供本机归档,以验证导入时不会读取其他平台的包
let artifacts = [
("linux", "amd64", "x86_64-unknown-linux-gnu"),
("linux", "arm64", "aarch64-unknown-linux-gnu"),
("darwin", "amd64", "x86_64-apple-darwin"),
("darwin", "arm64", "aarch64-apple-darwin"),
("windows", "amd64", "x86_64-pc-windows-msvc"),
("windows", "arm64", "aarch64-pc-windows-msvc"),
]
.map(|(release_os, release_architecture, target)| {
let os = if release_os == "darwin" {
"macos"
} else {
release_os
};
let (architecture, _) = target.split_once('-').unwrap();
serde_json::json!({
"release_os": release_os,
"release_architecture": release_architecture,
"target": target,
"os": os,
"architecture": architecture,
"archive": format!("{id}-1.0.0-{os}-{architecture}.tar.gz"),
"sha256": format!("{marker:01x}").repeat(64),
})
});
serde_json::json!({
"id": id,
"version": "1.0.0",
"publisher": "test",
"stability": "experimental",
"artifacts": [{
"release_os": release_os,
"release_architecture": release_architecture,
"target": target,
"os": std::env::consts::OS,
"architecture": std::env::consts::ARCH,
"archive": format!("{id}-1.0.0-{}-{}.tar.gz", std::env::consts::OS, std::env::consts::ARCH),
"sha256": format!("{marker:01x}").repeat(64),
}]
"artifacts": artifacts,
})
}
fn release_platform() -> (&'static str, &'static str, &'static str) {
match (std::env::consts::OS, std::env::consts::ARCH) {
("linux", "x86_64") => ("linux", "amd64", "x86_64-unknown-linux-gnu"),
("linux", "aarch64") => ("linux", "arm64", "aarch64-unknown-linux-gnu"),
("macos", "aarch64") => ("darwin", "arm64", "aarch64-apple-darwin"),
("windows", "x86_64") => ("windows", "amd64", "x86_64-pc-windows-msvc"),
("windows", "aarch64") => ("windows", "arm64", "aarch64-pc-windows-msvc"),
platform => panic!("unsupported test platform: {platform:?}"),
}
}
@@ -56,8 +56,13 @@ impl ProxyStore for TestProxies {
guard: Box::new(ImportGuard(events.clone())),
})
}
async fn list(&self, _: ProxyListQuery) -> AdminStoreResult<ProxyPage> {
Err(super::unavailable("proxy"))
async fn list(&self, query: ProxyListQuery) -> AdminStoreResult<ProxyPage> {
Ok(ProxyPage {
items: self.records.clone(),
total: self.records.len() as u64,
page: query.page,
page_size: query.page_size.get(),
})
}
async fn list_accounts(
&self,
@@ -114,6 +119,79 @@ impl ProxyStore for TestProxies {
}
}
#[tokio::test]
async fn plugin_proxy_resolution_requires_enabled_configuration_without_account_or_probe() {
use gateway_admin::model::{AdminErrorKind, PageSize};
use std::sync::Arc;
let now = chrono::Utc::now();
let records: Vec<_> = [true, false]
.into_iter()
.map(|enabled| ProxyRecord {
id: format!("proxy_{enabled}"),
name: "Login".into(),
proxy: OutboundProxy::parse("http://user:secret@127.0.0.1:8080").unwrap(),
revision: Revision::new(1).unwrap(),
enabled,
auto_location: false,
location: None,
detected_location: None,
account_count: 0,
last_test_at: Some(now),
last_test: Some(ProxyTestResult {
location: Default::default(),
success: false,
latency_ms: 1,
exit_ip: None,
exit_ipv4: None,
exit_ipv6: None,
message: "fixture failure".into(),
}),
created_at: now,
updated_at: now,
})
.collect();
let access = gateway_admin::initialize_plugin_proxies(Arc::new(TestProxies {
records: records.clone(),
..Default::default()
}));
assert_eq!(
access.resolve("proxy_true").await.unwrap(),
records[0].proxy
);
assert_eq!(
access.resolve("proxy_false").await.unwrap_err().kind(),
AdminErrorKind::Invalid
);
assert!(access.resolve("missing").await.is_err());
for id in ["", "bad\n", &"x".repeat(129)] {
assert_eq!(
access.resolve(id).await.unwrap_err().kind(),
AdminErrorKind::Invalid
);
}
let query = || ProxyListQuery {
page: 1,
page_size: PageSize::new(10).unwrap(),
search: String::new(),
};
let page = access.list(query()).await.unwrap();
assert_eq!(page.items.len(), 2);
assert!(!page.items[1].enabled);
for invalid in [
ProxyListQuery { page: 0, ..query() },
ProxyListQuery {
search: "bad\n".into(),
..query()
},
] {
assert_eq!(
access.list(invalid).await.unwrap_err().kind(),
AdminErrorKind::Invalid
);
}
}
#[async_trait]
impl ProxyProbe for TestProxies {
async fn test(&self, _: Option<&OutboundProxy>, _: bool) -> ProxyTestResult {
@@ -203,6 +281,7 @@ async fn linked_accounts_share_plan_resolution_and_only_read_cached_quota() {
] {
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
paused_windows: Vec::new(),
upstream: None,
credits: None,
plan_type: cached.map(str::to_owned),
@@ -333,7 +333,9 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
max_concurrent_per_account: 1,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
key_queue_timeout_seconds: 30,
account_queue_timeout_seconds: 30,
switch_account_on_queue_failure: false,
max_retry_after_seconds: 240,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
@@ -349,9 +351,10 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
account_error_protection:
gateway_core::account::AccountErrorProtection::default(),
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_cron: "0 8 * * *".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
reset_credit_on_low_weekly_quota: false,
reset_credit_max_weekly_remaining_percent: None,
},
},
@@ -41,7 +41,7 @@ impl SystemOperations for RecordingSystemOperations {
async fn update_detail(
&self,
_: bool,
_: gateway_admin::model::system::SystemUpdateCheck,
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, SystemOperationError> {
Ok(SystemUpdateDetail {
@@ -60,6 +60,7 @@ impl SystemOperations for RecordingSystemOperations {
update_supported: false,
unsupported_reason: Some("source build".to_owned()),
warning: None,
retry_at: None,
})
}
@@ -250,7 +250,7 @@ impl SystemOperations for PreflightingSystem {
async fn update_detail(
&self,
_: bool,
_: gateway_admin::model::system::SystemUpdateCheck,
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, SystemOperationError> {
unreachable!()
@@ -276,6 +276,7 @@ impl UpdateAccountRequest {
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountConnectionUpdateRequest {
pub protocol: Option<String>,
pub base_url: Option<String>,
pub transport: String,
pub api_key: Option<String>,
@@ -283,6 +284,13 @@ pub struct AccountConnectionUpdateRequest {
impl AccountConnectionUpdateRequest {
fn validate(&self) -> Result<(), WireValidationError> {
if self
.protocol
.as_deref()
.is_some_and(|protocol| !matches!(protocol, "responses" | "messages"))
{
return Err(WireValidationError::new("connection.protocol"));
}
if let Some(base_url) = &self.base_url {
require_text(base_url, 2048, "connection.baseUrl")?;
}
@@ -302,6 +310,9 @@ impl AccountConnectionUpdateRequest {
fn into_document(self) -> ProviderDocument {
let mut material =
Map::from_iter([("transport".to_owned(), Value::String(self.transport))]);
if let Some(protocol) = self.protocol {
material.insert("protocol".to_owned(), Value::String(protocol));
}
if let Some(base_url) = self.base_url {
material.insert("base_url".to_owned(), Value::String(base_url));
}
@@ -25,6 +25,7 @@ pub(super) fn account_page_data(
summary: AccountSummaryView {
total: result.summary.total,
normal: result.summary.normal,
quota_paused: result.summary.quota_paused,
quota_exhausted: result.summary.quota_exhausted,
rate_limited: result.summary.rate_limited,
disabled: result.summary.disabled,
@@ -116,6 +117,8 @@ pub(super) fn account_view(
let (mut quota, refresh_token_expires_at) = account_quota_view(quota, cooldown, now, time);
quota.protection_rules = protection_rules;
AccountView {
quota_limits: account.quota_limits,
auto_use_quota_credits: account.auto_use_quota_credits,
upstream,
capabilities: capabilities.into(),
id: account.id.clone(),
@@ -271,6 +274,7 @@ pub(super) fn account_quota_view(
let recovery_probe_required = cooldown.is_some_and(|value| value.kind.requires_probe());
(
AccountQuotaView {
paused_windows: quota.paused_windows,
refreshed_at: quota.observed_at.map(|value| time.rfc3339(&value)),
refreshed_at_relative: quota
.observed_at
@@ -305,7 +309,7 @@ pub(crate) fn quota_window_view(
limit_id,
limit_name,
role,
local_usage_attribution: _,
local_usage_attribution,
window_seconds,
used_percent,
reset_at,
@@ -317,6 +321,8 @@ pub(crate) fn quota_window_view(
.as_ref()
.map_or_else(|| label.clone(), |name| format!("{name} · {label}"));
AccountQuotaWindowView {
account_wide: local_usage_attribution
== gateway_admin::model::provider_credentials::QuotaLocalUsageAttribution::AccountWide,
source_account_id: upstream.as_ref().and_then(|value| value.account_id.clone()),
amount: upstream
.as_ref()
@@ -61,6 +61,8 @@ pub struct ListQuery {
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct BatchUpdateAccountsRequest {
pub quota_limits: Option<gateway_core::account::AccountQuotaLimits>,
pub auto_use_quota_credits: Option<bool>,
pub outbound_proxy_id: Option<String>,
pub outbound_proxy_url: Option<AccountProxyUpdate>,
pub account_ids: Vec<String>,
@@ -117,7 +119,9 @@ impl BatchUpdateAccountsRequest {
.map(parse_concurrency_limit)
.transpose()?;
self.weight.map(parse_account_weight).transpose()?;
if self.enabled.is_none()
if self.quota_limits.is_none()
&& self.auto_use_quota_credits.is_none()
&& self.enabled.is_none()
&& self.concurrency_limit.is_none()
&& self.weight.is_none()
&& self.group_ids.is_none()
@@ -133,6 +137,8 @@ impl BatchUpdateAccountsRequest {
pub(super) fn into_command(self) -> Result<BatchUpdateAccounts, WireValidationError> {
self.validate()?;
Ok(BatchUpdateAccounts {
quota_limits: self.quota_limits,
auto_use_quota_credits: self.auto_use_quota_credits,
outbound_proxy: proxy_selection(self.outbound_proxy_id, self.outbound_proxy_url)?,
account_ids: self.account_ids,
enabled: self.enabled,
@@ -256,6 +262,7 @@ pub struct AccountPageData {
pub struct AccountSummaryView {
pub total: u64,
pub normal: u64,
pub quota_paused: u64,
pub quota_exhausted: u64,
pub rate_limited: u64,
pub disabled: u64,
@@ -266,6 +273,8 @@ pub struct AccountSummaryView {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountView {
pub quota_limits: gateway_core::account::AccountQuotaLimits,
pub auto_use_quota_credits: bool,
pub upstream: Option<super::AccountUpstreamInfoView>,
pub capabilities: AccountCapabilitiesView,
pub outbound_proxy_id: Option<String>,
@@ -318,6 +327,8 @@ pub struct AccountCapacityView {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountCapabilitiesView {
pub quota_limits_supported: bool,
pub quota_credits: bool,
pub quota: bool,
pub quota_refresh: bool,
pub profile: bool,
@@ -336,6 +347,8 @@ impl From<gateway_admin::model::accounts::ProviderAccountCapabilities> for Accou
subscription: value.subscription,
avatar: value.avatar,
reset_credits: value.reset_credits,
quota_limits_supported: value.quota_limits_supported,
quota_credits: value.quota_credits,
consume_reset_credit: value.consume_reset_credit,
}
}
@@ -386,6 +399,7 @@ pub use crate::admin::account_groups::AccountGroupRefView;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountQuotaView {
pub paused_windows: Vec<String>,
pub refreshed_at: Option<String>,
pub refreshed_at_relative: Option<crate::time::RelativeTimeView>,
pub refreshed_at_display: String,
@@ -413,6 +427,7 @@ pub struct AccountQuotaCreditsView {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountQuotaWindowView {
pub account_wide: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_account_id: Option<String>,
pub amount: Option<super::AccountQuotaAmountView>,
@@ -1094,7 +1109,7 @@ impl From<(DomainConnectionTestEvent, crate::time::TimePresenter)> for AccountCo
DomainConnectionTestEvent::Started { model } => serde_json::json!({
"type": "test_start",
"model": model,
"text": "正在连接上游 Responses"
"text": "正在连接上游"
}),
DomainConnectionTestEvent::Request {
model,
@@ -1123,6 +1138,7 @@ impl From<(DomainConnectionTestEvent, crate::time::TimePresenter)> for AccountCo
DomainConnectionTestEvent::Failed {
source,
gateway_error_code,
proxy_hint,
send_state,
message,
provider_error_code,
@@ -1134,6 +1150,7 @@ impl From<(DomainConnectionTestEvent, crate::time::TimePresenter)> for AccountCo
"type": "error",
"source": source.as_str(),
"gatewayErrorCode": gateway_error_code.as_str(),
"proxyHint": proxy_hint.map(gateway_core::error::ProxyConnectionHint::as_str),
"sendState": send_state.map(gateway_core::upstream::UpstreamSendState::as_str),
"error": message,
"providerErrorCode": provider_error_code,
@@ -1229,6 +1246,7 @@ pub struct AccountResetCreditAutomationRequest {
pub struct AccountResetCreditAutomationData {
pub enabled: bool,
pub lead_minutes: u32,
pub weekly_trigger_remaining_percent: Option<u32>,
pub next_discovery_at_display: Option<String>,
pub next_consume_at_display: Option<String>,
pub last_result: Option<String>,
@@ -1249,6 +1267,7 @@ impl
Self {
enabled: status.enabled,
lead_minutes: status.lead_minutes,
weekly_trigger_remaining_percent: status.weekly_trigger_remaining_percent,
next_discovery_at_display: status
.next_discovery_at
.as_ref()
@@ -415,14 +415,14 @@ impl From<(ClientKeyRecord, crate::time::TimePresenter)> for ClientKeyView {
weekly_used_usd: record.budget.weekly_used_usd.canonical(),
daily_resets_at_display: record
.budget
.daily_resets_at
.daily_resets_at()
.map(|value| time.datetime(&value.into())),
daily_resets_at: record.budget.daily_resets_at.map(DateTime::from),
daily_resets_at: record.budget.daily_resets_at().map(DateTime::from),
weekly_resets_at_display: record
.budget
.weekly_resets_at
.weekly_resets_at()
.map(|value| time.datetime(&value.into())),
weekly_resets_at: record.budget.weekly_resets_at.map(DateTime::from),
weekly_resets_at: record.budget.weekly_resets_at().map(DateTime::from),
created_at_display: time.datetime(&record.created_at),
created_at: record.created_at,
updated_at_display: time.datetime(&record.updated_at),
@@ -258,6 +258,9 @@ pub(crate) fn usage_list_record_view(
.clone()
.or_else(|| record.requested_model_id.clone());
UsageListRecordView {
error_origin: record.error_origin().as_str(),
client_status_code: record.client_status_code,
upstream_status_code: record.upstream_status_code,
error_code: record.error_code,
error_message: record.error_message,
error_kind: record.error_kind,
@@ -362,6 +365,7 @@ pub(crate) fn usage_record_view(
.or_else(|| record.requested_model_id.clone());
let metadata = provider_metadata_fields(record.provider_metadata_json.as_deref());
UsageRecordView {
error_origin: record.error_origin().as_str(),
upstream_error: record
.upstream_error
.map(|error| super::wire::UsageUpstreamErrorView {
@@ -1038,6 +1042,7 @@ pub(crate) fn dashboard_view(
pool_summary: DashboardPoolSummaryView {
total: provider_accounts.total,
normal: provider_accounts.normal,
quota_paused: provider_accounts.quota_paused,
quota_exhausted: provider_accounts.quota_exhausted,
rate_limited: provider_accounts.rate_limited,
disabled: provider_accounts.disabled,
@@ -86,6 +86,9 @@ pub struct ImageBillingView {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageListRecordView {
pub error_origin: &'static str,
pub client_status_code: Option<u16>,
pub upstream_status_code: Option<u16>,
pub error_code: Option<String>,
pub error_message: Option<String>,
pub error_kind: Option<String>,
@@ -143,6 +146,7 @@ pub struct UsageUpstreamErrorView {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageRecordView {
pub error_origin: &'static str,
pub upstream_error: Option<UsageUpstreamErrorView>,
pub retry_after_ms: Option<u64>,
pub error_code: Option<String>,
@@ -268,7 +272,7 @@ pub struct UsageAttemptView {
pub transport: String,
pub send_state: String,
pub outcome: String,
pub downstream_committed: bool,
pub downstream_committed: Option<bool>,
pub status_code: Option<u16>,
pub provider_error_code: Option<String>,
pub failure_class: Option<String>,
@@ -456,6 +460,7 @@ pub struct DashboardAccountUsageView {
pub struct DashboardPoolSummaryView {
pub total: u64,
pub normal: u64,
pub quota_paused: u64,
pub quota_exhausted: u64,
pub rate_limited: u64,
pub disabled: u64,
@@ -1,5 +1,9 @@
//! 插件管理页面、资源、授权回调与页面模型请求的 HTTP 边界
use crate::openai::generation::{
GenerationHttpRequest, ResponseAuthorization, execute_prepared_generation,
};
use std::{net::SocketAddr, sync::Arc};
use axum::{
@@ -35,10 +39,7 @@ use crate::{
auth::SessionState,
openai::{
error::{gateway_error_response, protocol_error_response},
responses::{
ResponseAuthorization, ResponsesHttpRequest, decode_request_with_body,
execute_prepared_responses,
},
responses::decode_request_with_body,
},
};
@@ -140,10 +141,10 @@ async fn model_responses(
};
let authorization: Arc<dyn ResponseAuthorization> =
Arc::new(PluginModelsAuthorization { state, target });
let response = execute_prepared_responses(
let response = execute_prepared_generation(
service,
prepared,
ResponsesHttpRequest {
GenerationHttpRequest {
peer_address: parts
.extensions
.get::<ConnectInfo<SocketAddr>>()
@@ -158,6 +159,7 @@ async fn model_responses(
middleware_body,
},
Some(authorization),
None,
)
.await;
Ok(plugin_response(response))
@@ -44,7 +44,9 @@ pub struct RuntimeSettingsFields {
pub max_concurrent_per_account: u64,
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub key_queue_timeout_seconds: u32,
pub account_queue_timeout_seconds: u32,
pub switch_account_on_queue_failure: bool,
pub max_retry_after_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub openai_account_affinity: gateway_core::account::AccountAffinity,
@@ -59,8 +61,10 @@ pub struct RuntimeSettingsFields {
pub audit_retention_days: u64,
pub account_error_protection: gateway_core::account::AccountErrorProtection,
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_cron: String,
pub account_warmup_model: Option<String>,
#[serde(default)]
pub reset_credit_on_low_weekly_quota: bool,
pub reset_credit_lead_minutes: u32,
pub reset_credit_max_weekly_remaining_percent: Option<u32>,
}
@@ -751,7 +755,9 @@ impl RuntimeSettingsFields {
.map_err(|_| WireValidationError::new("settingsMaxConcurrencyOverflow"))?,
max_waiting_per_key: self.max_waiting_per_key,
max_waiting_per_account: self.max_waiting_per_account,
concurrency_wait_timeout_seconds: self.concurrency_wait_timeout_seconds,
key_queue_timeout_seconds: self.key_queue_timeout_seconds,
account_queue_timeout_seconds: self.account_queue_timeout_seconds,
switch_account_on_queue_failure: self.switch_account_on_queue_failure,
max_retry_after_seconds: self.max_retry_after_seconds,
openai_guardian_reserved_concurrency: self.openai_guardian_reserved_concurrency,
openai_account_affinity: self.openai_account_affinity,
@@ -769,8 +775,9 @@ impl RuntimeSettingsFields {
.map_err(|_| WireValidationError::new("settingsAuditRetentionOverflow"))?,
account_error_protection: self.account_error_protection,
account_warmup_enabled: self.account_warmup_enabled,
account_warmup_schedule_time: self.account_warmup_schedule_time,
account_warmup_cron: self.account_warmup_cron,
account_warmup_model: self.account_warmup_model,
reset_credit_on_low_weekly_quota: self.reset_credit_on_low_weekly_quota,
reset_credit_lead_minutes: self.reset_credit_lead_minutes,
reset_credit_max_weekly_remaining_percent: self
.reset_credit_max_weekly_remaining_percent,
@@ -791,7 +798,9 @@ impl From<gateway_admin::model::settings::RuntimeSettingsValues> for RuntimeSett
max_concurrent_per_account: u64::from(settings.max_concurrent_per_account),
max_waiting_per_key: settings.max_waiting_per_key,
max_waiting_per_account: settings.max_waiting_per_account,
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
key_queue_timeout_seconds: settings.key_queue_timeout_seconds,
account_queue_timeout_seconds: settings.account_queue_timeout_seconds,
switch_account_on_queue_failure: settings.switch_account_on_queue_failure,
max_retry_after_seconds: settings.max_retry_after_seconds,
openai_guardian_reserved_concurrency: settings.openai_guardian_reserved_concurrency,
openai_account_affinity: settings.openai_account_affinity,
@@ -806,8 +815,9 @@ impl From<gateway_admin::model::settings::RuntimeSettingsValues> for RuntimeSett
audit_retention_days: u64::from(settings.audit_retention_days),
account_error_protection: settings.account_error_protection,
account_warmup_enabled: settings.account_warmup_enabled,
account_warmup_schedule_time: settings.account_warmup_schedule_time,
account_warmup_cron: settings.account_warmup_cron,
account_warmup_model: settings.account_warmup_model,
reset_credit_on_low_weekly_quota: settings.reset_credit_on_low_weekly_quota,
reset_credit_lead_minutes: settings.reset_credit_lead_minutes,
reset_credit_max_weekly_remaining_percent: settings
.reset_credit_max_weekly_remaining_percent,
@@ -822,9 +832,10 @@ fn settings_validation_error(field: &'static str) -> WireValidationError {
"account_warmup_model" => "accountWarmupModel",
"reset_credit_lead_minutes" => "resetCreditLeadMinutes",
"reset_credit_max_weekly_remaining_percent" => "resetCreditMaxWeeklyRemainingPercent",
"account_warmup_schedule_time" => "accountWarmupScheduleTime",
"account_warmup_cron" => "accountWarmupCron",
"audit_retention_days" => "auditRetentionDays",
"concurrency_wait_timeout_seconds" => "concurrencyWaitTimeoutSeconds",
"key_queue_timeout_seconds" => "keyQueueTimeoutSeconds",
"account_queue_timeout_seconds" => "accountQueueTimeoutSeconds",
"max_account_rotations" => "maxAccountRotations",
"max_concurrent_per_account" => "maxConcurrentPerAccount",
"max_waiting_per_account" => "maxWaitingPerAccount",
+27 -10
View File
@@ -20,8 +20,9 @@ use axum::{
use futures::{Stream, StreamExt};
use gateway_admin::model::system::{
SystemOperationAccepted, SystemOperationKind, SystemOperationState, SystemOperationStatus,
SystemRestartPlan, SystemUpdateChannel, SystemUpdateDetail, SystemUpdateEvent,
SystemUpdateEventLevel, SystemUpdatePolicy, SystemUpdateStatus, SystemVersion,
SystemRestartPlan, SystemUpdateChannel, SystemUpdateCheck, SystemUpdateDetail,
SystemUpdateEvent, SystemUpdateEventLevel, SystemUpdatePolicy, SystemUpdateStatus,
SystemVersion,
};
use serde::{Deserialize, Serialize};
@@ -34,7 +35,10 @@ use super::{
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateDetailQuery {
refresh: Option<bool>,
#[serde(default)]
refresh: bool,
#[serde(default)]
summary_only: bool,
channel: Option<SystemUpdateChannel>,
}
@@ -44,10 +48,16 @@ impl UpdateDetailQuery {
self.channel
}
/// 是否强制从发布源刷新
/// 摘要只读取发布索引;手动刷新有独立的最短间隔
#[must_use]
pub fn refresh(&self) -> bool {
self.refresh.unwrap_or(false)
pub const fn check(&self) -> SystemUpdateCheck {
if self.summary_only {
SystemUpdateCheck::Summary
} else if self.refresh {
SystemUpdateCheck::Refresh
} else {
SystemUpdateCheck::Detail
}
}
}
@@ -116,10 +126,12 @@ struct SystemUpdateDetailView {
update_supported: bool,
unsupported_reason: Option<String>,
warning: Option<String>,
retry_at: Option<String>,
retry_at_display: Option<String>,
}
impl From<SystemUpdateDetail> for SystemUpdateDetailView {
fn from(detail: SystemUpdateDetail) -> Self {
impl From<(SystemUpdateDetail, crate::time::TimePresenter)> for SystemUpdateDetailView {
fn from((detail, time): (SystemUpdateDetail, crate::time::TimePresenter)) -> Self {
Self {
deployment_mode_label: deployment_mode_label(&detail.deployment_mode).to_owned(),
build_type_label: build_type_label(&detail.build_type),
@@ -135,6 +147,8 @@ impl From<SystemUpdateDetail> for SystemUpdateDetailView {
update_supported: detail.update_supported,
unsupported_reason: detail.unsupported_reason,
warning: detail.warning,
retry_at: detail.retry_at.map(|at| at.to_rfc3339()),
retry_at_display: detail.retry_at.map(|at| time.label(at, "%m-%d %H:%M:%S")),
}
}
}
@@ -284,12 +298,15 @@ where
let detail = state
.admin_services()
.system()
.update_detail(query.refresh(), query.channel())
.update_detail(query.check(), query.channel())
.await
.map_err(map_system_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(SystemUpdateDetailView::from(detail)),
AdminEnvelope::ok(SystemUpdateDetailView::from((
detail,
crate::time::TimePresenter::new(state.admin_services().timezone()),
))),
))
}
@@ -143,6 +143,11 @@ pub const fn gateway_error_contract(
"invalid_request_error",
"invalid_request",
),
GatewayErrorKind::PeerRoutingLoop => (
StatusCode::LOOP_DETECTED,
"invalid_request_error",
gateway_protocol::peer::ROUTING_LOOP_CODE,
),
GatewayErrorKind::Unsupported => (
StatusCode::BAD_REQUEST,
"invalid_request_error",
@@ -139,16 +139,16 @@ pub(super) fn overview(value: KeyUsageOverview, time: crate::time::TimePresenter
daily_used_usd: key.budget.daily_used_usd.canonical(),
daily_resets_at_display: key
.budget
.daily_resets_at
.daily_resets_at()
.map(|value| time.datetime(&value.into())),
daily_resets_at: key.budget.daily_resets_at.map(DateTime::from),
daily_resets_at: key.budget.daily_resets_at().map(DateTime::from),
weekly_limit_usd: key.budget.limits.weekly_usd.canonical(),
weekly_used_usd: key.budget.weekly_used_usd.canonical(),
weekly_resets_at_display: key
.budget
.weekly_resets_at
.weekly_resets_at()
.map(|value| time.datetime(&value.into())),
weekly_resets_at: key.budget.weekly_resets_at.map(DateTime::from),
weekly_resets_at: key.budget.weekly_resets_at().map(DateTime::from),
},
summary: metrics(
&value.overview.requests,
+14
View File
@@ -137,6 +137,7 @@ pub enum ApiConfigError {
/// 完成组装的唯一 API router
pub struct ApiBundle {
peer_node: HeaderValue,
router: Router,
settings: middleware::SettingsSource,
middleware: Option<middleware::PlanSource>,
@@ -169,6 +170,7 @@ impl ApiBundle {
self.timeout,
self.request_id_header.clone(),
self.settings.clone(),
self.peer_node.clone(),
),
settings: self.settings.clone(),
})
@@ -181,12 +183,15 @@ impl ApiBundle {
self.timeout,
self.request_id_header,
self.settings,
self.peer_node,
)
}
}
/// 组装客户端、管理端、健康检查和静态资源路由
#[expect(clippy::too_many_arguments)]
pub fn initialize(
peer_node: uuid::Uuid,
mut config: ApiConfig,
execution: Arc<dyn ExecutionService>,
admin: AdminServices,
@@ -250,6 +255,8 @@ pub fn initialize(
axum::http::header::AUTHORIZATION,
axum::http::header::CONTENT_TYPE,
HeaderName::from_static("x-api-key"),
HeaderName::from_static("anthropic-version"),
HeaderName::from_static("anthropic-beta"),
request_id_header.clone(),
])
.allow_credentials(true),
@@ -297,6 +304,13 @@ pub fn initialize(
))
.with_state(state);
Ok(ApiBundle {
peer_node: HeaderValue::from_bytes(
peer_node
.simple()
.encode_lower(&mut uuid::Uuid::encode_buffer())
.as_bytes(),
)
.expect("UUID encodes as ASCII"),
settings,
router,
middleware: None,
@@ -1,6 +1,7 @@
//! 路由外层只适配 HTTP 值和正文,不解释插件协议或业务策略
pub(crate) mod headers;
mod peer_route;
pub(crate) mod websocket;
use std::{
@@ -110,6 +111,7 @@ pub(crate) fn wrap(
timeout: Option<Duration>,
request_id_header: HeaderName,
settings: SettingsSource,
peer_node: HeaderValue,
) -> Router {
// Axum 的 route layer 在匹配后运行;外层 fallback service 才能让改写后的 URI 重新匹配内层路由
Router::new().fallback_service(tower::service_fn(move |mut request: Request<Body>| {
@@ -136,7 +138,10 @@ pub(crate) fn wrap(
.and_then(|source| source(snapshot.as_deref()))
});
let request_id_header = request_id_header.clone();
async move { Ok::<_, Infallible>(handle(router, request, plan, request_id_header).await) }
let peer_node = peer_node.clone();
async move {
Ok::<_, Infallible>(handle(router, request, plan, request_id_header, peer_node).await)
}
}))
}
@@ -145,13 +150,23 @@ async fn handle(
mut request: Request<Body>,
plan: Option<FrozenMiddlewarePlan>,
request_id_header: HeaderName,
peer_node: HeaderValue,
) -> Response {
let started = Instant::now();
let inherited = request.extensions_mut().remove::<contract::Context>();
if let Some(plan) = plan.as_ref().filter(|plan| plan.has_websocket()) {
request.extensions_mut().insert(plan.clone());
}
let context = inherited.unwrap_or_else(|| {
let context = if let Some(context) = inherited {
request
.headers_mut()
.remove(gateway_protocol::peer::ROUTE_HEADER);
context
} else {
let path = match peer_route::enter(request.headers_mut(), peer_node) {
Ok(path) => path,
Err(error) => return error.response(request.uri().path()),
};
let request_id = request
.headers()
.get(&request_id_header)
@@ -164,11 +179,12 @@ async fn handle(
request_id,
parent_call_id: None,
extensions: gateway_core::engine::extensions::ExtensionCallScope::default()
.with_request(crate::request_context::capture(&request)),
.with_request(crate::request_context::capture(&request))
.with_peer_route(path),
cancellation: CancellationToken::new(),
plan: plan.clone(),
}
});
};
let context = contract::Context {
plan: plan.clone(),
..context
@@ -0,0 +1,67 @@
//! 入口消费互联推理路径,拒绝回到本部署的请求
use axum::{
http::{HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse as _, Response},
};
use gateway_protocol::peer::{ROUTE_HEADER, ROUTING_LOOP_CODE};
pub(super) enum Error {
Invalid,
Loop,
}
impl Error {
pub(super) fn response(self, path: &str) -> Response {
let (status, message, code) = match self {
Self::Invalid => (
StatusCode::BAD_REQUEST,
"Invalid peer routing path.",
"invalid_peer_route",
),
Self::Loop => (
StatusCode::LOOP_DETECTED,
"Peer routing loop detected.",
ROUTING_LOOP_CODE,
),
};
let (_, mut json) = crate::client_error::openai_error_response(
status,
message,
"invalid_request_error",
code,
);
if matches!(path, "/v1/messages" | "/v1/messages/count_tokens") {
json.0["type"] = serde_json::json!("error");
}
(status, json).into_response()
}
}
pub(super) fn enter(headers: &mut HeaderMap, node: HeaderValue) -> Result<HeaderValue, Error> {
if !headers.contains_key(ROUTE_HEADER) {
return Ok(node);
}
let mut path = Vec::new();
for value in headers.get_all(ROUTE_HEADER) {
for part in value.to_str().map_err(|_| Error::Invalid)?.split(',') {
let id = part.trim();
if id.len() != 32
|| !id
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
{
return Err(Error::Invalid);
}
if id.as_bytes() == node.as_bytes() {
return Err(Error::Loop);
}
path.extend_from_slice(id.as_bytes());
path.push(b',');
}
}
// 消费线协议头;内部调用继承执行作用域,上游重试不能重复追加本节点
headers.remove(ROUTE_HEADER);
path.extend_from_slice(node.as_bytes());
HeaderValue::from_maybe_shared(bytes::Bytes::from(path)).map_err(|_| Error::Invalid)
}
+29 -9
View File
@@ -1,4 +1,4 @@
//! OpenAI 客户端协议的 Bearer API key 认证
//! 数据面客户端 Bearer 与 x-api-key 认证
use axum::{
http::{HeaderMap, header::AUTHORIZATION},
@@ -87,8 +87,9 @@ impl ClientApiKeyAuthError {
pub(crate) async fn authenticate_client(
service: &OpenAiService,
headers: &HeaderMap,
accepted: ClientKeyHeaders,
) -> Result<AuthenticatedClient, ClientAccessError> {
let request = client_authentication_request(headers)?;
let request = client_authentication_request(headers, accepted)?;
let client = service.authenticate(request).await?;
if let Some(identified) = identify_codex_client(headers) {
client
@@ -104,19 +105,38 @@ pub(crate) async fn verify_client(
service: &OpenAiService,
headers: &HeaderMap,
) -> Result<AuthenticatedClient, ClientApiKeyAuthError> {
let request = client_authentication_request(headers)?;
let request = client_authentication_request(headers, ClientKeyHeaders::Bearer)?;
service.verify(request).await
}
/// 入口声明其认证头合同,所有形式仍进入同一 Client Key 认证链路
#[derive(Clone, Copy)]
pub(crate) enum ClientKeyHeaders {
Bearer,
Anthropic,
}
fn client_authentication_request(
headers: &HeaderMap,
accepted: ClientKeyHeaders,
) -> Result<ClientAuthenticationRequest, ClientApiKeyAuthError> {
let raw = headers
.get(AUTHORIZATION)
.ok_or(ClientApiKeyAuthError::MissingAuthorization)?
.to_str()
.map_err(|_| ClientApiKeyAuthError::MalformedAuthorization)?;
let request = ClientAuthenticationRequest::new(raw.to_owned())
let raw = if let Some(authorization) = headers.get(AUTHORIZATION) {
authorization
.to_str()
.map_err(|_| ClientApiKeyAuthError::MalformedAuthorization)?
.to_owned()
} else if matches!(accepted, ClientKeyHeaders::Anthropic)
&& let Some(key) = headers.get("x-api-key")
{
format!(
"Bearer {}",
key.to_str()
.map_err(|_| ClientApiKeyAuthError::MalformedAuthorization)?
)
} else {
return Err(ClientApiKeyAuthError::MissingAuthorization);
};
let request = ClientAuthenticationRequest::new(raw)
.map_err(|_| ClientApiKeyAuthError::MalformedAuthorization)?;
let request = request.with_request_context(
crate::middleware::current().and_then(|context| context.extensions.shared_request()),
@@ -11,12 +11,13 @@ use axum::{
use crate::ApiState;
use crate::openai::{
auth::{authenticate_client, client_access_error_response},
auth::{ClientKeyHeaders, authenticate_client, client_access_error_response},
error::{gateway_error_response, protocol_error_response},
responses::{
DecodedResponsesRequest, HttpResponseAdapter, RequestDecodeError, ResponsesHttpRequest,
decode_request_with_headers, decompress_request_body, execute_prepared_response,
generation::{
DecodedGenerationRequest, GenerationHttpRequest, HttpResponseAdapter,
execute_prepared_generation,
},
responses::{RequestDecodeError, decode_request_with_headers, decompress_request_body},
router::CHAT_COMPLETIONS_PATH,
};
@@ -34,7 +35,7 @@ pub(in crate::openai) async fn chat_completions(
body: Bytes,
) -> Response {
let service = state.openai().clone();
let client = match authenticate_client(&service, &headers).await {
let client = match authenticate_client(&service, &headers, ClientKeyHeaders::Bearer).await {
Ok(client) => client,
Err(error) => return client_access_error_response(error),
};
@@ -68,10 +69,10 @@ pub(in crate::openai) async fn chat_completions(
}),
stream_error: chat_error_frame,
};
execute_prepared_response(
execute_prepared_generation(
service,
prepared,
ResponsesHttpRequest {
GenerationHttpRequest {
peer_address: connect_info.map(|Extension(ConnectInfo(address))| address),
ingress_id,
headers,
@@ -88,7 +89,7 @@ fn decode_request(
body: &[u8],
headers: &HeaderMap,
maximum_body_bytes: usize,
) -> Result<(DecodedResponsesRequest, Bytes, bool), RequestDecodeError> {
) -> Result<(DecodedGenerationRequest, Bytes, bool), RequestDecodeError> {
let body = decompress_request_body(body, headers, maximum_body_bytes)?;
let converted = convert_request(&body)?;
let body =
@@ -89,7 +89,7 @@ where
)
.await;
let response = match result {
Ok(response) => into_http_response(response, ExpectedBody::SingleJson).await,
Ok(response) => into_http_response(response, ExpectedBody::SingleJson, "openai").await,
Err(error) => error_response(error),
};
if !execution_entered.load(Ordering::Relaxed) {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,80 @@
//! 生成与计数协议共用的解码事实和 HTTP 执行边界
pub(crate) mod http;
mod request;
mod response;
mod validation;
pub(crate) use http::{
GenerationHttpRequest, HttpResponseAdapter, ResponseAuthorization, execute_prepared_generation,
};
pub use request::{ContinuationIntent, DecodedGenerationRequest, GenerationRequestMetadata};
use axum::http::HeaderMap;
use gateway_core::{
error::{GatewayError, GatewayErrorKind},
operation::OperationKind,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum GenerationProtocol {
Responses,
Messages,
MessageTokenCount,
}
impl GenerationProtocol {
pub(crate) fn from_request(request: &DecodedGenerationRequest) -> Self {
if request.operation().kind() == OperationKind::CountTokens {
Self::MessageTokenCount
} else if request.operation().protocol() == "anthropic" {
Self::Messages
} else {
Self::Responses
}
}
pub(crate) const fn wire(self) -> &'static str {
match self {
Self::Responses => "openai",
Self::Messages | Self::MessageTokenCount => "anthropic",
}
}
pub(crate) const fn endpoint(self) -> &'static str {
match self {
Self::Responses => super::router::RESPONSES_PATH,
Self::Messages => super::router::MESSAGES_PATH,
Self::MessageTokenCount => super::router::MESSAGE_TOKENS_PATH,
}
}
pub(crate) const fn operation(self) -> OperationKind {
match self {
Self::Responses | Self::Messages => OperationKind::Generate,
Self::MessageTokenCount => OperationKind::CountTokens,
}
}
pub(crate) fn decode(
self,
body: &[u8],
headers: &HeaderMap,
maximum: usize,
) -> Result<DecodedGenerationRequest, GatewayError> {
match self {
Self::Responses => super::responses::decode_request_with_headers(
body, headers, maximum,
)
.map_err(|_| {
GatewayError::new(
GatewayErrorKind::InvalidRequest,
"Request middleware produced an invalid Responses request.",
)
}),
Self::Messages | Self::MessageTokenCount => {
super::messages::decode_request(body, headers, maximum, self)
}
}
}
}
@@ -0,0 +1,160 @@
//! 生成与计数入口共用的已解码操作和交付元数据
use gateway_core::operation::{Operation, ProviderSessionState};
use std::{fmt, net::IpAddr};
/// 客户端声明的 continuation 意图
#[derive(Clone, PartialEq, Eq)]
pub enum ContinuationIntent {
/// 不使用先前响应
None,
/// 使用当前调用方可见的 OpenAI response ID
PreviousResponseId(String),
}
impl ContinuationIntent {
/// 返回待 history owner 解析的 response ID
#[must_use]
pub fn previous_response_id(&self) -> Option<&str> {
match self {
Self::None => None,
Self::PreviousResponseId(value) => Some(value),
}
}
}
impl fmt::Debug for ContinuationIntent {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::None => formatter.write_str("None"),
Self::PreviousResponseId(_) => formatter.write_str("PreviousResponseId(<redacted>)"),
}
}
}
/// Handler、Router 和 history owner 使用的请求元数据
#[derive(Clone, PartialEq, Eq)]
pub struct GenerationRequestMetadata {
pub(in crate::openai) requested_model: String,
pub(in crate::openai) stream: bool,
pub(in crate::openai) store: bool,
pub(in crate::openai) continuation: ContinuationIntent,
pub(in crate::openai) client_ip: Option<IpAddr>,
pub(in crate::openai) user_agent: Option<String>,
}
impl GenerationRequestMetadata {
/// 返回客户端原始模型名
#[must_use]
pub fn requested_model(&self) -> &str {
&self.requested_model
}
/// 返回客户端是否请求 SSE
#[must_use]
pub const fn stream(&self) -> bool {
self.stream
}
/// 返回客户端 storage intent
#[must_use]
pub const fn store(&self) -> bool {
self.store
}
/// 返回 continuation intent
#[must_use]
pub const fn continuation(&self) -> &ContinuationIntent {
&self.continuation
}
/// 返回从 HTTP 连接边界解析出的客户端地址
#[must_use]
pub const fn client_ip(&self) -> Option<IpAddr> {
self.client_ip
}
/// 返回经过 UTF-8 校验和空白归一化的 User-Agent
#[must_use]
pub fn user_agent(&self) -> Option<&str> {
self.user_agent.as_deref()
}
}
impl fmt::Debug for GenerationRequestMetadata {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("GenerationRequestMetadata")
.field("requested_model", &self.requested_model)
.field("stream", &self.stream)
.field("store", &self.store)
.field("continuation", &self.continuation)
.field("client_ip", &self.client_ip)
.field("has_user_agent", &self.user_agent.is_some())
.finish()
}
}
/// 一次成功解码的生成或计数请求
#[derive(Clone, PartialEq)]
pub struct DecodedGenerationRequest {
pub(in crate::openai) operation: Operation,
pub(in crate::openai) metadata: GenerationRequestMetadata,
}
impl DecodedGenerationRequest {
pub(crate) fn with_middleware_capabilities(
mut self,
request: &gateway_core::engine::middleware::MiddlewareRequest,
) -> Result<Self, gateway_core::engine::middleware::MiddlewareError> {
self.operation = request.apply_capabilities(self.operation)?;
Ok(self)
}
/// 附着当前 WebSocket 连接保存的 Provider 私有上一轮状态
#[must_use]
pub fn with_provider_session_state(mut self, state: ProviderSessionState) -> Self {
self.operation = self.operation.with_provider_session_state(state);
self
}
/// 返回协议无关 operation
#[must_use]
pub const fn operation(&self) -> &Operation {
&self.operation
}
/// 返回 handler 元数据
#[must_use]
pub const fn metadata(&self) -> &GenerationRequestMetadata {
&self.metadata
}
/// 拆分为 operation 与元数据
#[must_use]
pub fn into_parts(self) -> (Operation, GenerationRequestMetadata) {
(self.operation, self.metadata)
}
/// 附着只由 HTTP/WebSocket 连接边界提供的诊断事实
#[must_use]
pub fn with_client_context(
mut self,
client_ip: Option<IpAddr>,
user_agent: Option<String>,
) -> Self {
self.metadata.client_ip = client_ip;
self.metadata.user_agent = user_agent;
self
}
}
impl fmt::Debug for DecodedGenerationRequest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("DecodedGenerationRequest")
.field("operation", &self.operation)
.field("metadata", &self.metadata)
.finish()
}
}
@@ -0,0 +1,191 @@
//! 原生协议出口保留 wire,只在非流式交付时聚合消息正文
use axum::body::Bytes;
use gateway_core::{error::GatewayError, event::ProviderEvent};
use gateway_protocol::{
anthropic::MessagesAccumulator,
openai::sse::{
DONE_SSE_FRAME, encode_sse_event_with_metadata, response_failed_sse_event_with_id,
},
};
use serde_json::{Value, json};
use super::GenerationProtocol;
use crate::openai::{
error::gateway_error_contract,
messages,
responses::{OpenAiResponsesEncoder, ResponseEncodeError},
};
pub(super) enum GenerationEncoder {
Responses(OpenAiResponsesEncoder),
Messages {
response_id: Option<String>,
completed: bool,
failed: bool,
},
}
impl GenerationEncoder {
pub(super) fn new(protocol: GenerationProtocol) -> Self {
match protocol {
GenerationProtocol::Responses => Self::Responses(OpenAiResponsesEncoder::new()),
GenerationProtocol::Messages | GenerationProtocol::MessageTokenCount => {
Self::Messages {
response_id: None,
completed: false,
failed: false,
}
}
}
}
pub(super) fn push_sse(&mut self, event: &ProviderEvent) -> Vec<Bytes> {
match self {
Self::Responses(encoder) => encoder.push_sse(event),
Self::Messages {
response_id,
completed,
failed,
} => {
let Some(wire) = event
.wire_event()
.filter(|wire| wire.protocol() == "anthropic")
else {
return Vec::new();
};
if let Some(raw) = wire.raw_json_body() {
let events = serde_json::from_slice(raw).ok().and_then(|value| {
gateway_protocol::anthropic::message_to_stream_events(value).ok()
});
let Some(events) = events else {
*failed = true;
return vec![messages::stream_error(
&json!({"error":{"message":"The upstream message is invalid."}}),
)];
};
*response_id = events
.first()
.and_then(|value| value.pointer("/message/id"))
.and_then(Value::as_str)
.map(str::to_owned);
*completed = true;
return events
.into_iter()
.map(|value| {
Bytes::from(encode_sse_event_with_metadata(
value["type"].as_str().unwrap_or_default(),
&value.to_string(),
None,
None,
))
})
.collect();
}
let kind = wire
.event_type()
.or_else(|| wire.data().get("type").and_then(Value::as_str));
match kind {
Some("message_start") => {
*response_id = wire
.data()
.pointer("/message/id")
.and_then(Value::as_str)
.map(str::to_owned)
}
Some("message_stop") => *completed = true,
Some("error") => *failed = true,
_ => {}
}
if let Some(raw) = wire.raw_sse_frame() {
return vec![raw.clone()];
}
vec![Bytes::from(encode_sse_event_with_metadata(
kind.unwrap_or_default(),
&wire.data().to_string(),
wire.sse_id(),
wire.sse_retry(),
))]
}
}
}
pub(super) fn is_completed(&self) -> bool {
match self {
Self::Responses(encoder) => encoder.is_completed(),
Self::Messages { completed, .. } => *completed,
}
}
pub(super) fn has_wire_failure(&self) -> bool {
match self {
Self::Responses(encoder) => encoder.has_wire_failure(),
Self::Messages { failed, .. } => *failed,
}
}
pub(super) fn error_frame(&self, error: &GatewayError) -> Bytes {
let (status, error_type, code) = gateway_error_contract(error.kind());
match self {
Self::Responses(encoder) => Bytes::from(response_failed_sse_event_with_id(
encoder.response_id(),
error.client_error_type().unwrap_or(error_type),
crate::client_error::client_error_code(error.client_error_code().unwrap_or(code)),
error.client_message(),
)),
Self::Messages { .. } => {
let value = messages::error_payload(
&json!({"error":{"message":error.client_message()}}),
status,
);
messages::stream_error(&value)
}
}
}
}
impl GenerationProtocol {
pub(super) fn terminal_bytes(self) -> Bytes {
match self {
Self::Responses => Bytes::from_static(DONE_SSE_FRAME.as_bytes()),
// Messages 的 message_stop 已在 wire 中交付,空帧只确认宿主交付终结
Self::Messages | Self::MessageTokenCount => Bytes::new(),
}
}
pub(super) fn collect(self, events: &[ProviderEvent]) -> Result<Vec<u8>, ResponseEncodeError> {
if self == Self::Responses {
return serde_json::to_vec(&crate::openai::responses::response::collect_response(
events,
)?)
.map_err(|_| ResponseEncodeError::Serialization);
}
let mut accumulator = MessagesAccumulator::default();
let mut response = None;
for wire in events
.iter()
.filter_map(ProviderEvent::wire_event)
.filter(|wire| wire.protocol() == self.wire())
{
if let Some(raw) = wire.raw_json_body() {
return Ok(raw.to_vec());
}
let value = wire.data();
if self == Self::MessageTokenCount {
if value.get("input_tokens").and_then(Value::as_u64).is_some() {
response = Some(value.clone());
}
} else if value.get("type").and_then(Value::as_str) == Some("message") {
response = Some(value.clone());
} else if wire.has_json_data()
&& let Some(message) = accumulator
.push(value)
.map_err(|_| ResponseEncodeError::InvalidOutputItems)?
{
response = Some(message);
}
}
serde_json::to_vec(&response.ok_or(ResponseEncodeError::MissingWireTerminal)?)
.map_err(|_| ResponseEncodeError::Serialization)
}
}
@@ -0,0 +1,187 @@
//! 出口校验按协议维护边界事实,不在流式路径保存生成正文
use gateway_core::event::ProviderEvent;
use gateway_protocol::openai::sse::parse_sse_events;
use serde_json::Value;
use std::sync::{Arc, Mutex};
use super::GenerationProtocol;
use crate::openai::responses::validation::{
ResponseValidationFacts, ResponsesDeliveryValidator, validate_buffered_response,
};
#[derive(Clone)]
pub(super) enum GenerationValidationFacts {
Responses(ResponseValidationFacts),
Messages(Arc<Mutex<Option<String>>>),
Count,
}
impl GenerationValidationFacts {
pub(super) fn new(protocol: GenerationProtocol) -> Self {
match protocol {
GenerationProtocol::Responses => Self::Responses(ResponseValidationFacts::default()),
GenerationProtocol::Messages => Self::Messages(Arc::default()),
GenerationProtocol::MessageTokenCount => Self::Count,
}
}
pub(super) fn observe_event(&self, event: &ProviderEvent) {
match self {
Self::Responses(facts) => facts.observe_event(event),
Self::Messages(id) => {
if let Some(value) = event
.wire_event()
.filter(|wire| wire.protocol() == "anthropic")
.map(|wire| wire.data())
&& let Some(value) = value
.pointer("/message/id")
.or_else(|| value.get("id"))
.and_then(Value::as_str)
&& let Ok(mut id) = id.lock()
{
*id = Some(value.to_owned());
}
}
Self::Count => {}
}
}
pub(super) fn observe_buffered(&self, events: &[ProviderEvent], bytes: &[u8]) {
if let Self::Responses(facts) = self {
facts.observe_buffered(events, bytes);
} else {
for event in events {
self.observe_event(event);
}
if let Self::Messages(id) = self
&& let Ok(value) = serde_json::from_slice::<Value>(bytes)
&& let Some(value) = value.get("id").and_then(Value::as_str)
&& let Ok(mut id) = id.lock()
{
*id = Some(value.to_owned());
}
}
}
pub(super) fn response_id(&self) -> Option<String> {
match self {
Self::Responses(facts) => facts.response_id(),
Self::Messages(id) => id.lock().ok().and_then(|id| id.clone()),
Self::Count => None,
}
}
pub(super) fn validate_buffered(&self, bytes: &[u8]) -> Result<(), ()> {
match self {
Self::Responses(facts) => validate_buffered_response(facts, bytes).map_err(|_| ()),
Self::Messages(_) => {
let value: Value = serde_json::from_slice(bytes).map_err(|_| ())?;
let id = value.get("id").and_then(Value::as_str).ok_or(())?;
if value.get("type").and_then(Value::as_str) != Some("message")
|| !value.get("content").is_some_and(Value::is_array)
|| self.response_id().is_some_and(|expected| expected != id)
{
return Err(());
}
Ok(())
}
Self::Count => serde_json::from_slice::<Value>(bytes)
.ok()
.and_then(|value| value.get("input_tokens").and_then(Value::as_u64))
.map(|_| ())
.ok_or(()),
}
}
}
pub(super) enum GenerationDeliveryValidator {
Responses(ResponsesDeliveryValidator),
Messages { id: Option<String>, finished: bool },
}
impl GenerationDeliveryValidator {
pub(super) fn new(protocol: GenerationProtocol) -> Self {
match protocol {
GenerationProtocol::Responses => Self::Responses(ResponsesDeliveryValidator::default()),
_ => Self::Messages {
id: None,
finished: false,
},
}
}
pub(super) fn response_id(&self) -> Option<&str> {
match self {
Self::Responses(validator) => validator.response_id(),
Self::Messages { id, .. } => id.as_deref(),
}
}
pub(super) fn validate_frame(
&mut self,
bytes: &[u8],
transformed: bool,
facts: &GenerationValidationFacts,
) -> Result<(), ()> {
match (self, facts) {
(Self::Responses(validator), GenerationValidationFacts::Responses(facts)) => validator
.validate_frame(bytes, transformed, facts)
.map_err(|_| ()),
(Self::Messages { id, finished }, GenerationValidationFacts::Messages(_)) => {
if bytes.is_empty() {
return if *finished { Ok(()) } else { Err(()) };
}
for event in
parse_sse_events(std::str::from_utf8(bytes).map_err(|_| ())?).map_err(|_| ())?
{
let value: Value = match serde_json::from_str(&event.data) {
Ok(value) => value,
Err(_) if !transformed => continue,
Err(_) => return Err(()),
};
let kind = value
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
if *finished {
return Err(());
}
match kind {
"message_start" => {
let next = value
.pointer("/message/id")
.and_then(Value::as_str)
.ok_or(())?;
if id.is_some()
|| (transformed
&& facts.response_id().is_some_and(|expected| expected != next))
{
return Err(());
}
*id = Some(next.to_owned());
}
"message_stop" => {
if id.is_none() {
return Err(());
}
*finished = true;
}
"error" => *finished = true,
_ => {}
}
}
Ok(())
}
_ => Err(()),
}
}
pub(super) fn finish_delivery(&self) -> Result<(), ()> {
match self {
Self::Responses(validator) => validator.finish_delivery().map_err(|_| ()),
Self::Messages { finished: true, .. } => Ok(()),
_ => Err(()),
}
}
}
@@ -19,7 +19,7 @@ use crate::ApiState;
use crate::middleware::headers::encode_headers;
use crate::openai::middleware::RequestInput;
use crate::openai::{
auth::{authenticate_client, client_access_error_response},
auth::{ClientKeyHeaders, authenticate_client, client_access_error_response},
endpoint::provider_endpoint_response,
responses::{OpenAiRequestHeaders, request_client_context},
router::{IMAGE_EDITS_PATH, IMAGE_GENERATIONS_PATH},
@@ -73,7 +73,7 @@ async fn handle_image_request(
endpoint: &'static str,
) -> Response {
let service = state.openai();
let client = match authenticate_client(service, &headers).await {
let client = match authenticate_client(service, &headers, ClientKeyHeaders::Bearer).await {
Ok(client) => client,
Err(error) => return client_access_error_response(error),
};
@@ -23,7 +23,7 @@ use super::{
use crate::{
ApiState,
openai::{
auth::{authenticate_client, client_access_error_response},
auth::{ClientKeyHeaders, authenticate_client, client_access_error_response},
error::{engine_error_response, gateway_error_response},
middleware::PendingExecution,
responses::request_client_context,
@@ -42,7 +42,7 @@ pub(crate) async fn live_call(
request: Request,
) -> Response {
let service = state.openai();
let client = match authenticate_client(service, &headers).await {
let client = match authenticate_client(service, &headers, ClientKeyHeaders::Bearer).await {
Ok(client) => client,
Err(error) => return client_access_error_response(error),
};
@@ -116,7 +116,7 @@ pub(crate) async fn hangup(
body: Bytes,
) -> Response {
let service = state.openai();
let client = match authenticate_client(service, &headers).await {
let client = match authenticate_client(service, &headers, ClientKeyHeaders::Bearer).await {
Ok(client) => client,
Err(error) => return client_access_error_response(error),
};
@@ -22,7 +22,7 @@ use super::LiveErrorShape;
use crate::{
ApiState,
openai::{
auth::{authenticate_client, client_access_error_response},
auth::{ClientKeyHeaders, authenticate_client, client_access_error_response},
error::runtime_unavailable_response,
live::{
filter_protocol_headers, live_error_response, offered_subprotocols,
@@ -110,7 +110,7 @@ async fn open_sideband(
return upgrade_required_response();
};
let service = state.openai();
let client = match authenticate_client(service, &headers).await {
let client = match authenticate_client(service, &headers, ClientKeyHeaders::Bearer).await {
Ok(client) => client,
Err(error) => return client_access_error_response(error),
};
@@ -0,0 +1,244 @@
//! Messages 与输入计数的 HTTP 入口和原生错误表达
use std::net::SocketAddr;
use axum::{
body::{Body, Bytes},
extract::{Extension, State, connect_info::ConnectInfo},
http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE},
response::Response,
};
use gateway_core::{
error::{GatewayError, GatewayErrorKind},
operation::{GenerateRequest, Operation, ProtocolPayload, RawJsonPayload, TokenCountRequest},
};
use gateway_protocol::{anthropic, openai::sse::encode_sse_event};
use serde_json::{Value, json};
use super::{
auth::{ClientKeyHeaders, authenticate_client, client_access_error_response},
error::gateway_error_response,
generation::{
ContinuationIntent, DecodedGenerationRequest, GenerationHttpRequest, GenerationProtocol,
GenerationRequestMetadata, execute_prepared_generation,
},
responses::{OpenAiRequestHeaders, decompress_request_body},
};
use crate::ApiState;
pub(super) async fn messages(
State(state): State<ApiState>,
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
ingress_id: Option<Extension<tower_http::request_id::RequestId>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
execute(
state,
connect_info,
ingress_id,
headers,
body,
GenerationProtocol::Messages,
)
.await
}
pub(super) async fn count_tokens(
State(state): State<ApiState>,
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
ingress_id: Option<Extension<tower_http::request_id::RequestId>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
execute(
state,
connect_info,
ingress_id,
headers,
body,
GenerationProtocol::MessageTokenCount,
)
.await
}
async fn execute(
state: ApiState,
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
ingress_id: Option<Extension<tower_http::request_id::RequestId>>,
mut headers: HeaderMap,
body: Bytes,
protocol: GenerationProtocol,
) -> Response {
let service = state.openai().clone();
let client = match authenticate_client(&service, &headers, ClientKeyHeaders::Anthropic).await {
Ok(client) => client,
Err(error) => return project_error_response(client_access_error_response(error)).await,
};
let maximum = client.snapshot().responses_max_decompressed_body_bytes();
let body = match decompress_request_body(&body, &headers, maximum) {
Ok(body) => Bytes::copy_from_slice(&body),
Err(error) => {
return error_response(
StatusCode::BAD_REQUEST,
"invalid_request_error",
&error.protocol_body().error.message,
);
}
};
headers.remove(axum::http::header::CONTENT_ENCODING);
headers.remove(axum::http::header::CONTENT_LENGTH);
let decoded = match decode_request(&body, &headers, maximum, protocol) {
Ok(decoded) => decoded,
Err(error) => return project_error_response(gateway_error_response(&error)).await,
};
let prepared = match service.execution().prepare_execution(client).await {
Ok(prepared) => prepared,
Err(error) => return project_error_response(gateway_error_response(&error)).await,
};
execute_prepared_generation(
service,
prepared,
GenerationHttpRequest {
peer_address: connect_info.map(|Extension(ConnectInfo(address))| address),
ingress_id,
headers,
decoded,
middleware_body: body,
},
None,
None,
)
.await
}
pub(super) fn decode_request(
body: &[u8],
headers: &HeaderMap,
maximum: usize,
protocol: GenerationProtocol,
) -> Result<DecodedGenerationRequest, GatewayError> {
let invalid = |message| GatewayError::new(GatewayErrorKind::InvalidRequest, message);
let body = decompress_request_body(body, headers, maximum)
.map_err(|_| invalid("Request body could not be decoded."))?;
let object = serde_json::from_slice::<Value>(&body)
.ok()
.and_then(|value| value.as_object().cloned())
.ok_or_else(|| invalid("Request body must be a JSON object."))?;
let counted = protocol == GenerationProtocol::MessageTokenCount;
let validation = if counted {
anthropic::validate_count_request(&object)
} else {
anthropic::validate_request(&object)
};
validation.map_err(|error| {
invalid(match error {
anthropic::TranslationError::InvalidRequest(message) => message,
_ => "Messages request is invalid.",
})
})?;
let model = object
.get("model")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
let stream = !counted
&& object
.get("stream")
.and_then(Value::as_bool)
.unwrap_or(false);
let context = OpenAiRequestHeaders::from_headers(headers).session_context();
let operation = if counted {
let payload = RawJsonPayload::new(protocol.wire(), Bytes::copy_from_slice(&body))
.map_err(|_| invalid("Request protocol is invalid."))?
.with_context(context);
Operation::CountTokens(TokenCountRequest::from_raw_json(payload))
} else {
let payload = ProtocolPayload::json_object(protocol.wire(), object)
.map_err(|_| invalid("Request protocol is invalid."))?
.with_context(context);
Operation::Generate(GenerateRequest::from_protocol_payload(payload))
};
Ok(DecodedGenerationRequest {
operation,
metadata: GenerationRequestMetadata {
requested_model: model,
stream,
store: false,
continuation: ContinuationIntent::None,
client_ip: None,
user_agent: None,
},
})
}
fn error_type(status: StatusCode) -> &'static str {
match status.as_u16() {
400 | 405 | 422 => "invalid_request_error",
401 => "authentication_error",
403 => "permission_error",
404 => "not_found_error",
413 => "request_too_large",
429 => "rate_limit_error",
503 | 529 => "overloaded_error",
_ => "api_error",
}
}
pub(super) fn error_payload(value: &Value, status: StatusCode) -> Value {
if value.get("type").and_then(Value::as_str) == Some("error") && value.get("error").is_some() {
return value.clone();
}
let error = value
.pointer("/response/error")
.or_else(|| value.get("error"))
.unwrap_or(value);
// 转接 Responses 的互联拒绝时保留机器码,下一节点需能识别相同的环路
if gateway_protocol::peer::is_routing_loop(
Some(status.as_u16()),
error.get("code").and_then(Value::as_str),
) {
return json!({"type": "error", "error": error});
}
let message = error
.get("message")
.and_then(Value::as_str)
.unwrap_or("The gateway could not complete the request.");
json!({"type":"error", "error":{"type":error_type(status), "message":message}})
}
fn error_response(status: StatusCode, kind: &str, message: &str) -> Response {
let mut response = Response::new(Body::from(
json!({"type":"error", "error":{"type":kind,"message":message}}).to_string(),
));
*response.status_mut() = status;
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
response
}
pub(super) async fn project_error_response(response: Response) -> Response {
if response.status().is_success() {
return response;
}
let (mut parts, body) = response.into_parts();
let bytes = axum::body::to_bytes(body, usize::MAX)
.await
.unwrap_or_default();
let value = serde_json::from_slice(&bytes)
.unwrap_or_else(|_| json!({"error":{"message":String::from_utf8_lossy(&bytes)}}));
parts.headers.remove(axum::http::header::CONTENT_LENGTH);
parts
.headers
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
let projected = error_payload(&value, parts.status);
Response::from_parts(parts, Body::from(projected.to_string()))
}
pub(super) fn stream_error(value: &Value) -> Bytes {
Bytes::from(encode_sse_event(
"error",
&error_payload(value, StatusCode::INTERNAL_SERVER_ERROR).to_string(),
))
}
@@ -94,9 +94,10 @@ impl ExpectedBody {
pub(crate) async fn into_http_response(
response: MiddlewareResponse,
expected: ExpectedBody,
expected_protocol: &str,
) -> Response {
let (protocol, status, headers, mut body, _) = response.into_parts();
if protocol != "openai" {
if protocol != expected_protocol {
return fail_before_commit(body, MiddlewareError::InvalidState).await;
}
let head = response_head(status, headers);
@@ -64,6 +64,7 @@ where
Err(error) => return gateway_error_response(&error),
};
let protocol = input.protocol.clone();
let expected_protocol = protocol.clone();
let result = invoke_request(
execution,
prepared,
@@ -79,7 +80,9 @@ where
)
.await;
match result {
Ok(response) => into_http_response(response, ExpectedBody::SingleJson).await,
Ok(response) => {
into_http_response(response, ExpectedBody::SingleJson, &expected_protocol).await
}
Err(error) => error_response(error),
}
}
@@ -4,8 +4,10 @@ pub mod auth;
mod chat_completions;
mod endpoint;
pub mod error;
pub(crate) mod generation;
pub mod images;
pub(crate) mod live;
mod messages;
pub(crate) mod middleware;
pub mod models;
mod peer_link;
+108 -22
View File
@@ -1,4 +1,4 @@
//! OpenAI 模型目录 HTTP adapter
//! 按显式协议头投影模型目录,Codex 目录保留独立合同
use axum::{
Json,
@@ -19,7 +19,7 @@ use crate::{
};
use super::{
auth::{authenticate_client, client_access_error_response},
auth::{ClientKeyHeaders, authenticate_client, client_access_error_response},
error::{model_not_found_response, openai_error_response},
};
@@ -29,6 +29,9 @@ const CODEX_BASE_INSTRUCTIONS: &str = "You are Codex, a coding agent. Follow the
#[derive(Debug, Deserialize)]
pub(crate) struct ModelsQuery {
client_version: Option<String>,
after_id: Option<String>,
before_id: Option<String>,
limit: Option<String>,
}
/// `GET /v1/models`
@@ -38,28 +41,37 @@ pub(crate) async fn models(
Query(query): Query<ModelsQuery>,
headers: HeaderMap,
) -> Response {
let anthropic = headers.contains_key("anthropic-version") || headers.contains_key("x-api-key");
let service = state.openai();
let client = match authenticate_client(service, &headers).await {
let client = match authenticate_client(service, &headers, ClientKeyHeaders::Anthropic).await {
Ok(client) => client,
Err(error) => return client_access_error_response(error),
Err(error) => {
return model_protocol_response(client_access_error_response(error), anthropic).await;
}
};
middleware::query_response(
service.execution(),
client,
RequestInput::query(super::router::MODELS_PATH.to_owned(), None, &headers),
move |client| models_response(state, client, query),
)
.await
let mut input = RequestInput::query(super::router::MODELS_PATH.to_owned(), None, &headers);
if anthropic {
input.protocol = "anthropic".to_owned();
}
let response = middleware::query_response(service.execution(), client, input, move |client| {
models_response(state, client, query, anthropic)
})
.await;
model_protocol_response(response, anthropic).await
}
async fn models_response(
state: ApiState,
client: AuthenticatedClient,
query: ModelsQuery,
anthropic: bool,
) -> Response {
let service = state.openai();
if anthropic {
return anthropic_models_response(service.public_models(&client), &query);
}
if let Some(version) = query
.client_version
.as_deref()
@@ -147,28 +159,37 @@ pub(crate) async fn model_detail(
headers: HeaderMap,
Path(model_id): Path<String>,
) -> Response {
let anthropic = headers.contains_key("anthropic-version") || headers.contains_key("x-api-key");
let service = state.openai();
let client = match authenticate_client(service, &headers).await {
let client = match authenticate_client(service, &headers, ClientKeyHeaders::Anthropic).await {
Ok(client) => client,
Err(error) => return client_access_error_response(error),
Err(error) => {
return model_protocol_response(client_access_error_response(error), anthropic).await;
}
};
middleware::query_response(
let mut input = RequestInput::query(
format!("{}/{model_id}", super::router::MODELS_PATH),
Some(model_id.clone()),
&headers,
);
if anthropic {
input.protocol = "anthropic".to_owned();
}
let response = middleware::query_response(
service.execution(),
client,
RequestInput::query(
format!("{}/{model_id}", super::router::MODELS_PATH),
Some(model_id.clone()),
&headers,
),
move |client| async move { model_detail_response(&state, &client, model_id) },
input,
move |client| async move { model_detail_response(&state, &client, model_id, anthropic) },
)
.await
.await;
model_protocol_response(response, anthropic).await
}
fn model_detail_response(
state: &ApiState,
client: &AuthenticatedClient,
model_id: String,
anthropic: bool,
) -> Response {
let service = state.openai();
let Ok(public_model) = PublicModelId::new(model_id) else {
@@ -180,11 +201,76 @@ fn model_detail_response(
(
StatusCode::OK,
Json(openai_model_json(public_model.as_str())),
Json(if anthropic {
anthropic_model_json(public_model.as_str())
} else {
openai_model_json(public_model.as_str())
}),
)
.into_response()
}
async fn model_protocol_response(response: Response, anthropic: bool) -> Response {
if anthropic {
super::messages::project_error_response(response).await
} else {
response
}
}
fn anthropic_models_response(models: Vec<String>, query: &ModelsQuery) -> Response {
let limit = match query.limit.as_deref().unwrap_or("20").parse::<usize>() {
Ok(limit @ 1..=1000) => limit,
_ => {
return openai_error_response(
StatusCode::BAD_REQUEST,
"Model list limit must be between 1 and 1000.",
"invalid_request_error",
"invalid_limit",
)
.into_response();
}
};
let cursor = |value: &Option<String>| -> Result<Option<usize>, ()> {
value
.as_ref()
.map(|id| models.iter().position(|model| model == id).ok_or(()))
.transpose()
};
let (Ok(after), Ok(before)) = (cursor(&query.after_id), cursor(&query.before_id)) else {
return openai_error_response(
StatusCode::BAD_REQUEST,
"The model pagination cursor was not found.",
"invalid_request_error",
"invalid_cursor",
)
.into_response();
};
let start = after.map_or(0, |index| index + 1);
let end = before.unwrap_or(models.len());
let range = if before.is_some() {
start.max(end.saturating_sub(limit))..end
} else {
start..end.min(start.saturating_add(limit))
};
let data = models
.get(range.clone())
.unwrap_or_default()
.iter()
.map(|model| anthropic_model_json(model))
.collect::<Vec<_>>();
let has_more = if before.is_some() {
range.start > start
} else {
range.end < end
};
Json(json!({"first_id":data.first().and_then(|model| model.get("id")), "last_id":data.last().and_then(|model| model.get("id")), "has_more":has_more, "data":data})).into_response()
}
fn anthropic_model_json(id: &str) -> Value {
json!({"id":id, "type":"model", "display_name":id, "created_at":"1970-01-01T00:00:00Z", "capabilities":null, "max_input_tokens":null, "max_tokens":null, "line":null, "lifecycle":"active", "deprecated_at":null, "retires_at":null})
}
fn openai_model_json(id: &str) -> Value {
json!({
"id": id,
@@ -2,13 +2,12 @@
use chrono::{DateTime, Utc};
use gateway_admin::model::peer_link::{PeerLinkExport, PeerModelState as ModelState};
use gateway_core::metering::Decimal;
use gateway_core::{engine::budget::ClientBudgetWindow, metering::Decimal};
use gateway_protocol::peer::{
OBJECT, PeerBudgetWindow, PeerComponentStatus, PeerKeyLimits, PeerKeyUsage, PeerLink,
PeerModelAvailability, PeerModelCoverage, PeerModelState, PeerPlanCount, PeerPool,
PeerPoolStates, PeerQuota, PeerQuotaAccount, PeerQuotaWindow, PeerUsage, SCHEMA_VERSION,
};
use std::time::SystemTime;
pub(super) fn response(export: PeerLinkExport) -> PeerLink {
let mut capabilities = vec!["key_usage".to_owned(), "key_limits".to_owned()];
@@ -88,19 +87,17 @@ pub(super) fn response(export: PeerLinkExport) -> PeerLink {
budget_window(
"daily",
"日额度",
86400,
export.budget.limits.daily_usd,
export.budget.daily_used_usd,
export.budget.daily_resets_at,
export.budget.daily_window,
export.budget_access.daily_limit_reached,
),
budget_window(
"weekly",
"周额度",
604800,
export.budget.limits.weekly_usd,
export.budget.weekly_used_usd,
export.budget.weekly_resets_at,
export.budget.weekly_window,
export.budget_access.weekly_limit_reached,
),
],
@@ -133,21 +130,24 @@ pub(super) fn response(export: PeerLinkExport) -> PeerLink {
fn budget_window(
key: &str,
label: &str,
window_seconds: u64,
total: Decimal,
used: Decimal,
resets_at: Option<SystemTime>,
window: Option<ClientBudgetWindow>,
limit_reached: bool,
) -> PeerBudgetWindow {
let limited = total != Decimal::ZERO;
let window = window.filter(|w| w.start < w.end);
PeerBudgetWindow {
key: key.to_owned(),
label: Some(label.to_owned()),
window_seconds: Some(window_seconds),
window_seconds: window.and_then(|w| {
let duration = w.end.duration_since(w.start).ok()?;
(duration.subsec_nanos() == 0).then_some(duration.as_secs())
}),
limit_reached: Some(limit_reached),
total: limited.then(|| total.canonical()),
used: used.canonical(),
remaining: limited.then(|| total.saturating_sub(used).canonical()),
reset_at: resets_at.map(|time| DateTime::<Utc>::from(time).to_rfc3339()),
reset_at: window.map(|w| DateTime::<Utc>::from(w.end).to_rfc3339()),
}
}
File diff suppressed because it is too large Load Diff
@@ -18,17 +18,18 @@ use tokio::sync::oneshot;
use crate::openai::middleware::{SSE_KEEPALIVE_FRAME, SSE_KEEPALIVE_INTERVAL};
pub(super) async fn response(
pub(in crate::openai) async fn response(
response: impl Future<Output = Response> + Send + 'static,
admitted: oneshot::Receiver<ResponseCommit>,
) -> Response {
response_with_encoder(response, admitted, None).await
response_with_encoder(response, admitted, None, true).await
}
pub(super) async fn response_with_encoder(
pub(in crate::openai) async fn response_with_encoder(
response: impl Future<Output = Response> + Send + 'static,
admitted: oneshot::Receiver<ResponseCommit>,
error_encoder: Option<fn(&Value) -> Bytes>,
append_done: bool,
) -> Response {
let mut response = Box::pin(response);
// 鉴权、模型门禁和准入失败仍保留原 HTTP 状态,不提前建立 SSE
@@ -48,7 +49,7 @@ pub(super) async fn response_with_encoder(
if !commit.record(Some(StatusCode::OK.as_u16())) {
return response.await;
}
let pending = PendingResponse::Waiting(response, error_encoder);
let pending = PendingResponse::Waiting(response, error_encoder, append_done);
let stream = stream::once(async { Ok::<_, axum::Error>(SSE_KEEPALIVE_FRAME) })
.chain(stream::try_unfold(pending, PendingResponse::next));
let mut response = Response::new(Body::from_stream(stream));
@@ -66,7 +67,11 @@ pub(super) async fn response_with_encoder(
}
enum PendingResponse {
Waiting(BoxFuture<'static, Response>, Option<fn(&Value) -> Bytes>),
Waiting(
BoxFuture<'static, Response>,
Option<fn(&Value) -> Bytes>,
bool,
),
Streaming(BodyDataStream),
}
@@ -74,7 +79,7 @@ impl PendingResponse {
async fn next(mut self) -> Result<Option<(Bytes, Self)>, axum::Error> {
loop {
match &mut self {
Self::Waiting(response, error_encoder) => {
Self::Waiting(response, error_encoder, append_done) => {
// 心跳只轮询同一个 future,不取消重建首帧等待或插件调用
let response = tokio::select! {
biased;
@@ -93,7 +98,7 @@ impl PendingResponse {
let body = if sse {
response.into_body()
} else {
stream_failure(response, *error_encoder).await
stream_failure(response, *error_encoder, *append_done).await
};
// 正式响应的正文已经带有交付期心跳,后续只转交,不叠加定时器
self = Self::Streaming(body.into_data_stream());
@@ -110,7 +115,11 @@ impl PendingResponse {
}
}
async fn stream_failure(response: Response, error_encoder: Option<fn(&Value) -> Bytes>) -> Body {
async fn stream_failure(
response: Response,
error_encoder: Option<fn(&Value) -> Bytes>,
append_done: bool,
) -> Body {
let status = response.status();
let retry_after = response
.headers()
@@ -157,6 +166,8 @@ async fn stream_failure(response: Response, error_encoder: Option<fn(&Value) ->
Some(encode) => encode(&data).to_vec(),
None => encode_sse_event("response.failed", &data.to_string()).into_bytes(),
};
event.extend_from_slice(DONE_SSE_FRAME.as_bytes());
if append_done {
event.extend_from_slice(DONE_SSE_FRAME.as_bytes());
}
Body::from(event)
}
@@ -2,25 +2,22 @@
mod error;
mod http;
mod keepalive;
mod request;
mod response;
mod validation;
pub(in crate::openai) mod keepalive;
pub(in crate::openai) mod request;
pub(in crate::openai) mod response;
pub(in crate::openai) mod validation;
pub mod websocket;
pub use crate::client_error::{ProtocolError, ProtocolErrorBody};
pub use crate::openai::generation::http::{collect_execution_response, stream_execution_response};
pub use crate::openai::generation::{
ContinuationIntent, DecodedGenerationRequest, GenerationRequestMetadata,
};
pub(crate) use crate::request_context::request_client_context;
pub use error::{RequestDecodeError, ResponseEncodeError};
pub(in crate::openai) use http::{HttpResponseAdapter, execute_prepared_response};
pub(crate) use http::{
ResponseAuthorization, ResponsesHttpRequest, execute_prepared_responses, responses,
};
pub use http::{collect_execution_response, stream_execution_response};
pub(crate) use http::responses;
pub(in crate::openai) use request::decompress_request_body;
pub use request::{
ContinuationIntent, DecodedResponsesRequest, OpenAiRequestHeaders, ResponsesRequestMetadata,
decode_request_with_headers,
};
pub use request::{OpenAiRequestHeaders, decode_request_with_headers};
pub(crate) use request::{decode_request_with_body, passthrough_request_headers};
pub use response::OpenAiResponsesEncoder;
pub(crate) use websocket::responses_websocket;

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