Compare commits

..
32 Commits
Author SHA1 Message Date
ZyyandCodex 53739ebe5c chore: bump version to 3.2.13
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 15:20:29 +08:00
ZyyandCodex 7f420257a6 fix(api): keep provider degradation out of healthz
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 15:14:28 +08:00
ZyyandCodex 4f85209dc9 fix(config): centralize runtime data paths
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 15:14:28 +08:00
ZyyandCodex cd89e2e8fc fix(admin): align quota metadata and usage layout
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 15:14:28 +08:00
ZyyandCodex 0cb5a29699 chore: bump version to 3.2.12
Co-authored-by: Codex <noreply@openai.com>
2026-08-19 23:52:11 +08:00
ZyyandCodex 61788804b4 fix(openai): recover cross-account websocket continuations
Co-authored-by: Codex <noreply@openai.com>
2026-08-19 23:40:40 +08:00
ZyyandCodex 2751ea6aa4 fix(openai): preserve cross-account session recovery
Co-authored-by: Codex <noreply@openai.com>
2026-08-19 23:05:48 +08:00
ZyyandCodex 58fe66789c chore(deps): bump h2 to 0.4.16
Resolve RUSTSEC-2026-0258 reported by cargo audit.

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


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

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

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

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

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

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

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


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

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

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

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

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

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

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


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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:05:19 +08:00
ZyyandCodex 0558693949 fix(openai): harden refresh recovery handling
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 13:37:54 +08:00
ZyyandCodex 2a9cb02cc9 fix(openai): align OAuth refresh error handling
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 12:30:05 +08:00
ZyyandCodex 5ce36931a8 chore: bump version to 3.2.9
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 11:38:49 +08:00
ZyyandCodex 0d826d9eba fix(openai): align OAuth token refresh
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 11:14:03 +08:00
ZyyandCodex 304242957a fix(accounts): preserve upstream error messages
Co-authored-by: Codex <noreply@openai.com>
2026-08-16 13:28:28 +08:00
ZyyandCodex ef37ae8d9e chore: bump version to 3.2.8
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 23:31:04 +08:00
ZyyandCodex fc13d4f2b6 fix(frontend): restore responsive component behavior
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 20:38:48 +08:00
ZyyandCodex 78f9c0c800 chore: bump version to 3.2.7
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:54:52 +08:00
ZyyandCodex a3bc7bc445 fix(frontend): increase account table row height
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:54:02 +08:00
ZyyandCodex 0132114285 fix(openai): let upstream validate catalog models
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:45:06 +08:00
ZyyandCodex 5970efbb7c fix(container): refresh runtime packages before scans
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:34:41 +08:00
ZyyandCodex 7bf28b571d chore: bump version to 3.2.6
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 15:47:32 +08:00
ZyyandCodex bc3bd6bc43 refactor(frontend): unify component design system
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 15:39:31 +08:00
ZyyandCodex 9acfab0a86 chore(frontend): enforce template interpolation layout
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 12:31:10 +08:00
ZyyandCodex 3e5a84d2a0 fix(frontend): refine dashboard capacity display
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 12:31:06 +08:00
ZyyandCodex 92948f51a9 fix(frontend): remove table bottom gap
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 11:59:58 +08:00
252 changed files with 9774 additions and 6100 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
+35 -35
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",
@@ -1246,7 +1246,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -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",
@@ -1686,9 +1686,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.15"
version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
dependencies = [
"atomic-waker",
"bytes",
@@ -2714,7 +2714,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -2984,7 +2984,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -3628,23 +3628,23 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[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",
+3 -2
View File
@@ -27,8 +27,9 @@ impl LoadableConfig for GatewayConfig {
return Err(ConfigError::InvalidField("schema_version"));
}
self.host.resolve_and_validate(source_dir)?;
let runtime_data_dir = self.host.runtime_data_dir().to_path_buf();
self.store
.resolve_and_validate(source_dir)
.resolve_and_validate(&runtime_data_dir)
.map_err(|_| ConfigError::InvalidField("store"))?;
self.admin
.resolve_and_validate(source_dir)
@@ -37,7 +38,7 @@ impl LoadableConfig for GatewayConfig {
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("api"))?;
self.openai
.resolve_and_validate(source_dir)
.resolve_and_validate(&runtime_data_dir)
.map_err(|_| ConfigError::InvalidField("openai"))?;
self.xai
.resolve_and_validate(source_dir)
+8
View File
@@ -18,10 +18,18 @@ fn config_loader_should_load_complete_terminal_example() {
fn config_loader_should_resolve_paths_relative_to_config_file() {
let (config, _directory) = parse_config(&valid_config()).expect("resolved config");
let debug = format!("{config:?}");
assert!(debug.contains(".runtime/data"));
assert!(debug.contains(".runtime/logs"));
assert!(debug.contains("frontend/dist"));
}
#[test]
fn config_loader_should_reject_missing_runtime_data_dir() {
let config = valid_config().replace(" runtime_data_dir: '../.runtime/data'\n", "");
assert!(parse_config(&config).is_err());
}
#[test]
fn config_loader_should_inject_connection_passwords_into_urls() {
parse_config(&valid_config()).expect("Store validates password injection into both URLs");
@@ -570,13 +570,34 @@ pub enum QuotaLocalUsageAttribution {
Unavailable,
}
/// Provider quota bucket 中窗口的官方位置语义。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderQuotaWindowRole {
Primary,
Secondary,
Monthly,
}
impl ProviderQuotaWindowRole {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Primary => "primary",
Self::Secondary => "secondary",
Self::Monthly => "monthly",
}
}
}
/// 一个 Provider quota 窗口的公共投影。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderQuotaWindow {
pub key: String,
pub group: String,
pub label: String,
pub source: Option<String>,
pub limit_id: Option<String>,
pub limit_name: Option<String>,
pub role: Option<ProviderQuotaWindowRole>,
pub local_usage_attribution: QuotaLocalUsageAttribution,
pub window_seconds: Option<u64>,
pub used_percent: Option<f64>,
@@ -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 -2
View File
@@ -165,7 +165,9 @@ fn representative_window_usage_should_accept_account_wide_rolling_window_without
key: "free-rolling-24h".to_owned(),
group: "shortTerm".to_owned(),
label: "日限额".to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(86_400),
used_percent: None,
@@ -234,7 +236,9 @@ fn quota_window(
key: group.to_owned(),
group: group.to_owned(),
label: group.to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds,
used_percent,
@@ -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(()),
}
}
@@ -1257,7 +1262,9 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop
key: "primary".to_owned(),
group: "shortTerm".to_owned(),
label: "5小时限额".to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(5 * 60 * 60),
used_percent: Some(97.0),
@@ -1386,7 +1393,9 @@ async fn accounts_list_should_not_derive_rate_limited_from_provider_quota_view()
key: "primary".to_owned(),
group: "shortTerm".to_owned(),
label: "5小时限额".to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(5 * 60 * 60),
used_percent: Some(100.0),
@@ -1431,7 +1440,9 @@ async fn accounts_list_should_not_derive_exhaustion_from_provider_quota_view() {
key: "primary".to_owned(),
group: "shortTerm".to_owned(),
label: "5小时限额".to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(5 * 60 * 60),
used_percent: Some(100.0),
@@ -1477,7 +1488,9 @@ async fn accounts_list_should_attach_local_usage_to_quota_windows() {
key: "primary".to_owned(),
group: "shortTerm".to_owned(),
label: "5小时限额".to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(5 * 60 * 60),
used_percent: Some(97.0),
@@ -1542,7 +1555,9 @@ async fn accounts_list_should_not_attach_account_usage_to_model_specific_quota_w
key: "core-primary".to_owned(),
group: "weekly".to_owned(),
label: "周额度".to_owned(),
source: Some("codex".to_owned()),
limit_id: Some("codex".to_owned()),
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(7 * 24 * 60 * 60),
used_percent: Some(1.0),
@@ -1554,8 +1569,10 @@ async fn accounts_list_should_not_attach_account_usage_to_model_specific_quota_w
ProviderQuotaWindow {
key: "codex-bengalfox-primary".to_owned(),
group: "weekly".to_owned(),
label: "GPT-5.3-Codex-Spark · 周额度".to_owned(),
source: Some("codex_bengalfox".to_owned()),
label: "周额度".to_owned(),
limit_id: Some("codex_bengalfox".to_owned()),
limit_name: Some("GPT-5.3-Codex-Spark".to_owned()),
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::Unavailable,
window_seconds: Some(7 * 24 * 60 * 60),
used_percent: Some(0.0),
@@ -1650,6 +1667,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();
@@ -305,8 +305,12 @@ pub struct AccountQuotaView {
pub struct AccountQuotaWindowView {
pub key: String,
pub group: String,
pub limit_id: Option<String>,
pub limit_name: Option<String>,
pub role: Option<String>,
pub window_seconds: Option<u64>,
pub label_display: String,
pub window_label_display: String,
pub used_percent: Option<f64>,
pub used_percent_display: String,
pub limit_reached: bool,
@@ -1603,7 +1607,9 @@ fn quota_window_view(window: ProviderQuotaWindow) -> AccountQuotaWindowView {
key,
group,
label,
source: _,
limit_id,
limit_name,
role,
local_usage_attribution: _,
window_seconds,
used_percent,
@@ -1612,10 +1618,17 @@ fn quota_window_view(window: ProviderQuotaWindow) -> AccountQuotaWindowView {
local_usage,
provider_data: _,
} = window;
let label_display = limit_name
.as_ref()
.map_or_else(|| label.clone(), |name| format!("{name} · {label}"));
AccountQuotaWindowView {
label_display: label,
label_display,
window_label_display: label,
key,
group,
limit_id,
limit_name,
role: role.map(|value| value.as_str().to_owned()),
window_seconds,
used_percent,
used_percent_display: used_percent
+21 -3
View File
@@ -1,4 +1,4 @@
//! `/healthz` 对 Core、Store 与 Host worker 健康事实的聚合。
//! `/healthz` 对 Core、Store 与 Host 关键 worker 健康事实的聚合。
use std::sync::Arc;
use std::time::Duration;
@@ -6,8 +6,10 @@ use std::time::Duration;
use axum::{extract::State, http::StatusCode};
use futures::future::join_all;
use gateway_core::health::{
HealthProbe, HealthState, WorkerHealthSnapshot, WorkerHealthSource, WorkerRuntimeState,
HealthProbe, HealthState, WorkerHealthKey, WorkerHealthSnapshot, WorkerHealthSource,
WorkerRuntimeState,
};
use gateway_core::task::WorkerKind;
use crate::ApiState;
@@ -32,7 +34,13 @@ impl HealthStatus {
}
pub(crate) async fn healthy(&self) -> bool {
if !self.workers.snapshot().iter().all(worker_is_healthy) {
if !self
.workers
.snapshot()
.iter()
.filter(|worker| worker_affects_healthz(worker))
.all(worker_is_healthy)
{
return false;
}
let checks = self.probes.iter().map(|probe| probe.check());
@@ -54,6 +62,16 @@ pub(crate) async fn healthz(State(state): State<ApiState>) -> StatusCode {
}
}
fn worker_affects_healthz(worker: &WorkerHealthSnapshot) -> bool {
let WorkerHealthKey::Task(id) = &worker.key else {
return false;
};
!matches!(
id.kind(),
WorkerKind::OAuthRefresh | WorkerKind::QuotaCatalogHealth
)
}
fn worker_is_healthy(worker: &WorkerHealthSnapshot) -> bool {
match worker.state {
WorkerRuntimeState::Disabled | WorkerRuntimeState::Standby => true,
@@ -73,7 +73,6 @@ pub(super) fn response_header_is_forwardable(name: &str, connection_options: &[S
| "x-openai-organization"
| "x-openai-project"
| "x-codex-installation-id"
| "x-codex-turn-state"
| "x-codex-turn-metadata"
)
}
@@ -11,7 +11,9 @@ use gateway_core::{
StartedExecution,
},
error::GatewayError,
health::{WorkerHealthKey, WorkerHealthSnapshot, WorkerHealthSource, WorkerRuntimeState},
routing::PublicModelId,
task::{WorkerId, WorkerKind},
};
use tower::ServiceExt;
@@ -31,6 +33,75 @@ async fn healthz_should_return_no_content_when_all_inputs_are_healthy() {
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn healthz_should_ignore_provider_maintenance_worker_failure() {
let worker_health = StaticWorkerHealth(vec![worker_snapshot(
WorkerKind::QuotaCatalogHealth,
WorkerRuntimeState::BackingOff,
)]);
let response = crate::openai::api_router_with_worker_health(
Arc::new(UnusedExecution),
Arc::new(worker_health),
)
.await
.oneshot(
Request::builder()
.uri("/healthz")
.body(Body::empty())
.expect("health request"),
)
.await
.expect("health response");
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn healthz_should_reject_critical_worker_failure() {
let worker_health = StaticWorkerHealth(vec![worker_snapshot(
WorkerKind::RuntimeSnapshotReconciliation,
WorkerRuntimeState::BackingOff,
)]);
let response = crate::openai::api_router_with_worker_health(
Arc::new(UnusedExecution),
Arc::new(worker_health),
)
.await
.oneshot(
Request::builder()
.uri("/healthz")
.body(Body::empty())
.expect("health request"),
)
.await
.expect("health response");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[derive(Clone)]
struct StaticWorkerHealth(Vec<WorkerHealthSnapshot>);
impl WorkerHealthSource for StaticWorkerHealth {
fn snapshot(&self) -> Vec<WorkerHealthSnapshot> {
self.0.clone()
}
}
fn worker_snapshot(kind: WorkerKind, state: WorkerRuntimeState) -> WorkerHealthSnapshot {
let id = WorkerId::try_new(kind, "health-test").expect("worker ID");
WorkerHealthSnapshot {
key: WorkerHealthKey::Task(id),
state,
consecutive_failures: 1,
completed_cycles: 0,
last_fencing_token: None,
last_success_at: None,
last_failure_at: None,
last_error: Some("test worker failure".to_owned()),
}
}
struct UnusedExecution;
impl ExecutionService for UnusedExecution {
+21 -1
View File
@@ -44,9 +44,29 @@ pub(super) async fn api_router(execution: Arc<dyn ExecutionService>) -> axum::Ro
api_router_with_origins(execution, Vec::new()).await
}
pub(super) async fn api_router_with_worker_health(
execution: Arc<dyn ExecutionService>,
worker_health: Arc<dyn WorkerHealthSource>,
) -> axum::Router {
api_router_with_origins_and_worker_health(execution, Vec::new(), worker_health).await
}
pub(super) async fn api_router_with_origins(
execution: Arc<dyn ExecutionService>,
cors_allowed_origins: Vec<String>,
) -> axum::Router {
api_router_with_origins_and_worker_health(
execution,
cors_allowed_origins,
Arc::new(EmptyWorkerHealth),
)
.await
}
async fn api_router_with_origins_and_worker_health(
execution: Arc<dyn ExecutionService>,
cors_allowed_origins: Vec<String>,
worker_health: Arc<dyn WorkerHealthSource>,
) -> axum::Router {
let admin = crate::admin::AdminTestFixture::new().await;
gateway_api::initialize(
@@ -59,7 +79,7 @@ pub(super) async fn api_router_with_origins(
execution,
admin.services,
Vec::new(),
Arc::new(EmptyWorkerHealth),
worker_health,
Arc::new(TestLifecycle::default()),
)
.expect("API bundle")
@@ -788,6 +788,10 @@ async fn streaming_response_should_append_ordinary_headers_and_skip_unrepresenta
let trace = Arc::new(Trace::default());
let headers = vec![
ProviderResponseHeader::new("x-models-etag", Bytes::from_static(b"models-v2")),
ProviderResponseHeader::new(
"x-codex-turn-state",
Bytes::from_static(b"turn-state-from-upstream"),
),
ProviderResponseHeader::new("x-future-multi", Bytes::from_static(b"first")),
ProviderResponseHeader::new("x-future-multi", Bytes::from_static(b"second")),
ProviderResponseHeader::new("x-future-bytes", Bytes::from_static(b"\xffopaque")),
@@ -834,6 +838,13 @@ async fn streaming_response_should_append_ordinary_headers_and_skip_unrepresenta
.map(HeaderValue::as_bytes),
Some(b"\xffopaque".as_slice())
);
assert_eq!(
response
.headers()
.get("x-codex-turn-state")
.and_then(|value| value.to_str().ok()),
Some("turn-state-from-upstream")
);
assert!(response.headers().get("authorization").is_none());
assert!(response.headers().get("connection").is_none());
assert!(response.headers().get("x-hop-secret").is_none());
@@ -411,6 +411,10 @@ async fn websocket_response_metadata_should_preserve_ordinary_headers_without_bl
ProviderResponseHeader::new("x-future-header", Bytes::from_static(b"future-value")),
ProviderResponseHeader::new("x-future-multi", Bytes::from_static(b"first")),
ProviderResponseHeader::new("x-future-multi", Bytes::from_static(b"second")),
ProviderResponseHeader::new(
"x-codex-turn-state",
Bytes::from_static(b"turn-state-from-upstream"),
),
ProviderResponseHeader::new("x-future-bytes", Bytes::from_static(b"\xffopaque")),
ProviderResponseHeader::new("bad\0name", Bytes::from_static(b"unrepresentable")),
ProviderResponseHeader::new(
@@ -483,6 +487,10 @@ async fn websocket_response_metadata_should_preserve_ordinary_headers_without_bl
// 官方 metadata 是 string map,同名多值只能保持既有的后值覆盖语义。
assert_eq!(headers.get("x-future-multi"), Some(&json!("second")));
assert_eq!(headers.get("x-request-id"), Some(&json!("req_upstream")));
assert_eq!(
headers.get("x-codex-turn-state"),
Some(&json!("turn-state-from-upstream"))
);
for omitted in [
"x-future-bytes",
"bad\0name",
@@ -1162,10 +1162,11 @@ where
true
}
/// 原生续写的原账号在本地调度阶段已不可用时,改用完整输入重放。
/// 原生续写的原账号在本地调度阶段已不可用时,交给 Provider 执行跨账号恢复。
///
/// 这不是强制 Smart:下一次 attempt 仍使用请求配置的调度策略。只有网关持有
/// 对应 Provider 的可携带会话状态时,才可安全移除 native handle 并换号。
/// 对应 Provider 的会话状态时才进入恢复;是否保留 native handle、执行 probe
/// 或使用完整 transcript,由 Provider 自己的协议边界决定。
fn prepare_unavailable_native_continuation_replay(&mut self, error: &ProviderError) -> bool {
if self.account_selection.required_account().is_some()
|| self.continuation_attempt != ContinuationAttempt::Native
@@ -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,
@@ -362,9 +362,14 @@ pub enum ContinuationAttempt {
None,
/// 使用 Store 解析出的原生 handle 与账号绑定。
Native,
/// 在原账号上用完整连接内 transcript 重放。
/// 在原账号上执行 Provider 定义的恢复。
///
/// 只有持有可携带 transcript 的 Provider 才能清除 native handle 后重放;
/// 只持有 opaque state 的 Provider 必须保留 continuation 依赖或返回客户端错误。
ReplayOwner,
/// transcript 已可携带,允许选择其他账号。
/// 允许选择其他账号并执行 Provider 定义的恢复。
///
/// 该枚举本身不证明客户端 input 完整,也不授权 Provider 清除 previous-response。
ReplayAny,
}
@@ -493,6 +493,31 @@ fn bare_atomic_response_failed(response_id: &str) -> ProviderError {
.with_atomic_client_events(vec![failed])
}
fn atomic_history_unavailable(response_id: &str) -> ProviderError {
let failed = ProviderEvent::wire(
ProtocolWireEvent::json(
"openai",
Some("response.failed".to_owned()),
json!({
"type": "response.failed",
"response": {
"id": response_id,
"status": "failed",
"error": {
"code": "previous_response_not_found",
"message": "Previous response was not found. Retrying the full request."
}
}
}),
)
.expect("history unavailable wire"),
);
ProviderError::new(ProviderErrorKind::InvalidRequest, UpstreamSendState::Sent)
.with_status(400)
.with_continuation_failure(ContinuationFailure::HistoryUnavailable)
.with_atomic_client_events(vec![failed])
}
fn image_stream(image_output_tokens: Option<u64>) -> Vec<Result<GatewayEvent, ProviderError>> {
let mut usage = Usage::new();
usage.input_tokens = Some(12);
@@ -1481,7 +1506,7 @@ fn streaming_native_continuation_delivers_started_event_before_later_output() {
}
#[test]
fn native_continuation_replays_owner_before_safely_switching_account() {
fn native_continuation_uses_provider_defined_owner_then_cross_account_recovery() {
let Operation::Generate(generate) = generate_operation() else {
panic!("generate operation");
};
@@ -1536,7 +1561,7 @@ fn native_continuation_replays_owner_before_safely_switching_account() {
CancellationToken::new(),
))
.expect("start execution");
block_on(session.collect_uncommitted()).expect("portable replay succeeds");
block_on(session.collect_uncommitted()).expect("provider-defined recovery succeeds");
block_on(session.commit_downstream(Some(200))).expect("commit response");
let contexts = provider.contexts.lock().expect("contexts lock");
@@ -1561,7 +1586,7 @@ fn native_continuation_replays_owner_before_safely_switching_account() {
}
#[test]
fn unavailable_native_continuation_replays_with_the_configured_account_policy() {
fn unavailable_native_continuation_uses_provider_recovery_with_the_configured_account_policy() {
let Operation::Generate(generate) = generate_operation() else {
panic!("generate operation");
};
@@ -1602,7 +1627,7 @@ fn unavailable_native_continuation_replays_with_the_configured_account_policy()
CancellationToken::new(),
))
.expect("start execution");
block_on(session.collect_uncommitted()).expect("portable replay succeeds");
block_on(session.collect_uncommitted()).expect("provider-defined recovery succeeds");
block_on(session.commit_downstream(Some(200))).expect("commit response");
let contexts = provider.contexts.lock().expect("contexts lock");
@@ -1625,6 +1650,76 @@ fn unavailable_native_continuation_replays_with_the_configured_account_policy()
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
}
#[test]
fn continuation_history_failure_without_replay_proof_is_delivered_without_hidden_retry() {
let Operation::Generate(generate) = generate_operation() else {
panic!("generate operation");
};
let operation = Operation::Generate(generate.with_provider_session_state(
ProviderSessionState::new("openai", Map::new()).expect("provider session state"),
));
let route_plan = plan(&operation);
let (coordinator, _, provider) = coordinator(vec![
Script::ObservedStream {
account_id: "acct_one",
items: vec![Err(atomic_history_unavailable("response-history-missing"))],
},
Script::Stream {
account_id: "acct_two",
items: complete_stream(None),
},
]);
let continuation = NativeContinuationPin::new(
PreviousResponseId::new("previous-response"),
PreviousResponseId::new("upstream-response"),
ClientApiKeyId::new("key_client_1").expect("client key"),
ProviderKind::new("openai").expect("provider"),
ProviderAccountId::new("acct_one").expect("account"),
);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
Some(ContinuationBinding::Pinned(continuation)),
CancellationToken::new(),
))
.expect("start execution");
let terminal = block_on(session.next_event())
.expect("history failure remains deliverable")
.expect("terminal history failure batch");
assert_eq!(
terminal.commit_requirement(),
CommitRequirement::CommitBeforeDelivery
);
let events = terminal.into_provider_events();
let failure = events
.iter()
.filter_map(ProviderEvent::wire_event)
.find(|wire| wire.event_type() == Some("response.failed"))
.expect("raw response.failed event");
assert_eq!(
failure
.data()
.pointer("/response/error/code")
.and_then(Value::as_str),
Some("previous_response_not_found")
);
block_on(session.commit_downstream(Some(200))).expect("commit terminal history failure");
assert!(matches!(
block_on(session.next_event()),
Err(EngineError::Provider(_))
));
let contexts = provider.contexts.lock().expect("contexts lock");
assert_eq!(contexts.len(), 1);
assert_eq!(
contexts[0].continuation_attempt(),
ContinuationAttempt::Native
);
}
#[test]
fn diagnostic_account_reaches_provider_and_matching_metadata_succeeds() {
let operation = operation(RetrySafety::Idempotent);
+12 -1
View File
@@ -41,6 +41,7 @@ pub fn load_config<T: LoadableConfig>() -> Result<T, ConfigError> {
#[serde(deny_unknown_fields)]
pub struct HostConfig {
pub listen: ListenConfig,
pub runtime_data_dir: PathBuf,
pub logging: LoggingConfig,
#[serde(default)]
pub system_update: SystemUpdateConfig,
@@ -76,11 +77,21 @@ impl HostConfig {
"host.worker_shutdown_timeout_seconds",
));
}
if self.runtime_data_dir.as_os_str().is_empty() {
return Err(ConfigError::InvalidField("host.runtime_data_dir"));
}
resolve_relative_path(source_dir, &mut self.runtime_data_dir);
self.logging.resolve_and_validate(source_dir)?;
self.system_update.resolve_and_validate(source_dir)?;
self.system_update
.resolve_and_validate(source_dir, &self.runtime_data_dir)?;
Ok(())
}
#[must_use]
pub fn runtime_data_dir(&self) -> &Path {
&self.runtime_data_dir
}
#[must_use]
pub const fn drain_timeout(&self) -> Duration {
Duration::from_secs(self.drain_timeout_seconds)
@@ -81,10 +81,10 @@ impl Default for SystemUpdateConfig {
});
let update_state_file = environment_value("CPR_UPDATE_STATE_FILE")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/app/.runtime/data/update-state.json"));
.unwrap_or_else(|| PathBuf::from("update-state.json"));
let update_lock_file = environment_value("CPR_UPDATE_LOCK_FILE")
.map(PathBuf::from)
.unwrap_or_else(|| update_state_file.with_extension("lock"));
.unwrap_or_else(|| update_state_file.with_file_name("update.lock"));
let update_temp_dir = environment_value("CPR_UPDATE_TEMP_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| state::default_temp_dir(&update_state_file));
@@ -120,21 +120,28 @@ impl Default for SystemUpdateConfig {
}
impl SystemUpdateConfig {
pub(crate) fn resolve_and_validate(&mut self, source_dir: &Path) -> Result<(), ConfigError> {
for path in [
self.executable_path.as_mut(),
Some(&mut self.web_dist_dir),
Some(&mut self.update_state_file),
Some(&mut self.update_lock_file),
Some(&mut self.update_temp_dir),
]
.into_iter()
.flatten()
pub(crate) fn resolve_and_validate(
&mut self,
source_dir: &Path,
runtime_data_dir: &Path,
) -> Result<(), ConfigError> {
for path in [self.executable_path.as_mut(), Some(&mut self.web_dist_dir)]
.into_iter()
.flatten()
{
if path.is_relative() {
*path = source_dir.join(&*path);
}
}
for path in [
&mut self.update_state_file,
&mut self.update_lock_file,
&mut self.update_temp_dir,
] {
if path.is_relative() {
*path = runtime_data_dir.join(&*path);
}
}
if self.version.trim().is_empty() {
return Err(ConfigError::InvalidField("host.system_update.version"));
}
+38 -3
View File
@@ -34,6 +34,40 @@ fn host_config_resolves_only_host_owned_relative_paths() {
);
}
#[test]
fn host_config_resolves_runtime_data_dir_relative_to_configuration() {
let mut config = valid_config();
config
.resolve_and_validate(std::path::Path::new("/srv/gateway"))
.expect("valid host config");
assert_eq!(
config.runtime_data_dir(),
PathBuf::from("/srv/gateway/runtime-data")
);
}
#[test]
fn host_config_derives_update_paths_from_runtime_data_dir() {
let mut config = valid_config();
config
.resolve_and_validate(std::path::Path::new("/srv/gateway"))
.expect("valid host config");
assert_eq!(
(
config.system_update.update_state_file,
config.system_update.update_lock_file,
config.system_update.update_temp_dir,
),
(
PathBuf::from("/srv/gateway/runtime-data/update-state.json"),
PathBuf::from("/srv/gateway/runtime-data/update.lock"),
PathBuf::from("/srv/gateway/runtime-data/update-tmp"),
)
);
}
#[test]
fn host_config_rejects_zero_drain_window() {
let mut config = valid_config();
@@ -48,9 +82,9 @@ fn host_config_rejects_zero_drain_window() {
fn valid_config() -> HostConfig {
let system_update = SystemUpdateConfig {
update_state_file: PathBuf::from(".runtime/update-state.json"),
update_lock_file: PathBuf::from(".runtime/update-state.lock"),
update_temp_dir: PathBuf::from(".runtime/update-tmp"),
update_state_file: PathBuf::from("update-state.json"),
update_lock_file: PathBuf::from("update.lock"),
update_temp_dir: PathBuf::from("update-tmp"),
..SystemUpdateConfig::default()
};
HostConfig {
@@ -58,6 +92,7 @@ fn valid_config() -> HostConfig {
host: "127.0.0.1".to_owned(),
port: 8080,
},
runtime_data_dir: PathBuf::from("runtime-data"),
logging: LoggingConfig {
level: "info".to_owned(),
stdout: true,
@@ -23,6 +23,7 @@ fn logging_requires_at_least_one_sink() {
host: "127.0.0.1".to_owned(),
port: 8080,
},
runtime_data_dir: PathBuf::from("/tmp/runtime-data"),
logging: LoggingConfig {
level: "info".to_owned(),
stdout: false,
@@ -98,6 +99,7 @@ fn write_oauth_recovery_log(directory: PathBuf) {
host: "127.0.0.1".to_owned(),
port: 8080,
},
runtime_data_dir: PathBuf::from("/tmp/runtime-data"),
logging: LoggingConfig {
level: "off".to_owned(),
stdout: false,
@@ -208,11 +208,24 @@ pub struct RateLimitWindow {
pub reset_at: Option<i64>,
}
/// 限流项键的来源。
///
/// HTTP headers 和事件里的 `metered_limit_name` 提供稳定 `limit_id`;
/// WebSocket `additional_rate_limits` 在部分协议版本里只以可读名称为 map key,
/// 此时必须由持久化层用既有名称元数据解析,不能把名称误当成稳定 ID。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RateLimitKeySource {
LimitId,
LimitName,
}
/// 单个计量项的限流信息。
#[derive(Debug, Clone, PartialEq)]
pub struct RateLimitDetails {
/// 稳定的计量项 ID。
pub limit_id: String,
/// [`limit_id`](Self::limit_id) 是稳定 ID,还是名称生成的临时解析键。
pub key_source: RateLimitKeySource,
/// 上游提供的可读名称。
pub limit_name: Option<String>,
/// 当前请求是否被允许。
@@ -341,20 +354,40 @@ pub fn parse_rate_limits_event(value: &Value) -> Option<ParsedRateLimits> {
return None;
}
let limit_id = rate_limit_name(value)
.map(normalize_limit_id)
let explicit_limit_id = top_level_rate_limit_id(value).map(normalize_limit_id);
let explicit_limit_name = non_empty_string(value, "limit_name");
let default_limit_id = explicit_limit_id
.clone()
.unwrap_or_else(|| "codex".to_string());
let limit_name = value
.get("metered_limit_name")
.and_then(Value::as_str)
.and_then(|_| value.get("limit_name"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|name| !name.is_empty())
.map(ToString::to_string);
let details = value
.get("rate_limits")
.and_then(|details| parse_details_from_object(details, &limit_id, limit_name));
let default_details = value.get("rate_limits").and_then(|details| {
parse_details_from_object(
details,
&default_limit_id,
explicit_limit_name,
RateLimitKeySource::LimitId,
)
});
let mut limits = parse_additional_rate_limits(value);
let matching_additional_ids = default_details
.as_ref()
.map(|default| {
limits
.iter()
.filter(|(_, additional)| same_rate_limit_facts(default, additional))
.map(|(limit_id, _)| limit_id.clone())
.collect::<Vec<_>>()
})
.unwrap_or_default();
// 当前 WebSocket 协议会把活动具名桶同时镜像到顶层 `rate_limits`,但不附
// `metered_limit_name`。只要 additional 中存在相同事实,顶层就不是 core 桶。
let default_is_additional_alias =
explicit_limit_id.is_none() && !matching_additional_ids.is_empty();
if let Some(details) = default_details
&& !default_is_additional_alias
{
limits.insert(default_limit_id.clone(), details);
}
let credits = value.get("credits").and_then(parse_credits_from_object);
let plan_type = value
.get("plan_type")
@@ -363,17 +396,18 @@ pub fn parse_rate_limits_event(value: &Value) -> Option<ParsedRateLimits> {
.filter(|plan| !plan.is_empty())
.map(ToString::to_string);
if details.is_none() && credits.is_none() && plan_type.is_none() {
if limits.is_empty() && credits.is_none() && plan_type.is_none() {
return None;
}
let mut limits = BTreeMap::new();
if let Some(details) = details {
limits.insert(limit_id.clone(), details);
}
let active_limit = explicit_limit_id.or_else(|| match matching_additional_ids.as_slice() {
[limit_id] => Some(limit_id.clone()),
[] if !limits.is_empty() => Some(default_limit_id),
_ => None,
});
Some(ParsedRateLimits {
limits,
active_limit: Some(limit_id),
active_limit,
credits,
plan_type,
promo_message: value
@@ -597,6 +631,7 @@ fn parse_details_from_lookup(
}
Some(RateLimitDetails {
limit_id: limit_id.to_string(),
key_source: RateLimitKeySource::LimitId,
limit_name,
allowed,
limit_reached,
@@ -687,6 +722,7 @@ fn parse_details_from_object(
value: &Value,
limit_id: &str,
limit_name: Option<String>,
key_source: RateLimitKeySource,
) -> Option<RateLimitDetails> {
let primary = value.get("primary").and_then(parse_window_from_object);
let secondary = value.get("secondary").and_then(parse_window_from_object);
@@ -697,6 +733,7 @@ fn parse_details_from_object(
}
Some(RateLimitDetails {
limit_id: limit_id.to_string(),
key_source,
limit_name,
allowed,
limit_reached,
@@ -752,11 +789,59 @@ fn value_as_positive_i64(value: &Value) -> Option<i64> {
.filter(|value| *value > 0)
}
fn rate_limit_name(value: &Value) -> Option<&str> {
fn rate_limit_id(value: &Value) -> Option<&str> {
value
.get("metered_limit_name")
.or_else(|| value.get("limit_name"))
.or_else(|| value.get("limit_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn top_level_rate_limit_id(value: &Value) -> Option<&str> {
rate_limit_id(value).or_else(|| {
value
.get("limit_name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
})
}
fn non_empty_string(value: &Value, field: &str) -> Option<String> {
value
.get(field)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn parse_additional_rate_limits(value: &Value) -> BTreeMap<String, RateLimitDetails> {
value
.get("additional_rate_limits")
.and_then(Value::as_object)
.into_iter()
.flatten()
.filter_map(|(map_key, value)| {
let explicit_limit_id = rate_limit_id(value).map(normalize_limit_id);
let (limit_id, key_source) = explicit_limit_id.map_or_else(
|| (normalize_limit_id(map_key), RateLimitKeySource::LimitName),
|limit_id| (limit_id, RateLimitKeySource::LimitId),
);
let limit_name = non_empty_string(value, "limit_name")
.or_else(|| Some(map_key.trim().to_owned()).filter(|name| !name.is_empty()));
parse_details_from_object(value, &limit_id, limit_name, key_source)
.map(|details| (limit_id, details))
})
.collect()
}
fn same_rate_limit_facts(left: &RateLimitDetails, right: &RateLimitDetails) -> bool {
left.allowed == right.allowed
&& left.limit_reached == right.limit_reached
&& left.primary == right.primary
&& left.secondary == right.secondary
}
fn normalize_limit_id(value: impl AsRef<str>) -> String {
@@ -1,7 +1,7 @@
use gateway_protocol::openai::events::{
RateLimitWindow, TokenUsage, extract_sse_usage, extract_usage, is_codex_quota_header_name,
is_rate_limit_header_name, parse_rate_limit_headers, parse_rate_limits_event,
rate_limits_to_header_pairs, retry_after_seconds_from_body,
RateLimitKeySource, RateLimitWindow, TokenUsage, extract_sse_usage, extract_usage,
is_codex_quota_header_name, is_rate_limit_header_name, parse_rate_limit_headers,
parse_rate_limits_event, rate_limits_to_header_pairs, retry_after_seconds_from_body,
};
use serde_json::json;
@@ -483,6 +483,58 @@ fn parse_rate_limits_event_should_extract_websocket_metered_limit() {
);
}
#[test]
fn parse_rate_limits_event_should_preserve_name_keyed_additional_limit() {
let event = json!({
"type": "codex.rate_limits",
"plan_type": "pro",
"rate_limits": {
"allowed": true,
"limit_reached": false,
"primary": {
"used_percent": 0,
"window_minutes": 10080,
"reset_after_seconds": 354916,
"reset_at": 1787396838
},
"secondary": null
},
"additional_rate_limits": {
"GPT-5.3-Codex-Spark": {
"allowed": true,
"limit_reached": false,
"primary": {
"used_percent": 0,
"window_minutes": 10080,
"reset_after_seconds": 354916,
"reset_at": 1787396838
},
"secondary": null
}
},
"credits": null
});
let parsed = parse_rate_limits_event(&event).expect("rate-limit event");
let details = parsed
.limits
.get("gpt_5.3_codex_spark")
.expect("name-keyed additional limit");
assert_eq!(parsed.active_limit.as_deref(), Some("gpt_5.3_codex_spark"));
assert_eq!(details.key_source, RateLimitKeySource::LimitName);
assert_eq!(details.limit_name.as_deref(), Some("GPT-5.3-Codex-Spark"));
assert_eq!(
details.primary,
Some(RateLimitWindow {
used_percent: 0.0,
window_minutes: Some(10080),
reset_at: Some(1787396838),
})
);
assert!(!parsed.limits.contains_key("codex"));
}
#[test]
fn parse_rate_limits_event_should_reject_unrelated_event_type() {
for event in [
+2 -2
View File
@@ -50,7 +50,7 @@ impl StoreBundle {
pub async fn initialize(mut config: StoreConfig) -> StoreResult<StoreBundle> {
const REDIS_NAMESPACE: &str = "codex-proxy-rs";
config.resolve_and_validate(std::path::Path::new("."))?;
config.validate_resolved()?;
let pool = postgres::connect_and_migrate(&config.database_url()?, config.pool).await?;
let redis_client = ::redis::Client::open(config.redis_url()?)
.map_err(|_| redis_unavailable("create Redis client"))?;
@@ -209,7 +209,7 @@ pub(crate) fn backup_ports(
config: &StoreConfig,
) -> StoreResult<BackupStorePorts> {
let staging = Arc::new(backup::staging::StagingArea::open(
std::path::PathBuf::from(".runtime/data/backup-staging"),
config.backup_staging_dir().to_path_buf(),
backup::staging::DEFAULT_MAX_ARCHIVE_BYTES,
)?);
let repository = Arc::new(postgres::PgBackupRepository::new(pool));
+28 -1
View File
@@ -1,5 +1,7 @@
//! Store 启动配置、环境变量解析与校验。
use std::path::{Path, PathBuf};
use super::*;
pub(crate) const DATABASE_URL_ENV: &str = "CPR_DATABASE_URL";
@@ -15,6 +17,8 @@ pub struct StoreConfig {
pub(crate) redis: StoreConnectionConfig,
#[serde(default)]
pub(crate) pool: StorePoolConfig,
#[serde(skip)]
backup_staging_dir: PathBuf,
}
/// PostgreSQL 连接池预算;acquire 超时决定池耗尽时快速失败而非排队积压。
@@ -47,7 +51,18 @@ impl StorePoolConfig {
}
impl StoreConfig {
pub fn resolve_and_validate(&mut self, _source_dir: &std::path::Path) -> StoreResult<()> {
pub fn resolve_and_validate(&mut self, runtime_data_dir: &Path) -> StoreResult<()> {
if runtime_data_dir.as_os_str().is_empty() {
return Err(StoreError::InvalidData {
entity: "store config",
message: "runtime_data_dir must not be empty".to_owned(),
});
}
self.backup_staging_dir = runtime_data_dir.join("backup-staging");
self.validate_resolved()
}
pub(crate) fn validate_resolved(&mut self) -> StoreResult<()> {
if let Some(url) = optional_environment_value(DATABASE_URL_ENV)? {
self.database.url = url;
}
@@ -63,6 +78,12 @@ impl StoreConfig {
self.database.validate("database")?;
self.redis.validate("redis")?;
self.pool.validate()?;
if self.backup_staging_dir.as_os_str().is_empty() {
return Err(StoreError::InvalidData {
entity: "store config",
message: "runtime_data_dir was not resolved".to_owned(),
});
}
Ok(())
}
@@ -73,6 +94,12 @@ impl StoreConfig {
pub(crate) fn redis_url(&self) -> StoreResult<String> {
self.redis.connection_url("redis")
}
/// 返回由统一运行数据根目录派生的备份暂存目录。
#[must_use]
pub fn backup_staging_dir(&self) -> &Path {
&self.backup_staging_dir
}
}
pub(crate) fn optional_environment_value(name: &'static str) -> StoreResult<Option<String>> {
@@ -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, updated_at = greatest(now(), $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,32 @@
use gateway_store::StoreConfig;
const PASSWORD: &str = "111111111111111111111111111111111111111111111111";
#[test]
fn store_config_should_derive_backup_staging_from_runtime_data_dir() {
let root = tempfile::tempdir().expect("runtime data root");
let mut config = valid_config();
config
.resolve_and_validate(root.path())
.expect("valid Store configuration");
assert_eq!(
config.backup_staging_dir(),
root.path().join("backup-staging")
);
}
fn valid_config() -> StoreConfig {
serde_json::from_value(serde_json::json!({
"database": {
"url": "postgres://codex_proxy@127.0.0.1:5432/codex_proxy",
"password": PASSWORD,
},
"redis": {
"url": "redis://127.0.0.1:6379/",
"password": PASSWORD,
},
}))
.expect("test Store configuration")
}
@@ -1,3 +1,4 @@
mod config;
mod postgres;
mod redis;
mod s3;
@@ -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,115 @@ async fn core_quota_batch_reads_only_observed_accounts_in_one_contract_call() {
database.close().await;
}
#[tokio::test]
async fn quota_observation_touch_preserves_quota_state_and_advances_account_update_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(90))
.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.timestamp_micros(),
chrono::DateTime::<Utc>::from(touched_at).timestamp_micros()
);
assert!(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 +405,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 +1354,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 +1399,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 +1411,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
@@ -21,12 +21,18 @@ async fn store_bundle_worker_plan_and_leader_lease_are_single_use_and_fenced() {
return;
};
let database = TestDatabase::create(&database_url).await;
let config = store_config(&database.url, &redis_url);
let runtime_data = tempfile::tempdir().expect("Store runtime data");
let mut config = store_config(&database.url, &redis_url);
config
.resolve_and_validate(runtime_data.path())
.expect("resolved Store config");
let mut first = initialize(config.clone())
.await
.expect("first Store bundle");
let second = initialize(config).await.expect("second Store bundle");
assert!(runtime_data.path().join("backup-staging").is_dir());
let kinds = first
.take_worker_contributions()
.into_iter()
+55 -84
View File
@@ -19,14 +19,15 @@ use gateway_admin::model::provider_credentials::{
PreparedCredentialCreate, PreparedCredentialImport, PreparedCredentialRotation,
PreparedCredentialRotationFacts, ProviderDocument, ProviderExport,
ProviderExportCredentialInput, ProviderModel, ProviderModels, ProviderQuota,
ProviderQuotaRequest, ProviderQuotaWindow, QuotaLocalUsageAttribution,
ProviderQuotaRequest, ProviderQuotaWindow, ProviderQuotaWindowRole, QuotaLocalUsageAttribution,
};
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,7 +50,9 @@ 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::profile::{
CodexDesktopReleaseSnapshot, CodexDesktopReleaseStatus, CodexWireProfile, CodexWireProfileState,
@@ -258,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();
@@ -380,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)
}
@@ -546,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));
}
@@ -728,39 +734,24 @@ fn project_quota_snapshot(snapshot: CodexAccountQuotaSnapshot) -> ProviderQuota
.windows()
.iter()
.filter(|window| should_project_quota_window(window))
.map(|window| {
let mut data = Map::new();
data.insert(
"source".to_owned(),
Value::String(window.source().to_owned()),
);
data.insert(
"role".to_owned(),
Value::String(quota_role(window.role()).to_owned()),
);
ProviderQuotaWindow {
key: window.key().to_owned(),
group: quota_group(window.kind()).to_owned(),
label: codex_quota_window_label(
window.limit_name(),
window.kind(),
window.role(),
window.source(),
window.window_seconds(),
),
source: Some(window.source().to_owned()),
local_usage_attribution: if window.is_account_wide() {
QuotaLocalUsageAttribution::AccountWide
} else {
QuotaLocalUsageAttribution::Unavailable
},
window_seconds: window.window_seconds(),
used_percent: window.used_percent(),
reset_at: window.reset_at(),
limit_reached: window.limit_reached(),
local_usage: None,
provider_data: Some(ProviderDocument::new(OpaqueProviderData::new(data))),
}
.map(|window| ProviderQuotaWindow {
key: window.key().to_owned(),
group: quota_group(window.kind()).to_owned(),
label: codex_quota_window_label(window.kind(), window.role(), window.window_seconds()),
limit_id: Some(window.source().to_owned()),
limit_name: window.limit_name().map(str::to_owned),
role: Some(quota_role(window.role())),
local_usage_attribution: if window.is_account_wide() {
QuotaLocalUsageAttribution::AccountWide
} else {
QuotaLocalUsageAttribution::Unavailable
},
window_seconds: window.window_seconds(),
used_percent: window.used_percent(),
reset_at: window.reset_at(),
limit_reached: window.limit_reached(),
local_usage: None,
provider_data: None,
})
.collect();
// 快照级 limit_reached 只看滚动后的窗口触顶:顶层标记是观测事实,不能
@@ -800,23 +791,21 @@ const fn quota_group(kind: CodexQuotaWindowKind) -> &'static str {
}
}
const fn quota_role(role: CodexQuotaWindowRole) -> &'static str {
const fn quota_role(role: CodexQuotaWindowRole) -> ProviderQuotaWindowRole {
match role {
CodexQuotaWindowRole::Primary => "primary",
CodexQuotaWindowRole::Secondary => "secondary",
CodexQuotaWindowRole::Monthly => "monthly",
CodexQuotaWindowRole::Primary => ProviderQuotaWindowRole::Primary,
CodexQuotaWindowRole::Secondary => ProviderQuotaWindowRole::Secondary,
CodexQuotaWindowRole::Monthly => ProviderQuotaWindowRole::Monthly,
}
}
/// 按窗口时长显示汉化额度名;非核心桶补上上游桶名称作为前缀。
/// 按窗口时长显示汉化额度名;额度桶名称通过独立字段投影。
fn codex_quota_window_label(
limit_name: Option<&str>,
kind: CodexQuotaWindowKind,
role: CodexQuotaWindowRole,
source: &str,
window_seconds: Option<u64>,
) -> String {
let base = match kind {
match kind {
CodexQuotaWindowKind::Monthly => "月额度".to_owned(),
CodexQuotaWindowKind::Weekly => "周额度".to_owned(),
CodexQuotaWindowKind::ShortTerm => {
@@ -827,29 +816,7 @@ fn codex_quota_window_label(
}
}
CodexQuotaWindowKind::Other => custom_quota_window_label(window_seconds, role),
};
if let Some(prefix) = codex_quota_window_prefix(limit_name, source) {
return format!("{prefix} · {base}");
}
base
}
fn codex_quota_window_prefix(limit_name: Option<&str>, source: &str) -> Option<String> {
let source_identifier = quota_identifier(source);
if matches!(source_identifier.as_str(), "core" | "codex") {
return None;
}
let label = limit_name.unwrap_or(source);
match quota_identifier(label).as_str() {
"premium" => Some("Premium".to_owned()),
"code_review" => Some("代码审查".to_owned()),
"spend_control" => Some("消费控制".to_owned()),
_ => Some(label.replace('_', " ")),
}
}
fn quota_identifier(value: &str) -> String {
value.trim().to_ascii_lowercase().replace(['-', ' '], "_")
}
fn custom_quota_window_label(window_seconds: Option<u64>, role: CodexQuotaWindowRole) -> String {
@@ -1275,31 +1242,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",
}
}
@@ -1368,9 +1341,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,
@@ -36,15 +36,17 @@ pub struct OpenAiConfig {
}
impl OpenAiConfig {
/// 校验 Provider-owned 字段,并定位旧版会话锚点持续使用的身份密钥。
pub fn resolve_and_validate(&mut self, source_dir: &Path) -> Result<(), OpenAiConfigError> {
/// 校验 Provider-owned 字段,并从统一运行数据目录定位会话身份密钥。
pub fn resolve_and_validate(
&mut self,
runtime_data_dir: &Path,
) -> Result<(), OpenAiConfigError> {
self.api.validate()?;
self.ws_pool.validate()?;
self.quota.validate()?;
self.auth.validate()?;
self.wire_profile.validate()?;
let project_root = source_dir.parent().unwrap_or(source_dir);
self.identity_secret_path = project_root.join(".runtime/data/identity_hmac_secret");
self.identity_secret_path = runtime_data_dir.join("identity_hmac_secret");
Ok(())
}
@@ -101,7 +103,7 @@ impl Default for OpenAiConfig {
quota: CodexQuotaSettings::default(),
auth: CodexAuthSettings::default(),
wire_profile: CodexWireProfileConfig::default(),
identity_secret_path: PathBuf::from(".runtime/data/identity_hmac_secret"),
identity_secret_path: PathBuf::new(),
}
}
}
@@ -376,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,
@@ -389,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。
@@ -800,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
@@ -821,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;
@@ -998,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,保证本次补全不会丢失后续刷新能力。
@@ -1012,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))
}
@@ -1133,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> {
@@ -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,7 +31,7 @@ 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";
@@ -527,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);
@@ -671,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(),
@@ -709,7 +697,7 @@ impl CodexOAuthAdminService {
.as_ref()
.map(ExposeSecret::expose_secret),
);
Ok((secret, tokens.id_token, tokens.expires_in))
Ok((secret, tokens.id_token))
}
}
@@ -0,0 +1,173 @@
//! OpenAI quota 文档的规范化 map 与上游 `/usage` 输入适配。
use std::collections::BTreeMap;
use gateway_protocol::openai::events::{RateLimitDetails, RateLimitKeySource};
use serde_json::{Map, Value};
pub(super) const DEFAULT_CODEX_LIMIT_ID: &str = "codex";
pub(super) const RATE_LIMITS_BY_LIMIT_ID: &str = "rate_limits_by_limit_id";
/// 官方新协议的多桶视图:每个 `limit_id` 对应一个独立快照。
#[derive(Debug, Default)]
pub(super) struct RateLimitSnapshotsByLimitId {
snapshots: BTreeMap<String, Map<String, Value>>,
}
impl RateLimitSnapshotsByLimitId {
/// 接收上游 `/usage` 形状或已经规范化的 map;输出始终只保留 map。
pub(super) fn take_from_document(quota: &mut Map<String, Value>) -> Self {
let mut result = Self::default();
if let Some(by_limit_id) = quota
.remove(RATE_LIMITS_BY_LIMIT_ID)
.and_then(|value| value.as_object().cloned())
{
for (raw_limit_id, value) in by_limit_id {
let Some(limit_id) = normalize_limit_id(&raw_limit_id) else {
continue;
};
let mut snapshot = snapshot_object(value);
snapshot.insert("limit_id".to_owned(), Value::String(limit_id.clone()));
result.snapshots.insert(limit_id, snapshot);
}
}
if let Some(rate_limit) = quota.remove("rate_limit") {
result.insert_usage_snapshot(DEFAULT_CODEX_LIMIT_ID, None, rate_limit);
}
if let Some(rate_limit) = quota.remove("code_review_rate_limit") {
result.insert_usage_snapshot("code_review", Some("code_review"), rate_limit);
}
let additional = quota
.remove("additional_rate_limits")
.and_then(|value| value.as_array().cloned())
.unwrap_or_default();
for (index, value) in additional.into_iter().enumerate() {
let Value::Object(mut item) = value else {
result.insert_usage_snapshot(&format!("additional_{index}"), None, value);
continue;
};
let limit_id = item_limit_id(&item).unwrap_or_else(|| format!("additional_{index}"));
let limit_name = item
.get("limit_name")
.and_then(Value::as_str)
.map(str::to_owned);
let rate_limit = item.remove("rate_limit").unwrap_or(Value::Null);
result.insert_usage_snapshot(&limit_id, limit_name.as_deref(), rate_limit);
}
result
}
fn insert_usage_snapshot(
&mut self,
limit_id: &str,
limit_name: Option<&str>,
rate_limit: Value,
) {
let Some(limit_id) = normalize_limit_id(limit_id) else {
return;
};
let mut snapshot = snapshot_object(rate_limit);
snapshot.insert("limit_id".to_owned(), Value::String(limit_id.clone()));
if let Some(limit_name) = limit_name {
snapshot.insert(
"limit_name".to_owned(),
Value::String(limit_name.to_owned()),
);
}
// map 协议中一个 ID 只能有一个快照;规范化 map 与顶层账号桶优先。
self.snapshots.entry(limit_id).or_insert(snapshot);
}
pub(super) fn upsert(
&mut self,
limit_id: &str,
details: &RateLimitDetails,
rate_limit: Map<String, Value>,
) {
let mut snapshot = self.snapshots.remove(limit_id).unwrap_or_default();
for field in [
"allowed",
"limit_reached",
"primary_window",
"secondary_window",
] {
snapshot.remove(field);
}
snapshot.extend(rate_limit);
snapshot.insert("limit_id".to_owned(), Value::String(limit_id.to_owned()));
if let Some(name) = details.limit_name.as_ref() {
snapshot.insert("limit_name".to_owned(), Value::String(name.clone()));
}
self.snapshots.insert(limit_id.to_owned(), snapshot);
}
/// 把 wire 观察解析到稳定 `limit_id`。
///
/// WebSocket 的 `additional_rate_limits` 可能只返回可读名称;这时只接受
/// `/usage` 或 HTTP headers 已建立的唯一名称映射。无法唯一解析就不落库,
/// 避免凭名称猜 ID 或污染默认 `codex` 桶。
pub(super) fn resolve_limit_id(&self, details: &RateLimitDetails) -> Option<String> {
if details.key_source == RateLimitKeySource::LimitId {
return normalize_limit_id(&details.limit_id);
}
let limit_name = details.limit_name.as_deref()?.trim();
let mut matches = self
.snapshots
.iter()
.filter(|(_, snapshot)| {
snapshot
.get("limit_name")
.and_then(Value::as_str)
.is_some_and(|stored| stored.trim().eq_ignore_ascii_case(limit_name))
})
.map(|(limit_id, _)| limit_id.clone());
let resolved = matches.next()?;
matches.next().is_none().then_some(resolved)
}
pub(super) fn write_to_document(self, quota: &mut Map<String, Value>) {
let by_limit_id = self
.snapshots
.into_iter()
.map(|(limit_id, snapshot)| (limit_id, Value::Object(snapshot)))
.collect::<Map<_, _>>();
quota.insert(
RATE_LIMITS_BY_LIMIT_ID.to_owned(),
Value::Object(by_limit_id),
);
}
}
pub(super) fn canonicalize_rate_limit_document(
mut quota: Map<String, Value>,
) -> Map<String, Value> {
let snapshots = RateLimitSnapshotsByLimitId::take_from_document(&mut quota);
snapshots.write_to_document(&mut quota);
quota
}
fn snapshot_object(value: Value) -> Map<String, Value> {
value.as_object().cloned().unwrap_or_else(|| {
let mut snapshot = Map::new();
snapshot.insert("rate_limit".to_owned(), value);
snapshot
})
}
fn item_limit_id(item: &Map<String, Value>) -> Option<String> {
item.get("limit_id")
.or_else(|| item.get("metered_feature"))
.or_else(|| item.get("limit_name"))
.and_then(Value::as_str)
.and_then(normalize_limit_id)
}
fn normalize_limit_id(value: &str) -> Option<String> {
let value = value.trim();
(!value.is_empty() && value.len() <= 128 && !value.chars().any(char::is_control))
.then(|| value.to_ascii_lowercase().replace('-', "_"))
}
@@ -1,8 +1,10 @@
//! Codex quota 服务编排:权威额度事实、展示投影、主动/被动同步与 429 冷却。
//!
//! - [`document`]:上游 `/usage` 输入到多桶 map 的单向规范化。
//! - [`snapshot`]:快照/窗口解析、聚合、O(1) 滚动与调度信号。
//! - [`evidence`]:额度接口错误到凭据/额度事实的分类。
mod document;
pub(crate) mod evidence;
pub(crate) mod snapshot;
@@ -19,8 +21,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::{
@@ -38,6 +40,10 @@ use crate::transport::{CodexBackendClient, CodexClientError, CodexRequestContext
use super::agent_identity::{CodexAgentIdentityTaskService, PreparedCodexRuntimeCredential};
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
use document::{
DEFAULT_CODEX_LIMIT_ID, RATE_LIMITS_BY_LIMIT_ID, RateLimitSnapshotsByLimitId,
canonicalize_rate_limit_document,
};
use evidence::{QuotaEndpointFailure, classify_quota_endpoint_failure};
use snapshot::{
parse_account_quota_snapshot, quota_projection_ttl, quota_snapshot_from_observation,
@@ -646,7 +652,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(());
}
@@ -688,10 +712,25 @@ impl CodexCredentialQuotaService {
let Some(rate_limits) = parse_rate_limit_headers(headers) else {
return Ok(false);
};
let has_quota_facts = rate_limits
.limits
.values()
.any(|details| passive_rate_limit_snapshot(details).is_some());
self.synchronize_passive_rate_limits(account, std::slice::from_ref(&rate_limits))
.await
}
/// 把一次推理响应中采集的结构化限流观察合并后单次落库。
pub async fn synchronize_passive_rate_limits(
&self,
account: &ProviderAccount,
rate_limits: &[ParsedRateLimits],
) -> Result<bool, CodexCredentialQuotaError> {
if rate_limits.is_empty() {
return Ok(false);
}
let has_quota_facts = rate_limits.iter().any(|observation| {
observation
.limits
.values()
.any(|details| passive_rate_limit_snapshot(details).is_some())
});
let existing = self
.store
.get_quotas(std::slice::from_ref(account.id()))
@@ -716,14 +755,14 @@ impl CodexCredentialQuotaService {
.compare_and_swap_quota(QuotaObservation {
account_id: account.id().clone(),
expected_revision: account.revision(),
quota: OpaqueProviderData::new(merge_passive_quota(existing, &rate_limits)),
quota: OpaqueProviderData::new(merge_passive_quota(existing, rate_limits)),
observed_at: SystemTime::now(),
state,
})
.await?;
return Ok(outcome != QuotaWriteOutcome::Conflict);
}
let quota = merge_passive_quota(existing, &rate_limits);
let quota = merge_passive_quota(existing, rate_limits);
let observed_at = SystemTime::now();
let snapshot = parse_account_quota_snapshot(
account.id().clone(),
@@ -930,8 +969,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 +1087,7 @@ impl CodexCredentialQuotaService {
}
}
fn quota_refresh_can_update(
fn quota_refresh_allows_recovery(
account: &ProviderAccount,
refreshed: &CodexAccountQuotaSnapshot,
previous: Option<&CodexAccountQuotaSnapshot>,
@@ -1138,18 +1198,48 @@ fn access_token_is_current(account: &ProviderAccount, now: SystemTime) -> bool {
fn merge_passive_quota(
mut quota: Map<String, Value>,
rate_limits: &ParsedRateLimits,
observations: &[ParsedRateLimits],
) -> Map<String, Value> {
let active_limit = rate_limits
.active_limit
.as_deref()
.or_else(|| rate_limits.limits.contains_key("codex").then_some("codex"));
if let Some(active_limit) = active_limit {
quota.insert(
"active_limit".to_owned(),
Value::String(active_limit.to_owned()),
);
let mut snapshots = RateLimitSnapshotsByLimitId::take_from_document(&mut quota);
for rate_limits in observations {
let default_is_named_alias = default_limit_is_named_alias(rate_limits);
let mut resolved_limit_ids = BTreeMap::new();
for (wire_limit_id, details) in &rate_limits.limits {
// HTTP 与 WebSocket 都可能把活动具名桶镜像为默认 `codex` 窗口。
// 同一 wire 观察里存在相同具名事实时丢弃镜像,不触碰 core 桶。
if wire_limit_id == DEFAULT_CODEX_LIMIT_ID && default_is_named_alias {
continue;
}
let Some(limit_id) = snapshots.resolve_limit_id(details) else {
continue;
};
let Some(rate_limit) = passive_rate_limit_snapshot(details) else {
continue;
};
snapshots.upsert(&limit_id, details, rate_limit);
resolved_limit_ids.insert(wire_limit_id.as_str(), limit_id);
}
let active_limit = rate_limits
.active_limit
.as_deref()
.and_then(|wire_limit_id| {
resolved_limit_ids.get(wire_limit_id).cloned().or_else(|| {
(!rate_limits.limits.contains_key(wire_limit_id))
.then(|| wire_limit_id.to_owned())
})
})
.or_else(|| resolved_limit_ids.get(DEFAULT_CODEX_LIMIT_ID).cloned());
if let Some(active_limit) = active_limit {
quota.insert("active_limit".to_owned(), Value::String(active_limit));
}
merge_passive_metadata(&mut quota, rate_limits);
}
snapshots.write_to_document(&mut quota);
quota
}
fn merge_passive_metadata(quota: &mut Map<String, Value>, rate_limits: &ParsedRateLimits) {
if let Some(plan_type) = rate_limits.plan_type.as_ref() {
quota.insert("plan_type".to_owned(), Value::String(plan_type.clone()));
}
@@ -1177,48 +1267,19 @@ fn merge_passive_quota(
}
quota.insert("credits".to_owned(), Value::Object(value));
}
}
let mut additional = quota
.remove("additional_rate_limits")
.and_then(|value| value.as_array().cloned())
.unwrap_or_default();
for (limit_id, details) in &rate_limits.limits {
let Some(rate_limit) = passive_rate_limit_snapshot(details) else {
continue;
};
if Some(limit_id.as_str()) == active_limit {
quota.insert("rate_limit".to_owned(), Value::Object(rate_limit));
continue;
}
let index = additional.iter().position(|item| {
item.get("metered_feature")
.or_else(|| item.get("limit_name"))
.and_then(Value::as_str)
.is_some_and(|existing| existing == limit_id)
});
let mut item = index
.and_then(|index| additional.get(index))
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
item.insert(
"metered_feature".to_owned(),
Value::String(limit_id.clone()),
);
if let Some(name) = details.limit_name.as_ref() {
item.insert("limit_name".to_owned(), Value::String(name.clone()));
}
item.insert("rate_limit".to_owned(), Value::Object(rate_limit));
match index {
Some(index) => additional[index] = Value::Object(item),
None => additional.push(Value::Object(item)),
}
}
quota.insert(
"additional_rate_limits".to_owned(),
Value::Array(additional),
);
quota
fn default_limit_is_named_alias(rate_limits: &ParsedRateLimits) -> bool {
let Some(default_primary) = rate_limits
.limits
.get(DEFAULT_CODEX_LIMIT_ID)
.and_then(|details| details.primary)
else {
return false;
};
rate_limits.limits.iter().any(|(limit_id, details)| {
limit_id != DEFAULT_CODEX_LIMIT_ID && details.primary == Some(default_primary)
})
}
/// 丢弃 core 限额中上游给出的无事实 `secondary_window` 占位。
@@ -1230,7 +1291,13 @@ fn merge_passive_quota(
/// 无事实值。带 reset、时长、正用量、触顶、未知或非法字段的次级窗口均完全按
/// 原有额度逻辑保留。
fn normalize_quota_window_placeholders(mut quota: Map<String, Value>) -> Map<String, Value> {
if let Some(rate_limit) = quota.get_mut("rate_limit").and_then(Value::as_object_mut) {
quota = canonicalize_rate_limit_document(quota);
if let Some(rate_limit) = quota
.get_mut(RATE_LIMITS_BY_LIMIT_ID)
.and_then(Value::as_object_mut)
.and_then(|limits| limits.get_mut(DEFAULT_CODEX_LIMIT_ID))
.and_then(Value::as_object_mut)
{
drop_secondary_window_placeholder(rate_limit);
}
quota
@@ -12,6 +12,9 @@ use gateway_core::engine::credential::{
};
use serde_json::{Map, Value};
use super::document::{
DEFAULT_CODEX_LIMIT_ID, RATE_LIMITS_BY_LIMIT_ID, canonicalize_rate_limit_document,
};
use super::{CodexCredentialQuotaError, QUOTA_SCHEDULING_TTL};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -151,6 +154,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 {
@@ -166,9 +176,18 @@ impl CodexQuotaFact {
}
pub fn parse_codex_quota_usage(usage: &Value) -> Result<CodexQuotaFact, CodexCredentialQuotaError> {
let object = usage
.as_object()
.ok_or(CodexCredentialQuotaError::InvalidCredentialData)?;
let object = canonicalize_rate_limit_document(
usage
.as_object()
.cloned()
.ok_or(CodexCredentialQuotaError::InvalidCredentialData)?,
);
parse_codex_quota_object(&object)
}
fn parse_codex_quota_object(
object: &Map<String, Value>,
) -> Result<CodexQuotaFact, CodexCredentialQuotaError> {
let mut aggregate = QuotaAggregate::default();
for limit in canonical_rate_limits(object)? {
aggregate.observe_rate_limit(limit.rate_limit)?;
@@ -189,6 +208,15 @@ pub fn parse_codex_quota_usage(usage: &Value) -> Result<CodexQuotaFact, CodexCre
})
}
fn canonical_quota_object(usage: &Value) -> Result<Map<String, Value>, CodexCredentialQuotaError> {
Ok(canonicalize_rate_limit_document(
usage
.as_object()
.cloned()
.ok_or(CodexCredentialQuotaError::InvalidCredentialData)?,
))
}
pub(crate) fn quota_snapshot_from_observation(
observation: &QuotaObservation,
) -> Option<CodexAccountQuotaSnapshot> {
@@ -270,13 +298,11 @@ pub(crate) fn parse_account_quota_snapshot(
observed_at: SystemTime,
usage: &Value,
) -> Result<CodexAccountQuotaSnapshot, CodexCredentialQuotaError> {
let fact = parse_codex_quota_usage(usage)?;
let object = usage
.as_object()
.ok_or(CodexCredentialQuotaError::InvalidCredentialData)?;
let quota = authoritative_quota_state(object, observed_at)?;
let object = canonical_quota_object(usage)?;
let fact = parse_codex_quota_object(&object)?;
let quota = authoritative_quota_state(&object, observed_at)?;
let mut windows = Vec::new();
for limit in canonical_rate_limits(object)? {
for limit in canonical_rate_limits(&object)? {
parse_rate_limit_windows(
&limit.key,
&limit.source,
@@ -309,12 +335,16 @@ fn quota_source_order(source: &str) -> u8 {
}
}
/// 顶层 account-wide Codex 桶是唯一可改变账号额度访问结论的 quota 响应字段。
/// 默认 `codex` 桶是唯一可改变账号额度访问结论的 quota 响应字段。
fn authoritative_quota_state(
usage: &Map<String, Value>,
observed_at: SystemTime,
) -> Result<QuotaState, CodexCredentialQuotaError> {
let Some(rate_limit) = usage.get("rate_limit") else {
let Some(rate_limit) = usage
.get(RATE_LIMITS_BY_LIMIT_ID)
.and_then(Value::as_object)
.and_then(|limits| limits.get(DEFAULT_CODEX_LIMIT_ID))
else {
return Ok(QuotaState::observed_unknown(observed_at));
};
if rate_limit.is_null() {
@@ -391,64 +421,32 @@ fn canonical_rate_limits(
object: &Map<String, Value>,
) -> Result<Vec<CanonicalRateLimit<'_>>, CodexCredentialQuotaError> {
let mut limits = Vec::new();
let has_primary_codex_rate_limit = object
.get("rate_limit")
.is_some_and(|value| !value.is_null());
if let Some(rate_limit) = object.get("rate_limit") {
// 官方 Codex 将顶层 `rate_limit` 固定识别为 `codex`,而不把
// `active_limit` 投影成另一个展示桶。后者只是当前活动套餐的元数据。
limits.push(CanonicalRateLimit {
key: "core".to_owned(),
source: "codex".to_owned(),
account_wide: true,
limit_name: None,
rate_limit,
});
}
if let Some(rate_limit) = object
.get("code_review_rate_limit")
.filter(|value| !value.is_null())
{
limits.push(CanonicalRateLimit {
key: "code-review".to_owned(),
source: "code_review".to_owned(),
account_wide: false,
limit_name: Some("code_review".to_owned()),
rate_limit,
});
}
let Some(additional) = object
.get("additional_rate_limits")
.filter(|value| !value.is_null())
else {
let Some(by_limit_id) = object.get(RATE_LIMITS_BY_LIMIT_ID) else {
return Ok(limits);
};
for (index, value) in additional
.as_array()
for (limit_id, rate_limit) in by_limit_id
.as_object()
.ok_or(CodexCredentialQuotaError::InvalidCredentialData)?
.iter()
.enumerate()
{
let item = value
let item = rate_limit
.as_object()
.ok_or(CodexCredentialQuotaError::InvalidCredentialData)?;
let (source, limit_name) = canonical_rate_limit_source(item, index);
// 部分上游响应会在 `additional_rate_limits` 中重复默认的 `codex`
// 桶。它没有独立的 limit id,不能在账号面板再次展示。
if has_primary_codex_rate_limit && is_codex_limit_id(&source) {
continue;
}
if let Some(rate_limit) = item.get("rate_limit") {
// 非 Codex additional 桶保留为独立快照;额外用索引生成窗口键,
// 避免同名 additional 产生重复前端 key。
limits.push(CanonicalRateLimit {
key: format!("additional-{index}-{source}"),
account_wide: is_codex_limit_id(&source),
source,
limit_name,
rate_limit,
});
}
let source = item
.get("limit_id")
.and_then(Value::as_str)
.unwrap_or(limit_id)
.to_owned();
let limit_name = item
.get("limit_name")
.and_then(Value::as_str)
.map(str::to_owned);
limits.push(CanonicalRateLimit {
key: source.clone(),
account_wide: is_codex_limit_id(&source),
source,
limit_name,
rate_limit,
});
}
Ok(limits)
}
@@ -457,30 +455,6 @@ fn is_codex_limit_id(value: &str) -> bool {
value.trim().eq_ignore_ascii_case("codex")
}
fn canonical_rate_limit_source(
item: &Map<String, Value>,
index: usize,
) -> (String, Option<String>) {
let source = item
.get("metered_feature")
.or_else(|| item.get("limit_name"))
.and_then(Value::as_str)
.filter(|value| {
!value.is_empty() && value.len() <= 128 && !value.chars().any(char::is_control)
})
.map(str::to_owned)
.unwrap_or_else(|| format!("additional-{index}"));
let limit_name = item
.get("limit_name")
.or_else(|| item.get("metered_feature"))
.and_then(Value::as_str)
.filter(|value| {
!value.is_empty() && value.len() <= 128 && !value.chars().any(char::is_control)
})
.map(str::to_owned);
(source, limit_name)
}
fn parse_rate_limit_windows(
key_source: &str,
source: &str,
@@ -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 {
@@ -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,
}
}
}
+202 -84
View File
@@ -6,6 +6,7 @@ use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime};
use async_trait::async_trait;
use bytes::Bytes;
use futures::{StreamExt, future::BoxFuture};
use gateway_core::engine::continuation::{ContinuationBinding, NativeContinuationScope};
use gateway_core::engine::credential::{AccountFeedbackStats, ProviderAccount};
@@ -33,6 +34,9 @@ use gateway_core::task::{
WorkerDefinitionError, WorkerId, WorkerKind, WorkerLeaseRequest, WorkerRegistration,
WorkerRunnable, WorkerSchedule, WorkerTaskError,
};
use gateway_protocol::openai::events::{
ParsedRateLimits, parse_rate_limit_headers, rate_limits_to_header_pairs,
};
use reqwest::Client;
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
@@ -63,16 +67,16 @@ use crate::transport::protocol::responses::{
CodexResponsesRequest, PreviousResponseScope, ResponseEventSignals,
};
use crate::transport::request::{
CodexRequestEncodeError, encode_generate_request, scope_request_to_account,
CodexRequestEncodeError, RequestAccountScope, encode_generate_request, scope_request_to_account,
};
use crate::transport::session::CodexSessionIdentity;
use crate::transport::usage::normalize_service_tier;
use crate::transport::websocket::{CodexWebSocketExchangeError, PreviousResponseUnavailableReason};
use crate::transport::{
CODEX_RESPONSES_PATH, CodexAccountSelectionTelemetry, CodexBackendClient,
CodexBackendStreamingResponse, CodexBackendTransport, CodexClientError,
CodexRateLimitHeaderUpdates, CodexRequestContext, CodexResponseMetadata, CodexTransportMetrics,
CodexUpstreamDiagnostics, CodexWebSocketPool, endpoint_url,
CodexBackendStreamingResponse, CodexBackendTransport, CodexClientError, CodexRateLimitUpdates,
CodexRequestContext, CodexResponseMetadata, CodexTransportMetrics, CodexUpstreamDiagnostics,
CodexWebSocketPool, endpoint_url,
};
const PROVIDER_NAME: &str = "openai";
@@ -247,7 +251,12 @@ impl Provider for CodexProvider {
if let Some(identity) = &self.session_identity {
identity.prepare_local_conversation(&mut upstream_request);
}
if upstream_request.turn_state.is_none() {
if previous_session.as_ref().is_some_and(|state| {
same_client_turn(
state.client_turn_id.as_deref(),
upstream_request.client_turn_id.as_deref(),
)
}) {
upstream_request.turn_state = previous_session
.as_ref()
.and_then(|state| state.turn_state.clone());
@@ -277,57 +286,48 @@ impl Provider for CodexProvider {
let output_started_at = Instant::now();
let provider_kind = ProviderKind::new(PROVIDER_NAME)
.map_err(|_| provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent))?;
let cross_account = context
let state_owner_cross_account = context
.account_state_owner()
.is_some_and(|owner| !owner.matches(&provider_kind, lease.account_id()))
|| previous_session
.as_ref()
.is_some_and(|state| state.account_id != lease.account_id().as_str());
let replay_previous_response = matches!(
context.continuation_attempt(),
ContinuationAttempt::ReplayOwner | ContinuationAttempt::ReplayAny
) || previous_session.as_ref().is_some_and(|state| {
state.continuation_scope == OpenAiContinuationScope::ReplayRequired
});
if replay_previous_response && previous_session.is_some() {
// Codex 客户端 input 是完整历史;恢复时只解除上游续链绑定,避免重复叠加代理副本。
upstream_request.set_previous_response_id(None);
upstream_request.previous_response_scope = None;
upstream_request.turn_state = None;
}
scope_request_to_account(
&mut upstream_request,
lease.installation_id(),
cross_account,
);
if context.continuation_attempt() == ContinuationAttempt::Native
&& !replay_previous_response
let has_explicit_state_owner =
context.account_state_owner().is_some() || previous_session.is_some();
let cross_account =
state_owner_cross_account || (!has_explicit_state_owner && lease.account_switch());
if context.continuation_attempt() != ContinuationAttempt::None
&& let Some(continuation) = context.continuation()
{
match continuation {
ContinuationBinding::Pinned(continuation) => {
let previous_response_scope = match previous_session
.as_ref()
.map(|state| state.continuation_scope)
{
Some(OpenAiContinuationScope::Persisted) => {
PreviousResponseScope::Persisted
}
Some(OpenAiContinuationScope::ConnectionLocal) => {
PreviousResponseScope::ConnectionLocal
}
Some(OpenAiContinuationScope::ReplayRequired) => {
return Err(provider_error(
ProviderErrorKind::Protocol,
UpstreamSendState::NotSent,
));
}
None => match continuation.scope() {
NativeContinuationScope::Persisted => PreviousResponseScope::Persisted,
NativeContinuationScope::ConnectionLocal => {
let previous_response_scope = match context.continuation_attempt() {
ContinuationAttempt::Native => match previous_session
.as_ref()
.map(|state| state.continuation_scope)
{
Some(OpenAiContinuationScope::Persisted) => {
PreviousResponseScope::Persisted
}
Some(OpenAiContinuationScope::ConnectionLocal) => {
PreviousResponseScope::ConnectionLocal
}
Some(OpenAiContinuationScope::ReplayRequired) => {
PreviousResponseScope::ExternalUnknown
}
None => match continuation.scope() {
NativeContinuationScope::Persisted => {
PreviousResponseScope::Persisted
}
NativeContinuationScope::ConnectionLocal => {
PreviousResponseScope::ConnectionLocal
}
},
},
ContinuationAttempt::ReplayOwner | ContinuationAttempt::ReplayAny => {
PreviousResponseScope::ExternalUnknown
}
ContinuationAttempt::None => PreviousResponseScope::ExternalUnknown,
};
upstream_request.set_previous_response_id(Some(
continuation.upstream_response_id().as_str().to_owned(),
@@ -342,6 +342,18 @@ impl Provider for CodexProvider {
}
}
}
let account_scope = if !cross_account {
RequestAccountScope::SameAccount
} else if upstream_request.previous_response_id().is_some() {
RequestAccountScope::CrossAccountContinuationProbe
} else {
RequestAccountScope::CrossAccountFullRequest
};
scope_request_to_account(
&mut upstream_request,
lease.installation_id(),
account_scope,
);
let metadata = ProviderCallMetadata::new(
provider_kind,
candidate.upstream_model().clone(),
@@ -359,6 +371,7 @@ impl Provider for CodexProvider {
account_id: lease.account_id().as_str().to_owned(),
conversation_id: upstream_request.local_conversation_id.clone(),
turn_state: upstream_request.turn_state.clone(),
client_turn_id: upstream_request.client_turn_id.clone(),
response_store,
continuation_scope: None,
});
@@ -411,6 +424,8 @@ struct OpenAiSessionState {
conversation_id: Option<String>,
#[serde(default)]
turn_state: Option<String>,
#[serde(default)]
client_turn_id: Option<String>,
continuation_scope: OpenAiContinuationScope,
}
@@ -426,10 +441,17 @@ struct OpenAiSessionCapture {
account_id: String,
conversation_id: Option<String>,
turn_state: Option<String>,
client_turn_id: Option<String>,
response_store: bool,
continuation_scope: Option<OpenAiContinuationScope>,
}
fn same_client_turn(previous: Option<&str>, current: Option<&str>) -> bool {
previous
.zip(current)
.is_some_and(|(previous, current)| !previous.is_empty() && previous == current)
}
fn decode_openai_session_state(request: &GenerateRequest) -> Option<OpenAiSessionState> {
request
.provider_session_state(PROVIDER_NAME)
@@ -471,6 +493,7 @@ fn attach_openai_session_update(
account_id: capture.account_id,
conversation_id: capture.conversation_id,
turn_state: capture.turn_state,
client_turn_id: capture.client_turn_id,
continuation_scope,
}) else {
return;
@@ -698,9 +721,6 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
if let Some(observation) = observation_state.observation() {
yield ProviderEvent::observation(observation);
}
if allows_account_state_mutation {
synchronize_passive_quota(&quota, &active_account, &response.rate_limit_headers).await;
}
if let Some(etag) = response.response_metadata.models_etag.as_deref()
&& let Err(error) = catalog.observe_response_etag(etag)
{
@@ -728,7 +748,9 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
let failure_diagnostics = response.diagnostics.clone();
let failure_set_cookie_headers = response.set_cookie_headers.clone();
let failure_rate_limit_headers = response.rate_limit_headers.clone();
let rate_limit_updates = response.rate_limit_header_updates;
let mut passive_quota_observation =
OpenAiPassiveQuotaObservation::new(response.rate_limit_headers);
let rate_limit_updates = response.rate_limit_updates;
let turn_state_updates = response.turn_state_update;
// OpenAI 线路为透明代理:HTTP SSE 与 WebSocket 两条上游均启用 raw 透传,
// 下游按字节转发上游原文,避免 serde 往返改写数值/精度(大整数→f64、logprobs 等)。
@@ -741,6 +763,14 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
let mut pre_commit_events = PreCommitClientEvents::new();
loop {
let Some(stream_deadline) = remaining(context.deadline()) else {
if allows_account_state_mutation {
synchronize_passive_quota(
&quota,
&active_account,
passive_quota_observation.rate_limits(),
)
.await;
}
Err(provider_error(ProviderErrorKind::Timeout, UpstreamSendState::Sent))?;
return;
};
@@ -773,15 +803,22 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
Err(failure) => {
let updates = take_rate_limit_updates(rate_limit_updates.as_ref()).await;
if !updates.is_empty() {
if allows_account_state_mutation {
synchronize_passive_quota(&quota, &active_account, &updates).await;
}
if observation_state.merge_rate_limit_headers(&updates)
passive_quota_observation.observe(&updates);
let update_headers = rate_limit_update_headers(&updates);
if observation_state.merge_rate_limit_headers(&update_headers)
&& let Some(observation) = observation_state.observation()
{
yield ProviderEvent::observation(observation);
}
}
if allows_account_state_mutation {
synchronize_passive_quota(
&quota,
&active_account,
passive_quota_observation.rate_limits(),
)
.await;
}
apply_failure(&failure_context, &active_account, &failure)
.await;
Err(failure.error)?;
@@ -793,18 +830,19 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
let rate_limits_changed = if updates.is_empty() {
false
} else {
if allows_account_state_mutation {
synchronize_passive_quota(&quota, &active_account, &updates).await;
}
observation_state.merge_rate_limit_headers(&updates)
passive_quota_observation.observe(&updates);
observation_state.merge_rate_limit_headers(&rate_limit_update_headers(&updates))
};
if let (Some(capture), Some(updates)) =
(session_capture.as_mut(), turn_state_updates.as_ref())
// take:仅在上游写入过新 turn_state 时更新 capture,避免逐 chunk 克隆。
let turn_state_changed = if let Some(updates) = turn_state_updates.as_ref()
&& let Some(turn_state) = updates.lock().await.take()
{
capture.turn_state = Some(turn_state);
}
if let Some(capture) = session_capture.as_mut() {
capture.turn_state = Some(turn_state.clone());
}
observation_state.merge_client_header("x-codex-turn-state", &turn_state)
} else {
false
};
let first_event_changed =
observation_state.observe_stream_chunk(&chunk, output_started_at);
let chunk_len = chunk.len();
@@ -843,6 +881,14 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
.any(|event| matches!(event, GatewayEvent::Completed(_)));
let terminal_changed = completed
&& observation_state.mark_completed(terminal_response_is_incomplete(&events));
if allows_account_state_mutation && (completed || terminal_failure.is_some()) {
synchronize_passive_quota(
&quota,
&active_account,
passive_quota_observation.rate_limits(),
)
.await;
}
if let Some((failure, _)) = terminal_failure.as_ref() {
apply_failure(&failure_context, &active_account, failure)
.await;
@@ -861,6 +907,7 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
if (rate_limits_changed
|| service_tier_changed
|| timing_changed
|| turn_state_changed
|| terminal_changed)
&& let Some(observation) = observation_state.observation()
{
@@ -920,21 +967,31 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
let rate_limits_changed = if updates.is_empty() {
false
} else {
if allows_account_state_mutation {
synchronize_passive_quota(&quota, &active_account, &updates).await;
}
observation_state.merge_rate_limit_headers(&updates)
passive_quota_observation.observe(&updates);
observation_state.merge_rate_limit_headers(&rate_limit_update_headers(&updates))
};
if allows_account_state_mutation {
synchronize_passive_quota(
&quota,
&active_account,
passive_quota_observation.rate_limits(),
)
.await;
}
if let Some((failure, _)) = terminal_failure.as_ref() {
apply_failure(&failure_context, &active_account, failure)
.await;
}
if let (Some(capture), Some(updates)) =
(session_capture.as_mut(), turn_state_updates.as_ref())
let turn_state_changed = if let Some(updates) = turn_state_updates.as_ref()
&& let Some(turn_state) = updates.lock().await.take()
{
capture.turn_state = Some(turn_state);
}
if let Some(capture) = session_capture.as_mut() {
capture.turn_state = Some(turn_state.clone());
}
observation_state.merge_client_header("x-codex-turn-state", &turn_state)
} else {
false
};
attach_openai_session_update(&mut events, &mut session_capture);
let completed = events
.iter()
@@ -951,7 +1008,11 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
.observe_cyber_policy_success(cyber_policy_scope.as_ref())
.await;
}
if (service_tier_changed || timing_changed || rate_limits_changed || terminal_changed)
if (service_tier_changed
|| timing_changed
|| rate_limits_changed
|| turn_state_changed
|| terminal_changed)
&& let Some(observation) = observation_state.observation()
{
yield ProviderEvent::observation(observation);
@@ -1001,6 +1062,26 @@ struct OpenAiResponseObservationState {
terminal: Option<OpenAiResponseTerminal>,
}
struct OpenAiPassiveQuotaObservation {
rate_limits: Vec<ParsedRateLimits>,
}
impl OpenAiPassiveQuotaObservation {
fn new(headers: Vec<(String, String)>) -> Self {
Self {
rate_limits: parse_rate_limit_headers(&headers).into_iter().collect(),
}
}
fn observe(&mut self, updates: &[ParsedRateLimits]) {
self.rate_limits.extend_from_slice(updates);
}
fn rate_limits(&self) -> &[ParsedRateLimits] {
&self.rate_limits
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum OpenAiResponseTerminal {
Completed,
@@ -1120,6 +1201,26 @@ impl OpenAiResponseObservationState {
changed
}
fn merge_client_header(&mut self, name: &str, value: &str) -> bool {
let value = Bytes::copy_from_slice(value.as_bytes());
if let Some((_, existing)) = self
.response_metadata
.client_headers
.iter_mut()
.find(|(existing_name, _)| existing_name.eq_ignore_ascii_case(name))
{
if *existing == value {
return false;
}
*existing = value;
return true;
}
self.response_metadata
.client_headers
.push((name.to_owned(), value));
true
}
fn mark_completed(&mut self, incomplete: bool) -> bool {
let terminal = if incomplete {
OpenAiResponseTerminal::Incomplete
@@ -1432,15 +1533,20 @@ fn insert_optional_metadata_millis(
}
}
async fn take_rate_limit_updates(
updates: Option<&CodexRateLimitHeaderUpdates>,
) -> Vec<(String, String)> {
async fn take_rate_limit_updates(updates: Option<&CodexRateLimitUpdates>) -> Vec<ParsedRateLimits> {
let Some(updates) = updates else {
return Vec::new();
};
std::mem::take(&mut *updates.lock().await)
}
fn rate_limit_update_headers(updates: &[ParsedRateLimits]) -> Vec<(String, String)> {
updates
.iter()
.flat_map(rate_limits_to_header_pairs)
.collect()
}
fn terminal_response_is_incomplete(events: &[ProviderEvent]) -> bool {
events
.iter()
@@ -1466,12 +1572,15 @@ fn terminal_response_is_incomplete(events: &[ProviderEvent]) -> bool {
async fn synchronize_passive_quota(
quota: &CodexCredentialQuotaService,
account: &ProviderAccount,
headers: &[(String, String)],
rate_limits: &[ParsedRateLimits],
) {
if headers.is_empty() {
if rate_limits.is_empty() {
return;
}
if let Err(error) = quota.synchronize_passive_headers(account, headers).await {
if let Err(error) = quota
.synchronize_passive_rate_limits(account, rate_limits)
.await
{
tracing::warn!(
account_id = %account.id(),
error = %error,
@@ -1480,6 +1589,17 @@ async fn synchronize_passive_quota(
}
}
async fn synchronize_passive_quota_headers(
quota: &CodexCredentialQuotaService,
account: &ProviderAccount,
headers: &[(String, String)],
) {
let Some(rate_limits) = parse_rate_limit_headers(headers) else {
return;
};
synchronize_passive_quota(quota, account, std::slice::from_ref(&rate_limits)).await;
}
const fn actual_transport_name(transport: CodexBackendTransport) -> &'static str {
match transport {
CodexBackendTransport::HttpSse => HTTP_SSE_TRANSPORT,
@@ -1492,12 +1612,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))
}
@@ -1875,7 +1993,7 @@ async fn apply_failure(
if !context.allows_account_state_mutation {
return;
}
synchronize_passive_quota(context.quota, account, &failure.rate_limit_headers).await;
synchronize_passive_quota_headers(context.quota, account, &failure.rate_limit_headers).await;
let needs_authoritative_quota_refresh = matches!(
failure.account_failure,
Some(CodexAccountFailure::QuotaExhausted | CodexAccountFailure::UsageLimitExhausted { .. })
@@ -2154,7 +2272,7 @@ fn map_upstream_failure(
error = error.with_status(status.as_u16());
}
if replay_boundary.permits_provider_proof()
&& (failure.replay_is_safe() || continuation_failure.is_some() || cyber_policy_failure)
&& (failure.replay_is_safe() || cyber_policy_failure)
{
error = error.with_replay_safe();
}
@@ -30,7 +30,7 @@ use super::response_meta::CodexResponseMetadata;
use super::tls::{CustomCaError, build_reqwest_client_with_custom_ca, custom_ca_env_cache_key};
use super::websocket::{
CodexWebSocketExchangeError, CodexWebSocketPool, CodexWebSocketPoolKey,
CodexWebSocketRateLimitHeaderUpdates, CodexWebSocketRequest, CodexWebSocketTurnStateUpdate,
CodexWebSocketRateLimitUpdates, CodexWebSocketRequest, CodexWebSocketTurnStateUpdate,
PreparedWebSocket, WebSocketOriginBreaker, WebSocketPoolDecision,
};
@@ -519,8 +519,8 @@ pub struct CodexTransportMetrics {
pub http_version: Option<String>,
}
/// 响应头之后在 live 流中采集的限流更新。
pub type CodexRateLimitHeaderUpdates = CodexWebSocketRateLimitHeaderUpdates;
/// 响应头之后在 live 流中采集的结构化限流更新。
pub type CodexRateLimitUpdates = CodexWebSocketRateLimitUpdates;
/// 响应头之后在 live 流中采集的 turn state 更新。
pub type CodexTurnStateUpdate = CodexWebSocketTurnStateUpdate;
@@ -537,8 +537,8 @@ pub struct CodexBackendStreamingResponse {
pub set_cookie_headers: Vec<String>,
/// 上游透传的限流头。
pub rate_limit_headers: Vec<(String, String)>,
/// live stream 期间捕获的限流头更新。
pub rate_limit_header_updates: Option<CodexRateLimitHeaderUpdates>,
/// live stream 期间捕获的结构化限流更新。
pub rate_limit_updates: Option<CodexRateLimitUpdates>,
/// live stream 期间捕获的 turn-state 更新。
pub turn_state_update: Option<CodexTurnStateUpdate>,
/// WebSocket 连接池决策。
@@ -147,14 +147,14 @@ impl CodexBackendClient {
});
}
let rate_limit_header_updates = Arc::new(tokio::sync::Mutex::new(Vec::new()));
let rate_limit_updates = Arc::new(tokio::sync::Mutex::new(Vec::new()));
Ok(CodexBackendStreamingResponse {
body: http_sse_stream(response, Arc::clone(&rate_limit_header_updates)),
body: http_sse_stream(response, Arc::clone(&rate_limit_updates)),
transport: CodexBackendTransport::HttpSse,
turn_state,
set_cookie_headers,
rate_limit_headers,
rate_limit_header_updates: Some(rate_limit_header_updates),
rate_limit_updates: Some(rate_limit_updates),
turn_state_update: None,
websocket_pool_decision: None,
diagnostics,
@@ -245,11 +245,12 @@ impl CodexBackendClient {
self.websocket_pool.as_deref().zip(pool_key)
};
let fast_path_budget = match requirement {
TransportRequirement::PersistedContinuation
| TransportRequirement::ExternalUnknown
| TransportRequirement::NewChain => Some(WEBSOCKET_FAST_PATH_BUDGET),
TransportRequirement::PersistedContinuation | TransportRequirement::NewChain => {
Some(WEBSOCKET_FAST_PATH_BUDGET)
}
TransportRequirement::ExplicitWebSocketWarmup
| TransportRequirement::ExactWebSocketContinuation => None,
| TransportRequirement::ExactWebSocketContinuation
| TransportRequirement::ExternalUnknown => None,
TransportRequirement::HttpRequired => None,
};
let prepare_started_at = Instant::now();
@@ -515,7 +516,7 @@ impl CodexBackendClient {
turn_state: exchange.turn_state,
set_cookie_headers: exchange.set_cookie_headers,
rate_limit_headers: exchange.rate_limit_headers,
rate_limit_header_updates: Some(exchange.rate_limit_header_updates),
rate_limit_updates: Some(exchange.rate_limit_updates),
turn_state_update: Some(exchange.turn_state_update),
websocket_pool_decision: exchange.pool_decision,
diagnostics: exchange.diagnostics,
@@ -878,7 +879,7 @@ fn websocket_connection_profile(headers: &HeaderMap) -> String {
fn http_sse_stream(
response: ReqwestResponse,
rate_limit_updates: CodexRateLimitHeaderUpdates,
rate_limit_updates: CodexRateLimitUpdates,
) -> CodexBackendSseStream {
let stream: CodexBackendSseStream =
Box::pin(response.bytes_stream().map_err(CodexClientError::Http));
@@ -901,7 +902,7 @@ fn http_sse_stream(
fn observe_http_sse_rate_limits(
stream: CodexBackendSseStream,
updates: CodexRateLimitHeaderUpdates,
updates: CodexRateLimitUpdates,
) -> CodexBackendSseStream {
Box::pin(futures::stream::unfold(
(stream, SseEventDecoder::default(), updates),
@@ -925,9 +926,9 @@ fn observe_http_sse_rate_limits(
async fn append_http_sse_rate_limit_updates(
frames: Vec<SseFrame>,
updates: &CodexRateLimitHeaderUpdates,
updates: &CodexRateLimitUpdates,
) {
let mut headers = Vec::new();
let mut observations = Vec::new();
for frame in frames {
for event in frame.events() {
if event
@@ -940,10 +941,10 @@ async fn append_http_sse_rate_limit_updates(
let Some(rate_limits) = events::parse_rate_limits_event_raw(&event.data) else {
continue;
};
headers.extend(events::rate_limits_to_header_pairs(&rate_limits));
observations.push(rate_limits);
}
}
if !headers.is_empty() {
updates.lock().await.extend(headers);
if !observations.is_empty() {
updates.lock().await.extend(observations);
}
}
@@ -27,8 +27,8 @@ pub use self::{
client::{
CodexAccountSelectionTelemetry, CodexBackendClient, CodexBackendSseStream,
CodexBackendStreamingResponse, CodexBackendTransport, CodexClientError, CodexClientResult,
CodexRateLimitHeaderUpdates, CodexRequestContext, CodexTransportDecision,
CodexTransportMetrics, CodexTurnStateUpdate, build_reqwest_client,
CodexRateLimitUpdates, CodexRequestContext, CodexTransportDecision, CodexTransportMetrics,
CodexTurnStateUpdate, build_reqwest_client,
},
diagnostics::{CodexUpstreamDiagnostics, CodexUpstreamSendPhase},
endpoints::{
@@ -5,6 +5,8 @@ use serde_json::{Map, Value};
use crate::transport::protocol::responses::{CodexResponsesRequest, transport_requirement};
const REDACTED_PAYLOAD_VALUE: &str = "<redacted>";
const PREVIOUS_RESPONSE_NOT_FOUND_CODE: &str = "previous_response_not_found";
const INVALID_PREVIOUS_RESPONSE_ID_MESSAGE: &str = "Invalid `previous_response_id`.";
/// WebSocket 握手审计快照。
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
@@ -54,16 +56,49 @@ pub fn websocket_event_to_sse_frame(raw: &str) -> Option<String> {
websocket_event_frame(&value, raw)
}
/// 同上,复用已解析的事件 JSON;`data` 段逐字节内嵌 `raw` 原文。
/// 同上,复用已解析的事件 JSON;`data` 段通常逐字节内嵌 `raw` 原文。
///
/// 仅剥离传输层内部帧:`codex.rate_limits` 与承载 turn_state 的
/// `response.metadata` 不下发客户端;业务事件一律原样转发,不做 schema 校验。
/// `response.metadata` 不下发客户端。唯一的业务帧适配是为上游省略错误码的
/// `Invalid previous_response_id` 补齐官方客户端用于完整历史重放的稳定错误码;
/// 原始 status、type 与 message 均保持不变。
pub(crate) fn websocket_event_frame(value: &Value, raw: &str) -> Option<String> {
let event = websocket_event_type(value)?;
if is_internal_websocket_event(event) || event == "response.metadata" {
return None;
}
Some(encode_sse_event(event, raw))
let normalized = previous_response_not_found_event(value);
Some(encode_sse_event(
event,
normalized.as_deref().unwrap_or(raw),
))
}
fn previous_response_not_found_event(value: &Value) -> Option<String> {
if websocket_event_type(value) != Some("error")
|| value
.get("status")
.or_else(|| value.get("status_code"))
.and_then(Value::as_u64)
!= Some(400)
{
return None;
}
let error = value.get("error")?.as_object()?;
if error.get("code").is_some_and(|code| !code.is_null())
|| error.get("type").and_then(Value::as_str) != Some("invalid_request_error")
|| error.get("message").and_then(Value::as_str)
!= Some(INVALID_PREVIOUS_RESPONSE_ID_MESSAGE)
{
return None;
}
let mut normalized = value.clone();
normalized.get_mut("error")?.as_object_mut()?.insert(
"code".to_owned(),
Value::String(PREVIOUS_RESPONSE_NOT_FOUND_CODE.to_owned()),
);
serde_json::to_string(&normalized).ok()
}
pub(crate) fn websocket_event_type(value: &Value) -> Option<&str> {
@@ -61,6 +61,23 @@ const INSTALLATION_ID_KEYS: &[&str] = &[
"x-codex-installation-id",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RequestAccountScope {
SameAccount,
CrossAccountFullRequest,
CrossAccountContinuationProbe,
}
impl RequestAccountScope {
const fn is_cross_account(self) -> bool {
!matches!(self, Self::SameAccount)
}
const fn preserves_continuation(self) -> bool {
matches!(self, Self::CrossAccountContinuationProbe)
}
}
/// Provider 专属编码错误;不保存 prompt、schema 或 option 值。
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum CodexRequestEncodeError {
@@ -289,12 +306,18 @@ fn normalize_conversation_anchor_text(text: &str) -> String {
///
/// 真实 account ID 与 installation ID 由随后构造的 `CodexRequestContext` 注入
/// 请求头;正文只替换客户端原本声明过的 installation 字段,绝不接受客户端
/// 提供的 token、cookie 或账号身份。
/// 提供的 token、cookie 或账号身份。Continuation probe 保留 canonical
/// previous-response 和原始增量 input,不能把它们误当成完整请求清理。
pub(crate) fn scope_request_to_account(
request: &mut CodexResponsesRequest,
installation_id: &str,
cross_account: bool,
account_scope: RequestAccountScope,
) {
let cross_account = account_scope.is_cross_account();
let continuation = account_scope
.preserves_continuation()
.then(|| request.previous_response_id().map(ToOwned::to_owned))
.flatten();
let client_metadata_turn_state = metadata_string(request, "x-codex-turn-state");
let preserve_turn_state =
!cross_account && (request.turn_state.is_some() || client_metadata_turn_state.is_some());
@@ -306,23 +329,38 @@ pub(crate) fn scope_request_to_account(
} else {
None
};
let turn_metadata = request
.turn_metadata
.as_deref()
.and_then(|metadata| scope_turn_metadata(metadata, installation_id, cross_account));
let client_metadata_turn_metadata = metadata_string(request, "x-codex-turn-metadata")
.and_then(|metadata| scope_turn_metadata(&metadata, installation_id, cross_account));
let turn_metadata = (!account_scope.preserves_continuation())
.then(|| {
request
.turn_metadata
.as_deref()
.and_then(|metadata| scope_turn_metadata(metadata, installation_id, cross_account))
})
.flatten();
let client_metadata_turn_metadata = (!account_scope.preserves_continuation())
.then(|| {
metadata_string(request, "x-codex-turn-metadata")
.and_then(|metadata| scope_turn_metadata(&metadata, installation_id, cross_account))
})
.flatten();
if cross_account {
request.passthrough_headers.remove("x-codex-turn-state");
request.passthrough_headers.remove("x-codex-turn-metadata");
sanitize_cross_account_input(request);
if !account_scope.preserves_continuation() {
sanitize_cross_account_input(request);
}
for key in CROSS_ACCOUNT_IDENTITY_KEYS
.iter()
.chain(ACCOUNT_BOUND_STATE_KEYS)
{
request.body_mut().remove(*key);
}
if account_scope.preserves_continuation() {
for key in TURN_METADATA_KEYS {
request.body_mut().remove(*key);
}
}
}
for key in INSTALLATION_ID_KEYS {
@@ -356,6 +394,11 @@ pub(crate) fn scope_request_to_account(
{
metadata.remove(*key);
}
if account_scope.preserves_continuation() {
for key in TURN_METADATA_KEYS {
metadata.remove(*key);
}
}
}
metadata.insert(
"x-codex-installation-id".to_owned(),
@@ -393,6 +436,9 @@ pub(crate) fn scope_request_to_account(
request.turn_state = turn_state;
request.turn_metadata = turn_metadata;
if let Some(continuation) = continuation {
request.set_previous_response_id(Some(continuation));
}
}
fn sanitize_cross_account_input(request: &mut CodexResponsesRequest) {
@@ -193,7 +193,6 @@ fn client_response_header_is_forwardable(name: &str, connection_options: &[Strin
| "x-openai-organization"
| "x-openai-project"
| "x-codex-installation-id"
| "x-codex-turn-state"
| "x-codex-turn-metadata"
)
}
@@ -1,6 +1,6 @@
//! Codex 本地会话锚点的稳定派生。
//!
//! 旧版 Redis 会话亲和与 WebSocket 复用以 `.runtime/data/identity_hmac_secret`
//! 旧版 Redis 会话亲和与 WebSocket 复用以运行数据目录下的 `identity_hmac_secret`
//! 生成的 `lc_` 值为键。该密钥是持久运行数据,不是账号凭据;保留它可避免
//! 架构迁移后同一客户端会话全部换键。
@@ -8,6 +8,7 @@ use std::{pin::Pin, sync::Arc, time::Duration};
use bytes::Bytes;
use futures::Stream;
use gateway_protocol::openai::events::ParsedRateLimits;
use tokio::sync::Mutex;
use uuid::Uuid;
@@ -36,8 +37,8 @@ pub struct CodexWebSocketStreamingExchange {
pub set_cookie_headers: Vec<String>,
/// 上游握手响应里的限流头。
pub rate_limit_headers: Vec<(String, String)>,
/// 上游内部 rate-limit 事件里的动态更新。
pub rate_limit_header_updates: CodexWebSocketRateLimitHeaderUpdates,
/// 上游内部 `codex.rate_limits` 事件里的结构化动态更新。
pub rate_limit_updates: CodexWebSocketRateLimitUpdates,
/// 上游内部 metadata 事件里的动态 turn state。
pub turn_state_update: CodexWebSocketTurnStateUpdate,
/// WebSocket 连接池决策。
@@ -53,8 +54,8 @@ pub struct CodexWebSocketStreamingExchange {
/// Responses WebSocket live SSE 字节流。
pub type CodexWebSocketSseStream =
Pin<Box<dyn Stream<Item = Result<Bytes, CodexWebSocketExchangeError>> + Send + 'static>>;
/// live 流中的限流头动态更新。
pub type CodexWebSocketRateLimitHeaderUpdates = Arc<Mutex<Vec<(String, String)>>>;
/// live 流中的结构化限流动态更新。
pub type CodexWebSocketRateLimitUpdates = Arc<Mutex<Vec<ParsedRateLimits>>>;
/// live 流中的 turn state 动态更新。
pub type CodexWebSocketTurnStateUpdate = Arc<Mutex<Option<String>>>;
@@ -20,7 +20,7 @@ pub(in crate::transport::websocket) enum WebSocketTerminalKind {
}
pub(super) enum ExchangeAction {
RateLimits(Vec<(String, String)>),
RateLimits(events::ParsedRateLimits),
TurnState(String),
Forward {
frame: String,
@@ -41,8 +41,8 @@ pub(super) fn reduce_websocket_event(
};
if let Some(parsed) = events::parse_rate_limits_event(&value) {
let headers = events::rate_limits_to_header_pairs(&parsed);
metadata.rate_limit_headers.extend(headers.iter().cloned());
return Ok(ExchangeAction::RateLimits(headers));
metadata.rate_limit_headers.extend(headers);
return Ok(ExchangeAction::RateLimits(parsed));
}
response_meta::merge_response_metadata(
@@ -16,9 +16,8 @@ use super::super::{
use super::io::{next_websocket_message, receive_idle_timeout, reused_stream_receive_error};
use super::reducer::{ExchangeAction, WebSocketTerminalKind, reduce_websocket_event};
use super::{
CodexWebSocketExchangeError, CodexWebSocketRateLimitHeaderUpdates,
CodexWebSocketStreamingExchange, CodexWebSocketTurnStateUpdate, WEBSOCKET_STREAM_BUFFER,
reusable_websocket_metadata,
CodexWebSocketExchangeError, CodexWebSocketRateLimitUpdates, CodexWebSocketStreamingExchange,
CodexWebSocketTurnStateUpdate, WEBSOCKET_STREAM_BUFFER, reusable_websocket_metadata,
};
pub(in crate::transport::websocket) struct WebSocketStreamPoolReturn {
@@ -63,8 +62,8 @@ pub(in crate::transport::websocket) fn stream_websocket_response(
) -> CodexWebSocketStreamingExchange {
let websocket_connection_id = websocket.connection_id();
let response_metadata = metadata.clone();
let rate_limit_header_updates = Arc::new(Mutex::new(Vec::new()));
let rate_limit_header_updates_for_task = Arc::clone(&rate_limit_header_updates);
let rate_limit_updates = Arc::new(Mutex::new(Vec::new()));
let rate_limit_updates_for_task = Arc::clone(&rate_limit_updates);
let turn_state_update = Arc::new(Mutex::new(metadata.turn_state.clone()));
let turn_state_update_for_task = Arc::clone(&turn_state_update);
let (tx, rx) = mpsc::channel(WEBSOCKET_STREAM_BUFFER);
@@ -83,7 +82,7 @@ pub(in crate::transport::websocket) fn stream_websocket_response(
reused_connection,
initial_event_timeout,
shutdown,
rate_limit_header_updates: rate_limit_header_updates_for_task,
rate_limit_updates: rate_limit_updates_for_task,
turn_state_update: turn_state_update_for_task,
tx,
})
@@ -105,7 +104,7 @@ pub(in crate::transport::websocket) fn stream_websocket_response(
turn_state: response_metadata.turn_state,
set_cookie_headers: response_metadata.set_cookie_headers,
rate_limit_headers: response_metadata.rate_limit_headers,
rate_limit_header_updates,
rate_limit_updates,
turn_state_update,
pool_decision: None,
connection_local_continuation: false,
@@ -121,7 +120,7 @@ struct WebSocketStreamForwardState {
reused_connection: bool,
initial_event_timeout: Option<Duration>,
shutdown: CancellationToken,
rate_limit_header_updates: CodexWebSocketRateLimitHeaderUpdates,
rate_limit_updates: CodexWebSocketRateLimitUpdates,
turn_state_update: CodexWebSocketTurnStateUpdate,
tx: mpsc::Sender<Result<Bytes, CodexWebSocketExchangeError>>,
}
@@ -134,7 +133,7 @@ async fn forward_websocket_response_stream(state: WebSocketStreamForwardState) {
reused_connection,
initial_event_timeout,
shutdown,
rate_limit_header_updates,
rate_limit_updates,
turn_state_update,
tx,
} = state;
@@ -231,8 +230,8 @@ async fn forward_websocket_response_stream(state: WebSocketStreamForwardState) {
};
let (frame, terminal) = match reduce_websocket_event(&raw, &mut metadata, &mut continuation)
{
Ok(ExchangeAction::RateLimits(headers)) => {
rate_limit_header_updates.lock().await.extend(headers);
Ok(ExchangeAction::RateLimits(rate_limits)) => {
rate_limit_updates.lock().await.push(rate_limits);
continue;
}
Ok(ExchangeAction::TurnState(turn_state)) => {
@@ -21,8 +21,8 @@ pub use self::{
},
error::{CodexWebSocketCloseError, CodexWebSocketExchangeError, CodexWebSocketUpstreamError},
exchange::{
CodexWebSocketRateLimitHeaderUpdates, CodexWebSocketSseStream,
CodexWebSocketStreamingExchange, CodexWebSocketTurnStateUpdate,
CodexWebSocketRateLimitUpdates, CodexWebSocketSseStream, CodexWebSocketStreamingExchange,
CodexWebSocketTurnStateUpdate,
},
handshake::responses_websocket_endpoint,
model::{
+64 -6
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;
@@ -13,7 +14,7 @@ use gateway_admin::model::provider_credentials::{
AuthorizationMutationTarget, AuthorizationOwnerBinding, CompleteAuthorization,
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
PrepareCredentialRotation, ProviderDocument, ProviderExportCredentialInput,
ProviderQuotaRequest, QuotaLocalUsageAttribution,
ProviderQuotaRequest, ProviderQuotaWindowRole, QuotaLocalUsageAttribution,
};
use gateway_admin::model::{MutationActor, MutationContext, Revision};
use gateway_admin::ports::provider::ProviderAdminErrorKind;
@@ -565,7 +566,7 @@ async fn openai_admin_provider_projects_official_codex_quota_and_independent_buc
assert_eq!(monthly.len(), 1);
assert!(monthly.iter().any(|window| {
window.label == "月额度"
&& window.source.as_deref() == Some("codex")
&& window.limit_id.as_deref() == Some("codex")
&& window.used_percent == Some(91.0)
}));
assert!(
@@ -578,20 +579,22 @@ async fn openai_admin_provider_projects_official_codex_quota_and_independent_buc
let secondary = quota
.windows
.iter()
.find(|window| window.key == "core-secondary")
.find(|window| window.key == "codex-secondary")
.expect("core secondary quota");
assert_eq!(secondary.label, "次级额度");
assert_eq!(secondary.used_percent, Some(88.0));
let review = quota
.windows
.iter()
.find(|window| window.source.as_deref() == Some("code_review"))
.find(|window| window.limit_id.as_deref() == Some("code_review"))
.expect("code review quota");
assert_eq!(review.label, "代码审查 · 周额度");
assert_eq!(review.label, "周额度");
assert_eq!(review.limit_name.as_deref(), Some("code_review"));
assert_eq!(review.role, Some(ProviderQuotaWindowRole::Primary));
let spark = quota
.windows
.iter()
.find(|window| window.source.as_deref() == Some("codex_bengalfox"))
.find(|window| window.limit_id.as_deref() == Some("codex_bengalfox"))
.expect("Spark quota");
assert_eq!(
(
@@ -762,6 +765,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()),
@@ -18,6 +18,16 @@ fn openai_config_builds_the_audited_wire_profile() {
assert_eq!(profile.desktop_build, "19012345678");
}
#[test]
fn openai_config_derives_identity_secret_from_runtime_data_dir() {
let mut config = valid_config();
config
.resolve_and_validate(Path::new("/srv/gateway/runtime-data"))
.expect("valid OpenAI config");
assert!(format!("{config:?}").contains("/srv/gateway/runtime-data/identity_hmac_secret"));
}
#[test]
fn openai_config_rejects_noncanonical_versions_and_empty_fields() {
let mut config = valid_config();
@@ -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]
@@ -112,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)),
})
}
}
@@ -11,6 +11,7 @@ use gateway_core::engine::credential::{
AccountStateChange, AccountStatus, CredentialState, ProviderAccount, ProviderAccountStore as _,
QuotaAccessChange, QuotaAccessState, QuotaEvidence, QuotaState,
};
use gateway_protocol::openai::events::parse_rate_limits_event;
use provider_openai::OFFICIAL_CODEX_BASE_URL;
use provider_openai::credential::{
CodexCredentialQuotaError, CodexCredentialQuotaService, ImportCodexOAuthCredential,
@@ -163,6 +164,224 @@ async fn successful_inference_headers_are_authoritative_even_at_full_display_usa
);
}
#[tokio::test]
async fn passive_quota_updates_keep_core_and_model_specific_buckets_independent() {
let store = Arc::new(MemoryAccountStore::default());
let account_id = "acct_independent_passive_buckets";
create_account(&store, account_id).await;
let account = store.account(account_id).expect("account");
let service = quota_service(&store);
let initial_headers = vec![
("x-codex-active-limit".to_owned(), "codex".to_owned()),
("x-codex-primary-used-percent".to_owned(), "81".to_owned()),
(
"x-codex-primary-window-minutes".to_owned(),
"10080".to_owned(),
),
(
"x-codex-bengalfox-primary-used-percent".to_owned(),
"0".to_owned(),
),
(
"x-codex-bengalfox-primary-window-minutes".to_owned(),
"10080".to_owned(),
),
(
"x-codex-bengalfox-limit-name".to_owned(),
"GPT-5.3-Codex-Spark".to_owned(),
),
];
service
.synchronize_passive_headers(&account, &initial_headers)
.await
.expect("initial passive quota");
let spark_headers = vec![
// 生产响应会把当前具名桶同时复制到旧版 `x-codex-*` 默认头;这个
// 兼容别名不能覆盖已有的账号 core 桶。
("x-codex-active-limit".to_owned(), "codex".to_owned()),
("x-codex-primary-used-percent".to_owned(), "1".to_owned()),
(
"x-codex-primary-window-minutes".to_owned(),
"10080".to_owned(),
),
(
"x-codex-primary-reset-at".to_owned(),
"1900000100".to_owned(),
),
(
"x-codex-bengalfox-primary-used-percent".to_owned(),
"1".to_owned(),
),
(
"x-codex-bengalfox-primary-window-minutes".to_owned(),
"10080".to_owned(),
),
(
"x-codex-bengalfox-primary-reset-at".to_owned(),
"1900000100".to_owned(),
),
(
"x-codex-bengalfox-limit-name".to_owned(),
"GPT-5.3-Codex-Spark".to_owned(),
),
];
service
.synchronize_passive_headers(&account, &spark_headers)
.await
.expect("Spark passive quota");
let snapshot = service
.read_account(account.id())
.await
.expect("read Spark quota")
.expect("Spark quota snapshot");
let core = snapshot
.windows()
.iter()
.find(|window| window.source() == "codex")
.expect("core quota window");
let spark = snapshot
.windows()
.iter()
.find(|window| window.source() == "codex_bengalfox")
.expect("Spark quota window");
assert_eq!(core.used_percent(), Some(81.0));
assert_eq!(spark.used_percent(), Some(1.0));
let core_headers = vec![
("x-codex-active-limit".to_owned(), "codex".to_owned()),
("x-codex-primary-used-percent".to_owned(), "82".to_owned()),
(
"x-codex-primary-window-minutes".to_owned(),
"10080".to_owned(),
),
];
service
.synchronize_passive_headers(&account, &core_headers)
.await
.expect("core passive quota");
let snapshot = service
.read_account(account.id())
.await
.expect("read core quota")
.expect("core quota snapshot");
let core = snapshot
.windows()
.iter()
.find(|window| window.source() == "codex")
.expect("core quota window");
let spark = snapshot
.windows()
.iter()
.find(|window| window.source() == "codex_bengalfox")
.expect("Spark quota window");
assert_eq!(core.used_percent(), Some(82.0));
assert_eq!(spark.used_percent(), Some(1.0));
let persisted = store.quota_json(account_id).expect("persisted quota");
let by_limit_id = persisted
.get("rate_limits_by_limit_id")
.and_then(serde_json::Value::as_object)
.expect("rate-limit map");
assert_eq!(
by_limit_id.keys().map(String::as_str).collect::<Vec<_>>(),
["codex", "codex_bengalfox"]
);
assert!(persisted.get("rate_limit").is_none());
assert!(persisted.get("additional_rate_limits").is_none());
}
#[tokio::test]
async fn websocket_additional_rate_limit_resolves_by_name_without_touching_core() {
let store = Arc::new(MemoryAccountStore::default());
let account_id = "acct_websocket_additional_limit";
create_account(&store, account_id).await;
let account = store.account(account_id).expect("account");
let service = quota_service(&store);
let initial_headers = vec![
("x-codex-active-limit".to_owned(), "codex".to_owned()),
("x-codex-primary-used-percent".to_owned(), "84".to_owned()),
(
"x-codex-primary-window-minutes".to_owned(),
"10080".to_owned(),
),
(
"x-codex-primary-reset-at".to_owned(),
"1900000000".to_owned(),
),
(
"x-codex-bengalfox-primary-used-percent".to_owned(),
"0".to_owned(),
),
(
"x-codex-bengalfox-primary-window-minutes".to_owned(),
"10080".to_owned(),
),
(
"x-codex-bengalfox-primary-reset-at".to_owned(),
"1900000100".to_owned(),
),
(
"x-codex-bengalfox-limit-name".to_owned(),
"GPT-5.3-Codex-Spark".to_owned(),
),
];
service
.synchronize_passive_headers(&account, &initial_headers)
.await
.expect("initial quota metadata");
let event = json!({
"type": "codex.rate_limits",
"plan_type": "pro",
"rate_limits": {
"allowed": true,
"limit_reached": false,
"primary": {
"used_percent": 2,
"window_minutes": 10080,
"reset_at": 1900000100
},
"secondary": null
},
"additional_rate_limits": {
"GPT-5.3-Codex-Spark": {
"allowed": true,
"limit_reached": false,
"primary": {
"used_percent": 2,
"window_minutes": 10080,
"reset_at": 1900000100
},
"secondary": null
}
},
"credits": null
});
let observation = parse_rate_limits_event(&event).expect("WebSocket rate-limit event");
service
.synchronize_passive_rate_limits(&account, std::slice::from_ref(&observation))
.await
.expect("WebSocket passive quota");
let persisted = store.quota_json(account_id).expect("persisted quota");
let by_limit_id = persisted["rate_limits_by_limit_id"]
.as_object()
.expect("rate-limit map");
assert_eq!(
by_limit_id["codex"]["primary_window"]["used_percent"],
json!(84.0)
);
assert_eq!(
by_limit_id["codex_bengalfox"]["primary_window"]["used_percent"],
json!(2.0)
);
assert_eq!(persisted["active_limit"], json!("codex_bengalfox"));
assert_eq!(by_limit_id.len(), 2);
}
#[tokio::test]
async fn quota_refresh_persists_explicit_provider_denial() {
let store = Arc::new(MemoryAccountStore::default());
@@ -209,6 +428,14 @@ async fn quota_refresh_persists_explicit_provider_denial() {
current.status_projection(SystemTime::now(), None).status,
AccountStatus::QuotaExhausted
);
let persisted = store.quota_json(account_id).expect("persisted quota");
assert!(
persisted
.get("rate_limits_by_limit_id")
.and_then(|limits| limits.get("codex"))
.is_some()
);
assert!(persisted.get("rate_limit").is_none());
}
#[tokio::test]
@@ -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());
}
}
@@ -396,11 +396,11 @@ async fn persisted_quota_orders_core_window_before_additional_limit() {
.map(|window| window.source())
.collect::<Vec<_>>();
assert_eq!(sources, ["codex", "gpt-5.3-codex-spark"]);
assert_eq!(sources, ["codex", "gpt_5.3_codex_spark"]);
}
#[tokio::test]
async fn code_review_limit_projects_as_independent_window_with_limit_name() {
async fn code_review_limit_projects_as_one_snapshot_per_limit_id() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_code_review").await;
let account = store.account("acct_code_review").expect("account");
@@ -455,21 +455,16 @@ async fn code_review_limit_projects_as_independent_window_with_limit_name() {
.expect("read quota")
.expect("quota snapshot");
// spend_control 不再生成窗口(只作 exhaustion 信号);顶层和 additional
// 的 code_review 桶分别保留,避免一个快照覆盖另一个。
// spend_control 不生成窗口(只作 exhaustion 信号);官方 map 协议中同一个
// limit_id 只能保留一个快照,顶层 code_review 事实优先于重复 additional。
let review = snapshot
.windows()
.iter()
.filter(|window| window.source() == "code_review")
.collect::<Vec<_>>();
assert_eq!(review.len(), 2);
assert!(review.iter().any(|window| {
window.limit_name() == Some("code_review") && window.used_percent() == Some(80.0)
}));
assert!(review.iter().any(|window| {
window.limit_name() == Some("code review") && window.used_percent() == Some(55.0)
}));
assert_ne!(review[0].key(), review[1].key());
assert_eq!(snapshot.windows().len(), 3);
assert_eq!(review.len(), 1);
assert_eq!(review[0].limit_name(), Some("code_review"));
assert_eq!(review[0].used_percent(), Some(80.0));
assert_eq!(snapshot.windows().len(), 2);
assert_eq!(snapshot.quota().access(), QuotaAccessState::Unknown);
}
@@ -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"))
@@ -6,6 +6,9 @@ use std::time::{Duration, SystemTime};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use chrono::Utc;
use futures::{SinkExt, StreamExt};
use gateway_core::engine::continuation::{
ContinuationBinding, NativeContinuationPin, PreviousResponseId,
};
use gateway_core::engine::credential::{
AccountFeedbackStats, CredentialState, OpaqueProviderData, ProviderAccountId,
ProviderAccountStore as _, QuotaAccessChange, QuotaAccessState, QuotaEvidence,
@@ -16,9 +19,12 @@ use gateway_core::engine::{
AccountAttemptContext, AttemptContext, CancellationToken, ContinuationAttempt, ModelRequestId,
ProviderAccountStateOwner, RequestAttemptContext, UpstreamSendState,
};
use gateway_core::error::ProviderErrorKind;
use gateway_core::error::{ContinuationFailure, 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,
@@ -267,7 +273,8 @@ fn planned_request(provider_name: &str, operation: Operation) -> ProviderRequest
vec![ProviderModel::new(
provider,
upstream_model,
ModelCapabilities::new(BTreeSet::from([operation.kind()]), Some(32_000)),
ModelCapabilities::new(BTreeSet::from([operation.kind()]), Some(32_000))
.with_upstream_feature_validation(),
)],
Vec::new(),
)
@@ -288,6 +295,8 @@ fn contract_account_scope() -> Arc<FrozenAccountScope> {
let provider = ProviderKind::new("openai").expect("provider");
let accounts = [
"acct_affinity",
"acct_affinity_switch_a",
"acct_affinity_switch_b",
"acct_atomic_failure",
"acct_bare_atomic_failure",
"acct_bounded_replay_grace",
@@ -319,6 +328,7 @@ fn contract_account_scope() -> Arc<FrozenAccountScope> {
"acct_truncated_stream",
"acct_usage_limit_request_path",
"acct_websocket_close",
"acct_websocket_turn_state",
]
.into_iter()
.map(|id| {
@@ -389,6 +399,33 @@ fn context_with_state_owner(request_id: &str, owner_account_id: &str) -> Attempt
)
}
fn replay_any_context(request_id: &str, owner_account_id: &str) -> AttemptContext {
let owner_account = ProviderAccountId::new(owner_account_id).expect("owner account id");
let provider = ProviderKind::new("openai").expect("provider");
let owner = ProviderAccountStateOwner::new(provider.clone(), owner_account.clone());
let continuation = NativeContinuationPin::new(
PreviousResponseId::new("client-previous-response"),
PreviousResponseId::new("upstream-previous-response"),
ClientApiKeyId::new("key_openai_contract").expect("client key id"),
provider,
owner_account,
);
AttemptContext::new(
RequestAttemptContext::new(
ModelRequestId::new(request_id).expect("request id"),
ClientApiKeyId::new("key_openai_contract").expect("client key id"),
),
NonZeroU32::new(2).expect("attempt"),
SystemTime::now() + Duration::from_secs(30),
account_policy(),
AccountAttemptContext::new(BTreeSet::new(), None, Some(owner))
.with_account_scope(contract_account_scope()),
Some(ContinuationBinding::Pinned(continuation)),
CancellationToken::new(),
)
.with_continuation_attempt(ContinuationAttempt::ReplayAny)
}
async fn capture_scoped_http_request(
request_id: &str,
selected_account_id: &str,
@@ -433,6 +470,76 @@ async fn capture_scoped_http_request(
requests.pop().expect("single scoped request")
}
async fn capture_turn_state_request(
request_id: &str,
previous_turn_id: Option<&str>,
current_turn_id: Option<&str>,
client_turn_state: Option<&str>,
) -> wiremock::Request {
let account_id = "acct_session_affinity";
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, account_id).await;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(CAPTURE_COMPLETED_SSE),
)
.expect(1)
.mount(&server)
.await;
let mut session_state = Map::from_iter([
("account_id".to_owned(), json!(account_id)),
("conversation_id".to_owned(), json!("conversation")),
("turn_state".to_owned(), json!("previous-turn-state")),
("continuation_scope".to_owned(), json!("persisted")),
]);
if let Some(previous_turn_id) = previous_turn_id {
session_state.insert("client_turn_id".to_owned(), json!(previous_turn_id));
}
let mut protocol_context = Map::from_iter([("use_websocket".to_owned(), json!(false))]);
if let Some(current_turn_id) = current_turn_id {
protocol_context.insert("turn_id".to_owned(), json!(current_turn_id));
}
if let Some(client_turn_state) = client_turn_state {
protocol_context.insert("turn_state".to_owned(), json!(client_turn_state));
}
let operation = Operation::Generate(
GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object(
"openai",
Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), json!("current input")),
]),
)
.expect("OpenAI payload")
.with_context(protocol_context),
)
.with_provider_session_state(
ProviderSessionState::new("openai", session_state).expect("provider session state"),
),
);
let mut stream = provider_with_base_url(&store, server.uri())
.execute(
planned_request("openai", operation),
context(request_id, CancellationToken::new()),
)
.await
.expect("prepare turn-state provider stream");
while let Some(event) = stream.next().await {
event.expect("turn-state provider response");
}
let mut requests = server
.received_requests()
.await
.expect("captured turn-state request");
assert_eq!(requests.len(), 1);
requests.pop().expect("single turn-state request")
}
fn captured_header_values(request: &wiremock::Request, name: &str) -> Vec<Vec<u8>> {
request
.headers
@@ -789,6 +896,94 @@ async fn websocket_close_details_are_only_exposed_through_the_client_error() {
assert_eq!(detail.error_type(), Some("websocket_close_error"));
}
#[tokio::test]
async fn websocket_turn_state_metadata_is_exposed_through_response_observation() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_websocket_turn_state").await;
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind WebSocket listener");
let base_url = format!(
"http://{}",
listener.local_addr().expect("listener address")
);
let server = tokio::spawn(async move {
let (stream, _) = listener
.accept()
.await
.expect("accept WebSocket connection");
let mut websocket = accept_codex_test_websocket(stream).await;
let _request = websocket
.next()
.await
.expect("WebSocket request")
.expect("valid WebSocket request");
websocket
.send(Message::Text(
json!({
"type": "response.metadata",
"headers": {
"x-codex-turn-state": ["turn-state-from-websocket"]
}
})
.to_string()
.into(),
))
.await
.expect("send response metadata");
websocket
.send(Message::Text(
json!({
"type": "response.completed",
"response": {
"id": "resp_websocket_turn_state",
"model": "gpt-5.4",
"status": "completed",
"output": [],
"usage": {
"input_tokens": 1,
"output_tokens": 1,
"total_tokens": 2
}
}
})
.to_string()
.into(),
))
.await
.expect("send completed response");
websocket.close(None).await.expect("close WebSocket");
});
let mut stream = provider_with_base_url(&store, base_url)
.execute(
planned_request("openai", generate_operation()),
context("req_websocket_turn_state", CancellationToken::new()),
)
.await
.expect("prepare WebSocket provider stream");
let mut observed_turn_state = false;
while let Some(event) = stream.next().await {
let event = event.expect("provider event");
if let Some(observation) = event.response_observation() {
observed_turn_state |= observation.client_headers().iter().any(|header| {
header.name().eq_ignore_ascii_case("x-codex-turn-state")
&& header.value().as_ref() == b"turn-state-from-websocket"
});
}
if event
.canonical_facts()
.iter()
.any(|event| matches!(event, GatewayEvent::Completed(_)))
{
break;
}
}
server.await.expect("WebSocket server");
assert!(observed_turn_state);
}
#[tokio::test]
async fn disabled_account_is_excluded_from_normal_scheduling() {
let store = Arc::new(MemoryAccountStore::default());
@@ -971,9 +1166,9 @@ async fn cross_account_scope_sanitizes_only_known_turn_metadata_fields() {
}
#[tokio::test]
async fn continuation_replay_should_use_the_client_full_input_without_legacy_transcript() {
async fn cross_account_continuation_probe_should_preserve_delta_input_and_previous_response() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_client_history").await;
create_account(&store, "acct_scope_new").await;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
@@ -985,33 +1180,61 @@ async fn continuation_replay_should_use_the_client_full_input_without_legacy_tra
.expect(1)
.mount(&server)
.await;
let client_input = json!([
{"role": "user", "content": "complete client history"},
{"role": "assistant", "content": "previous answer"},
{"role": "user", "content": "current turn"}
let delta_input = json!([
{
"type": "message",
"id": "delta-message-id",
"encrypted_content": "opaque-delta-ciphertext",
"role": "user",
"content": "current turn only"
},
{
"type": "compaction",
"id": "delta-compaction-id",
"encrypted_content": "opaque-compaction-ciphertext"
}
]);
let generation = GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object(
"openai",
Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), client_input.clone()),
("input".to_owned(), delta_input.clone()),
(
"previous_response_id".to_owned(),
json!("client-previous-response"),
),
("authorization".to_owned(), json!("client-extension-secret")),
(
"turnMetadata".to_owned(),
json!(r#"{"account_id":"acct_scope_old","safe":true}"#),
),
(
"client_metadata".to_owned(),
json!({
"x-codex-turn-state": "old-account-turn-state",
"x-codex-turn-metadata": "old-account-turn-metadata",
"future": "keep"
}),
),
]),
)
.expect("OpenAI payload")
.with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))])),
.with_context(Map::from_iter([
("use_websocket".to_owned(), json!(false)),
("turn_id".to_owned(), json!("turn-probe")),
("turn_state".to_owned(), json!("old-account-turn-state")),
])),
)
.with_provider_session_state(
ProviderSessionState::new(
"openai",
Map::from_iter([
("account_id".to_owned(), json!("acct_client_history")),
("account_id".to_owned(), json!("acct_scope_old")),
("conversation_id".to_owned(), json!("conversation")),
("continuation_scope".to_owned(), json!("replay_required")),
(
"transcript".to_owned(),
json!([{"client_input": {"role": "user", "content": "stale proxy copy"}}]),
),
("turn_state".to_owned(), json!("old-account-turn-state")),
("client_turn_id".to_owned(), json!("turn-probe")),
("continuation_scope".to_owned(), json!("persisted")),
]),
)
.expect("provider session state"),
@@ -1019,21 +1242,84 @@ async fn continuation_replay_should_use_the_client_full_input_without_legacy_tra
let mut stream = provider_with_base_url(&store, server.uri())
.execute(
planned_request("openai", Operation::Generate(generation)),
context("req_client_history", CancellationToken::new())
.with_continuation_attempt(ContinuationAttempt::ReplayAny),
replay_any_context("req_cross_account_probe", "acct_scope_old"),
)
.await
.expect("prepare replay stream");
.expect("prepare continuation probe stream");
while let Some(event) = stream.next().await {
event.expect("replay response");
event.expect("continuation probe response");
}
let requests = server
.received_requests()
.await
.expect("captured replay request");
.expect("captured continuation probe request");
let body: Value = captured_request_body(&requests[0]);
assert_eq!(body.get("input"), Some(&client_input));
assert_eq!(body.get("input"), Some(&delta_input));
assert_eq!(
body.get("previous_response_id"),
Some(&json!("upstream-previous-response"))
);
assert!(body.get("authorization").is_none());
assert!(body.get("turnMetadata").is_none());
assert!(
body.pointer("/client_metadata/x-codex-turn-state")
.is_none()
);
assert!(
body.pointer("/client_metadata/x-codex-turn-metadata")
.is_none()
);
assert_eq!(
body.pointer("/client_metadata/future"),
Some(&json!("keep"))
);
assert!(captured_header_values(&requests[0], "x-codex-turn-state").is_empty());
}
#[tokio::test]
async fn matching_turn_id_should_restore_previous_turn_state() {
let request = capture_turn_state_request(
"req_restore_same_turn_state",
Some("turn-same"),
Some("turn-same"),
None,
)
.await;
assert_eq!(
captured_header_values(&request, "x-codex-turn-state"),
vec![b"previous-turn-state".to_vec()]
);
}
#[tokio::test]
async fn matching_turn_id_should_prefer_the_latest_provider_state_over_a_stale_client_echo() {
let request = capture_turn_state_request(
"req_replace_stale_client_turn_state",
Some("turn-same"),
Some("turn-same"),
Some("stale-client-turn-state"),
)
.await;
assert_eq!(
captured_header_values(&request, "x-codex-turn-state"),
vec![b"previous-turn-state".to_vec()]
);
}
#[tokio::test]
async fn new_or_unidentified_turn_should_not_restore_previous_turn_state() {
for (request_id, previous_turn_id, current_turn_id) in [
("req_new_turn_state", Some("turn-old"), Some("turn-new")),
("req_unidentified_turn_state", None, Some("turn-new")),
("req_missing_current_turn_state", Some("turn-old"), None),
] {
let request =
capture_turn_state_request(request_id, previous_turn_id, current_turn_id, None).await;
assert!(captured_header_values(&request, "x-codex-turn-state").is_empty());
}
}
#[tokio::test]
@@ -1259,6 +1545,134 @@ async fn explicit_session_id_should_override_turn_specific_prompt_cache_keys_for
assert_eq!(keys[0], keys[1]);
}
#[tokio::test]
async fn affinity_quota_switch_should_clear_old_turn_state_without_a_provider_state_owner() {
let store = Arc::new(MemoryAccountStore::default());
let first_account_id = "acct_affinity_switch_a";
let second_account_id = "acct_affinity_switch_b";
create_account(&store, first_account_id).await;
let affinity = Arc::new(MemorySessionAffinity::default());
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(CAPTURE_COMPLETED_SSE),
)
.expect(1)
.mount(&server)
.await;
let (provider, quota) = provider_and_quota_with_affinity_and_base_url_and_leases(
&store,
Arc::clone(&affinity),
server.uri(),
Arc::new(TestLeaseCoordinator::default()),
);
let session_id = "stable-affinity-switch-session";
let first_payload = ProtocolPayload::json_object(
"openai",
Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), json!("first request")),
("session_id".to_owned(), json!(session_id)),
]),
)
.expect("OpenAI payload")
.with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))]));
let first = provider
.execute(
planned_request(
"openai",
Operation::Generate(GenerateRequest::from_protocol_payload(first_payload)),
),
context("req_affinity_switch_first", CancellationToken::new()),
)
.await
.expect("prepare first affinity request");
drop(first);
let affinity_keys = affinity.lookup_keys();
assert_eq!(affinity_keys.len(), 1);
affinity.seed_binding(
&ProviderKind::new("openai").expect("provider"),
&affinity_keys[0],
ProviderAccountId::new(first_account_id).expect("first account id"),
);
assert_eq!(affinity.binding_count(), 1);
create_account(&store, second_account_id).await;
let first_account = store.account(first_account_id).expect("first account");
let observed_at = SystemTime::now();
store
.compare_and_swap_quota(QuotaObservation {
account_id: first_account.id().clone(),
expected_revision: first_account.revision(),
quota: OpaqueProviderData::new(Map::new()),
observed_at,
state: QuotaState::exhausted(QuotaEvidence::ProviderDenied, observed_at, None),
})
.await
.expect("seed exhausted affinity account");
let accounts = [
store
.account(first_account_id)
.expect("first account after quota"),
store.account(second_account_id).expect("second account"),
];
quota.prepare_scheduling(&accounts).await;
let second_payload = ProtocolPayload::json_object(
"openai",
Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), json!("second request")),
("session_id".to_owned(), json!(session_id)),
]),
)
.expect("OpenAI payload")
.with_context(Map::from_iter([
("use_websocket".to_owned(), json!(false)),
("turn_id".to_owned(), json!("turn-affinity-switch")),
(
"turn_state".to_owned(),
json!("state-created-by-exhausted-account"),
),
]));
let mut second = provider
.execute(
planned_request(
"openai",
Operation::Generate(GenerateRequest::from_protocol_payload(second_payload)),
),
context("req_affinity_switch_second", CancellationToken::new()),
)
.await
.expect("prepare switched affinity request");
while let Some(event) = second.next().await {
event.expect("switched affinity response");
}
let requests = server
.received_requests()
.await
.expect("captured affinity requests");
assert_eq!(requests.len(), 1);
let second_request = &requests[0];
assert_eq!(
captured_request_body(second_request).get("input"),
Some(&json!("second request"))
);
assert!(captured_header_values(second_request, "x-codex-turn-state").is_empty());
let expected_account_id = format!("chatgpt-{second_account_id}");
assert_eq!(
second_request
.headers
.get("chatgpt-account-id")
.and_then(|value| value.to_str().ok()),
Some(expected_account_id.as_str())
);
}
#[tokio::test]
async fn thread_spawn_children_should_have_independent_session_affinity_keys() {
let store = Arc::new(MemoryAccountStore::default());
@@ -1931,6 +2345,77 @@ async fn bare_response_failed_should_remain_an_atomic_replay_safe_failure() {
let _ = server.await;
}
#[tokio::test]
async fn previous_response_not_found_should_remain_client_visible_without_internal_replay_proof() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_client_history").await;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(concat!(
"event: response.failed\n",
"data: {\"type\":\"response.failed\",\"status_code\":400,\"response\":{\"id\":\"resp_history_missing\",\"status\":\"failed\",\"error\":{\"code\":\"previous_response_not_found\",\"message\":\"Previous response was not found. Retrying the full request.\"}}}\n\n"
)),
)
.expect(1)
.mount(&server)
.await;
let payload = ProtocolPayload::json_object(
"openai",
Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
(
"input".to_owned(),
json!([{"role": "user", "content": "delta"}]),
),
(
"previous_response_id".to_owned(),
json!("missing-previous-response"),
),
]),
)
.expect("OpenAI payload")
.with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))]));
let mut stream = provider_with_base_url(&store, server.uri())
.execute(
planned_request(
"openai",
Operation::Generate(GenerateRequest::from_protocol_payload(payload)),
),
context("req_history_missing", CancellationToken::new()),
)
.await
.expect("prepare missing-history stream");
let mut failure = loop {
match stream.next().await {
Some(Ok(_)) => {}
Some(Err(error)) => break error,
None => panic!("previous_response_not_found must produce a typed failure"),
}
};
assert_eq!(
failure.continuation_failure(),
Some(ContinuationFailure::HistoryUnavailable)
);
assert!(!failure.replay_is_safe());
assert_eq!(
failure
.upstream_code()
.map(gateway_core::error::OpaqueUpstreamValue::as_str),
Some("previous_response_not_found")
);
let events = failure.take_atomic_client_events();
assert_eq!(events.len(), 1);
assert_eq!(
events[0].wire_event().and_then(|wire| wire.event_type()),
Some("response.failed")
);
}
#[tokio::test]
async fn continuation_prefetch_over_64_kib_should_commit_wire_without_protocol_failure() {
let store = Arc::new(MemoryAccountStore::default());
@@ -2424,3 +2909,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,
@@ -583,6 +622,18 @@ impl MemorySessionAffinity {
pub(crate) fn binding_count(&self) -> usize {
self.bindings.lock().expect("session affinity lock").len()
}
pub(crate) fn seed_binding(
&self,
provider_kind: &ProviderKind,
key: &str,
account_id: ProviderAccountId,
) {
self.bindings.lock().expect("session affinity lock").insert(
(provider_kind.as_str().to_owned(), key.to_owned()),
account_id,
);
}
}
impl ProviderSessionAffinityPort for MemorySessionAffinity {
@@ -371,6 +371,7 @@ async fn codex_backend_client_should_capture_forwardable_response_metadata() {
.insert_header("openai-model", "gpt-5.5-2026-07-01")
.insert_header("x-models-etag", "models-v2")
.insert_header("x-reasoning-included", "true")
.insert_header("x-codex-turn-state", "turn-state-from-upstream")
.insert_header("openai-processing-ms", "42")
.append_header("x-future-multi", "first")
.append_header("x-future-multi", "second")
@@ -452,6 +453,7 @@ async fn codex_backend_client_should_capture_forwardable_response_metadata() {
"openai-processing-ms",
"retry-after",
"x-codex-safety-buffering-enabled",
"x-codex-turn-state",
"x-future-bytes",
"x-future-multi",
"x-future-multi",
@@ -478,6 +480,15 @@ async fn codex_backend_client_should_capture_forwardable_response_metadata() {
.iter()
.any(|(name, value)| { name == "x-future-bytes" && value.as_ref() == b"\xffopaque" })
);
assert!(
response
.response_metadata
.client_headers
.iter()
.any(|(name, value)| {
name == "x-codex-turn-state" && value.as_ref() == b"turn-state-from-upstream"
})
);
for blocked in [
"set-cookie",
"authorization",
@@ -16,6 +16,13 @@ fn new_chain_request(conversation_id: &str) -> CodexResponsesRequest {
request
}
fn external_continuation_request(conversation_id: &str) -> CodexResponsesRequest {
let mut request = new_chain_request(conversation_id);
request.set_previous_response_id(Some("resp_from_another_account".to_owned()));
request.previous_response_scope = Some(PreviousResponseScope::ExternalUnknown);
request
}
fn explicit_websocket_warmup_request(conversation_id: &str) -> CodexResponsesRequest {
let mut body = serde_json::Map::new();
body.insert("model".to_string(), json!("gpt-5.5"));
@@ -108,6 +115,43 @@ async fn cold_websocket_should_fall_back_without_recording_a_successful_connect(
pool.shutdown().await;
}
#[tokio::test(start_paused = true)]
async fn external_continuation_should_wait_for_a_cold_websocket() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (websocket_stream, _) = listener.accept().await.unwrap();
tokio::time::sleep(Duration::from_secs(1)).await;
let mut websocket = accept_codex_test_websocket(websocket_stream).await;
let _payload = websocket.next().await.unwrap().unwrap();
websocket
.send(Message::Text(
completed_websocket_response("resp_cross_account", 2, 1).into(),
))
.await
.unwrap();
});
let pool = Arc::new(CodexWebSocketPool::default());
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
let response = backend
.create_response(
&external_continuation_request("conversation-cross-account"),
request_context("req_cross_account", Some("chatgpt-account-b")),
)
.await
.expect("external continuation should complete over WebSocket");
server.await.unwrap();
assert_eq!(response.transport, CodexBackendTransport::WebSocket);
pool.shutdown().await;
}
#[tokio::test(start_paused = true)]
async fn timed_out_websocket_should_finish_in_background_and_serve_the_next_request() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -9,7 +9,9 @@ use std::{
use chrono::Utc;
use futures::{SinkExt, StreamExt};
use gateway_protocol::openai::events::{TokenUsage, extract_sse_usage};
use gateway_protocol::openai::events::{
TokenUsage, extract_sse_usage, rate_limits_to_header_pairs,
};
use provider_openai::transport::profile::{
CodexBundledReleaseProfile, CodexWireProfile, CodexWireProfileState,
};
@@ -233,7 +235,7 @@ async fn collect_backend_response(
mut turn_state,
set_cookie_headers,
mut rate_limit_headers,
rate_limit_header_updates,
rate_limit_updates,
turn_state_update,
websocket_pool_decision,
diagnostics: _,
@@ -251,8 +253,10 @@ async fn collect_backend_response(
});
body_bytes.extend_from_slice(&chunk);
}
if let Some(updates) = rate_limit_header_updates {
rate_limit_headers.extend(updates.lock().await.iter().cloned());
if let Some(updates) = rate_limit_updates {
for update in updates.lock().await.iter() {
rate_limit_headers.extend(rate_limits_to_header_pairs(update));
}
}
if let Some(update) = turn_state_update {
turn_state = update.lock().await.clone().or(turn_state);
@@ -159,6 +159,23 @@ fn websocket_event_to_sse_should_forward_public_events_and_strip_internal_events
assert!(websocket_event_to_sse_frame("not-json").is_none());
}
#[test]
fn websocket_event_to_sse_should_add_missing_previous_response_recovery_code() {
let event = json!({
"type": "error",
"status": 400,
"error": {
"type": "invalid_request_error",
"message": "Invalid `previous_response_id`."
}
})
.to_string();
let frame = websocket_event_to_sse_frame(&event).expect("public error event");
assert!(frame.contains(r#""code":"previous_response_not_found""#));
}
#[test]
fn websocket_metadata_turn_state_should_accept_case_insensitive_header() {
let event = json!({
+13 -7
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};
@@ -906,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));
}
@@ -1069,7 +1071,9 @@ fn project_quota(
key: billing.period_type().unwrap_or("billing").to_owned(),
group: quota_group(period_kind).to_owned(),
label: xai_quota_window_label(period_kind, window_seconds),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds,
used_percent: billing.used_percent(),
@@ -1085,7 +1089,9 @@ fn project_quota(
key: "free-rolling-24h".to_owned(),
group: "shortTerm".to_owned(),
label: "日限额".to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(crate::GROK_FREE_ROLLING_WINDOW_SECONDS),
used_percent: None,
+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,
-1
View File
@@ -55,7 +55,6 @@ RUN apt-get update \
&& useradd --system --uid 10001 --gid cpr --home-dir /app --shell /usr/sbin/nologin cpr \
&& install -d -o cpr -g cpr \
/app/bin /app/deploy /app/.runtime/data /app/.runtime/logs /app/web \
&& install -d -o cpr -g cpr -m 0700 /app/.runtime/data/backup-staging \
&& rm -rf /var/lib/apt/lists/*
COPY --from=pg-client /usr/lib/postgresql/18/bin/pg_dump /usr/lib/postgresql/18/bin/pg_dump
+11 -9
View File
@@ -91,16 +91,17 @@ cd backend
cargo run -p codex-proxy-rs
```
后端会从当前目录向上查找 `deploy/config.yaml`。相对数据和日志目录以该文件所在目录解析;
Compose 把监听地址和数据库、Redis 地址固定覆盖为容器内部服务名,并把前端静态目录指向
容器内构建产物。PG/Redis 集成测试所需的 `CPR_TEST_DATABASE_URL` / `CPR_TEST_REDIS_URL`
约定见 [迁移文档](../backend/migrations/README.md)。
后端会从当前目录向上查找 `deploy/config.yaml`。`host.runtime_data_dir` 是运行数据的统一根目录,
相对数据和日志目录均以该配置文件所在目录解析;Compose 把监听地址和数据库、Redis 地址固定
覆盖为容器内部服务名,并把前端静态目录指向容器内构建产物。PG/Redis 集成测试所需的
`CPR_TEST_DATABASE_URL` / `CPR_TEST_REDIS_URL` 约定见
[迁移文档](../backend/migrations/README.md)。
## 持久化与备份
Compose 使用以下绑定目录:
- `.runtime/data` → OpenAI 会话锚点密钥、更新状态与临时更新目录
- `.runtime/data` → OpenAI 会话锚点密钥、更新状态、临时更新目录与备份暂存区
- `.runtime/logs` → 应用文件日志
- `.runtime/postgres` → PostgreSQL
- `.runtime/redis` → Redis AOF
@@ -177,8 +178,8 @@ Compose 已显式装配正式发布构建所需的运行参数:
- `CPR_GITHUB_API_BASE`:正式环境必须为 `https://api.github.com/repos`。
- `CPR_UPDATE_CHANNEL`:`stable` 会拒绝 prerelease。
- `CPR_UPDATE_EXE_PATH`、`CPR_WEB_DIST_DIR`:分别指向容器内二进制和前端静态目录。
- `CPR_UPDATE_TEMP_DIR`、`CPR_UPDATE_STATE_FILE`、`CPR_UPDATE_LOCK_FILE`:全部位于持久化的
`.runtime/data`。
- 更新临时目录、状态文件和锁文件默认由 `host.runtime_data_dir` 派生;
`CPR_UPDATE_TEMP_DIR`、`CPR_UPDATE_STATE_FILE`、`CPR_UPDATE_LOCK_FILE` 仅用于显式覆盖。
- `CPR_ENABLE_SELF_RESTART=true`:更新或回滚完成后允许管理端请求重启;Docker 进程退出后由
Compose 的 `restart: unless-stopped` 拉起新进程。
@@ -201,8 +202,9 @@ Release 必须提供当前 OS/架构的 `codex-proxy-rs_<version>_<os>_<arch>.ta
`postgres:18-bookworm` 镜像(`Dockerfile` 的 `POSTGRES_IMAGE`)COPY 到
`/usr/lib/postgresql/18/bin` 并加入容器 PATH,版本与服务端严格一致;运行时只从
Debian 官方源补 `libpq5` 依赖,不引入第三方 APT 源,也不依赖运行时动态安装。
- 备份暂存目录为 `/app/.runtime/data/backup-staging`,由 `.runtime/data` 卷持久化,权限
`0700`(仅 `cpr` 用户可读写)。部署卷至少预留一个最大数据库归档的空间。
- 备份暂存目录为 `host.runtime_data_dir/backup-staging`;Compose 默认对应
`/app/.runtime/data/backup-staging`,由 `.runtime/data` 卷持久化,权限 `0700`(仅 `cpr`
用户可读写)。部署卷至少预留一个最大数据库归档的空间。
- OAuth 恢复记录通道使用 `oauth_recovery` 结构化日志事件;当前 OpenAI provider 每次成功取得
AT/RT 后,都会在账号资料补全、过期时间计算和数据库写入前写入独立的
`codex-proxy-rs-oath.YYYY-MM-DD[.N].log` 文件集。事件含原始 AT/RT,并以 `provider`
-3
View File
@@ -127,9 +127,6 @@ services:
CPR_UPDATE_CHANNEL: stable
CPR_GITHUB_API_BASE: https://api.github.com/repos
CPR_UPDATE_EXE_PATH: /app/bin/codex-proxy-rs
CPR_UPDATE_TEMP_DIR: /app/.runtime/data/update-tmp
CPR_UPDATE_STATE_FILE: /app/.runtime/data/update-state.json
CPR_UPDATE_LOCK_FILE: /app/.runtime/data/update.lock
CPR_WEB_DIST_DIR: /app/web/dist
CPR_ENABLE_SELF_RESTART: 'true'
logging: *json-logging
+3
View File
@@ -14,6 +14,9 @@ host:
# 本地 cargo run 使用;Docker 内由 CPR_SERVER_HOST/PORT 覆盖。
host: '127.0.0.1'
port: 8080
# OpenAI 会话身份、系统更新状态和备份暂存区的统一根目录。
# 相对路径以 deploy/config.yaml 所在目录为基准。
runtime_data_dir: '../.runtime/data'
logging:
level: 'info'
stdout: true
+5 -1
View File
@@ -104,7 +104,11 @@ continuation 或 claim 表。
- `store=true`:使用 Provider 持久化的 native handle。
- `store=false`:opaque replay state 仅存在于活连接内,不落 PostgreSQL。
- OpenAI continuation 顺序为 native、replay owner、replay any。
- OpenAI continuation 顺序为 native、replay owner、replay any。`replay any` 是
Provider 定义的恢复阶段,不代表 Core 已持有完整 input:OpenAI 在跨账号时保留
canonical `previous_response_id` 与原始增量 input 做 continuation probe;上游返回
`previous_response_not_found` 时原样交给官方客户端触发 full retry。只有确实持有
可携带 transcript 的 Provider 才能清除 native handle 后在代理内部重放。
- xAI 使用客户端提交的完整历史作为已验证 continuation 路径。
- native continuation pin 同时绑定创建它的 Client Key、冻结账号范围、Provider 和账号;跨 Key 复用、
scope 外账号或计划中不存在该 Provider 都 fail closed。`ReplayOwner` 同样固定原 Provider 和账号,
+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):
+4
View File
@@ -14,8 +14,12 @@ export type AccountErrorReason
export interface AccountQuotaWindow {
key: string
group: string
limitId: string | null
limitName: string | null
role: 'primary' | 'secondary' | 'monthly' | null
windowSeconds: number | null
labelDisplay: string
windowLabelDisplay: string
usedPercent: number | null
usedPercentDisplay: string
limitReached: boolean
-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>
+3 -1
View File
@@ -1,13 +1,15 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useUiClock } from '@/composables/useUiClock'
import { formatDateTime, formatRelativeTime } from '@/utils/date'
const props = defineProps<{
value: string | null
}>()
const displayValue = computed(() => props.value ? formatRelativeTime(props.value) : '')
const now = useUiClock()
const displayValue = computed(() => props.value ? formatRelativeTime(props.value, now.value) : '')
const fullValue = computed(() => props.value ? formatDateTime(props.value) : undefined)
</script>
@@ -2,6 +2,7 @@
import { Openai, Xai } from '@boxicons/vue'
import { LayoutGrid } from '@lucide/vue'
import { PROVIDER_DISPLAY_NAMES } from '@/utils/providers'
import BaseSegmented from './base/BaseSegmented.vue'
withDefaults(
@@ -17,18 +18,17 @@ const provider = defineModel<string>({ required: true })
const providerOptions = [
{ label: '全部平台', value: '', icon: LayoutGrid },
{ label: 'OpenAI', value: 'openai', icon: Openai },
{ label: 'xAI', value: 'xai', icon: Xai },
{ label: PROVIDER_DISPLAY_NAMES.openai, value: 'openai', icon: Openai },
{ label: PROVIDER_DISPLAY_NAMES.xai, value: 'xai', icon: Xai },
]
</script>
<template>
<BaseSegmented
v-model="provider"
label="按平台筛选"
:options="providerOptions"
:disabled="disabled"
icon-only
size="input"
aria-label="按平台筛选"
display="icon"
/>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { Key, Openai, Robot, Xai } from '@boxicons/vue'
import { computed } from 'vue'
import { providerDisplayName } from '@/utils/providers'
import { formatProviderLabel } from '@/utils/providers'
const props = withDefaults(
defineProps<{
@@ -25,9 +25,7 @@ const iconContainerClass = computed(() =>
)
const iconClass = computed(() => (props.size === 'sm' ? 'size-3' : 'size-4'))
const providerLabel = computed(
() => providerDisplayName(normalizedProvider.value) ?? (props.provider?.trim() || '未知平台'),
)
const providerLabel = computed(() => formatProviderLabel(props.provider, '未知平台'))
const authenticationLabel = computed(() => {
if (normalizedAuthenticationKind.value === 'agent_identity')
@@ -41,18 +39,18 @@ const authenticationLabel = computed(() => {
<template>
<span class="inline-flex items-center whitespace-nowrap" :class="groupGapClass">
<span
class="inline-flex shrink-0 items-center justify-center bg-(--cp-bg-muted) text-(--cp-text-primary)"
class="inline-flex shrink-0 items-center justify-center bg-cp-muted text-cp-primary"
:class="iconContainerClass"
:title="providerLabel"
>
<Openai v-if="normalizedProvider === 'openai'" :class="iconClass" aria-hidden="true" />
<Xai v-else-if="normalizedProvider === 'xai'" :class="iconClass" aria-hidden="true" />
<span v-else class="text-[10px] font-heavy text-(--cp-text-muted)">?</span>
<span v-else class="text-[10px] font-heavy text-cp-muted-text">?</span>
</span>
<span
v-if="showAuthenticationKind"
class="inline-flex shrink-0 items-center justify-center bg-(--cp-bg-muted) text-(--cp-text-primary)"
class="inline-flex shrink-0 items-center justify-center bg-cp-muted text-cp-primary"
:class="iconContainerClass"
:title="authenticationLabel"
>
@@ -66,7 +64,7 @@ const authenticationLabel = computed(() => {
:class="iconClass"
aria-hidden="true"
/>
<span v-else class="text-[10px] font-heavy text-(--cp-text-muted)">?</span>
<span v-else class="text-[10px] font-heavy text-cp-muted-text">?</span>
</span>
<span class="sr-only">
+38 -102
View File
@@ -1,119 +1,62 @@
<script setup lang="ts">
import { LoaderCircle } from '@lucide/vue'
import { computed, useSlots } from 'vue'
import { computed } from 'vue'
type ButtonVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'ghost'
type ButtonSize = 'sm' | 'default' | 'md' | 'lg'
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive'
type ButtonSize = 'sm' | 'md' | 'lg'
const props = withDefaults(
defineProps<{
variant?: ButtonVariant
size?: ButtonSize
loading?: boolean
spinIconOnLoading?: boolean
disabled?: boolean
active?: boolean
iconOnly?: boolean
label?: string
title?: string
type?: 'button' | 'submit' | 'reset'
}>(),
{
variant: 'primary',
size: 'default',
variant: 'secondary',
size: 'md',
loading: false,
spinIconOnLoading: false,
disabled: false,
active: false,
iconOnly: false,
type: 'button',
},
)
const slots = useSlots()
defineSlots<{
icon?: () => unknown
loading?: () => unknown
default: () => unknown
}>()
const variantClasses = {
default:
'bg-(--cp-bg-muted) text-(--cp-text-primary) shadow-(--cp-shadow-control) hover:bg-(--cp-default-bg-active) active:bg-(--cp-default-bg-active)',
const variantClasses: Record<ButtonVariant, string> = {
primary:
'bg-(--cp-info) text-(--cp-info-on) hover:bg-(--cp-info-hover) active:bg-(--cp-info-pressed)',
success:
'bg-(--cp-success-bg) text-(--cp-success-text) hover:bg-(--cp-success-bg-hover) active:bg-(--cp-success-bg-active)',
warning:
'bg-(--cp-warning-bg) text-(--cp-warning-text) hover:bg-(--cp-warning-bg-hover) active:bg-(--cp-warning-bg-active)',
danger:
'bg-(--cp-danger-bg) text-(--cp-danger-text) hover:bg-(--cp-danger-bg-hover) active:bg-(--cp-danger-bg-active)',
'bg-cp-accent text-cp-accent-on shadow-cp-control hover:bg-cp-accent-hover active:bg-cp-accent-pressed',
secondary:
'bg-cp-muted text-cp-primary shadow-cp-control hover:bg-cp-default-active active:bg-cp-nav-active',
ghost:
'bg-transparent text-(--cp-text-secondary) hover:bg-(--cp-bg-subtle) active:bg-(--cp-bg-muted)',
'bg-transparent text-cp-secondary shadow-none hover:bg-cp-subtle hover:text-cp-primary active:bg-cp-muted',
destructive:
'bg-cp-danger-bg text-cp-danger-text shadow-none hover:bg-cp-danger-bg-hover active:bg-cp-danger-bg-active',
}
const iconOnlyDefaultClasses
= 'bg-(--cp-bg-surface) text-(--cp-text-secondary) shadow-(--cp-shadow-control) hover:bg-(--cp-default-bg-hover) hover:text-(--cp-normal) active:bg-(--cp-default-bg-active)'
const sizeClasses: Record<ButtonSize, string> = {
sm: 'h-7 gap-1.5 px-3 text-xs',
default: 'h-(--cp-input-height-default) gap-2 px-4 text-[13px]',
md: 'h-11 gap-2 px-4 text-[13px]',
lg: 'h-11.5 gap-2.5 px-5 text-[15px]',
sm: 'h-cp-control-sm gap-1.5 px-3 text-xs',
md: 'h-cp-control-md gap-2 px-4 text-[13px]',
lg: 'h-cp-control-lg gap-2.5 px-5 text-[14px]',
}
const iconOnlySizeClasses: Record<ButtonSize, string> = {
sm: 'size-8 rounded-(--cp-icon-button-radius)',
default: 'size-(--cp-input-height-default) rounded-(--cp-icon-button-radius)',
md: 'size-11 rounded-(--cp-icon-button-radius)',
lg: 'size-11.5 rounded-(--cp-icon-button-radius)',
}
const loadingIconSize: Record<ButtonSize, number> = {
const spinnerSizes: Record<ButtonSize, number> = {
sm: 14,
default: 15,
md: 16,
md: 15,
lg: 17,
}
const loadingIndicatorStyle = computed(() => {
const iconSize = loadingIconSize[props.size]
return {
width: `${iconSize}px`,
height: `${iconSize}px`,
}
})
const classes = computed(() => [
'inline-flex items-center justify-center border-0 font-bold leading-[1.15] transition-[background-color,box-shadow,color,opacity,transform] duration-150 cursor-pointer outline-none motion-safe:active:translate-y-px motion-safe:active:scale-[0.985]',
props.iconOnly ? iconOnlySizeClasses[props.size] : sizeClasses[props.size],
props.iconOnly ? 'shrink-0' : undefined,
props.iconOnly ? '' : 'rounded-(--cp-button-radius-base)',
'focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-(--cp-disabled-bg) disabled:text-(--cp-disabled-text) disabled:shadow-none',
props.iconOnly && (props.variant === 'default' || props.variant === 'primary')
? iconOnlyDefaultClasses
: variantClasses[props.variant],
props.active ? 'bg-(--cp-default-bg-hover) text-(--cp-normal)' : undefined,
])
const ariaLabel = computed(() => props.label || props.title)
const labelText = computed(() => {
const defaultSlot = slots.default?.()
const text = defaultSlot
?.map(node => (typeof node.children === 'string' ? node.children : ''))
.join('')
.trim()
return text || ''
})
const labelClasses = computed(() => [
'inline-flex min-w-0 items-center justify-center gap-2 [&>svg]:shrink-0',
labelText.value.length === 2 ? 'tracking-[0.12em]' : undefined,
])
const loadingIconClasses = computed(() => [
'inline-grid shrink-0 place-items-center leading-none [&>svg]:block',
props.loading
? '[&>svg]:origin-center [&>svg]:animate-spin [&>svg]:transform-view [&>svg]:will-change-transform motion-reduce:[&>svg]:animate-none'
: undefined,
'inline-flex shrink-0 touch-manipulation items-center justify-center rounded-cp-control-sm border-0 font-bold leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:translate-y-px motion-safe:active:scale-[0.985] motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-disabled disabled:text-cp-disabled-text disabled:shadow-none',
sizeClasses[props.size],
variantClasses[props.variant],
])
</script>
@@ -122,31 +65,24 @@ const loadingIconClasses = computed(() => [
:type="type"
:class="classes"
:disabled="disabled || loading"
:aria-label="iconOnly ? ariaLabel : undefined"
:title="title || (iconOnly ? label : undefined)"
:aria-busy="loading"
:aria-busy="loading || undefined"
>
<span
v-if="loading && !spinIconOnLoading"
:class="loadingIconClasses"
:style="loadingIndicatorStyle"
v-if="loading"
class="inline-grid shrink-0 place-items-center leading-none"
aria-hidden="true"
>
<LoaderCircle
:size="loadingIconSize[size]"
/>
<slot name="loading">
<LoaderCircle
class="animate-spin motion-reduce:animate-none"
:size="spinnerSizes[size]"
/>
</slot>
</span>
<span v-else-if="$slots.icon" :class="loadingIconClasses" aria-hidden="true">
<span v-else-if="$slots.icon" class="inline-grid shrink-0 place-items-center" aria-hidden="true">
<slot name="icon" />
</span>
<span v-if="!iconOnly && $slots.default" :class="labelClasses">
<slot />
</span>
<span
v-if="iconOnly && !$slots.icon && (!loading || spinIconOnLoading)"
:class="loadingIconClasses"
aria-hidden="true"
>
<span class="inline-flex min-w-0 items-center justify-center gap-2">
<slot />
</span>
</button>
+17 -51
View File
@@ -1,30 +1,19 @@
<script setup lang="ts">
import { computed } from 'vue'
type CardPadding = 'default' | 'compact'
type CardLayout = 'content' | 'flush'
type HeaderCollapseAt = 'sm' | 'lg' | 'none'
type CardPadding = 'none' | 'compact' | 'default'
const props = withDefaults(
defineProps<{
as?: keyof HTMLElementTagNameMap
padding?: CardPadding
layout?: CardLayout
title?: string
description?: string
headerCollapseAt?: HeaderCollapseAt
headerClass?: string
bodyClass?: string
}>(),
{
as: 'section',
padding: 'default',
layout: 'content',
title: undefined,
description: undefined,
headerCollapseAt: 'sm',
headerClass: undefined,
bodyClass: '',
},
)
@@ -37,55 +26,36 @@ const slots = defineSlots<{
default?: () => unknown
}>()
const paddingClasses: Record<CardPadding, string> = {
default: 'p-5.5',
const paddingClasses: Record<CardPadding, string | undefined> = {
none: undefined,
compact: 'p-4',
default: 'p-5.5',
}
const hasManagedHeader = computed(
() =>
!!props.title || !!props.description || !!slots.actions || !!slots.title || !!slots.description,
() => Boolean(props.title || props.description || slots.actions || slots.title || slots.description),
)
const outerPaddingClass = computed(() => {
if (props.layout === 'flush')
return undefined
return paddingClasses[props.padding]
})
const managedHeaderLayoutClasses = computed(() => {
if (props.headerCollapseAt === 'none') {
return 'flex items-start justify-between gap-3'
}
if (props.headerCollapseAt === 'lg') {
return 'flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between'
}
return 'flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'
})
const hasHeader = computed(() => Boolean(slots.header || hasManagedHeader.value))
const contentClasses = computed(() => [
hasHeader.value ? 'mt-4' : undefined,
'flex min-h-0 min-w-0 flex-1 flex-col',
])
</script>
<template>
<component
:is="props.as"
class="[--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] overflow-hidden rounded-(--cp-card-radius) bg-(--cp-bg-surface) shadow-(--cp-shadow-card)"
:class="[
outerPaddingClass,
]"
class="[--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] overflow-hidden rounded-cp-surface bg-cp-surface shadow-cp-card"
:class="[paddingClasses[padding], hasHeader ? 'flex min-h-0 flex-col' : undefined]"
>
<template v-if="$slots.header || hasManagedHeader || $slots.body">
<header
v-if="$slots.header || hasManagedHeader"
class="shrink-0"
:class="props.headerClass"
>
<header v-if="$slots.header || hasManagedHeader" class="shrink-0">
<slot name="header">
<div :class="managedHeaderLayoutClasses">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 pt-0.5">
<h2
v-if="props.title || $slots.title"
class="m-0 text-xl leading-[1.15] font-heavy text-(--cp-text-primary)"
class="m-0 text-xl leading-[1.15] font-heavy text-cp-primary text-balance"
>
<slot name="title">
{{ props.title }}
@@ -93,14 +63,13 @@ const managedHeaderLayoutClasses = computed(() => {
</h2>
<p
v-if="props.description || $slots.description"
class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-emphasis text-(--cp-text-secondary)"
class="mt-1.75 mb-0 text-[13px] leading-[1.3] font-emphasis text-cp-secondary text-pretty"
>
<slot name="description">
{{ props.description }}
</slot>
</p>
</div>
<div v-if="$slots.actions" class="shrink-0">
<slot name="actions" />
</div>
@@ -108,10 +77,7 @@ const managedHeaderLayoutClasses = computed(() => {
</slot>
</header>
<div
v-if="$slots.body || $slots.default"
:class="props.bodyClass"
>
<div v-if="$slots.body || $slots.default" :class="contentClasses">
<slot name="body">
<slot />
</slot>
+35 -36
View File
@@ -1,70 +1,69 @@
<script setup lang="ts">
import { Check, Minus } from '@lucide/vue'
import { computed } from 'vue'
import { computed, useTemplateRef, watchEffect } from 'vue'
const props = withDefaults(
defineProps<{
label: string
indeterminate?: boolean
disabled?: boolean
label?: string
showLabel?: boolean
}>(),
{
indeterminate: false,
disabled: false,
label: undefined,
showLabel: false,
},
)
const model = defineModel<boolean>({ default: false })
const inputRef = useTemplateRef<HTMLInputElement>('input')
const checked = computed(() => model.value || props.indeterminate)
watchEffect(() => {
if (inputRef.value)
inputRef.value.indeterminate = props.indeterminate
})
const boxClasses = computed(() => [
'relative inline-flex size-4 shrink-0 items-center justify-center rounded-(--cp-checkbox-radius) border-0 transition-[background-color,box-shadow,color] duration-150',
'relative inline-flex size-4 shrink-0 items-center justify-center rounded-cp-control-sm border-0 transition-[background-color,box-shadow,color] duration-150 motion-reduce:transition-none',
props.disabled
? 'bg-(--cp-disabled-bg) text-(--cp-disabled-icon)'
? 'bg-cp-disabled text-cp-disabled-icon'
: checked.value
? 'bg-(--cp-info-bg) text-(--cp-info)'
: 'bg-(--cp-bg-surface) text-transparent shadow-[inset_0_0_0_1px_var(--cp-default-border-hover)]',
? 'bg-cp-accent text-cp-accent-on'
: 'bg-cp-surface text-transparent shadow-[inset_0_0_0_1px_var(--cp-default-border-hover)]',
])
function toggle() {
if (props.disabled) {
return
}
model.value = !model.value
}
</script>
<template>
<button
type="button"
class="group inline-flex min-h-4 min-w-4 appearance-none items-center gap-2.5 border-0 bg-transparent p-0 text-[13px] leading-none font-emphasis transition-opacity outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)"
<!-- The native checkbox is nested; the rule does not resolve this Vue template pattern. -->
<!-- eslint-disable-next-line vue-a11y/label-has-for -->
<label
class="group relative inline-flex min-h-4 min-w-4 items-center gap-2.5 text-[13px] leading-none font-emphasis"
:class="disabled ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'"
role="checkbox"
:aria-checked="indeterminate ? 'mixed' : model"
:aria-label="label"
:disabled="disabled"
@click="toggle"
>
<span :class="boxClasses">
<input
ref="input"
v-model="model"
type="checkbox"
class="peer sr-only"
:aria-label="label"
:disabled="disabled"
>
<span
:class="boxClasses"
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-accent-border peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-surface"
aria-hidden="true"
>
<Minus
class="absolute transition-opacity duration-150 size-3"
:class="[indeterminate ? 'opacity-100' : 'opacity-0']"
class="absolute size-3 transition-opacity duration-150 motion-reduce:transition-none"
:class="indeterminate ? 'opacity-100' : 'opacity-0'"
/>
<Check
class="absolute transition-opacity duration-150 size-3"
:class="[!indeterminate && model ? 'opacity-100' : 'opacity-0']"
class="absolute size-3 transition-opacity duration-150 motion-reduce:transition-none"
:class="!indeterminate && model ? 'opacity-100' : 'opacity-0'"
/>
</span>
<span
v-if="showLabel && label"
class="text-[13px] leading-none font-emphasis"
>
{{ label }}
</span>
</button>
<span v-if="showLabel" class="text-[13px] leading-none font-emphasis">{{ label }}</span>
</label>
</template>
@@ -0,0 +1,113 @@
import { normalizeRgbaHexColor } from '@/utils/color'
const RGBA_COLOR_PATTERN
= /^rgba\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(0(?:\.\d+)?|1(?:\.0+)?)\s*\)$/i
interface HsvColor {
h: number
s: number
v: number
a: number
}
interface RgbaColor {
r: number
g: number
b: number
a: number
}
export function hexToHsv(value: string): HsvColor | null {
const normalized = normalizeRgbaHexColor(value)
if (!normalized)
return null
const red = Number.parseInt(normalized.slice(1, 3), 16) / 255
const green = Number.parseInt(normalized.slice(3, 5), 16) / 255
const blue = Number.parseInt(normalized.slice(5, 7), 16) / 255
const alpha = Number.parseInt(normalized.slice(7, 9), 16) / 255
const maximum = Math.max(red, green, blue)
const minimum = Math.min(red, green, blue)
const delta = maximum - minimum
let hue = 0
if (delta > 0) {
if (maximum === red)
hue = 60 * (((green - blue) / delta) % 6)
else if (maximum === green)
hue = 60 * ((blue - red) / delta + 2)
else
hue = 60 * ((red - green) / delta + 4)
}
return {
h: hue < 0 ? hue + 360 : hue,
s: maximum === 0 ? 0 : (delta / maximum) * 100,
v: maximum * 100,
a: alpha,
}
}
export function hsvToHex(color: HsvColor) {
const hue = ((color.h % 360) + 360) % 360
const saturation = clamp(color.s, 0, 100) / 100
const brightness = clamp(color.v, 0, 100) / 100
const chroma = brightness * saturation
const section = hue / 60
const intermediate = chroma * (1 - Math.abs((section % 2) - 1))
const offset = brightness - chroma
const [red, green, blue] = section < 1
? [chroma, intermediate, 0]
: section < 2
? [intermediate, chroma, 0]
: section < 3
? [0, chroma, intermediate]
: section < 4
? [0, intermediate, chroma]
: section < 5
? [intermediate, 0, chroma]
: [chroma, 0, intermediate]
const rgb = [red, green, blue]
.map(channel => Math.round((channel + offset) * 255).toString(16).padStart(2, '0'))
.join('')
const alpha = Math.round(clamp(color.a, 0, 1) * 255).toString(16).padStart(2, '0')
return `#${rgb}${alpha}`.toUpperCase()
}
export function parseRgbaColor(value: string): RgbaColor | null {
const match = RGBA_COLOR_PATTERN.exec(value.trim())
if (!match)
return null
const red = Number(match[1])
const green = Number(match[2])
const blue = Number(match[3])
const alpha = Number(match[4])
if ([red, green, blue].some(channel => channel > 255))
return null
return { r: red, g: green, b: blue, a: alpha }
}
export function rgbaToHexColor(color: RgbaColor) {
return `#${[color.r, color.g, color.b, color.a * 255]
.map(channel => Math.round(channel).toString(16).padStart(2, '0'))
.join('')}`.toUpperCase()
}
export function formatRgbaColor(value: string) {
const normalized = normalizeRgbaHexColor(value)
if (!normalized)
return null
const red = Number.parseInt(normalized.slice(1, 3), 16)
const green = Number.parseInt(normalized.slice(3, 5), 16)
const blue = Number.parseInt(normalized.slice(5, 7), 16)
const alpha = Number((Number.parseInt(normalized.slice(7, 9), 16) / 255).toFixed(3))
return `rgba(${red}, ${green}, ${blue}, ${alpha})`
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(maximum, Math.max(minimum, value))
}

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