Compare commits

...
16 Commits
Author SHA1 Message Date
ZyyandCodex 5c69b1b082 chore: bump version to 3.3.12
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 11:56:13 +08:00
dependabot[bot]andZyy 6a78fb9232 chore(deps): bump argon2 from 0.5.3 to 0.6.0 in /backend (#49)
* chore(deps): bump argon2 from 0.5.3 to 0.6.0 in /backend

Bumps [argon2](https://github.com/RustCrypto/password-hashes) from 0.5.3 to 0.6.0.
- [Commits](https://github.com/RustCrypto/password-hashes/compare/argon2-v0.5.3...argon2-v0.6.0)

---
updated-dependencies:
- dependency-name: argon2
  dependency-version: 0.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

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

* fix(admin): update argon2 password hashing API

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-08-31 11:47:53 +08:00
ZyyandCodex 02a03df79a fix(observability): preserve upstream failure evidence
Log original OpenAI error payloads and WebSocket close reasons with request, account, attempt, transport, and connection correlation. Project PostgreSQL inet values as bare client addresses in observability queries.

Co-authored-by: Codex <noreply@openai.com>
2026-08-31 11:36:28 +08:00
dependabot[bot] 2aca8fb79b chore(deps): bump the minor-and-patch group across 1 directory with 3 updates (#48)
Bumps the minor-and-patch group with 3 updates in the /backend directory: [syn](https://github.com/dtolnay/syn), [uuid](https://github.com/uuid-rs/uuid) and [aws-sdk-s3](https://github.com/awslabs/aws-sdk-rust).


Updates `syn` from 3.0.3 to 3.0.4
- [Release notes](https://github.com/dtolnay/syn/releases)
- [Commits](https://github.com/dtolnay/syn/compare/3.0.3...3.0.4)

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

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

---
updated-dependencies:
- dependency-name: aws-sdk-s3
  dependency-version: 1.144.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: syn
  dependency-version: 3.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: uuid
  dependency-version: 1.26.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-31 11:13:35 +08:00
dependabot[bot] 891b227b69 chore(deps): bump debian from abd67ff to 8820086 in /deploy (#52)
Bumps debian from `abd67ff` to `8820086`.

---
updated-dependencies:
- dependency-name: debian
  dependency-version: bookworm-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 11:13:27 +08:00
dependabot[bot] 04096dddb7 chore(deps): bump rust from e70e2ee to 82150a5 in /deploy (#51)
Bumps rust from `e70e2ee` to `82150a5`.

---
updated-dependencies:
- dependency-name: rust
  dependency-version: 1.98-bookworm
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 11:13:20 +08:00
dependabot[bot] 3a9b1c479f chore(deps): bump the minor-and-patch group in /frontend with 8 updates (#50)
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.33.0` | `1.37.0` |
| [axios](https://github.com/axios/axios) | `1.19.0` | `1.20.0` |
| [es-toolkit](https://github.com/toss/es-toolkit) | `1.51.0` | `1.52.0` |
| [marked](https://github.com/markedjs/marked) | `18.0.10` | `18.0.11` |
| [vue](https://github.com/vuejs/core) | `3.5.41` | `3.5.42` |
| [vue-router](https://github.com/vuejs/router) | `5.2.0` | `5.3.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.2.0` | `26.4.0` |
| [eslint](https://github.com/eslint/eslint) | `10.9.0` | `10.9.1` |


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

Updates `axios` from 1.19.0 to 1.20.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.19.0...v1.20.0)

Updates `es-toolkit` from 1.51.0 to 1.52.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.51.0...v1.52.0)

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

Updates `vue` from 3.5.41 to 3.5.42
- [Release notes](https://github.com/vuejs/core/releases)
- [Changelog](https://github.com/vuejs/core/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vuejs/core/compare/v3.5.41...v3.5.42)

Updates `vue-router` from 5.2.0 to 5.3.0
- [Release notes](https://github.com/vuejs/router/releases)
- [Commits](https://github.com/vuejs/router/compare/v5.2.0...v5.3.0)

Updates `@types/node` from 26.2.0 to 26.4.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

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

---
updated-dependencies:
- dependency-name: "@lucide/vue"
  dependency-version: 1.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: axios
  dependency-version: 1.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: es-toolkit
  dependency-version: 1.52.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: marked
  dependency-version: 18.0.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vue
  dependency-version: 3.5.42
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vue-router
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 26.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: eslint
  dependency-version: 10.9.1
  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-31 11:13:12 +08:00
dependabot[bot] 944d3f299e chore(deps): bump github/codeql-action/upload-sarif (#47)
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.8 to 4.37.9
- [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/db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28...cdf488f595d80d6e07e03d4674febd5ab45fa938)

---
updated-dependencies:
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.9
  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-31 11:12:30 +08:00
ZyyandCodex 56451601fb feat(observability): enrich error request traces
Expose request, client, model, transport, and account facts in operations errors and surface them in the error table and detail view.

Co-authored-by: Codex <noreply@openai.com>
2026-08-31 10:57:53 +08:00
ZyyandCodex 4d53ef6068 fix(openai): recover premature websocket closes
Pin the selected account and retry over HTTP/SSE when a new-chain WebSocket fails before downstream delivery.

Co-authored-by: Codex <noreply@openai.com>
2026-08-31 10:57:35 +08:00
ZyyandCodex a62bb196f7 fix(accounts): proxy profile avatars through backend
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 09:56:23 +08:00
ZyyandCodex 7ad0ca48c5 fix(frontend): improve radio contrast in light mode
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 09:56:01 +08:00
ZyyandCodex e25639c11b fix(release): gate publishing on container validation
Skip standalone CI for version-only release commits so they do not cancel the preceding source run. Reuse the container workflow in Release and require it before publishing.

Co-authored-by: Codex <noreply@openai.com>
2026-08-31 04:40:24 +08:00
ZyyandCodex 8abac080a2 chore: bump version to 3.3.11
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 04:22:47 +08:00
ZyyandCodex 37cd059531 fix(openai): normalize HTTP previous response errors
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 04:22:11 +08:00
ZyyandCodex c46910df22 fix(observability): restore direct page navigation
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 04:09:31 +08:00
68 changed files with 2581 additions and 1010 deletions
+1 -1
View File
@@ -79,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@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
sarif_file: trivy-results.sarif
category: runtime-container
+2
View File
@@ -4,6 +4,8 @@ on:
push:
branches:
- main
paths-ignore:
- 'release/version.yaml'
pull_request:
branches:
- main
+10 -1
View File
@@ -163,6 +163,15 @@ jobs:
ref: ${{ needs.release-info.outputs.git_sha }}
upload_frontend: true
container:
needs: [release-info]
permissions:
contents: read
security-events: write
uses: ./.github/workflows/_container.yml
with:
ref: ${{ needs.release-info.outputs.git_sha }}
build-docker-binary:
name: build-docker-binary (${{ matrix.os }}-${{ matrix.arch }})
needs: release-info
@@ -497,7 +506,7 @@ jobs:
exit-code: '1'
publish:
needs: [release-info, package-assets, build-docker-image]
needs: [release-info, package-assets, build-docker-image, container]
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
+45 -30
View File
@@ -49,13 +49,13 @@ checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
[[package]]
name = "argon2"
version = "0.5.3"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c"
dependencies = [
"base64ct",
"blake2",
"cpufeatures 0.2.17",
"cpufeatures 0.3.0",
"password-hash",
]
@@ -116,7 +116,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.4",
]
[[package]]
@@ -206,9 +206,9 @@ dependencies = [
[[package]]
name = "aws-sdk-s3"
version = "1.142.0"
version = "1.144.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9e15a5c55e05f4b0b7e483160b3c85cccdf77cff02c95504f3e71d460855cd2"
checksum = "30dc8bf6baaf7d46336a0ca2c69f223d9b90d7a801fb3e28f7ea17b00dc6b1de"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -331,9 +331,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http-client"
version = "1.3.0"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c1c8a04cb31ba74d0115af5a890bb8c0d48fba64b52812fa13929a6ef0cc83c"
checksum = "ebfd138fac0337cee7516c352757ea73b9f2266e57d0bcb5bc70e9547e45aef1"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
@@ -375,9 +375,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime"
version = "1.13.1"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "483b858ff67522011c4786310c5cd8fd88d0be7ea3d5f1a48328446300c4269e"
checksum = "b82e438d30e02a825d363bd639a9efaed68a8089d86101054b0081e7e0d3e606"
dependencies = [
"aws-smithy-async",
"aws-smithy-http",
@@ -599,11 +599,11 @@ dependencies = [
[[package]]
name = "blake2"
version = "0.10.6"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f"
dependencies = [
"digest 0.10.7",
"digest 0.11.3",
]
[[package]]
@@ -740,7 +740,7 @@ dependencies = [
"provider-xai",
"serde",
"serde_json",
"syn 3.0.3",
"syn 3.0.4",
"tempfile",
"thiserror",
"tokio",
@@ -1013,7 +1013,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
@@ -1318,7 +1317,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.4",
]
[[package]]
@@ -1363,6 +1362,7 @@ dependencies = [
"argon2",
"async-trait",
"base64 0.23.1",
"bytes",
"chrono",
"chrono-tz",
"cron",
@@ -1586,6 +1586,11 @@ name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "hashlink"
@@ -2046,11 +2051,11 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "lru"
version = "0.16.4"
version = "0.18.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c"
dependencies = [
"hashbrown 0.16.1",
"hashbrown 0.17.1",
]
[[package]]
@@ -2232,13 +2237,12 @@ dependencies = [
[[package]]
name = "password-hash"
version = "0.5.0"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
"getrandom 0.4.3",
"phc",
]
[[package]]
@@ -2262,6 +2266,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phc"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892"
dependencies = [
"base64ct",
"ctutils",
"getrandom 0.4.3",
]
[[package]]
name = "phf"
version = "0.11.3"
@@ -2982,7 +2997,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.4",
]
[[package]]
@@ -3407,9 +3422,9 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.3"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [
"proc-macro2",
"quote",
@@ -3477,7 +3492,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.4",
]
[[package]]
@@ -3913,9 +3928,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.24.1"
version = "1.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
dependencies = [
"getrandom 0.4.3",
"js-sys",
+2 -1
View File
@@ -12,9 +12,10 @@ name = "main"
path = "tests/main.rs"
[dependencies]
argon2 = "0.5.3"
argon2 = "0.6.0"
async-trait = "0.1.89"
base64 = "0.23.0"
bytes = "1.11.1"
chrono = "0.4.45"
futures = "0.3.32"
gateway-core = { path = "../gateway-core" }
@@ -38,13 +38,6 @@ impl TimeRange {
}
}
/// 观测记录的稳定键集游标。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ObservabilityCursor {
pub observed_at: DateTime<Utc>,
pub stable_id: String,
}
/// 请求结果状态。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RequestOutcome {
@@ -130,9 +123,8 @@ pub struct UsageFilter {
pub struct UsageQuery {
pub range: TimeRange,
pub filter: UsageFilter,
pub cursor: Option<ObservabilityCursor>,
pub current_page: u32,
pub page_size: PageSize,
pub include_total: bool,
}
/// 运维错误过滤条件。
@@ -143,6 +135,7 @@ pub struct OpsErrorFilter {
pub provider_kind: Option<String>,
pub provider_account_ref: Option<String>,
pub operation: Option<String>,
pub endpoint: Option<String>,
pub model: Option<String>,
pub transport: Option<String>,
pub attempt_index: Option<u32>,
@@ -158,9 +151,8 @@ pub struct OpsErrorFilter {
pub struct OpsErrorQuery {
pub range: TimeRange,
pub filter: OpsErrorFilter,
pub cursor: Option<ObservabilityCursor>,
pub current_page: u32,
pub page_size: PageSize,
pub include_total: bool,
}
/// 用量诊断维度。
@@ -667,8 +659,9 @@ pub struct UsageRecord {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsagePage {
pub items: Vec<UsageListRecord>,
pub total: Option<u64>,
pub next_cursor: Option<ObservabilityCursor>,
pub current_page: u32,
pub page_size: u16,
pub total: u64,
}
/// 请求中的一次上游尝试或运维事件。
@@ -770,6 +763,10 @@ pub struct OpsError {
pub client_api_key_ref: Option<String>,
pub component: String,
pub operation: String,
pub protocol: Option<String>,
pub client_transport: Option<String>,
pub requested_model_id: Option<String>,
pub service_tier: Option<String>,
pub endpoint: Option<String>,
pub provider_kind: Option<String>,
pub provider_account_ref: Option<String>,
@@ -787,6 +784,13 @@ pub struct OpsError {
pub latency_ms: Option<u64>,
pub message: String,
pub occurrence_count: u32,
pub client_ip: Option<String>,
pub user_agent: Option<String>,
pub reasoning_effort: Option<String>,
pub reasoning_preset: Option<String>,
pub request_kind: Option<String>,
pub subagent_kind: Option<String>,
pub compact: Option<bool>,
pub occurred_at: DateTime<Utc>,
pub stable_sort_id: String,
}
@@ -795,8 +799,9 @@ pub struct OpsError {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpsErrorPage {
pub items: Vec<OpsError>,
pub total: Option<u64>,
pub next_cursor: Option<ObservabilityCursor>,
pub current_page: u32,
pub page_size: u16,
pub total: u64,
}
/// 仪表盘趋势指标。
@@ -1,9 +1,11 @@
//! Provider 管理能力交换的中立 Command 与 Result。
use std::fmt;
use std::{fmt, pin::Pin};
use async_trait::async_trait;
use bytes::Bytes;
use chrono::{DateTime, NaiveDate, Utc};
use futures::Stream;
use gateway_core::{
engine::credential::{OpaqueProviderData, ProviderAccountId, ProviderAccountIdentity},
routing::{ProviderKind, UpstreamModelId},
@@ -679,6 +681,35 @@ pub struct ProviderProfileStatistics {
pub activity_insights: ProviderProfileActivityInsights,
}
/// Provider 头像正文的中立字节流;公共层不解释 MIME,也不限制总字节数。
pub type ProviderProfileAvatarStream =
Pin<Box<dyn Stream<Item = Result<Bytes, ProviderProfileAvatarStreamError>> + Send + 'static>>;
/// Provider 头像流在响应开始后的通用失败,不携带上游 URL 或正文。
#[derive(Debug, thiserror::Error)]
#[error("provider profile avatar stream failed")]
pub struct ProviderProfileAvatarStreamError;
/// Provider 已校验并打开的官方头像响应。
pub struct ProviderProfileAvatar {
pub content_type: Option<String>,
pub content_length: Option<u64>,
pub etag: Option<String>,
pub body: ProviderProfileAvatarStream,
}
impl fmt::Debug for ProviderProfileAvatar {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ProviderProfileAvatar")
.field("content_type", &self.content_type)
.field("content_length", &self.content_length)
.field("etag", &self.etag)
.field("body", &"<stream>")
.finish()
}
}
/// Provider 返回的一张安全主动额度重置卡。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderResetCredit {
@@ -17,8 +17,8 @@ use crate::model::provider_credentials::{
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels,
ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest, ProviderResetCreditResult,
ProviderResetCredits,
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
ProviderResetCreditResult, ProviderResetCredits,
};
/// Provider 管理失败的稳定分类。
@@ -157,6 +157,14 @@ pub trait ProviderAdmin: Send + Sync {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 打开 Provider 官方头像字节流;不支持该能力的 Provider 使用默认拒绝。
async fn profile_avatar(
&self,
_account_id: &ProviderAccountId,
) -> Result<ProviderProfileAvatar, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 查询 Provider 主动额度重置卡;不支持该能力的 Provider 使用默认拒绝。
async fn reset_credits(
&self,
@@ -25,8 +25,9 @@ use crate::{
provider_credentials::{
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
ConsumeProviderResetCredit, PrepareCredentialRefresh, ProviderModels,
ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest, ProviderQuotaWindow,
ProviderResetCreditResult, ProviderResetCredits, QuotaLocalUsageAttribution,
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
ProviderQuotaWindow, ProviderResetCreditResult, ProviderResetCredits,
QuotaLocalUsageAttribution,
},
},
ports::{
@@ -90,6 +91,13 @@ pub trait AccountsService: Send + Sync {
Err(AdminError::invalid("当前 Provider 不支持账号统计"))
}
async fn profile_avatar(
&self,
_account_id: &ProviderAccountId,
) -> Result<ProviderProfileAvatar, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持账号头像"))
}
async fn reset_credits(
&self,
_context: &MutationContext,
@@ -566,6 +574,17 @@ impl AccountsService for DefaultAccountsService {
.map_err(|error| map_provider_error(error, "provider profile statistics"))
}
async fn profile_avatar(
&self,
account_id: &ProviderAccountId,
) -> Result<ProviderProfileAvatar, AdminError> {
let (_, provider) = self.provider_for_account(account_id).await?;
provider
.profile_avatar(account_id)
.await
.map_err(|error| map_provider_error(error, "provider profile avatar"))
}
async fn reset_credits(
&self,
context: &MutationContext,
@@ -2,7 +2,7 @@
use std::sync::Arc;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{Duration, Utc};
@@ -195,9 +195,8 @@ impl AuthService for DefaultAuthService {
}
fn hash_admin_password(password: &str) -> Result<String, AdminError> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.hash_password(password.as_bytes())
.map(|hash| hash.to_string())
.map_err(|_| AdminError::internal("管理员密码哈希失败"))
}
@@ -675,13 +675,14 @@ impl ObservabilityStore for FixtureObservabilityStore {
.clone())
}
async fn list_usage_records(&self, _: UsageQuery) -> AdminStoreResult<UsagePage> {
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage> {
let items = self.usage_records.lock().expect("usage records").clone();
let total = u64::try_from(items.len()).unwrap_or(u64::MAX);
Ok(UsagePage {
items,
total: Some(total),
next_cursor: None,
current_page: query.current_page,
page_size: query.page_size.get(),
total,
})
}
@@ -783,9 +784,8 @@ fn usage_query(now: DateTime<Utc>) -> UsageQuery {
UsageQuery {
range: observation_range(now),
filter: UsageFilter::default(),
cursor: None,
current_page: 1,
page_size: PageSize::new(50).expect("page size"),
include_total: true,
}
}
@@ -4,8 +4,9 @@ use std::{collections::BTreeSet, convert::Infallible, fmt};
use axum::{
Router,
body::Body,
extract::State,
http::{HeaderValue, StatusCode, header::CACHE_CONTROL},
http::{HeaderName, HeaderValue, StatusCode, header},
response::{
IntoResponse, Response,
sse::{Event, KeepAlive, Sse},
@@ -28,10 +29,10 @@ use gateway_admin::model::{
AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit,
CredentialDeletion, CredentialDeletionResult, CredentialImportResult, CredentialMutation,
CredentialMutationResult, ImportCredentials, ProviderDocument, ProviderModels,
ProviderProfileActivityInsights, ProviderProfileDailyUsage, ProviderProfileInvocation,
ProviderProfileStatistics, ProviderProfileStatisticsSummary, ProviderQuota,
ProviderQuotaWindow, ProviderResetCredit, ProviderResetCreditResult, ProviderResetCredits,
RotateCredential, StartAuthorization,
ProviderProfileActivityInsights, ProviderProfileAvatar, ProviderProfileDailyUsage,
ProviderProfileInvocation, ProviderProfileStatistics, ProviderProfileStatisticsSummary,
ProviderQuota, ProviderQuotaWindow, ProviderResetCredit, ProviderResetCreditResult,
ProviderResetCredits, RotateCredential, StartAuthorization,
},
};
use gateway_core::{
@@ -60,6 +61,7 @@ const MAX_ID_TOKEN_BYTES: usize = 16 * 1024;
const MAX_CALLBACK_URL_BYTES: usize = 64 * 1024;
const MAX_ACCOUNT_DELETE_BATCH: usize = 200;
const MAX_ACCOUNT_GROUP_BATCH: usize = 1000;
const MAX_AVATAR_VERSION_BYTES: usize = 32;
/// 账号列表查询参数。
#[derive(Debug, Clone, Default, Deserialize)]
@@ -422,6 +424,33 @@ impl AccountIdQuery {
}
}
/// 头像 GET 的固定 query;`version` 只参与浏览器缓存键,不进入上游请求。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountProfileAvatarQuery {
pub account_id: String,
pub version: Option<String>,
}
impl AccountProfileAvatarQuery {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_account_id(&self.account_id, "accountId")?;
if self.version.as_deref().is_some_and(|version| {
version.is_empty()
|| version.len() > MAX_AVATAR_VERSION_BYTES
|| !version.bytes().all(|byte| byte.is_ascii_alphanumeric())
}) {
return Err(WireValidationError::new("version"));
}
Ok(())
}
fn into_id(self) -> Result<ProviderAccountId, WireValidationError> {
self.validate()?;
ProviderAccountId::new(self.account_id).map_err(|_| WireValidationError::new("accountId"))
}
}
/// 敏感导出的固定 query;IDs 使用逗号分隔,禁止隐式导出全部账号。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -1291,6 +1320,10 @@ where
"/api/admin/accounts/profile-statistics",
get(account_profile_statistics::<S>),
)
.route(
"/api/admin/accounts/profile-avatar",
get(account_profile_avatar::<S>),
)
.route(
"/api/admin/accounts/reset-credits",
get(account_reset_credits::<S>).post(consume_account_reset_credit::<S>),
@@ -1400,7 +1433,7 @@ where
let mut response = AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)).into_response();
response
.headers_mut()
.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
Ok(response)
}
@@ -1646,6 +1679,69 @@ where
))
}
async fn account_profile_avatar<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountProfileAvatarQuery>,
) -> Result<Response, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let avatar = state
.admin_services()
.accounts()
.profile_avatar(&account_id)
.await
.map_err(map_service_error)?;
Ok(profile_avatar_response(avatar))
}
/// 将 Provider 头像流投影为受保护的同源 HTTP 响应。
#[must_use]
pub fn profile_avatar_response(avatar: ProviderProfileAvatar) -> Response {
let ProviderProfileAvatar {
content_type,
content_length,
etag,
body,
} = avatar;
let mut response = Response::new(Body::from_stream(body));
*response.status_mut() = StatusCode::OK;
let headers = response.headers_mut();
headers.insert(
header::CONTENT_TYPE,
content_type
.and_then(|value| HeaderValue::from_str(&value).ok())
.unwrap_or_else(|| HeaderValue::from_static("application/octet-stream")),
);
if let Some(value) =
content_length.and_then(|length| HeaderValue::from_str(&length.to_string()).ok())
{
headers.insert(header::CONTENT_LENGTH, value);
}
if let Some(value) = etag.and_then(|value| HeaderValue::from_str(&value).ok()) {
headers.insert(header::ETAG, value);
}
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("private, max-age=3600"),
);
headers.insert(
HeaderName::from_static("x-content-type-options"),
HeaderValue::from_static("nosniff"),
);
headers.insert(
HeaderName::from_static("cross-origin-resource-policy"),
HeaderValue::from_static("same-origin"),
);
headers.insert(
HeaderName::from_static("content-security-policy"),
HeaderValue::from_static("sandbox; default-src 'none'"),
);
response
}
async fn refresh_account_quota<S>(
_auth: AdminAuth,
State(state): State<S>,
+2 -1
View File
@@ -58,6 +58,7 @@ async fn admin_not_found() -> AdminError {
async fn no_store(mut response: Response) -> Response {
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
.entry(header::CACHE_CONTROL)
.or_insert(HeaderValue::from_static("no-store"));
response
}
@@ -3,7 +3,6 @@
use std::collections::BTreeMap;
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};
use serde::{Deserialize, Serialize};
@@ -515,18 +515,13 @@ pub(crate) fn usage_detail_view(detail: domain::UsageDetail) -> UsageRecordDetai
}
}
pub(crate) fn usage_page_view(
page: domain::UsagePage,
) -> Result<PageData<UsageListRecordView>, WireValidationError> {
Ok(PageData {
pub(crate) fn usage_page_view(page: domain::UsagePage) -> PageData<UsageListRecordView> {
PageData {
items: page.items.into_iter().map(usage_list_record_view).collect(),
current_page: page.current_page,
page_size: page.page_size,
total: page.total,
next_cursor: page
.next_cursor
.as_ref()
.map(encode_observability_cursor)
.transpose()?,
})
}
}
pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
@@ -536,24 +531,45 @@ pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
.or(error.provider_account_name.as_ref())
.or(error.provider_account_ref.as_ref())
.cloned();
let model = error
.upstream_model_id
.clone()
.or_else(|| error.requested_model_id.clone());
OpsErrorView {
id: error.event_id,
request_id: error.request_id,
client_api_key_id: error.client_api_key_ref,
kind: error.component.clone(),
operation: error.operation,
protocol: error.protocol,
client_transport: error.client_transport,
provider: error.provider_kind,
authentication_kind: error.provider_account_authentication_kind,
account_id: error.provider_account_ref,
account_name: error.provider_account_name,
account_email: error.provider_account_email,
route: error.endpoint.unwrap_or_else(|| "—".to_owned()),
model: error.upstream_model_id,
model,
requested_model: error.requested_model_id,
upstream_model: error.upstream_model_id,
service_tier: error.service_tier,
client_status_code: error.client_status_code.map(i64::from),
upstream_status_code: error.upstream_status_code.map(i64::from),
transport: error.upstream_transport,
attempt_index: error.attempt_index,
failure_class: error.failure_kind,
provider_error_code: error.provider_error_code,
occurrence_count: error.occurrence_count,
response_id: error.client_response_id,
upstream_request_id: error.upstream_request_id,
latency_ms: error.latency_ms,
client_ip: error.client_ip,
user_agent: error.user_agent,
reasoning_effort: error.reasoning_effort,
reasoning_preset: error.reasoning_preset,
request_kind: error.request_kind,
subagent_kind: error.subagent_kind,
compact: error.compact,
message: error.message,
metadata: OpsErrorMetadataView {
source: error.source,
@@ -566,18 +582,13 @@ pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
}
}
pub(crate) fn ops_page_view(
page: domain::OpsErrorPage,
) -> Result<PageData<OpsErrorView>, WireValidationError> {
Ok(PageData {
pub(crate) fn ops_page_view(page: domain::OpsErrorPage) -> PageData<OpsErrorView> {
PageData {
items: page.items.into_iter().map(ops_error_view).collect(),
current_page: page.current_page,
page_size: page.page_size,
total: page.total,
next_cursor: page
.next_cursor
.as_ref()
.map(encode_observability_cursor)
.transpose()?,
})
}
}
pub(crate) fn trend_point_view(point: domain::TrendPoint) -> TrendPointView {
@@ -2,14 +2,10 @@
use super::*;
use base64::Engine as _;
/// 观测列表默认页大小。
pub const DEFAULT_PAGE_SIZE: u16 = 50;
/// 观测列表允许的最大页大小。
pub const MAX_PAGE_SIZE: u16 = 100;
/// 游标编码在 HTTP 层解码前允许的最大长度。
pub const MAX_CURSOR_BYTES: usize = 1024;
/// Dashboard 查询参数。
#[derive(Clone, Default, Deserialize)]
@@ -31,8 +27,8 @@ impl DashboardQuery {
#[derive(Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UsageQuery {
pub current_page: Option<u32>,
pub page_size: Option<u16>,
pub cursor: Option<String>,
pub kind: Option<String>,
pub outcome: Option<String>,
pub client_api_key_id: Option<String>,
@@ -52,18 +48,17 @@ pub struct UsageQuery {
}
impl UsageQuery {
/// 校验分页字段,不改变仓储层的分页 owner。
pub fn validate_page_size(&self) -> Result<u16, WireValidationError> {
/// 校验 Element Plus 风格的页码分页字段。
pub fn validate_pagination(&self) -> Result<(u32, u16), WireValidationError> {
let current_page = self.current_page.unwrap_or(1);
if current_page == 0 {
return Err(WireValidationError::new("currentPage"));
}
let page_size = self.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
if page_size == 0 || page_size > MAX_PAGE_SIZE {
return Err(WireValidationError::new("pageSize"));
}
Ok(page_size)
}
/// 校验游标的 wire 边界;编码和排序语义由应用层负责。
pub fn validate_cursor(&self) -> Result<(), WireValidationError> {
validate_cursor(self.cursor.as_deref())
Ok((current_page, page_size))
}
}
@@ -105,8 +100,8 @@ impl DiagnosticsQuery {
#[derive(Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct OpsQuery {
pub current_page: Option<u32>,
pub page_size: Option<u16>,
pub cursor: Option<String>,
pub kind: Option<String>,
pub client_api_key_id: Option<String>,
pub provider: Option<String>,
@@ -128,18 +123,17 @@ pub struct OpsQuery {
}
impl OpsQuery {
/// 校验分页字段。
pub fn validate_page_size(&self) -> Result<u16, WireValidationError> {
/// 校验 Element Plus 风格的页码分页字段。
pub fn validate_pagination(&self) -> Result<(u32, u16), WireValidationError> {
let current_page = self.current_page.unwrap_or(1);
if current_page == 0 {
return Err(WireValidationError::new("currentPage"));
}
let page_size = self.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
if page_size == 0 || page_size > MAX_PAGE_SIZE {
return Err(WireValidationError::new("pageSize"));
}
Ok(page_size)
}
/// 校验游标的 wire 边界。
pub fn validate_cursor(&self) -> Result<(), WireValidationError> {
validate_cursor(self.cursor.as_deref())
Ok((current_page, page_size))
}
}
@@ -248,13 +242,6 @@ pub(crate) fn require_text(value: &str, field: &'static str) -> Result<(), WireV
}
}
pub(crate) fn validate_cursor(value: Option<&str>) -> Result<(), WireValidationError> {
if value.is_some_and(|value| value.is_empty() || value.len() > MAX_CURSOR_BYTES) {
return Err(WireValidationError::new("cursor"));
}
Ok(())
}
pub(crate) fn trimmed(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
@@ -336,22 +323,19 @@ pub(crate) fn usage_filter(query: &UsageQuery) -> Result<domain::UsageFilter, Wi
}
pub(crate) fn usage_command(query: &UsageQuery) -> Result<domain::UsageQuery, WireValidationError> {
let page_size = query.validate_page_size()?;
query.validate_cursor()?;
let (current_page, page_size) = query.validate_pagination()?;
let page_size_value =
DomainPageSize::new(page_size).map_err(|_| WireValidationError::new("pageSize"))?;
Ok(domain::UsageQuery {
range: usage_range(query.start_time.as_deref(), query.end_time.as_deref())?,
filter: usage_filter(query)?,
cursor: decode_observability_cursor(query.cursor.as_deref())?,
current_page,
page_size: page_size_value,
include_total: query.cursor.is_none(),
})
}
pub(crate) fn ops_command(query: &OpsQuery) -> Result<domain::OpsErrorQuery, WireValidationError> {
let page_size = query.validate_page_size()?;
query.validate_cursor()?;
let (current_page, page_size) = query.validate_pagination()?;
let page_size_value =
DomainPageSize::new(page_size).map_err(|_| WireValidationError::new("pageSize"))?;
let status_code = parse_status(
@@ -367,7 +351,8 @@ pub(crate) fn ops_command(query: &OpsQuery) -> Result<domain::OpsErrorQuery, Wir
request_id: non_empty(query.request_id.clone()),
provider_kind: non_empty(query.provider.clone()),
provider_account_ref: non_empty(query.account_id.clone()),
operation: non_empty(query.route.clone()).or_else(|| non_empty(query.kind.clone())),
operation: non_empty(query.kind.clone()),
endpoint: non_empty(query.route.clone()),
model: non_empty(query.model.clone()),
transport: non_empty(query.transport.clone()),
attempt_index: parse_attempt_index(query.attempt_index)?,
@@ -377,47 +362,11 @@ pub(crate) fn ops_command(query: &OpsQuery) -> Result<domain::OpsErrorQuery, Wir
status_code,
search: non_empty(query.search.clone()),
},
cursor: decode_observability_cursor(query.cursor.as_deref())?,
current_page,
page_size: page_size_value,
include_total: query.cursor.is_none(),
})
}
pub(crate) fn decode_observability_cursor(
value: Option<&str>,
) -> Result<Option<domain::ObservabilityCursor>, WireValidationError> {
value
.map(|encoded| {
if encoded.is_empty() || encoded.len() > MAX_CURSOR_BYTES {
return Err(WireValidationError::new("cursor"));
}
let bytes = URL_SAFE_NO_PAD
.decode(encoded)
.map_err(|_| WireValidationError::new("cursor"))?;
let wire: CursorWire =
serde_json::from_slice(&bytes).map_err(|_| WireValidationError::new("cursor"))?;
if wire.stable_id.trim().is_empty() {
return Err(WireValidationError::new("cursor"));
}
Ok(domain::ObservabilityCursor {
observed_at: wire.observed_at,
stable_id: wire.stable_id,
})
})
.transpose()
}
pub(crate) fn encode_observability_cursor(
cursor: &domain::ObservabilityCursor,
) -> Result<String, WireValidationError> {
let bytes = serde_json::to_vec(&CursorWire {
observed_at: cursor.observed_at,
stable_id: cursor.stable_id.clone(),
})
.map_err(|_| WireValidationError::new("cursor"))?;
Ok(URL_SAFE_NO_PAD.encode(bytes))
}
pub(crate) fn non_empty(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_owned())
@@ -91,7 +91,7 @@ where
.usage_records(command)
.await
.map_err(map_service_error)?;
let data = usage_page_view(result).map_err(|_| AdminError::internal())?;
let data = usage_page_view(result);
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
@@ -207,6 +207,6 @@ where
.ops_errors(command)
.await
.map_err(map_service_error)?;
let data = ops_page_view(result).map_err(|_| AdminError::internal())?;
let data = ops_page_view(result);
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
@@ -2,21 +2,14 @@
use super::*;
/// 观测列表游标的稳定 wire 形状。
#[derive(Clone, Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CursorWire {
pub observed_at: DateTime<Utc>,
pub stable_id: String,
}
/// 观测列表响应数据。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PageData<T> {
pub items: Vec<T>,
pub total: Option<u64>,
pub next_cursor: Option<String>,
pub current_page: u32,
pub page_size: u16,
pub total: u64,
}
/// Token 详情展示。
@@ -765,19 +758,36 @@ pub struct OpsErrorView {
pub request_id: Option<String>,
pub client_api_key_id: Option<String>,
pub kind: String,
pub operation: String,
pub protocol: Option<String>,
pub client_transport: Option<String>,
pub provider: Option<String>,
pub authentication_kind: Option<String>,
pub account_id: Option<String>,
pub account_name: Option<String>,
pub account_email: Option<String>,
pub route: String,
pub model: Option<String>,
pub requested_model: Option<String>,
pub upstream_model: Option<String>,
pub service_tier: Option<String>,
pub client_status_code: Option<i64>,
pub upstream_status_code: Option<i64>,
pub transport: Option<String>,
pub attempt_index: Option<u32>,
pub failure_class: String,
pub provider_error_code: Option<String>,
pub occurrence_count: u32,
pub response_id: Option<String>,
pub upstream_request_id: Option<String>,
pub latency_ms: Option<u64>,
pub client_ip: Option<String>,
pub user_agent: Option<String>,
pub reasoning_effort: Option<String>,
pub reasoning_preset: Option<String>,
pub request_kind: Option<String>,
pub subagent_kind: Option<String>,
pub compact: Option<bool>,
pub message: String,
pub metadata: OpsErrorMetadataView,
pub created_at: DateTime<Utc>,
@@ -76,7 +76,7 @@ mod profile_statistics {
ProviderProfileActivityInsights, ProviderProfileDailyUsage, ProviderProfileInvocation,
ProviderProfileStatistics, ProviderProfileStatisticsSummary,
};
use gateway_api::admin::accounts::AccountProfileStatisticsData;
use gateway_api::admin::accounts::{AccountProfileAvatarQuery, AccountProfileStatisticsData};
use serde_json::json;
#[test]
@@ -134,6 +134,74 @@ mod profile_statistics {
assert!(value.get("models").is_none());
assert!(value.get("estimatedCost").is_none());
}
#[test]
fn profile_avatar_query_accepts_only_bounded_cache_versions() {
let valid: AccountProfileAvatarQuery = serde_json::from_value(json!({
"accountId": "acct_openai",
"version": "k9m2z1"
}))
.expect("decode avatar query");
valid.validate().expect("validate avatar query");
for version in ["", "contains-dash", "x23456789012345678901234567890123"] {
let query: AccountProfileAvatarQuery = serde_json::from_value(json!({
"accountId": "acct_openai",
"version": version
}))
.expect("decode invalid avatar query");
assert_eq!(
query.validate().expect_err("reject version").field(),
"version"
);
}
assert!(
serde_json::from_value::<AccountProfileAvatarQuery>(json!({
"accountId": "acct_openai",
"sourceUrl": "https://example.test/avatar"
}))
.is_err()
);
}
}
mod profile_avatar {
use axum::body::to_bytes;
use bytes::Bytes;
use futures::stream;
use gateway_admin::model::provider_credentials::ProviderProfileAvatar;
use gateway_api::admin::accounts::profile_avatar_response;
#[tokio::test]
async fn profile_avatar_response_preserves_stream_metadata_and_private_cache() {
let response = profile_avatar_response(ProviderProfileAvatar {
content_type: Some("image/svg+xml".to_owned()),
content_length: Some(6),
etag: Some("\"v1\"".to_owned()),
body: Box::pin(stream::iter([Ok(Bytes::from_static(b"avatar"))])),
});
assert_eq!(response.status(), 200);
assert_eq!(response.headers()["content-type"], "image/svg+xml");
assert_eq!(response.headers()["content-length"], "6");
assert_eq!(response.headers()["etag"], "\"v1\"");
assert_eq!(response.headers()["cache-control"], "private, max-age=3600");
assert_eq!(response.headers()["x-content-type-options"], "nosniff");
assert_eq!(
response.headers()["cross-origin-resource-policy"],
"same-origin"
);
assert_eq!(
response.headers()["content-security-policy"],
"sandbox; default-src 'none'"
);
assert_eq!(
to_bytes(response.into_body(), 16)
.await
.expect("avatar response body"),
Bytes::from_static(b"avatar")
);
}
}
mod batch_update {
@@ -858,9 +858,10 @@ impl ObservabilityStore for UnusedStore {
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage> {
let items = self.usage_records.lock().expect("usage records").clone();
Ok(UsagePage {
total: query.include_total.then_some(items.len() as u64),
current_page: query.current_page,
page_size: query.page_size.get(),
total: items.len() as u64,
items,
next_cursor: None,
})
}
@@ -888,9 +889,10 @@ impl ObservabilityStore for UnusedStore {
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage> {
let items = self.ops_errors.lock().expect("ops errors").clone();
Ok(OpsErrorPage {
total: query.include_total.then_some(items.len() as u64),
current_page: query.current_page,
page_size: query.page_size.get(),
total: items.len() as u64,
items,
next_cursor: None,
})
}
}
@@ -19,14 +19,13 @@ fn dashboard_query_should_reject_unknown_trend_kind() {
}
#[test]
fn usage_query_should_bound_page_size_and_cursor() {
fn usage_query_should_accept_element_plus_pagination() {
let query: UsageQuery = serde_json::from_value(json!({
"pageSize": 100,
"cursor": "opaque"
"currentPage": 129,
"pageSize": 10
}))
.unwrap();
assert_eq!(query.validate_page_size().unwrap(), 100);
assert!(query.validate_cursor().is_ok());
assert_eq!(query.validate_pagination().unwrap(), (129, 10));
}
#[test]
@@ -35,14 +34,19 @@ fn usage_query_should_reject_removed_total_toggle() {
}
#[test]
fn usage_query_should_reject_removed_offset_page_contract() {
fn usage_query_should_reject_removed_cursor_contract() {
assert!(serde_json::from_value::<UsageQuery>(json!({"cursor": "opaque"})).is_err());
}
#[test]
fn usage_query_should_reject_nonstandard_page_name() {
assert!(serde_json::from_value::<UsageQuery>(json!({"page": 1})).is_err());
}
#[test]
fn ops_query_should_reject_page_size_above_terminal_limit() {
let query: OpsQuery = serde_json::from_value(json!({"pageSize": 101})).unwrap();
assert_eq!(query.validate_page_size().unwrap_err().field(), "pageSize");
assert_eq!(query.validate_pagination().unwrap_err().field(), "pageSize");
}
#[test]
@@ -3,10 +3,10 @@
use chrono::{TimeZone, Utc};
use gateway_admin::model::observability::DesktopReleaseStatus;
use gateway_api::admin::observability::{
BillingView, CostCoverageView, CursorWire, DashboardAccountRequestBucketView,
DashboardAccountUsageView, DashboardDesktopReleaseStatusView, DashboardWireAttributeView,
DashboardWireProfileView, DashboardWireTargetView, PageData, TokenDetailsView, TrendData,
TrendKind, TrendPointView, TrendSummaryView,
BillingView, CostCoverageView, DashboardAccountRequestBucketView, DashboardAccountUsageView,
DashboardDesktopReleaseStatusView, DashboardWireAttributeView, DashboardWireProfileView,
DashboardWireTargetView, PageData, TokenDetailsView, TrendData, TrendKind, TrendPointView,
TrendSummaryView,
};
use serde_json::json;
@@ -14,12 +14,15 @@ use serde_json::json;
fn usage_page_should_keep_terminal_camel_case_shape() {
let data = PageData {
items: vec![json!({"id": "request_1"})],
total: Some(1),
next_cursor: Some("cursor".to_owned()),
current_page: 129,
page_size: 10,
total: 1287,
};
let value = serde_json::to_value(data).unwrap();
assert_eq!(value["total"], 1);
assert_eq!(value["nextCursor"], "cursor");
assert_eq!(value["currentPage"], 129);
assert_eq!(value["pageSize"], 10);
assert_eq!(value["total"], 1287);
assert!(value.get("nextCursor").is_none());
}
#[test]
@@ -154,11 +157,7 @@ fn sensitive_response_views_do_not_require_debug_or_add_secret_fields() {
image_output_tokens_display: "-".to_owned(),
total_tokens_display: "3".to_owned(),
};
let cursor = CursorWire {
observed_at: Utc.timestamp_opt(0, 0).single().unwrap(),
stable_id: "request_1".to_owned(),
};
let value = serde_json::to_value((&coverage, &token_details, &cursor)).unwrap();
let value = serde_json::to_value((&coverage, &token_details)).unwrap();
assert!(value.to_string().contains("known"));
assert!(!value.to_string().contains("secret"));
}
@@ -540,6 +539,10 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
client_api_key_ref: Some("key_err".to_owned()),
component: "model_request".to_owned(),
operation: "responses".to_owned(),
protocol: Some("openai".to_owned()),
client_transport: Some("http_sse".to_owned()),
requested_model_id: Some("gpt-5.4".to_owned()),
service_tier: Some("priority".to_owned()),
endpoint: Some("/v1/responses".to_owned()),
provider_kind: Some("openai".to_owned()),
provider_account_ref: Some("acct_err".to_owned()),
@@ -557,6 +560,13 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
latency_ms: Some(90),
message: "snapshot error".to_owned(),
occurrence_count: 1,
client_ip: Some("203.0.113.8".to_owned()),
user_agent: Some("codex-cli/0.144.0".to_owned()),
reasoning_effort: Some("medium".to_owned()),
reasoning_preset: None,
request_kind: Some("root".to_owned()),
subagent_kind: None,
compact: Some(false),
occurred_at: Utc::now(),
stable_sort_id: "model_request:req_err".to_owned(),
});
@@ -586,6 +596,11 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
"accountLabel": value["data"]["items"][0]["metadata"]["accountLabel"],
"clientStatusCode": value["data"]["items"][0]["clientStatusCode"],
"route": value["data"]["items"][0]["route"],
"requestedModel": value["data"]["items"][0]["requestedModel"],
"clientTransport": value["data"]["items"][0]["clientTransport"],
"clientIp": value["data"]["items"][0]["clientIp"],
"userAgent": value["data"]["items"][0]["userAgent"],
"providerErrorCode": value["data"]["items"][0]["providerErrorCode"],
}),
serde_json::json!({
"provider": "openai",
@@ -595,6 +610,11 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
"accountLabel": "err@example.invalid",
"clientStatusCode": 502,
"route": "/v1/responses",
"requestedModel": "gpt-5.4",
"clientTransport": "http_sse",
"clientIp": "203.0.113.8",
"userAgent": "codex-cli/0.144.0",
"providerErrorCode": "upstream",
})
);
}
@@ -12,11 +12,11 @@ use crate::engine::continuation::{
};
use crate::engine::provider::{Provider, ProviderCallMetadata, ProviderRequest, ProviderStream};
use crate::engine::{
AccountAttemptContext, AttemptContext, AttemptRecord, AttemptTrigger, CancellationToken,
CommitRequirement, ContinuationAttempt, CoordinatedEvent, EngineError, ExecutionOutcome,
ExecutionStore, GatewayEngine, IntermediateFailure, ModelRequestFinalization, ModelRequestId,
ModelRequestTimings, NewModelRequest, ProviderAccountStateOwner, ProviderAttemptOutcome,
RequestAttemptContext, UpstreamSendState,
AccountAttemptContext, AttemptContext, AttemptRecord, AttemptTransport, AttemptTrigger,
CancellationToken, CommitRequirement, ContinuationAttempt, CoordinatedEvent, EngineError,
ExecutionOutcome, ExecutionStore, GatewayEngine, IntermediateFailure, ModelRequestFinalization,
ModelRequestId, ModelRequestTimings, NewModelRequest, ProviderAccountStateOwner,
ProviderAttemptOutcome, RequestAttemptContext, UpstreamSendState,
};
use crate::error::{GatewayError, GatewayErrorKind, ProviderError, ProviderErrorKind, StoreError};
use crate::event::{
@@ -166,6 +166,7 @@ where
excluded_accounts: BTreeSet::new(),
credential_recovery_attempted_accounts: BTreeSet::new(),
recovery_account: None,
transport_fallback_account: None,
current: None,
send_state_watermark: UpstreamSendState::NotSent,
downstream_committed_at: None,
@@ -245,6 +246,9 @@ pub struct ResponseExecutionSession<S: ?Sized> {
/// attempt 建立时即被消费,后续可重试错误仍可换号消耗剩余重试预算。
/// 与 `required_account`(外部指定、贯穿整个请求)语义不同,不可合并。
recovery_account: Option<crate::engine::credential::ProviderAccountId>,
/// 上游传输在提交边界前失败后的一次性同账号备用传输钉选。
/// 与凭据恢复分开保存,避免把 transport fallback 误记为 OAuth 恢复。
transport_fallback_account: Option<crate::engine::credential::ProviderAccountId>,
current: Option<CurrentAttempt>,
/// 请求级发送状态水位;跨 attempt 单调不降,终态写回不得低于此档。
send_state_watermark: UpstreamSendState,
@@ -610,14 +614,26 @@ where
.checked_add(1)
.and_then(NonZeroU32::new)
.ok_or(EngineError::EmptyRoutingPlan)?;
// recovery 钉选在此被一次性消费,只绑定本次 replay attempt;
// 请求局部恢复钉选在此被一次性消费,只绑定本次 replay attempt;
// 外部 required_account 每次 attempt 都重新生效。
let pinned_account = match &self.account_selection {
AccountSelection::Diagnostic(account) => Some(account.clone()),
AccountSelection::Scheduled(_) => self
.recovery_account
.take()
.or_else(|| self.account_selection.required_account().cloned()),
let (pinned_account, attempt_transport) = match &self.account_selection {
AccountSelection::Diagnostic(account) => {
(Some(account.clone()), AttemptTransport::Default)
}
AccountSelection::Scheduled(_) => {
let transport_fallback_account = self.transport_fallback_account.take();
let transport = if transport_fallback_account.is_some() {
AttemptTransport::Fallback
} else {
AttemptTransport::Default
};
(
transport_fallback_account
.or_else(|| self.recovery_account.take())
.or_else(|| self.account_selection.required_account().cloned()),
transport,
)
}
};
let account_context = match &self.account_selection {
AccountSelection::Diagnostic(account) => AccountAttemptContext::diagnostic(
@@ -646,7 +662,8 @@ where
self.continuation.clone(),
self.cancellation.clone(),
)
.with_continuation_attempt(self.continuation_attempt);
.with_continuation_attempt(self.continuation_attempt)
.with_transport(attempt_transport);
let trigger = if self.attempts == 0 {
AttemptTrigger::Initial
} else {
@@ -1023,6 +1040,11 @@ where
&& !self.delivery_pending
&& error.allows_pre_delivery_retry()
&& self.attempts < self.plan.max_attempts().get();
let transport_fallback_retry = pre_delivery_retry
&& matches!(
error.pre_delivery_retry(),
Some(crate::error::PreDeliveryRetry::SameAccountTransportFallback)
);
let ordinary_retry = self.account_selection.required_account().is_none()
&& self.continuation_attempt == ContinuationAttempt::None
&& self.downstream_committed_at.is_none()
@@ -1053,6 +1075,9 @@ where
// 只钉住紧随其后的 replay attempt;replay 再遇可重试错误时,
// ordinary/continuation 重试门不受影响,仍可换号。
self.recovery_account = Some(account);
} else if transport_fallback_retry {
self.transport_fallback_account =
Some(current.metadata.provider_account_id().clone());
} else if !continuation_retry {
self.excluded_accounts
.insert(current.metadata.provider_account_id().clone());
+28 -3
View File
@@ -106,6 +106,16 @@ pub enum AttemptTrigger {
AccountRetry,
}
/// Provider 可为当前 attempt 选择的请求局部传输档位。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum AttemptTransport {
/// 使用 Provider 的默认传输策略。
#[default]
Default,
/// 使用 Provider 定义的备用传输。
Fallback,
}
impl AttemptTrigger {
#[must_use]
pub const fn as_str(self) -> &'static str {
@@ -224,8 +234,8 @@ fn lock_unpoisoned<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
/// 单次 attempt 的账号选择与账号绑定状态事实。
///
/// `required_account` 只用于管理端 connection test 等必须命中唯一账号的内部请求;
/// 一旦设置,Core 与 Provider 都不得换号或切换 target。
/// `required_account` 用于管理端 connection test,或请求局部的同账号恢复 attempt;
/// 一旦设置,Provider 在本 attempt 内不得换号或切换 target。
#[derive(Debug, Clone, Default)]
pub struct AccountAttemptContext {
excluded_accounts: BTreeSet<ProviderAccountId>,
@@ -425,6 +435,7 @@ pub struct AttemptContext {
account: AccountAttemptContext,
continuation: Option<ContinuationBinding>,
continuation_attempt: ContinuationAttempt,
transport: AttemptTransport,
cancellation: CancellationToken,
}
@@ -452,6 +463,7 @@ impl AttemptContext {
account,
continuation,
continuation_attempt,
transport: AttemptTransport::Default,
cancellation,
}
}
@@ -466,6 +478,13 @@ impl AttemptContext {
self
}
/// 覆盖本次 attempt 的 Provider 传输档位。
#[must_use]
pub const fn with_transport(mut self, transport: AttemptTransport) -> Self {
self.transport = transport;
self
}
#[must_use]
pub const fn request_id(&self) -> &ModelRequestId {
self.request.request_id()
@@ -503,7 +522,7 @@ impl AttemptContext {
self.account.excluded_accounts()
}
/// 管理端 connection test 等内部请求强制使用的唯一账号。
/// 管理端诊断或请求局部恢复 attempt 强制使用的唯一账号。
#[must_use]
pub const fn required_account(&self) -> Option<&ProviderAccountId> {
self.account.required_account()
@@ -542,6 +561,12 @@ impl AttemptContext {
self.continuation_attempt
}
/// 返回本次 attempt 的 Provider 传输档位。
#[must_use]
pub const fn transport(&self) -> AttemptTransport {
self.transport
}
#[must_use]
pub const fn cancellation(&self) -> &CancellationToken {
&self.cancellation
+29 -4
View File
@@ -197,6 +197,15 @@ pub enum ContinuationFailure {
HistoryUnavailable,
}
/// Provider 在下游尚未提交事件时请求的隐藏恢复方式。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PreDeliveryRetry {
/// 排除本次账号,按普通调度策略重新选号。
AccountRotation,
/// 固定本次账号,并要求 Provider 使用备用传输。
SameAccountTransportFallback,
}
/// Adapter 已明确分类为非 bearer 的不透明上游值。
///
/// Core 不解释或校验内容,只通过自定义 [`Debug`] 避免它意外进入日志。协议 adapter
@@ -360,7 +369,7 @@ pub struct ProviderError {
retry_after: Option<Duration>,
continuation_failure: Option<ContinuationFailure>,
replay_safe: bool,
pre_delivery_retry: bool,
pre_delivery_retry: Option<PreDeliveryRetry>,
credential_recovery_required: bool,
retry_same_account: bool,
sensitive_context_redacted: bool,
@@ -390,7 +399,7 @@ impl ProviderError {
retry_after: None,
continuation_failure: None,
replay_safe: false,
pre_delivery_retry: false,
pre_delivery_retry: None,
credential_recovery_required: false,
retry_same_account: false,
sensitive_context_redacted: false,
@@ -456,7 +465,17 @@ impl ProviderError {
/// 指定账号或下游已经进入提交状态时必须忽略它。
#[must_use]
pub const fn with_pre_delivery_retry(mut self) -> Self {
self.pre_delivery_retry = true;
self.pre_delivery_retry = Some(PreDeliveryRetry::AccountRotation);
self
}
/// 允许 Core 在首个客户端事件前固定原账号并改用 Provider 备用传输。
///
/// 与普通换号恢复相同,Core 仍会拒绝 continuation、指定账号、预算耗尽或
/// 下游已提交后的隐藏重放。
#[must_use]
pub const fn with_pre_delivery_transport_fallback(mut self) -> Self {
self.pre_delivery_retry = Some(PreDeliveryRetry::SameAccountTransportFallback);
self
}
@@ -561,9 +580,15 @@ impl ProviderError {
self.replay_safe
}
/// 返回 Provider 是否允许在首个客户端事件前换号恢复传输失败。
/// 返回 Provider 是否允许在首个客户端事件前隐藏恢复传输失败。
#[must_use]
pub const fn allows_pre_delivery_retry(&self) -> bool {
self.pre_delivery_retry.is_some()
}
/// 返回下游提交前由 Provider 选择的隐藏恢复方式。
#[must_use]
pub const fn pre_delivery_retry(&self) -> Option<PreDeliveryRetry> {
self.pre_delivery_retry
}
@@ -23,9 +23,9 @@ use gateway_core::engine::provider::{
ProviderRegistry, ProviderRequest, ProviderStream, UpstreamTransport,
};
use gateway_core::engine::{
AttemptContext, AttemptCoordinator, AttemptRecord, CancellationToken, CommitRequirement,
ContinuationAttempt, EngineError, ExecutionOutcome, ExecutionStore, GatewayEngine,
IntermediateFailure, ModelRequestFinalization, ModelRequestId, NewModelRequest,
AttemptContext, AttemptCoordinator, AttemptRecord, AttemptTransport, CancellationToken,
CommitRequirement, ContinuationAttempt, EngineError, ExecutionOutcome, ExecutionStore,
GatewayEngine, IntermediateFailure, ModelRequestFinalization, ModelRequestId, NewModelRequest,
ProviderAttemptOutcome, RecoveryReport, UpstreamSendState,
};
use gateway_core::error::{
@@ -2754,6 +2754,49 @@ fn pre_delivery_transport_failure_rotates_account_for_non_idempotent_generation(
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
}
#[test]
fn pre_delivery_transport_fallback_retries_the_same_account_with_fallback_transport() {
let operation = generate_operation();
let route_plan = plan(&operation);
let (coordinator, store, provider) = coordinator(vec![
Script::Stream {
account_id: "acct_first",
items: vec![Err(ProviderError::new(
ProviderErrorKind::Transport,
UpstreamSendState::Ambiguous,
)
.with_pre_delivery_transport_fallback())],
},
Script::Stream {
account_id: "acct_first",
items: complete_stream(None),
},
]);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
None,
CancellationToken::new(),
))
.expect("start execution");
block_on(session.collect_uncommitted()).expect("same-account fallback succeeds");
block_on(session.commit_downstream(Some(200))).expect("commit winning response");
let original = ProviderAccountId::new("acct_first").expect("account id");
let contexts = provider.contexts.lock().expect("contexts lock");
assert_eq!(contexts.len(), 2);
assert_eq!(contexts[0].transport(), AttemptTransport::Default);
assert_eq!(contexts[1].transport(), AttemptTransport::Fallback);
assert_eq!(contexts[1].required_account(), Some(&original));
assert!(!contexts[1].excluded_accounts().contains(&original));
let state = store.state.lock().expect("store lock");
assert_eq!(state.intermediate_failures, 1);
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
}
#[test]
fn final_capacity_exhaustion_returns_the_last_retryable_upstream_failure() {
let operation = generate_operation();
@@ -2890,7 +2933,7 @@ fn pre_delivery_retry_marker_is_ignored_after_downstream_commit() {
))),
Err(
ProviderError::new(ProviderErrorKind::Transport, UpstreamSendState::Ambiguous)
.with_pre_delivery_retry(),
.with_pre_delivery_transport_fallback(),
),
],
},
@@ -125,9 +125,8 @@ impl ObservabilityRepository for PgObservabilityRepository {
outcome: Some("succeeded".to_owned()),
..UsageRecordFilter::default()
},
cursor: None,
current_page: 1,
page_size: ObservabilityPageSize::new(10)?,
include_total: false,
};
// 每条 SQL 独立取一个全局观测槽位,避免整包预留造成队头阻塞。
let (totals, (provider_accounts, _)) = futures::try_join!(
@@ -148,7 +147,7 @@ impl ObservabilityRepository for PgObservabilityRepository {
),
self.query_budget
.run("load dashboard recent requests", async {
Ok(list_usage_records(&self.pool, recent_query).await?.items)
list_usage_record_items(&self.pool, &recent_query).await
}),
)?;
Ok(DashboardObservation {
@@ -30,22 +30,15 @@ pub(crate) fn store_request_outcome(outcome: admin_observability::RequestOutcome
outcome.as_str().to_owned()
}
pub(crate) fn store_cursor(
cursor: admin_observability::ObservabilityCursor,
) -> AdminStoreResult<ObservabilityCursor> {
ObservabilityCursor::new(cursor.observed_at, cursor.stable_id).map_err(observability_error)
}
pub(crate) fn store_usage_query(
query: admin_observability::UsageQuery,
) -> AdminStoreResult<UsageRecordQuery> {
Ok(UsageRecordQuery {
range: store_range(query.range)?,
filter: store_usage_filter(query.filter),
cursor: query.cursor.map(store_cursor).transpose()?,
current_page: query.current_page,
page_size: ObservabilityPageSize::new(query.page_size.get())
.map_err(observability_error)?,
include_total: query.include_total,
})
}
@@ -58,6 +51,7 @@ pub(crate) fn store_ops_error_filter(
provider_account_ref: filter.provider_account_ref,
provider_kind: filter.provider_kind,
operation: filter.operation,
endpoint: filter.endpoint,
model: filter.model,
transport: filter.transport,
attempt_index: filter.attempt_index,
@@ -75,10 +69,9 @@ pub(crate) fn store_ops_error_query(
Ok(OpsErrorQuery {
range: store_range(query.range)?,
filter: store_ops_error_filter(query.filter),
cursor: query.cursor.map(store_cursor).transpose()?,
current_page: query.current_page,
page_size: ObservabilityPageSize::new(query.page_size.get())
.map_err(observability_error)?,
include_total: query.include_total,
})
}
@@ -409,20 +402,12 @@ pub(crate) fn admin_usage_page(
.into_iter()
.map(admin_usage_list_record)
.collect::<AdminStoreResult<_>>()?,
current_page: page.current_page,
page_size: page.page_size,
total: page.total,
next_cursor: page.next_cursor.map(admin_cursor),
})
}
pub(crate) fn admin_cursor(
cursor: ObservabilityCursor,
) -> admin_observability::ObservabilityCursor {
admin_observability::ObservabilityCursor {
observed_at: cursor.observed_at,
stable_id: cursor.stable_id,
}
}
pub(crate) fn admin_usage_list_record(
record: UsageListRecord,
) -> AdminStoreResult<admin_observability::UsageListRecord> {
@@ -701,8 +686,9 @@ pub(crate) fn admin_ops_error_page(
) -> AdminStoreResult<admin_observability::OpsErrorPage> {
Ok(admin_observability::OpsErrorPage {
items: page.items.into_iter().map(admin_ops_error).collect(),
current_page: page.current_page,
page_size: page.page_size,
total: page.total,
next_cursor: page.next_cursor.map(admin_cursor),
})
}
@@ -715,6 +701,10 @@ pub(crate) fn admin_ops_error(error: OpsErrorRecord) -> admin_observability::Ops
client_api_key_ref: error.client_api_key_ref,
component: error.component,
operation: error.operation,
protocol: error.protocol,
client_transport: error.client_transport,
requested_model_id: error.requested_model_id,
service_tier: error.service_tier,
endpoint: error.endpoint,
provider_kind: error.provider_kind,
provider_account_ref: error.provider_account_ref,
@@ -732,6 +722,13 @@ pub(crate) fn admin_ops_error(error: OpsErrorRecord) -> admin_observability::Ops
latency_ms: error.latency_ms,
message: error.message,
occurrence_count: error.occurrence_count,
client_ip: error.client_ip,
user_agent: error.user_agent,
reasoning_effort: error.reasoning_effort,
reasoning_preset: error.reasoning_preset,
request_kind: error.request_kind,
subagent_kind: error.subagent_kind,
compact: error.compact,
occurred_at: error.occurred_at,
stable_sort_id: error.stable_sort_id,
}
@@ -881,6 +878,10 @@ pub(crate) fn ops_error_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<Ops
client_api_key_ref: get(row, "client_api_key_ref")?,
component: get(row, "component")?,
operation: get(row, "operation")?,
protocol: get(row, "protocol")?,
client_transport: get(row, "client_transport")?,
requested_model_id: get(row, "requested_model_id")?,
service_tier: get(row, "service_tier")?,
endpoint: get(row, "endpoint")?,
provider_kind: get(row, "provider_kind")?,
provider_account_ref: get(row, "provider_account_ref")?,
@@ -898,6 +899,13 @@ pub(crate) fn ops_error_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<Ops
latency_ms: optional_unsigned(row, "latency_ms")?,
message: get(row, "message")?,
occurrence_count: to_u32(get(row, "occurrence_count")?)?,
client_ip: get(row, "client_ip")?,
user_agent: get(row, "user_agent")?,
reasoning_effort: get(row, "reasoning_effort")?,
reasoning_preset: get(row, "reasoning_preset")?,
request_kind: get(row, "request_kind")?,
subagent_kind: get(row, "subagent_kind")?,
compact: get(row, "compact")?,
occurred_at: get(row, "occurred_at")?,
stable_sort_id: get(row, "stable_sort_id")?,
})
@@ -43,21 +43,17 @@ impl ObservabilityPageSize {
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ObservabilityCursor {
pub observed_at: DateTime<Utc>,
pub stable_id: String,
}
impl ObservabilityCursor {
pub fn new(observed_at: DateTime<Utc>, stable_id: impl Into<String>) -> StoreResult<Self> {
let stable_id = stable_id.into();
validate_text(&stable_id, MAX_FILTER_BYTES, "cursor ID")?;
Ok(Self {
observed_at,
stable_id,
})
}
pub(crate) fn observability_page_offset(
current_page: u32,
page_size: ObservabilityPageSize,
) -> StoreResult<i64> {
let page_index = current_page
.checked_sub(1)
.ok_or_else(|| invalid("current page must be positive"))?;
u64::from(page_index)
.checked_mul(u64::from(page_size.get()))
.and_then(|offset| i64::try_from(offset).ok())
.ok_or_else(|| invalid("page offset is too large"))
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
@@ -119,9 +115,8 @@ impl UsageRecordFilter {
pub struct UsageRecordQuery {
pub range: ObservabilityRange,
pub filter: UsageRecordFilter,
pub cursor: Option<ObservabilityCursor>,
pub current_page: u32,
pub page_size: ObservabilityPageSize,
pub include_total: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
@@ -131,6 +126,7 @@ pub struct OpsErrorFilter {
pub provider_account_ref: Option<String>,
pub provider_kind: Option<String>,
pub operation: Option<String>,
pub endpoint: Option<String>,
pub model: Option<String>,
pub transport: Option<String>,
pub attempt_index: Option<u32>,
@@ -152,6 +148,7 @@ impl OpsErrorFilter {
),
(self.provider_kind.as_deref(), "provider filter"),
(self.operation.as_deref(), "operation filter"),
(self.endpoint.as_deref(), "endpoint filter"),
(self.model.as_deref(), "model filter"),
(self.transport.as_deref(), "transport filter"),
(
@@ -183,9 +180,8 @@ impl OpsErrorFilter {
pub struct OpsErrorQuery {
pub range: ObservabilityRange,
pub filter: OpsErrorFilter,
pub cursor: Option<ObservabilityCursor>,
pub current_page: u32,
pub page_size: ObservabilityPageSize,
pub include_total: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -609,8 +605,9 @@ pub struct UsageRecord {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageRecordPage {
pub items: Vec<UsageListRecord>,
pub total: Option<u64>,
pub next_cursor: Option<ObservabilityCursor>,
pub current_page: u32,
pub page_size: u16,
pub total: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -699,6 +696,10 @@ pub struct OpsErrorRecord {
pub client_api_key_ref: Option<String>,
pub component: String,
pub operation: String,
pub protocol: Option<String>,
pub client_transport: Option<String>,
pub requested_model_id: Option<String>,
pub service_tier: Option<String>,
pub endpoint: Option<String>,
pub provider_kind: Option<String>,
pub provider_account_ref: Option<String>,
@@ -716,6 +717,13 @@ pub struct OpsErrorRecord {
pub latency_ms: Option<u64>,
pub message: String,
pub occurrence_count: u32,
pub client_ip: Option<String>,
pub user_agent: Option<String>,
pub reasoning_effort: Option<String>,
pub reasoning_preset: Option<String>,
pub request_kind: Option<String>,
pub subagent_kind: Option<String>,
pub compact: Option<bool>,
pub occurred_at: DateTime<Utc>,
pub stable_sort_id: String,
}
@@ -723,8 +731,9 @@ pub struct OpsErrorRecord {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpsErrorPage {
pub items: Vec<OpsErrorRecord>,
pub total: Option<u64>,
pub next_cursor: Option<ObservabilityCursor>,
pub current_page: u32,
pub page_size: u16,
pub total: u64,
}
#[async_trait]
@@ -6,6 +6,7 @@ const REQUEST_ERROR_SELECT: &str = "select 'model_request'::text as source,
mr.id as event_id, mr.id as request_id,
nullif(mr.attempt_count, 0) as attempt_index,
mr.client_api_key_ref, 'model_request'::text as component, mr.operation,
mr.protocol, mr.client_transport, mr.requested_model_id, mr.service_tier,
mr.endpoint, mr.provider_kind, mr.provider_account_ref,
mr.provider_account_name_snapshot as provider_account_name,
mr.provider_account_email_snapshot as provider_account_email,
@@ -17,7 +18,11 @@ const REQUEST_ERROR_SELECT: &str = "select 'model_request'::text as source,
mr.provider_error_code, mr.client_response_id, mr.upstream_request_id,
mr.latency_ms,
coalesce(mr.error_message, mr.error_kind, 'request failed') as message,
1::integer as occurrence_count, mr.completed_at as occurred_at,
1::integer as occurrence_count,
host(mr.client_ip) as client_ip, mr.user_agent,
mr.reasoning_effort, mr.reasoning_preset, mr.request_kind,
mr.subagent_kind, mr.compact,
mr.completed_at as occurred_at,
'model_request:' || mr.id as stable_sort_id
from model_requests mr
where mr.outcome = 'failed'";
@@ -25,7 +30,8 @@ where mr.outcome = 'failed'";
const OPS_EVENT_SELECT: &str = "select 'ops_event'::text as source,
oe.id as event_id, oe.model_request_id as request_id, oe.attempt_index,
mr.client_api_key_ref, oe.component, oe.operation,
null::text as endpoint, oe.provider_kind, oe.provider_account_ref,
mr.protocol, mr.client_transport, mr.requested_model_id, mr.service_tier,
mr.endpoint, oe.provider_kind, oe.provider_account_ref,
oe.provider_account_name_snapshot as provider_account_name,
oe.provider_account_email_snapshot as provider_account_email,
oe.provider_account_authentication_kind_snapshot
@@ -33,7 +39,11 @@ const OPS_EVENT_SELECT: &str = "select 'ops_event'::text as source,
oe.upstream_model_id, null::text as upstream_transport, oe.failure_kind,
null::integer as client_status_code, oe.status_code as upstream_status_code,
oe.provider_error_code, mr.client_response_id, oe.upstream_request_id,
oe.latency_ms, oe.message, oe.occurrence_count, oe.created_at as occurred_at,
oe.latency_ms, oe.message, oe.occurrence_count,
host(mr.client_ip) as client_ip, mr.user_agent,
mr.reasoning_effort, mr.reasoning_preset, mr.request_kind,
mr.subagent_kind, mr.compact,
oe.created_at as occurred_at,
'ops_event:' || oe.id as stable_sort_id
from ops_events oe
left join model_requests mr on mr.id = oe.model_request_id
@@ -44,54 +54,32 @@ pub(crate) async fn list_ops_errors(
query: OpsErrorQuery,
) -> StoreResult<OpsErrorPage> {
query.filter.validate()?;
let total = if query.include_total {
Some(count_ops_errors(pool, query.range, &query.filter).await?)
} else {
None
};
let total = count_ops_errors(pool, query.range, &query.filter).await?;
let offset = observability_page_offset(query.current_page, query.page_size)?;
let mut statement = QueryBuilder::<Postgres>::new("select * from (");
statement.push(REQUEST_ERROR_SELECT);
push_request_error_predicates(
&mut statement,
query.range,
&query.filter,
query.cursor.as_ref(),
);
push_request_error_predicates(&mut statement, query.range, &query.filter);
statement.push(" union all ");
statement.push(OPS_EVENT_SELECT);
push_ops_event_predicates(
&mut statement,
query.range,
&query.filter,
query.cursor.as_ref(),
);
push_ops_event_predicates(&mut statement, query.range, &query.filter);
statement.push(") e order by occurred_at desc, stable_sort_id desc limit ");
statement.push_bind(i64::from(query.page_size.get()) + 1);
statement.push_bind(i64::from(query.page_size.get()));
statement.push(" offset ");
statement.push_bind(offset);
let rows = statement
.build()
.fetch_all(pool)
.await
.map_err(|_| postgres_unavailable("list ops errors"))?;
let mut items = rows
let items = rows
.iter()
.map(ops_error_from_row)
.collect::<StoreResult<Vec<_>>>()?;
let has_more = items.len() > usize::from(query.page_size.get());
if has_more {
items.pop();
}
let next_cursor = if has_more {
items
.last()
.map(|item| ObservabilityCursor::new(item.occurred_at, item.stable_sort_id.clone()))
.transpose()?
} else {
None
};
Ok(OpsErrorPage {
items,
current_page: query.current_page,
page_size: query.page_size.get(),
total,
next_cursor,
})
}
@@ -103,11 +91,11 @@ pub(crate) async fn count_ops_errors(
let mut statement = QueryBuilder::<Postgres>::new(
"select coalesce(sum(source_count), 0)::bigint from (select count(*)::bigint as source_count from model_requests mr where mr.outcome = 'failed'",
);
push_request_error_predicates(&mut statement, range, filter, None);
push_request_error_predicates(&mut statement, range, filter);
statement.push(
" union all select count(*)::bigint as source_count from ops_events oe left join model_requests mr on mr.id = oe.model_request_id where true",
);
push_ops_event_predicates(&mut statement, range, filter, None);
push_ops_event_predicates(&mut statement, range, filter);
statement.push(") counts");
let total = statement
.build_query_scalar::<i64>()
@@ -121,7 +109,6 @@ fn push_request_error_predicates(
statement: &mut QueryBuilder<Postgres>,
range: ObservabilityRange,
filter: &OpsErrorFilter,
cursor: Option<&ObservabilityCursor>,
) {
push_range(statement, "mr.completed_at", range);
for (column, value) in [
@@ -130,6 +117,7 @@ fn push_request_error_predicates(
("mr.provider_account_ref", &filter.provider_account_ref),
("mr.provider_kind", &filter.provider_kind),
("mr.operation", &filter.operation),
("mr.endpoint", &filter.endpoint),
("mr.upstream_transport", &filter.transport),
("mr.upstream_request_id", &filter.upstream_request_id),
] {
@@ -162,19 +150,12 @@ fn push_request_error_predicates(
search,
);
}
push_cursor(
statement,
"mr.completed_at",
"'model_request:' || mr.id",
cursor,
);
}
fn push_ops_event_predicates(
statement: &mut QueryBuilder<Postgres>,
range: ObservabilityRange,
filter: &OpsErrorFilter,
cursor: Option<&ObservabilityCursor>,
) {
push_range(statement, "oe.created_at", range);
for (column, value) in [
@@ -183,6 +164,7 @@ fn push_ops_event_predicates(
("oe.provider_account_ref", &filter.provider_account_ref),
("oe.provider_kind", &filter.provider_kind),
("oe.operation", &filter.operation),
("mr.endpoint", &filter.endpoint),
("oe.upstream_request_id", &filter.upstream_request_id),
("oe.failure_kind", &filter.failure_kind),
] {
@@ -217,7 +199,6 @@ fn push_ops_event_predicates(
search,
);
}
push_cursor(statement, "oe.created_at", "'ops_event:' || oe.id", cursor);
}
fn push_range(statement: &mut QueryBuilder<Postgres>, column: &str, range: ObservabilityRange) {
@@ -262,18 +243,3 @@ fn push_prefix_search(statement: &mut QueryBuilder<Postgres>, columns: &[&str],
}
statement.push(")");
}
fn push_cursor(
statement: &mut QueryBuilder<Postgres>,
occurred_at: &str,
stable_sort_id: &str,
cursor: Option<&ObservabilityCursor>,
) {
if let Some(cursor) = cursor {
statement.push(format!(" and ({occurred_at}, {stable_sort_id}) < ("));
statement.push_bind(cursor.observed_at);
statement.push(", ");
statement.push_bind(cursor.stable_id.clone());
statement.push(")");
}
}
@@ -117,7 +117,7 @@ pub(crate) const USAGE_LIST_RECORD_SELECT: &str =
mr.first_event_ms, mr.first_reasoning_ms, mr.first_text_ms, mr.first_token_ms,
mr.provider_processing_ms, mr.latency_ms, mr.admission_decision_ms,
mr.account_selection_wait_ms, mr.capacity_used_slots, mr.capacity_total_slots,
mr.client_ip::text as client_ip, mr.user_agent,
host(mr.client_ip) as client_ip, mr.user_agent,
mr.reasoning_effort, mr.reasoning_preset, mr.subagent_kind, mr.compact,
mr.started_at
from model_requests mr";
@@ -145,7 +145,7 @@ pub(crate) const USAGE_RECORD_DETAIL_SELECT: &str =
mr.first_event_ms, mr.first_reasoning_ms, mr.first_text_ms, mr.first_token_ms,
mr.provider_processing_ms, mr.latency_ms, mr.admission_decision_ms,
mr.account_selection_wait_ms, mr.capacity_used_slots, mr.capacity_total_slots,
mr.client_ip::text as client_ip,
host(mr.client_ip) as client_ip,
mr.user_agent, mr.reasoning_effort, mr.reasoning_preset, mr.request_kind,
mr.subagent_kind, mr.compact, mr.image_generation_requested,
mr.image_generation_succeeded, mr.started_at, mr.deadline_at, mr.completed_at
@@ -156,11 +156,22 @@ pub(crate) async fn list_usage_records(
query: UsageRecordQuery,
) -> StoreResult<UsageRecordPage> {
query.filter.validate()?;
let total = if query.include_total {
Some(count_usage_records(pool, query.range, &query.filter).await?)
} else {
None
};
let total = count_usage_records(pool, query.range, &query.filter).await?;
let items = list_usage_record_items(pool, &query).await?;
Ok(UsageRecordPage {
items,
current_page: query.current_page,
page_size: query.page_size.get(),
total,
})
}
pub(crate) async fn list_usage_record_items(
pool: &PgPool,
query: &UsageRecordQuery,
) -> StoreResult<Vec<UsageListRecord>> {
query.filter.validate()?;
let offset = observability_page_offset(query.current_page, query.page_size)?;
let mut statement = QueryBuilder::<Postgres>::new(USAGE_LIST_RECORD_SELECT);
statement.push(" where mr.started_at >= ");
statement.push_bind(query.range.start);
@@ -168,41 +179,20 @@ pub(crate) async fn list_usage_records(
statement.push_bind(query.range.end);
push_completed_usage_fact_filter(&mut statement, "mr");
push_usage_filter(&mut statement, &query.filter, "mr");
if let Some(cursor) = &query.cursor {
statement.push(" and (mr.started_at, mr.id) < (");
statement.push_bind(cursor.observed_at);
statement.push(", ");
statement.push_bind(cursor.stable_id.clone());
statement.push(")");
}
statement.push(" order by mr.started_at desc, mr.id desc limit ");
statement.push_bind(i64::from(query.page_size.get()) + 1);
statement.push_bind(i64::from(query.page_size.get()));
statement.push(" offset ");
statement.push_bind(offset);
let rows = statement
.build()
.fetch_all(pool)
.await
.map_err(|_| postgres_unavailable("list usage records"))?;
let mut items = rows
let items = rows
.iter()
.map(usage_list_record_from_row)
.collect::<StoreResult<Vec<_>>>()?;
let has_more = items.len() > usize::from(query.page_size.get());
if has_more {
items.pop();
}
let next_cursor = if has_more {
items
.last()
.map(|item| ObservabilityCursor::new(item.started_at, item.id.clone()))
.transpose()?
} else {
None
};
Ok(UsageRecordPage {
items,
total,
next_cursor,
})
Ok(items)
}
pub(crate) async fn count_usage_records(
@@ -106,13 +106,12 @@ async fn observability_preserves_and_filters_opaque_response_ids() {
response_id: Some(response_id.clone()),
..UsageRecordFilter::default()
},
cursor: None,
current_page: 1,
page_size: ObservabilityPageSize::new(10).expect("page size"),
include_total: true,
})
.await
.expect("filter opaque response ID");
assert_eq!(records.total, Some(1));
assert_eq!(records.total, 1);
assert_eq!(records.items[0].id, "req_observe_success");
let detail = repository
.usage_record_detail("req_observe_success")
@@ -131,8 +130,8 @@ async fn observability_preserves_and_filters_opaque_response_ids() {
}
#[tokio::test]
async fn usage_page_should_skip_exact_count_when_total_is_not_requested() {
let Some(database) = TestDatabase::create("usage_page_without_total").await else {
async fn usage_page_should_always_return_total() {
let Some(database) = TestDatabase::create("usage_page_with_total").await else {
return;
};
let now = Utc::now();
@@ -146,14 +145,15 @@ async fn usage_page_should_skip_exact_count_when_total_is_not_requested() {
.list_usage_records(UsageRecordQuery {
range,
filter: UsageRecordFilter::default(),
cursor: None,
current_page: 1,
page_size: ObservabilityPageSize::new(10).expect("page size"),
include_total: false,
})
.await
.expect("usage page without total");
.expect("usage page with total");
assert_eq!(page.total, None);
assert_eq!(page.total, 1);
assert_eq!(page.current_page, 1);
assert_eq!(page.page_size, 10);
database.close().await;
}
@@ -176,14 +176,13 @@ async fn usage_search_should_match_literal_prefix_instead_of_substring() {
search: Some("observe_success".to_owned()),
..UsageRecordFilter::default()
},
cursor: None,
current_page: 1,
page_size: ObservabilityPageSize::new(10).expect("page size"),
include_total: true,
})
.await
.expect("usage substring search");
assert_eq!(page.total, Some(0));
assert_eq!(page.total, 0);
database.close().await;
}
@@ -206,14 +205,13 @@ async fn ops_search_should_treat_sql_wildcards_as_literals() {
search: Some("req%".to_owned()),
..OpsErrorFilter::default()
},
cursor: None,
current_page: 1,
page_size: ObservabilityPageSize::new(10).expect("page size"),
include_total: true,
})
.await
.expect("ops literal wildcard search");
assert_eq!(page.total, Some(0));
assert_eq!(page.total, 0);
database.close().await;
}
@@ -625,20 +623,34 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
.list_usage_records(admin_observability::UsageQuery {
range,
filter: admin_observability::UsageFilter::default(),
cursor: None,
current_page: 1,
page_size: PageSize::new(1).expect("page size"),
include_total: true,
})
.await
.expect("first usage page");
assert_eq!(first_page.total, Some(1));
assert_eq!(first_page.total, 1);
assert_eq!(first_page.items.len(), 1);
assert!(first_page.next_cursor.is_none());
assert_eq!(first_page.current_page, 1);
assert_eq!(first_page.page_size, 1);
assert_eq!(
first_page.items[0].service_tier.as_deref(),
Some("priority")
);
let deep_page = store
.list_usage_records(admin_observability::UsageQuery {
range,
filter: admin_observability::UsageFilter::default(),
current_page: 129,
page_size: PageSize::new(1).expect("page size"),
})
.await
.expect("direct deep usage page");
assert_eq!(deep_page.current_page, 129);
assert_eq!(deep_page.page_size, 1);
assert_eq!(deep_page.total, 1);
assert!(deep_page.items.is_empty());
let filtered = store
.list_usage_records(admin_observability::UsageQuery {
range,
@@ -657,13 +669,12 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
upstream_request_id: Some("upstream_req_success".to_owned()),
search: Some("req_observe_success".to_owned()),
},
cursor: None,
current_page: 1,
page_size: PageSize::new(10).expect("page size"),
include_total: true,
})
.await
.expect("fully filtered usage page");
assert_eq!(filtered.total, Some(1));
assert_eq!(filtered.total, 1);
assert_eq!(filtered.items[0].id, "req_observe_success");
assert_eq!(filtered.items[0].service_tier.as_deref(), Some("priority"));
@@ -677,13 +688,12 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
),
..admin_observability::UsageFilter::default()
},
cursor: None,
current_page: 1,
page_size: PageSize::new(10).expect("page size"),
include_total: true,
})
.await
.expect("other outcome filter should reach PostgreSQL");
assert_eq!(other_outcome.total, Some(0));
assert_eq!(other_outcome.total, 0);
assert!(other_outcome.items.is_empty());
let detail = store
@@ -751,21 +761,36 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
request_id: Some("req_observe_failed".to_owned()),
provider_kind: Some("openai".to_owned()),
provider_account_ref: Some("acct_observe".to_owned()),
operation: Some("responses".to_owned()),
endpoint: Some("/v1/responses".to_owned()),
model: Some("upstream-model".to_owned()),
failure_kind: Some("rate_limited".to_owned()),
status_code: Some(429),
search: Some("req_observe_failed".to_owned()),
..admin_observability::OpsErrorFilter::default()
},
cursor: None,
current_page: 1,
page_size: PageSize::new(10).expect("page size"),
include_total: true,
})
.await
.expect("admin ops errors");
assert_eq!(errors.total, Some(2));
assert_eq!(errors.total, 2);
assert!(errors.items.iter().all(|item| item.occurred_at <= now));
let deep_errors = store
.list_ops_errors(admin_observability::OpsErrorQuery {
range,
filter: admin_observability::OpsErrorFilter::default(),
current_page: 129,
page_size: PageSize::new(1).expect("page size"),
})
.await
.expect("direct deep ops page");
assert_eq!(deep_errors.current_page, 129);
assert_eq!(deep_errors.page_size, 1);
assert_eq!(deep_errors.total, 2);
assert!(deep_errors.items.is_empty());
for filter in [
admin_observability::OpsErrorFilter {
client_api_key_ref: Some("missing-key".to_owned()),
@@ -796,13 +821,12 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
.list_ops_errors(admin_observability::OpsErrorQuery {
range,
filter,
cursor: None,
current_page: 1,
page_size: PageSize::new(10).expect("page size"),
include_total: true,
})
.await
.expect("fully forwarded ops filter");
assert_eq!(page.total, Some(0));
assert_eq!(page.total, 0);
}
database.close().await;
@@ -1052,13 +1076,12 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
provider_account_ref: Some("acct_observe".to_owned()),
..UsageRecordFilter::default()
},
cursor: None,
current_page: 1,
page_size: ObservabilityPageSize::new(10).expect("page size"),
include_total: true,
})
.await
.expect("usage records");
assert_eq!(usage_page.total, Some(1));
assert_eq!(usage_page.total, 1);
let successful_image = usage_page
.items
.iter()
@@ -1157,13 +1180,12 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
.list_ops_errors(OpsErrorQuery {
range,
filter: OpsErrorFilter::default(),
cursor: None,
current_page: 1,
page_size: ObservabilityPageSize::new(10).expect("page size"),
include_total: true,
})
.await
.expect("ops errors");
assert_eq!(errors.total, Some(2));
assert_eq!(errors.total, 2);
let request_error = errors
.items
.iter()
@@ -1171,6 +1193,15 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
.expect("request error");
assert_eq!(request_error.client_status_code, Some(502));
assert_eq!(request_error.upstream_status_code, Some(429));
assert_eq!(request_error.client_ip.as_deref(), Some("203.0.113.9"));
assert_eq!(
request_error.user_agent.as_deref(),
Some("codex-cli/0.144.0")
);
assert_eq!(
request_error.requested_model_id.as_deref(),
Some("public-model")
);
let attempt_error = errors
.items
.iter()
@@ -1178,6 +1209,12 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
.expect("attempt error");
assert_eq!(attempt_error.client_status_code, None);
assert_eq!(attempt_error.upstream_status_code, Some(429));
assert_eq!(attempt_error.endpoint.as_deref(), Some("/v1/responses"));
assert_eq!(attempt_error.client_ip.as_deref(), Some("203.0.113.9"));
assert_eq!(
attempt_error.user_agent.as_deref(),
Some("codex-cli/0.144.0")
);
database.close().await;
}
@@ -1266,7 +1303,7 @@ async fn seed_observability_facts(
sqlx::query(
"insert into model_requests (
id, client_api_key_ref, config_revision, protocol, operation, endpoint,
client_transport, requested_model_id,
client_transport, requested_model_id, service_tier,
provider_kind, provider_account_id,
provider_account_ref, upstream_model_id, upstream_transport, attempt_count,
provider_account_name_snapshot, provider_account_email_snapshot,
@@ -1275,16 +1312,20 @@ async fn seed_observability_facts(
error_kind, provider_error_code, error_message, retry_after_ms,
input_tokens, cached_tokens, image_generation_requested,
image_generation_succeeded, cost_source, latency_ms,
client_ip, user_agent, reasoning_effort, reasoning_preset,
request_kind, subagent_kind, compact,
started_at, deadline_at, completed_at,
routing_scope, routing_group_refs, routing_group_names_snapshot
) values (
'req_observe_failed', 'key_observe', 1, 'openai', 'responses', '/v1/responses',
'http_sse', 'public-model', 'openai', 'acct_observe',
'http_sse', 'public-model', 'priority', 'openai', 'acct_observe',
'acct_observe', 'upstream-model',
'http_sse', 2,
'primary', 'account@example.invalid', 'oauth',
'sent', 'failed', 502, 429, 'rate_limited', 'rate_limit',
'upstream limited', 1000, 0, 0, true, false, 'unavailable', 700,
'203.0.113.9', 'codex-cli/0.144.0', 'medium', null,
'root', null, false,
$1 - interval '10 minutes', $1 + interval '20 minutes', $1 - interval '9 minutes',
'all', '{}'::text[], '[]'::jsonb
)",
@@ -60,7 +60,7 @@ async fn completed_usage_projections_should_accept_statusless_websocket_but_reje
assert_eq!(
(page.total, request_ids),
(Some(1), vec!["req_statusless_websocket"])
(1, vec!["req_statusless_websocket"])
);
let overview = repository
@@ -399,9 +399,8 @@ async fn ops_errors_should_keep_request_and_event_snapshots_after_account_deleti
.list_ops_errors(OpsErrorQuery {
range: range_around(started_at),
filter: OpsErrorFilter::default(),
cursor: None,
current_page: 1,
page_size: ObservabilityPageSize::new(10).expect("page size"),
include_total: true,
})
.await
.expect("list ops errors after deletion");
@@ -886,9 +885,8 @@ fn usage_query(started_at: DateTime<Utc>, filter: UsageRecordFilter) -> UsageRec
UsageRecordQuery {
range: range_around(started_at),
filter,
cursor: None,
current_page: 1,
page_size: ObservabilityPageSize::new(10).expect("page size"),
include_total: true,
}
}
+46 -8
View File
@@ -5,6 +5,7 @@ use std::time::SystemTime;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use futures::TryStreamExt as _;
use gateway_admin::model::accounts::AccountRecord;
use gateway_admin::model::observability::{
CalculatedBillingBreakdown, CurrencyCost, DashboardDesktopRelease, DashboardWireAttribute,
@@ -19,9 +20,10 @@ use gateway_admin::model::provider_credentials::{
PreparedCredentialCreate, PreparedCredentialImport, PreparedCredentialRotation,
PreparedCredentialRotationFacts, ProviderDocument, ProviderExport,
ProviderExportCredentialInput, ProviderModel, ProviderModels, ProviderProfileActivityInsights,
ProviderProfileDailyUsage, ProviderProfileInvocation, ProviderProfileStatistics,
ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaRequest, ProviderQuotaWindow,
ProviderQuotaWindowRole, ProviderResetCredit, ProviderResetCreditResult, ProviderResetCredits,
ProviderProfileAvatar, ProviderProfileAvatarStreamError, ProviderProfileDailyUsage,
ProviderProfileInvocation, ProviderProfileStatistics, ProviderProfileStatisticsSummary,
ProviderQuota, ProviderQuotaRequest, ProviderQuotaWindow, ProviderQuotaWindowRole,
ProviderResetCredit, ProviderResetCreditResult, ProviderResetCredits,
QuotaLocalUsageAttribution,
};
use gateway_admin::model::{MutationActor, MutationContext, Revision};
@@ -49,10 +51,10 @@ use crate::credential::{
CodexCredentialAdminService, CodexCredentialCatalogError, CodexCredentialCatalogService,
CodexCredentialProfileService, CodexCredentialQuotaError, CodexCredentialQuotaService,
CodexOAuthAdmin, CodexOAuthAdminError, CodexOAuthPendingClaimOutcome, CodexOAuthPendingStore,
CodexOAuthPendingStoreError, CodexPendingAuthorization, CodexProfileStatisticsError,
CodexQuotaWindow, CodexQuotaWindowKind, CodexQuotaWindowRole, CodexResetCreditsError,
CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential, ExportManagedCodexCredential,
StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
CodexOAuthPendingStoreError, CodexPendingAuthorization, CodexProfileAvatarError,
CodexProfileStatisticsError, CodexQuotaWindow, CodexQuotaWindowKind, CodexQuotaWindowRole,
CodexResetCreditsError, CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential,
ExportManagedCodexCredential, StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
};
use crate::credential::{
CodexCredentialCodec, CodexOAuthSecret, oauth_owner_ref, parse_access_token_expiration,
@@ -61,7 +63,9 @@ use crate::transport::CodexWebSocketPool;
use crate::transport::profile::{
CodexDesktopReleaseSnapshot, CodexDesktopReleaseStatus, CodexWireProfile, CodexWireProfileState,
};
use crate::transport::{CodexProfileStatistics, OpenAiBillingUsage, openai_billing_breakdown};
use crate::transport::{
CodexProfileAvatar, CodexProfileStatistics, OpenAiBillingUsage, openai_billing_breakdown,
};
const PROVIDER_NAME: &str = "openai";
const PENDING_DOCUMENT_SCHEMA_VERSION: u64 = 3;
@@ -462,6 +466,18 @@ impl ProviderAdmin for OpenAiAdminProvider {
Ok(project_profile_statistics(statistics))
}
async fn profile_avatar(
&self,
account_id: &ProviderAccountId,
) -> Result<ProviderProfileAvatar, ProviderAdminError> {
self.account(account_id).await?;
self.profile_statistics
.profile_avatar(account_id)
.await
.map(project_profile_avatar)
.map_err(map_profile_avatar_error)
}
async fn reset_credits(
&self,
account_id: &ProviderAccountId,
@@ -865,6 +881,15 @@ fn project_profile_statistics(statistics: CodexProfileStatistics) -> ProviderPro
}
}
fn project_profile_avatar(avatar: CodexProfileAvatar) -> ProviderProfileAvatar {
ProviderProfileAvatar {
content_type: avatar.content_type,
content_length: avatar.content_length,
etag: avatar.etag,
body: Box::pin(avatar.body.map_err(|_| ProviderProfileAvatarStreamError)),
}
}
fn project_reset_credits(
credits: crate::transport::CodexRateLimitResetCredits,
) -> ProviderResetCredits {
@@ -1498,6 +1523,19 @@ fn map_profile_statistics_error(error: CodexProfileStatisticsError) -> ProviderA
}
}
fn map_profile_avatar_error(error: CodexProfileAvatarError) -> ProviderAdminError {
match error {
CodexProfileAvatarError::ProfileStatistics(error) => map_profile_statistics_error(error),
CodexProfileAvatarError::Missing => provider_admin_error(ProviderAdminErrorKind::NotFound),
CodexProfileAvatarError::InvalidSource | CodexProfileAvatarError::Upstream { .. } => {
provider_admin_error(ProviderAdminErrorKind::BadGateway)
}
CodexProfileAvatarError::TransportUnavailable => {
provider_admin_error(ProviderAdminErrorKind::Unavailable)
}
}
}
fn map_reset_credits_error(error: CodexResetCreditsError) -> ProviderAdminError {
use CodexResetCreditsError as Error;
@@ -38,7 +38,9 @@ pub use oauth::{
CodexPendingAuthorization, CompleteCodexOAuthAuthorization, CompletedCodexOAuthAuthorization,
CompletedCodexOAuthCredential, StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
};
pub use profile_statistics::{CodexCredentialProfileService, CodexProfileStatisticsError};
pub use profile_statistics::{
CodexCredentialProfileService, CodexProfileAvatarError, CodexProfileStatisticsError,
};
pub use quota::{
CodexAccountQuotaSnapshot, CodexCredentialQuotaError, CodexCredentialQuotaService,
CodexQuotaFact, CodexQuotaRefreshPolicy, CodexQuotaSyncSummary, CodexQuotaWindow,
@@ -1,6 +1,10 @@
//! OpenAI 当前账号个人资料统计查询编排。
//! OpenAI 当前账号个人资料统计与官方头像查询编排。
use std::time::SystemTime;
use std::{
collections::HashMap,
sync::Mutex,
time::{Duration, Instant, SystemTime},
};
use gateway_core::engine::credential::ProviderAccountId;
use reqwest::Client;
@@ -10,11 +14,14 @@ use uuid::Uuid;
use crate::transport::profile::CodexWireProfileState;
use crate::transport::{
CodexBackendClient, CodexClientError, CodexProfileStatistics, CodexRequestContext,
CodexBackendClient, CodexClientError, CodexProfileAvatar, CodexProfileAvatarFetchError,
CodexProfileStatistics, CodexRequestContext, fetch_profile_avatar,
};
use super::CodexCredentialRepository;
const PROFILE_AVATAR_SOURCE_TTL: Duration = Duration::from_secs(10 * 60);
#[derive(Error)]
pub enum CodexProfileStatisticsError {
#[error("Codex profile-statistics credential data is invalid")]
@@ -59,16 +66,55 @@ impl std::fmt::Debug for CodexProfileStatisticsError {
}
}
#[derive(Error)]
pub enum CodexProfileAvatarError {
#[error(transparent)]
ProfileStatistics(#[from] CodexProfileStatisticsError),
#[error("Codex profile does not provide an avatar")]
Missing,
#[error("Codex profile avatar source is invalid")]
InvalidSource,
#[error("Codex profile avatar upstream returned HTTP {status}")]
Upstream { status: u16 },
#[error("Codex profile avatar transport is unavailable")]
TransportUnavailable,
}
impl std::fmt::Debug for CodexProfileAvatarError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ProfileStatistics(error) => formatter
.debug_tuple("ProfileStatistics")
.field(error)
.finish(),
Self::Missing => formatter.write_str("Missing"),
Self::InvalidSource => formatter.write_str("InvalidSource"),
Self::Upstream { status } => formatter
.debug_struct("Upstream")
.field("status", status)
.finish(),
Self::TransportUnavailable => formatter.write_str("TransportUnavailable"),
}
}
}
#[derive(Clone)]
struct CachedProfileAvatarSource {
source: String,
expires_at: Instant,
}
pub struct CodexCredentialProfileService {
repository: CodexCredentialRepository,
profile: CodexWireProfileState,
http: Client,
base_url: String,
avatar_sources: Mutex<HashMap<ProviderAccountId, CachedProfileAvatarSource>>,
}
impl CodexCredentialProfileService {
#[must_use]
pub const fn new(
pub fn new(
repository: CodexCredentialRepository,
profile: CodexWireProfileState,
http: Client,
@@ -79,6 +125,7 @@ impl CodexCredentialProfileService {
profile,
http,
base_url,
avatar_sources: Mutex::new(HashMap::new()),
}
}
@@ -114,7 +161,7 @@ impl CodexCredentialProfileService {
.authorization_header()
.map_err(|_| CodexProfileStatisticsError::InvalidCredentialData)?;
let request_id = format!("profile_statistics_{}", Uuid::now_v7().simple());
CodexBackendClient::new(
let statistics = CodexBackendClient::new(
self.http.clone(),
self.base_url.clone(),
self.profile.clone(),
@@ -126,7 +173,72 @@ impl CodexCredentialProfileService {
None,
))
.await
.map_err(map_client_error)
.map_err(map_client_error)?;
self.remember_avatar_source(account_id, statistics.image_url.as_deref());
Ok(statistics)
}
pub async fn profile_avatar(
&self,
account_id: &ProviderAccountId,
) -> Result<CodexProfileAvatar, CodexProfileAvatarError> {
let source = match self.cached_avatar_source(account_id) {
Some(source) => source,
None => self
.profile_statistics(account_id)
.await?
.image_url
.ok_or(CodexProfileAvatarError::Missing)?,
};
let desktop_user_agent = self.profile.snapshot().desktop_user_agent();
fetch_profile_avatar(&self.http, &self.base_url, &desktop_user_agent, &source)
.await
.map_err(map_avatar_fetch_error)
}
fn cached_avatar_source(&self, account_id: &ProviderAccountId) -> Option<String> {
let now = Instant::now();
let mut sources = self
.avatar_sources
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
sources.retain(|_, source| source.expires_at > now);
sources.get(account_id).map(|source| source.source.clone())
}
fn remember_avatar_source(&self, account_id: &ProviderAccountId, source: Option<&str>) {
let now = Instant::now();
let mut sources = self
.avatar_sources
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
sources.retain(|_, source| source.expires_at > now);
match source {
Some(source) => {
sources.insert(
account_id.clone(),
CachedProfileAvatarSource {
source: source.to_owned(),
expires_at: now + PROFILE_AVATAR_SOURCE_TTL,
},
);
}
None => {
sources.remove(account_id);
}
}
}
}
fn map_avatar_fetch_error(error: CodexProfileAvatarFetchError) -> CodexProfileAvatarError {
match error {
CodexProfileAvatarFetchError::InvalidSource => CodexProfileAvatarError::InvalidSource,
CodexProfileAvatarFetchError::Upstream { status } => {
CodexProfileAvatarError::Upstream { status }
}
CodexProfileAvatarFetchError::TransportUnavailable => {
CodexProfileAvatarError::TransportUnavailable
}
}
}
+203 -8
View File
@@ -16,7 +16,7 @@ use gateway_core::engine::provider::{
ProviderSelectionObservation, ProviderStream, UpstreamTransport,
};
use gateway_core::engine::{
AttemptContext, CancellationToken, ContinuationAttempt, UpstreamSendState,
AttemptContext, AttemptTransport, CancellationToken, ContinuationAttempt, UpstreamSendState,
};
use gateway_core::error::{
ClientVisibleUpstreamError, ClientVisibleUpstreamResponse, ContinuationFailure,
@@ -47,6 +47,7 @@ use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use url::Url;
use uuid::Uuid;
use crate::credential::{
CodexAccountFailure, CodexCredentialCatalogError, CodexCredentialCatalogService,
@@ -69,10 +70,8 @@ use crate::transport::profile::{
APPCAST_POLL_INTERVAL, CodexDesktopReleaseService, CodexWireProfileState,
};
use crate::transport::protocol::responses::{
CodexResponsesRequest, PreviousResponseScope, ResponseEventSignals,
};
use crate::transport::protocol::websocket::{
PREVIOUS_RESPONSE_NOT_FOUND_CODE, PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE,
CodexResponsesRequest, PREVIOUS_RESPONSE_NOT_FOUND_CODE, PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE,
PreviousResponseScope, ResponseEventSignals, transport_requirement,
};
use crate::transport::request::{
CodexRequestEncodeError, RequestAccountScope, encode_generate_request, scope_request_to_account,
@@ -289,7 +288,10 @@ impl Provider for CodexProvider {
derive_codex_session_affinity(&upstream_request, context.client_api_key_ref());
let cyber_policy_session_key =
derive_codex_cyber_policy_session_key(&upstream_request, context.client_api_key_ref());
let transport = selected_transport(&upstream_request);
let transport = match context.transport() {
AttemptTransport::Default => selected_transport(&upstream_request),
AttemptTransport::Fallback => CodexProviderTransport::HttpOnly,
};
apply_transport(&mut upstream_request, transport);
let selection_started_at = Instant::now();
@@ -753,6 +755,12 @@ fn cold_json_response_stream(request: ColdJsonResponse) -> EventStream {
account_selection,
)
.await;
if let Err(CodexHandshakeAttemptError::Client(error)) = &response {
log_client_upstream_error(
UpstreamErrorLogContext::new(&request.context, &active_account, None),
error,
);
}
let response = match response.map_err(map_handshake_attempt_error) {
Ok(response) => response,
Err(mut failure) => {
@@ -879,6 +887,12 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
&cancellation,
)
.await;
if let Err(CodexHandshakeAttemptError::Client(error)) = &response {
log_client_upstream_error(
UpstreamErrorLogContext::new(&context, &active_account, None),
error,
);
}
let response = response.map_err(map_handshake_attempt_error);
let response = match response {
Ok(response) => response,
@@ -946,6 +960,8 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
{
active_account = current;
}
let response_transport = response.transport;
let websocket_connection_id = response.websocket_connection_id;
let mut body = response.body;
let failure_diagnostics = response.diagnostics.clone();
let failure_set_cookie_headers = response.set_cookie_headers.clone();
@@ -990,7 +1006,17 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
_ = wait_for_replay_grace(replay_grace_deadline) => Ok(PreCommitPoll::GraceElapsed),
chunk = body.next() => match chunk {
Some(Ok(chunk)) => Ok(PreCommitPoll::Upstream(Some(chunk))),
Some(Err(error)) => Err(map_stream_error(error)),
Some(Err(error)) => {
log_client_upstream_error(
UpstreamErrorLogContext::new(
&context,
&active_account,
websocket_connection_id,
),
&error,
);
Err(map_stream_error(error))
}
None => Ok(PreCommitPoll::Upstream(None)),
},
};
@@ -1002,7 +1028,14 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
}
continue;
}
Err(failure) => {
Err(mut failure) => {
if failure.error.allows_pre_delivery_retry()
&& response_transport == CodexBackendTransport::WebSocket
&& transport_requirement(&request).allows_pre_delivery_http_fallback()
&& !pre_commit_events.is_committed()
{
failure.error = failure.error.with_pre_delivery_transport_fallback();
}
let updates = take_rate_limit_updates(rate_limit_updates.as_ref()).await;
if !updates.is_empty() {
passive_quota_observation.observe(&updates);
@@ -1059,6 +1092,15 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
let service_tier_changed = observation_state
.observe_upstream_service_tier(decoder.response_service_tier());
let terminal_failure = canonical_failure.map(|(error, semantic_output_seen)| {
log_canonical_upstream_error(
UpstreamErrorLogContext::new(
&context,
&active_account,
websocket_connection_id,
),
response_transport,
&error,
);
let atomic_upstream_failure = matches!(&error, CodexCanonicalError::Upstream(_));
(
map_canonical_error(
@@ -1150,6 +1192,15 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
}
};
let terminal_failure = canonical_failure.map(|(error, semantic_output_seen)| {
log_canonical_upstream_error(
UpstreamErrorLogContext::new(
&context,
&active_account,
websocket_connection_id,
),
response_transport,
&error,
);
let atomic_upstream_failure = matches!(&error, CodexCanonicalError::Upstream(_));
(
map_canonical_error(
@@ -2196,6 +2247,141 @@ struct OpenAiFailureContext<'a> {
allows_account_state_mutation: bool,
}
#[derive(Clone, Copy)]
struct UpstreamErrorLogContext<'a> {
request_id: &'a str,
account_id: &'a str,
attempt_index: u32,
websocket_connection_id: Option<Uuid>,
}
impl<'a> UpstreamErrorLogContext<'a> {
fn new(
context: &'a AttemptContext,
account: &'a ProviderAccount,
websocket_connection_id: Option<Uuid>,
) -> Self {
Self {
request_id: context.request_id().as_str(),
account_id: account.id().as_str(),
attempt_index: context.attempt_index().get(),
websocket_connection_id,
}
}
fn with_websocket_connection_id(mut self, connection_id: Option<Uuid>) -> Self {
self.websocket_connection_id = self.websocket_connection_id.or(connection_id);
self
}
}
fn log_client_upstream_error(context: UpstreamErrorLogContext<'_>, error: &CodexClientError) {
match error {
CodexClientError::Upstream {
status,
body,
transport,
..
} => log_raw_upstream_body(
context,
*transport,
"http_error_response",
Some(status.as_u16()),
None,
None,
body,
),
CodexClientError::WebSocket(CodexWebSocketExchangeError::Upstream(upstream)) => {
log_raw_upstream_body(
context,
CodexBackendTransport::WebSocket,
"websocket_opening_response",
Some(upstream.status_code),
None,
None,
&upstream.body,
);
}
CodexClientError::WebSocket(error) => {
let Some(close) = error.close_before_terminal() else {
return;
};
let context = context.with_websocket_connection_id(close.connection_id());
let websocket_connection_id = context
.websocket_connection_id
.map(|connection_id| connection_id.to_string())
.unwrap_or_default();
let upstream_error_raw = close.reason().unwrap_or_default();
tracing::warn!(
request_id = %context.request_id,
account_id = %context.account_id,
attempt_index = context.attempt_index,
websocket_connection_id,
upstream_transport = WEBSOCKET_TRANSPORT,
upstream_error_kind = "websocket_close",
upstream_close_code = close.code().unwrap_or_default(),
upstream_close_code_present = close.code().is_some(),
upstream_error_raw,
upstream_error_raw_bytes = upstream_error_raw.len(),
upstream_error_raw_present = close.reason().is_some(),
"OpenAI upstream WebSocket closed before terminal event"
);
}
_ => {}
}
}
fn log_canonical_upstream_error(
context: UpstreamErrorLogContext<'_>,
transport: CodexBackendTransport,
error: &CodexCanonicalError,
) {
let CodexCanonicalError::Upstream(failure) = error else {
return;
};
log_raw_upstream_body(
context,
transport,
"responses_error_event",
failure.explicit_status_code,
failure.upstream_code.as_deref(),
failure.upstream_type.as_deref(),
failure.raw_body(),
);
}
fn log_raw_upstream_body(
context: UpstreamErrorLogContext<'_>,
transport: CodexBackendTransport,
error_kind: &'static str,
status_code: Option<u16>,
upstream_code: Option<&str>,
upstream_type: Option<&str>,
upstream_error_raw: &str,
) {
let websocket_connection_id = context
.websocket_connection_id
.map(|connection_id| connection_id.to_string())
.unwrap_or_default();
tracing::warn!(
request_id = %context.request_id,
account_id = %context.account_id,
attempt_index = context.attempt_index,
websocket_connection_id,
upstream_transport = actual_transport_name(transport),
upstream_error_kind = error_kind,
upstream_status_code = status_code.unwrap_or_default(),
upstream_status_code_present = status_code.is_some(),
upstream_error_code = upstream_code.unwrap_or_default(),
upstream_error_code_present = upstream_code.is_some(),
upstream_error_type = upstream_type.unwrap_or_default(),
upstream_error_type_present = upstream_type.is_some(),
upstream_error_raw,
upstream_error_raw_bytes = upstream_error_raw.len(),
"OpenAI upstream returned an error payload"
);
}
async fn apply_failure(
context: &OpenAiFailureContext<'_>,
account: &ProviderAccount,
@@ -2417,11 +2603,20 @@ fn map_client_error(
))
}
CodexClientError::WebSocket(error) => {
let close_code = error.close_before_terminal().and_then(|close| close.code());
let client_visible_error = websocket_client_visible_error(&error);
let mut failure = MappedProviderFailure::plain(provider_error(
websocket_error_kind(&error),
websocket_send_state(&error),
));
if let Some(close_code) = close_code {
failure.error =
failure
.error
.with_upstream_code(OpaqueUpstreamValue::new(format!(
"websocket_close_{close_code}"
)));
}
if let Some(client_visible_error) = client_visible_error {
failure.error = failure
.error
@@ -59,7 +59,7 @@ pub enum CodexCanonicalError {
Protocol(#[source] ProviderError),
/// 上游在成功建立流后发送了明确的失败事件。
#[error("Codex upstream reported a failed response")]
Upstream(ResponsesSseFailure),
Upstream(Box<ResponsesSseFailure>),
}
impl fmt::Debug for CodexCanonicalError {
@@ -291,7 +291,11 @@ impl CodexCanonicalDecoder {
let signals = response_event_signals(event_type, &value);
self.merge_timing_signals(signals);
if matches!(event_type, Some("response.failed" | "error")) {
let failure = ResponsesSseFailure::from_event(event_type.unwrap_or_default(), &value);
let failure = ResponsesSseFailure::from_raw_event(
event_type.unwrap_or_default(),
&event.data,
&value,
);
let mut canonical = Vec::new();
if !self.started {
// 失败首帧可能是唯一携带 response_id 的上游事实。身份投影只做
@@ -307,7 +311,7 @@ impl CodexCanonicalDecoder {
} else {
output.extend(canonical.into_iter().map(ProviderEvent::canonical));
}
return Err(CodexCanonicalError::Upstream(failure));
return Err(CodexCanonicalError::Upstream(Box::new(failure)));
}
let mut canonical = Vec::new();
if let Some(event_type) = event_type {
@@ -22,6 +22,7 @@ use reqwest::{
};
use serde_json::{Value, map::Map};
use thiserror::Error;
use uuid::Uuid;
use crate::transport::protocol::responses::{CodexResponsesRequest, TransportRequirement};
@@ -538,6 +539,8 @@ pub struct CodexBackendStreamingResponse {
pub body: CodexBackendSseStream,
/// 实际使用的上游传输。
pub transport: CodexBackendTransport,
/// WebSocket 响应所绑定的连接;HTTP transport 为 `None`。
pub websocket_connection_id: Option<Uuid>,
/// 响应头里的最新 turn state。
pub turn_state: Option<String>,
/// 上游透传的 `set-cookie` 列表。
@@ -151,6 +151,7 @@ impl CodexBackendClient {
Ok(CodexBackendStreamingResponse {
body: http_sse_stream(response, Arc::clone(&rate_limit_updates)),
transport: CodexBackendTransport::HttpSse,
websocket_connection_id: None,
turn_state,
set_cookie_headers,
rate_limit_headers,
@@ -387,7 +388,20 @@ impl CodexBackendClient {
loop {
match await_websocket_delivery_boundary(&mut exchange).await {
Ok(DeliveryBoundary::Ready) => break,
Ok(DeliveryBoundary::ConnectionLimitReached { message }) => {
Ok(DeliveryBoundary::ConnectionLimitReached {
message,
raw_frame,
}) => {
let upstream_error_raw = String::from_utf8_lossy(&raw_frame);
tracing::warn!(
request_id = %context.request_id,
account_id = context.account_id.unwrap_or_default(),
websocket_connection_id = %exchange.websocket_connection_id,
upstream_transport = "websocket",
upstream_error_kind = "websocket_error_frame",
upstream_error_raw = %upstream_error_raw,
"OpenAI upstream returned a recoverable WebSocket error frame"
);
if fresh_retry_used {
// 新连接仍被限流:与官方重试预算耗尽一致,降级同账号 HTTP/SSE。
return self
@@ -513,6 +527,7 @@ impl CodexBackendClient {
.map_err(websocket_exchange_error_to_client_error),
),
transport: CodexBackendTransport::WebSocket,
websocket_connection_id: Some(exchange.websocket_connection_id),
turn_state: exchange.turn_state,
set_cookie_headers: exchange.set_cookie_headers,
rate_limit_headers: exchange.rate_limit_headers,
@@ -546,6 +561,7 @@ impl CodexBackendClient {
delivery_wait_started_at: Instant,
error: CodexWebSocketExchangeError,
) -> CodexClientResult<CodexBackendStreamingResponse> {
log_raw_websocket_close(context, &error);
self.http_fallback_before_delivery(
request,
context,
@@ -807,6 +823,8 @@ enum DeliveryBoundary {
ConnectionLimitReached {
/// 上游错误说明。
message: String,
/// 从 WebSocket 文本事件构造的完整 SSE 错误帧。
raw_frame: bytes::Bytes,
},
}
@@ -819,26 +837,54 @@ async fn await_websocket_delivery_boundary(
Some(Ok(frame)) if is_websocket_lifecycle_prelude(&frame) => prelude.push(frame),
Some(Ok(frame)) => {
let limit_message = websocket_connection_limit_message(&frame);
let raw_frame = limit_message.as_ref().map(|_| frame.clone());
prelude.push(frame);
let remaining =
std::mem::replace(&mut exchange.body, Box::pin(futures::stream::empty()));
exchange.body =
Box::pin(futures::stream::iter(prelude.into_iter().map(Ok)).chain(remaining));
return Ok(match limit_message {
Some(message) => DeliveryBoundary::ConnectionLimitReached { message },
Some(message) => DeliveryBoundary::ConnectionLimitReached {
message,
raw_frame: raw_frame.unwrap_or_default(),
},
None => DeliveryBoundary::Ready,
});
}
Some(Err(error)) => return Err(error),
None => {
return Err(CodexWebSocketExchangeError::closed_before_terminal(
None, None,
return Err(CodexWebSocketExchangeError::closed_before_terminal_on(
exchange.websocket_connection_id,
None,
None,
));
}
}
}
}
fn log_raw_websocket_close(context: CodexRequestContext<'_>, error: &CodexWebSocketExchangeError) {
let Some(close) = error.close_before_terminal() else {
return;
};
let websocket_connection_id = close
.connection_id()
.map(|connection_id| connection_id.to_string())
.unwrap_or_default();
let upstream_error_raw = close.reason().unwrap_or_default();
tracing::warn!(
request_id = %context.request_id,
account_id = context.account_id.unwrap_or_default(),
websocket_connection_id,
upstream_transport = "websocket",
upstream_error_kind = "websocket_close",
upstream_close_code = ?close.code(),
upstream_error_raw,
upstream_error_raw_present = close.reason().is_some(),
"OpenAI upstream WebSocket closed before delivery"
);
}
fn is_websocket_lifecycle_prelude(frame: &[u8]) -> bool {
frame.starts_with(b"event: response.created\n")
|| frame.starts_with(b"event: response.in_progress\n")
@@ -1,7 +1,10 @@
//! 在 transport 边界采集的上游响应诊断信息。
use super::client::CodexClientVisibleUpstreamResponse;
use super::protocol::responses::ResponsesSseFailure;
use super::protocol::responses::{
PREVIOUS_RESPONSE_NOT_FOUND_CODE, ResponsesSseFailure,
is_bare_invalid_previous_response_id_error,
};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use chrono::{DateTime, Utc};
use reqwest::{StatusCode, header::HeaderMap};
@@ -140,7 +143,7 @@ impl CodexUpstreamFailure {
rate_limit_headers: &[(String, String)],
send_phase: CodexUpstreamSendPhase,
) -> Self {
let fields = ParsedUpstreamError::from_body(body);
let fields = ParsedUpstreamError::from_http_response(status, body);
let category = classify_upstream_failure(
UpstreamFailureSource::HttpResponse,
Some(status),
@@ -260,7 +263,7 @@ struct ParsedUpstreamError {
}
impl ParsedUpstreamError {
fn from_body(body: &str) -> Self {
fn from_http_response(status: StatusCode, body: &str) -> Self {
let Ok(value) = serde_json::from_str::<Value>(body) else {
return Self {
code: None,
@@ -275,11 +278,7 @@ impl ParsedUpstreamError {
.or_else(|| value.get("error"))
.or_else(|| value.get("detail"))
.unwrap_or(&value);
let code = error
.get("code")
.or_else(|| value.get("code"))
.and_then(Value::as_str)
.and_then(non_empty_owned);
let code_value = error.get("code").or_else(|| value.get("code"));
let error_type = error
.get("type")
.or_else(|| value.get("type"))
@@ -290,6 +289,16 @@ impl ParsedUpstreamError {
.or_else(|| value.get("message"))
.and_then(Value::as_str)
.and_then(non_empty_owned);
let code = if status == StatusCode::BAD_REQUEST
&& is_bare_invalid_previous_response_id_error(
code_value,
error_type.as_deref(),
client_message.as_deref(),
) {
Some(PREVIOUS_RESPONSE_NOT_FOUND_CODE.to_owned())
} else {
code_value.and_then(Value::as_str).and_then(non_empty_owned)
};
let message = client_message
.clone()
.or_else(|| error.as_str().and_then(non_empty_owned))
@@ -9,6 +9,7 @@ pub mod diagnostics;
pub mod endpoints;
pub mod headers;
pub mod profile;
pub mod profile_avatar;
pub mod profile_statistics;
pub mod protocol;
pub mod request;
@@ -41,6 +42,10 @@ pub use self::{
WHAM_USAGE_PATH, endpoint_url, usage_endpoint_url,
},
headers::build_codex_base_headers,
profile_avatar::{
CodexProfileAvatar, CodexProfileAvatarFetchError, CodexProfileAvatarStreamError,
fetch_profile_avatar,
},
profile_statistics::{
CodexProfileActivityInsights, CodexProfileDailyUsage, CodexProfileInvocation,
CodexProfileStatistics, CodexProfileStatisticsSummary,
@@ -0,0 +1,141 @@
//! Codex 官方个人资料头像的固定来源流式 transport。
use std::{pin::Pin, time::Duration};
use async_stream::try_stream;
use bytes::Bytes;
use futures::{Stream, StreamExt as _};
use reqwest::{
Client, Url,
header::{ACCEPT, CONTENT_TYPE, ETAG, USER_AGENT},
};
use tokio::time::timeout;
use super::endpoints::endpoint_url;
const OFFICIAL_AVATAR_ORIGIN: &str = "https://chatgpt.com";
const OFFICIAL_AVATAR_PATH_PREFIX: &str = "/backend-api/estuary/public_content/enc/";
const PROVIDER_AVATAR_PATH_PREFIX: &str = "/estuary/public_content/enc/";
const PROFILE_AVATAR_ACCEPT: &str =
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8";
const PROFILE_AVATAR_HEADERS_TIMEOUT: Duration = Duration::from_secs(15);
const PROFILE_AVATAR_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
/// 头像正文流;不累积完整正文,也不设置总字节数上限。
pub type CodexProfileAvatarStream =
Pin<Box<dyn Stream<Item = Result<Bytes, CodexProfileAvatarStreamError>> + Send + 'static>>;
/// 响应开始后的头像流失败;不得携带带签名的上游 URL。
#[derive(Debug, thiserror::Error)]
#[error("Codex profile avatar stream failed")]
pub struct CodexProfileAvatarStreamError;
/// 已打开的官方头像响应;MIME 只透传,不做格式白名单判断。
pub struct CodexProfileAvatar {
pub content_type: Option<String>,
pub content_length: Option<u64>,
pub etag: Option<String>,
pub body: CodexProfileAvatarStream,
}
impl std::fmt::Debug for CodexProfileAvatar {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("CodexProfileAvatar")
.field("content_type", &self.content_type)
.field("content_length", &self.content_length)
.field("etag", &self.etag)
.field("body", &"<stream>")
.finish()
}
}
#[derive(Debug, thiserror::Error)]
pub enum CodexProfileAvatarFetchError {
#[error("Codex profile avatar source is invalid")]
InvalidSource,
#[error("Codex profile avatar upstream returned HTTP {status}")]
Upstream { status: u16 },
#[error("Codex profile avatar transport is unavailable")]
TransportUnavailable,
}
/// 校验官方来源后,通过当前 Provider base URL 打开头像字节流。
///
/// # Errors
///
/// 来源不是固定的 ChatGPT Estuary 路径、请求失败或上游返回非成功状态时返回错误。
pub async fn fetch_profile_avatar(
client: &Client,
base_url: &str,
desktop_user_agent: &str,
source: &str,
) -> Result<CodexProfileAvatar, CodexProfileAvatarFetchError> {
let source_path = official_avatar_source_path(source)?;
let target = Url::parse(&endpoint_url(base_url, &source_path))
.map_err(|_| CodexProfileAvatarFetchError::TransportUnavailable)?;
let response = timeout(
PROFILE_AVATAR_HEADERS_TIMEOUT,
client
.get(target)
.header(ACCEPT, PROFILE_AVATAR_ACCEPT)
.header(USER_AGENT, desktop_user_agent)
.send(),
)
.await
.map_err(|_| CodexProfileAvatarFetchError::TransportUnavailable)?
.map_err(|_| CodexProfileAvatarFetchError::TransportUnavailable)?;
let status = response.status();
if !status.is_success() {
return Err(CodexProfileAvatarFetchError::Upstream {
status: status.as_u16(),
});
}
let content_type = response
.headers()
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
let etag = response
.headers()
.get(ETAG)
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
let content_length = response.content_length();
let mut upstream = Box::pin(response.bytes_stream());
let body = try_stream! {
loop {
match timeout(PROFILE_AVATAR_STREAM_IDLE_TIMEOUT, upstream.next()).await {
Ok(Some(Ok(chunk))) => yield chunk,
Ok(Some(Err(_))) | Err(_) => Err(CodexProfileAvatarStreamError)?,
Ok(None) => break,
}
}
};
Ok(CodexProfileAvatar {
content_type,
content_length,
etag,
body: Box::pin(body),
})
}
fn official_avatar_source_path(source: &str) -> Result<String, CodexProfileAvatarFetchError> {
let source = Url::parse(source).map_err(|_| CodexProfileAvatarFetchError::InvalidSource)?;
if source.origin().ascii_serialization() != OFFICIAL_AVATAR_ORIGIN
|| !source.username().is_empty()
|| source.password().is_some()
|| source.query().is_some()
|| source.fragment().is_some()
{
return Err(CodexProfileAvatarFetchError::InvalidSource);
}
let opaque_path = source
.path()
.strip_prefix(OFFICIAL_AVATAR_PATH_PREFIX)
.filter(|path| !path.is_empty())
.ok_or(CodexProfileAvatarFetchError::InvalidSource)?;
Ok(format!("{PROVIDER_AVATAR_PATH_PREFIX}{opaque_path}"))
}
@@ -8,6 +8,24 @@ use reqwest::header::HeaderMap;
use serde::Serialize;
use serde_json::{Map, Value};
/// 官方 Codex 客户端据此触发完整历史重放的稳定错误码。
pub(crate) const PREVIOUS_RESPONSE_NOT_FOUND_CODE: &str = "previous_response_not_found";
/// 本地生成 history unavailable 错误时使用的官方提示文本。
pub(crate) const PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE: &str =
"Previous response was not found. Retrying the full request.";
const INVALID_PREVIOUS_RESPONSE_ID_MESSAGE: &str = "Invalid `previous_response_id`.";
/// 精确匹配上游省略错误码的旧版 previous response 拒绝形状。
pub(crate) fn is_bare_invalid_previous_response_id_error(
code: Option<&Value>,
error_type: Option<&str>,
message: Option<&str>,
) -> bool {
code.is_none_or(Value::is_null)
&& error_type == Some("invalid_request_error")
&& message == Some(INVALID_PREVIOUS_RESPONSE_ID_MESSAGE)
}
/// Codex Responses 上游请求体。
///
/// 发往上游的 Responses 请求。`body` 持有客户端原始 JSON object,逐字段(含顺序、
@@ -386,6 +404,8 @@ pub struct ResponsesSseFailure {
pub explicit_status_code: Option<u16>,
/// 上游显式重试间隔,或从官方限流消息中解析出的重试间隔。
pub retry_after_seconds: Option<u64>,
/// 上游错误事件的原始 JSON data;只应在明确的失败审计边界读取。
raw_body: String,
}
impl fmt::Debug for ResponsesSseFailure {
@@ -404,6 +424,10 @@ impl fmt::Debug for ResponsesSseFailure {
impl ResponsesSseFailure {
pub fn from_event(event: &str, value: &Value) -> Self {
Self::from_raw_event(event, &value.to_string(), value)
}
pub(crate) fn from_raw_event(event: &str, raw_body: &str, value: &Value) -> Self {
Self {
event: event.to_string(),
message: failure_message(value).unwrap_or_else(|| "Codex upstream SSE failed".into()),
@@ -411,8 +435,15 @@ impl ResponsesSseFailure {
upstream_type: failure_type(value),
explicit_status_code: failure_explicit_status_code(value),
retry_after_seconds: events::retry_after_seconds_from_value(value),
raw_body: raw_body.to_owned(),
}
}
/// 返回上游错误事件未经重编码的 JSON data。
#[must_use]
pub fn raw_body(&self) -> &str {
&self.raw_body
}
}
fn failure_message(value: &Value) -> Option<String> {
@@ -2,13 +2,12 @@ use gateway_protocol::openai::sse::encode_sse_event;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::transport::protocol::responses::{CodexResponsesRequest, transport_requirement};
use crate::transport::protocol::responses::{
CodexResponsesRequest, PREVIOUS_RESPONSE_NOT_FOUND_CODE,
is_bare_invalid_previous_response_id_error, transport_requirement,
};
const REDACTED_PAYLOAD_VALUE: &str = "<redacted>";
pub(crate) const PREVIOUS_RESPONSE_NOT_FOUND_CODE: &str = "previous_response_not_found";
pub(crate) const PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE: &str =
"Previous response was not found. Retrying the full request.";
const INVALID_PREVIOUS_RESPONSE_ID_MESSAGE: &str = "Invalid `previous_response_id`.";
/// WebSocket 握手审计快照。
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
@@ -87,11 +86,11 @@ fn previous_response_not_found_event(value: &Value) -> Option<String> {
return None;
}
let error = value.get("error")?.as_object()?;
if error.get("code").is_some_and(|code| !code.is_null())
|| error.get("type").and_then(Value::as_str) != Some("invalid_request_error")
|| error.get("message").and_then(Value::as_str)
!= Some(INVALID_PREVIOUS_RESPONSE_ID_MESSAGE)
{
if !is_bare_invalid_previous_response_id_error(
error.get("code"),
error.get("type").and_then(Value::as_str),
error.get("message").and_then(Value::as_str),
) {
return None;
}
@@ -4,6 +4,7 @@ use std::{fmt, time::Duration};
use gateway_protocol::openai::sse::SseError;
use thiserror::Error;
use uuid::Uuid;
use crate::transport::client::CodexClientVisibleUpstreamResponse;
use crate::transport::diagnostics::CodexUpstreamDiagnostics;
@@ -104,13 +105,29 @@ pub enum CodexWebSocketExchangeError {
/// close reason 只供原请求的客户端错误响应使用;`Debug` 与 `Display` 均不会输出它。
#[derive(Clone, PartialEq, Eq)]
pub struct CodexWebSocketCloseError {
connection_id: Option<Uuid>,
code: Option<u16>,
reason: Option<String>,
}
impl CodexWebSocketCloseError {
pub(crate) fn new(code: Option<u16>, reason: Option<String>) -> Self {
Self { code, reason }
Self {
connection_id: None,
code,
reason,
}
}
pub(crate) fn with_connection_id(mut self, connection_id: Uuid) -> Self {
self.connection_id = Some(connection_id);
self
}
/// 返回承载该 close frame 的 WebSocket 连接标识。
#[must_use]
pub const fn connection_id(&self) -> Option<Uuid> {
self.connection_id
}
/// 返回上游 close code;没有 close frame 时为 `None`。
@@ -130,6 +147,7 @@ impl fmt::Debug for CodexWebSocketCloseError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CodexWebSocketCloseError")
.field("connection_id", &self.connection_id)
.field("code", &self.code)
.field("reason", &self.reason.as_ref().map(|_| "<redacted>"))
.finish()
@@ -192,8 +210,14 @@ impl fmt::Display for CodexWebSocketUpstreamError {
}
impl CodexWebSocketExchangeError {
pub(crate) fn closed_before_terminal(code: Option<u16>, reason: Option<String>) -> Self {
Self::ClosedBeforeTerminal(CodexWebSocketCloseError::new(code, reason))
pub(crate) fn closed_before_terminal_on(
connection_id: Uuid,
code: Option<u16>,
reason: Option<String>,
) -> Self {
Self::ClosedBeforeTerminal(
CodexWebSocketCloseError::new(code, reason).with_connection_id(connection_id),
)
}
pub(crate) fn close_before_terminal(&self) -> Option<&CodexWebSocketCloseError> {
@@ -208,13 +208,15 @@ async fn forward_websocket_response_stream(state: WebSocketStreamForwardState) {
return;
}
tungstenite::Message::Close(frame) => {
let websocket_connection_id = websocket.connection_id();
discard_stream_websocket(
websocket,
pool_return,
StreamWebSocketDiscardReason::UpstreamClosed,
)
.await;
let error = CodexWebSocketExchangeError::closed_before_terminal(
let error = CodexWebSocketExchangeError::closed_before_terminal_on(
websocket_connection_id,
frame.as_ref().map(|frame| u16::from(frame.code)),
frame.map(|frame| frame.reason.to_string()),
);
@@ -287,9 +289,11 @@ async fn forward_websocket_response_stream(state: WebSocketStreamForwardState) {
}
}
let websocket_connection_id = websocket.connection_id();
let upstream_close = websocket
.exit_reason()
.and_then(|reason| reason.upstream_close().cloned());
.and_then(|reason| reason.upstream_close().cloned())
.map(|close| close.with_connection_id(websocket_connection_id));
discard_stream_websocket(
websocket,
pool_return,
@@ -297,7 +301,13 @@ async fn forward_websocket_response_stream(state: WebSocketStreamForwardState) {
)
.await;
let error = upstream_close.map_or_else(
|| CodexWebSocketExchangeError::closed_before_terminal(None, None),
|| {
CodexWebSocketExchangeError::closed_before_terminal_on(
websocket_connection_id,
None,
None,
)
},
CodexWebSocketExchangeError::ClosedBeforeTerminal,
);
let error = if reused_connection {
@@ -556,12 +556,19 @@ fn log_pump_exit(
"Responses WebSocket pump stopped unexpectedly"
);
} else if matches!(reason, PumpExitReason::UpstreamCloseFrame { .. }) {
let upstream_close = reason.upstream_close();
let upstream_error_raw = upstream_close
.and_then(CodexWebSocketCloseError::reason)
.unwrap_or_default();
tracing::info!(
websocket_connection_id = %connection_id,
account_id = %account_id,
conversation_id_hash = %conversation_id_hash,
pump_exit_reason = reason.as_str(),
pump_exit_detail = detail,
upstream_close_code = ?upstream_close.and_then(CodexWebSocketCloseError::code),
upstream_error_raw,
upstream_error_raw_present = upstream_close.is_some_and(|close| close.reason().is_some()),
backpressure_events,
"Responses WebSocket pump received close frame"
);
@@ -1,6 +1,7 @@
use std::sync::Arc;
use chrono::{TimeZone as _, Utc};
use futures::TryStreamExt as _;
use gateway_core::engine::credential::ProviderAccountId;
use provider_openai::credential::{CodexCredentialProfileService, ImportCodexOAuthCredential};
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
@@ -155,3 +156,62 @@ async fn profile_statistics_keeps_profile_when_stats_are_unavailable() {
assert_eq!(statistics.summary.total_text_tokens, None);
assert_eq!(statistics.daily_usage, None);
}
#[tokio::test]
async fn profile_avatar_reuses_cached_source_and_sends_no_account_credentials() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/wham/profiles/me"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"profile": {
"display_name": "Ada",
"profile_picture_url": "https://chatgpt.com/backend-api/estuary/public_content/enc/avatar-token="
},
"stats": {},
"metadata": {"stats_error": null}
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/estuary/public_content/enc/avatar-token="))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "image/svg+xml")
.set_body_bytes(b"<svg xmlns=\"http://www.w3.org/2000/svg\"/>"),
)
.expect(1)
.mount(&server)
.await;
let (store, service) = service("acct_profile_avatar", &server).await;
let account = store
.account("acct_profile_avatar")
.expect("profile account");
service
.profile_statistics(account.id())
.await
.expect("profile statistics");
let avatar = service
.profile_avatar(account.id())
.await
.expect("profile avatar");
let content_type = avatar.content_type.clone();
let chunks = avatar
.body
.try_collect::<Vec<_>>()
.await
.expect("avatar body");
let body = chunks.into_iter().flatten().collect::<Vec<_>>();
let requests = server.received_requests().await.expect("recorded requests");
let avatar_request = requests
.iter()
.find(|request| request.url.path() == "/estuary/public_content/enc/avatar-token=")
.expect("avatar request");
assert_eq!(content_type.as_deref(), Some("image/svg+xml"));
assert_eq!(body, b"<svg xmlns=\"http://www.w3.org/2000/svg\"/>");
assert!(!avatar_request.headers.contains_key("authorization"));
assert!(!avatar_request.headers.contains_key("cookie"));
assert!(!avatar_request.headers.contains_key("chatgpt-account-id"));
}
@@ -18,10 +18,11 @@ use gateway_core::engine::credential::{
};
use gateway_core::engine::provider::{Provider as _, ProviderRequest};
use gateway_core::engine::{
AccountAttemptContext, AttemptContext, CancellationToken, ContinuationAttempt, ModelRequestId,
ProviderAccountStateOwner, RequestAttemptContext, UpstreamSendState,
AccountAttemptContext, AttemptContext, AttemptTransport, CancellationToken,
ContinuationAttempt, ModelRequestId, ProviderAccountStateOwner, RequestAttemptContext,
UpstreamSendState,
};
use gateway_core::error::{ContinuationFailure, ProviderErrorKind};
use gateway_core::error::{ContinuationFailure, PreDeliveryRetry, ProviderErrorKind};
use gateway_core::event::GatewayEvent;
use gateway_core::operation::{
CapabilityRequirements, GenerateRequest, ImageRequest, ImageRequestKind, Operation,
@@ -412,6 +413,7 @@ fn contract_account_scope() -> Arc<FrozenAccountScope> {
"acct_truncated_stream",
"acct_usage_limit_request_path",
"acct_websocket_close",
"acct_websocket_busy_replay",
"acct_websocket_turn_state",
]
.into_iter()
@@ -444,6 +446,10 @@ fn context(request_id: &str, cancellation: CancellationToken) -> AttemptContext
)
}
fn fallback_transport_context(request_id: &str) -> AttemptContext {
context(request_id, CancellationToken::new()).with_transport(AttemptTransport::Fallback)
}
fn diagnostic_context(request_id: &str, account_id: &str) -> AttemptContext {
AttemptContext::new(
RequestAttemptContext::new(
@@ -510,6 +516,40 @@ fn replay_any_context(request_id: &str, owner_account_id: &str) -> AttemptContex
.with_continuation_attempt(ContinuationAttempt::ReplayAny)
}
fn pinned_continuation_context(
request_id: &str,
account_id: &str,
client_previous_response_id: &str,
upstream_previous_response_id: &str,
attempt_index: u32,
continuation_attempt: ContinuationAttempt,
) -> AttemptContext {
let account = ProviderAccountId::new(account_id).expect("account id");
let provider = ProviderKind::new("openai").expect("provider");
let owner = ProviderAccountStateOwner::new(provider.clone(), account.clone());
let continuation = NativeContinuationPin::new(
PreviousResponseId::new(client_previous_response_id),
PreviousResponseId::new(upstream_previous_response_id),
ClientApiKeyId::new("key_openai_contract").expect("client key id"),
provider,
account,
);
AttemptContext::new(
RequestAttemptContext::new(
ModelRequestId::new(request_id).expect("request id"),
ClientApiKeyId::new("key_openai_contract").expect("client key id"),
),
NonZeroU32::new(attempt_index).expect("attempt index"),
SystemTime::now() + Duration::from_secs(30),
account_policy(),
AccountAttemptContext::new(BTreeSet::new(), None, Some(owner))
.with_account_scope(contract_account_scope()),
Some(ContinuationBinding::Pinned(continuation)),
CancellationToken::new(),
)
.with_continuation_attempt(continuation_attempt)
}
fn external_continuation_context(request_id: &str) -> AttemptContext {
AttemptContext::new(
RequestAttemptContext::new(
@@ -1159,6 +1199,125 @@ async fn websocket_close_details_are_only_exposed_through_the_client_error() {
assert_eq!(detail.message(), "message too big");
assert_eq!(detail.code(), Some("1009"));
assert_eq!(detail.error_type(), Some("websocket_close_error"));
assert_eq!(
error.upstream_code().map(|code| code.as_str()),
Some("websocket_close_1009")
);
}
#[tokio::test]
async fn websocket_normal_close_before_semantic_output_requests_same_account_http_fallback() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_websocket_close").await;
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind WebSocket listener");
let base_url = format!(
"http://{}",
listener.local_addr().expect("listener address")
);
let server = tokio::spawn(async move {
let (stream, _) = listener
.accept()
.await
.expect("accept WebSocket connection");
let mut websocket = accept_codex_test_websocket(stream).await;
let _request = websocket
.next()
.await
.expect("WebSocket request")
.expect("valid WebSocket request");
for event in [
json!({
"type": "response.created",
"response": {"id": "resp_structural", "model": "gpt-5.4"}
}),
json!({
"type": "response.in_progress",
"response": {"id": "resp_structural", "model": "gpt-5.4"}
}),
json!({
"type": "response.output_item.added",
"output_index": 0,
"item": {
"id": "msg_structural",
"type": "message",
"role": "assistant",
"status": "in_progress",
"content": []
}
}),
] {
websocket
.send(Message::Text(event.to_string().into()))
.await
.expect("send structural WebSocket event");
}
websocket
.close(Some(CloseFrame {
code: CloseCode::Normal,
reason: "".into(),
}))
.await
.expect("close WebSocket");
});
let mut stream = provider_with_base_url(&store, base_url)
.execute(
planned_request("openai", generate_operation()),
context("req_websocket_normal_close", CancellationToken::new()),
)
.await
.expect("prepare WebSocket provider stream");
let error = loop {
match stream.next().await {
Some(Ok(event)) => assert!(
!event.has_client_event(),
"structural events must remain behind the replay boundary"
),
Some(Err(error)) => break error,
None => panic!("WebSocket close must surface a provider error"),
}
};
server.await.expect("WebSocket server");
assert_eq!(
error.pre_delivery_retry(),
Some(PreDeliveryRetry::SameAccountTransportFallback),
"unexpected provider error: {error:?}"
);
assert_eq!(
error.upstream_code().map(|code| code.as_str()),
Some("websocket_close_1000")
);
}
#[tokio::test]
async fn fallback_attempt_transport_forces_http_sse_for_a_websocket_request() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_http_sse_exhausted").await;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(CAPTURE_COMPLETED_SSE),
)
.expect(1)
.mount(&server)
.await;
let mut stream = provider_with_base_url(&store, server.uri())
.execute(
planned_request("openai", generate_operation()),
fallback_transport_context("req_fallback_transport_http"),
)
.await
.expect("prepare fallback HTTP stream");
while let Some(event) = stream.next().await {
event.expect("fallback HTTP response");
}
}
#[tokio::test]
@@ -2919,6 +3078,288 @@ async fn same_account_previous_response_not_found_should_remain_client_visible()
);
}
#[tokio::test]
async fn exact_websocket_busy_then_http_invalid_previous_response_should_request_official_replay() {
const ACCOUNT_ID: &str = "acct_websocket_busy_replay";
const CONVERSATION_ID: &str = "conversation-websocket-busy-replay";
const CLIENT_PREVIOUS_RESPONSE_ID: &str = "client-resp-busy-seed";
const UPSTREAM_PREVIOUS_RESPONSE_ID: &str = "resp_busy_seed";
fn operation(
previous_response_id: Option<&str>,
session_state: ProviderSessionState,
) -> Operation {
let mut body = Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), json!("hello")),
]);
if let Some(previous_response_id) = previous_response_id {
body.insert(
"previous_response_id".to_owned(),
json!(previous_response_id),
);
}
Operation::Generate(
GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object("openai", body).expect("OpenAI payload"),
)
.with_provider_session_state(session_state),
)
}
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, ACCOUNT_ID).await;
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind upstream listener");
let base_url = format!(
"http://{}",
listener.local_addr().expect("listener address")
);
let (release_busy_sender, release_busy_receiver) = oneshot::channel();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("accept WebSocket");
let mut websocket = accept_codex_test_websocket(stream).await;
let seed = websocket
.next()
.await
.expect("seed request")
.expect("valid seed request");
let Message::Text(seed) = seed else {
panic!("seed request must be text");
};
let seed: Value = serde_json::from_str(&seed).expect("seed request JSON");
assert_eq!(seed.get("previous_response_id"), None);
websocket
.send(Message::Text(
json!({
"type": "response.completed",
"response": {
"id": UPSTREAM_PREVIOUS_RESPONSE_ID,
"model": "gpt-5.4",
"status": "completed",
"output": [],
"usage": {
"input_tokens": 1,
"output_tokens": 1,
"total_tokens": 2
}
}
})
.to_string()
.into(),
))
.await
.expect("complete seed request");
let busy = websocket
.next()
.await
.expect("busy continuation request")
.expect("valid busy continuation request");
let Message::Text(busy) = busy else {
panic!("busy continuation request must be text");
};
let busy: Value = serde_json::from_str(&busy).expect("busy continuation JSON");
assert_eq!(
busy.get("previous_response_id").and_then(Value::as_str),
Some(UPSTREAM_PREVIOUS_RESPONSE_ID)
);
websocket
.send(Message::Text(
json!({
"type": "response.output_text.delta",
"delta": "the exact WebSocket is busy"
})
.to_string()
.into(),
))
.await
.expect("send busy stream output");
let (mut http, _) = listener.accept().await.expect("accept HTTP fallback");
read_http_request(&mut http).await;
let body = json!({
"error": {
"code": null,
"type": "invalid_request_error",
"message": "Invalid `previous_response_id`."
}
})
.to_string();
http.write_all(
format!(
"HTTP/1.1 400 Bad Request\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
)
.as_bytes(),
)
.await
.expect("write HTTP 400");
release_busy_receiver.await.expect("release busy stream");
websocket
.send(Message::Text(
json!({
"type": "response.completed",
"response": {
"id": "resp_busy_finished",
"model": "gpt-5.4",
"status": "completed",
"output": [],
"usage": {
"input_tokens": 1,
"output_tokens": 1,
"total_tokens": 2
}
}
})
.to_string()
.into(),
))
.await
.expect("complete busy stream");
});
let provider = provider_with_base_url(&store, base_url);
let initial_session_state = ProviderSessionState::new(
"openai",
Map::from_iter([
("account_id".to_owned(), json!(ACCOUNT_ID)),
("conversation_id".to_owned(), json!(CONVERSATION_ID)),
("continuation_scope".to_owned(), json!("connection_local")),
]),
)
.expect("initial provider session state");
let mut seed = provider
.execute(
planned_request("openai", operation(None, initial_session_state)),
context("req_ws_busy_seed", CancellationToken::new()),
)
.await
.expect("prepare seed stream");
let mut session_state = None;
while let Some(event) = seed.next().await {
let event = event.expect("seed event");
if let Some(update) = event.session_update() {
session_state = Some(update.clone());
}
}
let session_state = session_state.expect("seed session update");
assert_eq!(
session_state
.payload()
.get("continuation_scope")
.and_then(Value::as_str),
Some("connection_local")
);
let continuation = operation(Some(CLIENT_PREVIOUS_RESPONSE_ID), session_state);
let mut busy = provider
.execute(
planned_request("openai", continuation.clone()),
pinned_continuation_context(
"req_ws_busy_owner",
ACCOUNT_ID,
CLIENT_PREVIOUS_RESPONSE_ID,
UPSTREAM_PREVIOUS_RESPONSE_ID,
1,
ContinuationAttempt::Native,
),
)
.await
.expect("prepare busy continuation stream");
loop {
let event = busy
.next()
.await
.expect("busy stream event")
.expect("valid busy stream event");
if event.has_client_event() {
break;
}
}
let mut exact = provider
.execute(
planned_request("openai", continuation.clone()),
pinned_continuation_context(
"req_ws_busy_exact",
ACCOUNT_ID,
CLIENT_PREVIOUS_RESPONSE_ID,
UPSTREAM_PREVIOUS_RESPONSE_ID,
1,
ContinuationAttempt::Native,
),
)
.await
.expect("prepare competing exact continuation");
let exact_error = loop {
match exact.next().await {
Some(Ok(_)) => {}
Some(Err(error)) => break error,
None => panic!("competing exact continuation must fail while its socket is busy"),
}
};
assert_eq!(
exact_error.continuation_failure(),
Some(ContinuationFailure::Busy)
);
let mut replay = provider
.execute(
planned_request("openai", continuation),
pinned_continuation_context(
"req_ws_busy_replay",
ACCOUNT_ID,
CLIENT_PREVIOUS_RESPONSE_ID,
UPSTREAM_PREVIOUS_RESPONSE_ID,
2,
ContinuationAttempt::ReplayAny,
),
)
.await
.expect("prepare replay-any continuation");
let replay_error = loop {
match replay.next().await {
Some(Ok(_)) => {}
Some(Err(error)) => break error,
None => panic!("HTTP fallback 400 must produce a provider error"),
}
};
let detail = replay_error
.client_visible_upstream_error()
.expect("client-visible replay error");
assert_eq!(
(
replay_error.kind(),
replay_error.continuation_failure(),
detail.code(),
detail.error_type(),
detail.message(),
),
(
ProviderErrorKind::InvalidRequest,
Some(ContinuationFailure::HistoryUnavailable),
Some("previous_response_not_found"),
Some("invalid_request_error"),
"Invalid `previous_response_id`.",
)
);
release_busy_sender.send(()).expect("release busy stream");
while busy
.next()
.await
.transpose()
.expect("busy stream event")
.is_some()
{}
server.await.expect("upstream server");
}
#[tokio::test]
async fn continuation_prefetch_over_64_kib_should_commit_wire_without_protocol_failure() {
let store = Arc::new(MemoryAccountStore::default());
@@ -854,9 +854,10 @@ fn decoder_should_preserve_changed_upstream_response_ids_as_wire() {
}
fn assert_failed_event(code: &str, marker: &str) {
let body = format!(
"event: response.failed\ndata: {{\"response\":{{\"id\":\"resp_failed\",\"status\":\"failed\",\"error\":{{\"code\":\"{code}\",\"message\":\"{marker}\"}}}}}}\n\n"
let raw_error = format!(
"{{\"response\":{{\"id\":\"resp_failed\",\"status\":\"failed\",\"error\":{{\"code\":\"{code}\",\"message\":\"{marker}\"}}}}}}"
);
let body = format!("event: response.failed\ndata: {raw_error}\n\n");
let failure = CodexCanonicalDecoder::new("fallback")
.push(body.as_bytes())
.expect_err("failed event must become a typed error");
@@ -865,6 +866,7 @@ fn assert_failed_event(code: &str, marker: &str) {
panic!("response.failed must preserve its typed upstream failure");
};
assert_eq!(upstream.upstream_code.as_deref(), Some(code));
assert_eq!(upstream.raw_body(), raw_error);
assert!(!failure.semantic_output_seen());
assert!(!format!("{failure:?}").contains(marker));
}
@@ -1117,6 +1117,10 @@ async fn websocket_failure_after_first_delivery_should_not_open_http_fallback()
let CodexWebSocketExchangeError::ClosedBeforeTerminal(close) = *source else {
panic!("expected upstream close frame source");
};
assert!(
close.connection_id().is_some(),
"close failures must retain their WebSocket connection correlation"
);
assert_eq!(close.code(), Some(1009));
assert_eq!(close.reason(), Some("message too big after output"));
}
@@ -54,6 +54,7 @@ mod headers;
mod http_client;
mod latency;
mod profile;
mod profile_avatar;
mod protocol;
mod request;
mod reset_credits;
@@ -233,6 +234,7 @@ async fn collect_backend_response(
let CodexBackendStreamingResponse {
mut body,
transport,
websocket_connection_id: _,
mut turn_state,
set_cookie_headers,
mut rate_limit_headers,
@@ -0,0 +1,80 @@
use futures::TryStreamExt as _;
use provider_openai::transport::{CodexProfileAvatarFetchError, fetch_profile_avatar};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const OFFICIAL_AVATAR_SOURCE: &str =
"https://chatgpt.com/backend-api/estuary/public_content/enc/opaque-token=";
#[tokio::test]
async fn profile_avatar_streams_unrestricted_content_type_and_body_size() {
let server = MockServer::start().await;
let body = vec![b'x'; 1024 * 1024 + 1];
Mock::given(method("GET"))
.and(path("/estuary/public_content/enc/opaque-token="))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/x-avatar-fixture")
.insert_header("etag", "\"avatar-v1\"")
.set_body_bytes(body.clone()),
)
.expect(1)
.mount(&server)
.await;
let client = reqwest::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("client");
let avatar = fetch_profile_avatar(
&client,
&server.uri(),
"codex_cli_rs/1.0.0 (linux; x86_64)",
OFFICIAL_AVATAR_SOURCE,
)
.await
.expect("profile avatar");
let content_type = avatar.content_type.clone();
let content_length = avatar.content_length;
let etag = avatar.etag.clone();
let chunks = avatar.body.try_collect::<Vec<_>>().await.expect("body");
let streamed = chunks.into_iter().flatten().collect::<Vec<_>>();
let requests = server.received_requests().await.expect("avatar request");
let headers = &requests.first().expect("one avatar request").headers;
assert_eq!(
content_type.as_deref(),
Some("application/x-avatar-fixture")
);
assert_eq!(content_length, Some(body.len() as u64));
assert_eq!(etag.as_deref(), Some("\"avatar-v1\""));
assert_eq!(streamed, body);
assert_eq!(
headers.get("accept").and_then(|value| value.to_str().ok()),
Some("image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
);
assert_eq!(
headers
.get("user-agent")
.and_then(|value| value.to_str().ok()),
Some("codex_cli_rs/1.0.0 (linux; x86_64)")
);
}
#[tokio::test]
async fn profile_avatar_rejects_non_official_sources_before_request() {
let client = reqwest::Client::new();
for source in [
"https://example.com/backend-api/estuary/public_content/enc/token",
"https://chatgpt.com/other/token",
"https://chatgpt.com/backend-api/estuary/public_content/enc/token?next=1",
"https://chatgpt.com/backend-api/estuary/public_content/enc/",
] {
let error = fetch_profile_avatar(&client, "http://127.0.0.1:9", "test-agent", source)
.await
.expect_err("invalid source");
assert!(matches!(error, CodexProfileAvatarFetchError::InvalidSource));
}
}
+2 -2
View File
@@ -13,7 +13,7 @@ RUN npm install --global pnpm@11.7.0 \
COPY frontend ./
RUN pnpm build
FROM rust:1.98-bookworm@sha256:e70e2eec3d495fd5c8e0be74adda86507dfac7f51a724fbf9813ff59b2b247c7 AS backend-builder
FROM rust:1.98-bookworm@sha256:82150a52ec202c1b14d7817e14516c392bb7f5cfebd88f1ed531cb37ebd39922 AS backend-builder
WORKDIR /app/backend
ARG TARGETARCH
@@ -40,7 +40,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
# 备份客户端来源:与 compose 服务端同一固定镜像,版本一致。
FROM ${POSTGRES_IMAGE} AS pg-client
FROM debian:bookworm-slim@sha256:abd67ffcfa541b485a3dff59865ab629aa048a6c613e639d36e7456b0b229241 AS runtime-base
FROM debian:bookworm-slim@sha256:88200866dfff7ea7f5cbcb6ec7c8a701889efe6fe859fe64d6990e4b07ea4171 AS runtime-base
ENV TZ=Asia/Shanghai
+8 -8
View File
@@ -19,27 +19,27 @@
"@boxicons/vue": "1.0.1",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@lucide/vue": "^1.33.0",
"@lucide/vue": "^1.37.0",
"@vueuse/core": "^14.4.0",
"axios": "^1.19.0",
"axios": "^1.20.0",
"dayjs": "^1.11.23",
"dompurify": "^3.4.14",
"echarts": "^6.1.0",
"es-toolkit": "^1.51.0",
"es-toolkit": "^1.52.0",
"gsap": "^3.15.0",
"marked": "^18.0.10",
"marked": "^18.0.11",
"pinia": "^4.0.3",
"pinia-plugin-persistedstate": "^4.7.1",
"vue": "^3.5.41",
"vue-router": "^5.2.0"
"vue": "^3.5.42",
"vue-router": "^5.3.0"
},
"devDependencies": {
"@antfu/eslint-config": "^9.3.0",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^26.2.0",
"@types/node": "^26.4.0",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.9.0",
"eslint": "^10.9.1",
"eslint-plugin-format": "^2.0.1",
"eslint-plugin-vuejs-accessibility": "^2.6.0",
"tailwindcss": "^4.3.3",
+293 -369
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -368,6 +368,21 @@ export function getAccountProfileStatistics(data: AccountIdParam) {
})
}
export function accountProfileAvatarUrl(accountId: string, sourceUrl: string) {
const params = new URLSearchParams({
accountId,
version: stableAvatarVersion(sourceUrl),
})
return `/api/admin/accounts/profile-avatar?${params.toString()}`
}
function stableAvatarVersion(value: string) {
let hash = 0
for (const character of value)
hash = (hash * 33 + (character.codePointAt(0) ?? 0)) % 2_147_483_647
return hash.toString(36)
}
export function refreshAccountQuota(data: AccountIdParam) {
return request<AccountQuotaResponse>({
url: '/api/admin/accounts/quota/refresh',
+29 -84
View File
@@ -211,19 +211,36 @@ export interface OpsError {
requestId: string | null
clientApiKeyId: string | null
kind: string
operation: string
protocol: string | null
clientTransport: string | null
provider: string | null
authenticationKind: string | null
accountId: string | null
accountName: string | null
accountEmail: string | null
route: string
model: string | null
requestedModel: string | null
upstreamModel: string | null
serviceTier: string | null
clientStatusCode: number | null
upstreamStatusCode: number | null
transport: string | null
attemptIndex: number | null
failureClass: string
providerErrorCode: string | null
occurrenceCount: number
responseId: string | null
upstreamRequestId: string | null
latencyMs: number | null
clientIp: string | null
userAgent: string | null
reasoningEffort: string | null
reasoningPreset: string | null
requestKind: string | null
subagentKind: string | null
compact: boolean | null
message: string
metadata: OpsErrorMetadata
createdAt: string
@@ -406,98 +423,26 @@ type OpsErrorPageQuery = UsagePageQuery & {
route?: string
}
interface CursorPageResponse<Item> {
items: Item[]
total: number | null
nextCursor: string | null
}
type CursorPageQuery<Query> = Query & {
pageSize: number
cursor?: string
}
interface UsageDetailQuery {
id: string
}
type UsageDiagnosticsQuery = UsageRangeQuery & { dimension: string }
export function createUsageRecordsPager() {
return createPageLoader<UsageListRecord, UsageRangeQuery>(data =>
request<CursorPageResponse<UsageListRecord>>({
url: '/api/admin/usage/records',
method: 'GET',
params: data,
}))
export function getUsageRecords(data: UsagePageQuery) {
return request<UsageRecordsResponse>({
url: '/api/admin/usage/records',
method: 'GET',
params: data,
})
}
export function createOpsErrorsPager() {
return createPageLoader<OpsError, Omit<OpsErrorPageQuery, keyof PageQuery>>(data =>
request<CursorPageResponse<OpsError>>({
url: '/api/admin/operations/errors',
method: 'GET',
params: data,
}))
}
function createPageLoader<Item, Query extends object>(
fetchPage: (query: CursorPageQuery<Query>) => Promise<CursorPageResponse<Item>>,
) {
const cursors = new Map<number, string | undefined>([[1, undefined]])
let queryKey = ''
let total = 0
function reset(nextQueryKey: string) {
cursors.clear()
cursors.set(1, undefined)
queryKey = nextQueryKey
total = 0
}
async function load(data: Query & PageQuery) {
const { currentPage, pageSize, ...query } = data
const nextQueryKey = JSON.stringify([pageSize, query])
if (queryKey !== nextQueryKey)
reset(nextQueryKey)
const requestedPage = Math.max(1, Math.trunc(currentPage))
let page = [...cursors.keys()].reduce(
(known, candidate) => candidate <= requestedPage && candidate > known ? candidate : known,
1,
)
let result: CursorPageResponse<Item>
while (page < requestedPage) {
result = await fetchPage({
...(query as Query),
pageSize,
cursor: cursors.get(page),
})
if (result.total != null)
total = result.total
if (!result.nextCursor)
return { items: result.items, currentPage: page, pageSize, total }
cursors.set(page + 1, result.nextCursor)
page += 1
}
result = await fetchPage({
...(query as Query),
pageSize,
cursor: cursors.get(page),
})
if (result.total != null)
total = result.total
if (result.nextCursor)
cursors.set(page + 1, result.nextCursor)
else
cursors.delete(page + 1)
return { items: result.items, currentPage: page, pageSize, total }
}
return { load }
export function getOpsErrors(data: OpsErrorPageQuery) {
return request<OpsErrorsResponse>({
url: '/api/admin/operations/errors',
method: 'GET',
params: data,
})
}
export function getUsageRecordDetail(data: UsageDetailQuery) {
+1 -1
View File
@@ -27,7 +27,7 @@ const indicatorClasses = computed(() => [
? 'bg-cp-bg-container-disabled text-cp-text-disabled'
: checked.value
? 'bg-cp-primary text-(--cp-button-primary-color)'
: 'bg-cp-fill-tertiary text-transparent group-hover:bg-cp-bg-text-active',
: 'bg-cp-fill-secondary text-transparent',
])
</script>
@@ -14,7 +14,7 @@ interface PageRequest {
pageSize: number
}
/** Element Plus 风格的页面分页状态;稳定深分页由 API pager 内部完成。 */
/** Element Plus 风格的服务端页码分页;一次 execute 只发起一次列表请求。 */
export function useStablePagedQuery<Result extends PageResult>(options: {
initialPageSize: number
load: (pagination: PageRequest) => Promise<Result>
@@ -4,6 +4,7 @@ import type { AccountProfileStatisticsResponse } from '@/api'
import { UserRound } from '@lucide/vue'
import { computed, shallowRef, watch } from 'vue'
import { accountProfileAvatarUrl } from '@/api'
import { formatCompactNumber, formatInteger } from '@/utils/number'
import { stablePresetVisualToneClass } from '../../utils/visualTone'
import AccountPlanBadge from '../AccountPlanBadge.vue'
@@ -23,6 +24,10 @@ const initial = computed(() => Array.from(displayName.value.trim())[0]?.toUpperC
const avatarToneClass = computed(() =>
stablePresetVisualToneClass(props.account.id || accountIdentity.value || displayName.value),
)
const avatarUrl = computed(() => {
const sourceUrl = props.profile.imageUrl?.trim()
return sourceUrl ? accountProfileAvatarUrl(props.account.id, sourceUrl) : null
})
const metrics = computed(() => [
{
label: '累计 Token 数',
@@ -49,7 +54,7 @@ const metrics = computed(() => [
])
watch(
() => props.profile.imageUrl,
avatarUrl,
() => {
imageFailed.value = false
},
@@ -95,8 +100,8 @@ function formatDuration(value: number | null) {
:class="avatarToneClass"
>
<img
v-if="profile.imageUrl && !imageFailed"
:src="profile.imageUrl"
v-if="avatarUrl && !imageFailed"
:src="avatarUrl"
:alt="`${displayName} 的头像`"
class="size-full object-cover"
referrerpolicy="no-referrer"
@@ -6,6 +6,7 @@ import BaseModal from '@/components/base/BaseModal/index.vue'
import { displayValue } from '../utils/detail'
import { presentOpsError } from '../utils/opsErrorPresentation'
import UsageDetailCodePanel from './UsageDetailCodePanel.vue'
import UsageDetailFieldGrid from './UsageDetailFieldGrid.vue'
import UsageStatusCodeBadge from './UsageStatusCodeBadge.vue'
const props = defineProps<{
@@ -16,23 +17,52 @@ const open = defineModel<boolean>({ default: false })
const presentation = computed(() => props.record ? presentOpsError(props.record) : null)
const fields = computed(() => [
{ label: '账号', value: props.record?.metadata.accountLabel },
{ label: '时间', value: props.record?.createdAtDisplay },
const panelClass = 'min-w-0 rounded-cp-card bg-cp-fill-quaternary px-4 py-3.5'
const panelTitleClass = 'm-0 text-cp-sm leading-none font-heavy text-cp-text-secondary'
const accountLabel = computed(() => props.record?.accountEmail
|| props.record?.accountName
|| props.record?.metadata.accountLabel
|| props.record?.accountId)
const requestFields = computed(() => [
{ label: '客户端 IP', value: props.record?.clientIp, mono: true },
{ label: 'User-Agent', value: props.record?.userAgent, mono: true, wrap: true },
{ label: '协议', value: props.record?.protocol, mono: true },
{ label: '客户端传输', value: props.record?.clientTransport, mono: true },
{ label: '客户端 Key ID', value: props.record?.clientApiKeyId, mono: true },
{ label: '请求 ID', value: props.record?.requestId, mono: true },
{ label: '响应 ID', value: props.record?.responseId, mono: true },
{ label: '上游请求 ID', value: props.record?.upstreamRequestId, mono: true },
])
const routeFields = computed(() => [
{ label: '端点', value: props.record?.route, mono: true },
{ label: '请求模型', value: props.record?.requestedModel, mono: true },
{ label: '上游模型', value: props.record?.upstreamModel, mono: true },
{ label: '记录模型', value: props.record?.model, mono: true },
{ label: '推理强度', value: props.record?.reasoningEffort, mono: true },
{ label: '推理预设', value: props.record?.reasoningPreset, mono: true },
{ label: '请求类型', value: props.record?.requestKind, mono: true },
{ label: '子代理类型', value: props.record?.subagentKind, mono: true },
{ label: '压缩请求', value: booleanDisplay(props.record?.compact) },
{ label: '服务档位', value: props.record?.serviceTier, mono: true },
{ label: '上游传输', value: props.record?.transport, mono: true },
{ label: '尝试序号', value: props.record?.attemptIndex },
])
const diagnosticFields = computed(() => [
{ label: '账号', value: accountLabel.value, mono: true },
{ label: '时间', value: props.record?.createdAtDisplay, mono: true },
{ label: '记录来源', value: presentation.value?.sourceLabel },
{ label: '组件', value: presentation.value?.componentLabel },
{ label: '操作/触发', value: props.record?.operation, mono: true },
{ label: '失败分类', value: presentation.value?.failureClassLabel },
{ label: '平台/类型', value: providerKindLabel(props.record) },
{ label: '端点', value: props.record?.route },
{ label: '模型', value: props.record?.model },
{ label: '账号 ID', value: props.record?.accountId },
{ label: '客户端 Key ID', value: props.record?.clientApiKeyId },
{ label: '传输方式', value: props.record?.transport },
{ label: '尝试序号', value: props.record?.attemptIndex },
{ label: '耗时', value: latencyDisplay(props.record?.latencyMs) },
{ label: '请求 ID', value: props.record?.requestId, wide: true },
{ label: '响应 ID', value: props.record?.responseId, wide: true },
{ label: '上游请求 ID', value: props.record?.upstreamRequestId, wide: true },
{ label: '账号 ID', value: props.record?.accountId, mono: true },
{ label: 'Provider 错误码', value: props.record?.providerErrorCode, mono: true },
{ label: '聚合次数', value: props.record?.occurrenceCount },
{ label: '耗时', value: latencyDisplay(props.record?.latencyMs), mono: true },
])
const metadataText = computed(() => {
@@ -46,6 +76,14 @@ function latencyDisplay(value: unknown) {
return typeof value === 'number' ? `${value} ms` : '—'
}
function booleanDisplay(value: boolean | null | undefined) {
if (value === true)
return '是'
if (value === false)
return '否'
return '—'
}
function providerKindLabel(record: OpsError | null) {
if (!record?.provider)
return '—'
@@ -59,7 +97,7 @@ function providerKindLabel(record: OpsError | null) {
<BaseModal
v-model="open"
title="错误明细"
description="状态码、失败分类与诊断信息"
description="请求来源、链路上下文与原始诊断"
tone="danger"
size="xl"
>
@@ -79,34 +117,37 @@ function providerKindLabel(record: OpsError | null) {
</p>
</section>
<dl
class="mt-3 grid grid-cols-1 gap-3 rounded-cp-card bg-cp-fill-quaternary px-4 py-3.5 sm:grid-cols-2"
>
<div
v-for="field in fields"
:key="field.label"
class="min-w-0"
:class="field.wide ? 'sm:col-span-2' : undefined"
>
<dt class="text-cp-xs leading-none font-bold text-cp-text-quaternary">
{{ field.label }}
</dt>
<dd
class="mt-1.5 mb-0 truncate font-mono text-cp-sm leading-normal font-emphasis text-cp-text"
:title="displayValue(field.value)"
>
{{ displayValue(field.value) }}
</dd>
</div>
</dl>
<div class="mt-3 grid min-w-0 gap-3 lg:grid-cols-2">
<section :class="panelClass">
<h3 :class="panelTitleClass">
请求追踪
</h3>
<UsageDetailFieldGrid :items="requestFields" />
</section>
<section class="mt-3 rounded-cp-card bg-cp-fill-quaternary px-4 py-3.5">
<section :class="panelClass">
<h3 :class="panelTitleClass">
模型与链路
</h3>
<UsageDetailFieldGrid :items="routeFields" />
</section>
</div>
<section class="mt-3" :class="panelClass">
<h3 :class="panelTitleClass">
错误事件
</h3>
<UsageDetailFieldGrid :items="diagnosticFields" />
</section>
<section class="mt-3" :class="panelClass">
<UsageDetailCodePanel title="原始诊断" max-height="260px" :content="record.message" />
</section>
<section
v-if="metadataText"
class="mt-3 rounded-cp-card bg-cp-fill-quaternary px-4 py-3.5"
class="mt-3"
:class="panelClass"
>
<UsageDetailCodePanel title="Metadata" max-height="260px" :content="metadataText" />
</section>
@@ -8,11 +8,13 @@ import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseTablePagination from '@/components/base/BaseTable/BaseTablePagination.vue'
import BaseTable from '@/components/base/BaseTable/index.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import { useOpsErrorsTable } from '../composables/useOpsErrorsTable'
import { opsErrorColumns } from '../constants'
import { presentOpsError } from '../utils/opsErrorPresentation'
import OpsErrorDetailModal from './OpsErrorDetailModal.vue'
import UsageStatusCodeBadge from './UsageStatusCodeBadge.vue'
import UsageTransportBadge from './UsageTransportBadge.vue'
const props = defineProps<{
timeRangeParams: UsageTimeRangeParams
@@ -46,6 +48,30 @@ function failureClassText(record: OpsError) {
function errorSummary(record: OpsError) {
return presentOpsError(record).summary
}
function accountText(record: OpsError) {
return record.accountEmail
|| record.accountName
|| record.metadata.accountLabel
|| record.accountId
|| '—'
}
function modelText(record: OpsError) {
return record.requestedModel || record.model || record.upstreamModel || '—'
}
function modelTitle(record: OpsError) {
const requested = record.requestedModel
const upstream = record.upstreamModel
return requested && upstream && requested !== upstream
? `${requested} → ${upstream}`
: modelText(record)
}
function reasoningText(record: OpsError) {
return record.reasoningPreset || record.reasoningEffort || '—'
}
</script>
<template>
@@ -111,6 +137,12 @@ function errorSummary(record: OpsError) {
{{ failureClassText(row) }}
</span>
</template>
<template #provider="{ row }">
<ProviderIconGroup
:provider="String(row.provider || '')"
:authentication-kind="row.authenticationKind"
/>
</template>
<template #message="{ row }">
<span class="block max-w-full truncate text-cp-sm font-emphasis text-cp-text" :title="errorSummary(row)">
{{ errorSummary(row) }}
@@ -119,9 +151,41 @@ function errorSummary(record: OpsError) {
<template #accountId="{ row }">
<span
class="block max-w-full truncate font-mono text-cp-sm font-bold text-cp-text"
:title="row.accountId || ''"
:title="accountText(row)"
>
{{ row.metadata.accountLabel || '—' }}
{{ accountText(row) }}
</span>
</template>
<template #model="{ row }">
<span
class="block max-w-full truncate font-mono text-cp-sm font-bold text-cp-text"
:title="modelTitle(row)"
>
{{ modelText(row) }}
</span>
</template>
<template #reasoningEffort="{ row }">
<span class="text-cp-sm font-bold text-cp-text-secondary">
{{ reasoningText(row) }}
</span>
</template>
<template #transport="{ row }">
<UsageTransportBadge :transport="row.transport" />
</template>
<template #clientTransport="{ row }">
<UsageTransportBadge :transport="row.clientTransport" />
</template>
<template #clientIp="{ row }">
<span
class="inline-flex h-6 max-w-full items-center rounded-full bg-cp-blue-bg px-2.5 font-mono text-cp-sm leading-none font-bold text-cp-blue-text-on-bg"
:title="row.clientIp || '—'"
>
<span class="min-w-0 truncate">{{ row.clientIp || '—' }}</span>
</span>
</template>
<template #userAgent="{ row }">
<span class="block max-w-full wrap-break-word whitespace-normal font-mono text-cp-sm leading-[1.4] font-emphasis text-cp-text-secondary">
{{ row.userAgent || '—' }}
</span>
</template>
<template #actions="{ row }">
@@ -3,7 +3,7 @@ import type { UsageTimeRangeParams } from './useUsageTimeRange'
import { watchDebounced } from '@vueuse/core'
import { computed, onMounted, shallowRef, watch } from 'vue'
import { createOpsErrorsPager } from '@/api'
import { getOpsErrors } from '@/api'
import { toast } from '@/components/base/BaseToast'
import { useStablePagedQuery } from '@/composables/useStablePagedQuery'
import { errorMessage, withMinimumDuration } from '@/utils/async'
@@ -13,11 +13,9 @@ export function useOpsErrorsTable(timeRangeParams: Readonly<Ref<UsageTimeRangePa
const searchQuery = shallowRef('')
const failureClass = shallowRef('')
const route = shallowRef('')
const pager = createOpsErrorsPager()
const query = useStablePagedQuery({
initialPageSize: 10,
load: ({ currentPage, pageSize }) => pager.load({
load: ({ currentPage, pageSize }) => getOpsErrors({
currentPage,
pageSize,
search: searchQuery.value.trim() || undefined,
@@ -5,9 +5,9 @@ import { watchDebounced } from '@vueuse/core'
import { computed, onMounted, onScopeDispose, shallowRef, watch } from 'vue'
import {
createUsageRecordsPager,
getUsageRecordInsightsDiagnostics,
getUsageRecordInsightsOverview,
getUsageRecords,
getUsageRecordSummary,
} from '@/api'
import { toast } from '@/components/base/BaseToast'
@@ -26,13 +26,6 @@ interface UsageLoadOptions {
background?: boolean
}
interface UsageTableParams {
startTime: string
endTime: string
provider?: string
search?: string
}
export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
const loading = shallowRef(true)
const analyticsLoading = shallowRef(true)
@@ -49,7 +42,6 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
})
const refreshingList = shallowRef(false)
const diagnosticDimension = shallowRef('model')
const pager = createUsageRecordsPager()
let loadRequestId = 0
let diagnosticRequestId = 0
const scopedParams = () => ({
@@ -70,21 +62,6 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
totalRecords.value = 0
}
async function loadTablePage(
params: UsageTableParams,
targetPage: number,
requestId: number,
) {
const result = await pager.load({
currentPage: targetPage,
pageSize: pageSize.value,
...params,
})
if (requestId !== loadRequestId)
return null
return { result, page: result.currentPage }
}
async function loadUsageRecords(loadOptions: UsageLoadOptions = {}) {
const { scope = 'all', background = false } = loadOptions
const requestId = ++loadRequestId
@@ -114,8 +91,11 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
...tableTimeRangeParams.value,
...filterParams(),
}
const requestedPage = currentPage.value
const resultPromise = loadTablePage(tableParams, requestedPage, requestId)
const resultPromise = getUsageRecords({
currentPage: currentPage.value,
pageSize: pageSize.value,
...tableParams,
})
const analyticsPromise
= scope === 'all'
? loadUsageAnalytics(globalParams)
@@ -123,10 +103,9 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
summary: summary.value,
insights: insights.value,
})
const [tableResult, nextAnalytics] = await Promise.all([resultPromise, analyticsPromise])
if (requestId !== loadRequestId || tableResult == null)
const [result, nextAnalytics] = await Promise.all([resultPromise, analyticsPromise])
if (requestId !== loadRequestId)
return
const { result, page: resultPage } = tableResult
records.value = result.items
summary.value = nextAnalytics.summary
@@ -137,8 +116,9 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
? nextAnalytics.insights.diagnostics
: insights.value.diagnostics,
}
pageSize.value = result.pageSize
totalRecords.value = result.total
currentPage.value = resultPage
currentPage.value = result.currentPage
}
catch (error: unknown) {
if (requestId !== loadRequestId)
+7 -1
View File
@@ -35,13 +35,19 @@ export const usageRecordColumns = defineTableColumns<UsageDisplayRecord>([
export const opsErrorColumns = defineTableColumns<OpsErrorRow>([
{ key: 'accountId', label: '账号', kind: 'mono', size: '3xl' },
{ key: 'provider', label: '平台/类型', kind: 'custom', size: 'sm' },
{ key: 'createdAtDisplay', label: '时间', kind: 'datetime' },
{ key: 'upstreamStatusCode', label: '上游状态', kind: 'status', size: 'sm' },
{ key: 'clientStatusCode', label: '客户端状态', kind: 'status', size: 'md' },
{ key: 'failureClass', label: '失败分类', kind: 'mono' },
{ key: 'kind', label: '事件', kind: 'mono' },
{ key: 'route', label: '端点', kind: 'mono' },
{ key: 'model', label: '模型', kind: 'mono' },
{ key: 'model', label: '模型', kind: 'custom', size: 'xl' },
{ key: 'reasoningEffort', label: '推理强度', kind: 'custom' },
{ key: 'transport', label: '上游', kind: 'custom', size: 'md' },
{ key: 'clientTransport', label: '接入', kind: 'custom', size: 'md' },
{ key: 'clientIp', label: 'IP', kind: 'custom', size: '3xl' },
{ key: 'userAgent', label: 'User-Agent', kind: 'custom', size: '4xl' },
{ key: 'requestId', label: '请求 ID', kind: 'mono', size: '2xl' },
{ key: 'message', label: '错误摘要', kind: 'custom', size: '3xl' },
{ key: 'actions', label: '操作', kind: 'actions', size: 'sm' },
+1 -1
View File
@@ -1 +1 @@
version: "3.3.10"
version: "3.3.12"