mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix(codex): align upstream contracts and transport behavior (#352)
Co-authored-by: Zyy <zyycn@users.noreply.github.com> Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
committed by
GitHub
co-authored by
Zyy
Codex
parent
f8439eb4b9
commit
78ba7e412e
Generated
+4
-2
@@ -1858,6 +1858,7 @@ dependencies = [
|
||||
name = "gateway-protocol"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"httpdate",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1978,9 +1979,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.16"
|
||||
version = "0.4.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
|
||||
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -3234,6 +3235,7 @@ dependencies = [
|
||||
"gateway-core",
|
||||
"gateway-protocol",
|
||||
"getrandom 0.4.3",
|
||||
"h2",
|
||||
"hex",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
|
||||
@@ -147,9 +147,9 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
/// 将原始套餐值投影为展示名称;默认保留未知 Provider 的原始名称
|
||||
/// 提供最终套餐展示名称;未覆盖时采用通用的大驼峰格式
|
||||
fn plan_type_display(&self, plan_type: &str) -> String {
|
||||
plan_type.to_owned()
|
||||
default_plan_type_display(plan_type)
|
||||
}
|
||||
|
||||
/// 账号已经由控制面提交为不可调度状态,释放 Provider 持有的账号级运行时资源
|
||||
@@ -354,7 +354,7 @@ impl ProviderAdminRegistry {
|
||||
self.plan_type_display(provider_kind, plan_type.as_deref())
|
||||
}
|
||||
|
||||
/// 账号页和 Dashboard 共用的大驼峰套餐展示名称,不修改原始套餐值
|
||||
/// 账号页和 Dashboard 共用 Provider 的最终展示名称,不改写官方拼写或原始套餐值
|
||||
pub(crate) fn plan_type_display(
|
||||
&self,
|
||||
provider_kind: &str,
|
||||
@@ -364,12 +364,10 @@ impl ProviderAdminRegistry {
|
||||
let provider = ProviderKind::new(provider_kind.to_owned())
|
||||
.ok()
|
||||
.and_then(|kind| self.require(&kind).ok());
|
||||
let display = provider.map_or_else(
|
||||
|| plan_type.to_owned(),
|
||||
Some(provider.map_or_else(
|
||||
|| default_plan_type_display(plan_type),
|
||||
|provider| provider.plan_type_display(plan_type),
|
||||
);
|
||||
// 所有 Provider 及未知套餐统一排版,保留 Plus 后缀的含义
|
||||
Some(display.replace('+', " Plus ").to_upper_camel_case())
|
||||
))
|
||||
}
|
||||
|
||||
/// 返回所有已注册 Provider 的 Dashboard 上游身份画像
|
||||
@@ -398,3 +396,7 @@ impl ProviderAdminRegistry {
|
||||
self.require(provider_kind)?.calculated_billing(input)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_plan_type_display(plan_type: &str) -> String {
|
||||
plan_type.replace('+', " Plus ").to_upper_camel_case()
|
||||
}
|
||||
|
||||
@@ -305,7 +305,12 @@ impl ProviderAdmin for FakeProviderAdmin {
|
||||
}
|
||||
|
||||
fn plan_type_display(&self, plan_type: &str) -> String {
|
||||
format!("{} display: {plan_type}", self.kind)
|
||||
match plan_type {
|
||||
"prolite" => "ProLite".to_owned(),
|
||||
"pro" => "Pro".to_owned(),
|
||||
"promax" => "ProMax".to_owned(),
|
||||
_ => format!("{} display: {plan_type}", self.kind),
|
||||
}
|
||||
}
|
||||
|
||||
async fn account_unavailable(&self, _: &ProviderAccountId) {
|
||||
@@ -1911,7 +1916,7 @@ async fn accounts_list_should_return_complete_directory_semantics() {
|
||||
),
|
||||
(
|
||||
Some(" self_serve_business_prolite "),
|
||||
Some("OpenaiDisplaySelfServeBusinessProlite"),
|
||||
Some("openai display: self_serve_business_prolite"),
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1931,25 +1936,33 @@ async fn accounts_list_should_return_complete_directory_semantics() {
|
||||
#[tokio::test]
|
||||
async fn accounts_should_fill_missing_plan_from_quota_without_overriding_known_subtypes() {
|
||||
for (stored_plan, quota_plan, expected, expected_display) in [
|
||||
(None, Some("free"), Some("free"), Some("OpenaiDisplayFree")),
|
||||
(
|
||||
None,
|
||||
Some("free"),
|
||||
Some("free"),
|
||||
Some("openai display: free"),
|
||||
),
|
||||
(
|
||||
Some(" "),
|
||||
Some("free"),
|
||||
Some("free"),
|
||||
Some("OpenaiDisplayFree"),
|
||||
Some("openai display: free"),
|
||||
),
|
||||
(
|
||||
Some("unknown"),
|
||||
Some("free"),
|
||||
Some("free"),
|
||||
Some("OpenaiDisplayFree"),
|
||||
Some("openai display: free"),
|
||||
),
|
||||
(
|
||||
Some("self_serve_business_prolite"),
|
||||
Some("team"),
|
||||
Some("self_serve_business_prolite"),
|
||||
Some("OpenaiDisplaySelfServeBusinessProlite"),
|
||||
Some("openai display: self_serve_business_prolite"),
|
||||
),
|
||||
(Some("prolite"), None, Some("prolite"), Some("ProLite")),
|
||||
(Some("pro"), None, Some("pro"), Some("Pro")),
|
||||
(Some("promax"), None, Some("promax"), Some("ProMax")),
|
||||
(None, None, None, None),
|
||||
] {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
|
||||
@@ -17,12 +17,13 @@ use gateway_admin::{
|
||||
model::{
|
||||
MutationContext, PageSize, Revision,
|
||||
observability::{
|
||||
AccountPoolMetrics, AttemptMetrics, CostCoverage, CurrencyCost, DashboardObservation,
|
||||
DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation,
|
||||
DiagnosticsObservation, Granularity, HealthStatus, LatencyPercentiles, OpsErrorPage,
|
||||
OpsErrorQuery, PercentileMilliseconds, RequestMetricPoint, RequestMetrics, TimeRange,
|
||||
TrendKind, UsageBilling, UsageCalculatedBillingFact, UsageDetail, UsageFilter,
|
||||
UsageListRecord, UsageOverview, UsagePage, UsageQuery,
|
||||
AccountPoolMetrics, AttemptMetrics, CostCoverage, CurrencyCost, DashboardAccountUsage,
|
||||
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension,
|
||||
DiagnosticObservation, DiagnosticsObservation, Granularity, HealthStatus,
|
||||
LatencyPercentiles, OpsErrorPage, OpsErrorQuery, PercentileMilliseconds,
|
||||
RequestMetricPoint, RequestMetrics, TimeRange, TrendKind, UsageBilling,
|
||||
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageListRecord, UsageOverview,
|
||||
UsagePage, UsageQuery,
|
||||
},
|
||||
settings::{
|
||||
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
|
||||
@@ -721,6 +722,65 @@ async fn usage_insights_should_reject_partial_costs_when_billing_stream_fails()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_should_preserve_provider_plan_names() {
|
||||
let now = Utc::now();
|
||||
let range = observation_range(now);
|
||||
let store = Arc::new(FixtureObservabilityStore::new(range));
|
||||
let plans = [("prolite", "ProLite"), ("pro", "Pro"), ("promax", "ProMax")];
|
||||
*store.account_usage.lock().unwrap() = plans
|
||||
.iter()
|
||||
.map(|(plan, _)| DashboardAccountUsage {
|
||||
account_id: format!("account-{plan}"),
|
||||
provider_kind: "openai".to_owned(),
|
||||
authentication_kind: "oauth".to_owned(),
|
||||
name: (*plan).to_owned(),
|
||||
email: None,
|
||||
plan_type: Some((*plan).to_owned()),
|
||||
plan_type_display: None,
|
||||
request_count: 0,
|
||||
success_count: 0,
|
||||
input_tokens: None,
|
||||
output_tokens: None,
|
||||
cached_tokens: None,
|
||||
cache_write_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
image_input_tokens: None,
|
||||
image_output_tokens: None,
|
||||
image_request_count: 0,
|
||||
image_request_failed_count: 0,
|
||||
total_tokens: None,
|
||||
cost_coverage: CostCoverage::default(),
|
||||
costs: Vec::new(),
|
||||
last_used_at: None,
|
||||
request_buckets: Vec::new(),
|
||||
quota_used_percent: None,
|
||||
quota_window: None,
|
||||
models: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
let provider =
|
||||
super::accounts::FakeProviderAdmin::new("openai", Arc::new(Mutex::new(Vec::new())));
|
||||
let services = super::AdminHarness::new()
|
||||
.observability(store)
|
||||
.settings(Arc::new(FixtureSettingsStore {
|
||||
max_concurrent_per_account: 1,
|
||||
}))
|
||||
.provider(provider)
|
||||
.build()
|
||||
.await;
|
||||
let result = services
|
||||
.observability()
|
||||
.dashboard_summary(range, TrendKind::Usage)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.observation.account_usage.len(), plans.len());
|
||||
for (account, (raw, display)) in result.observation.account_usage.iter().zip(plans) {
|
||||
assert_eq!(account.plan_type.as_deref(), Some(raw));
|
||||
assert_eq!(account.plan_type_display.as_deref(), Some(display));
|
||||
}
|
||||
}
|
||||
|
||||
struct FixtureObservabilityStore {
|
||||
trend: Mutex<Vec<RequestMetricPoint>>,
|
||||
overview: Mutex<UsageOverview>,
|
||||
@@ -731,6 +791,7 @@ struct FixtureObservabilityStore {
|
||||
summary_observed_at: Mutex<Option<DateTime<Utc>>>,
|
||||
slots_observed_at: Mutex<Option<DateTime<Utc>>>,
|
||||
usage_records: Mutex<Vec<UsageListRecord>>,
|
||||
account_usage: Mutex<Vec<DashboardAccountUsage>>,
|
||||
dashboard_delay: Mutex<StdDuration>,
|
||||
dashboard_summary_calls: AtomicUsize,
|
||||
}
|
||||
@@ -752,6 +813,7 @@ impl FixtureObservabilityStore {
|
||||
summary_observed_at: Mutex::new(None),
|
||||
slots_observed_at: Mutex::new(None),
|
||||
usage_records: Mutex::new(Vec::new()),
|
||||
account_usage: Mutex::new(Vec::new()),
|
||||
dashboard_delay: Mutex::new(StdDuration::ZERO),
|
||||
dashboard_summary_calls: AtomicUsize::new(0),
|
||||
}
|
||||
@@ -824,7 +886,7 @@ impl ObservabilityStore for FixtureObservabilityStore {
|
||||
totals: Default::default(),
|
||||
provider_accounts: AccountPoolMetrics::default(),
|
||||
trend: self.trend.lock().expect("trend").clone(),
|
||||
account_usage: Vec::new(),
|
||||
account_usage: self.account_usage.lock().expect("account usage").clone(),
|
||||
recent_requests: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -232,8 +232,8 @@ async fn linked_accounts_share_plan_resolution_and_only_read_cached_quota() {
|
||||
assert_eq!(
|
||||
result.items[0].plan_type_display.as_deref(),
|
||||
match expected {
|
||||
Some("free") => Some("OpenaiDisplayFree"),
|
||||
Some("plus") => Some("OpenaiDisplayPlus"),
|
||||
Some("free") => Some("openai display: free"),
|
||||
Some("plus") => Some("openai display: plus"),
|
||||
_ => None,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1510,7 +1510,10 @@ where
|
||||
&& !self.delivery_pending
|
||||
&& attempt_send_state != UpstreamSendState::Ambiguous =>
|
||||
{
|
||||
Some((AttemptTransport::Fallback, Duration::ZERO))
|
||||
Some((
|
||||
AttemptTransport::Fallback,
|
||||
error.retry_after().unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
@@ -1536,7 +1539,10 @@ where
|
||||
.or_default();
|
||||
if *retries < max_retries.get() {
|
||||
let multiplier = 1_u32.checked_shl(*retries).unwrap_or(u32::MAX);
|
||||
let delay = initial_delay.saturating_mul(multiplier).min(max_delay);
|
||||
// 服务器建议优先于本地退避,不能被本地上限缩短或再次指数放大
|
||||
let delay = error
|
||||
.retry_after()
|
||||
.unwrap_or_else(|| initial_delay.saturating_mul(multiplier).min(max_delay));
|
||||
*retries = retries.saturating_add(1);
|
||||
Some(delay)
|
||||
} else {
|
||||
@@ -1555,11 +1561,12 @@ where
|
||||
&& !self
|
||||
.credential_recovery_attempted_accounts
|
||||
.contains(current.metadata.provider_account_id());
|
||||
let retryable = continuation_retry
|
||||
|| same_account_retry
|
||||
|| ordinary_retry
|
||||
|| account_rotation_retry
|
||||
|| transport_recovery.is_some();
|
||||
let retryable = !error.retry_is_prohibited()
|
||||
&& (continuation_retry
|
||||
|| same_account_retry
|
||||
|| ordinary_retry
|
||||
|| account_rotation_retry
|
||||
|| transport_recovery.is_some());
|
||||
|
||||
let retryable = match self
|
||||
.apply_retry_policy(super::policy::RetryFacts {
|
||||
|
||||
@@ -444,6 +444,7 @@ pub struct ProviderError {
|
||||
continuation_recovery_disposition: Option<ContinuationRecoveryDisposition>,
|
||||
failure_observation: Option<Box<ProviderErrorFailureObservation>>,
|
||||
replay_safe: bool,
|
||||
retry_prohibited: bool,
|
||||
pre_delivery_retry: Option<Box<PreDeliveryRetry>>,
|
||||
credential_recovery_required: bool,
|
||||
retry_same_account: bool,
|
||||
@@ -484,6 +485,7 @@ impl ProviderError {
|
||||
continuation_recovery_disposition: None,
|
||||
failure_observation: None,
|
||||
replay_safe: false,
|
||||
retry_prohibited: false,
|
||||
pre_delivery_retry: None,
|
||||
credential_recovery_required: false,
|
||||
retry_same_account: false,
|
||||
@@ -581,6 +583,19 @@ impl ProviderError {
|
||||
self
|
||||
}
|
||||
|
||||
/// 标记必须交付客户端的终止拒绝,即使 payload 未发送也不自动重试
|
||||
#[must_use]
|
||||
pub const fn with_retry_prohibited(mut self) -> Self {
|
||||
self.retry_prohibited = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// 返回 Provider 是否明确禁止本请求的重试与传输回退
|
||||
#[must_use]
|
||||
pub const fn retry_is_prohibited(&self) -> bool {
|
||||
self.retry_prohibited
|
||||
}
|
||||
|
||||
/// 允许 Core 仅在客户端尚未收到任何事件时执行一次受预算约束的换号恢复
|
||||
///
|
||||
/// 该标记不证明上游未执行,也不等价于 [`Self::with_replay_safe`];它只表达
|
||||
@@ -880,6 +895,7 @@ impl ProviderError {
|
||||
continuation_recovery_disposition: self.continuation_recovery_disposition,
|
||||
failure_observation: self.failure_observation.clone(),
|
||||
replay_safe: self.replay_safe,
|
||||
retry_prohibited: self.retry_prohibited,
|
||||
pre_delivery_retry: self.pre_delivery_retry.clone(),
|
||||
credential_recovery_required: self.credential_recovery_required,
|
||||
retry_same_account: self.retry_same_account,
|
||||
@@ -909,6 +925,7 @@ impl fmt::Debug for ProviderError {
|
||||
&self.upstream_request_id().map(|_| "<classified-safe>"),
|
||||
)
|
||||
.field("retry_after", &self.retry_after)
|
||||
.field("retry_prohibited", &self.retry_prohibited)
|
||||
.field("continuation_failure", &self.continuation_failure)
|
||||
.field(
|
||||
"continuation_recovery_disposition",
|
||||
|
||||
@@ -3438,6 +3438,124 @@ fn transient_rejection() -> ProviderError {
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_retry_advice_should_override_local_backoff_and_transport_fallback() {
|
||||
let advised_delay = Duration::from_millis(120);
|
||||
for rejection in [
|
||||
transient_rejection().with_transient_retry(
|
||||
NonZeroU32::MIN,
|
||||
Duration::from_millis(1),
|
||||
Duration::from_millis(1),
|
||||
),
|
||||
ProviderError::new(ProviderErrorKind::Transport, UpstreamSendState::NotSent)
|
||||
.with_pre_delivery_transport_fallback(),
|
||||
] {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let (coordinator, _, provider) = coordinator(vec![
|
||||
Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(rejection.with_retry_after(advised_delay))],
|
||||
},
|
||||
Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: complete_stream(None),
|
||||
},
|
||||
]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let started = std::time::Instant::now();
|
||||
let mut collection = Box::pin(session.collect_uncommitted());
|
||||
assert!(block_on(async { futures::poll!(&mut collection) }).is_pending());
|
||||
assert_eq!(provider.contexts.lock().unwrap().len(), 1);
|
||||
block_on(collection).unwrap();
|
||||
assert!(started.elapsed() >= advised_delay);
|
||||
let contexts = provider.contexts.lock().unwrap();
|
||||
assert_eq!(contexts.len(), 2);
|
||||
assert_eq!(
|
||||
contexts[1].required_account(),
|
||||
Some(&ProviderAccountId::new("acct_first").unwrap())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_server_retry_advice_should_override_nonzero_local_backoff() {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let (coordinator, _, _) = coordinator(vec![
|
||||
Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(transient_rejection()
|
||||
.with_transient_retry(
|
||||
NonZeroU32::MIN,
|
||||
Duration::from_secs(60),
|
||||
Duration::from_secs(60),
|
||||
)
|
||||
.with_retry_after(Duration::ZERO))],
|
||||
},
|
||||
Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: complete_stream(None),
|
||||
},
|
||||
]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let mut collection = Box::pin(session.collect_uncommitted());
|
||||
assert!(matches!(
|
||||
block_on(async { futures::poll!(&mut collection) }),
|
||||
std::task::Poll::Ready(Ok(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_rejection_should_prevent_not_sent_rotation_and_explicit_transport_recovery() {
|
||||
for fallback in [false, true] {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let mut error =
|
||||
ProviderError::new(ProviderErrorKind::Unavailable, UpstreamSendState::NotSent)
|
||||
.with_replay_safe()
|
||||
.with_retry_prohibited();
|
||||
if fallback {
|
||||
error = error.with_pre_delivery_transport_fallback();
|
||||
}
|
||||
assert!(error.stable_snapshot().retry_is_prohibited());
|
||||
let (coordinator, _, provider) = coordinator(vec![Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(error)],
|
||||
}]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
block_on(session.collect_uncommitted()),
|
||||
Err(EngineError::Provider(_))
|
||||
));
|
||||
assert_eq!(provider.contexts.lock().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_retries_back_off_on_the_same_account_then_rotate_with_a_fresh_budget() {
|
||||
let operation = generate_operation();
|
||||
|
||||
@@ -12,6 +12,7 @@ name = "main"
|
||||
path = "tests/main.rs"
|
||||
|
||||
[dependencies]
|
||||
httpdate = "1.0.3"
|
||||
memchr = "2.8.3"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
|
||||
|
||||
@@ -78,8 +78,12 @@ pub fn codex_responses_request_semantics_with_turn_metadata(
|
||||
let reasoning_effort = body
|
||||
.get("reasoning")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|reasoning| non_empty_string(reasoning.get("effort")))
|
||||
.map(ToOwned::to_owned);
|
||||
.and_then(|reasoning| reasoning.get("effort"))
|
||||
.and_then(|effort| {
|
||||
non_empty_string(Some(effort))
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| effort.as_u64().map(|value| value.to_string()))
|
||||
});
|
||||
let parsed_turn_metadata = turn_metadata
|
||||
.or_else(|| request_turn_metadata(body))
|
||||
.and_then(|value| serde_json::from_str::<Value>(value).ok());
|
||||
|
||||
@@ -248,7 +248,9 @@ pub fn retry_after_seconds_from_value(value: &Value) -> Option<u64> {
|
||||
.pointer("/response/error")
|
||||
.or_else(|| value.get("error"))
|
||||
.unwrap_or(value);
|
||||
retry_after_seconds_field(error)
|
||||
retry_after_seconds_header(error)
|
||||
.or_else(|| retry_after_seconds_header(value))
|
||||
.or_else(|| retry_after_seconds_field(error))
|
||||
.or_else(|| {
|
||||
error
|
||||
.get("resets_in_seconds")
|
||||
@@ -257,7 +259,6 @@ pub fn retry_after_seconds_from_value(value: &Value) -> Option<u64> {
|
||||
})
|
||||
.or_else(|| retry_after_seconds_from_resets_at(error))
|
||||
.or_else(|| retry_after_seconds_field(value))
|
||||
.or_else(|| retry_after_seconds_header(value))
|
||||
.or_else(|| retry_after_seconds_from_rate_limit_message(error))
|
||||
}
|
||||
|
||||
@@ -280,7 +281,7 @@ fn retry_after_seconds_header(value: &Value) -> Option<u64> {
|
||||
.and_then(|headers| {
|
||||
headers.iter().find_map(|(name, value)| {
|
||||
if name.eq_ignore_ascii_case("retry-after") {
|
||||
json_value_as_positive_u64(value)
|
||||
json_value_as_retry_after_seconds(value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -288,14 +289,13 @@ fn retry_after_seconds_header(value: &Value) -> Option<u64> {
|
||||
})
|
||||
}
|
||||
|
||||
fn json_value_as_positive_u64(value: &Value) -> Option<u64> {
|
||||
let seconds = match value {
|
||||
Value::Number(value) => value.as_u64()?,
|
||||
Value::String(value) => value.trim().parse::<u64>().ok()?,
|
||||
Value::Array(values) => values.first().and_then(json_value_as_positive_u64)?,
|
||||
Value::Null | Value::Bool(_) | Value::Object(_) => return None,
|
||||
};
|
||||
(seconds > 0).then_some(seconds)
|
||||
fn json_value_as_retry_after_seconds(value: &Value) -> Option<u64> {
|
||||
match value {
|
||||
Value::Number(value) => value.as_u64(),
|
||||
Value::String(value) => super::headers::parse_retry_after_seconds(value),
|
||||
Value::Array(values) => values.first().and_then(json_value_as_retry_after_seconds),
|
||||
Value::Null | Value::Bool(_) | Value::Object(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 标准化的单个限流窗口
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
//! OpenAI 请求头的 HTTP 传输边界
|
||||
//! OpenAI 头部的传输边界与重试时间解析
|
||||
|
||||
/// 解析 HTTP 和流内错误共用的 Retry-After,保留零延迟并向上取整剩余秒数
|
||||
pub fn parse_retry_after_seconds(value: &str) -> Option<u64> {
|
||||
let now = std::time::SystemTime::now();
|
||||
let value = value.trim();
|
||||
if !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return value.parse().ok();
|
||||
}
|
||||
let remaining = httpdate::parse_http_date(value)
|
||||
.ok()?
|
||||
.duration_since(now)
|
||||
.unwrap_or_default();
|
||||
Some(
|
||||
remaining
|
||||
.as_secs()
|
||||
.saturating_add(u64::from(remaining.subsec_nanos() != 0)),
|
||||
)
|
||||
}
|
||||
|
||||
/// 判断小写请求头是否属于传输层管理的字段,不得作为业务扩展头透传
|
||||
///
|
||||
|
||||
@@ -21,4 +21,6 @@ pub use codex::{
|
||||
CodexResponsesRequestSemantics, codex_responses_request_semantics,
|
||||
codex_responses_request_semantics_with_turn_metadata, codex_session_id, codex_thread_id,
|
||||
};
|
||||
pub use headers::{is_transport_managed_request_header, response_header_is_forwardable};
|
||||
pub use headers::{
|
||||
is_transport_managed_request_header, parse_retry_after_seconds, response_header_is_forwardable,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,25 @@ use gateway_protocol::openai::{
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_should_observe_unsigned_numbers_without_changing_the_body() {
|
||||
for (effort, expected) in [
|
||||
(json!(0), Some("0")),
|
||||
(json!(64), Some("64")),
|
||||
(json!(u64::MAX), Some("18446744073709551615")),
|
||||
(json!(" high "), Some("high")),
|
||||
(json!(-1), None),
|
||||
(json!(1.5), None),
|
||||
(json!(true), None),
|
||||
(json!(null), None),
|
||||
] {
|
||||
let body = object(json!({"reasoning":{"effort":effort}}));
|
||||
let semantics = codex_responses_request_semantics(&body, &Map::new());
|
||||
assert_eq!(semantics.reasoning_effort.as_deref(), expected);
|
||||
assert_eq!(body["reasoning"]["effort"], effort);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_session_should_prefer_the_connection_identity_over_body_metadata() {
|
||||
let body = object(
|
||||
|
||||
@@ -464,6 +464,44 @@ fn retry_after_should_reject_malformed_json() {
|
||||
assert_eq!(retry_after_seconds_from_body("not-json"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_after_should_prioritize_nested_headers_over_other_advice() {
|
||||
for header in [json!(30), json!("30"), json!(["30"])] {
|
||||
for wrap_response in [false, true] {
|
||||
let error = json!({
|
||||
"code": "rate_limit_exceeded", "message": "Try again in 2s",
|
||||
"retry_after_seconds": 10, "headers": {"ReTrY-AfTeR": header}
|
||||
});
|
||||
let body = if wrap_response {
|
||||
json!({"type":"response.failed", "response":{"error":error}, "headers":{"retry-after":"20"}})
|
||||
} else {
|
||||
json!({"type":"error", "error":error, "headers":{"retry-after":"20"}})
|
||||
};
|
||||
assert_eq!(retry_after_seconds_from_body(&body.to_string()), Some(30));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_after_should_keep_zero_advice_and_fall_back_only_for_invalid_headers() {
|
||||
for (header, expected) in [
|
||||
(json!("0"), 0),
|
||||
(json!(0), 0),
|
||||
(json!("Sun, 06 Nov 1994 08:49:37 GMT"), 0),
|
||||
(json!("invalid"), 2),
|
||||
(json!({"seconds":30}), 2),
|
||||
] {
|
||||
let body = json!({"type":"response.failed", "response":{"error":{
|
||||
"code":"rate_limit_exceeded", "message":"Try again in 2s",
|
||||
"headers":{"Retry-After":header}
|
||||
}}});
|
||||
assert_eq!(
|
||||
retry_after_seconds_from_body(&body.to_string()),
|
||||
Some(expected)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rate_limit_headers_should_extract_primary_and_secondary_windows() {
|
||||
let headers = vec![
|
||||
|
||||
@@ -1,9 +1,40 @@
|
||||
//! 验证传输头部分类,以及响应中的连接级和身份字段过滤
|
||||
|
||||
use gateway_protocol::openai::{
|
||||
is_transport_managed_request_header, response_header_is_forwardable,
|
||||
is_transport_managed_request_header, parse_retry_after_seconds, response_header_is_forwardable,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn retry_after_should_accept_nonnegative_seconds_and_http_dates() {
|
||||
for (header, expected) in [
|
||||
(" 30 ", Some(30)),
|
||||
("0", Some(0)),
|
||||
("Sun, 06 Nov 1994 08:49:37 GMT", Some(0)),
|
||||
("Sunday, 06-Nov-94 08:49:37 GMT", Some(0)),
|
||||
("Sun Nov 6 08:49:37 1994", Some(0)),
|
||||
("+30", None),
|
||||
("-1", None),
|
||||
("1.5", None),
|
||||
("18446744073709551616", None),
|
||||
("", None),
|
||||
("later", None),
|
||||
] {
|
||||
assert_eq!(parse_retry_after_seconds(header), expected, "{header}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_after_http_date_should_not_round_down_the_remaining_delay() {
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
|
||||
let retry_at = UNIX_EPOCH + Duration::from_secs(now.as_secs() + 60);
|
||||
let header = httpdate::fmt_http_date(retry_at);
|
||||
let seconds = parse_retry_after_seconds(&header).expect("HTTP date");
|
||||
let remaining = retry_at.duration_since(SystemTime::now()).unwrap();
|
||||
assert!(Duration::from_secs(seconds) >= remaining);
|
||||
assert!(seconds <= 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_headers_should_include_hop_fields_and_compression() {
|
||||
for name in [
|
||||
|
||||
@@ -27,6 +27,7 @@ hex = "0.4.3"
|
||||
# hyper 受 workspace 的 SOCKS IPv6 修复补丁约束,保留补丁要求的兼容版本。
|
||||
# 升级前须重复采样 ClientHello,核对算法、扩展集合及排序约束、key_share 与 ALPN。
|
||||
aws-lc-rs = "=1.18.1"
|
||||
h2 = "=0.4.19"
|
||||
hyper = { version = "=1.11.1", default-features = false }
|
||||
hyper-rustls = "=0.27.7"
|
||||
hyper-util = { version = "0.1.20", features = ["client-legacy", "http1", "tokio"] }
|
||||
|
||||
@@ -207,21 +207,22 @@ impl ProviderAdmin for OpenAiAdminProvider {
|
||||
}
|
||||
|
||||
fn plan_type_display(&self, plan_type: &str) -> String {
|
||||
// 与官方 Desktop 的套餐名称映射一致;原始子类型继续由 plan_type 保留
|
||||
// Pro 系列按套餐类型区分展示,其他套餐遵循官方 KnownPlan::display_name
|
||||
// 只映射展示名称,原始套餐值与未知套餐保持不变
|
||||
match plan_type.to_ascii_lowercase().as_str() {
|
||||
"free" | "free_workspace" | "guest" => "Free",
|
||||
"free" => "Free",
|
||||
"go" => "Go",
|
||||
"plus" => "Plus",
|
||||
"pro" | "prolite" => "Pro",
|
||||
"team" | "self_serve_business_prolite" | "self_serve_business_usage_based" => {
|
||||
"Business"
|
||||
}
|
||||
"business"
|
||||
| "ent26"
|
||||
| "enterprise_cbp_automation"
|
||||
| "enterprise_cbp_usage_based"
|
||||
| "enterprise"
|
||||
| "hc" => "Enterprise",
|
||||
"pro" => "Pro",
|
||||
"prolite" => "ProLite",
|
||||
"promax" => "ProMax",
|
||||
"team" => "Team",
|
||||
"self_serve_business_prolite" => "Self Serve Business ProLite",
|
||||
"self_serve_business_usage_based" => "Self Serve Business Usage Based",
|
||||
"business" => "Business",
|
||||
"ent26" | "enterprise" | "hc" => "Enterprise",
|
||||
"enterprise_cbp_automation" => "Enterprise (Automation)",
|
||||
"enterprise_cbp_usage_based" => "Enterprise CBP Usage Based",
|
||||
"edu" | "education" => "Edu",
|
||||
"edu_plus" => "Edu Plus",
|
||||
"edu_pro" => "Edu Pro",
|
||||
|
||||
@@ -23,7 +23,7 @@ pub const OFFICIAL_CODEX_OAUTH_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
/// Codex Desktop 使用的官方 token endpoint
|
||||
pub const OFFICIAL_CODEX_TOKEN_ENDPOINT: &str = "https://auth.openai.com/oauth/token";
|
||||
/// Codex Desktop loopback callback;管理员复制完整回调 URL 交回固定 complete API
|
||||
pub const OFFICIAL_CODEX_REDIRECT_URI: &str = "http://localhost:1455/auth/callback";
|
||||
pub const OFFICIAL_CODEX_REDIRECT_URI: &str = "http://127.0.0.1:1455/auth/callback";
|
||||
const PERSONAL_ACCESS_TOKEN_WHOAMI_PATH: &str = "/api/accounts/v1/user-auth-credential/whoami";
|
||||
|
||||
/// PAT 验证失败;不保留令牌、响应体或可能包含秘密的底层 HTTP 错误
|
||||
|
||||
@@ -170,6 +170,7 @@ enum KnownChatgptPlan {
|
||||
Plus,
|
||||
Pro,
|
||||
ProLite,
|
||||
ProMax,
|
||||
Team,
|
||||
#[serde(rename = "self_serve_business_prolite")]
|
||||
SelfServeBusinessProLite,
|
||||
@@ -185,6 +186,10 @@ enum KnownChatgptPlan {
|
||||
Enterprise,
|
||||
#[serde(alias = "education")]
|
||||
Edu,
|
||||
#[serde(rename = "edu_plus")]
|
||||
EduPlus,
|
||||
#[serde(rename = "edu_pro")]
|
||||
EduPro,
|
||||
}
|
||||
|
||||
impl KnownChatgptPlan {
|
||||
@@ -195,6 +200,7 @@ impl KnownChatgptPlan {
|
||||
Self::Plus => "plus",
|
||||
Self::Pro => "pro",
|
||||
Self::ProLite => "prolite",
|
||||
Self::ProMax => "promax",
|
||||
Self::Team => "team",
|
||||
Self::SelfServeBusinessProLite => "self_serve_business_prolite",
|
||||
Self::SelfServeBusinessUsageBased => "self_serve_business_usage_based",
|
||||
@@ -204,6 +210,8 @@ impl KnownChatgptPlan {
|
||||
Self::EnterpriseCbpUsageBased => "enterprise_cbp_usage_based",
|
||||
Self::Enterprise => "enterprise",
|
||||
Self::Edu => "edu",
|
||||
Self::EduPlus => "edu_plus",
|
||||
Self::EduPro => "edu_pro",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,6 +545,10 @@ pub(super) fn apply_websocket_recovery_policy(
|
||||
failure: &mut MappedProviderFailure,
|
||||
context: WebSocketRecoveryContext<'_>,
|
||||
) {
|
||||
// Flex 拒绝即使发生在 WS opening 阶段也必须直达客户端,不能转换成传输回退
|
||||
if failure.error.retry_is_prohibited() {
|
||||
return;
|
||||
}
|
||||
// 明确账号拒绝走已有换号路径,容量拒绝走请求内退避;两者都不消耗 WS 传输预算
|
||||
if failure.error.replay_is_safe()
|
||||
&& (failure.account_failure.is_some()
|
||||
@@ -1351,6 +1355,9 @@ pub(super) fn map_upstream_failure(
|
||||
provider_error_kind(category)
|
||||
};
|
||||
let mut error = provider_error(error_kind, send_state);
|
||||
if category == CodexFailureCategory::FlexUnavailable {
|
||||
error = error.with_retry_prohibited();
|
||||
}
|
||||
error = error.with_raw_upstream_error(RawUpstreamError::new(failure.raw_body.clone()));
|
||||
if let Some(message) = failure.client_message.as_ref() {
|
||||
error = error.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
|
||||
@@ -1399,10 +1406,7 @@ pub(super) fn map_upstream_failure(
|
||||
let max_delay = Duration::from_secs(8);
|
||||
error = error.with_transient_retry(
|
||||
NonZeroU32::new(3).unwrap_or(NonZeroU32::MIN),
|
||||
failure
|
||||
.retry_after_seconds
|
||||
.map_or(Duration::from_millis(500), Duration::from_secs)
|
||||
.min(max_delay),
|
||||
Duration::from_millis(500),
|
||||
max_delay,
|
||||
);
|
||||
}
|
||||
@@ -1474,6 +1478,7 @@ pub(super) const fn provider_error_kind(category: CodexFailureCategory) -> Provi
|
||||
CodexFailureCategory::QuotaExhausted => ProviderErrorKind::QuotaExhausted,
|
||||
CodexFailureCategory::CloudflareChallenge
|
||||
| CodexFailureCategory::CloudflarePathBlocked
|
||||
| CodexFailureCategory::FlexUnavailable
|
||||
| CodexFailureCategory::Unavailable => ProviderErrorKind::Unavailable,
|
||||
CodexFailureCategory::CapacityUnavailable => ProviderErrorKind::UpstreamCapacityUnavailable,
|
||||
CodexFailureCategory::InvalidRequest => ProviderErrorKind::InvalidRequest,
|
||||
@@ -1528,6 +1533,7 @@ pub(super) fn account_failure(
|
||||
| CodexFailureCategory::PermissionDenied
|
||||
| CodexFailureCategory::Timeout
|
||||
| CodexFailureCategory::CapacityUnavailable
|
||||
| CodexFailureCategory::FlexUnavailable
|
||||
| CodexFailureCategory::Unavailable
|
||||
| CodexFailureCategory::Transport => None,
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ use std::{
|
||||
|
||||
use crate::transport::profile::CodexWireProfileState;
|
||||
use bytes::Bytes;
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use futures::{Stream, StreamExt};
|
||||
use gateway_core::engine::middleware::MiddlewareHeader;
|
||||
use gateway_protocol::openai::{
|
||||
@@ -882,33 +881,10 @@ pub(super) fn retry_after_seconds(headers: &HeaderMap, body: Option<&str>) -> Op
|
||||
headers
|
||||
.get(RETRY_AFTER)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(parse_retry_after)
|
||||
.and_then(gateway_protocol::openai::parse_retry_after_seconds)
|
||||
.or_else(|| body.and_then(retry_after_seconds_from_body))
|
||||
}
|
||||
|
||||
/// 解析 RFC 7231 `Retry-After` 值:delay-seconds 或 HTTP-date,统一换算为剩余秒数
|
||||
pub(super) fn parse_retry_after(value: &str) -> Option<u64> {
|
||||
let value = value.trim();
|
||||
if let Ok(seconds) = value.parse::<u64>() {
|
||||
return (seconds > 0).then_some(seconds);
|
||||
}
|
||||
let remaining = parse_http_date(value)?.signed_duration_since(Utc::now());
|
||||
u64::try_from(remaining.num_seconds())
|
||||
.ok()
|
||||
.filter(|seconds| *seconds > 0)
|
||||
}
|
||||
|
||||
/// IMF-fixdate 优先;RFC 7231 要求接收方兼容过时的 RFC 850 与 asctime 形式
|
||||
fn parse_http_date(value: &str) -> Option<DateTime<Utc>> {
|
||||
if let Ok(date) = DateTime::parse_from_rfc2822(value) {
|
||||
return Some(date.with_timezone(&Utc));
|
||||
}
|
||||
["%A, %d-%b-%y %H:%M:%S GMT", "%a %b %e %H:%M:%S %Y"]
|
||||
.iter()
|
||||
.find_map(|format| NaiveDateTime::parse_from_str(value, format).ok())
|
||||
.map(|date| date.and_utc())
|
||||
}
|
||||
|
||||
pub(super) fn truncate_for_error(body: &str) -> String {
|
||||
body.chars().take(200).collect()
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ const PERSISTABLE_UPSTREAM_CODES: &[&str] = &[
|
||||
"billing_limit",
|
||||
"cyber_policy",
|
||||
"deactivated_workspace",
|
||||
"flex_unavailable",
|
||||
"identity_verification_required",
|
||||
"insufficient_quota",
|
||||
"invalid_api_key",
|
||||
@@ -104,6 +105,8 @@ pub enum CodexFailureCategory {
|
||||
Timeout,
|
||||
/// 模型容量暂时不足,只影响当前请求,不证明账号健康异常
|
||||
CapacityUnavailable,
|
||||
/// Flex 容量不足是当前请求的终止错误,不冷却账号或自动重放
|
||||
FlexUnavailable,
|
||||
Unavailable,
|
||||
Transport,
|
||||
}
|
||||
@@ -231,6 +234,9 @@ impl CodexUpstreamFailure {
|
||||
/// 返回该拒绝是否允许换号重放
|
||||
#[must_use]
|
||||
pub const fn replay_is_safe(&self) -> bool {
|
||||
if matches!(self.category, CodexFailureCategory::FlexUnavailable) {
|
||||
return false;
|
||||
}
|
||||
match self.send_phase {
|
||||
CodexUpstreamSendPhase::BeforePayload => true,
|
||||
CodexUpstreamSendPhase::Ambiguous => false,
|
||||
@@ -431,6 +437,10 @@ fn classify_upstream_failure(
|
||||
let message = fields.message.to_ascii_lowercase();
|
||||
let body = body.to_ascii_lowercase();
|
||||
|
||||
if code == "flex_unavailable" {
|
||||
return CodexFailureCategory::FlexUnavailable;
|
||||
}
|
||||
|
||||
// 容量拒绝可能带 400/429/503;仅用结构化错误字段识别,不能扫描任意正文
|
||||
if is_capacity_error(
|
||||
fields.code.as_deref(),
|
||||
@@ -441,7 +451,7 @@ fn classify_upstream_failure(
|
||||
}
|
||||
|
||||
// 与官方 Codex HTTP/WS 路径一致:429 只有结构化
|
||||
// `error.type=usage_limit_reached` 才能确认额度窗口耗尽;其余 429 都是临时限流
|
||||
// `error.type=usage_limit_reached` 才能确认额度窗口耗尽;排除容量错误后才按临时限流处理
|
||||
// SSE `response.failed` 的结构化字段形态不同,保留其 code/type 语义单独分类
|
||||
if matches!(source, UpstreamFailureSource::HttpResponse)
|
||||
&& status == Some(StatusCode::TOO_MANY_REQUESTS)
|
||||
|
||||
@@ -21,10 +21,8 @@ use crate::{
|
||||
responses::CodexResponsesRequest, websocket::websocket_response_create_payload_text,
|
||||
},
|
||||
transport::{
|
||||
client::{CodexClientVisibleUpstreamResponse, parse_retry_after},
|
||||
diagnostics::CodexUpstreamSendPhase,
|
||||
endpoints::CODEX_RESPONSES_PATH,
|
||||
response_meta, tls,
|
||||
client::CodexClientVisibleUpstreamResponse, diagnostics::CodexUpstreamSendPhase,
|
||||
endpoints::CODEX_RESPONSES_PATH, response_meta, tls,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -364,7 +362,7 @@ fn websocket_opening_error(response: &WsResponse<Option<Vec<u8>>>) -> CodexWebSo
|
||||
.headers()
|
||||
.get("retry-after")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(parse_retry_after)
|
||||
.and_then(gateway_protocol::openai::parse_retry_after_seconds)
|
||||
.or_else(|| events::retry_after_seconds_from_body(&body));
|
||||
CodexWebSocketExchangeError::upstream(
|
||||
status_code,
|
||||
|
||||
@@ -66,6 +66,42 @@ const COMPLETED_SESSION_SSE: &str = concat!(
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_initialized_session\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n"
|
||||
);
|
||||
|
||||
#[tokio::test]
|
||||
async fn plan_display_should_distinguish_pro_tiers_and_preserve_other_official_names() {
|
||||
let config = valid_config();
|
||||
let bundle = provider_openai::initialize(config.config.clone(), provider_ports())
|
||||
.await
|
||||
.unwrap();
|
||||
let admin = bundle.admin_provider();
|
||||
for (raw, display) in [
|
||||
("prolite", "ProLite"),
|
||||
("pro", "Pro"),
|
||||
("promax", "ProMax"),
|
||||
("free", "Free"),
|
||||
("go", "Go"),
|
||||
("plus", "Plus"),
|
||||
("team", "Team"),
|
||||
("self_serve_business_prolite", "Self Serve Business ProLite"),
|
||||
(
|
||||
"self_serve_business_usage_based",
|
||||
"Self Serve Business Usage Based",
|
||||
),
|
||||
("business", "Business"),
|
||||
("ent26", "Enterprise"),
|
||||
("enterprise", "Enterprise"),
|
||||
("hc", "Enterprise"),
|
||||
("enterprise_cbp_automation", "Enterprise (Automation)"),
|
||||
("enterprise_cbp_usage_based", "Enterprise CBP Usage Based"),
|
||||
("edu", "Edu"),
|
||||
("education", "Edu"),
|
||||
("edu_plus", "Edu Plus"),
|
||||
("edu_pro", "Edu Pro"),
|
||||
("future_plan", "future_plan"),
|
||||
] {
|
||||
assert_eq!(admin.plan_type_display(raw), display);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_capabilities_distinguish_oauth_from_api_key_and_unknown_credentials() {
|
||||
let config = valid_config();
|
||||
@@ -645,7 +681,7 @@ async fn openai_admin_provider_persists_the_full_pending_envelope_and_binds_owne
|
||||
request_id: "request-complete".to_owned(),
|
||||
},
|
||||
flow_id: started.flow_id,
|
||||
callback_url: "http://localhost:1455/auth/callback?code=unused&state=unused".to_owned(),
|
||||
callback_url: "http://127.0.0.1:1455/auth/callback?code=unused&state=unused".to_owned(),
|
||||
})
|
||||
.await
|
||||
.expect_err("wrong owner");
|
||||
|
||||
@@ -720,6 +720,25 @@ async fn oauth_import_uses_official_plan_alias_projection_and_user_id_fallback()
|
||||
assert_eq!(account.plan_type(), Some("enterprise"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oauth_import_should_preserve_pro_max_and_future_plan_claims() {
|
||||
let service = CodexCredentialAdminService::new(
|
||||
Arc::new(UnusedRefresher),
|
||||
Arc::new(TestLeaseCoordinator::default()),
|
||||
runtime_policy(),
|
||||
);
|
||||
for plan in ["prolite", "pro", "promax", "future_plan"] {
|
||||
let access_token = test_jwt(serde_json::json!({"https://api.openai.com/auth":{
|
||||
"chatgpt_user_id":"plan-user", "chatgpt_plan_type":plan
|
||||
}}));
|
||||
let prepared = service
|
||||
.prepare_import_document(serde_json::json!({"accessToken":access_token}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(prepared.accounts()[0].account.plan_type(), Some(plan));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oauth_import_uses_id_token_then_access_token_for_missing_claims() {
|
||||
let id_token = test_jwt(serde_json::json!({
|
||||
|
||||
@@ -194,7 +194,7 @@ async fn complete(
|
||||
owner_ref: "test-owner".to_owned(),
|
||||
flow_id: started.flow_id,
|
||||
callback_url: SecretString::from(format!(
|
||||
"http://localhost:1455/auth/callback?code=code-from-browser&state={state}"
|
||||
"http://127.0.0.1:1455/auth/callback?code=code-from-browser&state={state}"
|
||||
)),
|
||||
})
|
||||
.await?;
|
||||
@@ -299,7 +299,7 @@ async fn authorize_url_matches_the_official_desktop_parameter_contract() {
|
||||
);
|
||||
assert_eq!(
|
||||
parameters.get("redirect_uri").map(String::as_str),
|
||||
Some("http://localhost:1455/auth/callback")
|
||||
Some("http://127.0.0.1:1455/auth/callback")
|
||||
);
|
||||
assert_eq!(
|
||||
parameters.get("scope").map(String::as_str),
|
||||
|
||||
@@ -328,7 +328,7 @@ async fn authorization_code_exchange_should_require_bounded_oidc_token_set_and_p
|
||||
assert!(body.contains("client_id=test-public-client"));
|
||||
assert!(body.contains("code=authorization+code"));
|
||||
assert!(body.contains("code_verifier=pkce-verifier-secret"));
|
||||
assert!(body.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback"));
|
||||
assert!(body.contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A1455%2Fauth%2Fcallback"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -8025,10 +8025,11 @@ async fn capacity_http_and_websocket_opening_rejections_use_bounded_business_ret
|
||||
assert_eq!(error.kind(), ProviderErrorKind::UpstreamCapacityUnavailable);
|
||||
assert_eq!(error.upstream_status(), Some(status));
|
||||
assert!(error.replay_is_safe());
|
||||
assert_eq!(error.retry_after(), Some(Duration::from_secs(129600)));
|
||||
assert!(
|
||||
matches!(error.pre_delivery_retry(), Some(PreDeliveryRetry::SameAccountTransientRetry {
|
||||
max_retries, initial_delay, max_delay,
|
||||
}) if max_retries.get() == 3 && initial_delay == Duration::from_secs(8) && max_delay == Duration::from_secs(8))
|
||||
}) if max_retries.get() == 3 && initial_delay == Duration::from_millis(500) && max_delay == Duration::from_secs(8))
|
||||
);
|
||||
assert!(provider_openai::openai_failure_affects_account_score(
|
||||
&error
|
||||
@@ -8052,6 +8053,68 @@ async fn capacity_http_and_websocket_opening_rejections_use_bounded_business_ret
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn flex_http_and_websocket_opening_rejections_should_not_retry_or_cool_down_accounts() {
|
||||
use gateway_core::provider_ports::ProviderCooldownPort as _;
|
||||
for use_websocket in [false, true] {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_provider_contract";
|
||||
create_account(&store, account_id).await;
|
||||
let cooldowns = Arc::new(MemoryCooldownPort::new());
|
||||
let server = MockServer::start().await;
|
||||
let body = json!({"error":{"type":"resource_unavailable","code":"flex_unavailable","message":"Flex capacity unavailable."}});
|
||||
Mock::given(method(if use_websocket { "GET" } else { "POST" }))
|
||||
.and(path("/codex/responses"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(429)
|
||||
.insert_header("retry-after", "300")
|
||||
.set_body_json(&body),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let operation = if use_websocket {
|
||||
generate_operation()
|
||||
} else {
|
||||
http_generate_operation()
|
||||
};
|
||||
let (provider, _) =
|
||||
provider_with_capacity_tracking(&store, server.uri(), Arc::clone(&cooldowns));
|
||||
let mut stream = provider
|
||||
.execute(
|
||||
planned_request("openai", operation),
|
||||
context("req_flex", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let error = loop {
|
||||
match stream.next().await {
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(error)) => break error,
|
||||
None => panic!("expected Flex failure"),
|
||||
}
|
||||
};
|
||||
assert_eq!(error.kind(), ProviderErrorKind::Unavailable);
|
||||
assert_eq!(
|
||||
error.upstream_code().map(|code| code.as_str()),
|
||||
Some("flex_unavailable")
|
||||
);
|
||||
assert!(!error.replay_is_safe());
|
||||
assert!(error.pre_delivery_retry().is_none());
|
||||
assert!(error.retry_is_prohibited());
|
||||
assert!(!provider_openai::openai_failure_affects_account_score(
|
||||
&error
|
||||
));
|
||||
let response = error.client_visible_upstream_response().unwrap();
|
||||
assert_eq!(response.status(), 429);
|
||||
assert_eq!(response.body().as_ref(), body.to_string().as_bytes());
|
||||
let account = store.account(account_id).unwrap();
|
||||
assert_eq!(account.credential_state(), CredentialState::Ready);
|
||||
assert_eq!(account.quota().access(), QuotaAccessState::Unknown);
|
||||
assert!(cooldowns.read(account.id()).await.unwrap().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
fn capacity_websocket_operation() -> Operation {
|
||||
let payload = ProtocolPayload::json_object(
|
||||
"openai",
|
||||
@@ -8302,6 +8365,12 @@ async fn capacity_feedback_in_stream_only_counts_explicit_overload() {
|
||||
ProviderErrorKind::UpstreamCapacityUnavailable,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"flex_unavailable",
|
||||
"Flex capacity unavailable.",
|
||||
ProviderErrorKind::Unavailable,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"invalid_prompt",
|
||||
"Invalid prompt: we've limited access to this content for safety reasons.",
|
||||
@@ -9589,20 +9658,21 @@ fn endpoint_observation_should_read_models_without_rewriting_or_requiring_a_cata
|
||||
#[test]
|
||||
fn request_observation_preserves_the_raw_reasoning_effort() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let payload = ProtocolPayload::json_object(
|
||||
"openai",
|
||||
Map::from_iter([("reasoning".to_owned(), json!({"effort": "future-value"}))]),
|
||||
)
|
||||
.expect("protocol payload");
|
||||
let operation = Operation::Generate(GenerateRequest::from_protocol_payload(payload));
|
||||
|
||||
let client_key_id = ClientApiKeyId::new("key_openai_observation").expect("client key");
|
||||
let observation = provider(&store).request_observation(&operation, &client_key_id);
|
||||
|
||||
assert_eq!(
|
||||
observation.reasoning_effort.as_deref(),
|
||||
Some("future-value")
|
||||
);
|
||||
for (effort, expected) in [
|
||||
(json!("future-value"), "future-value"),
|
||||
(json!(64), "64"),
|
||||
(json!(0), "0"),
|
||||
] {
|
||||
let payload = ProtocolPayload::json_object(
|
||||
"openai",
|
||||
Map::from_iter([("reasoning".to_owned(), json!({"effort":effort}))]),
|
||||
)
|
||||
.unwrap();
|
||||
let operation = Operation::Generate(GenerateRequest::from_protocol_payload(payload));
|
||||
let client_key_id = ClientApiKeyId::new("key_openai_observation").unwrap();
|
||||
let observation = provider(&store).request_observation(&operation, &client_key_id);
|
||||
assert_eq!(observation.reasoning_effort.as_deref(), Some(expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -22,6 +22,27 @@ fn classify(status: u16, body: &str) -> CodexFailureCategory {
|
||||
.category()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flex_capacity_rejection_should_be_terminal_even_before_payload_send() {
|
||||
for phase in [
|
||||
CodexUpstreamSendPhase::BeforePayload,
|
||||
CodexUpstreamSendPhase::AfterPayload,
|
||||
] {
|
||||
let failure = CodexUpstreamFailure::from_response(
|
||||
reqwest::StatusCode::TOO_MANY_REQUESTS,
|
||||
r#"{"error":{"type":"resource_unavailable","code":"flex_unavailable","message":"Flex capacity unavailable."}}"#,
|
||||
Some(300),
|
||||
&CodexUpstreamDiagnostics::default(),
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
phase,
|
||||
);
|
||||
assert_eq!(failure.category(), CodexFailureCategory::FlexUnavailable);
|
||||
assert!(!failure.replay_is_safe());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_rejections_are_request_scoped_across_http_statuses() {
|
||||
for status in [400, 429, 503] {
|
||||
|
||||
@@ -6,10 +6,15 @@ use bytes::{Buf as _, Bytes};
|
||||
|
||||
use super::*;
|
||||
|
||||
// 2026-09-19 从 Desktop 26.915.31945 / Core 0.155.0-alpha.9.2 各重复抓取三次
|
||||
// 基线来自官方 Codex 823ea830c0 的客户端源码,各路径重复采样五次
|
||||
// HTTP 另与本机 Core 0.160.0 的采样核对,源码客户端与本项目还通过真实 models 请求验证
|
||||
// HTTP/native-tls 的扩展顺序固定;WebSocket/rustls 会随机化扩展顺序和临时密钥
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct ClientHello {
|
||||
legacy_version: u16,
|
||||
supported_versions: Vec<u16>,
|
||||
point_formats: Vec<u8>,
|
||||
psk_modes: Vec<u8>,
|
||||
cipher_suites: Vec<u16>,
|
||||
extensions: Vec<u16>,
|
||||
groups: Vec<u16>,
|
||||
@@ -19,11 +24,11 @@ struct ClientHello {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_client_hello_should_match_official_native_tls_transport() {
|
||||
const CASE_ENV: &str = "CODEX_PROXY_TEST_NATIVE_TLS_CLIENT_HELLO";
|
||||
const CASE_COMPLETED: &str = "native-tls-client-hello-case-completed";
|
||||
fn http_client_hellos_should_match_official_tls_transports() {
|
||||
const CASE_ENV: &str = "CODEX_PROXY_TEST_HTTP_CLIENT_HELLO";
|
||||
const CASE_COMPLETED: &str = "http-tls-client-hello-case-completed";
|
||||
|
||||
if std::env::var_os(CASE_ENV).is_some() {
|
||||
if let Ok(case) = std::env::var(CASE_ENV) {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
@@ -46,7 +51,13 @@ fn http_client_hello_should_match_official_native_tls_transport() {
|
||||
);
|
||||
hello
|
||||
});
|
||||
assert_eq!(hello, official_http_hello());
|
||||
if case == "custom-ca" {
|
||||
let mut normalized = hello;
|
||||
normalized.extensions.sort_unstable();
|
||||
assert_eq!(normalized, official_http_rustls_hello());
|
||||
} else {
|
||||
assert_eq!(hello, official_http_hello());
|
||||
}
|
||||
println!("\n{CASE_COMPLETED}");
|
||||
return;
|
||||
}
|
||||
@@ -54,30 +65,38 @@ fn http_client_hello_should_match_official_native_tls_transport() {
|
||||
// Cargo 运行测试时可能注入 SSL_CERT_FILE,生产代码会把它视作自定义 CA 并切到 rustls;
|
||||
// 因此在清理相关环境变量的子进程里验证默认路径
|
||||
let current_exe = std::env::current_exe().expect("current test binary path");
|
||||
let output = Command::new(current_exe)
|
||||
.arg("--exact")
|
||||
.arg("transport::tls::http_client_hello_should_match_official_native_tls_transport")
|
||||
.arg("--nocapture")
|
||||
.env(CASE_ENV, "1")
|
||||
.env_remove(provider_openai::transport::tls::CODEX_CA_CERT_ENV)
|
||||
.env_remove(provider_openai::transport::tls::SSL_CERT_FILE_ENV)
|
||||
.output()
|
||||
.expect("run isolated native TLS ClientHello case");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"isolated native TLS ClientHello case failed\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert_eq!(
|
||||
stdout
|
||||
.lines()
|
||||
.filter(|line| *line == CASE_COMPLETED)
|
||||
.count(),
|
||||
1,
|
||||
"isolated native TLS ClientHello case did not complete exactly once\nstdout:\n{stdout}"
|
||||
);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let ca_path = directory.path().join("ca.pem");
|
||||
std::fs::write(&ca_path, TEST_CA_PEM).unwrap();
|
||||
for case in ["native", "custom-ca"] {
|
||||
let mut command = Command::new(¤t_exe);
|
||||
command
|
||||
.arg("--exact")
|
||||
.arg("transport::tls::http_client_hellos_should_match_official_tls_transports")
|
||||
.arg("--nocapture")
|
||||
.env(CASE_ENV, case)
|
||||
.env_remove(provider_openai::transport::tls::CODEX_CA_CERT_ENV)
|
||||
.env_remove(provider_openai::transport::tls::SSL_CERT_FILE_ENV);
|
||||
if case == "custom-ca" {
|
||||
command.env(provider_openai::transport::tls::CODEX_CA_CERT_ENV, &ca_path);
|
||||
}
|
||||
let output = command.output().expect("run isolated TLS ClientHello case");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"isolated {case} TLS ClientHello case failed\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert_eq!(
|
||||
stdout
|
||||
.lines()
|
||||
.filter(|line| *line == CASE_COMPLETED)
|
||||
.count(),
|
||||
1,
|
||||
"isolated {case} TLS ClientHello case did not complete exactly once\nstdout:\n{stdout}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -107,6 +126,10 @@ async fn websocket_client_hello_should_match_official_rustls_transport() {
|
||||
|
||||
fn official_http_hello() -> ClientHello {
|
||||
ClientHello {
|
||||
legacy_version: 771,
|
||||
supported_versions: vec![772, 771],
|
||||
point_formats: vec![0],
|
||||
psk_modes: vec![1],
|
||||
cipher_suites: vec![
|
||||
4866, 4867, 4865, 49196, 49200, 159, 52393, 52392, 52394, 49195, 49199, 158, 49188,
|
||||
49192, 107, 49187, 49191, 103, 49162, 49172, 57, 49161, 49171, 51, 157, 156, 61, 60,
|
||||
@@ -125,6 +148,10 @@ fn official_http_hello() -> ClientHello {
|
||||
|
||||
fn official_websocket_hello() -> ClientHello {
|
||||
ClientHello {
|
||||
legacy_version: 771,
|
||||
supported_versions: vec![772, 771],
|
||||
point_formats: vec![0],
|
||||
psk_modes: vec![1],
|
||||
cipher_suites: vec![
|
||||
4866, 4865, 4867, 49196, 49195, 52393, 49200, 49199, 52392, 255,
|
||||
],
|
||||
@@ -138,6 +165,14 @@ fn official_websocket_hello() -> ClientHello {
|
||||
}
|
||||
}
|
||||
|
||||
fn official_http_rustls_hello() -> ClientHello {
|
||||
let mut hello = official_websocket_hello();
|
||||
hello.extensions.push(16);
|
||||
hello.extensions.sort_unstable();
|
||||
hello.alpn = vec!["h2".to_owned(), "http/1.1".to_owned()];
|
||||
hello
|
||||
}
|
||||
|
||||
async fn read_client_hello(listener: TcpListener) -> ClientHello {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut header = [0; 5];
|
||||
@@ -151,7 +186,9 @@ async fn read_client_hello(listener: TcpListener) -> ClientHello {
|
||||
|
||||
let mut hello = Bytes::from(record);
|
||||
assert_eq!(hello.get_u8(), 1, "ClientHello message");
|
||||
hello.advance(3 + 2 + 32); // 消息长度、旧版协议版本与随机数
|
||||
hello.advance(3); // 消息长度
|
||||
let legacy_version = hello.get_u16();
|
||||
hello.advance(32); // 随机数
|
||||
let session_id_len = usize::from(hello.get_u8());
|
||||
hello.advance(session_id_len);
|
||||
let cipher_suites = u16_values(take_vector(&mut hello));
|
||||
@@ -187,6 +224,10 @@ async fn read_client_hello(listener: TcpListener) -> ClientHello {
|
||||
}
|
||||
}
|
||||
ClientHello {
|
||||
legacy_version,
|
||||
supported_versions: u16_values(values[&43].slice(1..)),
|
||||
point_formats: values[&11].slice(1..).to_vec(),
|
||||
psk_modes: values[&45].slice(1..).to_vec(),
|
||||
cipher_suites,
|
||||
extensions: extension_order,
|
||||
groups,
|
||||
@@ -208,3 +249,16 @@ fn u16_values(mut bytes: Bytes) -> Vec<u16> {
|
||||
}
|
||||
values
|
||||
}
|
||||
|
||||
// 仅用于触发自定义 CA 路径,采集器在 ClientHello 后终止握手
|
||||
const TEST_CA_PEM: &str = r"-----BEGIN CERTIFICATE-----
|
||||
MIIBZTCCARegAwIBAgIUbyYrPhPgUSvZ6mOSWi/YkCn0nKIwBQYDK2VwMCgxJjAk
|
||||
BgNVBAMMHUNvZGV4IFRMUyBmaW5nZXJwcmludCB0ZXN0IENBMB4XDTI2MTAwNTE1
|
||||
MDY0N1oXDTM2MTAwMjE1MDY0N1owKDEmMCQGA1UEAwwdQ29kZXggVExTIGZpbmdl
|
||||
cnByaW50IHRlc3QgQ0EwKjAFBgMrZXADIQB2lY3pLKckV8KmxZiVZcmw70+RriQE
|
||||
oAoUPx49BMSE9KNTMFEwHQYDVR0OBBYEFD8ham5c8YWHeYP7rsqbE/+8Sm1NMB8G
|
||||
A1UdIwQYMBaAFD8ham5c8YWHeYP7rsqbE/+8Sm1NMA8GA1UdEwEB/wQFMAMBAf8w
|
||||
BQYDK2VwA0EA15f5R32nl9ke0xc5sNM67R4tUEnxUiG2tUSGYinnFqFgEacMdK37
|
||||
T/qdob9dovtvUOziymxLs80cMCsGpbFiCw==
|
||||
-----END CERTIFICATE-----
|
||||
";
|
||||
|
||||
@@ -646,7 +646,7 @@ async fn retry_after_http_date_should_be_converted_to_remaining_seconds() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_after_http_date_in_the_past_should_be_ignored() {
|
||||
async fn retry_after_http_date_in_the_past_should_preserve_zero_delay() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
@@ -674,7 +674,7 @@ async fn retry_after_http_date_in_the_past_should_be_ignored() {
|
||||
else {
|
||||
panic!("expected an upstream rate-limit error");
|
||||
};
|
||||
assert_eq!(retry_after_seconds, None);
|
||||
assert_eq!(retry_after_seconds, Some(0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -265,7 +265,10 @@ impl ToolNormalizer {
|
||||
}
|
||||
let mut converted = Vec::new();
|
||||
for child in children {
|
||||
if child.pointer("/type").and_then(Value::as_str) != Some("function") {
|
||||
if !matches!(
|
||||
child.get("type").and_then(Value::as_str),
|
||||
Some("function" | "custom")
|
||||
) {
|
||||
return Err(GrokRequestEncodeError::InvalidRequestNormalization);
|
||||
}
|
||||
converted.extend(self.normalize_tool(child, name, client_search, force)?);
|
||||
|
||||
@@ -693,6 +693,36 @@ fn decoder_should_restore_namespace_custom_search_and_apply_patch_wire_events()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_restore_namespaced_custom_tool_identity_and_raw_input() {
|
||||
use serde_json::json;
|
||||
let request = tool_request(json!({"model":"client", "input":"run",
|
||||
"tools":[{"type":"namespace","name":"functions","tools":[{"type":"custom","name":"exec"}]}]
|
||||
}));
|
||||
let item = json!({"type":"function_call","id":"fc_exec","call_id":"call_exec","name":"functions__exec","arguments":"{\"input\":\"echo hello\"}"});
|
||||
let body = [
|
||||
json!({"type":"response.created","response":{"id":"resp_exec"}}),
|
||||
json!({"type":"response.output_item.added","output_index":0,"item":item}),
|
||||
json!({"type":"response.output_item.done","output_index":0,"item":item}),
|
||||
json!({"type":"response.completed","response":{"id":"resp_exec","status":"completed","output":[item]}}),
|
||||
].iter().map(|event| format!("event: {}\ndata: {event}\n\n", event["type"].as_str().unwrap())).collect::<String>();
|
||||
let events = GrokCanonicalDecoder::for_request("grok-4.5", &request)
|
||||
.push(body.as_bytes())
|
||||
.unwrap();
|
||||
let wire = wire_events(&events);
|
||||
let done = wire
|
||||
.iter()
|
||||
.find(|event| event.event_type() == Some("response.output_item.done"))
|
||||
.unwrap();
|
||||
let item = &done.data()["item"];
|
||||
assert_eq!(item["type"], "custom_tool_call");
|
||||
assert_eq!(item["namespace"], "functions");
|
||||
assert_eq!(item["name"], "exec");
|
||||
assert_eq!(item["id"], "ctc_exec");
|
||||
assert_eq!(item["call_id"], "call_exec");
|
||||
assert_eq!(item["input"], "echo hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_restore_custom_apply_patch_arguments_as_raw_input() {
|
||||
let patch = concat!(
|
||||
|
||||
@@ -19,6 +19,71 @@ fn client_key() -> ClientApiKeyId {
|
||||
ClientApiKeyId::new("key_xai_request_test").expect("client key id")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespaced_custom_tools_should_preserve_grammar_choice_and_history() {
|
||||
let grammar = "start: /.+/";
|
||||
let request = raw_request(json!({
|
||||
"model":"client",
|
||||
"tools":[{"type":"namespace","name":"functions","tools":[
|
||||
{"type":"function","name":"lookup","parameters":{"type":"object"}},
|
||||
{"type":"custom","name":"exec","format":{"type":"grammar","syntax":"lark","definition":grammar}}
|
||||
]}],
|
||||
"tool_choice":{"type":"custom","namespace":"functions","name":"exec"},
|
||||
"input":[
|
||||
{"type":"custom_tool_call","namespace":"functions","name":"exec","id":"ctc_exec","call_id":"call_exec","input":"echo hello"},
|
||||
{"type":"custom_tool_call_output","call_id":"call_exec","output":"hello"}
|
||||
]
|
||||
}));
|
||||
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key()).unwrap();
|
||||
let body = Value::Object(encoded.body().clone());
|
||||
assert_eq!(body["tools"][0]["name"], "functions__lookup");
|
||||
assert_eq!(body["tools"][1]["name"], "functions__exec");
|
||||
assert!(
|
||||
body["tools"][1]["description"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains(grammar)
|
||||
);
|
||||
assert_eq!(body["tools"][1]["parameters"]["required"], json!(["input"]));
|
||||
assert_eq!(
|
||||
body["tool_choice"],
|
||||
json!({"type":"function","name":"functions__exec"})
|
||||
);
|
||||
assert_eq!(body["input"][0]["name"], "functions__exec");
|
||||
assert_eq!(body["input"][0]["id"], "fc_exec");
|
||||
assert_eq!(body["input"][0]["call_id"], "call_exec");
|
||||
assert_eq!(
|
||||
body["input"][0]["arguments"],
|
||||
json!("{\"input\":\"echo hello\"}")
|
||||
);
|
||||
assert_eq!(body["input"][1]["call_id"], "call_exec");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn additional_tools_should_accept_namespaced_custom_tools() {
|
||||
let request = raw_request(json!({
|
||||
"model":"client", "input":[{"type":"additional_tools","tools":[
|
||||
{"type":"namespace","name":"functions","tools":[{"type":"custom","name":"exec"}]}
|
||||
]}]
|
||||
}));
|
||||
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key()).unwrap();
|
||||
assert_eq!(encoded.body()["tools"][0]["name"], "functions__exec");
|
||||
assert_eq!(encoded.body()["input"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_should_still_reject_non_function_or_custom_children() {
|
||||
for kind in ["namespace", "web_search", "tool_search", "future_tool"] {
|
||||
let request = raw_request(
|
||||
json!({"tools":[{"type":"namespace","name":"functions","tools":[{"type":kind,"name":"invalid"}]}]}),
|
||||
);
|
||||
assert!(matches!(
|
||||
GrokResponsesRequest::encode(&request, "grok-4.5", &client_key()),
|
||||
Err(GrokRequestEncodeError::InvalidRequestField { field: "tools" })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_should_preserve_raw_images_hosted_tools_and_unknown_fields() {
|
||||
let request = raw_request(json!({
|
||||
|
||||
+10
-5
@@ -399,8 +399,10 @@ Responses wire 之间的协议转换层,转换只在 xAI Provider 内完成。
|
||||
全局精确映射,未命中时模型名原样交给候选 Provider;分组只限定账号集合,不参与模型改名
|
||||
|
||||
OpenAI 明确返回 `server_is_overloaded`、`slow_down` 或模型容量不足错误时,代理在允许安全重放且
|
||||
尚未交付输出的前提下,先做最多 3 次同账号指数退避,再通过现有调度换号。默认间隔从 500ms 开始,
|
||||
上游 `Retry-After` 参与退避计算,单次等待不超过 8 秒;重试同时受请求总尝试次数和截止时间约束。
|
||||
尚未交付输出的前提下,先做最多 3 次同账号重试,再通过现有调度换号。没有有效服务器建议时,
|
||||
按 500ms 起步的指数退避等待,单次最多 8 秒;有效 `Retry-After` 优先,接受秒数、HTTP 日期和零延迟,
|
||||
不受本地退避上限截断。流内失败事件使用相同解析规则,WS 转 HTTP 回退也遵守服务器建议;
|
||||
等待受请求截止时间和取消约束,重试受请求总尝试次数约束。
|
||||
`server_is_overloaded`、`slow_down` 等可计分的结构化错误按已发送的失败尝试计入 Smart 账号
|
||||
健康分。已确认容量拒绝的平滑权重为 0.4,其他可计分失败与成功样本保持 0.2。
|
||||
失败率使用账号级平滑与时间衰减,影响后续普通选路,已有可用账号的
|
||||
@@ -415,6 +417,8 @@ SSE/WS 的 `response.failed` 保留原消息、响应 ID 与其他业务字段
|
||||
客户端决定如何恢复,代理不因此重放已提交的请求。
|
||||
明确额度耗尽触发账号隔离与安全换号,
|
||||
包括 WebSocket 握手返回的 429;不会因其长 `Retry-After` 而转入同账号传输恢复等待。
|
||||
`flex_unavailable` 是当前请求的终止拒绝,保留原始错误,不自动重试、切换传输或冷却账号
|
||||
|
||||
OpenAI 选号阶段确认本次可选账号全部额度耗尽时,HTTP 返回 `429`,WebSocket 错误帧返回
|
||||
`status: 429`,两者的 `error.type` 与 `error.code` 均为 `usage_limit_reached`,提示客户端停止
|
||||
本轮自动重试,等待额度恢复或补充可用账号。空账号池、认证失效和临时容量不足不按额度耗尽处理;
|
||||
@@ -598,9 +602,10 @@ OpenAI 点数随现有额度刷新和正常请求的额度信息同步,不与
|
||||
字符,允许换行和制表符,保存时去除首尾空白,空字符串清空备注。备注独立于上游身份,导入时未显式提供备注、
|
||||
重新授权、凭据刷新及批量调度更新均保留已有备注
|
||||
|
||||
账号视图和 Dashboard 账号概览中的 `planType` 保留原始套餐值;`planTypeDisplay` 由后端先按 Provider 解析名称,
|
||||
再统一为大驼峰格式,前端直接展示该字段,例如 `Free`、`SuperGrokPro`、`EduPlus`。
|
||||
OpenAI 的 `self_serve_business_prolite` 等 Team 套餐显示为 `Business`;新套餐也使用相同格式。
|
||||
账号视图和 Dashboard 账号概览中的 `planType` 保留原始套餐值;`planTypeDisplay` 使用 Provider 提供的最终名称,
|
||||
前端直接展示。OpenAI 的 `prolite`、`pro`、`promax` 分别显示为 `ProLite`、`Pro`、`ProMax`;
|
||||
其余已知套餐使用官方名称,未知 OpenAI 套餐保留原值。
|
||||
展示名称保留空格和括号,未提供专属名称规则的 Provider 使用通用大驼峰格式。
|
||||
OpenAI 主动额度刷新和正常响应携带的明确套餐会同步到账号,支持升级与降级;
|
||||
空值或 `unknown` 不覆盖已有套餐,同族泛化值(如 `team`)保留已知的具体套餐子类型。
|
||||
账号套餐为空或 `unknown` 时,后端优先用已保存的上游额度响应
|
||||
|
||||
@@ -369,6 +369,7 @@ Client Key 鉴权完成后,API adapter 从有界请求头识别 Codex Desktop/
|
||||
重试与提交边界。具体错误合同见 [数据面接口](api.md#3-openai-数据面与模型目录)
|
||||
- Provider 可将明确容量拒绝标记为有界同账号退避,Core 在既有安全重放边界内执行,按账号维护请求内
|
||||
预算,耗尽后复用普通换号路径。该退避消耗总路由预算,与 WS 传输恢复、OAuth 刷新及账号额度冷却分开
|
||||
- Provider 显式标记的终止拒绝禁止自动重试与传输回退,优先于未发送状态和安全重放证明
|
||||
- 跨 Provider 只在账号范围和能力都允许,且请求尚未到达上游或已被证明可安全重放时发生
|
||||
- 可恢复观测写入失败不能替换已经确定的客户端协议结果
|
||||
|
||||
|
||||
Reference in New Issue
Block a user