mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2922b410e | ||
|
|
d303efc6f7 | ||
|
|
dd17332c31 | ||
|
|
47d08786ad | ||
|
|
3434ad5e72 |
@@ -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 @@
|
||||
version: "3.3.24"
|
||||
version: "3.3.26"
|
||||
|
||||
Reference in New Issue
Block a user