Compare commits

..
32 Commits
Author SHA1 Message Date
ZyyandCodex cf0b14c5fd chore: bump version to 3.2.10
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 15:40:35 +08:00
ZyyandCodex 056561de41 refactor(frontend): clarify component boundaries
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 15:33:36 +08:00
dependabot[bot] f2215df72e chore(deps): bump the minor-and-patch group in /frontend with 4 updates
Bumps the minor-and-patch group in /frontend with 4 updates: [@lucide/vue](https://github.com/lucide-icons/lucide/tree/HEAD/packages/vue), [pinia](https://github.com/vuejs/pinia), [eslint-plugin-vuejs-accessibility](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility) and [vue-tsc](https://github.com/vuejs/language-tools/tree/HEAD/packages/tsc).


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

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

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

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

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

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

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


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

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

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

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

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

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

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


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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:05:19 +08:00
ZyyandCodex 0558693949 fix(openai): harden refresh recovery handling
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 13:37:54 +08:00
ZyyandCodex 2a9cb02cc9 fix(openai): align OAuth refresh error handling
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 12:30:05 +08:00
ZyyandCodex 5ce36931a8 chore: bump version to 3.2.9
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 11:38:49 +08:00
ZyyandCodex 0d826d9eba fix(openai): align OAuth token refresh
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 11:14:03 +08:00
ZyyandCodex 304242957a fix(accounts): preserve upstream error messages
Co-authored-by: Codex <noreply@openai.com>
2026-08-16 13:28:28 +08:00
ZyyandCodex ef37ae8d9e chore: bump version to 3.2.8
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 23:31:04 +08:00
ZyyandCodex fc13d4f2b6 fix(frontend): restore responsive component behavior
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 20:38:48 +08:00
ZyyandCodex 78f9c0c800 chore: bump version to 3.2.7
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:54:52 +08:00
ZyyandCodex a3bc7bc445 fix(frontend): increase account table row height
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:54:02 +08:00
ZyyandCodex 0132114285 fix(openai): let upstream validate catalog models
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:45:06 +08:00
ZyyandCodex 5970efbb7c fix(container): refresh runtime packages before scans
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:34:41 +08:00
ZyyandCodex 7bf28b571d chore: bump version to 3.2.6
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 15:47:32 +08:00
ZyyandCodex bc3bd6bc43 refactor(frontend): unify component design system
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 15:39:31 +08:00
ZyyandCodex 9acfab0a86 chore(frontend): enforce template interpolation layout
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 12:31:10 +08:00
ZyyandCodex 3e5a84d2a0 fix(frontend): refine dashboard capacity display
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 12:31:06 +08:00
ZyyandCodex 92948f51a9 fix(frontend): remove table bottom gap
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 11:59:58 +08:00
ZyyandCodex 776637fccb chore: bump version to 3.2.5
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 01:52:54 +08:00
ZyyandCodex 82acb08d26 fix(frontend): restore diagnostic table scrolling
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 01:51:56 +08:00
ZyyandCodex 7744e1b0b4 fix(accounts): preserve rolling window usage
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 01:28:44 +08:00
ZyyandCodex 0b8a1add18 feat(frontend): refresh account setup defaults and copy
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 01:09:31 +08:00
ZyyandCodex 2dd60c08b6 chore: bump version to 3.2.4
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 00:29:44 +08:00
ZyyandCodex cce9880937 fix(xai): align Grok behavior with sub2api
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 23:36:12 +08:00
Zyy b8ea1cd076 fix(core): normalize account scheduling load 2026-08-14 23:27:06 +08:00
ZyyandCodex 4117840f75 fix(xai): align OAuth and Codex compatibility
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 22:24:08 +08:00
ZyyandCodex f642764f34 fix(openai): align billing with official pricing
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 21:14:36 +08:00
ZyyandCodex 482e192e2d fix(openai): align desktop oauth identity
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 17:43:07 +08:00
ZyyandCodex ee51a07de4 fix(openai): align desktop wire profile
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 16:13:11 +08:00
258 changed files with 13447 additions and 6946 deletions
+4 -1
View File
@@ -55,6 +55,9 @@ jobs:
target: runtime
load: true
tags: ${{ env.CPR_IMAGE }}
no-cache-filters: |
runtime-base
runtime
cache-from: type=gha,scope=runtime-ci
cache-to: type=gha,mode=max,scope=runtime-ci
@@ -76,7 +79,7 @@ jobs:
${{ hashFiles('trivy-results.sarif') != '' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository) }}
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
sarif_file: trivy-results.sarif
category: runtime-container
+3
View File
@@ -468,6 +468,9 @@ jobs:
push: true
build-args: |
CPR_PREBUILT_DIR=dist/docker
no-cache-filters: |
runtime-base
runtime-prebuilt
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.os }}-${{ matrix.arch }}
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.os }}-${{ matrix.arch }},mode=min
provenance: mode=max
+38 -30
View File
@@ -120,9 +120,9 @@ dependencies = [
[[package]]
name = "async-trait"
version = "0.1.91"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
@@ -451,9 +451,9 @@ dependencies = [
[[package]]
name = "aws-smithy-types"
version = "1.6.1"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8"
checksum = "fce83ce9abbb198d25bc7131e468d0f9fe1257125e58c39f3f9fc9f5098c9647"
dependencies = [
"base64-simd",
"bytes",
@@ -801,9 +801,9 @@ checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "cookie"
version = "0.18.1"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
dependencies = [
"percent-encoding",
"time",
@@ -1384,9 +1384,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
dependencies = [
"futures-channel",
"futures-core",
@@ -1399,9 +1399,9 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
"futures-sink",
@@ -1409,15 +1409,15 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-executor"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
dependencies = [
"futures-core",
"futures-task",
@@ -1437,32 +1437,32 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-macro"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.3",
]
[[package]]
name = "futures-sink"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-timer"
@@ -1472,9 +1472,9 @@ checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
[[package]]
name = "futures-util"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-channel",
"futures-core",
@@ -2581,6 +2581,7 @@ dependencies = [
"cookie",
"crypto_box",
"ed25519-dalek",
"flate2",
"futures",
"gateway-admin",
"gateway-core",
@@ -2608,6 +2609,7 @@ dependencies = [
"tracing-subscriber",
"tungstenite 0.27.0",
"url",
"urlencoding",
"uuid",
"wiremock",
"zstd",
@@ -3623,7 +3625,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -3631,18 +3633,18 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.19"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.19"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
@@ -4078,6 +4080,12 @@ dependencies = [
"serde",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf-8"
version = "0.7.6"
+5 -2
View File
@@ -135,7 +135,10 @@ fn config_loader_should_reject_removed_fingerprint_section() {
#[test]
fn config_loader_should_reject_invalid_codex_cli_version() {
assert_rejected(valid_config().replace("codex_version: '0.146.0'", "codex_version: 'latest'"));
assert_rejected(valid_config().replace(
"codex_version: '0.147.0-alpha.6.6'",
"codex_version: 'latest'",
));
}
#[test]
@@ -154,7 +157,7 @@ fn config_loader_should_reject_zero_server_port() {
#[test]
fn config_loader_should_reject_invalid_desktop_profile_fields() {
assert_rejected(valid_config().replace("desktop_build: '6119'", "desktop_build: 'build'"));
assert_rejected(valid_config().replace("desktop_build: '6415'", "desktop_build: 'build'"));
}
fn assert_rejected(config: String) {
@@ -608,8 +608,9 @@ pub struct ProviderQuota {
impl ProviderQuota {
/// 返回账号展开面板使用的当前额度窗口用量。
///
/// 只选择具备完整时间边界且可按账号归属的窗口;优先级与 Dashboard
/// 代表性额度窗口一致,同一优先级保持 Provider 投影顺序。
/// Provider 已提供账号级本地用量时,固定窗口和无固定重置点的滚动窗口
/// 都可作为代表用量;优先级与 Dashboard 代表性额度窗口一致,
/// 同一优先级保持 Provider 投影顺序。
#[must_use]
pub fn representative_window_usage(&self) -> Option<&AccountUsage> {
self.windows
@@ -618,7 +619,6 @@ impl ProviderQuota {
.filter(|(_, window)| {
window.local_usage_attribution == QuotaLocalUsageAttribution::AccountWide
&& window.window_seconds.is_some()
&& window.reset_at.is_some()
&& window.local_usage.is_some()
})
.min_by_key(|(index, window)| (quota_usage_priority(window), *index))
@@ -31,23 +31,51 @@ pub enum ProviderAdminErrorKind {
Internal,
}
/// 不泄漏 OAuth material 与 Provider 响应体的管理错误。
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
/// 不携带 OAuth 请求材料的管理错误。
///
/// `message` 用于当前认证管理请求展示;上游没有标准 `error.message` 时,
/// Provider 可以按其官方契约回退为完整的非成功响应正文。
#[derive(Clone, PartialEq, Eq, thiserror::Error)]
#[error("provider admin operation failed: {kind:?}")]
pub struct ProviderAdminError {
kind: ProviderAdminErrorKind,
message: Option<String>,
}
impl std::fmt::Debug for ProviderAdminError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ProviderAdminError")
.field("kind", &self.kind)
.field("message", &self.message.as_ref().map(|_| "<redacted>"))
.finish()
}
}
impl ProviderAdminError {
#[must_use]
pub const fn new(kind: ProviderAdminErrorKind) -> Self {
Self { kind }
Self {
kind,
message: None,
}
}
#[must_use]
pub fn with_message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
#[must_use]
pub const fn kind(&self) -> ProviderAdminErrorKind {
self.kind
}
#[must_use]
pub fn message(&self) -> Option<&str> {
self.message.as_deref()
}
}
/// 一个具体 Provider 对管理控制面提供的解析、验证、上游交互与运行时资源回收能力。
@@ -565,11 +565,7 @@ impl AccountsService for DefaultAccountsService {
let upstream_body = error
.upstream_response()
.map(|response| String::from_utf8_lossy(response.body()).into_owned());
let message = upstream_body
.as_deref()
.filter(|body| !body.is_empty())
.unwrap_or_else(|| error.client_message())
.to_owned();
let message = error.client_message().to_owned();
vec![AccountConnectionTestEvent::Failed {
message,
provider_error_code: error.client_error_code().map(ToOwned::to_owned),
@@ -59,7 +59,11 @@ fn map_provider_error(
ProviderAdminErrorKind::Unavailable => AdminErrorKind::Unavailable,
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
};
AdminError::new(kind, format!("{resource} operation failed"))
let message = error
.message()
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("{resource} operation failed"));
AdminError::new(kind, message)
}
async fn publish_committed(
@@ -6,6 +6,7 @@ use gateway_core::engine::credential::OpaqueProviderData;
use gateway_admin::{
model::{
PageSize, Revision,
accounts::AccountUsage,
auth::LoginCommand,
client_keys::ClientKeyPageSize,
observability::{DecimalAmount, RequestOutcome, TimeRange},
@@ -135,6 +136,51 @@ fn representative_quota_should_prefer_short_window_and_highest_usage() {
assert_eq!(quota.representative_used_percent(), Some(45.0));
}
#[test]
fn representative_window_usage_should_accept_account_wide_rolling_window_without_reset() {
let usage = AccountUsage {
account_id: "acct_xai".to_owned(),
request_count: 5,
success_count: 5,
input_tokens: Some(100),
output_tokens: Some(20),
cached_tokens: Some(80),
cache_write_tokens: Some(0),
reasoning_tokens: Some(0),
image_input_tokens: Some(0),
image_output_tokens: Some(0),
image_request_count: 0,
image_request_failed_count: 0,
total_tokens: Some(120),
cost_coverage: Default::default(),
costs: Vec::new(),
last_used_at: Some(Utc::now()),
request_buckets: Vec::new(),
models: Vec::new(),
};
let quota = ProviderQuota {
observed_at: None,
refresh_token_expires_at: None,
windows: vec![ProviderQuotaWindow {
key: "free-rolling-24h".to_owned(),
group: "shortTerm".to_owned(),
label: "日限额".to_owned(),
source: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(86_400),
used_percent: None,
reset_at: None,
limit_reached: false,
local_usage: Some(usage.clone()),
provider_data: None,
}],
limit_reached: false,
provider_data: None,
};
assert_eq!(quota.representative_window_usage(), Some(&usage));
}
#[test]
fn exhausted_quota_should_project_full_usage_to_only_the_representative_window() {
let mut quota = ProviderQuota {
@@ -60,7 +60,7 @@ pub(super) type EventLog = Arc<Mutex<Vec<&'static str>>>;
pub(super) struct FakeProviderAdmin {
kind: ProviderKind,
events: EventLog,
failure: Mutex<Option<ProviderAdminErrorKind>>,
failure: Mutex<Option<ProviderAdminError>>,
quota_failure: Mutex<Option<ProviderAdminErrorKind>>,
pending: Arc<Mutex<Option<PendingAuthorizationMutation>>>,
retry_authorization_after_abort: Mutex<bool>,
@@ -89,7 +89,12 @@ impl FakeProviderAdmin {
}
pub(super) fn fail_next(&self, kind: ProviderAdminErrorKind) {
*self.failure.lock().expect("provider failure") = Some(kind);
*self.failure.lock().expect("provider failure") = Some(ProviderAdminError::new(kind));
}
fn fail_next_with_message(&self, kind: ProviderAdminErrorKind, message: &str) {
*self.failure.lock().expect("provider failure") =
Some(ProviderAdminError::new(kind).with_message(message));
}
pub(super) fn fail_next_quota(&self, kind: ProviderAdminErrorKind) {
@@ -145,7 +150,7 @@ impl FakeProviderAdmin {
fn require_available(&self) -> Result<(), ProviderAdminError> {
match self.failure.lock().expect("provider failure").take() {
Some(kind) => Err(ProviderAdminError::new(kind)),
Some(error) => Err(error),
None => Ok(()),
}
}
@@ -1650,6 +1655,32 @@ async fn accounts_refresh_provider_failure_should_not_call_store_commit() {
);
}
#[tokio::test]
async fn accounts_refresh_should_preserve_the_provider_failure_message() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let upstream_message = "Your refresh token has already been used.";
provider.fail_next_with_message(ProviderAdminErrorKind::Conflict, upstream_message);
let store = FakeAccountStore::new("openai", events.clone());
let services = accounts_service(provider, store).await;
let error = services
.accounts()
.refresh(
&context("refresh-provider-message"),
ProviderAccountId::new("acct_test").expect("account ID"),
)
.await
.expect_err("Provider preparation must fail");
assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Conflict);
assert_eq!(error.to_string(), upstream_message);
assert_eq!(
recorded(&events),
["store.load_account", "provider.prepare_refresh"]
);
}
#[tokio::test]
async fn accounts_refresh_store_failure_should_drop_guard_after_commit_attempt() {
let events = events();
@@ -946,7 +946,28 @@ impl fmt::Debug for LoadedCredential {
}
}
/// 刷新成功后的完整 CAS 写回。
/// 与 credential revision CAS 同事务提交的账号错误事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialStateWrite {
pub credential_state: CredentialState,
pub observed_at: SystemTime,
pub error_reason: Option<AccountErrorReason>,
pub message: Option<String>,
}
/// [`CredentialCasUpdate`] 跨 store 边界的命名字段。
pub struct CredentialCasUpdateParts {
pub account_id: ProviderAccountId,
pub expected_revision: CredentialRevision,
pub profile: ProviderAccountUpdate,
pub credential: PlaintextCredential,
pub has_refresh_token: bool,
pub access_token_expires_at: Option<SystemTime>,
pub next_refresh_at: Option<SystemTime>,
pub account_state: Option<Box<CredentialStateWrite>>,
}
/// 刷新后的完整 CAS 写回。
#[derive(Clone, PartialEq)]
pub struct CredentialCasUpdate {
account_id: ProviderAccountId,
@@ -956,6 +977,7 @@ pub struct CredentialCasUpdate {
has_refresh_token: bool,
access_token_expires_at: Option<SystemTime>,
next_refresh_at: Option<SystemTime>,
account_state: Option<Box<CredentialStateWrite>>,
}
impl fmt::Debug for CredentialCasUpdate {
@@ -969,6 +991,7 @@ impl fmt::Debug for CredentialCasUpdate {
.field("has_refresh_token", &self.has_refresh_token)
.field("access_token_expires_at", &self.access_token_expires_at)
.field("next_refresh_at", &self.next_refresh_at)
.field("account_state", &self.account_state)
.finish()
}
}
@@ -1002,9 +1025,34 @@ impl CredentialCasUpdate {
has_refresh_token,
access_token_expires_at,
next_refresh_at,
account_state: None,
})
}
/// 将刷新调度与账号错误事实放入同一个 revision CAS。
#[must_use]
pub fn with_account_state(
mut self,
credential_state: CredentialState,
observed_at: SystemTime,
error_reason: Option<AccountErrorReason>,
message: Option<String>,
) -> Self {
let message = message.filter(|value| !value.trim().is_empty());
let error_reason = if credential_state == CredentialState::Ready {
message.as_ref().and(error_reason)
} else {
error_reason.or_else(|| credential_state.error_reason())
};
self.account_state = Some(Box::new(CredentialStateWrite {
credential_state,
observed_at,
error_reason,
message,
}));
self
}
#[must_use]
pub const fn account_id(&self) -> &ProviderAccountId {
&self.account_id
@@ -1041,26 +1089,17 @@ impl CredentialCasUpdate {
}
#[must_use]
pub fn into_parts(
self,
) -> (
ProviderAccountId,
CredentialRevision,
ProviderAccountUpdate,
PlaintextCredential,
bool,
Option<SystemTime>,
Option<SystemTime>,
) {
(
self.account_id,
self.expected_revision,
self.profile,
self.credential,
self.has_refresh_token,
self.access_token_expires_at,
self.next_refresh_at,
)
pub fn into_parts(self) -> CredentialCasUpdateParts {
CredentialCasUpdateParts {
account_id: self.account_id,
expected_revision: self.expected_revision,
profile: self.profile,
credential: self.credential,
has_refresh_token: self.has_refresh_token,
access_token_expires_at: self.access_token_expires_at,
next_refresh_at: self.next_refresh_at,
account_state: self.account_state,
}
}
}
@@ -1102,6 +1141,14 @@ pub enum QuotaWriteOutcome {
Conflict,
}
/// 只推进 Provider quota 文档的最后成功查询时间。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QuotaObservationTouch {
pub account_id: ProviderAccountId,
pub expected_revision: CredentialRevision,
pub observed_at: SystemTime,
}
/// 不改 Provider 原始 quota JSON 的额度访问事实写入。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QuotaAccessChange {
@@ -1117,9 +1164,13 @@ pub struct AccountStateChange {
pub expected_revision: CredentialRevision,
pub credential_state: CredentialState,
pub observed_at: SystemTime,
/// 受控错误原因;恢复 Ready 时必须清空。
/// 受控错误原因;无失败事实的 Ready 写入必须清空。
///
/// Ready 凭据可以在 AT 过期后继续 RT 恢复,此时允许保留最近一次
/// 刷新失败原因,供错误状态投影展示。
pub error_reason: Option<AccountErrorReason>,
/// 已脱敏的上游诊断信息;恢复 Ready 时必须清空。
/// 供管理端展示的错误消息;结构化上游失败应保留原始 message,不能写入整个正文。
/// 刷新成功或其他无失败事实的 Ready 写入必须清空。
pub message: Option<String>,
}
@@ -1170,6 +1221,11 @@ pub trait ProviderAccountStore: Send + Sync {
observation: QuotaObservation,
) -> Result<QuotaWriteOutcome, StoreError>;
async fn touch_quota_observation(
&self,
touch: QuotaObservationTouch,
) -> Result<QuotaWriteOutcome, StoreError>;
async fn apply_quota_access(
&self,
change: QuotaAccessChange,
@@ -1588,14 +1644,26 @@ impl AccountSelector {
let candidate = match context.policy.strategy() {
RotationStrategy::QuotaResetPriority => {
eligible.sort_by_key(|candidate| {
let default_concurrency = context.policy.max_concurrent_per_account();
eligible.sort_by(|left, right| {
(
candidate.signals.quota_reset_at.is_none(),
candidate.signals.quota_reset_at,
candidate.signals.in_flight,
candidate.signals.last_started_at,
candidate.account.id().clone(),
left.signals.quota_reset_at.is_none(),
left.signals.quota_reset_at,
)
.cmp(&(
right.signals.quota_reset_at.is_none(),
right.signals.quota_reset_at,
))
.then_with(|| {
capacity_utilization(left, default_concurrency)
.total_cmp(&capacity_utilization(right, default_concurrency))
})
.then_with(|| {
left.signals
.last_started_at
.cmp(&right.signals.last_started_at)
})
.then_with(|| left.account.id().cmp(right.account.id()))
});
eligible.first().copied()?
}
@@ -1604,9 +1672,11 @@ impl AccountSelector {
let index = context.round_robin_cursor as usize % eligible.len();
eligible.get(index).copied()?
}
RotationStrategy::Smart => {
select_smart_candidate(&eligible, context.round_robin_cursor)?
}
RotationStrategy::Smart => select_smart_candidate(
&eligible,
context.policy.max_concurrent_per_account(),
context.round_robin_cursor,
)?,
RotationStrategy::Sticky => {
eligible.sort_by_key(|candidate| {
(
@@ -1676,8 +1746,18 @@ const SMART_QUOTA_WEIGHT: f64 = 0.8;
const SMART_FAILURE_WEIGHT: f64 = 1.0;
const SMART_LATENCY_WEIGHT: f64 = 0.5;
fn capacity_utilization(candidate: &AccountCandidate, default_concurrency: NonZeroU32) -> f64 {
f64::from(candidate.signals.in_flight)
/ f64::from(
candidate
.account
.effective_concurrency(default_concurrency)
.get(),
)
}
struct SmartNormalization {
max_in_flight: u32,
max_capacity_utilization: f64,
min_quota: Option<u64>,
max_quota: Option<u64>,
min_latency_ms: Option<u64>,
@@ -1685,11 +1765,11 @@ struct SmartNormalization {
}
impl SmartNormalization {
fn from_candidates(candidates: &[&AccountCandidate]) -> Self {
let max_in_flight = candidates
fn from_candidates(candidates: &[&AccountCandidate], default_concurrency: NonZeroU32) -> Self {
let max_capacity_utilization = candidates
.iter()
.map(|candidate| candidate.signals.in_flight)
.max()
.map(|candidate| capacity_utilization(candidate, default_concurrency))
.reduce(f64::max)
.unwrap_or_default();
let min_quota = candidates
.iter()
@@ -1710,7 +1790,7 @@ impl SmartNormalization {
.filter(|latency| *latency > 0)
.max();
Self {
max_in_flight,
max_capacity_utilization,
min_quota,
max_quota,
min_latency_ms,
@@ -1721,12 +1801,18 @@ impl SmartNormalization {
fn select_smart_candidate<'a>(
candidates: &[&'a AccountCandidate],
default_concurrency: NonZeroU32,
cursor: u64,
) -> Option<&'a AccountCandidate> {
let normalization = SmartNormalization::from_candidates(candidates);
let normalization = SmartNormalization::from_candidates(candidates, default_concurrency);
let mut ranked = candidates
.iter()
.map(|candidate| (*candidate, smart_score(candidate, &normalization)))
.map(|candidate| {
(
*candidate,
smart_score(candidate, default_concurrency, &normalization),
)
})
.collect::<Vec<_>>();
ranked.sort_by(|(left, left_score), (right, right_score)| {
right_score
@@ -1742,11 +1828,14 @@ fn select_smart_candidate<'a>(
Some(ranked[index].0)
}
fn smart_score(candidate: &AccountCandidate, normalization: &SmartNormalization) -> f64 {
let load = lower_is_better(
u64::from(candidate.signals.in_flight),
0,
u64::from(normalization.max_in_flight),
fn smart_score(
candidate: &AccountCandidate,
default_concurrency: NonZeroU32,
normalization: &SmartNormalization,
) -> f64 {
let load = lower_ratio_is_better(
capacity_utilization(candidate, default_concurrency),
normalization.max_capacity_utilization,
);
let quota = candidate.signals.quota_remaining_rank.map_or(0.5, |quota| {
higher_is_better(
@@ -1781,6 +1870,13 @@ fn smart_score(candidate: &AccountCandidate, normalization: &SmartNormalization)
+ SMART_LATENCY_WEIGHT * latency
}
fn lower_ratio_is_better(value: f64, maximum: f64) -> f64 {
if maximum <= 0.0 {
return 1.0;
}
1.0 - (value / maximum).clamp(0.0, 1.0)
}
fn lower_is_better(value: u64, minimum: u64, maximum: u64) -> f64 {
if maximum <= minimum {
return 1.0;
@@ -407,6 +407,84 @@ pub trait ProviderCatalogCachePort: Send + Sync {
) -> BoxFuture<'a, Result<Option<OpaqueProviderData>, ProviderStoreError>>;
}
/// Provider 从官方制品核验出的可重建请求画像。
///
/// Core 只用单调制品序号约束覆盖顺序;具体版本字段由对应 Provider 放在
/// `profile` 中解释。每个 Provider 在 Store 中只保留一份最新画像。
#[derive(Clone, PartialEq)]
pub struct ProviderArtifactProfile {
provider_kind: ProviderKind,
artifact_sequence: u64,
verified_at: SystemTime,
profile: OpaqueProviderData,
}
impl ProviderArtifactProfile {
#[must_use]
pub const fn new(
provider_kind: ProviderKind,
artifact_sequence: u64,
verified_at: SystemTime,
profile: OpaqueProviderData,
) -> Self {
Self {
provider_kind,
artifact_sequence,
verified_at,
profile,
}
}
#[must_use]
pub const fn provider_kind(&self) -> &ProviderKind {
&self.provider_kind
}
#[must_use]
pub const fn artifact_sequence(&self) -> u64 {
self.artifact_sequence
}
#[must_use]
pub const fn verified_at(&self) -> SystemTime {
self.verified_at
}
#[must_use]
pub const fn profile(&self) -> &OpaqueProviderData {
&self.profile
}
}
impl fmt::Debug for ProviderArtifactProfile {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ProviderArtifactProfile")
.field("provider_kind", &self.provider_kind)
.field("artifact_sequence", &self.artifact_sequence)
.field("verified_at", &self.verified_at)
.field("profile", &"[PROVIDER-OWNED]")
.finish()
}
}
pub trait ProviderArtifactProfileCachePort: Send + Sync {
/// 覆盖同一 Provider 的固定 cache key。
///
/// 返回 `false` 表示 Store 已持有更高的制品序号;相同序号但内容不同必须返回
/// [`ProviderStoreErrorKind::Conflict`],不能静默改写已核验画像。
fn replace_if_newer(
&self,
profile: ProviderArtifactProfile,
ttl: Duration,
) -> BoxFuture<'_, Result<bool, ProviderStoreError>>;
fn read<'a>(
&'a self,
provider_kind: &'a ProviderKind,
) -> BoxFuture<'a, Result<Option<ProviderArtifactProfile>, ProviderStoreError>>;
}
/// Redis 中可重建的账号状态投影。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderCredentialState {
@@ -946,6 +1024,7 @@ pub struct ProviderStorePorts {
session_exclusions: Arc<dyn ProviderSessionExclusionPort>,
account_feedback: Arc<AccountFeedbackStats>,
catalog_cache: Arc<dyn ProviderCatalogCachePort>,
artifact_profiles: Arc<dyn ProviderArtifactProfileCachePort>,
credential_state: Arc<dyn ProviderCredentialStatePort>,
cooldowns: Arc<dyn ProviderCooldownPort>,
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
@@ -962,6 +1041,7 @@ impl ProviderStorePorts {
session_affinity: Arc<dyn ProviderSessionAffinityPort>,
session_exclusions: Arc<dyn ProviderSessionExclusionPort>,
catalog_cache: Arc<dyn ProviderCatalogCachePort>,
artifact_profiles: Arc<dyn ProviderArtifactProfileCachePort>,
credential_state: Arc<dyn ProviderCredentialStatePort>,
cooldowns: Arc<dyn ProviderCooldownPort>,
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
@@ -974,6 +1054,7 @@ impl ProviderStorePorts {
session_exclusions,
account_feedback: Arc::new(AccountFeedbackStats::default()),
catalog_cache,
artifact_profiles,
credential_state,
cooldowns,
runtime_policy,
@@ -1011,6 +1092,11 @@ impl ProviderStorePorts {
Arc::clone(&self.catalog_cache)
}
#[must_use]
pub fn artifact_profiles(&self) -> Arc<dyn ProviderArtifactProfileCachePort> {
Arc::clone(&self.artifact_profiles)
}
#[must_use]
pub fn credential_state(&self) -> Arc<dyn ProviderCredentialStatePort> {
Arc::clone(&self.credential_state)
@@ -51,6 +51,15 @@ fn candidate(id: &str, in_flight: u32, remaining: Option<u64>) -> AccountCandida
}
}
fn candidate_with_concurrency(id: &str, in_flight: u32, concurrency: u32) -> AccountCandidate {
let mut candidate = candidate(id, in_flight, Some(100));
candidate.account = candidate.account.with_scheduling(
Some(AccountConcurrencyLimit::new(concurrency).expect("concurrency override")),
AccountWeight::DEFAULT,
);
candidate
}
fn weighted_candidate(id: &str, weight: u16, in_flight: u32) -> AccountCandidate {
let mut candidate = candidate(id, in_flight, None);
candidate.account = candidate.account.with_scheduling(
@@ -348,6 +357,20 @@ fn smart_selector_should_prefer_lower_inflight_count() {
assert_eq!(selected.candidate().account.id().as_str(), "acct_idle");
}
#[test]
fn smart_selector_should_prefer_lower_capacity_utilization_over_lower_inflight_count() {
let candidates = [
candidate_with_concurrency("acct_smaller", 1, 2),
candidate_with_concurrency("acct_larger", 2, 10),
];
let selected = AccountSelector
.select(&candidates, &context(RotationStrategy::Smart))
.expect("candidate available");
assert_eq!(selected.candidate().account.id().as_str(), "acct_larger");
}
#[test]
fn selector_should_prioritize_the_highest_weight_for_every_rotation_strategy() {
let candidates = vec![
@@ -622,6 +645,22 @@ fn quota_reset_selector_should_prefer_known_earliest_window() {
assert_eq!(selected.candidate().account.id().as_str(), "acct_earlier");
}
#[test]
fn quota_reset_selector_should_use_capacity_utilization_as_load_tiebreaker() {
let reset_at = SystemTime::now() + Duration::from_secs(60);
let mut smaller = candidate_with_concurrency("acct_smaller", 1, 2);
smaller.signals.quota_reset_at = Some(reset_at);
let mut larger = candidate_with_concurrency("acct_larger", 2, 10);
larger.signals.quota_reset_at = Some(reset_at);
let candidates = [smaller, larger];
let selected = AccountSelector
.select(&candidates, &context(RotationStrategy::QuotaResetPriority))
.expect("candidate available");
assert_eq!(selected.candidate().account.id().as_str(), "acct_larger");
}
#[test]
fn smart_selector_should_prefer_known_quota_over_unknown_after_load_is_equal() {
let candidates = vec![
@@ -70,7 +70,7 @@ pub const MAX_SSE_EVENT_BUFFER_BYTES: usize = 64 * 1024 * 1024;
pub const DONE_SSE_FRAME: &str = "data: [DONE]\n\n";
/// 用于处理任意分块边界的增量 SSE 解码器。
#[derive(Debug, Default)]
#[derive(Debug)]
pub struct SseEventDecoder {
pending: Vec<u8>,
/// `pending` 中已确认不含帧分隔符的前缀长度。新数据到达时从该边界
@@ -79,6 +79,19 @@ pub struct SseEventDecoder {
/// 超大未完成帧已开始直接交付;在遇到帧分隔符前只保留跨 chunk 扫描尾部,
/// 不再尝试解析该帧。
opaque_frame: bool,
/// 只有整个 SSE 流的第一条物理行允许携带 UTF-8 BOM。
stream_start: bool,
}
impl Default for SseEventDecoder {
fn default() -> Self {
Self {
pending: Vec::new(),
scanned: 0,
opaque_frame: false,
stream_start: true,
}
}
}
impl SseEventDecoder {
@@ -115,7 +128,8 @@ impl SseEventDecoder {
while let Some(end) = self.next_frame_end(consumed) {
let frame = std::str::from_utf8(&self.pending[consumed..end])
.map_err(|error| SseError::ParseError(error.to_string()))?;
events.extend(parse_sse_events(frame)?);
events.extend(parse_sse_events_inner(frame, self.stream_start)?);
self.stream_start = false;
consumed = end;
}
@@ -149,9 +163,10 @@ impl SseEventDecoder {
} else {
std::str::from_utf8(&raw)
.ok()
.and_then(|frame| parse_sse_events(frame).ok())
.and_then(|frame| parse_sse_events_inner(frame, self.stream_start).ok())
.unwrap_or_default()
};
self.stream_start = false;
frames.push(SseFrame::new(raw, events));
consumed = end;
}
@@ -173,7 +188,9 @@ impl SseEventDecoder {
let pending = std::mem::take(&mut self.pending);
let frame = std::str::from_utf8(&pending)
.map_err(|error| SseError::ParseError(error.to_string()))?;
parse_sse_events(frame)
let events = parse_sse_events_inner(frame, self.stream_start)?;
self.stream_start = false;
Ok(events)
}
/// 在流结束时返回未带空行分隔符的最后一帧及其旁路解析结果。
@@ -191,9 +208,10 @@ impl SseEventDecoder {
} else {
std::str::from_utf8(&raw)
.ok()
.and_then(|frame| parse_sse_events(frame).ok())
.and_then(|frame| parse_sse_events_inner(frame, self.stream_start).ok())
.unwrap_or_default()
};
self.stream_start = false;
self.opaque_frame = false;
vec![SseFrame::new(raw, events)]
}
@@ -207,6 +225,7 @@ impl SseEventDecoder {
let remaining = self.pending.split_off(end);
let raw = std::mem::replace(&mut self.pending, remaining);
frames.push(SseFrame::new(raw, Vec::new()));
self.stream_start = false;
self.opaque_frame = false;
self.scanned = 0;
true
@@ -226,6 +245,7 @@ impl SseEventDecoder {
let raw = std::mem::replace(&mut self.pending, tail);
self.scanned = 0;
frames.push(SseFrame::new(raw, Vec::new()));
self.stream_start = false;
}
}
@@ -351,12 +371,21 @@ impl EventBuilder {
/// 解析 SSE 事件流。
pub fn parse_sse_events(input: &str) -> Result<Vec<SseEvent>, SseError> {
parse_sse_events_inner(input, true)
}
fn parse_sse_events_inner(input: &str, strip_leading_bom: bool) -> Result<Vec<SseEvent>, SseError> {
let mut events = Vec::new();
let mut builder = EventBuilder::default();
let mut saw_sse_syntax = false;
let mut event_buffer_bytes = 0usize;
for raw_line in input.lines() {
for (index, raw_line) in input.lines().enumerate() {
let raw_line = if strip_leading_bom && index == 0 {
raw_line.strip_prefix('\u{feff}').unwrap_or(raw_line)
} else {
raw_line
};
let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
if line.is_empty() {
event_buffer_bytes = 0;
@@ -31,6 +31,50 @@ fn parser_should_preserve_multiline_data_id_and_retry() {
);
}
#[test]
fn parser_should_ignore_a_leading_utf8_bom() {
let input = concat!(
"\u{feff}event: response.created\n",
"data: {\"type\":\"response.created\"}\n\n",
);
assert_eq!(
parse_sse_events(input).expect("SSE with BOM"),
vec![SseEvent {
event: Some("response.created".to_owned()),
data: "{\"type\":\"response.created\"}".to_owned(),
id: None,
retry: None,
}]
);
}
#[test]
fn incremental_decoder_should_ignore_bom_only_at_the_stream_start() {
let mut decoder = SseEventDecoder::default();
let first = decoder
.push(
concat!(
"\u{feff}event: response.created\n",
"data: {\"type\":\"response.created\"}\n\n",
)
.as_bytes(),
)
.expect("first SSE frame");
let second = decoder
.push(
concat!(
"\u{feff}event: response.completed\n",
"data: {\"type\":\"response.completed\"}\n\n",
)
.as_bytes(),
)
.expect("second SSE frame");
assert_eq!(first[0].event.as_deref(), Some("response.created"));
assert_eq!(second[0].event, None);
}
#[test]
fn parser_should_ignore_done_control_frame() {
assert!(
@@ -79,6 +79,10 @@ pub async fn initialize(mut config: StoreConfig) -> StoreResult<StoreBundle> {
redis_connection.clone(),
REDIS_NAMESPACE,
)?);
let artifact_profiles = Arc::new(redis::RedisProviderArtifactProfileRepository::new(
redis_connection.clone(),
REDIS_NAMESPACE,
)?);
let runtime_policy = Arc::new(postgres::PgRuntimeSettingsRepository::new(pool.clone()));
let oauth_pending = Arc::new(redis::RedisOAuthPendingFlowRepository::new(
redis_connection.clone(),
@@ -167,6 +171,7 @@ pub async fn initialize(mut config: StoreConfig) -> StoreResult<StoreBundle> {
REDIS_NAMESPACE,
)?),
credential_state.clone(),
artifact_profiles,
credential_state,
cooldowns,
runtime_policy,
@@ -103,7 +103,7 @@ impl ProviderAccountStore for PgProviderAccountRepository {
&self,
update: CredentialCasUpdate,
) -> Result<CredentialCasOutcome, CoreStoreError> {
let (
let CredentialCasUpdateParts {
account_id,
expected_revision,
profile,
@@ -111,7 +111,8 @@ impl ProviderAccountStore for PgProviderAccountRepository {
has_refresh_token,
access_token_expires_at,
next_refresh_at,
) = update.into_parts();
account_state,
} = update.into_parts();
if profile.account_id != account_id {
return Err(CoreStoreError::new(CoreStoreErrorKind::InvalidData));
}
@@ -122,13 +123,38 @@ impl ProviderAccountStore for PgProviderAccountRepository {
CREDENTIALS_MAX_BYTES,
)
.map_err(core_store_error)?;
let credential_state = account_state
.as_ref()
.map(|state| state.credential_state.as_str());
let credential_observed_at = account_state
.as_ref()
.map(|state| DateTime::<Utc>::from(state.observed_at));
let error_reason = account_state
.as_ref()
.and_then(|state| state.error_reason.map(AccountErrorReason::as_str));
let message = account_state
.as_ref()
.and_then(|state| state.message.as_deref());
let next = sqlx::query_scalar::<_, i64>(
"update provider_accounts
set name = $3, email = $4, plan_type = $5,
provider_credentials_json = $6,
credential_revision = credential_revision + 1,
has_refresh_token = $7, access_token_expires_at = $8,
next_refresh_at = $9, updated_at = now()
next_refresh_at = $9,
credential_state = case
when $10::text is not null and enabled and upstream_user_id is not null
then $10 else credential_state end,
credential_observed_at = case
when $10::text is not null and enabled and upstream_user_id is not null
then $11 else credential_observed_at end,
last_error_reason = case
when $10::text is not null and enabled and upstream_user_id is not null
then $12 else last_error_reason end,
last_error_message = case
when $10::text is not null and enabled and upstream_user_id is not null
then $13 else last_error_message end,
updated_at = greatest(now(), coalesce($11, now()))
where id = $1 and credential_revision = $2
returning credential_revision",
)
@@ -141,6 +167,10 @@ impl ProviderAccountStore for PgProviderAccountRepository {
.bind(has_refresh_token)
.bind(access_token_expires_at.map(DateTime::<Utc>::from))
.bind(next_refresh_at.map(DateTime::<Utc>::from))
.bind(credential_state)
.bind(credential_observed_at)
.bind(error_reason)
.bind(message)
.fetch_optional(&self.pool)
.await
.map_err(|_| CoreStoreError::new(CoreStoreErrorKind::Unavailable))?;
@@ -280,6 +310,25 @@ impl ProviderAccountStore for PgProviderAccountRepository {
})
}
async fn touch_quota_observation(
&self,
touch: QuotaObservationTouch,
) -> Result<QuotaWriteOutcome, CoreStoreError> {
let updated = self
.touch_provider_quota_observation(
touch.account_id.as_str(),
Revision::new(touch.expected_revision.get()).map_err(core_store_error)?,
DateTime::<Utc>::from(touch.observed_at),
)
.await
.map_err(core_store_error)?;
Ok(if updated {
QuotaWriteOutcome::Updated
} else {
QuotaWriteOutcome::Conflict
})
}
async fn apply_state_change(&self, change: AccountStateChange) -> Result<(), CoreStoreError> {
let updated = self
.apply_provider_account_state(ProviderAccountStateUpdate {
@@ -38,12 +38,13 @@ use sqlx::{PgPool, Postgres, Row, Transaction};
use gateway_core::engine::credential::{
AccountConcurrencyLimit, AccountErrorReason, AccountStateChange, AccountWeight,
CredentialCasOutcome, CredentialCasUpdate, CredentialRevision as CoreCredentialRevision,
CredentialState, LoadedCredential, NewProviderAccount as CoreNewProviderAccount,
OpaqueProviderData, PlaintextCredential, ProviderAccount as CoreProviderAccount,
ProviderAccountId as CoreProviderAccountId, ProviderAccountIdentity, ProviderAccountStore,
CredentialCasOutcome, CredentialCasUpdate, CredentialCasUpdateParts,
CredentialRevision as CoreCredentialRevision, CredentialState, LoadedCredential,
NewProviderAccount as CoreNewProviderAccount, OpaqueProviderData, PlaintextCredential,
ProviderAccount as CoreProviderAccount, ProviderAccountId as CoreProviderAccountId,
ProviderAccountIdentity, ProviderAccountStore,
ProviderAccountUpdate as CoreProviderAccountUpdate, QuotaAccessChange, QuotaAccessState,
QuotaEvidence, QuotaObservation, QuotaState, QuotaWriteOutcome,
QuotaEvidence, QuotaObservation, QuotaObservationTouch, QuotaState, QuotaWriteOutcome,
};
use gateway_core::error::{StoreError as CoreStoreError, StoreErrorKind as CoreStoreErrorKind};
use gateway_core::routing::{AccountGroupId, ProviderKind};
@@ -29,6 +29,12 @@ pub trait ProviderAccountRepository: Send + Sync {
observed_at: DateTime<Utc>,
state: QuotaState,
) -> StoreResult<bool>;
async fn touch_provider_quota_observation(
&self,
account_id: &str,
expected_revision: Revision,
observed_at: DateTime<Utc>,
) -> StoreResult<bool>;
async fn apply_provider_quota_access(
&self,
account_id: &str,
@@ -237,13 +243,11 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
else credential_observed_at
end,
last_error_reason = case
when enabled and upstream_user_id is not null then
case when $3 = 'ready' then null else $5 end
when enabled and upstream_user_id is not null then $5
else last_error_reason
end,
last_error_message = case
when enabled and upstream_user_id is not null then
case when $3 = 'ready' then null else $6 end
when enabled and upstream_user_id is not null then $6
else last_error_message
end,
updated_at = case when enabled then greatest(now(), $4) else updated_at end
@@ -354,6 +358,29 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
Ok(result.rows_affected() == 1)
}
async fn touch_provider_quota_observation(
&self,
account_id: &str,
expected_revision: Revision,
observed_at: DateTime<Utc>,
) -> StoreResult<bool> {
require_nonempty(ENTITY, "account_id", account_id)?;
let result = sqlx::query(
"update provider_accounts
set quota_observed_at = $3
where id = $1 and credential_revision = $2
and provider_quota_json is not null
and (quota_observed_at is null or quota_observed_at <= $3)",
)
.bind(account_id)
.bind(to_i64(expected_revision.get())?)
.bind(observed_at)
.execute(&self.pool)
.await
.map_err(|_| postgres_unavailable("touch provider quota observation"))?;
Ok(result.rows_affected() == 1)
}
async fn delete_provider_account(&self, id: &str) -> StoreResult<bool> {
require_nonempty(ENTITY, "id", id)?;
let result = sqlx::query("delete from provider_accounts where id = $1 and not enabled")
@@ -0,0 +1,175 @@
//! Provider 官方制品画像的单键 Redis cache。
use std::time::{Duration, SystemTime};
use gateway_core::engine::credential::OpaqueProviderData;
use gateway_core::provider_ports::{
ProviderArtifactProfile, ProviderArtifactProfileCachePort, ProviderStoreError,
ProviderStoreErrorKind,
};
use gateway_core::routing::ProviderKind;
use redis::{Script, aio::ConnectionManager};
use crate::StoreResult;
use super::{MAX_REDIS_EXACT_INTEGER, namespace};
const MAX_PROFILE_BYTES: usize = 16 * 1024;
const REPLACE_SCRIPT: &str = r#"
local current_sequence = redis.call('HGET', KEYS[1], 'artifact_sequence')
if current_sequence then
local current_number = tonumber(current_sequence)
local candidate_number = tonumber(ARGV[1])
if current_number > candidate_number then return 0 end
if current_number == candidate_number then
local current_profile = redis.call('HGET', KEYS[1], 'profile')
if current_profile ~= ARGV[3] then return -1 end
end
end
redis.call('HSET', KEYS[1],
'artifact_sequence', ARGV[1],
'verified_at_ms', ARGV[2],
'profile', ARGV[3])
redis.call('PEXPIRE', KEYS[1], ARGV[4])
return 1
"#;
/// 每个 Provider 只占一个覆盖写 key;TTL 负责进程长期停机后的最终清理。
#[derive(Clone)]
pub struct RedisProviderArtifactProfileRepository {
connection: ConnectionManager,
namespace: String,
}
impl RedisProviderArtifactProfileRepository {
pub fn new(connection: ConnectionManager, key_namespace: &str) -> StoreResult<Self> {
Ok(Self {
connection,
namespace: namespace(key_namespace)?,
})
}
fn key(&self, provider_kind: &ProviderKind) -> String {
format!(
"{}:provider:{}:artifact-profile:v1",
self.namespace,
provider_kind.as_str()
)
}
}
impl ProviderArtifactProfileCachePort for RedisProviderArtifactProfileRepository {
fn replace_if_newer(
&self,
profile: ProviderArtifactProfile,
ttl: Duration,
) -> futures::future::BoxFuture<'_, Result<bool, ProviderStoreError>> {
Box::pin(async move {
let artifact_sequence = profile.artifact_sequence();
if artifact_sequence == 0 || artifact_sequence > MAX_REDIS_EXACT_INTEGER {
return Err(provider_invalid("validate artifact profile sequence"));
}
let ttl_ms = exact_positive_millis(ttl, "validate artifact profile TTL")?;
let verified_at_ms = profile
.verified_at()
.duration_since(SystemTime::UNIX_EPOCH)
.ok()
.and_then(|duration| u64::try_from(duration.as_millis()).ok())
.filter(|millis| *millis <= MAX_REDIS_EXACT_INTEGER)
.ok_or_else(|| provider_invalid("validate artifact profile timestamp"))?;
let payload = serde_json::to_vec(&serde_json::Value::Object(
profile.profile().expose_to_provider().clone(),
))
.map_err(|_| provider_invalid("encode artifact profile"))?;
if payload.len() > MAX_PROFILE_BYTES {
return Err(provider_invalid("validate artifact profile size"));
}
let mut connection = self.connection.clone();
let outcome = Script::new(REPLACE_SCRIPT)
.key(self.key(profile.provider_kind()))
.arg(artifact_sequence)
.arg(verified_at_ms)
.arg(payload)
.arg(ttl_ms)
.invoke_async::<i64>(&mut connection)
.await
.map_err(|_| provider_unavailable("replace artifact profile"))?;
match outcome {
1 => Ok(true),
0 => Ok(false),
-1 => Err(ProviderStoreError::new(
ProviderStoreErrorKind::Conflict,
"replace artifact profile",
)),
_ => Err(provider_invalid("decode artifact profile write outcome")),
}
})
}
fn read<'a>(
&'a self,
provider_kind: &'a ProviderKind,
) -> futures::future::BoxFuture<'a, Result<Option<ProviderArtifactProfile>, ProviderStoreError>>
{
Box::pin(async move {
let mut connection = self.connection.clone();
let values: (Option<u64>, Option<u64>, Option<Vec<u8>>) = redis::cmd("HMGET")
.arg(self.key(provider_kind))
.arg("artifact_sequence")
.arg("verified_at_ms")
.arg("profile")
.query_async(&mut connection)
.await
.map_err(|_| provider_unavailable("read artifact profile"))?;
let (artifact_sequence, verified_at_ms, payload) = values;
if artifact_sequence.is_none() && verified_at_ms.is_none() && payload.is_none() {
return Ok(None);
}
let (Some(artifact_sequence), Some(verified_at_ms), Some(payload)) =
(artifact_sequence, verified_at_ms, payload)
else {
return Err(provider_invalid("decode artifact profile"));
};
if artifact_sequence == 0 || artifact_sequence > MAX_REDIS_EXACT_INTEGER {
return Err(provider_invalid("decode artifact profile sequence"));
}
if payload.len() > MAX_PROFILE_BYTES {
return Err(provider_invalid("decode artifact profile size"));
}
let serde_json::Value::Object(fields) = serde_json::from_slice(&payload)
.map_err(|_| provider_invalid("decode artifact profile"))?
else {
return Err(provider_invalid("decode artifact profile"));
};
let verified_at = SystemTime::UNIX_EPOCH
.checked_add(Duration::from_millis(verified_at_ms))
.ok_or_else(|| provider_invalid("decode artifact profile timestamp"))?;
Ok(Some(ProviderArtifactProfile::new(
provider_kind.clone(),
artifact_sequence,
verified_at,
OpaqueProviderData::new(fields),
)))
})
}
}
fn exact_positive_millis(
duration: Duration,
operation: &'static str,
) -> Result<u64, ProviderStoreError> {
u64::try_from(duration.as_millis())
.ok()
.filter(|millis| *millis > 0 && *millis <= MAX_REDIS_EXACT_INTEGER)
.ok_or_else(|| provider_invalid(operation))
}
fn provider_unavailable(operation: &'static str) -> ProviderStoreError {
ProviderStoreError::new(ProviderStoreErrorKind::Unavailable, operation)
}
fn provider_invalid(operation: &'static str) -> ProviderStoreError {
ProviderStoreError::new(ProviderStoreErrorKind::InvalidData, operation)
}
@@ -7,6 +7,7 @@ use sha2::{Digest, Sha256};
use async_trait::async_trait;
mod artifact_profile;
mod client_admission;
mod coordination_buffer;
mod credential_cooldown;
@@ -20,6 +21,7 @@ mod provider_session_exclusion;
mod runtime_change;
pub(crate) mod worker_lease;
pub use artifact_profile::*;
pub use client_admission::*;
pub use coordination_buffer::*;
pub use credential_cooldown::*;
@@ -18,8 +18,8 @@ use gateway_core::engine::credential::{
AccountErrorReason, AccountStateChange, CredentialCasOutcome, CredentialCasUpdate,
CredentialRevision, CredentialState, OpaqueProviderData, PlaintextCredential,
ProviderAccountId, ProviderAccountIdentity, ProviderAccountStore, ProviderAccountUpdate,
QuotaAccessChange, QuotaAccessState, QuotaEvidence, QuotaObservation, QuotaState,
QuotaWriteOutcome,
QuotaAccessChange, QuotaAccessState, QuotaEvidence, QuotaObservation, QuotaObservationTouch,
QuotaState, QuotaWriteOutcome,
};
use gateway_core::routing::{AccountGroupId, ProviderKind};
use gateway_store::{
@@ -51,6 +51,22 @@ struct RecoveredAccountRow {
access_token_expires_at: Option<chrono::DateTime<Utc>>,
}
#[derive(sqlx::FromRow)]
struct CoreRefreshRow {
name: String,
email: Option<String>,
plan_type: Option<String>,
provider_credentials_json: serde_json::Value,
credential_revision: i64,
credential_state: String,
last_error_reason: Option<String>,
last_error_message: Option<String>,
quota_access_state: String,
quota_evidence: Option<String>,
quota_access_observed_at: Option<chrono::DateTime<Utc>>,
quota_reset_at: Option<chrono::DateTime<Utc>>,
}
#[test]
fn postgres_provider_account_adapter_implements_core_port() {
fn assert_port<T: ProviderAccountStore>() {}
@@ -156,6 +172,111 @@ async fn core_quota_batch_reads_only_observed_accounts_in_one_contract_call() {
database.close().await;
}
#[tokio::test]
async fn quota_observation_touch_only_advances_the_refresh_time() {
let Some(database) = TestDatabase::create("provider_account_quota_touch").await else {
return;
};
let repository = PgProviderAccountRepository::new(database.pool.clone());
let account_id = ProviderAccountId::new("acct_quota_touch").expect("account id");
repository
.insert_provider_account(account(account_id.as_str(), "user-quota-touch"))
.await
.expect("insert quota fixture");
let revision = CredentialRevision::new(1).expect("revision");
let observed_at = SystemTime::now()
.checked_sub(Duration::from_secs(60))
.expect("old observation time");
let touched_at = observed_at
.checked_add(Duration::from_secs(30))
.expect("new observation time");
let reset_at = observed_at
.checked_add(Duration::from_secs(3_600))
.expect("quota reset time");
let quota_json = json!({
"rate_limit": {
"allowed": false,
"limit_reached": true,
"primary_window": {"used_percent": 100, "reset_at": 1_900_000_000}
}
});
assert_eq!(
repository
.compare_and_swap_quota(QuotaObservation {
account_id: account_id.clone(),
expected_revision: revision,
quota: OpaqueProviderData::new(
quota_json.as_object().expect("quota object").clone(),
),
observed_at,
state: QuotaState::exhausted(
QuotaEvidence::UsageLimitReached,
observed_at,
Some(reset_at),
),
})
.await
.expect("persist exhausted quota"),
QuotaWriteOutcome::Updated
);
let account_updated_at: chrono::DateTime<Utc> = sqlx::query_scalar(
"select updated_at from provider_accounts where id = 'acct_quota_touch'",
)
.fetch_one(&database.pool)
.await
.expect("load account update time before quota touch");
assert_eq!(
repository
.touch_quota_observation(QuotaObservationTouch {
account_id,
expected_revision: revision,
observed_at: touched_at,
})
.await
.expect("touch quota refresh time"),
QuotaWriteOutcome::Updated
);
let stored = sqlx::query_as::<
_,
(
serde_json::Value,
chrono::DateTime<Utc>,
String,
Option<String>,
chrono::DateTime<Utc>,
Option<chrono::DateTime<Utc>>,
chrono::DateTime<Utc>,
),
>(
"select provider_quota_json, quota_observed_at, quota_access_state,
quota_evidence, quota_access_observed_at, quota_reset_at, updated_at
from provider_accounts where id = 'acct_quota_touch'",
)
.fetch_one(&database.pool)
.await
.expect("load touched quota row");
assert_eq!(stored.0, quota_json);
assert_eq!(
stored.1.timestamp_micros(),
chrono::DateTime::<Utc>::from(touched_at).timestamp_micros()
);
assert_eq!(stored.2, "exhausted");
assert_eq!(stored.3.as_deref(), Some("usage_limit_reached"));
assert_eq!(
stored.4.timestamp_micros(),
chrono::DateTime::<Utc>::from(observed_at).timestamp_micros()
);
assert_eq!(
stored.5.map(|value| value.timestamp_micros()),
Some(chrono::DateTime::<Utc>::from(reset_at).timestamp_micros())
);
assert_eq!(stored.6, account_updated_at);
database.close().await;
}
#[tokio::test]
async fn delayed_provider_observations_cannot_overwrite_newer_state_or_quota() {
let Some(database) = TestDatabase::create("provider_account_observation_fence").await else {
@@ -280,6 +401,27 @@ async fn credential_error_message_is_persisted_and_cleared_on_recovery() {
Some("upstream returned 402 payment required")
);
repository
.apply_state_change(AccountStateChange {
message: Some("Invalid refresh token.".to_owned()),
account_id: account_id.clone(),
expected_revision: revision,
credential_state: CredentialState::Ready,
observed_at: SystemTime::now(),
error_reason: Some(AccountErrorReason::AccessTokenExpired),
})
.await
.expect("persist recoverable refresh failure");
let recoverable: (Option<String>, Option<String>) = sqlx::query_as(
"select last_error_reason, last_error_message from provider_accounts where id = $1",
)
.bind(account_id.as_str())
.fetch_one(&database.pool)
.await
.expect("read recoverable refresh failure");
assert_eq!(recoverable.0.as_deref(), Some("access_token_expired"));
assert_eq!(recoverable.1.as_deref(), Some("Invalid refresh token."));
repository
.apply_state_change(AccountStateChange {
message: None,
@@ -1208,8 +1350,37 @@ async fn core_refresh_cas_updates_profile_and_credential_under_one_revision() {
})
.await
.expect("seed provider account");
sqlx::query(
"update provider_accounts
set last_error_reason = 'access_token_expired',
last_error_message = 'stale refresh failure'
where id = 'acct_core_refresh'",
)
.execute(&database.pool)
.await
.expect("seed refresh failure details");
let account_id = ProviderAccountId::new("acct_core_refresh").expect("account ID");
let quota_observed_at = SystemTime::now();
let quota_reset_at = quota_observed_at
.checked_add(Duration::from_secs(3_600))
.expect("quota reset time");
assert_eq!(
repository
.apply_quota_access(QuotaAccessChange {
account_id: account_id.clone(),
expected_revision: CredentialRevision::new(1).expect("credential revision"),
state: QuotaState::exhausted(
QuotaEvidence::UsageLimitReached,
quota_observed_at,
Some(quota_reset_at),
),
})
.await
.expect("seed quota exhaustion"),
QuotaWriteOutcome::Updated
);
let state_observed_at = SystemTime::now();
let refreshed = CredentialCasUpdate::new(
account_id.clone(),
CredentialRevision::new(1).expect("credential revision"),
@@ -1224,7 +1395,8 @@ async fn core_refresh_cas_updates_profile_and_credential_under_one_revision() {
Some(SystemTime::now() + Duration::from_secs(3_600)),
Some(SystemTime::now() + Duration::from_secs(1_800)),
)
.expect("valid refresh update");
.expect("valid refresh update")
.with_account_state(CredentialState::Ready, state_observed_at, None, None);
assert_eq!(
repository
.compare_and_swap_credential(refreshed)
@@ -1235,24 +1407,37 @@ async fn core_refresh_cas_updates_profile_and_credential_under_one_revision() {
)
);
let row: (
String,
Option<String>,
Option<String>,
serde_json::Value,
i64,
) = sqlx::query_as(
"select name, email, plan_type, provider_credentials_json, credential_revision
let row = sqlx::query_as::<_, CoreRefreshRow>(
"select name, email, plan_type, provider_credentials_json, credential_revision,
credential_state, last_error_reason, last_error_message,
quota_access_state, quota_evidence, quota_access_observed_at, quota_reset_at
from provider_accounts where id = 'acct_core_refresh'",
)
.fetch_one(&database.pool)
.await
.expect("load refreshed account");
assert_eq!(row.0, "after refresh");
assert_eq!(row.1.as_deref(), Some("after@example.invalid"));
assert_eq!(row.2.as_deref(), Some("premium"));
assert_eq!(row.3["access_token"], "after-secret");
assert_eq!(row.4, 2);
assert_eq!(row.name, "after refresh");
assert_eq!(row.email.as_deref(), Some("after@example.invalid"));
assert_eq!(row.plan_type.as_deref(), Some("premium"));
assert_eq!(
row.provider_credentials_json["access_token"],
"after-secret"
);
assert_eq!(row.credential_revision, 2);
assert_eq!(row.credential_state, "ready");
assert_eq!(row.last_error_reason, None);
assert_eq!(row.last_error_message, None);
assert_eq!(row.quota_access_state, "exhausted");
assert_eq!(row.quota_evidence.as_deref(), Some("usage_limit_reached"));
assert_eq!(
row.quota_access_observed_at
.map(|value| value.timestamp_micros()),
Some(chrono::DateTime::<Utc>::from(quota_observed_at).timestamp_micros())
);
assert_eq!(
row.quota_reset_at.map(|value| value.timestamp_micros()),
Some(chrono::DateTime::<Utc>::from(quota_reset_at).timestamp_micros())
);
assert!(
repository
@@ -0,0 +1,114 @@
use std::time::{Duration, SystemTime};
use gateway_core::engine::credential::OpaqueProviderData;
use gateway_core::provider_ports::{
ProviderArtifactProfile, ProviderArtifactProfileCachePort, ProviderStoreErrorKind,
};
use gateway_core::routing::ProviderKind;
use gateway_store::redis::RedisProviderArtifactProfileRepository;
use redis::aio::ConnectionManager;
use serde_json::{Map, Value};
use uuid::Uuid;
#[test]
fn artifact_profile_adapter_implements_provider_port() {
fn assert_port<T: ProviderArtifactProfileCachePort>() {}
assert_port::<RedisProviderArtifactProfileRepository>();
}
#[tokio::test]
async fn artifact_profile_uses_one_expiring_key_and_rejects_rollback_or_conflict() {
let Some((repository, mut connection, namespace)) = repository().await else {
return;
};
let provider = ProviderKind::new("openai").expect("provider kind");
let current = profile(provider.clone(), 6_570, "0.148.0-alpha.9");
assert!(
repository
.replace_if_newer(current.clone(), Duration::from_secs(60))
.await
.expect("store current profile")
);
assert_eq!(
repository.read(&provider).await.expect("read profile"),
Some(current.clone())
);
assert!(
!repository
.replace_if_newer(
profile(provider.clone(), 6_415, "0.147.0-alpha.6.6"),
Duration::from_secs(60),
)
.await
.expect("reject older profile")
);
let conflict = repository
.replace_if_newer(
profile(provider.clone(), 6_570, "0.148.0-alpha.10"),
Duration::from_secs(60),
)
.await
.expect_err("same build with different Core must conflict");
assert_eq!(conflict.kind(), ProviderStoreErrorKind::Conflict);
assert_eq!(
repository
.read(&provider)
.await
.expect("read retained profile"),
Some(current)
);
let keys = redis::cmd("KEYS")
.arg(format!("{namespace}:*"))
.query_async::<Vec<String>>(&mut connection)
.await
.expect("list isolated keys");
assert_eq!(keys.len(), 1);
assert_eq!(
keys[0],
format!("{namespace}:provider:openai:artifact-profile:v1")
);
let ttl = redis::cmd("PTTL")
.arg(&keys[0])
.query_async::<i64>(&mut connection)
.await
.expect("read profile TTL");
assert!((1..=60_000).contains(&ttl));
}
fn profile(
provider: ProviderKind,
artifact_sequence: u64,
codex_version: &str,
) -> ProviderArtifactProfile {
let mut fields = Map::new();
fields.insert("schema_version".to_owned(), Value::from(1));
fields.insert(
"codex_version".to_owned(),
Value::String(codex_version.to_owned()),
);
ProviderArtifactProfile::new(
provider,
artifact_sequence,
SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000),
OpaqueProviderData::new(fields),
)
}
async fn repository() -> Option<(
RedisProviderArtifactProfileRepository,
ConnectionManager,
String,
)> {
let redis_url = crate::support::test_env("CPR_TEST_REDIS_URL")?;
let client = redis::Client::open(redis_url).expect("valid CPR_TEST_REDIS_URL");
let connection = client
.get_connection_manager()
.await
.expect("connect test Redis");
let namespace = format!("gateway-store-test-{}", Uuid::new_v4());
let repository = RedisProviderArtifactProfileRepository::new(connection.clone(), &namespace)
.expect("valid test namespace");
Some((repository, connection, namespace))
}
@@ -1,3 +1,4 @@
mod artifact_profile;
mod client_admission;
mod coordination_buffer;
mod credential_cooldown;
@@ -17,6 +17,7 @@ cookie = "0.18.1"
crypto_box = { version = "0.9.1", features = ["seal"] }
ed25519-dalek = { version = "3.0.0", features = ["alloc", "pkcs8"] }
futures = "0.3.32"
flate2 = "1.1.9"
gateway-admin = { path = "../../gateway-admin" }
gateway-core = { path = "../../gateway-core" }
gateway-protocol = { path = "../../gateway-protocol" }
@@ -49,6 +50,7 @@ tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungsteni
tracing = "0.1.44"
tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "4fffad30fe373adbdcffab9545e9e9bf4f2fc19f", features = ["deflate", "proxy"] }
url = "2.5.8"
urlencoding = "2.1.3"
uuid = { version = "1.23.3", features = ["v4", "v7"] }
# 官方 Codex app-server 对 /v1/responses 请求体默认做 zstd 压缩(level 3),
# 并携带 Content-Encoding: zstd;保持同版本同级别以对齐上游指纹。
+43 -25
View File
@@ -25,8 +25,9 @@ use gateway_admin::model::{MutationActor, MutationContext, Revision};
use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind};
use gateway_core::accounting::Money;
use gateway_core::engine::credential::{
CredentialRevision, LoadedCredential, NewProviderAccount, OpaqueProviderData,
PlaintextCredential, ProviderAccount, ProviderAccountId, ProviderAccountStore,
CredentialCasUpdateParts, CredentialRevision, LoadedCredential, NewProviderAccount,
OpaqueProviderData, PlaintextCredential, ProviderAccount, ProviderAccountId,
ProviderAccountStore,
};
use gateway_core::error::StoreErrorKind;
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload};
@@ -49,12 +50,14 @@ use crate::credential::{
CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential, ExportManagedCodexCredential,
StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
};
use crate::credential::{CodexCredentialCodec, CodexOAuthSecret, oauth_owner_ref};
use crate::credential::{
CodexCredentialCodec, CodexOAuthSecret, oauth_owner_ref, parse_access_token_expiration,
};
use crate::transport::CodexWebSocketPool;
use crate::transport::openai_billing_breakdown;
use crate::transport::profile::{
CodexDesktopReleaseSnapshot, CodexDesktopReleaseStatus, CodexWireProfile, CodexWireProfileState,
};
use crate::transport::{OpenAiBillingUsage, openai_billing_breakdown};
const PROVIDER_NAME: &str = "openai";
const PENDING_DOCUMENT_SCHEMA_VERSION: u64 = 3;
@@ -215,10 +218,12 @@ impl ProviderAdmin for OpenAiAdminProvider {
};
let Some(breakdown) = openai_billing_breakdown(
&input.upstream_model_id,
input_tokens,
output_tokens,
input.cached_tokens.unwrap_or_default(),
input.cache_write_tokens.unwrap_or_default(),
OpenAiBillingUsage::new(
input_tokens,
output_tokens,
input.cached_tokens.unwrap_or_default(),
input.cache_write_tokens.unwrap_or_default(),
),
input.service_tier.as_deref(),
) else {
return Ok(None);
@@ -256,7 +261,7 @@ impl ProviderAdmin for OpenAiAdminProvider {
))
.await
.inspect_err(|error| {
log_import_failure("prepare_document", credential_admin_error_code(*error));
log_import_failure("prepare_document", credential_admin_error_code(error));
})
.map_err(map_credential_admin_error)?;
let observed_at = Utc::now();
@@ -378,8 +383,10 @@ impl ProviderAdmin for OpenAiAdminProvider {
.oauth()
.and_then(|oauth| oauth.id_token.clone());
}
let access_token_expires_at =
parse_access_token_expiration(secret.access_token.expose_secret());
let prepared = CodexCredentialAdmin
.prepare_refreshed_oauth_rotation(current, secret, None, None)
.prepare_refreshed_oauth_rotation(current, secret, access_token_expires_at, None)
.map_err(map_credential_admin_error)?;
prepared_rotation(prepared, command.account.provider_kind)
}
@@ -544,15 +551,16 @@ fn prepared_rotation(
provider_kind: ProviderKind,
) -> Result<PreparedCredentialRotation, ProviderAdminError> {
let (profile, credential, replacement_identity, guard) = prepared.into_parts();
let (
let CredentialCasUpdateParts {
account_id,
expected_revision,
credential_profile,
profile: credential_profile,
credential,
has_refresh_token,
access_token_expires_at,
next_refresh_at,
) = credential.into_parts();
account_state: _account_state,
} = credential.into_parts();
if profile != credential_profile || profile.account_id != account_id {
return Err(provider_admin_error(ProviderAdminErrorKind::Internal));
}
@@ -1011,6 +1019,7 @@ struct PendingDocument {
state: String,
nonce: String,
code_verifier: String,
installation_id: String,
reauthorization_account_id: Option<String>,
mutation: PendingMutationDocument,
}
@@ -1071,6 +1080,10 @@ fn encode_pending(pending: &CodexPendingAuthorization) -> Map<String, Value> {
"code_verifier".to_owned(),
Value::String(pending.code_verifier().expose_secret().to_owned()),
);
document.insert(
"installation_id".to_owned(),
Value::String(pending.installation_id().to_owned()),
);
document.insert(
"reauthorization_account_id".to_owned(),
pending
@@ -1161,6 +1174,7 @@ fn decode_pending(
state: SecretString::from(document.state),
nonce: SecretString::from(document.nonce),
code_verifier: SecretString::from(document.code_verifier),
installation_id: document.installation_id,
reauthorization_account_id: document.reauthorization_account_id,
mutation: decode_mutation(document.mutation)?,
})
@@ -1267,31 +1281,37 @@ fn map_store_error(error: gateway_core::error::StoreError) -> ProviderAdminError
fn map_credential_admin_error(error: CodexCredentialAdminError) -> ProviderAdminError {
use CodexCredentialAdminError as Error;
provider_admin_error(match error {
let upstream_message = error.upstream_message().map(ToOwned::to_owned);
let kind = match error {
Error::InvalidInput
| Error::InvalidCredential
| Error::MissingRefreshToken
| Error::RefreshRejected
| Error::AccountBanned => ProviderAdminErrorKind::Invalid,
| Error::RefreshRejected { .. }
| Error::AccountBanned { .. } => ProviderAdminErrorKind::Invalid,
Error::NotFound => ProviderAdminErrorKind::NotFound,
Error::RefreshLeaseUnavailable | Error::RefreshAmbiguous => {
Error::RefreshLeaseUnavailable | Error::RefreshAmbiguous { .. } => {
ProviderAdminErrorKind::Conflict
}
Error::RefreshUnavailable => ProviderAdminErrorKind::Unavailable,
})
};
let error = provider_admin_error(kind);
match upstream_message {
Some(message) => error.with_message(message),
None => error,
}
}
const fn credential_admin_error_code(error: CodexCredentialAdminError) -> &'static str {
const fn credential_admin_error_code(error: &CodexCredentialAdminError) -> &'static str {
match error {
CodexCredentialAdminError::InvalidInput => "invalid_input",
CodexCredentialAdminError::InvalidCredential => "invalid_credential",
CodexCredentialAdminError::NotFound => "not_found",
CodexCredentialAdminError::MissingRefreshToken => "missing_refresh_token",
CodexCredentialAdminError::RefreshLeaseUnavailable => "refresh_lease_unavailable",
CodexCredentialAdminError::RefreshRejected => "refresh_rejected",
CodexCredentialAdminError::AccountBanned => "account_banned",
CodexCredentialAdminError::RefreshRejected { .. } => "refresh_rejected",
CodexCredentialAdminError::AccountBanned { .. } => "account_banned",
CodexCredentialAdminError::RefreshUnavailable => "refresh_unavailable",
CodexCredentialAdminError::RefreshAmbiguous => "refresh_ambiguous",
CodexCredentialAdminError::RefreshAmbiguous { .. } => "refresh_ambiguous",
}
}
@@ -1360,9 +1380,7 @@ fn map_quota_error(error: CodexCredentialQuotaError) -> ProviderAdminError {
fn map_catalog_error(error: CodexCredentialCatalogError) -> ProviderAdminError {
use CodexCredentialCatalogError as Error;
provider_admin_error(match error {
Error::InvalidCredentialData | Error::ConflictingModelFacts | Error::InvalidEtag => {
ProviderAdminErrorKind::Invalid
}
Error::InvalidCredentialData | Error::InvalidEtag => ProviderAdminErrorKind::Invalid,
Error::NoEligibleCredential => ProviderAdminErrorKind::NotFound,
Error::ConcurrentUpdate => ProviderAdminErrorKind::Conflict,
Error::Upstream { .. } | Error::Cache => ProviderAdminErrorKind::Unavailable,
@@ -269,7 +269,7 @@ impl CodexAuthSettings {
#[serde(deny_unknown_fields)]
pub struct CodexWireProfileConfig {
pub originator: String,
/// npm `@openai/codex` 启动版本;运行时发布任务会更新此画像字段。
/// 官方 Desktop ZIP 内嵌 Core 的启动基线;运行时按完整制品元组更新。
pub codex_version: String,
pub desktop_version: String,
pub desktop_build: String,
@@ -284,9 +284,9 @@ impl Default for CodexWireProfileConfig {
fn default() -> Self {
Self {
originator: "Codex Desktop".to_owned(),
codex_version: "0.146.0".to_owned(),
desktop_version: "26.727.51351".to_owned(),
desktop_build: "6119".to_owned(),
codex_version: "0.147.0-alpha.6.6".to_owned(),
desktop_version: "26.803.81509".to_owned(),
desktop_build: "6415".to_owned(),
os_type: "Mac OS".to_owned(),
os_version: "15.7.1".to_owned(),
arch: "arm64".to_owned(),
@@ -55,6 +55,7 @@ pub struct ImportCodexOAuthCredential {
pub(crate) struct UnresolvedCodexOAuthCredential {
pub(crate) account_id: String,
pub(crate) name: String,
pub(crate) installation_id: String,
pub(crate) secret: CodexOAuthSecret,
pub(crate) metadata: CodexOAuthMetadata,
pub(crate) access_token_expires_at: Option<DateTime<Utc>>,
@@ -68,6 +69,7 @@ impl fmt::Debug for UnresolvedCodexOAuthCredential {
.debug_struct("UnresolvedCodexOAuthCredential")
.field("account_id", &self.account_id)
.field("name", &self.name)
.field("installation_id", &"<pseudonymous>")
.field("secret", &"<redacted>")
.field("metadata", &self.metadata)
.field("access_token_expires_at", &self.access_token_expires_at)
@@ -374,7 +376,7 @@ impl fmt::Debug for PreparedCodexCredentialRotationGuard {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum CodexCredentialAdminError {
#[error("Codex account input is invalid")]
InvalidInput,
@@ -387,13 +389,30 @@ pub enum CodexCredentialAdminError {
#[error("Codex refresh lease is unavailable")]
RefreshLeaseUnavailable,
#[error("Codex refresh token was rejected")]
RefreshRejected,
RefreshRejected { message: Option<String> },
#[error("Codex account is banned")]
AccountBanned,
AccountBanned { message: Option<String> },
#[error("Codex refresh service is unavailable")]
RefreshUnavailable,
#[error("Codex refresh send state is ambiguous")]
RefreshAmbiguous,
RefreshAmbiguous { message: Option<String> },
}
impl CodexCredentialAdminError {
#[must_use]
pub fn upstream_message(&self) -> Option<&str> {
match self {
Self::RefreshRejected { message }
| Self::AccountBanned { message }
| Self::RefreshAmbiguous { message } => message.as_deref(),
Self::InvalidInput
| Self::InvalidCredential
| Self::NotFound
| Self::MissingRefreshToken
| Self::RefreshLeaseUnavailable
| Self::RefreshUnavailable => None,
}
}
}
/// 无状态的 Codex Admin command preparer。
@@ -473,8 +492,12 @@ impl CodexCredentialAdmin {
chatgpt_account_id: upstream_account_id,
} = input.metadata;
let has_upstream_user_id = upstream_user_id.is_some();
let credential = CodexCredentialCodec::encode_unresolved(&input.secret, Vec::new())
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let credential = CodexCredentialCodec::encode_unresolved(
&input.secret,
input.installation_id,
Vec::new(),
)
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let account = ProviderAccount::new(
account_id,
provider,
@@ -794,8 +817,15 @@ impl CodexCredentialAdminService {
.refresher
.refresh(refresh_token.expose_secret())
.await
.inspect_err(|error| log_manual_refresh_failure(&account_id, error))
.map_err(map_refresh_failure)?;
let access_token_rotated = tokens.access_token.is_some();
let access_token_expires_at = match tokens.access_token.as_deref() {
Some(access_token) => parse_access_token_expiration(access_token),
None => current
.account
.access_token_expires_at()
.map(DateTime::<Utc>::from),
};
let secret = CodexOAuthSecret {
access_token: tokens
.access_token
@@ -815,17 +845,6 @@ impl CodexCredentialAdminService {
Some(account_id.as_str()),
&secret,
);
let access_token_expires_at = if access_token_rotated {
tokens
.expires_in
.and_then(|expires_in| SystemTime::now().checked_add(expires_in))
.map(DateTime::<Utc>::from)
} else {
current
.account
.access_token_expires_at()
.map(DateTime::<Utc>::from)
};
// 正常预刷新由 worker 读取当时的 runtime policy 动态判断;这里仅清除
// 之前瞬态失败留下的 retry-not-before。
let next_refresh_at = None;
@@ -917,6 +936,7 @@ impl CodexCredentialAdminService {
.or_else(|| metadata.email.clone())
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| "Codex OAuth".to_owned()),
installation_id: uuid::Uuid::new_v4().to_string(),
secret,
metadata,
access_token_expires_at,
@@ -991,6 +1011,7 @@ impl CodexCredentialAdminService {
let access_token = tokens
.access_token
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
let access_token_expires_at = parse_access_token_expiration(&access_token);
let secret = CodexOAuthSecret {
access_token: SecretString::from(access_token),
// RT 未轮换时仍保留导入提供的 RT,保证本次补全不会丢失后续刷新能力。
@@ -1005,10 +1026,6 @@ impl CodexCredentialAdminService {
Some(account_id.as_str()),
&secret,
);
let access_token_expires_at = tokens
.expires_in
.and_then(|expires_in| SystemTime::now().checked_add(expires_in))
.map(DateTime::<Utc>::from);
Ok((secret, access_token_expires_at))
}
@@ -1126,15 +1143,35 @@ fn china_rfc3339(value: DateTime<Utc>) -> String {
.to_rfc3339()
}
const fn map_refresh_failure(error: RefreshFailure) -> CodexCredentialAdminError {
fn map_refresh_failure(error: RefreshFailure) -> CodexCredentialAdminError {
match error {
RefreshFailure::InvalidGrant => CodexCredentialAdminError::RefreshRejected,
RefreshFailure::Banned => CodexCredentialAdminError::AccountBanned,
RefreshFailure::RetryableTransport => CodexCredentialAdminError::RefreshUnavailable,
RefreshFailure::Transport => CodexCredentialAdminError::RefreshAmbiguous,
RefreshFailure::InvalidGrant { message, .. } => {
CodexCredentialAdminError::RefreshRejected { message }
}
RefreshFailure::Banned { message, .. } => {
CodexCredentialAdminError::AccountBanned { message }
}
RefreshFailure::RetryableTransport { .. } => CodexCredentialAdminError::RefreshUnavailable,
RefreshFailure::Transport { message, .. } => {
CodexCredentialAdminError::RefreshAmbiguous { message }
}
}
}
fn log_manual_refresh_failure(account_id: &ProviderAccountId, error: &RefreshFailure) {
let upstream = error.upstream();
tracing::warn!(
account_id = %account_id,
failure_class = error.classification(),
upstream_message = ?error.message(),
upstream_status = ?upstream.map(super::token_client::RefreshUpstreamFailure::status),
upstream_code = ?upstream.and_then(super::token_client::RefreshUpstreamFailure::code),
upstream_type = ?upstream.and_then(super::token_client::RefreshUpstreamFailure::error_type),
upstream_body = ?upstream.map(super::token_client::RefreshUpstreamFailure::body),
"OpenAI OAuth manual refresh failed"
);
}
fn parse_import_document(
payload: &Value,
) -> Result<Vec<ParsedCodexImportAccount>, CodexCredentialAdminError> {
@@ -22,6 +22,8 @@ use url::Url;
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
use super::security::{CodexRuntimeAuthentication, CodexRuntimeCredential};
use crate::transport::headers::build_codex_profile_headers;
use crate::transport::profile::CodexWireProfileState;
use crate::transport::websocket::CodexWebSocketExchangeError;
use crate::transport::{CodexClientError, CodexWebSocketPool};
@@ -167,13 +169,21 @@ pub trait CodexAgentIdentityTaskRegistrar: Send + Sync + 'static {
pub struct OfficialCodexAgentIdentityTaskRegistrar {
client: reqwest::Client,
base_url: Url,
profile: CodexWireProfileState,
}
impl OfficialCodexAgentIdentityTaskRegistrar {
pub fn new(client: reqwest::Client) -> Result<Self, CodexAgentIdentityError> {
pub fn new(
client: reqwest::Client,
profile: CodexWireProfileState,
) -> Result<Self, CodexAgentIdentityError> {
let base_url = Url::parse(OFFICIAL_AGENT_IDENTITY_AUTH_BASE_URL)
.map_err(|_| CodexAgentIdentityError::InvalidConfiguration)?;
Ok(Self { client, base_url })
Ok(Self {
client,
base_url,
profile,
})
}
}
@@ -197,9 +207,13 @@ impl CodexAgentIdentityTaskRegistrar for OfficialCodexAgentIdentityTaskRegistrar
.path_segments_mut()
.map_err(|_| CodexAgentIdentityError::InvalidConfiguration)?
.extend(["v1", "agent", credential.runtime_id(), "task", "register"]);
let profile = self.profile.snapshot();
let headers = build_codex_profile_headers(&profile)
.map_err(|_| CodexAgentIdentityError::InvalidConfiguration)?;
let response = self
.client
.post(endpoint)
.headers(headers)
.header(reqwest::header::ACCEPT, "application/json")
.json(&credential.registration(Utc::now()))
.send()
@@ -138,8 +138,6 @@ pub enum CodexCredentialCatalogError {
InvalidCredentialData,
#[error("Codex model catalog upstream query failed: {detail}")]
Upstream { detail: String },
#[error("Codex model catalog contains conflicting account facts")]
ConflictingModelFacts,
#[error("Codex model catalog cache is unavailable")]
Cache,
#[error("Codex model catalog ETag is invalid")]
@@ -359,11 +357,6 @@ impl CodexCredentialCatalogService {
union_order.push(entry.key().clone());
entry.insert(model.clone());
}
// 展示与元数据允许跨套餐漂移,union 取首见值;只有路由
// 事实冲突才让整轮刷新失败。
Entry::Occupied(entry) if conflicting_routing_facts(entry.get(), model) => {
return Err(CodexCredentialCatalogError::ConflictingModelFacts);
}
Entry::Occupied(_) => {}
}
}
@@ -661,13 +654,6 @@ fn model_ids(models: &[CodexCatalogModel]) -> Vec<String> {
.collect()
}
/// 路由正确性只依赖 Responses API 支持证据(决定 Generate operation 是否
/// 编译进 `ModelCapabilities`);display_name/limits/metadata 等展示事实
/// 跨套餐漂移不构成冲突。
fn conflicting_routing_facts(existing: &CodexCatalogModel, candidate: &CodexCatalogModel) -> bool {
existing.capabilities().responses_api() != candidate.capabilities().responses_api()
}
fn catalog_candidates_by_scope(
accounts: Vec<ProviderAccount>,
) -> Result<BTreeMap<CodexCatalogScope, Vec<ProviderAccount>>, CodexCredentialCatalogError> {
@@ -19,6 +19,7 @@ pub(crate) use affinity::{
derive_codex_cyber_policy_session_key, derive_codex_session_affinity_key,
};
pub(crate) use oauth::oauth_owner_ref;
pub(crate) use types::parse_access_token_expiration;
pub use agent_identity::{
CodexAgentIdentityError, CodexAgentIdentitySecret, CodexAgentIdentityTaskRegistrar,
@@ -1,10 +1,6 @@
//! Codex Authorization Code + PKCE/OIDC 管理流。
use std::{
fmt,
sync::Arc,
time::{Duration, SystemTime},
};
use std::{fmt, sync::Arc, time::Duration};
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
@@ -35,10 +31,17 @@ use super::token_client::{
AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant,
OFFICIAL_CODEX_OAUTH_CLIENT_ID, OFFICIAL_CODEX_REDIRECT_URI,
};
use super::types::parse_chatgpt_jwt_claims;
use super::types::{parse_access_token_expiration, parse_chatgpt_jwt_claims};
use crate::transport::profile::CodexWireProfileState;
const AUTHORIZATION_ENDPOINT: &str = "https://auth.openai.com/oauth/authorize";
const AUTHORIZATION_SCOPE: &str = "openid profile email offline_access";
const DESKTOP_AUTH_ENDPOINT: &str = "https://chatgpt.com/codex/desktop-auth";
const AUTHORIZATION_SCOPE: &str =
"openid profile email offline_access api.connectors.read api.connectors.invoke";
const AUTHORIZATION_ORIGINATOR: &str = "Codex Desktop";
const STREAMLINED_LOGIN_QUERY: &str = "codex_streamlined_login";
const NO_UNIVERSAL_LINKS_QUERY: &str = "no_universal_links";
const SURFACE_STABLE_ID_DOMAIN: &[u8] = b"openai/codex-desktop/surface-stable-id/v1\0";
const AUTHORIZATION_TTL: TimeDelta = TimeDelta::minutes(10);
const AUTHORIZATION_CLAIM_TTL: Duration = Duration::from_secs(90);
const MAX_CALLBACK_BYTES: usize = 16 * 1024;
@@ -137,6 +140,7 @@ pub struct CodexPendingAuthorization {
state: SecretString,
nonce: SecretString,
code_verifier: SecretString,
installation_id: String,
reauthorization: Option<ProviderAccountId>,
mutation: PendingAuthorizationMutation,
}
@@ -150,6 +154,7 @@ pub struct StoredCodexPendingAuthorization {
pub state: SecretString,
pub nonce: SecretString,
pub code_verifier: SecretString,
pub installation_id: String,
pub reauthorization_account_id: Option<String>,
pub mutation: PendingAuthorizationMutation,
}
@@ -172,6 +177,7 @@ impl CodexPendingAuthorization {
state: input.state,
nonce: input.nonce,
code_verifier: input.code_verifier,
installation_id: input.installation_id,
reauthorization,
mutation: input.mutation,
};
@@ -183,6 +189,7 @@ impl CodexPendingAuthorization {
|| !valid_secret(pending.state.expose_secret())
|| !valid_secret(pending.nonce.expose_secret())
|| !valid_secret(pending.code_verifier.expose_secret())
|| !valid_installation_id(&pending.installation_id)
|| !pending_mutation_matches(&pending)
{
return Err(CodexOAuthPendingStoreError::InvalidValue);
@@ -230,6 +237,11 @@ impl CodexPendingAuthorization {
&self.code_verifier
}
#[must_use]
pub fn installation_id(&self) -> &str {
&self.installation_id
}
#[must_use]
pub const fn reauthorization(&self) -> Option<&ProviderAccountId> {
self.reauthorization.as_ref()
@@ -253,6 +265,7 @@ impl fmt::Debug for CodexPendingAuthorization {
.field("state", &"<redacted>")
.field("nonce", &"<redacted>")
.field("code_verifier", &"<redacted>")
.field("installation_id", &"<pseudonymous>")
.field("reauthorization", &self.reauthorization)
.field("mutation", &self.mutation)
.finish()
@@ -348,6 +361,7 @@ pub struct CodexOAuthAdminService {
store: Arc<dyn ProviderAccountStore>,
credentials: CodexCredentialAdmin,
oauth_client_id: String,
profile: CodexWireProfileState,
}
struct CodexOAuthAuthorizationCommitGuard {
@@ -397,6 +411,7 @@ impl CodexOAuthAdminService {
exchanger: Arc<dyn AuthorizationCodeExchanger>,
store: Arc<dyn ProviderAccountStore>,
credentials: CodexCredentialAdmin,
profile: CodexWireProfileState,
) -> Self {
Self {
pending,
@@ -404,6 +419,7 @@ impl CodexOAuthAdminService {
store,
credentials,
oauth_client_id: OFFICIAL_CODEX_OAUTH_CLIENT_ID.to_owned(),
profile,
}
}
@@ -420,8 +436,10 @@ impl CodexOAuthAdmin for CodexOAuthAdminService {
&self,
command: StartCodexOAuthAuthorization,
) -> Result<CodexOAuthAuthorizationStarted, CodexOAuthAdminError> {
let (name, reauthorization) = match command.mutation.target() {
AuthorizationMutationTarget::Create { name } => (name.clone(), None),
let (name, reauthorization, installation_id) = match command.mutation.target() {
AuthorizationMutationTarget::Create { name } => {
(name.clone(), None, Uuid::new_v4().to_string())
}
AuthorizationMutationTarget::Reauthorize { account_id } => {
let current = self
.store
@@ -433,10 +451,15 @@ impl CodexOAuthAdmin for CodexOAuthAdminService {
{
return Err(CodexOAuthAdminError::NotFound);
}
(current.account.name().to_owned(), Some(account_id.clone()))
let installation_id = current_installation_id(&current)?;
(
current.account.name().to_owned(),
Some(account_id.clone()),
installation_id,
)
}
};
self.start_pending(command.mutation, name, reauthorization)
self.start_pending(command.mutation, name, reauthorization, installation_id)
.await
}
@@ -488,6 +511,9 @@ impl CodexOAuthAdminService {
.load_current_credential(target)
.await
.map_err(map_store_error)?;
if current_installation_id(&current)? != pending.installation_id() {
return Err(CodexOAuthAdminError::Conflict);
}
Some(current)
} else {
None
@@ -497,13 +523,12 @@ impl CodexOAuthAdminService {
.map(current_oauth_refresh_token)
.transpose()?
.flatten();
let (mut secret, id_token, expires_in) = self
let (mut secret, id_token) = self
.exchange_pending(&pending, callback_url, fallback_refresh_token)
.await?;
let mutation = pending.mutation.clone();
let access_token_expires_at = expires_in
.and_then(|expires_in| SystemTime::now().checked_add(expires_in))
.map(DateTime::<Utc>::from);
let access_token_expires_at =
parse_access_token_expiration(secret.access_token.expose_secret());
let metadata = parse_chatgpt_jwt_claims(id_token.expose_secret())
.map_err(|_| CodexOAuthAdminError::TokenRejected)?;
secret.id_token = Some(id_token);
@@ -525,6 +550,7 @@ impl CodexOAuthAdminService {
.prepare_unresolved_oauth(UnresolvedCodexOAuthCredential {
account_id,
name: pending.name,
installation_id: pending.installation_id,
secret,
metadata,
access_token_expires_at,
@@ -544,6 +570,7 @@ impl CodexOAuthAdminService {
mutation: PendingAuthorizationMutation,
name: String,
reauthorization: Option<ProviderAccountId>,
installation_id: String,
) -> Result<CodexOAuthAuthorizationStarted, CodexOAuthAdminError> {
if !valid_text(&name) {
return Err(CodexOAuthAdminError::InvalidInput);
@@ -562,17 +589,21 @@ impl CodexOAuthAdminService {
state: SecretString::from(random_secret()?),
nonce: SecretString::from(random_secret()?),
code_verifier: SecretString::from(random_secret()?),
installation_id,
reauthorization_account_id: reauthorization.as_ref().map(ToString::to_string),
mutation,
})
.map_err(map_pending_error)?;
let profile = self.profile.snapshot();
let authorization_url =
authorization_url(&pending, &self.oauth_client_id, &profile.desktop_version)?;
self.pending
.create(&pending)
.await
.map_err(map_pending_error)?;
Ok(CodexOAuthAuthorizationStarted {
flow_id: pending.flow_id.clone(),
authorization_url: authorization_url(&pending, &self.oauth_client_id)?,
authorization_url,
expires_at,
})
}
@@ -635,14 +666,7 @@ impl CodexOAuthAdminService {
pending: &CodexPendingAuthorization,
callback_url: &str,
fallback_refresh_token: Option<SecretString>,
) -> Result<
(
super::types::CodexOAuthSecret,
SecretString,
Option<Duration>,
),
CodexOAuthAdminError,
> {
) -> Result<(super::types::CodexOAuthSecret, SecretString), CodexOAuthAdminError> {
let (code, callback_state) = callback_parts(callback_url)?;
if !constant_time_equal(
pending.state.expose_secret().as_bytes(),
@@ -673,7 +697,7 @@ impl CodexOAuthAdminService {
.as_ref()
.map(ExposeSecret::expose_secret),
);
Ok((secret, tokens.id_token, tokens.expires_in))
Ok((secret, tokens.id_token))
}
}
@@ -688,27 +712,70 @@ fn current_oauth_refresh_token(
.and_then(|oauth| oauth.refresh_token.clone()))
}
fn current_installation_id(current: &LoadedCredential) -> Result<String, CodexOAuthAdminError> {
let runtime = CodexCredentialCodec::decode(&current.credential)
.map_err(|_| CodexOAuthAdminError::Credential)?;
if runtime.authentication.oauth().is_none() {
return Err(CodexOAuthAdminError::Credential);
}
Ok(runtime.installation_id)
}
fn derive_surface_stable_id(installation_id: &str) -> Result<String, CodexOAuthAdminError> {
let installation_id =
Uuid::parse_str(installation_id).map_err(|_| CodexOAuthAdminError::Credential)?;
let mut digest = Sha256::new();
digest.update(SURFACE_STABLE_ID_DOMAIN);
digest.update(installation_id.as_bytes());
let digest = digest.finalize();
let mut bytes = [0_u8; 16];
bytes.copy_from_slice(&digest[..16]);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
Ok(Uuid::from_bytes(bytes).hyphenated().to_string())
}
fn authorization_url(
pending: &CodexPendingAuthorization,
oauth_client_id: &str,
desktop_version: &str,
) -> Result<String, CodexOAuthAdminError> {
let mut url =
if !valid_text(desktop_version) {
return Err(CodexOAuthAdminError::InvalidInput);
}
let mut inner =
Url::parse(AUTHORIZATION_ENDPOINT).map_err(|_| CodexOAuthAdminError::InvalidInput)?;
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(
pending.code_verifier.expose_secret().as_bytes(),
));
url.query_pairs_mut()
.append_pair("response_type", "code")
.append_pair("client_id", oauth_client_id)
.append_pair("redirect_uri", OFFICIAL_CODEX_REDIRECT_URI)
.append_pair("scope", AUTHORIZATION_SCOPE)
.append_pair("state", pending.state.expose_secret())
.append_pair("nonce", pending.nonce.expose_secret())
.append_pair("code_challenge", &challenge)
.append_pair("code_challenge_method", "S256")
.append_pair("id_token_add_organizations", "true")
.append_pair("codex_cli_simplified_flow", "true");
Ok(url.into())
let stable_id = derive_surface_stable_id(pending.installation_id())?;
{
let mut query = inner.query_pairs_mut();
query
.append_pair("response_type", "code")
.append_pair("client_id", oauth_client_id)
.append_pair("redirect_uri", OFFICIAL_CODEX_REDIRECT_URI)
.append_pair("scope", AUTHORIZATION_SCOPE)
.append_pair("code_challenge", challenge.as_str())
.append_pair("code_challenge_method", "S256")
.append_pair("id_token_add_organizations", "true")
.append_pair("codex_cli_simplified_flow", "true")
.append_pair("state", pending.state.expose_secret())
.append_pair("originator", AUTHORIZATION_ORIGINATOR)
.append_pair("codex_app_version", desktop_version)
.append_pair("source_surface_stable_id", &stable_id)
.append_pair("codex_origin_stable_id", &stable_id)
.append_pair(STREAMLINED_LOGIN_QUERY, "true");
}
let mut outer =
Url::parse(DESKTOP_AUTH_ENDPOINT).map_err(|_| CodexOAuthAdminError::InvalidInput)?;
outer
.query_pairs_mut()
.append_pair("authorize_url", inner.as_str())
.append_pair(STREAMLINED_LOGIN_QUERY, "true")
.append_pair(NO_UNIVERSAL_LINKS_QUERY, "1");
Ok(outer.into())
}
fn callback_parts(value: &str) -> Result<(SecretString, SecretString), CodexOAuthAdminError> {
@@ -760,6 +827,12 @@ fn valid_secret(value: &str) -> bool {
valid_text(value) && value.len() >= 16
}
fn valid_installation_id(value: &str) -> bool {
Uuid::parse_str(value)
.ok()
.is_some_and(|uuid| uuid.get_version_num() == 4)
}
pub(crate) fn oauth_owner_ref(owner: &AuthorizationOwner) -> String {
let mut digest = Sha256::new();
match owner {
@@ -19,8 +19,8 @@ use chrono::Utc;
use gateway_core::engine::credential::{
AccountErrorReason, AccountQuotaSignals, CredentialRevision, CredentialState,
OpaqueProviderData, ProviderAccount, ProviderAccountId, ProviderAccountStore,
QuotaAccessChange, QuotaAccessState, QuotaEvidence, QuotaObservation, QuotaState,
QuotaWriteOutcome,
QuotaAccessChange, QuotaAccessState, QuotaEvidence, QuotaObservation, QuotaObservationTouch,
QuotaState, QuotaWriteOutcome,
};
use gateway_core::provider_ports::{ProviderCooldown, ProviderCooldownPort};
use gateway_protocol::openai::events::{
@@ -646,7 +646,25 @@ impl CodexCredentialQuotaService {
} else {
None
};
if !quota_refresh_can_update(account, &snapshot, previous.as_ref()) {
if account.quota().is_exhausted()
&& !quota_refresh_allows_recovery(account, &snapshot, previous.as_ref())
{
let outcome = self
.store
.touch_quota_observation(QuotaObservationTouch {
account_id: account.id().clone(),
expected_revision: account.revision(),
observed_at,
})
.await?;
if outcome == QuotaWriteOutcome::Conflict {
summary.stale += 1;
return Ok(());
}
if let Some(previous) = previous {
self.scheduling
.observe(&previous.with_observed_at(observed_at));
}
summary.exhausted += 1;
return Ok(());
}
@@ -930,8 +948,29 @@ impl CodexCredentialQuotaService {
} else {
None
};
if !quota_refresh_can_update(&account, &snapshot, previous.as_ref()) {
return Ok(previous.unwrap_or_else(|| snapshot.with_quota_state(account.quota())));
if !quota_refresh_allows_recovery(&account, &snapshot, previous.as_ref()) {
if self
.store
.touch_quota_observation(QuotaObservationTouch {
account_id: account.id().clone(),
expected_revision: account.revision(),
observed_at,
})
.await?
== QuotaWriteOutcome::Conflict
{
return Err(CodexCredentialQuotaError::RevisionConflict);
}
return Ok(match previous {
Some(previous) => {
let previous = previous.with_observed_at(observed_at);
self.scheduling.observe(&previous);
previous
}
None => snapshot
.with_quota_state(account.quota())
.with_observed_at(observed_at),
});
}
let state = if account.quota().is_exhausted() {
QuotaState::allowed(observed_at)
@@ -1027,7 +1066,7 @@ impl CodexCredentialQuotaService {
}
}
fn quota_refresh_can_update(
fn quota_refresh_allows_recovery(
account: &ProviderAccount,
refreshed: &CodexAccountQuotaSnapshot,
previous: Option<&CodexAccountQuotaSnapshot>,
@@ -151,6 +151,13 @@ impl CodexAccountQuotaSnapshot {
self.quota = quota;
self
}
/// 只更新展示与调度使用的最后成功查询时间,保留既有额度事实。
#[must_use]
pub(crate) const fn with_observed_at(mut self, observed_at: SystemTime) -> Self {
self.observed_at = observed_at;
self
}
}
impl CodexQuotaFact {
@@ -20,8 +20,8 @@ use thiserror::Error;
use super::recovery_log::{CodexOAuthRecoveryOperation, record_oauth_recovery};
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
use super::token_client::{RefreshFailure, TokenPair, TokenRefresher};
use super::types::CodexOAuthSecret;
use super::token_client::{RefreshFailure, RefreshUpstreamFailure, TokenPair, TokenRefresher};
use super::types::{CodexOAuthSecret, parse_access_token_expiration};
const PROVIDER_NAME: &str = "openai";
const MAX_REFRESH_BATCH: u32 = 1_000;
@@ -32,7 +32,7 @@ const REFRESH_BACKOFF_MAX_ATTEMPTS: u32 = 5;
/// 耗尽指数退避后的 OAuth 恢复周期。
const REFRESH_RECOVERY_DELAY: Duration = Duration::from_secs(10 * 60);
/// 过期 AT 仍允许 RT 恢复的最长窗口。
const REFRESH_RECOVERY_WINDOW: Duration = Duration::from_secs(24 * 60 * 60);
const REFRESH_RECOVERY_WINDOW: Duration = Duration::from_secs(2 * 60 * 60);
pub(crate) fn refresh_recovery_deadline(
access_token_expires_at: Option<SystemTime>,
@@ -91,12 +91,19 @@ fn log_refresh_deferred(
access_token_expires_at: Option<SystemTime>,
attempt: u32,
reason: &'static str,
upstream_message: Option<&str>,
upstream: Option<&RefreshUpstreamFailure>,
retry_at: SystemTime,
) {
tracing::warn!(
account_id = %account_id,
attempt,
reason,
upstream_message = ?upstream_message,
upstream_status = ?upstream.map(RefreshUpstreamFailure::status),
upstream_code = ?upstream.and_then(RefreshUpstreamFailure::code),
upstream_type = ?upstream.and_then(RefreshUpstreamFailure::error_type),
upstream_body = ?upstream.map(RefreshUpstreamFailure::body),
retry_at = %DateTime::<Utc>::from(retry_at),
access_token_expires_at = ?access_token_expires_at.map(DateTime::<Utc>::from),
recovery_deadline = ?refresh_recovery_deadline(access_token_expires_at)
@@ -105,6 +112,26 @@ fn log_refresh_deferred(
);
}
struct RefreshFailureContext<'a> {
reason: &'static str,
message: Option<String>,
upstream: Option<&'a RefreshUpstreamFailure>,
}
impl<'a> RefreshFailureContext<'a> {
const fn new(
reason: &'static str,
message: Option<String>,
upstream: Option<&'a RefreshUpstreamFailure>,
) -> Self {
Self {
reason,
message,
upstream,
}
}
}
fn refresh_due_at(
account: &ProviderAccount,
policy: ProviderRefreshPolicy,
@@ -248,20 +275,10 @@ impl CodexCredentialRefreshService {
policy: ProviderRefreshPolicy,
) -> Result<CodexCredentialRefreshOutcome, CodexCredentialRefreshError> {
let account_id = due.account.id().to_string();
if refresh_recovery_window_exhausted(
let recovery_window_exhausted = refresh_recovery_window_exhausted(
due.account.access_token_expires_at(),
SystemTime::now(),
) {
return self
.persist_terminal(
&due.account,
CredentialState::Expired,
AccountErrorReason::CredentialExpired,
"refresh_recovery_window_exhausted",
CodexCredentialRefreshOutcome::Invalidated { account_id },
)
.await;
}
);
let capacity = self
.leases
.try_acquire(ProviderLeaseRequest::RefreshCapacity(
@@ -291,31 +308,72 @@ impl CodexCredentialRefreshService {
.refresh_token
.as_ref()
.ok_or(CodexCredentialRefreshError::InvalidRefreshResponse)?;
match self.refresher.refresh(refresh_token.expose_secret()).await {
let refresh_result = self.refresher.refresh(refresh_token.expose_secret()).await;
if recovery_window_exhausted && let Err(failure) = &refresh_result {
let message = failure.message().map(str::to_owned);
let upstream = failure.upstream();
return match failure {
RefreshFailure::Banned { .. } => {
self.persist_terminal(
&due.account,
CredentialState::Banned,
AccountErrorReason::AccountBanned,
RefreshFailureContext::new(
"refresh_recovery_window_exhausted",
message,
upstream,
),
CodexCredentialRefreshOutcome::Banned { account_id },
)
.await
}
RefreshFailure::InvalidGrant { .. }
| RefreshFailure::RetryableTransport { .. }
| RefreshFailure::Transport { .. } => {
self.persist_terminal(
&due.account,
CredentialState::Expired,
AccountErrorReason::CredentialExpired,
RefreshFailureContext::new(
"refresh_recovery_window_exhausted",
message,
upstream,
),
CodexCredentialRefreshOutcome::Invalidated { account_id },
)
.await
}
};
}
match refresh_result {
Ok(tokens) => self.persist_success(due, tokens).await,
Err(RefreshFailure::InvalidGrant) => {
Err(RefreshFailure::InvalidGrant { message, upstream }) => {
self.persist_terminal(
&due.account,
CredentialState::Expired,
AccountErrorReason::CredentialExpired,
"refresh_invalid_grant",
RefreshFailureContext::new(
"refresh_invalid_grant",
message,
upstream.as_deref(),
),
CodexCredentialRefreshOutcome::Invalidated { account_id },
)
.await
}
Err(RefreshFailure::Banned) => {
Err(RefreshFailure::Banned { message, upstream }) => {
self.persist_terminal(
&due.account,
CredentialState::Banned,
AccountErrorReason::AccountBanned,
"account_banned",
RefreshFailureContext::new("account_banned", message, upstream.as_deref()),
CodexCredentialRefreshOutcome::Banned { account_id },
)
.await
}
Err(RefreshFailure::RetryableTransport) => {
Err(RefreshFailure::RetryableTransport { message }) => {
if self
.defer_refresh(&due.account, "transport-not-sent")
.defer_refresh(&due.account, "transport-not-sent", Some(&message), None)
.await?
{
Ok(CodexCredentialRefreshOutcome::Transient { account_id })
@@ -323,11 +381,16 @@ impl CodexCredentialRefreshService {
Ok(CodexCredentialRefreshOutcome::Stale { account_id })
}
}
Err(RefreshFailure::Transport) => {
// 上游瞬态(429/5xx/超时/畸形响应等,发送状态未知)保留现有凭据、
// 推进退避重试;仅 invalid_grant/banned 才终态失效账号。
Err(RefreshFailure::Transport { message, upstream }) => {
// 上游瞬态(401/429/5xx/超时/畸形响应等)保留现有凭据、
// 记录最近一次失败并推进有界退避。
if self
.defer_refresh(&due.account, "transport-ambiguous")
.defer_refresh(
&due.account,
"transport-ambiguous",
message.as_deref(),
upstream.as_deref(),
)
.await?
{
Ok(CodexCredentialRefreshOutcome::Transient { account_id })
@@ -403,9 +466,11 @@ impl CodexCredentialRefreshService {
access_token,
refresh_token: rotated_refresh_token,
id_token: rotated_id_token,
expires_in,
} = tokens;
let access_token_rotated = access_token.is_some();
let access_token_expires_at = match access_token.as_deref() {
Some(access_token) => parse_access_token_expiration(access_token).map(SystemTime::from),
None => due.account.access_token_expires_at(),
};
let CodexOAuthSecret {
access_token: current_access_token,
refresh_token: current_refresh_token,
@@ -432,12 +497,6 @@ impl CodexCredentialRefreshService {
.as_ref()
.map(ExposeSecret::expose_secret),
);
let observed_at = SystemTime::now();
let access_token_expires_at = if access_token_rotated {
expires_in.and_then(|value| observed_at.checked_add(value))
} else {
due.account.access_token_expires_at()
};
// 成功轮换清掉仅用于失败退避的 retry-not-before;正常预刷新窗口由
// worker 结合当前 runtime policy 动态判断,不写入账号时间字段。
let next_refresh_at = None;
@@ -483,9 +542,14 @@ impl CodexCredentialRefreshService {
account: &ProviderAccount,
credential_state: CredentialState,
error_reason: AccountErrorReason,
reason: &'static str,
failure: RefreshFailureContext<'_>,
outcome: CodexCredentialRefreshOutcome,
) -> Result<CodexCredentialRefreshOutcome, CodexCredentialRefreshError> {
let RefreshFailureContext {
reason,
message,
upstream,
} = failure;
match self.repository.load_runtime_credential(account).await {
Ok(_) => {}
Err(CredentialRepositoryError::RevisionConflict) => {
@@ -495,6 +559,12 @@ impl CodexCredentialRefreshService {
}
Err(error) => return Err(error.into()),
}
let persisted_message = message
.as_ref()
.filter(|message| !message.trim().is_empty())
.map(String::as_str)
.or_else(|| account.last_error_message())
.map(str::to_owned);
match self
.repository
.apply_state_with_reason(
@@ -502,7 +572,7 @@ impl CodexCredentialRefreshService {
credential_state,
SystemTime::now(),
Some(error_reason),
Some(reason.to_owned()),
persisted_message,
)
.await
{
@@ -511,6 +581,11 @@ impl CodexCredentialRefreshService {
account_id = %account.id(),
?credential_state,
reason,
upstream_message = ?message.as_deref(),
upstream_status = ?upstream.map(RefreshUpstreamFailure::status),
upstream_code = ?upstream.and_then(RefreshUpstreamFailure::code),
upstream_type = ?upstream.and_then(RefreshUpstreamFailure::error_type),
upstream_body = ?upstream.map(RefreshUpstreamFailure::body),
access_token_expires_at = ?account.access_token_expires_at()
.map(DateTime::<Utc>::from),
recovery_deadline = ?refresh_recovery_deadline(account.access_token_expires_at())
@@ -532,6 +607,8 @@ impl CodexCredentialRefreshService {
&self,
account: &ProviderAccount,
reason: &'static str,
upstream_message: Option<&str>,
upstream: Option<&RefreshUpstreamFailure>,
) -> Result<bool, CodexCredentialRefreshError> {
let attempt = self
.credential_state
@@ -545,13 +622,28 @@ impl CodexCredentialRefreshService {
attempt,
reason,
)?;
match self.repository.defer_refresh(account, retry_at).await {
let message = upstream_message
.filter(|message| !message.trim().is_empty())
.map(str::to_owned);
let error_reason = upstream.map(|_| {
account
.credential_state()
.error_reason()
.unwrap_or(AccountErrorReason::AccessTokenExpired)
});
match self
.repository
.defer_refresh(account, retry_at, error_reason, message)
.await
{
Ok(_) => {
log_refresh_deferred(
account.id(),
account.access_token_expires_at(),
attempt,
reason,
upstream_message,
upstream,
retry_at,
);
Ok(true)
@@ -120,15 +120,18 @@ impl CodexCredentialRepository {
access_token_expires_at,
next_refresh_at,
)
.map_err(|_| CredentialRepositoryError::InvalidCredentialData)?;
.map_err(|_| CredentialRepositoryError::InvalidCredentialData)?
.with_account_state(CredentialState::Ready, SystemTime::now(), None, None);
cas_revision(self.store.compare_and_swap_credential(update).await?)
}
/// token 签名边界不可用时,以相同 credential 推进持久刷新退避。
/// 以相同 credential 原子推进刷新退避及本次上游错误事实。
pub async fn defer_refresh(
&self,
account: &ProviderAccount,
next_refresh_at: SystemTime,
error_reason: Option<AccountErrorReason>,
message: Option<String>,
) -> Result<CredentialRevision, CredentialRepositoryError> {
let current = self
.store
@@ -140,7 +143,7 @@ impl CodexCredentialRepository {
let data = CodexCredentialCodec::decode_complete(&current.credential)?;
let has_refresh_token = data.has_refresh_token();
let credential = CodexCredentialCodec::encode_complete(data)?;
let update = CredentialCasUpdate::new(
let mut update = CredentialCasUpdate::new(
account.id().clone(),
account.revision(),
unchanged_profile(account),
@@ -150,6 +153,14 @@ impl CodexCredentialRepository {
Some(next_refresh_at),
)
.map_err(|_| CredentialRepositoryError::InvalidCredentialData)?;
if let Some(error_reason) = error_reason {
update = update.with_account_state(
account.credential_state(),
SystemTime::now(),
Some(error_reason),
message,
);
}
cas_revision(self.store.compare_and_swap_credential(update).await?)
}
@@ -245,13 +256,11 @@ impl CodexCredentialRepository {
if !account.enabled() {
return Ok(());
}
let (error_reason, message) = if credential_state == CredentialState::Ready {
(None, None)
let message = message.filter(|value| !value.trim().is_empty());
let error_reason = if credential_state == CredentialState::Ready {
message.as_ref().and(error_reason)
} else {
(
error_reason.or_else(|| credential_state.error_reason()),
message.filter(|value| !value.trim().is_empty()),
)
error_reason.or_else(|| credential_state.error_reason())
};
self.store
.apply_state_change(AccountStateChange {
@@ -108,6 +108,7 @@ impl CodexCredentialCodec {
oauth_subject: account.oauth_subject.clone(),
poid: account.poid.clone(),
}),
uuid::Uuid::new_v4().to_string(),
cookies,
)
}
@@ -115,20 +116,22 @@ impl CodexCredentialCodec {
/// 为尚未解析资料的 OAuth 账号编码凭据。
pub(crate) fn encode_unresolved(
secret: &CodexOAuthSecret,
installation_id: String,
cookies: Vec<CodexCookie>,
) -> Result<PlaintextCredential, CodexCredentialDataError> {
Self::encode_oauth(secret, None, cookies)
Self::encode_oauth(secret, None, installation_id, cookies)
}
fn encode_oauth(
secret: &CodexOAuthSecret,
principal: Option<CodexCredentialPrincipal>,
installation_id: String,
cookies: Vec<CodexCookie>,
) -> Result<PlaintextCredential, CodexCredentialDataError> {
Self::encode_complete(CodexCredentialData::OAuth(CodexOAuthCredentialData {
schema_version: CODEX_CREDENTIAL_SCHEMA_VERSION,
principal,
installation_id: uuid::Uuid::new_v4().to_string(),
installation_id,
access_token: secret.access_token.expose_secret().to_owned(),
refresh_token: secret
.refresh_token
@@ -720,7 +720,15 @@ impl CodexCredentialSelector {
.map(chrono::DateTime::<chrono::Utc>::from),
"OpenAI access token expired; retaining account for bounded OAuth refresh recovery"
);
return Ok(());
return self
.apply_credential_state(
account,
CredentialState::Ready,
AccountErrorReason::AccessTokenExpired,
now,
message,
)
.await;
}
self.apply_credential_state(
account,
@@ -3,7 +3,7 @@
use async_trait::async_trait;
use reqwest::{Client, StatusCode, redirect::Policy};
use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::time::Duration;
@@ -27,9 +27,6 @@ pub struct TokenPair {
pub refresh_token: Option<String>,
/// 官方刷新响应省略时由持久化调用方保留当前 ID token。
pub id_token: Option<String>,
/// OAuth endpoint 可以省略或返回零 expiry;这不影响已经成功的 token
/// exchange,只意味着调用方不能据此安排下一次刷新。
pub expires_in: Option<Duration>,
}
impl fmt::Debug for TokenPair {
@@ -45,22 +42,131 @@ impl fmt::Debug for TokenPair {
&self.refresh_token.as_ref().map(|_| "[REDACTED]"),
)
.field("id_token", &self.id_token.as_ref().map(|_| "[REDACTED]"))
.field("expires_in", &self.expires_in)
.finish()
}
}
/// Codex token 刷新的稳定失败分类。
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[derive(Clone, PartialEq, Eq, thiserror::Error)]
pub enum RefreshFailure {
#[error("refresh token is invalid or expired")]
InvalidGrant,
InvalidGrant {
message: Option<String>,
upstream: Option<Box<RefreshUpstreamFailure>>,
},
#[error("account is banned")]
Banned,
Banned {
message: Option<String>,
upstream: Option<Box<RefreshUpstreamFailure>>,
},
#[error("refresh transport failed before server processing")]
RetryableTransport,
RetryableTransport { message: String },
#[error("refresh transport failed after possible server processing")]
Transport,
Transport {
message: Option<String>,
upstream: Option<Box<RefreshUpstreamFailure>>,
},
}
impl fmt::Debug for RefreshFailure {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("RefreshFailure")
.field("classification", &self.classification())
.field("message", &self.message().map(|_| "<redacted>"))
.field("upstream", &self.upstream())
.finish()
}
}
impl RefreshFailure {
#[must_use]
pub fn message(&self) -> Option<&str> {
match self {
Self::InvalidGrant { message, .. }
| Self::Banned { message, .. }
| Self::Transport { message, .. } => message.as_deref(),
Self::RetryableTransport { message } => Some(message),
}
}
#[must_use]
pub fn upstream(&self) -> Option<&RefreshUpstreamFailure> {
match self {
Self::InvalidGrant { upstream, .. }
| Self::Banned { upstream, .. }
| Self::Transport { upstream, .. } => upstream.as_deref(),
Self::RetryableTransport { .. } => None,
}
}
#[must_use]
pub const fn classification(&self) -> &'static str {
match self {
Self::InvalidGrant { .. } => "invalid-grant",
Self::Banned { .. } => "account-banned",
Self::RetryableTransport { .. } => "transport-not-sent",
Self::Transport { .. } => "transport-ambiguous",
}
}
}
/// 当前 OAuth 刷新请求收到的完整非成功响应。
///
/// 该值不持久化;`Debug` 不输出正文。调用方仅在受控刷新失败日志中显式记录正文。
#[derive(Clone, PartialEq, Eq)]
pub struct RefreshUpstreamFailure {
status: u16,
code: Option<String>,
error_type: Option<String>,
body: String,
}
impl RefreshUpstreamFailure {
fn new(status: StatusCode, body: &[u8], error: Option<&RefreshErrorResponse>) -> Self {
Self {
status: status.as_u16(),
code: error
.and_then(RefreshErrorResponse::code)
.map(str::to_owned),
error_type: error
.and_then(RefreshErrorResponse::error_type)
.map(str::to_owned),
body: String::from_utf8_lossy(body).into_owned(),
}
}
#[must_use]
pub const fn status(&self) -> u16 {
self.status
}
#[must_use]
pub fn code(&self) -> Option<&str> {
self.code.as_deref()
}
#[must_use]
pub fn error_type(&self) -> Option<&str> {
self.error_type.as_deref()
}
#[must_use]
pub fn body(&self) -> &str {
&self.body
}
}
impl fmt::Debug for RefreshUpstreamFailure {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("RefreshUpstreamFailure")
.field("status", &self.status)
.field("code", &self.code)
.field("error_type", &self.error_type)
.field("body", &"<redacted>")
.finish()
}
}
/// Codex token 刷新端口。
@@ -92,7 +198,6 @@ impl fmt::Debug for AuthorizationCodeGrant {
pub struct AuthorizationTokenSet {
pub secret: crate::credential::CodexOAuthSecret,
pub id_token: SecretString,
pub expires_in: Option<Duration>,
}
impl fmt::Debug for AuthorizationTokenSet {
@@ -101,7 +206,6 @@ impl fmt::Debug for AuthorizationTokenSet {
.debug_struct("AuthorizationTokenSet")
.field("secret", &"[REDACTED]")
.field("id_token", &"[REDACTED]")
.field("expires_in", &self.expires_in)
.finish()
}
}
@@ -183,7 +287,60 @@ struct RefreshTokenResponse {
access_token: Option<String>,
refresh_token: Option<String>,
id_token: Option<String>,
expires_in: Option<u64>,
}
#[derive(Serialize)]
struct RefreshTokenRequest<'a> {
client_id: &'a str,
grant_type: &'static str,
refresh_token: &'a str,
}
#[derive(Deserialize)]
struct RefreshErrorResponse {
error: Option<RefreshError>,
code: Option<String>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum RefreshError {
Details(RefreshErrorDetails),
Code(String),
}
#[derive(Deserialize)]
struct RefreshErrorDetails {
code: Option<String>,
message: Option<String>,
#[serde(rename = "type")]
error_type: Option<String>,
}
impl RefreshErrorResponse {
fn code(&self) -> Option<&str> {
match self.error.as_ref() {
Some(RefreshError::Details(error)) => error.code.as_deref(),
Some(RefreshError::Code(code)) => Some(code.as_str()),
None => None,
}
.or(self.code.as_deref())
}
fn message(&self) -> Option<String> {
match self.error.as_ref() {
Some(RefreshError::Details(error)) => error.message.as_deref(),
Some(RefreshError::Code(_)) | None => None,
}
.map(ToOwned::to_owned)
}
fn error_type(&self) -> Option<&str> {
match self.error.as_ref() {
Some(RefreshError::Details(error)) => error.error_type.as_deref(),
Some(RefreshError::Code(_)) | None => None,
}
}
}
#[derive(Deserialize)]
@@ -191,7 +348,6 @@ struct AuthorizationCodeResponse {
access_token: String,
refresh_token: String,
id_token: String,
expires_in: Option<u64>,
}
#[async_trait]
@@ -200,11 +356,11 @@ impl TokenRefresher for OpenAiTokenClient {
let response = self
.client
.post(&self.config.token_endpoint)
.form(&[
("grant_type", "refresh_token"),
("client_id", self.config.client_id.as_str()),
("refresh_token", refresh_token),
])
.json(&RefreshTokenRequest {
client_id: self.config.client_id.as_str(),
grant_type: "refresh_token",
refresh_token,
})
.send()
.await
.map_err(|error| refresh_transport_failure(&error))?;
@@ -212,7 +368,10 @@ impl TokenRefresher for OpenAiTokenClient {
if !status.is_success() {
return Err(classify_refresh_failure(status, &body));
}
parse_token_pair(&body).map_err(|()| RefreshFailure::Transport)
parse_token_pair(&body).map_err(|()| RefreshFailure::Transport {
message: Some("OpenAI OAuth refresh returned an invalid success response".to_owned()),
upstream: None,
})
}
}
@@ -260,7 +419,6 @@ impl AuthorizationCodeExchanger for OpenAiTokenClient {
id_token: None,
},
id_token,
expires_in: optional_expiry(tokens.expires_in),
})
}
}
@@ -273,13 +431,21 @@ async fn read_bounded_response(
while let Some(chunk) = response
.chunk()
.await
.map_err(|_| RefreshFailure::Transport)?
.map_err(|error| RefreshFailure::Transport {
message: Some(error.to_string()),
upstream: None,
})?
{
let next_len = body
.len()
.checked_add(chunk.len())
.filter(|length| *length <= MAX_OAUTH_RESPONSE_BYTES)
.ok_or(RefreshFailure::Transport)?;
.ok_or_else(|| RefreshFailure::Transport {
message: Some(format!(
"OpenAI OAuth response exceeded {MAX_OAUTH_RESPONSE_BYTES} bytes"
)),
upstream: None,
})?;
body.reserve(next_len.saturating_sub(body.len()));
body.extend_from_slice(&chunk);
}
@@ -301,43 +467,68 @@ fn parse_token_pair(body: &[u8]) -> Result<TokenPair, ()> {
refresh_token: tokens.refresh_token,
// 官方刷新响应允许省略 ID token;缺失时由调用方保留当前值。
id_token: tokens.id_token,
expires_in: optional_expiry(tokens.expires_in),
})
}
fn optional_expiry(value: Option<u64>) -> Option<Duration> {
value
.filter(|seconds| *seconds > 0)
.map(Duration::from_secs)
}
fn classify_refresh_failure(status: StatusCode, body: &[u8]) -> RefreshFailure {
// 5xx 与 429 按状态码判为瞬态:其正文(CDN/网关页等)不是权威 OAuth 错误响应,
// 不据此终态失效账号。
if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS {
return RefreshFailure::Transport;
// 官方刷新错误的消息与错误码分别位于 `error.message`、`error.code`;
// `error` 字符串与顶层 `code` 仅用于兼容官方客户端自身的错误码提取契约。
let error = serde_json::from_slice::<RefreshErrorResponse>(body).ok();
let message = error.as_ref().and_then(RefreshErrorResponse::message);
let upstream = || {
Some(Box::new(RefreshUpstreamFailure::new(
status,
body,
error.as_ref(),
)))
};
let normalized_code = error
.as_ref()
.and_then(RefreshErrorResponse::code)
.map(str::to_ascii_lowercase);
// 生产策略故意与官方 Codex 的“任意 401 立即终态”不同:
// 显式 401 先进入有界恢复退避,避免瞬时授权故障直接失效账号。
if status == StatusCode::UNAUTHORIZED {
return RefreshFailure::Transport {
message,
upstream: upstream(),
};
}
// 4xx 正文才是权威 OAuth 错误响应,据其区分真过期/封禁与其余瞬态。
let lower = String::from_utf8_lossy(body).to_ascii_lowercase();
if lower.contains("account has been deactivated") {
return RefreshFailure::Banned;
// 非 401 响应仍与官方一致:三个明确的 RT 原因是永久失败。
if matches!(
normalized_code.as_deref(),
Some("refresh_token_expired" | "refresh_token_reused" | "refresh_token_invalidated")
) {
return RefreshFailure::InvalidGrant {
message,
upstream: upstream(),
};
}
if lower.contains("invalid_grant")
|| lower.contains("invalid_token")
|| lower.contains("access_denied")
|| lower.contains("refresh_token_expired")
|| lower.contains("refresh_token_reused")
{
return RefreshFailure::InvalidGrant;
if message.as_deref().is_some_and(|message| {
message
.to_ascii_lowercase()
.contains("account has been deactivated")
}) {
return RefreshFailure::Banned {
message,
upstream: upstream(),
};
}
RefreshFailure::Transport {
message,
upstream: upstream(),
}
RefreshFailure::Transport
}
fn refresh_transport_failure(error: &reqwest::Error) -> RefreshFailure {
let message = error.to_string();
if is_safe_to_retry_refresh_transport(error) {
RefreshFailure::RetryableTransport
RefreshFailure::RetryableTransport { message }
} else {
RefreshFailure::Transport
RefreshFailure::Transport {
message: Some(message),
upstream: None,
}
}
}
+24 -15
View File
@@ -22,8 +22,7 @@ use crate::credential::{
CodexOAuthAdmin, CodexOAuthAdminService, OfficialCodexAgentIdentityTaskRegistrar,
};
use crate::transport::profile::{
CodexCliReleaseService, CodexDesktopReleaseService, OfficialCodexCliReleaseTransport,
OfficialCodexDesktopReleaseTransport,
CodexArtifactProfileCache, CodexDesktopReleaseService, OfficialCodexDesktopReleaseTransport,
};
use crate::transport::{CodexWebSocketPool, build_reqwest_client};
@@ -37,8 +36,8 @@ pub mod credential;
pub mod transport;
pub use transport::{
CodexCanonicalDecoder, CodexCanonicalError, CodexRequestEncodeError, encode_generate_request,
openai_billing_breakdown,
CodexCanonicalDecoder, CodexCanonicalError, CodexRequestEncodeError, OpenAiBillingUsage,
encode_generate_request, openai_billing_breakdown,
};
/// OpenAI 初始化后交给组装根的最小能力集。
@@ -63,6 +62,23 @@ pub async fn initialize(
let runtime_policy = ports.runtime_policy();
let credential_state = ports.credential_state();
let profile = config.wire_profile_state();
let artifact_cache =
CodexArtifactProfileCache::new(provider_kind.clone(), ports.artifact_profiles());
let configured_build = profile.snapshot().desktop_build.parse::<u64>().ok();
let verified_profile = match artifact_cache.load().await {
Ok(cached) => cached.filter(|cached| {
cached
.desktop_build
.parse::<u64>()
.ok()
.zip(configured_build)
.is_some_and(|(cached, configured)| cached >= configured)
}),
Err(error) => {
tracing::warn!(error = %error, "OpenAI artifact profile cache could not be loaded");
None
}
};
let session_identity = config
.session_identity()
.map_err(|_| OpenAiInitializeError::SessionIdentity)?;
@@ -73,15 +89,10 @@ pub async fn initialize(
OfficialCodexDesktopReleaseTransport::new()
.map_err(|_| OpenAiInitializeError::DesktopRelease)?,
),
artifact_cache,
verified_profile,
));
let desktop_release_status = desktop_release.status();
let cli_release = Arc::new(CodexCliReleaseService::new(
profile.clone(),
Arc::new(
OfficialCodexCliReleaseTransport::new()
.map_err(|_| OpenAiInitializeError::CliRelease)?,
),
));
let repository = CodexCredentialRepository::new(Arc::clone(&accounts));
let websocket_pool = Arc::new(CodexWebSocketPool::with_config(
config.websocket_pool_config(),
@@ -89,7 +100,7 @@ pub async fn initialize(
let agent_identity = Arc::new(CodexAgentIdentityTaskService::new(
repository.clone(),
Arc::new(
OfficialCodexAgentIdentityTaskRegistrar::new(http.clone())
OfficialCodexAgentIdentityTaskRegistrar::new(http.clone(), profile.clone())
.map_err(|_| OpenAiInitializeError::AgentIdentity)?,
),
Arc::clone(&websocket_pool),
@@ -165,6 +176,7 @@ pub async fn initialize(
exchanger,
Arc::clone(&accounts),
CodexCredentialAdmin,
profile.clone(),
)
.with_oauth_client_id(config.oauth_client_id()),
);
@@ -187,7 +199,6 @@ pub async fn initialize(
catalog,
config.quota_refresh_policy(),
config.oauth_refresh_enabled(),
cli_release,
desktop_release,
)
.map_err(|_| OpenAiInitializeError::Worker)?;
@@ -243,8 +254,6 @@ pub enum OpenAiInitializeError {
Refresh,
#[error("OpenAI Desktop release service could not initialize")]
DesktopRelease,
#[error("OpenAI CLI release service could not initialize")]
CliRelease,
#[error("OpenAI worker plan is invalid")]
Worker,
}
@@ -57,8 +57,7 @@ use crate::transport::diagnostics::{
CodexFailureCategory, CodexUpstreamFailure, CodexUpstreamSendPhase,
};
use crate::transport::profile::{
APPCAST_POLL_INTERVAL, CodexCliReleaseService, CodexDesktopReleaseService,
CodexWireProfileState,
APPCAST_POLL_INTERVAL, CodexDesktopReleaseService, CodexWireProfileState,
};
use crate::transport::protocol::responses::{
CodexResponsesRequest, PreviousResponseScope, ResponseEventSignals,
@@ -737,6 +736,7 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
// (transport/protocol/websocket.rs),push_frames 抽出的 data 即上游原文。
let mut decoder = CodexCanonicalDecoder::new(upstream_model.as_str())
.with_requested_service_tier(request.service_tier())
.with_request_tool_pricing(upstream_model.as_str(), request.tools())
.with_raw_sse_passthrough();
let mut pre_commit_events = PreCommitClientEvents::new();
loop {
@@ -1492,12 +1492,10 @@ fn nonnegative_millis(value: Option<i64>) -> Option<u64> {
}
fn compile_model_capabilities(model: &CodexCatalogModel) -> ProviderModelCapabilities {
let evidence = model.capabilities();
let mut operations = BTreeSet::new();
if evidence.responses_api() == CodexCatalogCapabilityEvidence::DeclaredNative {
operations.insert(OperationKind::Generate);
}
let capabilities = ModelCapabilities::new(operations, None).with_upstream_feature_validation();
// Catalog membership is enough to publish Generate. `supported_in_api` is advisory;
// the upstream response is authoritative for normal and diagnostic requests.
let capabilities = ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), None)
.with_upstream_feature_validation();
ProviderModelCapabilities::new(model.request_model().clone(), capabilities)
.with_presentation(codex_model_presentation(model))
}
@@ -2339,7 +2337,6 @@ const WORKER_LEASE_TTL: Duration = Duration::from_secs(15 * 60);
const WORKER_LEASE_RENEWAL: Duration = Duration::from_secs(5 * 60);
const OAUTH_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
const DESKTOP_RELEASE_WORKER_OWNER: &str = "openai-desktop-release";
const CLI_RELEASE_WORKER_OWNER: &str = "openai-cli-release";
const MODEL_ETAG_WORKER_OWNER: &str = "openai-model-etag";
pub(crate) fn worker_contributions(
@@ -2348,7 +2345,6 @@ pub(crate) fn worker_contributions(
catalog: Arc<CodexCredentialCatalogService>,
quota_refresh_policy: CodexQuotaRefreshPolicy,
oauth_refresh_enabled: bool,
cli_release: Arc<CodexCliReleaseService>,
desktop_release: Arc<CodexDesktopReleaseService>,
) -> Result<Vec<WorkerContribution>, WorkerDefinitionError> {
let refresh_id = WorkerId::try_new(WorkerKind::OAuthRefresh, PROVIDER_NAME)?;
@@ -2356,8 +2352,6 @@ pub(crate) fn worker_contributions(
let etag_id = WorkerId::try_new(WorkerKind::QuotaCatalogHealth, MODEL_ETAG_WORKER_OWNER)?;
let desktop_release_id =
WorkerId::try_new(WorkerKind::QuotaCatalogHealth, DESKTOP_RELEASE_WORKER_OWNER)?;
let cli_release_id =
WorkerId::try_new(WorkerKind::QuotaCatalogHealth, CLI_RELEASE_WORKER_OWNER)?;
let mut contributions = Vec::new();
if oauth_refresh_enabled {
contributions.push(WorkerContribution::Registration(scheduled_registration(
@@ -2385,13 +2379,6 @@ pub(crate) fn worker_contributions(
task: Box::new(OpenAiCatalogEtagTask { catalog }),
},
)?),
WorkerContribution::Registration(scheduled_registration(
cli_release_id,
APPCAST_POLL_INTERVAL,
Box::new(OpenAiCliReleaseTask {
service: cli_release,
}),
)?),
WorkerContribution::Registration(scheduled_registration(
desktop_release_id,
APPCAST_POLL_INTERVAL,
@@ -2512,27 +2499,6 @@ struct OpenAiDesktopReleaseTask {
service: Arc<CodexDesktopReleaseService>,
}
struct OpenAiCliReleaseTask {
service: Arc<CodexCliReleaseService>,
}
impl ScheduledTask for OpenAiCliReleaseTask {
fn run_cycle(&self, context: WorkerCycleContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move {
let refresh = self.service.refresh();
tokio::pin!(refresh);
let result = tokio::select! {
() = context.cancellation().cancelled() => return Ok(()),
result = &mut refresh => result,
};
if let Err(error) = result {
tracing::warn!(error = %error, "OpenAI CLI release check failed");
}
Ok(())
})
}
}
impl ScheduledTask for OpenAiDesktopReleaseTask {
fn run_cycle(&self, context: WorkerCycleContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move {
@@ -21,7 +21,10 @@ use thiserror::Error;
use super::protocol::responses::{
ResponseEventSignals, ResponsesSseFailure, response_event_signals,
};
use super::usage::{normalize_service_tier, openai_billing_breakdown};
use super::usage::{
OpenAiBillingUsage, WebSearchPricing, normalize_service_tier, openai_billing_breakdown,
web_search_pricing,
};
const CONTENTS_PER_OUTPUT: u32 = 1_024;
@@ -43,6 +46,7 @@ pub struct CodexCanonicalDecoder {
semantic_output_seen: bool,
requested_service_tier: Option<String>,
response_service_tier: Option<String>,
web_search_pricing: Option<WebSearchPricing>,
timing_signals: ResponseEventSignals,
raw_sse_passthrough: bool,
}
@@ -139,6 +143,7 @@ impl CodexCanonicalDecoder {
semantic_output_seen: false,
requested_service_tier: None,
response_service_tier: None,
web_search_pricing: None,
timing_signals: ResponseEventSignals::default(),
raw_sse_passthrough: false,
}
@@ -160,6 +165,13 @@ impl CodexCanonicalDecoder {
self
}
/// 记录请求工具类型,用于区分标准与预览 Web Search 的按次价格。
#[must_use]
pub fn with_request_tool_pricing(mut self, model: &str, tools: Option<&[Value]>) -> Self {
self.web_search_pricing = web_search_pricing(model, tools);
self
}
pub fn push(&mut self, chunk: &[u8]) -> CodexCanonicalOutcome {
self.timing_signals = ResponseEventSignals::default();
if self.raw_sse_passthrough {
@@ -849,15 +861,15 @@ impl CodexCanonicalDecoder {
.requested_service_tier
.as_deref()
.or(self.response_service_tier.as_deref());
let web_search_calls = web_search_call_count(response);
if let Some(breakdown) = usage
.filter(|usage| billable_usage_is_complete(response, *usage))
.and_then(|usage| {
let web_search_calls = web_search_calls?;
openai_billing_breakdown(
&model,
usage.input_tokens,
usage.output_tokens,
usage.cached_tokens,
usage.cache_write_tokens,
OpenAiBillingUsage::from(usage)
.with_web_search_calls(web_search_calls, self.web_search_pricing),
service_tier,
)
})
@@ -984,6 +996,28 @@ fn core_usage(usage: TokenUsage) -> Usage {
normalized
}
fn web_search_call_count(response: &Value) -> Option<u64> {
let mut calls = 0_u64;
for item in response
.get("output")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter(|item| item.get("type").and_then(Value::as_str) == Some("web_search_call"))
{
match item
.get("action")
.and_then(|action| action.get("type"))
.and_then(Value::as_str)
{
Some("search") => calls = calls.checked_add(1)?,
Some("open_page" | "find_in_page") => {}
Some(_) | None => return None,
}
}
Some(calls)
}
fn billable_usage_is_complete(response: &Value, usage: TokenUsage) -> bool {
let Some(raw) = response.get("usage").filter(|value| value.is_object()) else {
return false;
@@ -677,7 +677,13 @@ impl CodexBackendClient {
// API 透传黑名单也不能覆盖画像,避免下游客户端暴露不一致指纹。
if matches!(
name.as_str(),
"originator" | "user-agent" | "x-oai-attestation" | "x-oai-is" | "x-oai-is-update"
"openai-beta"
| "originator"
| "user-agent"
| "x-oai-attestation"
| "x-oai-is"
| "x-oai-is-update"
| "x-openai-internal-codex-residency"
) {
continue;
}
@@ -696,6 +702,10 @@ impl CodexBackendClient {
) -> CodexClientResult<HeaderMap> {
let mut headers = self.request_headers_for_http_response(request, context)?;
headers.remove(X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER);
headers.insert(
HeaderName::from_static("openai-beta"),
HeaderValue::from_static("responses_websockets=2026-02-06"),
);
Ok(headers)
}
@@ -709,14 +719,6 @@ impl CodexBackendClient {
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
insert_optional_header(&mut headers, "cookie", context.cookie_header)?;
headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream"));
headers.insert(
HeaderName::from_static("openai-beta"),
HeaderValue::from_static("responses_websockets=2026-02-06"),
);
headers.insert(
HeaderName::from_static("x-openai-internal-codex-residency"),
HeaderValue::from_static("us"),
);
headers.insert(
HeaderName::from_static("x-client-request-id"),
HeaderValue::from_str(context.request_id)?,
@@ -788,10 +790,6 @@ impl CodexBackendClient {
let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, HeaderValue::from_str(&profile.user_agent())?);
headers.insert(AUTHORIZATION, HeaderValue::from_str(context.authorization)?);
headers.insert(
HeaderName::from_static("originator"),
HeaderValue::from_str(&profile.originator)?,
);
insert_optional_header(&mut headers, "chatgpt-account-id", context.account_id)?;
insert_optional_header(&mut headers, "cookie", context.cookie_header)?;
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
@@ -1,7 +1,5 @@
/// `/codex/responses`
pub const CODEX_RESPONSES_PATH: &str = "/codex/responses";
/// `/codex/usage`
pub const CODEX_USAGE_PATH: &str = "/codex/usage";
/// `/api/codex/usage`
pub const CODEX_USAGE_API_PATH: &str = "/api/codex/usage";
/// `/wham/usage`
@@ -16,20 +14,14 @@ pub fn endpoint_url(base_url: &str, endpoint_path: &str) -> String {
)
}
/// 返回 usage 相关 endpoint 的完整 URL 列表。
pub fn usage_endpoint_urls(base_url: &str) -> Vec<String> {
usage_endpoint_paths(base_url)
.into_iter()
.map(|path| endpoint_url(base_url, path))
.collect()
}
fn usage_endpoint_paths(base_url: &str) -> Vec<&'static str> {
if has_backend_api_base_path(base_url) {
vec![WHAM_USAGE_PATH, CODEX_USAGE_PATH]
/// 返回与 base path 对应的唯一 usage endpoint。
pub fn usage_endpoint_url(base_url: &str) -> String {
let path = if has_backend_api_base_path(base_url) {
WHAM_USAGE_PATH
} else {
vec![CODEX_USAGE_API_PATH, CODEX_USAGE_PATH]
}
CODEX_USAGE_API_PATH
};
endpoint_url(base_url, path)
}
fn has_backend_api_base_path(base_url: &str) -> bool {
@@ -3,20 +3,33 @@ use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue, USER_AG
use super::client::CodexClientResult;
const CODEX_RESIDENCY_HEADER: &str = "x-openai-internal-codex-residency";
/// 构造 Codex Core 为模型请求设置的稳定身份请求头。
pub fn build_codex_base_headers(
profile: &CodexWireProfile,
authorization: &str,
account_id: Option<&str>,
) -> CodexClientResult<HeaderMap> {
let mut headers = HeaderMap::new();
let mut headers = build_codex_profile_headers(profile)?;
headers.insert(AUTHORIZATION, HeaderValue::from_str(authorization)?);
insert_optional_header(&mut headers, "chatgpt-account-id", account_id)?;
Ok(headers)
}
/// 构造不依赖访问凭据的 Codex Desktop 身份请求头。
pub fn build_codex_profile_headers(profile: &CodexWireProfile) -> CodexClientResult<HeaderMap> {
let mut headers = HeaderMap::new();
headers.insert(
HeaderName::from_static("originator"),
HeaderValue::from_str(&profile.originator)?,
);
headers.insert(USER_AGENT, HeaderValue::from_str(&profile.user_agent())?);
// 此部署固定采用官方支持的 US residency requirement。
headers.insert(
HeaderName::from_static(CODEX_RESIDENCY_HEADER),
HeaderValue::from_static("us"),
);
Ok(headers)
}
@@ -32,13 +32,13 @@ pub use self::{
},
diagnostics::{CodexUpstreamDiagnostics, CodexUpstreamSendPhase},
endpoints::{
CODEX_RESPONSES_PATH, CODEX_USAGE_API_PATH, CODEX_USAGE_PATH, WHAM_USAGE_PATH,
endpoint_url, usage_endpoint_urls,
CODEX_RESPONSES_PATH, CODEX_USAGE_API_PATH, WHAM_USAGE_PATH, endpoint_url,
usage_endpoint_url,
},
headers::build_codex_base_headers,
request::{CodexRequestEncodeError, encode_generate_request},
response_meta::CodexResponseMetadata,
usage::{MAX_CODEX_USAGE_BODY_BYTES, openai_billing_breakdown},
usage::{MAX_CODEX_USAGE_BODY_BYTES, OpenAiBillingUsage, openai_billing_breakdown},
websocket::{
CodexWebSocketPool, CodexWebSocketPoolConfig, CodexWebSocketPoolKey, WebSocketPoolDecision,
},
@@ -0,0 +1,600 @@
//! 有界读取 Codex Desktop ZIP 内嵌的 arm64 Core 版本。
use std::cmp;
use flate2::{Decompress, FlushDecompress, Status};
use futures::StreamExt as _;
use reqwest::header::{CONTENT_LENGTH, CONTENT_RANGE, RANGE};
use reqwest::{Client, StatusCode};
use url::Url;
const OFFICIAL_ARTIFACT_HOST: &str = "persistent.oaistatic.com";
const EOCD_SIGNATURE: &[u8; 4] = b"PK\x05\x06";
const CENTRAL_SIGNATURE: &[u8; 4] = b"PK\x01\x02";
const LOCAL_SIGNATURE: &[u8; 4] = b"PK\x03\x04";
const CORE_PATH_SUFFIX: &[u8] = b"/Contents/Resources/codex";
const CORE_VERSION_ANCHOR: &[u8] = b"codex-mcp-client/";
const MAX_EOCD_SEARCH_BYTES: u64 = 65_535 + 22;
const MAX_CENTRAL_DIRECTORY_BYTES: u64 = 16 * 1024 * 1024;
const MAX_RANGE_BYTES: u64 = 16 * 1024 * 1024;
const CORE_RANGE_CHUNK_BYTES: u64 = 4 * 1024 * 1024;
const MAX_CORE_COMPRESSED_BYTES: u64 = 256 * 1024 * 1024;
const MAX_CORE_UNCOMPRESSED_BYTES: u64 = 512 * 1024 * 1024;
const MAX_VERSION_BYTES: usize = 64;
const MACHO_HEADER_BYTES: usize = 8;
const MACHO_64_LE_MAGIC: [u8; 4] = [0xcf, 0xfa, 0xed, 0xfe];
const MACHO_ARM64_CPU_TYPE: u32 = 0x0100_000c;
#[derive(Debug, thiserror::Error)]
pub enum CodexDesktopArtifactError {
#[error("Codex Desktop artifact URL is invalid")]
InvalidUrl,
#[error("Codex Desktop artifact URL is not an approved official ZIP")]
UnapprovedUrl,
#[error("Codex Desktop artifact size is invalid")]
InvalidSize,
#[error("Codex Desktop artifact range request failed")]
Http(#[from] reqwest::Error),
#[error("Codex Desktop artifact did not honor an exact range request")]
RangeUnsupported,
#[error("Codex Desktop artifact returned an invalid range")]
InvalidRange,
#[error("Codex Desktop artifact ZIP metadata is invalid or unsupported")]
InvalidArchive,
#[error("Codex Desktop artifact does not contain exactly one bundled Core")]
MissingCore,
#[error("Codex Desktop bundled Core format is unsupported")]
UnsupportedCore,
#[error("Codex Desktop bundled Core exceeds the scan limit")]
CoreTooLarge,
#[error("Codex Desktop bundled Core version marker is missing or invalid")]
MissingCoreVersion,
}
#[derive(Debug)]
struct CentralDirectory {
offset: u64,
size: u64,
entries: u16,
}
#[derive(Debug)]
pub(super) struct CoreEntry {
pub(super) name: Vec<u8>,
flags: u16,
compression: u16,
pub(super) compressed_size: u64,
pub(super) uncompressed_size: u64,
pub(super) local_header_offset: u64,
}
pub(super) async fn fetch_codex_core_version(
client: &Client,
artifact_url: &str,
artifact_size: u64,
) -> Result<String, CodexDesktopArtifactError> {
let url = validate_artifact(artifact_url, artifact_size)?;
let tail_start = artifact_size.saturating_sub(MAX_EOCD_SEARCH_BYTES);
let tail = read_range(client, &url, artifact_size, tail_start, artifact_size - 1).await?;
let directory = parse_eocd(&tail, tail_start, artifact_size)?;
let directory_end = directory
.offset
.checked_add(directory.size)
.and_then(|end| end.checked_sub(1))
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
let central = if directory.offset >= tail_start && directory_end < artifact_size {
let start = usize::try_from(directory.offset - tail_start)
.map_err(|_| CodexDesktopArtifactError::InvalidArchive)?;
let end = usize::try_from(directory_end - tail_start + 1)
.map_err(|_| CodexDesktopArtifactError::InvalidArchive)?;
tail.get(start..end)
.ok_or(CodexDesktopArtifactError::InvalidArchive)?
.to_vec()
} else {
read_range(client, &url, artifact_size, directory.offset, directory_end).await?
};
let entry = find_core_entry(&central, directory.entries)?;
let data_offset = core_data_offset(client, &url, artifact_size, &entry).await?;
scan_core_version(client, &url, artifact_size, data_offset, &entry).await
}
pub(super) fn validate_codex_artifact(
artifact_url: &str,
artifact_size: u64,
) -> Result<(), CodexDesktopArtifactError> {
validate_artifact(artifact_url, artifact_size).map(drop)
}
fn validate_artifact(
artifact_url: &str,
artifact_size: u64,
) -> Result<Url, CodexDesktopArtifactError> {
if artifact_size == 0 || artifact_size > u64::from(u32::MAX) {
return Err(CodexDesktopArtifactError::InvalidSize);
}
let url = Url::parse(artifact_url).map_err(|_| CodexDesktopArtifactError::InvalidUrl)?;
if url.scheme() != "https"
|| url.host_str() != Some(OFFICIAL_ARTIFACT_HOST)
|| !url.path().to_ascii_lowercase().ends_with(".zip")
|| url.username() != ""
|| url.password().is_some()
|| url.fragment().is_some()
{
return Err(CodexDesktopArtifactError::UnapprovedUrl);
}
Ok(url)
}
async fn read_range(
client: &Client,
url: &Url,
artifact_size: u64,
start: u64,
end: u64,
) -> Result<Vec<u8>, CodexDesktopArtifactError> {
let expected = end
.checked_sub(start)
.and_then(|length| length.checked_add(1))
.filter(|length| *length <= MAX_RANGE_BYTES)
.ok_or(CodexDesktopArtifactError::InvalidRange)?;
let response = client
.get(url.clone())
.header(RANGE, format!("bytes={start}-{end}"))
.send()
.await?;
if response.status() != StatusCode::PARTIAL_CONTENT {
return Err(CodexDesktopArtifactError::RangeUnsupported);
}
let content_range = response
.headers()
.get(CONTENT_RANGE)
.and_then(|value| value.to_str().ok())
.ok_or(CodexDesktopArtifactError::InvalidRange)?;
if parse_content_range(content_range) != Some((start, end, artifact_size)) {
return Err(CodexDesktopArtifactError::InvalidRange);
}
if response
.headers()
.get(CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<u64>().ok())
!= Some(expected)
{
return Err(CodexDesktopArtifactError::InvalidRange);
}
let capacity =
usize::try_from(expected).map_err(|_| CodexDesktopArtifactError::InvalidRange)?;
let mut body = Vec::with_capacity(capacity);
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
if body
.len()
.checked_add(chunk.len())
.is_none_or(|length| length > capacity)
{
return Err(CodexDesktopArtifactError::InvalidRange);
}
body.extend_from_slice(&chunk);
}
if body.len() != capacity {
return Err(CodexDesktopArtifactError::InvalidRange);
}
Ok(body)
}
pub(super) fn parse_content_range(value: &str) -> Option<(u64, u64, u64)> {
let value = value.strip_prefix("bytes ")?;
let (range, size) = value.split_once('/')?;
let (start, end) = range.split_once('-')?;
Some((start.parse().ok()?, end.parse().ok()?, size.parse().ok()?))
}
fn parse_eocd(
tail: &[u8],
tail_start: u64,
artifact_size: u64,
) -> Result<CentralDirectory, CodexDesktopArtifactError> {
let position = tail
.windows(EOCD_SIGNATURE.len())
.rposition(|window| window == EOCD_SIGNATURE)
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
let eocd = tail
.get(position..)
.filter(|value| value.len() >= 22)
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
let comment_length = usize::from(le_u16(eocd, 20)?);
if eocd.len() != 22 + comment_length || le_u16(eocd, 4)? != 0 || le_u16(eocd, 6)? != 0 {
return Err(CodexDesktopArtifactError::InvalidArchive);
}
let entries_on_disk = le_u16(eocd, 8)?;
let entries = le_u16(eocd, 10)?;
let size = u64::from(le_u32(eocd, 12)?);
let offset = u64::from(le_u32(eocd, 16)?);
if entries == 0
|| entries != entries_on_disk
|| entries == u16::MAX
|| size == u64::from(u32::MAX)
|| offset == u64::from(u32::MAX)
|| size == 0
|| size > MAX_CENTRAL_DIRECTORY_BYTES
{
return Err(CodexDesktopArtifactError::InvalidArchive);
}
let eocd_offset = tail_start
.checked_add(
u64::try_from(position).map_err(|_| CodexDesktopArtifactError::InvalidArchive)?,
)
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
if offset
.checked_add(size)
.is_none_or(|end| end > eocd_offset || end > artifact_size)
{
return Err(CodexDesktopArtifactError::InvalidArchive);
}
Ok(CentralDirectory {
offset,
size,
entries,
})
}
pub(super) fn find_core_entry(
central: &[u8],
expected_entries: u16,
) -> Result<CoreEntry, CodexDesktopArtifactError> {
let mut cursor = 0_usize;
let mut core = None;
for _ in 0..expected_entries {
let header = central
.get(cursor..cursor.saturating_add(46))
.filter(|header| header.get(..4) == Some(CENTRAL_SIGNATURE.as_slice()))
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
let name_length = usize::from(le_u16(header, 28)?);
let extra_length = usize::from(le_u16(header, 30)?);
let comment_length = usize::from(le_u16(header, 32)?);
let entry_length = 46_usize
.checked_add(name_length)
.and_then(|length| length.checked_add(extra_length))
.and_then(|length| length.checked_add(comment_length))
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
let entry = central
.get(cursor..cursor.saturating_add(entry_length))
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
let name = entry
.get(46..46 + name_length)
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
if name.ends_with(CORE_PATH_SUFFIX) {
if core.is_some() {
return Err(CodexDesktopArtifactError::MissingCore);
}
let flags = le_u16(header, 8)?;
let compression = le_u16(header, 10)?;
let compressed_size = u64::from(le_u32(header, 20)?);
let uncompressed_size = u64::from(le_u32(header, 24)?);
let local_header_offset = u64::from(le_u32(header, 42)?);
if flags & 1 != 0
|| compression != 8
|| compressed_size == 0
|| compressed_size == u64::from(u32::MAX)
|| compressed_size > MAX_CORE_COMPRESSED_BYTES
|| uncompressed_size == 0
|| uncompressed_size == u64::from(u32::MAX)
|| uncompressed_size > MAX_CORE_UNCOMPRESSED_BYTES
|| local_header_offset == u64::from(u32::MAX)
{
return Err(CodexDesktopArtifactError::UnsupportedCore);
}
core = Some(CoreEntry {
name: name.to_vec(),
flags,
compression,
compressed_size,
uncompressed_size,
local_header_offset,
});
}
cursor = cursor
.checked_add(entry_length)
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
}
core.ok_or(CodexDesktopArtifactError::MissingCore)
}
async fn core_data_offset(
client: &Client,
url: &Url,
artifact_size: u64,
entry: &CoreEntry,
) -> Result<u64, CodexDesktopArtifactError> {
let fixed_end = entry
.local_header_offset
.checked_add(29)
.filter(|end| *end < artifact_size)
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
let fixed = read_range(
client,
url,
artifact_size,
entry.local_header_offset,
fixed_end,
)
.await?;
if fixed.get(..4) != Some(LOCAL_SIGNATURE.as_slice())
|| le_u16(&fixed, 6)? != entry.flags
|| le_u16(&fixed, 8)? != entry.compression
{
return Err(CodexDesktopArtifactError::InvalidArchive);
}
let name_length = u64::from(le_u16(&fixed, 26)?);
let extra_length = u64::from(le_u16(&fixed, 28)?);
let variable_length = name_length
.checked_add(extra_length)
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
let variable_start = entry
.local_header_offset
.checked_add(30)
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
let data_offset = variable_start
.checked_add(variable_length)
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
let data_end = data_offset
.checked_add(entry.compressed_size)
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
if data_end > artifact_size || name_length == 0 {
return Err(CodexDesktopArtifactError::InvalidArchive);
}
let variable_end = data_offset
.checked_sub(1)
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
let variable = read_range(client, url, artifact_size, variable_start, variable_end).await?;
let local_name_length =
usize::try_from(name_length).map_err(|_| CodexDesktopArtifactError::InvalidArchive)?;
if variable.get(..local_name_length) != Some(entry.name.as_slice()) {
return Err(CodexDesktopArtifactError::InvalidArchive);
}
Ok(data_offset)
}
async fn scan_core_version(
client: &Client,
url: &Url,
artifact_size: u64,
data_offset: u64,
entry: &CoreEntry,
) -> Result<String, CodexDesktopArtifactError> {
let mut decompressor = Decompress::new(false);
let mut scanner = CoreVersionScanner::default();
let mut macho_header = Vec::with_capacity(MACHO_HEADER_BYTES);
let mut compressed_read = 0_u64;
let mut uncompressed_read = 0_u64;
while compressed_read < entry.compressed_size {
let chunk_length = cmp::min(
CORE_RANGE_CHUNK_BYTES,
entry.compressed_size - compressed_read,
);
let start = data_offset
.checked_add(compressed_read)
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
let end = start
.checked_add(chunk_length - 1)
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
let compressed = read_range(client, url, artifact_size, start, end).await?;
compressed_read = compressed_read
.checked_add(chunk_length)
.ok_or(CodexDesktopArtifactError::CoreTooLarge)?;
let final_chunk = compressed_read == entry.compressed_size;
let mut input_offset = 0_usize;
loop {
if input_offset == compressed.len() && !final_chunk {
break;
}
let mut output = [0_u8; 64 * 1024];
let input_before = decompressor.total_in();
let output_before = decompressor.total_out();
let status = decompressor
.decompress(
&compressed[input_offset..],
&mut output,
if final_chunk {
FlushDecompress::Finish
} else {
FlushDecompress::None
},
)
.map_err(|_| CodexDesktopArtifactError::UnsupportedCore)?;
let consumed = usize::try_from(decompressor.total_in() - input_before)
.map_err(|_| CodexDesktopArtifactError::CoreTooLarge)?;
let produced = usize::try_from(decompressor.total_out() - output_before)
.map_err(|_| CodexDesktopArtifactError::CoreTooLarge)?;
if consumed == 0 && produced == 0 {
return Err(CodexDesktopArtifactError::UnsupportedCore);
}
input_offset = input_offset
.checked_add(consumed)
.ok_or(CodexDesktopArtifactError::CoreTooLarge)?;
uncompressed_read = uncompressed_read
.checked_add(
u64::try_from(produced).map_err(|_| CodexDesktopArtifactError::CoreTooLarge)?,
)
.filter(|read| {
*read <= entry.uncompressed_size && *read <= MAX_CORE_UNCOMPRESSED_BYTES
})
.ok_or(CodexDesktopArtifactError::CoreTooLarge)?;
let output = &output[..produced];
if macho_header.len() < MACHO_HEADER_BYTES {
let needed = MACHO_HEADER_BYTES - macho_header.len();
macho_header.extend_from_slice(&output[..cmp::min(needed, output.len())]);
if macho_header.len() == MACHO_HEADER_BYTES {
validate_macho_header(&macho_header)?;
}
}
if let Some(version) = scanner.push(output)? {
if macho_header.len() != MACHO_HEADER_BYTES {
return Err(CodexDesktopArtifactError::UnsupportedCore);
}
return Ok(version);
}
if status == Status::StreamEnd {
if !final_chunk
|| input_offset != compressed.len()
|| uncompressed_read != entry.uncompressed_size
|| macho_header.len() != MACHO_HEADER_BYTES
{
return Err(CodexDesktopArtifactError::UnsupportedCore);
}
return scanner
.finish()?
.ok_or(CodexDesktopArtifactError::MissingCoreVersion);
}
}
}
Err(CodexDesktopArtifactError::UnsupportedCore)
}
fn validate_macho_header(header: &[u8]) -> Result<(), CodexDesktopArtifactError> {
if header.get(..4) != Some(MACHO_64_LE_MAGIC.as_slice())
|| header
.get(4..8)
.and_then(|bytes| bytes.try_into().ok())
.map(u32::from_le_bytes)
!= Some(MACHO_ARM64_CPU_TYPE)
{
return Err(CodexDesktopArtifactError::UnsupportedCore);
}
Ok(())
}
#[derive(Default)]
pub(super) struct CoreVersionScanner {
carry: Vec<u8>,
}
impl CoreVersionScanner {
pub(super) fn push(
&mut self,
bytes: &[u8],
) -> Result<Option<String>, CodexDesktopArtifactError> {
let mut window = Vec::with_capacity(self.carry.len() + bytes.len());
window.extend_from_slice(&self.carry);
window.extend_from_slice(bytes);
let mut search_start = 0_usize;
while let Some(relative_position) = find_bytes(&window[search_start..], CORE_VERSION_ANCHOR)
{
let position = search_start + relative_position;
if let Some(version) =
parse_codex_semver(&window[position + CORE_VERSION_ANCHOR.len()..], false)?
{
return Ok(Some(version));
}
search_start = position + CORE_VERSION_ANCHOR.len();
}
let carry_length = cmp::min(window.len(), CORE_VERSION_ANCHOR.len() + MAX_VERSION_BYTES);
self.carry.clear();
self.carry
.extend_from_slice(&window[window.len() - carry_length..]);
Ok(None)
}
fn finish(&self) -> Result<Option<String>, CodexDesktopArtifactError> {
let mut search_start = 0_usize;
while let Some(relative_position) =
find_bytes(&self.carry[search_start..], CORE_VERSION_ANCHOR)
{
let position = search_start + relative_position;
if let Some(version) =
parse_codex_semver(&self.carry[position + CORE_VERSION_ANCHOR.len()..], true)?
{
return Ok(Some(version));
}
search_start = position + CORE_VERSION_ANCHOR.len();
}
Ok(None)
}
}
fn parse_codex_semver(
bytes: &[u8],
allow_end: bool,
) -> Result<Option<String>, CodexDesktopArtifactError> {
let mut cursor = 0_usize;
for component in 0..3 {
let start = cursor;
while bytes.get(cursor).is_some_and(u8::is_ascii_digit) {
cursor += 1;
}
if cursor == start || (cursor - start > 1 && bytes[start] == b'0') {
return Ok(None);
}
if component < 2 {
if bytes.get(cursor) != Some(&b'.') {
return Ok(None);
}
cursor += 1;
}
}
if bytes.get(cursor) == Some(&b'-') {
cursor += 1;
let channel = [b"alpha".as_slice(), b"beta".as_slice(), b"rc".as_slice()]
.into_iter()
.find(|channel| bytes.get(cursor..cursor + channel.len()) == Some(*channel));
let Some(channel) = channel else {
return Ok(None);
};
cursor += channel.len();
let mut numeric_parts = 0_usize;
while bytes.get(cursor) == Some(&b'.') {
cursor += 1;
let start = cursor;
while bytes.get(cursor).is_some_and(u8::is_ascii_digit) {
cursor += 1;
}
if cursor == start || (cursor - start > 1 && bytes[start] == b'0') {
return Ok(None);
}
numeric_parts += 1;
}
if numeric_parts == 0 {
return Ok(None);
}
}
if cursor == bytes.len() && !allow_end {
return Ok(None);
}
if bytes.get(cursor).is_some_and(u8::is_ascii_digit)
|| bytes.get(cursor) == Some(&b'.')
|| bytes
.get(cursor)
.is_some_and(|byte| matches!(*byte, b'+' | b'-'))
|| cursor > MAX_VERSION_BYTES
{
return Ok(None);
}
let version = std::str::from_utf8(&bytes[..cursor])
.map_err(|_| CodexDesktopArtifactError::MissingCoreVersion)?;
semver::Version::parse(version).map_err(|_| CodexDesktopArtifactError::MissingCoreVersion)?;
Ok(Some(version.to_owned()))
}
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}
fn le_u16(bytes: &[u8], offset: usize) -> Result<u16, CodexDesktopArtifactError> {
bytes
.get(offset..offset + 2)
.and_then(|value| value.try_into().ok())
.map(u16::from_le_bytes)
.ok_or(CodexDesktopArtifactError::InvalidArchive)
}
fn le_u32(bytes: &[u8], offset: usize) -> Result<u32, CodexDesktopArtifactError> {
bytes
.get(offset..offset + 4)
.and_then(|value| value.try_into().ok())
.map(u32::from_le_bytes)
.ok_or(CodexDesktopArtifactError::InvalidArchive)
}
@@ -5,31 +5,42 @@ use std::time::Duration;
use chrono::{DateTime, Utc};
use futures::{StreamExt as _, future::BoxFuture};
use gateway_core::engine::credential::OpaqueProviderData;
use gateway_core::provider_ports::{
ProviderArtifactProfile, ProviderArtifactProfileCachePort, ProviderStoreError,
};
use gateway_core::routing::ProviderKind;
use reqwest::Client;
use reqwest::redirect::Policy;
use roxmltree::{Document, Node};
use serde::Deserialize;
use serde_json::{Map, Value};
use url::Url;
use self::desktop_artifact::{CodexDesktopArtifactError, fetch_codex_core_version};
mod desktop_artifact;
/// Codex Desktop 官方 appcast 地址。
pub const CODEX_DESKTOP_APPCAST_URL: &str =
"https://persistent.oaistatic.com/codex-app-prod/appcast.xml";
pub const CODEX_CLI_RELEASE_URL: &str = "https://registry.npmjs.org/@openai%2Fcodex/latest";
/// 官方发布元数据检查周期。
pub const APPCAST_POLL_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
const APPCAST_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_APPCAST_BYTES: usize = 1024 * 1024;
const MAX_CLI_RELEASE_BYTES: usize = 64 * 1024;
const ARTIFACT_PROFILE_CACHE_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60);
const ARTIFACT_PROFILE_SCHEMA_VERSION: u64 = 1;
/// Codex Desktop 上游请求身份。
///
/// 启动配置提供经源码审计的 Core、运行环境和 Desktop 启动版本。npm CLI metadata
/// 与官方 appcast 检查成功后,分别同步 Core 版本和 Desktop 版本及构建号。
/// 启动配置提供经源码审计的 Core、运行环境和 Desktop 启动版本。运行时只会使用
/// 同一个官方 Desktop ZIP 中核验出的 Core、Desktop 版本及构建号原子替换版本字段。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexWireProfile {
/// `originator` 请求头及 User-Agent 产品名。
pub originator: String,
/// npm `@openai/codex` 启动版本;用于 `/codex/models?client_version=` 与 UA。
/// Desktop ZIP 内嵌 Core 版本;用于 `/codex/models?client_version=` 与 UA。
pub codex_version: String,
/// Desktop 应用版本,用于 app-server `clientInfo.version` 对应的 UA 后缀。
pub desktop_version: String,
@@ -86,124 +97,148 @@ impl CodexWireProfileState {
.clone()
}
/// 使用官方 appcast 已验证的 Desktop 版本和构建号更新运行时画像。
pub fn update_desktop_release(&self, desktop_version: &str, desktop_build: &str) {
/// 原子发布同一 Desktop ZIP 中核验出的完整版本元组。
pub fn update_bundled_release(&self, release: &CodexBundledReleaseProfile) {
let mut profile = self
.profile
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if profile.desktop_version == desktop_version && profile.desktop_build == desktop_build {
return;
profile.codex_version.clone_from(&release.codex_version);
profile.desktop_version.clone_from(&release.desktop_version);
profile.desktop_build.clone_from(&release.desktop_build);
profile.verified_at = release.verified_at;
}
}
/// 同一个官方 Desktop ZIP 中核验出的原子版本元组。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexBundledReleaseProfile {
pub codex_version: String,
pub desktop_version: String,
pub desktop_build: String,
pub verified_at: DateTime<Utc>,
}
impl CodexBundledReleaseProfile {
fn artifact_sequence(&self) -> Result<u64, CodexDesktopReleaseError> {
parse_build_sequence(&self.desktop_build)
}
fn validate(&self) -> Result<(), CodexDesktopReleaseError> {
semver::Version::parse(&self.codex_version)
.map_err(|_| CodexDesktopReleaseError::InvalidCoreVersion)?;
if !numeric_dotted_version(&self.desktop_version) {
return Err(CodexDesktopReleaseError::InvalidVersion);
}
profile.desktop_version = desktop_version.to_owned();
profile.desktop_build = desktop_build.to_owned();
}
/// 使用官方 npm metadata 发布的 Codex CLI 版本更新运行时请求画像。
pub fn update_cli_release(&self, codex_version: &str) {
let mut profile = self
.profile
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if profile.codex_version != codex_version {
profile.codex_version = codex_version.to_owned();
}
}
}
pub trait CodexCliReleaseTransport: Send + Sync {
fn fetch(&self) -> BoxFuture<'_, Result<String, CodexCliReleaseError>>;
}
#[derive(Clone)]
pub struct OfficialCodexCliReleaseTransport {
client: Client,
endpoint: Url,
}
impl OfficialCodexCliReleaseTransport {
pub fn new() -> Result<Self, CodexCliReleaseError> {
let endpoint =
Url::parse(CODEX_CLI_RELEASE_URL).map_err(|_| CodexCliReleaseError::InvalidEndpoint)?;
let client = Client::builder()
.https_only(true)
.no_proxy()
.redirect(Policy::none())
.connect_timeout(Duration::from_secs(10))
.timeout(APPCAST_TIMEOUT)
.build()
.map_err(|_| CodexCliReleaseError::ClientInitialization)?;
Ok(Self { client, endpoint })
}
}
impl CodexCliReleaseTransport for OfficialCodexCliReleaseTransport {
fn fetch(&self) -> BoxFuture<'_, Result<String, CodexCliReleaseError>> {
Box::pin(async move {
let response = self.client.get(self.endpoint.clone()).send().await?;
if !response.status().is_success() {
return Err(CodexCliReleaseError::HttpStatus(response.status().as_u16()));
}
if response
.content_length()
.is_some_and(|size| size > MAX_CLI_RELEASE_BYTES as u64)
{
return Err(CodexCliReleaseError::ResponseTooLarge);
}
let bytes = response.bytes().await?;
if bytes.len() > MAX_CLI_RELEASE_BYTES {
return Err(CodexCliReleaseError::ResponseTooLarge);
}
let document: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|_| CodexCliReleaseError::InvalidDocument)?;
let version = document
.get("version")
.and_then(serde_json::Value::as_str)
.ok_or(CodexCliReleaseError::InvalidDocument)?;
semver::Version::parse(version).map_err(|_| CodexCliReleaseError::InvalidVersion)?;
Ok(version.to_owned())
})
self.artifact_sequence()?;
Ok(())
}
}
#[derive(Clone)]
pub struct CodexCliReleaseService {
transport: Arc<dyn CodexCliReleaseTransport>,
profile: CodexWireProfileState,
pub struct CodexArtifactProfileCache {
provider_kind: ProviderKind,
store: Arc<dyn ProviderArtifactProfileCachePort>,
}
impl CodexCliReleaseService {
impl CodexArtifactProfileCache {
#[must_use]
pub fn new(
profile: CodexWireProfileState,
transport: Arc<dyn CodexCliReleaseTransport>,
provider_kind: ProviderKind,
store: Arc<dyn ProviderArtifactProfileCachePort>,
) -> Self {
Self { transport, profile }
Self {
provider_kind,
store,
}
}
pub async fn refresh(&self) -> Result<String, CodexCliReleaseError> {
let version = self.transport.fetch().await?;
self.profile.update_cli_release(&version);
Ok(version)
pub async fn load(
&self,
) -> Result<Option<CodexBundledReleaseProfile>, CodexDesktopReleaseError> {
self.store
.read(&self.provider_kind)
.await
.map_err(CodexDesktopReleaseError::ArtifactCache)?
.map(decode_artifact_profile)
.transpose()
}
async fn replace_if_newer(
&self,
profile: &CodexBundledReleaseProfile,
) -> Result<bool, CodexDesktopReleaseError> {
profile.validate()?;
self.store
.replace_if_newer(
encode_artifact_profile(self.provider_kind.clone(), profile)?,
ARTIFACT_PROFILE_CACHE_TTL,
)
.await
.map_err(CodexDesktopReleaseError::ArtifactCache)
}
}
#[derive(Debug, thiserror::Error)]
pub enum CodexCliReleaseError {
#[error("Codex CLI release client initialization failed")]
ClientInitialization,
#[error("Codex CLI release endpoint is invalid")]
InvalidEndpoint,
#[error("Codex CLI release request failed")]
Request(#[from] reqwest::Error),
#[error("Codex CLI release endpoint returned HTTP {0}")]
HttpStatus(u16),
#[error("Codex CLI release response exceeded the size limit")]
ResponseTooLarge,
#[error("Codex CLI release document is invalid")]
InvalidDocument,
#[error("Codex CLI release version is invalid")]
InvalidVersion,
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CachedArtifactProfile {
schema_version: u64,
codex_version: String,
desktop_version: String,
desktop_build: String,
}
fn encode_artifact_profile(
provider_kind: ProviderKind,
profile: &CodexBundledReleaseProfile,
) -> Result<ProviderArtifactProfile, CodexDesktopReleaseError> {
let artifact_sequence = profile.artifact_sequence()?;
let mut fields = Map::new();
fields.insert(
"schema_version".to_owned(),
Value::from(ARTIFACT_PROFILE_SCHEMA_VERSION),
);
fields.insert(
"codex_version".to_owned(),
Value::String(profile.codex_version.clone()),
);
fields.insert(
"desktop_version".to_owned(),
Value::String(profile.desktop_version.clone()),
);
fields.insert(
"desktop_build".to_owned(),
Value::String(profile.desktop_build.clone()),
);
Ok(ProviderArtifactProfile::new(
provider_kind,
artifact_sequence,
profile.verified_at.into(),
OpaqueProviderData::new(fields),
))
}
fn decode_artifact_profile(
profile: ProviderArtifactProfile,
) -> Result<CodexBundledReleaseProfile, CodexDesktopReleaseError> {
let wire: CachedArtifactProfile = serde_json::from_value(Value::Object(
profile.profile().expose_to_provider().clone(),
))
.map_err(|_| CodexDesktopReleaseError::InvalidArtifactCache)?;
if wire.schema_version != ARTIFACT_PROFILE_SCHEMA_VERSION {
return Err(CodexDesktopReleaseError::InvalidArtifactCache);
}
let decoded = CodexBundledReleaseProfile {
codex_version: wire.codex_version,
desktop_version: wire.desktop_version,
desktop_build: wire.desktop_build,
verified_at: DateTime::<Utc>::from(profile.verified_at()),
};
decoded.validate()?;
if decoded.artifact_sequence()? != profile.artifact_sequence() {
return Err(CodexDesktopReleaseError::InvalidArtifactCache);
}
Ok(decoded)
}
/// 官方 appcast 中按顺序出现的首个完整 Desktop 制品。
@@ -266,6 +301,11 @@ impl CodexDesktopReleaseStatus {
/// Desktop appcast 获取边界。生产实现固定访问官方 HTTPS,测试实现只替换此边界。
pub trait CodexDesktopReleaseTransport: Send + Sync {
fn fetch(&self) -> BoxFuture<'_, Result<CodexDesktopRelease, CodexDesktopReleaseError>>;
fn fetch_bundled_core_version<'a>(
&'a self,
release: &'a CodexDesktopRelease,
) -> BoxFuture<'a, Result<String, CodexDesktopReleaseError>>;
}
/// 固定访问官方 Desktop appcast 的生产 transport。
@@ -325,6 +365,24 @@ impl CodexDesktopReleaseTransport for OfficialCodexDesktopReleaseTransport {
parse_desktop_release(xml)
})
}
fn fetch_bundled_core_version<'a>(
&'a self,
release: &'a CodexDesktopRelease,
) -> BoxFuture<'a, Result<String, CodexDesktopReleaseError>> {
Box::pin(async move {
let artifact_url = release
.download_url
.as_deref()
.ok_or(CodexDesktopReleaseError::MissingArtifactIdentity)?;
let artifact_size = release
.download_size
.ok_or(CodexDesktopReleaseError::MissingArtifactIdentity)?;
fetch_codex_core_version(&self.client, artifact_url, artifact_size)
.await
.map_err(Into::into)
})
}
}
/// 拉取 appcast 并原子发布请求画像的 Provider 服务。
@@ -333,6 +391,8 @@ pub struct CodexDesktopReleaseService {
transport: Arc<dyn CodexDesktopReleaseTransport>,
status: CodexDesktopReleaseStatus,
profile: CodexWireProfileState,
cache: CodexArtifactProfileCache,
verified: Arc<RwLock<Option<CodexBundledReleaseProfile>>>,
}
impl CodexDesktopReleaseService {
@@ -341,11 +401,18 @@ impl CodexDesktopReleaseService {
pub fn new(
profile: CodexWireProfileState,
transport: Arc<dyn CodexDesktopReleaseTransport>,
cache: CodexArtifactProfileCache,
verified: Option<CodexBundledReleaseProfile>,
) -> Self {
if let Some(verified) = verified.as_ref() {
profile.update_bundled_release(verified);
}
Self {
transport,
status: CodexDesktopReleaseStatus::default(),
profile,
cache,
verified: Arc::new(RwLock::new(verified)),
}
}
@@ -357,11 +424,9 @@ impl CodexDesktopReleaseService {
/// 执行一次有界检查;失败只更新观察状态,不修改上一份成功画像。
pub async fn refresh(&self) -> Result<CodexDesktopRelease, CodexDesktopReleaseError> {
let checked_at = Utc::now();
let result = self.transport.fetch().await;
let result = self.refresh_inner(checked_at).await;
match result {
Ok(release) => {
self.profile
.update_desktop_release(&release.version, &release.build);
self.status.record_success(checked_at, release.clone());
Ok(release)
}
@@ -371,6 +436,91 @@ impl CodexDesktopReleaseService {
}
}
}
async fn refresh_inner(
&self,
checked_at: DateTime<Utc>,
) -> Result<CodexDesktopRelease, CodexDesktopReleaseError> {
let release = self.transport.fetch().await?;
validate_release_artifact(&release)?;
let release_sequence = parse_build_sequence(&release.build)?;
let verified = self
.verified
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
if let Some(verified) = verified {
let verified_sequence = verified.artifact_sequence()?;
if release_sequence < verified_sequence {
return Err(CodexDesktopReleaseError::ReleaseRollback);
}
if release_sequence == verified_sequence {
if release.version != verified.desktop_version
|| release.build != verified.desktop_build
{
return Err(CodexDesktopReleaseError::InconsistentRelease);
}
if !self.cache.replace_if_newer(&verified).await? {
return Err(CodexDesktopReleaseError::ReleaseRollback);
}
self.profile.update_bundled_release(&verified);
return Ok(release);
}
}
let codex_version = self.transport.fetch_bundled_core_version(&release).await?;
let bundled = CodexBundledReleaseProfile {
codex_version,
desktop_version: release.version.clone(),
desktop_build: release.build.clone(),
verified_at: checked_at,
};
bundled.validate()?;
let published = if self.cache.replace_if_newer(&bundled).await? {
bundled
} else {
self.cache
.load()
.await?
.filter(|cached| {
cached
.artifact_sequence()
.is_ok_and(|sequence| sequence >= release_sequence)
})
.ok_or(CodexDesktopReleaseError::ReleaseRollback)?
};
self.profile.update_bundled_release(&published);
*self
.verified
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(published);
Ok(release)
}
}
fn validate_release_artifact(
release: &CodexDesktopRelease,
) -> Result<(), CodexDesktopReleaseError> {
if !release.signature_present {
return Err(CodexDesktopReleaseError::MissingArtifactSignature);
}
let artifact_url = release
.download_url
.as_deref()
.ok_or(CodexDesktopReleaseError::MissingArtifactIdentity)?;
let artifact_size = release
.download_size
.ok_or(CodexDesktopReleaseError::MissingArtifactIdentity)?;
desktop_artifact::validate_codex_artifact(artifact_url, artifact_size)?;
Ok(())
}
fn parse_build_sequence(value: &str) -> Result<u64, CodexDesktopReleaseError> {
value
.parse::<u64>()
.ok()
.filter(|sequence| *sequence > 0)
.ok_or(CodexDesktopReleaseError::InvalidBuild)
}
/// 解析 appcast 中按顺序出现的首个完整发布项。
@@ -489,4 +639,20 @@ pub enum CodexDesktopReleaseError {
InvalidPublishedAt,
#[error("Codex Desktop appcast download size is invalid")]
InvalidDownloadSize,
#[error("Codex Desktop appcast artifact identity is incomplete")]
MissingArtifactIdentity,
#[error("Codex Desktop appcast artifact signature is missing")]
MissingArtifactSignature,
#[error("Codex Desktop bundled Core version is invalid")]
InvalidCoreVersion,
#[error(transparent)]
Artifact(#[from] CodexDesktopArtifactError),
#[error("Codex Desktop artifact profile cache operation failed")]
ArtifactCache(#[source] ProviderStoreError),
#[error("Codex Desktop artifact profile cache is invalid")]
InvalidArtifactCache,
#[error("Codex Desktop appcast attempted to roll back the verified artifact")]
ReleaseRollback,
#[error("Codex Desktop appcast release identity conflicts with the verified artifact")]
InconsistentRelease,
}
@@ -2,19 +2,95 @@ use gateway_core::accounting::{
CalculatedCostAmounts, CalculatedCostBreakdown, CalculatedCostRates, CurrencyCode, Decimal,
Money,
};
use gateway_protocol::openai::events::retry_after_seconds_from_body;
use gateway_protocol::openai::events::{TokenUsage, retry_after_seconds_from_body};
use reqwest::StatusCode;
use serde_json::Value;
use super::{
CodexBackendClient, CodexClientError, CodexClientResult, CodexRequestContext,
CodexUpstreamDiagnostics,
client::{read_capped_response_body, retry_after_seconds, truncate_for_error},
endpoints::usage_endpoint_urls,
endpoints::usage_endpoint_url,
response_meta,
};
const LONG_CONTEXT_THRESHOLD: u64 = 272_000;
const WEB_SEARCH_CALL_TICKS: u128 = 100_000_000;
const WEB_SEARCH_PREVIEW_NON_REASONING_CALL_TICKS: u128 = 250_000_000;
/// OpenAI 公开 Token 价格计算所需的单次用量事实。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct OpenAiBillingUsage {
input_tokens: u64,
output_tokens: u64,
cached_tokens: u64,
cache_write_tokens: u64,
image_input_tokens: u64,
image_output_tokens: u64,
web_search_calls: u64,
web_search_pricing: Option<WebSearchPricing>,
}
impl OpenAiBillingUsage {
/// 构造不含托管工具费用的 Token 用量。
#[must_use]
pub const fn new(
input_tokens: u64,
output_tokens: u64,
cached_tokens: u64,
cache_write_tokens: u64,
) -> Self {
Self {
input_tokens,
output_tokens,
cached_tokens,
cache_write_tokens,
image_input_tokens: 0,
image_output_tokens: 0,
web_search_calls: 0,
web_search_pricing: None,
}
}
pub(crate) const fn with_web_search_calls(
mut self,
calls: u64,
pricing: Option<WebSearchPricing>,
) -> Self {
self.web_search_calls = calls;
self.web_search_pricing = pricing;
self
}
}
impl From<TokenUsage> for OpenAiBillingUsage {
fn from(usage: TokenUsage) -> Self {
Self {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cached_tokens: usage.cached_tokens,
cache_write_tokens: usage.cache_write_tokens,
image_input_tokens: usage.image_input_tokens,
image_output_tokens: usage.image_output_tokens,
web_search_calls: 0,
web_search_pricing: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum WebSearchPricing {
Standard,
PreviewNonReasoning,
}
impl WebSearchPricing {
const fn price_per_call_ticks(self) -> u128 {
match self {
Self::Standard => WEB_SEARCH_CALL_TICKS,
Self::PreviewNonReasoning => WEB_SEARCH_PREVIEW_NON_REASONING_CALL_TICKS,
}
}
}
#[derive(Clone, Copy)]
struct TokenRates {
@@ -43,9 +119,11 @@ impl TokenRates {
#[derive(Clone, Copy)]
struct ModelPricing {
standard: TokenRates,
priority: TokenRates,
long: TokenRates,
long_priority: TokenRates,
flex: TokenRates,
fast: TokenRates,
long_standard: TokenRates,
long_flex: TokenRates,
long_fast: TokenRates,
cache_write_percent: u32,
}
@@ -53,29 +131,37 @@ impl ModelPricing {
const fn new(input: u128, output: u128, cache_read: u128) -> Self {
Self {
standard: TokenRates::new(input, output, cache_read),
priority: TokenRates::ZERO,
long: TokenRates::ZERO,
long_priority: TokenRates::ZERO,
flex: TokenRates::ZERO,
fast: TokenRates::ZERO,
long_standard: TokenRates::ZERO,
long_flex: TokenRates::ZERO,
long_fast: TokenRates::ZERO,
cache_write_percent: 0,
}
}
const fn with_priority(mut self, input: u128, output: u128, cache_read: u128) -> Self {
self.priority = TokenRates::new(input, output, cache_read);
const fn with_flex(mut self, input: u128, output: u128, cache_read: u128) -> Self {
self.flex = TokenRates::new(input, output, cache_read);
self
}
const fn with_long_context(
mut self,
input: u128,
output: u128,
cache_read: u128,
priority_input: u128,
priority_output: u128,
priority_cache_read: u128,
) -> Self {
self.long = TokenRates::new(input, output, cache_read);
self.long_priority = TokenRates::new(priority_input, priority_output, priority_cache_read);
const fn with_fast(mut self, input: u128, output: u128, cache_read: u128) -> Self {
self.fast = TokenRates::new(input, output, cache_read);
self
}
const fn with_long(mut self, input: u128, output: u128, cache_read: u128) -> Self {
self.long_standard = TokenRates::new(input, output, cache_read);
self
}
const fn with_long_flex(mut self, input: u128, output: u128, cache_read: u128) -> Self {
self.long_flex = TokenRates::new(input, output, cache_read);
self
}
const fn with_long_fast(mut self, input: u128, output: u128, cache_read: u128) -> Self {
self.long_fast = TokenRates::new(input, output, cache_read);
self
}
@@ -83,6 +169,29 @@ impl ModelPricing {
self.cache_write_percent = percent;
self
}
fn rates(self, tier: PricingTier, long_context: bool) -> Option<TokenRates> {
// Only models with a published long-context column switch at the threshold.
// A dash in that column is not a second, unavailable price tier for models
// whose entire supported context is covered by the short-context price.
let uses_long_rates = long_context && self.long_standard.is_configured();
let rates = match (tier, uses_long_rates) {
(PricingTier::Standard, false) => self.standard,
(PricingTier::Flex, false) => self.flex,
(PricingTier::Fast, false) => self.fast,
(PricingTier::Standard, true) => self.long_standard,
(PricingTier::Flex, true) => self.long_flex,
(PricingTier::Fast, true) => self.long_fast,
};
rates.is_configured().then_some(rates)
}
}
#[derive(Clone, Copy)]
enum PricingTier {
Standard,
Flex,
Fast,
}
#[derive(Clone, Copy)]
@@ -91,86 +200,178 @@ struct PricingRule {
pricing: ModelPricing,
}
// Source: https://developers.openai.com/api/docs/pricing (verified 2026-08-14).
const PRICING_RULES: &[PricingRule] = &[
PricingRule {
model: "gpt-5.6-sol",
pricing: ModelPricing::new(50_000, 300_000, 5_000)
.with_cache_write(125)
.with_priority(100_000, 600_000, 10_000)
.with_long_context(100_000, 450_000, 10_000, 0, 0, 0),
.with_flex(25_000, 150_000, 2_500)
.with_fast(100_000, 600_000, 10_000)
.with_long(100_000, 450_000, 10_000)
.with_long_flex(50_000, 225_000, 5_000)
.with_long_fast(200_000, 900_000, 20_000),
},
PricingRule {
model: "gpt-5.6-terra",
pricing: ModelPricing::new(20_000, 120_000, 2_000)
.with_cache_write(125)
.with_priority(40_000, 240_000, 4_000)
.with_long_context(40_000, 180_000, 4_000, 0, 0, 0),
.with_flex(10_000, 60_000, 1_000)
.with_fast(40_000, 240_000, 4_000)
.with_long(40_000, 180_000, 4_000)
.with_long_flex(20_000, 90_000, 2_000)
.with_long_fast(80_000, 360_000, 8_000),
},
PricingRule {
model: "gpt-5.6-luna",
pricing: ModelPricing::new(2_000, 12_000, 200)
.with_cache_write(125)
.with_priority(4_000, 24_000, 400)
.with_long_context(4_000, 18_000, 400, 0, 0, 0),
.with_flex(1_000, 6_000, 100)
.with_fast(4_000, 24_000, 400)
.with_long(4_000, 18_000, 400)
.with_long_flex(2_000, 9_000, 200)
.with_long_fast(8_000, 36_000, 800),
},
PricingRule {
model: "gpt-5.6",
pricing: ModelPricing::new(50_000, 300_000, 5_000)
.with_cache_write(125)
.with_priority(100_000, 600_000, 10_000)
.with_long_context(100_000, 450_000, 10_000, 0, 0, 0),
.with_flex(25_000, 150_000, 2_500)
.with_fast(100_000, 600_000, 10_000)
.with_long(100_000, 450_000, 10_000)
.with_long_flex(50_000, 225_000, 5_000)
.with_long_fast(200_000, 900_000, 20_000),
},
PricingRule {
model: "gpt-5.5-pro",
pricing: ModelPricing::new(300_000, 1_800_000, 0)
.with_priority(750_000, 4_500_000, 0)
.with_long_context(600_000, 2_700_000, 0, 1_500_000, 6_750_000, 0),
.with_flex(150_000, 900_000, 0)
.with_long(600_000, 2_700_000, 0),
},
PricingRule {
model: "gpt-5.5",
pricing: ModelPricing::new(50_000, 300_000, 5_000)
.with_priority(125_000, 750_000, 12_500)
.with_long_context(100_000, 450_000, 10_000, 250_000, 1_125_000, 25_000),
.with_flex(25_000, 150_000, 2_500)
.with_fast(125_000, 750_000, 12_500)
.with_long(100_000, 450_000, 10_000)
.with_long_flex(50_000, 225_000, 5_000),
},
PricingRule {
model: "gpt-5.4-mini",
pricing: ModelPricing::new(7_500, 45_000, 750),
pricing: ModelPricing::new(7_500, 45_000, 750)
.with_flex(3_750, 22_500, 375)
.with_fast(15_000, 90_000, 1_500),
},
PricingRule {
model: "gpt-5.4-nano",
pricing: ModelPricing::new(2_000, 12_500, 200),
pricing: ModelPricing::new(2_000, 12_500, 200).with_flex(1_000, 6_250, 100),
},
PricingRule {
model: "gpt-5.4-pro",
pricing: ModelPricing::new(300_000, 1_800_000, 0)
.with_priority(750_000, 4_500_000, 0)
.with_long_context(600_000, 2_700_000, 0, 1_500_000, 6_750_000, 0),
.with_flex(150_000, 900_000, 0)
.with_long(600_000, 2_700_000, 0)
.with_long_flex(300_000, 1_350_000, 0),
},
PricingRule {
model: "gpt-5.4",
pricing: ModelPricing::new(25_000, 150_000, 2_500)
.with_priority(50_000, 300_000, 5_000)
.with_long_context(50_000, 225_000, 5_000, 100_000, 450_000, 10_000),
},
PricingRule {
model: "gpt-5.3-codex-spark",
pricing: ModelPricing::new(12_500, 100_000, 1_250).with_priority(25_000, 200_000, 2_500),
.with_flex(12_500, 75_000, 1_300)
.with_fast(50_000, 300_000, 5_000)
.with_long(50_000, 225_000, 5_000)
.with_long_flex(25_000, 112_500, 2_500),
},
PricingRule {
model: "gpt-5.3-codex",
pricing: ModelPricing::new(17_500, 140_000, 1_750).with_priority(35_000, 280_000, 3_500),
pricing: ModelPricing::new(17_500, 140_000, 1_750).with_fast(35_000, 280_000, 3_500),
},
PricingRule {
model: "gpt-5.2-pro",
pricing: ModelPricing::new(210_000, 1_680_000, 0),
},
PricingRule {
model: "gpt-5.2",
pricing: ModelPricing::new(17_500, 140_000, 1_750).with_priority(35_000, 280_000, 3_500),
pricing: ModelPricing::new(17_500, 140_000, 1_750)
.with_flex(8_750, 70_000, 875)
.with_fast(35_000, 280_000, 3_500),
},
PricingRule {
model: "gpt-5.1",
pricing: ModelPricing::new(12_500, 100_000, 1_250)
.with_flex(6_250, 50_000, 625)
.with_fast(25_000, 200_000, 2_500),
},
PricingRule {
model: "gpt-5-mini",
pricing: ModelPricing::new(2_500, 20_000, 250)
.with_flex(1_250, 10_000, 125)
.with_fast(4_500, 36_000, 450),
},
PricingRule {
model: "gpt-5-nano",
pricing: ModelPricing::new(500, 4_000, 50).with_flex(250, 2_000, 25),
},
PricingRule {
model: "gpt-5-pro",
pricing: ModelPricing::new(150_000, 1_200_000, 0),
},
PricingRule {
model: "gpt-5",
pricing: ModelPricing::new(12_500, 100_000, 1_250)
.with_flex(6_250, 50_000, 625)
.with_fast(25_000, 200_000, 2_500),
},
PricingRule {
model: "gpt-4.1-mini",
pricing: ModelPricing::new(4_000, 16_000, 1_000).with_fast(7_000, 28_000, 1_750),
},
PricingRule {
model: "gpt-4.1-nano",
pricing: ModelPricing::new(1_000, 4_000, 250).with_fast(2_000, 8_000, 500),
},
PricingRule {
model: "gpt-4.1",
pricing: ModelPricing::new(20_000, 80_000, 5_000).with_fast(35_000, 140_000, 8_750),
},
PricingRule {
model: "gpt-4o-2024-05-13",
pricing: ModelPricing::new(50_000, 150_000, 0).with_fast(87_500, 262_500, 0),
},
PricingRule {
model: "gpt-4o-mini",
pricing: ModelPricing::new(1_500, 6_000, 750),
pricing: ModelPricing::new(1_500, 6_000, 750).with_fast(2_500, 10_000, 1_250),
},
PricingRule {
model: "gpt-4o",
pricing: ModelPricing::new(25_000, 100_000, 12_500),
pricing: ModelPricing::new(25_000, 100_000, 12_500).with_fast(42_500, 170_000, 21_250),
},
PricingRule {
model: "o1-pro",
pricing: ModelPricing::new(1_500_000, 6_000_000, 0),
},
PricingRule {
model: "o1",
pricing: ModelPricing::new(150_000, 600_000, 75_000),
},
PricingRule {
model: "o3-pro",
pricing: ModelPricing::new(200_000, 800_000, 0),
},
PricingRule {
model: "o3-mini",
pricing: ModelPricing::new(11_000, 44_000, 5_500),
},
PricingRule {
model: "o3",
pricing: ModelPricing::new(20_000, 80_000, 5_000)
.with_flex(10_000, 40_000, 2_500)
.with_fast(35_000, 140_000, 8_750),
},
PricingRule {
model: "o4-mini",
pricing: ModelPricing::new(11_000, 44_000, 2_750)
.with_flex(5_500, 22_000, 1_380)
.with_fast(20_000, 80_000, 5_000),
},
PricingRule {
model: "gpt-4-turbo",
@@ -180,93 +381,86 @@ const PRICING_RULES: &[PricingRule] = &[
model: "gpt-4",
pricing: ModelPricing::new(300_000, 600_000, 0),
},
PricingRule {
model: "gpt-3.5-turbo-instruct",
pricing: ModelPricing::new(15_000, 20_000, 0),
},
PricingRule {
model: "gpt-3.5-turbo-1106",
pricing: ModelPricing::new(10_000, 20_000, 0),
},
PricingRule {
model: "gpt-3.5-turbo",
pricing: ModelPricing::new(5_000, 15_000, 0),
},
];
const UNPRICED_MODELS: &[&str] = &["gpt-5.3-codex-spark"];
#[derive(Clone, Copy)]
struct TokenAmounts {
input_ticks: u128,
output_ticks: u128,
cache_read_ticks: u128,
cache_write_ticks: u128,
total_ticks: u128,
}
/// 按 OpenAI Provider 当前受控价格规则计算费用明细。
#[must_use]
pub fn openai_billing_breakdown(
model: &str,
input_tokens: u64,
output_tokens: u64,
cached_tokens: u64,
cache_write_tokens: u64,
usage: OpenAiBillingUsage,
service_tier: Option<&str>,
) -> Option<CalculatedCostBreakdown> {
if cached_tokens > input_tokens {
if usage.cached_tokens > usage.input_tokens
|| usage.image_input_tokens > 0
|| usage.image_output_tokens > 0
{
return None;
}
let web_search_ticks = web_search_amount_ticks(usage)?;
let pricing = model_pricing(model)?;
let long_context = input_tokens > LONG_CONTEXT_THRESHOLD;
let long_context = usage.input_tokens > LONG_CONTEXT_THRESHOLD;
let normalized_tier = normalize_service_tier(service_tier);
let priority = matches!(normalized_tier.as_deref(), Some("priority" | "fast"));
let mut rates = if long_context && pricing.long.is_configured() {
pricing.long
} else {
pricing.standard
};
let multiplier_percent = if priority {
let priority_rates = if long_context && pricing.long_priority.is_configured() {
pricing.long_priority
} else {
pricing.priority
};
if priority_rates.is_configured() {
rates = priority_rates;
100
} else {
200
}
} else if normalized_tier.as_deref() == Some("flex") {
50
} else {
100
};
let billed_cache_read = if rates.cache_read_ticks > 0 {
cached_tokens.min(input_tokens)
} else {
0
};
let cache_write_rate = if pricing.cache_write_percent > 0 {
apply_percent(rates.input_ticks, pricing.cache_write_percent)?
} else {
0
};
let billed_cache_write = if cache_write_rate > 0 {
cache_write_tokens.min(input_tokens.saturating_sub(billed_cache_read))
} else {
0
};
let uncached_input = input_tokens
.saturating_sub(billed_cache_read)
.saturating_sub(billed_cache_write);
let input_amount = u128::from(uncached_input).checked_mul(rates.input_ticks)?;
let output_amount = u128::from(output_tokens).checked_mul(rates.output_ticks)?;
let cache_read_amount = u128::from(billed_cache_read).checked_mul(rates.cache_read_ticks)?;
let cache_write_amount = u128::from(billed_cache_write).checked_mul(cache_write_rate)?;
let standard_amount = input_amount
.checked_add(output_amount)?
.checked_add(cache_read_amount)?
.checked_add(cache_write_amount)?;
let total_amount = apply_percent(standard_amount, multiplier_percent)?;
let tier = pricing_tier(normalized_tier.as_deref())?;
let standard_rates = pricing.rates(PricingTier::Standard, long_context)?;
let selected_rates = pricing.rates(tier, long_context)?;
let mut standard = token_amounts(
standard_rates,
pricing.cache_write_percent,
usage.input_tokens,
usage.output_tokens,
usage.cached_tokens,
usage.cache_write_tokens,
)?;
let mut selected = token_amounts(
selected_rates,
pricing.cache_write_percent,
usage.input_tokens,
usage.output_tokens,
usage.cached_tokens,
usage.cache_write_tokens,
)?;
standard.total_ticks = standard.total_ticks.checked_add(web_search_ticks)?;
selected.total_ticks = selected.total_ticks.checked_add(web_search_ticks)?;
let multiplier_percent =
effective_multiplier_percent(selected.total_ticks, standard.total_ticks)?;
let cache_write_rate = cache_write_rate(selected_rates, pricing.cache_write_percent)?;
Some(CalculatedCostBreakdown::new(
CalculatedCostAmounts::new(
usd_money(input_amount)?,
usd_money(output_amount)?,
usd_money(cache_read_amount)?,
usd_money(cache_write_amount)?,
usd_money(standard_amount)?,
usd_money(total_amount)?,
usd_money(selected.input_ticks)?,
usd_money(selected.output_ticks)?,
usd_money(selected.cache_read_ticks)?,
usd_money(selected.cache_write_ticks)?,
usd_money(standard.total_ticks)?,
usd_money(selected.total_ticks)?,
),
CalculatedCostRates::new(
usd_price_per_million(rates.input_ticks)?,
usd_price_per_million(rates.output_ticks)?,
usd_price_per_million(rates.cache_read_ticks)?,
usd_price_per_million(selected_rates.input_ticks)?,
usd_price_per_million(selected_rates.output_ticks)?,
usd_price_per_million(selected_rates.cache_read_ticks)?,
usd_price_per_million(cache_write_rate)?,
),
Some(normalized_tier.unwrap_or_else(|| "default".to_owned())),
@@ -276,6 +470,12 @@ pub fn openai_billing_breakdown(
fn model_pricing(model: &str) -> Option<ModelPricing> {
let normalized = normalize_model_name(model);
if UNPRICED_MODELS
.iter()
.any(|rule| model_matches_rule(&normalized, rule))
{
return None;
}
PRICING_RULES
.iter()
.filter(|rule| model_matches_rule(&normalized, rule.model))
@@ -283,6 +483,120 @@ fn model_pricing(model: &str) -> Option<ModelPricing> {
.map(|rule| rule.pricing)
}
pub(crate) fn web_search_pricing(model: &str, tools: Option<&[Value]>) -> Option<WebSearchPricing> {
let mut standard = false;
let mut preview = false;
for tool_type in tools
.into_iter()
.flatten()
.filter_map(|tool| tool.get("type").and_then(Value::as_str))
{
if tool_type == "web_search_preview" || tool_type.starts_with("web_search_preview_") {
preview = true;
} else if tool_type == "web_search" || tool_type.starts_with("web_search_") {
standard = true;
}
}
match (standard, preview) {
// These two models bill non-preview search content as a fixed 8K input block.
// The response does not expose enough detail to replace that block without
// double-counting ordinary input, so leave the request unpriced.
(true, false) if fixed_block_web_search_model(model) => None,
(true, false) => Some(WebSearchPricing::Standard),
(false, true) if reasoning_model(model) => Some(WebSearchPricing::Standard),
(false, true) => Some(WebSearchPricing::PreviewNonReasoning),
(false, false) | (true, true) => None,
}
}
fn fixed_block_web_search_model(model: &str) -> bool {
let normalized = normalize_model_name(model);
["gpt-4o-mini", "gpt-4.1-mini"]
.iter()
.any(|rule| model_matches_rule(&normalized, rule))
}
fn reasoning_model(model: &str) -> bool {
let normalized = normalize_model_name(model);
normalized.starts_with("gpt-5")
|| normalized.starts_with("o1")
|| normalized.starts_with("o3")
|| normalized.starts_with("o4")
}
fn pricing_tier(service_tier: Option<&str>) -> Option<PricingTier> {
match service_tier {
None | Some("auto" | "default" | "standard") => Some(PricingTier::Standard),
Some("flex") => Some(PricingTier::Flex),
Some("fast" | "priority") => Some(PricingTier::Fast),
Some(_) => None,
}
}
fn web_search_amount_ticks(usage: OpenAiBillingUsage) -> Option<u128> {
if usage.web_search_calls == 0 {
return Some(0);
}
u128::from(usage.web_search_calls).checked_mul(usage.web_search_pricing?.price_per_call_ticks())
}
fn token_amounts(
rates: TokenRates,
cache_write_percent: u32,
input_tokens: u64,
output_tokens: u64,
cached_tokens: u64,
cache_write_tokens: u64,
) -> Option<TokenAmounts> {
let billed_cache_read = if rates.cache_read_ticks > 0 {
cached_tokens.min(input_tokens)
} else {
0
};
let cache_write_rate = cache_write_rate(rates, cache_write_percent)?;
let billed_cache_write = if cache_write_rate > 0 {
cache_write_tokens.min(input_tokens.saturating_sub(billed_cache_read))
} else {
0
};
let uncached_input = input_tokens
.saturating_sub(billed_cache_read)
.saturating_sub(billed_cache_write);
let input_ticks = u128::from(uncached_input).checked_mul(rates.input_ticks)?;
let output_ticks = u128::from(output_tokens).checked_mul(rates.output_ticks)?;
let cache_read_ticks = u128::from(billed_cache_read).checked_mul(rates.cache_read_ticks)?;
let cache_write_ticks = u128::from(billed_cache_write).checked_mul(cache_write_rate)?;
let total_ticks = input_ticks
.checked_add(output_ticks)?
.checked_add(cache_read_ticks)?
.checked_add(cache_write_ticks)?;
Some(TokenAmounts {
input_ticks,
output_ticks,
cache_read_ticks,
cache_write_ticks,
total_ticks,
})
}
fn cache_write_rate(rates: TokenRates, percent: u32) -> Option<u128> {
if percent == 0 {
return Some(0);
}
apply_percent(rates.input_ticks, percent)
}
fn effective_multiplier_percent(total: u128, standard: u128) -> Option<u32> {
if standard == 0 {
return Some(100);
}
let rounded = total
.checked_mul(100)?
.checked_add(standard / 2)?
.checked_div(standard)?;
u32::try_from(rounded).ok()
}
fn normalize_model_name(model: &str) -> String {
model
.trim()
@@ -336,78 +650,68 @@ pub const MAX_CODEX_USAGE_BODY_BYTES: usize = 1024 * 1024;
impl CodexBackendClient {
/// 获取 Codex usage JSON。
pub async fn fetch_usage(&self, context: CodexRequestContext<'_>) -> CodexClientResult<Value> {
let mut last_invalid_body = None;
let headers = self.usage_request_headers(context)?;
let response = self
.client
.get(usage_endpoint_url(&self.base_url))
.headers(headers)
.send()
.await?;
let status = response.status();
let diagnostics = response_meta::diagnostics(Some(status.as_u16()), response.headers());
let retry_after_seconds = retry_after_seconds(response.headers(), None);
let body = read_capped_response_body(response, MAX_CODEX_USAGE_BODY_BYTES).await?;
if body.limit_exceeded() {
return Err(CodexClientError::Upstream {
status: if status.is_success() {
StatusCode::BAD_GATEWAY
} else {
status
},
retry_after_seconds,
body: "upstream usage response exceeded the body limit".to_owned(),
client_response: None,
diagnostics: Box::new(diagnostics),
set_cookie_headers: Vec::new(),
rate_limit_headers: Vec::new(),
transport: super::client::CodexBackendTransport::HttpSse,
transport_metrics: Box::default(),
send_phase: super::diagnostics::CodexUpstreamSendPhase::AfterPayload,
});
}
let body = body.into_string();
for endpoint in usage_endpoint_urls(&self.base_url) {
let headers = self.usage_request_headers(context)?;
let response = self.client.get(endpoint).headers(headers).send().await?;
let status = response.status();
let diagnostics = response_meta::diagnostics(Some(status.as_u16()), response.headers());
let retry_after_seconds = retry_after_seconds(response.headers(), None);
let body = read_capped_response_body(response, MAX_CODEX_USAGE_BODY_BYTES).await?;
if body.limit_exceeded() {
return Err(CodexClientError::Upstream {
status: if status.is_success() {
StatusCode::BAD_GATEWAY
} else {
status
},
retry_after_seconds,
body: "upstream usage response exceeded the body limit".to_owned(),
client_response: None,
diagnostics: Box::new(diagnostics),
set_cookie_headers: Vec::new(),
rate_limit_headers: Vec::new(),
transport: super::client::CodexBackendTransport::HttpSse,
transport_metrics: Box::default(),
send_phase: super::diagnostics::CodexUpstreamSendPhase::AfterPayload,
});
}
let body = body.into_string();
if status == StatusCode::NOT_FOUND {
last_invalid_body = Some(body);
continue;
}
if !status.is_success() {
return Err(CodexClientError::Upstream {
status,
retry_after_seconds: retry_after_seconds
.or_else(|| retry_after_seconds_from_body(&body)),
body,
client_response: None,
diagnostics: Box::new(diagnostics),
set_cookie_headers: Vec::new(),
rate_limit_headers: Vec::new(),
transport: super::client::CodexBackendTransport::HttpSse,
transport_metrics: Box::default(),
send_phase: super::diagnostics::CodexUpstreamSendPhase::AfterPayload,
});
}
match serde_json::from_str::<Value>(&body) {
Ok(parsed) if is_usage_response(&parsed) => return Ok(parsed),
_ => last_invalid_body = Some(body),
}
if !status.is_success() {
return Err(CodexClientError::Upstream {
status,
retry_after_seconds: retry_after_seconds
.or_else(|| retry_after_seconds_from_body(&body)),
body,
client_response: None,
diagnostics: Box::new(diagnostics),
set_cookie_headers: Vec::new(),
rate_limit_headers: Vec::new(),
transport: super::client::CodexBackendTransport::HttpSse,
transport_metrics: Box::default(),
send_phase: super::diagnostics::CodexUpstreamSendPhase::AfterPayload,
});
}
Err(CodexClientError::Upstream {
status: StatusCode::BAD_GATEWAY,
retry_after_seconds: None,
body: last_invalid_body.map_or_else(
|| "usage endpoint is unavailable".to_string(),
|body| format!("invalid usage response: {}", truncate_for_error(&body)),
),
client_response: None,
diagnostics: Box::new(CodexUpstreamDiagnostics::with_status(
StatusCode::BAD_GATEWAY.as_u16(),
)),
set_cookie_headers: Vec::new(),
rate_limit_headers: Vec::new(),
transport: super::client::CodexBackendTransport::HttpSse,
transport_metrics: Box::default(),
send_phase: super::diagnostics::CodexUpstreamSendPhase::AfterPayload,
})
match serde_json::from_str::<Value>(&body) {
Ok(parsed) if is_usage_response(&parsed) => Ok(parsed),
_ => Err(CodexClientError::Upstream {
status: StatusCode::BAD_GATEWAY,
retry_after_seconds: None,
body: format!("invalid usage response: {}", truncate_for_error(&body)),
client_response: None,
diagnostics: Box::new(diagnostics),
set_cookie_headers: Vec::new(),
rate_limit_headers: Vec::new(),
transport: super::client::CodexBackendTransport::HttpSse,
transport_metrics: Box::default(),
send_phase: super::diagnostics::CodexUpstreamSendPhase::AfterPayload,
}),
}
}
}
@@ -112,6 +112,7 @@ fn is_sensitive_opening_header(name: &str) -> bool {
| "x-codex-turn-metadata"
| "x-codex-turn-state"
| "x-codex-parent-thread-id"
| "x-oai-attestation"
)
}
+107 -29
View File
@@ -3,6 +3,7 @@ use std::num::NonZeroU32;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, TimeZone as _, Utc};
use futures::future::BoxFuture;
use gateway_admin::model::accounts::AccountRecord;
@@ -25,15 +26,15 @@ use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload};
use gateway_core::provider_ports::{
NewOAuthPendingFlow, OAuthPendingClaimOutcome, OAuthPendingConsumeOutcome,
OAuthPendingFlowPort, OAuthPendingPutOutcome, OAuthPendingReleaseOutcome,
ProviderCatalogCacheKey, ProviderCatalogCachePort, ProviderCooldown, ProviderCooldownPort,
ProviderCooldownScope, ProviderCredentialState, ProviderCredentialStatePort,
ProviderRefreshPolicy, ProviderRuntimePolicyPort, ProviderScopedCooldown, ProviderStoreError,
ProviderStorePorts,
ProviderArtifactProfile, ProviderArtifactProfileCachePort, ProviderCatalogCacheKey,
ProviderCatalogCachePort, ProviderCooldown, ProviderCooldownPort, ProviderCooldownScope,
ProviderCredentialState, ProviderCredentialStatePort, ProviderRefreshPolicy,
ProviderRuntimePolicyPort, ProviderScopedCooldown, ProviderStoreError, ProviderStorePorts,
};
use gateway_core::routing::{ProviderKind, UpstreamModelId};
use gateway_core::task::{WorkerContribution, WorkerKind, WorkerRunnable};
use provider_openai::config::{CodexWireProfileConfig, OpenAiConfig};
use provider_openai::credential::ImportCodexOAuthCredential;
use provider_openai::credential::{CodexCredentialCodec, ImportCodexOAuthCredential};
use provider_openai::transport::profile::APPCAST_POLL_INTERVAL;
use secrecy::SecretString;
use serde_json::{Map, Value, json};
@@ -54,7 +55,7 @@ async fn openai_bundle_exposes_one_core_provider_and_drains_worker_contributions
assert_eq!(bundle.core_provider().name(), "openai");
assert_eq!(bundle.admin_provider().provider_kind().as_str(), "openai");
let contributions = bundle.take_worker_contributions();
assert_eq!(contributions.len(), 5);
assert_eq!(contributions.len(), 4);
assert!(
contributions
.iter()
@@ -81,22 +82,6 @@ async fn openai_bundle_exposes_one_core_provider_and_drains_worker_contributions
panic!("Desktop release worker must be scheduled");
};
assert_eq!(schedule.interval(), APPCAST_POLL_INTERVAL);
let cli_release_worker = contributions
.iter()
.find_map(|contribution| match contribution {
WorkerContribution::Registration(registration)
if registration.id.owner() == "openai-cli-release" =>
{
Some(registration)
}
WorkerContribution::Registration(_) | WorkerContribution::Disabled { .. } => None,
})
.expect("CLI release worker");
assert_eq!(cli_release_worker.id.kind(), WorkerKind::QuotaCatalogHealth);
let WorkerRunnable::Scheduled { schedule, .. } = &cli_release_worker.runnable else {
panic!("CLI release worker must be scheduled");
};
assert_eq!(schedule.interval(), APPCAST_POLL_INTERVAL);
assert!(contributions.iter().any(|contribution| {
matches!(
contribution,
@@ -164,15 +149,15 @@ async fn openai_admin_provider_exposes_live_wire_profile_and_validated_billing()
cache_write_tokens: Some(0),
total: CurrencyCost {
currency: "USD".to_owned(),
amount: "5".parse().expect("fast amount"),
amount: "4.25".parse().expect("fast amount"),
},
})
.expect("fast billing")
.expect("known fast pricing");
assert_eq!(fast_billing.service_tier.as_deref(), Some("priority"));
assert_eq!(fast_billing.multiplier_percent, 200);
assert_eq!(fast_billing.multiplier_percent, 170);
assert_eq!(fast_billing.standard_amount.amount.as_str(), "2.5");
assert_eq!(fast_billing.total_amount.amount.as_str(), "5");
assert_eq!(fast_billing.total_amount.amount.as_str(), "4.25");
}
#[tokio::test]
@@ -249,6 +234,14 @@ async fn openai_admin_provider_persists_the_full_pending_envelope_and_binds_owne
.get("reauthorization_credential_revision")
.is_none()
);
assert!(
payload
.expose_to_provider()
.get("installation_id")
.and_then(Value::as_str)
.and_then(|value| uuid::Uuid::parse_str(value).ok())
.is_some_and(|value| value.get_version_num() == 4)
);
assert_eq!(
mutation.get("schema_version").and_then(Value::as_u64),
Some(3)
@@ -284,7 +277,7 @@ async fn openai_admin_provider_persists_the_full_pending_envelope_and_binds_owne
}
#[tokio::test]
async fn openai_reauthorization_pending_payload_contains_only_stable_account_identity() {
async fn openai_reauthorization_pending_payload_reuses_the_account_installation_id() {
let accounts = Arc::new(MemoryAccountStore::default());
accounts
.seed_oauth_credential(ImportCodexOAuthCredential {
@@ -296,6 +289,14 @@ async fn openai_reauthorization_pending_payload_contains_only_stable_account_ide
enabled: true,
})
.await;
let account_id = ProviderAccountId::new("acct_pending_reauth").expect("account id");
let existing = accounts
.load_current_credential(&account_id)
.await
.expect("seeded credential");
let expected_installation_id = CodexCredentialCodec::decode(&existing.credential)
.expect("decode seeded credential")
.installation_id;
let pending = Arc::new(TestOAuthPending::default());
let config = valid_config();
let bundle = provider_openai::initialize(
@@ -313,9 +314,7 @@ async fn openai_reauthorization_pending_payload_contains_only_stable_account_ide
.admin_provider()
.start_authorization(PendingAuthorizationMutation::new(
ProviderKind::new("openai").expect("provider"),
AuthorizationMutationTarget::Reauthorize {
account_id: ProviderAccountId::new("acct_pending_reauth").expect("account id"),
},
AuthorizationMutationTarget::Reauthorize { account_id },
AuthorizationOwnerBinding::from_context(&context),
))
.await
@@ -342,6 +341,10 @@ async fn openai_reauthorization_pending_payload_contains_only_stable_account_ide
.and_then(Value::as_str),
Some("acct_pending_reauth")
);
assert_eq!(
document.get("installation_id").and_then(Value::as_str),
Some(expected_installation_id.as_str())
);
assert!(
document
.get("reauthorization_credential_revision")
@@ -760,6 +763,61 @@ async fn openai_admin_provider_rejects_unprepared_mutations_before_store_commit(
assert_eq!(refresh_error.kind(), ProviderAdminErrorKind::NotFound);
}
#[tokio::test]
async fn openai_rotation_preserves_the_new_access_token_jwt_expiration() {
let store = Arc::new(MemoryAccountStore::default());
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: "acct_admin_rotation_expiration".to_owned(),
name: "admin rotation expiration".to_owned(),
secret: secret("admin-rotation-access"),
verified_account: profile("chatgpt-admin-rotation-expiration"),
next_refresh_at: None,
enabled: true,
})
.await;
let account = store
.account("acct_admin_rotation_expiration")
.expect("stored account");
let record = account_record(&account);
let config = valid_config();
let bundle = provider_openai::initialize(
config.config.clone(),
provider_ports_with(store, Arc::new(TestOAuthPending::default())),
)
.await
.expect("OpenAI bundle");
let expires_at = Utc
.timestamp_opt(2_000_000_000, 0)
.single()
.expect("valid test timestamp");
let payload = URL_SAFE_NO_PAD.encode(
serde_json::to_vec(&serde_json::json!({"exp": expires_at.timestamp()}))
.expect("test JWT payload"),
);
let mut material = Map::new();
material.insert(
"access_token".to_owned(),
Value::String(format!("unverified-header.{payload}.unverified-signature")),
);
material.insert(
"refresh_token".to_owned(),
Value::String("admin-rotation-refresh".to_owned()),
);
let prepared = bundle
.admin_provider()
.prepare_rotation(PrepareCredentialRotation {
account: record,
provider_material: ProviderDocument::new(OpaqueProviderData::new(material)),
})
.await
.expect("JWT rotation should be prepared");
assert_eq!(prepared.facts().access_token_expires_at, Some(expires_at));
}
fn provider_ports() -> ProviderStorePorts {
provider_ports_with(
Arc::new(MemoryAccountStore::default()),
@@ -785,6 +843,7 @@ fn provider_ports_with_catalog(
Arc::new(MemorySessionAffinity::default()),
Arc::new(MemorySessionExclusions::default()),
catalog_cache,
Arc::new(TestArtifactProfiles),
Arc::new(TestCredentialState),
Arc::new(TestCooldown),
Arc::new(TestRuntimePolicy),
@@ -852,6 +911,25 @@ fn valid_config() -> TestOpenAiConfig {
}
}
struct TestArtifactProfiles;
impl ProviderArtifactProfileCachePort for TestArtifactProfiles {
fn replace_if_newer(
&self,
_profile: ProviderArtifactProfile,
_ttl: Duration,
) -> BoxFuture<'_, Result<bool, ProviderStoreError>> {
Box::pin(async { Ok(true) })
}
fn read<'a>(
&'a self,
_provider_kind: &'a ProviderKind,
) -> BoxFuture<'a, Result<Option<ProviderArtifactProfile>, ProviderStoreError>> {
Box::pin(async { Ok(None) })
}
}
#[derive(Default)]
struct TestCatalogCache {
values: Mutex<BTreeMap<String, OpaqueProviderData>>,
@@ -116,7 +116,7 @@ fn openai_config_defaults_to_the_provider_owned_operating_values() {
);
assert_eq!(
config.wire_profile_state().snapshot().user_agent(),
"Codex Desktop/0.146.0 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.727.51351)"
"Codex Desktop/0.147.0-alpha.6.6 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.803.81509)"
);
}
@@ -13,6 +13,7 @@ use gateway_core::engine::credential::{
ProviderAccountStore, QuotaState,
};
use gateway_core::routing::ProviderKind;
use provider_openai::OpenAiConfig;
use provider_openai::credential::{
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CodexAgentIdentityAuthMode,
CodexAgentIdentityCredentialData, CodexAgentIdentityError, CodexAgentIdentitySecret,
@@ -20,7 +21,9 @@ use provider_openai::credential::{
CodexRuntimeAuthentication, is_agent_identity_task_invalid_response,
};
use provider_openai::transport::CodexWebSocketPool;
use provider_openai::transport::headers::build_codex_profile_headers;
use reqwest::StatusCode;
use reqwest::header::USER_AGENT;
use secrecy::ExposeSecret as _;
use serde_json::Value;
@@ -144,6 +147,20 @@ fn agent_identity_task_error_classifier_requires_unauthorized_task_error() {
));
}
#[test]
fn agent_identity_task_registration_should_use_the_shared_desktop_profile_headers() {
let profile = OpenAiConfig::default().wire_profile_state().snapshot();
let headers = build_codex_profile_headers(&profile).expect("Desktop profile headers");
assert_eq!(headers.len(), 3);
assert_eq!(headers["originator"], "Codex Desktop");
assert_eq!(headers["x-openai-internal-codex-residency"], "us");
assert_eq!(
headers[USER_AGENT],
"Codex Desktop/0.147.0-alpha.6.6 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.803.81509)"
);
}
struct SequenceTaskRegistrar {
task_ids: Mutex<VecDeque<String>>,
calls: AtomicUsize,
@@ -253,7 +253,7 @@ async fn cross_plan_presentation_drift_unions_on_the_first_seen_model() {
}
#[tokio::test]
async fn cross_plan_responses_api_conflict_still_fails_the_union() {
async fn cross_plan_supported_in_api_drift_unions_on_the_first_seen_model() {
let store = Arc::new(MemoryAccountStore::default());
seed_account_with_plan(&store, "acct_conflict_plus", "plus").await;
seed_account_with_plan(&store, "acct_conflict_pro", "pro").await;
@@ -272,15 +272,12 @@ async fn cross_plan_responses_api_conflict_still_fails_the_union() {
.await;
let service = service_with_catalog_cache(&store, server.uri(), catalog_cache());
let error = service
let snapshot = service
.synchronize()
.await
.expect_err("routing facts must still agree across plans");
.expect("supported_in_api drift must not block catalog publication");
assert!(matches!(
error,
CodexCredentialCatalogError::ConflictingModelFacts
));
assert_eq!(snapshot.models().len(), 1);
server.verify().await;
}
@@ -812,13 +812,15 @@ fn credential_expired_failure_keeps_expired_oauth_for_bounded_refresh_recovery()
))
.expect("record credential expiry");
let retained = store
.account("acct_primary")
.expect("account retained for refresh");
assert_eq!(retained.credential_state(), CredentialState::Ready);
assert_eq!(
store
.account("acct_primary")
.expect("account retained for refresh")
.credential_state(),
CredentialState::Ready
retained.last_error_reason(),
Some(AccountErrorReason::AccessTokenExpired)
);
assert_eq!(retained.last_error_message(), Some("token_expired"));
}
#[test]
@@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
@@ -7,22 +8,26 @@ use gateway_admin::model::provider_credentials::{
AuthorizationMutationTarget, AuthorizationOwnerBinding, PendingAuthorizationMutation,
};
use gateway_admin::model::{MutationActor, MutationContext};
use gateway_core::engine::credential::AccountStatus;
use gateway_core::engine::credential::{AccountStatus, ProviderAccountId};
use gateway_core::routing::ProviderKind;
use provider_openai::OpenAiConfig;
use provider_openai::credential::token_client::{
AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant,
AuthorizationTokenSet,
};
use provider_openai::credential::{
CodexCredentialAdmin, CodexOAuthAdmin, CodexOAuthAdminError, CodexOAuthAdminService,
CodexOAuthPendingClaimOutcome, CodexOAuthPendingStore, CodexOAuthPendingStoreError,
CodexOAuthSecret, CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential,
StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
CodexCredentialAdmin, CodexCredentialCodec, CodexOAuthAdmin, CodexOAuthAdminError,
CodexOAuthAdminService, CodexOAuthPendingClaimOutcome, CodexOAuthPendingStore,
CodexOAuthPendingStoreError, CodexOAuthSecret, CompleteCodexOAuthAuthorization,
CompletedCodexOAuthCredential, ImportCodexOAuthCredential, StartCodexOAuthAuthorization,
StoredCodexPendingAuthorization,
};
use provider_openai::transport::profile::CodexWireProfileState;
use secrecy::SecretString;
use url::Url;
use uuid::Uuid;
use crate::support::MemoryAccountStore;
use crate::support::{MemoryAccountStore, profile as account_profile, secret};
#[derive(Default)]
struct PendingStore {
@@ -48,6 +53,7 @@ impl CodexOAuthPendingStore for PendingStore {
state: pending.state().clone(),
nonce: pending.nonce().clone(),
code_verifier: pending.code_verifier().clone(),
installation_id: pending.installation_id().to_owned(),
reauthorization_account_id: pending.reauthorization().map(ToString::to_string),
mutation: pending.mutation().clone(),
});
@@ -106,7 +112,6 @@ impl AuthorizationCodeExchanger for Exchanger {
id_token: None,
},
id_token: SecretString::from(self.id_token.clone()),
expires_in: Some(Duration::from_secs(60 * 60)),
})
}
}
@@ -125,18 +130,37 @@ fn mutation() -> PendingAuthorizationMutation {
)
}
fn reauthorization_mutation(
account_id: ProviderAccountId,
request_id: &str,
) -> PendingAuthorizationMutation {
let context = MutationContext {
actor: MutationActor::System,
request_id: request_id.to_owned(),
};
PendingAuthorizationMutation::new(
ProviderKind::new("openai").expect("provider"),
AuthorizationMutationTarget::Reauthorize { account_id },
AuthorizationOwnerBinding::from_context(&context),
)
}
fn id_token(payload: serde_json::Value) -> String {
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload JSON"));
// 官方逻辑只读取 payload;header/signature 不参与本地 metadata 解析。
format!("unverified-header.{payload}.unverified-signature")
}
async fn complete(id_token: String) -> Result<CompletedCodexOAuthCredential, CodexOAuthAdminError> {
async fn complete(
id_token: String,
) -> Result<(CompletedCodexOAuthCredential, String, String), CodexOAuthAdminError> {
let pending_store = Arc::new(PendingStore::default());
let service = CodexOAuthAdminService::new(
Arc::new(PendingStore::default()),
pending_store.clone(),
Arc::new(Exchanger { id_token }),
Arc::new(MemoryAccountStore::default()),
CodexCredentialAdmin,
profile(),
);
let started = service
.start_authorization(StartCodexOAuthAuthorization {
@@ -144,11 +168,26 @@ async fn complete(id_token: String) -> Result<CompletedCodexOAuthCredential, Cod
})
.await
.expect("start OAuth authorization");
let state = Url::parse(&started.authorization_url)
.expect("authorization URL")
let outer = Url::parse(&started.authorization_url).expect("authorization URL");
let inner_url = outer
.query_pairs()
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
.expect("state parameter");
.find_map(|(key, value)| (key == "authorize_url").then(|| value.into_owned()))
.expect("inner authorization URL");
let inner = Url::parse(&inner_url).expect("authorization URL");
let parameters = inner.query_pairs().into_owned().collect::<BTreeMap<_, _>>();
let state = parameters.get("state").expect("state parameter");
let surface_stable_id = parameters
.get("source_surface_stable_id")
.expect("surface stable ID")
.to_owned();
let installation_id = pending_store
.pending
.lock()
.expect("pending lock")
.as_ref()
.expect("stored pending authorization")
.installation_id
.clone();
let completed = service
.complete_authorization(CompleteCodexOAuthAuthorization {
owner_ref: "test-owner".to_owned(),
@@ -158,12 +197,279 @@ async fn complete(id_token: String) -> Result<CompletedCodexOAuthCredential, Cod
)),
})
.await?;
Ok(completed.credential)
Ok((completed.credential, installation_id, surface_stable_id))
}
#[tokio::test]
async fn authorize_url_matches_the_official_desktop_parameter_contract() {
let service = CodexOAuthAdminService::new(
Arc::new(PendingStore::default()),
Arc::new(Exchanger {
id_token: "unused".to_owned(),
}),
Arc::new(MemoryAccountStore::default()),
CodexCredentialAdmin,
profile(),
);
let started = service
.start_authorization(StartCodexOAuthAuthorization {
mutation: mutation(),
})
.await
.expect("start OAuth authorization");
let url = Url::parse(&started.authorization_url).expect("authorization URL");
let outer_parameters = url.query_pairs().into_owned().collect::<Vec<_>>();
assert_eq!(
outer_parameters
.iter()
.map(|(name, _)| name.as_str())
.collect::<Vec<_>>(),
vec![
"authorize_url",
"codex_streamlined_login",
"no_universal_links"
]
);
let outer_parameters = outer_parameters.into_iter().collect::<BTreeMap<_, _>>();
assert_eq!(url.scheme(), "https");
assert_eq!(url.host_str(), Some("chatgpt.com"));
assert_eq!(url.path(), "/codex/desktop-auth");
assert_eq!(outer_parameters.len(), 3);
assert_eq!(
outer_parameters
.get("codex_streamlined_login")
.map(String::as_str),
Some("true")
);
assert_eq!(
outer_parameters
.get("no_universal_links")
.map(String::as_str),
Some("1")
);
let inner = Url::parse(
outer_parameters
.get("authorize_url")
.expect("authorize_url parameter"),
)
.expect("inner authorization URL");
let ordered_parameters = inner.query_pairs().into_owned().collect::<Vec<_>>();
assert_eq!(
ordered_parameters
.iter()
.map(|(name, _)| name.as_str())
.collect::<Vec<_>>(),
vec![
"response_type",
"client_id",
"redirect_uri",
"scope",
"code_challenge",
"code_challenge_method",
"id_token_add_organizations",
"codex_cli_simplified_flow",
"state",
"originator",
"codex_app_version",
"source_surface_stable_id",
"codex_origin_stable_id",
"codex_streamlined_login",
]
);
let parameters = ordered_parameters.into_iter().collect::<BTreeMap<_, _>>();
assert_eq!(inner.host_str(), Some("auth.openai.com"));
assert_eq!(inner.path(), "/oauth/authorize");
assert_eq!(parameters.len(), 14);
assert!(
inner
.query()
.is_some_and(|query| query.contains("scope=openid+profile+email+offline_access"))
);
assert!(
!inner
.query()
.is_some_and(|query| query.contains("scope=openid%20profile"))
);
assert_eq!(
parameters.get("response_type").map(String::as_str),
Some("code")
);
assert_eq!(
parameters.get("redirect_uri").map(String::as_str),
Some("http://localhost:1455/auth/callback")
);
assert_eq!(
parameters.get("scope").map(String::as_str),
Some("openid profile email offline_access api.connectors.read api.connectors.invoke")
);
assert_eq!(
parameters.get("originator").map(String::as_str),
Some("Codex Desktop")
);
assert_eq!(
parameters.get("codex_app_version").map(String::as_str),
Some("26.803.81509")
);
let surface_stable_id = parameters
.get("source_surface_stable_id")
.expect("surface stable ID");
let parsed_surface_id = Uuid::parse_str(surface_stable_id).expect("UUID surface stable ID");
assert_eq!(parsed_surface_id.get_version_num(), 4);
assert_eq!(
parameters.get("codex_origin_stable_id"),
Some(surface_stable_id)
);
assert_eq!(
parameters
.get("codex_streamlined_login")
.map(String::as_str),
Some("true")
);
assert_eq!(
parameters.get("code_challenge_method").map(String::as_str),
Some("S256")
);
assert_eq!(
parameters
.get("id_token_add_organizations")
.map(String::as_str),
Some("true")
);
assert_eq!(
parameters
.get("codex_cli_simplified_flow")
.map(String::as_str),
Some("true")
);
assert!(
parameters
.get("client_id")
.is_some_and(|value| !value.is_empty())
);
assert!(
parameters
.get("state")
.is_some_and(|value| !value.is_empty())
);
assert!(
parameters
.get("code_challenge")
.is_some_and(|value| !value.is_empty())
);
assert!(!parameters.contains_key("nonce"));
}
fn profile() -> CodexWireProfileState {
OpenAiConfig::default().wire_profile_state()
}
#[tokio::test]
async fn separate_new_authorizations_use_distinct_surface_stable_ids() {
let mut ids = Vec::new();
for _ in 0..2 {
let service = CodexOAuthAdminService::new(
Arc::new(PendingStore::default()),
Arc::new(Exchanger {
id_token: "unused".to_owned(),
}),
Arc::new(MemoryAccountStore::default()),
CodexCredentialAdmin,
profile(),
);
let started = service
.start_authorization(StartCodexOAuthAuthorization {
mutation: mutation(),
})
.await
.expect("start OAuth authorization");
let outer = Url::parse(&started.authorization_url).expect("outer authorization URL");
let inner = outer
.query_pairs()
.find_map(|(key, value)| (key == "authorize_url").then(|| value.into_owned()))
.and_then(|value| Url::parse(&value).ok())
.expect("inner authorization URL");
let surface_stable_id = inner
.query_pairs()
.find_map(|(key, value)| {
(key == "source_surface_stable_id").then(|| value.into_owned())
})
.expect("surface stable ID");
ids.push(surface_stable_id);
}
assert_ne!(ids[0], ids[1]);
}
#[tokio::test]
async fn repeated_reauthorization_derives_the_same_surface_id_for_one_account() {
let store = Arc::new(MemoryAccountStore::default());
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: "acct_surface_reauth".to_owned(),
name: "surface reauthorization".to_owned(),
secret: secret("surface-reauth-access"),
verified_account: account_profile("chatgpt-surface-reauth"),
next_refresh_at: None,
enabled: true,
})
.await;
let account_id = ProviderAccountId::new("acct_surface_reauth").expect("account id");
let mut surface_ids = Vec::new();
for request_id in ["surface-reauth-first", "surface-reauth-second"] {
let service = CodexOAuthAdminService::new(
Arc::new(PendingStore::default()),
Arc::new(Exchanger {
id_token: "unused".to_owned(),
}),
store.clone(),
CodexCredentialAdmin,
profile(),
);
let started = service
.start_authorization(StartCodexOAuthAuthorization {
mutation: reauthorization_mutation(account_id.clone(), request_id),
})
.await
.expect("start OAuth reauthorization");
let outer = Url::parse(&started.authorization_url).expect("outer authorization URL");
let inner = outer
.query_pairs()
.find_map(|(key, value)| (key == "authorize_url").then(|| value.into_owned()))
.and_then(|value| Url::parse(&value).ok())
.expect("inner authorization URL");
surface_ids.push(
inner
.query_pairs()
.find_map(|(key, value)| {
(key == "source_surface_stable_id").then(|| value.into_owned())
})
.expect("surface stable ID"),
);
}
assert_eq!(surface_ids[0], surface_ids[1]);
}
#[tokio::test]
async fn first_exchange_persists_the_installation_id_without_reusing_it_as_the_surface_id() {
let (credential, pending_installation_id, surface_stable_id) = complete(id_token(
serde_json::json!({ "email": "identity@example.com" }),
))
.await
.expect("completed OAuth exchange");
let CompletedCodexOAuthCredential::Create(account) = credential else {
panic!("expected account creation");
};
let runtime = CodexCredentialCodec::decode(&account.credential).expect("stored credential");
assert_eq!(runtime.installation_id, pending_installation_id);
assert_ne!(runtime.installation_id, surface_stable_id);
}
#[tokio::test]
async fn first_exchange_uses_official_id_token_claim_mapping_without_signature_validation() {
let credential = complete(id_token(serde_json::json!({
let (credential, _, _) = complete(id_token(serde_json::json!({
"email": "top@example.com",
"https://api.openai.com/profile": { "email": "fallback@example.com" },
"https://api.openai.com/auth": {
@@ -198,7 +504,7 @@ async fn first_exchange_uses_official_id_token_claim_mapping_without_signature_v
#[tokio::test]
async fn first_exchange_permits_missing_identity_claims_but_requires_a_parseable_id_token() {
let credential = complete(id_token(serde_json::json!({
let (credential, _, _) = complete(id_token(serde_json::json!({
"https://api.openai.com/profile": { "email": "profile@example.com" }
})))
.await
@@ -75,7 +75,7 @@ async fn quota_refresh_updates_access_fact_without_recovering_credential_error()
}
#[tokio::test]
async fn exhausted_quota_worker_keeps_old_document_when_new_window_usage_is_high() {
async fn exhausted_quota_worker_only_touches_refresh_time_when_recovery_is_rejected() {
let store = Arc::new(MemoryAccountStore::default());
let account_id = "acct_worker_quota_recovery_gate";
create_account(&store, account_id).await;
@@ -101,7 +101,7 @@ async fn exhausted_quota_worker_keeps_old_document_when_new_window_usage_is_high
server.uri(),
);
service
let old_snapshot = service
.refresh_account(account.id())
.await
.expect("seed exhausted quota");
@@ -125,6 +125,14 @@ async fn exhausted_quota_worker_keeps_old_document_when_new_window_usage_is_high
assert_eq!(summary.exhausted, 1);
assert_eq!(summary.updated, 0);
assert_eq!(store.quota_json(account_id), Some(old_quota));
let refreshed = service
.read_account(account.id())
.await
.expect("read refreshed quota")
.expect("refreshed quota snapshot");
assert!(refreshed.observed_at() > old_snapshot.observed_at());
assert_eq!(refreshed.fact(), old_snapshot.fact());
assert_eq!(refreshed.quota().access(), QuotaAccessState::Exhausted);
assert_eq!(
store
.account(account_id)
@@ -137,10 +145,18 @@ async fn exhausted_quota_worker_keeps_old_document_when_new_window_usage_is_high
#[tokio::test]
async fn manual_quota_refresh_only_recovers_after_reset_advances_below_ten_percent() {
for (suffix, reset_at, used_percent, recovered) in [
("same_reset", 1_900_000_000_i64, 0_u64, false),
("exactly_ten", 1_900_003_600_i64, 10_u64, false),
("below_ten", 1_900_003_600_i64, 9_u64, true),
for (suffix, allowed, limit_reached, reset_at, used_percent, recovered) in [
(
"still_exhausted",
false,
true,
1_900_003_600_i64,
0_u8,
false,
),
("same_reset", true, false, 1_900_000_000_i64, 0_u8, false),
("exactly_ten", true, false, 1_900_003_600_i64, 10_u8, false),
("below_ten", true, false, 1_900_003_600_i64, 9_u8, true),
] {
let store = Arc::new(MemoryAccountStore::default());
let account_id = format!("acct_manual_quota_recovery_{suffix}");
@@ -166,7 +182,7 @@ async fn manual_quota_refresh_only_recovers_after_reset_advances_below_ten_perce
.expect("client"),
server.uri(),
);
service
let old_snapshot = service
.refresh_account(account.id())
.await
.expect("seed exhausted quota");
@@ -177,8 +193,8 @@ async fn manual_quota_refresh_only_recovers_after_reset_advances_below_ten_perce
.and(path("/api/codex/usage"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"rate_limit": {
"allowed": true,
"limit_reached": false,
"allowed": allowed,
"limit_reached": limit_reached,
"primary_window": {"used_percent": used_percent, "reset_at": reset_at}
}
})))
@@ -197,11 +213,12 @@ async fn manual_quota_refresh_only_recovers_after_reset_advances_below_ten_perce
assert_ne!(store.quota_json(&account_id), Some(old_quota));
assert_eq!(current.quota().access(), QuotaAccessState::Allowed);
} else {
assert_eq!(snapshot.fact().remaining_percent(), Some(0));
assert_eq!(snapshot.fact(), old_snapshot.fact());
assert_eq!(snapshot.quota().access(), QuotaAccessState::Exhausted);
assert_eq!(store.quota_json(&account_id), Some(old_quota));
assert_eq!(current.quota().access(), QuotaAccessState::Exhausted);
}
assert!(snapshot.observed_at() > old_snapshot.observed_at());
}
}
@@ -4,9 +4,13 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, TimeZone as _, Utc};
use futures::future::BoxFuture;
use gateway_core::engine::credential::{ProviderAccountId, ProviderAccountStore};
use gateway_core::engine::credential::{
AccountErrorReason, AccountStateChange, AccountStatus, CredentialState, ProviderAccountId,
ProviderAccountStore, QuotaAccessChange, QuotaEvidence, QuotaState, QuotaWriteOutcome,
};
use gateway_core::policy::ClientApiKeyId;
use gateway_core::provider_ports::{
ProviderCredentialState, ProviderCredentialStatePort, ProviderLeaseAcquisition,
@@ -14,12 +18,16 @@ use gateway_core::provider_ports::{
ProviderSchedulingState, ProviderStoreError,
};
use gateway_core::routing::ProviderKind;
use provider_openai::credential::token_client::{RefreshFailure, TokenPair, TokenRefresher};
use provider_openai::credential::token_client::{
OpenAiTokenClient, RefreshFailure, TokenClientConfig, TokenPair, TokenRefresher,
};
use provider_openai::credential::{
CodexCredentialCodec, CodexCredentialRefreshOutcome, CodexCredentialRefreshService,
ImportCodexOAuthCredential,
};
use secrecy::{ExposeSecret as _, SecretString};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::support::{MemoryAccountStore, profile, secret};
@@ -36,7 +44,6 @@ impl SingleUseRefresher {
access_token: Some("refreshed-access-token".to_owned()),
refresh_token: None,
id_token: None,
expires_in: Some(Duration::from_secs(60 * 60)),
})),
})
}
@@ -52,7 +59,17 @@ impl SingleUseRefresher {
access_token: Some("refreshed-access-token".to_owned()),
refresh_token: None,
id_token: Some(id_token.to_owned()),
expires_in: Some(Duration::from_secs(60 * 60)),
})),
})
}
fn with_access_token(access_token: String) -> Arc<Self> {
Arc::new(Self {
calls: AtomicUsize::new(0),
response: Mutex::new(Some(TokenPair {
access_token: Some(access_token),
refresh_token: None,
id_token: None,
})),
})
}
@@ -64,7 +81,6 @@ impl SingleUseRefresher {
access_token: None,
refresh_token: None,
id_token: None,
expires_in: Some(Duration::from_secs(60 * 60)),
})),
})
}
@@ -78,7 +94,21 @@ impl TokenRefresher for SingleUseRefresher {
.lock()
.expect("refresh response lock")
.take()
.ok_or(RefreshFailure::Transport)
.ok_or_else(|| RefreshFailure::Transport {
message: Some("test refresh response is exhausted".to_owned()),
upstream: None,
})
}
}
struct FailingRefresher {
failure: RefreshFailure,
}
#[async_trait]
impl TokenRefresher for FailingRefresher {
async fn refresh(&self, _: &str) -> Result<TokenPair, RefreshFailure> {
Err(self.failure.clone())
}
}
@@ -267,6 +297,263 @@ async fn scheduled_refresh_uses_the_current_margin_without_persisting_a_normal_s
);
}
#[tokio::test]
async fn scheduled_refresh_rotates_tokens_while_quota_is_exhausted() {
let store = Arc::new(MemoryAccountStore::default());
let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60));
let service = refresh_service(&store, SingleUseRefresher::new(), policy);
let account_id = "acct_exhausted_quota_refresh";
seed_refreshable_account(
&store,
account_id,
SystemTime::now()
.checked_add(Duration::from_secs(120))
.expect("test expiry"),
None,
)
.await;
let account = store.account(account_id).expect("seeded account");
assert_eq!(
store
.apply_quota_access(QuotaAccessChange {
account_id: account.id().clone(),
expected_revision: account.revision(),
state: QuotaState::exhausted(
QuotaEvidence::UsageLimitReached,
SystemTime::now(),
None,
),
})
.await
.expect("persist exhausted quota"),
QuotaWriteOutcome::Updated
);
let outcomes = service.refresh_due().await.expect("refresh cycle");
assert!(matches!(
outcomes.as_slice(),
[CodexCredentialRefreshOutcome::Refreshed {
account_id: refreshed_account_id,
..
}] if refreshed_account_id == account_id
));
let account = store.account(account_id).expect("refreshed account");
assert!(account.quota().is_exhausted());
let loaded = store
.load_credential(account.id(), account.revision())
.await
.expect("refreshed credential");
let runtime = CodexCredentialCodec::decode(&loaded.credential).expect("runtime credential");
let oauth = runtime.authentication.oauth().expect("OAuth credential");
assert_eq!(oauth.access_token.expose_secret(), "refreshed-access-token");
let expected_refresh_token = format!("rt-access-{account_id}");
assert_eq!(
oauth
.refresh_token
.as_ref()
.map(SecretString::expose_secret),
Some(expected_refresh_token.as_str())
);
}
#[tokio::test]
async fn scheduled_refresh_persists_the_original_upstream_error_message() {
let store = Arc::new(MemoryAccountStore::default());
let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60));
let upstream_message = "Refresh token has already been used.";
let service = CodexCredentialRefreshService::new(
store.repository(),
Arc::new(FailingRefresher {
failure: RefreshFailure::InvalidGrant {
message: Some(upstream_message.to_owned()),
upstream: None,
},
}),
Arc::new(RefreshLeases),
Arc::new(RefreshCredentialState),
policy,
);
let account_id = "acct_invalid_refresh_token";
seed_refreshable_account(
&store,
account_id,
SystemTime::now()
.checked_add(Duration::from_secs(120))
.expect("test expiry"),
None,
)
.await;
let outcomes = service.refresh_due().await.expect("refresh cycle");
assert!(matches!(
outcomes.as_slice(),
[CodexCredentialRefreshOutcome::Invalidated {
account_id: invalidated_account_id,
}] if invalidated_account_id == account_id
));
assert_eq!(
store
.account(account_id)
.expect("invalidated account")
.last_error_message(),
Some(upstream_message)
);
}
#[tokio::test]
async fn scheduled_refresh_persists_retryable_message_inside_the_two_hour_window() {
let store = Arc::new(MemoryAccountStore::default());
let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60));
let upstream_message = "Invalid refresh token.";
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"error": {
"message": upstream_message,
"type": "invalid_request_error",
"code": "invalid_refresh_token"
}
})))
.expect(1)
.mount(&server)
.await;
let service = CodexCredentialRefreshService::new(
store.repository(),
Arc::new(OpenAiTokenClient::new(
reqwest::Client::builder()
.no_proxy()
.build()
.expect("test HTTP client"),
TokenClientConfig {
client_id: "test-public-client".to_owned(),
token_endpoint: format!("{}/oauth/token", server.uri()),
},
)),
Arc::new(RefreshLeases),
Arc::new(RefreshCredentialState),
policy,
);
let account_id = "acct_retryable_unauthorized";
let expires_at = SystemTime::now()
.checked_sub(Duration::from_secs(30 * 60))
.expect("expired access token");
seed_refreshable_account(&store, account_id, expires_at, None).await;
let outcomes = service.refresh_due().await.expect("refresh cycle");
assert!(matches!(
outcomes.as_slice(),
[CodexCredentialRefreshOutcome::Transient {
account_id: deferred_account_id,
}] if deferred_account_id == account_id
));
let account = store.account(account_id).expect("deferred account");
assert_eq!(account.credential_state(), CredentialState::Ready);
assert_eq!(
account.last_error_reason(),
Some(AccountErrorReason::AccessTokenExpired)
);
assert_eq!(account.last_error_message(), Some(upstream_message));
let retry_at = account.next_refresh_at().expect("next refresh attempt");
assert!(retry_at > SystemTime::now());
assert!(
retry_at
<= expires_at
.checked_add(Duration::from_secs(2 * 60 * 60))
.expect("recovery deadline")
);
let projection = account.status_projection(SystemTime::now(), None);
assert_eq!(projection.status, AccountStatus::Error);
assert_eq!(projection.error_message.as_deref(), Some(upstream_message));
}
#[tokio::test]
async fn scheduled_refresh_uses_the_final_message_after_the_two_hour_window() {
let store = Arc::new(MemoryAccountStore::default());
let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60));
let upstream_message = "Invalid refresh token.";
let service = CodexCredentialRefreshService::new(
store.repository(),
Arc::new(FailingRefresher {
failure: RefreshFailure::Transport {
message: Some(upstream_message.to_owned()),
upstream: None,
},
}),
Arc::new(RefreshLeases),
Arc::new(RefreshCredentialState),
policy,
);
let account_id = "acct_exhausted_unauthorized";
seed_refreshable_account(
&store,
account_id,
SystemTime::now()
.checked_sub(Duration::from_secs(2 * 60 * 60 + 60))
.expect("expired recovery window"),
None,
)
.await;
let outcomes = service.refresh_due().await.expect("refresh cycle");
assert!(matches!(
outcomes.as_slice(),
[CodexCredentialRefreshOutcome::Invalidated {
account_id: invalidated_account_id,
}] if invalidated_account_id == account_id
));
let account = store.account(account_id).expect("invalidated account");
assert_eq!(account.credential_state(), CredentialState::Expired);
assert_eq!(account.last_error_message(), Some(upstream_message));
}
#[tokio::test]
async fn scheduled_refresh_success_clears_a_deferred_failure_message() {
let store = Arc::new(MemoryAccountStore::default());
let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60));
let account_id = "acct_recovered_after_backoff";
seed_refreshable_account(
&store,
account_id,
SystemTime::now()
.checked_sub(Duration::from_secs(60))
.expect("expired access token"),
None,
)
.await;
let account = store.account(account_id).expect("seeded account");
store
.apply_state_change(AccountStateChange {
account_id: account.id().clone(),
expected_revision: account.revision(),
credential_state: CredentialState::Ready,
observed_at: SystemTime::now(),
error_reason: Some(AccountErrorReason::AccessTokenExpired),
message: Some("Invalid refresh token.".to_owned()),
})
.await
.expect("seed deferred failure");
let service = refresh_service(&store, SingleUseRefresher::new(), policy);
let outcomes = service.refresh_due().await.expect("refresh cycle");
assert!(matches!(
outcomes.as_slice(),
[CodexCredentialRefreshOutcome::Refreshed {
account_id: refreshed_account_id,
..
}] if refreshed_account_id == account_id
));
let account = store.account(account_id).expect("refreshed account");
assert_eq!(account.credential_state(), CredentialState::Ready);
assert_eq!(account.last_error_reason(), None);
assert_eq!(account.last_error_message(), None);
}
#[tokio::test]
async fn scheduled_refresh_persists_a_returned_id_token() {
let store = Arc::new(MemoryAccountStore::default());
@@ -302,6 +589,48 @@ async fn scheduled_refresh_persists_a_returned_id_token() {
);
}
#[tokio::test]
async fn scheduled_refresh_uses_the_rotated_access_token_jwt_expiration() {
let expires_at = Utc
.timestamp_opt(2_000_000_000, 0)
.single()
.expect("valid test timestamp");
let payload = URL_SAFE_NO_PAD.encode(
serde_json::to_vec(&serde_json::json!({"exp": expires_at.timestamp()}))
.expect("test JWT payload"),
);
let refresher = SingleUseRefresher::with_access_token(format!(
"unverified-header.{payload}.unverified-signature"
));
let store = Arc::new(MemoryAccountStore::default());
let service = refresh_service(
&store,
refresher,
MutableRuntimePolicy::new(Duration::from_secs(5 * 60)),
);
let account_id = "acct_rotated_access_expiration";
seed_refreshable_account(
&store,
account_id,
SystemTime::now()
.checked_add(Duration::from_secs(120))
.expect("test expiry"),
None,
)
.await;
service.refresh_due().await.expect("refresh cycle");
assert_eq!(
store
.account(account_id)
.expect("refreshed account")
.access_token_expires_at()
.map(DateTime::<Utc>::from),
Some(expires_at)
);
}
#[tokio::test]
async fn scheduled_refresh_preserves_the_stored_token_set_when_rotation_fields_are_omitted() {
let store = Arc::new(MemoryAccountStore::default());
@@ -1,5 +1,3 @@
use std::time::Duration;
use provider_openai::credential::token_client::{
AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant,
OpenAiTokenClient, RefreshFailure, TokenClientConfig, TokenRefresher,
@@ -46,13 +44,16 @@ async fn oversized_chunked_oauth_response_should_fail_closed_and_redact_body() {
.expect_err("oversized response must fail closed before body classification");
let diagnostic = format!("{failure:?} {failure}");
assert_eq!(failure, RefreshFailure::Transport);
assert!(matches!(
failure,
RefreshFailure::Transport { upstream: None, .. }
));
assert!(!diagnostic.contains(marker));
assert!(!diagnostic.contains("refresh-secret-request-marker"));
}
#[tokio::test]
async fn bounded_oauth_response_should_parse_lifetime_and_rotated_token() {
async fn bounded_oauth_response_should_parse_the_official_rotated_token_fields() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
@@ -74,7 +75,6 @@ async fn bounded_oauth_response_should_parse_lifetime_and_rotated_token() {
assert_eq!(tokens.access_token.as_deref(), Some("access-rotated"));
assert_eq!(tokens.refresh_token.as_deref(), Some("refresh-rotated"));
assert_eq!(tokens.id_token.as_deref(), Some("header.e30.signature"));
assert_eq!(tokens.expires_in, Some(Duration::from_secs(3600)));
}
#[tokio::test]
@@ -95,7 +95,10 @@ async fn refresh_response_should_reject_a_malformed_rotated_id_token() {
.await
.expect_err("malformed ID token must not replace the stored token set");
assert_eq!(failure, RefreshFailure::Transport);
assert!(matches!(
failure,
RefreshFailure::Transport { upstream: None, .. }
));
}
#[tokio::test]
@@ -116,7 +119,6 @@ async fn refresh_response_should_allow_all_rotated_tokens_to_be_omitted() {
assert!(tokens.access_token.is_none());
assert!(tokens.refresh_token.is_none());
assert!(tokens.id_token.is_none());
assert!(tokens.expires_in.is_none());
}
#[tokio::test]
@@ -142,7 +144,7 @@ async fn refresh_response_keeps_the_upstream_rotated_token_verbatim() {
}
#[tokio::test]
async fn refresh_should_exchange_the_official_form_fields() {
async fn refresh_should_exchange_the_official_json_fields() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
@@ -160,11 +162,24 @@ async fn refresh_should_exchange_the_official_form_fields() {
.await
.expect("refresh succeeds");
let requests = server.received_requests().await.expect("received request");
let body = String::from_utf8(requests[0].body.clone()).expect("form body is UTF-8");
let body: serde_json::Value =
serde_json::from_slice(&requests[0].body).expect("JSON request body");
assert!(body.contains("grant_type=refresh_token"));
assert!(body.contains("client_id=test-public-client"));
assert!(body.contains("refresh_token=refresh+secret"));
assert_eq!(
body,
serde_json::json!({
"client_id": "test-public-client",
"grant_type": "refresh_token",
"refresh_token": "refresh secret"
})
);
assert_eq!(
requests[0]
.headers
.get("content-type")
.and_then(|value| value.to_str().ok()),
Some("application/json")
);
}
#[tokio::test]
@@ -273,72 +288,165 @@ async fn authorization_code_exchange_requires_id_token_and_json_response() {
#[tokio::test]
async fn refresh_token_reuse_should_be_classified_as_invalid_grant() {
let upstream_message = "Your refresh token has already been used to generate a new access token. Please try signing in again.";
let failure = refresh_failure(
400,
r#"{"error":"invalid_grant","error_description":"refresh_token_reused"}"#,
r#"{"error":{"message":"Your refresh token has already been used to generate a new access token. Please try signing in again.","type":"invalid_request_error","param":null,"code":"refresh_token_reused"}}"#,
)
.await;
assert_eq!(failure, RefreshFailure::InvalidGrant);
let RefreshFailure::InvalidGrant { message, upstream } = failure else {
panic!("refresh-token reuse must be terminal");
};
assert_eq!(message.as_deref(), Some(upstream_message));
let upstream = upstream.expect("complete upstream failure");
assert_eq!(upstream.status(), 400);
assert_eq!(upstream.code(), Some("refresh_token_reused"));
assert_eq!(upstream.error_type(), Some("invalid_request_error"));
assert!(upstream.body().contains(r#""code":"refresh_token_reused""#));
}
#[tokio::test]
async fn generic_invalid_grant_should_remain_transient_like_official_codex() {
let body = r#"{"error":"invalid_grant","error_description":"not the official refresh error message field"}"#;
let failure = refresh_failure(400, body).await;
let diagnostic = format!("{failure:?} {failure}");
let RefreshFailure::Transport { message, upstream } = failure else {
panic!("unknown official refresh code must remain transient");
};
assert_eq!(message, None);
let upstream = upstream.expect("complete upstream failure");
assert_eq!(upstream.code(), Some("invalid_grant"));
assert_eq!(upstream.body(), body);
assert!(!diagnostic.contains("error_description"));
}
#[tokio::test]
async fn unauthorized_should_preserve_upstream_details_and_remain_retryable() {
let body = r#"{
"error": {
"message": "Invalid refresh token.",
"type": "invalid_request_error",
"param": null,
"code": "invalid_refresh_token"
}
}"#;
let failure = refresh_failure(401, body).await;
let RefreshFailure::Transport { message, upstream } = failure else {
panic!("production policy gives every 401 a bounded recovery window");
};
assert_eq!(message.as_deref(), Some("Invalid refresh token."));
let upstream = upstream.expect("complete upstream failure");
assert_eq!(upstream.status(), 401);
assert_eq!(upstream.code(), Some("invalid_refresh_token"));
assert_eq!(upstream.error_type(), Some("invalid_request_error"));
assert_eq!(upstream.body(), body);
}
#[tokio::test]
async fn unauthorized_should_back_off_even_with_a_recognized_refresh_code() {
let failure = refresh_failure(
401,
r#"{"error":{"code":"refresh_token_expired","message":"Refresh token expired."}}"#,
)
.await;
assert_transport_failure(
&failure,
401,
Some("Refresh token expired."),
r#"{"error":{"code":"refresh_token_expired","message":"Refresh token expired."}}"#,
);
}
#[tokio::test]
async fn deactivated_account_should_be_classified_as_banned() {
let failure = refresh_failure(
403,
r#"{"error":"access_denied","error_description":"account has been deactivated"}"#,
r#"{"error":{"code":"access_denied","message":"account has been deactivated"}}"#,
)
.await;
assert_eq!(failure, RefreshFailure::Banned);
let RefreshFailure::Banned { message, upstream } = failure else {
panic!("deactivated account must be banned");
};
assert_eq!(message.as_deref(), Some("account has been deactivated"));
assert_eq!(upstream.expect("complete upstream failure").status(), 403);
}
#[tokio::test]
async fn generic_banned_text_should_not_impersonate_the_deactivation_contract() {
let failure = refresh_failure(403, "account is banned").await;
assert_eq!(failure, RefreshFailure::Transport);
assert_transport_failure(&failure, 403, None, "account is banned");
}
#[tokio::test]
async fn unregistered_disabled_account_text_should_remain_a_transport_failure() {
let failure = refresh_failure(400, "account disabled").await;
assert_eq!(failure, RefreshFailure::Transport);
assert_transport_failure(&failure, 400, None, "account disabled");
}
#[tokio::test]
async fn quota_text_should_not_disable_the_oauth_credential() {
let failure = refresh_failure(400, "quota exceeded").await;
assert_eq!(failure, RefreshFailure::Transport);
assert_transport_failure(&failure, 400, None, "quota exceeded");
}
#[tokio::test]
async fn token_revoked_text_without_invalid_grant_should_remain_temporary() {
let failure = refresh_failure(400, "token_revoked").await;
assert_eq!(failure, RefreshFailure::Transport);
assert_transport_failure(&failure, 400, None, "token_revoked");
}
#[tokio::test]
async fn server_error_or_rate_limit_must_stay_transient_even_with_oauth_error_body() {
// 5xx/429(含 CDN/网关页恰好嵌入 invalid_grant 字样)按状态码判瞬态,
// 不因正文子串把账号永久终态——正文只在 4xx 时才是权威 OAuth 错误。
async fn recognized_refresh_code_should_be_permanent_on_non_unauthorized_status() {
for status in [500, 502, 503, 429] {
let failure = refresh_failure(
status,
r#"{"error":"invalid_grant","error_description":"refresh_token_expired"}"#,
r#"{"error":{"code":"refresh_token_expired","message":"Refresh token expired."}}"#,
)
.await;
assert_eq!(
failure,
RefreshFailure::Transport,
"status {status} must classify as transient"
assert!(
matches!(failure, RefreshFailure::InvalidGrant { .. }),
"official refresh code must take precedence for status {status}"
);
}
}
#[tokio::test]
async fn unknown_server_error_or_rate_limit_should_remain_transient() {
let body = r#"{"error":{"code":"temporarily_unavailable","message":"Try again."}}"#;
for status in [500, 502, 503, 429] {
let failure = refresh_failure(status, body).await;
assert_transport_failure(&failure, status, Some("Try again."), body);
}
}
fn assert_transport_failure(
failure: &RefreshFailure,
status: u16,
message: Option<&str>,
body: &str,
) {
let RefreshFailure::Transport {
message: actual_message,
upstream,
} = failure
else {
panic!("status {status} must classify as transient");
};
assert_eq!(actual_message.as_deref(), message);
let upstream = upstream.as_deref().expect("complete upstream failure");
assert_eq!(upstream.status(), status);
assert_eq!(upstream.body(), body);
}
async fn refresh_failure(status: u16, body: &str) -> RefreshFailure {
let server = MockServer::start().await;
Mock::given(method("POST"))
@@ -18,7 +18,10 @@ use gateway_core::engine::{
};
use gateway_core::error::ProviderErrorKind;
use gateway_core::event::GatewayEvent;
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload, ProviderSessionState};
use gateway_core::operation::{
CapabilityRequirements, GenerateRequest, Operation, OperationKind, ProtocolPayload,
ProviderSessionState,
};
use gateway_core::policy::ClientApiKeyId;
use gateway_core::routing::{
ClientRoutingScope, ConfigRevision, FrozenAccountScope, ModelCapabilities, ProviderKind,
@@ -2424,3 +2427,36 @@ async fn provider_compiles_catalog_presentation_for_codex_models() {
assert!(presentation.verbosity());
assert!(!presentation.hidden());
}
#[tokio::test]
async fn provider_routes_catalog_model_when_supported_in_api_is_false() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_non_api_model").await;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/codex/models"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/json")
.set_body_raw(
br#"{"models":[{"slug":"gpt-5.6-sol-wm","display_name":"GPT-5.6-Sol-WM","supported_in_api":false,"visibility":"hide"}]}"#,
"application/json",
),
)
.mount(&server)
.await;
let provider = provider_with_base_url(&store, server.uri());
let capabilities = provider
.query_model_capabilities()
.await
.expect("catalog model capabilities");
let model = capabilities.first().expect("catalog model");
assert!(
model
.capabilities()
.match_requirements(&CapabilityRequirements::new(OperationKind::Generate))
.is_some()
);
}
@@ -10,10 +10,10 @@ use async_trait::async_trait;
use futures::future::BoxFuture;
use gateway_core::engine::credential::{
AccountConcurrencyLimit, AccountErrorReason, AccountRuntimeSignals, AccountStateChange,
AccountWeight, CredentialCasOutcome, CredentialCasUpdate, CredentialRevision, CredentialState,
LoadedCredential, NewProviderAccount, OpaqueProviderData, ProviderAccount, ProviderAccountId,
ProviderAccountStore, ProviderAccountUpdate, QuotaAccessChange, QuotaObservation, QuotaState,
QuotaWriteOutcome,
AccountWeight, CredentialCasOutcome, CredentialCasUpdate, CredentialCasUpdateParts,
CredentialRevision, CredentialState, LoadedCredential, NewProviderAccount, OpaqueProviderData,
ProviderAccount, ProviderAccountId, ProviderAccountStore, ProviderAccountUpdate,
QuotaAccessChange, QuotaObservation, QuotaObservationTouch, QuotaState, QuotaWriteOutcome,
};
use gateway_core::error::{StoreError, StoreErrorKind};
use gateway_core::policy::ClientApiKeyId;
@@ -207,7 +207,7 @@ impl ProviderAccountStore for MemoryAccountStore {
&self,
update: CredentialCasUpdate,
) -> Result<CredentialCasOutcome, StoreError> {
let (
let CredentialCasUpdateParts {
account_id,
expected_revision,
profile,
@@ -215,7 +215,8 @@ impl ProviderAccountStore for MemoryAccountStore {
has_refresh_token,
access_token_expires_at,
next_refresh_at,
) = update.into_parts();
account_state,
} = update.into_parts();
let mut accounts = self.accounts.lock().expect("account store lock");
let stored = accounts
.get_mut(&account_id)
@@ -226,15 +227,29 @@ impl ProviderAccountStore for MemoryAccountStore {
let next = expected_revision
.next()
.map_err(|_| store_error(StoreErrorKind::Conflict))?;
let credential_state = account_state
.as_ref()
.map_or(stored.account.credential_state(), |state| {
state.credential_state
});
let last_error_reason = account_state.as_ref().map_or_else(
|| stored.account.last_error_reason(),
|state| state.error_reason,
);
let last_error_message = account_state.as_ref().map_or_else(
|| stored.account.last_error_message().map(str::to_owned),
|state| state.message.clone(),
);
let state_observed_at = account_state.as_ref().map(|state| state.observed_at);
stored.account = rebuild_account(
&stored.account,
AccountRebuild {
revision: next,
enabled: stored.account.enabled(),
credential_state: stored.account.credential_state(),
credential_state,
quota: stored.account.quota(),
last_error_reason: stored.account.last_error_reason(),
last_error_message: stored.account.last_error_message().map(str::to_owned),
last_error_reason,
last_error_message,
access_token_expires_at,
has_refresh_token,
next_refresh_at,
@@ -243,6 +258,9 @@ impl ProviderAccountStore for MemoryAccountStore {
);
stored.credential = credential;
stored.quota = None;
if let Some(observed_at) = state_observed_at {
stored.state_observed_at = Some(observed_at);
}
Ok(CredentialCasOutcome::Updated(next))
}
@@ -285,6 +303,27 @@ impl ProviderAccountStore for MemoryAccountStore {
Ok(QuotaWriteOutcome::Updated)
}
async fn touch_quota_observation(
&self,
touch: QuotaObservationTouch,
) -> Result<QuotaWriteOutcome, StoreError> {
let mut accounts = self.accounts.lock().expect("account store lock");
let stored = accounts
.get_mut(&touch.account_id)
.ok_or_else(|| store_error(StoreErrorKind::InvalidData))?;
if stored.account.revision() != touch.expected_revision {
return Ok(QuotaWriteOutcome::Conflict);
}
let Some(quota) = &mut stored.quota else {
return Ok(QuotaWriteOutcome::Conflict);
};
if quota.observed_at > touch.observed_at {
return Ok(QuotaWriteOutcome::Conflict);
}
quota.observed_at = touch.observed_at;
Ok(QuotaWriteOutcome::Updated)
}
async fn apply_quota_access(
&self,
change: QuotaAccessChange,
@@ -485,8 +524,11 @@ pub(crate) fn agent_identity_service_with_pool(
.no_proxy()
.build()
.expect("agent task client");
let registrar =
OfficialCodexAgentIdentityTaskRegistrar::new(client).expect("agent task registrar");
let registrar = OfficialCodexAgentIdentityTaskRegistrar::new(
client,
provider_openai::OpenAiConfig::default().wire_profile_state(),
)
.expect("agent task registrar");
Arc::new(CodexAgentIdentityTaskService::new(
store.repository(),
Arc::new(registrar),
@@ -4,6 +4,7 @@ use provider_openai::transport::canonical::{
CodexCanonicalDecoder, CodexCanonicalError, CodexCanonicalFailure, CodexCanonicalOutcome,
};
use provider_openai::transport::protocol::websocket::websocket_event_to_sse_frame;
use serde_json::json;
const METADATA_PREFIX_FIXTURE: &str = include_str!(
"../../../../gateway-api/tests/openai/responses/fixtures/http_sse/metadata_only_prefix.sse"
@@ -262,6 +263,69 @@ fn decoder_should_use_response_service_tier_when_request_omits_it() {
)));
}
#[test]
fn decoder_should_add_standard_web_search_call_cost() {
let body = concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_search_cost\",\"model\":\"gpt-5.4\"}}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_search_cost\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[{\"type\":\"web_search_call\",\"id\":\"ws_1\",\"status\":\"completed\",\"action\":{\"type\":\"search\"}}],\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"total_tokens\":0}}}\n\n",
);
let tools = vec![json!({ "type": "web_search" })];
let events = CodexCanonicalDecoder::new("fallback")
.with_request_tool_pricing("gpt-5.4", Some(&tools))
.push(body.as_bytes())
.expect("canonical web search response");
assert!(canonical_facts(&events).into_iter().any(|event| matches!(
event,
GatewayEvent::CalculatedCost(cost)
if cost.total().amount().scaled() == 100_000_000
)));
}
#[test]
fn decoder_should_use_non_reasoning_preview_web_search_price() {
let body = concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_preview_search_cost\",\"model\":\"gpt-4o\"}}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_preview_search_cost\",\"model\":\"gpt-4o\",\"status\":\"completed\",\"output\":[{\"type\":\"web_search_call\",\"id\":\"ws_1\",\"status\":\"completed\",\"action\":{\"type\":\"search\"}}],\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"total_tokens\":0}}}\n\n",
);
let tools = vec![json!({ "type": "web_search_preview" })];
let events = CodexCanonicalDecoder::new("fallback")
.with_request_tool_pricing("gpt-4o", Some(&tools))
.push(body.as_bytes())
.expect("canonical preview web search response");
assert!(canonical_facts(&events).into_iter().any(|event| matches!(
event,
GatewayEvent::CalculatedCost(cost)
if cost.total().amount().scaled() == 250_000_000
)));
}
#[test]
fn decoder_should_fail_closed_for_fixed_block_web_search_content() {
let body = concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_fixed_search_cost\",\"model\":\"gpt-4o-mini\"}}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_fixed_search_cost\",\"model\":\"gpt-4o-mini\",\"status\":\"completed\",\"output\":[{\"type\":\"web_search_call\",\"id\":\"ws_1\",\"status\":\"completed\",\"action\":{\"type\":\"search\"}}],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n",
);
let tools = vec![json!({ "type": "web_search" })];
let events = CodexCanonicalDecoder::new("fallback")
.with_request_tool_pricing("gpt-4o-mini", Some(&tools))
.push(body.as_bytes())
.expect("canonical fixed-block search response");
assert!(
canonical_facts(&events)
.into_iter()
.all(|event| !matches!(event, GatewayEvent::CalculatedCost(_)))
);
}
#[test]
fn websocket_decoder_should_prefer_requested_service_tier_over_response_tier_for_billing() {
let created = websocket_event_to_sse_frame(
@@ -423,6 +487,25 @@ fn decoder_should_preserve_image_tool_tokens_in_canonical_usage() {
assert_eq!(image_usage, Some((Some(31), Some(9))));
}
#[test]
fn decoder_should_not_understate_cost_when_image_tool_price_is_ambiguous() {
let body = concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_image_cost\",\"model\":\"gpt-5.4\"}}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_image_cost\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":5,\"total_tokens\":17},\"tool_usage\":{\"image_gen\":{\"input_tokens\":31,\"output_tokens\":9}}}}\n\n",
);
let events = CodexCanonicalDecoder::new("fallback")
.push(body.as_bytes())
.expect("canonical image cost response");
assert!(
canonical_facts(&events)
.into_iter()
.all(|event| !matches!(event, GatewayEvent::CalculatedCost(_)))
);
}
#[test]
fn decoder_should_drop_rate_limit_control_and_preserve_metadata_lifecycle_events() {
let body = concat!(
@@ -223,6 +223,7 @@ async fn fetch_should_send_official_catalog_headers_and_query() {
"user-agent",
"codex_cli_rs/0.144.0 (linux 6.8; x86_64) xterm (codex_cli_rs; 1.0.0)",
))
.and(header("x-openai-internal-codex-residency", "us"))
.and(header("accept", "application/json"))
.and(header("x-codex-installation-id", "installation-123"))
.respond_with(
@@ -254,7 +255,6 @@ async fn fetch_should_send_official_catalog_headers_and_query() {
for forbidden in [
"content-type",
"openai-beta",
"x-openai-internal-codex-residency",
"x-client-request-id",
"session_id",
"session-id",
@@ -1,4 +1,4 @@
use provider_openai::transport::{endpoint_url, usage_endpoint_urls};
use provider_openai::transport::{endpoint_url, usage_endpoint_url};
#[test]
fn endpoints_should_join_origin_and_backend_paths_without_double_slashes() {
@@ -9,12 +9,9 @@ fn endpoints_should_join_origin_and_backend_paths_without_double_slashes() {
}
#[test]
fn usage_endpoints_should_preserve_the_official_backend_api_fallback_order() {
fn usage_endpoint_should_use_the_official_wham_path_for_backend_api() {
assert_eq!(
usage_endpoint_urls("https://chatgpt.com/backend-api"),
vec![
"https://chatgpt.com/backend-api/wham/usage".to_owned(),
"https://chatgpt.com/backend-api/codex/usage".to_owned(),
]
usage_endpoint_url("https://chatgpt.com/backend-api"),
"https://chatgpt.com/backend-api/wham/usage"
);
}
@@ -178,6 +178,7 @@ fn websocket_opening_audit_should_redact_sensitive_headers() {
"x-codex-turn-metadata".to_owned(),
"{\"secret\":true}".to_owned(),
),
("x-oai-attestation".to_owned(), "device-token".to_owned()),
],
);
let audit = connection.opening_audit_snapshot();
@@ -190,6 +191,7 @@ fn websocket_opening_audit_should_redact_sensitive_headers() {
"user-agent",
"x-client-request-id",
"x-codex-turn-metadata",
"x-oai-attestation",
]
);
assert!(
@@ -313,6 +315,7 @@ async fn backend_websocket_should_forward_context_headers_and_preserve_payload_f
("content-type", "application/json"),
("accept", "text/event-stream"),
("x-codex-installation-id", "install-123"),
("openai-beta", "responses_websockets=2026-02-06"),
("x-openai-internal-codex-residency", "us"),
("x-codex-turn-state", "turn-state"),
("x-codex-turn-metadata", "{\"thread_source\":\"subagent\"}"),
@@ -382,10 +385,7 @@ async fn backend_http_should_restore_opaque_multivalue_header_bytes_and_lease_id
raw_header_values(&raw, "x-openai-future-mode"),
vec![b"future-ascii".to_vec(), b"\x80\xff".to_vec()]
);
assert_eq!(
raw_header_values(&raw, "openai-beta"),
vec![b"future_responses=v2".to_vec(), b"future_tools=v3".to_vec()]
);
assert!(raw_header_values(&raw, "openai-beta").is_empty());
assert_eq!(
raw_header_values(&raw, "x-codex-turn-state"),
vec![b"turn-ascii".to_vec(), b"turn-\x80".to_vec()]
@@ -396,10 +396,7 @@ async fn backend_http_should_restore_opaque_multivalue_header_bytes_and_lease_id
"content-type",
b"application/vnd.openai.request+json".as_slice(),
),
(
"x-openai-internal-codex-residency",
b"future-region".as_slice(),
),
("x-openai-internal-codex-residency", b"us".as_slice()),
("x-still-valid", b"after-invalid".as_slice()),
("authorization", b"Bearer lease-token".as_slice()),
("chatgpt-account-id", b"lease-account".as_slice()),
@@ -416,7 +413,12 @@ async fn backend_http_should_restore_opaque_multivalue_header_bytes_and_lease_id
raw_header_values(&raw, "originator"),
vec![b"codex_cli_rs".to_vec()]
);
for dropped in ["x-oai-attestation", "x-oai-is", "x-oai-is-update"] {
for dropped in [
"openai-beta",
"x-oai-attestation",
"x-oai-is",
"x-oai-is-update",
] {
assert!(
raw_header_values(&raw, dropped).is_empty(),
"unexpected {dropped}"
@@ -509,7 +511,7 @@ async fn backend_websocket_should_drop_only_unrepresentable_opaque_header_values
);
assert_eq!(
values("openai-beta"),
vec![b"future_responses=v2".to_vec(), b"future_tools=v3".to_vec()]
vec![b"responses_websockets=2026-02-06".to_vec()]
);
assert_eq!(
values("accept"),
@@ -524,6 +526,10 @@ async fn backend_websocket_should_drop_only_unrepresentable_opaque_header_values
vec![test_wire_profile().snapshot().user_agent().into_bytes()]
);
assert_eq!(values("originator"), vec![b"codex_cli_rs".to_vec()]);
assert_eq!(
values("x-openai-internal-codex-residency"),
vec![b"us".to_vec()]
);
for dropped in ["x-oai-attestation", "x-oai-is", "x-oai-is-update"] {
assert!(values(dropped).is_empty(), "unexpected {dropped}");
}
@@ -631,7 +637,6 @@ async fn backend_http_should_send_codex_context_without_browser_headers() {
"content-type",
"cookie",
"accept",
"openai-beta",
"x-openai-internal-codex-residency",
"x-client-request-id",
"x-codex-installation-id",
@@ -654,6 +659,7 @@ async fn backend_http_should_send_codex_context_without_browser_headers() {
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-dest",
"openai-beta",
] {
assert!(header_names.iter().all(|name| name != forbidden));
}
@@ -947,7 +953,12 @@ async fn websocket_should_keep_an_exact_chain_while_new_connections_adopt_the_la
)
.await
.expect("first response");
profile.update_cli_release("1.2.4");
profile.update_bundled_release(&CodexBundledReleaseProfile {
codex_version: "1.2.4".to_owned(),
desktop_version: "1.2.4".to_owned(),
desktop_build: "124".to_owned(),
verified_at: Utc::now(),
});
let mut continuation = request.clone();
continuation.set_previous_response_id(Some("resp_profile_first".to_owned()));
@@ -10,7 +10,9 @@ use std::{
use chrono::Utc;
use futures::{SinkExt, StreamExt};
use gateway_protocol::openai::events::{TokenUsage, extract_sse_usage};
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
use provider_openai::transport::profile::{
CodexBundledReleaseProfile, CodexWireProfile, CodexWireProfileState,
};
use provider_openai::transport::protocol::responses::{
CodexResponsesRequest, PreviousResponseScope,
};
@@ -1,60 +1,131 @@
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use chrono::{TimeZone, Utc};
use futures::future::BoxFuture;
use gateway_core::provider_ports::{
ProviderArtifactProfile, ProviderArtifactProfileCachePort, ProviderStoreError,
ProviderStoreErrorKind,
};
use gateway_core::routing::ProviderKind;
use provider_openai::transport::profile::{
CodexCliReleaseError, CodexCliReleaseService, CodexCliReleaseTransport, CodexDesktopRelease,
CodexArtifactProfileCache, CodexBundledReleaseProfile, CodexDesktopRelease,
CodexDesktopReleaseError, CodexDesktopReleaseService, CodexDesktopReleaseTransport,
CodexWireProfile, CodexWireProfileState, parse_desktop_release,
CodexWireProfile, CodexWireProfileState, OfficialCodexDesktopReleaseTransport,
parse_desktop_release,
};
mod desktop_artifact;
struct ReleaseTransport {
outcomes: Mutex<VecDeque<Result<CodexDesktopRelease, CodexDesktopReleaseError>>>,
releases: Mutex<VecDeque<Result<CodexDesktopRelease, CodexDesktopReleaseError>>>,
core_versions: Mutex<VecDeque<Result<String, CodexDesktopReleaseError>>>,
core_probes: Mutex<usize>,
}
impl ReleaseTransport {
fn new(
outcomes: impl IntoIterator<Item = Result<CodexDesktopRelease, CodexDesktopReleaseError>>,
releases: impl IntoIterator<Item = Result<CodexDesktopRelease, CodexDesktopReleaseError>>,
core_versions: impl IntoIterator<Item = Result<String, CodexDesktopReleaseError>>,
) -> Self {
Self {
outcomes: Mutex::new(outcomes.into_iter().collect()),
releases: Mutex::new(releases.into_iter().collect()),
core_versions: Mutex::new(core_versions.into_iter().collect()),
core_probes: Mutex::new(0),
}
}
fn core_probes(&self) -> usize {
*self.core_probes.lock().expect("core probe count")
}
}
impl CodexDesktopReleaseTransport for ReleaseTransport {
fn fetch(&self) -> BoxFuture<'_, Result<CodexDesktopRelease, CodexDesktopReleaseError>> {
Box::pin(async move {
self.outcomes
self.releases
.lock()
.expect("release outcomes")
.pop_front()
.expect("release outcome")
})
}
fn fetch_bundled_core_version<'a>(
&'a self,
_release: &'a CodexDesktopRelease,
) -> BoxFuture<'a, Result<String, CodexDesktopReleaseError>> {
Box::pin(async move {
*self.core_probes.lock().expect("core probe count") += 1;
self.core_versions
.lock()
.expect("Core version outcomes")
.pop_front()
.expect("Core version outcome")
})
}
}
struct CliReleaseTransport {
outcomes: Mutex<VecDeque<Result<String, CodexCliReleaseError>>>,
#[derive(Default)]
struct ArtifactProfiles {
profile: Mutex<Option<ProviderArtifactProfile>>,
fail_writes: bool,
}
impl CliReleaseTransport {
fn new(outcomes: impl IntoIterator<Item = Result<String, CodexCliReleaseError>>) -> Self {
impl ArtifactProfiles {
fn failing() -> Self {
Self {
outcomes: Mutex::new(outcomes.into_iter().collect()),
profile: Mutex::new(None),
fail_writes: true,
}
}
}
impl CodexCliReleaseTransport for CliReleaseTransport {
fn fetch(&self) -> BoxFuture<'_, Result<String, CodexCliReleaseError>> {
impl ProviderArtifactProfileCachePort for ArtifactProfiles {
fn replace_if_newer(
&self,
profile: ProviderArtifactProfile,
_ttl: Duration,
) -> BoxFuture<'_, Result<bool, ProviderStoreError>> {
Box::pin(async move {
self.outcomes
if self.fail_writes {
return Err(ProviderStoreError::new(
ProviderStoreErrorKind::Unavailable,
"replace test artifact profile",
));
}
let mut current = self.profile.lock().expect("artifact profile");
if let Some(current) = current.as_ref() {
if current.artifact_sequence() > profile.artifact_sequence() {
return Ok(false);
}
if current.artifact_sequence() == profile.artifact_sequence()
&& current.profile() != profile.profile()
{
return Err(ProviderStoreError::new(
ProviderStoreErrorKind::Conflict,
"replace test artifact profile",
));
}
}
*current = Some(profile);
Ok(true)
})
}
fn read<'a>(
&'a self,
provider_kind: &'a ProviderKind,
) -> BoxFuture<'a, Result<Option<ProviderArtifactProfile>, ProviderStoreError>> {
Box::pin(async move {
Ok(self
.profile
.lock()
.expect("CLI release outcomes")
.pop_front()
.expect("CLI release outcome")
.expect("artifact profile")
.as_ref()
.filter(|profile| profile.provider_kind() == provider_kind)
.cloned())
})
}
}
@@ -63,22 +134,22 @@ impl CodexCliReleaseTransport for CliReleaseTransport {
fn wire_profile_should_generate_bundled_core_app_server_user_agent() {
let profile = CodexWireProfile {
originator: "Codex Desktop".to_owned(),
codex_version: "0.144.2".to_owned(),
desktop_version: "26.707.72221".to_owned(),
desktop_build: "72221".to_owned(),
codex_version: "0.147.0-alpha.6.6".to_owned(),
desktop_version: "26.803.81509".to_owned(),
desktop_build: "6415".to_owned(),
os_type: "Mac OS".to_owned(),
os_version: "15.7.1".to_owned(),
arch: "arm64".to_owned(),
terminal: "unknown".to_owned(),
verified_at: Utc
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
.with_ymd_and_hms(2026, 8, 3, 0, 0, 0)
.single()
.expect("valid fixture time"),
};
assert_eq!(
profile.user_agent(),
"Codex Desktop/0.144.2 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.707.72221)"
"Codex Desktop/0.147.0-alpha.6.6 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.803.81509)"
);
}
@@ -95,7 +166,7 @@ fn desktop_appcast_should_publish_the_first_complete_release() {
<pubDate>Sun, 19 Jul 2026 08:00:00 +0000</pubDate>
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
<sparkle:hardwareRequirements>arm64</sparkle:hardwareRequirements>
<enclosure url="https://persistent.oaistatic.com/codex.dmg"
<enclosure url="https://persistent.oaistatic.com/codex.zip"
length="123456" sparkle:shortVersionString="26.720.1"
sparkle:version="72001" sparkle:edSignature="signature" />
</item>
@@ -135,77 +206,75 @@ fn desktop_appcast_should_reject_invalid_version_and_build() {
}
#[tokio::test]
async fn wire_profile_release_update_should_change_only_desktop_identity() {
async fn bundled_release_update_should_change_core_and_desktop_identity_atomically() {
let original = wire_profile();
let original_user_agent = original.user_agent();
let state = CodexWireProfileState::new(original.clone());
let service = CodexDesktopReleaseService::new(
state.clone(),
Arc::new(ReleaseTransport::new([Ok(release("26.720.1", "72001"))])),
);
let transport = Arc::new(ReleaseTransport::new(
[Ok(release("26.810.41047", "6570"))],
[Ok("0.148.0-alpha.9".to_owned())],
));
let cache = Arc::new(ArtifactProfiles::default());
let service = service(state.clone(), transport.clone(), cache.clone(), None);
service.refresh().await.expect("release refresh");
let updated = state.snapshot();
assert_eq!(updated.desktop_version, "26.720.1");
assert_eq!(updated.desktop_build, "72001");
assert_eq!(updated.codex_version, original.codex_version);
assert_eq!(updated.desktop_version, "26.810.41047");
assert_eq!(updated.desktop_build, "6570");
assert_eq!(updated.codex_version, "0.148.0-alpha.9");
assert_eq!(updated.originator, original.originator);
assert_eq!(updated.os_type, original.os_type);
assert_eq!(updated.os_version, original.os_version);
assert_eq!(updated.arch, original.arch);
assert_eq!(updated.terminal, original.terminal);
assert_eq!(updated.verified_at, original.verified_at);
assert!(updated.verified_at > original.verified_at);
assert_ne!(updated.user_agent(), original_user_agent);
assert_eq!(
updated.user_agent(),
"Codex Desktop/0.144.2 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.720.1)"
"Codex Desktop/0.148.0-alpha.9 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.810.41047)"
);
assert_eq!(transport.core_probes(), 1);
assert!(cache.profile.lock().expect("artifact profile").is_some());
let status = service.status().snapshot();
assert_eq!(status.latest, Some(release("26.720.1", "72001")));
assert_eq!(status.latest, Some(release("26.810.41047", "6570")));
assert!(status.checked_at.is_some());
assert!(status.last_error.is_none());
}
#[tokio::test]
async fn cli_release_update_should_change_only_codex_core_identity() {
let original = wire_profile();
let original_user_agent = original.user_agent();
let state = CodexWireProfileState::new(original.clone());
let service = CodexCliReleaseService::new(
state.clone(),
Arc::new(CliReleaseTransport::new([Ok("0.145.0".to_owned())])),
);
async fn unchanged_verified_release_should_refresh_cache_without_downloading_core_again() {
let state = CodexWireProfileState::new(wire_profile());
let latest = release("26.810.41047", "6570");
let transport = Arc::new(ReleaseTransport::new(
[Ok(latest.clone()), Ok(latest)],
[Ok("0.148.0-alpha.9".to_owned())],
));
let cache = Arc::new(ArtifactProfiles::default());
let service = service(state, transport.clone(), cache, None);
service.refresh().await.expect("CLI release refresh");
service.refresh().await.expect("first release refresh");
service.refresh().await.expect("cached release refresh");
let updated = state.snapshot();
assert_eq!(updated.codex_version, "0.145.0");
assert_eq!(updated.desktop_version, original.desktop_version);
assert_eq!(updated.desktop_build, original.desktop_build);
assert_eq!(updated.originator, original.originator);
assert_eq!(updated.os_type, original.os_type);
assert_eq!(updated.os_version, original.os_version);
assert_eq!(updated.arch, original.arch);
assert_eq!(updated.terminal, original.terminal);
assert_eq!(updated.verified_at, original.verified_at);
assert_ne!(updated.user_agent(), original_user_agent);
assert_eq!(
updated.user_agent(),
"Codex Desktop/0.145.0 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.707.72221)"
);
assert_eq!(transport.core_probes(), 1);
}
#[tokio::test]
async fn failed_release_refresh_should_preserve_the_last_successful_profile_and_release() {
let state = CodexWireProfileState::new(wire_profile());
let latest = release("26.720.1", "72001");
let service = CodexDesktopReleaseService::new(
state.clone(),
Arc::new(ReleaseTransport::new([
let latest = release("26.810.41047", "6570");
let transport = Arc::new(ReleaseTransport::new(
[
Ok(latest.clone()),
Err(CodexDesktopReleaseError::InvalidDocument),
])),
],
[Ok("0.148.0-alpha.9".to_owned())],
));
let service = service(
state.clone(),
transport,
Arc::new(ArtifactProfiles::default()),
None,
);
service.refresh().await.expect("first release refresh");
let successful_profile = state.snapshot();
@@ -225,18 +294,74 @@ async fn failed_release_refresh_should_preserve_the_last_successful_profile_and_
assert!(status.checked_at.is_some());
}
#[tokio::test]
async fn cache_failure_should_not_publish_a_partially_verified_profile() {
let original = wire_profile();
let state = CodexWireProfileState::new(original.clone());
let transport = Arc::new(ReleaseTransport::new(
[Ok(release("26.810.41047", "6570"))],
[Ok("0.148.0-alpha.9".to_owned())],
));
let service = service(
state.clone(),
transport,
Arc::new(ArtifactProfiles::failing()),
None,
);
service
.refresh()
.await
.expect_err("cache failure must fail the refresh");
assert_eq!(state.snapshot(), original);
assert!(service.status().snapshot().latest.is_none());
}
#[tokio::test]
#[ignore = "downloads bounded ranges from the current official macOS artifact"]
async fn official_desktop_artifact_should_expose_its_bundled_core_version() {
let transport = OfficialCodexDesktopReleaseTransport::new().expect("official transport");
let release = transport.fetch().await.expect("official appcast release");
let core_version = transport
.fetch_bundled_core_version(&release)
.await
.expect("bundled Core version");
semver::Version::parse(&core_version).expect("semantic bundled Core version");
eprintln!(
"official Desktop {} (build {}) bundles Core {core_version}",
release.version, release.build
);
}
fn service(
state: CodexWireProfileState,
transport: Arc<ReleaseTransport>,
cache: Arc<ArtifactProfiles>,
verified: Option<CodexBundledReleaseProfile>,
) -> CodexDesktopReleaseService {
let provider = ProviderKind::new("openai").expect("provider kind");
CodexDesktopReleaseService::new(
state,
transport,
CodexArtifactProfileCache::new(provider, cache),
verified,
)
}
fn wire_profile() -> CodexWireProfile {
CodexWireProfile {
originator: "Codex Desktop".to_owned(),
codex_version: "0.144.2".to_owned(),
desktop_version: "26.707.72221".to_owned(),
desktop_build: "72221".to_owned(),
codex_version: "0.147.0-alpha.6.6".to_owned(),
desktop_version: "26.803.81509".to_owned(),
desktop_build: "6415".to_owned(),
os_type: "Mac OS".to_owned(),
os_version: "15.7.1".to_owned(),
arch: "arm64".to_owned(),
terminal: "unknown".to_owned(),
verified_at: Utc
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
.with_ymd_and_hms(2026, 8, 3, 0, 0, 0)
.single()
.expect("valid fixture time"),
}
@@ -249,8 +374,8 @@ fn release(version: &str, build: &str) -> CodexDesktopRelease {
published_at: None,
minimum_system_version: Some("14.0".to_owned()),
hardware_requirements: Some("arm64".to_owned()),
download_url: Some("https://persistent.oaistatic.com/codex.dmg".to_owned()),
download_size: Some(123_456),
download_url: Some("https://persistent.oaistatic.com/codex.zip".to_owned()),
download_size: Some(589_926_710),
signature_present: true,
}
}
@@ -0,0 +1,83 @@
#[allow(dead_code)]
#[path = "../../../src/transport/profile/desktop_artifact.rs"]
mod subject;
use subject::{CoreVersionScanner, find_core_entry, parse_content_range};
#[test]
fn scanner_accepts_stable_and_multi_part_alpha_versions_across_chunks() {
for expected in ["0.148.0", "0.147.0-alpha.6.6"] {
let bytes = format!("prefixcodex-mcp-client/{expected}OAuth suffix");
let split = bytes.find("client").expect("split point") + 3;
let mut scanner = CoreVersionScanner::default();
assert_eq!(
scanner.push(&bytes.as_bytes()[..split]).expect("scan"),
None
);
assert_eq!(
scanner.push(&bytes.as_bytes()[split..]).expect("scan"),
Some(expected.to_owned())
);
}
}
#[test]
fn scanner_rejects_unrecognized_prerelease_shapes() {
let mut scanner = CoreVersionScanner::default();
assert!(
scanner
.push(b"codex-mcp-client/0.149.0-nightly.1 next")
.expect("scan")
.is_none()
);
}
#[test]
fn scanner_rejects_build_metadata_instead_of_truncating_the_version() {
for version in ["0.149.0+desktop.1", "0.149.0-alpha.1+desktop.1"] {
let mut scanner = CoreVersionScanner::default();
let bytes = format!("codex-mcp-client/{version}OAuth");
assert!(scanner.push(bytes.as_bytes()).expect("scan").is_none());
}
}
#[test]
fn scanner_skips_an_invalid_marker_before_the_bundled_version() {
let mut scanner = CoreVersionScanner::default();
assert_eq!(
scanner
.push(b"codex-mcp-client/not-a-version codex-mcp-client/0.147.0-alpha.6.6OAuth",)
.expect("scan"),
Some("0.147.0-alpha.6.6".to_owned())
);
}
#[test]
fn central_directory_requires_one_deflated_core_entry() {
let name = b"ChatGPT.app/Contents/Resources/codex";
let mut central = vec![0_u8; 46 + name.len()];
central[..4].copy_from_slice(b"PK\x01\x02");
central[10..12].copy_from_slice(&8_u16.to_le_bytes());
central[20..24].copy_from_slice(&100_u32.to_le_bytes());
central[24..28].copy_from_slice(&200_u32.to_le_bytes());
central[28..30].copy_from_slice(
&u16::try_from(name.len())
.expect("name length")
.to_le_bytes(),
);
central[42..46].copy_from_slice(&50_u32.to_le_bytes());
central[46..].copy_from_slice(name);
let entry = find_core_entry(&central, 1).expect("core entry");
assert_eq!(entry.name, name);
assert_eq!(entry.compressed_size, 100);
assert_eq!(entry.uncompressed_size, 200);
assert_eq!(entry.local_header_offset, 50);
}
#[test]
fn content_range_parser_is_strict() {
assert_eq!(parse_content_range("bytes 10-19/100"), Some((10, 19, 100)));
assert_eq!(parse_content_range("bytes */100"), None);
assert_eq!(parse_content_range("10-19/100"), None);
}
@@ -8,7 +8,7 @@ use chrono::{TimeZone, Utc};
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
use provider_openai::transport::{
CodexBackendClient, CodexClientError, CodexRequestContext, MAX_CODEX_USAGE_BODY_BYTES,
build_reqwest_client, openai_billing_breakdown,
OpenAiBillingUsage, build_reqwest_client, openai_billing_breakdown,
};
use reqwest::StatusCode;
use wiremock::matchers::{method, path};
@@ -16,10 +16,24 @@ use wiremock::{Mock, MockServer, ResponseTemplate};
const OVERSIZED_BODY_ERROR: &str = "upstream usage response exceeded the body limit";
fn billing_usage(
input_tokens: u64,
output_tokens: u64,
cached_tokens: u64,
cache_write_tokens: u64,
) -> OpenAiBillingUsage {
OpenAiBillingUsage::new(
input_tokens,
output_tokens,
cached_tokens,
cache_write_tokens,
)
}
#[test]
fn billing_breakdown_should_preserve_input_output_and_cache_components() {
let breakdown =
openai_billing_breakdown("gpt-5.6-sol", 100, 5, 20, 10, None).expect("known model pricing");
let breakdown = openai_billing_breakdown("gpt-5.6-sol", billing_usage(100, 5, 20, 10), None)
.expect("known model pricing");
assert_eq!(breakdown.input_amount().amount().scaled(), 3_500_000);
assert_eq!(breakdown.output_amount().amount().scaled(), 1_500_000);
@@ -32,50 +46,120 @@ fn billing_breakdown_should_preserve_input_output_and_cache_components() {
#[test]
fn billing_breakdown_should_use_latest_gpt_5_6_and_cached_input_prices() {
let terra = openai_billing_breakdown("gpt-5.6-terra", 1, 1, 0, 0, None)
let terra = openai_billing_breakdown("gpt-5.6-terra", billing_usage(1, 1, 0, 0), None)
.expect("gpt-5.6-terra standard pricing");
assert_eq!(terra.total_amount().amount().scaled(), 140_000);
let terra_fast = openai_billing_breakdown("gpt-5.6-terra", 1, 1, 0, 0, Some("fast"))
.expect("gpt-5.6-terra fast pricing");
let terra_fast =
openai_billing_breakdown("gpt-5.6-terra", billing_usage(1, 1, 0, 0), Some("fast"))
.expect("gpt-5.6-terra fast pricing");
assert_eq!(terra_fast.total_amount().amount().scaled(), 280_000);
let terra_long = openai_billing_breakdown("gpt-5.6-terra", 272_001, 0, 0, 0, None)
.expect("gpt-5.6-terra long-context pricing");
let terra_long =
openai_billing_breakdown("gpt-5.6-terra", billing_usage(272_001, 0, 0, 0), None)
.expect("gpt-5.6-terra long-context pricing");
assert_eq!(terra_long.total_amount().amount().scaled(), 10_880_040_000);
let luna = openai_billing_breakdown("gpt-5.6-luna", 1, 1, 0, 0, None)
let luna = openai_billing_breakdown("gpt-5.6-luna", billing_usage(1, 1, 0, 0), None)
.expect("gpt-5.6-luna standard pricing");
assert_eq!(luna.total_amount().amount().scaled(), 14_000);
let gpt_4o =
openai_billing_breakdown("gpt-4o", 1, 1, 1, 0, None).expect("gpt-4o cached-input pricing");
let gpt_4o = openai_billing_breakdown("gpt-4o", billing_usage(1, 1, 1, 0), None)
.expect("gpt-4o cached-input pricing");
assert_eq!(gpt_4o.total_amount().amount().scaled(), 112_500);
let gpt_4o_mini = openai_billing_breakdown("gpt-4o-mini", 1, 1, 1, 0, None)
let gpt_4o_mini = openai_billing_breakdown("gpt-4o-mini", billing_usage(1, 1, 1, 0), None)
.expect("gpt-4o-mini cached-input pricing");
assert_eq!(gpt_4o_mini.total_amount().amount().scaled(), 6_750);
}
#[test]
fn billing_breakdown_should_apply_fast_and_flex_tiers_without_guessing_unknown_models() {
let fast = openai_billing_breakdown("gpt-5.4", 1, 1, 0, 0, Some("fast")).expect("fast pricing");
let flex = openai_billing_breakdown("gpt-5.4", 1, 1, 0, 0, Some("flex")).expect("flex pricing");
let fast = openai_billing_breakdown("gpt-5.4", billing_usage(1, 1, 0, 0), Some("fast"))
.expect("fast pricing");
let flex = openai_billing_breakdown("gpt-5.4", billing_usage(1, 1, 0, 0), Some("flex"))
.expect("flex pricing");
assert_eq!(fast.total_amount().amount().scaled(), 350_000);
assert_eq!(fast.service_tier(), Some("fast"));
assert_eq!(fast.multiplier_percent(), 100);
assert_eq!(fast.multiplier_percent(), 200);
assert_eq!(flex.total_amount().amount().scaled(), 87_500);
assert_eq!(flex.multiplier_percent(), 50);
assert!(openai_billing_breakdown("unknown-model", 1, 1, 0, 0, None).is_none());
assert!(openai_billing_breakdown("unknown-model", billing_usage(1, 1, 0, 0), None).is_none());
}
#[test]
fn billing_breakdown_should_use_official_fast_long_context_prices() {
let breakdown =
openai_billing_breakdown("gpt-5.6-sol", billing_usage(272_001, 1, 1, 1), Some("fast"))
.expect("gpt-5.6-sol fast long-context pricing");
assert_eq!(
(
breakdown.input_price_per_million().amount().scaled(),
breakdown.cache_read_price_per_million().amount().scaled(),
breakdown.cache_write_price_per_million().amount().scaled(),
breakdown.output_price_per_million().amount().scaled(),
),
(
200_000_000_000,
20_000_000_000,
250_000_000_000,
900_000_000_000,
)
);
}
#[test]
fn billing_breakdown_should_fail_closed_for_unpublished_prices() {
assert_eq!(
(
openai_billing_breakdown("gpt-5.5", billing_usage(272_001, 1, 0, 0), Some("fast"))
.is_none(),
openai_billing_breakdown("gpt-5.5-pro", billing_usage(1, 1, 0, 0), Some("fast"))
.is_none(),
openai_billing_breakdown("gpt-5.3-codex-spark", billing_usage(1, 1, 0, 0), None)
.is_none(),
openai_billing_breakdown("gpt-4o", billing_usage(1, 1, 0, 0), Some("flex")).is_none(),
),
(true, true, true, true)
);
}
#[test]
fn billing_breakdown_should_use_official_gpt_4o_fast_prices() {
let breakdown = openai_billing_breakdown("gpt-4o", billing_usage(1, 1, 1, 0), Some("priority"))
.expect("gpt-4o fast pricing");
assert_eq!(
(
breakdown.cache_read_price_per_million().amount().scaled(),
breakdown.output_price_per_million().amount().scaled(),
breakdown.standard_amount().amount().scaled(),
breakdown.total_amount().amount().scaled(),
breakdown.multiplier_percent(),
),
(21_250_000_000, 170_000_000_000, 112_500, 191_250, 170,)
);
}
#[test]
fn billing_breakdown_should_use_published_flex_cache_price() {
let breakdown = openai_billing_breakdown("gpt-5.4", billing_usage(1, 0, 1, 0), Some("flex"))
.expect("gpt-5.4 flex pricing");
assert_eq!(
breakdown.cache_read_price_per_million().amount().scaled(),
1_300_000_000
);
}
#[test]
fn billing_breakdown_should_switch_only_after_the_long_context_threshold() {
let boundary = openai_billing_breakdown("gpt-5.4", 272_000, 0, 0, 0, None)
let boundary = openai_billing_breakdown("gpt-5.4", billing_usage(272_000, 0, 0, 0), None)
.expect("short-context boundary");
let long =
openai_billing_breakdown("gpt-5.4", 272_001, 0, 0, 0, None).expect("long-context pricing");
let long = openai_billing_breakdown("gpt-5.4", billing_usage(272_001, 0, 0, 0), None)
.expect("long-context pricing");
assert_eq!(
boundary.input_price_per_million().amount().scaled(),
@@ -85,7 +169,7 @@ fn billing_breakdown_should_switch_only_after_the_long_context_threshold() {
long.input_price_per_million().amount().scaled(),
50_000_000_000
);
assert!(openai_billing_breakdown("gpt-5.4", 1, 0, 2, 0, None).is_none());
assert!(openai_billing_breakdown("gpt-5.4", billing_usage(1, 0, 2, 0), None).is_none());
}
#[tokio::test]
@@ -153,7 +237,6 @@ async fn fetch_should_use_wham_usage_headers_only() {
for (name, expected) in [
("authorization", "Bearer oauth-access"),
("chatgpt-account-id", "acct_123"),
("originator", "codex_cli_rs"),
("accept", "application/json"),
("cookie", "session=old"),
] {
@@ -181,12 +264,12 @@ async fn fetch_should_use_wham_usage_headers_only() {
"authorization",
"chatgpt-account-id",
"cookie",
"originator",
"user-agent",
])
);
for forbidden in [
"content-type",
"originator",
"sec-ch-ua",
"x-openai-internal-codex-residency",
"x-client-request-id",
@@ -210,6 +293,30 @@ async fn fetch_should_use_wham_usage_headers_only() {
}
}
#[tokio::test]
async fn usage_not_found_should_preserve_status_without_a_nonofficial_fallback() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.respond_with(ResponseTemplate::new(404).set_body_string("missing"))
.expect(1)
.mount(&server)
.await;
let error = client(&server.uri())
.fetch_usage(context())
.await
.expect_err("usage 404");
let CodexClientError::Upstream { status, .. } = error else {
panic!("expected upstream status");
};
assert_eq!(status, StatusCode::NOT_FOUND);
let requests = server.received_requests().await.expect("usage requests");
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].url.path(), "/api/codex/usage");
}
#[tokio::test]
async fn content_length_over_limit_success_body_should_be_rejected() {
let server = MockServer::start().await;
@@ -109,7 +109,12 @@ async fn exact_continuation_reuses_owning_socket_after_connection_profile_update
)
.await
.expect("connection-local response");
profile.update_desktop_release("2.0.0", "200");
profile.update_bundled_release(&CodexBundledReleaseProfile {
codex_version: "2.0.0".to_owned(),
desktop_version: "2.0.0".to_owned(),
desktop_build: "200".to_owned(),
verified_at: Utc::now(),
});
let mut continuation = first_request;
continuation.set_previous_response_id(Some("resp_connection_local".to_owned()));
continuation.previous_response_scope = Some(PreviousResponseScope::ConnectionLocal);
+14 -31
View File
@@ -27,8 +27,9 @@ use gateway_admin::model::{AdminError, MutationActor, MutationContext, Revision}
use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind};
use gateway_core::accounting::Money;
use gateway_core::engine::credential::{
CredentialRevision, LoadedCredential, NewProviderAccount, OpaqueProviderData,
PlaintextCredential, ProviderAccount, ProviderAccountId, ProviderAccountStore,
CredentialCasUpdateParts, CredentialRevision, LoadedCredential, NewProviderAccount,
OpaqueProviderData, PlaintextCredential, ProviderAccount, ProviderAccountId,
ProviderAccountStore,
};
use gateway_core::error::StoreErrorKind;
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload};
@@ -41,17 +42,16 @@ use gateway_core::routing::{ProviderKind, UpstreamModelId};
use serde::Deserialize;
use serde_json::{Map, Number, Value};
use sha2::{Digest as _, Sha256};
use url::Url;
use uuid::Uuid;
use crate::XaiWireProfileState;
use crate::credential::{
AuthorizationCallback, FailureClass, GrokAccountProfile, GrokCredentialAdmin,
GrokCredentialCatalogError, GrokCredentialCatalogService, GrokCredentialQuotaService,
GrokCredentialRefreshError, GrokCredentialRefreshService, GrokCredentialRepository,
GrokCredentialRepositoryError, GrokOAuthClient, GrokOAuthConfig, GrokOAuthImportCandidate,
GrokOAuthImportDocument, GrokOAuthImportMetadata, GrokOAuthImportTokens, GrokOAuthSecret,
GrokQuotaError, GrokQuotaPeriodKind, GrokQuotaSnapshot, OAuthError, PendingAuthorization,
FailureClass, GrokAccountProfile, GrokCredentialAdmin, GrokCredentialCatalogError,
GrokCredentialCatalogService, GrokCredentialQuotaService, GrokCredentialRefreshError,
GrokCredentialRefreshService, GrokCredentialRepository, GrokCredentialRepositoryError,
GrokOAuthClient, GrokOAuthConfig, GrokOAuthImportCandidate, GrokOAuthImportDocument,
GrokOAuthImportMetadata, GrokOAuthImportTokens, GrokOAuthSecret, GrokQuotaError,
GrokQuotaPeriodKind, GrokQuotaSnapshot, OAuthError, PendingAuthorization,
PreparedGrokCredentialRotation, PreparedGrokCredentialRotationGuard, RedirectUriAllowlist,
RotateManagedGrokCredential, SecretValue, VerifiedGrokAccount, VerifiedTokenSet,
};
@@ -771,9 +771,8 @@ impl XaiAdminProvider {
&SecretValue::new(stored.server_state),
)
.map_err(map_oauth_error)?;
let callback = callback(callback_url)?;
let grant = authorization
.accept_callback(callback)
.accept_authorization_input(callback_url)
.map_err(map_oauth_error)?;
let discovery = self.oauth.discover().await.map_err(map_oauth_error)?;
let tokens = self
@@ -908,15 +907,16 @@ fn prepared_rotation(
provider_kind: ProviderKind,
) -> Result<PreparedCredentialRotation, ProviderAdminError> {
let (profile, credential, guard) = prepared.into_parts();
let (
let CredentialCasUpdateParts {
account_id,
expected_revision,
credential_profile,
profile: credential_profile,
credential,
has_refresh_token,
access_token_expires_at,
next_refresh_at,
) = credential.into_parts();
account_state: _account_state,
} = credential.into_parts();
if profile != credential_profile || profile.account_id != account_id {
return Err(provider_error(ProviderAdminErrorKind::Internal));
}
@@ -1343,23 +1343,6 @@ fn decode_mutation(
))
}
fn callback(value: &str) -> Result<AuthorizationCallback, ProviderAdminError> {
let mut callback =
Url::parse(value).map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))?;
if callback.fragment().is_some() {
return Err(provider_error(ProviderAdminErrorKind::Invalid));
}
let query = callback.query().unwrap_or_default().to_owned();
callback.set_query(None);
let expected = Url::parse(crate::OFFICIAL_REDIRECT_URI)
.map_err(|_| provider_error(ProviderAdminErrorKind::Internal))?;
if callback != expected {
return Err(provider_error(ProviderAdminErrorKind::Invalid));
}
AuthorizationCallback::parse(&query)
.map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))
}
fn random_flow_id() -> Result<String, ProviderAdminError> {
let mut random = [0_u8; 32];
getrandom::fill(&mut random)
@@ -197,6 +197,51 @@ impl PendingAuthorization {
return Err(rejection.into());
}
let code = callback.code.ok_or(CallbackRejection::MissingCode)?;
self.accept_code(code)
}
/// 消费一次性流程,并接受管理员粘贴的完整回调 URL、回调 query 或裸授权码。
///
/// 完整 URL 仍须精确匹配本流程的 redirect URI;query 输入仍须回传正确 state。
/// 裸授权码依赖已经绑定 owner 的一次性 flow 与 PKCE verifier,不降低 token 交换约束。
///
/// # Errors
///
/// URL、state 或授权码不满足当前一次性流程约束时返回回调拒绝错误。
pub fn accept_authorization_input(
self,
input: &str,
) -> Result<AuthorizationCodeGrant, OAuthError> {
let input = input.trim();
if let Ok(mut callback_url) = Url::parse(input)
&& matches!(callback_url.scheme(), "http" | "https")
&& callback_url.has_host()
{
if callback_url.fragment().is_some() {
return Err(CallbackRejection::ProviderRejected.into());
}
let query = callback_url.query().unwrap_or_default().to_owned();
callback_url.set_query(None);
if &callback_url != self.redirect_uri.as_url() {
return Err(CallbackRejection::ProviderRejected.into());
}
let callback = AuthorizationCallback::parse(&query)?;
return self.accept_callback(callback);
}
if looks_like_callback_url(input) {
return Err(CallbackRejection::ProviderRejected.into());
}
if looks_like_callback_query(input) {
let callback = AuthorizationCallback::parse(input)?;
return self.accept_callback(callback);
}
self.accept_code(SecretValue::new(input.to_owned()))
}
fn accept_code(self, code: SecretValue) -> Result<AuthorizationCodeGrant, OAuthError> {
if code.is_empty()
|| code.len() > MAX_CALLBACK_VALUE_BYTES
|| code.expose().chars().any(char::is_control)
@@ -213,6 +258,15 @@ impl PendingAuthorization {
}
}
fn looks_like_callback_url(value: &str) -> bool {
let value = value.to_ascii_lowercase();
value.starts_with("http:") || value.starts_with("https:") || value.starts_with("//")
}
fn looks_like_callback_query(value: &str) -> bool {
value.starts_with('?') || value.starts_with("code=")
}
#[derive(Serialize)]
struct PendingAuthorizationWire<'a> {
schema_version: u32,
@@ -26,12 +26,17 @@ use crate::{
SelectedGrokSession,
};
const DEFAULT_RATE_LIMIT_COOLDOWN: Duration = Duration::from_secs(60);
const DEFAULT_RATE_LIMIT_COOLDOWN: Duration = Duration::from_secs(2 * 60);
const MAX_RATE_LIMIT_COOLDOWN: Duration = Duration::from_secs(24 * 60 * 60);
const STREAM_INTERRUPTION_COOLDOWN: Duration = Duration::from_secs(30);
const UNAUTHORIZED_COOLDOWN: Duration = Duration::from_secs(60);
const UNAUTHORIZED_COOLDOWN: Duration = Duration::from_secs(10 * 60);
const ACCESS_DENIED_COOLDOWN: Duration = Duration::from_secs(30 * 60);
const PAYMENT_REQUIRED_COOLDOWN: Duration = Duration::from_secs(30 * 60);
const MODEL_CAPACITY_COOLDOWN: Duration = Duration::from_secs(3 * 60);
const EMPTY_UPSTREAM_COOLDOWN: Duration = Duration::from_secs(4 * 60);
const UPSTREAM_UNAVAILABLE_COOLDOWN: Duration = Duration::from_secs(2 * 60);
const MODEL_ACCESS_DENIED_COOLDOWN: Duration = Duration::from_secs(5 * 60);
const MODEL_QUOTA_COOLDOWN: Duration = Duration::from_secs(24 * 60 * 60);
const MODEL_QUOTA_COOLDOWN: Duration = Duration::from_secs(10 * 60);
const MAX_MODEL_COOLDOWN: Duration = Duration::from_secs(24 * 60 * 60);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -479,7 +484,7 @@ impl GrokSessionSelector for GrokAccountSessionSelector {
// 不持久化 QuotaExhausted(结构化 QuotaExhausted/FreeQuotaExhausted 走上面)。
GrokCredentialFailure::PaymentRequired { retry_after } => {
let retry_after = retry_after
.unwrap_or(DEFAULT_RATE_LIMIT_COOLDOWN)
.unwrap_or(PAYMENT_REQUIRED_COOLDOWN)
.min(MAX_RATE_LIMIT_COOLDOWN);
self.record_runtime_cooldown(session, retry_after).await;
}
@@ -507,6 +512,31 @@ impl GrokSessionSelector for GrokAccountSessionSelector {
self.record_model_runtime_cooldown(session, upstream_model, retry_after)
.await;
}
GrokCredentialFailure::AccessDenied => {
self.record_runtime_cooldown(session, ACCESS_DENIED_COOLDOWN)
.await;
}
GrokCredentialFailure::ModelCapacity { upstream_model } => {
if upstream_model.as_str().contains("multi-agent") {
self.record_model_runtime_cooldown(
session,
upstream_model,
MODEL_CAPACITY_COOLDOWN,
)
.await;
} else {
self.record_runtime_cooldown(session, MODEL_CAPACITY_COOLDOWN)
.await;
}
}
GrokCredentialFailure::EmptyUpstream => {
self.record_runtime_cooldown(session, EMPTY_UPSTREAM_COOLDOWN)
.await;
}
GrokCredentialFailure::UpstreamUnavailable => {
self.record_runtime_cooldown(session, UPSTREAM_UNAVAILABLE_COOLDOWN)
.await;
}
GrokCredentialFailure::RateLimited { retry_after } => {
let retry_after = retry_after
.unwrap_or(DEFAULT_RATE_LIMIT_COOLDOWN)
+274 -188
View File
@@ -75,7 +75,8 @@ const RESPONSE_NOT_FOUND_CODE: &str = "not_found";
/// 官方 Grok Build Provider;会话选择与 HTTP SSE transport 均由外部注入。
///
/// 每次调用只选择一个 OAuth 会话,只准备一次可见的上游 POST。重试、凭据轮换、
/// 每次调用只选择一个 OAuth 会话。仅当 xAI 明确拒绝历史 reasoning 密文时,
/// 允许在同账号、同凭据、同会话绑定上剥离密文后有界重试一次。凭据轮换、
/// endpoint fallback 以及公开 xAI API key 推理都不在该 adapter 内。
pub struct GrokBuildProvider {
selector: Arc<dyn GrokSessionSelector>,
@@ -202,6 +203,13 @@ impl GrokBuildProvider {
context.client_api_key_ref(),
)
.map_err(map_request_error)?;
let wire_upstream_model = UpstreamModelId::new(
upstream_request
.upstream_model()
.ok_or_else(protocol_not_sent)?
.to_owned(),
)
.map_err(|_| protocol_not_sent())?;
let request_input = upstream_request.input_items();
if let Some(previous) = previous_session.as_ref() {
upstream_request.inherit_session(previous.session_id.as_deref());
@@ -209,6 +217,7 @@ impl GrokBuildProvider {
let selected = select_grok_session(
self.selector.as_ref(),
candidate,
&wire_upstream_model,
&context,
continuation_account,
upstream_request.affinity().cloned(),
@@ -237,7 +246,7 @@ impl GrokBuildProvider {
.or(inherited_replay_session_id)
.and_then(|session_id| {
self.reasoning_replay.key(
candidate.upstream_model().as_str(),
wire_upstream_model.as_str(),
session_id,
selected.account_id().as_str(),
)
@@ -278,7 +287,7 @@ impl GrokBuildProvider {
credential_recovery: Arc::clone(&self.credential_recovery),
responses_url: self.responses_url.clone(),
request: upstream_request,
upstream_model: candidate.upstream_model().clone(),
upstream_model: wire_upstream_model,
context,
session: Arc::clone(&selected),
output_started_at,
@@ -323,6 +332,13 @@ impl GrokBuildProvider {
context.client_api_key_ref(),
)
.map_err(map_request_error)?;
let wire_upstream_model = UpstreamModelId::new(
upstream_request
.upstream_model()
.ok_or_else(protocol_not_sent)?
.to_owned(),
)
.map_err(|_| protocol_not_sent())?;
let explicit_replay_session_id = upstream_request
.reasoning_replay_session_id()
.map(str::to_owned);
@@ -333,6 +349,7 @@ impl GrokBuildProvider {
select_grok_session(
self.selector.as_ref(),
candidate,
&wire_upstream_model,
&context,
operation_account,
upstream_request.affinity().cloned(),
@@ -344,7 +361,7 @@ impl GrokBuildProvider {
.or(inherited_session_id.as_deref())
.and_then(|session_id| {
self.reasoning_replay.key(
candidate.upstream_model().as_str(),
wire_upstream_model.as_str(),
session_id,
selected.account_id().as_str(),
)
@@ -360,7 +377,7 @@ impl GrokBuildProvider {
credential_recovery: Arc::clone(&self.credential_recovery),
responses_url: self.responses_url.clone(),
request: upstream_request,
upstream_model: candidate.upstream_model().clone(),
upstream_model: wire_upstream_model,
upstream_session_id,
context,
session: Arc::clone(&selected),
@@ -380,13 +397,14 @@ impl GrokBuildProvider {
async fn select_grok_session(
selector: &dyn GrokSessionSelector,
candidate: &ProviderCandidate,
wire_upstream_model: &UpstreamModelId,
context: &AttemptContext,
operation_account: Option<ProviderAccountId>,
affinity: Option<GrokSessionAffinityKey>,
) -> Result<SelectedGrokSession, ProviderError> {
let required_account = context.required_account().cloned().or(operation_account);
let selection = GrokSessionSelection::new(
candidate.upstream_model().clone(),
wire_upstream_model.clone(),
context.excluded_accounts().clone(),
required_account.clone(),
context.account_selection_policy(),
@@ -941,81 +959,6 @@ struct AcceptedGrokInference {
observation: ProviderResponseObservation,
}
struct GrokInferenceStartFailure {
observation: Option<ProviderResponseObservation>,
error: ProviderError,
}
async fn start_grok_inference(
selector: &dyn GrokSessionSelector,
transport: &dyn GrokInferenceTransport,
credential_recovery: &dyn GrokCredentialRecovery,
request: GrokInferenceRequest,
upstream_model: &UpstreamModelId,
context: &AttemptContext,
session: &SelectedGrokSession,
) -> Result<AcceptedGrokInference, GrokInferenceStartFailure> {
if context.cancellation().is_cancelled() {
return Err(GrokInferenceStartFailure {
observation: None,
error: provider_error(ProviderErrorKind::Cancelled, UpstreamSendState::NotSent),
});
}
let Some(handshake_deadline) = remaining(context.deadline()) else {
return Err(GrokInferenceStartFailure {
observation: None,
error: provider_error(ProviderErrorKind::Timeout, UpstreamSendState::NotSent),
});
};
let cancellation = context.cancellation().clone();
let boundary = tokio::select! {
biased;
_ = cancellation.cancelled() => InferenceBoundary::Cancelled,
_ = tokio::time::sleep(handshake_deadline) => InferenceBoundary::Deadline,
response = transport.execute(request) => InferenceBoundary::Response(response),
};
let response = match boundary {
InferenceBoundary::Cancelled => {
return Err(GrokInferenceStartFailure {
observation: None,
error: provider_error(ProviderErrorKind::Cancelled, UpstreamSendState::Ambiguous),
});
}
InferenceBoundary::Deadline => {
return Err(GrokInferenceStartFailure {
observation: None,
error: provider_error(ProviderErrorKind::Timeout, UpstreamSendState::Ambiguous),
});
}
InferenceBoundary::Response(Ok(response)) => response,
InferenceBoundary::Response(Err(error)) => {
let observation = xai_error_observation(&error).ok();
let credential_failure = transport_credential_failure(&error, upstream_model);
let error =
map_continuation_failure(context, map_transport_error_for_context(error, context));
let error = recover_or_record_failure(
selector,
credential_recovery,
session,
error,
credential_failure,
context.credential_recovery_attempted(),
)
.await;
return Err(GrokInferenceStartFailure { observation, error });
}
};
let observation =
xai_response_observation(&response).map_err(|error| GrokInferenceStartFailure {
observation: None,
error,
})?;
Ok(AcceptedGrokInference {
response,
observation,
})
}
async fn next_grok_chunk(
body: &mut GrokInferenceChunkStream,
selector: &dyn GrokSessionSelector,
@@ -1063,7 +1006,7 @@ fn cold_compaction_http_sse_stream(
wire_profile,
credential_recovery,
responses_url,
request,
mut request,
upstream_model,
upstream_session_id,
context,
@@ -1081,32 +1024,90 @@ fn cold_compaction_http_sse_stream(
None,
&upstream_model,
);
let body = request.to_json_bytes().map_err(map_request_error)?;
let inference_request = GrokInferenceRequest::new(
responses_url,
headers,
body,
session.binding().clone(),
);
let accepted = match start_grok_inference(
selector.as_ref(),
transport.as_ref(),
credential_recovery.as_ref(),
inference_request,
&upstream_model,
&context,
&session,
)
.await
{
Ok(accepted) => accepted,
Err(failure) => {
if let Some(observation) = failure.observation {
yield ProviderEvent::observation(observation);
}
Err(mark_transient_compaction_failure(failure.error))?;
let mut invalid_encrypted_content_retried = false;
let accepted = loop {
if context.cancellation().is_cancelled() {
Err(provider_error(
ProviderErrorKind::Cancelled,
UpstreamSendState::NotSent,
))?;
return;
}
let body = request.to_json_bytes().map_err(map_request_error)?;
let inference_request = GrokInferenceRequest::new(
responses_url.clone(),
headers.clone(),
body,
session.binding().clone(),
);
let Some(handshake_deadline) = remaining(context.deadline()) else {
Err(mark_transient_compaction_failure(provider_error(
ProviderErrorKind::Timeout,
UpstreamSendState::NotSent,
)))?;
return;
};
let cancellation = context.cancellation().clone();
let boundary = tokio::select! {
biased;
_ = cancellation.cancelled() => InferenceBoundary::Cancelled,
_ = tokio::time::sleep(handshake_deadline) => InferenceBoundary::Deadline,
response = transport.execute(inference_request) => InferenceBoundary::Response(response),
};
match boundary {
InferenceBoundary::Cancelled => {
Err(provider_error(
ProviderErrorKind::Cancelled,
UpstreamSendState::Ambiguous,
))?;
return;
}
InferenceBoundary::Deadline => {
Err(mark_transient_compaction_failure(provider_error(
ProviderErrorKind::Timeout,
UpstreamSendState::Ambiguous,
)))?;
return;
}
InferenceBoundary::Response(Ok(response)) => {
let observation = xai_response_observation(&response)
.map_err(mark_transient_compaction_failure)?;
break AcceptedGrokInference {
response,
observation,
};
}
InferenceBoundary::Response(Err(error)) => {
if !invalid_encrypted_content_retried
&& is_invalid_encrypted_content_failure(&error)
&& request.strip_invalid_encrypted_reasoning()
{
invalid_encrypted_content_retried = true;
continue;
}
let observation = xai_error_observation(&error).ok();
let credential_failure =
transport_credential_failure(&error, &upstream_model);
let error = map_continuation_failure(
&context,
map_transport_error_for_context(error, &context),
);
let error = recover_or_record_failure(
selector.as_ref(),
credential_recovery.as_ref(),
&session,
error,
credential_failure,
context.credential_recovery_attempted(),
)
.await;
if let Some(observation) = observation {
yield ProviderEvent::observation(observation);
}
Err(mark_transient_compaction_failure(error))?;
return;
}
}
};
yield ProviderEvent::observation(accepted.observation);
@@ -1174,22 +1175,27 @@ fn cold_compaction_http_sse_stream(
}
}
let summary = summary.finish().map_err(map_compaction_decode_error)?;
let started = facts.started.ok_or_else(protocol_sent)?;
let upstream_completed = facts.completed.is_some();
let completed = facts.completed.unwrap_or_else(|| started.clone());
let started = facts
.started
.ok_or_else(|| mark_transient_compaction_failure(protocol_sent()))?;
let completed = facts
.completed
.ok_or_else(|| mark_transient_compaction_failure(protocol_sent()))?;
let (summary, encrypted_content) = summary
.finish_with_encrypted_content()
.map_err(map_compaction_decode_error)?;
let (created, output_done, terminal) = crate::transport::compaction::compaction_wire_events(
&started,
&completed,
&summary,
summary.as_deref(),
&encrypted_content,
facts.created_response.as_ref(),
facts.terminal_response.as_ref(),
facts.terminal_was_incomplete,
)
.map_err(|_| protocol_sent())?
.map_err(|_| mark_transient_compaction_failure(protocol_sent()))?
.into_parts();
ensure_sent_context(&context)?;
if upstream_completed && session.allows_account_state_mutation() {
if session.allows_account_state_mutation() {
selector.record_success(&session).await;
}
if let Some(key) = reasoning_replay_key.as_ref() {
@@ -1210,7 +1216,6 @@ struct CompactionFacts {
accounting: Vec<GatewayEvent>,
created_response: Option<Value>,
terminal_response: Option<Value>,
terminal_was_incomplete: bool,
}
impl CompactionFacts {
@@ -1249,7 +1254,6 @@ impl CompactionFacts {
Some("response.completed" | "response.incomplete")
if self.terminal_response.is_none() =>
{
self.terminal_was_incomplete = event_type == Some("response.incomplete");
self.terminal_response = response;
}
_ => {}
@@ -1259,7 +1263,9 @@ impl CompactionFacts {
fn map_compaction_decode_error(error: GrokCompactionDecodeError) -> ProviderError {
match error {
GrokCompactionDecodeError::Degenerate => mark_transient_compaction_failure(protocol_sent()),
GrokCompactionDecodeError::MissingEncryptedContent => {
mark_transient_compaction_failure(protocol_sent())
}
}
}
@@ -1288,7 +1294,7 @@ fn cold_http_sse_stream(
wire_profile,
credential_recovery,
responses_url,
request,
mut request,
upstream_model,
context,
session,
@@ -1312,54 +1318,69 @@ fn cold_http_sse_stream(
None,
&upstream_model,
);
let body = request.to_json_bytes().map_err(map_request_error)?;
let inference_request = GrokInferenceRequest::new(
responses_url,
headers,
body,
session.binding().clone(),
);
let Some(handshake_deadline) = remaining(context.deadline()) else {
Err(provider_error(
ProviderErrorKind::Timeout,
UpstreamSendState::NotSent,
))?;
return;
};
let cancellation = context.cancellation().clone();
let boundary = tokio::select! {
biased;
_ = cancellation.cancelled() => InferenceBoundary::Cancelled,
_ = tokio::time::sleep(handshake_deadline) => InferenceBoundary::Deadline,
response = transport.execute(inference_request) => InferenceBoundary::Response(response),
};
let response = match boundary {
InferenceBoundary::Cancelled => {
Err(provider_error(ProviderErrorKind::Cancelled, UpstreamSendState::Ambiguous))?;
return;
}
InferenceBoundary::Deadline => {
Err(provider_error(ProviderErrorKind::Timeout, UpstreamSendState::Ambiguous))?;
return;
}
InferenceBoundary::Response(Ok(response)) => response,
InferenceBoundary::Response(Err(error)) => {
let observation = xai_error_observation(&error)?;
let credential_failure = transport_credential_failure(&error, &upstream_model);
let error =
map_continuation_failure(&context, map_transport_error_for_context(error, &context));
let error = recover_or_record_failure(
selector.as_ref(),
credential_recovery.as_ref(),
&session,
error,
credential_failure,
context.credential_recovery_attempted(),
)
.await;
yield ProviderEvent::observation(observation);
Err(error)?;
let mut invalid_encrypted_content_retried = false;
let response = loop {
let body = request.to_json_bytes().map_err(map_request_error)?;
let inference_request = GrokInferenceRequest::new(
responses_url.clone(),
headers.clone(),
body,
session.binding().clone(),
);
let Some(handshake_deadline) = remaining(context.deadline()) else {
Err(provider_error(
ProviderErrorKind::Timeout,
UpstreamSendState::NotSent,
))?;
return;
};
let boundary = tokio::select! {
biased;
_ = cancellation.cancelled() => InferenceBoundary::Cancelled,
_ = tokio::time::sleep(handshake_deadline) => InferenceBoundary::Deadline,
response = transport.execute(inference_request) => InferenceBoundary::Response(response),
};
match boundary {
InferenceBoundary::Cancelled => {
Err(provider_error(ProviderErrorKind::Cancelled, UpstreamSendState::Ambiguous))?;
return;
}
InferenceBoundary::Deadline => {
Err(provider_error(ProviderErrorKind::Timeout, UpstreamSendState::Ambiguous))?;
return;
}
InferenceBoundary::Response(Ok(response)) => break response,
InferenceBoundary::Response(Err(error)) => {
if !invalid_encrypted_content_retried
&& is_invalid_encrypted_content_failure(&error)
&& request.strip_invalid_encrypted_reasoning()
{
invalid_encrypted_content_retried = true;
if let Some(capture) = session_capture.as_mut() {
capture.request_input = request.input_items();
}
continue;
}
let observation = xai_error_observation(&error)?;
let credential_failure = transport_credential_failure(&error, &upstream_model);
let error = map_continuation_failure(
&context,
map_transport_error_for_context(error, &context),
);
let error = recover_or_record_failure(
selector.as_ref(),
credential_recovery.as_ref(),
&session,
error,
credential_failure,
context.credential_recovery_attempted(),
)
.await;
yield ProviderEvent::observation(observation);
Err(error)?;
return;
}
}
};
@@ -1498,6 +1519,17 @@ fn cold_http_sse_stream(
})
}
fn is_invalid_encrypted_content_failure(error: &GrokInferenceTransportError) -> bool {
error.kind() == GrokInferenceTransportErrorKind::InvalidRequest
&& error.status() == Some(400)
&& error.upstream_code().is_some_and(|code| {
matches!(
code.as_str(),
REASONING_DECODE_FAILED_CODE | "invalid_encrypted_content"
)
})
}
enum InferenceBoundary {
Response(Result<crate::transport::GrokInferenceResponse, GrokInferenceTransportError>),
Cancelled,
@@ -1689,6 +1721,14 @@ fn transport_credential_failure(
) -> Option<GrokCredentialFailure> {
match error.kind() {
GrokInferenceTransportErrorKind::Unauthorized => Some(GrokCredentialFailure::Unauthorized),
GrokInferenceTransportErrorKind::PermissionDenied => {
Some(GrokCredentialFailure::AccessDenied)
}
GrokInferenceTransportErrorKind::RateLimited if model_capacity_failure(error) => {
Some(GrokCredentialFailure::ModelCapacity {
upstream_model: upstream_model.clone(),
})
}
GrokInferenceTransportErrorKind::RateLimited => Some(GrokCredentialFailure::RateLimited {
retry_after: error.retry_after(),
}),
@@ -1715,10 +1755,58 @@ fn transport_credential_failure(
retry_after: error.retry_after(),
})
}
GrokInferenceTransportErrorKind::Unavailable if empty_upstream_failure(error) => {
Some(GrokCredentialFailure::EmptyUpstream)
}
GrokInferenceTransportErrorKind::Unavailable => {
Some(GrokCredentialFailure::UpstreamUnavailable)
}
_ => None,
}
}
fn model_capacity_failure(error: &GrokInferenceTransportError) -> bool {
transport_error_contains_any(
error,
&[
"model_capacity",
"capacity",
"overloaded",
"server_busy",
"too many concurrent",
"engine_overloaded",
],
)
}
fn empty_upstream_failure(error: &GrokInferenceTransportError) -> bool {
transport_error_contains_any(
error,
&[
"empty_upstream",
"empty model output",
"no content/tool_calls",
"no client-visible content",
"empty upstream",
],
)
}
fn transport_error_contains_any(error: &GrokInferenceTransportError, signals: &[&str]) -> bool {
let code = error
.upstream_code()
.map(|code| code.as_str())
.unwrap_or_default();
let message = error
.client_visible_upstream_error()
.map(ClientVisibleUpstreamError::message)
.unwrap_or_default()
.to_ascii_lowercase();
signals
.iter()
.any(|signal| code.contains(signal) || message.contains(signal))
}
async fn map_and_record_stream_transport_failure(
selector: &dyn GrokSessionSelector,
session: &SelectedGrokSession,
@@ -1916,7 +2004,7 @@ fn map_transport_error_with_state(
mapped = mapped.with_status(status);
if allow_explicit_replay
&& forced_send_state.is_none()
&& explicit_rejection_is_replay_safe(transport_kind, status)
&& explicit_rejection_is_replay_safe(&error, status)
{
mapped = mapped.with_replay_safe();
}
@@ -1942,26 +2030,24 @@ fn map_transport_error_with_state(
mapped
}
fn explicit_rejection_is_replay_safe(kind: GrokInferenceTransportErrorKind, status: u16) -> bool {
matches!(
(kind, status),
(GrokInferenceTransportErrorKind::Unauthorized, 401)
| (
GrokInferenceTransportErrorKind::QuotaExhausted,
402 | 403 | 429
)
| (
GrokInferenceTransportErrorKind::FreeQuotaExhausted,
402 | 403 | 429
)
| (
GrokInferenceTransportErrorKind::ModelQuotaExhausted,
402 | 403 | 429
)
| (GrokInferenceTransportErrorKind::PaymentRequired, 402)
| (GrokInferenceTransportErrorKind::ModelAccessDenied, 403)
| (GrokInferenceTransportErrorKind::RateLimited, 429)
)
fn explicit_rejection_is_replay_safe(error: &GrokInferenceTransportError, status: u16) -> bool {
let kind = error.kind();
if kind == GrokInferenceTransportErrorKind::SafetyRejected {
return false;
}
if matches!(status, 401 | 402 | 403 | 405 | 429 | 529) || (500..=599).contains(&status) {
return true;
}
status == 400
&& (matches!(
kind,
GrokInferenceTransportErrorKind::QuotaExhausted
| GrokInferenceTransportErrorKind::FreeQuotaExhausted
| GrokInferenceTransportErrorKind::ModelQuotaExhausted
| GrokInferenceTransportErrorKind::PaymentRequired
| GrokInferenceTransportErrorKind::Unavailable
) || (kind == GrokInferenceTransportErrorKind::RateLimited
&& model_capacity_failure(error)))
}
fn provider_error(kind: ProviderErrorKind, send_state: UpstreamSendState) -> ProviderError {
@@ -1981,9 +2067,9 @@ const WORKER_LEASE_TTL: Duration = Duration::from_secs(15 * 60);
const WORKER_LEASE_RENEWAL: Duration = Duration::from_secs(5 * 60);
const OAUTH_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
const QUOTA_CATALOG_INTERVAL: Duration = Duration::from_secs(5 * 60);
// grok2api 对 Free/未知额度耗尽采用 24 小时恢复探测;当前公共账号状态没有
// 独立的 paid period-end 字段,因此 Build 账号统一使用同一保守下限。
const EXHAUSTED_QUOTA_FALLBACK_RECHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
// rolling 24h 描述的是上游用量窗口,不代表从本次观测起封禁 24 小时;
// 缺少可信 reset 时间时按 sub2api 的短周期探测策略恢复检查。
const EXHAUSTED_QUOTA_FALLBACK_RECHECK_INTERVAL: Duration = Duration::from_secs(10 * 60);
const EXHAUSTED_QUOTA_REFRESH_RETRY_INTERVAL: Duration = QUOTA_CATALOG_INTERVAL;
const CLI_RELEASE_WORKER_OWNER: &str = "xai-cli-release";
@@ -23,6 +23,7 @@ use super::{classify_grok_quota_failure, scrub_account_fingerprints};
const CONTENTS_PER_OUTPUT: u32 = 1_024;
const LONG_CONTEXT_THRESHOLD: u64 = 200_000;
const GROK_PING_SSE_COMMENT: &[u8] = b": ping\n\n";
#[derive(Clone, Copy)]
struct TokenRates {
@@ -198,27 +199,64 @@ impl GrokCanonicalDecoder {
fn decode(&mut self, events: Vec<SseEvent>) -> Result<Vec<ProviderEvent>, ProviderError> {
let mut output = Vec::new();
for event in events {
if event
.event
.as_deref()
.is_some_and(|event_type| event_type.trim() == "response.doom_loop_check")
{
continue;
}
if event.data.trim() == "[DONE]" {
if !self.completed {
return Err(protocol_error_marker());
}
continue;
}
let Ok(value) = serde_json::from_str::<Value>(&event.data) else {
continue;
};
let body_type = value.get("type").and_then(Value::as_str);
let Some(event_type) = body_type.or(event.event.as_deref()) else {
let wire = ProtocolWireEvent::json_with_sse_metadata(
let parsed = serde_json::from_str::<Value>(&event.data);
if event
.event
.as_deref()
.is_some_and(|event_type| event_type.trim() == "ping")
&& parsed
.as_ref()
.ok()
.and_then(|value| value.get("type").and_then(Value::as_str))
.is_none_or(|event_type| event_type == "ping")
{
let wire = ProtocolWireEvent::raw_sse(
"openai",
event.event,
value,
event.id,
event.retry,
bytes::Bytes::from_static(GROK_PING_SSE_COMMENT),
)
.map_err(|_| protocol_error_marker())?;
output.push(ProviderEvent::wire(wire));
continue;
}
let Ok(value) = parsed else {
continue;
};
let body_type = value.get("type").and_then(Value::as_str);
if body_type == Some("response.doom_loop_check") {
continue;
}
let Some(event_type) = body_type.or(event.event.as_deref()) else {
let Ok(transformed) = self.response_transform.rewrite_stream_event("", value)
else {
continue;
};
for (index, transformed) in transformed.into_iter().enumerate() {
let mut value = transformed.into_value();
self.response_transform.resequence_stream_value(&mut value);
let wire = ProtocolWireEvent::json_with_sse_metadata(
"openai",
None,
value,
(index == 0).then(|| event.id.clone()).flatten(),
(index == 0).then_some(event.retry).flatten(),
)
.map_err(|_| protocol_error_marker())?;
output.push(ProviderEvent::wire(wire));
}
continue;
};
let event_type = event_type.to_owned();
// 无法转换的上游帧(多为畸形帧)直接丢弃,保持已开始的客户端流不中断。
@@ -238,7 +276,8 @@ impl GrokCanonicalDecoder {
transformed_type.as_str(),
"response.created" | "response.in_progress" | "response.failed" | "error"
);
let value = transformed.into_value();
let mut value = transformed.into_value();
self.response_transform.resequence_stream_value(&mut value);
let mut canonical = Vec::new();
// 终态事件(completed/incomplete)fail-closed:用量/计费校验失败即断流。
// 其余内容事件容忍字段校验失败——正常上游变体(空 delta、重复 index、
@@ -611,7 +650,8 @@ impl GrokCanonicalDecoder {
}
fn client_visible_event(event_type: &str) -> bool {
event_type == "error" || event_type.starts_with("response.")
event_type == "error"
|| (event_type.starts_with("response.") && event_type != "response.doom_loop_check")
}
fn response_object(value: &Value) -> Option<&Value> {
@@ -2,16 +2,15 @@ use std::fmt;
use std::time::{SystemTime, UNIX_EPOCH};
use gateway_core::error::IdentifierError;
use gateway_core::event::{
FinishReason, GatewayEvent, ProtocolWireEvent, ProviderEvent, ResponseMeta,
};
use gateway_core::event::{GatewayEvent, ProtocolWireEvent, ProviderEvent, ResponseMeta};
use gateway_core::operation::GenerateRequest;
use gateway_core::policy::ClientApiKeyId;
use serde_json::{Map, Value, json};
use uuid::Uuid;
use super::request::strip_invalid_encrypted_reasoning_from_body;
use super::{GrokRequestEncodeError, GrokResponsesRequest, GrokSessionAffinityKey};
const MIN_GROK_COMPACTION_SUMMARY_CHARS: usize = 500;
// 来自 xAI grok-build 的 full-replace compaction 模板;当前协议没有额外的
// `/compact <text>` 用户上下文,因此不保留上游模板中的占位符。
const GROK_COMPACTION_PROMPT: &str = r#"Your task is to produce a faithful, concise summary of the conversation so far so that a successor assistant can continue the work seamlessly after the earlier turns are discarded. The successor will see the user's original query plus this summary. Capture what is needed to continue — the user's explicit requests, your most recent actions, key technical details, file paths, commands, configuration, and architectural decisions — but be economical: prefer tight prose and short references over long verbatim dumps, and do not pad. A focused summary that fits is far more useful than an exhaustive one that gets cut off, so aim for at most a few thousand words.
@@ -68,27 +67,16 @@ impl GrokCompactionRequest {
input.push(summary_prompt_item());
body.insert("input".to_owned(), Value::Array(input));
for field in [
"background",
"include",
"max_output_tokens",
"parallel_tool_calls",
"previous_response_id",
"prompt_cache_key",
"response_format",
"service_tier",
"text",
"truncation",
] {
body.remove(field);
}
body.insert("temperature".to_owned(), json!(1.0));
// 压缩请求已经携带规范化后的完整历史,不能让账号派生的缓存身份
// 把它重新变成依赖原账号状态的原生续接请求。
body.remove("prompt_cache_key");
body.insert("include".to_owned(), json!(["reasoning.encrypted_content"]));
if body
.get("tools")
.and_then(Value::as_array)
.is_some_and(|tools| !tools.is_empty())
{
body.insert("tool_choice".to_owned(), Value::String("auto".to_owned()));
body.insert("tool_choice".to_owned(), Value::String("none".to_owned()));
} else {
body.remove("tool_choice");
}
@@ -120,6 +108,11 @@ impl GrokCompactionRequest {
self.reasoning_replay_session_id.as_deref()
}
/// 返回经 sub2api 别名表归一化后的 xAI wire 模型。
pub(crate) fn upstream_model(&self) -> Option<&str> {
self.body.get("model").and_then(Value::as_str)
}
/// 序列化上游请求正文。
///
/// # Errors
@@ -128,6 +121,11 @@ impl GrokCompactionRequest {
pub fn to_json_bytes(&self) -> Result<Vec<u8>, GrokRequestEncodeError> {
serde_json::to_vec(&self.body).map_err(|_| GrokRequestEncodeError::Serialization)
}
/// 为同账号的一次 `invalid_encrypted_content` 恢复请求剥离被拒绝密文。
pub(crate) fn strip_invalid_encrypted_reasoning(&mut self) -> bool {
strip_invalid_encrypted_reasoning_from_body(&mut self.body)
}
}
/// 返回 xAI 专用全历史压缩是否由客户端请求。
@@ -166,6 +164,8 @@ impl fmt::Debug for GrokCompactionRequest {
#[derive(Default)]
pub struct GrokCompactionSummaryDecoder {
text: String,
terminal_text: Option<String>,
encrypted_content: Option<String>,
}
impl GrokCompactionSummaryDecoder {
@@ -174,6 +174,8 @@ impl GrokCompactionSummaryDecoder {
pub const fn new() -> Self {
Self {
text: String::new(),
terminal_text: None,
encrypted_content: None,
}
}
@@ -188,20 +190,48 @@ impl GrokCompactionSummaryDecoder {
self.text.push_str(&delta.text);
}
}
if let Some(wire) = event.wire_event().filter(|wire| wire.has_json_data()) {
capture_encrypted_reasoning(wire.data(), &mut self.encrypted_content);
capture_completed_summary(wire.data(), &mut self.terminal_text);
}
Ok(())
}
/// 完成摘要解码并返回清理后的摘要文本。
/// 完成摘要解码并返回上游摘要文本。
///
/// # Errors
///
/// 摘要为空或过短时返回错误。
/// 保留此 `Result` 签名以稳定 decoder contract。
pub fn finish(self) -> Result<String, GrokCompactionDecodeError> {
let summary = clean_summary(&self.text);
if summary.is_empty() || summary.chars().count() < MIN_GROK_COMPACTION_SUMMARY_CHARS {
return Err(GrokCompactionDecodeError::Degenerate);
Ok(self.summary_text().unwrap_or_default())
}
/// 完成摘要解码,并返回可见摘要与 xAI 真实 reasoning 密文。
pub(crate) fn finish_with_encrypted_content(
self,
) -> Result<(Option<String>, String), GrokCompactionDecodeError> {
let summary = self.summary_text();
let encrypted_content = self
.encrypted_content
.filter(|value| !value.trim().is_empty())
.ok_or(GrokCompactionDecodeError::MissingEncryptedContent)?;
Ok((summary, encrypted_content))
}
fn summary_text(&self) -> Option<String> {
if let Some(text) = self
.terminal_text
.as_deref()
.map(str::trim)
.filter(|text| !text.is_empty())
{
return Some(text.to_owned());
}
Ok(summary)
let text = self.text.trim();
if !text.is_empty() {
return Some(text.to_owned());
}
None
}
}
@@ -210,6 +240,8 @@ impl fmt::Debug for GrokCompactionSummaryDecoder {
formatter
.debug_struct("GrokCompactionSummaryDecoder")
.field("summary_bytes", &self.text.len())
.field("has_terminal_summary", &self.terminal_text.is_some())
.field("has_encrypted_content", &self.encrypted_content.is_some())
.finish()
}
}
@@ -217,8 +249,8 @@ impl fmt::Debug for GrokCompactionSummaryDecoder {
/// Grok 摘要响应不满足压缩 contract。
#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)]
pub enum GrokCompactionDecodeError {
#[error("Grok compaction response summary was too short")]
Degenerate,
#[error("Grok compaction response omitted reasoning.encrypted_content")]
MissingEncryptedContent,
}
pub(crate) struct GrokCompactionWireEvents {
@@ -240,10 +272,10 @@ impl GrokCompactionWireEvents {
pub(crate) fn compaction_wire_events(
started: &ResponseMeta,
completed: &ResponseMeta,
summary: &str,
summary: Option<&str>,
encrypted_content: &str,
created_source: Option<&Value>,
terminal_source: Option<&Value>,
terminal_was_incomplete: bool,
) -> Result<GrokCompactionWireEvents, IdentifierError> {
let created_response = compaction_response(
started,
@@ -253,17 +285,14 @@ pub(crate) fn compaction_wire_events(
Vec::new(),
Value::Null,
);
let item = compaction_item(summary);
let (status, terminal_event, incomplete_details) = terminal_shape(
completed.finish_reason(),
terminal_was_incomplete,
terminal_source,
);
let item = compaction_item(summary, encrypted_content);
// sub2api 的 compact contract 以真实密文为成功依据,不向客户端延续
// 摘要生成阶段的 incomplete 状态。
let terminal_response = compaction_response(
completed,
terminal_source.or(created_source),
status,
incomplete_details,
"completed",
Value::Null,
vec![item.clone()],
terminal_source
.and_then(|source| source.get("usage"))
@@ -294,9 +323,9 @@ pub(crate) fn compaction_wire_events(
)?,
terminal: ProtocolWireEvent::json_with_sse_metadata(
"openai",
Some(terminal_event.to_owned()),
Some("response.completed".to_owned()),
json!({
"type": terminal_event,
"type": "response.completed",
"response": terminal_response,
}),
None,
@@ -333,38 +362,96 @@ fn compaction_response(
response.insert("model".to_owned(), Value::String(meta.model().to_owned()));
response.insert("output".to_owned(), Value::Array(output));
response.insert("usage".to_owned(), usage);
response.remove("output_text");
Value::Object(response)
}
fn compaction_item(summary: &str) -> Value {
json!({
"type": "compaction",
"encrypted_content": summary,
})
fn compaction_item(summary: Option<&str>, encrypted_content: &str) -> Value {
let mut item = Map::from_iter([
(
"id".to_owned(),
Value::String(format!("cmp_{}", Uuid::new_v4().simple())),
),
("type".to_owned(), Value::String("compaction".to_owned())),
("status".to_owned(), Value::String("completed".to_owned())),
(
"encrypted_content".to_owned(),
Value::String(encrypted_content.to_owned()),
),
]);
if let Some(summary) = summary.map(str::trim).filter(|value| !value.is_empty()) {
item.insert(
"summary".to_owned(),
json!([{
"type": "summary_text",
"text": summary,
}]),
);
}
Value::Object(item)
}
fn terminal_shape(
finish_reason: Option<FinishReason>,
terminal_was_incomplete: bool,
terminal_source: Option<&Value>,
) -> (&'static str, &'static str, Value) {
let incomplete = terminal_was_incomplete
|| matches!(
finish_reason,
Some(FinishReason::Length | FinishReason::ContentFilter)
);
if !incomplete {
return ("completed", "response.completed", Value::Null);
fn capture_encrypted_reasoning(value: &Value, captured: &mut Option<String>) {
if let Some(item) = value.get("item") {
capture_encrypted_reasoning_item(item, captured);
}
for output in [value.get("output"), value.pointer("/response/output")]
.into_iter()
.flatten()
.filter_map(Value::as_array)
{
for item in output {
capture_encrypted_reasoning_item(item, captured);
}
}
}
fn capture_encrypted_reasoning_item(item: &Value, captured: &mut Option<String>) {
let Some(item) = item.as_object() else {
return;
};
if item.get("type").and_then(Value::as_str) != Some("reasoning") {
return;
}
if let Some(encrypted_content) = item
.get("encrypted_content")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
*captured = Some(encrypted_content.to_owned());
}
}
fn capture_completed_summary(value: &Value, captured: &mut Option<String>) {
let mut parts = Vec::new();
for output in [value.get("output"), value.pointer("/response/output")]
.into_iter()
.flatten()
.filter_map(Value::as_array)
{
for item in output {
let Some(item) = item.as_object() else {
continue;
};
if item.get("type").and_then(Value::as_str) != Some("message") {
continue;
}
let Some(content) = item.get("content").and_then(Value::as_array) else {
continue;
};
parts.extend(content.iter().filter_map(|part| {
part.get("text")
.and_then(Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.map(str::to_owned)
}));
}
}
if !parts.is_empty() {
*captured = Some(parts.join("\n"));
}
let details = terminal_source
.and_then(|source| source.get("incomplete_details"))
.cloned()
.unwrap_or_else(|| match finish_reason {
Some(FinishReason::Length) => json!({ "reason": "max_output_tokens" }),
Some(FinishReason::ContentFilter) => json!({ "reason": "content_filter" }),
_ => json!({ "reason": "other" }),
});
("incomplete", "response.incomplete", details)
}
fn unix_seconds() -> u64 {
@@ -384,71 +471,3 @@ fn summary_prompt_item() -> Value {
}],
})
}
fn clean_summary(raw: &str) -> String {
let mut summary = raw.to_owned();
while let Some(start) = summary.find("<analysis>") {
let leading = match summary.find("<summary>") {
Some(summary_start) => {
start < summary_start
|| summary[summary_start + "<summary>".len()..start]
.trim()
.is_empty()
}
None => summary[..start].trim().is_empty(),
};
if !leading {
break;
}
if let Some(relative_end) = summary[start..].find("</analysis>") {
let end = start + relative_end + "</analysis>".len();
summary.replace_range(start..end, "");
} else {
let drop_end = summary[start..]
.find("<summary>")
.map_or(summary.len(), |relative| start + relative);
summary.replace_range(start..drop_end, "");
break;
}
}
if let Some(start) = summary.find("<summary>")
&& let Some(end) = summary.rfind("</summary>")
&& end > start
{
let before = summary[..start].to_owned();
let after = summary[end + "</summary>".len()..].to_owned();
let inner = strip_leading_scratchpad(summary[start + "<summary>".len()..end].trim());
summary = format!("{before}Summary:\n{inner}{after}");
}
summary = neutralize_compaction_control_tokens(&summary);
while summary.contains("\n\n\n") {
summary = summary.replace("\n\n\n", "\n\n");
}
summary.trim().to_owned()
}
fn strip_leading_scratchpad(inner: &str) -> String {
let mut summary = inner.trim();
let lead = summary.trim_start_matches(['#', '*', '-', '>', ' ', '\t']);
if !lead.starts_with(|character: char| character.is_ascii_digit())
&& let Some(end) = summary.rfind("</analysis>")
{
summary = summary[end + "</analysis>".len()..].trim_start();
}
if let Some(inner) = summary.strip_prefix("<summary>") {
summary = inner.trim_start();
}
summary.to_owned()
}
fn neutralize_compaction_control_tokens(summary: &str) -> String {
summary
.replace("</summary>", "<\u{200b}/summary>")
.replace("<summary>", "<\u{200b}summary>")
.replace("</analysis>", "<\u{200b}/analysis>")
.replace("<analysis>", "<\u{200b}analysis>")
.replace("</summary_request>", "<\u{200b}/summary_request>")
.replace("<summary_request>", "<\u{200b}summary_request>")
}
@@ -61,8 +61,26 @@ const FREE_USAGE_SIGNALS: &[&str] = &[
"subscription_free_usage_exhausted",
"free-usage-exhausted",
"free_usage_exhausted",
"usage-limit-exceeded",
"usage_limit_exceeded",
"free-tier-limit",
"free_tier_limit",
"used all the included free usage",
"used all your free usage",
"free quota",
"no remaining free",
"out of free",
"usage resets over a rolling",
"额度耗尽",
"额度用完",
"额度不足",
"额度已用尽",
"免费额度",
"免费用量",
"用量耗尽",
"配额耗尽",
"配额不足",
"模型额度",
];
const ACCOUNT_QUOTA_SIGNALS: &[&str] = &[
"personal-team-blocked:spending-limit",
@@ -50,7 +50,7 @@ const HTTP2_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(15);
const HTTP2_KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_ERROR_BODY_BYTES: usize = 64 * 1024;
const MAX_INFERENCE_BODY_BYTES: usize = 256 * 1024 * 1024;
const MAX_RETRY_AFTER_SECONDS: u64 = 120;
const MAX_RETRY_AFTER_SECONDS: u64 = 24 * 60 * 60;
const TRUSTED_DOH_HOST: &str = "dns.google";
const TRUSTED_DOH_URL: &str = "https://dns.google/resolve";
const MAX_DOH_RESPONSE_BYTES: usize = 64 * 1024;
@@ -992,19 +992,32 @@ async fn classify_inference_status(response: Response) -> GrokInferenceTransport
Ok(BoundedBody::TooLarge) | Err(_) => Vec::new(),
};
let metadata = inference_error_metadata(&body);
let body_failure = classify_grok_body_failure(&metadata, &body);
let kind = match status {
StatusCode::BAD_REQUEST
| StatusCode::NOT_FOUND
| StatusCode::CONFLICT
| StatusCode::UNPROCESSABLE_ENTITY => GrokInferenceTransportErrorKind::InvalidRequest,
StatusCode::UNAUTHORIZED => GrokInferenceTransportErrorKind::Unauthorized,
StatusCode::PAYMENT_REQUIRED => classify_payment_required(&metadata),
StatusCode::FORBIDDEN => classify_forbidden(&metadata, &body),
StatusCode::REQUEST_TIMEOUT | StatusCode::GATEWAY_TIMEOUT => {
GrokInferenceTransportErrorKind::Timeout
StatusCode::BAD_REQUEST => match body_failure {
Some(GrokBodyFailure::RateLimit) | None => {
GrokInferenceTransportErrorKind::InvalidRequest
}
Some(failure) => failure.transport_kind(),
},
StatusCode::NOT_FOUND | StatusCode::CONFLICT | StatusCode::UNPROCESSABLE_ENTITY => {
GrokInferenceTransportErrorKind::InvalidRequest
}
StatusCode::TOO_MANY_REQUESTS => classify_too_many_requests(&metadata),
status if status.is_server_error() => GrokInferenceTransportErrorKind::Unavailable,
StatusCode::UNAUTHORIZED => GrokInferenceTransportErrorKind::Unauthorized,
StatusCode::PAYMENT_REQUIRED => body_failure
.map(GrokBodyFailure::transport_kind)
.unwrap_or_else(|| classify_payment_required(&metadata)),
StatusCode::FORBIDDEN => classify_forbidden(&metadata, &body, body_failure),
StatusCode::REQUEST_TIMEOUT => GrokInferenceTransportErrorKind::Timeout,
StatusCode::TOO_MANY_REQUESTS => body_failure
.map(GrokBodyFailure::transport_kind)
.unwrap_or_else(|| classify_too_many_requests(&metadata)),
_ if status_code == 529 => body_failure
.map(GrokBodyFailure::transport_kind)
.unwrap_or(GrokInferenceTransportErrorKind::Unavailable),
status if status.is_server_error() => body_failure
.map(GrokBodyFailure::transport_kind)
.unwrap_or(GrokInferenceTransportErrorKind::Unavailable),
_ => GrokInferenceTransportErrorKind::Protocol,
};
let credential_recovery_required = kind == GrokInferenceTransportErrorKind::Unauthorized;
@@ -1015,7 +1028,11 @@ async fn classify_inference_status(response: Response) -> GrokInferenceTransport
let upstream_code = if status == StatusCode::BAD_REQUEST && reasoning_decode_failed(&metadata) {
Some("reasoning_decode_failed".to_owned())
} else {
metadata.code.as_deref().and_then(normalize_failure_code)
metadata
.code
.as_deref()
.and_then(normalize_failure_code)
.or_else(|| body_failure.map(GrokBodyFailure::marker).map(str::to_owned))
};
if let Some(message) = metadata.client_message.as_deref() {
error = error.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
@@ -1045,6 +1062,115 @@ struct InferenceErrorMetadata {
client_message: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GrokBodyFailure {
Quota(GrokQuotaFailureKind),
Billing,
EmptyUpstream,
ModelCapacity,
RateLimit,
}
impl GrokBodyFailure {
fn transport_kind(self) -> GrokInferenceTransportErrorKind {
match self {
Self::Quota(failure) => quota_transport_error_kind(failure),
Self::Billing => GrokInferenceTransportErrorKind::PaymentRequired,
Self::EmptyUpstream => GrokInferenceTransportErrorKind::Unavailable,
Self::ModelCapacity | Self::RateLimit => GrokInferenceTransportErrorKind::RateLimited,
}
}
const fn marker(self) -> &'static str {
match self {
Self::Quota(GrokQuotaFailureKind::Account) => "quota_exhausted",
Self::Quota(GrokQuotaFailureKind::FreeAccount) => "free_usage_exhausted",
Self::Quota(GrokQuotaFailureKind::FreeModelUsage) => "model_quota_exhausted",
Self::Billing => "billing_quota",
Self::EmptyUpstream => "empty_upstream",
Self::ModelCapacity => "model_capacity",
Self::RateLimit => "rate_limit",
}
}
}
fn classify_grok_body_failure(
metadata: &InferenceErrorMetadata,
body: &[u8],
) -> Option<GrokBodyFailure> {
let text = format!(
"{} {}",
inference_metadata_text(metadata),
String::from_utf8_lossy(body).to_ascii_lowercase()
);
let quota_failure = classify_grok_quota_failure(
metadata.code.as_deref(),
metadata.error_type.as_deref(),
Some(&text),
);
if let Some(
failure @ (GrokQuotaFailureKind::FreeAccount | GrokQuotaFailureKind::FreeModelUsage),
) = quota_failure
{
return Some(GrokBodyFailure::Quota(failure));
}
if contains_any(
&text,
&[
"billing quota",
"insufficient_quota",
"payment required",
"payment failed",
"spending limit",
"run out of credits",
"out of credits",
"余额不足",
"欠费",
"需要付费",
],
) {
return Some(GrokBodyFailure::Billing);
}
if let Some(failure) = quota_failure {
return Some(GrokBodyFailure::Quota(failure));
}
if contains_any(
&text,
&[
"empty model output",
"no content/tool_calls",
"no client-visible content",
"empty_upstream",
"empty upstream",
],
) {
return Some(GrokBodyFailure::EmptyUpstream);
}
if contains_any(
&text,
&[
"capacity",
"overloaded",
"server_busy",
"too many concurrent",
"engine_overloaded",
],
) {
return Some(GrokBodyFailure::ModelCapacity);
}
contains_any(
&text,
&[
"rate limit",
"rate_limit",
"too many requests",
"请求过于频繁",
"速率限制",
],
)
.then_some(GrokBodyFailure::RateLimit)
}
fn inference_error_metadata(body: &[u8]) -> InferenceErrorMetadata {
let Ok(value) = serde_json::from_slice::<Value>(body) else {
let message = String::from_utf8_lossy(body).trim().to_owned();
@@ -1091,13 +1217,18 @@ fn classify_payment_required(metadata: &InferenceErrorMetadata) -> GrokInference
fn classify_forbidden(
metadata: &InferenceErrorMetadata,
body: &[u8],
body_failure: Option<GrokBodyFailure>,
) -> GrokInferenceTransportErrorKind {
let text = inference_metadata_text(metadata);
let body_text = String::from_utf8_lossy(body).to_ascii_lowercase();
if safety_rejected(&text) || safety_rejected(&body_text) {
if account_access_rejected(&text) || account_access_rejected(&body_text) {
GrokInferenceTransportErrorKind::PermissionDenied
} else if safety_rejected(&text) || safety_rejected(&body_text) {
GrokInferenceTransportErrorKind::SafetyRejected
} else if credential_rejected(&text) {
GrokInferenceTransportErrorKind::Unauthorized
} else if let Some(failure) = body_failure {
failure.transport_kind()
} else if let Some(failure) = quota_failure(metadata) {
quota_transport_error_kind(failure)
} else if model_access_denied(metadata) {
@@ -1117,10 +1248,27 @@ fn classify_too_many_requests(
}
fn reasoning_decode_failed(metadata: &InferenceErrorMetadata) -> bool {
metadata.message.as_deref().is_some_and(|message| {
message.contains("could not decode the compaction blob")
|| message.contains("could not decrypt the provided encrypted_content")
})
let code = metadata.code.as_deref().map(str::trim).unwrap_or_default();
let message = metadata
.message
.as_deref()
.map(str::trim)
.unwrap_or_default()
.to_ascii_lowercase();
if message.contains("could not decode the compaction blob") {
return true;
}
if code.eq_ignore_ascii_case("invalid_encrypted_content") {
return !message.is_empty();
}
if !code.is_empty() && !code.eq_ignore_ascii_case("invalid-argument") {
return false;
}
if code.is_empty() && !message.contains("decrypt") {
return false;
}
message.contains("encrypted_content")
&& (message.contains("decrypt") || message.contains("unmodified"))
}
fn quota_failure(metadata: &InferenceErrorMetadata) -> Option<GrokQuotaFailureKind> {
@@ -1152,7 +1300,48 @@ fn model_access_denied(metadata: &InferenceErrorMetadata) -> bool {
fn safety_rejected(text: &str) -> bool {
contains_any(
text,
&["content violates usage guidelines", "safety_check_type_"],
&[
"content violates usage guidelines",
"safety_check_type_",
"content_filter",
"content_policy",
"content moderation",
"cyber_policy",
"new_sensitive",
"moderation feature is not available",
"image is sensitive",
"text is sensitive",
"prohibited content",
"forbidden content",
"request blocked by policy",
"request rejected by policy",
"request violates policy",
"prompt violates policy",
"input violates policy",
],
)
}
fn account_access_rejected(text: &str) -> bool {
contains_any(
text,
&[
"account_suspended",
"account_disabled",
"user_suspended",
"user_disabled",
"subscription_required",
"entitlement_required",
"not_entitled",
"plan_required",
"permission_denied",
"account suspended",
"account disabled",
"user suspended",
"subscription required",
"entitlement required",
"not entitled",
],
)
}
File diff suppressed because it is too large Load Diff
@@ -347,6 +347,14 @@ pub enum GrokCredentialFailure {
upstream_model: UpstreamModelId,
retry_after: Option<Duration>,
},
/// 非内容策略的 403:当前账号的访问资格暂不可用。
AccessDenied,
/// 上游明确报告模型容量不足;重模型按模型隔离,其余账号短暂冷却。
ModelCapacity { upstream_model: UpstreamModelId },
/// 上游返回空模型输出。
EmptyUpstream,
/// 上游 5xx/529 暂时不可用。
UpstreamUnavailable,
/// 成功的 SSE 响应在终止 Responses 事件前结束。
StreamInterrupted,
}
+25 -4
View File
@@ -27,10 +27,11 @@ use gateway_core::policy::ClientApiKeyId;
use gateway_core::provider_ports::{
NewOAuthPendingFlow, OAuthPendingBinding, OAuthPendingClaimOutcome, OAuthPendingConsumeOutcome,
OAuthPendingFlowPort, OAuthPendingPutOutcome, OAuthPendingReleaseOutcome,
ProviderCatalogCacheKey, ProviderCatalogCachePort, ProviderCooldown, ProviderCooldownPort,
ProviderCooldownScope, ProviderCredentialState, ProviderCredentialStatePort,
ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest, ProviderRefreshPolicy,
ProviderRuntimePolicyPort, ProviderScopedCooldown, ProviderStoreError, ProviderStorePorts,
ProviderArtifactProfile, ProviderArtifactProfileCachePort, ProviderCatalogCacheKey,
ProviderCatalogCachePort, ProviderCooldown, ProviderCooldownPort, ProviderCooldownScope,
ProviderCredentialState, ProviderCredentialStatePort, ProviderLeaseAcquisition,
ProviderLeasePort, ProviderLeaseRequest, ProviderRefreshPolicy, ProviderRuntimePolicyPort,
ProviderScopedCooldown, ProviderStoreError, ProviderStorePorts,
};
use gateway_core::routing::{ProviderKind, UpstreamModelId};
use gateway_core::task::{
@@ -448,6 +449,7 @@ fn provider_ports_with_catalog(
Arc::new(TestSessionAffinity),
Arc::new(TestSessionExclusions),
catalog_cache,
Arc::new(TestArtifactProfiles),
Arc::new(TestCredentialState),
Arc::new(TestCooldown),
Arc::new(TestRuntimePolicy),
@@ -529,6 +531,25 @@ impl ProviderLeasePort for TestLeases {
}
}
struct TestArtifactProfiles;
impl ProviderArtifactProfileCachePort for TestArtifactProfiles {
fn replace_if_newer(
&self,
_profile: ProviderArtifactProfile,
_ttl: Duration,
) -> BoxFuture<'_, Result<bool, ProviderStoreError>> {
Box::pin(async { Ok(true) })
}
fn read<'a>(
&'a self,
_provider_kind: &'a ProviderKind,
) -> BoxFuture<'a, Result<Option<ProviderArtifactProfile>, ProviderStoreError>> {
Box::pin(async { Ok(None) })
}
}
#[derive(Default)]
struct TestCatalogCache {
values: Mutex<BTreeMap<String, OpaqueProviderData>>,
@@ -3,6 +3,8 @@ use provider_xai::{
PendingAuthorization, RedirectUriAllowlist,
};
const REDIRECT_URI: &str = "https://gateway.example/admin/xai/callback";
#[test]
fn callback_should_reject_duplicate_state() {
let result = AuthorizationCallback::parse("code=fake&state=one&state=two");
@@ -28,10 +30,9 @@ fn pending_authorization_should_round_trip_only_through_server_state() {
let config = GrokOAuthConfig::official().expect("fixture config");
let discovery = DiscoveryDocument::parse(&config, include_bytes!("fixtures/discovery.json"))
.expect("fixture discovery");
let redirect = "https://gateway.example/admin/xai/callback";
let allowed = RedirectUriAllowlist::new([redirect])
let allowed = RedirectUriAllowlist::new([REDIRECT_URI])
.expect("redirect allowlist")
.authorize(redirect)
.authorize(REDIRECT_URI)
.expect("allowlisted redirect");
let pending = PendingAuthorization::start(&config, &discovery, allowed, None)
.expect("start pending flow");
@@ -61,3 +62,121 @@ fn pending_authorization_should_round_trip_only_through_server_state() {
.accept_callback(callback)
.expect("restored state should validate callback");
}
#[test]
fn authorization_input_should_accept_callback_query_and_bare_code() {
let (pending, state) = pending_authorization();
pending
.accept_authorization_input(&format!("{REDIRECT_URI}?code=full-url-code&state={state}"))
.expect("official callback URL");
let (pending, state) = pending_authorization();
pending
.accept_authorization_input(&format!("?code=query-code&state={state}"))
.expect("callback query");
let (pending, state) = pending_authorization();
pending
.accept_authorization_input(&format!("code=query-code&state={state}"))
.expect("callback query without question mark");
let (pending, _) = pending_authorization();
pending
.accept_authorization_input(" bare-code ")
.expect("bare authorization code");
for code in [
"opaque&state=still-code",
"opaque&code=still-code",
"opaque-error=value",
"opaque://still-code",
] {
let (pending, _) = pending_authorization();
pending
.accept_authorization_input(code)
.expect("opaque bare authorization code");
}
}
#[test]
fn authorization_input_should_keep_url_state_and_code_validation_strict() {
let (pending, state) = pending_authorization();
pending
.accept_authorization_input(&format!(
"https://attacker.invalid/callback?code=code&state={state}"
))
.expect_err("foreign callback host");
let (pending, _) = pending_authorization();
pending
.accept_authorization_input(&format!("{REDIRECT_URI}?code=code&state=wrong-state"))
.expect_err("callback state mismatch");
let (pending, state) = pending_authorization();
pending
.accept_authorization_input(&format!("{REDIRECT_URI}?code=code&state={state}#fragment"))
.expect_err("callback fragment");
let (pending, _) = pending_authorization();
pending
.accept_authorization_input("https:// malformed callback")
.expect_err("malformed callback URL");
let (pending, _) = pending_authorization();
pending
.accept_authorization_input("//attacker.invalid/callback?code=code")
.expect_err("scheme-relative callback URL");
let (pending, _) = pending_authorization();
pending
.accept_authorization_input("?unrelated=value")
.expect_err("query input without code or state");
let (pending, _) = pending_authorization();
pending
.accept_authorization_input("code=query-code")
.expect_err("query input without state");
let (pending, state) = pending_authorization();
pending
.accept_authorization_input(&format!("code=one&code=two&state={state}"))
.expect_err("duplicate code");
let (pending, _) = pending_authorization();
pending
.accept_authorization_input("code-with\n-control")
.expect_err("control character");
let (pending, state) = pending_authorization();
pending
.accept_authorization_input(&format!("?code=line%0Abreak&state={state}"))
.expect_err("percent-decoded control character");
let (pending, _) = pending_authorization();
pending
.accept_authorization_input(&"x".repeat(64 * 1024))
.expect("maximum-size code");
let (pending, _) = pending_authorization();
pending
.accept_authorization_input(&"x".repeat(64 * 1024 + 1))
.expect_err("oversized code");
}
fn pending_authorization() -> (PendingAuthorization, String) {
let config = GrokOAuthConfig::official().expect("fixture config");
let discovery = DiscoveryDocument::parse(&config, include_bytes!("fixtures/discovery.json"))
.expect("fixture discovery");
let redirect = RedirectUriAllowlist::new([REDIRECT_URI])
.expect("redirect allowlist")
.authorize(REDIRECT_URI)
.expect("allowlisted redirect");
let pending = PendingAuthorization::start(&config, &discovery, redirect, None)
.expect("start pending flow");
let state = pending
.authorization_url()
.query_pairs()
.find_map(|(name, value)| (name == "state").then(|| value.into_owned()))
.expect("authorization state");
(pending, state)
}
@@ -560,7 +560,7 @@ async fn model_quota_feedback_writes_model_scoped_cooldown_without_blocking_othe
let account = fixture.store.account(&selected).expect("selected account");
assert_eq!(account.credential_state(), CredentialState::Ready);
let minimum_until = SystemTime::now() + Duration::from_secs(23 * 60 * 60);
let minimum_until = SystemTime::now() + Duration::from_secs(9 * 60);
let scope = ProviderCooldownScope::upstream_model(failed_model);
// model-scoped cooldown:目标模型被排除,账号级无 cooldown。
assert!(
@@ -42,7 +42,7 @@ use provider_xai::{
GrokSessionSelection, GrokSessionSelector, GrokSessionSelectorError, GrokSessionSelectorFuture,
SecretValue, SelectedGrokSession,
};
use serde_json::{Map, json};
use serde_json::{Map, Value, json};
use crate::support::{
MemoryGrokCatalogCache, MemoryProviderAccountStore, account_id, create_input, seed_input,
@@ -208,6 +208,8 @@ fn compaction_sse(summary: &str, reasoning: Option<&str>) -> Vec<u8> {
"event: response.created\n",
"data: {{\"type\":\"response.created\",\"response\":{{\"id\":\"resp_compaction\",\"model\":\"grok-4.5\"}}}}\n\n",
"{reasoning}",
"event: response.output_item.done\n",
"data: {{\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{{\"id\":\"reason_compaction\",\"type\":\"reasoning\",\"status\":\"completed\",\"summary\":[],\"encrypted_content\":\"grok-compaction-ciphertext\"}}}}\n\n",
"event: response.content_part.added\n",
"data: {{\"type\":\"response.content_part.added\",\"output_index\":1,\"content_index\":0,\"part\":{{\"type\":\"output_text\"}}}}\n\n",
"event: response.output_text.delta\n",
@@ -221,7 +223,53 @@ fn compaction_sse(summary: &str, reasoning: Option<&str>) -> Vec<u8> {
.into_bytes()
}
fn terminal_only_compaction_sse(summary: &str) -> Vec<u8> {
let summary = serde_json::to_string(summary).expect("summary JSON string");
format!(
concat!(
"event: response.created\n",
"data: {{\"type\":\"response.created\",\"response\":{{\"id\":\"resp_compaction\",\"model\":\"grok-4.5\"}}}}\n\n",
"event: response.completed\n",
"data: {{\"type\":\"response.completed\",\"response\":{{\"id\":\"resp_compaction\",\"model\":\"grok-4.5\",\"status\":\"completed\",\"output\":[{{\"id\":\"reason_compaction\",\"type\":\"reasoning\",\"status\":\"completed\",\"encrypted_content\":\"grok-compaction-ciphertext\"}},{{\"id\":\"msg_compaction\",\"type\":\"message\",\"status\":\"completed\",\"role\":\"assistant\",\"content\":[{{\"type\":\"output_text\",\"text\":{summary}}}]}}],\"usage\":{{\"input_tokens\":100,\"output_tokens\":20,\"total_tokens\":120}}}}}}\n\n",
),
summary = summary,
)
.into_bytes()
}
fn incomplete_compaction_sse(summary: &str) -> Vec<u8> {
let summary = serde_json::to_string(summary).expect("summary JSON string");
format!(
concat!(
"event: response.created\n",
"data: {{\"type\":\"response.created\",\"response\":{{\"id\":\"resp_compaction\",\"model\":\"grok-4.5\"}}}}\n\n",
"event: response.incomplete\n",
"data: {{\"type\":\"response.incomplete\",\"response\":{{\"id\":\"resp_compaction\",\"model\":\"grok-4.5\",\"status\":\"incomplete\",\"incomplete_details\":{{\"reason\":\"max_output_tokens\"}},\"output_text\":{summary},\"output\":[{{\"id\":\"reason_compaction\",\"type\":\"reasoning\",\"status\":\"completed\",\"encrypted_content\":\"grok-compaction-ciphertext\"}},{{\"id\":\"msg_compaction\",\"type\":\"message\",\"status\":\"incomplete\",\"role\":\"assistant\",\"content\":[{{\"type\":\"output_text\",\"text\":{summary}}}]}}],\"usage\":{{\"input_tokens\":100,\"output_tokens\":20,\"total_tokens\":120}}}}}}\n\n",
),
summary = summary,
)
.into_bytes()
}
fn compaction_sse_without_terminal(summary: &str) -> Vec<u8> {
let summary = serde_json::to_string(summary).expect("summary JSON string");
format!(
concat!(
"event: response.created\n",
"data: {{\"type\":\"response.created\",\"response\":{{\"id\":\"resp_compaction\",\"model\":\"grok-4.5\"}}}}\n\n",
"event: response.output_item.done\n",
"data: {{\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{{\"id\":\"reason_compaction\",\"type\":\"reasoning\",\"status\":\"completed\",\"summary\":[],\"encrypted_content\":\"grok-compaction-ciphertext\"}}}}\n\n",
"event: response.content_part.added\n",
"data: {{\"type\":\"response.content_part.added\",\"output_index\":0,\"content_index\":0,\"part\":{{\"type\":\"output_text\"}}}}\n\n",
"event: response.output_text.delta\n",
"data: {{\"type\":\"response.output_text.delta\",\"output_index\":0,\"content_index\":0,\"delta\":{summary}}}\n\n",
),
summary = summary,
)
.into_bytes()
}
fn compaction_sse_without_encrypted_content(summary: &str) -> Vec<u8> {
let summary = serde_json::to_string(summary).expect("summary JSON string");
format!(
concat!(
@@ -231,6 +279,8 @@ fn compaction_sse_without_terminal(summary: &str) -> Vec<u8> {
"data: {{\"type\":\"response.content_part.added\",\"output_index\":0,\"content_index\":0,\"part\":{{\"type\":\"output_text\"}}}}\n\n",
"event: response.output_text.delta\n",
"data: {{\"type\":\"response.output_text.delta\",\"output_index\":0,\"content_index\":0,\"delta\":{summary}}}\n\n",
"event: response.completed\n",
"data: {{\"type\":\"response.completed\",\"response\":{{\"id\":\"resp_compaction\",\"model\":\"grok-4.5\",\"status\":\"completed\"}}}}\n\n",
),
summary = summary,
)
@@ -260,6 +310,7 @@ struct StubSelector {
feedback: Mutex<Vec<GrokCredentialFailure>>,
error: Mutex<Option<GrokSessionSelectorError>>,
required_accounts: Mutex<Vec<Option<gateway_core::engine::credential::ProviderAccountId>>>,
upstream_models: Mutex<Vec<String>>,
}
impl StubSelector {
@@ -269,6 +320,7 @@ impl StubSelector {
feedback: Mutex::new(Vec::new()),
error: Mutex::new(None),
required_accounts: Mutex::new(Vec::new()),
upstream_models: Mutex::new(Vec::new()),
})
}
@@ -278,6 +330,7 @@ impl StubSelector {
feedback: Mutex::new(Vec::new()),
error: Mutex::new(Some(error)),
required_accounts: Mutex::new(Vec::new()),
upstream_models: Mutex::new(Vec::new()),
})
}
}
@@ -285,6 +338,10 @@ impl StubSelector {
impl GrokSessionSelector for StubSelector {
fn select(&self, request: GrokSessionSelection) -> GrokSessionSelectorFuture<'_> {
self.calls.fetch_add(1, Ordering::SeqCst);
self.upstream_models
.lock()
.expect("upstream models")
.push(request.upstream_model().as_str().to_owned());
let error = self.error.lock().expect("selector error").take();
let required_account = request.required_account().cloned();
self.required_accounts
@@ -630,6 +687,70 @@ async fn mapped_transport_error(
next_provider_error(&mut stream).await
}
#[tokio::test]
async fn invalid_encrypted_reasoning_retries_once_on_the_same_account_without_ciphertext() {
let transport = StubInferenceTransport::sequence([
InferenceMode::Error(
GrokInferenceTransportError::new(
GrokInferenceTransportErrorKind::InvalidRequest,
UpstreamSendState::Sent,
)
.with_status(400)
.with_upstream_code(OpaqueUpstreamValue::new("reasoning_decode_failed")),
),
InferenceMode::Success,
]);
let provider = provider(StubSelector::success(), transport.clone()).await;
let operation = Operation::Generate(GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object(
"openai",
Map::from_iter([
("model".to_owned(), json!("client-model")),
(
"input".to_owned(),
json!([
{
"type": "reasoning",
"id": "reason_stale",
"summary": [{"type": "summary_text", "text": "keep summary"}],
"content": null,
"encrypted_content": "stale-ciphertext"
},
{"type": "message", "role": "user", "content": "continue"}
]),
),
]),
)
.expect("OpenAI payload"),
));
let mut stream = provider
.execute(
provider_request_with_operation("xai", operation),
context(CancellationToken::new(), None),
)
.await
.expect("retrying stream");
while let Some(event) = stream.next().await {
event.expect("recovered response");
}
let requests = transport.requests.lock().expect("requests");
assert_eq!(requests.len(), 2);
let first: serde_json::Value = serde_json::from_slice(requests[0].body()).expect("first body");
let second: serde_json::Value = serde_json::from_slice(requests[1].body()).expect("retry body");
assert_eq!(
first.pointer("/input/0/encrypted_content"),
Some(&json!("stale-ciphertext"))
);
assert_eq!(second.pointer("/input/0/encrypted_content"), None);
assert_eq!(second.pointer("/input/0/content"), None);
assert_eq!(
second.pointer("/input/0/summary/0/text"),
Some(&json!("keep summary"))
);
assert_eq!(requests[0].binding(), requests[1].binding());
}
async fn next_provider_error(
stream: &mut gateway_core::engine::provider::ProviderStream,
) -> ProviderError {
@@ -803,10 +924,18 @@ fn provider_request(provider_kind: &str) -> ProviderRequest {
}
fn provider_request_with_operation(provider_kind: &str, operation: Operation) -> ProviderRequest {
provider_request_with_upstream_model(provider_kind, MODEL, operation)
}
fn provider_request_with_upstream_model(
provider_kind: &str,
upstream_model: &str,
operation: Operation,
) -> ProviderRequest {
let provider = ProviderKind::new(provider_kind).expect("provider");
let provider_model = ProviderModel::new(
provider.clone(),
UpstreamModelId::new(MODEL).expect("model"),
UpstreamModelId::new(upstream_model).expect("model"),
ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), Some(131_072))
.with_feature(Feature::Tools, SupportLevel::Native)
.with_feature(Feature::NativeContinuation, SupportLevel::Native),
@@ -828,7 +957,7 @@ fn provider_request_with_operation(provider_kind: &str, operation: Operation) ->
.expect("snapshot");
let plan = snapshot
.plan(
&PublicModelId::new(MODEL).expect("model"),
&PublicModelId::new(upstream_model).expect("model"),
&operation,
account_scope,
&RoutingContext::default(),
@@ -904,6 +1033,31 @@ async fn execute_successfully(provider: &GrokBuildProvider, operation: Operation
assert!(events.iter().all(Result::is_ok));
}
#[tokio::test]
async fn model_alias_should_be_canonical_before_account_selection() {
let selector = StubSelector::success();
let provider = provider(selector.clone(), StubInferenceTransport::success()).await;
let mut stream = provider
.execute(
provider_request_with_upstream_model("xai", "grok-4.6-latest", operation()),
context(CancellationToken::new(), None),
)
.await
.expect("provider stream");
while let Some(event) = stream.next().await {
event.expect("provider event");
}
assert_eq!(
selector
.upstream_models
.lock()
.expect("upstream models")
.as_slice(),
["grok-4.6"]
);
}
#[tokio::test]
async fn request_observation_preserves_raw_xai_reasoning_effort() {
let provider = provider(StubSelector::success(), StubInferenceTransport::success()).await;
@@ -1125,6 +1279,145 @@ async fn compaction_stream_should_emit_openai_wire_and_keep_only_accounting_cano
);
}
#[tokio::test]
async fn compaction_should_use_terminal_output_when_upstream_omits_text_deltas() {
let summary = valid_compaction_summary("terminal-only summary");
let transport = StubInferenceTransport::sequence([InferenceMode::SuccessBody(
terminal_only_compaction_sse(&summary),
)]);
let provider = provider(StubSelector::success(), transport).await;
let mut stream = provider
.execute(
provider_request_with_operation("xai", compaction_operation()),
context(CancellationToken::new(), None),
)
.await
.expect("compaction stream");
let events = stream.by_ref().collect::<Vec<_>>().await;
let compact_item = events
.iter()
.map(|event| event.as_ref().expect("successful compaction event"))
.filter_map(|event| event.wire_event())
.find(|wire| wire.event_type() == Some("response.output_item.done"))
.and_then(|wire| wire.data().get("item"))
.expect("compaction output item");
assert_eq!(
compact_item.pointer("/summary/0/text"),
Some(&Value::String(summary))
);
}
#[tokio::test]
async fn compaction_should_complete_incomplete_upstream_when_ciphertext_is_present() {
let summary = valid_compaction_summary("incomplete upstream summary");
let transport = StubInferenceTransport::sequence([InferenceMode::SuccessBody(
incomplete_compaction_sse(&summary),
)]);
let provider = provider(StubSelector::success(), transport).await;
let mut stream = provider
.execute(
provider_request_with_operation("xai", compaction_operation()),
context(CancellationToken::new(), None),
)
.await
.expect("compaction stream");
let events = stream.by_ref().collect::<Vec<_>>().await;
let terminal = events
.iter()
.map(|event| event.as_ref().expect("successful compaction event"))
.filter_map(|event| event.wire_event())
.find(|wire| wire.event_type() == Some("response.completed"))
.expect("completed compaction response");
let response = terminal
.data()
.get("response")
.expect("terminal response object");
assert_eq!(
terminal.data().get("type"),
Some(&json!("response.completed"))
);
assert_eq!(response.get("status"), Some(&json!("completed")));
assert_eq!(response.get("incomplete_details"), Some(&Value::Null));
assert!(
!response
.as_object()
.expect("response object")
.contains_key("output_text")
);
assert_eq!(
response.pointer("/output/0/encrypted_content"),
Some(&json!("grok-compaction-ciphertext"))
);
}
#[tokio::test]
async fn compaction_invalid_encrypted_reasoning_should_retry_once_on_the_same_account() {
let transport = StubInferenceTransport::sequence([
InferenceMode::Error(
GrokInferenceTransportError::new(
GrokInferenceTransportErrorKind::InvalidRequest,
UpstreamSendState::Sent,
)
.with_status(400)
.with_upstream_code(OpaqueUpstreamValue::new("reasoning_decode_failed")),
),
InferenceMode::SuccessBody(compaction_sse(
&valid_compaction_summary("recovered compaction"),
None,
)),
]);
let provider = provider(StubSelector::success(), transport.clone()).await;
let payload = ProtocolPayload::json_object(
"openai",
object(json!({
"model": "client-model",
"input": [
{
"type": "reasoning",
"summary": [{"type": "summary_text", "text": "keep summary"}],
"content": null,
"encrypted_content": "stale-compaction-ciphertext"
},
{"type": "message", "role": "user", "content": "history"},
{"type": "compaction_trigger"}
],
"stream": true
})),
)
.expect("OpenAI payload");
let mut stream = provider
.execute(
provider_request_with_operation(
"xai",
Operation::Generate(GenerateRequest::from_protocol_payload(payload)),
),
context(CancellationToken::new(), None),
)
.await
.expect("retrying compaction stream");
while let Some(event) = stream.next().await {
event.expect("recovered compaction response");
}
let requests = transport.requests.lock().expect("requests");
assert_eq!(requests.len(), 2);
let first: serde_json::Value = serde_json::from_slice(requests[0].body()).expect("first body");
let second: serde_json::Value = serde_json::from_slice(requests[1].body()).expect("retry body");
assert_eq!(
first.pointer("/input/0/encrypted_content"),
Some(&json!("stale-compaction-ciphertext"))
);
assert_eq!(second.pointer("/input/0/encrypted_content"), None);
assert_eq!(second.pointer("/input/0/content"), None);
assert_eq!(
second.pointer("/input/0/summary/0/text"),
Some(&json!("keep summary"))
);
assert_eq!(requests[0].binding(), requests[1].binding());
}
#[tokio::test]
async fn compaction_should_pin_recorded_account_and_forward_session_headers() {
let state = ProviderSessionState::new(
@@ -1180,7 +1473,7 @@ async fn compaction_should_pin_recorded_account_and_forward_session_headers() {
}
#[tokio::test]
async fn compaction_should_accept_summary_when_upstream_stream_ends_without_terminal() {
async fn compaction_should_reject_upstream_stream_that_ends_without_terminal() {
let transport = StubInferenceTransport::sequence([InferenceMode::SuccessBody(
compaction_sse_without_terminal(&valid_compaction_summary("usable eof summary")),
)]);
@@ -1192,32 +1485,10 @@ async fn compaction_should_accept_summary_when_upstream_stream_ends_without_term
)
.await
.expect("compaction stream");
let events = stream
.by_ref()
.map(|event| event.expect("successful compaction event"))
.collect::<Vec<_>>()
.await;
let facts = events
.iter()
.flat_map(|event| event.canonical_facts())
.collect::<Vec<_>>();
let wire_types = events
.iter()
.filter_map(|event| event.wire_event().and_then(|wire| wire.event_type()))
.collect::<Vec<_>>();
let error = next_provider_error(&mut stream).await;
assert!(matches!(
facts.as_slice(),
[GatewayEvent::Started(_), GatewayEvent::Completed(_)]
));
assert_eq!(
wire_types,
[
"response.created",
"response.output_item.done",
"response.completed"
]
);
assert_eq!(error.kind(), ProviderErrorKind::Protocol);
assert!(error.allows_pre_delivery_retry());
}
#[tokio::test]
@@ -1236,21 +1507,34 @@ async fn compaction_wire_should_exclude_reasoning_from_summary_content() {
.await
.expect("compaction stream");
let events = stream.by_ref().collect::<Vec<_>>().await;
let summary = events
let item = events
.iter()
.map(|event| event.as_ref().expect("successful compaction event"))
.filter_map(|event| event.wire_event())
.find(|wire| wire.event_type() == Some("response.output_item.done"))
.and_then(|wire| wire.data().pointer("/item/encrypted_content"))
.and_then(serde_json::Value::as_str)
.and_then(|wire| wire.data().get("item"))
.expect("compaction output wire");
let summary = item
.pointer("/summary/0/text")
.and_then(serde_json::Value::as_str)
.expect("visible compaction summary");
assert!(summary.contains("continuation marker"));
assert!(!summary.contains("private reasoning"));
assert_eq!(
item.get("encrypted_content"),
Some(&json!("grok-compaction-ciphertext"))
);
assert_eq!(item.get("status"), Some(&json!("completed")));
assert!(
item.get("id")
.and_then(serde_json::Value::as_str)
.is_some_and(|id| id.starts_with("cmp_"))
);
}
#[tokio::test]
async fn compaction_stream_should_reject_degenerate_summary_before_commit() {
async fn compaction_stream_should_accept_short_summary_like_sub2api() {
let transport = StubInferenceTransport::sequence([InferenceMode::SuccessBody(compaction_sse(
"<summary>too short</summary>",
None,
@@ -1264,12 +1548,77 @@ async fn compaction_stream_should_reject_degenerate_summary_before_commit() {
.await
.expect("compaction stream");
let events = stream
.by_ref()
.map(|event| event.expect("successful compaction event"))
.collect::<Vec<_>>()
.await;
let item = events
.iter()
.filter_map(|event| event.wire_event())
.find(|wire| wire.event_type() == Some("response.output_item.done"))
.and_then(|wire| wire.data().get("item"))
.expect("compaction item");
assert_eq!(
item.pointer("/summary/0/text"),
Some(&json!("<summary>too short</summary>"))
);
assert_eq!(
item.get("encrypted_content"),
Some(&json!("grok-compaction-ciphertext"))
);
}
#[tokio::test]
async fn compaction_stream_should_allow_ciphertext_without_visible_summary() {
let transport =
StubInferenceTransport::sequence([InferenceMode::SuccessBody(compaction_sse(" ", None))]);
let provider = provider(StubSelector::success(), transport).await;
let mut stream = provider
.execute(
provider_request_with_operation("xai", compaction_operation()),
context(CancellationToken::new(), None),
)
.await
.expect("compaction stream");
let events = stream
.by_ref()
.map(|event| event.expect("successful compaction event"))
.collect::<Vec<_>>()
.await;
let item = events
.iter()
.filter_map(|event| event.wire_event())
.find(|wire| wire.event_type() == Some("response.output_item.done"))
.and_then(|wire| wire.data().get("item"))
.expect("compaction item");
assert_eq!(item.get("summary"), None);
assert_eq!(
item.get("encrypted_content"),
Some(&json!("grok-compaction-ciphertext"))
);
}
#[tokio::test]
async fn compaction_stream_should_require_real_reasoning_ciphertext_before_commit() {
let transport = StubInferenceTransport::sequence([InferenceMode::SuccessBody(
compaction_sse_without_encrypted_content(&valid_compaction_summary("visible summary")),
)]);
let provider = provider(StubSelector::success(), transport).await;
let mut stream = provider
.execute(
provider_request_with_operation("xai", compaction_operation()),
context(CancellationToken::new(), None),
)
.await
.expect("compaction stream");
let error = next_provider_error(&mut stream).await;
assert_eq!(error.kind(), ProviderErrorKind::Protocol);
assert!(!error.replay_is_safe());
assert!(error.allows_pre_delivery_retry());
assert!(!error.retries_same_account());
}
#[tokio::test]
@@ -1701,7 +2050,7 @@ async fn explicit_http_408_does_not_mark_provider_error_replay_safe() {
}
#[tokio::test]
async fn generic_http_403_does_not_mutate_or_switch_credential() {
async fn generic_http_403_cools_account_and_allows_next_account_replay() {
let selector = StubSelector::success();
let transport = StubInferenceTransport::error(
GrokInferenceTransportError::new(
@@ -1721,14 +2070,17 @@ async fn generic_http_403_does_not_mutate_or_switch_credential() {
let error = next_provider_error(&mut stream).await;
assert_eq!(error.kind(), ProviderErrorKind::PermissionDenied);
assert!(!error.replay_is_safe());
assert!(error.replay_is_safe());
assert_eq!(selector.calls.load(Ordering::SeqCst), 1);
assert_eq!(transport.calls.load(Ordering::SeqCst), 1);
assert!(selector.feedback.lock().expect("feedback").is_empty());
assert_eq!(
selector.feedback.lock().expect("feedback").as_slice(),
&[GrokCredentialFailure::AccessDenied]
);
}
#[tokio::test]
async fn generic_http_500_does_not_mark_provider_error_replay_safe() {
async fn generic_http_500_allows_next_account_replay() {
let error = mapped_transport_error(
GrokInferenceTransportError::new(
GrokInferenceTransportErrorKind::Unavailable,
@@ -1740,7 +2092,64 @@ async fn generic_http_500_does_not_mark_provider_error_replay_safe() {
.await;
assert_eq!(error.kind(), ProviderErrorKind::Unavailable);
assert!(error.replay_is_safe());
}
#[tokio::test]
async fn rate_limit_wording_on_http_400_stays_request_scoped() {
let selector = StubSelector::success();
let transport = StubInferenceTransport::error(
GrokInferenceTransportError::new(
GrokInferenceTransportErrorKind::InvalidRequest,
UpstreamSendState::Sent,
)
.with_status(400)
.with_upstream_code(OpaqueUpstreamValue::new("rate_limit")),
);
let provider = provider(selector.clone(), transport).await;
let mut stream = provider
.execute(
provider_request("xai"),
context(CancellationToken::new(), None),
)
.await
.expect("stream");
let error = next_provider_error(&mut stream).await;
assert_eq!(error.kind(), ProviderErrorKind::InvalidRequest);
assert!(!error.replay_is_safe());
assert!(selector.feedback.lock().expect("feedback").is_empty());
}
#[tokio::test]
async fn model_capacity_on_http_400_cools_scope_and_allows_replay() {
let selector = StubSelector::success();
let transport = StubInferenceTransport::error(
GrokInferenceTransportError::new(
GrokInferenceTransportErrorKind::RateLimited,
UpstreamSendState::Sent,
)
.with_status(400)
.with_upstream_code(OpaqueUpstreamValue::new("model_capacity")),
);
let provider = provider(selector.clone(), transport).await;
let mut stream = provider
.execute(
provider_request("xai"),
context(CancellationToken::new(), None),
)
.await
.expect("stream");
let error = next_provider_error(&mut stream).await;
assert_eq!(error.kind(), ProviderErrorKind::RateLimited);
assert!(error.replay_is_safe());
assert_eq!(
selector.feedback.lock().expect("feedback").as_slice(),
&[GrokCredentialFailure::ModelCapacity {
upstream_model: UpstreamModelId::new("grok-4.5").expect("model"),
}]
);
}
#[tokio::test]
+42 -9
View File
@@ -9,10 +9,10 @@ use async_trait::async_trait;
use chrono::Utc;
use gateway_core::engine::credential::{
AccountConcurrencyLimit, AccountErrorReason, AccountStateChange, AccountWeight,
CredentialCasOutcome, CredentialCasUpdate, CredentialRevision, CredentialState,
LoadedCredential, NewProviderAccount, PlaintextCredential, ProviderAccount, ProviderAccountId,
ProviderAccountStore, ProviderAccountUpdate, QuotaAccessChange, QuotaObservation, QuotaState,
QuotaWriteOutcome,
CredentialCasOutcome, CredentialCasUpdate, CredentialCasUpdateParts, CredentialRevision,
CredentialState, LoadedCredential, NewProviderAccount, PlaintextCredential, ProviderAccount,
ProviderAccountId, ProviderAccountStore, ProviderAccountUpdate, QuotaAccessChange,
QuotaObservation, QuotaObservationTouch, QuotaState, QuotaWriteOutcome,
};
use gateway_core::error::{StoreError, StoreErrorKind};
use gateway_core::provider_ports::{
@@ -327,7 +327,7 @@ impl ProviderAccountStore for MemoryProviderAccountStore {
&self,
update: CredentialCasUpdate,
) -> Result<CredentialCasOutcome, StoreError> {
let (
let CredentialCasUpdateParts {
account_id,
expected_revision,
profile,
@@ -335,22 +335,36 @@ impl ProviderAccountStore for MemoryProviderAccountStore {
has_refresh_token,
access_token_expires_at,
next_refresh_at,
) = update.into_parts();
account_state,
} = update.into_parts();
let mut accounts = lock(&self.accounts);
let stored = accounts.get_mut(&account_id).ok_or_else(invalid)?;
if stored.account.revision() != expected_revision {
return Ok(CredentialCasOutcome::Conflict);
}
let next = expected_revision.next().map_err(|_| invalid())?;
let credential_state = account_state
.as_ref()
.map_or(stored.account.credential_state(), |state| {
state.credential_state
});
let last_error_reason = account_state.as_ref().map_or_else(
|| stored.account.last_error_reason(),
|state| state.error_reason,
);
let last_error_message = account_state.as_ref().map_or_else(
|| stored.account.last_error_message().map(str::to_owned),
|state| state.message.clone(),
);
stored.account = rebuild_account(
&stored.account,
AccountReplacement {
revision: next,
access_token_expires_at,
credential_state: stored.account.credential_state(),
credential_state,
quota: stored.account.quota(),
last_error_reason: stored.account.last_error_reason(),
last_error_message: stored.account.last_error_message().map(str::to_owned),
last_error_reason,
last_error_message,
enabled: stored.account.enabled(),
has_refresh_token,
next_refresh_at,
@@ -396,6 +410,25 @@ impl ProviderAccountStore for MemoryProviderAccountStore {
Ok(QuotaWriteOutcome::Updated)
}
async fn touch_quota_observation(
&self,
touch: QuotaObservationTouch,
) -> Result<QuotaWriteOutcome, StoreError> {
let mut accounts = lock(&self.accounts);
let stored = accounts.get_mut(&touch.account_id).ok_or_else(invalid)?;
if stored.account.revision() != touch.expected_revision {
return Ok(QuotaWriteOutcome::Conflict);
}
let Some(quota) = &mut stored.quota else {
return Ok(QuotaWriteOutcome::Conflict);
};
if quota.observed_at > touch.observed_at {
return Ok(QuotaWriteOutcome::Conflict);
}
quota.observed_at = touch.observed_at;
Ok(QuotaWriteOutcome::Updated)
}
async fn apply_quota_access(
&self,
change: QuotaAccessChange,
@@ -2,6 +2,7 @@ use gateway_core::error::ProviderError;
use gateway_core::event::{ContentKind, FinishReason, GatewayEvent, ProviderEvent};
use gateway_core::operation::{GenerateRequest, ProtocolPayload};
use gateway_core::policy::ClientApiKeyId;
use gateway_protocol::openai::sse::{encode_sse_event, encode_sse_event_with_metadata};
use serde_json::Value;
use provider_xai::{GrokCanonicalDecoder, GrokResponsesRequest, grok_billing_breakdown};
@@ -579,6 +580,274 @@ fn decoder_should_buffer_only_custom_arguments_until_the_done_event() {
}));
}
#[test]
fn decoder_should_normalize_schema_integer_arguments_and_resequence_split_events() {
let request = tool_request(serde_json::json!({
"model": "client",
"input": "wait",
"tools": [{
"type": "function",
"name": "wait",
"parameters": {
"$ref": "#/$defs/Args",
"$defs": {
"Args": {
"type": "object",
"properties": {
"timeout_ms": {"allOf": [{"type": "integer"}]},
"fractional": {"type": "integer"},
"unsafe": {"type": "integer"},
"list": {
"type": "array",
"prefixItems": [{"type": "integer"}],
"items": {"type": "number"}
},
"extra": {
"type": "object",
"additionalProperties": {"type": "integer"}
}
}
},
"Unused": {"type": "integer"}
}
}
}]
}));
let arguments = concat!(
"{\"timeout_ms\":60000.0,\"fractional\":1.5,",
"\"unsafe\":9007199254740992.0,\"list\":[2.0,3.0],",
"\"extra\":{\"retries\":4.0},\"untyped\":7.0}"
);
let body = [
encode_sse_event(
"response.created",
&serde_json::json!({
"type": "response.created",
"response": {"id": "resp_integer"}
})
.to_string(),
),
encode_sse_event(
"response.output_item.added",
&serde_json::json!({
"type": "response.output_item.added",
"sequence_number": 40.0,
"output_index": 0,
"item": {
"id": "item_wait",
"type": "function_call",
"call_id": "call_wait",
"name": "wait"
}
})
.to_string(),
),
encode_sse_event(
"",
&serde_json::json!({
"sequence_number": "invalid",
"future": true
})
.to_string(),
),
encode_sse_event(
"response.function_call_arguments.delta",
&serde_json::json!({
"type": "response.function_call_arguments.delta",
"sequence_number": 91,
"item_id": "item_wait",
"call_id": "call_wait",
"output_index": 0,
"delta": arguments
})
.to_string(),
),
encode_sse_event_with_metadata(
"response.function_call_arguments.done",
&serde_json::json!({
"type": "response.function_call_arguments.done",
"sequence_number": 120,
"item_id": "item_wait",
"call_id": "call_wait",
"output_index": 0,
"arguments": arguments
})
.to_string(),
Some("evt_arguments_done"),
Some(250),
),
encode_sse_event(
"response.output_item.done",
&serde_json::json!({
"type": "response.output_item.done",
"sequence_number": 200,
"output_index": 0,
"item": {
"id": "item_wait",
"type": "function_call",
"call_id": "call_wait",
"name": "wait",
"arguments": arguments
}
})
.to_string(),
),
]
.concat();
let events = GrokCanonicalDecoder::for_request("grok-4.5", &request)
.push(body.as_bytes())
.expect("integer argument stream");
let wire = wire_events(&events);
let sequences = wire
.iter()
.filter_map(|event| event.data().get("sequence_number")?.as_u64())
.collect::<Vec<_>>();
assert_eq!(sequences, vec![40, 41, 42, 43, 44]);
assert!(wire.iter().any(|event| {
event.event_type().is_none()
&& event.data().get("future") == Some(&serde_json::json!(true))
&& event.data().get("sequence_number") == Some(&serde_json::json!(41))
}));
let argument_events = wire
.iter()
.filter(|event| {
matches!(
event.event_type(),
Some(
"response.function_call_arguments.delta"
| "response.function_call_arguments.done"
| "response.output_item.done"
)
)
})
.collect::<Vec<_>>();
assert_eq!(argument_events.len(), 3);
for event in &argument_events {
let raw = event
.data()
.pointer(if event.event_type() == Some("response.output_item.done") {
"/item/arguments"
} else if event.event_type() == Some("response.function_call_arguments.delta") {
"/delta"
} else {
"/arguments"
})
.and_then(Value::as_str)
.expect("normalized arguments");
let normalized = serde_json::from_str::<Value>(raw).expect("argument JSON");
assert_eq!(
normalized.pointer("/timeout_ms"),
Some(&serde_json::json!(60000))
);
assert_eq!(
normalized.pointer("/fractional"),
Some(&serde_json::json!(1.5))
);
assert_eq!(
normalized.pointer("/unsafe"),
Some(&serde_json::json!(9007199254740992.0))
);
assert_eq!(normalized.pointer("/list/0"), Some(&serde_json::json!(2)));
assert_eq!(normalized.pointer("/list/1"), Some(&serde_json::json!(3.0)));
assert_eq!(
normalized.pointer("/extra/retries"),
Some(&serde_json::json!(4))
);
assert_eq!(
normalized.pointer("/untyped"),
Some(&serde_json::json!(7.0))
);
}
assert_eq!(argument_events[0].sse_id(), Some("evt_arguments_done"));
assert_eq!(argument_events[0].sse_retry(), Some(250));
assert_eq!(argument_events[1].sse_id(), None);
assert_eq!(argument_events[1].sse_retry(), None);
}
#[test]
fn decoder_should_fail_open_after_the_function_argument_buffer_limit() {
let request = tool_request(serde_json::json!({
"model": "client",
"input": "wait",
"tools": [{
"type": "function",
"name": "wait",
"parameters": {
"type": "object",
"properties": {"timeout_ms": {"type": "integer"}}
}
}]
}));
let arguments = format!(
"{{\"timeout_ms\":1.0,\"padding\":\"{}\"}}",
"x".repeat((1 << 20) + 1)
);
let mut decoder = GrokCanonicalDecoder::for_request("grok-4.5", &request);
let added = encode_sse_event(
"response.output_item.added",
&serde_json::json!({
"type": "response.output_item.added",
"output_index": 0,
"item": {
"id": "item_large",
"type": "function_call",
"call_id": "call_large",
"name": "wait"
}
})
.to_string(),
);
decoder
.push(added.as_bytes())
.expect("large function item added");
let delta = encode_sse_event(
"response.function_call_arguments.delta",
&serde_json::json!({
"type": "response.function_call_arguments.delta",
"item_id": "item_large",
"call_id": "call_large",
"output_index": 0,
"delta": arguments
})
.to_string(),
);
let delta = decoder
.push(delta.as_bytes())
.expect("oversized arguments should pass through");
let delta_wire = wire_events(&delta);
assert_eq!(delta_wire.len(), 1);
assert_eq!(
delta_wire[0].data().get("delta").and_then(Value::as_str),
Some(arguments.as_str())
);
let done = encode_sse_event(
"response.function_call_arguments.done",
&serde_json::json!({
"type": "response.function_call_arguments.done",
"item_id": "item_large",
"call_id": "call_large",
"output_index": 0,
"arguments": arguments
})
.to_string(),
);
let done = decoder
.push(done.as_bytes())
.expect("oversized arguments done");
let normalized = wire_events(&done)[0]
.data()
.get("arguments")
.and_then(Value::as_str)
.and_then(|arguments| serde_json::from_str::<Value>(arguments).ok())
.expect("normalized final arguments");
assert_eq!(normalized.get("timeout_ms"), Some(&serde_json::json!(1)));
}
#[test]
fn decoder_should_coalesce_reasoning_item_part_and_summary_index() {
let body = concat!(
@@ -612,6 +881,10 @@ fn decoder_should_preserve_unknown_events_as_openai_wire() {
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n",
"event: xai.internal.trace\n",
"data: {\"type\":\"xai.internal.trace\",\"secret\":\"must-not-reach-client\"}\n\n",
"event: response.doom_loop_check \n",
"data: {\"type\":\"response.backend_tool_call.started\",\"secret\":\"drop-header\"}\n\n",
"event: response.backend_tool_call.started\n",
"data: {\"type\":\"response.doom_loop_check\",\"secret\":\"drop-body\"}\n\n",
"event: response.backend_tool_call.started\n",
"id: evt_future\n",
"retry: 1250\n",
@@ -639,6 +912,32 @@ fn decoder_should_preserve_unknown_events_as_openai_wire() {
assert!(events[1].canonical_facts().is_empty());
}
#[test]
fn decoder_should_rewrite_grok_ping_frames_as_sse_comments() {
let body = concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_ping\"}}\n\n",
"event: ping\n",
"data: {\"type\":\"ping\",\"x-opencode-type\":\"inference-cost\",\"cost\":2.75}\n\n",
"event: ping\n",
"data: {not-json}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ping\",\"status\":\"completed\"}}\n\n",
);
let events = GrokCanonicalDecoder::new("fallback")
.push(body.as_bytes())
.expect("ping-filtered response");
let wire = wire_events(&events);
let ping_comments = wire
.iter()
.filter(|event| event.raw_sse_frame().map(AsRef::as_ref) == Some(b": ping\n\n".as_slice()))
.count();
assert_eq!(ping_comments, 2);
assert!(wire.iter().all(|event| event.event_type() != Some("ping")));
}
#[test]
fn decoder_should_preserve_image_and_hosted_tool_items_without_inventing_facts() {
let body = concat!(
@@ -1,4 +1,6 @@
use gateway_core::event::{GatewayEvent, ProviderEvent, ReasoningDelta, TextDelta, ToolCallDelta};
use gateway_core::event::{
GatewayEvent, ProtocolWireEvent, ProviderEvent, ReasoningDelta, TextDelta, ToolCallDelta,
};
use gateway_core::operation::{GenerateRequest, ProtocolPayload};
use gateway_core::policy::ClientApiKeyId;
use provider_xai::{
@@ -74,7 +76,7 @@ fn encoder_should_preserve_history_order_and_append_summary_prompt() {
}
#[test]
fn encoder_should_preserve_tools_and_remove_non_prefix_constraints() {
fn encoder_should_preserve_sub2api_compaction_constraints() {
let request = encode(json!({
"model": "client-model",
"input": [
@@ -89,21 +91,24 @@ fn encoder_should_preserve_tools_and_remove_non_prefix_constraints() {
"prompt_cache_key": "cache_private",
"service_tier": "priority",
"max_output_tokens": 16,
"background": true,
"truncation": "auto",
"stream": false,
"store": true
}))
.expect("compaction request");
let body = request.body();
let forbidden = [
"parallel_tool_calls",
"text",
"previous_response_id",
"prompt_cache_key",
"service_tier",
"max_output_tokens",
];
let forbidden = ["previous_response_id", "prompt_cache_key", "service_tier"];
assert!(forbidden.into_iter().all(|field| !body.contains_key(field)));
assert_eq!(body.get("parallel_tool_calls"), Some(&Value::Bool(true)));
assert_eq!(
body.get("text"),
Some(&json!({"format": {"type": "json_object"}}))
);
assert_eq!(body.get("max_output_tokens"), Some(&json!(16)));
assert_eq!(body.get("background"), Some(&Value::Bool(true)));
assert_eq!(body.get("truncation"), Some(&json!("auto")));
assert_eq!(
body.get("tools"),
Some(&json!([{
@@ -112,8 +117,12 @@ fn encoder_should_preserve_tools_and_remove_non_prefix_constraints() {
"parameters": {"type": "object"}
}]))
);
assert_eq!(body.get("tool_choice"), Some(&json!("auto")));
assert_eq!(body.get("temperature"), Some(&json!(1.0)));
assert_eq!(body.get("tool_choice"), Some(&json!("none")));
assert_eq!(
body.get("include"),
Some(&json!(["reasoning.encrypted_content"]))
);
assert!(!body.contains_key("temperature"));
assert_eq!(body.get("stream"), Some(&Value::Bool(true)));
assert_eq!(body.get("store"), Some(&Value::Bool(false)));
}
@@ -181,54 +190,56 @@ fn encoder_should_consume_only_the_terminal_compaction_trigger() {
}
#[test]
fn decoder_should_return_clean_typed_summary_after_normal_terminal() {
let summary = decode_summary(&valid_summary("checkpoint-only-secret")).expect("summary");
fn decoder_should_preserve_sub2api_summary_text_after_trimming_edges() {
let raw = valid_summary("checkpoint-only-secret");
let summary = decode_summary(&format!(" {raw} \n")).expect("summary");
assert!(summary.as_str().starts_with("Summary:\nPrivate state:"));
assert!(!summary.as_str().contains("<summary>"));
assert_eq!(summary, raw);
}
#[test]
fn decoder_should_remove_nested_leading_scratchpad() {
fn decoder_should_not_interpret_nested_summary_or_analysis_tokens() {
let raw = format!(
"<summary>\n<analysis>private scratchpad</analysis>\nActual state\n{}\n</summary>",
"preserved implementation context ".repeat(20)
);
let summary = decode_summary(&raw).expect("clean summary");
let summary = decode_summary(&raw).expect("summary");
assert!(summary.as_str().contains("Actual state"));
assert!(!summary.as_str().contains("private scratchpad"));
assert!(summary.as_str().contains("private scratchpad"));
assert!(summary.as_str().starts_with("<summary>"));
}
#[test]
fn decoder_should_remove_top_level_leading_scratchpad() {
fn decoder_should_preserve_top_level_analysis_as_upstream_text() {
let raw = format!(
"<analysis>private scratchpad</analysis>\n{}",
valid_summary("actual state")
);
let summary = decode_summary(&raw).expect("clean summary");
let summary = decode_summary(&raw).expect("summary");
assert!(!summary.as_str().contains("private scratchpad"));
assert!(summary.as_str().contains("private scratchpad"));
}
#[test]
fn decoder_should_keep_summary_after_unclosed_leading_scratchpad() {
fn decoder_should_preserve_unclosed_leading_analysis() {
let raw = format!(
"<analysis>truncated private scratchpad\n{}",
valid_summary("actual state")
);
let summary = decode_summary(&raw).expect("clean summary");
let summary = decode_summary(&raw).expect("summary");
assert!(summary.as_str().contains("truncated private scratchpad"));
assert!(summary.as_str().contains("actual state"));
}
#[test]
fn decoder_should_neutralize_live_compaction_control_tokens_in_body() {
fn decoder_should_preserve_compaction_control_tokens_like_sub2api() {
let raw = valid_summary(
"Quoted <summary> and </summary>, <analysis> and </analysis>, \
plus <summary_request> and </summary_request>.",
);
let summary = decode_summary(&raw).expect("clean summary");
let summary = decode_summary(&raw).expect("summary");
for token in [
"<summary>",
@@ -238,20 +249,20 @@ fn decoder_should_neutralize_live_compaction_control_tokens_in_body() {
"<summary_request>",
"</summary_request>",
] {
assert!(!summary.as_str().contains(token), "live token {token}");
assert!(summary.as_str().contains(token), "token {token}");
}
}
#[test]
fn decoder_should_preserve_unclosed_summary_body_without_live_token() {
fn decoder_should_preserve_unclosed_summary_token() {
let raw = format!(
"<summary>\nActual state\n{}",
"preserved implementation context ".repeat(20)
);
let summary = decode_summary(&raw).expect("clean summary");
let summary = decode_summary(&raw).expect("summary");
assert!(summary.as_str().contains("Actual state"));
assert!(!summary.as_str().contains("<summary>"));
assert!(summary.as_str().contains("<summary>"));
}
#[test]
@@ -267,17 +278,95 @@ fn decoder_should_preserve_numbered_sections_that_quote_analysis_close() {
}
#[test]
fn decoder_should_reject_degenerate_summary() {
let error = decode_summary("<summary>too short</summary>").expect_err("short summary");
fn decoder_should_accept_short_summary_like_sub2api() {
let summary = decode_summary("<summary>too short</summary>").expect("short summary");
assert_eq!(error, GrokCompactionDecodeError::Degenerate);
assert_eq!(summary, "<summary>too short</summary>");
}
#[test]
fn decoder_should_reject_empty_cleaned_summary_as_degenerate() {
let error = decode_summary("<analysis>draft only</analysis>").expect_err("empty summary");
fn decoder_should_accept_empty_summary_text() {
let summary = decode_summary(" \n\t ").expect("empty summary");
assert_eq!(error, GrokCompactionDecodeError::Degenerate);
assert!(summary.is_empty());
}
#[test]
fn decoder_should_fall_back_to_completed_output_without_text_deltas() {
let mut decoder = GrokCompactionSummaryDecoder::new();
let wire = ProtocolWireEvent::json_with_sse_metadata(
"openai",
Some("response.completed".to_owned()),
json!({
"type": "response.completed",
"response": {
"output": [
{
"type": "reasoning",
"encrypted_content": "real-ciphertext"
},
{
"type": "message",
"content": [
{"type": "output_text", "text": "first part"},
{"type": "output_text", "text": "second part"}
]
}
]
}
}),
None,
None,
)
.expect("terminal wire event");
decoder
.observe(&ProviderEvent::wire(wire))
.expect("terminal response");
assert_eq!(
decoder.finish().expect("summary"),
"first part\nsecond part"
);
}
#[test]
fn decoder_should_prefer_complete_terminal_output_over_partial_deltas() {
let mut decoder = GrokCompactionSummaryDecoder::new();
decoder
.observe(&ProviderEvent::canonical(GatewayEvent::TextDelta(
TextDelta {
content_index: 0,
text: "partial summary".to_owned(),
},
)))
.expect("partial summary delta");
let wire = ProtocolWireEvent::json_with_sse_metadata(
"openai",
Some("response.completed".to_owned()),
json!({
"type": "response.completed",
"response": {
"output": [{
"type": "message",
"content": [{
"type": "output_text",
"text": "complete terminal summary"
}]
}]
}
}),
None,
None,
)
.expect("terminal wire event");
decoder
.observe(&ProviderEvent::wire(wire))
.expect("terminal response");
assert_eq!(
decoder.finish().expect("summary"),
"complete terminal summary"
);
}
#[test]
@@ -26,8 +26,10 @@
}
}
],
"tool_choice": "auto",
"temperature": 1.0,
"tool_choice": "none",
"include": [
"reasoning.encrypted_content"
],
"store": false,
"stream": true
}
@@ -262,6 +262,8 @@ async fn inference_transport_should_classify_http_failures_without_retaining_bod
(408, GrokInferenceTransportErrorKind::Timeout),
(429, GrokInferenceTransportErrorKind::RateLimited),
(500, GrokInferenceTransportErrorKind::Unavailable),
(504, GrokInferenceTransportErrorKind::Unavailable),
(529, GrokInferenceTransportErrorKind::Unavailable),
(418, GrokInferenceTransportErrorKind::Protocol),
];
@@ -332,7 +334,7 @@ async fn inference_transport_should_expose_safe_flat_json_error_details() {
assert_eq!(
error.kind(),
GrokInferenceTransportErrorKind::QuotaExhausted
GrokInferenceTransportErrorKind::PaymentRequired
);
assert_eq!(
(detail.message(), detail.code(), detail.error_type()),
@@ -395,16 +397,22 @@ async fn inference_transport_should_scope_forbidden_failures_from_safe_metadata(
),
(
json!({"error": {"code": "permission_denied", "message": "access to the chat endpoint is denied"}}),
GrokInferenceTransportErrorKind::ModelAccessDenied,
GrokInferenceTransportErrorKind::PermissionDenied,
false,
Some("permission_denied"),
),
(
json!({"error": {"code": "permission_denied", "message": "request rejected", "details": {"check": "SAFETY_CHECK_TYPE_INPUT"}}}),
GrokInferenceTransportErrorKind::SafetyRejected,
GrokInferenceTransportErrorKind::PermissionDenied,
false,
Some("permission_denied"),
),
(
json!({"error": {"code": "invalid_token", "message": "request blocked by policy"}}),
GrokInferenceTransportErrorKind::SafetyRejected,
false,
Some("invalid_token"),
),
(
json!({"error": {"code": "policy_denied", "message": "request rejected"}}),
GrokInferenceTransportErrorKind::PermissionDenied,
@@ -558,11 +566,70 @@ async fn inference_transport_should_classify_free_usage_429_as_account_free_quot
);
}
#[tokio::test]
async fn inference_transport_should_apply_sub2api_body_aware_400_failures() {
let cases = [
(
json!({"error": {"code": "subscription:free-usage-exhausted", "message": "free usage exhausted"}}),
GrokInferenceTransportErrorKind::FreeQuotaExhausted,
Some("subscription_free_usage_exhausted"),
),
(
json!({"error": {"message": "selected model is at capacity"}}),
GrokInferenceTransportErrorKind::RateLimited,
Some("model_capacity"),
),
(
json!({"error": {"message": "spending limit reached"}}),
GrokInferenceTransportErrorKind::PaymentRequired,
Some("billing_quota"),
),
(
json!({"error": {"message": "empty model output: no content/tool_calls"}}),
GrokInferenceTransportErrorKind::Unavailable,
Some("empty_upstream"),
),
(
json!({"error": {"message": "rate limit exceeded"}}),
GrokInferenceTransportErrorKind::InvalidRequest,
Some("rate_limit"),
),
(
json!({"error": {"message": "invalid tool schema"}}),
GrokInferenceTransportErrorKind::InvalidRequest,
None,
),
];
for (body, expected_kind, expected_code) in cases {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(ResponseTemplate::new(400).set_body_json(body))
.expect(1)
.mount(&server)
.await;
let origin = Url::parse(&server.uri()).expect("wiremock origin");
let error = inference_transport(&origin)
.execute(inference_request(&origin))
.await
.expect_err("400 response must be classified");
assert_eq!(error.kind(), expected_kind);
assert_eq!(
error.upstream_code().map(|code| code.as_str()),
expected_code
);
}
}
#[tokio::test]
async fn inference_transport_should_bound_retry_after_to_the_safe_window() {
for (header, expected) in [
("120", Some(Duration::from_secs(120))),
("121", None),
("121", Some(Duration::from_secs(121))),
("86400", Some(Duration::from_secs(86_400))),
("86401", None),
("0", None),
] {
let server = MockServer::start().await;
@@ -51,6 +51,10 @@ fn encoder_should_preserve_raw_images_hosted_tools_and_unknown_fields() {
assert_eq!(body.pointer("/model"), Some(&json!("grok-routed")));
assert_eq!(body.pointer("/stream"), Some(&json!(true)));
assert_eq!(body.pointer("/store"), Some(&json!(true)));
assert_eq!(
body.pointer("/include"),
Some(&json!(["reasoning.encrypted_content"]))
);
assert_eq!(
body.pointer("/input/0/content/1/detail"),
Some(&json!("high"))
@@ -71,25 +75,230 @@ fn encoder_should_preserve_raw_images_hosted_tools_and_unknown_fields() {
}
#[test]
fn encoder_should_strip_openai_only_client_metadata_before_grok_build() {
fn encoder_should_strip_all_client_metadata_before_grok_build() {
let request = raw_request(json!({
"model": "client-model",
"input": "hello",
"client_metadata": {
"x-openai-subagent": "review",
"application_tag": "preserve-me"
}
},
"metadata": {"application_tag": "keep-this"}
}));
let encoded = GrokResponsesRequest::encode(&request, "grok-routed", &client_key())
.expect("sanitized request");
let body = Value::Object(encoded.body().clone());
assert_eq!(body.pointer("/client_metadata/x-openai-subagent"), None);
assert_eq!(body.pointer("/client_metadata"), None);
assert_eq!(
body.pointer("/client_metadata/application_tag"),
Some(&json!("preserve-me"))
body.pointer("/metadata/application_tag"),
Some(&json!("keep-this"))
);
let request = raw_request(json!({
"model": "client-model",
"input": "hello",
"client_metadata": "opaque-local-envelope"
}));
let encoded = GrokResponsesRequest::encode(&request, "grok-routed", &client_key())
.expect("scalar client metadata");
assert_eq!(encoded.body().get("client_metadata"), None);
}
#[test]
fn encoder_should_apply_build_defaults_and_normalize_reasoning_effort() {
let request = raw_request(json!({
"model": "client-model",
"input": "hello",
"include": ["web_search_call.action.sources"],
"reasoning": {"effort": "XHIGH", "summary": "auto"}
}));
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
.expect("normalized request");
let body = Value::Object(encoded.body().clone());
assert_eq!(body.pointer("/store"), Some(&json!(false)));
assert_eq!(
body.pointer("/include"),
Some(&json!([
"web_search_call.action.sources",
"reasoning.encrypted_content"
]))
);
assert_eq!(body.pointer("/reasoning/effort"), Some(&json!("high")));
assert_eq!(body.pointer("/reasoning/summary"), Some(&json!("auto")));
let request = raw_request(json!({
"model": "client-model",
"input": "hello",
"reasoning": {"effort": "max"},
"reasoning_effort": "extra_high",
"reasoningEffort": "minimal"
}));
let encoded = GrokResponsesRequest::encode(&request, "xai/grok-4.6-latest", &client_key())
.expect("aliased request");
let body = Value::Object(encoded.body().clone());
assert_eq!(body.pointer("/model"), Some(&json!("grok-4.6")));
assert_eq!(body.pointer("/reasoning/effort"), Some(&json!("high")));
assert_eq!(body.pointer("/reasoning_effort"), Some(&json!("high")));
assert_eq!(body.pointer("/reasoningEffort"), None);
let request = raw_request(json!({
"model": "client-model",
"input": "hello",
"reasoning": {"effort": "high", "summary": "auto"}
}));
let encoded = GrokResponsesRequest::encode(&request, "grok-composer-2.5-fast", &client_key())
.expect("Composer request");
let body = Value::Object(encoded.body().clone());
assert_eq!(body.pointer("/reasoning"), None);
let request = raw_request(json!({
"model": "client-model",
"input": "hello",
"store": null,
"include": null,
"reasoning": {"effort": "max"}
}));
let encoded = GrokResponsesRequest::encode(&request, "grok-composer-2.5-fast", &client_key())
.expect("empty Composer reasoning");
let body = Value::Object(encoded.body().clone());
assert_eq!(body.pointer("/store"), Some(&json!(false)));
assert_eq!(
body.pointer("/include"),
Some(&json!(["reasoning.encrypted_content"]))
);
assert_eq!(body.pointer("/reasoning"), None);
let request = raw_request(json!({
"model": "client-model",
"input": "hello",
"reasoning": {}
}));
let encoded = GrokResponsesRequest::encode(&request, "grok-composer-2.5-fast", &client_key())
.expect("Composer reasoning without effort");
assert_eq!(
Value::Object(encoded.body().clone()).pointer("/reasoning"),
None
);
let request = raw_request(json!({
"model": "client-model",
"input": "hello",
"reasoning": {"effort": "max"}
}));
let encoded = GrokResponsesRequest::encode(&request, "tenant/foo/grok-4.6", &client_key())
.expect("unknown provider prefix");
assert_eq!(
Value::Object(encoded.body().clone()).pointer("/reasoning/effort"),
None
);
}
#[test]
fn encoder_should_strip_sub2api_grok_unsupported_fields() {
let request = raw_request(json!({
"model": "client-model",
"input": "hello",
"prompt_cache_retention": "24h",
"safety_identifier": "user-1",
"presence_penalty": 0.1,
"presencePenalty": 0.2,
"frequency_penalty": 0.3,
"frequencyPenalty": 0.4,
"stop": ["done"],
"external_web_access": true,
"metadata": {"external_web_access": false},
"tools": [{
"type": "function",
"name": "lookup",
"external_web_access": true,
"parameters": {
"type": "object",
"properties": {"q": {"type": "string", "external_web_access": true}}
}
}]
}));
let encoded = GrokResponsesRequest::encode(&request, "grok-latest", &client_key())
.expect("sanitized 4.5 request");
let body = Value::Object(encoded.body().clone());
for pointer in [
"/prompt_cache_retention",
"/safety_identifier",
"/presence_penalty",
"/presencePenalty",
"/frequency_penalty",
"/frequencyPenalty",
"/stop",
"/external_web_access",
"/metadata/external_web_access",
"/tools/0/external_web_access",
"/tools/0/parameters/properties/q/external_web_access",
] {
assert_eq!(body.pointer(pointer), None, "field survived at {pointer}");
}
let request = raw_request(json!({
"model": "client-model",
"input": "hello",
"logprobs": true,
"top_logprobs": 5
}));
let encoded = GrokResponsesRequest::encode(&request, "grok-4.20-reasoning", &client_key())
.expect("sanitized 4.20 request");
let body = Value::Object(encoded.body().clone());
assert_eq!(
body.pointer("/model"),
Some(&json!("grok-4.20-0309-reasoning"))
);
assert_eq!(body.pointer("/logprobs"), None);
assert_eq!(body.pointer("/top_logprobs"), None);
}
#[test]
fn external_web_access_false_should_strip_only_the_unsupported_field() {
let request = raw_request(json!({
"model": "client-model",
"input": "hello",
"tools": [{
"type": "web_search",
"external_web_access": false
}],
"tool_choice": {"type": "web_search"}
}));
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
.expect("web search survives field sanitization");
let body = Value::Object(encoded.body().clone());
assert_eq!(
body.pointer("/tools/0"),
Some(&json!({"type": "web_search"}))
);
assert_eq!(
body.pointer("/tool_choice"),
Some(&json!({"type": "web_search"}))
);
}
#[test]
fn encoder_should_reject_non_string_build_include_values() {
for include in [json!("reasoning.encrypted_content"), json!([1])] {
let request = raw_request(json!({
"model": "client-model",
"input": "hello",
"include": include
}));
let error = GrokResponsesRequest::encode(&request, "grok-routed", &client_key())
.expect_err("invalid include");
assert!(matches!(
error,
GrokRequestEncodeError::InvalidRequestField { field: "include" }
));
}
}
#[test]
@@ -326,6 +535,28 @@ fn function_parameters_should_remove_only_nullable_object_roots() {
);
}
#[test]
fn function_parameters_should_default_missing_or_null_schemas() {
let request = raw_request(json!({
"model": "client",
"input": "hello",
"tools": [
{"type": "function", "name": "lookup"},
{"type": "function", "name": "wait", "parameters": null}
]
}));
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
.expect("default function schemas");
let body = Value::Object(encoded.body().clone());
for index in 0..2 {
assert_eq!(
body.pointer(&format!("/tools/{index}/parameters")),
Some(&json!({"type": "object", "properties": {}}))
);
}
}
#[test]
fn function_parameters_should_keep_local_object_refs_after_removing_null() {
let request = raw_request(json!({
@@ -411,6 +642,8 @@ fn explicit_session_should_add_x_search_after_codex_additional_tools_normalizati
let body = Value::Object(encoded.body().clone());
assert_eq!(body.pointer("/input/0/type"), Some(&json!("message")));
assert_eq!(body.pointer("/input/0/role"), Some(&json!("user")));
assert_eq!(body.pointer("/input/1"), None);
assert_eq!(body.pointer("/tools/0/name"), Some(&json!("apply_patch")));
assert_eq!(
body.pointer("/tools/0/parameters/required"),
@@ -422,7 +655,55 @@ fn explicit_session_should_add_x_search_after_codex_additional_tools_normalizati
}
#[test]
fn explicit_session_should_add_x_search_to_allowed_tools_choice() {
fn additional_tools_should_keep_top_level_definition_and_drop_unsupported_types() {
let request = raw_request(json!({
"model": "client",
"tools": [
{
"type": "function",
"name": "existing",
"description": "top-level wins",
"parameters": {"type": "object"}
},
{"type": "image_generation", "model": "gpt-image-2"}
],
"tool_choice": "auto",
"input": [
{
"type": "additional_tools",
"tools": [
{
"type": "function",
"name": "existing",
"description": "carrier duplicate",
"parameters": {"type": "object"}
},
{"type": "function", "name": "wait"},
{"type": "future_client_tool", "name": "drop-me"}
]
},
{"type": "message", "role": "user", "content": "hello"}
]
}));
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
.expect("promoted additional tools");
let body = Value::Object(encoded.body().clone());
assert_eq!(body.pointer("/input/0/role"), Some(&json!("user")));
assert_eq!(body.pointer("/input/1"), None);
assert_eq!(body.pointer("/tools/0/name"), Some(&json!("existing")));
assert_eq!(
body.pointer("/tools/0/description"),
Some(&json!("top-level wins"))
);
assert_eq!(body.pointer("/tools/1/name"), Some(&json!("wait")));
assert_eq!(body.pointer("/tools/2"), None);
assert_eq!(body.pointer("/tool_choice"), Some(&json!("auto")));
}
#[test]
fn explicit_session_should_drop_xai_unsupported_allowed_tools_choice() {
let request = raw_request(json!({
"model": "client",
"prompt_cache_key": "conversation-42",
@@ -443,10 +724,8 @@ fn explicit_session_should_add_x_search_to_allowed_tools_choice() {
GrokResponsesRequest::encode(&request, "grok-4.5", &client_key()).expect("request");
let body = Value::Object(encoded.body().clone());
assert_eq!(
body.pointer("/tool_choice/tools/1"),
Some(&json!({"type": "x_search"}))
);
assert_eq!(body.pointer("/tool_choice"), None);
assert_eq!(body.pointer("/tools/1"), Some(&json!({"type": "x_search"})));
}
#[test]
@@ -664,7 +943,7 @@ fn custom_apply_patch_declaration_should_use_patch_parameter() {
}
#[test]
fn hosted_tool_choice_should_narrow_visible_tools_and_require_the_selected_kind() {
fn hosted_tool_choice_should_preserve_the_sub2api_specific_choice() {
let request = raw_request(json!({
"model": "client",
"input": "search",
@@ -679,13 +958,19 @@ fn hosted_tool_choice_should_narrow_visible_tools_and_require_the_selected_kind(
GrokResponsesRequest::encode(&request, "grok-4.5", &client_key()).expect("hosted choice");
let body = Value::Object(encoded.body().clone());
assert_eq!(body.pointer("/tool_choice"), Some(&json!("required")));
assert_eq!(
body.pointer("/tool_choice"),
Some(&json!({"type": "web_search"}))
);
assert_eq!(
body.pointer("/tools"),
Some(&json!([{
"type": "web_search",
"filters": {"allowed_domains": ["example.com"]}
}]))
Some(&json!([
{
"type": "web_search",
"filters": {"allowed_domains": ["example.com"]}
},
{"type": "x_search"}
]))
);
}
@@ -927,7 +1212,6 @@ fn compaction_history_should_become_plaintext_user_continuation_in_place() {
{"type": "message", "role": "user", "content": "before compaction"},
{
"type": "compaction",
"id": "cmp_1",
"encrypted_content": "Repository state and pending work."
},
{"type": "message", "role": "user", "content": "continue"}
@@ -960,7 +1244,53 @@ fn compaction_history_should_become_plaintext_user_continuation_in_place() {
}
#[test]
fn compaction_history_should_reject_missing_empty_or_non_string_plaintext() {
fn sub2api_compaction_history_should_restore_reasoning_ciphertext_and_visible_summary() {
for item_type in ["compaction", "compaction_summary"] {
let request = raw_request(json!({
"model": "client",
"input": [
{
"type": item_type,
"id": "cmp_1",
"status": "completed",
"encrypted_content": "grok-encrypted-state",
"summary": [
{"type": "summary_text", "text": "first part"},
{"type": "summary_text", "text": "second part"}
]
},
{"type": "message", "role": "user", "content": "continue"}
]
}));
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
.expect("sub2api compaction continuation");
let body = Value::Object(encoded.body().clone());
assert_eq!(
body.pointer("/input"),
Some(&json!([
{
"type": "reasoning",
"summary": [],
"encrypted_content": "grok-encrypted-state"
},
{
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": "<conversation_summary>\nfirst part\nsecond part\n</conversation_summary>"
}]
},
{"type": "message", "role": "user", "content": "continue"}
]))
);
}
}
#[test]
fn malformed_compaction_history_should_be_dropped_like_sub2api() {
for encrypted_content in [
None,
Some(json!(null)),
@@ -978,12 +1308,9 @@ fn compaction_history_should_reject_missing_empty_or_non_string_plaintext() {
"model": "client",
"input": [Value::Object(compaction)]
}));
assert_eq!(
GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
.expect_err("invalid compaction plaintext"),
GrokRequestEncodeError::InvalidRequestField { field: "input" }
);
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
.expect("malformed compaction item is omitted");
assert_eq!(encoded.body().get("input"), Some(&json!([])));
}
}
@@ -1025,12 +1352,34 @@ fn tool_search_history_should_load_returned_tools_at_the_original_turn() {
}
#[test]
fn unsupported_or_ambiguous_tool_contracts_should_fail_before_upstream_io() {
fn unsupported_tools_should_be_filtered_with_their_orphaned_choice() {
let request = raw_request(json!({
"model": "client",
"input": "x",
"tools": [{"type": "future_tool"}],
"tool_choice": {"type": "future_tool"}
}));
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
.expect("unsupported tool filter");
assert_eq!(encoded.body().get("tools"), None);
assert_eq!(encoded.body().get("tool_choice"), None);
let request = raw_request(json!({
"model": "client",
"input": "x",
"tools": [{"type": "function", "name": "kept"}],
"tool_choice": {"type": "function", "name": "missing"}
}));
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
.expect("orphaned function choice filter");
let body = Value::Object(encoded.body().clone());
assert_eq!(body.pointer("/tools/0/name"), Some(&json!("kept")));
assert_eq!(encoded.body().get("tool_choice"), None);
}
#[test]
fn ambiguous_tool_contracts_should_fail_before_upstream_io() {
for (body, field) in [
(
json!({"model": "client", "input": "x", "tools": [{"type": "future_tool"}]}),
"tools",
),
(
json!({"model": "client", "input": "x", "tools": [
{"type": "tool_search", "execution": "client"},
+1 -1
View File
@@ -47,7 +47,7 @@ UID/GID 和 mode,因此配置由当前用户持有,并只向容器组 `10001
模板中的 `openai` / `xai` 只保留请求画像启动基线。OpenAI 的上游地址、WebSocket 池、额度刷新
与 OAuth 设置,以及 xAI 的 OAuth、额度和模型目录策略,均由各自 Provider 使用代码内默认值管理;
模板不重复列出这些默认项。运行后,OpenAI CLI/Desktop 与 xAI CLI 的官方发布检查只更新进程内
模板不重复列出这些默认项。运行后,OpenAI Desktop 与 xAI CLI 的官方发布检查只更新进程内
版本字段,不回写 `config.yaml`;检查失败时继续使用上一份有效画像。
## 启动
+6 -6
View File
@@ -57,17 +57,17 @@ api:
openai:
# 上游地址、WebSocket 池、额度刷新和 OAuth 使用 Provider 内置默认值;模板只保留画像启动基线。
wire_profile:
# codex_version 是 npm CLI 的启动基线,并用于模型目录与请求 UA;Provider 每 24 小时更新它。
# Desktop appcast 同期只更新 Desktop 版本与构建号。
# 版本元组来自同一个官方 macOS Desktop ZIP;Provider 每 24 小时检查 appcast,
# 仅在包内 Core 核验和 Redis 条件覆盖都成功后原子更新。
originator: 'Codex Desktop'
codex_version: '0.146.0'
desktop_version: '26.727.51351'
desktop_build: '6119'
codex_version: '0.147.0-alpha.6.6'
desktop_version: '26.803.81509'
desktop_build: '6415'
os_type: 'Mac OS'
os_version: '15.7.1'
arch: 'arm64'
terminal: 'unknown'
verified_at: '2026-08-04T00:00:00+08:00'
verified_at: '2026-08-03T00:00:00+08:00'
xai:
# OAuth 端点、额度与模型目录策略由 xAI Provider 固定管理;这里只声明请求画像启动基线。
+13 -4
View File
@@ -10,8 +10,8 @@ Codex Proxy RS 是单进程、单副本部署的多 Provider AI 网关:
- 管理面提供 `/api/admin/*` 和 Vue 静态管理端。
- 当前 Provider 为 OpenAI 与 xAI;账号 credential 均由所属 Provider 独占解释。
- OpenAI 行为以重构前正式实现为语义基准;xAI/Grok 行为以 `grok2api` 验证结果为基准。
- 两个 Provider 的请求画像都由启动配置提供基线,并通过进程级共享状态向每次新请求发布一致快照。OpenAI CLI、OpenAI Desktop 与 xAI CLI 的官方版本检查会分别原子更新自己负责的版本字段。
- OpenAI CLI 使用官方 npm 包 `@openai/codex`,OpenAI Desktop 使用官方 appcast,xAI CLI 使用官方 npm 包 `@xai-official/grok`。检查失败保留上一份成功画像;检查成功后 OAuth、catalog、quota/billing、inference 与 Dashboard 自动采用最新版本。
- 两个 Provider 的请求画像都由启动配置提供基线,并通过进程级共享状态向每次新请求发布一致快照。OpenAI Desktop 与 xAI CLI 的官方版本检查会分别原子更新自己负责的版本字段。
- OpenAI Desktop 使用官方 appcast,并从同一 ZIP 的内嵌 Core 做有界 Range 探测;xAI CLI 使用官方 npm 包 `@xai-official/grok`。检查失败保留上一份成功画像;检查成功后 OAuth、catalog、quota/billing、inference 与 Dashboard 自动采用最新版本。OpenAI 制品画像写入 Redis 单键并使用 TTL,避免按发布次数堆积。
- PostgreSQL 是业务与配置事实的唯一权威存储。
- Redis 只保存可丢失、可重建或有自然过期时间的协调状态。
@@ -148,7 +148,16 @@ OpenAI OAuth 导入先取得 access token(仅有 refresh token 时先交换)
由 `accessToken` 补齐。解析器只读取 `email`、`https://api.openai.com/profile` 与
`https://api.openai.com/auth` 中的官方 claims,不调用 `whoami`,也不使用导入文档顶层的
`userId/accountId` 补身份。两个令牌都没有用户身份 claims 时,账号以未验证错误状态入库。首次 OAuth
在服务端 `state`、PKCE 与官方 token exchange 成功后解析并保存同一 token set。重新授权的目标绑定
返回与当前 Codex Desktop 一致的两层登录地址:内层使用 `auth.openai.com/oauth/authorize` 的
官方参数顺序,外层使用 `https://chatgpt.com/codex/desktop-auth` 并开启
`codex_streamlined_login`。`codex_app_version` 每次从共享 Desktop 画像快照读取;
首次授权在 10 分钟 pending flow 中生成账号级 `installation_id`,换码成功后把同一个值写入 credential。
`source_surface_stable_id` 与 `codex_origin_stable_id` 由该 ID 经过带版本域的 SHA-256 确定性派生,并映射为
UUIDv4;重新授权读取既有 `installation_id`,因此得到相同的 surface ID。系统不额外持久化 surface ID,
也不再从全局 HMAC 身份锚点派生;它不等同于 DeviceCheck attestation。为了对齐 macOS Desktop
交给外部浏览器的最终 `chatgpt.com` URL,Provider 同样携带 `no_universal_links=1` 浏览器路由参数。
服务端随后仍以 `state`、PKCE
与官方 token exchange 成功后解析并保存同一 token set。重新授权的目标绑定
只保存目标账号 ID,不接受或冻结客户端提供的 credential revision;complete 阶段重新读取目标账号及其当前 revision,并以当前 revision 做最终 CAS。
重新授权和手工或后台 RT refresh 只轮换目标账号的 token,保留既有账号资料与 OAuth principal。
@@ -262,7 +271,7 @@ HTTP serve/drain 与 worker 运行时都在 `gateway-host`:Host 负责注册
Cron 游标、恢复中间状态、完成删除收敛、领取并执行一个 queued 任务、执行一小批保留清理;长时间
`pg_dump` 与 multipart 上传由 daemon 自身持有,Host 只负责 panic 后重启、健康与关闭。它不使用
Redis leader lease(单副本无竞争),只依赖 PostgreSQL 部分唯一约束防御并发 API/调度创建。
- Provider:credential refresh、quota/catalog 健康,以及模型 etag 与官方版本 release 检查(OpenAI CLI、OpenAI Desktop、xAI CLI)。
- Provider:credential refresh、quota/catalog 健康,以及模型 etag 与官方版本 release 检查(OpenAI Desktop、xAI CLI)。
带 lease 的周期任务在每个周期开始向 Redis 申请一次 leader lease,周期内续租,周期结束即释放。这只是单周期互斥,不是跨周期 leader 选举:各副本独立计时,N 副本部署下同一任务的实际执行频率最坏可达单副本的 N 倍。
+62
View File
@@ -1,5 +1,48 @@
import antfu from '@antfu/eslint-config'
const templateStylePlugin = {
rules: {
'single-line-simple-interpolation': {
meta: {
type: 'layout',
fixable: 'whitespace',
schema: [],
messages: {
expectedSingleLine: 'Expected a simple mustache interpolation to stay on one line.',
},
},
create(context) {
const { parserServices } = context.sourceCode
if (!parserServices.defineTemplateBodyVisitor)
return {}
return parserServices.defineTemplateBodyVisitor({
'VExpressionContainer[expression!=null]': function (node) {
if (node.parent.type !== 'VElement')
return
const expression = node.expression
if (
expression.loc.start.line !== expression.loc.end.line
|| node.loc.start.line === node.loc.end.line
) {
return
}
context.report({
node,
messageId: 'expectedSingleLine',
fix(fixer) {
return fixer.replaceText(node, `{{ ${context.sourceCode.getText(expression)} }}`)
},
})
},
})
},
},
},
}
export default antfu(
{
type: 'app',
@@ -18,4 +61,23 @@ export default antfu(
a11y: true,
},
},
{
files: ['**/*.vue'],
plugins: {
'template-style': templateStylePlugin,
},
rules: {
'template-style/single-line-simple-interpolation': 'error',
'vue/html-closing-bracket-newline': ['error', {
singleline: 'never',
multiline: 'always',
}],
'vue/multiline-html-element-content-newline': ['error', {
ignores: ['pre', 'textarea'],
ignoreWhenEmpty: true,
allowEmptyLines: false,
}],
'vue/mustache-interpolation-spacing': ['error', 'always'],
},
},
)
+4 -4
View File
@@ -17,7 +17,7 @@
"@boxicons/vue": "1.0.1",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@lucide/vue": "^1.30.0",
"@lucide/vue": "^1.31.0",
"@vueuse/core": "^14.4.0",
"axios": "^1.19.0",
"dayjs": "^1.11.21",
@@ -26,7 +26,7 @@
"es-toolkit": "^1.50.0",
"gsap": "^3.15.0",
"marked": "^18.0.9",
"pinia": "^4.0.2",
"pinia": "^4.0.3",
"pinia-plugin-persistedstate": "^4.7.1",
"vue": "^3.5.41",
"vue-router": "^5.2.0"
@@ -39,10 +39,10 @@
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.8.1",
"eslint-plugin-format": "^2.0.1",
"eslint-plugin-vuejs-accessibility": "^2.5.0",
"eslint-plugin-vuejs-accessibility": "^2.6.0",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"vite": "^8.2.1",
"vue-tsc": "^3.3.9"
"vue-tsc": "^3.3.10"
}
}
+33 -33
View File
@@ -18,8 +18,8 @@ importers:
specifier: ^5.3.0
version: 5.3.0
'@lucide/vue':
specifier: ^1.30.0
version: 1.30.0(vue@3.5.41(typescript@6.0.3))
specifier: ^1.31.0
version: 1.31.0(vue@3.5.41(typescript@6.0.3))
'@vueuse/core':
specifier: ^14.4.0
version: 14.4.0(vue@3.5.41(typescript@6.0.3))
@@ -45,21 +45,21 @@ importers:
specifier: ^18.0.9
version: 18.0.9
pinia:
specifier: ^4.0.2
version: 4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
specifier: ^4.0.3
version: 4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
pinia-plugin-persistedstate:
specifier: ^4.7.1
version: 4.7.1(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))
version: 4.7.1(pinia@4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))
vue:
specifier: ^3.5.41
version: 3.5.41(typescript@6.0.3)
vue-router:
specifier: ^5.2.0
version: 5.2.0(@vue/compiler-sfc@3.5.41)(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.3)(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))
version: 5.2.0(@vue/compiler-sfc@3.5.41)(pinia@4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.3)(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))
devDependencies:
'@antfu/eslint-config':
specifier: ^9.3.0
version: 9.3.0(@typescript-eslint/typescript-estree@8.66.0(typescript@6.0.3))(@typescript-eslint/utils@8.66.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(eslint-plugin-format@2.0.1(eslint@10.8.1(jiti@2.7.0)))(eslint-plugin-vuejs-accessibility@2.5.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0))(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)
version: 9.3.0(@typescript-eslint/typescript-estree@8.66.0(typescript@6.0.3))(@typescript-eslint/utils@8.66.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(eslint-plugin-format@2.0.1(eslint@10.8.1(jiti@2.7.0)))(eslint-plugin-vuejs-accessibility@2.6.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0))(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)
'@tailwindcss/vite':
specifier: ^4.3.3
version: 4.3.3(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))
@@ -79,8 +79,8 @@ importers:
specifier: ^2.0.1
version: 2.0.1(eslint@10.8.1(jiti@2.7.0))
eslint-plugin-vuejs-accessibility:
specifier: ^2.5.0
version: 2.5.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0)
specifier: ^2.6.0
version: 2.6.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0)
tailwindcss:
specifier: ^4.3.3
version: 4.3.3
@@ -91,8 +91,8 @@ importers:
specifier: ^8.2.1
version: 8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0)
vue-tsc:
specifier: ^3.3.9
version: 3.3.9(typescript@6.0.3)
specifier: ^3.3.10
version: 3.3.10(typescript@6.0.3)
packages:
@@ -354,8 +354,8 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lucide/vue@1.30.0':
resolution: {integrity: sha512-EF+N6eH/zaaZN0EY0y0URvKjMAj4QUeVtdDeFxjWHjhXihve/pBge+ahaeXLtqa1np0Ax5vsZEIsOTcsGZwPNQ==}
'@lucide/vue@1.31.0':
resolution: {integrity: sha512-NtjEHhcAa7umPh40wrRmlESJqNGdnpc7LziBKFnW3fmlrW0g2xMCDpdWU7WeEHRSl3xOpxbqiFTLKxoK3CoVCg==}
peerDependencies:
vue: '>=3.0.1'
@@ -849,8 +849,8 @@ packages:
'@vue/devtools-shared@8.2.1':
resolution: {integrity: sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==}
'@vue/language-core@3.3.9':
resolution: {integrity: sha512-in/68oAa4BCtVY6n/nkuhLIkV8DHYd2UivedJ6cMZ6UYtlq9jaoaSNUBHYCVO44z3nKg7MdE5OBoHKt5SxeBKQ==}
'@vue/language-core@3.3.10':
resolution: {integrity: sha512-CR7ByBbgPHqhxrioKPOcZBqttaozzLNwtkCzXQ+uF8gLPHnUe03srPnGpdtHD3zp+bq5iyVkZ1WNx7W564RPwg==}
'@vue/reactivity@3.5.41':
resolution: {integrity: sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==}
@@ -1297,8 +1297,8 @@ packages:
'@typescript-eslint/parser':
optional: true
eslint-plugin-vuejs-accessibility@2.5.0:
resolution: {integrity: sha512-oZ2fL4tS91Cm/ezH3BueNP+FtpbbeS627OSqqgp9/lsN//glmoPcLBT6D53xwGocLtyBybaT99tX4ThBh8+ytA==}
eslint-plugin-vuejs-accessibility@2.6.0:
resolution: {integrity: sha512-QtJO3UdLH+aydue/im7aBsYKO99ouJ6hm4wEHL1Xm/BwDfYTAmXEPhTImNwlOHp2bbid7rNY7IrHz+IZzGApYA==}
engines: {node: '>=16.0.0'}
peerDependencies:
eslint: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0
@@ -2045,8 +2045,8 @@ packages:
pinia:
optional: true
pinia@4.0.2:
resolution: {integrity: sha512-yKVVA7bSj5oRZFp/Ab9wLlmyb5gPUYEiIm4ryiWTe/xe7PtkRdMVOp1X1ggvq0c6Uj7Q0Du1HnV2mtAwM0Ks1g==}
pinia@4.0.3:
resolution: {integrity: sha512-XMQqpvjgG7LMqVhhFUzKLT4KEbsYbfZZ0CZU9PgdFm1O2VKBmIkykL8+SfNhOaT7sR0wT6BEgKT0YNDYWgmVRA==}
peerDependencies:
'@vue/devtools-api': ^8.1.5
typescript: '>=5.6.0'
@@ -2377,8 +2377,8 @@ packages:
vite:
optional: true
vue-tsc@3.3.9:
resolution: {integrity: sha512-TS3Y1ux/IRoE8OCP2PpACAeOseuIs0UvWrcr7u+w3PmfY+SlCfEf8zjrBgnQksHUgLpthi5vHlffcQTQTdPBZA==}
vue-tsc@3.3.10:
resolution: {integrity: sha512-YaDVxcW+CGtaOt3pZahMG5jYPx0hsUTxyEoPOTSMebcGUXP9lIBabQ14vfKMORb2CqqK5CxsNo/d1d+4IQwiKg==}
hasBin: true
peerDependencies:
typescript: '>=5.0.0'
@@ -2431,7 +2431,7 @@ packages:
snapshots:
'@antfu/eslint-config@9.3.0(@typescript-eslint/typescript-estree@8.66.0(typescript@6.0.3))(@typescript-eslint/utils@8.66.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(eslint-plugin-format@2.0.1(eslint@10.8.1(jiti@2.7.0)))(eslint-plugin-vuejs-accessibility@2.5.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0))(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)':
'@antfu/eslint-config@9.3.0(@typescript-eslint/typescript-estree@8.66.0(typescript@6.0.3))(@typescript-eslint/utils@8.66.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(eslint-plugin-format@2.0.1(eslint@10.8.1(jiti@2.7.0)))(eslint-plugin-vuejs-accessibility@2.6.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0))(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
'@antfu/install-pkg': 2.0.1
'@clack/prompts': 1.7.0
@@ -2472,7 +2472,7 @@ snapshots:
yaml-eslint-parser: 2.1.0
optionalDependencies:
eslint-plugin-format: 2.0.1(eslint@10.8.1(jiti@2.7.0))
eslint-plugin-vuejs-accessibility: 2.5.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0)
eslint-plugin-vuejs-accessibility: 2.6.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0)
transitivePeerDependencies:
- '@eslint/json'
- '@typescript-eslint/typescript-estree'
@@ -2687,7 +2687,7 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@lucide/vue@1.30.0(vue@3.5.41(typescript@6.0.3))':
'@lucide/vue@1.31.0(vue@3.5.41(typescript@6.0.3))':
dependencies:
vue: 3.5.41(typescript@6.0.3)
@@ -3086,7 +3086,7 @@ snapshots:
'@vue/devtools-shared@8.2.1': {}
'@vue/language-core@3.3.9':
'@vue/language-core@3.3.10':
dependencies:
'@volar/language-core': 2.4.28
'@vue/compiler-dom': 3.5.41
@@ -3551,7 +3551,7 @@ snapshots:
'@stylistic/eslint-plugin': 5.10.0(eslint@10.8.1(jiti@2.7.0))
'@typescript-eslint/parser': 8.66.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)
eslint-plugin-vuejs-accessibility@2.5.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0):
eslint-plugin-vuejs-accessibility@2.6.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0):
dependencies:
aria-query: 5.3.2
eslint: 10.8.1(jiti@2.7.0)
@@ -4423,13 +4423,13 @@ snapshots:
picomatch@4.0.5: {}
pinia-plugin-persistedstate@4.7.1(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))):
pinia-plugin-persistedstate@4.7.1(pinia@4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))):
dependencies:
defu: 6.1.7
optionalDependencies:
pinia: 4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
pinia: 4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)):
pinia@4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)):
dependencies:
'@vue/devtools-api': 8.1.5
nostics: 1.2.0
@@ -4685,7 +4685,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
vue-router@5.2.0(@vue/compiler-sfc@3.5.41)(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.3)(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)):
vue-router@5.2.0(@vue/compiler-sfc@3.5.41)(pinia@4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.3)(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)):
dependencies:
'@babel/generator': 8.0.0
'@vue-macros/common': 3.1.4(vue@3.5.41(typescript@6.0.3))
@@ -4708,7 +4708,7 @@ snapshots:
yaml: 2.9.0
optionalDependencies:
'@vue/compiler-sfc': 3.5.41
pinia: 4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
pinia: 4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
vite: 8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0)
transitivePeerDependencies:
- '@farmfe/core'
@@ -4720,10 +4720,10 @@ snapshots:
- unloader
- webpack
vue-tsc@3.3.9(typescript@6.0.3):
vue-tsc@3.3.10(typescript@6.0.3):
dependencies:
'@volar/typescript': 2.4.28
'@vue/language-core': 3.3.9
'@vue/language-core': 3.3.10
typescript: 6.0.3
vue@3.5.41(typescript@6.0.3):
-176
View File
@@ -392,179 +392,3 @@ export function getUsageRecordInsightsDiagnostics(data: UsageDiagnosticsQuery) {
params: data,
})
}
// Usage 记录的规范化 view model:组件只消费这个形状,兼容性回退只发生在本文件。
export interface UsageViewModel {
id: string
requestId: string
clientApiKeyId: string | null
kind: string
provider: string | null
authenticationKind: string | null
accountId: string | null
accountEmail: string | null
accountName: string | null
route: string
model: string
requestedModel: string | null
upstreamModel: string | null
serviceTier: string | null
statusCode: number | null
transport: string | null
stream: boolean | null
apiKind: string | null
attemptIndex: number | null
attemptCount: number
responseId: string | null
upstreamRequestId: string | null
protocol: string
httpVersion: string | null
clientStatusCode: number | null
upstreamStatusCode: number | null
websocketPool: { kind: string } | null
imageGenerationRequested: boolean
imageGenerationSucceeded: boolean | null
latencyMs: number | null
firstTokenLatencyMs: number | null
latencyDetails: UsageLatencyDetails | null
inputTokens: number | null
outputTokens: number | null
cachedTokens: number | null
cacheWriteTokens: number | null
reasoningTokens: number | null
imageInputTokens: number | null
imageOutputTokens: number | null
message: string
createdAt: string
createdAtDisplay: string
clientIp: string | null
userAgent: string | null
reasoningEffort: string | null
reasoningPreset: string | null
compact: boolean
requestKind: string | null
subagentKind: string | null
tokenDetails: UsageTokenDetails
billing: UsageBilling | null
costs: UsageCost[]
costCoverage: UsageCostCoverage
firstTokenLatencyMsDisplay: string
latencyMsDisplay: string
logicalOutcome: string
providerMetadata: Record<string, unknown>
requestBody?: unknown
responseBody?: unknown
attempts?: UsageAttempt[]
attemptsComplete?: boolean
}
const CORE_METADATA_KEYS = new Set([
'protocol',
'logicalOutcome',
'attemptCount',
'requestedModel',
'upstreamModel',
'clientIp',
'userAgent',
'reasoningEffort',
'reasoningPreset',
'compact',
'requestKind',
'subagentKind',
'transport',
'httpVersion',
'clientStatusCode',
'upstreamStatusCode',
'responseId',
'upstreamRequestId',
'websocketPool',
'imageGenerationRequested',
'imageGenerationSucceeded',
'latencyDetails',
])
function metadataRecord(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {}
}
/** 一次性兼容 normalize:顶层 Core 字段优先,旧响应中的 metadata 回退只发生在这里。 */
export function normalizeUsageRecord(record: UsageRecord | UsageRecordDetail): UsageViewModel {
const metadata = metadataRecord(record.metadata)
const legacy = <T>(top: T | null | undefined, key: string): T | null | undefined =>
top ?? (metadata[key] as T | undefined)
const latencyDetails = legacy(record.latencyDetails, 'latencyDetails') ?? null
const providerMetadata: Record<string, unknown> = {}
for (const [key, value] of Object.entries(metadata)) {
if (!CORE_METADATA_KEYS.has(key))
providerMetadata[key] = value
}
return {
id: record.id,
requestId: record.requestId,
clientApiKeyId: record.clientApiKeyId,
kind: record.kind,
provider: record.provider,
authenticationKind: record.authenticationKind,
accountId: record.accountId,
accountEmail: record.accountEmail,
accountName: record.accountName,
route: record.route,
model: record.model,
requestedModel: legacy(record.requestedModel, 'requestedModel') ?? null,
upstreamModel: legacy(record.upstreamModel, 'upstreamModel') ?? null,
serviceTier: record.serviceTier,
statusCode: record.statusCode,
transport: legacy(record.transport, 'transport') ?? null,
stream: typeof metadata.stream === 'boolean' ? metadata.stream : null,
apiKind: typeof metadata.apiKind === 'string' ? metadata.apiKind : null,
attemptIndex: record.attemptIndex,
attemptCount: legacy(record.attemptCount, 'attemptCount') ?? 0,
responseId: legacy(record.responseId, 'responseId') ?? null,
upstreamRequestId: legacy(record.upstreamRequestId, 'upstreamRequestId') ?? null,
protocol: legacy(record.protocol, 'protocol') ?? '',
httpVersion: legacy(record.httpVersion, 'httpVersion') ?? null,
clientStatusCode: legacy(record.clientStatusCode, 'clientStatusCode') ?? null,
upstreamStatusCode: legacy(record.upstreamStatusCode, 'upstreamStatusCode') ?? null,
websocketPool: legacy(record.websocketPool, 'websocketPool') ?? null,
imageGenerationRequested:
legacy(record.imageGenerationRequested, 'imageGenerationRequested') ?? false,
imageGenerationSucceeded:
legacy(record.imageGenerationSucceeded, 'imageGenerationSucceeded') ?? null,
latencyMs: record.latencyMs,
firstTokenLatencyMs: record.firstTokenLatencyMs,
latencyDetails,
inputTokens: record.inputTokens,
outputTokens: record.outputTokens,
cachedTokens: record.cachedTokens,
cacheWriteTokens: record.cacheWriteTokens,
reasoningTokens: record.reasoningTokens,
imageInputTokens: record.imageInputTokens,
imageOutputTokens: record.imageOutputTokens,
message: record.message,
createdAt: record.createdAt,
createdAtDisplay: record.createdAtDisplay,
clientIp: legacy(record.clientIp, 'clientIp') ?? null,
userAgent: legacy(record.userAgent, 'userAgent') ?? null,
reasoningEffort: legacy(record.reasoningEffort, 'reasoningEffort') ?? null,
reasoningPreset: legacy(record.reasoningPreset, 'reasoningPreset') ?? null,
compact: legacy(record.compact, 'compact') ?? false,
requestKind: legacy(record.requestKind, 'requestKind') ?? null,
subagentKind: legacy(record.subagentKind, 'subagentKind') ?? null,
tokenDetails: record.tokenDetails,
billing: record.billing,
costs: record.costs,
costCoverage: record.costCoverage,
firstTokenLatencyMsDisplay: record.firstTokenLatencyMsDisplay,
latencyMsDisplay: record.latencyMsDisplay,
logicalOutcome: legacy(record.logicalOutcome, 'logicalOutcome') ?? '',
providerMetadata,
requestBody: metadata.requestBody,
responseBody: metadata.responseBody,
attempts: 'attempts' in record ? record.attempts : undefined,
attemptsComplete: 'attemptsComplete' in record ? record.attemptsComplete : undefined,
}
}
@@ -32,11 +32,11 @@ function updateGroup(groupId: string, selected: boolean) {
<div
v-for="group in groups"
:key="group.id"
class="flex min-h-11 items-center justify-between gap-3 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3.5 py-2.5"
class="flex min-h-11 items-center justify-between gap-3 rounded-cp-control bg-cp-subtle px-3.5 py-2.5"
>
<span class="flex min-w-0 items-center gap-2.5">
<span
class="size-3.5 shrink-0 rounded-[4px]"
class="size-3.5 shrink-0 rounded-sm"
:style="{ backgroundColor: group.color }"
aria-hidden="true"
/>
@@ -50,7 +50,7 @@ function updateGroup(groupId: string, selected: boolean) {
</span>
<span
v-if="!group.enabled"
class="shrink-0 text-[11px] font-emphasis text-(--cp-text-muted)"
class="shrink-0 text-[11px] font-emphasis text-cp-muted-text"
>
已禁用
</span>
@@ -58,7 +58,7 @@ function updateGroup(groupId: string, selected: boolean) {
</div>
<p
v-else
class="m-0 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3.5 py-3 text-[12px] font-emphasis text-(--cp-text-secondary)"
class="m-0 rounded-cp-control bg-cp-subtle px-3.5 py-3 text-[12px] font-emphasis text-cp-secondary"
>
{{ loading ? '正在加载分组...' : '暂无分组,请先在分组管理中创建。' }}
</p>
@@ -21,11 +21,11 @@ const fullNames = computed(() => props.groups
<span
v-for="group in groups"
:key="group.id"
class="size-4 shrink-0 rounded-[4px]"
class="size-4 shrink-0 rounded-sm"
:style="{ backgroundColor: group.color }"
:title="group.name"
aria-hidden="true"
/>
</div>
<span v-else class="text-xs text-(--cp-text-muted)">—</span>
<span v-else class="text-xs text-cp-muted-text">—</span>
</template>
@@ -7,7 +7,7 @@ import BaseCheckbox from '@/components/base/BaseCheckbox.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import { providerDisplayName } from '@/utils/providers'
import { formatProviderLabel } from '@/utils/providers'
const props = withDefaults(
defineProps<{
@@ -76,7 +76,7 @@ function toggle(groupId: string) {
function providerSummary(group: AccountGroup) {
return Object.entries(group.providerCounts)
.filter(([, count]) => count > 0)
.map(([provider, count]) => `${providerDisplayName(provider) ?? provider} ${count}`)
.map(([provider, count]) => `${formatProviderLabel(provider)} ${count}`)
.join(' · ')
}
</script>
@@ -90,72 +90,73 @@ function providerSummary(group: AccountGroup) {
:disabled="disabled || loading"
>
<template #prefix>
<Search class="size-4 text-(--cp-text-tertiary)" />
<Search class="size-4 text-cp-tertiary" />
</template>
</BaseInput>
<BaseScrollbar
v-if="visibleGroups.length > 0"
max-height="260px"
view-class="grid gap-2 pr-2"
>
<div
v-for="group in visibleGroups"
:key="group.id"
class="flex w-full items-start gap-3 rounded-(--cp-input-radius-base) border-0 px-3 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
:class="selectedIds.includes(group.id)
? 'bg-(--cp-info-bg)'
: 'bg-(--cp-bg-subtle) hover:bg-(--cp-default-bg-hover)'"
>
<BaseCheckbox
:model-value="selectedIds.includes(group.id)"
:disabled="disabled || loading"
:label="`选择${group.name}`"
@update:model-value="toggle(group.id)"
/>
<span class="min-w-0 flex-1">
<span class="flex min-w-0 items-center gap-2">
<strong class="min-w-0 truncate text-[13px] text-(--cp-text-primary)">
{{ group.name }}
</strong>
<span
v-if="!group.enabled"
class="shrink-0 rounded-md bg-(--cp-warning-bg) px-1.5 py-1 text-[10px] leading-none font-bold text-(--cp-warning-text)"
>
已禁用
<div class="grid gap-2">
<div
v-for="group in visibleGroups"
:key="group.id"
class="flex w-full items-start gap-3 rounded-cp-control border-0 px-3 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-cp-info-border"
:class="selectedIds.includes(group.id)
? 'bg-cp-info-bg'
: 'bg-cp-subtle hover:bg-cp-default-hover'"
>
<BaseCheckbox
:model-value="selectedIds.includes(group.id)"
:disabled="disabled || loading"
:label="`选择${group.name}`"
@update:model-value="toggle(group.id)"
/>
<span class="min-w-0 flex-1">
<span class="flex min-w-0 items-center gap-2">
<strong class="min-w-0 truncate text-[13px] text-cp-primary">
{{ group.name }}
</strong>
<span
v-if="!group.enabled"
class="shrink-0 rounded-md bg-cp-warning-bg px-1.5 py-1 text-[10px] leading-none font-bold text-cp-warning-text"
>
已禁用
</span>
</span>
<span class="mt-1 block text-[11px] font-emphasis text-cp-secondary">
{{ group.memberCount }} 个账号<template v-if="providerSummary(group)"> · {{ providerSummary(group) }}</template>
</span>
</span>
<span class="mt-1 block text-[11px] font-emphasis text-(--cp-text-secondary)">
{{ group.memberCount }} 个账号<template v-if="providerSummary(group)"> · {{ providerSummary(group) }}</template>
</span>
</span>
</div>
</div>
</BaseScrollbar>
<BaseEmpty
v-else-if="groups.length === 0 && !loading"
compact
size="sm"
title="暂无可选分组"
description="请先在分组管理中创建分组。"
/>
<BaseEmpty
v-else-if="!loading"
compact
size="sm"
title="没有匹配分组"
description="请调整搜索关键词。"
/>
<div
v-if="selectedIds.length === 0"
class="rounded-(--cp-input-radius-base) px-3.5 py-3"
class="rounded-cp-control px-3.5 py-3"
:class="emptyCopy.tone === 'warning'
? 'bg-(--cp-warning-bg) text-(--cp-warning-text)'
: 'bg-(--cp-bg-subtle) text-(--cp-text-secondary)'"
? 'bg-cp-warning-bg text-cp-warning-text'
: 'bg-cp-subtle text-cp-secondary'"
>
<p class="m-0 text-[12px] font-bold">
{{ emptyCopy.title }}
</p>
<p class="mt-1 mb-0 text-[11px] leading-[1.5] font-emphasis">
<p class="mt-1 mb-0 text-[11px] leading-normal font-emphasis">
{{ emptyCopy.description }}
</p>
</div>

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