Compare commits

...
5 Commits
Author SHA1 Message Date
ZyyandCodex a2922b410e chore: bump version to 3.3.26
Co-authored-by: Codex <noreply@openai.com>
2026-09-07 09:49:49 +08:00
ZyyandCodex d303efc6f7 fix(openai): preserve ASCII encoding in scoped turn metadata
Keep turn metadata ASCII after account scoping by escaping Unicode as UTF-16 code units. Preserve metadata values and Unicode request input across HTTP headers, body aliases and WebSocket client metadata.

Cover Unicode workspace paths, emoji, escaped literals and account changes with HTTP and WebSocket regression tests.

Validation: provider-openai tests (528 passed) and workspace rustfmt.

Co-authored-by: Codex <noreply@openai.com>
2026-09-07 09:37:08 +08:00
ZyyandCodex dd17332c31 chore: bump version to 3.3.25
Co-authored-by: Codex <noreply@openai.com>
2026-09-07 04:48:14 +08:00
ZyyandCodex 47d08786ad fix(scheduling): retain valid quota signals across idle periods
Keep OpenAI quota windows eligible for scheduling until their reset time,
independently of the projection cache TTL. Expire undated observations and
reset windows separately, and clear old signals when a fresh observation
contains no active window.

Record bounded candidate diagnostics with quota, concurrency, smart scores
and affinity results. Cover idle 74%/96% accounts with one concurrent title
request, independent window expiry, cache replacement and trace retention.

Validation: workspace tests (1852 passed), rustfmt, and strict workspace
Clippy with all targets and features. PostgreSQL/Redis integration services
were not configured.

Co-authored-by: Codex <noreply@openai.com>
2026-09-07 04:47:30 +08:00
ZyyandCodex 3434ad5e72 fix(ui): polish diagnostics and account usage displays
Use the shared scrollbar for request diagnostics, improve timeline spacing
and disclosure styling, and show export loading feedback with duplicate
click protection and a minimum visible duration.

Hide reset times in metric account usage windows while retaining them in
the detail variant.

Validation: frontend typecheck and build, targeted ESLint for both files,
and browser checks for diagnostic scrolling, expansion, mobile layout,
loading feedback, and exported content.

Co-authored-by: Codex <noreply@openai.com>
2026-09-07 04:25:16 +08:00
16 changed files with 674 additions and 53 deletions
@@ -9,4 +9,5 @@ mod store;
pub use error::CredentialError;
pub use model::*;
pub use selection::*;
pub(crate) use selection::{SMART_SCORE_TOLERANCE, smart_score};
pub use store::ProviderAccountStore;
@@ -590,7 +590,7 @@ impl AccountSelector {
})
}
fn scheduling_blocker(
pub(crate) fn scheduling_blocker(
&self,
candidate: &AccountCandidate,
context: &AccountSelectionContext,
@@ -643,7 +643,7 @@ const SMART_QUOTA_WEIGHT: f64 = 0.8;
const SMART_FAILURE_WEIGHT: f64 = 1.0;
const SMART_LATENCY_WEIGHT: f64 = 0.5;
// 容忍 5 个百分点的单项负载/失败率差异,避免微小信号波动独占新会话。
const SMART_SCORE_TOLERANCE: f64 = 0.05;
pub(crate) const SMART_SCORE_TOLERANCE: f64 = 0.05;
// 首输出 10 秒时延迟得分减半;固定尺度不随其他候选账号变化。
const SMART_LATENCY_HALF_SCORE_MS: f64 = 10_000.0;
@@ -677,7 +677,7 @@ fn select_smart_candidate<'a>(
Some(ranked[index].0)
}
fn smart_score(candidate: &AccountCandidate, default_concurrency: NonZeroU32) -> f64 {
pub(crate) fn smart_score(candidate: &AccountCandidate, default_concurrency: NonZeroU32) -> f64 {
let load = 1.0 - capacity_utilization(candidate, default_concurrency).clamp(0.0, 1.0);
let quota = candidate
.signals
@@ -1,6 +1,7 @@
//! 有界、请求局部的诊断证据;不参与路由、重试或客户端协议决策。
mod capture;
mod selection;
mod stream;
mod trace;
@@ -0,0 +1,73 @@
//! 复用选号时的输入,保留有界的候选评分与阻断原因。
use serde_json::json;
use super::TraceContext;
use crate::account::{
AccountCandidate, AccountSelection, AccountSelectionContext, AccountSelector, RotationStrategy,
SMART_SCORE_TOLERANCE, smart_score,
};
// 单个 trace 事件最多 4 KiB;优先保留实际选中的账号。
const MAX_CANDIDATES: usize = 6;
impl TraceContext {
/// 记录实际选号输入与结果;只复用评分规则,不参与调度决策。
pub fn account_selection(
&self,
candidates: &[AccountCandidate],
context: &AccountSelectionContext,
selection: Option<&AccountSelection<'_>>,
) {
if !self.is_enabled() {
return;
}
let selected = selection.as_ref().map(|s| s.candidate());
let selected_id = selected.map(|c| c.account.id());
let smart = context.policy.strategy() == RotationStrategy::Smart;
let observations = selected
.into_iter()
.chain(
candidates
.iter()
.filter(|c| Some(c.account.id()) != selected_id),
)
.take(MAX_CANDIDATES)
.map(|candidate| {
let signals = &candidate.signals;
json!({
"accountId": candidate.account.id().as_str(),
"weight": candidate.account.weight().get(),
"blocker": AccountSelector.scheduling_blocker(candidate, context)
.map(|blocker| format!("{blocker:?}")),
"inFlight": signals.in_flight,
"concurrencyLimit": candidate.account
.effective_concurrency(context.policy.max_concurrent_per_account()).get(),
"quotaRemainingBasisPoints": signals.quota_remaining_rank,
"quotaResetAtUnixMs": signals.quota_reset_at.and_then(|reset|
reset.duration_since(std::time::UNIX_EPOCH).ok()
).map(|duration| duration.as_millis()),
"failureRateBasisPoints": signals.failure_rate_basis_points,
"firstOutputLatencyMs": signals.first_output_latency_ms,
"smartScore": smart.then(|| smart_score(
candidate, context.policy.max_concurrent_per_account()
)),
})
})
.collect::<Vec<_>>();
self.record(
"account.selection",
json!({
"strategy": context.policy.strategy().as_str(),
"selectedAccountId": selected_id.map(|id| id.as_str()),
"preferredAccountId": context.preferred_account.as_ref().map(|id| id.as_str()),
"preferredResult": selection.as_ref().map(|s| format!("{:?}", s.preferred())),
"roundRobinCursor": context.round_robin_cursor,
"smartScoreTolerance": smart.then_some(SMART_SCORE_TOLERANCE),
"candidateCount": candidates.len(),
"omittedCandidates": candidates.len().saturating_sub(observations.len()),
"candidates": observations,
}),
);
}
}
@@ -65,6 +65,12 @@ struct TraceEvent {
}
impl TraceContext {
/// 是否已启用请求诊断,供调用方避免构造不会保存的事实。
#[must_use]
pub const fn is_enabled(&self) -> bool {
self.state.is_some()
}
/// 一个模型请求只创建一次,所有 attempt 共享同一有界时间线。
#[must_use]
pub fn new(request_id: &str) -> Self {
@@ -306,6 +312,7 @@ impl TraceContext {
!matches!(
state.events[*index].stage,
"attempt.started"
| "account.selection"
| "account.selected"
| "attempt.failed"
| "retry.decided"
@@ -1,4 +1,5 @@
mod selection;
mod selection_diagnostics;
use std::collections::{BTreeMap, BTreeSet};
use std::num::NonZeroU32;
@@ -0,0 +1,92 @@
use gateway_core::account::{AccountSelector, RotationStrategy};
use gateway_core::diagnostics::TraceContext;
use serde_json::json;
use super::{candidate, candidate_with_concurrency, context};
#[test]
fn selection_trace_should_distinguish_load_quota_and_affinity() {
let mut candidates = [
candidate_with_concurrency("acct_74", 1, 10),
candidate_with_concurrency("acct_96", 0, 10),
];
candidates[0].signals.quota_remaining_rank = Some(2600);
candidates[1].signals.quota_remaining_rank = Some(400);
let mut context = context(RotationStrategy::Smart);
let trace = TraceContext::new("req_smart_trace");
let selection = AccountSelector.select(&candidates, &context);
trace.account_selection(&candidates, &context, selection.as_ref());
context.preferred_account = Some(candidates[1].account.id().clone());
let selection = AccountSelector.select(&candidates, &context);
trace.account_selection(&candidates, &context, selection.as_ref());
let snapshot = trace.snapshot().expect("trace");
let first = &snapshot["events"][0]["data"];
let second = &snapshot["events"][1]["data"];
assert_eq!(first["selectedAccountId"], "acct_74");
assert_eq!(first["preferredResult"], "NotRequested");
let busy = &first["candidates"][0];
assert_eq!(busy["inFlight"], 1);
assert_eq!(busy["concurrencyLimit"], 10);
assert_eq!(busy["quotaRemainingBasisPoints"], 2600);
assert!(busy["blocker"].is_null());
assert!(
busy["smartScore"].as_f64().expect("score")
> first["candidates"][1]["smartScore"]
.as_f64()
.expect("score")
);
assert_eq!(second["selectedAccountId"], "acct_96");
assert_eq!(second["preferredResult"], "Hit");
}
#[test]
fn selection_trace_should_bound_candidates_and_preserve_the_selected_account() {
let mut candidates = (0..30)
.map(|index| candidate(&format!("acct_{index}"), 0, None))
.collect::<Vec<_>>();
candidates.push(candidate("acct_best", 0, Some(10_000)));
let trace = TraceContext::new("req_many_candidates");
// 选号位于首部保留区之外,也应在长流的常规事件淘汰后保留。
for _ in 0..10 {
trace.record("test.event", json!({}));
}
let context = context(RotationStrategy::Smart);
let selection = AccountSelector.select(&candidates, &context);
trace.account_selection(&candidates, &context, selection.as_ref());
for index in 0..400 {
trace.record("test.event", json!({"index": index}));
}
let snapshot = trace.snapshot().expect("trace");
let event = snapshot["events"]
.as_array()
.expect("events")
.iter()
.find(|event| event["stage"] == "account.selection")
.expect("selection survives event eviction");
let data = &event["data"];
let recorded = data["candidates"].as_array().expect("bounded candidates");
assert!(recorded.len() < candidates.len());
assert_eq!(recorded[0]["accountId"], "acct_best");
assert!(recorded[1]["quotaRemainingBasisPoints"].is_null());
assert_eq!(
recorded.len() as u64 + data["omittedCandidates"].as_u64().expect("omitted"),
candidates.len() as u64
);
assert!(data.to_string().len() < 4096);
}
#[test]
fn selection_trace_should_explain_when_all_accounts_are_busy() {
let candidates = [candidate_with_concurrency("acct_busy", 10, 10)];
let trace = TraceContext::new("req_no_candidate");
let context = context(RotationStrategy::Smart);
let selection = AccountSelector.select(&candidates, &context);
assert!(selection.is_none());
trace.account_selection(&candidates, &context, selection.as_ref());
let snapshot = trace.snapshot().expect("trace");
let data = &snapshot["events"][0]["data"];
assert!(data["selectedAccountId"].is_null());
assert_eq!(data["candidates"][0]["blocker"], "ConcurrencyLimit");
}
@@ -236,25 +236,36 @@ pub(crate) fn quota_snapshot_from_observation(
Some(snapshot)
}
/// 调度投影缓存 TTL:`min(常规 TTL, 最近 active reset - now)`。
/// reset 到期时缓存自然失效,不固定多等一个常规 TTL 周期。
/// 缓存定期重读持久化事实,额度窗口独立按 reset 到期。
/// 正常账号依赖被动同步,空闲超过缓存 TTL 不能把尚未重置的额度抹成未知。
pub(crate) fn quota_projection_ttl(snapshot: &CodexAccountQuotaSnapshot) -> Option<Duration> {
let age = SystemTime::now()
.duration_since(snapshot.observed_at())
.unwrap_or(Duration::ZERO);
let normal_ttl = QUOTA_SCHEDULING_TTL.checked_sub(age)?;
let now = SystemTime::now();
let reset_ttl = snapshot
snapshot
.windows()
.iter()
.filter_map(|window| window.reset_at())
.map(SystemTime::from)
.filter(|reset| *reset > now)
.map(|reset| reset.duration_since(now).unwrap_or(Duration::ZERO))
.min();
let ttl = match reset_ttl {
Some(reset_ttl) if reset_ttl < normal_ttl => reset_ttl,
_ => normal_ttl,
.filter_map(|window| quota_window_ttl(window, snapshot.observed_at(), now))
.min()
// 新观测即使没有有效窗口,也要用“未知”替换此前缓存的排序信号。
.or_else(|| {
QUOTA_SCHEDULING_TTL.checked_sub(
now.duration_since(snapshot.observed_at())
.unwrap_or(Duration::ZERO),
)
})
.filter(|ttl| !ttl.is_zero())
.map(|ttl| ttl.min(QUOTA_SCHEDULING_TTL))
}
fn quota_window_ttl(
window: &CodexQuotaWindow,
observed_at: SystemTime,
now: SystemTime,
) -> Option<Duration> {
let ttl = match window.reset_at() {
Some(reset_at) => SystemTime::from(reset_at).duration_since(now).ok()?,
// 没有明确窗口边界时,不能无限延长旧观测的有效期。
None => QUOTA_SCHEDULING_TTL
.checked_sub(now.duration_since(observed_at).unwrap_or(Duration::ZERO))?,
};
(!ttl.is_zero()).then_some(ttl)
}
@@ -273,12 +284,13 @@ pub(crate) fn quota_scheduling_signals(
let mut remaining_rank: Option<u64> = None;
let mut reset_at: Option<SystemTime> = None;
for window in &snapshot.windows {
if quota_window_ttl(window, snapshot.observed_at(), now).is_none() {
continue;
}
let window_reset_at = window.reset_at().map(SystemTime::from);
let observation_expired = window_reset_at.is_some_and(|reset_at| reset_at <= now);
if !observation_expired
&& let Some(used) = window
.used_percent()
.filter(|used| used.is_finite() && (0.0..=100.0).contains(used))
if let Some(used) = window
.used_percent()
.filter(|used| used.is_finite() && (0.0..=100.0).contains(used))
{
let rank = ((100.0 - used) * 100.0).round() as u64;
remaining_rank = Some(remaining_rank.map_or(rank, |current| current.min(rank)));
@@ -512,7 +512,12 @@ impl CodexCredentialSelector {
account_scope: request.attempt.account_scope().cloned(),
};
let capacity = AccountSelector.capacity_snapshot(&candidates, &context);
let Some(selection) = AccountSelector.select(&candidates, &context) else {
let selection = AccountSelector.select(&candidates, &context);
request
.attempt
.trace()
.account_selection(&candidates, &context, selection.as_ref());
let Some(selection) = selection else {
return match shortest_retry {
Some(retry_after) => Err(CredentialSelectionError::CapacityUnavailable {
retry_after: Some(retry_after),
@@ -1,9 +1,12 @@
//! 核心 Generate operation 到 Codex Responses wire request 的严格编码。
use std::io;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use gateway_core::operation::GenerateRequest;
use gateway_protocol::openai::WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use serde::Serialize as _;
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
@@ -447,7 +450,8 @@ pub(crate) fn scope_turn_metadata(
changed |= metadata.remove(*key).is_some();
}
}
if !cross_account
if raw.is_ascii()
&& !cross_account
&& !changed
&& !INSTALLATION_ID_KEYS
.iter()
@@ -460,7 +464,40 @@ pub(crate) fn scope_turn_metadata(
metadata.insert((*key).to_owned(), Value::String(installation_id.to_owned()));
}
}
serde_json::to_string(&metadata).ok()
// Codex 的 turn metadata 同时承载于 HTTP header 与 WS client_metadata。
// 改写安装 ID 后仍须保持官方 to_ascii_json_string 的编码合同;普通
// to_string 会把中文工作区路径还原成 UTF-8,触发上游 WS metadata 后 Close 1000。
let mut bytes = Vec::new();
metadata
.serialize(&mut serde_json::Serializer::with_formatter(
&mut bytes,
AsciiTurnMetadataFormatter,
))
.ok()?;
String::from_utf8(bytes).ok()
}
struct AsciiTurnMetadataFormatter;
impl serde_json::ser::Formatter for AsciiTurnMetadataFormatter {
fn write_string_fragment<W: ?Sized + io::Write>(
&mut self,
writer: &mut W,
fragment: &str,
) -> io::Result<()> {
let mut start = 0;
for (index, ch) in fragment.char_indices() {
if ch.is_ascii() {
continue;
}
writer.write_all(&fragment.as_bytes()[start..index])?;
for unit in ch.encode_utf16(&mut [0; 2]) {
write!(writer, "\\u{unit:04x}")?;
}
start = index + ch.len_utf8();
}
writer.write_all(&fragment.as_bytes()[start..])
}
}
fn replace_existing_body_string(
@@ -1,6 +1,7 @@
//! OpenAI 额度事实边界与展示快照回归。
mod recovery;
mod scheduling;
mod slots;
mod snapshot;
@@ -0,0 +1,277 @@
//! 持久化额度的有效期与短暂并发占用共同参与选号。
use std::collections::BTreeSet;
use std::num::NonZeroU32;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use gateway_core::account::{
AccountCandidate, AccountConcurrencyLimit, AccountEligibilityPolicy, AccountRuntimeSignals,
AccountSelectionContext, AccountSelectionPolicy, AccountSelector, AccountWeight,
OpaqueProviderData, ProviderAccount, ProviderAccountStore as _, QuotaAccessState,
QuotaEvidence, QuotaObservation, QuotaState, RotationStrategy,
};
use serde_json::{Value, json};
use super::{MemoryAccountStore, create_account, quota_service};
async fn persist_snapshot(
store: &Arc<MemoryAccountStore>,
id: &str,
observed_at: SystemTime,
rate_limit: Value,
) -> ProviderAccount {
create_account(store, id).await;
let account = store.account(id).expect("created account");
store
.compare_and_swap_quota(QuotaObservation {
account_id: account.id().clone(),
expected_revision: account.revision(),
quota: OpaqueProviderData::new(
json!({"rate_limit": rate_limit})
.as_object()
.expect("quota object")
.clone(),
),
observed_at,
state: QuotaState::allowed(observed_at),
})
.await
.expect("persist quota");
store.account(id).expect("persisted account")
}
fn timestamp(time: SystemTime) -> u64 {
time.duration_since(UNIX_EPOCH).expect("epoch").as_secs()
}
#[tokio::test]
async fn old_weekly_quota_should_outweigh_one_in_flight_title_request() {
let store = Arc::new(MemoryAccountStore::default());
let now = SystemTime::now();
let reset_at = timestamp(now + Duration::from_secs(6 * 3600));
let mut accounts = Vec::new();
for (id, used_percent, age_minutes) in [("acct_74", 74, 41), ("acct_96", 96, 16)] {
accounts.push(
persist_snapshot(
&store,
id,
now - Duration::from_secs(age_minutes * 60),
json!({
"allowed": true,
"primary_window": {
"used_percent": used_percent,
"reset_at": reset_at,
"limit_window_seconds": 604_800
}
}),
)
.await
.with_scheduling(
Some(AccountConcurrencyLimit::new(10).expect("concurrency")),
AccountWeight::DEFAULT,
),
);
}
let service = quota_service(&store);
service.prepare_scheduling(&accounts).await;
let candidates = accounts
.into_iter()
.map(|account| AccountCandidate {
signals: AccountRuntimeSignals {
in_flight: u32::from(account.id().as_str() == "acct_74"),
last_started_at: None,
quota_reset_at: None,
quota_remaining_rank: None,
rate_limited_until: None,
failure_rate_basis_points: None,
first_output_latency_ms: None,
}
.with_provider_quota(service.scheduling_signals(&account)),
account,
})
.collect::<Vec<_>>();
for cursor in 0..20 {
let context = AccountSelectionContext {
policy: AccountSelectionPolicy::new(
RotationStrategy::Smart,
NonZeroU32::new(3).expect("default concurrency"),
Duration::ZERO,
),
now,
excluded_accounts: BTreeSet::new(),
preferred_account: None,
preferred_account_overrides_weight: true,
round_robin_cursor: cursor,
eligibility: AccountEligibilityPolicy::Enforce,
account_scope: None,
};
let selected = AccountSelector
.select(&candidates, &context)
.expect("available account");
assert_eq!(selected.candidate().account.id().as_str(), "acct_74");
}
}
#[tokio::test]
async fn old_quota_should_drop_reset_short_window_and_keep_weekly_window() {
let store = Arc::new(MemoryAccountStore::default());
let now = SystemTime::now();
let weekly_reset = now + Duration::from_secs(6 * 3600);
let account = persist_snapshot(
&store,
"acct_mixed_resets",
now - Duration::from_secs(41 * 60),
json!({
"primary_window": {
"used_percent": 99,
"reset_at": timestamp(now - Duration::from_secs(60)),
"limit_window_seconds": 18_000
},
"secondary_window": {
"used_percent": 74,
"reset_at": timestamp(weekly_reset),
"limit_window_seconds": 604_800
}
}),
)
.await;
let service = quota_service(&store);
service
.read_account(account.id())
.await
.expect("read quota");
let signals = service.scheduling_signals(&account).expect("weekly quota");
assert_eq!(
(signals.remaining_rank(), signals.reset_at().map(timestamp)),
(Some(2600), Some(timestamp(weekly_reset)))
);
}
#[tokio::test]
async fn undated_quota_should_expire_independently_of_dated_weekly_quota() {
for (age_minutes, expected_remaining) in [(1, 100), (41, 2600)] {
let store = Arc::new(MemoryAccountStore::default());
let now = SystemTime::now();
let account = persist_snapshot(
&store,
"acct_undated_window",
now - Duration::from_secs(age_minutes * 60),
json!({
"primary_window": {"used_percent": 99},
"secondary_window": {
"used_percent": 74,
"reset_at": timestamp(now + Duration::from_secs(6 * 3600)),
"limit_window_seconds": 604_800
}
}),
)
.await;
let service = quota_service(&store);
service
.prepare_scheduling(std::slice::from_ref(&account))
.await;
assert_eq!(
service
.scheduling_signals(&account)
.and_then(|s| s.remaining_rank()),
Some(expected_remaining),
"observation age: {age_minutes} minutes"
);
}
}
#[tokio::test]
async fn old_undated_quota_should_remain_unknown_after_cache_hydration() {
let store = Arc::new(MemoryAccountStore::default());
let account = persist_snapshot(
&store,
"acct_undated_only",
SystemTime::now() - Duration::from_secs(41 * 60),
json!({"primary_window": {"used_percent": 74}}),
)
.await;
let service = quota_service(&store);
for _ in 0..2 {
service
.prepare_scheduling(std::slice::from_ref(&account))
.await;
assert_eq!(service.scheduling_signals(&account), None);
}
assert_eq!(store.quota_reads(), 1);
}
#[tokio::test]
async fn fresh_reset_observation_should_clear_previous_scheduling_signals() {
let store = Arc::new(MemoryAccountStore::default());
let now = SystemTime::now();
let account = persist_snapshot(
&store,
"acct_fresh_reset",
now,
json!({"primary_window": {
"used_percent": 74,
"reset_at": timestamp(now + Duration::from_secs(3600))
}}),
)
.await;
let service = quota_service(&store);
service
.prepare_scheduling(std::slice::from_ref(&account))
.await;
assert_eq!(
service
.scheduling_signals(&account)
.and_then(|s| s.remaining_rank()),
Some(2600)
);
let headers = vec![
("x-codex-active-limit".to_owned(), "codex".to_owned()),
("x-codex-primary-used-percent".to_owned(), "74".to_owned()),
(
"x-codex-primary-reset-at".to_owned(),
timestamp(now - Duration::from_secs(60)).to_string(),
),
];
assert!(
service
.synchronize_passive_headers(&account, &headers)
.await
.expect("reset observation")
);
assert_eq!(service.scheduling_signals(&account), None);
}
#[tokio::test]
async fn reset_window_should_not_clear_confirmed_exhaustion() {
let store = Arc::new(MemoryAccountStore::default());
let now = SystemTime::now();
let reset_at = now - Duration::from_secs(60);
let account = persist_snapshot(
&store,
"acct_exhausted_reset",
now - Duration::from_secs(41 * 60),
json!({"primary_window": {"used_percent": 100, "reset_at": timestamp(reset_at)}}),
)
.await;
super::persist_quota_state(
&store,
&account,
QuotaState::exhausted(QuotaEvidence::ProviderDenied, now, Some(reset_at)),
)
.await;
let service = quota_service(&store);
service
.prepare_scheduling(std::slice::from_ref(&account))
.await;
assert_eq!(service.scheduling_signals(&account), None);
assert_eq!(
store
.account("acct_exhausted_reset")
.expect("account")
.quota()
.access(),
QuotaAccessState::Exhausted
);
}
@@ -2942,6 +2942,108 @@ async fn cross_account_scope_sanitizes_only_known_turn_metadata_fields() {
);
}
#[tokio::test]
async fn websocket_account_scoping_preserves_ascii_turn_metadata_and_unicode_input() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_scope_same").await;
let listener = TcpListener::bind("127.0.0.1:0").await.expect("listener");
let base_url = format!("http://{}", listener.local_addr().expect("address"));
let server = tokio::spawn(async move {
let (socket, _) = listener.accept().await.expect("accept websocket");
let mut socket = accept_codex_test_websocket(socket).await;
let message = socket.next().await.expect("request").expect("valid frame");
let body: Value = serde_json::from_str(message.to_text().expect("text")).expect("JSON");
socket.send(Message::Text(json!({
"type": "response.completed",
"response": {"id": "resp_ascii_metadata", "model": "gpt-5.4", "status": "completed", "output": []}
}).to_string().into())).await.expect("complete response");
body
});
// Official Codex keeps embedded turn metadata ASCII even for Unicode workspaces.
let raw = r#"{"installation_id":"client-installation","workspaces":{"C:\\Users\\\u9879\u76ee\\\ud83d\ude80":{"label":"caf\u00e9","literal":"\\u4e2d","quoted":"\"line\n"}}}"#;
let input = json!([{"role": "user", "content": "中文正文 🚀"}]);
let payload = ProtocolPayload::json_object(
"openai",
Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), input.clone()),
(
"client_metadata".to_owned(),
json!({"x-codex-turn-metadata": raw}),
),
]),
)
.expect("payload")
.with_context(Map::from_iter([("turn_metadata".to_owned(), json!(raw))]));
let mut stream = provider_with_base_url(&store, base_url)
.execute(
planned_request(
"openai",
Operation::Generate(GenerateRequest::from_protocol_payload(payload)),
),
context_with_state_owner("req_ascii_metadata", "acct_scope_same"),
)
.await
.expect("provider stream");
while let Some(event) = stream.next().await {
event.expect("successful websocket response");
}
let body = server.await.expect("server");
let encoded = body
.pointer("/client_metadata/x-codex-turn-metadata")
.and_then(Value::as_str)
.expect("turn metadata");
assert!(encoded.is_ascii(), "embedded header JSON must remain ASCII");
let mut expected: Value = serde_json::from_str(raw).expect("original metadata");
expected["installation_id"] = body["client_metadata"]["installation_id"].clone();
assert_eq!(
serde_json::from_str::<Value>(encoded).expect("metadata JSON"),
expected
);
assert_eq!(body["input"], input);
}
#[tokio::test]
async fn http_account_scoping_keeps_unicode_metadata_ascii_in_headers_and_body() {
for owner in ["acct_scope_same", "acct_scope_old"] {
let raw = r#"{"installation_id":"client-installation","workspaces":{"/tmp/\u4e2d\u6587/\ud83d\ude80":{"label":"caf\u00e9"}}}"#;
let request = capture_scoped_http_request(
"req_ascii_http",
"acct_scope_same",
owner,
Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), json!("中文正文 🚀")),
("turnMetadata".to_owned(), json!(raw)),
(
"client_metadata".to_owned(),
json!({"x-codex-turn-metadata": raw}),
),
]),
Map::from_iter([("turn_metadata".to_owned(), json!(raw))]),
)
.await;
let body = captured_request_body(&request);
let headers = captured_header_values(&request, "x-codex-turn-metadata");
assert_eq!(headers.len(), 1);
let mut expected: Value = serde_json::from_str(raw).expect("original metadata");
expected["installation_id"] = body["client_metadata"]["installation_id"].clone();
for encoded in [
std::str::from_utf8(&headers[0]).expect("UTF-8 header"),
body["turnMetadata"].as_str().expect("body turn metadata"),
body["client_metadata"]["x-codex-turn-metadata"]
.as_str()
.expect("client metadata"),
] {
assert!(encoded.is_ascii(), "scoped header JSON must remain ASCII");
assert_eq!(
serde_json::from_str::<Value>(encoded).expect("metadata JSON"),
expected
);
}
}
}
#[tokio::test]
async fn cross_account_continuation_should_require_client_replay_without_an_upstream_probe() {
let store = Arc::new(MemoryAccountStore::default());
@@ -63,7 +63,7 @@ const variantDefinitions: Record<
minimumBarWidth: '6px',
compactValues: false,
labelTooltip: true,
showResetTime: true,
showResetTime: false,
},
compact: {
rootClass: 'flex min-w-0 flex-col',
@@ -1,12 +1,15 @@
<script setup lang="ts">
import { computed } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { useDownload } from '@/composables/useDownload'
import { useRequestDiagnostics } from '../composables/useRequestDiagnostics'
import UsageDetailCodePanel from './UsageDetailCodePanel.vue'
const props = defineProps<{ requestId: string }>()
const { downloadJson } = useDownload()
const { loading: exporting, run: runExport } = useAsyncAction()
const { selectedId, detail, loading, error, refresh } = useRequestDiagnostics(() => props.requestId)
const trace = computed(() => detail.value?.trace)
const events = computed(() => (trace.value?.events ?? []).map(event => ({
@@ -15,7 +18,7 @@ const events = computed(() => (trace.value?.events ?? []).map(event => ({
})))
function download() {
if (!detail.value)
if (!detail.value || exporting.value)
return
const record = detail.value
const bundle = {
@@ -40,7 +43,10 @@ function download() {
attemptsComplete: record.attemptsComplete,
relatedRequests: record.relatedRequests,
}
return downloadJson(bundle, `diagnostics-${record.requestId.replace(/[^\w-]/g, '_')}.json`)
return runExport(
() => downloadJson(bundle, `diagnostics-${record.requestId.replace(/[^\w-]/g, '_')}.json`),
{ errorText: '导出诊断包失败', minimumMs: 400 },
)
}
</script>
@@ -57,12 +63,12 @@ function download() {
<BaseButton size="sm" :disabled="loading" @click="refresh">
刷新
</BaseButton>
<BaseButton size="sm" :disabled="!detail" @click="download">
<BaseButton size="sm" :loading="exporting" :disabled="!detail" @click="download">
导出诊断包
</BaseButton>
</div>
</div>
<p class="break-all font-mono text-cp-xs text-cp-text-secondary">
<p class="mt-1 mb-3 break-all font-mono text-cp-xs leading-relaxed text-cp-text-secondary">
{{ selectedId }}
</p>
<p v-if="loading" role="status" class="text-cp-sm text-cp-text-secondary">
@@ -81,27 +87,33 @@ function download() {
这条记录没有保存诊断时间线。旧记录无法补回当时未采集的事件。
</p>
<template v-else>
<p class="text-cp-xs text-cp-text-secondary">
已观测 {{ trace.totalEvents }} 个阶段或事件,展示 {{ events.length }} 条摘要。连续增量合并计数;正文和敏感字段仅保留摘要。
</p>
<p v-if="trace.droppedEvents" role="status" class="text-cp-xs text-cp-warning-text">
达到保存上限,{{ trace.droppedEvents }} 个事件已被淘汰;保留请求开头与最近事件。
</p>
<ol class="m-0 grid max-h-128 list-none gap-2 overflow-auto p-0">
<li v-for="event in events" :key="event.sequence" class="min-w-0 rounded-cp bg-cp-bg-container px-3 py-2">
<details>
<summary class="cursor-pointer break-all text-cp-xs leading-relaxed">
<span class="font-mono text-cp-text-secondary">+{{ event.elapsedMs }} ms · #{{ event.sequence }}</span>
<span class="mx-2 font-mono">{{ event.stage }}</span>
<span v-if="event.attemptIndex">尝试 {{ event.attemptIndex }}</span>
<span v-if="event.exchangeId"> · 交换 {{ event.exchangeId }}</span>
<span v-if="event.count > 1"> · {{ event.count }} 次(至 +{{ event.lastElapsedMs }} ms)</span>
</summary>
<UsageDetailCodePanel class="mt-2" title="诊断事实" :content="event.content" max-height="280px" />
</details>
</li>
</ol>
<p class="mb-0 text-cp-xs text-cp-text-secondary">
<div class="mb-3 grid gap-1 text-cp-xs leading-relaxed">
<p class="m-0 text-cp-text-secondary">
已观测 {{ trace.totalEvents }} 个阶段或事件,展示 {{ events.length }} 条摘要。连续增量合并计数;正文和敏感字段仅保留摘要。
</p>
<p v-if="trace.droppedEvents" role="status" class="m-0 text-cp-warning-text">
达到保存上限,{{ trace.droppedEvents }} 个事件已被淘汰;保留请求开头与最近事件。
</p>
</div>
<BaseScrollbar max-height="32rem">
<ol class="m-0 grid list-none gap-2 p-0 pr-3">
<li v-for="event in events" :key="event.sequence" class="min-w-0 rounded-cp bg-cp-bg-container">
<details>
<summary class="cursor-pointer rounded-cp px-3 py-2.5 break-all text-cp-xs leading-relaxed hover:bg-cp-fill-quaternary focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-cp-primary">
<span class="font-mono text-cp-text-secondary">+{{ event.elapsedMs }} ms · #{{ event.sequence }}</span>
<span class="mx-2 font-mono text-cp-text">{{ event.stage }}</span>
<span v-if="event.attemptIndex" class="text-cp-text-secondary">尝试 {{ event.attemptIndex }}</span>
<span v-if="event.exchangeId" class="text-cp-text-secondary"> · 交换 {{ event.exchangeId }}</span>
<span v-if="event.count > 1" class="text-cp-text-secondary"> · {{ event.count }} 次(至 +{{ event.lastElapsedMs }} ms)</span>
</summary>
<div class="mx-3 border-t border-cp-border-secondary py-3">
<UsageDetailCodePanel title="诊断事实" :content="event.content" max-height="280px" />
</div>
</details>
</li>
</ol>
</BaseScrollbar>
<p class="mt-3 mb-0 text-cp-xs leading-relaxed text-cp-text-secondary">
时间线保存至执行结束。客户端最终写入、进程退出等后续事实,可用此请求 ID 检索服务器日志。
</p>
</template>
+1 -1
View File
@@ -1 +1 @@
version: "3.3.24"
version: "3.3.26"