Compare commits

..
55 Commits
Author SHA1 Message Date
ZyyandCodex bd69329a37 chore: bump version to 3.45.2
Co-authored-by: Codex <noreply@openai.com>
2026-11-11 17:04:39 +08:00
ZyyandCodex 151a3c6bfd fix(openai): negotiate Codex catalogs for Responses upstreams
Co-authored-by: Codex <noreply@openai.com>
2026-11-11 16:49:10 +08:00
ZyyandCodex e2f194875f refactor: clarify protocol ownership and request observations
Separate protocol identifiers from Provider identities and centralize shared
request facts and observations in gateway-protocol. Keep endpoint models
and private continuation state owned by Providers.

Reorganize the architecture documentation and clarify the shutdown guarantees
for buffered charges and observations.

Validated with workspace formatting, strict Clippy, 3013 affected-module
tests and 16 architecture checks. One reference-binary TLS test is ignored.

Co-authored-by: Codex <noreply@openai.com>
2026-11-11 16:04:16 +08:00
ZyyandCodex 07b61bdc83 fix(ui): keep help buttons aligned with form labels and clarify hints
Co-authored-by: Codex <noreply@openai.com>
2026-11-11 15:13:00 +08:00
c0fdc1bbff fix(openai): record explicit Anthropic reasoning effort (#680)
Observe top-level output_config.effort before the OpenAI encoder rejects
Messages payloads. Reuse the protocol parser without inferring an effort
from thinking budgets or changing upstream conversion behavior.

Cover absent and explicit effort values, native Messages forwarding, and
Messages-to-Responses conversion. Document the observation contract.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-11 15:12:02 +08:00
343c579017 fix(openai): rotate new round-robin sessions without affinity hits (#678)
* fix(openai): rotate new round-robin sessions without affinity hits

Strict affinity keeps a session on one account, but the shared request
cursor advanced on every scheduling load, including affinity hits. A new
session then took whatever slot that request volume happened to leave.

Advance a separate session cursor only when round-robin assigns an
account, and keep the request cursor for other strategies.

* fix(openai): advance round robin when an account assignment succeeds

Commit the session binding and round-robin cursor together after acquiring the account lease. Release a conflicting lease before retrying so concurrent first requests share a binding without consuming extra turns. Count delegated selections and preserve explicit plugin picks.

Verified with OpenAI provider tests, store tests against Redis and PostgreSQL, architecture checks, and workspace Clippy.

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

---------

Co-authored-by: koeria <riennie@qq.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-11 14:40:59 +08:00
koeriaandCursor Agent 914c00785c fix(openai): skip same-account retry on NO_MORE_RETRY (#675)
Treat x-retry-metadata: NO_MORE_RETRY as a hard signal to skip
same-account backoff and transport recovery, even when Retry-After
is within maxRetryAfterSeconds. Capacity failures still rotate under
the existing policy; server_error still returns the upstream error.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-11-11 14:08:24 +08:00
ZyyandCodex b3de0d637d fix(peer): send routing headers only to discovered nodes
Reuse connection-scoped peer discovery for native HTTP and WebSocket forwarding. Strip the route header for ordinary upstreams and leave explicit plugin forwarding changes to plugins.

Co-authored-by: Codex <noreply@openai.com>
2026-11-11 13:14:25 +08:00
ZyyandCodex 1271fa0186 test(openai): verify platform TLS against official Codex
Compare native HTTP, custom CA HTTP, and WebSocket ClientHello captures with an official Codex executable on the same platform. Document the explicit reference test workflow.

Co-authored-by: Codex <noreply@openai.com>
2026-11-11 12:41:04 +08:00
ZyyandCodex 055da40476 feat(keys): enable async compaction in Codex configs
Co-authored-by: Codex <noreply@openai.com>
2026-11-11 12:37:12 +08:00
ZyyandCodex 36ba79ca5e fix(openai): preserve native Responses service tiers
Limit default tier omission to Messages-to-OAuth conversion so native Responses retain their requested tier and routing hint.

Co-authored-by: Codex <noreply@openai.com>
2026-11-11 11:33:01 +08:00
github-actions[bot] 1b24029a97 chore(release): update release channels to 3.45.1 2026-11-11 00:03:25 +00:00
zyycnandCodex 431992a7a4 chore: bump version to 3.45.1
Co-authored-by: Codex <noreply@openai.com>
2026-11-11 07:41:09 +08:00
ZyyandCodex d3143406dc fix(openai): restore account catalogs without blocking requests
Persist validated directories per credential revision. Restore them in bulk and refresh only missing, expired, or changed accounts with paced background requests. Filter large pools using current per-account evidence while preserving unknown and explicitly allowed candidates.

Co-authored-by: Codex <noreply@openai.com>
2026-11-11 03:46:25 +08:00
github-actions[bot] 478f2d1f21 chore(release): update release channels to 3.45.0 2026-11-10 18:15:24 +00:00
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
429 changed files with 22458 additions and 6602 deletions
+1 -1
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
+5 -5
View File
@@ -115,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
@@ -340,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 }}
@@ -434,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 }}
@@ -539,7 +539,7 @@ jobs:
- 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
+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
+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 -30
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,7 +1853,7 @@ dependencies = [
"tracing-appender",
"tracing-subscriber",
"wiremock",
"yaml-rust2",
"yaml-rust2 0.13.0",
]
[[package]]
@@ -2065,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"
@@ -2577,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",
@@ -2936,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",
]
@@ -2983,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",
@@ -3334,6 +3361,7 @@ dependencies = [
"hyper",
"hyper-rustls",
"hyper-util",
"lru",
"native-tls",
"openssl",
"openssl-sys",
@@ -3352,6 +3380,7 @@ dependencies = [
"tar",
"tempfile",
"thiserror",
"tiktoken-rs",
"tokio",
"tokio-rustls",
"tokio-tungstenite 0.28.0",
@@ -4218,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",
@@ -4230,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",
@@ -4245,7 +4274,7 @@ dependencies = [
"futures-io",
"futures-util",
"hashbrown 0.16.1",
"hashlink",
"hashlink 0.11.1",
"indexmap",
"log",
"memchr",
@@ -4267,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",
@@ -4279,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",
@@ -4304,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",
@@ -4331,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",
@@ -4367,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",
@@ -4594,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"
@@ -5519,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]]
@@ -5647,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,7 @@ 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,
@@ -111,6 +112,8 @@ 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,
@@ -251,6 +254,7 @@ 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>,
@@ -261,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 {
@@ -293,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>,
@@ -845,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>,
@@ -852,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>,
@@ -70,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>,
}
@@ -233,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(())
@@ -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,
@@ -759,6 +759,7 @@ 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),
@@ -768,6 +769,7 @@ impl AccountsService for DefaultAccountsService {
group_ids: None,
outbound_proxy: None,
},
Default::default(),
context,
)
.await
@@ -841,8 +843,52 @@ impl AccountsService for DefaultAccountsService {
Vec<ProviderAccountId>,
),
>::new();
let mut pauses = BTreeMap::new();
for account_id in &account_ids {
let (item, provider) = self.provider_for_account(account_id).await?;
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)
@@ -864,7 +910,7 @@ impl AccountsService for DefaultAccountsService {
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() {
@@ -1214,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)
@@ -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),
@@ -52,7 +52,7 @@ impl PeerInfoService {
});
let limits = peer.and_then(|peer| peer.key_limits);
let info = ProviderUpstreamInfo {
discovered: peer.is_some() || stored_failure == Some(UpstreamInfoFailure::Unauthorized),
discovered: snapshot::is_discovered(peer, stored_failure),
observed_at,
stale,
connection_revision: connection.revision.clone(),
@@ -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,
@@ -282,6 +282,21 @@ impl PeerInfoService {
}
impl UpstreamInfoSource for PeerInfoService {
fn is_peer(&self, account: &ProviderAccount) -> bool {
if !self.supported(account) {
return false;
}
let connection = UpstreamConnection::for_account(account);
self.observations
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(account.id())
.is_some_and(|cached| {
cached.connection == connection
&& snapshot::is_discovered(cached.summary.as_deref(), cached.stored_failure)
})
}
fn signal(
&self,
account: &ProviderAccount,
@@ -16,6 +16,10 @@ use serde::Deserialize as _;
pub(super) const MAX_AGE: Duration = Duration::from_secs(30 * 60);
pub(super) const FRESH_FOR: Duration = Duration::from_secs(5 * 60);
pub(super) fn is_discovered(peer: Option<&PeerLink>, failure: Option<UpstreamInfoFailure>) -> bool {
peer.is_some() || failure == Some(UpstreamInfoFailure::Unauthorized)
}
// 不支持协议是连接的识别结果,复用观测存储与版本保护,不伪造上游额度快照
pub(super) fn unsupported(connection: &UpstreamConnection) -> OpaqueProviderData {
OpaqueProviderData::new(serde_json::Map::from_iter([
@@ -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(
@@ -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,
@@ -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>,
}
@@ -1183,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,
},
@@ -1908,6 +1913,7 @@ 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,
@@ -2076,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,
@@ -2236,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,
@@ -2287,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,
@@ -2893,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,
@@ -3057,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,
@@ -3362,6 +3373,7 @@ pub(super) fn document() -> ProviderDocument {
fn empty_quota() -> ProviderQuota {
ProviderQuota {
paused_windows: Vec::new(),
upstream: None,
credits: None,
plan_type: None,
@@ -3399,6 +3411,8 @@ 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,
@@ -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,
@@ -140,6 +140,10 @@ impl PendingPeer {
}
impl UpstreamInfoSource for PendingPeer {
fn is_peer(&self, _: &ProviderAccount) -> bool {
false
}
fn signal(
&self,
_: &ProviderAccount,
@@ -73,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,
@@ -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();
@@ -1437,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,
},
})
@@ -1519,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,
@@ -1527,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(),
@@ -239,6 +239,7 @@ async fn discovery_uses_provider_capabilities_and_publishes_neutral_signals() {
initialize_peer_upstream(accounts.clone(), &registry, transport.clone()).unwrap();
let initial_accounts = accounts.accounts.lock().unwrap().clone();
let reader = UpstreamInfoReader::default();
assert!(!reader.is_peer(&initial_accounts[0]));
assert!(
reader
.signal(&initial_accounts[0], Some("model"), SystemTime::now())
@@ -246,6 +247,7 @@ async fn discovery_uses_provider_capabilities_and_publishes_neutral_signals() {
);
let source = bundle.scheduling_source();
reader.bind(&source).unwrap();
assert!(!reader.is_peer(&initial_accounts[0]));
assert!(reader.bind(&source).is_err());
drop(source);
let WorkerContribution::Registration(worker) = bundle.take_worker_contributions().remove(0)
@@ -272,6 +274,10 @@ async fn discovery_uses_provider_capabilities_and_publishes_neutral_signals() {
}
);
for account in &initial_accounts[..2] {
assert_eq!(
reader.is_peer(account),
Some(account.provider().as_str()) != failed_provider
);
if Some(account.provider().as_str()) == failed_provider {
assert!(
reader
@@ -304,6 +310,7 @@ async fn discovery_uses_provider_capabilities_and_publishes_neutral_signals() {
);
drop(worker);
drop(bundle);
assert!(!reader.is_peer(&initial_accounts[0]));
assert!(
reader
.signal(&initial_accounts[0], Some("model"), SystemTime::now())
@@ -340,6 +347,7 @@ async fn account_edits_preserve_observation_and_refresh_budget_until_connection_
.is_some()
);
let after = service.refresh_quota(edited.id()).await.unwrap();
assert!(bundle.scheduling_source().is_peer(&edited));
assert_eq!(after.upstream, before.upstream);
assert_eq!(transport.0.lock().unwrap().len(), 1);
@@ -347,6 +355,7 @@ async fn account_edits_preserve_observation_and_refresh_budget_until_connection_
OutboundProxy::parse("http://proxy.invalid:8080").unwrap(),
));
*accounts.accounts.lock().unwrap() = vec![reconnected.clone()];
assert!(!bundle.scheduling_source().is_peer(&reconnected));
service.account_changed(reconnected.id()).await.unwrap();
assert!(
bundle
@@ -422,6 +431,21 @@ fn loaded(account: &ProviderAccount) -> LoadedCredential {
#[async_trait]
impl ProviderAccountStore for TestAccounts {
async fn load_account_catalogs(
&self,
_: &gateway_core::routing::ProviderKind,
) -> Result<Vec<gateway_core::account::StoredAccountCatalog>, gateway_core::error::StoreError>
{
Ok(Vec::new())
}
async fn save_account_catalog(
&self,
_: &gateway_core::account::AccountCatalogObservation,
_: &[u8],
) -> Result<bool, gateway_core::error::StoreError> {
unreachable!("fixture does not discover account catalogs")
}
async fn create_account(&self, _account: NewProviderAccount) -> Result<(), StoreError> {
panic!("unused account operation")
}
@@ -215,6 +215,10 @@ async fn known_peer_identity_changes_only_on_definitive_detection() {
quota.upstream
);
let account = accounts.accounts.lock().unwrap()[0].clone();
assert_eq!(
bundle.scheduling_source().is_peer(&account),
!clears_identity
);
assert_eq!(
bundle
.scheduling_source()
@@ -237,6 +241,7 @@ async fn known_peer_identity_changes_only_on_definitive_detection() {
drop(bundle);
let restarted = initialize(accounts, transport);
let restored = restarted.service().read_quota(&id).await.unwrap().unwrap();
assert!(!restarted.scheduling_source().is_peer(&account));
assert_eq!(
restored.upstream.as_ref().unwrap().discovered,
!clears_identity
@@ -258,6 +263,10 @@ async fn known_peer_identity_changes_only_on_definitive_detection() {
.upstream,
restored.upstream
);
assert_eq!(
restarted.scheduling_source().is_peer(&account),
!clears_identity
);
}
}
@@ -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),
@@ -351,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,
},
},
@@ -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,7 @@ 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(),
@@ -272,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
@@ -306,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,
@@ -318,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,7 @@ 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>,
@@ -118,7 +119,8 @@ impl BatchUpdateAccountsRequest {
.map(parse_concurrency_limit)
.transpose()?;
self.weight.map(parse_account_weight).transpose()?;
if self.auto_use_quota_credits.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()
@@ -135,6 +137,7 @@ 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,
@@ -259,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,
@@ -269,6 +273,7 @@ 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,
@@ -322,6 +327,7 @@ 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,
@@ -341,6 +347,7 @@ 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,
}
@@ -392,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,
@@ -419,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>,
@@ -1100,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,
@@ -1129,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,
@@ -1140,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,
@@ -1235,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>,
@@ -1255,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))
@@ -61,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>,
}
@@ -773,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,
@@ -812,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,
@@ -828,7 +832,7 @@ 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",
"key_queue_timeout_seconds" => "keyQueueTimeoutSeconds",
"account_queue_timeout_seconds" => "accountQueueTimeoutSeconds",
@@ -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 =
@@ -22,6 +22,7 @@ use gateway_core::{
error::GatewayError,
operation::Operation,
};
use gateway_protocol::openai::OPENAI_PROTOCOL;
use crate::client_error::{ProtocolError, ProtocolErrorBody};
use crate::middleware::headers::encode_headers;
@@ -65,7 +66,7 @@ where
Box::new(move |prepared, request| {
Box::pin(async move {
let (protocol, headers, body) = request_parts(request.clone())?;
if protocol != "openai" {
if protocol != OPENAI_PROTOCOL {
return Err(MiddlewareError::Rejected);
}
let operation = request.apply_capabilities(decode(body, headers).await?)?;
@@ -80,7 +81,8 @@ where
{
Ok(started) => started,
Err(error) => {
return buffered_response("openai", gateway_error_response(&error)).await;
return buffered_response(OPENAI_PROTOCOL, gateway_error_response(&error))
.await;
}
};
collect_raw_json_response(started).await
@@ -89,7 +91,9 @@ where
)
.await;
let response = match result {
Ok(response) => into_http_response(response, ExpectedBody::SingleJson).await,
Ok(response) => {
into_http_response(response, ExpectedBody::SingleJson, OPENAI_PROTOCOL).await
}
Err(error) => error_response(error),
};
if !execution_entered.load(Ordering::Relaxed) {
@@ -143,7 +147,7 @@ async fn collect_raw_json_response(
.await
.map_err(|_| MiddlewareError::Fault)?;
Ok(pending_execution_response(
"openai".to_owned(),
OPENAI_PROTOCOL.to_owned(),
parts.status.as_u16(),
encode_headers(&parts.headers),
MiddlewareFrame::new(bytes, framing, true).with_transformed(transformed),
@@ -155,7 +159,7 @@ fn raw_json_body(events: Vec<ProviderEvent>) -> Option<Bytes> {
let mut body = None;
for event in events {
let (_, wire) = event.into_parts();
let Some(wire) = wire.filter(|wire| wire.protocol() == "openai") else {
let Some(wire) = wire.filter(|wire| wire.protocol() == OPENAI_PROTOCOL) else {
continue;
};
let Some(raw) = wire.into_raw_json_body() else {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,82 @@
//! 生成与计数协议共用的解码事实和 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,
};
use gateway_protocol::anthropic::ANTHROPIC_PROTOCOL;
use gateway_protocol::openai::OPENAI_PROTOCOL;
#[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_PROTOCOL {
Self::Messages
} else {
Self::Responses
}
}
pub(crate) const fn wire(self) -> &'static str {
match self {
Self::Responses => OPENAI_PROTOCOL,
Self::Messages | Self::MessageTokenCount => ANTHROPIC_PROTOCOL,
}
}
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,192 @@
//! 原生协议出口保留 wire,只在非流式交付时聚合消息正文
use axum::body::Bytes;
use gateway_core::{error::GatewayError, event::ProviderEvent};
use gateway_protocol::anthropic::ANTHROPIC_PROTOCOL;
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_PROTOCOL)
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,188 @@
//! 出口校验按协议维护边界事实,不在流式路径保存生成正文
use gateway_core::event::ProviderEvent;
use gateway_protocol::anthropic::ANTHROPIC_PROTOCOL;
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_PROTOCOL)
.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(()),
}
}
}
@@ -13,19 +13,19 @@ use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::operation::{
ImageRequest, ImageRequestKind, Operation, OperationKind, RawJsonPayload,
};
use gateway_protocol::openai::OPENAI_PROTOCOL;
use serde_json::Value;
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},
};
const OPENAI_PROTOCOL: &str = "openai";
const IMAGE_TURN_ID_CONTEXT_KEY: &str = "image_turn_id";
/// `POST /v1/images/generations`
@@ -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),
};
@@ -13,6 +13,7 @@ use gateway_core::engine::execution::StartedExecution;
use gateway_core::event::ProviderEvent;
use gateway_core::live::LiveHangupRequest;
use gateway_core::operation::Operation;
use gateway_protocol::openai::OPENAI_PROTOCOL;
use serde_json::Value;
use super::{
@@ -23,7 +24,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,
@@ -31,9 +32,6 @@ use crate::{
},
};
/// 引导 wire 事件使用的协议名;与 Provider 侧 `PROVIDER_NAME` 一致。
const LIVE_PROTOCOL: &str = "openai";
pub(crate) async fn live_call(
State(state): State<ApiState>,
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
@@ -42,7 +40,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 +114,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),
};
@@ -344,7 +342,7 @@ fn live_call_body(events: Vec<ProviderEvent>) -> Option<Bytes> {
let mut raw_events = 0usize;
for event in events {
let (_, wire) = event.into_parts();
let Some(wire) = wire.filter(|wire| wire.protocol() == LIVE_PROTOCOL) else {
let Some(wire) = wire.filter(|wire| wire.protocol() == OPENAI_PROTOCOL) else {
continue;
};
let raw = wire.into_raw_http_body()?;
@@ -20,6 +20,7 @@ use gateway_core::event::ProviderResponseHeader;
use gateway_core::operation::{
ProviderHttpHeader, ProviderHttpMethod, ProviderHttpRequest, RawHttpPayload,
};
use gateway_protocol::openai::OPENAI_PROTOCOL;
use serde_json::{Value, json};
use crate::openai::error::openai_error_response;
@@ -172,7 +173,7 @@ pub(crate) fn live_call_operation(
"invalid_request",
))
};
let payload = RawHttpPayload::new("openai", body).map_err(|_| invalid())?;
let payload = RawHttpPayload::new(OPENAI_PROTOCOL, body).map_err(|_| invalid())?;
ProviderHttpRequest::new(
LIVE_CALLS_ENDPOINT,
ProviderHttpMethod::Post,
@@ -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);
@@ -20,6 +20,7 @@ use gateway_core::{
lifecycle::CancellationToken,
operation::OperationKind,
};
use gateway_protocol::openai::OPENAI_PROTOCOL;
use std::{future::Future, sync::Arc};
use super::http::{ExpectedBody, buffered_response, error_response, into_http_response};
@@ -38,7 +39,7 @@ impl RequestInput {
pub(crate) fn query(endpoint: String, model_hint: Option<String>, headers: &HeaderMap) -> Self {
Self {
endpoint,
protocol: "openai".to_owned(),
protocol: OPENAI_PROTOCOL.to_owned(),
operation: None,
transport: ClientTransport::HttpJson,
model_hint,
@@ -64,6 +65,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 +81,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;
+109 -22
View File
@@ -1,4 +1,4 @@
//! OpenAI 模型目录 HTTP adapter
//! 按显式协议头投影模型目录,Codex 目录保留独立合同
use axum::{
Json,
@@ -10,6 +10,7 @@ use gateway_core::{
engine::execution::AuthenticatedClient,
routing::{PublicModelDescriptor, PublicModelId, PublicModelProfile},
};
use gateway_protocol::anthropic::ANTHROPIC_PROTOCOL;
use serde::Deserialize;
use serde_json::{Value, json};
@@ -19,7 +20,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 +30,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 +42,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_PROTOCOL.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 +160,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_PROTOCOL.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 +202,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;
@@ -1,13 +1,12 @@
//! OpenAI Responses JSON 到 Core 路由事实与不透明 wire payload 的单一解码边界
use std::{borrow::Cow, fmt, net::IpAddr};
use std::borrow::Cow;
use axum::http::{HeaderMap, HeaderName, HeaderValue};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use bytes::Bytes;
use gateway_core::operation::{
ClientTurn, GenerateRequest, Operation, ProtocolPayload, ProviderSessionState,
};
use gateway_core::operation::{ClientTurn, GenerateRequest, Operation, ProtocolPayload};
use gateway_protocol::openai::OPENAI_PROTOCOL;
use gateway_protocol::openai::{
X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER, X_OPENAI_MEMGEN_REQUEST_HEADER,
is_transport_managed_request_header,
@@ -15,8 +14,10 @@ use gateway_protocol::openai::{
use serde_json::{Map, Value};
use super::error::RequestDecodeError;
use crate::openai::generation::{
ContinuationIntent, DecodedGenerationRequest, GenerationRequestMetadata,
};
const OPENAI_PROTOCOL: &str = "openai";
const OPENAI_SUBAGENT_KEY: &str = "x-openai-subagent";
const CODEX_TURN_METADATA_KEY: &str = "x-codex-turn-metadata";
const PASSTHROUGH_HEADERS_CONTEXT_KEY: &str = "opaque_request_headers";
@@ -179,162 +180,6 @@ fn insert_protocol_context(context: &mut Map<String, Value>, field: &str, value:
}
}
/// 客户端声明的 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 ResponsesRequestMetadata {
requested_model: String,
stream: bool,
store: bool,
continuation: ContinuationIntent,
client_ip: Option<IpAddr>,
user_agent: Option<String>,
}
impl ResponsesRequestMetadata {
/// 返回客户端原始模型名
#[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 ResponsesRequestMetadata {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ResponsesRequestMetadata")
.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()
}
}
/// 一次成功解码的 Responses 请求
#[derive(Clone, PartialEq)]
pub struct DecodedResponsesRequest {
operation: Operation,
metadata: ResponsesRequestMetadata,
}
impl DecodedResponsesRequest {
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) -> &ResponsesRequestMetadata {
&self.metadata
}
/// 拆分为 operation 与元数据
#[must_use]
pub fn into_parts(self) -> (Operation, ResponsesRequestMetadata) {
(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 DecodedResponsesRequest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("DecodedResponsesRequest")
.field("operation", &self.operation)
.field("metadata", &self.metadata)
.finish()
}
}
/// 使用下游 OpenAI/Codex 请求头解码 `POST /v1/responses`
///
/// # Errors
@@ -344,7 +189,7 @@ pub fn decode_request_with_headers(
body: &[u8],
headers: &HeaderMap,
max_decompressed_bytes: usize,
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
) -> Result<DecodedGenerationRequest, RequestDecodeError> {
let body = decompress_request_body(body, headers, max_decompressed_bytes)?;
decode_request_inner(&body, &OpenAiRequestHeaders::from_headers(headers))
}
@@ -357,7 +202,7 @@ pub(crate) fn decode_request_with_body(
body: &[u8],
headers: &HeaderMap,
max_decompressed_bytes: usize,
) -> Result<(DecodedResponsesRequest, Bytes), RequestDecodeError> {
) -> Result<(DecodedGenerationRequest, Bytes), RequestDecodeError> {
let body = decompress_request_body(body, headers, max_decompressed_bytes)?;
let decoded = decode_request_inner(&body, &OpenAiRequestHeaders::from_headers(headers))?;
Ok((decoded, Bytes::copy_from_slice(&body)))
@@ -449,7 +294,7 @@ fn read_bounded<R: std::io::Read>(
pub(super) fn decode_request_inner(
body: &[u8],
request_headers: &OpenAiRequestHeaders,
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
) -> Result<DecodedGenerationRequest, RequestDecodeError> {
let value =
serde_json::from_slice::<Value>(body).map_err(|_| RequestDecodeError::MalformedJson)?;
let Value::Object(object) = value else {
@@ -463,7 +308,7 @@ pub(super) fn decode_request_object(
mut object: Map<String, Value>,
request_headers: &OpenAiRequestHeaders,
source: RequestDecodeSource,
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
) -> Result<DecodedGenerationRequest, RequestDecodeError> {
// 仅消费已识别的本地 transport 开关;未知同名值保留给未来上游协议
let use_websocket = object.get("use_websocket").and_then(Value::as_bool);
if use_websocket.is_some() {
@@ -523,9 +368,9 @@ pub(super) fn decode_request_object(
let operation = Operation::Generate(
GenerateRequest::from_protocol_payload(payload).with_client_turn(client_turn),
);
Ok(DecodedResponsesRequest {
Ok(DecodedGenerationRequest {
operation,
metadata: ResponsesRequestMetadata {
metadata: GenerationRequestMetadata {
requested_model: model,
stream,
store,
@@ -4,6 +4,7 @@ use std::collections::BTreeMap;
use bytes::Bytes;
use gateway_core::event::{GatewayEvent, ProtocolWireEvent, ProviderEvent};
use gateway_protocol::openai::OPENAI_PROTOCOL;
use gateway_protocol::openai::sse::{
encode_sse_event_with_metadata, response_failed_sse_data_from_error_event,
};
@@ -12,8 +13,6 @@ use serde_json::Value;
use super::error::ResponseEncodeError;
use crate::client_error::capacity_error_for_client;
const OPENAI_PROTOCOL: &str = "openai";
/// OpenAI Responses wire 的转发器
///
/// OpenAI Provider 与 xAI adapter 都必须交付 OpenAI wire
@@ -187,7 +186,9 @@ impl OpenAiResponsesEncoder {
}
/// 仅在非流式出口聚合同一响应的完成项,不让流式转发常驻保存输出内容
pub(super) fn collect_response(events: &[ProviderEvent]) -> Result<Value, ResponseEncodeError> {
pub(in crate::openai) fn collect_response(
events: &[ProviderEvent],
) -> Result<Value, ResponseEncodeError> {
let mut encoder = OpenAiResponsesEncoder::new();
let mut response_id = None;
let mut items = BTreeMap::new();
@@ -6,6 +6,7 @@ use std::{
};
use gateway_core::event::{GatewayEvent, ProtocolWireEvent, ProviderEvent};
use gateway_protocol::openai::OPENAI_PROTOCOL;
use gateway_protocol::openai::sse::{parse_sse_events, sse_frame_is_done};
use serde_json::{Map, Value};
@@ -14,7 +15,7 @@ const MAX_IDENTIFIER_BYTES: usize = 1_024;
/// 插件输出不能安全投递为 OpenAI Responses 协议
#[derive(Debug, Clone, Copy)]
pub(super) struct ResponseValidationError;
pub(in crate::openai) struct ResponseValidationError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TerminalKind {
@@ -33,10 +34,10 @@ struct ExpectedResponseFacts {
/// 仅保存最终协议复核所需的响应 ID 与终态类别,不缓存流正文
#[derive(Clone, Default)]
pub(super) struct ResponseValidationFacts(Arc<Mutex<ExpectedResponseFacts>>);
pub(in crate::openai) struct ResponseValidationFacts(Arc<Mutex<ExpectedResponseFacts>>);
impl ResponseValidationFacts {
pub(super) fn observe_event(&self, event: &ProviderEvent) {
pub(in crate::openai) fn observe_event(&self, event: &ProviderEvent) {
let mut facts = self
.0
.lock()
@@ -44,7 +45,7 @@ impl ResponseValidationFacts {
if let Some(wire) = event
.middleware_origin_wire()
.or_else(|| event.wire_event())
.filter(|wire| wire.protocol() == "openai")
.filter(|wire| wire.protocol() == OPENAI_PROTOCOL)
{
facts.observe_wire(wire);
}
@@ -55,7 +56,7 @@ impl ResponseValidationFacts {
}
}
pub(super) fn observe_buffered(&self, events: &[ProviderEvent], original: &[u8]) {
pub(in crate::openai) fn observe_buffered(&self, events: &[ProviderEvent], original: &[u8]) {
for event in events {
self.observe_event(event);
}
@@ -81,7 +82,7 @@ impl ResponseValidationFacts {
.clone()
}
pub(super) fn response_id(&self) -> Option<String> {
pub(in crate::openai) fn response_id(&self) -> Option<String> {
self.snapshot().response_id
}
}
@@ -146,7 +147,7 @@ impl ExpectedResponseFacts {
}
/// 只复核中间件确实改写过的完整 JSON;未经改写的原生快路保持原行为
pub(super) fn validate_buffered_response(
pub(in crate::openai) fn validate_buffered_response(
facts: &ResponseValidationFacts,
modified: &[u8],
) -> Result<(), ResponseValidationError> {
@@ -176,7 +177,7 @@ pub(super) fn validate_buffered_response(
/// 原始前缀只旁路构建状态;直到插件首次改写或丢弃正文才启用严格拒绝,避免给
/// 无策略原生热路增加新的兼容性条件
#[derive(Debug, Default)]
pub(super) struct ResponsesDeliveryValidator {
pub(in crate::openai) struct ResponsesDeliveryValidator {
active: bool,
invalid_prefix: bool,
state: DeliveryState,
@@ -184,7 +185,7 @@ pub(super) struct ResponsesDeliveryValidator {
impl ResponsesDeliveryValidator {
/// 复核实际交付帧;只有宿主标记为已改写时才激活严格模式
pub(super) fn validate_frame(
pub(in crate::openai) fn validate_frame(
&mut self,
delivered: &[u8],
transformed: bool,
@@ -194,7 +195,7 @@ impl ResponsesDeliveryValidator {
}
/// WebSocket JSON message 与 SSE 共用同一响应顺序和关联校验
pub(super) fn validate_websocket_frame(
pub(in crate::openai) fn validate_websocket_frame(
&mut self,
delivered: &[u8],
transformed: bool,
@@ -263,11 +264,11 @@ impl ResponsesDeliveryValidator {
self.state.apply(parsed)
}
pub(super) fn response_id(&self) -> Option<&str> {
pub(in crate::openai) fn response_id(&self) -> Option<&str> {
self.state.response_id.as_deref()
}
pub(super) fn finish_delivery(&self) -> Result<(), ResponseValidationError> {
pub(in crate::openai) fn finish_delivery(&self) -> Result<(), ResponseValidationError> {
if !self.active || self.state.done {
Ok(())
} else {
@@ -275,12 +276,14 @@ impl ResponsesDeliveryValidator {
}
}
pub(super) fn completed_response_id(&self) -> Option<&str> {
pub(in crate::openai) fn completed_response_id(&self) -> Option<&str> {
self.response_id()
.filter(|_| self.state.terminal == Some(TerminalKind::Completed))
}
pub(super) fn finish_websocket_delivery(&self) -> Result<(), ResponseValidationError> {
pub(in crate::openai) fn finish_websocket_delivery(
&self,
) -> Result<(), ResponseValidationError> {
if !self.active || self.state.terminal.is_some() {
Ok(())
} else {
@@ -16,13 +16,14 @@ use gateway_core::engine::response_control::{ResponseControl, ResponseControlUna
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::event::ProviderResponseHeader;
use gateway_core::operation::ProviderSessionState;
use gateway_protocol::openai::OPENAI_PROTOCOL;
use crate::openai::error::{gateway_error_contract, gateway_error_from_engine};
use crate::openai::middleware::{PendingExecution, pending_execution_response};
use crate::openai::responses::validation::{ResponseValidationFacts, ResponsesDeliveryValidator};
use super::{
super::{DecodedResponsesRequest, OpenAiResponsesEncoder, ProtocolErrorBody},
super::{DecodedGenerationRequest, OpenAiResponsesEncoder, ProtocolErrorBody},
connection::{
ConnectionEvent, FramePhase, ResponsesWebSocketConnection, WriteContext, WriteOutcome,
},
@@ -45,7 +46,7 @@ pub(super) struct ConnectionReplaySnapshot {
}
impl ConnectionReplaySnapshot {
pub(super) fn prepare(&self, request: DecodedResponsesRequest) -> DecodedResponsesRequest {
pub(super) fn prepare(&self, request: DecodedGenerationRequest) -> DecodedGenerationRequest {
match (
request.metadata().continuation().previous_response_id(),
self.last_response_id.as_deref(),
@@ -118,7 +119,7 @@ pub(super) async fn execution_response(
.collect();
// 失败也要保留会话到洋葱链返回,否则释放会话会取消仍在等待 next 的插件
return Ok(pending_execution_response(
"openai".to_owned(),
OPENAI_PROTOCOL.to_owned(),
status,
response_headers,
MiddlewareFrame::new(
@@ -142,7 +143,7 @@ pub(super) async fn execution_response(
.map(|header| MiddlewareHeader::new(header.name(), header.value().clone()))
.collect();
Ok(MiddlewareResponse::new(
"openai".to_owned(),
OPENAI_PROTOCOL.to_owned(),
200,
headers,
Box::new(body),
@@ -159,7 +160,7 @@ pub(super) async fn forward_response(
response_control: &ResponseControl,
) -> ForwardOutcome {
let (protocol, status, headers, mut body, _) = response.into_parts();
if protocol != "openai" || StatusCode::from_u16(status).is_err() {
if protocol != OPENAI_PROTOCOL || StatusCode::from_u16(status).is_err() {
return fail_body(connection, body, MiddlewareError::InvalidState, &request_id).await;
}
let response_headers = headers
@@ -26,13 +26,14 @@ use gateway_core::{
lifecycle::{ConnectionGuard, ConnectionLifecycle},
operation::OperationKind,
};
use gateway_protocol::openai::OPENAI_PROTOCOL;
use crate::middleware::headers::encode_headers;
use crate::{
ApiState,
openai::middleware::{RequestInput, invoke_request, request_parts},
openai::{
auth::{authenticate_client, client_access_error_response},
auth::{ClientKeyHeaders, authenticate_client, client_access_error_response},
error::runtime_unavailable_response,
service::OpenAiService,
},
@@ -65,7 +66,7 @@ pub(crate) async fn responses_websocket(
websocket: Upgrade,
) -> 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),
};
@@ -328,7 +329,7 @@ async fn serve_responses_websocket(socket: Socket, session: ResponsesWebSocketSe
let validation = ResponseValidationFacts::default();
let input = RequestInput {
endpoint: crate::openai::router::RESPONSES_PATH.to_owned(),
protocol: "openai".to_owned(),
protocol: OPENAI_PROTOCOL.to_owned(),
operation: Some(OperationKind::Generate),
transport: ClientTransport::WebSocket,
model_hint,
@@ -349,7 +350,7 @@ async fn serve_responses_websocket(socket: Socket, session: ResponsesWebSocketSe
Box::new(move |prepared, request| {
Box::pin(async move {
let (protocol, headers, body) = request_parts(request.clone())?;
if protocol != "openai" {
if protocol != OPENAI_PROTOCOL {
return Err(MiddlewareError::Rejected);
}
let payload =
@@ -365,7 +366,7 @@ async fn serve_responses_websocket(socket: Socket, session: ResponsesWebSocketSe
.prepare(decoded)
.with_middleware_capabilities(&request)?;
let started = service_for_terminal
.start_prepared_response(
.start_prepared_generation(
prepared,
decoded,
ClientTransport::WebSocket,
@@ -12,7 +12,7 @@ use thiserror::Error;
use crate::openai::error::{gateway_error_contract, gateway_error_from_engine};
use super::super::{
DecodedResponsesRequest, ProtocolErrorBody, RequestDecodeError,
DecodedGenerationRequest, ProtocolErrorBody, RequestDecodeError,
request::{OpenAiRequestHeaders, RequestDecodeSource},
};
@@ -27,7 +27,7 @@ use super::super::{
pub fn decode_response_create_with_context(
payload: &str,
request_headers: &OpenAiRequestHeaders,
) -> Result<DecodedResponsesRequest, ResponseCreateFrameError> {
) -> Result<DecodedGenerationRequest, ResponseCreateFrameError> {
decode_response_create_inner(payload, request_headers)
}
@@ -52,7 +52,7 @@ pub(super) fn is_response_create(payload: &str) -> bool {
fn decode_response_create_inner(
payload: &str,
request_headers: &OpenAiRequestHeaders,
) -> Result<DecodedResponsesRequest, ResponseCreateFrameError> {
) -> Result<DecodedGenerationRequest, ResponseCreateFrameError> {
let Value::Object(mut body) = serde_json::from_str::<Value>(payload)
.map_err(|_| ResponseCreateFrameError::InvalidJson)?
else {
@@ -10,6 +10,7 @@ use super::{
chat_completions::chat_completions,
images::{image_edits, image_generations},
live,
messages::{count_tokens, messages},
models::{model_detail, models},
responses::{responses, responses_websocket},
search::standalone_search,
@@ -20,6 +21,8 @@ use crate::ApiState;
// 公开协议路径由 API 层统一定义;Core 使用操作类型,不根据这些路径分派业务
pub(super) const RESPONSES_PATH: &str = "/v1/responses";
pub(super) const MESSAGES_PATH: &str = "/v1/messages";
pub(super) const MESSAGE_TOKENS_PATH: &str = "/v1/messages/count_tokens";
pub(super) const CHAT_COMPLETIONS_PATH: &str = "/v1/chat/completions";
pub(super) const IMAGE_GENERATIONS_PATH: &str = "/v1/images/generations";
pub(super) const IMAGE_EDITS_PATH: &str = "/v1/images/edits";
@@ -37,6 +40,8 @@ pub(crate) fn router() -> Router<ApiState> {
.route(SEARCH_PATH, post(standalone_search))
.route(RESPONSES_PATH, get(responses_websocket).post(responses))
.route(CHAT_COMPLETIONS_PATH, post(chat_completions))
.route(MESSAGES_PATH, post(messages))
.route(MESSAGE_TOKENS_PATH, post(count_tokens))
.route(MODELS_PATH, get(models))
// 官方 OpenAI 模型详情合同使用 path ID;它不属于 Admin API 约束
.route(MODEL_DETAIL_PATH, get(model_detail))
@@ -11,19 +11,18 @@ use axum::{
use gateway_core::engine::execution::ClientTransport;
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::operation::{Operation, OperationKind, RawJsonPayload, StandaloneSearchRequest};
use gateway_protocol::openai::OPENAI_PROTOCOL;
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::SEARCH_PATH,
};
const OPENAI_PROTOCOL: &str = "openai";
/// `POST /v1/alpha/search`
pub(crate) async fn standalone_search(
State(state): State<ApiState>,
@@ -32,7 +31,7 @@ pub(crate) async fn standalone_search(
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),
};
@@ -15,10 +15,11 @@ use gateway_core::lifecycle::{ConnectionDraining, ConnectionGuard, ConnectionLif
use gateway_core::routing::{
ProviderCatalogUnavailable, ProviderKind, PublicModelDescriptor, PublicModelId, UpstreamModelId,
};
use gateway_protocol::openai::OPENAI_PROTOCOL;
use uuid::Uuid;
use super::auth::ClientApiKeyAuthError;
use super::responses::{ContinuationIntent, DecodedResponsesRequest};
use super::responses::{ContinuationIntent, DecodedGenerationRequest};
/// OpenAI HTTP/WS adapter 共享的 Core 与连接生命周期能力
#[derive(Clone)]
@@ -76,8 +77,14 @@ impl OpenAiService {
client: &AuthenticatedClient,
client_version: &str,
) -> Result<Vec<PublicModelDescriptor>, ProviderCatalogUnavailable> {
let context = crate::middleware::current();
self.execution
.client_model_catalog(client, "codex", client_version)
.client_model_catalog(
client,
"codex",
client_version,
context.as_ref().and_then(|ctx| ctx.extensions.peer_route()),
)
.await
}
@@ -89,14 +96,15 @@ impl OpenAiService {
self.execution.contains_public_model(client, model)
}
pub(crate) async fn start_prepared_response(
pub(crate) async fn start_prepared_generation(
&self,
prepared: PreparedRootExecution,
request: DecodedResponsesRequest,
request: DecodedGenerationRequest,
transport: ClientTransport,
endpoint: &'static str,
) -> Result<StartedExecution, GatewayError> {
let (operation, metadata) = request.into_parts();
let protocol = operation.protocol().to_owned();
let public_model = PublicModelId::from_client_wire(metadata.requested_model().to_owned())
.map_err(|_| {
GatewayError::new(
@@ -117,7 +125,7 @@ impl OpenAiService {
public_model,
operation,
metadata: ExecutionRequestMetadata {
protocol: "openai".to_owned(),
protocol,
endpoint: endpoint.to_owned(),
transport,
stream: metadata.stream(),
@@ -152,7 +160,7 @@ impl OpenAiService {
upstream_model,
operation,
ExecutionRequestMetadata {
protocol: "openai".to_owned(),
protocol: OPENAI_PROTOCOL.to_owned(),
endpoint,
transport: ClientTransport::HttpJson,
stream: false,
@@ -89,8 +89,8 @@ async fn usage_response(
};
Json(json!({
"unit": "USD",
"daily": window(budget.limits.daily_usd, budget.daily_used_usd, budget.daily_resets_at),
"weekly": window(budget.limits.weekly_usd, budget.weekly_used_usd, budget.weekly_resets_at),
"daily": window(budget.limits.daily_usd, budget.daily_used_usd, budget.daily_resets_at()),
"weekly": window(budget.limits.weekly_usd, budget.weekly_used_usd, budget.weekly_resets_at()),
}))
.into_response()
}
+3 -3
View File
@@ -27,7 +27,7 @@ use gateway_core::{
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, openai_error_response},
middleware::PendingExecution,
responses::request_client_context,
@@ -67,7 +67,7 @@ async fn count_tokens(
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),
};
@@ -132,7 +132,7 @@ async fn provider_http(
_ => return invalid_request("Provider HTTP method is unsupported."),
};
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),
};
@@ -16,6 +16,7 @@ async fn peer_refresh_uses_the_account_quota_endpoint_and_requires_admin() {
fixture.auth.insert_session("valid-session");
*fixture.account.lock().unwrap() = Some(account_page_item(
AccountStatusFacts {
quota_paused: false,
enabled: true,
credential_state: CredentialState::Ready,
access_token_expires_at: None,
@@ -73,6 +74,7 @@ async fn account_model_reads_and_refreshes_preserve_provider_observation_time()
let observed_at: DateTime<Utc> = "2026-09-12T08:00:00Z".parse().unwrap();
*fixture.account.lock().unwrap() = Some(account_page_item(
AccountStatusFacts {
quota_paused: false,
enabled: true,
credential_state: CredentialState::Ready,
access_token_expires_at: None,
@@ -161,6 +163,7 @@ async fn account_proxy_binding_is_distinct_from_the_redacted_endpoint() {
] {
let now = Utc::now();
let facts = AccountStatusFacts {
quota_paused: false,
enabled: true,
credential_state: CredentialState::Ready,
access_token_expires_at: None,
@@ -239,6 +242,7 @@ async fn account_cooldown_returns_an_instant_and_server_formatted_recovery() {
let now = Utc::now();
let until = seconds.map(|seconds| now + Duration::seconds(seconds));
let facts = AccountStatusFacts {
quota_paused: false,
enabled: true,
credential_state: CredentialState::Ready,
access_token_expires_at: None,
@@ -323,6 +327,7 @@ async fn account_quota_refresh_time_uses_server_clock_and_deployment_timezone()
let now = Utc::now();
*fixture.account.lock().unwrap() = Some(account_page_item(
AccountStatusFacts {
quota_paused: false,
enabled: true,
credential_state: CredentialState::Ready,
access_token_expires_at: None,
@@ -356,6 +361,7 @@ async fn account_quota_refresh_time_uses_server_clock_and_deployment_timezone()
),
] {
*fixture.provider_quota.lock().unwrap() = Some(ProviderQuota {
paused_windows: Vec::new(),
upstream: None,
plan_type: None,
observed_at,
@@ -406,6 +412,8 @@ fn account_page_item(
AccountPageItem {
protection_rules: Vec::new(),
account: AccountRecord {
quota_paused: false,
quota_limits: Default::default(),
auto_use_quota_credits: false,
outbound_proxy_id: None,
outbound_proxy_enabled: true,
@@ -458,6 +466,7 @@ async fn account_usage_preserves_periods_cache_counts_and_missing_values() {
fixture.auth.insert_session("valid-session");
let now = Utc::now();
let facts = AccountStatusFacts {
quota_paused: false,
enabled: true,
credential_state: CredentialState::Ready,
access_token_expires_at: None,
@@ -741,6 +750,7 @@ async fn concurrency_details_require_admin_validate_paging_and_report_lease_time
fixture.auth.insert_session("valid-session");
*fixture.account.lock().unwrap() = Some(account_page_item(
gateway_core::account::AccountStatusFacts {
quota_paused: false,
enabled: true,
credential_state: gateway_core::account::CredentialState::Ready,
access_token_expires_at: None,
@@ -944,6 +944,7 @@ mod actions {
DomainConnectionTestEvent::Failed {
source: AccountProbeErrorSource::Upstream,
gateway_error_code: GatewayErrorKind::RateLimited,
proxy_hint: None,
send_state: Some(UpstreamSendState::Sent),
message: "upstream unavailable".to_owned(),
provider_error_code: Some("usage_exhausted".to_owned()),
@@ -983,7 +984,7 @@ mod actions {
assert_eq!(
events,
[
json!({ "type": "test_start", "model": "grok-4.5", "text": "正在连接上游 Responses" }),
json!({ "type": "test_start", "model": "grok-4.5", "text": "正在连接上游" }),
json!({
"type": "request",
"payload": {
@@ -1005,6 +1006,7 @@ mod actions {
"type": "error",
"source": "upstream",
"gatewayErrorCode": "rate_limited",
"proxyHint": null,
"sendState": "sent",
"error": "upstream unavailable",
"providerErrorCode": "usage_exhausted",

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