mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 08:05:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53739ebe5c | ||
|
|
7f420257a6 | ||
|
|
4f85209dc9 | ||
|
|
cd89e2e8fc | ||
|
|
0cb5a29699 | ||
|
|
61788804b4 | ||
|
|
2751ea6aa4 | ||
|
|
58fe66789c | ||
|
|
573b6b26db | ||
|
|
d425428ebe | ||
|
|
b26d3b386a | ||
|
|
cf0b14c5fd | ||
|
|
056561de41 | ||
|
|
f2215df72e | ||
|
|
72f8957163 | ||
|
|
73f0c15790 | ||
|
|
0558693949 | ||
|
|
2a9cb02cc9 |
@@ -79,7 +79,7 @@ jobs:
|
||||
${{ hashFiles('trivy-results.sarif') != '' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: runtime-container
|
||||
|
||||
Generated
+35
-35
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1262,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),
|
||||
@@ -1391,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),
|
||||
@@ -1436,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),
|
||||
@@ -1482,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),
|
||||
@@ -1547,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),
|
||||
@@ -1559,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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,10 +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>,
|
||||
/// 供管理端展示的错误消息;结构化上游失败应保留原始 message,不能写入整个正文。
|
||||
/// 恢复 Ready 时必须清空。
|
||||
/// 刷新成功或其他无失败事实的 Ready 写入必须清空。
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
@@ -1171,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);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -819,7 +819,13 @@ impl CodexCredentialAdminService {
|
||||
.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
|
||||
@@ -839,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;
|
||||
@@ -1016,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,保证本次补全不会丢失后续刷新能力。
|
||||
@@ -1030,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))
|
||||
}
|
||||
|
||||
@@ -1173,6 +1165,8 @@ fn log_manual_refresh_failure(account_id: &ProviderAccountId, error: &RefreshFai
|
||||
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"
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -21,7 +21,7 @@ use thiserror::Error;
|
||||
use super::recovery_log::{CodexOAuthRecoveryOperation, record_oauth_recovery};
|
||||
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
|
||||
use super::token_client::{RefreshFailure, RefreshUpstreamFailure, TokenPair, TokenRefresher};
|
||||
use super::types::CodexOAuthSecret;
|
||||
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>,
|
||||
@@ -101,6 +101,8 @@ fn log_refresh_deferred(
|
||||
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),
|
||||
@@ -273,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,
|
||||
RefreshFailureContext::new("refresh_recovery_window_exhausted", None, None),
|
||||
CodexCredentialRefreshOutcome::Invalidated { account_id },
|
||||
)
|
||||
.await;
|
||||
}
|
||||
);
|
||||
let capacity = self
|
||||
.leases
|
||||
.try_acquire(ProviderLeaseRequest::RefreshCapacity(
|
||||
@@ -316,7 +308,44 @@ 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 { message, upstream }) => {
|
||||
self.persist_terminal(
|
||||
@@ -353,8 +382,8 @@ impl CodexCredentialRefreshService {
|
||||
}
|
||||
}
|
||||
Err(RefreshFailure::Transport { message, upstream }) => {
|
||||
// 上游瞬态(429/5xx/超时/畸形响应等,发送状态未知)保留现有凭据、
|
||||
// 推进退避重试;仅 invalid_grant/banned 才终态失效账号。
|
||||
// 上游瞬态(401/429/5xx/超时/畸形响应等)保留现有凭据、
|
||||
// 记录最近一次失败并推进有界退避。
|
||||
if self
|
||||
.defer_refresh(
|
||||
&due.account,
|
||||
@@ -437,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,
|
||||
@@ -466,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;
|
||||
@@ -534,7 +559,12 @@ impl CodexCredentialRefreshService {
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
let persisted_message = message.as_deref().unwrap_or(reason).to_owned();
|
||||
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(
|
||||
@@ -542,7 +572,7 @@ impl CodexCredentialRefreshService {
|
||||
credential_state,
|
||||
SystemTime::now(),
|
||||
Some(error_reason),
|
||||
Some(persisted_message),
|
||||
persisted_message,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -553,6 +583,8 @@ impl CodexCredentialRefreshService {
|
||||
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),
|
||||
@@ -590,7 +622,20 @@ 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(),
|
||||
|
||||
@@ -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(¤t.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,
|
||||
|
||||
@@ -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,7 +42,6 @@ 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()
|
||||
}
|
||||
}
|
||||
@@ -121,13 +117,21 @@ impl RefreshFailure {
|
||||
#[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]) -> Self {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -137,6 +141,16 @@ impl RefreshUpstreamFailure {
|
||||
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
|
||||
@@ -148,6 +162,8 @@ impl fmt::Debug for RefreshUpstreamFailure {
|
||||
formatter
|
||||
.debug_struct("RefreshUpstreamFailure")
|
||||
.field("status", &self.status)
|
||||
.field("code", &self.code)
|
||||
.field("error_type", &self.error_type)
|
||||
.field("body", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
@@ -182,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 {
|
||||
@@ -191,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()
|
||||
}
|
||||
}
|
||||
@@ -273,7 +287,6 @@ struct RefreshTokenResponse {
|
||||
access_token: Option<String>,
|
||||
refresh_token: Option<String>,
|
||||
id_token: Option<String>,
|
||||
expires_in: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -300,6 +313,8 @@ enum RefreshError {
|
||||
struct RefreshErrorDetails {
|
||||
code: Option<String>,
|
||||
message: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
error_type: Option<String>,
|
||||
}
|
||||
|
||||
impl RefreshErrorResponse {
|
||||
@@ -319,6 +334,13 @@ impl RefreshErrorResponse {
|
||||
}
|
||||
.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)]
|
||||
@@ -326,7 +348,6 @@ struct AuthorizationCodeResponse {
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
id_token: String,
|
||||
expires_in: Option<u64>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -398,7 +419,6 @@ impl AuthorizationCodeExchanger for OpenAiTokenClient {
|
||||
id_token: None,
|
||||
},
|
||||
id_token,
|
||||
expires_in: optional_expiry(tokens.expires_in),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -447,36 +467,43 @@ 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 {
|
||||
// 官方刷新错误的消息与错误码分别位于 `error.message`、`error.code`;
|
||||
// `error` 字符串与顶层 `code` 仅用于兼容官方客户端自身的错误码提取契约。
|
||||
let error = serde_json::from_slice::<RefreshErrorResponse>(body).ok();
|
||||
let message = Some(refresh_failure_message(error.as_ref(), body));
|
||||
let upstream = || Some(Box::new(RefreshUpstreamFailure::new(status, body)));
|
||||
// 5xx 与 429 按状态码判为瞬态:其正文(CDN/网关页等)不是权威 OAuth 错误响应,
|
||||
// 不据此终态失效账号,但官方 `error.message` 仍用于本次诊断。
|
||||
if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
let Some(error) = error else {
|
||||
return RefreshFailure::Transport {
|
||||
// 非 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 message.as_deref().is_some_and(|message| {
|
||||
message
|
||||
.to_ascii_lowercase()
|
||||
@@ -487,39 +514,12 @@ fn classify_refresh_failure(status: StatusCode, body: &[u8]) -> RefreshFailure {
|
||||
upstream: upstream(),
|
||||
};
|
||||
}
|
||||
let normalized_code = error.code().map(str::to_ascii_lowercase);
|
||||
if matches!(
|
||||
normalized_code.as_deref(),
|
||||
Some(
|
||||
"invalid_grant"
|
||||
| "invalid_token"
|
||||
| "access_denied"
|
||||
| "refresh_token_expired"
|
||||
| "refresh_token_reused"
|
||||
| "refresh_token_invalidated"
|
||||
)
|
||||
) {
|
||||
return RefreshFailure::InvalidGrant {
|
||||
message,
|
||||
upstream: upstream(),
|
||||
};
|
||||
}
|
||||
RefreshFailure::Transport {
|
||||
message,
|
||||
upstream: upstream(),
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_failure_message(error: Option<&RefreshErrorResponse>, body: &[u8]) -> String {
|
||||
if let Some(message) = error.and_then(RefreshErrorResponse::message) {
|
||||
return message;
|
||||
}
|
||||
if body.is_empty() {
|
||||
return "Unknown error".to_owned();
|
||||
}
|
||||
String::from_utf8_lossy(body).into_owned()
|
||||
}
|
||||
|
||||
fn refresh_transport_failure(error: &reqwest::Error) -> RefreshFailure {
|
||||
let message = error.to_string();
|
||||
if is_safe_to_retry_refresh_transport(error) {
|
||||
|
||||
@@ -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("a, &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(
|
||||
"a,
|
||||
&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("a, &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(
|
||||
"a,
|
||||
&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("a, &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(
|
||||
"a,
|
||||
&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("a, &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(
|
||||
"a,
|
||||
&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,
|
||||
@@ -1873,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 { .. })
|
||||
@@ -2152,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::{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)),
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -281,6 +297,66 @@ 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());
|
||||
@@ -326,6 +402,158 @@ async fn scheduled_refresh_persists_the_original_upstream_error_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());
|
||||
@@ -361,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,
|
||||
@@ -55,7 +53,7 @@ async fn oversized_chunked_oauth_response_should_fail_closed_and_redact_body() {
|
||||
}
|
||||
|
||||
#[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"))
|
||||
@@ -77,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]
|
||||
@@ -122,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]
|
||||
@@ -305,23 +301,66 @@ async fn refresh_token_reuse_should_be_classified_as_invalid_grant() {
|
||||
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 oauth_error_without_nested_message_should_use_the_complete_body() {
|
||||
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::InvalidGrant { message, upstream } = failure else {
|
||||
panic!("invalid_grant must be terminal");
|
||||
let RefreshFailure::Transport { message, upstream } = failure else {
|
||||
panic!("unknown official refresh code must remain transient");
|
||||
};
|
||||
assert_eq!(message.as_deref(), Some(body));
|
||||
assert_eq!(upstream.expect("complete upstream failure").body(), body);
|
||||
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(
|
||||
@@ -341,50 +380,60 @@ async fn deactivated_account_should_be_classified_as_banned() {
|
||||
async fn generic_banned_text_should_not_impersonate_the_deactivation_contract() {
|
||||
let failure = refresh_failure(403, "account is banned").await;
|
||||
|
||||
assert_transport_failure(&failure, 403, "account is banned", "account is banned");
|
||||
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_transport_failure(&failure, 400, "account disabled", "account disabled");
|
||||
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_transport_failure(&failure, 400, "quota exceeded", "quota exceeded");
|
||||
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_transport_failure(&failure, 400, "token_revoked", "token_revoked");
|
||||
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":{"code":"refresh_token_expired","message":"Refresh token expired."}}"#,
|
||||
)
|
||||
.await;
|
||||
assert_transport_failure(
|
||||
&failure,
|
||||
status,
|
||||
"Refresh token expired.",
|
||||
r#"{"error":{"code":"refresh_token_expired","message":"Refresh token expired."}}"#,
|
||||
assert!(
|
||||
matches!(failure, RefreshFailure::InvalidGrant { .. }),
|
||||
"official refresh code must take precedence for status {status}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_transport_failure(failure: &RefreshFailure, status: u16, message: &str, body: &str) {
|
||||
#[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,
|
||||
@@ -392,7 +441,7 @@ fn assert_transport_failure(failure: &RefreshFailure, status: u16, message: &str
|
||||
else {
|
||||
panic!("status {status} must classify as transient");
|
||||
};
|
||||
assert_eq!(actual_message.as_deref(), Some(message));
|
||||
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);
|
||||
|
||||
@@ -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,7 +19,7 @@ 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::{
|
||||
CapabilityRequirements, GenerateRequest, Operation, OperationKind, ProtocolPayload,
|
||||
@@ -270,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(),
|
||||
)
|
||||
@@ -291,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",
|
||||
@@ -322,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| {
|
||||
@@ -392,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,
|
||||
@@ -436,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
|
||||
@@ -792,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());
|
||||
@@ -974,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"))
|
||||
@@ -988,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"),
|
||||
@@ -1022,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]
|
||||
@@ -1262,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());
|
||||
@@ -1934,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());
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 和账号,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+33
-33
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,8 +18,8 @@ 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>
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
+10
-13
@@ -1,18 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronDown } from '@lucide/vue'
|
||||
import { clamp } from 'es-toolkit'
|
||||
import { computed, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import { normalizeRgbaHexColor } from '@/utils/color'
|
||||
import BaseButton from '../BaseButton.vue'
|
||||
import BaseInput from '../BaseInput.vue'
|
||||
import BasePopover from '../BasePopover.vue'
|
||||
import {
|
||||
formatRgbaColor,
|
||||
hexToHsv,
|
||||
hsvToHex,
|
||||
normalizeHexColor,
|
||||
parseRgbaColor,
|
||||
rgbaToHexColor,
|
||||
} from '@/utils/color'
|
||||
import BaseButton from './BaseButton.vue'
|
||||
import BaseInput from './BaseInput.vue'
|
||||
import BasePopover from './BasePopover.vue'
|
||||
} from './color'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -38,7 +39,7 @@ const svPanel = useTemplateRef<HTMLElement>('svPanel')
|
||||
const hueSlider = useTemplateRef<HTMLElement>('hueSlider')
|
||||
const alphaSlider = useTemplateRef<HTMLElement>('alphaSlider')
|
||||
|
||||
const normalizedModel = computed(() => normalizeHexColor(model.value) ?? '#60A5FA80')
|
||||
const normalizedModel = computed(() => normalizeRgbaHexColor(model.value) ?? '#60A5FA80')
|
||||
const draftColor = computed(() => hsvToHex({
|
||||
h: hue.value,
|
||||
s: saturation.value,
|
||||
@@ -201,18 +202,18 @@ function updateFromInput() {
|
||||
}
|
||||
|
||||
function choosePreset(value: string) {
|
||||
const normalized = normalizeHexColor(value)
|
||||
const normalized = normalizeRgbaHexColor(value)
|
||||
if (normalized)
|
||||
resetDraft(normalized)
|
||||
}
|
||||
|
||||
function isPresetSelected(value: string) {
|
||||
const normalized = normalizeHexColor(value)
|
||||
const normalized = normalizeRgbaHexColor(value)
|
||||
return normalized?.slice(0, 7) === draftColor.value.slice(0, 7)
|
||||
}
|
||||
|
||||
function presetCheckClass(value: string) {
|
||||
const normalized = normalizeHexColor(value)
|
||||
const normalized = normalizeRgbaHexColor(value)
|
||||
if (!normalized)
|
||||
return 'text-white'
|
||||
|
||||
@@ -233,10 +234,6 @@ function confirm() {
|
||||
model.value = rgbaToHexColor(rgba)
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number) {
|
||||
return Math.min(maximum, Math.max(minimum, value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import BaseButton from './BaseButton.vue'
|
||||
import BaseModal from './BaseModal.vue'
|
||||
import BaseModal from './BaseModal/index.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useScrollLock } from '@vueuse/core'
|
||||
|
||||
let bodyScrollLocked: ReturnType<typeof useScrollLock> | undefined
|
||||
let lockCount = 0
|
||||
|
||||
function getBodyScrollLock() {
|
||||
if (typeof document === 'undefined')
|
||||
return undefined
|
||||
|
||||
bodyScrollLocked ??= useScrollLock(document.body)
|
||||
return bodyScrollLocked
|
||||
}
|
||||
|
||||
export function lockBodyScroll() {
|
||||
const scrollLock = getBodyScrollLock()
|
||||
if (!scrollLock)
|
||||
return
|
||||
|
||||
lockCount += 1
|
||||
if (lockCount === 1)
|
||||
scrollLock.value = true
|
||||
}
|
||||
|
||||
export function unlockBodyScroll() {
|
||||
if (!bodyScrollLocked)
|
||||
return
|
||||
|
||||
lockCount = Math.max(0, lockCount - 1)
|
||||
if (lockCount === 0)
|
||||
bodyScrollLocked.value = false
|
||||
}
|
||||
+3
-3
@@ -2,9 +2,9 @@
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Info, X } from '@lucide/vue'
|
||||
import { nextTick, onBeforeUnmount, useId, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import { lockBodyScroll, unlockBodyScroll } from '@/utils/body-scroll-lock'
|
||||
import BaseIconButton from './BaseIconButton.vue'
|
||||
import BaseScrollbar from './BaseScrollbar.vue'
|
||||
import BaseIconButton from '../BaseIconButton.vue'
|
||||
import BaseScrollbar from '../BaseScrollbar.vue'
|
||||
import { lockBodyScroll, unlockBodyScroll } from './bodyScrollLock'
|
||||
|
||||
type ModalSize = 'sm' | 'md' | 'lg' | 'xl'
|
||||
type ModalTone = 'neutral' | 'info' | 'warning' | 'danger' | 'success'
|
||||
@@ -1,14 +1,57 @@
|
||||
// ECharts tooltip 回调参数的通用安全取值:参数形状不受控(数组/对象混合),
|
||||
// 统一在此收敛类型判断。
|
||||
import { isRecord } from '@/utils/object'
|
||||
|
||||
interface ChartTooltipTheme {
|
||||
surface: string
|
||||
textPrimary: string
|
||||
pointer: string
|
||||
}
|
||||
|
||||
interface ChartTooltipStyleOptions {
|
||||
axisPointer?: boolean
|
||||
confine?: boolean
|
||||
padding?: [number, number]
|
||||
fontFamily?: string
|
||||
fontWeight?: number
|
||||
}
|
||||
|
||||
export function chartTooltipStyle(
|
||||
theme: ChartTooltipTheme,
|
||||
options: ChartTooltipStyleOptions = {},
|
||||
) {
|
||||
const base = {
|
||||
confine: options.confine,
|
||||
backgroundColor: theme.surface,
|
||||
borderColor: 'transparent',
|
||||
borderWidth: 0,
|
||||
padding: options.padding ?? [10, 14],
|
||||
textStyle: {
|
||||
color: theme.textPrimary,
|
||||
fontSize: 12,
|
||||
fontFamily: options.fontFamily ?? 'Inter Variable, Inter, system-ui, sans-serif',
|
||||
fontWeight: options.fontWeight ?? 650,
|
||||
},
|
||||
extraCssText: 'border-radius: 12px; box-shadow: var(--cp-shadow-popover);',
|
||||
}
|
||||
|
||||
if (!options.axisPointer)
|
||||
return base
|
||||
|
||||
return {
|
||||
...base,
|
||||
axisPointer: {
|
||||
type: 'line' as const,
|
||||
lineStyle: { color: theme.pointer, type: 'dashed' as const, width: 1 },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function tooltipRows(params: unknown): Record<string, unknown>[] {
|
||||
const values = Array.isArray(params) ? params : [params]
|
||||
return values.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === 'object' && value !== null,
|
||||
)
|
||||
return values.filter(isRecord)
|
||||
}
|
||||
|
||||
export function tooltipIndex(source: unknown) {
|
||||
if (typeof source !== 'object' || source === null || !('dataIndex' in source))
|
||||
if (!isRecord(source))
|
||||
return -1
|
||||
return typeof source.dataIndex === 'number' ? source.dataIndex : -1
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
import { withMinimumDuration } from '@/utils/async'
|
||||
import { errorMessage, withMinimumDuration } from '@/utils/async'
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>
|
||||
type AsyncActionErrorText = string | false | ((error: unknown) => string | false)
|
||||
@@ -13,26 +13,13 @@ interface AsyncActionRunOptions {
|
||||
rethrow?: boolean
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
if (error && typeof error === 'object' && 'message' in error) {
|
||||
const message = (error as { message?: unknown }).message
|
||||
return typeof message === 'string' ? message : ''
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function resolveErrorText(error: unknown, errorText: AsyncActionErrorText | undefined) {
|
||||
const fallback = typeof errorText === 'function' ? errorText(error) : errorText
|
||||
if (fallback === false) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return errorMessage(error) || fallback || ''
|
||||
return errorMessage(error, '') || fallback || ''
|
||||
}
|
||||
|
||||
export function useAsyncAction() {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useThemeColor } from '@/composables/useThemeColor'
|
||||
|
||||
export function useChartPalette() {
|
||||
const color = useThemeColor()
|
||||
const palette = computed(() => ({
|
||||
textPrimary: color('--cp-text-primary', '#0E1726'),
|
||||
textSecondary: color('--cp-text-secondary', '#64748B'),
|
||||
textMuted: color('--cp-text-muted', '#94A3B8'),
|
||||
surface: color('--cp-bg-surface', '#FFFFFF'),
|
||||
surfaceMuted: color('--cp-bg-muted', '#F1F5F9'),
|
||||
grid: color('--cp-bg-muted', '#F1F5F9'),
|
||||
divider: color('--cp-divider-subtle', '#E2E8F0'),
|
||||
border: color('--cp-default-border', '#E2E8F0'),
|
||||
pointer: color('--cp-default-border-hover', '#CBD5E1'),
|
||||
info: color('--cp-info', '#2563EB'),
|
||||
success: color('--cp-success', '#10B981'),
|
||||
warning: color('--cp-warning', '#F59E0B'),
|
||||
danger: color('--cp-danger', '#EF4444'),
|
||||
normal: color('--cp-normal', '#0F9F9A'),
|
||||
reasoning: color('--cp-reasoning', '#8B5CF6'),
|
||||
}))
|
||||
|
||||
return { color, palette }
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import { useUiStore } from '@/stores/modules/ui'
|
||||
import { readCssVariable } from '@/utils/css'
|
||||
|
||||
// 主题感知的 CSS 变量读取:themeRevision 作为响应式依赖,主题切换后
|
||||
// 引用该函数的 computed 会重算并读到新变量值。
|
||||
@@ -12,3 +11,9 @@ export function useThemeColor() {
|
||||
return readCssVariable(name, fallback)
|
||||
}
|
||||
}
|
||||
|
||||
function readCssVariable(name: string, fallback: string) {
|
||||
if (typeof document === 'undefined')
|
||||
return fallback
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createSharedComposable, useNow } from '@vueuse/core'
|
||||
|
||||
export const useUiClock = createSharedComposable(() =>
|
||||
useNow({ interval: 30_000 }),
|
||||
)
|
||||
@@ -4,7 +4,7 @@ import { ExternalLink } from '@lucide/vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import BaseModal from '@/components/base/BaseModal.vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
import { useSystemUpdateStore } from '@/stores/modules/system-update'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useStorage,
|
||||
useWindowSize,
|
||||
} from '@vueuse/core'
|
||||
import { clamp } from 'es-toolkit'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
@@ -179,10 +180,6 @@ function applyPosition(position: StoredPosition) {
|
||||
storedPosition.value = next
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
function positiveNumber(value: number, fallback: number) {
|
||||
return Number.isFinite(value) && value > 0 ? value : fallback
|
||||
}
|
||||
|
||||
+45
-175
@@ -1,13 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { SystemUpdateDetail } from '@/api'
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
ExternalLink,
|
||||
Power,
|
||||
RefreshCw,
|
||||
Terminal,
|
||||
XCircle,
|
||||
} from '@lucide/vue'
|
||||
|
||||
import { storeToRefs } from 'pinia'
|
||||
@@ -15,12 +14,17 @@ import { computed, nextTick, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
|
||||
import BaseEmpty from '@/components/base/BaseEmpty.vue'
|
||||
import BaseModal from '@/components/base/BaseModal.vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
import { useSystemUpdateStore } from '@/stores/modules/system-update'
|
||||
import { normalizeSystemVersion, useSystemUpdateStore } from '@/stores/modules/system-update'
|
||||
import { errorMessage } from '@/utils/async'
|
||||
import { renderMarkdown } from '@/utils/markdown'
|
||||
import { formatTime } from '@/utils/date'
|
||||
import { renderReleaseNotes } from './markdown'
|
||||
import {
|
||||
resolveSystemUpdateLogClasses,
|
||||
resolveSystemUpdatePresentation,
|
||||
} from './presenter'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
|
||||
@@ -46,177 +50,39 @@ const { loadSystem, checkUpdates, updateNow, restartNow } = systemUpdateStore
|
||||
|
||||
const updateLogScrollbar = useTemplateRef<InstanceType<typeof BaseScrollbar>>('updateLogScrollbar')
|
||||
const updateConfirmOpen = shallowRef(false)
|
||||
const updateConfirmInfo = shallowRef<any>(null)
|
||||
const updateConfirmInfo = shallowRef<SystemUpdateDetail | null>(null)
|
||||
const updateConfirmPreviousTarget = shallowRef('')
|
||||
const preparingUpdate = shallowRef(false)
|
||||
|
||||
const statusView = computed(() => {
|
||||
if (restarting.value) {
|
||||
return {
|
||||
label: '重启中',
|
||||
icon: RefreshCw,
|
||||
badge: 'bg-cp-info-bg text-cp-info-text',
|
||||
iconClass: 'text-cp-info',
|
||||
}
|
||||
}
|
||||
if (updating.value) {
|
||||
return {
|
||||
label: '更新中',
|
||||
icon: RefreshCw,
|
||||
badge: 'bg-cp-info-bg text-cp-info-text',
|
||||
iconClass: 'text-cp-info',
|
||||
}
|
||||
}
|
||||
if (updateError.value || updateInfo.value?.warning) {
|
||||
return {
|
||||
label: '异常',
|
||||
icon: XCircle,
|
||||
badge: 'bg-cp-danger-bg text-cp-danger-text',
|
||||
iconClass: 'text-cp-danger',
|
||||
}
|
||||
}
|
||||
if (updateSuccess.value) {
|
||||
return {
|
||||
label: '已更新',
|
||||
icon: CheckCircle2,
|
||||
badge: 'bg-cp-success-bg text-cp-success-text',
|
||||
iconClass: 'text-cp-success',
|
||||
}
|
||||
}
|
||||
if (hasUpdate.value) {
|
||||
return {
|
||||
label: '有新版本',
|
||||
icon: ArrowUpCircle,
|
||||
badge: 'bg-cp-success-bg text-cp-success-text',
|
||||
iconClass: 'text-cp-success',
|
||||
}
|
||||
}
|
||||
if (updateInfo.value) {
|
||||
return {
|
||||
label: '已是最新',
|
||||
icon: CheckCircle2,
|
||||
badge: 'bg-cp-success-bg text-cp-success-text',
|
||||
iconClass: 'text-cp-success',
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: '未检查',
|
||||
icon: CheckCircle2,
|
||||
badge: 'bg-cp-muted text-cp-secondary',
|
||||
iconClass: 'text-cp-muted-text',
|
||||
}
|
||||
})
|
||||
|
||||
const summaryItems = computed(() => [
|
||||
{
|
||||
key: 'current',
|
||||
label: '当前版本',
|
||||
value: loading.value ? '...' : version.value?.version ? `v${version.value.version}` : '-',
|
||||
title: version.value?.version,
|
||||
},
|
||||
{
|
||||
key: 'latest',
|
||||
label: '最新版本',
|
||||
value: updateInfo.value?.latestVersion ? `v${updateInfo.value.latestVersion}` : '-',
|
||||
title: updateInfo.value?.latestVersion,
|
||||
releaseUrl: updateInfo.value?.releaseUrl,
|
||||
},
|
||||
{
|
||||
key: 'build',
|
||||
label: '构建',
|
||||
value: displayValue(updateInfo.value?.buildTypeLabel),
|
||||
title: updateInfo.value?.buildType,
|
||||
},
|
||||
{
|
||||
key: 'deployment',
|
||||
label: '部署',
|
||||
value: displayValue(version.value?.deploymentModeLabel),
|
||||
title: version.value?.deploymentMode,
|
||||
},
|
||||
])
|
||||
const presentation = computed(() => resolveSystemUpdatePresentation({
|
||||
version: version.value,
|
||||
updateInfo: updateInfo.value,
|
||||
loading: loading.value,
|
||||
restarting: restarting.value,
|
||||
updating: updating.value,
|
||||
updateError: updateError.value,
|
||||
updateSuccess: updateSuccess.value,
|
||||
hasUpdate: hasUpdate.value,
|
||||
updateStreaming: updateStreaming.value,
|
||||
updateStreamError: updateStreamError.value,
|
||||
previousTargetVersion: updateConfirmPreviousTarget.value,
|
||||
confirmedTargetVersion: updateConfirmInfo.value?.latestVersion ?? null,
|
||||
}))
|
||||
|
||||
const updateLogRows = computed(() =>
|
||||
updateLogs.value.map(item => ({
|
||||
...item,
|
||||
time: formatLogTime(item.at),
|
||||
time: formatTime(item.at, '--:--:--'),
|
||||
classes: resolveSystemUpdateLogClasses(item.level),
|
||||
})),
|
||||
)
|
||||
|
||||
const renderedReleaseNotes = computed(() => renderMarkdown(updateInfo.value?.notes))
|
||||
const renderedReleaseNotes = computed(() => renderReleaseNotes(updateInfo.value?.notes))
|
||||
|
||||
const showUpdateProgress = computed(
|
||||
() => hasUpdate.value || updating.value || restarting.value || updateLogRows.value.length > 0,
|
||||
)
|
||||
|
||||
const updateConfirmRows = computed(() => [
|
||||
{
|
||||
key: 'current',
|
||||
label: '当前版本',
|
||||
value: version.value?.version ? `v${version.value.version}` : '-',
|
||||
},
|
||||
{
|
||||
key: 'previous',
|
||||
label: '已显示目标',
|
||||
value: updateConfirmPreviousTarget.value ? `v${updateConfirmPreviousTarget.value}` : '-',
|
||||
},
|
||||
{
|
||||
key: 'target',
|
||||
label: '远端最新目标',
|
||||
value: updateConfirmInfo.value?.latestVersion
|
||||
? `v${updateConfirmInfo.value.latestVersion}`
|
||||
: '-',
|
||||
},
|
||||
])
|
||||
|
||||
const streamStatusLabel = computed(() => {
|
||||
if (updateStreaming.value)
|
||||
return '实时'
|
||||
if (updateStreamError.value)
|
||||
return '断开'
|
||||
return '待连接'
|
||||
})
|
||||
|
||||
const restartButtonLabel = computed(() => {
|
||||
return restarting.value ? '重启中' : '立即重启'
|
||||
})
|
||||
|
||||
function displayValue(value: unknown) {
|
||||
return value ? String(value) : '-'
|
||||
}
|
||||
|
||||
function formatLogTime(value: string) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime()))
|
||||
return '--:--:--'
|
||||
return date.toLocaleTimeString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function logMarkerClass(level: string) {
|
||||
if (level === 'success')
|
||||
return 'text-cp-success'
|
||||
if (level === 'warning')
|
||||
return 'text-cp-warning'
|
||||
if (level === 'error')
|
||||
return 'text-cp-danger'
|
||||
return 'text-cp-info'
|
||||
}
|
||||
|
||||
function logTextClass(level: string) {
|
||||
if (level === 'success')
|
||||
return 'text-cp-success'
|
||||
if (level === 'warning')
|
||||
return 'text-cp-warning'
|
||||
if (level === 'error')
|
||||
return 'text-cp-danger'
|
||||
return 'text-cp-primary'
|
||||
}
|
||||
|
||||
function normalizeUpdateVersion(value: unknown) {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.replace(/^v/i, '')
|
||||
}
|
||||
|
||||
async function scrollUpdateLogsToBottom() {
|
||||
await nextTick()
|
||||
await updateLogScrollbar.value?.scrollToBottom()
|
||||
@@ -242,7 +108,7 @@ async function handleUpdateRequest() {
|
||||
if (preparingUpdate.value || updating.value)
|
||||
return
|
||||
|
||||
const previousTargetVersion = normalizeUpdateVersion(updateInfo.value?.latestVersion)
|
||||
const previousTargetVersion = normalizeSystemVersion(updateInfo.value?.latestVersion)
|
||||
preparingUpdate.value = true
|
||||
try {
|
||||
const data = await checkUpdates(true)
|
||||
@@ -250,7 +116,7 @@ async function handleUpdateRequest() {
|
||||
toast.success('当前已是最新版本')
|
||||
return
|
||||
}
|
||||
const remoteTargetVersion = normalizeUpdateVersion(data.latestVersion)
|
||||
const remoteTargetVersion = normalizeSystemVersion(data.latestVersion)
|
||||
if (!remoteTargetVersion) {
|
||||
toast.error('远端目标版本为空')
|
||||
return
|
||||
@@ -284,7 +150,7 @@ async function runConfirmedUpdate(targetVersion: string) {
|
||||
}
|
||||
|
||||
async function handleConfirmUpdate() {
|
||||
const targetVersion = normalizeUpdateVersion(updateConfirmInfo.value?.latestVersion)
|
||||
const targetVersion = normalizeSystemVersion(updateConfirmInfo.value?.latestVersion)
|
||||
if (!targetVersion)
|
||||
return
|
||||
|
||||
@@ -344,16 +210,20 @@ watch(
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-[12px] font-heavy"
|
||||
:class="statusView.badge"
|
||||
:class="presentation.status.badge"
|
||||
>
|
||||
<component :is="statusView.icon" class="size-3.5" :class="statusView.iconClass" />
|
||||
{{ statusView.label }}
|
||||
<component
|
||||
:is="presentation.status.icon"
|
||||
class="size-3.5"
|
||||
:class="presentation.status.iconClass"
|
||||
/>
|
||||
{{ presentation.status.label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2.5 sm:grid-cols-4">
|
||||
<div
|
||||
v-for="item in summaryItems"
|
||||
v-for="item in presentation.summaryItems"
|
||||
:key="item.key"
|
||||
class="min-w-0 rounded-cp-control bg-cp-surface px-3 py-2.5"
|
||||
>
|
||||
@@ -398,7 +268,7 @@ watch(
|
||||
发布说明
|
||||
</p>
|
||||
<span class="font-mono text-[11px] font-emphasis text-cp-muted-text">
|
||||
{{ displayValue(updateInfo?.latestVersion) }}
|
||||
{{ presentation.releaseVersion }}
|
||||
</span>
|
||||
</div>
|
||||
<BaseScrollbar class="-mx-4" max-height="160px">
|
||||
@@ -421,13 +291,13 @@ watch(
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-cp-subtle px-2 text-[11px] leading-none font-bold text-cp-secondary"
|
||||
:title="updateStreamError || streamStatusLabel"
|
||||
:title="updateStreamError || presentation.streamStatusLabel"
|
||||
>
|
||||
<i
|
||||
class="size-1.5 rounded-full"
|
||||
:class="updateStreaming ? 'bg-cp-success' : 'bg-cp-muted-text'"
|
||||
/>
|
||||
{{ streamStatusLabel }}
|
||||
{{ presentation.streamStatusLabel }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
@@ -445,10 +315,10 @@ watch(
|
||||
<span class="tabular-nums text-cp-muted-text">{{ log.time }}</span>
|
||||
<Circle
|
||||
class="mt-1 size-2.5"
|
||||
:class="logMarkerClass(log.level)"
|
||||
:class="log.classes.marker"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<p class="m-0 min-w-0 wrap-break-word" :class="logTextClass(log.level)">
|
||||
<p class="m-0 min-w-0 wrap-break-word" :class="log.classes.text">
|
||||
<span v-if="log.step" class="mr-1 text-cp-muted-text">[{{ log.step }}]</span>
|
||||
{{ log.message }}
|
||||
</p>
|
||||
@@ -486,7 +356,7 @@ watch(
|
||||
<template #icon>
|
||||
<Power class="size-4" />
|
||||
</template>
|
||||
{{ restartButtonLabel }}
|
||||
{{ presentation.restartButtonLabel }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-else
|
||||
@@ -515,7 +385,7 @@ watch(
|
||||
<div class="grid gap-3">
|
||||
<div class="grid gap-2 rounded-cp-control bg-cp-subtle p-3">
|
||||
<div
|
||||
v-for="item in updateConfirmRows"
|
||||
v-for="item in presentation.confirmRows"
|
||||
:key="item.key"
|
||||
class="flex min-w-0 items-center justify-between gap-3"
|
||||
>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user