Compare commits

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

Co-authored-by: Codex <noreply@openai.com>
2026-08-19 11:34:07 +08:00
ZyyandCodex 573b6b26db fix(openai): isolate quota buckets by limit id
Co-authored-by: Codex <noreply@openai.com>
2026-08-18 16:48:37 +08:00
74 changed files with 2652 additions and 530 deletions
+6 -6
View File
@@ -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]]
@@ -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,7 +3628,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
+3 -2
View File
@@ -27,8 +27,9 @@ impl LoadableConfig for GatewayConfig {
return Err(ConfigError::InvalidField("schema_version"));
}
self.host.resolve_and_validate(source_dir)?;
let runtime_data_dir = self.host.runtime_data_dir().to_path_buf();
self.store
.resolve_and_validate(source_dir)
.resolve_and_validate(&runtime_data_dir)
.map_err(|_| ConfigError::InvalidField("store"))?;
self.admin
.resolve_and_validate(source_dir)
@@ -37,7 +38,7 @@ impl LoadableConfig for GatewayConfig {
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("api"))?;
self.openai
.resolve_and_validate(source_dir)
.resolve_and_validate(&runtime_data_dir)
.map_err(|_| ConfigError::InvalidField("openai"))?;
self.xai
.resolve_and_validate(source_dir)
+8
View File
@@ -18,10 +18,18 @@ fn config_loader_should_load_complete_terminal_example() {
fn config_loader_should_resolve_paths_relative_to_config_file() {
let (config, _directory) = parse_config(&valid_config()).expect("resolved config");
let debug = format!("{config:?}");
assert!(debug.contains(".runtime/data"));
assert!(debug.contains(".runtime/logs"));
assert!(debug.contains("frontend/dist"));
}
#[test]
fn config_loader_should_reject_missing_runtime_data_dir() {
let config = valid_config().replace(" runtime_data_dir: '../.runtime/data'\n", "");
assert!(parse_config(&config).is_err());
}
#[test]
fn config_loader_should_inject_connection_passwords_into_urls() {
parse_config(&valid_config()).expect("Store validates password injection into both URLs");
@@ -570,13 +570,34 @@ pub enum QuotaLocalUsageAttribution {
Unavailable,
}
/// Provider quota bucket 中窗口的官方位置语义。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderQuotaWindowRole {
Primary,
Secondary,
Monthly,
}
impl ProviderQuotaWindowRole {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Primary => "primary",
Self::Secondary => "secondary",
Self::Monthly => "monthly",
}
}
}
/// 一个 Provider quota 窗口的公共投影。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderQuotaWindow {
pub key: String,
pub group: String,
pub label: String,
pub source: Option<String>,
pub limit_id: Option<String>,
pub limit_name: Option<String>,
pub role: Option<ProviderQuotaWindowRole>,
pub local_usage_attribution: QuotaLocalUsageAttribution,
pub window_seconds: Option<u64>,
pub used_percent: Option<f64>,
+6 -2
View File
@@ -165,7 +165,9 @@ fn representative_window_usage_should_accept_account_wide_rolling_window_without
key: "free-rolling-24h".to_owned(),
group: "shortTerm".to_owned(),
label: "日限额".to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(86_400),
used_percent: None,
@@ -234,7 +236,9 @@ fn quota_window(
key: group.to_owned(),
group: group.to_owned(),
label: group.to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds,
used_percent,
@@ -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
+21 -3
View File
@@ -1,4 +1,4 @@
//! `/healthz` 对 Core、Store 与 Host worker 健康事实的聚合。
//! `/healthz` 对 Core、Store 与 Host 关键 worker 健康事实的聚合。
use std::sync::Arc;
use std::time::Duration;
@@ -6,8 +6,10 @@ use std::time::Duration;
use axum::{extract::State, http::StatusCode};
use futures::future::join_all;
use gateway_core::health::{
HealthProbe, HealthState, WorkerHealthSnapshot, WorkerHealthSource, WorkerRuntimeState,
HealthProbe, HealthState, WorkerHealthKey, WorkerHealthSnapshot, WorkerHealthSource,
WorkerRuntimeState,
};
use gateway_core::task::WorkerKind;
use crate::ApiState;
@@ -32,7 +34,13 @@ impl HealthStatus {
}
pub(crate) async fn healthy(&self) -> bool {
if !self.workers.snapshot().iter().all(worker_is_healthy) {
if !self
.workers
.snapshot()
.iter()
.filter(|worker| worker_affects_healthz(worker))
.all(worker_is_healthy)
{
return false;
}
let checks = self.probes.iter().map(|probe| probe.check());
@@ -54,6 +62,16 @@ pub(crate) async fn healthz(State(state): State<ApiState>) -> StatusCode {
}
}
fn worker_affects_healthz(worker: &WorkerHealthSnapshot) -> bool {
let WorkerHealthKey::Task(id) = &worker.key else {
return false;
};
!matches!(
id.kind(),
WorkerKind::OAuthRefresh | WorkerKind::QuotaCatalogHealth
)
}
fn worker_is_healthy(worker: &WorkerHealthSnapshot) -> bool {
match worker.state {
WorkerRuntimeState::Disabled | WorkerRuntimeState::Standby => true,
@@ -73,7 +73,6 @@ pub(super) fn response_header_is_forwardable(name: &str, connection_options: &[S
| "x-openai-organization"
| "x-openai-project"
| "x-codex-installation-id"
| "x-codex-turn-state"
| "x-codex-turn-metadata"
)
}
@@ -11,7 +11,9 @@ use gateway_core::{
StartedExecution,
},
error::GatewayError,
health::{WorkerHealthKey, WorkerHealthSnapshot, WorkerHealthSource, WorkerRuntimeState},
routing::PublicModelId,
task::{WorkerId, WorkerKind},
};
use tower::ServiceExt;
@@ -31,6 +33,75 @@ async fn healthz_should_return_no_content_when_all_inputs_are_healthy() {
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn healthz_should_ignore_provider_maintenance_worker_failure() {
let worker_health = StaticWorkerHealth(vec![worker_snapshot(
WorkerKind::QuotaCatalogHealth,
WorkerRuntimeState::BackingOff,
)]);
let response = crate::openai::api_router_with_worker_health(
Arc::new(UnusedExecution),
Arc::new(worker_health),
)
.await
.oneshot(
Request::builder()
.uri("/healthz")
.body(Body::empty())
.expect("health request"),
)
.await
.expect("health response");
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn healthz_should_reject_critical_worker_failure() {
let worker_health = StaticWorkerHealth(vec![worker_snapshot(
WorkerKind::RuntimeSnapshotReconciliation,
WorkerRuntimeState::BackingOff,
)]);
let response = crate::openai::api_router_with_worker_health(
Arc::new(UnusedExecution),
Arc::new(worker_health),
)
.await
.oneshot(
Request::builder()
.uri("/healthz")
.body(Body::empty())
.expect("health request"),
)
.await
.expect("health response");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[derive(Clone)]
struct StaticWorkerHealth(Vec<WorkerHealthSnapshot>);
impl WorkerHealthSource for StaticWorkerHealth {
fn snapshot(&self) -> Vec<WorkerHealthSnapshot> {
self.0.clone()
}
}
fn worker_snapshot(kind: WorkerKind, state: WorkerRuntimeState) -> WorkerHealthSnapshot {
let id = WorkerId::try_new(kind, "health-test").expect("worker ID");
WorkerHealthSnapshot {
key: WorkerHealthKey::Task(id),
state,
consecutive_failures: 1,
completed_cycles: 0,
last_fencing_token: None,
last_success_at: None,
last_failure_at: None,
last_error: Some("test worker failure".to_owned()),
}
}
struct UnusedExecution;
impl ExecutionService for UnusedExecution {
+21 -1
View File
@@ -44,9 +44,29 @@ pub(super) async fn api_router(execution: Arc<dyn ExecutionService>) -> axum::Ro
api_router_with_origins(execution, Vec::new()).await
}
pub(super) async fn api_router_with_worker_health(
execution: Arc<dyn ExecutionService>,
worker_health: Arc<dyn WorkerHealthSource>,
) -> axum::Router {
api_router_with_origins_and_worker_health(execution, Vec::new(), worker_health).await
}
pub(super) async fn api_router_with_origins(
execution: Arc<dyn ExecutionService>,
cors_allowed_origins: Vec<String>,
) -> axum::Router {
api_router_with_origins_and_worker_health(
execution,
cors_allowed_origins,
Arc::new(EmptyWorkerHealth),
)
.await
}
async fn api_router_with_origins_and_worker_health(
execution: Arc<dyn ExecutionService>,
cors_allowed_origins: Vec<String>,
worker_health: Arc<dyn WorkerHealthSource>,
) -> axum::Router {
let admin = crate::admin::AdminTestFixture::new().await;
gateway_api::initialize(
@@ -59,7 +79,7 @@ pub(super) async fn api_router_with_origins(
execution,
admin.services,
Vec::new(),
Arc::new(EmptyWorkerHealth),
worker_health,
Arc::new(TestLifecycle::default()),
)
.expect("API bundle")
@@ -788,6 +788,10 @@ async fn streaming_response_should_append_ordinary_headers_and_skip_unrepresenta
let trace = Arc::new(Trace::default());
let headers = vec![
ProviderResponseHeader::new("x-models-etag", Bytes::from_static(b"models-v2")),
ProviderResponseHeader::new(
"x-codex-turn-state",
Bytes::from_static(b"turn-state-from-upstream"),
),
ProviderResponseHeader::new("x-future-multi", Bytes::from_static(b"first")),
ProviderResponseHeader::new("x-future-multi", Bytes::from_static(b"second")),
ProviderResponseHeader::new("x-future-bytes", Bytes::from_static(b"\xffopaque")),
@@ -834,6 +838,13 @@ async fn streaming_response_should_append_ordinary_headers_and_skip_unrepresenta
.map(HeaderValue::as_bytes),
Some(b"\xffopaque".as_slice())
);
assert_eq!(
response
.headers()
.get("x-codex-turn-state")
.and_then(|value| value.to_str().ok()),
Some("turn-state-from-upstream")
);
assert!(response.headers().get("authorization").is_none());
assert!(response.headers().get("connection").is_none());
assert!(response.headers().get("x-hop-secret").is_none());
@@ -411,6 +411,10 @@ async fn websocket_response_metadata_should_preserve_ordinary_headers_without_bl
ProviderResponseHeader::new("x-future-header", Bytes::from_static(b"future-value")),
ProviderResponseHeader::new("x-future-multi", Bytes::from_static(b"first")),
ProviderResponseHeader::new("x-future-multi", Bytes::from_static(b"second")),
ProviderResponseHeader::new(
"x-codex-turn-state",
Bytes::from_static(b"turn-state-from-upstream"),
),
ProviderResponseHeader::new("x-future-bytes", Bytes::from_static(b"\xffopaque")),
ProviderResponseHeader::new("bad\0name", Bytes::from_static(b"unrepresentable")),
ProviderResponseHeader::new(
@@ -483,6 +487,10 @@ async fn websocket_response_metadata_should_preserve_ordinary_headers_without_bl
// 官方 metadata 是 string map,同名多值只能保持既有的后值覆盖语义。
assert_eq!(headers.get("x-future-multi"), Some(&json!("second")));
assert_eq!(headers.get("x-request-id"), Some(&json!("req_upstream")));
assert_eq!(
headers.get("x-codex-turn-state"),
Some(&json!("turn-state-from-upstream"))
);
for omitted in [
"x-future-bytes",
"bad\0name",
@@ -1162,10 +1162,11 @@ where
true
}
/// 原生续写的原账号在本地调度阶段已不可用时,改用完整输入重放。
/// 原生续写的原账号在本地调度阶段已不可用时,交给 Provider 执行跨账号恢复。
///
/// 这不是强制 Smart:下一次 attempt 仍使用请求配置的调度策略。只有网关持有
/// 对应 Provider 的可携带会话状态时,才可安全移除 native handle 并换号。
/// 对应 Provider 的会话状态时才进入恢复;是否保留 native handle、执行 probe
/// 或使用完整 transcript,由 Provider 自己的协议边界决定。
fn prepare_unavailable_native_continuation_replay(&mut self, error: &ProviderError) -> bool {
if self.account_selection.required_account().is_some()
|| self.continuation_attempt != ContinuationAttempt::Native
@@ -362,9 +362,14 @@ pub enum ContinuationAttempt {
None,
/// 使用 Store 解析出的原生 handle 与账号绑定。
Native,
/// 在原账号上用完整连接内 transcript 重放。
/// 在原账号上执行 Provider 定义的恢复。
///
/// 只有持有可携带 transcript 的 Provider 才能清除 native handle 后重放;
/// 只持有 opaque state 的 Provider 必须保留 continuation 依赖或返回客户端错误。
ReplayOwner,
/// transcript 已可携带,允许选择其他账号。
/// 允许选择其他账号并执行 Provider 定义的恢复。
///
/// 该枚举本身不证明客户端 input 完整,也不授权 Provider 清除 previous-response。
ReplayAny,
}
@@ -493,6 +493,31 @@ fn bare_atomic_response_failed(response_id: &str) -> ProviderError {
.with_atomic_client_events(vec![failed])
}
fn atomic_history_unavailable(response_id: &str) -> ProviderError {
let failed = ProviderEvent::wire(
ProtocolWireEvent::json(
"openai",
Some("response.failed".to_owned()),
json!({
"type": "response.failed",
"response": {
"id": response_id,
"status": "failed",
"error": {
"code": "previous_response_not_found",
"message": "Previous response was not found. Retrying the full request."
}
}
}),
)
.expect("history unavailable wire"),
);
ProviderError::new(ProviderErrorKind::InvalidRequest, UpstreamSendState::Sent)
.with_status(400)
.with_continuation_failure(ContinuationFailure::HistoryUnavailable)
.with_atomic_client_events(vec![failed])
}
fn image_stream(image_output_tokens: Option<u64>) -> Vec<Result<GatewayEvent, ProviderError>> {
let mut usage = Usage::new();
usage.input_tokens = Some(12);
@@ -1481,7 +1506,7 @@ fn streaming_native_continuation_delivers_started_event_before_later_output() {
}
#[test]
fn native_continuation_replays_owner_before_safely_switching_account() {
fn native_continuation_uses_provider_defined_owner_then_cross_account_recovery() {
let Operation::Generate(generate) = generate_operation() else {
panic!("generate operation");
};
@@ -1536,7 +1561,7 @@ fn native_continuation_replays_owner_before_safely_switching_account() {
CancellationToken::new(),
))
.expect("start execution");
block_on(session.collect_uncommitted()).expect("portable replay succeeds");
block_on(session.collect_uncommitted()).expect("provider-defined recovery succeeds");
block_on(session.commit_downstream(Some(200))).expect("commit response");
let contexts = provider.contexts.lock().expect("contexts lock");
@@ -1561,7 +1586,7 @@ fn native_continuation_replays_owner_before_safely_switching_account() {
}
#[test]
fn unavailable_native_continuation_replays_with_the_configured_account_policy() {
fn unavailable_native_continuation_uses_provider_recovery_with_the_configured_account_policy() {
let Operation::Generate(generate) = generate_operation() else {
panic!("generate operation");
};
@@ -1602,7 +1627,7 @@ fn unavailable_native_continuation_replays_with_the_configured_account_policy()
CancellationToken::new(),
))
.expect("start execution");
block_on(session.collect_uncommitted()).expect("portable replay succeeds");
block_on(session.collect_uncommitted()).expect("provider-defined recovery succeeds");
block_on(session.commit_downstream(Some(200))).expect("commit response");
let contexts = provider.contexts.lock().expect("contexts lock");
@@ -1625,6 +1650,76 @@ fn unavailable_native_continuation_replays_with_the_configured_account_policy()
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
}
#[test]
fn continuation_history_failure_without_replay_proof_is_delivered_without_hidden_retry() {
let Operation::Generate(generate) = generate_operation() else {
panic!("generate operation");
};
let operation = Operation::Generate(generate.with_provider_session_state(
ProviderSessionState::new("openai", Map::new()).expect("provider session state"),
));
let route_plan = plan(&operation);
let (coordinator, _, provider) = coordinator(vec![
Script::ObservedStream {
account_id: "acct_one",
items: vec![Err(atomic_history_unavailable("response-history-missing"))],
},
Script::Stream {
account_id: "acct_two",
items: complete_stream(None),
},
]);
let continuation = NativeContinuationPin::new(
PreviousResponseId::new("previous-response"),
PreviousResponseId::new("upstream-response"),
ClientApiKeyId::new("key_client_1").expect("client key"),
ProviderKind::new("openai").expect("provider"),
ProviderAccountId::new("acct_one").expect("account"),
);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
Some(ContinuationBinding::Pinned(continuation)),
CancellationToken::new(),
))
.expect("start execution");
let terminal = block_on(session.next_event())
.expect("history failure remains deliverable")
.expect("terminal history failure batch");
assert_eq!(
terminal.commit_requirement(),
CommitRequirement::CommitBeforeDelivery
);
let events = terminal.into_provider_events();
let failure = events
.iter()
.filter_map(ProviderEvent::wire_event)
.find(|wire| wire.event_type() == Some("response.failed"))
.expect("raw response.failed event");
assert_eq!(
failure
.data()
.pointer("/response/error/code")
.and_then(Value::as_str),
Some("previous_response_not_found")
);
block_on(session.commit_downstream(Some(200))).expect("commit terminal history failure");
assert!(matches!(
block_on(session.next_event()),
Err(EngineError::Provider(_))
));
let contexts = provider.contexts.lock().expect("contexts lock");
assert_eq!(contexts.len(), 1);
assert_eq!(
contexts[0].continuation_attempt(),
ContinuationAttempt::Native
);
}
#[test]
fn diagnostic_account_reaches_provider_and_matching_metadata_succeeds() {
let operation = operation(RetrySafety::Idempotent);
+12 -1
View File
@@ -41,6 +41,7 @@ pub fn load_config<T: LoadableConfig>() -> Result<T, ConfigError> {
#[serde(deny_unknown_fields)]
pub struct HostConfig {
pub listen: ListenConfig,
pub runtime_data_dir: PathBuf,
pub logging: LoggingConfig,
#[serde(default)]
pub system_update: SystemUpdateConfig,
@@ -76,11 +77,21 @@ impl HostConfig {
"host.worker_shutdown_timeout_seconds",
));
}
if self.runtime_data_dir.as_os_str().is_empty() {
return Err(ConfigError::InvalidField("host.runtime_data_dir"));
}
resolve_relative_path(source_dir, &mut self.runtime_data_dir);
self.logging.resolve_and_validate(source_dir)?;
self.system_update.resolve_and_validate(source_dir)?;
self.system_update
.resolve_and_validate(source_dir, &self.runtime_data_dir)?;
Ok(())
}
#[must_use]
pub fn runtime_data_dir(&self) -> &Path {
&self.runtime_data_dir
}
#[must_use]
pub const fn drain_timeout(&self) -> Duration {
Duration::from_secs(self.drain_timeout_seconds)
@@ -81,10 +81,10 @@ impl Default for SystemUpdateConfig {
});
let update_state_file = environment_value("CPR_UPDATE_STATE_FILE")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/app/.runtime/data/update-state.json"));
.unwrap_or_else(|| PathBuf::from("update-state.json"));
let update_lock_file = environment_value("CPR_UPDATE_LOCK_FILE")
.map(PathBuf::from)
.unwrap_or_else(|| update_state_file.with_extension("lock"));
.unwrap_or_else(|| update_state_file.with_file_name("update.lock"));
let update_temp_dir = environment_value("CPR_UPDATE_TEMP_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| state::default_temp_dir(&update_state_file));
@@ -120,21 +120,28 @@ impl Default for SystemUpdateConfig {
}
impl SystemUpdateConfig {
pub(crate) fn resolve_and_validate(&mut self, source_dir: &Path) -> Result<(), ConfigError> {
for path in [
self.executable_path.as_mut(),
Some(&mut self.web_dist_dir),
Some(&mut self.update_state_file),
Some(&mut self.update_lock_file),
Some(&mut self.update_temp_dir),
]
.into_iter()
.flatten()
pub(crate) fn resolve_and_validate(
&mut self,
source_dir: &Path,
runtime_data_dir: &Path,
) -> Result<(), ConfigError> {
for path in [self.executable_path.as_mut(), Some(&mut self.web_dist_dir)]
.into_iter()
.flatten()
{
if path.is_relative() {
*path = source_dir.join(&*path);
}
}
for path in [
&mut self.update_state_file,
&mut self.update_lock_file,
&mut self.update_temp_dir,
] {
if path.is_relative() {
*path = runtime_data_dir.join(&*path);
}
}
if self.version.trim().is_empty() {
return Err(ConfigError::InvalidField("host.system_update.version"));
}
+38 -3
View File
@@ -34,6 +34,40 @@ fn host_config_resolves_only_host_owned_relative_paths() {
);
}
#[test]
fn host_config_resolves_runtime_data_dir_relative_to_configuration() {
let mut config = valid_config();
config
.resolve_and_validate(std::path::Path::new("/srv/gateway"))
.expect("valid host config");
assert_eq!(
config.runtime_data_dir(),
PathBuf::from("/srv/gateway/runtime-data")
);
}
#[test]
fn host_config_derives_update_paths_from_runtime_data_dir() {
let mut config = valid_config();
config
.resolve_and_validate(std::path::Path::new("/srv/gateway"))
.expect("valid host config");
assert_eq!(
(
config.system_update.update_state_file,
config.system_update.update_lock_file,
config.system_update.update_temp_dir,
),
(
PathBuf::from("/srv/gateway/runtime-data/update-state.json"),
PathBuf::from("/srv/gateway/runtime-data/update.lock"),
PathBuf::from("/srv/gateway/runtime-data/update-tmp"),
)
);
}
#[test]
fn host_config_rejects_zero_drain_window() {
let mut config = valid_config();
@@ -48,9 +82,9 @@ fn host_config_rejects_zero_drain_window() {
fn valid_config() -> HostConfig {
let system_update = SystemUpdateConfig {
update_state_file: PathBuf::from(".runtime/update-state.json"),
update_lock_file: PathBuf::from(".runtime/update-state.lock"),
update_temp_dir: PathBuf::from(".runtime/update-tmp"),
update_state_file: PathBuf::from("update-state.json"),
update_lock_file: PathBuf::from("update.lock"),
update_temp_dir: PathBuf::from("update-tmp"),
..SystemUpdateConfig::default()
};
HostConfig {
@@ -58,6 +92,7 @@ fn valid_config() -> HostConfig {
host: "127.0.0.1".to_owned(),
port: 8080,
},
runtime_data_dir: PathBuf::from("runtime-data"),
logging: LoggingConfig {
level: "info".to_owned(),
stdout: true,
@@ -23,6 +23,7 @@ fn logging_requires_at_least_one_sink() {
host: "127.0.0.1".to_owned(),
port: 8080,
},
runtime_data_dir: PathBuf::from("/tmp/runtime-data"),
logging: LoggingConfig {
level: "info".to_owned(),
stdout: false,
@@ -98,6 +99,7 @@ fn write_oauth_recovery_log(directory: PathBuf) {
host: "127.0.0.1".to_owned(),
port: 8080,
},
runtime_data_dir: PathBuf::from("/tmp/runtime-data"),
logging: LoggingConfig {
level: "off".to_owned(),
stdout: false,
@@ -208,11 +208,24 @@ pub struct RateLimitWindow {
pub reset_at: Option<i64>,
}
/// 限流项键的来源。
///
/// HTTP headers 和事件里的 `metered_limit_name` 提供稳定 `limit_id`;
/// WebSocket `additional_rate_limits` 在部分协议版本里只以可读名称为 map key,
/// 此时必须由持久化层用既有名称元数据解析,不能把名称误当成稳定 ID。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RateLimitKeySource {
LimitId,
LimitName,
}
/// 单个计量项的限流信息。
#[derive(Debug, Clone, PartialEq)]
pub struct RateLimitDetails {
/// 稳定的计量项 ID。
pub limit_id: String,
/// [`limit_id`](Self::limit_id) 是稳定 ID,还是名称生成的临时解析键。
pub key_source: RateLimitKeySource,
/// 上游提供的可读名称。
pub limit_name: Option<String>,
/// 当前请求是否被允许。
@@ -341,20 +354,40 @@ pub fn parse_rate_limits_event(value: &Value) -> Option<ParsedRateLimits> {
return None;
}
let limit_id = rate_limit_name(value)
.map(normalize_limit_id)
let explicit_limit_id = top_level_rate_limit_id(value).map(normalize_limit_id);
let explicit_limit_name = non_empty_string(value, "limit_name");
let default_limit_id = explicit_limit_id
.clone()
.unwrap_or_else(|| "codex".to_string());
let limit_name = value
.get("metered_limit_name")
.and_then(Value::as_str)
.and_then(|_| value.get("limit_name"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|name| !name.is_empty())
.map(ToString::to_string);
let details = value
.get("rate_limits")
.and_then(|details| parse_details_from_object(details, &limit_id, limit_name));
let default_details = value.get("rate_limits").and_then(|details| {
parse_details_from_object(
details,
&default_limit_id,
explicit_limit_name,
RateLimitKeySource::LimitId,
)
});
let mut limits = parse_additional_rate_limits(value);
let matching_additional_ids = default_details
.as_ref()
.map(|default| {
limits
.iter()
.filter(|(_, additional)| same_rate_limit_facts(default, additional))
.map(|(limit_id, _)| limit_id.clone())
.collect::<Vec<_>>()
})
.unwrap_or_default();
// 当前 WebSocket 协议会把活动具名桶同时镜像到顶层 `rate_limits`,但不附
// `metered_limit_name`。只要 additional 中存在相同事实,顶层就不是 core 桶。
let default_is_additional_alias =
explicit_limit_id.is_none() && !matching_additional_ids.is_empty();
if let Some(details) = default_details
&& !default_is_additional_alias
{
limits.insert(default_limit_id.clone(), details);
}
let credits = value.get("credits").and_then(parse_credits_from_object);
let plan_type = value
.get("plan_type")
@@ -363,17 +396,18 @@ pub fn parse_rate_limits_event(value: &Value) -> Option<ParsedRateLimits> {
.filter(|plan| !plan.is_empty())
.map(ToString::to_string);
if details.is_none() && credits.is_none() && plan_type.is_none() {
if limits.is_empty() && credits.is_none() && plan_type.is_none() {
return None;
}
let mut limits = BTreeMap::new();
if let Some(details) = details {
limits.insert(limit_id.clone(), details);
}
let active_limit = explicit_limit_id.or_else(|| match matching_additional_ids.as_slice() {
[limit_id] => Some(limit_id.clone()),
[] if !limits.is_empty() => Some(default_limit_id),
_ => None,
});
Some(ParsedRateLimits {
limits,
active_limit: Some(limit_id),
active_limit,
credits,
plan_type,
promo_message: value
@@ -597,6 +631,7 @@ fn parse_details_from_lookup(
}
Some(RateLimitDetails {
limit_id: limit_id.to_string(),
key_source: RateLimitKeySource::LimitId,
limit_name,
allowed,
limit_reached,
@@ -687,6 +722,7 @@ fn parse_details_from_object(
value: &Value,
limit_id: &str,
limit_name: Option<String>,
key_source: RateLimitKeySource,
) -> Option<RateLimitDetails> {
let primary = value.get("primary").and_then(parse_window_from_object);
let secondary = value.get("secondary").and_then(parse_window_from_object);
@@ -697,6 +733,7 @@ fn parse_details_from_object(
}
Some(RateLimitDetails {
limit_id: limit_id.to_string(),
key_source,
limit_name,
allowed,
limit_reached,
@@ -752,11 +789,59 @@ fn value_as_positive_i64(value: &Value) -> Option<i64> {
.filter(|value| *value > 0)
}
fn rate_limit_name(value: &Value) -> Option<&str> {
fn rate_limit_id(value: &Value) -> Option<&str> {
value
.get("metered_limit_name")
.or_else(|| value.get("limit_name"))
.or_else(|| value.get("limit_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn top_level_rate_limit_id(value: &Value) -> Option<&str> {
rate_limit_id(value).or_else(|| {
value
.get("limit_name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
})
}
fn non_empty_string(value: &Value, field: &str) -> Option<String> {
value
.get(field)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn parse_additional_rate_limits(value: &Value) -> BTreeMap<String, RateLimitDetails> {
value
.get("additional_rate_limits")
.and_then(Value::as_object)
.into_iter()
.flatten()
.filter_map(|(map_key, value)| {
let explicit_limit_id = rate_limit_id(value).map(normalize_limit_id);
let (limit_id, key_source) = explicit_limit_id.map_or_else(
|| (normalize_limit_id(map_key), RateLimitKeySource::LimitName),
|limit_id| (limit_id, RateLimitKeySource::LimitId),
);
let limit_name = non_empty_string(value, "limit_name")
.or_else(|| Some(map_key.trim().to_owned()).filter(|name| !name.is_empty()));
parse_details_from_object(value, &limit_id, limit_name, key_source)
.map(|details| (limit_id, details))
})
.collect()
}
fn same_rate_limit_facts(left: &RateLimitDetails, right: &RateLimitDetails) -> bool {
left.allowed == right.allowed
&& left.limit_reached == right.limit_reached
&& left.primary == right.primary
&& left.secondary == right.secondary
}
fn normalize_limit_id(value: impl AsRef<str>) -> String {
@@ -1,7 +1,7 @@
use gateway_protocol::openai::events::{
RateLimitWindow, TokenUsage, extract_sse_usage, extract_usage, is_codex_quota_header_name,
is_rate_limit_header_name, parse_rate_limit_headers, parse_rate_limits_event,
rate_limits_to_header_pairs, retry_after_seconds_from_body,
RateLimitKeySource, RateLimitWindow, TokenUsage, extract_sse_usage, extract_usage,
is_codex_quota_header_name, is_rate_limit_header_name, parse_rate_limit_headers,
parse_rate_limits_event, rate_limits_to_header_pairs, retry_after_seconds_from_body,
};
use serde_json::json;
@@ -483,6 +483,58 @@ fn parse_rate_limits_event_should_extract_websocket_metered_limit() {
);
}
#[test]
fn parse_rate_limits_event_should_preserve_name_keyed_additional_limit() {
let event = json!({
"type": "codex.rate_limits",
"plan_type": "pro",
"rate_limits": {
"allowed": true,
"limit_reached": false,
"primary": {
"used_percent": 0,
"window_minutes": 10080,
"reset_after_seconds": 354916,
"reset_at": 1787396838
},
"secondary": null
},
"additional_rate_limits": {
"GPT-5.3-Codex-Spark": {
"allowed": true,
"limit_reached": false,
"primary": {
"used_percent": 0,
"window_minutes": 10080,
"reset_after_seconds": 354916,
"reset_at": 1787396838
},
"secondary": null
}
},
"credits": null
});
let parsed = parse_rate_limits_event(&event).expect("rate-limit event");
let details = parsed
.limits
.get("gpt_5.3_codex_spark")
.expect("name-keyed additional limit");
assert_eq!(parsed.active_limit.as_deref(), Some("gpt_5.3_codex_spark"));
assert_eq!(details.key_source, RateLimitKeySource::LimitName);
assert_eq!(details.limit_name.as_deref(), Some("GPT-5.3-Codex-Spark"));
assert_eq!(
details.primary,
Some(RateLimitWindow {
used_percent: 0.0,
window_minutes: Some(10080),
reset_at: Some(1787396838),
})
);
assert!(!parsed.limits.contains_key("codex"));
}
#[test]
fn parse_rate_limits_event_should_reject_unrelated_event_type() {
for event in [
+2 -2
View File
@@ -50,7 +50,7 @@ impl StoreBundle {
pub async fn initialize(mut config: StoreConfig) -> StoreResult<StoreBundle> {
const REDIS_NAMESPACE: &str = "codex-proxy-rs";
config.resolve_and_validate(std::path::Path::new("."))?;
config.validate_resolved()?;
let pool = postgres::connect_and_migrate(&config.database_url()?, config.pool).await?;
let redis_client = ::redis::Client::open(config.redis_url()?)
.map_err(|_| redis_unavailable("create Redis client"))?;
@@ -209,7 +209,7 @@ pub(crate) fn backup_ports(
config: &StoreConfig,
) -> StoreResult<BackupStorePorts> {
let staging = Arc::new(backup::staging::StagingArea::open(
std::path::PathBuf::from(".runtime/data/backup-staging"),
config.backup_staging_dir().to_path_buf(),
backup::staging::DEFAULT_MAX_ARCHIVE_BYTES,
)?);
let repository = Arc::new(postgres::PgBackupRepository::new(pool));
+28 -1
View File
@@ -1,5 +1,7 @@
//! Store 启动配置、环境变量解析与校验。
use std::path::{Path, PathBuf};
use super::*;
pub(crate) const DATABASE_URL_ENV: &str = "CPR_DATABASE_URL";
@@ -15,6 +17,8 @@ pub struct StoreConfig {
pub(crate) redis: StoreConnectionConfig,
#[serde(default)]
pub(crate) pool: StorePoolConfig,
#[serde(skip)]
backup_staging_dir: PathBuf,
}
/// PostgreSQL 连接池预算;acquire 超时决定池耗尽时快速失败而非排队积压。
@@ -47,7 +51,18 @@ impl StorePoolConfig {
}
impl StoreConfig {
pub fn resolve_and_validate(&mut self, _source_dir: &std::path::Path) -> StoreResult<()> {
pub fn resolve_and_validate(&mut self, runtime_data_dir: &Path) -> StoreResult<()> {
if runtime_data_dir.as_os_str().is_empty() {
return Err(StoreError::InvalidData {
entity: "store config",
message: "runtime_data_dir must not be empty".to_owned(),
});
}
self.backup_staging_dir = runtime_data_dir.join("backup-staging");
self.validate_resolved()
}
pub(crate) fn validate_resolved(&mut self) -> StoreResult<()> {
if let Some(url) = optional_environment_value(DATABASE_URL_ENV)? {
self.database.url = url;
}
@@ -63,6 +78,12 @@ impl StoreConfig {
self.database.validate("database")?;
self.redis.validate("redis")?;
self.pool.validate()?;
if self.backup_staging_dir.as_os_str().is_empty() {
return Err(StoreError::InvalidData {
entity: "store config",
message: "runtime_data_dir was not resolved".to_owned(),
});
}
Ok(())
}
@@ -73,6 +94,12 @@ impl StoreConfig {
pub(crate) fn redis_url(&self) -> StoreResult<String> {
self.redis.connection_url("redis")
}
/// 返回由统一运行数据根目录派生的备份暂存目录。
#[must_use]
pub fn backup_staging_dir(&self) -> &Path {
&self.backup_staging_dir
}
}
pub(crate) fn optional_environment_value(name: &'static str) -> StoreResult<Option<String>> {
@@ -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;
@@ -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()
+25 -64
View File
@@ -19,7 +19,7 @@ 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};
@@ -734,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 只看滚动后的窗口触顶:顶层标记是观测事实,不能
@@ -806,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 => {
@@ -833,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(),
}
}
}
@@ -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;
@@ -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,
@@ -706,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()))
@@ -734,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(),
@@ -1177,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()));
}
@@ -1216,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` 占位。
@@ -1269,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)]
@@ -173,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)?;
@@ -196,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> {
@@ -277,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,
@@ -316,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() {
@@ -398,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)
}
@@ -464,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,
+198 -78
View File
@@ -6,6 +6,7 @@ use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime};
use async_trait::async_trait;
use bytes::Bytes;
use futures::{StreamExt, future::BoxFuture};
use gateway_core::engine::continuation::{ContinuationBinding, NativeContinuationScope};
use gateway_core::engine::credential::{AccountFeedbackStats, ProviderAccount};
@@ -33,6 +34,9 @@ use gateway_core::task::{
WorkerDefinitionError, WorkerId, WorkerKind, WorkerLeaseRequest, WorkerRegistration,
WorkerRunnable, WorkerSchedule, WorkerTaskError,
};
use gateway_protocol::openai::events::{
ParsedRateLimits, parse_rate_limit_headers, rate_limits_to_header_pairs,
};
use reqwest::Client;
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
@@ -63,16 +67,16 @@ use crate::transport::protocol::responses::{
CodexResponsesRequest, PreviousResponseScope, ResponseEventSignals,
};
use crate::transport::request::{
CodexRequestEncodeError, encode_generate_request, scope_request_to_account,
CodexRequestEncodeError, RequestAccountScope, encode_generate_request, scope_request_to_account,
};
use crate::transport::session::CodexSessionIdentity;
use crate::transport::usage::normalize_service_tier;
use crate::transport::websocket::{CodexWebSocketExchangeError, PreviousResponseUnavailableReason};
use crate::transport::{
CODEX_RESPONSES_PATH, CodexAccountSelectionTelemetry, CodexBackendClient,
CodexBackendStreamingResponse, CodexBackendTransport, CodexClientError,
CodexRateLimitHeaderUpdates, CodexRequestContext, CodexResponseMetadata, CodexTransportMetrics,
CodexUpstreamDiagnostics, CodexWebSocketPool, endpoint_url,
CodexBackendStreamingResponse, CodexBackendTransport, CodexClientError, CodexRateLimitUpdates,
CodexRequestContext, CodexResponseMetadata, CodexTransportMetrics, CodexUpstreamDiagnostics,
CodexWebSocketPool, endpoint_url,
};
const PROVIDER_NAME: &str = "openai";
@@ -247,7 +251,12 @@ impl Provider for CodexProvider {
if let Some(identity) = &self.session_identity {
identity.prepare_local_conversation(&mut upstream_request);
}
if upstream_request.turn_state.is_none() {
if previous_session.as_ref().is_some_and(|state| {
same_client_turn(
state.client_turn_id.as_deref(),
upstream_request.client_turn_id.as_deref(),
)
}) {
upstream_request.turn_state = previous_session
.as_ref()
.and_then(|state| state.turn_state.clone());
@@ -277,57 +286,48 @@ impl Provider for CodexProvider {
let output_started_at = Instant::now();
let provider_kind = ProviderKind::new(PROVIDER_NAME)
.map_err(|_| provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent))?;
let cross_account = context
let state_owner_cross_account = context
.account_state_owner()
.is_some_and(|owner| !owner.matches(&provider_kind, lease.account_id()))
|| previous_session
.as_ref()
.is_some_and(|state| state.account_id != lease.account_id().as_str());
let replay_previous_response = matches!(
context.continuation_attempt(),
ContinuationAttempt::ReplayOwner | ContinuationAttempt::ReplayAny
) || previous_session.as_ref().is_some_and(|state| {
state.continuation_scope == OpenAiContinuationScope::ReplayRequired
});
if replay_previous_response && previous_session.is_some() {
// Codex 客户端 input 是完整历史;恢复时只解除上游续链绑定,避免重复叠加代理副本。
upstream_request.set_previous_response_id(None);
upstream_request.previous_response_scope = None;
upstream_request.turn_state = None;
}
scope_request_to_account(
&mut upstream_request,
lease.installation_id(),
cross_account,
);
if context.continuation_attempt() == ContinuationAttempt::Native
&& !replay_previous_response
let has_explicit_state_owner =
context.account_state_owner().is_some() || previous_session.is_some();
let cross_account =
state_owner_cross_account || (!has_explicit_state_owner && lease.account_switch());
if context.continuation_attempt() != ContinuationAttempt::None
&& let Some(continuation) = context.continuation()
{
match continuation {
ContinuationBinding::Pinned(continuation) => {
let previous_response_scope = match previous_session
.as_ref()
.map(|state| state.continuation_scope)
{
Some(OpenAiContinuationScope::Persisted) => {
PreviousResponseScope::Persisted
}
Some(OpenAiContinuationScope::ConnectionLocal) => {
PreviousResponseScope::ConnectionLocal
}
Some(OpenAiContinuationScope::ReplayRequired) => {
return Err(provider_error(
ProviderErrorKind::Protocol,
UpstreamSendState::NotSent,
));
}
None => match continuation.scope() {
NativeContinuationScope::Persisted => PreviousResponseScope::Persisted,
NativeContinuationScope::ConnectionLocal => {
let previous_response_scope = match context.continuation_attempt() {
ContinuationAttempt::Native => match previous_session
.as_ref()
.map(|state| state.continuation_scope)
{
Some(OpenAiContinuationScope::Persisted) => {
PreviousResponseScope::Persisted
}
Some(OpenAiContinuationScope::ConnectionLocal) => {
PreviousResponseScope::ConnectionLocal
}
Some(OpenAiContinuationScope::ReplayRequired) => {
PreviousResponseScope::ExternalUnknown
}
None => match continuation.scope() {
NativeContinuationScope::Persisted => {
PreviousResponseScope::Persisted
}
NativeContinuationScope::ConnectionLocal => {
PreviousResponseScope::ConnectionLocal
}
},
},
ContinuationAttempt::ReplayOwner | ContinuationAttempt::ReplayAny => {
PreviousResponseScope::ExternalUnknown
}
ContinuationAttempt::None => PreviousResponseScope::ExternalUnknown,
};
upstream_request.set_previous_response_id(Some(
continuation.upstream_response_id().as_str().to_owned(),
@@ -342,6 +342,18 @@ impl Provider for CodexProvider {
}
}
}
let account_scope = if !cross_account {
RequestAccountScope::SameAccount
} else if upstream_request.previous_response_id().is_some() {
RequestAccountScope::CrossAccountContinuationProbe
} else {
RequestAccountScope::CrossAccountFullRequest
};
scope_request_to_account(
&mut upstream_request,
lease.installation_id(),
account_scope,
);
let metadata = ProviderCallMetadata::new(
provider_kind,
candidate.upstream_model().clone(),
@@ -359,6 +371,7 @@ impl Provider for CodexProvider {
account_id: lease.account_id().as_str().to_owned(),
conversation_id: upstream_request.local_conversation_id.clone(),
turn_state: upstream_request.turn_state.clone(),
client_turn_id: upstream_request.client_turn_id.clone(),
response_store,
continuation_scope: None,
});
@@ -411,6 +424,8 @@ struct OpenAiSessionState {
conversation_id: Option<String>,
#[serde(default)]
turn_state: Option<String>,
#[serde(default)]
client_turn_id: Option<String>,
continuation_scope: OpenAiContinuationScope,
}
@@ -426,10 +441,17 @@ struct OpenAiSessionCapture {
account_id: String,
conversation_id: Option<String>,
turn_state: Option<String>,
client_turn_id: Option<String>,
response_store: bool,
continuation_scope: Option<OpenAiContinuationScope>,
}
fn same_client_turn(previous: Option<&str>, current: Option<&str>) -> bool {
previous
.zip(current)
.is_some_and(|(previous, current)| !previous.is_empty() && previous == current)
}
fn decode_openai_session_state(request: &GenerateRequest) -> Option<OpenAiSessionState> {
request
.provider_session_state(PROVIDER_NAME)
@@ -471,6 +493,7 @@ fn attach_openai_session_update(
account_id: capture.account_id,
conversation_id: capture.conversation_id,
turn_state: capture.turn_state,
client_turn_id: capture.client_turn_id,
continuation_scope,
}) else {
return;
@@ -698,9 +721,6 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
if let Some(observation) = observation_state.observation() {
yield ProviderEvent::observation(observation);
}
if allows_account_state_mutation {
synchronize_passive_quota(&quota, &active_account, &response.rate_limit_headers).await;
}
if let Some(etag) = response.response_metadata.models_etag.as_deref()
&& let Err(error) = catalog.observe_response_etag(etag)
{
@@ -728,7 +748,9 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
let failure_diagnostics = response.diagnostics.clone();
let failure_set_cookie_headers = response.set_cookie_headers.clone();
let failure_rate_limit_headers = response.rate_limit_headers.clone();
let rate_limit_updates = response.rate_limit_header_updates;
let mut passive_quota_observation =
OpenAiPassiveQuotaObservation::new(response.rate_limit_headers);
let rate_limit_updates = response.rate_limit_updates;
let turn_state_updates = response.turn_state_update;
// OpenAI 线路为透明代理:HTTP SSE 与 WebSocket 两条上游均启用 raw 透传,
// 下游按字节转发上游原文,避免 serde 往返改写数值/精度(大整数→f64、logprobs 等)。
@@ -741,6 +763,14 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
let mut pre_commit_events = PreCommitClientEvents::new();
loop {
let Some(stream_deadline) = remaining(context.deadline()) else {
if allows_account_state_mutation {
synchronize_passive_quota(
&quota,
&active_account,
passive_quota_observation.rate_limits(),
)
.await;
}
Err(provider_error(ProviderErrorKind::Timeout, UpstreamSendState::Sent))?;
return;
};
@@ -773,15 +803,22 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
Err(failure) => {
let updates = take_rate_limit_updates(rate_limit_updates.as_ref()).await;
if !updates.is_empty() {
if allows_account_state_mutation {
synchronize_passive_quota(&quota, &active_account, &updates).await;
}
if observation_state.merge_rate_limit_headers(&updates)
passive_quota_observation.observe(&updates);
let update_headers = rate_limit_update_headers(&updates);
if observation_state.merge_rate_limit_headers(&update_headers)
&& let Some(observation) = observation_state.observation()
{
yield ProviderEvent::observation(observation);
}
}
if allows_account_state_mutation {
synchronize_passive_quota(
&quota,
&active_account,
passive_quota_observation.rate_limits(),
)
.await;
}
apply_failure(&failure_context, &active_account, &failure)
.await;
Err(failure.error)?;
@@ -793,18 +830,19 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
let rate_limits_changed = if updates.is_empty() {
false
} else {
if allows_account_state_mutation {
synchronize_passive_quota(&quota, &active_account, &updates).await;
}
observation_state.merge_rate_limit_headers(&updates)
passive_quota_observation.observe(&updates);
observation_state.merge_rate_limit_headers(&rate_limit_update_headers(&updates))
};
if let (Some(capture), Some(updates)) =
(session_capture.as_mut(), turn_state_updates.as_ref())
// take:仅在上游写入过新 turn_state 时更新 capture,避免逐 chunk 克隆。
let turn_state_changed = if let Some(updates) = turn_state_updates.as_ref()
&& let Some(turn_state) = updates.lock().await.take()
{
capture.turn_state = Some(turn_state);
}
if let Some(capture) = session_capture.as_mut() {
capture.turn_state = Some(turn_state.clone());
}
observation_state.merge_client_header("x-codex-turn-state", &turn_state)
} else {
false
};
let first_event_changed =
observation_state.observe_stream_chunk(&chunk, output_started_at);
let chunk_len = chunk.len();
@@ -843,6 +881,14 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
.any(|event| matches!(event, GatewayEvent::Completed(_)));
let terminal_changed = completed
&& observation_state.mark_completed(terminal_response_is_incomplete(&events));
if allows_account_state_mutation && (completed || terminal_failure.is_some()) {
synchronize_passive_quota(
&quota,
&active_account,
passive_quota_observation.rate_limits(),
)
.await;
}
if let Some((failure, _)) = terminal_failure.as_ref() {
apply_failure(&failure_context, &active_account, failure)
.await;
@@ -861,6 +907,7 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
if (rate_limits_changed
|| service_tier_changed
|| timing_changed
|| turn_state_changed
|| terminal_changed)
&& let Some(observation) = observation_state.observation()
{
@@ -920,21 +967,31 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
let rate_limits_changed = if updates.is_empty() {
false
} else {
if allows_account_state_mutation {
synchronize_passive_quota(&quota, &active_account, &updates).await;
}
observation_state.merge_rate_limit_headers(&updates)
passive_quota_observation.observe(&updates);
observation_state.merge_rate_limit_headers(&rate_limit_update_headers(&updates))
};
if allows_account_state_mutation {
synchronize_passive_quota(
&quota,
&active_account,
passive_quota_observation.rate_limits(),
)
.await;
}
if let Some((failure, _)) = terminal_failure.as_ref() {
apply_failure(&failure_context, &active_account, failure)
.await;
}
if let (Some(capture), Some(updates)) =
(session_capture.as_mut(), turn_state_updates.as_ref())
let turn_state_changed = if let Some(updates) = turn_state_updates.as_ref()
&& let Some(turn_state) = updates.lock().await.take()
{
capture.turn_state = Some(turn_state);
}
if let Some(capture) = session_capture.as_mut() {
capture.turn_state = Some(turn_state.clone());
}
observation_state.merge_client_header("x-codex-turn-state", &turn_state)
} else {
false
};
attach_openai_session_update(&mut events, &mut session_capture);
let completed = events
.iter()
@@ -951,7 +1008,11 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
.observe_cyber_policy_success(cyber_policy_scope.as_ref())
.await;
}
if (service_tier_changed || timing_changed || rate_limits_changed || terminal_changed)
if (service_tier_changed
|| timing_changed
|| rate_limits_changed
|| turn_state_changed
|| terminal_changed)
&& let Some(observation) = observation_state.observation()
{
yield ProviderEvent::observation(observation);
@@ -1001,6 +1062,26 @@ struct OpenAiResponseObservationState {
terminal: Option<OpenAiResponseTerminal>,
}
struct OpenAiPassiveQuotaObservation {
rate_limits: Vec<ParsedRateLimits>,
}
impl OpenAiPassiveQuotaObservation {
fn new(headers: Vec<(String, String)>) -> Self {
Self {
rate_limits: parse_rate_limit_headers(&headers).into_iter().collect(),
}
}
fn observe(&mut self, updates: &[ParsedRateLimits]) {
self.rate_limits.extend_from_slice(updates);
}
fn rate_limits(&self) -> &[ParsedRateLimits] {
&self.rate_limits
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum OpenAiResponseTerminal {
Completed,
@@ -1120,6 +1201,26 @@ impl OpenAiResponseObservationState {
changed
}
fn merge_client_header(&mut self, name: &str, value: &str) -> bool {
let value = Bytes::copy_from_slice(value.as_bytes());
if let Some((_, existing)) = self
.response_metadata
.client_headers
.iter_mut()
.find(|(existing_name, _)| existing_name.eq_ignore_ascii_case(name))
{
if *existing == value {
return false;
}
*existing = value;
return true;
}
self.response_metadata
.client_headers
.push((name.to_owned(), value));
true
}
fn mark_completed(&mut self, incomplete: bool) -> bool {
let terminal = if incomplete {
OpenAiResponseTerminal::Incomplete
@@ -1432,15 +1533,20 @@ fn insert_optional_metadata_millis(
}
}
async fn take_rate_limit_updates(
updates: Option<&CodexRateLimitHeaderUpdates>,
) -> Vec<(String, String)> {
async fn take_rate_limit_updates(updates: Option<&CodexRateLimitUpdates>) -> Vec<ParsedRateLimits> {
let Some(updates) = updates else {
return Vec::new();
};
std::mem::take(&mut *updates.lock().await)
}
fn rate_limit_update_headers(updates: &[ParsedRateLimits]) -> Vec<(String, String)> {
updates
.iter()
.flat_map(rate_limits_to_header_pairs)
.collect()
}
fn terminal_response_is_incomplete(events: &[ProviderEvent]) -> bool {
events
.iter()
@@ -1466,12 +1572,15 @@ fn terminal_response_is_incomplete(events: &[ProviderEvent]) -> bool {
async fn synchronize_passive_quota(
quota: &CodexCredentialQuotaService,
account: &ProviderAccount,
headers: &[(String, String)],
rate_limits: &[ParsedRateLimits],
) {
if headers.is_empty() {
if rate_limits.is_empty() {
return;
}
if let Err(error) = quota.synchronize_passive_headers(account, headers).await {
if let Err(error) = quota
.synchronize_passive_rate_limits(account, rate_limits)
.await
{
tracing::warn!(
account_id = %account.id(),
error = %error,
@@ -1480,6 +1589,17 @@ async fn synchronize_passive_quota(
}
}
async fn synchronize_passive_quota_headers(
quota: &CodexCredentialQuotaService,
account: &ProviderAccount,
headers: &[(String, String)],
) {
let Some(rate_limits) = parse_rate_limit_headers(headers) else {
return;
};
synchronize_passive_quota(quota, account, std::slice::from_ref(&rate_limits)).await;
}
const fn actual_transport_name(transport: CodexBackendTransport) -> &'static str {
match transport {
CodexBackendTransport::HttpSse => HTTP_SSE_TRANSPORT,
@@ -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::{
@@ -14,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;
@@ -566,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!(
@@ -579,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!(
(
@@ -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();
@@ -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]
@@ -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);
}
@@ -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());
@@ -622,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!({
+6 -2
View File
@@ -1071,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(),
@@ -1087,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,
-1
View File
@@ -55,7 +55,6 @@ RUN apt-get update \
&& useradd --system --uid 10001 --gid cpr --home-dir /app --shell /usr/sbin/nologin cpr \
&& install -d -o cpr -g cpr \
/app/bin /app/deploy /app/.runtime/data /app/.runtime/logs /app/web \
&& install -d -o cpr -g cpr -m 0700 /app/.runtime/data/backup-staging \
&& rm -rf /var/lib/apt/lists/*
COPY --from=pg-client /usr/lib/postgresql/18/bin/pg_dump /usr/lib/postgresql/18/bin/pg_dump
+11 -9
View File
@@ -91,16 +91,17 @@ cd backend
cargo run -p codex-proxy-rs
```
后端会从当前目录向上查找 `deploy/config.yaml`。相对数据和日志目录以该文件所在目录解析;
Compose 把监听地址和数据库、Redis 地址固定覆盖为容器内部服务名,并把前端静态目录指向
容器内构建产物。PG/Redis 集成测试所需的 `CPR_TEST_DATABASE_URL` / `CPR_TEST_REDIS_URL`
约定见 [迁移文档](../backend/migrations/README.md)。
后端会从当前目录向上查找 `deploy/config.yaml`。`host.runtime_data_dir` 是运行数据的统一根目录,
相对数据和日志目录均以该配置文件所在目录解析;Compose 把监听地址和数据库、Redis 地址固定
覆盖为容器内部服务名,并把前端静态目录指向容器内构建产物。PG/Redis 集成测试所需的
`CPR_TEST_DATABASE_URL` / `CPR_TEST_REDIS_URL` 约定见
[迁移文档](../backend/migrations/README.md)。
## 持久化与备份
Compose 使用以下绑定目录:
- `.runtime/data` → OpenAI 会话锚点密钥、更新状态与临时更新目录
- `.runtime/data` → OpenAI 会话锚点密钥、更新状态、临时更新目录与备份暂存区
- `.runtime/logs` → 应用文件日志
- `.runtime/postgres` → PostgreSQL
- `.runtime/redis` → Redis AOF
@@ -177,8 +178,8 @@ Compose 已显式装配正式发布构建所需的运行参数:
- `CPR_GITHUB_API_BASE`:正式环境必须为 `https://api.github.com/repos`。
- `CPR_UPDATE_CHANNEL`:`stable` 会拒绝 prerelease。
- `CPR_UPDATE_EXE_PATH`、`CPR_WEB_DIST_DIR`:分别指向容器内二进制和前端静态目录。
- `CPR_UPDATE_TEMP_DIR`、`CPR_UPDATE_STATE_FILE`、`CPR_UPDATE_LOCK_FILE`:全部位于持久化的
`.runtime/data`。
- 更新临时目录、状态文件和锁文件默认由 `host.runtime_data_dir` 派生;
`CPR_UPDATE_TEMP_DIR`、`CPR_UPDATE_STATE_FILE`、`CPR_UPDATE_LOCK_FILE` 仅用于显式覆盖。
- `CPR_ENABLE_SELF_RESTART=true`:更新或回滚完成后允许管理端请求重启;Docker 进程退出后由
Compose 的 `restart: unless-stopped` 拉起新进程。
@@ -201,8 +202,9 @@ Release 必须提供当前 OS/架构的 `codex-proxy-rs_<version>_<os>_<arch>.ta
`postgres:18-bookworm` 镜像(`Dockerfile` 的 `POSTGRES_IMAGE`)COPY 到
`/usr/lib/postgresql/18/bin` 并加入容器 PATH,版本与服务端严格一致;运行时只从
Debian 官方源补 `libpq5` 依赖,不引入第三方 APT 源,也不依赖运行时动态安装。
- 备份暂存目录为 `/app/.runtime/data/backup-staging`,由 `.runtime/data` 卷持久化,权限
`0700`(仅 `cpr` 用户可读写)。部署卷至少预留一个最大数据库归档的空间。
- 备份暂存目录为 `host.runtime_data_dir/backup-staging`;Compose 默认对应
`/app/.runtime/data/backup-staging`,由 `.runtime/data` 卷持久化,权限 `0700`(仅 `cpr`
用户可读写)。部署卷至少预留一个最大数据库归档的空间。
- OAuth 恢复记录通道使用 `oauth_recovery` 结构化日志事件;当前 OpenAI provider 每次成功取得
AT/RT 后,都会在账号资料补全、过期时间计算和数据库写入前写入独立的
`codex-proxy-rs-oath.YYYY-MM-DD[.N].log` 文件集。事件含原始 AT/RT,并以 `provider`
-3
View File
@@ -127,9 +127,6 @@ services:
CPR_UPDATE_CHANNEL: stable
CPR_GITHUB_API_BASE: https://api.github.com/repos
CPR_UPDATE_EXE_PATH: /app/bin/codex-proxy-rs
CPR_UPDATE_TEMP_DIR: /app/.runtime/data/update-tmp
CPR_UPDATE_STATE_FILE: /app/.runtime/data/update-state.json
CPR_UPDATE_LOCK_FILE: /app/.runtime/data/update.lock
CPR_WEB_DIST_DIR: /app/web/dist
CPR_ENABLE_SELF_RESTART: 'true'
logging: *json-logging
+3
View File
@@ -14,6 +14,9 @@ host:
# 本地 cargo run 使用;Docker 内由 CPR_SERVER_HOST/PORT 覆盖。
host: '127.0.0.1'
port: 8080
# OpenAI 会话身份、系统更新状态和备份暂存区的统一根目录。
# 相对路径以 deploy/config.yaml 所在目录为基准。
runtime_data_dir: '../.runtime/data'
logging:
level: 'info'
stdout: true
+5 -1
View File
@@ -104,7 +104,11 @@ continuation 或 claim 表。
- `store=true`:使用 Provider 持久化的 native handle。
- `store=false`:opaque replay state 仅存在于活连接内,不落 PostgreSQL。
- OpenAI continuation 顺序为 native、replay owner、replay any。
- OpenAI continuation 顺序为 native、replay owner、replay any。`replay any` 是
Provider 定义的恢复阶段,不代表 Core 已持有完整 input:OpenAI 在跨账号时保留
canonical `previous_response_id` 与原始增量 input 做 continuation probe;上游返回
`previous_response_not_found` 时原样交给官方客户端触发 full retry。只有确实持有
可携带 transcript 的 Provider 才能清除 native handle 后在代理内部重放。
- xAI 使用客户端提交的完整历史作为已验证 continuation 路径。
- native continuation pin 同时绑定创建它的 Client Key、冻结账号范围、Provider 和账号;跨 Key 复用、
scope 外账号或计划中不存在该 Provider 都 fail closed。`ReplayOwner` 同样固定原 Provider 和账号,
+4
View File
@@ -14,8 +14,12 @@ export type AccountErrorReason
export interface AccountQuotaWindow {
key: string
group: string
limitId: string | null
limitName: string | null
role: 'primary' | 'secondary' | 'monthly' | null
windowSeconds: number | null
labelDisplay: string
windowLabelDisplay: string
usedPercent: number | null
usedPercentDisplay: string
limitReached: boolean
@@ -4,9 +4,9 @@ import { RefreshCw } from '@lucide/vue'
import { computed } from 'vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import { orderedPanelQuotaWindows } from '../constants'
import { groupedAccountQuotaWindows, orderedPanelQuotaWindows } from '../constants'
import AccountPlanBadge from './AccountPlanBadge.vue'
import AccountUsageWindow from './AccountUsageWindow/index.vue'
import AccountQuotaPanelEntry from './AccountQuotaPanelEntry.vue'
const props = defineProps<{
account: AccountRow
@@ -17,23 +17,24 @@ const emit = defineEmits<{
refreshQuota: [accountId: string]
}>()
const quotaWindows = computed(() => orderedPanelQuotaWindows(props.account.quota.windows))
const quotaEntries = computed(() => groupedAccountQuotaWindows(
orderedPanelQuotaWindows(props.account.quota.windows),
))
</script>
<template>
<section class="rounded-lg bg-cp-surface p-4 shadow-cp-control">
<div class="mb-3 flex items-center justify-between gap-3">
<div>
<section class="flex min-h-0 flex-col rounded-lg bg-cp-surface p-4 shadow-cp-control">
<div class="mb-3 flex shrink-0 items-start justify-between gap-3">
<div class="min-w-0">
<h3 class="m-0 text-[14px] font-heavy text-cp-primary">
账号额度
</h3>
<p
class="m-0 mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-[12px] font-emphasis text-cp-secondary"
class="m-0 mt-1 flex min-w-0 items-center gap-1.5 text-[11px] font-emphasis text-cp-secondary"
>
<span>{{ account.provider === 'xai' ? 'xAI 用量窗口' : 'Codex 额度' }}</span>
<template v-if="account.provider === 'openai'">
<span>·</span>
<span>套餐:</span>
<AccountPlanBadge :plan-type="account.planType" size="sm" />
</template>
<span>·</span>
@@ -55,9 +56,16 @@ const quotaWindows = computed(() => orderedPanelQuotaWindows(props.account.quota
</BaseIconButton>
</div>
<div class="grid gap-3">
<AccountUsageWindow v-for="window in quotaWindows" :key="window.key" :window="window" />
<AccountUsageWindow v-if="quotaWindows.length === 0" />
<div class="grid max-h-48 min-h-0 gap-3 overflow-y-auto pr-1">
<AccountQuotaPanelEntry
v-for="entry in quotaEntries"
:key="entry.key"
:label="entry.label"
:windows="entry.windows"
/>
<p v-if="quotaEntries.length === 0" class="m-0 text-[12px] font-emphasis text-cp-secondary">
额度待观测
</p>
</div>
</section>
</template>
@@ -0,0 +1,97 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '../constants'
import { computed } from 'vue'
import {
quotaWindowLocalUsageDisplay,
quotaWindowPresentation,
} from './AccountUsageWindow/presenter'
const props = defineProps<{
label: string | null
windows: AccountQuotaWindow[]
}>()
const grouped = computed(() => props.windows.length > 1 && Boolean(props.label))
const items = computed(() => props.windows.map((window) => {
const presentation = quotaWindowPresentation(window, '4px')
return {
key: window.key,
label: grouped.value
? window.windowLabelDisplay
: window.labelDisplay,
ariaLabel: window.labelDisplay,
usedPercent: window.usedPercent,
usedPercentDisplay: window.usedPercentDisplay,
localUsageDisplay: quotaWindowLocalUsageDisplay(window),
resetAtDisplay: window.resetAtDisplay,
percentTextClass: presentation.percentTextClass,
barClass: presentation.barClass,
barStyle: presentation.barStyle,
}
}))
</script>
<template>
<section class="grid min-w-0 gap-2">
<h4
v-if="grouped && label"
class="m-0 truncate text-[11px] leading-4 font-heavy text-cp-secondary"
:title="label"
>
{{ label }}
</h4>
<div
class="grid min-w-0 gap-3"
:class="grouped ? 'grid-cols-2' : 'grid-cols-1'"
>
<div v-for="item in items" :key="item.key" class="grid min-w-0 gap-1.5">
<div class="flex min-w-0 items-baseline justify-between gap-2 text-[11px] leading-3.5">
<span class="min-w-0 truncate font-bold text-cp-muted-text">
{{ item.label }}
</span>
<span class="flex shrink-0 items-baseline gap-1.5 font-mono font-heavy tabular-nums">
<strong
v-if="item.localUsageDisplay"
class="text-cp-muted-text"
:title="`窗口消耗:${item.localUsageDisplay}`"
>
{{ item.localUsageDisplay }}
</strong>
<strong
:class="item.percentTextClass"
:title="`额度已使用:${item.usedPercentDisplay}`"
>
{{ item.usedPercentDisplay }}
</strong>
</span>
</div>
<div
class="h-1.5 w-full overflow-hidden rounded-full bg-cp-default-border"
role="progressbar"
:aria-label="item.ariaLabel"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="item.usedPercent ?? undefined"
:aria-valuetext="item.usedPercentDisplay"
>
<div
class="h-full rounded-full transition-[width,background-color] duration-200 motion-reduce:transition-none"
:class="item.barClass"
:style="item.barStyle"
/>
</div>
<p class="m-0 flex min-w-0 items-center justify-between gap-2 text-[10px] leading-3.5 text-cp-tertiary">
<span class="shrink-0 font-emphasis">重置</span>
<span class="min-w-0 truncate font-mono font-emphasis tabular-nums" :title="item.resetAtDisplay">
{{ item.resetAtDisplay }}
</span>
</p>
</div>
</div>
</section>
</template>
@@ -2,7 +2,8 @@
import type { AccountRow } from '../constants'
import { computed } from 'vue'
import { visibleSummaryQuotaWindows } from '../constants'
import { groupedAccountQuotaWindows, visibleSummaryQuotaWindows } from '../constants'
import AccountQuotaSummaryEntry from './AccountQuotaSummaryEntry.vue'
import AccountUsageWindow from './AccountUsageWindow/index.vue'
const props = defineProps<{
@@ -11,10 +12,11 @@ const props = defineProps<{
const quotaWindows = computed(() => props.account.quota.windows)
const visibleQuotaWindows = computed(() => visibleSummaryQuotaWindows(quotaWindows.value))
const summaryEntries = computed(() => groupedAccountQuotaWindows(visibleQuotaWindows.value))
const summaryClass = computed(
() =>
`grid w-full min-w-0 gap-1.5 py-0.5 ${
visibleQuotaWindows.value.length <= 1
`box-border grid w-full min-w-0 gap-1 py-1.5 ${
summaryEntries.value.length <= 1
? 'min-h-13 content-center'
: 'min-h-16.5 content-center'
}`,
@@ -23,12 +25,12 @@ const summaryClass = computed(
<template>
<div :class="summaryClass">
<AccountUsageWindow
v-for="window in visibleQuotaWindows"
:key="window.key"
:window="window"
variant="compact"
<AccountQuotaSummaryEntry
v-for="entry in summaryEntries"
:key="entry.key"
:label="entry.label"
:windows="entry.windows"
/>
<AccountUsageWindow v-if="quotaWindows.length === 0" variant="compact" />
<AccountUsageWindow v-if="summaryEntries.length === 0" variant="compact" />
</div>
</template>
@@ -0,0 +1,188 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '../constants'
import { computed } from 'vue'
import BasePopover from '@/components/base/BasePopover.vue'
import { useUiClock } from '@/composables/useUiClock'
import AccountQuotaWindowGroup from './AccountQuotaWindowGroup.vue'
import AccountUsageWindow from './AccountUsageWindow/index.vue'
import { resolveAccountUsageWindowPresentation } from './AccountUsageWindow/presenter'
const props = defineProps<{
label: string | null
windows: AccountQuotaWindow[]
}>()
const now = useUiClock()
const grouped = computed(() => (
props.windows.length > 1
&& Boolean(props.label)
))
const detailHeading = computed(() => (
props.label
))
const detailTitle = computed(() => (
detailHeading.value
?? props.windows[0]?.labelDisplay
?? '额度详情'
))
const detailItems = computed(() => props.windows.map((window) => {
const view = resolveAccountUsageWindowPresentation({
window,
variant: 'detail',
showLocalValue: true,
now: now.value.getTime(),
})
return {
key: window.key,
code: quotaWindowCode(window.windowSeconds, window.role),
label: window.windowLabelDisplay,
usedPercent: window.usedPercent,
usedPercentDisplay: window.usedPercentDisplay,
resetAtDisplay: window.resetAtDisplay,
localUsageDisplay: view.quota.localUsageVisible
? view.quota.localUsageDisplay
: '—',
percentTextClass: view.quota.percentTextClass,
barClass: view.quota.barClass,
barStyle: view.quota.barStyle,
}
}))
function quotaWindowCode(
windowSeconds: number | null,
role: AccountQuotaWindow['role'],
) {
if (typeof windowSeconds === 'number' && Number.isFinite(windowSeconds)) {
const daySeconds = 24 * 60 * 60
const hourSeconds = 60 * 60
if (windowSeconds >= daySeconds && windowSeconds % daySeconds === 0)
return `${windowSeconds / daySeconds}D`
if (windowSeconds >= hourSeconds && windowSeconds % hourSeconds === 0)
return `${windowSeconds / hourSeconds}H`
}
switch (role) {
case 'primary':
return 'P'
case 'secondary':
return 'S'
case 'monthly':
return 'M'
default:
return 'Q'
}
}
</script>
<template>
<BasePopover
class="w-full"
trigger="hover-click"
placement="right"
:hover-delay="240"
>
<template #trigger="{ open }">
<button
type="button"
class="block w-full cursor-help rounded-sm border-0 bg-transparent p-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
:aria-label="`查看${detailTitle}详情`"
:aria-expanded="open"
aria-haspopup="dialog"
>
<AccountQuotaWindowGroup
v-if="grouped && label"
:label="label"
:windows="windows"
/>
<AccountUsageWindow
v-else
:window="windows[0]"
variant="compact"
:show-local-value="true"
:show-percentage="false"
:show-native-tooltip="false"
/>
</button>
</template>
<section
class="w-84 overflow-hidden rounded-cp-overlay"
role="dialog"
:aria-label="`${detailTitle}详情`"
>
<header
v-if="detailHeading"
class="bg-cp-muted p-3"
>
<h3
class="m-0 truncate text-[13px] leading-5 font-heavy text-cp-primary"
:title="detailHeading"
>
{{ detailHeading }}
</h3>
</header>
<div
class="grid gap-4"
:class="detailHeading ? 'p-3' : 'p-4'"
>
<article
v-for="item in detailItems"
:key="item.key"
>
<div class="grid grid-cols-[32px_minmax(0,1fr)_auto] items-center gap-2.5">
<span
class="grid h-5 w-8 place-items-center rounded-sm bg-cp-muted font-mono text-[9px] leading-none font-heavy tracking-[0.04em] text-cp-info-text"
aria-hidden="true"
>
{{ item.code }}
</span>
<h4 class="m-0 min-w-0 truncate text-[12px] leading-4 font-heavy text-cp-secondary">
{{ item.label }}
</h4>
<strong
class="font-mono text-[11px] leading-none font-heavy tabular-nums"
:class="item.percentTextClass"
>
{{ item.usedPercentDisplay }}
</strong>
</div>
<div
class="mt-2 h-1 w-full overflow-hidden rounded-full bg-cp-default-border"
role="progressbar"
:aria-label="item.label"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="item.usedPercent ?? undefined"
:aria-valuetext="item.usedPercentDisplay"
>
<div
class="h-full rounded-full transition-[width,background-color] duration-200 motion-reduce:transition-none"
:class="item.barClass"
:style="item.barStyle"
/>
</div>
<dl class="mt-2.5 grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1.5 text-[10px] leading-4">
<dt class="font-emphasis text-cp-tertiary">
窗口消耗
</dt>
<dd class="m-0 text-right font-mono font-heavy tabular-nums text-cp-primary">
{{ item.localUsageDisplay }}
</dd>
<dt class="font-emphasis text-cp-tertiary">
重置时间
</dt>
<dd class="m-0 truncate text-right font-mono font-emphasis tabular-nums text-cp-secondary">
{{ item.resetAtDisplay }}
</dd>
</dl>
</article>
</div>
</section>
</BasePopover>
</template>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '../constants'
import { computed } from 'vue'
import { quotaWindowPresentation } from './AccountUsageWindow/presenter'
const props = defineProps<{
label: string
windows: AccountQuotaWindow[]
}>()
const windowItems = computed(() => props.windows.map(window => ({
key: window.key,
labelTooltip: window.labelDisplay,
usedPercent: window.usedPercent,
usedPercentDisplay: window.usedPercentDisplay,
presentation: quotaWindowPresentation(window, '2px'),
})))
const trackGridStyle = computed(() => ({
gridTemplateColumns: `repeat(${Math.max(windowItems.value.length, 1)}, minmax(0, 1fr))`,
}))
</script>
<template>
<div class="flex min-w-0 flex-col">
<div class="mb-1 min-w-0 truncate text-[11px] leading-3.5 font-bold text-cp-muted-text">
{{ label }}
</div>
<div class="grid min-w-0 gap-1" :style="trackGridStyle">
<div
v-for="item in windowItems"
:key="item.key"
class="h-0.75 w-full overflow-hidden rounded-full bg-cp-default-border"
role="progressbar"
:aria-label="item.labelTooltip"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="item.usedPercent ?? undefined"
:aria-valuetext="item.usedPercentDisplay"
>
<div
class="h-full rounded-full transition-[width,background-color] duration-200"
:class="item.presentation.barClass"
:style="item.presentation.barStyle"
/>
</div>
</div>
</div>
</template>
@@ -69,7 +69,7 @@ const modelUsageColumns = defineTableColumns<AccountModelUsage>([
</div>
<div
class="min-w-0 pt-4 shadow-[inset_0_1px_0_var(--cp-divider-subtle)] xl:pt-0 xl:pl-4 xl:shadow-[inset_1px_0_0_var(--cp-divider-subtle)]"
class="min-w-0 pt-4 xl:pt-0 xl:pl-4"
>
<div class="mb-3 flex items-center justify-between">
<h3 class="m-0 text-[14px] font-heavy text-cp-primary">
@@ -10,10 +10,14 @@ const props = withDefaults(
window?: AccountQuotaWindow
variant?: AccountUsageWindowVariant
showLocalValue?: boolean
showPercentage?: boolean
showNativeTooltip?: boolean
}>(),
{
variant: 'detail',
showLocalValue: true,
showPercentage: true,
showNativeTooltip: true,
},
)
@@ -33,7 +37,7 @@ const view = computed(() => resolveAccountUsageWindowPresentation({
<span
class="min-w-0"
:class="view.classes.label"
:title="view.labelTooltip"
:title="showNativeTooltip ? view.labelTooltip : undefined"
>
{{ window.labelDisplay }}
</span>
@@ -45,15 +49,15 @@ const view = computed(() => resolveAccountUsageWindowPresentation({
v-if="view.quota.localUsageVisible"
class="text-right text-cp-muted-text"
:class="view.classes.value"
:title="`本地 Token 用量:${view.quota.localUsageDisplay}`"
:title="showNativeTooltip ? `窗口消耗:${view.quota.localUsageDisplay}` : undefined"
>
{{ view.quota.localUsageDisplay }}
</span>
<span
v-if="view.quota.valueVisible"
v-if="showPercentage && view.quota.valueVisible"
class="text-right"
:class="[view.classes.value, view.quota.percentTextClass]"
:title="`额度已使用:${window.usedPercentDisplay}`"
:title="showNativeTooltip ? `额度已使用:${window.usedPercentDisplay}` : undefined"
>
{{ window.usedPercentDisplay }}
</span>
@@ -70,7 +74,7 @@ const view = computed(() => resolveAccountUsageWindowPresentation({
{{ view.quota.localUsageDisplay }}
</span>
<span
v-if="view.quota.valueVisible"
v-if="showPercentage && view.quota.valueVisible"
:class="[view.classes.value, view.quota.percentTextClass]"
>
{{ window.usedPercentDisplay }}
@@ -119,9 +119,7 @@ export function resolveAccountUsageWindowPresentation(
const localUsage = accountLocalUsage(input.window?.localUsage)
const mode = usageWindowMode(input.window, localUsage)
const quotaLocalUsageDisplay = localTokenDisplay(localUsage)
const quotaLocalUsageVisible = input.showLocalValue
&& typeof localUsage?.totalTokens === 'number'
&& localUsage.totalTokens > 0
const quotaLocalUsageVisible = input.showLocalValue && Boolean(quotaLocalUsageDisplay)
const localRequestDisplay = requestCountDisplay(localUsage)
const localRequestValueVisible = input.showLocalValue
&& typeof localUsage?.requestCount === 'number'
@@ -146,7 +144,7 @@ export function resolveAccountUsageWindowPresentation(
},
quota: {
valueVisible: typeof input.window?.usedPercent === 'number'
&& input.window.usedPercent > 0,
&& (input.variant !== 'compact' || input.window.usedPercent > 0),
localUsageDisplay: quotaLocalUsageDisplay,
localUsageVisible: quotaLocalUsageVisible,
barStyle: quota?.barStyle,
@@ -221,11 +219,19 @@ function usageWindowMode(
}
function localTokenDisplay(localUsage: AccountLocalUsage | null) {
const total = localUsage?.totalTokens
if (typeof total !== 'number' || total <= 0)
return ''
const display = localUsage?.totalTokensDisplay
if (typeof display === 'string' && display.trim())
return display.trim()
const total = localUsage?.totalTokens
return typeof total === 'number' && total > 0 ? formatInteger(total) : ''
return formatInteger(total)
}
export function quotaWindowLocalUsageDisplay(window: AccountQuotaWindow) {
const localUsage = accountLocalUsage(window.localUsage)
return localTokenDisplay(localUsage) || null
}
function requestCountDisplay(localUsage: AccountLocalUsage | null) {
@@ -236,7 +242,7 @@ function requestCountDisplay(localUsage: AccountLocalUsage | null) {
return typeof count === 'number' ? formatInteger(count) : '0'
}
function quotaWindowPresentation(window: AccountQuotaWindow, minimumWidth: string) {
export function quotaWindowPresentation(window: AccountQuotaWindow, minimumWidth: string) {
const percent = clamp(window.usedPercent ?? 0, 0, 100)
const tone = quotaWindowTone(window.usedPercent)
return {
+70 -6
View File
@@ -5,6 +5,12 @@ import { formatProviderLabel } from '@/utils/providers'
export type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
export type AccountQuotaWindow = AccountRow['quota']['windows'][number]
export interface AccountQuotaWindowEntry {
key: string
label: string | null
windows: AccountQuotaWindow[]
}
const quotaGroupOrder = new Map([
['shortTerm', 0],
['monthly', 1],
@@ -104,6 +110,37 @@ export function orderedPanelQuotaWindows(windows: AccountQuotaWindow[]) {
return [...windows].sort(compareQuotaWindows)
}
export function groupedAccountQuotaWindows(windows: AccountQuotaWindow[]) {
const entries: AccountQuotaWindowEntry[] = []
const limitEntryIndexes = new Map<string, number>()
for (const window of windows) {
const limitLabel = quotaLimitLabel(window)
if (!window.limitId || !limitLabel) {
entries.push({ key: window.key, label: null, windows: [window] })
continue
}
const existingIndex = limitEntryIndexes.get(window.limitId)
if (existingIndex !== undefined) {
entries[existingIndex]?.windows.push(window)
continue
}
limitEntryIndexes.set(window.limitId, entries.length)
entries.push({
key: `limit:${window.limitId}`,
label: limitLabel,
windows: [window],
})
}
for (const entry of entries)
entry.windows.sort(compareQuotaLimitWindows)
return entries
}
export function modelSuccessRateTextClass(successRate: number | null) {
if (successRate === null)
return 'text-cp-muted-text'
@@ -121,12 +158,7 @@ function compareQuotaWindows(left: AccountQuotaWindow, right: AccountQuotaWindow
if (groupDifference !== 0)
return groupDifference
const secondsDifference = (left.windowSeconds ?? Number.MAX_SAFE_INTEGER)
- (right.windowSeconds ?? Number.MAX_SAFE_INTEGER)
if (secondsDifference !== 0)
return secondsDifference
// 同组同周期保留 Provider 的投影顺序,避免内部 key 把 additional 排到 core 前面。
// 同组保留 Provider 的投影顺序,避免按窗口时长打散 core 与模型专属额度。
return 0
}
@@ -134,6 +166,38 @@ function groupOrder(window: AccountQuotaWindow) {
return quotaGroupOrder.get(window.group) ?? quotaGroupOrder.size
}
function compareQuotaLimitWindows(left: AccountQuotaWindow, right: AccountQuotaWindow) {
const durationDifference = windowDurationOrder(left.windowSeconds)
- windowDurationOrder(right.windowSeconds)
if (durationDifference !== 0)
return durationDifference
return windowRoleOrder(left.role) - windowRoleOrder(right.role)
}
function windowDurationOrder(windowSeconds: number | null) {
return windowSeconds ?? Number.POSITIVE_INFINITY
}
function windowRoleOrder(role: AccountQuotaWindow['role']) {
switch (role) {
case 'primary':
return 0
case 'secondary':
return 1
case 'monthly':
return 2
default:
return 3
}
}
function quotaLimitLabel(window: AccountQuotaWindow) {
if (window.limitId === 'codex')
return '核心额度'
return window.limitName
}
function accountProviderLabel(value?: string | null) {
return formatProviderLabel(value)
}
@@ -462,7 +462,11 @@ function dashboardUsageWindow(item: DashboardSummary['accountUsage'][number]) {
return {
key: 'quota',
group: 'other',
limitId: null,
limitName: null,
role: null,
labelDisplay: '额度',
windowLabelDisplay: '额度',
usedPercent,
usedPercentDisplay: usedPercent === null ? '—' : `${usedPercent}%`,
limitReached: (usedPercent ?? 0) >= 100,
@@ -484,7 +488,11 @@ function dashboardUsageWindow(item: DashboardSummary['accountUsage'][number]) {
return {
key: 'dailyRequests',
group: 'other',
limitId: null,
limitName: null,
role: null,
labelDisplay: '日请求',
windowLabelDisplay: '日请求',
usedPercent: null,
usedPercentDisplay: '—',
limitReached: false,
@@ -15,7 +15,7 @@ const latencyDetails = computed(() => usageLatencyDetails(props.record))
<template>
<div class="flex items-center justify-end gap-1.5">
<div
class="grid grid-cols-[auto_auto] items-center justify-end gap-x-2 gap-y-1.5 font-mono text-[12px] leading-none font-heavy tabular-nums"
class="grid grid-cols-[auto_auto] items-center justify-end gap-x-2 gap-y-1.5 whitespace-nowrap font-mono text-[12px] leading-none font-heavy tabular-nums"
>
<span class="text-[11px] text-cp-muted-text">{{ latencyDetails.firstOutputLabel }}</span>
<span class="text-cp-secondary">{{ latencyDetails.firstOutputDisplay }}</span>
+3 -3
View File
@@ -19,15 +19,15 @@ export const usageRecordColumns = defineTableColumns<UsageDisplayRecord>([
size: 'sm',
format: (value: unknown) => formatProvider(typeof value === 'string' ? value : null),
},
{ key: 'model', label: '模型', kind: 'custom' },
{ key: 'model', label: '模型', kind: 'custom', size: 'xl' },
{ key: 'reasoningEffort', label: '推理强度', kind: 'status' },
{ key: 'route', label: '端点', kind: 'mono' },
{ key: 'recordType', label: '类型', kind: 'status', size: 'sm' },
{ key: 'tokenDetails', label: 'TOKEN', kind: 'numeric', size: 'xl' },
{ key: 'billing', label: '费用', kind: 'numeric', size: 'lg' },
{ key: 'latency', label: '延迟', kind: 'numeric', size: 'lg' },
{ key: 'latency', label: '延迟', kind: 'numeric', size: 'xl' },
{ key: 'createdAtDisplay', label: '时间', kind: 'datetime' },
{ key: 'clientIp', label: 'IP', kind: 'custom', size: '2xl' },
{ key: 'clientIp', label: 'IP', kind: 'custom', size: '3xl' },
{ key: 'userAgent', label: 'User-Agent', kind: 'custom', size: '4xl' },
{ key: 'actions', label: '操作', kind: 'actions', size: 'sm' },
])
+1 -1
View File
@@ -1 +1 @@
version: "3.2.11"
version: "3.2.13"