Compare commits

...
91 Commits
Author SHA1 Message Date
ZyyandCodex a069001f01 chore: bump version to 3.3.6
Co-authored-by: Codex <noreply@openai.com>
2026-08-30 01:55:01 +08:00
ZyyandCodex d3a87d11ad fix(frontend): restore theme visual consistency
Co-authored-by: Codex <noreply@openai.com>
2026-08-30 01:53:00 +08:00
ZyyandCodex d7275dfd08 chore: bump version to 3.3.5
Co-authored-by: Codex <noreply@openai.com>
2026-08-30 00:53:46 +08:00
ZyyandCodex 3396f0880f fix(frontend): refine login controls and spacing
Co-authored-by: Codex <noreply@openai.com>
2026-08-30 00:46:01 +08:00
ZyyandCodex d5fa6c4921 refactor(frontend): remove redundant aria hiding
Co-authored-by: Codex <noreply@openai.com>
2026-08-30 00:46:01 +08:00
ZyyandCodex d4855cca9c feat(observability): expose runtime pressure insights
Co-authored-by: Codex <noreply@openai.com>
2026-08-30 00:46:01 +08:00
ZyyandCodex 1d862922db refactor(theme): rebuild runtime token architecture
Derive semantic and component tokens from structured appearance seeds, align visual consumers and Tailwind v4 base utilities, and keep popover arrows matched to their adjacent surfaces across placement changes.

Co-authored-by: Codex <noreply@openai.com>
2026-08-29 22:14:57 +08:00
ZyyandCodex 4830ef093b refactor(architecture): remove obsolete compatibility scaffolding
Remove orphaned fixtures, unused capability and resource branches, and stale credential APIs.

Retire legacy Usage metadata fallbacks and record the completed cleanup.

Co-authored-by: Codex <noreply@openai.com>
2026-08-29 22:08:34 +08:00
ZyyandCodex ad20064d15 fix(admin): unify control-plane error responses
Co-authored-by: Codex <noreply@openai.com>
2026-08-29 20:22:14 +08:00
ZyyandCodex 515487baee docs(api): document official profile statistics
Co-authored-by: Codex <noreply@openai.com>
2026-08-29 04:07:54 +08:00
ZyyandCodex a38c328975 style(frontend): format account usage summary
Co-authored-by: Codex <noreply@openai.com>
2026-08-29 03:57:06 +08:00
ZyyandCodex 97d95d25f1 chore: bump version to 3.3.4
Co-authored-by: Codex <noreply@openai.com>
2026-08-29 03:51:49 +08:00
ZyyandCodex abfd24267b fix(frontend): refine usage visual hierarchy
Co-authored-by: Codex <noreply@openai.com>
2026-08-29 03:51:05 +08:00
ZyyandCodex a363682929 feat(frontend): refine surfaces and draggable modals
Co-authored-by: Codex <noreply@openai.com>
2026-08-29 03:25:40 +08:00
ZyyandCodex 57422275bb feat(accounts): add official profile statistics
Co-authored-by: Codex <noreply@openai.com>
2026-08-29 02:36:45 +08:00
Zyy cb7f69850b feat(openai): add session affinity observability 2026-08-28 02:20:55 +08:00
ZyyandCodex 5ea3740de3 refactor(openai): remove agent identity support
Co-authored-by: Codex <noreply@openai.com>
2026-08-27 13:27:10 +08:00
ZyyandCodex 3920624862 chore: bump version to 3.3.3
Co-authored-by: Codex <noreply@openai.com>
2026-08-27 01:53:05 +08:00
Zyy d804bca495 fix(openai): strip unsupported Codex request fields 2026-08-27 01:51:30 +08:00
ZyyandCodex ca527d5833 fix(accounts): use personal usage reports only
Remove the workspace token-breakdown probe and its dedicated statistics mode so usage aggregation always uses the personal model-credit and token-total reports.

Drop the invariant mode field from provider projections, the admin response, and the account usage UI.

Co-authored-by: Codex <noreply@openai.com>
2026-08-26 11:31:55 +08:00
ZyyandCodex ae4eb49afc chore: bump version to 3.3.2
Co-authored-by: Codex <noreply@openai.com>
2026-08-26 00:51:31 +08:00
ZyyandCodex 5d97086511 feat(accounts): add official usage statistics
Fetch Codex cycle usage in the provider, calculate model and daily Token costs with the shared OpenAI pricing rules, and expose the result through the admin API.

Add the account usage statistics modal, structured loading state, and feature-scoped quota components.

Co-authored-by: Codex <noreply@openai.com>
2026-08-26 00:46:53 +08:00
ZyyandCodex 0e6da1e731 refactor: consolidate migration baseline and remove design source
Fold the nullable requested-model change into the initial migration and keep the frozen manifest and project guidance aligned.

Remove the staged Pen design source.

Co-authored-by: Codex <noreply@openai.com>
2026-08-25 17:53:29 +08:00
ZyyandCodex 5d0922b9d1 fix(theme): align functional colors with Ant Design
Keep functional base tokens on Ant Design P6 so light themes preserve the configured seed while dark themes retain palette adaptation.

Co-authored-by: Codex <noreply@openai.com>
2026-08-25 17:06:05 +08:00
ZyyandCodex ed3d622554 chore: bump version to 3.3.1
Co-authored-by: Codex <noreply@openai.com>
2026-08-25 15:53:32 +08:00
dependabot[bot] 0f71cf6df2 chore(deps): bump rust in /deploy in the minor-and-patch group (#46)
Bumps the minor-and-patch group in /deploy with 1 update: rust.


Updates `rust` from 1.97-bookworm to 1.98-bookworm

---
updated-dependencies:
- dependency-name: rust
  dependency-version: 1.98-bookworm
  dependency-type: direct:production
  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-08-25 15:37:44 +08:00
dependabot[bot] a1d3eba874 chore(deps): bump the minor-and-patch group in /frontend with 8 updates (#45)
Bumps the minor-and-patch group in /frontend with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [@lucide/vue](https://github.com/lucide-icons/lucide/tree/HEAD/packages/vue) | `1.31.0` | `1.33.0` |
| [dayjs](https://github.com/iamkun/dayjs) | `1.11.21` | `1.11.23` |
| [dompurify](https://github.com/cure53/DOMPurify) | `3.4.13` | `3.4.14` |
| [es-toolkit](https://github.com/toss/es-toolkit) | `1.50.0` | `1.51.0` |
| [marked](https://github.com/markedjs/marked) | `18.0.9` | `18.0.10` |
| [eslint](https://github.com/eslint/eslint) | `10.8.1` | `10.9.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.2.1` | `8.2.2` |
| [vue-tsc](https://github.com/vuejs/language-tools/tree/HEAD/packages/tsc) | `3.3.10` | `3.3.11` |


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

Updates `dayjs` from 1.11.21 to 1.11.23
- [Release notes](https://github.com/iamkun/dayjs/releases)
- [Changelog](https://github.com/iamkun/dayjs/blob/v1.11.23/CHANGELOG.md)
- [Commits](https://github.com/iamkun/dayjs/compare/v1.11.21...v1.11.23)

Updates `dompurify` from 3.4.13 to 3.4.14
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.13...3.4.14)

Updates `es-toolkit` from 1.50.0 to 1.51.0
- [Release notes](https://github.com/toss/es-toolkit/releases)
- [Changelog](https://github.com/toss/es-toolkit/blob/main/CHANGELOG.md)
- [Commits](https://github.com/toss/es-toolkit/compare/v1.50.0...v1.51.0)

Updates `marked` from 18.0.9 to 18.0.10
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](https://github.com/markedjs/marked/compare/v18.0.9...v18.0.10)

Updates `eslint` from 10.8.1 to 10.9.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.8.1...v10.9.0)

Updates `vite` from 8.2.1 to 8.2.2
- [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.2.2/packages/vite)

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

---
updated-dependencies:
- dependency-name: "@lucide/vue"
  dependency-version: 1.33.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: dayjs
  dependency-version: 1.11.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: dompurify
  dependency-version: 3.4.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: es-toolkit
  dependency-version: 1.51.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: marked
  dependency-version: 18.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: eslint
  dependency-version: 10.9.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: vite
  dependency-version: 8.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vue-tsc
  dependency-version: 3.3.11
  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-08-25 15:37:10 +08:00
dependabot[bot] d22c1d4ab9 chore(deps): bump the minor-and-patch group in /backend with 4 updates (#44)
Bumps the minor-and-patch group in /backend with 4 updates: [uuid](https://github.com/uuid-rs/uuid), [aws-sdk-s3](https://github.com/awslabs/aws-sdk-rust), [aws-smithy-runtime-api](https://github.com/smithy-lang/smithy-rs) and [redis](https://github.com/redis-rs/redis-rs).


Updates `uuid` from 1.24.0 to 1.24.1
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.24.0...v1.24.1)

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

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

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

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 1.24.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-sdk-s3
  dependency-version: 1.142.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: aws-smithy-runtime-api
  dependency-version: 1.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: redis
  dependency-version: 1.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-25 15:36:39 +08:00
dependabot[bot] cc8dfe3dd8 chore(deps): bump the minor-and-patch group with 2 updates (#43)
Bumps the minor-and-patch group with 2 updates: [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `docker/setup-buildx-action` from 4.2.0 to 4.3.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/bb05f3f5519dd87d3ba754cc423b652a5edd6d2c...37fe631027851001ddb9b187196cc803df7f5f0e)

Updates `github/codeql-action/upload-sarif` from 4.37.7 to 4.37.8
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.8
  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-08-25 15:36:01 +08:00
ZyyandCodex f25edadd5f fix(ci): keep OpenAI regression tests out of sources
Move the thread-spawn transport identity regression behind the public initialized-provider boundary so production source files remain free of embedded test modules.

Co-authored-by: Codex <noreply@openai.com>
2026-08-25 15:27:28 +08:00
ZyyandCodex 4f50c67332 fix(openai): preserve root session account affinity
Share the preferred account across derived child executions while keeping their continuation and WebSocket transport identities isolated.

Claim and migrate affinity atomically, clear account-bound state on fallback, reject cross-account previous-response continuations, and preserve complete replay transcripts.

Co-authored-by: Codex <noreply@openai.com>
2026-08-25 14:22:36 +08:00
ZyyandCodex 75f9d2a290 fix(openai): remove websocket pool slot caps
Co-authored-by: Codex <noreply@openai.com>
2026-08-25 11:22:07 +08:00
ZyyandCodex b93b8859f9 fix(frontend): polish theme controls and page headers
Remove the redundant preview mode switch, round the color picker input, and restore inline right-aligned page header actions.

Co-authored-by: Codex <noreply@openai.com>
2026-08-24 20:21:16 +08:00
ZyyandCodex 3510a45742 chore: bump version to 3.3.0
Co-authored-by: Codex <noreply@openai.com>
2026-08-24 04:07:37 +08:00
ZyyandCodex 85eb283b40 docs(readme): streamline project introduction
Co-authored-by: Codex <noreply@openai.com>
2026-08-24 04:06:49 +08:00
ZyyandCodex b8e1c5077d docs(theme): document runtime theme architecture
Describe the token model, preset themes, runtime stylesheet, isolated editor preview, naming conventions, and extension workflow.

Co-authored-by: Codex <noreply@openai.com>
2026-08-24 04:04:59 +08:00
ZyyandCodex 8422240967 feat(theme): add runtime token theme editor
Introduce Seed-to-Component token derivation, isolated live previews, semantic base component theming, and responsive theme controls.

Co-authored-by: Codex <noreply@openai.com>
2026-08-24 04:04:22 +08:00
ZyyandCodex fbd922117e fix(accounts): return persisted OAuth account ID
Co-authored-by: Codex <noreply@openai.com>
2026-08-22 19:37:03 +08:00
ZyyandCodex 941c4d57fa chore: bump version to 3.2.16
Co-authored-by: Codex <noreply@openai.com>
2026-08-22 09:28:34 +08:00
ZyyandCodex cf046ad9a4 fix(admin): close quota reset modal on success
Co-authored-by: Codex <noreply@openai.com>
2026-08-22 09:25:05 +08:00
ZyyandCodex 7699a954ac fix(admin): format account counts with separators
Co-authored-by: Codex <noreply@openai.com>
2026-08-22 09:21:11 +08:00
ZyyandCodex 2c31708d1b feat(admin): allow selecting quota reset credits
Co-authored-by: Codex <noreply@openai.com>
2026-08-22 09:19:20 +08:00
ZyyandCodex 80f338ce01 fix(admin): show raw account counts
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 23:12:54 +08:00
ZyyandCodex 299088a6e7 chore: bump version to 3.2.15
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 20:00:59 +08:00
ZyyandCodex a23bfca848 fix(admin): normalize OAuth credential formats
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 14:57:16 +08:00
ZyyandCodex 8951a64a05 fix(admin): raise billing abbreviation threshold
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 14:15:22 +08:00
ZyyandCodex eb20561893 fix(admin): align xAI quota usage displays
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 13:58:02 +08:00
ZyyandCodex a33637a99e fix(openai): use Codex image endpoint paths
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 13:02:40 +08:00
ZyyandCodex 97cd7a7097 chore: bump version to 3.2.14
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 01:11:57 +08:00
ZyyandCodex b0fe132736 docs: refresh architecture and project guides
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 01:01:07 +08:00
ZyyandCodex e2c75d1054 feat(admin): add direct token import and polish displays
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 00:33:26 +08:00
ZyyandCodex 94f298927d feat(openai): add active quota reset credits
Co-authored-by: Codex <noreply@openai.com>
2026-08-21 00:33:21 +08:00
ZyyandCodex 3be827204f fix(openai): keep image routes model-agnostic
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 19:05:14 +08:00
ZyyandCodex 227a1174b9 feat(openai): proxy image generation and edits
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 17:41:15 +08:00
ZyyandCodex c0defcb6df fix(admin): prefer account-wide dashboard quota
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 16:06:05 +08:00
ZyyandCodex 53739ebe5c chore: bump version to 3.2.13
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 15:20:29 +08:00
ZyyandCodex 7f420257a6 fix(api): keep provider degradation out of healthz
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 15:14:28 +08:00
ZyyandCodex 4f85209dc9 fix(config): centralize runtime data paths
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 15:14:28 +08:00
ZyyandCodex cd89e2e8fc fix(admin): align quota metadata and usage layout
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 15:14:28 +08:00
ZyyandCodex 0cb5a29699 chore: bump version to 3.2.12
Co-authored-by: Codex <noreply@openai.com>
2026-08-19 23:52:11 +08:00
ZyyandCodex 61788804b4 fix(openai): recover cross-account websocket continuations
Co-authored-by: Codex <noreply@openai.com>
2026-08-19 23:40:40 +08:00
ZyyandCodex 2751ea6aa4 fix(openai): preserve cross-account session recovery
Co-authored-by: Codex <noreply@openai.com>
2026-08-19 23:05:48 +08:00
ZyyandCodex 58fe66789c chore(deps): bump h2 to 0.4.16
Resolve RUSTSEC-2026-0258 reported by cargo audit.

Co-authored-by: Codex <noreply@openai.com>
2026-08-19 11:34:07 +08:00
ZyyandCodex 573b6b26db fix(openai): isolate quota buckets by limit id
Co-authored-by: Codex <noreply@openai.com>
2026-08-18 16:48:37 +08:00
ZyyandCodex d425428ebe chore: bump version to 3.2.11
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 20:02:02 +08:00
ZyyandCodex b26d3b386a fix(admin): align quota diagnostics and usage layout
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 20:00:29 +08:00
ZyyandCodex cf0b14c5fd chore: bump version to 3.2.10
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 15:40:35 +08:00
ZyyandCodex 056561de41 refactor(frontend): clarify component boundaries
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 15:33:36 +08:00
dependabot[bot] f2215df72e chore(deps): bump the minor-and-patch group in /frontend with 4 updates
Bumps the minor-and-patch group in /frontend with 4 updates: [@lucide/vue](https://github.com/lucide-icons/lucide/tree/HEAD/packages/vue), [pinia](https://github.com/vuejs/pinia), [eslint-plugin-vuejs-accessibility](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility) and [vue-tsc](https://github.com/vuejs/language-tools/tree/HEAD/packages/tsc).


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

Updates `pinia` from 4.0.2 to 4.0.3
- [Release notes](https://github.com/vuejs/pinia/releases)
- [Commits](https://github.com/vuejs/pinia/compare/v4.0.2...v4.0.3)

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

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

---
updated-dependencies:
- dependency-name: "@lucide/vue"
  dependency-version: 1.31.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: pinia
  dependency-version: 4.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: eslint-plugin-vuejs-accessibility
  dependency-version: 2.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: vue-tsc
  dependency-version: 3.3.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:05:19 +08:00
dependabot[bot] 72f8957163 chore(deps): bump the minor-and-patch group in /backend with 5 updates
Bumps the minor-and-patch group in /backend with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [thiserror](https://github.com/dtolnay/thiserror) | `2.0.19` | `2.0.20` |
| [async-trait](https://github.com/dtolnay/async-trait) | `0.1.91` | `0.1.92` |
| [futures](https://github.com/rust-lang/futures-rs) | `0.3.33` | `0.3.34` |
| [aws-smithy-types](https://github.com/smithy-lang/smithy-rs) | `1.6.1` | `1.6.2` |
| [cookie](https://github.com/SergioBenitez/cookie-rs) | `0.18.1` | `0.18.2` |


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

Updates `async-trait` from 0.1.91 to 0.1.92
- [Release notes](https://github.com/dtolnay/async-trait/releases)
- [Commits](https://github.com/dtolnay/async-trait/compare/0.1.91...0.1.92)

Updates `futures` from 0.3.33 to 0.3.34
- [Release notes](https://github.com/rust-lang/futures-rs/releases)
- [Changelog](https://github.com/rust-lang/futures-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/futures-rs/compare/0.3.33...0.3.34)

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

Updates `cookie` from 0.18.1 to 0.18.2
- [Changelog](https://github.com/rwf2/cookie-rs/blob/0.18.2/CHANGELOG.md)
- [Commits](https://github.com/SergioBenitez/cookie-rs/compare/0.18.1...0.18.2)

---
updated-dependencies:
- dependency-name: thiserror
  dependency-version: 2.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: async-trait
  dependency-version: 0.1.92
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: futures
  dependency-version: 0.3.34
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-smithy-types
  dependency-version: 1.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: cookie
  dependency-version: 0.18.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:05:19 +08:00
dependabot[bot] 73f0c15790 chore(deps): bump github/codeql-action/upload-sarif
Bumps the minor-and-patch group with 1 update: [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/upload-sarif` from 4.37.6 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd)

---
updated-dependencies:
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:05:19 +08:00
ZyyandCodex 0558693949 fix(openai): harden refresh recovery handling
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 13:37:54 +08:00
ZyyandCodex 2a9cb02cc9 fix(openai): align OAuth refresh error handling
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 12:30:05 +08:00
ZyyandCodex 5ce36931a8 chore: bump version to 3.2.9
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 11:38:49 +08:00
ZyyandCodex 0d826d9eba fix(openai): align OAuth token refresh
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 11:14:03 +08:00
ZyyandCodex 304242957a fix(accounts): preserve upstream error messages
Co-authored-by: Codex <noreply@openai.com>
2026-08-16 13:28:28 +08:00
ZyyandCodex ef37ae8d9e chore: bump version to 3.2.8
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 23:31:04 +08:00
ZyyandCodex fc13d4f2b6 fix(frontend): restore responsive component behavior
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 20:38:48 +08:00
ZyyandCodex 78f9c0c800 chore: bump version to 3.2.7
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:54:52 +08:00
ZyyandCodex a3bc7bc445 fix(frontend): increase account table row height
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:54:02 +08:00
ZyyandCodex 0132114285 fix(openai): let upstream validate catalog models
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:45:06 +08:00
ZyyandCodex 5970efbb7c fix(container): refresh runtime packages before scans
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:34:41 +08:00
ZyyandCodex 7bf28b571d chore: bump version to 3.2.6
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 15:47:32 +08:00
ZyyandCodex bc3bd6bc43 refactor(frontend): unify component design system
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 15:39:31 +08:00
ZyyandCodex 9acfab0a86 chore(frontend): enforce template interpolation layout
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 12:31:10 +08:00
ZyyandCodex 3e5a84d2a0 fix(frontend): refine dashboard capacity display
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 12:31:06 +08:00
ZyyandCodex 92948f51a9 fix(frontend): remove table bottom gap
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 11:59:58 +08:00
ZyyandCodex 776637fccb chore: bump version to 3.2.5
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 01:52:54 +08:00
ZyyandCodex 82acb08d26 fix(frontend): restore diagnostic table scrolling
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 01:51:56 +08:00
ZyyandCodex 7744e1b0b4 fix(accounts): preserve rolling window usage
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 01:28:44 +08:00
ZyyandCodex 0b8a1add18 feat(frontend): refresh account setup defaults and copy
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 01:09:31 +08:00
508 changed files with 28311 additions and 45467 deletions
+5 -2
View File
@@ -45,7 +45,7 @@ jobs:
ref: ${{ inputs.ref }}
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Build runtime image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
@@ -55,6 +55,9 @@ jobs:
target: runtime
load: true
tags: ${{ env.CPR_IMAGE }}
no-cache-filters: |
runtime-base
runtime
cache-from: type=gha,scope=runtime-ci
cache-to: type=gha,mode=max,scope=runtime-ci
@@ -76,7 +79,7 @@ jobs:
${{ hashFiles('trivy-results.sarif') != '' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository) }}
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
with:
sarif_file: trivy-results.sarif
category: runtime-container
+5 -2
View File
@@ -448,7 +448,7 @@ jobs:
PY
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Login to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
@@ -468,6 +468,9 @@ jobs:
push: true
build-args: |
CPR_PREBUILT_DIR=dist/docker
no-cache-filters: |
runtime-base
runtime-prebuilt
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.os }}-${{ matrix.arch }}
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.os }}-${{ matrix.arch }},mode=min
provenance: mode=max
@@ -516,7 +519,7 @@ jobs:
path: dist/release
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
+73 -70
View File
@@ -12,31 +12,32 @@
[![GHCR](https://img.shields.io/badge/GHCR-codex--proxy--rs-2496ED?logo=docker&logoColor=white&style=flat-square)](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
[![MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](https://opensource.org/license/mit)
[快速开始](#快速开始) · [客户端接入](#客户端接入) · [运维](#运维) · [部署](deploy/README.md) · [接口](docs/api.md) · [架构](docs/architecture.md)
[快速开始](#快速开始) · [客户端接入](#客户端接入) · [运行维护](#运行维护) · [部署](deploy/README.md) · [接口](docs/api.md) · [架构](docs/architecture.md)
</div>
> [!NOTE]
> 只支持 OpenAI Responses API,不提供 `/v1/chat/completions`。
> 数据面支持 OpenAI Responses、Images 和模型目录协议,不提供 `/v1/chat/completions`。
## 能力
## 功能
| 领域 | 实现 |
| -------- | ----------------------------------------------------------------------------------------------------- |
| 协议 | OpenAI Responses JSON、SSE、WebSocket 与模型目录 |
| Provider | 固定 OpenAI、xAI 两个 Provider;各自管理 OAuth、账号、额度与模型目录 |
| 路由 | Client Key 绑定 Provider、全局模型映射、会话亲和与同 Provider 安全 fallback |
| 透明边界 | OpenAI 请求保留未知字段和字段顺序,SSE 与 WebSocket 原样转发业务字节;xAI 在 Provider 内做协议转换 |
| 延续 | OpenAI native/replay continuation;xAI 使用客户端提交的完整历史 |
| 账号 | 导入、OAuth、credential 刷新、主动额度刷新,以及正常响应中的被动额度/状态观测 |
| 管理 | Client Key、账号、模型目录、设置、观测、系统更新与 OAuth |
| 计量 | 模型请求 Token、费用、延迟、账号与 Provider 归因,并记录响应确认的实际 `service_tier` |
| 领域 | 当前能力 |
| --- | --- |
| 数据面 | Responses JSON、SSE、WebSocket、review 子代理、Images 生成/编辑与模型目录 |
| Provider | 固定 `openai`、`xai` 两个 Provider,各自拥有 OAuth、credential、额度、目录与 transport |
| 路由 | Client Key 限定账号分组;按模型能力编译 Provider 候选;支持全局精确模型映射、会话亲和和安全 fallback |
| 透明边界 | OpenAI 保留未知请求字段及字段顺序,并原样转发 SSE、WebSocket 与 Images 业务字节;xAI 在 Provider 内转换协议 |
| 账号 | OAuth、OpenAI AT/RT 与账号文件导入,credential/额度刷新、账号恢复及主动额度重置卡 |
| 管理 | 账号与分组、Client Key、运行设置、用量/错误观测、S3/R2 备份、系统更新与回滚 |
| 计量 | Token、图片、费用、延迟、账号与 Provider 归因,并记录上游响应确认的实际 `service_tier` |
架构边界、状态所有权和请求不变量见 [架构文档](docs/architecture.md)。
## 快速开始
需要 Docker Engine 与 Docker Compose Plugin。
### 1. 准备
### 1. 准备配置和目录
```bash
git clone https://github.com/zyycn/codex-proxy-rs.git
@@ -56,16 +57,16 @@ openssl rand -hex 24
openssl rand -hex 24
```
把两个结果写入 `deploy/config.yaml`,并单独设置管理员初始密码:
把两个结果分别写入 `deploy/config.yaml`,再设置管理员初始密码:
| 配置 | 约束 |
| -------------------------- | ---------------------------- |
| `store.database.password` | 48 位十六进制 |
| `store.redis.password` | 48 位十六进制 |
| `admin.default_password` | 至少 12 位,不能包含 `$` |
| 配置 | 约束 |
| --- | --- |
| `store.database.password` | 48 位十六进制 |
| `store.redis.password` | 48 位十六进制 |
| `admin.default_password` | 至少 12 位,不能包含 `$` |
PostgreSQL、Redis 容器和应用通过同一份 `config.yaml` 使用这两个基础设施密码,不需要额外
导出环境变量。管理员密码只在首次创建管理员时生效。
PostgreSQL、Redis 和应用从同一份 `config.yaml` 读取基础设施密码,不需要额外导出环境变量。
管理员密码只在首次创建管理员时生效。
Linux 需要允许容器组写入运行目录:
@@ -83,37 +84,36 @@ docker compose -f deploy/compose.yaml up -d --no-build
curl -i http://127.0.0.1:8080/healthz
```
`204 No Content` 表示应用、PostgreSQL 与 Redis 均可用。管理端地址:
`204 No Content` 表示应用、PostgreSQL 与 Redis 均可用。管理端地址为
`http://127.0.0.1:8080`。
### 3. 初始化
### 3. 初始化网关
1. 使用 `admin@cpr.local` 与初始密码登录。
2. 在账号页选择对应 Provider:OpenAI 支持 OAuth、账号文件与 Agent Identity;xAI 支持 OAuth
和兼容账号文件导入。
3. 按需配置客户端模型到上游模型的全局映射;未命中映射的模型名原样透传。
4. 创建 `sk_...` Client Key,绑定 `openai` 或 `xai`,并设置速率与并发限制。
2. 导入或授权 Provider 账号:OpenAI 支持 OAuth、逐行 AT/RT 与 OAuth JSON;
xAI 支持 OAuth 和账号 JSON。
3. 创建账号分组并按需配置全局模型映射。未命中的模型名原样交给候选 Provider。
4. 创建 `sk_...` Client Key,选择可用账号分组,并设置速率和并发限制;空分组范围表示全部账号。
OpenAI 账号导入和首次 OAuth 创建在 credential 提交后会立即尝试一次额度观测;额度接口临时失败不会
回滚已提交的账号。Free、K12 等套餐共用同一套额度与状态转换逻辑,套餐字段只作为账号事实并隔离
对应的模型目录 cache。credential 刷新、额度刷新和正常请求中的被动额度观测是三条独立链路,详见
[账号接口](docs/api.md#5-账号)。
OpenAI RT 导入会先向官方 token endpoint 换取 AT;仅 AT 的账号不具备自动续期能力,
能否参与调度仍取决于 token 中可验证的身份与后续上游事实。账号导入和首次 OAuth 创建会尽力读取一次额度,
额度上游暂时失败不会回滚已提交的账号。
OpenAI 账号的主动重置卡只在用户打开相应弹窗或手工刷新时查询。消费成功后管理端重新查询卡片与额度;
系统不会通过修改本地 `reset_at` 伪造重置。完整账号合同见 [账号接口](docs/api.md#5-账号)。
> [!IMPORTANT]
> xAI 使用 OAuth session,不支持把 xAI API Key 作为上游 credential。
> [!NOTE]
> `admin.default_password` 只在首次创建管理员时生效。
完整部署、权限和密码轮换规则见 [deploy/README.md](deploy/README.md)。
完整的权限、密码轮换、备份和升级规则见 [部署文档](deploy/README.md)。
## 客户端接入
| 配置 | 值 |
| -------- | ---------------------------------------- |
| Base URL | `http://127.0.0.1:8080/v1` |
| API Key | 管理端创建的 `sk_...` |
| 鉴权 | `Authorization: Bearer <client-api-key>` |
| 配置 | 值 |
| --- | --- |
| Base URL | `http://127.0.0.1:8080/v1` |
| API Key | 管理端创建的 `sk_...` |
| 鉴权 | `Authorization: Bearer <client-api-key>` |
```bash
curl http://127.0.0.1:8080/v1/models \
@@ -130,67 +130,70 @@ curl http://127.0.0.1:8080/v1/responses \
```
<details>
<summary>API 路由</summary>
<summary>公开数据面路由</summary>
| 路由 | 用途 |
| ---------------------------------- | ------------------------------ |
| `POST /v1/responses` | JSON 响应或 SSE Responses 流 |
| `GET /v1/responses` | Responses WebSocket 升级 |
| `POST /v1/responses/review` | review 子代理请求 |
| `GET /v1/models` | 当前 Client Key 可用的模型列表 |
| `GET /v1/models/catalog` | Codex 客户端展示用模型目录 |
| `GET /v1/models/{model_id}/info` | Codex 客户端展示用模型详情 |
| `GET /v1/models/{model_id}` | OpenAI 兼容模型详情 |
| 路由 | 用途 |
| --- | --- |
| `POST /v1/responses` | JSON 响应或 SSE Responses 流 |
| `GET /v1/responses` | Responses WebSocket 升级 |
| `POST /v1/responses/review` | review 子代理请求 |
| `POST /v1/images/generations` | OpenAI Images 生成 JSON 透明转发 |
| `POST /v1/images/edits` | OpenAI Images 编辑 JSON 透明转发 |
| `GET /v1/models` | 当前 Client Key 账号范围内的模型列表 |
| `GET /v1/models/catalog` | Codex 客户端展示用模型目录 |
| `GET /v1/models/{model_id}/info` | Codex 客户端展示用模型详情 |
| `GET /v1/models/{model_id}` | OpenAI 兼容模型详情 |
所有 `/v1/*` 路由都需要客户端 API Key。
所有 `/v1/*` 路由都需要 Client Key。Images 是 OpenAI Provider 自有端点,不读取模型字段,也不参与
文本模型映射。
</details>
完整的客户端与管理端路由、鉴权和 mutation 规则见 [接口文档](docs/api.md)。
完整的客户端与管理端路由、鉴权、请求字段和 mutation 语义见 [接口文档](docs/api.md)。
## 运维
## 运行维护
```bash
# 实例升级
# 同一大版本内升级发布镜像
docker compose -f deploy/compose.yaml pull codex-proxy-rs
docker compose -f deploy/compose.yaml up -d --no-build
# 日志
# 查看日志
docker compose -f deploy/compose.yaml logs -f codex-proxy-rs
# 从源码构建
# 从当前源码构建
docker compose -f deploy/compose.yaml build codex-proxy-rs
docker compose -f deploy/compose.yaml up -d
```
> [!IMPORTANT]
> `.runtime/` 保存 PostgreSQL、Redis、日志、OpenAI 会话锚点密钥和自更新状态。删除该目录会永久清除
> 运行状态。
> `.runtime/` 保存 PostgreSQL、Redis、日志、OpenAI 会话锚点和在线更新状态。删除该目录会永久清除
> 本地运行状态。
> [!WARNING]
> 不支持跨大版本在线升级。升级到新的大版本时,请使用全新的 `.runtime/` 数据目录重新部署,
> 并重新导入或重新授权 Provider 账号与客户端 Key。
> 不支持跨大版本在线升级。升级到新的大版本时请使用全新的 `.runtime/` 数据目录,并重新导入或授权
> Provider 账号与 Client Key。
Compose 默认只绑定 `127.0.0.1`。公网接入应使用 HTTPS 反向代理,转发 WebSocket
upgrade 与真实客户端 IP;不要暴露 PostgreSQL 或 Redis。
Compose 默认只绑定 `127.0.0.1`。公网接入应使用 HTTPS 反向代理,转发 WebSocket upgrade 与真实客户端
IP;不要暴露 PostgreSQL 或 Redis。当前运行拓扑为单副本,不能通过复制应用容器扩容。
## 维护与发布
发布仓库版本必须从干净、已同步上游的发布分支运行:
仓库版本只能从干净、已同步上游的发布分支发布:
```bash
release/publish <version>
```
脚本依赖已登录的 GitHub CLI(`gh auth login`)。它会更新 `release/version.yaml`、创建约定提交与带
注释的 `v<version>` tag,原子推送分支和 tag,再从发布分支触发 Release 构建,使构建缓存可以跨版本
复用。不要手工修改版本后单独打 tag。该脚本不会登录服务器,也不会改变任何运行实例;源码提交、
仓库发版和实例升级是三个独立状态。
脚本依赖已登录的 GitHub CLI(`gh auth login`),会更新 `release/version.yaml`、创建约定提交和带注释的
`v<version>` tag,原子推送分支与 tag,再触发 Release 构建。源码提交、仓库发版和运行实例升级是三个
独立状态;脚本不会登录服务器或改变任何运行实例。
## 文档
- [部署](deploy/README.md)
- [部署与运维](deploy/README.md)
- [客户端与管理端接口](docs/api.md)
- [架构与数据边界](docs/architecture.md)
- [系统架构](docs/architecture.md)
- [数据库迁移规则](backend/migrations/README.md)
- [Release](https://github.com/zyycn/codex-proxy-rs/releases)
- [容器镜像](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
+45 -232
View File
@@ -8,16 +8,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common 0.1.7",
"generic-array",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -120,9 +110,9 @@ dependencies = [
[[package]]
name = "async-trait"
version = "0.1.91"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
@@ -216,9 +206,9 @@ dependencies = [
[[package]]
name = "aws-sdk-s3"
version = "1.141.0"
version = "1.142.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9f9420d3a2467eed22ed3635ca653653162c386a0b0f65c78189f9bd3c1379e"
checksum = "f9e15a5c55e05f4b0b7e483160b3c85cccdf77cff02c95504f3e71d460855cd2"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -308,9 +298,9 @@ dependencies = [
[[package]]
name = "aws-smithy-eventstream"
version = "0.61.1"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a9381123ab62d20c13082b151f30f962a3b112b727345394536dfa39a482944"
checksum = "6de526c7b567420a31bc283657a7921b45c4cafe0827fdf2490713dcc770c28f"
dependencies = [
"aws-smithy-types",
"bytes",
@@ -341,9 +331,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http-client"
version = "1.2.0"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "635d23afda0a6ab48d666c4d447c4873e8d1e83518a2be2093122397e50b838e"
checksum = "3c1c8a04cb31ba74d0115af5a890bb8c0d48fba64b52812fa13929a6ef0cc83c"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
@@ -385,9 +375,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime"
version = "1.12.1"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07505b34e8f4b3591a4fa69e9792b52289b95488dbbc68c3c0075b7bedb245e1"
checksum = "483b858ff67522011c4786310c5cd8fd88d0be7ea3d5f1a48328446300c4269e"
dependencies = [
"aws-smithy-async",
"aws-smithy-http",
@@ -411,9 +401,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime-api"
version = "1.14.0"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b98f2e1fd67ec06618f9c291e5e495a468e60519e44c9c1979cd0521f3affdb"
checksum = "954c563ce84507722d2679f07a35d21b9c6466b3872d513020d0281fc8112ac9"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api-macros",
@@ -451,9 +441,9 @@ dependencies = [
[[package]]
name = "aws-smithy-types"
version = "1.6.1"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8"
checksum = "fce83ce9abbb198d25bc7131e468d0f9fe1257125e58c39f3f9fc9f5098c9647"
dependencies = [
"base64-simd",
"bytes",
@@ -721,17 +711,6 @@ dependencies = [
"phf 0.12.1",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common 0.1.7",
"inout",
"zeroize",
]
[[package]]
name = "cmake"
version = "0.1.58"
@@ -801,9 +780,9 @@ checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "cookie"
version = "0.18.1"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
dependencies = [
"percent-encoding",
"time",
@@ -961,7 +940,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@@ -976,36 +954,6 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "crypto_box"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16182b4f39a82ec8a6851155cc4c0cda3065bb1db33651726a29e1951de0f009"
dependencies = [
"aead",
"blake2",
"crypto_secretbox",
"curve25519-dalek 4.1.3",
"salsa20",
"subtle",
"zeroize",
]
[[package]]
name = "crypto_secretbox"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1"
dependencies = [
"aead",
"cipher",
"generic-array",
"poly1305",
"salsa20",
"subtle",
"zeroize",
]
[[package]]
name = "ctutils"
version = "0.4.2"
@@ -1016,48 +964,6 @@ dependencies = [
"subtle",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"fiat-crypto 0.2.9",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"curve25519-dalek-derive",
"digest 0.11.3",
"fiat-crypto 0.3.0",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "data-encoding"
version = "2.11.0"
@@ -1169,31 +1075,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ed25519"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
dependencies = [
"pkcs8",
"signature 3.0.0",
]
[[package]]
name = "ed25519-dalek"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
dependencies = [
"curve25519-dalek 5.0.0",
"ed25519",
"serde",
"sha2 0.11.0",
"signature 3.0.0",
"subtle",
"zeroize",
]
[[package]]
name = "either"
version = "1.16.0"
@@ -1295,18 +1176,6 @@ dependencies = [
"subtle",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "fiat-crypto"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
[[package]]
name = "filetime"
version = "0.2.29"
@@ -1384,9 +1253,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
dependencies = [
"futures-channel",
"futures-core",
@@ -1399,9 +1268,9 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
"futures-sink",
@@ -1409,15 +1278,15 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-executor"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
dependencies = [
"futures-core",
"futures-task",
@@ -1437,32 +1306,32 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-macro"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.3",
]
[[package]]
name = "futures-sink"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-timer"
@@ -1472,9 +1341,9 @@ checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
[[package]]
name = "futures-util"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-channel",
"futures-core",
@@ -1577,7 +1446,6 @@ dependencies = [
"tempfile",
"thiserror",
"tokio",
"tower",
"tracing",
"tracing-appender",
"tracing-subscriber",
@@ -1631,7 +1499,6 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
"zeroize",
]
[[package]]
@@ -1686,9 +1553,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.15"
version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
dependencies = [
"atomic-waker",
"bytes",
@@ -2073,15 +1940,6 @@ dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -2318,12 +2176,6 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl-probe"
version = "0.2.1"
@@ -2498,17 +2350,6 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -2579,8 +2420,6 @@ dependencies = [
"bytes",
"chrono",
"cookie",
"crypto_box",
"ed25519-dalek",
"flate2",
"futures",
"gateway-admin",
@@ -2609,7 +2448,6 @@ dependencies = [
"tracing-subscriber",
"tungstenite 0.27.0",
"url",
"urlencoding",
"uuid",
"wiremock",
"zstd",
@@ -2813,9 +2651,9 @@ dependencies = [
[[package]]
name = "redis"
version = "1.5.0"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3257df217f7eab0044627a268c9cc6cdb60c0c421c88f83ac41c4e31520b6b84"
checksum = "e37a4ca5c6ca42aa3e6df2fd32b987a65d32a4c2159a6f3fe0fd1df306a2658f"
dependencies = [
"arc-swap",
"arcstr",
@@ -3049,15 +2887,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "salsa20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
dependencies = [
"cipher",
]
[[package]]
name = "schannel"
version = "0.1.29"
@@ -3633,18 +3462,18 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.19"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.19"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
@@ -4046,16 +3875,6 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "untrusted"
version = "0.7.1"
@@ -4080,12 +3899,6 @@ dependencies = [
"serde",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf-8"
version = "0.7.6"
@@ -4100,9 +3913,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.24.0"
version = "1.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
dependencies = [
"getrandom 0.4.3",
"js-sys",
+3 -2
View File
@@ -27,8 +27,9 @@ impl LoadableConfig for GatewayConfig {
return Err(ConfigError::InvalidField("schema_version"));
}
self.host.resolve_and_validate(source_dir)?;
let runtime_data_dir = self.host.runtime_data_dir().to_path_buf();
self.store
.resolve_and_validate(source_dir)
.resolve_and_validate(&runtime_data_dir)
.map_err(|_| ConfigError::InvalidField("store"))?;
self.admin
.resolve_and_validate(source_dir)
@@ -37,7 +38,7 @@ impl LoadableConfig for GatewayConfig {
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("api"))?;
self.openai
.resolve_and_validate(source_dir)
.resolve_and_validate(&runtime_data_dir)
.map_err(|_| ConfigError::InvalidField("openai"))?;
self.xai
.resolve_and_validate(source_dir)
+8
View File
@@ -18,10 +18,18 @@ fn config_loader_should_load_complete_terminal_example() {
fn config_loader_should_resolve_paths_relative_to_config_file() {
let (config, _directory) = parse_config(&valid_config()).expect("resolved config");
let debug = format!("{config:?}");
assert!(debug.contains(".runtime/data"));
assert!(debug.contains(".runtime/logs"));
assert!(debug.contains("frontend/dist"));
}
#[test]
fn config_loader_should_reject_missing_runtime_data_dir() {
let config = valid_config().replace(" runtime_data_dir: '../.runtime/data'\n", "");
assert!(parse_config(&config).is_err());
}
#[test]
fn config_loader_should_inject_connection_passwords_into_urls() {
parse_config(&valid_config()).expect("Store validates password injection into both URLs");
@@ -119,11 +119,11 @@ pub struct RetentionDecision {
/// 从按完成时间倒序的 completed 计划备份中决定纳入删除流程的记录。
///
/// 规则(`docs/s3-backup-design-audit.md` §4.4):
/// 自动保留规则:
/// - `retentionDays = 0` 时不按天数清理;`retentionCount = 0` 时不按份数清理。
/// - 自动计划备份满足任一启用阈值即可进入删除。
/// - 无论阈值如何,至少保留最近一个成功的计划备份(倒序下标 0)。
/// - 手动备份不受自动阈值影响;无 TTL,仅由管理员手动删除。
/// - 手动备份不进入本规则;手工/计划记录各自的 `expires_at` 由到期扫描处理。
#[must_use]
pub fn decide_retention(
retention_days: u32,
+12 -8
View File
@@ -134,9 +134,9 @@ impl fmt::Debug for AdminConfig {
/// Admin-owned 启动配置错误;不回显任何配置值。
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum AdminConfigError {
#[error("configuration field `{0}` is invalid")]
#[error("配置字段 `{0}` 不合法")]
InvalidField(&'static str),
#[error("admin.default_password does not meet the initial password policy")]
#[error("admin.default_password 不符合初始密码策略")]
WeakInitialPassword,
}
@@ -322,9 +322,9 @@ fn backup_worker_contribution(
task: backup::task::BackupTask,
) -> Result<Vec<WorkerContribution>, AdminError> {
let id = WorkerId::try_new(WorkerKind::Backup, BACKUP_WORKER_OWNER)
.map_err(|_| AdminError::internal("Backup worker id is invalid"))?;
.map_err(|_| AdminError::internal("备份 Worker ID 不合法"))?;
let restart = DaemonRestartPolicy::try_new(Duration::from_secs(1), Duration::from_secs(60))
.map_err(|_| AdminError::internal("Backup worker restart policy is invalid"))?;
.map_err(|_| AdminError::internal("备份 Worker 重启策略不合法"))?;
let registration = WorkerRegistration::try_new(
id,
WorkerRunnable::Daemon {
@@ -332,12 +332,12 @@ fn backup_worker_contribution(
task: Box::new(task),
},
)
.map_err(|_| AdminError::internal("Backup worker registration is invalid"))?;
.map_err(|_| AdminError::internal("备份 Worker 注册信息不合法"))?;
Ok(vec![WorkerContribution::Registration(registration)])
}
fn provider_kind(value: &'static str) -> Result<ProviderKind, AdminError> {
ProviderKind::new(value).map_err(|_| AdminError::internal("Built-in Provider kind is invalid"))
ProviderKind::new(value).map_err(|_| AdminError::internal("内置 Provider 类型不合法"))
}
fn map_provider_registry_error(error: ProviderAdminError) -> AdminError {
@@ -347,8 +347,12 @@ fn map_provider_registry_error(error: ProviderAdminError) -> AdminError {
}
ProviderAdminErrorKind::NotFound => AdminErrorKind::NotFound,
ProviderAdminErrorKind::Conflict => AdminErrorKind::Conflict,
ProviderAdminErrorKind::Unavailable => AdminErrorKind::Unavailable,
ProviderAdminErrorKind::Ambiguous => AdminErrorKind::UpstreamResultUnknown,
ProviderAdminErrorKind::Unavailable | ProviderAdminErrorKind::CredentialRefreshRequired => {
AdminErrorKind::Unavailable
}
ProviderAdminErrorKind::BadGateway => AdminErrorKind::BadGateway,
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
};
AdminError::new(kind, "Provider registry initialization failed")
AdminError::new(kind, "Provider 注册表初始化失败")
}
@@ -5,7 +5,11 @@ use std::pin::Pin;
use chrono::{DateTime, Utc};
use futures::Stream;
use gateway_core::routing::ProviderKind;
use gateway_core::{
engine::{UpstreamSendState, probe::AccountProbeErrorSource},
error::GatewayErrorKind,
routing::ProviderKind,
};
use super::{PageSize, Revision, account_groups::AccountGroupRef, observability::TimeRange};
@@ -249,6 +253,9 @@ pub enum AccountConnectionTestEvent {
},
Completed,
Failed {
source: AccountProbeErrorSource,
gateway_error_code: GatewayErrorKind,
send_state: Option<UpstreamSendState>,
message: String,
provider_error_code: Option<String>,
provider_error_type: Option<String>,
@@ -288,7 +288,7 @@ pub struct BackupRecord {
pub error_message: Option<String>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
/// 手动过期时间;创建时确定,到期自动清理。
/// 创建时确定的过期时间;手工和计划备份均可设置,到期自动清理。
pub expires_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
+13 -1
View File
@@ -21,11 +21,12 @@ pub enum AdminErrorKind {
Conflict,
RateLimited,
BadGateway,
UpstreamResultUnknown,
Unavailable,
Internal,
}
/// 不携带基础设施细节的管理用例错误。
/// 不携带基础设施细节、可安全返回给管理员的管理用例错误。
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{message}")]
pub struct AdminError {
@@ -47,6 +48,12 @@ impl AdminError {
self.kind
}
/// 返回已脱敏、可公开给管理员的文案。
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
#[must_use]
pub fn invalid(message: impl Into<String>) -> Self {
Self::new(AdminErrorKind::Invalid, message)
@@ -72,6 +79,11 @@ impl AdminError {
Self::new(AdminErrorKind::BadGateway, message)
}
#[must_use]
pub fn upstream_result_unknown(message: impl Into<String>) -> Self {
Self::new(AdminErrorKind::UpstreamResultUnknown, message)
}
#[must_use]
pub fn unavailable(message: impl Into<String>) -> Self {
Self::new(AdminErrorKind::Unavailable, message)
@@ -402,6 +402,16 @@ pub struct RequestMetrics {
pub max_latency_ms: Option<u64>,
pub latency_percentiles: LatencyPercentiles,
pub first_token_latency_percentiles: LatencyPercentiles,
pub admission_decision_count: u64,
pub admission_decision_percentiles: LatencyPercentiles,
pub account_selection_wait_count: u64,
pub account_selection_wait_percentiles: LatencyPercentiles,
pub output_throughput_p10: Option<u64>,
pub output_throughput_p50: Option<u64>,
pub output_throughput_p90: Option<u64>,
pub capacity_sample_count: u64,
pub capacity_utilization_avg_basis_points: Option<u64>,
pub capacity_utilization_p95_basis_points: Option<u64>,
pub cache_eligible_request_count: u64,
pub cache_hit_request_count: u64,
}
@@ -557,7 +567,7 @@ pub struct UsageRecord {
pub operation: String,
pub endpoint: String,
pub client_transport: String,
pub requested_model_id: String,
pub requested_model_id: Option<String>,
pub provider_kind: Option<String>,
pub provider_account_ref: Option<String>,
pub provider_account_name: Option<String>,
@@ -604,6 +614,10 @@ pub struct UsageRecord {
pub first_token_ms: Option<u64>,
pub provider_processing_ms: Option<u64>,
pub latency_ms: Option<u64>,
pub admission_decision_ms: Option<u64>,
pub account_selection_wait_ms: Option<u64>,
pub capacity_used_slots: Option<u64>,
pub capacity_total_slots: Option<u64>,
pub client_ip: Option<String>,
pub user_agent: Option<String>,
pub reasoning_effort: Option<String>,
@@ -708,6 +722,9 @@ pub struct DiagnosticObservation {
pub total_tokens: u64,
pub average_latency_ms: Option<u64>,
pub latency_p95_ms: Option<u64>,
pub first_token_p95_ms: Option<u64>,
pub non_completion_count: u64,
pub retry_count: u64,
pub cost_coverage: CostCoverage,
pub costs: Vec<CurrencyCost>,
}
@@ -787,6 +804,9 @@ pub struct TrendSummary {
pub average_latency_ms: Option<u64>,
pub max_latency_ms: Option<u64>,
pub min_latency_ms: Option<u64>,
pub peak_first_token_p95_ms: Option<f64>,
pub peak_latency_p95_ms: Option<f64>,
pub minimum_output_throughput_p50: Option<u64>,
pub success_rate: Option<f64>,
pub cache_hit_request_rate: Option<f64>,
pub costs: Vec<CurrencyCost>,
@@ -935,6 +955,7 @@ pub struct DashboardResult {
/// 洞察健康时间点的语义结果。
#[derive(Debug, Clone, PartialEq)]
pub struct UsageInsightsHealthPoint {
pub total_requests: u64,
pub bucket_start: DateTime<Utc>,
pub success_requests: u64,
pub failed_requests: u64,
@@ -954,6 +975,7 @@ pub struct UsageInsightsHealth {
pub incomplete_requests: u64,
pub caller_error_requests: u64,
pub success_rate: f64,
pub completion_rate: f64,
pub points: Vec<UsageInsightsHealthPoint>,
}
@@ -963,6 +985,13 @@ pub struct UsageInsightsPerformancePoint {
pub bucket_start: DateTime<Utc>,
pub latency_percentiles: LatencyPercentiles,
pub first_token_latency_percentiles: LatencyPercentiles,
pub admission_decision_percentiles: LatencyPercentiles,
pub account_selection_wait_percentiles: LatencyPercentiles,
pub output_throughput_p10: Option<u64>,
pub output_throughput_p50: Option<u64>,
pub output_throughput_p90: Option<u64>,
pub capacity_utilization_avg_basis_points: Option<u64>,
pub capacity_utilization_p95_basis_points: Option<u64>,
}
/// 洞察性能汇总与可观测覆盖率。
@@ -972,6 +1001,16 @@ pub struct UsageInsightsPerformance {
pub first_token_latency_percentiles: LatencyPercentiles,
pub latency_coverage: f64,
pub first_token_coverage: f64,
pub admission_decision_percentiles: LatencyPercentiles,
pub account_selection_wait_percentiles: LatencyPercentiles,
pub admission_decision_coverage: f64,
pub account_selection_wait_coverage: f64,
pub output_throughput_p10: Option<u64>,
pub output_throughput_p50: Option<u64>,
pub output_throughput_p90: Option<u64>,
pub capacity_utilization_avg_basis_points: Option<u64>,
pub capacity_utilization_p95_basis_points: Option<u64>,
pub capacity_coverage: f64,
pub points: Vec<UsageInsightsPerformancePoint>,
}
@@ -985,6 +1024,8 @@ pub struct UsageInsightsCostPoint {
pub total_tokens: u64,
pub estimated_cost: Option<DecimalAmount>,
pub standard_cost: Option<DecimalAmount>,
pub no_cache_cost: Option<DecimalAmount>,
pub cache_savings: Option<DecimalAmount>,
pub cached_token_rate: f64,
pub cache_hit_request_rate: Option<f64>,
}
@@ -994,7 +1035,11 @@ pub struct UsageInsightsCostPoint {
pub struct UsageInsightsCost {
pub estimated_cost: Option<DecimalAmount>,
pub standard_cost: Option<DecimalAmount>,
pub no_cache_cost: Option<DecimalAmount>,
pub cache_savings: Option<DecimalAmount>,
pub tier_premium: Option<DecimalAmount>,
pub cost_per_request: Option<DecimalAmount>,
pub cost_per_successful_request: Option<DecimalAmount>,
pub tokens_per_request: f64,
pub cached_token_rate: f64,
pub cache_hit_request_rate: Option<f64>,
@@ -1030,6 +1075,12 @@ pub struct DiagnosticsItem {
pub request_share: f64,
pub average_latency_ms: Option<u64>,
pub latency_p95_ms: Option<u64>,
pub first_token_p95_ms: Option<u64>,
pub non_completion_count: u64,
pub non_completion_rate: f64,
pub retry_count: u64,
pub retry_rate: f64,
pub impact_score: f64,
pub estimated_cost: Option<DecimalAmount>,
pub attempt_count: u64,
pub total_tokens: u64,
@@ -3,11 +3,12 @@
use std::fmt;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use chrono::{DateTime, NaiveDate, Utc};
use gateway_core::{
engine::credential::{OpaqueProviderData, ProviderAccountId, ProviderAccountIdentity},
routing::{ProviderKind, UpstreamModelId},
};
use uuid::Uuid;
use super::{
AdminError, MutationActor, MutationContext, PageSize, Revision,
@@ -570,13 +571,34 @@ pub enum QuotaLocalUsageAttribution {
Unavailable,
}
/// Provider quota bucket 中窗口的官方位置语义。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderQuotaWindowRole {
Primary,
Secondary,
Monthly,
}
impl ProviderQuotaWindowRole {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Primary => "primary",
Self::Secondary => "secondary",
Self::Monthly => "monthly",
}
}
}
/// 一个 Provider quota 窗口的公共投影。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderQuotaWindow {
pub key: String,
pub group: String,
pub label: String,
pub source: Option<String>,
pub limit_id: Option<String>,
pub limit_name: Option<String>,
pub role: Option<ProviderQuotaWindowRole>,
pub local_usage_attribution: QuotaLocalUsageAttribution,
pub window_seconds: Option<u64>,
pub used_percent: Option<f64>,
@@ -605,11 +627,96 @@ pub struct ProviderQuota {
pub provider_data: Option<ProviderDocument>,
}
/// Provider 官方个人资料中的累计摘要。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderProfileStatisticsSummary {
pub total_text_tokens: Option<u64>,
pub peak_tokens: Option<u64>,
pub longest_task_duration_ms: Option<u64>,
pub current_streak_days: Option<u64>,
pub longest_streak_days: Option<u64>,
}
/// Provider 官方个人资料中的单日 Token bucket。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderProfileDailyUsage {
pub date: NaiveDate,
pub tokens: u64,
}
/// Provider 官方个人资料中的插件或 Skill 调用排行。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderProfileInvocation {
pub invocation_type: String,
pub plugin_id: Option<String>,
pub plugin_name: Option<String>,
pub skill_id: Option<String>,
pub skill_name: Option<String>,
pub usage_count: Option<u64>,
}
/// Provider 官方个人资料中的活动洞察。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderProfileActivityInsights {
pub fast_mode_percent: Option<f64>,
pub reasoning_effort: Option<String>,
pub reasoning_effort_percent: Option<f64>,
pub skills_explored: Option<u64>,
pub total_skills_used: Option<u64>,
pub total_threads: Option<u64>,
pub invocations: Option<Vec<ProviderProfileInvocation>>,
}
/// Provider 已解释的官方个人资料统计。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderProfileStatistics {
pub display_name: Option<String>,
pub username: Option<String>,
pub image_url: Option<String>,
pub has_stats_error: bool,
pub summary: ProviderProfileStatisticsSummary,
pub daily_usage: Option<Vec<ProviderProfileDailyUsage>>,
pub activity_insights: ProviderProfileActivityInsights,
}
/// Provider 返回的一张安全主动额度重置卡。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderResetCredit {
pub id: String,
pub status: Option<String>,
pub title: Option<String>,
pub expires_at: Option<DateTime<Utc>>,
pub reset_type: Option<String>,
}
/// Provider 主动额度重置卡列表。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderResetCredits {
pub available_count: u64,
pub credits: Vec<ProviderResetCredit>,
}
/// 一次主动额度重置卡消费命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsumeProviderResetCredit {
pub account_id: ProviderAccountId,
pub credit_id: Option<String>,
pub redeem_request_id: Uuid,
}
/// Provider 返回的主动额度重置卡消费结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderResetCreditResult {
pub code: String,
pub credit: Option<ProviderResetCredit>,
}
impl ProviderQuota {
/// 返回账号展开面板使用的当前额度窗口用量。
///
/// 只选择具备完整时间边界且可按账号归属的窗口;优先级与 Dashboard
/// 代表性额度窗口一致,同一优先级保持 Provider 投影顺序。
/// Provider 已提供账号级本地用量时,固定窗口和无固定重置点的滚动窗口
/// 都可作为代表用量;优先级与 Dashboard 代表性额度窗口一致,
/// 同一优先级保持 Provider 投影顺序。
#[must_use]
pub fn representative_window_usage(&self) -> Option<&AccountUsage> {
self.windows
@@ -618,7 +725,6 @@ impl ProviderQuota {
.filter(|(_, window)| {
window.local_usage_attribution == QuotaLocalUsageAttribution::AccountWide
&& window.window_seconds.is_some()
&& window.reset_at.is_some()
&& window.local_usage.is_some()
})
.min_by_key(|(index, window)| (quota_usage_priority(window), *index))
@@ -627,8 +733,9 @@ impl ProviderQuota {
/// 返回 Dashboard 使用的代表性额度比例。
///
/// 优先使用短周期窗口,再依次使用周、月和其它窗口;同一优先级取较高的已用比例。
/// 这里只解释跨 Provider 共享的窗口语义,绝不读取 Provider 私有 JSON。
/// 优先使用覆盖账号全部请求的窗口;同一归属范围内再依次使用短周期、周、月
/// 和其它窗口,同一优先级取较高的已用比例。这里只解释跨 Provider 共享的窗口
/// 语义,绝不读取 Provider 私有 JSON。
#[must_use]
pub fn representative_used_percent(&self) -> Option<f64> {
if self.limit_reached {
@@ -687,7 +794,7 @@ impl ProviderQuota {
Some((index, quota_usage_priority(window), used_percent))
})
.fold(
None::<(usize, u8, f64)>,
None::<(usize, (u8, u8), f64)>,
|selected, candidate| match selected {
Some(current)
if current.1 < candidate.1
@@ -702,13 +809,18 @@ impl ProviderQuota {
}
}
fn quota_usage_priority(window: &ProviderQuotaWindow) -> u8 {
match window.group.as_str() {
fn quota_usage_priority(window: &ProviderQuotaWindow) -> (u8, u8) {
let attribution = match window.local_usage_attribution {
QuotaLocalUsageAttribution::AccountWide => 0,
QuotaLocalUsageAttribution::Unavailable => 1,
};
let duration = match window.group.as_str() {
"shortTerm" if window.window_seconds.is_some_and(is_week_window) => 1,
"shortTerm" => 0,
"monthly" => 2,
_ => 3,
}
};
(attribution, duration)
}
fn is_week_window(seconds: u64) -> bool {
@@ -13,11 +13,12 @@ use crate::model::observability::{
CalculatedBillingBreakdown, DashboardWireProfile, ProviderBillingInput,
};
use crate::model::provider_credentials::{
AuthorizationStarted, CompleteAuthorization, PendingAuthorizationMutation,
PrepareCredentialImport, PrepareCredentialRefresh, PrepareCredentialRotation,
PreparedAuthorizationCommit, PreparedCredentialImport, PreparedCredentialRotation,
ProviderExport, ProviderExportCredentialInput, ProviderModels, ProviderQuota,
ProviderQuotaRequest,
AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit,
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels,
ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest, ProviderResetCreditResult,
ProviderResetCredits,
};
/// Provider 管理失败的稳定分类。
@@ -27,27 +28,59 @@ pub enum ProviderAdminErrorKind {
Unsupported,
NotFound,
Conflict,
/// Provider 已发起不可逆操作,但无法确认最终执行结果。
Ambiguous,
Unavailable,
CredentialRefreshRequired,
BadGateway,
Internal,
}
/// 不泄漏 OAuth material 与 Provider 响应体的管理错误。
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
/// 不携带 OAuth 请求材料的管理错误。
///
/// `message` 是 Provider 局部诊断,可能包含原始上游正文;通用管理用例不得自动把它作为公开文案。
/// 只有明确拥有原始诊断合同的调用方才能读取,`Debug` 始终只记录是否存在。
#[derive(Clone, PartialEq, Eq, thiserror::Error)]
#[error("provider admin operation failed: {kind:?}")]
pub struct ProviderAdminError {
kind: ProviderAdminErrorKind,
message: Option<String>,
}
impl std::fmt::Debug for ProviderAdminError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ProviderAdminError")
.field("kind", &self.kind)
.field("message", &self.message.as_ref().map(|_| "<redacted>"))
.finish()
}
}
impl ProviderAdminError {
#[must_use]
pub const fn new(kind: ProviderAdminErrorKind) -> Self {
Self { kind }
Self {
kind,
message: None,
}
}
#[must_use]
pub fn with_message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
#[must_use]
pub const fn kind(&self) -> ProviderAdminErrorKind {
self.kind
}
#[must_use]
pub fn message(&self) -> Option<&str> {
self.message.as_deref()
}
}
/// 一个具体 Provider 对管理控制面提供的解析、验证、上游交互与运行时资源回收能力。
@@ -116,6 +149,30 @@ pub trait ProviderAdmin: Send + Sync {
request: ProviderQuotaRequest,
) -> Result<ProviderQuota, ProviderAdminError>;
/// 查询 Provider 官方个人资料统计;不支持该能力的 Provider 使用默认拒绝。
async fn profile_statistics(
&self,
_account_id: &ProviderAccountId,
) -> Result<ProviderProfileStatistics, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 查询 Provider 主动额度重置卡;不支持该能力的 Provider 使用默认拒绝。
async fn reset_credits(
&self,
_account_id: &ProviderAccountId,
) -> Result<ProviderResetCredits, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 消费 Provider 主动额度重置卡;不支持该能力的 Provider 使用默认拒绝。
async fn consume_reset_credit(
&self,
_command: ConsumeProviderResetCredit,
) -> Result<ProviderResetCreditResult, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
async fn models(
&self,
account_id: &ProviderAccountId,
@@ -84,7 +84,7 @@ impl AccountGroupService for DefaultAccountGroupService {
command: CreateAccountGroup,
) -> Result<AccountGroupMutation, AdminError> {
let id = AccountGroupId::new(format!("grp_{}", Uuid::now_v7().simple()))
.map_err(|_| AdminError::internal("Failed to create account group ID"))?;
.map_err(|_| AdminError::internal("创建账号组 ID 失败"))?;
self.publish(
self.store
.create_account_group(
@@ -24,8 +24,9 @@ use crate::{
observability::TimeRange,
provider_credentials::{
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
PrepareCredentialRefresh, ProviderModels, ProviderQuota, ProviderQuotaRequest,
ProviderQuotaWindow, QuotaLocalUsageAttribution,
ConsumeProviderResetCredit, PrepareCredentialRefresh, ProviderModels,
ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest, ProviderQuotaWindow,
ProviderResetCreditResult, ProviderResetCredits, QuotaLocalUsageAttribution,
},
},
ports::{provider::ProviderAdminRegistry, store::AccountStore},
@@ -79,6 +80,29 @@ pub trait AccountsService: Send + Sync {
refresh: bool,
) -> Result<AccountDirectoryItem, AdminError>;
async fn profile_statistics(
&self,
_account_id: &ProviderAccountId,
) -> Result<ProviderProfileStatistics, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持账号统计"))
}
async fn reset_credits(
&self,
_context: &MutationContext,
_account_id: ProviderAccountId,
) -> Result<ProviderResetCredits, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持重置额度"))
}
async fn consume_reset_credit(
&self,
_context: &MutationContext,
_command: ConsumeProviderResetCredit,
) -> Result<ProviderResetCreditResult, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持重置额度"))
}
async fn models(
&self,
account_id: &ProviderAccountId,
@@ -97,11 +121,13 @@ pub(crate) struct DefaultAccountsService {
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
reset_credit_locks:
Arc<futures::lock::Mutex<BTreeMap<ProviderAccountId, Arc<futures::lock::Mutex<()>>>>>,
}
impl DefaultAccountsService {
#[must_use]
pub(crate) const fn new(
pub(crate) fn new(
accounts: Arc<dyn AccountStore>,
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
@@ -112,9 +138,22 @@ impl DefaultAccountsService {
providers,
snapshot,
probe,
reset_credit_locks: Arc::new(futures::lock::Mutex::new(BTreeMap::new())),
}
}
async fn reset_credit_lock(
&self,
account_id: &ProviderAccountId,
) -> Arc<futures::lock::Mutex<()>> {
let mut locks = self.reset_credit_locks.lock().await;
Arc::clone(
locks
.entry(account_id.clone())
.or_insert_with(|| Arc::new(futures::lock::Mutex::new(()))),
)
}
async fn load_account(
&self,
account_id: &ProviderAccountId,
@@ -123,7 +162,7 @@ impl DefaultAccountsService {
.load_account(account_id.as_str())
.await
.map_err(|error| map_store_error(error, "provider account"))?
.ok_or_else(|| AdminError::not_found("Provider account was not found"))
.ok_or_else(|| AdminError::not_found("Provider 账号不存在"))
}
async fn provider_for_account(
@@ -257,7 +296,7 @@ impl AccountsService for DefaultAccountsService {
let mut quotas = futures::future::join_all(page.items.iter().map(|item| async {
let account = &item.account;
let account_id = ProviderAccountId::new(account.id.clone())
.map_err(|_| AdminError::invalid("Invalid provider account ID"))?;
.map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))?;
// 单个账号的 quota 投影失败(Provider 未注册或 quota 读取失败)不拖垮整页:
// 该账号降级为空额度投影,其余账号与页面状态照常返回。
let provider = match self.providers.require(&account.provider_kind) {
@@ -323,9 +362,7 @@ impl AccountsService for DefaultAccountsService {
account_ids: Vec<ProviderAccountId>,
) -> Result<AccountExportBundle, AdminError> {
if account_ids.is_empty() || account_ids.len() > 200 {
return Err(AdminError::invalid(
"Account export requires between 1 and 200 accounts",
));
return Err(AdminError::invalid("账号导出数量必须在 1 到 200 之间"));
}
let exported_ids = account_ids.clone();
let mut grouped = BTreeMap::<ProviderKind, Vec<ProviderAccountId>>::new();
@@ -340,7 +377,7 @@ impl AccountsService for DefaultAccountsService {
let unique = ids.iter().collect::<std::collections::BTreeSet<_>>();
unique.len() != ids.len()
}) {
return Err(AdminError::invalid("Account export contains duplicate IDs"));
return Err(AdminError::invalid("账号导出列表包含重复 ID"));
}
let mut documents = Vec::with_capacity(grouped.len());
for (provider_kind, ids) in grouped {
@@ -430,7 +467,7 @@ impl AccountsService for DefaultAccountsService {
command: UpdateAccount,
) -> Result<AccountUpdateResult, AdminError> {
let account_id = ProviderAccountId::new(command.account_id.clone())
.map_err(|_| AdminError::invalid("Invalid provider account ID"))?;
.map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))?;
let (_, provider) = self.provider_for_account(&account_id).await?;
let enabled = command.enabled;
let result = self
@@ -458,7 +495,7 @@ impl AccountsService for DefaultAccountsService {
.iter()
.map(|id| {
ProviderAccountId::new(id.clone())
.map_err(|_| AdminError::invalid("Invalid provider account ID"))
.map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))
})
.collect::<Result<Vec<_>, _>>()?;
let mut providers = BTreeMap::<
@@ -502,6 +539,68 @@ impl AccountsService for DefaultAccountsService {
self.load_directory_item(account_id, refresh).await
}
async fn profile_statistics(
&self,
account_id: &ProviderAccountId,
) -> Result<ProviderProfileStatistics, AdminError> {
let (_, provider) = self.provider_for_account(account_id).await?;
provider
.profile_statistics(account_id)
.await
.map_err(|error| map_provider_error(error, "provider profile statistics"))
}
async fn reset_credits(
&self,
context: &MutationContext,
account_id: ProviderAccountId,
) -> Result<ProviderResetCredits, AdminError> {
let (_, provider) = self.provider_for_account(&account_id).await?;
match provider.reset_credits(&account_id).await {
Ok(credits) => Ok(credits),
Err(error)
if error.kind()
== crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired =>
{
self.refresh(context, account_id.clone()).await?;
let (_, provider) = self.provider_for_account(&account_id).await?;
provider
.reset_credits(&account_id)
.await
.map_err(map_reset_credits_error_after_refresh)
}
Err(error) => Err(map_provider_error(error, "provider reset credits")),
}
}
async fn consume_reset_credit(
&self,
context: &MutationContext,
command: ConsumeProviderResetCredit,
) -> Result<ProviderResetCreditResult, AdminError> {
let account_id = command.account_id.clone();
// 覆盖 credential refresh + 同键重试的完整账号级临界区,避免 401 两次调用
// 之间插入另一笔不可逆消费。
let lock = self.reset_credit_lock(&account_id).await;
let _guard = lock.lock().await;
let (_, provider) = self.provider_for_account(&account_id).await?;
match provider.consume_reset_credit(command.clone()).await {
Ok(result) => Ok(result),
Err(error)
if error.kind()
== crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired =>
{
self.refresh(context, account_id.clone()).await?;
let (_, provider) = self.provider_for_account(&account_id).await?;
provider
.consume_reset_credit(command)
.await
.map_err(map_reset_credits_error_after_refresh)
}
Err(error) => Err(map_provider_error(error, "provider reset-credit consume")),
}
}
async fn models(
&self,
account_id: &ProviderAccountId,
@@ -565,12 +664,11 @@ impl AccountsService for DefaultAccountsService {
let upstream_body = error
.upstream_response()
.map(|response| String::from_utf8_lossy(response.body()).into_owned());
let message = upstream_body
.as_deref()
.filter(|body| !body.is_empty())
.unwrap_or_else(|| error.client_message())
.to_owned();
let message = error.client_message().to_owned();
vec![AccountConnectionTestEvent::Failed {
source: error.source(),
gateway_error_code: error.kind(),
send_state: error.send_state(),
message,
provider_error_code: error.client_error_code().map(ToOwned::to_owned),
provider_error_type: error.client_error_type().map(ToOwned::to_owned),
@@ -586,6 +684,15 @@ impl AccountsService for DefaultAccountsService {
}
}
fn map_reset_credits_error_after_refresh(
error: crate::ports::provider::ProviderAdminError,
) -> AdminError {
if error.kind() == crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired {
return AdminError::bad_gateway("上游服务拒绝了刷新后的凭据");
}
map_provider_error(error, "provider reset credits")
}
/// 账号目录中单个账号 quota 读取失败时使用的空额度投影。
fn empty_quota() -> ProviderQuota {
ProviderQuota {
@@ -199,12 +199,12 @@ fn hash_admin_password(password: &str) -> Result<String, AdminError> {
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|hash| hash.to_string())
.map_err(|_| AdminError::internal("Failed to hash administrator password"))
.map_err(|_| AdminError::internal("管理员密码哈希失败"))
}
fn verify_admin_password(password: &str, encoded: &str) -> Result<bool, AdminError> {
let hash = PasswordHash::new(encoded)
.map_err(|_| AdminError::internal("Stored administrator password hash is invalid"))?;
.map_err(|_| AdminError::internal("已保存的管理员密码哈希不合法"))?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &hash)
.is_ok())
@@ -429,9 +429,7 @@ fn map_backup_error(error: BackupError) -> AdminError {
code::ACTIVE_TASK_CONFLICT | code::STATE_CONFLICT | code::STORAGE_IDENTITY_LOCKED => {
AdminError::conflict(error.message().to_owned())
}
code::STORE_UNAVAILABLE => {
AdminError::new(AdminErrorKind::Unavailable, error.message().to_owned())
}
_ => AdminError::bad_gateway(error.message().to_owned()),
code::STORE_UNAVAILABLE => AdminError::new(AdminErrorKind::Unavailable, "备份服务暂不可用"),
_ => AdminError::bad_gateway("备份服务请求失败"),
}
}
@@ -77,7 +77,7 @@ impl ClientKeyService for DefaultClientKeyService {
.reveal_client_key(id)
.await
.map_err(|error| map_store_error(error, "client API key"))?
.ok_or_else(|| AdminError::not_found("Client API key was not found"))
.ok_or_else(|| AdminError::not_found("Client API Key 不存在"))
}
async fn create(
@@ -86,7 +86,7 @@ impl ClientKeyService for DefaultClientKeyService {
command: CreateClientKey,
) -> Result<CreatedClientKey, AdminError> {
let id = ClientApiKeyId::new(format!("key_{}", Uuid::now_v7().simple()))
.map_err(|_| AdminError::internal("Failed to create Client API key ID"))?;
.map_err(|_| AdminError::internal("创建 Client API Key ID 失败"))?;
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
let plaintext = format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes));
@@ -176,7 +176,7 @@ fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> {
};
if cursor.sort != query.sort {
return Err(AdminError::invalid(
"Client API key cursor sort does not match the query",
"Client API Key 游标排序与查询条件不一致",
));
}
let matches = matches!(
@@ -198,6 +198,6 @@ fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> {
if matches {
Ok(())
} else {
Err(AdminError::invalid("Invalid Client API key cursor"))
Err(AdminError::invalid("Client API Key 游标不合法"))
}
}
@@ -42,7 +42,15 @@ fn map_store_error(error: AdminStoreError, resource: &'static str) -> AdminError
}
AdminStoreErrorKind::Unavailable => AdminErrorKind::Unavailable,
};
AdminError::new(kind, format!("{resource} operation failed"))
tracing::warn!(resource, error_kind = ?error.kind(), "admin store operation failed");
let message = match kind {
AdminErrorKind::Invalid => "请求参数不合法",
AdminErrorKind::NotFound => "请求的资源不存在",
AdminErrorKind::Conflict => "当前资源状态冲突,请刷新后重试",
AdminErrorKind::Unavailable => "依赖服务暂不可用",
_ => "服务内部错误",
};
AdminError::new(kind, message)
}
fn map_provider_error(
@@ -56,10 +64,24 @@ fn map_provider_error(
ProviderAdminErrorKind::Unsupported => AdminErrorKind::Invalid,
ProviderAdminErrorKind::NotFound => AdminErrorKind::NotFound,
ProviderAdminErrorKind::Conflict => AdminErrorKind::Conflict,
ProviderAdminErrorKind::Ambiguous => AdminErrorKind::UpstreamResultUnknown,
ProviderAdminErrorKind::Unavailable => AdminErrorKind::Unavailable,
ProviderAdminErrorKind::CredentialRefreshRequired => AdminErrorKind::Unavailable,
ProviderAdminErrorKind::BadGateway => AdminErrorKind::BadGateway,
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
};
AdminError::new(kind, format!("{resource} operation failed"))
tracing::warn!(resource, error_kind = ?error.kind(), "admin provider operation failed");
let message = match kind {
AdminErrorKind::Invalid => "Provider 请求不合法",
AdminErrorKind::NotFound => "Provider 资源不存在",
AdminErrorKind::Conflict => "Provider 资源状态冲突,请刷新后重试",
AdminErrorKind::BadGateway => "上游服务请求失败",
AdminErrorKind::UpstreamResultUnknown => "上游执行结果未知,请刷新状态后再决定是否重试",
AdminErrorKind::Unavailable => "Provider 服务暂不可用",
AdminErrorKind::Internal => "服务内部错误",
_ => "Provider 操作失败",
};
AdminError::new(kind, message)
}
async fn publish_committed(
@@ -67,7 +89,7 @@ async fn publish_committed(
revision: crate::model::Revision,
) -> Result<(), AdminError> {
let revision = ConfigRevision::new(revision.get())
.map_err(|_| AdminError::internal("Committed configuration revision is invalid"))?;
.map_err(|_| AdminError::internal("已提交的配置版本不合法"))?;
snapshot.publish_committed(revision).await;
Ok(())
}
@@ -82,7 +104,7 @@ async fn required_credential(
.credential_details(provider_kind, account_id)
.await
.map_err(|error| map_store_error(error, resource))?
.ok_or_else(|| AdminError::not_found(format!("{resource} was not found")))
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
}
async fn pending_authorization(
@@ -114,7 +136,7 @@ async fn pending_authorization(
fn validate_prepared_import(
provider_kind: &ProviderKind,
prepared: &PreparedCredentialImport,
resource: &'static str,
_resource: &'static str,
) -> Result<(), AdminError> {
if prepared.provider_kind != *provider_kind
|| prepared
@@ -122,9 +144,7 @@ fn validate_prepared_import(
.iter()
.any(|credential| credential.provider_kind != *provider_kind)
{
return Err(AdminError::conflict(format!(
"{resource} prepared facts do not match the requested Provider scope"
)));
return Err(AdminError::conflict("Provider 准备结果与请求范围不一致"));
}
Ok(())
}
@@ -132,15 +152,13 @@ fn validate_prepared_import(
fn validate_prepared_rotation(
account: &crate::model::accounts::AccountRecord,
prepared: &PreparedCredentialRotation,
resource: &'static str,
_resource: &'static str,
) -> Result<(), AdminError> {
let facts = prepared.facts();
if facts.account_id.as_str() != account.id.as_str()
|| facts.provider_kind != account.provider_kind
{
return Err(AdminError::conflict(format!(
"{resource} prepared facts do not match the current credential"
)));
return Err(AdminError::conflict("Provider 准备结果与当前凭据不一致"));
}
Ok(())
}
@@ -154,9 +172,7 @@ async fn validate_authorization_commit(
if prepared.pending.provider_kind() != provider_kind
|| !prepared.pending.owner_binding().matches_context(context)
{
let validation_error = AdminError::conflict(format!(
"{resource} pending authorization binding is invalid"
));
let validation_error = AdminError::conflict("OAuth 授权绑定已失效,请重新发起授权");
if let Err(error) = prepared.abort().await {
tracing::warn!(
resource,
@@ -182,9 +198,7 @@ async fn validate_authorization_commit(
_ => false,
};
if !matches_target {
let validation_error = AdminError::conflict(format!(
"{resource} prepared credential does not match its pending target"
));
let validation_error = AdminError::conflict("OAuth 凭据与待提交目标不一致");
if let Err(error) = prepared.abort().await {
tracing::warn!(
resource,
@@ -2,6 +2,7 @@
use std::{
collections::{BTreeMap, btree_map::Entry},
str::FromStr,
sync::Arc,
};
@@ -186,7 +187,7 @@ impl ObservabilityService for DefaultObservabilityService {
async fn usage_record_detail(&self, request_id: &str) -> Result<UsageDetail, AdminError> {
if request_id.trim().is_empty() {
return Err(AdminError::invalid("Usage record ID is required"));
return Err(AdminError::invalid("用量记录 ID 不能为空"));
}
let mut detail = self
.store
@@ -246,26 +247,50 @@ impl ObservabilityService for DefaultObservabilityService {
let total_requests = items.iter().fold(0_u64, |total, item| {
total.saturating_add(item.request_count)
});
Ok(DiagnosticsResult {
dimension,
items: items
.into_iter()
.map(|item| DiagnosticsItem {
let mut items = items
.into_iter()
.map(|item| {
let error_rate = rate_or_zero(item.failure_count, item.request_count);
let non_completion_rate =
rate_or_zero(item.non_completion_count, item.request_count);
let retry_rate = rate_or_zero(item.retry_count, item.request_count);
let impact_score = diagnostic_impact_score(
item.request_count,
total_requests,
error_rate,
non_completion_rate,
retry_rate,
item.first_token_p95_ms,
);
DiagnosticsItem {
key: item.key,
name: item.name,
request_count: item.request_count,
success_count: item.request_count.saturating_sub(item.failure_count),
success_count: item.success_count,
error_count: item.failure_count,
error_rate: rate_or_zero(item.failure_count, item.request_count),
error_rate,
request_share: rate_or_zero(item.request_count, total_requests),
average_latency_ms: item.average_latency_ms,
latency_p95_ms: item.latency_p95_ms,
first_token_p95_ms: item.first_token_p95_ms,
non_completion_count: item.non_completion_count,
non_completion_rate,
retry_count: item.retry_count,
retry_rate,
impact_score,
estimated_cost: usd_cost(&item.costs),
attempt_count: item.attempt_count,
total_tokens: item.total_tokens,
})
.collect(),
})
}
})
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.impact_score
.total_cmp(&left.impact_score)
.then_with(|| right.request_count.cmp(&left.request_count))
});
Ok(DiagnosticsResult { dimension, items })
}
async fn ops_errors(&self, query: OpsErrorQuery) -> Result<OpsErrorPage, AdminError> {
@@ -279,35 +304,40 @@ impl ObservabilityService for DefaultObservabilityService {
fn build_usage_insights(
overview: UsageOverview,
trend: Vec<RequestMetricPoint>,
standard_costs: UsageStandardCosts,
standard_costs: UsageCostScenarios,
) -> Result<UsageInsights, AdminError> {
let granularity = trend.first().map_or(
crate::model::observability::Granularity::FifteenMinutes,
|point| point.granularity,
);
if trend.iter().any(|point| point.granularity != granularity) {
return Err(AdminError::internal(
"Usage insight trend contains mixed granularities",
));
return Err(AdminError::internal("用量洞察趋势包含不一致的时间粒度"));
}
let requests = &overview.requests;
let failed_requests = service_failure_count(requests);
let health_requests = requests.success_count.saturating_add(failed_requests);
let health = UsageInsightsHealth {
total_requests: health_requests,
total_requests: requests.request_count,
success_requests: requests.success_count,
failed_requests,
cancelled_requests: requests.cancelled_count,
incomplete_requests: requests.incomplete_count,
caller_error_requests: requests.caller_error_count,
success_rate: rate_or_zero(requests.success_count, health_requests),
completion_rate: rate_or_zero(
requests
.success_count
.saturating_add(requests.failure_count),
requests.request_count,
),
points: trend
.iter()
.map(|point| {
let failed_requests = service_failure_count(&point.metrics);
let health_requests = point.metrics.success_count.saturating_add(failed_requests);
UsageInsightsHealthPoint {
total_requests: point.metrics.request_count,
bucket_start: point.bucket_start,
success_requests: point.metrics.success_count,
failed_requests,
@@ -327,27 +357,74 @@ fn build_usage_insights(
requests.first_token_latency_count,
requests.request_count,
),
admission_decision_percentiles: requests.admission_decision_percentiles,
account_selection_wait_percentiles: requests.account_selection_wait_percentiles,
admission_decision_coverage: rate_or_zero(
requests.admission_decision_count,
requests.request_count,
),
account_selection_wait_coverage: rate_or_zero(
requests.account_selection_wait_count,
requests.request_count,
),
output_throughput_p10: requests.output_throughput_p10,
output_throughput_p50: requests.output_throughput_p50,
output_throughput_p90: requests.output_throughput_p90,
capacity_utilization_avg_basis_points: requests.capacity_utilization_avg_basis_points,
capacity_utilization_p95_basis_points: requests.capacity_utilization_p95_basis_points,
capacity_coverage: rate_or_zero(requests.capacity_sample_count, requests.request_count),
points: trend
.iter()
.map(|point| UsageInsightsPerformancePoint {
bucket_start: point.bucket_start,
latency_percentiles: point.metrics.latency_percentiles,
first_token_latency_percentiles: point.metrics.first_token_latency_percentiles,
admission_decision_percentiles: point.metrics.admission_decision_percentiles,
account_selection_wait_percentiles: point
.metrics
.account_selection_wait_percentiles,
output_throughput_p10: point.metrics.output_throughput_p10,
output_throughput_p50: point.metrics.output_throughput_p50,
output_throughput_p90: point.metrics.output_throughput_p90,
capacity_utilization_avg_basis_points: point
.metrics
.capacity_utilization_avg_basis_points,
capacity_utilization_p95_basis_points: point
.metrics
.capacity_utilization_p95_basis_points,
})
.collect(),
};
let estimated_cost = usd_cost(&overview.attempts.costs);
let UsageStandardCosts {
total: standard_cost,
by_bucket: standard_costs_by_bucket,
let UsageCostScenarios {
standard:
ScenarioCosts {
total: standard_cost,
by_bucket: standard_costs_by_bucket,
},
no_cache:
ScenarioCosts {
total: no_cache_cost,
by_bucket: no_cache_costs_by_bucket,
},
} = standard_costs;
let cache_savings = amount_difference(no_cache_cost.as_ref(), estimated_cost.as_ref());
let tier_premium = amount_difference(estimated_cost.as_ref(), standard_cost.as_ref());
let cost_per_request = estimated_cost
.as_ref()
.and_then(|cost| cost.checked_div_u64(requests.request_count));
let cost_per_successful_request = estimated_cost
.as_ref()
.and_then(|cost| cost.checked_div_u64(requests.success_count));
let cost = UsageInsightsCost {
cost_per_request: estimated_cost
.as_ref()
.and_then(|cost| cost.checked_div_u64(requests.request_count)),
cost_per_request,
estimated_cost,
standard_cost,
no_cache_cost,
cache_savings,
tier_premium,
tokens_per_request: rate_or_zero(requests.total_tokens, requests.request_count),
cost_per_successful_request,
cached_token_rate: rate_or_zero(requests.cached_tokens, requests.input_tokens),
cache_hit_request_rate: ratio(
requests.cache_hit_request_count,
@@ -367,6 +444,11 @@ fn build_usage_insights(
total_tokens: point.metrics.total_tokens,
estimated_cost: usd_cost(&point.costs),
standard_cost: standard_costs_by_bucket.get(&point.bucket_start).cloned(),
no_cache_cost: no_cache_costs_by_bucket.get(&point.bucket_start).cloned(),
cache_savings: amount_difference(
no_cache_costs_by_bucket.get(&point.bucket_start),
usd_cost(&point.costs).as_ref(),
),
cached_token_rate: rate_or_zero(
point.metrics.cached_tokens,
point.metrics.input_tokens,
@@ -391,12 +473,12 @@ fn build_usage_insights(
}
#[derive(Debug, Default)]
struct UsageStandardCosts {
struct ScenarioCosts {
total: Option<DecimalAmount>,
by_bucket: BTreeMap<DateTime<Utc>, DecimalAmount>,
}
impl UsageStandardCosts {
impl ScenarioCosts {
fn add(
&mut self,
bucket_start: DateTime<Utc>,
@@ -405,14 +487,15 @@ impl UsageStandardCosts {
self.total = Some(match self.total.take() {
Some(current) => current
.checked_add(&amount)
.ok_or_else(|| AdminError::internal("Usage standard cost exceeds decimal range"))?,
.ok_or_else(|| AdminError::internal("用量标准成本超出数值范围"))?,
None => amount.clone(),
});
match self.by_bucket.entry(bucket_start) {
Entry::Occupied(mut entry) => {
let sum = entry.get().checked_add(&amount).ok_or_else(|| {
AdminError::internal("Usage standard cost exceeds decimal range")
})?;
let sum = entry
.get()
.checked_add(&amount)
.ok_or_else(|| AdminError::internal("用量标准成本超出数值范围"))?;
entry.insert(sum);
}
Entry::Vacant(entry) => {
@@ -423,23 +506,29 @@ impl UsageStandardCosts {
}
}
#[derive(Debug, Default)]
struct UsageCostScenarios {
standard: ScenarioCosts,
no_cache: ScenarioCosts,
}
fn recover_standard_costs(
providers: &ProviderAdminRegistry,
facts: Vec<UsageCalculatedBillingFact>,
) -> Result<UsageStandardCosts, AdminError> {
let mut standard_costs = UsageStandardCosts::default();
) -> Result<UsageCostScenarios, AdminError> {
let mut scenarios = UsageCostScenarios::default();
for fact in facts {
let Ok(provider_kind) = ProviderKind::new(fact.provider_kind) else {
let Ok(provider_kind) = ProviderKind::new(fact.provider_kind.clone()) else {
continue;
};
let input = ProviderBillingInput {
upstream_model_id: fact.upstream_model_id,
service_tier: fact.service_tier,
upstream_model_id: fact.upstream_model_id.clone(),
service_tier: fact.service_tier.clone(),
input_tokens: fact.input_tokens,
output_tokens: fact.output_tokens,
cached_tokens: fact.cached_tokens,
cache_write_tokens: fact.cache_write_tokens,
total: fact.total,
total: fact.total.clone(),
};
let breakdown = match providers.calculated_billing(&provider_kind, &input) {
Ok(breakdown) => breakdown,
@@ -456,9 +545,80 @@ fn recover_standard_costs(
{
continue;
}
standard_costs.add(fact.bucket_start, breakdown.standard_amount.amount)?;
scenarios
.standard
.add(fact.bucket_start, breakdown.standard_amount.amount.clone())?;
if let Some(no_cache_cost) = no_cache_cost(&fact, &breakdown) {
scenarios.no_cache.add(fact.bucket_start, no_cache_cost)?;
}
}
Ok(standard_costs)
Ok(scenarios)
}
fn no_cache_cost(
fact: &UsageCalculatedBillingFact,
breakdown: &crate::model::observability::CalculatedBillingBreakdown,
) -> Option<DecimalAmount> {
let input_tokens = fact.input_tokens?;
let cached_tokens = fact.cached_tokens.unwrap_or_default().min(input_tokens);
let cache_write_tokens = fact
.cache_write_tokens
.unwrap_or_default()
.min(input_tokens.saturating_sub(cached_tokens));
let input_rate = decimal(&breakdown.input_price_per_million.amount)?;
let replaced_input_amount = input_rate
.scaled()
.checked_mul(u128::from(cached_tokens.saturating_add(cache_write_tokens)))?
.checked_div(1_000_000)?;
let total = decimal(&breakdown.total_amount.amount)?
.scaled()
.checked_sub(decimal(&breakdown.cache_read_amount.amount)?.scaled())?
.checked_sub(decimal(&breakdown.cache_write_amount.amount)?.scaled())?
.checked_add(replaced_input_amount)?;
DecimalAmount::from_str(
&gateway_core::accounting::Decimal::from_scaled(total)
.ok()?
.canonical(),
)
.ok()
}
fn amount_difference(
larger: Option<&DecimalAmount>,
smaller: Option<&DecimalAmount>,
) -> Option<DecimalAmount> {
let difference = decimal(larger?)?
.scaled()
.checked_sub(decimal(smaller?)?.scaled())?;
DecimalAmount::from_str(
&gateway_core::accounting::Decimal::from_scaled(difference)
.ok()?
.canonical(),
)
.ok()
}
fn decimal(value: &DecimalAmount) -> Option<gateway_core::accounting::Decimal> {
value.as_str().parse().ok()
}
fn diagnostic_impact_score(
request_count: u64,
total_requests: u64,
error_rate: f64,
non_completion_rate: f64,
retry_rate: f64,
first_token_p95_ms: Option<u64>,
) -> f64 {
let request_share = rate_or_zero(request_count, total_requests);
let slow_score = first_token_p95_ms
.map_or(0.0, |value| value as f64 / 30_000.0)
.min(1.0);
error_rate * 0.35
+ non_completion_rate * 0.25
+ retry_rate.min(1.0) * 0.20
+ request_share * 0.10
+ slow_score * 0.10
}
fn usd_cost(costs: &[CurrencyCost]) -> Option<DecimalAmount> {
@@ -612,9 +772,9 @@ fn trend(kind: TrendKind, points: Vec<RequestMetricPoint>) -> Result<Trend, Admi
.saturating_add(point.cost_coverage.not_billable_count);
for cost in &point.costs {
if let Some(amount) = costs.get_mut(&cost.currency) {
*amount = amount.checked_add(&cost.amount).ok_or_else(|| {
AdminError::internal("Cost aggregation exceeded numeric bounds")
})?;
*amount = amount
.checked_add(&cost.amount)
.ok_or_else(|| AdminError::internal("成本聚合结果超出数值范围"))?;
} else {
costs.insert(cost.currency.clone(), cost.amount.clone());
}
@@ -630,6 +790,20 @@ fn trend(kind: TrendKind, points: Vec<RequestMetricPoint>) -> Result<Trend, Admi
metrics.cache_hit_request_count,
metrics.cache_eligible_request_count,
);
let peak_first_token_p95_ms = points
.iter()
.filter_map(|point| point.metrics.first_token_latency_percentiles.p95_ms)
.map(|value| value.as_f64())
.reduce(f64::max);
let peak_latency_p95_ms = points
.iter()
.filter_map(|point| point.metrics.latency_percentiles.p95_ms)
.map(|value| value.as_f64())
.reduce(f64::max);
let minimum_output_throughput_p50 = points
.iter()
.filter_map(|point| point.metrics.output_throughput_p50)
.min();
Ok(Trend {
kind,
points: points.into_iter().map(trend_point).collect(),
@@ -643,6 +817,9 @@ fn trend(kind: TrendKind, points: Vec<RequestMetricPoint>) -> Result<Trend, Admi
average_latency_ms,
max_latency_ms: metrics.max_latency_ms,
min_latency_ms: metrics.min_latency_ms,
peak_first_token_p95_ms,
peak_latency_p95_ms,
minimum_output_throughput_p50,
success_rate,
cache_hit_request_rate,
costs: costs
@@ -123,6 +123,6 @@ fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError>
if valid {
Ok(())
} else {
Err(AdminError::invalid("Runtime settings violate constraints"))
Err(AdminError::invalid("运行时设置不满足约束"))
}
}
@@ -94,5 +94,12 @@ fn map_system_error(error: SystemOperationError) -> AdminError {
SystemOperationErrorKind::Upstream => AdminErrorKind::BadGateway,
SystemOperationErrorKind::Internal => AdminErrorKind::Internal,
};
AdminError::new(kind, error.message())
let message = match kind {
AdminErrorKind::Invalid => "系统操作请求不合法",
AdminErrorKind::Conflict => "系统当前状态不允许执行该操作",
AdminErrorKind::BadGateway => "系统更新服务请求失败",
AdminErrorKind::Internal => "系统操作失败",
_ => "系统操作失败",
};
AdminError::new(kind, message)
}
+69 -1
View File
@@ -6,6 +6,7 @@ use gateway_core::engine::credential::OpaqueProviderData;
use gateway_admin::{
model::{
PageSize, Revision,
accounts::AccountUsage,
auth::LoginCommand,
client_keys::ClientKeyPageSize,
observability::{DecimalAmount, RequestOutcome, TimeRange},
@@ -135,6 +136,71 @@ fn representative_quota_should_prefer_short_window_and_highest_usage() {
assert_eq!(quota.representative_used_percent(), Some(45.0));
}
#[test]
fn representative_quota_should_prefer_account_wide_window_over_model_specific_window() {
let mut model_specific = quota_window("shortTerm", Some(18_000), Some(64.0));
model_specific.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
let quota = ProviderQuota {
observed_at: None,
refresh_token_expires_at: None,
windows: vec![
model_specific,
quota_window("shortTerm", Some(604_800), Some(5.0)),
],
limit_reached: false,
provider_data: None,
};
assert_eq!(quota.representative_used_percent(), Some(5.0));
}
#[test]
fn representative_window_usage_should_accept_account_wide_rolling_window_without_reset() {
let usage = AccountUsage {
account_id: "acct_xai".to_owned(),
request_count: 5,
success_count: 5,
input_tokens: Some(100),
output_tokens: Some(20),
cached_tokens: Some(80),
cache_write_tokens: Some(0),
reasoning_tokens: Some(0),
image_input_tokens: Some(0),
image_output_tokens: Some(0),
image_request_count: 0,
image_request_failed_count: 0,
total_tokens: Some(120),
cost_coverage: Default::default(),
costs: Vec::new(),
last_used_at: Some(Utc::now()),
request_buckets: Vec::new(),
models: Vec::new(),
};
let quota = ProviderQuota {
observed_at: None,
refresh_token_expires_at: None,
windows: vec![ProviderQuotaWindow {
key: "free-rolling-24h".to_owned(),
group: "shortTerm".to_owned(),
label: "日限额".to_owned(),
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(86_400),
used_percent: None,
reset_at: None,
limit_reached: false,
local_usage: Some(usage.clone()),
provider_data: None,
}],
limit_reached: false,
provider_data: None,
};
assert_eq!(quota.representative_window_usage(), Some(&usage));
}
#[test]
fn exhausted_quota_should_project_full_usage_to_only_the_representative_window() {
let mut quota = ProviderQuota {
@@ -188,7 +254,9 @@ fn quota_window(
key: group.to_owned(),
group: group.to_owned(),
label: group.to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds,
used_percent,
@@ -5,11 +5,15 @@ use chrono::{TimeDelta, Utc};
use futures::{StreamExt as _, future::BoxFuture};
use gateway_core::{
engine::{
UpstreamSendState,
credential::{
AccountStatusFacts, CredentialState, OpaqueProviderData, ProviderAccountId,
QuotaEvidence, QuotaState, resolve_account_status,
},
probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
probe::{
AccountProbe, AccountProbeError, AccountProbeErrorSource, AccountProbeRequest,
AccountProbeResult,
},
},
error::{ClientVisibleUpstreamError, GatewayError, GatewayErrorKind},
operation::{GenerateRequest, Operation, ProtocolPayload},
@@ -30,14 +34,16 @@ use gateway_admin::{
provider_credentials::{
AuthorizationCommit, AuthorizationCommitGuard, AuthorizationCredentialCommit,
AuthorizationMutationTarget, AuthorizationStarted, CompleteAuthorization,
CredentialCommitGuard, CredentialDetails, CredentialImportCommit,
CredentialImportResult, CredentialListQuery, CredentialMutationResult, CredentialPage,
CredentialRotationCommit, PendingAuthorizationMutation, PrepareCredentialImport,
PrepareCredentialRefresh, PrepareCredentialRotation, PreparedAuthorizationCommit,
ConsumeProviderResetCredit, CredentialCommitGuard, CredentialDetails,
CredentialImportCommit, CredentialImportResult, CredentialListQuery,
CredentialMutationResult, CredentialPage, CredentialRotationCommit,
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
PrepareCredentialRotation, PreparedAuthorizationCommit,
PreparedAuthorizationCredential, PreparedCredentialCreate, PreparedCredentialImport,
PreparedCredentialRotation, PreparedCredentialRotationFacts, ProviderDocument,
ProviderExport, ProviderExportCredentialInput, ProviderModels, ProviderQuota,
ProviderQuotaRequest, ProviderQuotaWindow, QuotaLocalUsageAttribution,
ProviderQuotaRequest, ProviderQuotaWindow, ProviderResetCreditResult,
QuotaLocalUsageAttribution,
},
settings::{
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
@@ -60,7 +66,7 @@ pub(super) type EventLog = Arc<Mutex<Vec<&'static str>>>;
pub(super) struct FakeProviderAdmin {
kind: ProviderKind,
events: EventLog,
failure: Mutex<Option<ProviderAdminErrorKind>>,
failure: Mutex<Option<ProviderAdminError>>,
quota_failure: Mutex<Option<ProviderAdminErrorKind>>,
pending: Arc<Mutex<Option<PendingAuthorizationMutation>>>,
retry_authorization_after_abort: Mutex<bool>,
@@ -69,6 +75,7 @@ pub(super) struct FakeProviderAdmin {
quota_requests: Mutex<Vec<ProviderQuotaRequest>>,
quota: Mutex<ProviderQuota>,
current_credential_revision: Mutex<Revision>,
reset_credit_commands: Mutex<Vec<ConsumeProviderResetCredit>>,
}
impl FakeProviderAdmin {
@@ -85,11 +92,17 @@ impl FakeProviderAdmin {
quota_requests: Mutex::new(Vec::new()),
quota: Mutex::new(empty_quota()),
current_credential_revision: Mutex::new(revision(1)),
reset_credit_commands: Mutex::new(Vec::new()),
})
}
pub(super) fn fail_next(&self, kind: ProviderAdminErrorKind) {
*self.failure.lock().expect("provider failure") = Some(kind);
*self.failure.lock().expect("provider failure") = Some(ProviderAdminError::new(kind));
}
fn fail_next_with_message(&self, kind: ProviderAdminErrorKind, message: &str) {
*self.failure.lock().expect("provider failure") =
Some(ProviderAdminError::new(kind).with_message(message));
}
pub(super) fn fail_next_quota(&self, kind: ProviderAdminErrorKind) {
@@ -113,6 +126,13 @@ impl FakeProviderAdmin {
.clone()
}
fn reset_credit_commands(&self) -> Vec<ConsumeProviderResetCredit> {
self.reset_credit_commands
.lock()
.expect("provider reset-credit commands")
.clone()
}
pub(super) fn set_quota(&self, quota: ProviderQuota) {
*self.quota.lock().expect("provider quota") = quota;
}
@@ -145,7 +165,7 @@ impl FakeProviderAdmin {
fn require_available(&self) -> Result<(), ProviderAdminError> {
match self.failure.lock().expect("provider failure").take() {
Some(kind) => Err(ProviderAdminError::new(kind)),
Some(error) => Err(error),
None => Ok(()),
}
}
@@ -344,6 +364,22 @@ impl ProviderAdmin for FakeProviderAdmin {
Ok(self.quota.lock().expect("provider quota").clone())
}
async fn consume_reset_credit(
&self,
command: ConsumeProviderResetCredit,
) -> Result<ProviderResetCreditResult, ProviderAdminError> {
self.record("provider.consume_reset_credit");
self.reset_credit_commands
.lock()
.expect("provider reset-credit commands")
.push(command);
self.require_available()?;
Ok(ProviderResetCreditResult {
code: "reset".to_owned(),
credit: None,
})
}
async fn models(
&self,
_: &ProviderAccountId,
@@ -918,6 +954,9 @@ async fn connection_test_should_probe_unavailable_account() {
assert!(matches!(
events.last(),
Some(AccountConnectionTestEvent::Failed {
source: AccountProbeErrorSource::Upstream,
gateway_error_code: GatewayErrorKind::RateLimited,
send_state: Some(UpstreamSendState::NotSent),
message,
provider_error_code: Some(code),
provider_error_type: Some(error_type),
@@ -952,6 +991,9 @@ async fn connection_test_rate_limited_probe_returns_provider_failure() {
assert!(matches!(
events.last(),
Some(AccountConnectionTestEvent::Failed {
source: AccountProbeErrorSource::Provider,
gateway_error_code: GatewayErrorKind::RateLimited,
send_state: Some(UpstreamSendState::NotSent),
message,
..
})
@@ -1257,7 +1299,9 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop
key: "primary".to_owned(),
group: "shortTerm".to_owned(),
label: "5小时限额".to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(5 * 60 * 60),
used_percent: Some(97.0),
@@ -1386,7 +1430,9 @@ async fn accounts_list_should_not_derive_rate_limited_from_provider_quota_view()
key: "primary".to_owned(),
group: "shortTerm".to_owned(),
label: "5小时限额".to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(5 * 60 * 60),
used_percent: Some(100.0),
@@ -1431,7 +1477,9 @@ async fn accounts_list_should_not_derive_exhaustion_from_provider_quota_view() {
key: "primary".to_owned(),
group: "shortTerm".to_owned(),
label: "5小时限额".to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(5 * 60 * 60),
used_percent: Some(100.0),
@@ -1477,7 +1525,9 @@ async fn accounts_list_should_attach_local_usage_to_quota_windows() {
key: "primary".to_owned(),
group: "shortTerm".to_owned(),
label: "5小时限额".to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(5 * 60 * 60),
used_percent: Some(97.0),
@@ -1542,7 +1592,9 @@ async fn accounts_list_should_not_attach_account_usage_to_model_specific_quota_w
key: "core-primary".to_owned(),
group: "weekly".to_owned(),
label: "周额度".to_owned(),
source: Some("codex".to_owned()),
limit_id: Some("codex".to_owned()),
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(7 * 24 * 60 * 60),
used_percent: Some(1.0),
@@ -1554,8 +1606,10 @@ async fn accounts_list_should_not_attach_account_usage_to_model_specific_quota_w
ProviderQuotaWindow {
key: "codex-bengalfox-primary".to_owned(),
group: "weekly".to_owned(),
label: "GPT-5.3-Codex-Spark · 周额度".to_owned(),
source: Some("codex_bengalfox".to_owned()),
label: "周额度".to_owned(),
limit_id: Some("codex_bengalfox".to_owned()),
limit_name: Some("GPT-5.3-Codex-Spark".to_owned()),
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::Unavailable,
window_seconds: Some(7 * 24 * 60 * 60),
used_percent: Some(0.0),
@@ -1650,6 +1704,90 @@ async fn accounts_refresh_provider_failure_should_not_call_store_commit() {
);
}
#[tokio::test]
async fn reset_credit_oauth_refresh_should_reuse_the_exact_consume_command() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
provider.fail_next(ProviderAdminErrorKind::CredentialRefreshRequired);
let store = FakeAccountStore::new("openai", events.clone());
let services = accounts_service(provider.clone(), store).await;
let redeem_request_id =
uuid::Uuid::parse_str("8fbf302d-11df-4bd5-82e4-08e4b3df7874").expect("UUID v4");
let command = ConsumeProviderResetCredit {
account_id: ProviderAccountId::new("acct_test").expect("account ID"),
credit_id: Some("credit_1".to_owned()),
redeem_request_id,
};
let result = services
.accounts()
.consume_reset_credit(&context("reset-credit-refresh"), command.clone())
.await
.expect("consume after OAuth refresh");
assert_eq!(result.code, "reset");
assert_eq!(provider.reset_credit_commands(), [command.clone(), command]);
assert!(recorded(&events).contains(&"provider.prepare_refresh"));
assert!(recorded(&events).contains(&"store.commit_refresh"));
}
#[tokio::test]
async fn reset_credit_unknown_result_should_keep_a_stable_admin_kind() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
provider.fail_next(ProviderAdminErrorKind::Ambiguous);
let store = FakeAccountStore::new("openai", events);
let services = accounts_service(provider, store).await;
let command = ConsumeProviderResetCredit {
account_id: ProviderAccountId::new("acct_test").expect("account ID"),
credit_id: Some("credit_1".to_owned()),
redeem_request_id: uuid::Uuid::parse_str("244e790c-42a3-4ec9-a45d-a32b218bc8ac")
.expect("UUID v4"),
};
let error = services
.accounts()
.consume_reset_credit(&context("reset-credit-ambiguous"), command)
.await
.expect_err("ambiguous consume result must remain classified");
assert_eq!(
error.kind(),
gateway_admin::model::AdminErrorKind::UpstreamResultUnknown
);
assert_eq!(
error.to_string(),
"上游执行结果未知,请刷新状态后再决定是否重试"
);
}
#[tokio::test]
async fn accounts_refresh_should_not_expose_the_provider_failure_message() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let upstream_message = "Your refresh token has already been used.";
provider.fail_next_with_message(ProviderAdminErrorKind::Conflict, upstream_message);
let store = FakeAccountStore::new("openai", events.clone());
let services = accounts_service(provider, store).await;
let error = services
.accounts()
.refresh(
&context("refresh-provider-message"),
ProviderAccountId::new("acct_test").expect("account ID"),
)
.await
.expect_err("Provider preparation must fail");
assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Conflict);
assert_eq!(error.to_string(), "Provider 资源状态冲突,请刷新后重试");
assert!(!error.to_string().contains(upstream_message));
assert_eq!(
recorded(&events),
["store.load_account", "provider.prepare_refresh"]
);
}
#[tokio::test]
async fn accounts_refresh_store_failure_should_drop_guard_after_commit_attempt() {
let events = events();
@@ -1853,12 +1991,16 @@ impl AccountProbe for FailingAccountProbe {
Some("usage_exhausted".to_owned()),
Some("invalid_request_error".to_owned()),
);
Err(GatewayError::new(
GatewayErrorKind::RateLimited,
"upstream capacity is temporarily unavailable",
)
.with_client_visible_upstream_error(detail)
.into())
Err(AccountProbeError::new(
GatewayError::new(
GatewayErrorKind::RateLimited,
"upstream capacity is temporarily unavailable",
)
.with_client_visible_upstream_error(detail),
AccountProbeErrorSource::Upstream,
Some(UpstreamSendState::NotSent),
None,
))
})
}
}
@@ -1874,11 +2016,15 @@ impl AccountProbe for FreeModelQuotaProbe {
) -> BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
Box::pin(async move {
self.store.set_account_after_probe(account_record("xai"));
Err(GatewayError::new(
GatewayErrorKind::RateLimited,
"xAI free model quota is exhausted",
)
.into())
Err(AccountProbeError::new(
GatewayError::new(
GatewayErrorKind::RateLimited,
"xAI free model quota is exhausted",
),
AccountProbeErrorSource::Provider,
Some(UpstreamSendState::NotSent),
None,
))
})
}
}
@@ -14,10 +14,10 @@ use gateway_admin::{
observability::{
AccountPoolMetrics, AttemptMetrics, CostCoverage, CurrencyCost, DashboardObservation,
DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation, Granularity,
HealthStatus, OpsErrorPage, OpsErrorQuery, PageNumber, RequestMetricPoint,
RequestMetrics, RequestOutcome, TimeRange, TrendKind, UsageBilling,
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageOverview, UsagePage,
UsageQuery, UsageRecord, china_day_start,
HealthStatus, LatencyPercentiles, OpsErrorPage, OpsErrorQuery, PageNumber,
PercentileMilliseconds, RequestMetricPoint, RequestMetrics, RequestOutcome, TimeRange,
TrendKind, UsageBilling, UsageCalculatedBillingFact, UsageDetail, UsageFilter,
UsageOverview, UsagePage, UsageQuery, UsageRecord, china_day_start,
},
settings::{
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
@@ -234,6 +234,24 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
output_tokens: 200,
total_tokens: 1_000,
latency_count: 5,
admission_decision_count: 10,
admission_decision_percentiles: LatencyPercentiles {
p50_ms: Some(PercentileMilliseconds::new(2.0).expect("admission P50")),
p95_ms: Some(PercentileMilliseconds::new(5.0).expect("admission P95")),
p99_ms: Some(PercentileMilliseconds::new(8.0).expect("admission P99")),
},
account_selection_wait_count: 8,
account_selection_wait_percentiles: LatencyPercentiles {
p50_ms: Some(PercentileMilliseconds::new(10.0).expect("selection P50")),
p95_ms: Some(PercentileMilliseconds::new(40.0).expect("selection P95")),
p99_ms: Some(PercentileMilliseconds::new(70.0).expect("selection P99")),
},
output_throughput_p10: Some(40),
output_throughput_p50: Some(50),
output_throughput_p90: Some(60),
capacity_sample_count: 8,
capacity_utilization_avg_basis_points: Some(6_500),
capacity_utilization_p95_basis_points: Some(8_000),
..RequestMetrics::default()
};
store.replace_overview(UsageOverview {
@@ -294,10 +312,16 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
.await
.expect("usage diagnostics");
assert_eq!(insights.health.total_requests, 8);
assert_eq!(insights.health.total_requests, 10);
assert_eq!(insights.health.failed_requests, 2);
assert_eq!(insights.health.success_rate, 0.75);
assert_eq!(insights.health.completion_rate, 1.0);
assert_eq!(insights.granularity, Granularity::FifteenMinutes);
assert_eq!(insights.performance.latency_coverage, 0.5);
assert_eq!(insights.performance.admission_decision_coverage, 1.0);
assert_eq!(insights.performance.account_selection_wait_coverage, 0.8);
assert_eq!(insights.performance.capacity_coverage, 0.8);
assert_eq!(insights.performance.output_throughput_p50, Some(50));
assert_eq!(insights.cost.tokens_per_request, 100.0);
assert_eq!(
insights
@@ -337,8 +361,25 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
.map(gateway_admin::model::observability::DecimalAmount::as_str),
Some("1")
);
assert_eq!(
insights
.cost
.no_cache_cost
.as_ref()
.map(gateway_admin::model::observability::DecimalAmount::as_str),
Some("1.25")
);
assert_eq!(
insights
.cost
.tier_premium
.as_ref()
.map(gateway_admin::model::observability::DecimalAmount::as_str),
Some("0.25")
);
assert_eq!(diagnostics.items[0].request_share, 0.75);
assert_eq!(diagnostics.items[1].request_share, 0.25);
assert!((diagnostics.items[0].impact_score - 0.075).abs() < 1e-9);
}
#[tokio::test]
@@ -662,7 +703,7 @@ fn total_record(
operation: "generate".to_owned(),
endpoint: "/v1/responses".to_owned(),
client_transport: "http_sse".to_owned(),
requested_model_id: "gpt-5.5".to_owned(),
requested_model_id: Some("gpt-5.5".to_owned()),
provider_kind: provider_kind.map(str::to_owned),
provider_account_ref: None,
provider_account_name: None,
@@ -715,6 +756,10 @@ fn total_record(
first_token_ms: None,
provider_processing_ms: None,
latency_ms: Some(100),
admission_decision_ms: None,
account_selection_wait_ms: None,
capacity_used_slots: None,
capacity_total_slots: None,
client_ip: None,
user_agent: None,
reasoning_effort: None,
@@ -742,6 +787,9 @@ fn health_metric_point(bucket_start: DateTime<Utc>, metrics: RequestMetrics) ->
fn diagnostic(name: &str, request_count: u64) -> DiagnosticObservation {
DiagnosticObservation {
first_token_p95_ms: None,
non_completion_count: 0,
retry_count: 0,
key: name.to_owned(),
name: name.to_owned(),
request_count,
@@ -3,8 +3,8 @@
use std::collections::BTreeMap;
use axum::{
Json, Router,
extract::{Query, State},
Router,
extract::State,
http::StatusCode,
response::IntoResponse,
routing::{get, post},
@@ -22,8 +22,8 @@ use gateway_core::routing::AccountGroupId;
use serde::{Deserialize, Serialize};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, PageMeta,
WireValidationError, wire::map_admin_service_error,
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
PageMeta, WireValidationError, wire::map_admin_service_error,
};
const DEFAULT_PAGE_SIZE: u32 = 50;
@@ -235,7 +235,7 @@ where
async fn list<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<ListAccountGroupsQuery>,
AdminQuery(query): AdminQuery<ListAccountGroupsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -255,7 +255,7 @@ where
async fn create<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<CreateAccountGroupRequest>,
AdminJson(request): AdminJson<CreateAccountGroupRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -281,7 +281,7 @@ where
async fn update<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<UpdateAccountGroupRequest>,
AdminJson(request): AdminJson<UpdateAccountGroupRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -308,7 +308,7 @@ where
async fn enable<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<AccountGroupIdRequest>,
AdminJson(request): AdminJson<AccountGroupIdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -319,7 +319,7 @@ where
async fn disable<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<AccountGroupIdRequest>,
AdminJson(request): AdminJson<AccountGroupIdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -355,7 +355,7 @@ where
async fn delete<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<AccountGroupIdRequest>,
AdminJson(request): AdminJson<AccountGroupIdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -387,12 +387,11 @@ fn mutation_response(
}
fn group_id(value: String) -> Result<AccountGroupId, AdminError> {
AccountGroupId::new(value).map_err(|_| AdminError::bad_request("Invalid account group ID"))
AccountGroupId::new(value).map_err(|_| AdminError::bad_request("账号组 ID 不合法"))
}
fn group_color(value: &str) -> Result<AccountGroupColor, AdminError> {
AccountGroupColor::parse(value)
.ok_or_else(|| AdminError::bad_request("Invalid account group color"))
AccountGroupColor::parse(value).ok_or_else(|| AdminError::bad_request("账号组颜色不合法"))
}
fn validate_group_fields(name: &str, description: Option<&str>) -> Result<(), AdminError> {
@@ -403,15 +402,15 @@ fn validate_group_fields(name: &str, description: Option<&str>) -> Result<(), Ad
|| description
.is_some_and(|value| value.len() > 4096 || value.chars().any(char::is_control))
{
return Err(AdminError::bad_request("Invalid account group request"));
return Err(AdminError::bad_request("账号组请求不合法"));
}
Ok(())
}
fn map_wire_error(_: WireValidationError) -> AdminError {
AdminError::bad_request("Invalid account group query")
AdminError::bad_request("账号组查询参数不合法")
}
fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError {
map_admin_service_error(error, "Account group repository unavailable")
map_admin_service_error(error)
}
+346 -30
View File
@@ -3,8 +3,8 @@
use std::{collections::BTreeSet, convert::Infallible, fmt};
use axum::{
Json, Router,
extract::{Query, State},
Router,
extract::State,
http::{HeaderValue, StatusCode, header::CACHE_CONTROL},
response::{
IntoResponse, Response,
@@ -25,10 +25,13 @@ use gateway_admin::model::{
},
provider_credentials::{
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
AuthorizationStarted, CompleteAuthorization, CredentialDeletion, CredentialDeletionResult,
CredentialImportResult, CredentialMutation, CredentialMutationResult, ImportCredentials,
ProviderDocument, ProviderModels, ProviderQuota, ProviderQuotaWindow, RotateCredential,
StartAuthorization,
AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit,
CredentialDeletion, CredentialDeletionResult, CredentialImportResult, CredentialMutation,
CredentialMutationResult, ImportCredentials, ProviderDocument, ProviderModels,
ProviderProfileActivityInsights, ProviderProfileDailyUsage, ProviderProfileInvocation,
ProviderProfileStatistics, ProviderProfileStatisticsSummary, ProviderQuota,
ProviderQuotaWindow, ProviderResetCredit, ProviderResetCreditResult, ProviderResetCredits,
RotateCredential, StartAuthorization,
},
};
use gateway_core::{
@@ -37,11 +40,12 @@ use gateway_core::{
};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value};
use uuid::{Uuid, Version};
use super::presenter::{format_compact_number, format_decimal_currency, format_number};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, PageMeta,
WireValidationError,
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
PageMeta, WireValidationError,
};
const DEFAULT_PAGE_SIZE: u32 = 50;
@@ -305,8 +309,12 @@ pub struct AccountQuotaView {
pub struct AccountQuotaWindowView {
pub key: String,
pub group: String,
pub limit_id: Option<String>,
pub limit_name: Option<String>,
pub role: Option<String>,
pub window_seconds: Option<u64>,
pub label_display: String,
pub window_label_display: String,
pub used_percent: Option<f64>,
pub used_percent_display: String,
pub limit_reached: bool,
@@ -472,6 +480,48 @@ impl AccountActionRequest {
}
}
/// 主动额度重置卡消费请求。幂等键由 UI 生成并在不确定重试时复用,与官方一致。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountResetCreditConsumeRequest {
pub account_id: String,
#[serde(default)]
pub credit_id: Option<String>,
pub redeem_request_id: String,
}
impl AccountResetCreditConsumeRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_account_id(&self.account_id, "accountId")?;
if self.credit_id.as_deref().is_some_and(|value| {
let value = value.trim();
value.is_empty() || value.len() > 512 || value.chars().any(char::is_control)
}) {
return Err(WireValidationError::new("creditId"));
}
let value = self.redeem_request_id.trim();
let redeem_request_id =
Uuid::parse_str(value).map_err(|_| WireValidationError::new("redeemRequestId"))?;
if redeem_request_id.get_version() != Some(Version::Random)
|| redeem_request_id.hyphenated().to_string() != value
{
return Err(WireValidationError::new("redeemRequestId"));
}
Ok(())
}
fn into_command(self) -> Result<ConsumeProviderResetCredit, WireValidationError> {
self.validate()?;
Ok(ConsumeProviderResetCredit {
account_id: ProviderAccountId::new(self.account_id)
.map_err(|_| WireValidationError::new("accountId"))?,
credit_id: self.credit_id.map(|value| value.trim().to_owned()),
redeem_request_id: Uuid::parse_str(&self.redeem_request_id)
.map_err(|_| WireValidationError::new("redeemRequestId"))?,
})
}
}
/// 手工 OAuth 刷新会变更持久 credential。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -539,6 +589,186 @@ pub struct AccountQuotaData {
pub account: AccountView,
}
/// Provider 官方个人资料统计响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountProfileStatisticsData {
pub display_name: Option<String>,
pub username: Option<String>,
pub image_url: Option<String>,
pub has_stats_error: bool,
pub summary: AccountProfileStatisticsSummaryView,
pub daily_usage: Option<Vec<AccountProfileDailyUsageView>>,
pub activity_insights: AccountProfileActivityInsightsView,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountProfileStatisticsSummaryView {
pub total_text_tokens: Option<u64>,
pub peak_tokens: Option<u64>,
pub longest_task_duration_ms: Option<u64>,
pub current_streak_days: Option<u64>,
pub longest_streak_days: Option<u64>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountProfileDailyUsageView {
pub date: String,
pub tokens: u64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountProfileActivityInsightsView {
pub fast_mode_percent: Option<f64>,
pub reasoning_effort: Option<String>,
pub reasoning_effort_percent: Option<f64>,
pub skills_explored: Option<u64>,
pub total_skills_used: Option<u64>,
pub total_threads: Option<u64>,
pub invocations: Option<Vec<AccountProfileInvocationView>>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountProfileInvocationView {
#[serde(rename = "type")]
pub invocation_type: String,
pub plugin_id: Option<String>,
pub plugin_name: Option<String>,
pub skill_id: Option<String>,
pub skill_name: Option<String>,
pub usage_count: Option<u64>,
}
impl From<ProviderProfileStatistics> for AccountProfileStatisticsData {
fn from(statistics: ProviderProfileStatistics) -> Self {
Self {
display_name: statistics.display_name,
username: statistics.username,
image_url: statistics.image_url,
has_stats_error: statistics.has_stats_error,
summary: profile_statistics_summary_view(statistics.summary),
daily_usage: statistics
.daily_usage
.map(|daily| daily.into_iter().map(profile_daily_usage_view).collect()),
activity_insights: profile_activity_insights_view(statistics.activity_insights),
}
}
}
const fn profile_statistics_summary_view(
summary: ProviderProfileStatisticsSummary,
) -> AccountProfileStatisticsSummaryView {
AccountProfileStatisticsSummaryView {
total_text_tokens: summary.total_text_tokens,
peak_tokens: summary.peak_tokens,
longest_task_duration_ms: summary.longest_task_duration_ms,
current_streak_days: summary.current_streak_days,
longest_streak_days: summary.longest_streak_days,
}
}
fn profile_daily_usage_view(daily: ProviderProfileDailyUsage) -> AccountProfileDailyUsageView {
AccountProfileDailyUsageView {
date: daily.date.to_string(),
tokens: daily.tokens,
}
}
fn profile_activity_insights_view(
insights: ProviderProfileActivityInsights,
) -> AccountProfileActivityInsightsView {
AccountProfileActivityInsightsView {
fast_mode_percent: insights.fast_mode_percent,
reasoning_effort: insights.reasoning_effort,
reasoning_effort_percent: insights.reasoning_effort_percent,
skills_explored: insights.skills_explored,
total_skills_used: insights.total_skills_used,
total_threads: insights.total_threads,
invocations: insights.invocations.map(|invocations| {
invocations
.into_iter()
.map(profile_invocation_view)
.collect()
}),
}
}
fn profile_invocation_view(invocation: ProviderProfileInvocation) -> AccountProfileInvocationView {
AccountProfileInvocationView {
invocation_type: invocation.invocation_type,
plugin_id: invocation.plugin_id,
plugin_name: invocation.plugin_name,
skill_id: invocation.skill_id,
skill_name: invocation.skill_name,
usage_count: invocation.usage_count,
}
}
/// 主动额度重置卡列表响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountResetCreditsData {
pub available_count: u64,
pub credits: Vec<AccountResetCreditView>,
}
/// 一张安全主动额度重置卡视图。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountResetCreditView {
pub id: String,
pub status: Option<String>,
pub title: Option<String>,
pub expires_at: Option<String>,
pub reset_type: Option<String>,
}
/// 主动额度重置卡消费响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountResetCreditResultData {
pub code: String,
pub credit: Option<AccountResetCreditView>,
}
impl From<ProviderResetCredit> for AccountResetCreditView {
fn from(credit: ProviderResetCredit) -> Self {
Self {
id: credit.id,
status: credit.status,
title: credit.title,
expires_at: credit.expires_at.map(|value| value.to_rfc3339()),
reset_type: credit.reset_type,
}
}
}
impl From<ProviderResetCredits> for AccountResetCreditsData {
fn from(credits: ProviderResetCredits) -> Self {
Self {
available_count: credits.available_count,
credits: credits
.credits
.into_iter()
.map(AccountResetCreditView::from)
.collect(),
}
}
}
impl From<ProviderResetCreditResult> for AccountResetCreditResultData {
fn from(result: ProviderResetCreditResult) -> Self {
Self {
code: result.code,
credit: result.credit.map(AccountResetCreditView::from),
}
}
}
/// Provider-owned 明文导出文档;Debug 永远不输出内部 JSON。
#[derive(Serialize)]
#[serde(transparent)]
@@ -611,6 +841,9 @@ impl From<DomainConnectionTestEvent> for AccountConnectionTestEvent {
"success": true
}),
DomainConnectionTestEvent::Failed {
source,
gateway_error_code,
send_state,
message,
provider_error_code,
provider_error_type,
@@ -619,6 +852,9 @@ impl From<DomainConnectionTestEvent> for AccountConnectionTestEvent {
upstream_body,
} => serde_json::json!({
"type": "error",
"source": source.as_str(),
"gatewayErrorCode": gateway_error_code.as_str(),
"sendState": send_state.map(gateway_core::engine::UpstreamSendState::as_str),
"error": message,
"providerErrorCode": provider_error_code,
"providerErrorType": provider_error_type,
@@ -1051,6 +1287,14 @@ where
post(batch_update_accounts::<S>),
)
.route("/api/admin/accounts/quota", get(account_quota::<S>))
.route(
"/api/admin/accounts/profile-statistics",
get(account_profile_statistics::<S>),
)
.route(
"/api/admin/accounts/reset-credits",
get(account_reset_credits::<S>).post(consume_account_reset_credit::<S>),
)
.route(
"/api/admin/accounts/quota/refresh",
post(refresh_account_quota::<S>),
@@ -1077,7 +1321,7 @@ where
async fn batch_update_accounts<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<BatchUpdateAccountsRequest>,
AdminJson(request): AdminJson<BatchUpdateAccountsRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1098,7 +1342,7 @@ where
async fn list_accounts<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<ListQuery>,
AdminQuery(query): AdminQuery<ListQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1119,7 +1363,7 @@ where
async fn account_detail<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<AccountIdQuery>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1140,7 +1384,7 @@ where
async fn export_accounts<S>(
auth: AdminAuth,
State(state): State<S>,
Query(query): Query<AccountExportQuery>,
AdminQuery(query): AdminQuery<AccountExportQuery>,
) -> Result<Response, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1163,7 +1407,7 @@ where
async fn import_accounts<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<AccountImportRequest>,
AdminJson(request): AdminJson<AccountImportRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1191,7 +1435,7 @@ where
async fn start_account_authorization<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<StartAccountAuthorizationRequest>,
AdminJson(request): AdminJson<StartAccountAuthorizationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1225,7 +1469,7 @@ where
async fn complete_account_authorization<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<CompleteAccountAuthorizationRequest>,
AdminJson(request): AdminJson<CompleteAccountAuthorizationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1259,7 +1503,7 @@ where
async fn rotate_account<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<RotateAccountRequest>,
AdminJson(request): AdminJson<RotateAccountRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1282,7 +1526,7 @@ where
async fn update_account<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<UpdateAccountRequest>,
AdminJson(request): AdminJson<UpdateAccountRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1303,7 +1547,7 @@ where
async fn delete_accounts<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<AccountDeletionRequest>,
AdminJson(request): AdminJson<AccountDeletionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1325,7 +1569,7 @@ where
async fn refresh_account<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<AccountRefreshRequest>,
AdminJson(request): AdminJson<AccountRefreshRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1344,7 +1588,7 @@ where
async fn recover_account<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<AccountActionRequest>,
AdminJson(request): AdminJson<AccountActionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1363,7 +1607,7 @@ where
async fn account_quota<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<AccountIdQuery>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1381,10 +1625,31 @@ where
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn account_profile_statistics<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.profile_statistics(&account_id)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountProfileStatisticsData::from(result)),
))
}
async fn refresh_account_quota<S>(
_auth: AdminAuth,
State(state): State<S>,
Json(request): Json<AccountActionRequest>,
AdminJson(request): AdminJson<AccountActionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1402,10 +1667,52 @@ where
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn account_reset_credits<S>(
auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.reset_credits(&auth.context().mutation_context(), account_id)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountResetCreditsData::from(result)),
))
}
async fn consume_account_reset_credit<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<AccountResetCreditConsumeRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let command = request.into_command().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.consume_reset_credit(&auth.context().mutation_context(), command)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountResetCreditResultData::from(result)),
))
}
async fn account_models<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<AccountIdQuery>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1424,7 +1731,7 @@ where
async fn refresh_account_models<S>(
_auth: AdminAuth,
State(state): State<S>,
Json(request): Json<AccountActionRequest>,
AdminJson(request): AdminJson<AccountActionRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1443,7 +1750,7 @@ where
async fn test_account_connection<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<AccountTestQuery>,
AdminQuery(query): AdminQuery<AccountTestQuery>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -1603,7 +1910,9 @@ fn quota_window_view(window: ProviderQuotaWindow) -> AccountQuotaWindowView {
key,
group,
label,
source: _,
limit_id,
limit_name,
role,
local_usage_attribution: _,
window_seconds,
used_percent,
@@ -1612,10 +1921,17 @@ fn quota_window_view(window: ProviderQuotaWindow) -> AccountQuotaWindowView {
local_usage,
provider_data: _,
} = window;
let label_display = limit_name
.as_ref()
.map_or_else(|| label.clone(), |name| format!("{name} · {label}"));
AccountQuotaWindowView {
label_display: label,
label_display,
window_label_display: label,
key,
group,
limit_id,
limit_name,
role: role.map(|value| value.as_str().to_owned()),
window_seconds,
used_percent,
used_percent_display: used_percent
@@ -1772,7 +2088,7 @@ fn account_currency_cost_view(cost: &AccountCost) -> CurrencyCostView {
CurrencyCostView {
currency: cost.currency.clone(),
estimated_amount: cost.amount.as_str().to_owned(),
estimated_amount_display: format!("{} {}", cost.currency, cost.amount.as_str()),
estimated_amount_display: format_decimal_currency(cost.amount.as_str(), &cost.currency),
}
}
@@ -1926,9 +2242,9 @@ fn china_datetime(value: &DateTime<Utc>) -> String {
}
fn map_wire_error(error: WireValidationError) -> AdminError {
AdminError::bad_request(format!("Invalid {}", error.field()))
AdminError::bad_request(format!("{} 字段不合法", error.field()))
}
fn map_service_error(error: AdminServiceError) -> AdminError {
super::wire::map_admin_service_error(error, "Account directory unavailable")
super::wire::map_admin_service_error(error)
}
+10 -18
View File
@@ -3,7 +3,7 @@
use std::fmt;
use axum::{
Json, Router,
Router,
extract::{FromRequestParts, State},
http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE, request::Parts},
response::{IntoResponse, Response},
@@ -16,7 +16,7 @@ use gateway_admin::{
use serde::{Deserialize, Serialize};
use tower_http::request_id::RequestId;
use super::{AdminEnvelope, AdminError, AdminResponse};
use super::{AdminEnvelope, AdminError, AdminJson, AdminResponse};
const REQUEST_ID_HEADER: &str = "x-request-id";
const ADMIN_SESSION_COOKIE: &str = "cpr_admin_session";
@@ -37,11 +37,6 @@ impl AdminAuth {
pub const fn context(&self) -> &AdminRequestContext {
&self.context
}
#[must_use]
pub fn into_context(self) -> AdminRequestContext {
self.context
}
}
impl<S> FromRequestParts<S> for AdminAuth
@@ -52,8 +47,7 @@ where
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let principal = require_admin_auth(state, &parts.headers).await?;
let request_id = admin_request_id(parts)
.ok_or_else(|| AdminError::internal("Missing admin request context"))?;
let request_id = admin_request_id(parts).ok_or_else(AdminError::internal)?;
Ok(Self {
context: AdminRequestContext {
principal,
@@ -90,7 +84,7 @@ where
{
Ok(Some(admin_user_id)) => Ok(admin_user_id),
Ok(None) => Err(AdminError::admin_session_required()),
Err(_) => Err(AdminError::internal("Failed to validate admin session")),
Err(_) => Err(AdminError::internal()),
}
}
@@ -107,7 +101,7 @@ where
{
Ok(true) => Ok(AdminPrincipal::ApiKey),
Ok(false) => Err(AdminError::invalid_admin_api_key()),
Err(_) => Err(AdminError::internal("Failed to validate admin API key")),
Err(_) => Err(AdminError::internal()),
};
}
@@ -209,7 +203,7 @@ where
async fn login<S>(
State(state): State<S>,
Json(payload): Json<AdminLoginRequest>,
AdminJson(payload): AdminJson<AdminLoginRequest>,
) -> Result<Response, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -232,8 +226,7 @@ where
);
response.headers_mut().insert(
SET_COOKIE,
HeaderValue::from_str(&cookie)
.map_err(|_| AdminError::internal("Failed to create admin session cookie"))?,
HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?,
);
Ok(response)
}
@@ -250,7 +243,7 @@ where
.auth()
.validate_session(admin_session_cookie(&headers).as_deref())
.await
.map_err(|_| AdminError::internal("Failed to validate admin session"))?;
.map_err(|_| AdminError::internal())?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AdminSessionStatusData::new(authenticated)),
@@ -270,8 +263,7 @@ where
let cookie = format!("{ADMIN_SESSION_COOKIE}=; {ADMIN_SESSION_COOKIE_ATTRS}; Max-Age=0");
response.headers_mut().insert(
SET_COOKIE,
HeaderValue::from_str(&cookie)
.map_err(|_| AdminError::internal("Failed to clear admin session cookie"))?,
HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?,
);
Ok(response)
}
@@ -287,6 +279,6 @@ fn admin_session_cookie(headers: &HeaderMap) -> Option<String> {
fn map_login_error(error: LoginError) -> AdminError {
match error {
LoginError::InvalidCredentials => AdminError::invalid_admin_credentials(),
LoginError::Unavailable => AdminError::internal("Failed to create admin session"),
LoginError::Unavailable => AdminError::internal(),
}
}
+15 -15
View File
@@ -3,8 +3,8 @@
//! 路径统一位于 `/api/admin/settings/backups/*`,内部由独立 `BackupService` 承担业务。
use axum::{
Json, Router,
extract::{Query, State},
Router,
extract::State,
http::StatusCode,
response::IntoResponse,
routing::{get, post},
@@ -18,8 +18,8 @@ use secrecy::{ExposeSecret as _, SecretString};
use serde::{Deserialize, Serialize};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminPageData, AdminResponse, AdminSessionState,
PageMeta, wire::map_admin_service_error,
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminPageData, AdminQuery, AdminResponse,
AdminSessionState, PageMeta, wire::map_admin_service_error,
};
/// 备份设置视图;`secretAccessKey` 返回已保存的明文凭据,由前端掩码显示。
@@ -256,7 +256,7 @@ where
async fn update_backup_storage<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<UpdateBackupStorageRequest>,
AdminJson(request): AdminJson<UpdateBackupStorageRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -304,7 +304,7 @@ where
async fn update_backup_schedule<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<UpdateBackupScheduleRequest>,
AdminJson(request): AdminJson<UpdateBackupScheduleRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -331,28 +331,28 @@ where
async fn backup_records<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<BackupRecordsQuery>,
AdminQuery(query): AdminQuery<BackupRecordsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let page_size = gateway_admin::model::PageSize::new(query.page_size.unwrap_or(20))
.map_err(|_| AdminError::bad_request("Invalid pageSize"))?;
.map_err(|_| AdminError::bad_request("pageSize 不合法"))?;
let page = query.page.unwrap_or(1);
if page == 0 {
return Err(AdminError::bad_request("Invalid page"));
return Err(AdminError::bad_request("page 不合法"));
}
let status = match query.status.as_deref() {
None => None,
Some(value) => BackupStatus::parse(value)
.map(Some)
.ok_or_else(|| AdminError::bad_request("Invalid status"))?,
.ok_or_else(|| AdminError::bad_request("status 不合法"))?,
};
let trigger = match query.trigger.as_deref() {
None => None,
Some(value) => BackupTriggerKind::parse(value)
.map(Some)
.ok_or_else(|| AdminError::bad_request("Invalid trigger"))?,
.ok_or_else(|| AdminError::bad_request("trigger 不合法"))?,
};
let domain_query = gateway_admin::model::backup::BackupRecordListQuery {
page,
@@ -386,7 +386,7 @@ where
async fn create_backup<S>(
auth: AdminAuth,
State(state): State<S>,
body: Option<Json<CreateBackupRequest>>,
body: Option<AdminJson<CreateBackupRequest>>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -417,7 +417,7 @@ pub struct CreateBackupRequest {
async fn download_backup_url<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<BackupIdRequest>,
AdminJson(request): AdminJson<BackupIdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -437,7 +437,7 @@ where
async fn delete_backup<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<BackupIdRequest>,
AdminJson(request): AdminJson<BackupIdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -455,5 +455,5 @@ where
}
fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError {
map_admin_service_error(error, "Backup service unavailable")
map_admin_service_error(error)
}
@@ -17,16 +17,16 @@ use gateway_core::{
use serde::{Deserialize, Serialize};
use axum::{
Json, Router,
extract::{Query, State},
Router,
extract::State,
http::{HeaderValue, StatusCode, header::CACHE_CONTROL},
response::{IntoResponse, Response},
routing::{get, post},
};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, WireValidationError,
wire::map_admin_service_error,
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
WireValidationError, wire::map_admin_service_error,
};
const MAX_CURSOR_BYTES: usize = 512;
@@ -669,7 +669,7 @@ where
async fn list_client_keys<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<ListClientKeysQuery>,
AdminQuery(query): AdminQuery<ListClientKeysQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -680,15 +680,14 @@ where
.list(query.into_command().map_err(map_wire_error)?)
.await
.map_err(map_service_error)?;
let data = ClientKeyListData::try_from(result)
.map_err(|_| AdminError::internal("Failed to encode client key cursor"))?;
let data = ClientKeyListData::try_from(result).map_err(|_| AdminError::internal())?;
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn create_client_key<S>(
auth: AdminAuth,
State(state): State<S>,
Json(payload): Json<CreateClientKeyRequest>,
AdminJson(payload): AdminJson<CreateClientKeyRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -709,7 +708,7 @@ where
async fn reveal_client_key<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<ClientKeyIdQuery>,
AdminQuery(query): AdminQuery<ClientKeyIdQuery>,
) -> Result<Response, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -735,7 +734,7 @@ where
async fn update_client_key<S>(
auth: AdminAuth,
State(state): State<S>,
Json(payload): Json<UpdateClientKeyRequest>,
AdminJson(payload): AdminJson<UpdateClientKeyRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -753,7 +752,7 @@ where
async fn disable_client_key<S>(
auth: AdminAuth,
State(state): State<S>,
Json(payload): Json<ClientKeyMutationRequest>,
AdminJson(payload): AdminJson<ClientKeyMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -774,7 +773,7 @@ where
async fn enable_client_key<S>(
auth: AdminAuth,
State(state): State<S>,
Json(payload): Json<ClientKeyMutationRequest>,
AdminJson(payload): AdminJson<ClientKeyMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -795,7 +794,7 @@ where
async fn delete_client_key<S>(
auth: AdminAuth,
State(state): State<S>,
Json(payload): Json<ClientKeyMutationRequest>,
AdminJson(payload): AdminJson<ClientKeyMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -822,13 +821,14 @@ fn mutation_response(
fn map_wire_error(error: WireValidationError) -> AdminError {
match error.field() {
"cursor" => AdminError::bad_request("Invalid client key cursor"),
"clientKeyRevealNotFound" => AdminError::not_found("Client API key was not found"),
"clientKeyMutationNotFound" => AdminError::not_found("client API key was not found"),
_ => AdminError::bad_request("Invalid client API key request"),
"cursor" => AdminError::bad_request("Client API Key 游标不合法"),
"clientKeyRevealNotFound" | "clientKeyMutationNotFound" => {
AdminError::not_found("Client API Key 不存在")
}
_ => AdminError::bad_request("Client API Key 请求不合法"),
}
}
fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError {
map_admin_service_error(error, "Configuration repository unavailable")
map_admin_service_error(error)
}
@@ -0,0 +1,87 @@
//! 管理端 JSON 与 Query extractor 的稳定 rejection 映射。
use axum::{
Json,
extract::{
FromRequest, FromRequestParts, OptionalFromRequest, Query, Request,
rejection::{JsonRejection, QueryRejection},
},
http::{StatusCode, request::Parts},
};
use serde::de::DeserializeOwned;
use super::AdminError;
/// 将 Axum JSON rejection 收口为管理端错误信封。
pub struct AdminJson<T>(pub T);
impl<T, S> FromRequest<S> for AdminJson<T>
where
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = AdminError;
async fn from_request(request: Request, state: &S) -> Result<Self, Self::Rejection> {
<Json<T> as FromRequest<S>>::from_request(request, state)
.await
.map(|Json(value)| Self(value))
.map_err(map_json_rejection)
}
}
impl<T, S> OptionalFromRequest<S> for AdminJson<T>
where
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = AdminError;
async fn from_request(request: Request, state: &S) -> Result<Option<Self>, Self::Rejection> {
<Json<T> as OptionalFromRequest<S>>::from_request(request, state)
.await
.map(|value| value.map(|Json(value)| Self(value)))
.map_err(map_json_rejection)
}
}
/// 将 Axum Query rejection 收口为管理端错误信封。
pub struct AdminQuery<T>(pub T);
impl<T, S> FromRequestParts<S> for AdminQuery<T>
where
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = AdminError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
Query::<T>::from_request_parts(parts, state)
.await
.map(|Query(value)| Self(value))
.map_err(map_query_rejection)
}
}
fn map_json_rejection(rejection: JsonRejection) -> AdminError {
let status = rejection.status();
match rejection {
JsonRejection::JsonSyntaxError(_) => AdminError::malformed_json(),
JsonRejection::JsonDataError(_) => {
AdminError::invalid_request(StatusCode::UNPROCESSABLE_ENTITY, "请求字段不合法")
}
JsonRejection::MissingJsonContentType(_) => AdminError::invalid_request(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"请求必须使用 application/json",
),
JsonRejection::BytesRejection(_) if status == StatusCode::PAYLOAD_TOO_LARGE => {
AdminError::invalid_request(status, "请求体过大")
}
JsonRejection::BytesRejection(_) => AdminError::internal(),
_ => AdminError::invalid_request(status, "请求体不合法"),
}
}
fn map_query_rejection(_: QueryRejection) -> AdminError {
AdminError::bad_request("请求参数不合法")
}
@@ -5,6 +5,7 @@ use axum::{
http::{HeaderValue, header},
middleware,
response::Response,
routing::any,
};
pub mod account_groups;
@@ -12,6 +13,7 @@ pub mod accounts;
pub mod auth;
pub mod backups;
pub mod client_keys;
mod extract;
pub mod observability;
pub mod presenter;
pub mod settings;
@@ -19,6 +21,7 @@ pub mod system;
pub mod wire;
pub use auth::{AdminAuth, AdminSessionState};
pub use extract::{AdminJson, AdminQuery};
pub use wire::{
ADMIN_OK_CODE, ADMIN_OK_MESSAGE, AdminEnvelope, AdminError, AdminErrorBody, AdminErrorCode,
AdminPageData, AdminResponse, PageMeta, WireValidationError,
@@ -38,9 +41,20 @@ where
.merge(observability::router::<S>())
.merge(settings::router::<S>())
.merge(system::router::<S>())
.method_not_allowed_fallback(method_not_allowed)
.route("/api/admin", any(admin_not_found))
.route("/api/admin/{*path}", any(admin_not_found))
.layer(middleware::map_response(no_store))
}
async fn method_not_allowed() -> AdminError {
AdminError::method_not_allowed()
}
async fn admin_not_found() -> AdminError {
AdminError::admin_route_not_found()
}
async fn no_store(mut response: Response) -> Response {
response
.headers_mut()
@@ -2,13 +2,7 @@
use std::{collections::BTreeMap, num::NonZeroU32};
use axum::{
Router,
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
routing::get,
};
use axum::{Router, extract::State, http::StatusCode, response::IntoResponse, routing::get};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::{DateTime, Duration, Utc};
use gateway_admin::model::{PageSize as DomainPageSize, observability as domain};
@@ -17,7 +11,7 @@ use serde_json::Value;
use super::presenter::{format_compact_number, format_decimal_currency, format_number};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, PageMeta,
AdminAuth, AdminEnvelope, AdminError, AdminQuery, AdminResponse, AdminSessionState, PageMeta,
WireValidationError, wire::map_admin_service_error,
};
@@ -17,6 +17,21 @@ pub(crate) fn display_duration(value: Option<u64>) -> String {
}
}
fn display_duration_f64(value: Option<f64>) -> String {
let Some(value) = value.filter(|value| value.is_finite() && *value >= 0.0) else {
return "—".to_owned();
};
if value < 1_000.0 {
format!("{value:.0} ms")
} else if value < 60_000.0 {
format!("{:.2} s", value / 1_000.0)
} else if value < 3_600_000.0 {
format!("{:.1} min", value / 60_000.0)
} else {
format!("{:.1} h", value / 3_600_000.0)
}
}
pub(crate) fn display_rate(value: f64) -> String {
if value.is_finite() {
format!("{:.1}%", value * 100.0)
@@ -25,6 +40,10 @@ pub(crate) fn display_rate(value: f64) -> String {
}
}
fn basis_points_rate(value: u64) -> f64 {
value as f64 / 10_000.0
}
pub(crate) fn china_datetime(value: &DateTime<Utc>) -> String {
(*value + Duration::hours(8))
.format("%Y-%m-%d %H:%M:%S")
@@ -142,11 +161,19 @@ pub(crate) fn format_service_tier(service_tier: Option<&str>) -> String {
match service_tier {
Some("priority" | "fast") => "Fast".to_owned(),
Some("flex") => "Flex".to_owned(),
Some("default") | None => "Default".to_owned(),
Some(other) => other.to_owned(),
Some("default" | "standard") | None => "Standard".to_owned(),
Some(other) => capitalize_first(other),
}
}
fn capitalize_first(value: &str) -> String {
let mut chars = value.chars();
chars
.next()
.map(|first| first.to_uppercase().chain(chars).collect())
.unwrap_or_default()
}
pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option<BillingView> {
match billing? {
domain::UsageBilling::Total { total, .. } => Some(BillingView {
@@ -222,7 +249,7 @@ pub(crate) fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView
let model = record
.upstream_model_id
.clone()
.unwrap_or_else(|| record.requested_model_id.clone());
.or_else(|| record.requested_model_id.clone());
let transport = record
.upstream_transport
.clone()
@@ -243,7 +270,7 @@ pub(crate) fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView
account_name: record.provider_account_name,
route: record.endpoint,
model,
requested_model: Some(record.requested_model_id),
requested_model: record.requested_model_id,
upstream_model: record.upstream_model_id,
service_tier: record.service_tier,
status_code,
@@ -259,6 +286,10 @@ pub(crate) fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView
image_generation_requested: record.image_generation_requested,
image_generation_succeeded: record.image_generation_succeeded,
latency_details: UsageLatencyDetailsView {
admission_decision_ms: record.admission_decision_ms,
account_selection_wait_ms: record.account_selection_wait_ms,
capacity_used_slots: record.capacity_used_slots,
capacity_total_slots: record.capacity_total_slots,
transport_decision_wait_ms: record.transport_decision_wait_ms,
ws_connect_ms: record.connect_ms,
upstream_headers_ms: record.headers_ms,
@@ -351,9 +382,7 @@ pub(crate) fn usage_attempt_view(attempt: domain::UsageAttempt) -> UsageAttemptV
provider: attempt
.provider_kind
.unwrap_or_else(|| "unknown".to_owned()),
model: attempt
.upstream_model_id
.unwrap_or_else(|| "unknown".to_owned()),
model: attempt.upstream_model_id,
transport: attempt
.upstream_transport
.unwrap_or_else(|| "unknown".to_owned()),
@@ -508,6 +537,36 @@ pub(crate) fn trend_point_view(point: domain::TrendPoint) -> TrendPointView {
success_rate: success_rate_value
.map_or_else(|| "—".to_owned(), |value| format!("{value:.1}%")),
success_rate_value,
first_token_p50_ms: point
.metrics
.first_token_latency_percentiles
.p50_ms
.map(|value| value.as_f64()),
first_token_p95_ms: point
.metrics
.first_token_latency_percentiles
.p95_ms
.map(|value| value.as_f64()),
latency_p95_ms: point
.metrics
.latency_percentiles
.p95_ms
.map(|value| value.as_f64()),
output_throughput_p50: point.metrics.output_throughput_p50,
admission_decision_p95_ms: point
.metrics
.admission_decision_percentiles
.p95_ms
.map(|value| value.as_f64()),
account_selection_wait_p95_ms: point
.metrics
.account_selection_wait_percentiles
.p95_ms
.map(|value| value.as_f64()),
capacity_utilization: point
.metrics
.capacity_utilization_p95_basis_points
.map(basis_points_rate),
}
}
@@ -535,18 +594,20 @@ pub(crate) fn trend_summary_view(
],
TrendKind::Latency => vec![
TrendSummaryView {
label: "平均".to_owned(),
value: display_duration(summary.average_latency_ms),
label: "首字 P95".to_owned(),
value: display_duration_f64(summary.peak_first_token_p95_ms),
ratio: None,
},
TrendSummaryView {
label: "最高".to_owned(),
value: display_duration(summary.max_latency_ms),
label: "总耗时 P95".to_owned(),
value: display_duration_f64(summary.peak_latency_p95_ms),
ratio: None,
},
TrendSummaryView {
label: "最低".to_owned(),
value: display_duration(summary.min_latency_ms),
label: "吞吐 P50".to_owned(),
value: summary
.minimum_output_throughput_p50
.map_or_else(|| "—".to_owned(), |value| format!("{value} tok/s")),
ratio: None,
},
],
@@ -731,21 +792,19 @@ pub(crate) fn dashboard_view(
last_used: relative_time(credential.last_used_at, range.end),
});
}
let unavailable_accounts = provider_accounts
.total
.saturating_sub(provider_accounts.normal);
DashboardDataView {
cards: DashboardCardsView {
credentials: DashboardCredentialsCardView {
total: format_compact_number(provider_accounts.total),
total: provider_accounts.total.to_string(),
total_value: provider_accounts.total,
available: format_compact_number(provider_accounts.normal),
available: provider_accounts.normal.to_string(),
available_value: provider_accounts.normal,
unavailable: format_compact_number(
provider_accounts
.total
.saturating_sub(provider_accounts.normal),
),
unavailable_value: provider_accounts
.total
.saturating_sub(provider_accounts.normal),
unavailable: unavailable_accounts.to_string(),
unavailable_value: unavailable_accounts,
},
traffic: DashboardTrafficCardView {
today_requests: format_compact_number(today.request_count),
@@ -817,6 +876,7 @@ pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsig
.map(|point| OverviewHealthPointView {
bucket: point.bucket_start,
label: china_label(point.bucket_start, "%m-%d %H:%M"),
total_requests: point.total_requests,
success_requests: point.success_requests,
failed_requests: point.failed_requests,
cancelled_requests: point.cancelled_requests,
@@ -847,6 +907,31 @@ pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsig
.first_token_latency_percentiles
.p99_ms
.map(|value| value.as_f64()),
admission_decision_p50_ms: point
.admission_decision_percentiles
.p50_ms
.map(|value| value.as_f64()),
admission_decision_p95_ms: point
.admission_decision_percentiles
.p95_ms
.map(|value| value.as_f64()),
account_selection_wait_p50_ms: point
.account_selection_wait_percentiles
.p50_ms
.map(|value| value.as_f64()),
account_selection_wait_p95_ms: point
.account_selection_wait_percentiles
.p95_ms
.map(|value| value.as_f64()),
output_throughput_p10: point.output_throughput_p10,
output_throughput_p50: point.output_throughput_p50,
output_throughput_p90: point.output_throughput_p90,
capacity_utilization: point
.capacity_utilization_avg_basis_points
.map(basis_points_rate),
capacity_utilization_p95: point
.capacity_utilization_p95_basis_points
.map(basis_points_rate),
})
.collect();
let cost_points = insights
@@ -862,6 +947,8 @@ pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsig
total_tokens: point.total_tokens,
estimated_cost: point.estimated_cost.as_ref().map(ToString::to_string),
standard_cost: point.standard_cost.as_ref().map(ToString::to_string),
no_cache_cost: point.no_cache_cost.as_ref().map(ToString::to_string),
cache_savings: point.cache_savings.as_ref().map(ToString::to_string),
cached_token_rate: point.cached_token_rate,
cache_hit_request_rate: point.cache_hit_request_rate,
})
@@ -876,6 +963,7 @@ pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsig
incomplete_requests: insights.health.incomplete_requests,
caller_error_requests: insights.health.caller_error_requests,
success_rate: insights.health.success_rate,
completion_rate: insights.health.completion_rate,
request_change_rate: None,
success_rate_change: None,
points: health_points,
@@ -911,8 +999,42 @@ pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsig
.first_token_latency_percentiles
.p99_ms
.map(|value| value.as_f64()),
admission_decision_p50_ms: insights
.performance
.admission_decision_percentiles
.p50_ms
.map(|value| value.as_f64()),
admission_decision_p95_ms: insights
.performance
.admission_decision_percentiles
.p95_ms
.map(|value| value.as_f64()),
account_selection_wait_p50_ms: insights
.performance
.account_selection_wait_percentiles
.p50_ms
.map(|value| value.as_f64()),
account_selection_wait_p95_ms: insights
.performance
.account_selection_wait_percentiles
.p95_ms
.map(|value| value.as_f64()),
output_throughput_p10: insights.performance.output_throughput_p10,
output_throughput_p50: insights.performance.output_throughput_p50,
output_throughput_p90: insights.performance.output_throughput_p90,
capacity_utilization: insights
.performance
.capacity_utilization_avg_basis_points
.map(basis_points_rate),
capacity_utilization_p95: insights
.performance
.capacity_utilization_p95_basis_points
.map(basis_points_rate),
latency_coverage: insights.performance.latency_coverage,
first_token_coverage: insights.performance.first_token_coverage,
admission_decision_coverage: insights.performance.admission_decision_coverage,
account_selection_wait_coverage: insights.performance.account_selection_wait_coverage,
capacity_coverage: insights.performance.capacity_coverage,
points: performance_points,
},
cost: OverviewCostView {
@@ -926,11 +1048,27 @@ pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsig
.standard_cost
.as_ref()
.map(ToString::to_string),
no_cache_cost: insights
.cost
.no_cache_cost
.as_ref()
.map(ToString::to_string),
cache_savings: insights
.cost
.cache_savings
.as_ref()
.map(ToString::to_string),
tier_premium: insights.cost.tier_premium.as_ref().map(ToString::to_string),
cost_per_request: insights
.cost
.cost_per_request
.as_ref()
.map(ToString::to_string),
cost_per_successful_request: insights
.cost
.cost_per_successful_request
.as_ref()
.map(ToString::to_string),
tokens_per_request: insights.cost.tokens_per_request,
cached_token_rate: insights.cost.cached_token_rate,
cache_hit_request_rate: insights.cost.cache_hit_request_rate,
@@ -988,6 +1126,12 @@ pub(crate) fn diagnostics_view(
request_share: item.request_share,
average_latency_ms: item.average_latency_ms,
latency_p95_ms: item.latency_p95_ms,
first_token_p95_ms: item.first_token_p95_ms,
non_completion_count: item.non_completion_count,
non_completion_rate: item.non_completion_rate,
retry_count: item.retry_count,
retry_rate: item.retry_rate,
impact_score: item.impact_score,
estimated_cost: item.estimated_cost.as_ref().map(ToString::to_string),
attempt_count: item.attempt_count,
total_tokens: item.total_tokens,
@@ -997,21 +1141,21 @@ pub(crate) fn diagnostics_view(
}
pub(crate) fn map_wire_error(error: WireValidationError) -> AdminError {
if error.field() == "timeRange" {
return AdminError::invalid_time_range();
}
let message = match error.field() {
"timeRange" => "Invalid time range",
"statusCode" => "Status code must be between 100 and 599",
"attemptIndex" => "Attempt index is out of range",
"kind" => "Invalid dashboard trend kind",
"dimension" => "Invalid diagnostics dimension",
"outcome" => "Invalid Observability query",
"id" => "Usage record ID is required",
"page" | "pageSize" => "Invalid Observability query",
"cursor" => "Invalid observability cursor",
_ => "Invalid observability query",
"statusCode" => "statusCode 必须在 100 到 599 之间",
"attemptIndex" => "attemptIndex 超出有效范围",
"kind" => "Dashboard 趋势类型不合法",
"dimension" => "诊断维度不合法",
"id" => "用量记录 ID 不能为空",
"cursor" => "观测数据游标不合法",
_ => "观测查询参数不合法",
};
AdminError::bad_request(message)
}
pub(crate) fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError {
map_admin_service_error(error, "Observability repository unavailable")
map_admin_service_error(error)
}
@@ -32,7 +32,7 @@ where
pub(crate) async fn dashboard_summary<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<DashboardQuery>,
AdminQuery(query): AdminQuery<DashboardQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -56,7 +56,7 @@ where
pub(crate) async fn dashboard_trend<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<DashboardQuery>,
AdminQuery(query): AdminQuery<DashboardQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -79,7 +79,7 @@ where
pub(crate) async fn usage_records<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<UsageQuery>,
AdminQuery(query): AdminQuery<UsageQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -91,15 +91,14 @@ where
.usage_records(command)
.await
.map_err(map_service_error)?;
let data = usage_page_view(result, page, page_size)
.map_err(|_| AdminError::internal("Failed to encode observability cursor"))?;
let data = usage_page_view(result, page, page_size).map_err(|_| AdminError::internal())?;
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
pub(crate) async fn usage_record_detail<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<DetailQuery>,
AdminQuery(query): AdminQuery<DetailQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -120,7 +119,7 @@ where
pub(crate) async fn usage_records_summary<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<UsageQuery>,
AdminQuery(query): AdminQuery<UsageQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -143,7 +142,7 @@ where
pub(crate) async fn usage_insights_overview<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<UsageQuery>,
AdminQuery(query): AdminQuery<UsageQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -166,7 +165,7 @@ where
pub(crate) async fn usage_insights_diagnostics<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<DiagnosticsQuery>,
AdminQuery(query): AdminQuery<DiagnosticsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -196,7 +195,7 @@ where
pub(crate) async fn ops_errors<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<OpsQuery>,
AdminQuery(query): AdminQuery<OpsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -208,7 +207,6 @@ where
.ops_errors(command)
.await
.map_err(map_service_error)?;
let data = ops_page_view(result, page, page_size)
.map_err(|_| AdminError::internal("Failed to encode observability cursor"))?;
let data = ops_page_view(result, page, page_size).map_err(|_| AdminError::internal())?;
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
@@ -94,7 +94,7 @@ pub struct UsageRecordView {
pub account_email: Option<String>,
pub account_name: Option<String>,
pub route: String,
pub model: String,
pub model: Option<String>,
pub requested_model: Option<String>,
pub upstream_model: Option<String>,
pub service_tier: Option<String>,
@@ -157,6 +157,14 @@ pub struct WebSocketPoolMetadataView {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageLatencyDetailsView {
#[serde(skip_serializing_if = "Option::is_none")]
pub admission_decision_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub account_selection_wait_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub capacity_used_slots: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub capacity_total_slots: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub transport_decision_wait_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -183,7 +191,7 @@ pub struct UsageAttemptView {
pub attempt_index: u32,
pub trigger: String,
pub provider: String,
pub model: String,
pub model: Option<String>,
pub transport: String,
pub send_state: String,
pub outcome: String,
@@ -256,6 +264,13 @@ pub struct TrendPointView {
pub min_latency_value: Option<u64>,
pub success_rate: String,
pub success_rate_value: Option<f64>,
pub first_token_p50_ms: Option<f64>,
pub first_token_p95_ms: Option<f64>,
pub latency_p95_ms: Option<f64>,
pub output_throughput_p50: Option<u64>,
pub admission_decision_p95_ms: Option<f64>,
pub account_selection_wait_p95_ms: Option<f64>,
pub capacity_utilization: Option<f64>,
}
/// Dashboard 趋势摘要。
@@ -537,6 +552,7 @@ pub struct UsageSummaryView {
pub struct OverviewHealthPointView {
pub bucket: DateTime<Utc>,
pub label: String,
pub total_requests: u64,
pub success_requests: u64,
pub failed_requests: u64,
pub cancelled_requests: u64,
@@ -556,6 +572,7 @@ pub struct OverviewHealthView {
pub incomplete_requests: u64,
pub caller_error_requests: u64,
pub success_rate: f64,
pub completion_rate: f64,
pub request_change_rate: Option<f64>,
pub success_rate_change: Option<f64>,
pub points: Vec<OverviewHealthPointView>,
@@ -573,6 +590,15 @@ pub struct OverviewPerformancePointView {
pub first_token_p50_ms: Option<f64>,
pub first_token_p95_ms: Option<f64>,
pub first_token_p99_ms: Option<f64>,
pub admission_decision_p50_ms: Option<f64>,
pub admission_decision_p95_ms: Option<f64>,
pub account_selection_wait_p50_ms: Option<f64>,
pub account_selection_wait_p95_ms: Option<f64>,
pub output_throughput_p10: Option<u64>,
pub output_throughput_p50: Option<u64>,
pub output_throughput_p90: Option<u64>,
pub capacity_utilization: Option<f64>,
pub capacity_utilization_p95: Option<f64>,
}
/// 洞察性能摘要。
@@ -585,8 +611,20 @@ pub struct OverviewPerformanceView {
pub first_token_p50_ms: Option<f64>,
pub first_token_p95_ms: Option<f64>,
pub first_token_p99_ms: Option<f64>,
pub admission_decision_p50_ms: Option<f64>,
pub admission_decision_p95_ms: Option<f64>,
pub account_selection_wait_p50_ms: Option<f64>,
pub account_selection_wait_p95_ms: Option<f64>,
pub output_throughput_p10: Option<u64>,
pub output_throughput_p50: Option<u64>,
pub output_throughput_p90: Option<u64>,
pub capacity_utilization: Option<f64>,
pub capacity_utilization_p95: Option<f64>,
pub latency_coverage: f64,
pub first_token_coverage: f64,
pub admission_decision_coverage: f64,
pub account_selection_wait_coverage: f64,
pub capacity_coverage: f64,
pub points: Vec<OverviewPerformancePointView>,
}
@@ -602,6 +640,8 @@ pub struct OverviewCostPointView {
pub total_tokens: u64,
pub estimated_cost: Option<String>,
pub standard_cost: Option<String>,
pub no_cache_cost: Option<String>,
pub cache_savings: Option<String>,
pub cached_token_rate: f64,
pub cache_hit_request_rate: Option<f64>,
}
@@ -612,7 +652,11 @@ pub struct OverviewCostPointView {
pub struct OverviewCostView {
pub estimated_cost: Option<String>,
pub standard_cost: Option<String>,
pub no_cache_cost: Option<String>,
pub cache_savings: Option<String>,
pub tier_premium: Option<String>,
pub cost_per_request: Option<String>,
pub cost_per_successful_request: Option<String>,
pub tokens_per_request: f64,
pub cached_token_rate: f64,
pub cache_hit_request_rate: Option<f64>,
@@ -661,6 +705,12 @@ pub struct DiagnosticItemView {
pub request_share: f64,
pub average_latency_ms: Option<u64>,
pub latency_p95_ms: Option<u64>,
pub first_token_p95_ms: Option<u64>,
pub non_completion_count: u64,
pub non_completion_rate: f64,
pub retry_count: u64,
pub retry_rate: f64,
pub impact_score: f64,
pub estimated_cost: Option<String>,
pub attempt_count: u64,
pub total_tokens: u64,
@@ -1,7 +1,7 @@
//! Admin API 的展示层辅助:数值格式化。
//!
//! 按 docs/project-redundancy-boundary-audit.md BE-02 的目标,display 字段最终
//! 迁移到前端;过渡期统一由本模块提供,避免 accounts/observability 各自维护。
//! 当前 wire 仍保留 display 字段;统一由本模块提供,避免 accounts/observability
//! 各自维护格式化规则。
use gateway_core::accounting::Decimal;
@@ -3,7 +3,7 @@
use std::{collections::BTreeMap, fmt};
use axum::{
Json, Router,
Router,
extract::State,
http::StatusCode,
response::IntoResponse,
@@ -17,8 +17,8 @@ use gateway_core::routing::{PublicModelId, UpstreamModelId};
use serde::{Deserialize, Serialize};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, WireValidationError,
wire::map_admin_service_error,
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminResponse, AdminSessionState,
WireValidationError, wire::map_admin_service_error,
};
/// 客户端模型到上游模型的全局精确映射。
@@ -197,7 +197,7 @@ where
async fn update_settings<S>(
auth: AdminAuth,
State(state): State<S>,
Json(request): Json<UpdateRuntimeSettingsRequest>,
AdminJson(request): AdminJson<UpdateRuntimeSettingsRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -324,16 +324,16 @@ fn valid_model_name(value: &str, max_len: usize) -> bool {
fn map_wire_error(error: WireValidationError) -> AdminError {
let message = match error.field() {
"settingsRefreshConcurrencyOverflow" => "Invalid refreshConcurrency".to_owned(),
"settingsMaxConcurrencyOverflow" => "Invalid maxConcurrentPerAccount".to_owned(),
"settingsUsageRetentionOverflow" => "Invalid usageRetentionDays".to_owned(),
"settingsOpsRetentionOverflow" => "Invalid opsEventRetentionDays".to_owned(),
"settingsAuditRetentionOverflow" => "Invalid auditRetentionDays".to_owned(),
field => format!("Invalid field: {field}"),
"settingsRefreshConcurrencyOverflow" => "refreshConcurrency 不合法".to_owned(),
"settingsMaxConcurrencyOverflow" => "maxConcurrentPerAccount 不合法".to_owned(),
"settingsUsageRetentionOverflow" => "usageRetentionDays 不合法".to_owned(),
"settingsOpsRetentionOverflow" => "opsEventRetentionDays 不合法".to_owned(),
"settingsAuditRetentionOverflow" => "auditRetentionDays 不合法".to_owned(),
field => format!("{field} 字段不合法"),
};
AdminError::bad_request(message)
}
fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError {
map_admin_service_error(error, "Settings repository unavailable")
map_admin_service_error(error)
}
+10 -10
View File
@@ -5,8 +5,8 @@
use std::convert::Infallible;
use axum::{
Json, Router,
extract::{Query, State},
Router,
extract::State,
http::StatusCode,
response::{
IntoResponse,
@@ -23,7 +23,7 @@ use gateway_admin::model::system::{
use serde::{Deserialize, Serialize};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState,
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
wire::map_admin_service_error,
};
@@ -251,7 +251,7 @@ where
async fn update_detail<S>(
_auth: AdminAuth,
State(state): State<S>,
Query(query): Query<UpdateDetailQuery>,
AdminQuery(query): AdminQuery<UpdateDetailQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -290,7 +290,7 @@ where
async fn perform_update<S>(
_auth: AdminAuth,
State(state): State<S>,
payload: Option<Json<UpdateRequest>>,
payload: Option<AdminJson<UpdateRequest>>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
@@ -298,7 +298,7 @@ where
let result = state
.admin_services()
.system()
.perform_update(payload.map(|Json(value)| value.into_target_version()))
.perform_update(payload.map(|AdminJson(value)| value.into_target_version()))
.await
.map_err(map_system_error)?;
let SystemOperationAccepted::Update {
@@ -309,7 +309,7 @@ where
target_version,
} = result
else {
return Err(AdminError::internal("Invalid system update result"));
return Err(AdminError::internal());
};
Ok(AdminResponse::new(
StatusCode::OK,
@@ -361,7 +361,7 @@ where
need_restart,
} = result
else {
return Err(AdminError::internal("Invalid system rollback result"));
return Err(AdminError::internal());
};
Ok(AdminResponse::new(
StatusCode::OK,
@@ -391,7 +391,7 @@ where
message,
} = result
else {
return Err(AdminError::internal("Invalid system restart result"));
return Err(AdminError::internal());
};
Ok(AdminResponse::new(
StatusCode::OK,
@@ -477,5 +477,5 @@ const fn update_event_level_name(level: SystemUpdateEventLevel) -> &'static str
}
fn map_system_error(error: gateway_admin::model::AdminError) -> AdminError {
map_admin_service_error(error, "System operation unavailable")
map_admin_service_error(error)
}
+132 -53
View File
@@ -116,6 +116,8 @@ impl AdminErrorCode {
pub const USAGE_RECORD_ACCOUNTS: Self = Self(50002);
/// 上游网关失败。
pub const BAD_GATEWAY: Self = Self(50201);
/// 不可逆上游操作的执行结果未知。
pub const UPSTREAM_RESULT_UNKNOWN: Self = Self(50202);
/// 依赖的服务暂不可用。
pub const SERVICE_UNAVAILABLE: Self = Self(50301);
@@ -165,6 +167,89 @@ pub struct AdminError {
body: AdminErrorBody,
}
#[derive(Debug, Clone, Copy)]
struct AdminErrorSpec {
status: StatusCode,
code: AdminErrorCode,
message: &'static str,
}
impl AdminErrorSpec {
const fn new(status: StatusCode, code: AdminErrorCode, message: &'static str) -> Self {
Self {
status,
code,
message,
}
}
}
const MALFORMED_JSON: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::BAD_REQUEST,
AdminErrorCode::MALFORMED_JSON,
"请求体不是合法 JSON",
);
const BAD_REQUEST: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::BAD_REQUEST,
AdminErrorCode::BAD_REQUEST,
"请求参数不合法",
);
const INVALID_TIME_RANGE: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::BAD_REQUEST,
AdminErrorCode::INVALID_TIME_RANGE,
"时间范围不合法",
);
const SESSION_REQUIRED: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::UNAUTHORIZED,
AdminErrorCode::SESSION_REQUIRED,
"需要管理员登录",
);
const INVALID_CREDENTIALS: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::UNAUTHORIZED,
AdminErrorCode::INVALID_CREDENTIALS,
"管理员用户名或密码错误",
);
const INVALID_API_KEY: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::UNAUTHORIZED,
AdminErrorCode::INVALID_API_KEY,
"管理 API Key 无效",
);
const ADMIN_ROUTE_NOT_FOUND: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::NOT_FOUND,
AdminErrorCode::NOT_FOUND,
"管理接口不存在",
);
const METHOD_NOT_ALLOWED: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::METHOD_NOT_ALLOWED,
AdminErrorCode::BAD_REQUEST,
"请求方法不受支持",
);
const TOO_MANY_LOGIN_ATTEMPTS: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::TOO_MANY_REQUESTS,
AdminErrorCode::TOO_MANY_LOGIN_ATTEMPTS,
"登录尝试过多,请稍后重试",
);
const INTERNAL: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::INTERNAL_SERVER_ERROR,
AdminErrorCode::INTERNAL,
"服务内部错误",
);
const BAD_GATEWAY: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::BAD_GATEWAY,
AdminErrorCode::BAD_GATEWAY,
"上游服务请求失败",
);
const UPSTREAM_RESULT_UNKNOWN: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::BAD_GATEWAY,
AdminErrorCode::UPSTREAM_RESULT_UNKNOWN,
"上游执行结果未知,请刷新状态后再决定是否重试",
);
const SERVICE_UNAVAILABLE: AdminErrorSpec = AdminErrorSpec::new(
StatusCode::SERVICE_UNAVAILABLE,
AdminErrorCode::SERVICE_UNAVAILABLE,
"依赖服务暂不可用",
);
impl AdminError {
fn new(status: StatusCode, code: AdminErrorCode, message: impl Into<String>) -> Self {
Self {
@@ -173,44 +258,48 @@ impl AdminError {
}
}
fn from_spec(spec: AdminErrorSpec) -> Self {
Self::new(spec.status, spec.code, spec.message)
}
pub fn malformed_json() -> Self {
Self::from_spec(MALFORMED_JSON)
}
pub fn invalid_request(status: StatusCode, message: &'static str) -> Self {
Self::new(status, BAD_REQUEST.code, message)
}
pub fn invalid_time_range() -> Self {
Self::from_spec(INVALID_TIME_RANGE)
}
pub fn admin_route_not_found() -> Self {
Self::from_spec(ADMIN_ROUTE_NOT_FOUND)
}
pub fn method_not_allowed() -> Self {
Self::from_spec(METHOD_NOT_ALLOWED)
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self::new(
StatusCode::BAD_REQUEST,
AdminErrorCode::BAD_REQUEST,
message,
)
Self::new(BAD_REQUEST.status, BAD_REQUEST.code, message)
}
pub fn admin_session_required() -> Self {
Self::new(
StatusCode::UNAUTHORIZED,
AdminErrorCode::SESSION_REQUIRED,
"Admin session required",
)
Self::from_spec(SESSION_REQUIRED)
}
pub fn invalid_admin_credentials() -> Self {
Self::new(
StatusCode::UNAUTHORIZED,
AdminErrorCode::INVALID_CREDENTIALS,
"Invalid admin credentials",
)
Self::from_spec(INVALID_CREDENTIALS)
}
pub fn invalid_admin_api_key() -> Self {
Self::new(
StatusCode::UNAUTHORIZED,
AdminErrorCode::INVALID_API_KEY,
"Invalid admin API key",
)
Self::from_spec(INVALID_API_KEY)
}
pub fn too_many_login_attempts() -> Self {
Self::new(
StatusCode::TOO_MANY_REQUESTS,
AdminErrorCode::TOO_MANY_LOGIN_ATTEMPTS,
"Too many admin login attempts",
)
Self::from_spec(TOO_MANY_LOGIN_ATTEMPTS)
}
pub fn conflict(message: impl Into<String>) -> Self {
@@ -221,47 +310,37 @@ impl AdminError {
Self::new(StatusCode::NOT_FOUND, AdminErrorCode::NOT_FOUND, message)
}
pub fn internal(message: impl Into<String>) -> Self {
Self::new(
StatusCode::INTERNAL_SERVER_ERROR,
AdminErrorCode::INTERNAL,
message,
)
pub fn internal() -> Self {
Self::from_spec(INTERNAL)
}
pub fn bad_gateway(message: impl Into<String>) -> Self {
Self::new(
StatusCode::BAD_GATEWAY,
AdminErrorCode::BAD_GATEWAY,
message,
)
pub fn bad_gateway() -> Self {
Self::from_spec(BAD_GATEWAY)
}
pub fn service_unavailable(message: impl Into<String>) -> Self {
Self::new(
StatusCode::SERVICE_UNAVAILABLE,
AdminErrorCode::SERVICE_UNAVAILABLE,
message,
)
pub fn upstream_result_unknown() -> Self {
Self::from_spec(UPSTREAM_RESULT_UNKNOWN)
}
pub fn service_unavailable() -> Self {
Self::from_spec(SERVICE_UNAVAILABLE)
}
}
/// 把管理用例的稳定错误分类映射到既有 HTTP 错误 contract。
pub(crate) fn map_admin_service_error(
error: gateway_admin::model::AdminError,
unavailable_message: &'static str,
) -> AdminError {
pub(crate) fn map_admin_service_error(error: gateway_admin::model::AdminError) -> AdminError {
use gateway_admin::model::AdminErrorKind;
match error.kind() {
AdminErrorKind::Invalid => AdminError::bad_request(error.to_string()),
AdminErrorKind::Invalid => AdminError::bad_request(error.message()),
AdminErrorKind::Unauthorized => AdminError::admin_session_required(),
AdminErrorKind::NotFound => AdminError::not_found(error.to_string()),
AdminErrorKind::Conflict => AdminError::conflict(error.to_string()),
AdminErrorKind::NotFound => AdminError::not_found(error.message()),
AdminErrorKind::Conflict => AdminError::conflict(error.message()),
AdminErrorKind::RateLimited => AdminError::too_many_login_attempts(),
AdminErrorKind::BadGateway => AdminError::bad_gateway(error.to_string()),
AdminErrorKind::Unavailable => AdminError::service_unavailable(unavailable_message),
AdminErrorKind::Internal => AdminError::internal(error.to_string()),
AdminErrorKind::BadGateway => AdminError::bad_gateway(),
AdminErrorKind::UpstreamResultUnknown => AdminError::upstream_result_unknown(),
AdminErrorKind::Unavailable => AdminError::service_unavailable(),
AdminErrorKind::Internal => AdminError::internal(),
}
}
+21 -3
View File
@@ -1,4 +1,4 @@
//! `/healthz` 对 Core、Store 与 Host worker 健康事实的聚合。
//! `/healthz` 对 Core、Store 与 Host 关键 worker 健康事实的聚合。
use std::sync::Arc;
use std::time::Duration;
@@ -6,8 +6,10 @@ use std::time::Duration;
use axum::{extract::State, http::StatusCode};
use futures::future::join_all;
use gateway_core::health::{
HealthProbe, HealthState, WorkerHealthSnapshot, WorkerHealthSource, WorkerRuntimeState,
HealthProbe, HealthState, WorkerHealthKey, WorkerHealthSnapshot, WorkerHealthSource,
WorkerRuntimeState,
};
use gateway_core::task::WorkerKind;
use crate::ApiState;
@@ -32,7 +34,13 @@ impl HealthStatus {
}
pub(crate) async fn healthy(&self) -> bool {
if !self.workers.snapshot().iter().all(worker_is_healthy) {
if !self
.workers
.snapshot()
.iter()
.filter(|worker| worker_affects_healthz(worker))
.all(worker_is_healthy)
{
return false;
}
let checks = self.probes.iter().map(|probe| probe.check());
@@ -54,6 +62,16 @@ pub(crate) async fn healthz(State(state): State<ApiState>) -> StatusCode {
}
}
fn worker_affects_healthz(worker: &WorkerHealthSnapshot) -> bool {
let WorkerHealthKey::Task(id) = &worker.key else {
return false;
};
!matches!(
id.kind(),
WorkerKind::OAuthRefresh | WorkerKind::QuotaCatalogHealth
)
}
fn worker_is_healthy(worker: &WorkerHealthSnapshot) -> bool {
match worker.state {
WorkerRuntimeState::Disabled | WorkerRuntimeState::Standby => true,
@@ -0,0 +1,233 @@
//! Codex Images 非流式 HTTP adapter。
use std::net::SocketAddr;
use axum::{
body::{Body, Bytes},
extract::{Extension, State, connect_info::ConnectInfo},
http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header::CONTENT_TYPE},
response::{IntoResponse, Response},
};
use gateway_core::engine::execution::StartedExecution;
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::event::{ProviderEvent, ProviderResponseHeader};
use gateway_core::operation::{ImageRequest, ImageRequestKind, Operation, RawJsonPayload};
use serde_json::{Map, Value};
use crate::ApiState;
use crate::openai::{
auth::{authenticate_client, authentication_error_response},
error::{engine_error_response, gateway_error_response, protocol_error_response},
responses::{PendingExecution, ProtocolError, ProtocolErrorBody, request_client_context},
};
const OPENAI_PROTOCOL: &str = "openai";
const IMAGE_TURN_ID_CONTEXT_KEY: &str = "image_turn_id";
/// `POST /v1/images/generations`。
pub(crate) async fn image_generations(
State(state): State<ApiState>,
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
handle_image_request(
state,
connect_info,
headers,
body,
ImageRequestKind::Generation,
"/v1/images/generations",
)
.await
}
/// `POST /v1/images/edits`。
pub(crate) async fn image_edits(
State(state): State<ApiState>,
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
handle_image_request(
state,
connect_info,
headers,
body,
ImageRequestKind::Edit,
"/v1/images/edits",
)
.await
}
async fn handle_image_request(
state: ApiState,
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
headers: HeaderMap,
body: Bytes,
kind: ImageRequestKind,
endpoint: &'static str,
) -> Response {
let service = state.openai();
let client = match authenticate_client(service, &headers) {
Ok(client) => client,
Err(error) => return authentication_error_response(error),
};
let (client_ip, user_agent) = request_client_context(
&headers,
connect_info.map(|Extension(ConnectInfo(address))| address),
);
let operation = match image_operation(body, &headers, kind) {
Ok(operation) => operation,
Err(error) => return gateway_error_response(&error),
};
let started = match service
.start_image(client, operation, client_ip, user_agent, endpoint)
.await
{
Ok(started) => started,
Err(error) => return gateway_error_response(&error),
};
collect_image_response(started).await
}
fn image_operation(
body: Bytes,
headers: &HeaderMap,
kind: ImageRequestKind,
) -> Result<Operation, GatewayError> {
let mut context = Map::new();
if let Some(turn_id) = headers
.get("x-codex-image-turn-id")
.and_then(|value| value.to_str().ok())
{
context.insert(
IMAGE_TURN_ID_CONTEXT_KEY.to_owned(),
Value::String(turn_id.to_owned()),
);
}
let payload = RawJsonPayload::new(OPENAI_PROTOCOL, body)
.map_err(|_| {
GatewayError::new(
GatewayErrorKind::Internal,
"OpenAI protocol identifier is invalid",
)
})?
.with_context(context);
Ok(Operation::GenerateImage(ImageRequest::from_raw_json(
kind, payload,
)))
}
async fn collect_image_response(started: StartedExecution) -> Response {
let mut execution = PendingExecution::new(started.session);
let Some(session) = execution.session_mut() else {
return invalid_upstream_response();
};
let events = match session.collect_uncommitted().await {
Ok(events) => events,
Err(error) => {
let response = engine_error_response(&error);
return execution.record_response_status(response).await;
}
};
let body = match raw_json_body(events) {
Some(body) => body,
None => {
let response = invalid_upstream_response();
let response = execution.record_response_status(response).await;
execution.cancel_and_finalize().await;
return response;
}
};
let status = session
.response_status_code()
.and_then(|status| StatusCode::from_u16(status).ok())
.filter(StatusCode::is_success)
.unwrap_or(StatusCode::OK);
let response_headers = session.response_headers().to_vec();
let response = json_body_response(body, status, &response_headers);
let Some(session) = execution.session_mut() else {
return invalid_upstream_response();
};
if let Err(error) = session.commit_downstream(Some(status.as_u16())).await {
execution.cancel_and_finalize().await;
let response = engine_error_response(&error);
return execution.record_response_status(response).await;
}
if !session.is_finalized() {
execution.cancel_and_finalize().await;
return invalid_upstream_response();
}
execution.disarm();
response
}
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 {
continue;
};
let Some(raw) = wire.into_raw_json_body() else {
continue;
};
if body.replace(raw).is_some() {
return None;
}
}
body
}
fn json_body_response(
body: Bytes,
status: StatusCode,
response_headers: &[ProviderResponseHeader],
) -> Response {
let mut response = Response::new(Body::from(body));
*response.status_mut() = status;
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
apply_response_headers(response, response_headers)
}
fn apply_response_headers(
mut response: Response,
response_headers: &[ProviderResponseHeader],
) -> Response {
let connection_options =
crate::openai::responses::response_connection_options(response_headers);
for header in response_headers {
if !crate::openai::responses::response_header_is_forwardable(
header.name(),
&connection_options,
) {
continue;
}
let Ok(name) = HeaderName::from_bytes(header.name().as_bytes()) else {
continue;
};
let Ok(value) = HeaderValue::from_bytes(header.value()) else {
continue;
};
response.headers_mut().append(name, value);
}
response
}
fn invalid_upstream_response() -> Response {
protocol_error_response(
StatusCode::BAD_GATEWAY,
ProtocolErrorBody {
error: ProtocolError {
kind: "server_error",
code: "invalid_upstream_response",
message: "The gateway could not forward the upstream image response.".to_owned(),
param: None,
},
},
)
.into_response()
}
@@ -0,0 +1,5 @@
//! OpenAI/Codex Images JSON 透明 adapter。
mod http;
pub(crate) use http::{image_edits, image_generations};
@@ -2,6 +2,7 @@
pub mod auth;
pub mod error;
pub mod images;
pub mod models;
pub mod responses;
pub mod router;
@@ -123,7 +123,7 @@ async fn handle_responses(
}
/// 从 socket 与标准转发头提取旧 Usage 页面使用的诊断事实。
pub(super) fn request_client_context(
pub(in crate::openai) fn request_client_context(
headers: &HeaderMap,
peer_address: Option<SocketAddr>,
) -> (Option<IpAddr>, Option<String>) {
@@ -367,22 +367,24 @@ fn internal_gateway_response(message: &'static str) -> Response {
gateway_error_response(&GatewayError::new(GatewayErrorKind::Internal, message))
}
pub(super) struct PendingExecution {
pub(in crate::openai) struct PendingExecution {
session: Option<Box<dyn ExecutionSession>>,
}
impl PendingExecution {
pub(super) fn new(session: Box<dyn ExecutionSession>) -> Self {
pub(in crate::openai) fn new(session: Box<dyn ExecutionSession>) -> Self {
Self {
session: Some(session),
}
}
pub(super) fn session_mut(&mut self) -> Option<&mut (dyn ExecutionSession + 'static)> {
pub(in crate::openai) fn session_mut(
&mut self,
) -> Option<&mut (dyn ExecutionSession + 'static)> {
self.session.as_deref_mut()
}
async fn cancel_and_finalize(&mut self) {
pub(in crate::openai) async fn cancel_and_finalize(&mut self) {
if let Some(session) = self.session.as_mut() {
session.cancel();
if !session.is_finalized() {
@@ -391,7 +393,10 @@ impl PendingExecution {
}
}
async fn record_response_status(&mut self, response: Response) -> Response {
pub(in crate::openai) async fn record_response_status(
&mut self,
response: Response,
) -> Response {
if let Some(session) = self.session.as_mut() {
let _ = session
.record_client_status(response.status().as_u16())
@@ -400,7 +405,7 @@ impl PendingExecution {
response
}
pub(super) fn disarm(&mut self) {
pub(in crate::openai) fn disarm(&mut self) {
self.session = None;
}
@@ -6,7 +6,8 @@ mod request;
mod response;
mod websocket;
pub use error::{ProtocolErrorBody, RequestDecodeError, ResponseEncodeError};
pub use error::{ProtocolError, ProtocolErrorBody, RequestDecodeError, ResponseEncodeError};
pub(super) use http::{PendingExecution, request_client_context};
pub use http::{collect_execution_response, stream_execution_response};
pub(crate) use http::{responses, review_responses};
pub use request::{
@@ -73,7 +74,6 @@ pub(super) fn response_header_is_forwardable(name: &str, connection_options: &[S
| "x-openai-organization"
| "x-openai-project"
| "x-codex-installation-id"
| "x-codex-turn-state"
| "x-codex-turn-metadata"
)
}
@@ -136,12 +136,6 @@ pub enum ContinuationIntent {
}
impl ContinuationIntent {
/// 返回是否请求了对话延续。
#[must_use]
pub const fn is_continuation(&self) -> bool {
matches!(self, Self::PreviousResponseId(_))
}
/// 返回待 history owner 解析的 response ID。
#[must_use]
pub fn previous_response_id(&self) -> Option<&str> {
@@ -165,7 +159,6 @@ impl fmt::Debug for ContinuationIntent {
#[derive(Clone, PartialEq, Eq)]
pub struct ResponsesRequestMetadata {
requested_model: String,
public_model: String,
stream: bool,
store: bool,
continuation: ContinuationIntent,
@@ -180,12 +173,6 @@ impl ResponsesRequestMetadata {
&self.requested_model
}
/// 返回协议层公开模型候选。
#[must_use]
pub fn public_model(&self) -> &str {
&self.public_model
}
/// 返回客户端是否请求 SSE。
#[must_use]
pub const fn stream(&self) -> bool {
@@ -222,7 +209,6 @@ impl fmt::Debug for ResponsesRequestMetadata {
formatter
.debug_struct("ResponsesRequestMetadata")
.field("requested_model", &self.requested_model)
.field("public_model", &self.public_model)
.field("stream", &self.stream)
.field("store", &self.store)
.field("continuation", &self.continuation)
@@ -366,8 +352,7 @@ pub(super) fn decode_request_object(
Ok(DecodedResponsesRequest {
operation,
metadata: ResponsesRequestMetadata {
requested_model: model.clone(),
public_model: model,
requested_model: model,
stream,
store,
continuation,
@@ -7,6 +7,7 @@ use axum::{
};
use super::{
images::{image_edits, image_generations},
models::{model_catalog, model_detail, model_info, models},
responses::{responses, responses_websocket, review_responses},
};
@@ -16,6 +17,8 @@ use crate::ApiState;
/// 构造 OpenAI 客户端协议路由。
pub(crate) fn router() -> Router<ApiState> {
Router::new()
.route("/v1/images/generations", post(image_generations))
.route("/v1/images/edits", post(image_edits))
.route("/v1/responses", get(responses_websocket).post(responses))
.route("/v1/responses/review", post(review_responses))
.route("/v1/models", get(models))
@@ -23,7 +26,7 @@ pub(crate) fn router() -> Router<ApiState> {
.route("/v1/models/{model_id}/info", get(model_info))
// 官方 OpenAI 模型详情合同使用 path ID;它不属于 Admin API 约束。
.route("/v1/models/{model_id}", get(model_detail))
// Responses 正文属于 OpenAI/Codex 协议;代理不能用私有大小上限提前拒绝
// OpenAI 数据面正文属于客户端/上游协议;代理不能用私有大小上限提前拒绝
// 上游本可接受的未来 payload。
.layer(DefaultBodyLimit::disable())
}
@@ -1,11 +1,12 @@
//! OpenAI wire adapter 到 Core 执行用例的唯一映射。
use std::net::IpAddr;
use std::sync::Arc;
use gateway_core::engine::continuation::PreviousResponseId;
use gateway_core::engine::execution::{
AuthenticatedClient, ClientAuthenticationError, ClientTransport, ExecutionRequestMetadata,
ExecutionService, StartExecution, StartedExecution,
ExecutionService, StartExecution, StartProviderExecution, StartedExecution,
};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::lifecycle::{ConnectionDraining, ConnectionGuard, ConnectionLifecycle};
@@ -74,13 +75,13 @@ impl OpenAiService {
endpoint: &'static str,
) -> Result<StartedExecution, GatewayError> {
let (operation, metadata) = request.into_parts();
let public_model = PublicModelId::from_client_wire(metadata.public_model().to_owned())
let public_model = PublicModelId::from_client_wire(metadata.requested_model().to_owned())
.map_err(|_| {
GatewayError::new(
GatewayErrorKind::ModelNotFound,
"requested model was not found",
)
})?;
GatewayError::new(
GatewayErrorKind::ModelNotFound,
"requested model was not found",
)
})?;
let previous_response_id = match metadata.continuation() {
ContinuationIntent::None => None,
ContinuationIntent::PreviousResponseId(value) => {
@@ -105,6 +106,38 @@ impl OpenAiService {
.await
}
pub(crate) async fn start_image(
&self,
client: AuthenticatedClient,
operation: gateway_core::operation::Operation,
client_ip: Option<IpAddr>,
user_agent: Option<String>,
endpoint: &'static str,
) -> Result<StartedExecution, GatewayError> {
let provider = gateway_core::routing::ProviderKind::new("openai").map_err(|_| {
GatewayError::new(
GatewayErrorKind::Internal,
"OpenAI provider identifier is invalid",
)
})?;
self.execution
.start_provider_endpoint(StartProviderExecution {
client,
provider,
operation,
metadata: ExecutionRequestMetadata {
protocol: "openai".to_owned(),
endpoint: endpoint.to_owned(),
transport: ClientTransport::HttpJson,
stream: false,
client_ip,
user_agent,
previous_response_id: None,
},
})
.await
}
pub(crate) fn try_register_connection(
&self,
) -> Result<Box<dyn ConnectionGuard>, ConnectionDraining> {
@@ -70,6 +70,72 @@ mod query {
}
}
mod profile_statistics {
use chrono::NaiveDate;
use gateway_admin::model::provider_credentials::{
ProviderProfileActivityInsights, ProviderProfileDailyUsage, ProviderProfileInvocation,
ProviderProfileStatistics, ProviderProfileStatisticsSummary,
};
use gateway_api::admin::accounts::AccountProfileStatisticsData;
use serde_json::json;
#[test]
fn profile_statistics_response_preserves_nullable_official_fields() {
let response = AccountProfileStatisticsData::from(ProviderProfileStatistics {
display_name: Some("Ada".to_owned()),
username: Some("ada".to_owned()),
image_url: Some("https://example.test/avatar.png".to_owned()),
has_stats_error: false,
summary: ProviderProfileStatisticsSummary {
total_text_tokens: Some(409_500_000),
peak_tokens: Some(267_000_000),
longest_task_duration_ms: Some(51_900_000),
current_streak_days: Some(16),
longest_streak_days: Some(32),
},
daily_usage: Some(vec![ProviderProfileDailyUsage {
date: NaiveDate::from_ymd_opt(2026, 8, 25).expect("usage date"),
tokens: 42,
}]),
activity_insights: ProviderProfileActivityInsights {
fast_mode_percent: Some(0.0),
reasoning_effort: Some("high".to_owned()),
reasoning_effort_percent: Some(48.0),
skills_explored: Some(8),
total_skills_used: Some(61),
total_threads: Some(2_391),
invocations: Some(vec![ProviderProfileInvocation {
invocation_type: "plugin".to_owned(),
plugin_id: Some("plugin_1".to_owned()),
plugin_name: Some("example".to_owned()),
skill_id: None,
skill_name: None,
usage_count: Some(29),
}]),
},
});
let value = serde_json::to_value(response).expect("serialize profile statistics");
assert_eq!(value["displayName"], "Ada");
assert_eq!(value["summary"]["totalTextTokens"], 409_500_000);
assert_eq!(
value["dailyUsage"][0],
json!({"date": "2026-08-25", "tokens": 42})
);
assert_eq!(
value["activityInsights"]["invocations"][0]["type"],
"plugin"
);
assert_eq!(
value["activityInsights"]["invocations"][0]["usageCount"],
29
);
assert!(value.get("cycle").is_none());
assert!(value.get("models").is_none());
assert!(value.get("estimatedCost").is_none());
}
}
mod batch_update {
use gateway_api::admin::accounts::{BatchUpdateAccountsRequest, UpdateAccountRequest};
use serde_json::json;
@@ -266,10 +332,15 @@ mod actions {
AccountActionRequest, AccountConnectionTestEvent, AccountDeletionData,
AccountDeletionRequest, AccountExportData, AccountExportQuery, AccountIdQuery,
AccountImportData, AccountImportRequest, AccountMutationData, AccountRefreshRequest,
AccountTestQuery, CompleteAccountAuthorizationRequest, RotateAccountRequest,
StartAccountAuthorizationRequest,
AccountResetCreditConsumeRequest, AccountTestQuery, CompleteAccountAuthorizationRequest,
RotateAccountRequest, StartAccountAuthorizationRequest,
};
use gateway_core::{
engine::{
UpstreamSendState, credential::ProviderAccountId, probe::AccountProbeErrorSource,
},
error::GatewayErrorKind,
};
use gateway_core::engine::credential::ProviderAccountId;
use serde_json::json;
#[test]
@@ -325,6 +396,44 @@ mod actions {
);
}
#[test]
fn reset_credit_consume_should_require_canonical_v4_idempotency_key() {
let valid: AccountResetCreditConsumeRequest = serde_json::from_value(json!({
"accountId": "acct_1",
"creditId": "credit_1",
"redeemRequestId": "8fbf302d-11df-4bd5-82e4-08e4b3df7874"
}))
.expect("decode reset-credit consume");
valid.validate().expect("validate reset-credit consume");
for (redeem_request_id, field) in [
("019c0000-0000-7000-8000-000000000000", "redeemRequestId"),
("8FBF302D-11DF-4BD5-82E4-08E4B3DF7874", "redeemRequestId"),
("invalid", "redeemRequestId"),
] {
let request: AccountResetCreditConsumeRequest = serde_json::from_value(json!({
"accountId": "acct_1",
"redeemRequestId": redeem_request_id
}))
.expect("decode invalid reset-credit consume");
assert_eq!(request.validate().expect_err("reject UUID").field(), field);
}
let invalid_credit: AccountResetCreditConsumeRequest = serde_json::from_value(json!({
"accountId": "acct_1",
"creditId": " ",
"redeemRequestId": "8fbf302d-11df-4bd5-82e4-08e4b3df7874"
}))
.expect("decode invalid credit");
assert_eq!(
invalid_credit
.validate()
.expect_err("reject credit")
.field(),
"creditId"
);
}
#[test]
fn credential_recovery_requests_should_not_accept_client_revision_fences() {
let authorization: StartAccountAuthorizationRequest = serde_json::from_value(json!({
@@ -516,6 +625,9 @@ mod actions {
},
DomainConnectionTestEvent::Completed {},
DomainConnectionTestEvent::Failed {
source: AccountProbeErrorSource::Upstream,
gateway_error_code: GatewayErrorKind::RateLimited,
send_state: Some(UpstreamSendState::Sent),
message: "upstream unavailable".to_owned(),
provider_error_code: Some("usage_exhausted".to_owned()),
provider_error_type: Some("invalid_request_error".to_owned()),
@@ -549,6 +661,9 @@ mod actions {
json!({ "type": "test_complete", "success": true }),
json!({
"type": "error",
"source": "upstream",
"gatewayErrorCode": "rate_limited",
"sendState": "sent",
"error": "upstream unavailable",
"providerErrorCode": "usage_exhausted",
"providerErrorType": "invalid_request_error",
@@ -0,0 +1,320 @@
use axum::{
Router,
body::{Body, to_bytes},
http::{Method, Request, StatusCode, header},
};
use gateway_api::admin;
use serde_json::{Value, json};
use tower::ServiceExt as _;
use super::{AdminTestFixture, AdminTestState};
const SESSION_COOKIE: &str = "cpr_admin_session=valid-session";
fn app(state: AdminTestState) -> Router {
admin::router::<AdminTestState>().with_state(state)
}
fn request(method: Method, uri: &str, body: Body) -> Request<Body> {
Request::builder()
.method(method)
.uri(uri)
.header("x-request-id", "req_admin_errors")
.body(body)
.expect("build admin error request")
}
async fn response_json(response: axum::response::Response) -> (StatusCode, String, Value) {
let status = response.status();
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_owned();
let body = to_bytes(response.into_body(), 64 * 1024)
.await
.expect("read admin error response body");
let body = serde_json::from_slice(&body).expect("parse admin error response JSON");
(status, content_type, body)
}
#[tokio::test]
async fn malformed_login_json_should_use_the_admin_error_envelope() {
let fixture = AdminTestFixture::new().await;
let mut request = request(
Method::POST,
"/api/admin/auth/login",
Body::from(r#"{"password":"secret""#),
);
request
.headers_mut()
.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());
let response = app(fixture.state())
.oneshot(request)
.await
.expect("malformed JSON response");
let actual = response_json(response).await;
assert_eq!(
actual,
(
StatusCode::BAD_REQUEST,
"application/json".to_owned(),
json!({
"code": 40000,
"message": "请求体不是合法 JSON",
"data": null
})
)
);
}
#[tokio::test]
async fn invalid_login_json_data_should_not_echo_the_submitted_value() {
let fixture = AdminTestFixture::new().await;
let submitted = "password-must-not-leak";
let mut request = request(
Method::POST,
"/api/admin/auth/login",
Body::from(json!({ "password": submitted, "rememberMe": true }).to_string()),
);
request
.headers_mut()
.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());
let response = app(fixture.state())
.oneshot(request)
.await
.expect("invalid JSON data response");
let actual = response_json(response).await;
assert_eq!(
actual,
(
StatusCode::UNPROCESSABLE_ENTITY,
"application/json".to_owned(),
json!({
"code": 40001,
"message": "请求字段不合法",
"data": null
})
)
);
assert!(!actual.2.to_string().contains(submitted));
}
#[tokio::test]
async fn login_without_json_content_type_should_keep_415_with_an_admin_envelope() {
let fixture = AdminTestFixture::new().await;
let response = app(fixture.state())
.oneshot(request(
Method::POST,
"/api/admin/auth/login",
Body::from(json!({ "password": "secret" }).to_string()),
))
.await
.expect("missing JSON content type response");
let actual = response_json(response).await;
assert_eq!(
actual,
(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"application/json".to_owned(),
json!({
"code": 40001,
"message": "请求必须使用 application/json",
"data": null
})
)
);
}
#[tokio::test]
async fn malformed_admin_query_should_use_a_safe_bad_request_envelope() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let mut request = request(
Method::GET,
"/api/admin/usage/records?page=not-a-number",
Body::empty(),
);
request
.headers_mut()
.insert(header::COOKIE, SESSION_COOKIE.parse().unwrap());
let response = app(fixture.state())
.oneshot(request)
.await
.expect("invalid query response");
let actual = response_json(response).await;
assert_eq!(
actual,
(
StatusCode::BAD_REQUEST,
"application/json".to_owned(),
json!({
"code": 40001,
"message": "请求参数不合法",
"data": null
})
)
);
}
#[tokio::test]
async fn invalid_time_range_should_use_its_published_business_code() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let mut request = request(
Method::GET,
"/api/admin/dashboard/summary?startTime=not-a-time",
Body::empty(),
);
request
.headers_mut()
.insert(header::COOKIE, SESSION_COOKIE.parse().unwrap());
let response = app(fixture.state())
.oneshot(request)
.await
.expect("invalid time range response");
let actual = response_json(response).await;
assert_eq!(
actual,
(
StatusCode::BAD_REQUEST,
"application/json".to_owned(),
json!({
"code": 40002,
"message": "时间范围不合法",
"data": null
})
)
);
}
#[tokio::test]
async fn unknown_admin_path_should_not_fall_through_to_the_spa() {
let fixture = AdminTestFixture::new().await;
let response = app(fixture.state())
.oneshot(request(
Method::GET,
"/api/admin/not-a-real-route",
Body::empty(),
))
.await
.expect("unknown admin path response");
let actual = response_json(response).await;
assert_eq!(
actual,
(
StatusCode::NOT_FOUND,
"application/json".to_owned(),
json!({
"code": 40401,
"message": "管理接口不存在",
"data": null
})
)
);
}
#[tokio::test]
async fn unsupported_admin_method_should_keep_allow_and_return_an_envelope() {
let fixture = AdminTestFixture::new().await;
let response = app(fixture.state())
.oneshot(request(
Method::POST,
"/api/admin/auth/status",
Body::empty(),
))
.await
.expect("unsupported admin method response");
let allow = response
.headers()
.get(header::ALLOW)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_owned();
let actual = response_json(response).await;
assert_eq!(
(allow, actual),
(
"GET,HEAD".to_owned(),
(
StatusCode::METHOD_NOT_ALLOWED,
"application/json".to_owned(),
json!({
"code": 40001,
"message": "请求方法不受支持",
"data": null
})
)
)
);
}
#[tokio::test]
async fn admin_auth_failures_should_use_stable_chinese_contracts() {
let fixture = AdminTestFixture::new().await;
let unauthenticated = app(fixture.state())
.clone()
.oneshot(request(Method::GET, "/api/admin/accounts", Body::empty()))
.await
.expect("missing session response");
let mut invalid_api_key = request(Method::GET, "/api/admin/accounts", Body::empty());
invalid_api_key
.headers_mut()
.insert("x-api-key", "admin-invalid".parse().unwrap());
let invalid_api_key = app(fixture.state())
.clone()
.oneshot(invalid_api_key)
.await
.expect("invalid API key response");
let mut invalid_login = request(
Method::POST,
"/api/admin/auth/login",
Body::from(json!({ "password": "wrong-password" }).to_string()),
);
invalid_login
.headers_mut()
.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());
let invalid_login = app(fixture.state())
.oneshot(invalid_login)
.await
.expect("invalid credentials response");
let actual = [
response_json(unauthenticated).await,
response_json(invalid_login).await,
response_json(invalid_api_key).await,
];
assert_eq!(
actual.map(|(status, _, body)| (status, body["code"].clone(), body["message"].clone())),
[
(
StatusCode::UNAUTHORIZED,
json!(40101),
json!("需要管理员登录")
),
(
StatusCode::UNAUTHORIZED,
json!(40102),
json!("管理员用户名或密码错误")
),
(
StatusCode::UNAUTHORIZED,
json!(40103),
json!("管理 API Key 无效")
),
]
);
}
@@ -77,6 +77,7 @@ mod account_groups;
mod accounts;
mod auth;
mod client_keys;
mod errors;
mod observability;
mod settings;
mod system;
@@ -110,6 +110,13 @@ fn trend_wire_should_serialize_kind_and_values_without_store_types() {
min_latency_value: Some(1),
success_rate: "100.0%".to_owned(),
success_rate_value: Some(100.0),
first_token_p50_ms: Some(1.0),
first_token_p95_ms: Some(2.0),
latency_p95_ms: Some(3.0),
output_throughput_p50: Some(50),
admission_decision_p95_ms: Some(4.0),
account_selection_wait_p95_ms: Some(5.0),
capacity_utilization: Some(0.7),
}],
summary: vec![TrendSummaryView {
label: "输入".to_owned(),
@@ -221,9 +228,9 @@ async fn dashboard_summary_should_include_quota_exhaustion_in_unavailable_headli
attempts: AttemptMetrics::default(),
totals: Default::default(),
provider_accounts: AccountPoolMetrics {
total: 807,
normal: 15,
quota_exhausted: 785,
total: 1002,
normal: 943,
quota_exhausted: 52,
rate_limited: 0,
disabled: 3,
error: 4,
@@ -259,9 +266,21 @@ async fn dashboard_summary_should_include_quota_exhaustion_in_unavailable_headli
&value["data"]["cards"]["credentials"]["unavailableValue"],
),
(
&serde_json::json!(807),
&serde_json::json!(15),
&serde_json::json!(792)
&serde_json::json!(1002),
&serde_json::json!(943),
&serde_json::json!(59)
),
);
assert_eq!(
(
&value["data"]["cards"]["credentials"]["total"],
&value["data"]["cards"]["credentials"]["available"],
&value["data"]["cards"]["credentials"]["unavailable"],
),
(
&serde_json::json!("1002"),
&serde_json::json!("943"),
&serde_json::json!("59")
),
);
}
@@ -624,6 +643,9 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
total_tokens: 200,
average_latency_ms: Some(100),
latency_p95_ms: Some(3800),
first_token_p95_ms: Some(1200),
non_completion_count: 0,
retry_count: 0,
cost_coverage: CostCoverage::default(),
costs: Vec::new(),
});
@@ -681,7 +703,7 @@ fn usage_record_with_account(
operation: "responses".to_owned(),
endpoint: "/v1/responses".to_owned(),
client_transport: "http_sse".to_owned(),
requested_model_id: "coding".to_owned(),
requested_model_id: Some("coding".to_owned()),
provider_kind: Some("openai".to_owned()),
provider_account_ref: Some(account_id.to_owned()),
provider_account_name: Some(name.to_owned()),
@@ -727,6 +749,10 @@ fn usage_record_with_account(
first_token_ms: None,
provider_processing_ms: None,
latency_ms: None,
admission_decision_ms: None,
account_selection_wait_ms: None,
capacity_used_slots: None,
capacity_total_slots: None,
client_ip: None,
user_agent: None,
reasoning_effort: None,
@@ -810,7 +836,7 @@ async fn usage_route_should_expose_image_and_websocket_facts() {
operation: "generate".to_owned(),
endpoint: "/v1/responses".to_owned(),
client_transport: "http_sse".to_owned(),
requested_model_id: "grok-4.5".to_owned(),
requested_model_id: Some("grok-4.5".to_owned()),
provider_kind: Some("xai".to_owned()),
provider_account_ref: Some("acct_snapshot".to_owned()),
provider_account_name: Some("Snapshot Alpha".to_owned()),
@@ -820,7 +846,7 @@ async fn usage_route_should_expose_image_and_websocket_facts() {
upstream_transport: Some("http_sse".to_owned()),
http_version: Some("h2".to_owned()),
websocket_pool: Some("reuse".to_owned()),
service_tier: Some("priority".to_owned()),
service_tier: Some("default".to_owned()),
provider_metadata_json: Some(
serde_json::json!({
"effectiveModel": "grok-4.5",
@@ -865,7 +891,7 @@ async fn usage_route_should_expose_image_and_websocket_facts() {
output_price_per_million: usd("60.0000"),
cache_read_price_per_million: usd("1.0000"),
cache_write_price_per_million: usd("12.5000"),
service_tier: Some("priority".to_owned()),
service_tier: Some("default".to_owned()),
multiplier_percent: 100,
},
))),
@@ -878,6 +904,10 @@ async fn usage_route_should_expose_image_and_websocket_facts() {
first_token_ms: Some(19),
provider_processing_ms: Some(29),
latency_ms: Some(31),
admission_decision_ms: Some(3),
account_selection_wait_ms: Some(5),
capacity_used_slots: Some(7),
capacity_total_slots: Some(10),
client_ip: Some("127.0.0.1".to_owned()),
user_agent: Some("usage-metadata-test".to_owned()),
reasoning_effort: Some("max".to_owned()),
@@ -921,6 +951,10 @@ async fn usage_route_should_expose_image_and_websocket_facts() {
value["data"]["items"][0]["billing"]["cacheWritePriceDisplay"],
"$12.5 / 1M Token"
);
assert_eq!(
value["data"]["items"][0]["billing"]["serviceTierDisplay"],
"Standard"
);
assert_eq!(
serde_json::json!({
@@ -961,7 +995,7 @@ async fn usage_route_should_expose_image_and_websocket_facts() {
"routingScope": "groups",
"routingGroupRefs": ["grp_usage"],
"routingGroupNamesSnapshot": ["Usage group"],
"serviceTier": "priority",
"serviceTier": "default",
"accountId": "acct_snapshot",
"accountName": "Snapshot Alpha",
"accountEmail": "alpha@example.invalid",
+10 -8
View File
@@ -40,6 +40,7 @@ mod error {
AdminErrorCode::INTERNAL,
AdminErrorCode::USAGE_RECORD_ACCOUNTS,
AdminErrorCode::BAD_GATEWAY,
AdminErrorCode::UPSTREAM_RESULT_UNKNOWN,
AdminErrorCode::SERVICE_UNAVAILABLE,
]
.map(AdminErrorCode::value);
@@ -48,7 +49,7 @@ mod error {
actual,
[
40000, 40001, 40002, 40003, 40101, 40102, 40103, 40401, 40901, 42901, 50000, 50001,
50002, 50201, 50301,
50002, 50201, 50202, 50301,
]
);
}
@@ -57,7 +58,7 @@ mod error {
fn invalid_admin_api_key_should_keep_code_and_null_data() {
let value = serde_json::to_value(AdminErrorBody::new(
AdminErrorCode::INVALID_API_KEY,
"Invalid admin API key",
"管理 API Key 无效",
))
.expect("admin error body should serialize");
@@ -65,7 +66,7 @@ mod error {
value,
json!({
"code": 40103,
"message": "Invalid admin API key",
"message": "管理 API Key 无效",
"data": null
})
);
@@ -158,7 +159,7 @@ mod response {
StatusCode::UNAUTHORIZED,
json!({
"code": 40103,
"message": "Invalid admin API key",
"message": "管理 API Key 无效",
"data": null
})
)
@@ -199,17 +200,18 @@ mod response {
40401,
),
(
AdminError::internal("internal"),
AdminError::internal(),
StatusCode::INTERNAL_SERVER_ERROR,
50001,
),
(AdminError::bad_gateway(), StatusCode::BAD_GATEWAY, 50201),
(
AdminError::bad_gateway("upstream"),
AdminError::upstream_result_unknown(),
StatusCode::BAD_GATEWAY,
50201,
50202,
),
(
AdminError::service_unavailable("storage"),
AdminError::service_unavailable(),
StatusCode::SERVICE_UNAVAILABLE,
50301,
),
@@ -38,6 +38,7 @@ fn source_tree_should_match_frozen_machine_manifest() {
"src/admin/auth.rs",
"src/admin/backups.rs",
"src/admin/client_keys.rs",
"src/admin/extract.rs",
"src/admin/mod.rs",
"src/admin/observability/mod.rs",
"src/admin/observability/presenter.rs",
@@ -52,6 +53,8 @@ fn source_tree_should_match_frozen_machine_manifest() {
"src/lib.rs",
"src/openai/auth.rs",
"src/openai/error.rs",
"src/openai/images/http.rs",
"src/openai/images/mod.rs",
"src/openai/mod.rs",
"src/openai/models.rs",
"src/openai/responses/error.rs",
@@ -78,6 +81,7 @@ fn test_tree_should_match_frozen_rust_mirror() {
"tests/admin/accounts.rs",
"tests/admin/auth.rs",
"tests/admin/client_keys.rs",
"tests/admin/errors.rs",
"tests/admin/mod.rs",
"tests/admin/observability/mod.rs",
"tests/admin/observability/query.rs",
@@ -90,6 +94,7 @@ fn test_tree_should_match_frozen_rust_mirror() {
"tests/main.rs",
"tests/openai/auth.rs",
"tests/openai/error.rs",
"tests/openai/images.rs",
"tests/openai/mod.rs",
"tests/openai/models.rs",
"tests/openai/responses/http.rs",
@@ -102,14 +107,6 @@ fn test_tree_should_match_frozen_rust_mirror() {
assert_eq!(actual, expected);
}
#[test]
fn frozen_openai_fixture_set_should_be_complete() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/openai/responses/fixtures");
let files = all_files(&root);
assert_eq!(files.len(), 69);
}
#[test]
fn admin_routes_should_use_only_get_post_and_static_paths() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/admin");
+79 -1
View File
@@ -8,10 +8,12 @@ use futures::future::BoxFuture;
use gateway_core::{
engine::execution::{
AuthenticatedClient, ClientAuthenticationError, ExecutionService, StartExecution,
StartedExecution,
StartProviderExecution, StartedExecution,
},
error::GatewayError,
health::{WorkerHealthKey, WorkerHealthSnapshot, WorkerHealthSource, WorkerRuntimeState},
routing::PublicModelId,
task::{WorkerId, WorkerKind},
};
use tower::ServiceExt;
@@ -31,6 +33,75 @@ async fn healthz_should_return_no_content_when_all_inputs_are_healthy() {
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn healthz_should_ignore_provider_maintenance_worker_failure() {
let worker_health = StaticWorkerHealth(vec![worker_snapshot(
WorkerKind::QuotaCatalogHealth,
WorkerRuntimeState::BackingOff,
)]);
let response = crate::openai::api_router_with_worker_health(
Arc::new(UnusedExecution),
Arc::new(worker_health),
)
.await
.oneshot(
Request::builder()
.uri("/healthz")
.body(Body::empty())
.expect("health request"),
)
.await
.expect("health response");
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn healthz_should_reject_critical_worker_failure() {
let worker_health = StaticWorkerHealth(vec![worker_snapshot(
WorkerKind::RuntimeSnapshotReconciliation,
WorkerRuntimeState::BackingOff,
)]);
let response = crate::openai::api_router_with_worker_health(
Arc::new(UnusedExecution),
Arc::new(worker_health),
)
.await
.oneshot(
Request::builder()
.uri("/healthz")
.body(Body::empty())
.expect("health request"),
)
.await
.expect("health response");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[derive(Clone)]
struct StaticWorkerHealth(Vec<WorkerHealthSnapshot>);
impl WorkerHealthSource for StaticWorkerHealth {
fn snapshot(&self) -> Vec<WorkerHealthSnapshot> {
self.0.clone()
}
}
fn worker_snapshot(kind: WorkerKind, state: WorkerRuntimeState) -> WorkerHealthSnapshot {
let id = WorkerId::try_new(kind, "health-test").expect("worker ID");
WorkerHealthSnapshot {
key: WorkerHealthKey::Task(id),
state,
consecutive_failures: 1,
completed_cycles: 0,
last_fencing_token: None,
last_success_at: None,
last_failure_at: None,
last_error: Some("test worker failure".to_owned()),
}
}
struct UnusedExecution;
impl ExecutionService for UnusedExecution {
@@ -49,4 +120,11 @@ impl ExecutionService for UnusedExecution {
fn start(&self, _: StartExecution) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
Box::pin(async { unreachable!("health check does not execute requests") })
}
fn start_provider_endpoint(
&self,
_: StartProviderExecution,
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
Box::pin(async { unreachable!("health check does not execute provider endpoints") })
}
}
@@ -0,0 +1,331 @@
use std::sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
};
use std::time::SystemTime;
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode, header::AUTHORIZATION},
};
use bytes::Bytes;
use futures::future::BoxFuture;
use gateway_core::engine::UpstreamSendState;
use gateway_core::engine::execution::{
AuthenticatedClient, ClientAuthenticationError, ClientTransport, ExecutionService,
ExecutionSession, StartExecution, StartProviderExecution, StartedExecution,
};
use gateway_core::engine::{CoordinatedEvent, EngineError, ModelRequestId};
use gateway_core::error::{
ClientVisibleUpstreamResponse, GatewayError, GatewayErrorKind, ProviderError, ProviderErrorKind,
};
use gateway_core::event::{ProtocolWireEvent, ProviderEvent, ProviderResponseHeader};
use gateway_core::operation::{ImageRequestKind, Operation};
use gateway_core::routing::PublicModelId;
use serde_json::{Value, json};
use tower::ServiceExt;
use super::{api_router, authenticated_client};
const IMAGE_RESPONSE: &[u8] =
br#"{ "created": 1787212800, "data": [{"b64_json":"AAEC"}], "future": 9007199254740993 }"#;
#[derive(Debug, Clone, PartialEq)]
struct CapturedImageRequest {
provider: String,
endpoint: String,
transport: ClientTransport,
kind: ImageRequestKind,
body: Bytes,
context: Value,
}
struct ImageExecution {
client: AuthenticatedClient,
captured: Arc<Mutex<Vec<CapturedImageRequest>>>,
committed_statuses: Arc<Mutex<Vec<u16>>>,
fail_with_upstream_response: bool,
}
impl ImageExecution {
fn new() -> Arc<Self> {
Arc::new(Self {
client: authenticated_client("sk_images_test"),
captured: Arc::new(Mutex::new(Vec::new())),
committed_statuses: Arc::new(Mutex::new(Vec::new())),
fail_with_upstream_response: false,
})
}
fn failing() -> Arc<Self> {
Arc::new(Self {
client: authenticated_client("sk_images_test"),
captured: Arc::new(Mutex::new(Vec::new())),
committed_statuses: Arc::new(Mutex::new(Vec::new())),
fail_with_upstream_response: true,
})
}
fn captured(&self) -> Vec<CapturedImageRequest> {
self.captured.lock().expect("capture lock").clone()
}
fn committed_statuses(&self) -> Vec<u16> {
self.committed_statuses
.lock()
.expect("committed status lock")
.clone()
}
}
impl ExecutionService for ImageExecution {
fn authenticate(
&self,
plaintext: &str,
) -> Result<AuthenticatedClient, ClientAuthenticationError> {
(plaintext == "sk_images_test")
.then(|| self.client.clone())
.ok_or(ClientAuthenticationError::InvalidKey)
}
fn public_models(&self, _: &AuthenticatedClient) -> Vec<PublicModelId> {
Vec::new()
}
fn contains_public_model(&self, _: &AuthenticatedClient, _: &PublicModelId) -> bool {
false
}
fn start(&self, _: StartExecution) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
Box::pin(async {
Err(GatewayError::new(
GatewayErrorKind::Internal,
"images test must use provider endpoint execution",
))
})
}
fn start_provider_endpoint(
&self,
request: StartProviderExecution,
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
Box::pin(async move {
let Operation::GenerateImage(image) = &request.operation else {
return Err(GatewayError::new(
GatewayErrorKind::Internal,
"images test received a non-image operation",
));
};
let payload = image.payload();
self.captured
.lock()
.expect("capture lock")
.push(CapturedImageRequest {
provider: request.provider.as_str().to_owned(),
endpoint: request.metadata.endpoint,
transport: request.metadata.transport,
kind: image.kind(),
body: payload.body().clone(),
context: Value::Object(payload.context().clone()),
});
Ok(StartedExecution {
request_id: ModelRequestId::new("req_images_test").expect("request ID"),
created_at: SystemTime::now(),
stream: false,
session: Box::new(ImageSession {
response: Some(Bytes::from_static(IMAGE_RESPONSE)),
response_headers: vec![ProviderResponseHeader::new(
"x-image-rate-limit",
Bytes::from_static(b"42"),
)],
committed_statuses: Arc::clone(&self.committed_statuses),
finalized: Arc::new(AtomicBool::new(false)),
fail_with_upstream_response: self.fail_with_upstream_response,
}),
})
})
}
}
struct ImageSession {
response: Option<Bytes>,
response_headers: Vec<ProviderResponseHeader>,
committed_statuses: Arc<Mutex<Vec<u16>>>,
finalized: Arc<AtomicBool>,
fail_with_upstream_response: bool,
}
impl ExecutionSession for ImageSession {
fn next_event(&mut self) -> BoxFuture<'_, Result<Option<CoordinatedEvent>, EngineError>> {
Box::pin(async { unreachable!("buffered image delivery does not stream") })
}
fn collect_uncommitted(&mut self) -> BoxFuture<'_, Result<Vec<ProviderEvent>, EngineError>> {
Box::pin(async move {
if self.fail_with_upstream_response {
return Err(EngineError::Provider(
ProviderError::new(
ProviderErrorKind::InvalidRequest,
UpstreamSendState::Sent,
)
.with_status(StatusCode::UNPROCESSABLE_ENTITY.as_u16())
.with_client_visible_upstream_response(
ClientVisibleUpstreamResponse::new(
StatusCode::UNPROCESSABLE_ENTITY.as_u16(),
Some(b"application/problem+json".to_vec()),
Bytes::from_static(
br#"{ "error":{"message":"future image validation"}, "future":9007199254740993 }"#,
),
)
.with_headers(vec![ProviderResponseHeader::new(
"x-future-image-error",
Bytes::from_static(b"preserved"),
)]),
),
));
}
Ok(vec![ProviderEvent::wire(
ProtocolWireEvent::raw_json(
"openai",
self.response.take().expect("single image response"),
)
.expect("OpenAI protocol"),
)])
})
}
fn response_headers(&self) -> &[ProviderResponseHeader] {
&self.response_headers
}
fn response_status_code(&self) -> Option<u16> {
Some(StatusCode::CREATED.as_u16())
}
fn commit_downstream(
&mut self,
client_status_code: Option<u16>,
) -> BoxFuture<'_, Result<(), EngineError>> {
Box::pin(async move {
self.committed_statuses
.lock()
.expect("committed status lock")
.push(client_status_code.expect("image response status"));
self.finalized.store(true, Ordering::Release);
Ok(())
})
}
fn record_client_status(&mut self, _: u16) -> BoxFuture<'_, Result<(), EngineError>> {
Box::pin(async { Ok(()) })
}
fn is_finalized(&self) -> bool {
self.finalized.load(Ordering::Acquire)
}
fn cancel(&self) {
self.finalized.store(true, Ordering::Release);
}
fn detach_finalize(self: Box<Self>) -> BoxFuture<'static, ()> {
Box::pin(async move {
self.finalized.store(true, Ordering::Release);
})
}
}
#[tokio::test]
async fn image_routes_should_not_decode_bodies_and_should_preserve_both_directions() {
let execution = ImageExecution::new();
let router = api_router(execution.clone()).await;
let cases = [
(
"/v1/images/generations",
ImageRequestKind::Generation,
br#"{ "model":"gpt-image-future", "prompt":"a lighthouse", "future":9007199254740993 }"#.as_slice(),
),
(
"/v1/images/edits",
ImageRequestKind::Edit,
br#"{"model":"gpt-image-2","images":[{"image_url":"data:image/png;base64,AAEC"}],"prompt":"add fog","prompt":"duplicate stays raw"}"#.as_slice(),
),
];
for (path, _, body) in &cases {
let response = router
.clone()
.oneshot(
Request::post(*path)
.header(AUTHORIZATION, "Bearer sk_images_test")
.header("content-type", "application/json")
.header("x-codex-image-turn-id", "turn_image_route")
.body(Body::from(body.to_vec()))
.expect("image request"),
)
.await
.expect("image response");
assert_eq!(response.status(), StatusCode::CREATED);
assert_eq!(
response.headers().get("x-image-rate-limit"),
Some(&"42".parse().expect("header value"))
);
let response_body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read image response");
assert_eq!(response_body.as_ref(), IMAGE_RESPONSE);
}
let captured = execution.captured();
assert_eq!(captured.len(), cases.len());
for (captured, (endpoint, kind, body)) in captured.iter().zip(cases.iter()) {
assert_eq!(captured.provider, "openai");
assert_eq!(captured.endpoint, *endpoint);
assert_eq!(captured.transport, ClientTransport::HttpJson);
assert_eq!(captured.kind, *kind);
assert_eq!(captured.body.as_ref(), *body);
assert_eq!(
captured.context,
json!({"image_turn_id": "turn_image_route"})
);
}
assert_eq!(
execution.committed_statuses(),
vec![StatusCode::CREATED.as_u16(); cases.len()]
);
}
#[tokio::test]
async fn image_route_should_return_the_exact_upstream_error_response() {
let execution = ImageExecution::failing();
let response = api_router(execution)
.await
.oneshot(
Request::post("/v1/images/edits")
.header(AUTHORIZATION, "Bearer sk_images_test")
.header("content-type", "application/json")
.body(Body::from(
br#"{ "model":"gpt-image-2", "future_invalid":9007199254740993 }"#.to_vec(),
))
.expect("image request"),
)
.await
.expect("image response");
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(
response.headers().get("content-type"),
Some(&"application/problem+json".parse().expect("content type"))
);
assert_eq!(
response.headers().get("x-future-image-error"),
Some(&"preserved".parse().expect("header value"))
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read image response");
assert_eq!(
body.as_ref(),
br#"{ "error":{"message":"future image validation"}, "future":9007199254740993 }"#
);
}
+22 -1
View File
@@ -1,5 +1,6 @@
mod auth;
mod error;
mod images;
mod models;
mod responses;
mod router;
@@ -44,9 +45,29 @@ pub(super) async fn api_router(execution: Arc<dyn ExecutionService>) -> axum::Ro
api_router_with_origins(execution, Vec::new()).await
}
pub(super) async fn api_router_with_worker_health(
execution: Arc<dyn ExecutionService>,
worker_health: Arc<dyn WorkerHealthSource>,
) -> axum::Router {
api_router_with_origins_and_worker_health(execution, Vec::new(), worker_health).await
}
pub(super) async fn api_router_with_origins(
execution: Arc<dyn ExecutionService>,
cors_allowed_origins: Vec<String>,
) -> axum::Router {
api_router_with_origins_and_worker_health(
execution,
cors_allowed_origins,
Arc::new(EmptyWorkerHealth),
)
.await
}
async fn api_router_with_origins_and_worker_health(
execution: Arc<dyn ExecutionService>,
cors_allowed_origins: Vec<String>,
worker_health: Arc<dyn WorkerHealthSource>,
) -> axum::Router {
let admin = crate::admin::AdminTestFixture::new().await;
gateway_api::initialize(
@@ -59,7 +80,7 @@ pub(super) async fn api_router_with_origins(
execution,
admin.services,
Vec::new(),
Arc::new(EmptyWorkerHealth),
worker_health,
Arc::new(TestLifecycle::default()),
)
.expect("API bundle")
@@ -7,7 +7,7 @@ use axum::{
use futures::future::BoxFuture;
use gateway_core::engine::execution::{
AuthenticatedClient, ClientAuthenticationError, ExecutionService, StartExecution,
StartedExecution,
StartProviderExecution, StartedExecution,
};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::routing::{
@@ -99,6 +99,18 @@ impl ExecutionService for ModelsExecution {
))
})
}
fn start_provider_endpoint(
&self,
_: StartProviderExecution,
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
Box::pin(async {
Err(GatewayError::new(
GatewayErrorKind::Internal,
"models test must not start a provider endpoint",
))
})
}
}
fn authorized_request(path: &str) -> Request<Body> {
@@ -1,26 +0,0 @@
{
"type": "response.create",
"model": "gpt-5.5",
"instructions": "answer briefly",
"input": [
{
"type": "reasoning",
"id": "rs_implicit_resume",
"status": "completed",
"summary": [
{
"type": "summary_text",
"text": "cached reasoning"
}
],
"encrypted_content": "enc_reasoning_replay"
},
{
"role": "user",
"content": "continue"
}
],
"store": false,
"stream": false,
"previous_response_id": "resp_implicit_resume_first"
}
@@ -1,5 +0,0 @@
event: response.output_text.delta
data: {"delta":"auth fallback hello"}
event: response.completed
data: {"response":{"id":"resp_after_401","object":"response","status":"completed","usage":{"input_tokens":3,"output_tokens":1}}}
@@ -1,5 +0,0 @@
event: response.output_text.delta
data: {"delta":"quota fallback hello"}
event: response.completed
data: {"response":{"id":"resp_after_402","object":"response","status":"completed","usage":{"input_tokens":6,"output_tokens":2}}}
@@ -1,5 +0,0 @@
event: response.output_text.delta
data: {"delta":"banned fallback hello"}
event: response.completed
data: {"response":{"id":"resp_after_403","object":"response","status":"completed","usage":{"input_tokens":5,"output_tokens":1}}}
@@ -1,5 +0,0 @@
event: response.output_text.delta
data: {"delta":"retry hello"}
event: response.completed
data: {"response":{"id":"resp_after_5xx_retry","object":"response","status":"completed","usage":{"input_tokens":4,"output_tokens":2}}}
@@ -1,5 +0,0 @@
event: response.output_text.delta
data: {"delta":"cloudflare fallback hello"}
event: response.completed
data: {"response":{"id":"resp_after_cloudflare","object":"response","status":"completed","usage":{"input_tokens":5,"output_tokens":2}}}
@@ -1,5 +0,0 @@
event: response.output_text.delta
data: {"delta":"model fallback hello"}
event: response.completed
data: {"response":{"id":"resp_after_model_unsupported","object":"response","status":"completed","usage":{"input_tokens":4,"output_tokens":2}}}
@@ -1,6 +0,0 @@
event: response.output_text.delta
data: {"delta":"hello"}
event: response.completed
data: {"response":{"usage":{"input_tokens":2,"output_tokens":3}}}
@@ -1,6 +0,0 @@
event: response.output_text.delta
data: {"delta":"hello"}
event: response.completed
data: {"response":{"id":"resp_1","usage":{"input_tokens":2,"output_tokens":3}}}
@@ -1,5 +0,0 @@
event: response.output_text.delta
data: {"type":"response.output_text.delta","delta":"hello"}
event: response.incomplete
data: {"type":"response.incomplete","response":{"id":"resp_incomplete","object":"response","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":2,"output_tokens":3},"output":[{"type":"message","status":"incomplete","role":"assistant","content":[{"type":"output_text","text":"hello","annotations":[]}]}],"output_text":"hello"}}
@@ -1,9 +0,0 @@
event: response.reasoning_summary_text.delta
data: {"delta":"I considered the context."}
event: response.output_text.delta
data: {"delta":"hello"}
event: response.completed
data: {"response":{"id":"resp_1","output_text":"hello","usage":{"input_tokens":9,"output_tokens":3}}}
@@ -1,3 +0,0 @@
event: response.completed
data: {}
@@ -1,5 +0,0 @@
event: response.completed
data: {}
data: [DONE]
@@ -1,3 +0,0 @@
event: response.completed
data: {"response":{"id":"resp_1","usage":{"input_tokens":12,"output_tokens":5,"input_tokens_details":{"cached_tokens":3}},"tool_usage":{"image_gen":{"input_tokens":31,"output_tokens":9}}}}
@@ -1,5 +0,0 @@
event: response.output_text.delta
data: {"delta":"image done"}
event: response.completed
data: {"response":{"id":"resp_image","object":"response","status":"completed","output_text":"image done","usage":{"input_tokens":7,"output_tokens":4,"input_tokens_details":{"cached_tokens":2}},"tool_usage":{"image_gen":{"input_tokens":31,"output_tokens":9}}}}
@@ -1,3 +0,0 @@
event: response.completed
data: {"response":{"id":"resp_order","status":"completed","usage":{"input_tokens":1,"output_tokens":1}}}
@@ -1,5 +0,0 @@
event: response.output_text.delta
data: {"delta":"usage hello"}
event: response.completed
data: {"response":{"id":"resp_usage","object":"response","status":"completed","output_text":"usage hello","usage":{"input_tokens":7,"output_tokens":4,"input_tokens_details":{"cached_tokens":2}}}}
@@ -1,3 +0,0 @@
event: response.completed
data: {"response":{"id":"resp_1","usage":{"input_tokens":2,"output_tokens":3,"input_tokens_details":{"cached_tokens":1}}}}
@@ -1,6 +0,0 @@
event: response.created
data: {"response":{"id":"resp_1","usage":{"input_tokens":3,"output_tokens":5,"input_tokens_details":{"cached_tokens":1}}}}
event: response.completed
data: {"response":{"id":"resp_1","usage":{"input_tokens":3,"output_tokens":5,"input_tokens_details":{"cached_tokens":1}}}}
@@ -1,2 +0,0 @@
data: {"type":"response.output_text.delta","delta":"hello"}
@@ -1,5 +0,0 @@
event: response.output_item.done
data: {"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"from done item"}]}}
event: response.completed
data: {"response":{"id":"resp_item","object":"response","status":"completed","output":[],"usage":{"input_tokens":2,"output_tokens":3}}}
@@ -1,2 +0,0 @@
data: [DONE]
@@ -1,2 +0,0 @@
event: response.completed
data: {"response":{"id":"resp_empty","object":"response","status":"completed"}}
@@ -1,3 +0,0 @@
event: response.output_text.delta
data: {"delta":""}
@@ -1,2 +0,0 @@
event: response.failed
data: {"response":{"id":"resp_sse_auth_failed","object":"response","status":"failed","error":{"code":"token_invalid","message":"token revoked"}}}
@@ -1,3 +0,0 @@
event: response.failed
data: {"type":"response.failed","response":{"id":"resp_cyber_policy","object":"response","status":"failed","error":{"code":"cyber_policy","message":"This request has been flagged for possible cybersecurity risk."}}}
@@ -1,2 +0,0 @@
event: response.failed
data: {"response":{"id":"resp_model_unsupported_failed","object":"response","status":"failed","error":{"code":"model_not_supported","message":"Model gpt-5.5 is not supported on this account plan"}}}
@@ -1,2 +0,0 @@
event: response.failed
data: {"response":{"id":"resp_sse_quota_failed","object":"response","status":"failed","error":{"code":"quota_exceeded","message":"quota exhausted"}}}
@@ -1,2 +0,0 @@
event: response.failed
data: {"response":{"id":"resp_server_overloaded","object":"response","status":"failed","error":{"code":"server_is_overloaded","message":"upstream is temporarily overloaded"}}}
@@ -1,3 +0,0 @@
event: response.function_call_arguments.delta
data: {"delta":"{\"path\""}
@@ -1,2 +0,0 @@
event: response.output_text.delta
data: {"delta":"hello"}
@@ -1,3 +0,0 @@
event: response.completed
data: {"response":{"id":"resp_live_stream","object":"response","status":"completed","usage":{"input_tokens":3,"output_tokens":4}}}
@@ -1,2 +0,0 @@
event: response.failed
data: {"response":{"id":"resp_live_auth_failed","object":"response","status":"failed","error":{"code":"token_invalid","message":"live token expired"}}}
@@ -1,2 +0,0 @@
event: response.failed
data: {"response":{"id":"resp_live_quota_failed","object":"response","status":"failed","error":{"code":"quota_exceeded","message":"live quota exhausted"}}}
@@ -1,3 +0,0 @@
event: response.output_text.delta
data: {"delta":"live stream hello"}
@@ -1,9 +0,0 @@
event: codex.rate_limits
data: {"type":"codex.rate_limits","rate_limits":{"primary":{"used_percent":42}}}
event: response.metadata
data: {"type":"response.metadata","x-codex-turn-state":"state"}
event: response.in_progress
data: {"type":"response.in_progress","response":{"id":"resp_1","status":"in_progress"}}
@@ -1,8 +0,0 @@
event: response.created
data: {"type":"response.created","response":{"id":"resp_http_cyber_policy","status":"in_progress"}}
event: response.output_text.delta
data: {"type":"response.output_text.delta","response_id":"resp_http_cyber_policy","output_index":0,"content_index":0,"delta":"partial output before HTTP SSE policy failure"}
event: response.failed
data: {"type":"response.failed","response":{"id":"resp_http_cyber_policy","status":"failed","error":{"code":"cyber_policy","message":"This request has been flagged for possible cybersecurity risk."}}}
@@ -1,8 +0,0 @@
event: response.created
data: {"type":"response.created","response":{"id":"resp_http_output_then_missing","status":"in_progress"}}
event: response.output_text.delta
data: {"type":"response.output_text.delta","response_id":"resp_http_output_then_missing","output_index":0,"content_index":0,"delta":"visible HTTP SSE output"}
event: response.failed
data: {"type":"response.failed","response":{"id":"resp_http_output_then_missing","status":"failed","error":{"code":"previous_response_not_found","message":"Previous response was not found"}}}
@@ -1,6 +0,0 @@
event: response.created
data: {"response":{"id":"resp_clean_close"}}
event: response.output_text.delta
data: {"delta":"partial before clean close"}
@@ -1,3 +0,0 @@
event: response.output_text.delta
data: {"delta":"partial before logged disconnect"}

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