mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 08:05:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
776637fccb | ||
|
|
82acb08d26 | ||
|
|
7744e1b0b4 | ||
|
|
0b8a1add18 | ||
|
|
2dd60c08b6 | ||
|
|
cce9880937 | ||
|
|
b8ea1cd076 | ||
|
|
4117840f75 | ||
|
|
f642764f34 | ||
|
|
482e192e2d | ||
|
|
ee51a07de4 | ||
|
|
bf23658aaf | ||
|
|
bc4728332e | ||
|
|
2cb5cda077 | ||
|
|
9d90dedb81 | ||
|
|
6074cff68c | ||
|
|
6ab8e7d0a6 | ||
|
|
17f9ef8915 | ||
|
|
15e0a28bd0 |
Generated
+9
-1
@@ -2581,6 +2581,7 @@ dependencies = [
|
||||
"cookie",
|
||||
"crypto_box",
|
||||
"ed25519-dalek",
|
||||
"flate2",
|
||||
"futures",
|
||||
"gateway-admin",
|
||||
"gateway-core",
|
||||
@@ -2608,6 +2609,7 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"tungstenite 0.27.0",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"wiremock",
|
||||
"zstd",
|
||||
@@ -3623,7 +3625,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -4078,6 +4080,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
|
||||
@@ -135,7 +135,10 @@ fn config_loader_should_reject_removed_fingerprint_section() {
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_invalid_codex_cli_version() {
|
||||
assert_rejected(valid_config().replace("codex_version: '0.146.0'", "codex_version: 'latest'"));
|
||||
assert_rejected(valid_config().replace(
|
||||
"codex_version: '0.147.0-alpha.6.6'",
|
||||
"codex_version: 'latest'",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -154,7 +157,7 @@ fn config_loader_should_reject_zero_server_port() {
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_invalid_desktop_profile_fields() {
|
||||
assert_rejected(valid_config().replace("desktop_build: '6119'", "desktop_build: 'build'"));
|
||||
assert_rejected(valid_config().replace("desktop_build: '6415'", "desktop_build: 'build'"));
|
||||
}
|
||||
|
||||
fn assert_rejected(config: String) {
|
||||
|
||||
@@ -608,8 +608,9 @@ pub struct ProviderQuota {
|
||||
impl ProviderQuota {
|
||||
/// 返回账号展开面板使用的当前额度窗口用量。
|
||||
///
|
||||
/// 只选择具备完整时间边界且可按账号归属的窗口;优先级与 Dashboard
|
||||
/// 代表性额度窗口一致,同一优先级保持 Provider 投影顺序。
|
||||
/// Provider 已提供账号级本地用量时,固定窗口和无固定重置点的滚动窗口
|
||||
/// 都可作为代表用量;优先级与 Dashboard 代表性额度窗口一致,
|
||||
/// 同一优先级保持 Provider 投影顺序。
|
||||
#[must_use]
|
||||
pub fn representative_window_usage(&self) -> Option<&AccountUsage> {
|
||||
self.windows
|
||||
@@ -618,7 +619,6 @@ impl ProviderQuota {
|
||||
.filter(|(_, window)| {
|
||||
window.local_usage_attribution == QuotaLocalUsageAttribution::AccountWide
|
||||
&& window.window_seconds.is_some()
|
||||
&& window.reset_at.is_some()
|
||||
&& window.local_usage.is_some()
|
||||
})
|
||||
.min_by_key(|(index, window)| (quota_usage_priority(window), *index))
|
||||
@@ -631,25 +631,74 @@ impl ProviderQuota {
|
||||
/// 这里只解释跨 Provider 共享的窗口语义,绝不读取 Provider 私有 JSON。
|
||||
#[must_use]
|
||||
pub fn representative_used_percent(&self) -> Option<f64> {
|
||||
if self.limit_reached {
|
||||
return Some(100.0);
|
||||
}
|
||||
self.representative_used_window()
|
||||
.map(|(_, used_percent)| used_percent)
|
||||
}
|
||||
|
||||
/// 将已确认的账号级额度耗尽事实投影到展示窗口。
|
||||
///
|
||||
/// Provider 已指出具体触顶窗口时只归一化这些窗口;否则归一化 Dashboard
|
||||
/// 同样会选择的代表窗口,避免把多个独立额度窗口全部伪造成已用尽。
|
||||
pub fn apply_limit_reached_display(&mut self) {
|
||||
if !self.limit_reached {
|
||||
return;
|
||||
}
|
||||
let reached_windows = self
|
||||
.windows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, window)| window.limit_reached.then_some(index))
|
||||
.collect::<Vec<_>>();
|
||||
if !reached_windows.is_empty() {
|
||||
for index in reached_windows {
|
||||
self.windows[index].used_percent = Some(100.0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let representative = self
|
||||
.representative_used_window()
|
||||
.map(|(index, _)| index)
|
||||
.or_else(|| {
|
||||
self.windows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by_key(|(index, window)| (quota_usage_priority(window), *index))
|
||||
.map(|(index, _)| index)
|
||||
});
|
||||
if let Some(index) = representative {
|
||||
self.windows[index].used_percent = Some(100.0);
|
||||
self.windows[index].limit_reached = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn representative_used_window(&self) -> Option<(usize, f64)> {
|
||||
self.windows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|window| {
|
||||
let (index, window) = window;
|
||||
let used_percent = window
|
||||
.used_percent
|
||||
.filter(|value| value.is_finite())
|
||||
.map(|value| value.clamp(0.0, 100.0))?;
|
||||
Some((quota_usage_priority(window), used_percent))
|
||||
Some((index, quota_usage_priority(window), used_percent))
|
||||
})
|
||||
.fold(None::<(u8, f64)>, |selected, candidate| match selected {
|
||||
Some(current)
|
||||
if current.0 < candidate.0
|
||||
|| (current.0 == candidate.0 && current.1 >= candidate.1) =>
|
||||
{
|
||||
.fold(
|
||||
None::<(usize, u8, f64)>,
|
||||
|selected, candidate| match selected {
|
||||
Some(current)
|
||||
}
|
||||
_ => Some(candidate),
|
||||
})
|
||||
.map(|(_, used_percent)| used_percent)
|
||||
if current.1 < candidate.1
|
||||
|| (current.1 == candidate.1 && current.2 >= candidate.2) =>
|
||||
{
|
||||
Some(current)
|
||||
}
|
||||
_ => Some(candidate),
|
||||
},
|
||||
)
|
||||
.map(|(index, _, used_percent)| (index, used_percent))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use gateway_core::engine::credential::OpaqueProviderData;
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
PageSize, Revision,
|
||||
accounts::AccountUsage,
|
||||
auth::LoginCommand,
|
||||
client_keys::ClientKeyPageSize,
|
||||
observability::{DecimalAmount, RequestOutcome, TimeRange},
|
||||
@@ -135,6 +136,95 @@ fn representative_quota_should_prefer_short_window_and_highest_usage() {
|
||||
assert_eq!(quota.representative_used_percent(), Some(45.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn representative_window_usage_should_accept_account_wide_rolling_window_without_reset() {
|
||||
let usage = AccountUsage {
|
||||
account_id: "acct_xai".to_owned(),
|
||||
request_count: 5,
|
||||
success_count: 5,
|
||||
input_tokens: Some(100),
|
||||
output_tokens: Some(20),
|
||||
cached_tokens: Some(80),
|
||||
cache_write_tokens: Some(0),
|
||||
reasoning_tokens: Some(0),
|
||||
image_input_tokens: Some(0),
|
||||
image_output_tokens: Some(0),
|
||||
image_request_count: 0,
|
||||
image_request_failed_count: 0,
|
||||
total_tokens: Some(120),
|
||||
cost_coverage: Default::default(),
|
||||
costs: Vec::new(),
|
||||
last_used_at: Some(Utc::now()),
|
||||
request_buckets: Vec::new(),
|
||||
models: Vec::new(),
|
||||
};
|
||||
let quota = ProviderQuota {
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows: vec![ProviderQuotaWindow {
|
||||
key: "free-rolling-24h".to_owned(),
|
||||
group: "shortTerm".to_owned(),
|
||||
label: "日限额".to_owned(),
|
||||
source: None,
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds: Some(86_400),
|
||||
used_percent: None,
|
||||
reset_at: None,
|
||||
limit_reached: false,
|
||||
local_usage: Some(usage.clone()),
|
||||
provider_data: None,
|
||||
}],
|
||||
limit_reached: false,
|
||||
provider_data: None,
|
||||
};
|
||||
|
||||
assert_eq!(quota.representative_window_usage(), Some(&usage));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exhausted_quota_should_project_full_usage_to_only_the_representative_window() {
|
||||
let mut quota = ProviderQuota {
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows: vec![
|
||||
quota_window("monthly", Some(2_592_000), Some(99.0)),
|
||||
quota_window("shortTerm", Some(604_800), Some(80.0)),
|
||||
quota_window("shortTerm", Some(18_000), Some(30.0)),
|
||||
quota_window("shortTerm", Some(14_400), Some(95.0)),
|
||||
],
|
||||
limit_reached: true,
|
||||
provider_data: None,
|
||||
};
|
||||
|
||||
assert_eq!(quota.representative_used_percent(), Some(100.0));
|
||||
quota.apply_limit_reached_display();
|
||||
|
||||
assert_eq!(quota.windows[0].used_percent, Some(99.0));
|
||||
assert_eq!(quota.windows[1].used_percent, Some(80.0));
|
||||
assert_eq!(quota.windows[2].used_percent, Some(30.0));
|
||||
assert_eq!(quota.windows[3].used_percent, Some(100.0));
|
||||
assert!(quota.windows[3].limit_reached);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exhausted_quota_should_preserve_the_provider_identified_reached_window() {
|
||||
let mut reached = quota_window("monthly", Some(2_592_000), Some(98.0));
|
||||
reached.limit_reached = true;
|
||||
let mut quota = ProviderQuota {
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows: vec![reached, quota_window("shortTerm", Some(18_000), Some(95.0))],
|
||||
limit_reached: true,
|
||||
provider_data: None,
|
||||
};
|
||||
|
||||
quota.apply_limit_reached_display();
|
||||
|
||||
assert_eq!(quota.windows[0].used_percent, Some(100.0));
|
||||
assert_eq!(quota.windows[1].used_percent, Some(95.0));
|
||||
assert!(!quota.windows[1].limit_reached);
|
||||
}
|
||||
|
||||
fn quota_window(
|
||||
group: &str,
|
||||
window_seconds: Option<u64>,
|
||||
|
||||
@@ -1574,10 +1574,11 @@ fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> AccountView {
|
||||
}
|
||||
|
||||
fn account_quota_view(
|
||||
quota: ProviderQuota,
|
||||
mut quota: ProviderQuota,
|
||||
rate_limited_until: Option<DateTime<Utc>>,
|
||||
now: DateTime<Utc>,
|
||||
) -> (AccountQuotaView, Option<String>) {
|
||||
quota.apply_limit_reached_display();
|
||||
let refresh_token_expires_at = quota
|
||||
.refresh_token_expires_at
|
||||
.map(|value| china_rfc3339(&value));
|
||||
|
||||
@@ -1588,14 +1588,26 @@ impl AccountSelector {
|
||||
|
||||
let candidate = match context.policy.strategy() {
|
||||
RotationStrategy::QuotaResetPriority => {
|
||||
eligible.sort_by_key(|candidate| {
|
||||
let default_concurrency = context.policy.max_concurrent_per_account();
|
||||
eligible.sort_by(|left, right| {
|
||||
(
|
||||
candidate.signals.quota_reset_at.is_none(),
|
||||
candidate.signals.quota_reset_at,
|
||||
candidate.signals.in_flight,
|
||||
candidate.signals.last_started_at,
|
||||
candidate.account.id().clone(),
|
||||
left.signals.quota_reset_at.is_none(),
|
||||
left.signals.quota_reset_at,
|
||||
)
|
||||
.cmp(&(
|
||||
right.signals.quota_reset_at.is_none(),
|
||||
right.signals.quota_reset_at,
|
||||
))
|
||||
.then_with(|| {
|
||||
capacity_utilization(left, default_concurrency)
|
||||
.total_cmp(&capacity_utilization(right, default_concurrency))
|
||||
})
|
||||
.then_with(|| {
|
||||
left.signals
|
||||
.last_started_at
|
||||
.cmp(&right.signals.last_started_at)
|
||||
})
|
||||
.then_with(|| left.account.id().cmp(right.account.id()))
|
||||
});
|
||||
eligible.first().copied()?
|
||||
}
|
||||
@@ -1604,9 +1616,11 @@ impl AccountSelector {
|
||||
let index = context.round_robin_cursor as usize % eligible.len();
|
||||
eligible.get(index).copied()?
|
||||
}
|
||||
RotationStrategy::Smart => {
|
||||
select_smart_candidate(&eligible, context.round_robin_cursor)?
|
||||
}
|
||||
RotationStrategy::Smart => select_smart_candidate(
|
||||
&eligible,
|
||||
context.policy.max_concurrent_per_account(),
|
||||
context.round_robin_cursor,
|
||||
)?,
|
||||
RotationStrategy::Sticky => {
|
||||
eligible.sort_by_key(|candidate| {
|
||||
(
|
||||
@@ -1676,8 +1690,18 @@ const SMART_QUOTA_WEIGHT: f64 = 0.8;
|
||||
const SMART_FAILURE_WEIGHT: f64 = 1.0;
|
||||
const SMART_LATENCY_WEIGHT: f64 = 0.5;
|
||||
|
||||
fn capacity_utilization(candidate: &AccountCandidate, default_concurrency: NonZeroU32) -> f64 {
|
||||
f64::from(candidate.signals.in_flight)
|
||||
/ f64::from(
|
||||
candidate
|
||||
.account
|
||||
.effective_concurrency(default_concurrency)
|
||||
.get(),
|
||||
)
|
||||
}
|
||||
|
||||
struct SmartNormalization {
|
||||
max_in_flight: u32,
|
||||
max_capacity_utilization: f64,
|
||||
min_quota: Option<u64>,
|
||||
max_quota: Option<u64>,
|
||||
min_latency_ms: Option<u64>,
|
||||
@@ -1685,11 +1709,11 @@ struct SmartNormalization {
|
||||
}
|
||||
|
||||
impl SmartNormalization {
|
||||
fn from_candidates(candidates: &[&AccountCandidate]) -> Self {
|
||||
let max_in_flight = candidates
|
||||
fn from_candidates(candidates: &[&AccountCandidate], default_concurrency: NonZeroU32) -> Self {
|
||||
let max_capacity_utilization = candidates
|
||||
.iter()
|
||||
.map(|candidate| candidate.signals.in_flight)
|
||||
.max()
|
||||
.map(|candidate| capacity_utilization(candidate, default_concurrency))
|
||||
.reduce(f64::max)
|
||||
.unwrap_or_default();
|
||||
let min_quota = candidates
|
||||
.iter()
|
||||
@@ -1710,7 +1734,7 @@ impl SmartNormalization {
|
||||
.filter(|latency| *latency > 0)
|
||||
.max();
|
||||
Self {
|
||||
max_in_flight,
|
||||
max_capacity_utilization,
|
||||
min_quota,
|
||||
max_quota,
|
||||
min_latency_ms,
|
||||
@@ -1721,12 +1745,18 @@ impl SmartNormalization {
|
||||
|
||||
fn select_smart_candidate<'a>(
|
||||
candidates: &[&'a AccountCandidate],
|
||||
default_concurrency: NonZeroU32,
|
||||
cursor: u64,
|
||||
) -> Option<&'a AccountCandidate> {
|
||||
let normalization = SmartNormalization::from_candidates(candidates);
|
||||
let normalization = SmartNormalization::from_candidates(candidates, default_concurrency);
|
||||
let mut ranked = candidates
|
||||
.iter()
|
||||
.map(|candidate| (*candidate, smart_score(candidate, &normalization)))
|
||||
.map(|candidate| {
|
||||
(
|
||||
*candidate,
|
||||
smart_score(candidate, default_concurrency, &normalization),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
ranked.sort_by(|(left, left_score), (right, right_score)| {
|
||||
right_score
|
||||
@@ -1742,11 +1772,14 @@ fn select_smart_candidate<'a>(
|
||||
Some(ranked[index].0)
|
||||
}
|
||||
|
||||
fn smart_score(candidate: &AccountCandidate, normalization: &SmartNormalization) -> f64 {
|
||||
let load = lower_is_better(
|
||||
u64::from(candidate.signals.in_flight),
|
||||
0,
|
||||
u64::from(normalization.max_in_flight),
|
||||
fn smart_score(
|
||||
candidate: &AccountCandidate,
|
||||
default_concurrency: NonZeroU32,
|
||||
normalization: &SmartNormalization,
|
||||
) -> f64 {
|
||||
let load = lower_ratio_is_better(
|
||||
capacity_utilization(candidate, default_concurrency),
|
||||
normalization.max_capacity_utilization,
|
||||
);
|
||||
let quota = candidate.signals.quota_remaining_rank.map_or(0.5, |quota| {
|
||||
higher_is_better(
|
||||
@@ -1781,6 +1814,13 @@ fn smart_score(candidate: &AccountCandidate, normalization: &SmartNormalization)
|
||||
+ SMART_LATENCY_WEIGHT * latency
|
||||
}
|
||||
|
||||
fn lower_ratio_is_better(value: f64, maximum: f64) -> f64 {
|
||||
if maximum <= 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
1.0 - (value / maximum).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn lower_is_better(value: u64, minimum: u64, maximum: u64) -> f64 {
|
||||
if maximum <= minimum {
|
||||
return 1.0;
|
||||
|
||||
@@ -407,6 +407,84 @@ pub trait ProviderCatalogCachePort: Send + Sync {
|
||||
) -> BoxFuture<'a, Result<Option<OpaqueProviderData>, ProviderStoreError>>;
|
||||
}
|
||||
|
||||
/// Provider 从官方制品核验出的可重建请求画像。
|
||||
///
|
||||
/// Core 只用单调制品序号约束覆盖顺序;具体版本字段由对应 Provider 放在
|
||||
/// `profile` 中解释。每个 Provider 在 Store 中只保留一份最新画像。
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct ProviderArtifactProfile {
|
||||
provider_kind: ProviderKind,
|
||||
artifact_sequence: u64,
|
||||
verified_at: SystemTime,
|
||||
profile: OpaqueProviderData,
|
||||
}
|
||||
|
||||
impl ProviderArtifactProfile {
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
provider_kind: ProviderKind,
|
||||
artifact_sequence: u64,
|
||||
verified_at: SystemTime,
|
||||
profile: OpaqueProviderData,
|
||||
) -> Self {
|
||||
Self {
|
||||
provider_kind,
|
||||
artifact_sequence,
|
||||
verified_at,
|
||||
profile,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn provider_kind(&self) -> &ProviderKind {
|
||||
&self.provider_kind
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn artifact_sequence(&self) -> u64 {
|
||||
self.artifact_sequence
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn verified_at(&self) -> SystemTime {
|
||||
self.verified_at
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn profile(&self) -> &OpaqueProviderData {
|
||||
&self.profile
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ProviderArtifactProfile {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ProviderArtifactProfile")
|
||||
.field("provider_kind", &self.provider_kind)
|
||||
.field("artifact_sequence", &self.artifact_sequence)
|
||||
.field("verified_at", &self.verified_at)
|
||||
.field("profile", &"[PROVIDER-OWNED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ProviderArtifactProfileCachePort: Send + Sync {
|
||||
/// 覆盖同一 Provider 的固定 cache key。
|
||||
///
|
||||
/// 返回 `false` 表示 Store 已持有更高的制品序号;相同序号但内容不同必须返回
|
||||
/// [`ProviderStoreErrorKind::Conflict`],不能静默改写已核验画像。
|
||||
fn replace_if_newer(
|
||||
&self,
|
||||
profile: ProviderArtifactProfile,
|
||||
ttl: Duration,
|
||||
) -> BoxFuture<'_, Result<bool, ProviderStoreError>>;
|
||||
|
||||
fn read<'a>(
|
||||
&'a self,
|
||||
provider_kind: &'a ProviderKind,
|
||||
) -> BoxFuture<'a, Result<Option<ProviderArtifactProfile>, ProviderStoreError>>;
|
||||
}
|
||||
|
||||
/// Redis 中可重建的账号状态投影。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderCredentialState {
|
||||
@@ -946,6 +1024,7 @@ pub struct ProviderStorePorts {
|
||||
session_exclusions: Arc<dyn ProviderSessionExclusionPort>,
|
||||
account_feedback: Arc<AccountFeedbackStats>,
|
||||
catalog_cache: Arc<dyn ProviderCatalogCachePort>,
|
||||
artifact_profiles: Arc<dyn ProviderArtifactProfileCachePort>,
|
||||
credential_state: Arc<dyn ProviderCredentialStatePort>,
|
||||
cooldowns: Arc<dyn ProviderCooldownPort>,
|
||||
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
|
||||
@@ -962,6 +1041,7 @@ impl ProviderStorePorts {
|
||||
session_affinity: Arc<dyn ProviderSessionAffinityPort>,
|
||||
session_exclusions: Arc<dyn ProviderSessionExclusionPort>,
|
||||
catalog_cache: Arc<dyn ProviderCatalogCachePort>,
|
||||
artifact_profiles: Arc<dyn ProviderArtifactProfileCachePort>,
|
||||
credential_state: Arc<dyn ProviderCredentialStatePort>,
|
||||
cooldowns: Arc<dyn ProviderCooldownPort>,
|
||||
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
|
||||
@@ -974,6 +1054,7 @@ impl ProviderStorePorts {
|
||||
session_exclusions,
|
||||
account_feedback: Arc::new(AccountFeedbackStats::default()),
|
||||
catalog_cache,
|
||||
artifact_profiles,
|
||||
credential_state,
|
||||
cooldowns,
|
||||
runtime_policy,
|
||||
@@ -1011,6 +1092,11 @@ impl ProviderStorePorts {
|
||||
Arc::clone(&self.catalog_cache)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn artifact_profiles(&self) -> Arc<dyn ProviderArtifactProfileCachePort> {
|
||||
Arc::clone(&self.artifact_profiles)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn credential_state(&self) -> Arc<dyn ProviderCredentialStatePort> {
|
||||
Arc::clone(&self.credential_state)
|
||||
|
||||
@@ -51,6 +51,15 @@ fn candidate(id: &str, in_flight: u32, remaining: Option<u64>) -> AccountCandida
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate_with_concurrency(id: &str, in_flight: u32, concurrency: u32) -> AccountCandidate {
|
||||
let mut candidate = candidate(id, in_flight, Some(100));
|
||||
candidate.account = candidate.account.with_scheduling(
|
||||
Some(AccountConcurrencyLimit::new(concurrency).expect("concurrency override")),
|
||||
AccountWeight::DEFAULT,
|
||||
);
|
||||
candidate
|
||||
}
|
||||
|
||||
fn weighted_candidate(id: &str, weight: u16, in_flight: u32) -> AccountCandidate {
|
||||
let mut candidate = candidate(id, in_flight, None);
|
||||
candidate.account = candidate.account.with_scheduling(
|
||||
@@ -348,6 +357,20 @@ fn smart_selector_should_prefer_lower_inflight_count() {
|
||||
assert_eq!(selected.candidate().account.id().as_str(), "acct_idle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_selector_should_prefer_lower_capacity_utilization_over_lower_inflight_count() {
|
||||
let candidates = [
|
||||
candidate_with_concurrency("acct_smaller", 1, 2),
|
||||
candidate_with_concurrency("acct_larger", 2, 10),
|
||||
];
|
||||
|
||||
let selected = AccountSelector
|
||||
.select(&candidates, &context(RotationStrategy::Smart))
|
||||
.expect("candidate available");
|
||||
|
||||
assert_eq!(selected.candidate().account.id().as_str(), "acct_larger");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_should_prioritize_the_highest_weight_for_every_rotation_strategy() {
|
||||
let candidates = vec![
|
||||
@@ -622,6 +645,22 @@ fn quota_reset_selector_should_prefer_known_earliest_window() {
|
||||
assert_eq!(selected.candidate().account.id().as_str(), "acct_earlier");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quota_reset_selector_should_use_capacity_utilization_as_load_tiebreaker() {
|
||||
let reset_at = SystemTime::now() + Duration::from_secs(60);
|
||||
let mut smaller = candidate_with_concurrency("acct_smaller", 1, 2);
|
||||
smaller.signals.quota_reset_at = Some(reset_at);
|
||||
let mut larger = candidate_with_concurrency("acct_larger", 2, 10);
|
||||
larger.signals.quota_reset_at = Some(reset_at);
|
||||
let candidates = [smaller, larger];
|
||||
|
||||
let selected = AccountSelector
|
||||
.select(&candidates, &context(RotationStrategy::QuotaResetPriority))
|
||||
.expect("candidate available");
|
||||
|
||||
assert_eq!(selected.candidate().account.id().as_str(), "acct_larger");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_selector_should_prefer_known_quota_over_unknown_after_load_is_equal() {
|
||||
let candidates = vec![
|
||||
|
||||
@@ -70,7 +70,7 @@ pub const MAX_SSE_EVENT_BUFFER_BYTES: usize = 64 * 1024 * 1024;
|
||||
pub const DONE_SSE_FRAME: &str = "data: [DONE]\n\n";
|
||||
|
||||
/// 用于处理任意分块边界的增量 SSE 解码器。
|
||||
#[derive(Debug, Default)]
|
||||
#[derive(Debug)]
|
||||
pub struct SseEventDecoder {
|
||||
pending: Vec<u8>,
|
||||
/// `pending` 中已确认不含帧分隔符的前缀长度。新数据到达时从该边界
|
||||
@@ -79,6 +79,19 @@ pub struct SseEventDecoder {
|
||||
/// 超大未完成帧已开始直接交付;在遇到帧分隔符前只保留跨 chunk 扫描尾部,
|
||||
/// 不再尝试解析该帧。
|
||||
opaque_frame: bool,
|
||||
/// 只有整个 SSE 流的第一条物理行允许携带 UTF-8 BOM。
|
||||
stream_start: bool,
|
||||
}
|
||||
|
||||
impl Default for SseEventDecoder {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pending: Vec::new(),
|
||||
scanned: 0,
|
||||
opaque_frame: false,
|
||||
stream_start: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEventDecoder {
|
||||
@@ -115,7 +128,8 @@ impl SseEventDecoder {
|
||||
while let Some(end) = self.next_frame_end(consumed) {
|
||||
let frame = std::str::from_utf8(&self.pending[consumed..end])
|
||||
.map_err(|error| SseError::ParseError(error.to_string()))?;
|
||||
events.extend(parse_sse_events(frame)?);
|
||||
events.extend(parse_sse_events_inner(frame, self.stream_start)?);
|
||||
self.stream_start = false;
|
||||
consumed = end;
|
||||
}
|
||||
|
||||
@@ -149,9 +163,10 @@ impl SseEventDecoder {
|
||||
} else {
|
||||
std::str::from_utf8(&raw)
|
||||
.ok()
|
||||
.and_then(|frame| parse_sse_events(frame).ok())
|
||||
.and_then(|frame| parse_sse_events_inner(frame, self.stream_start).ok())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
self.stream_start = false;
|
||||
frames.push(SseFrame::new(raw, events));
|
||||
consumed = end;
|
||||
}
|
||||
@@ -173,7 +188,9 @@ impl SseEventDecoder {
|
||||
let pending = std::mem::take(&mut self.pending);
|
||||
let frame = std::str::from_utf8(&pending)
|
||||
.map_err(|error| SseError::ParseError(error.to_string()))?;
|
||||
parse_sse_events(frame)
|
||||
let events = parse_sse_events_inner(frame, self.stream_start)?;
|
||||
self.stream_start = false;
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
/// 在流结束时返回未带空行分隔符的最后一帧及其旁路解析结果。
|
||||
@@ -191,9 +208,10 @@ impl SseEventDecoder {
|
||||
} else {
|
||||
std::str::from_utf8(&raw)
|
||||
.ok()
|
||||
.and_then(|frame| parse_sse_events(frame).ok())
|
||||
.and_then(|frame| parse_sse_events_inner(frame, self.stream_start).ok())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
self.stream_start = false;
|
||||
self.opaque_frame = false;
|
||||
vec![SseFrame::new(raw, events)]
|
||||
}
|
||||
@@ -207,6 +225,7 @@ impl SseEventDecoder {
|
||||
let remaining = self.pending.split_off(end);
|
||||
let raw = std::mem::replace(&mut self.pending, remaining);
|
||||
frames.push(SseFrame::new(raw, Vec::new()));
|
||||
self.stream_start = false;
|
||||
self.opaque_frame = false;
|
||||
self.scanned = 0;
|
||||
true
|
||||
@@ -226,6 +245,7 @@ impl SseEventDecoder {
|
||||
let raw = std::mem::replace(&mut self.pending, tail);
|
||||
self.scanned = 0;
|
||||
frames.push(SseFrame::new(raw, Vec::new()));
|
||||
self.stream_start = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,12 +371,21 @@ impl EventBuilder {
|
||||
|
||||
/// 解析 SSE 事件流。
|
||||
pub fn parse_sse_events(input: &str) -> Result<Vec<SseEvent>, SseError> {
|
||||
parse_sse_events_inner(input, true)
|
||||
}
|
||||
|
||||
fn parse_sse_events_inner(input: &str, strip_leading_bom: bool) -> Result<Vec<SseEvent>, SseError> {
|
||||
let mut events = Vec::new();
|
||||
let mut builder = EventBuilder::default();
|
||||
let mut saw_sse_syntax = false;
|
||||
let mut event_buffer_bytes = 0usize;
|
||||
|
||||
for raw_line in input.lines() {
|
||||
for (index, raw_line) in input.lines().enumerate() {
|
||||
let raw_line = if strip_leading_bom && index == 0 {
|
||||
raw_line.strip_prefix('\u{feff}').unwrap_or(raw_line)
|
||||
} else {
|
||||
raw_line
|
||||
};
|
||||
let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
|
||||
if line.is_empty() {
|
||||
event_buffer_bytes = 0;
|
||||
|
||||
@@ -31,6 +31,50 @@ fn parser_should_preserve_multiline_data_id_and_retry() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_should_ignore_a_leading_utf8_bom() {
|
||||
let input = concat!(
|
||||
"\u{feff}event: response.created\n",
|
||||
"data: {\"type\":\"response.created\"}\n\n",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parse_sse_events(input).expect("SSE with BOM"),
|
||||
vec![SseEvent {
|
||||
event: Some("response.created".to_owned()),
|
||||
data: "{\"type\":\"response.created\"}".to_owned(),
|
||||
id: None,
|
||||
retry: None,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incremental_decoder_should_ignore_bom_only_at_the_stream_start() {
|
||||
let mut decoder = SseEventDecoder::default();
|
||||
let first = decoder
|
||||
.push(
|
||||
concat!(
|
||||
"\u{feff}event: response.created\n",
|
||||
"data: {\"type\":\"response.created\"}\n\n",
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("first SSE frame");
|
||||
let second = decoder
|
||||
.push(
|
||||
concat!(
|
||||
"\u{feff}event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\"}\n\n",
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("second SSE frame");
|
||||
|
||||
assert_eq!(first[0].event.as_deref(), Some("response.created"));
|
||||
assert_eq!(second[0].event, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_should_ignore_done_control_frame() {
|
||||
assert!(
|
||||
|
||||
@@ -79,6 +79,10 @@ pub async fn initialize(mut config: StoreConfig) -> StoreResult<StoreBundle> {
|
||||
redis_connection.clone(),
|
||||
REDIS_NAMESPACE,
|
||||
)?);
|
||||
let artifact_profiles = Arc::new(redis::RedisProviderArtifactProfileRepository::new(
|
||||
redis_connection.clone(),
|
||||
REDIS_NAMESPACE,
|
||||
)?);
|
||||
let runtime_policy = Arc::new(postgres::PgRuntimeSettingsRepository::new(pool.clone()));
|
||||
let oauth_pending = Arc::new(redis::RedisOAuthPendingFlowRepository::new(
|
||||
redis_connection.clone(),
|
||||
@@ -167,6 +171,7 @@ pub async fn initialize(mut config: StoreConfig) -> StoreResult<StoreBundle> {
|
||||
REDIS_NAMESPACE,
|
||||
)?),
|
||||
credential_state.clone(),
|
||||
artifact_profiles,
|
||||
credential_state,
|
||||
cooldowns,
|
||||
runtime_policy,
|
||||
|
||||
@@ -52,9 +52,12 @@ pub(crate) async fn provider_account_usage(
|
||||
}
|
||||
statement.push(
|
||||
" group by pa.id, pa.provider_kind, pa.authentication_kind, pa.name, pa.email,
|
||||
pa.plan_type
|
||||
order by max(mr.started_at) desc nulls last, pa.name, pa.id limit ",
|
||||
pa.plan_type",
|
||||
);
|
||||
if query.account_ids.is_none() {
|
||||
statement.push(" having count(mr.id) > 0");
|
||||
}
|
||||
statement.push(" order by max(mr.started_at) desc nulls last, pa.name, pa.id limit ");
|
||||
statement.push_bind(i64::from(query.limit));
|
||||
let rows = statement
|
||||
.build()
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Provider 官方制品画像的单键 Redis cache。
|
||||
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use gateway_core::engine::credential::OpaqueProviderData;
|
||||
use gateway_core::provider_ports::{
|
||||
ProviderArtifactProfile, ProviderArtifactProfileCachePort, ProviderStoreError,
|
||||
ProviderStoreErrorKind,
|
||||
};
|
||||
use gateway_core::routing::ProviderKind;
|
||||
use redis::{Script, aio::ConnectionManager};
|
||||
|
||||
use crate::StoreResult;
|
||||
|
||||
use super::{MAX_REDIS_EXACT_INTEGER, namespace};
|
||||
|
||||
const MAX_PROFILE_BYTES: usize = 16 * 1024;
|
||||
|
||||
const REPLACE_SCRIPT: &str = r#"
|
||||
local current_sequence = redis.call('HGET', KEYS[1], 'artifact_sequence')
|
||||
if current_sequence then
|
||||
local current_number = tonumber(current_sequence)
|
||||
local candidate_number = tonumber(ARGV[1])
|
||||
if current_number > candidate_number then return 0 end
|
||||
if current_number == candidate_number then
|
||||
local current_profile = redis.call('HGET', KEYS[1], 'profile')
|
||||
if current_profile ~= ARGV[3] then return -1 end
|
||||
end
|
||||
end
|
||||
redis.call('HSET', KEYS[1],
|
||||
'artifact_sequence', ARGV[1],
|
||||
'verified_at_ms', ARGV[2],
|
||||
'profile', ARGV[3])
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[4])
|
||||
return 1
|
||||
"#;
|
||||
|
||||
/// 每个 Provider 只占一个覆盖写 key;TTL 负责进程长期停机后的最终清理。
|
||||
#[derive(Clone)]
|
||||
pub struct RedisProviderArtifactProfileRepository {
|
||||
connection: ConnectionManager,
|
||||
namespace: String,
|
||||
}
|
||||
|
||||
impl RedisProviderArtifactProfileRepository {
|
||||
pub fn new(connection: ConnectionManager, key_namespace: &str) -> StoreResult<Self> {
|
||||
Ok(Self {
|
||||
connection,
|
||||
namespace: namespace(key_namespace)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn key(&self, provider_kind: &ProviderKind) -> String {
|
||||
format!(
|
||||
"{}:provider:{}:artifact-profile:v1",
|
||||
self.namespace,
|
||||
provider_kind.as_str()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderArtifactProfileCachePort for RedisProviderArtifactProfileRepository {
|
||||
fn replace_if_newer(
|
||||
&self,
|
||||
profile: ProviderArtifactProfile,
|
||||
ttl: Duration,
|
||||
) -> futures::future::BoxFuture<'_, Result<bool, ProviderStoreError>> {
|
||||
Box::pin(async move {
|
||||
let artifact_sequence = profile.artifact_sequence();
|
||||
if artifact_sequence == 0 || artifact_sequence > MAX_REDIS_EXACT_INTEGER {
|
||||
return Err(provider_invalid("validate artifact profile sequence"));
|
||||
}
|
||||
let ttl_ms = exact_positive_millis(ttl, "validate artifact profile TTL")?;
|
||||
let verified_at_ms = profile
|
||||
.verified_at()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.ok()
|
||||
.and_then(|duration| u64::try_from(duration.as_millis()).ok())
|
||||
.filter(|millis| *millis <= MAX_REDIS_EXACT_INTEGER)
|
||||
.ok_or_else(|| provider_invalid("validate artifact profile timestamp"))?;
|
||||
let payload = serde_json::to_vec(&serde_json::Value::Object(
|
||||
profile.profile().expose_to_provider().clone(),
|
||||
))
|
||||
.map_err(|_| provider_invalid("encode artifact profile"))?;
|
||||
if payload.len() > MAX_PROFILE_BYTES {
|
||||
return Err(provider_invalid("validate artifact profile size"));
|
||||
}
|
||||
|
||||
let mut connection = self.connection.clone();
|
||||
let outcome = Script::new(REPLACE_SCRIPT)
|
||||
.key(self.key(profile.provider_kind()))
|
||||
.arg(artifact_sequence)
|
||||
.arg(verified_at_ms)
|
||||
.arg(payload)
|
||||
.arg(ttl_ms)
|
||||
.invoke_async::<i64>(&mut connection)
|
||||
.await
|
||||
.map_err(|_| provider_unavailable("replace artifact profile"))?;
|
||||
match outcome {
|
||||
1 => Ok(true),
|
||||
0 => Ok(false),
|
||||
-1 => Err(ProviderStoreError::new(
|
||||
ProviderStoreErrorKind::Conflict,
|
||||
"replace artifact profile",
|
||||
)),
|
||||
_ => Err(provider_invalid("decode artifact profile write outcome")),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn read<'a>(
|
||||
&'a self,
|
||||
provider_kind: &'a ProviderKind,
|
||||
) -> futures::future::BoxFuture<'a, Result<Option<ProviderArtifactProfile>, ProviderStoreError>>
|
||||
{
|
||||
Box::pin(async move {
|
||||
let mut connection = self.connection.clone();
|
||||
let values: (Option<u64>, Option<u64>, Option<Vec<u8>>) = redis::cmd("HMGET")
|
||||
.arg(self.key(provider_kind))
|
||||
.arg("artifact_sequence")
|
||||
.arg("verified_at_ms")
|
||||
.arg("profile")
|
||||
.query_async(&mut connection)
|
||||
.await
|
||||
.map_err(|_| provider_unavailable("read artifact profile"))?;
|
||||
let (artifact_sequence, verified_at_ms, payload) = values;
|
||||
if artifact_sequence.is_none() && verified_at_ms.is_none() && payload.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let (Some(artifact_sequence), Some(verified_at_ms), Some(payload)) =
|
||||
(artifact_sequence, verified_at_ms, payload)
|
||||
else {
|
||||
return Err(provider_invalid("decode artifact profile"));
|
||||
};
|
||||
if artifact_sequence == 0 || artifact_sequence > MAX_REDIS_EXACT_INTEGER {
|
||||
return Err(provider_invalid("decode artifact profile sequence"));
|
||||
}
|
||||
if payload.len() > MAX_PROFILE_BYTES {
|
||||
return Err(provider_invalid("decode artifact profile size"));
|
||||
}
|
||||
let serde_json::Value::Object(fields) = serde_json::from_slice(&payload)
|
||||
.map_err(|_| provider_invalid("decode artifact profile"))?
|
||||
else {
|
||||
return Err(provider_invalid("decode artifact profile"));
|
||||
};
|
||||
let verified_at = SystemTime::UNIX_EPOCH
|
||||
.checked_add(Duration::from_millis(verified_at_ms))
|
||||
.ok_or_else(|| provider_invalid("decode artifact profile timestamp"))?;
|
||||
Ok(Some(ProviderArtifactProfile::new(
|
||||
provider_kind.clone(),
|
||||
artifact_sequence,
|
||||
verified_at,
|
||||
OpaqueProviderData::new(fields),
|
||||
)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn exact_positive_millis(
|
||||
duration: Duration,
|
||||
operation: &'static str,
|
||||
) -> Result<u64, ProviderStoreError> {
|
||||
u64::try_from(duration.as_millis())
|
||||
.ok()
|
||||
.filter(|millis| *millis > 0 && *millis <= MAX_REDIS_EXACT_INTEGER)
|
||||
.ok_or_else(|| provider_invalid(operation))
|
||||
}
|
||||
|
||||
fn provider_unavailable(operation: &'static str) -> ProviderStoreError {
|
||||
ProviderStoreError::new(ProviderStoreErrorKind::Unavailable, operation)
|
||||
}
|
||||
|
||||
fn provider_invalid(operation: &'static str) -> ProviderStoreError {
|
||||
ProviderStoreError::new(ProviderStoreErrorKind::InvalidData, operation)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use sha2::{Digest, Sha256};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
mod artifact_profile;
|
||||
mod client_admission;
|
||||
mod coordination_buffer;
|
||||
mod credential_cooldown;
|
||||
@@ -20,6 +21,7 @@ mod provider_session_exclusion;
|
||||
mod runtime_change;
|
||||
pub(crate) mod worker_lease;
|
||||
|
||||
pub use artifact_profile::*;
|
||||
pub use client_admission::*;
|
||||
pub use coordination_buffer::*;
|
||||
pub use credential_cooldown::*;
|
||||
|
||||
@@ -730,8 +730,18 @@ async fn dashboard_summary_totals_include_history_outside_selected_range() {
|
||||
.await
|
||||
.expect("dashboard summary");
|
||||
assert_eq!(dashboard.requests.request_count, 2);
|
||||
assert_eq!(dashboard.account_usage[0].request_count, 0);
|
||||
assert_eq!(dashboard.account_usage[0].request_buckets.len(), 2);
|
||||
assert!(dashboard.account_usage.is_empty());
|
||||
|
||||
let explicit_account_usage = repository
|
||||
.provider_account_usage(
|
||||
ProviderAccountUsageQuery::for_accounts(range, vec!["acct_observe".to_owned()])
|
||||
.expect("account usage query"),
|
||||
)
|
||||
.await
|
||||
.expect("provider account usage");
|
||||
assert_eq!(explicit_account_usage.len(), 1);
|
||||
assert_eq!(explicit_account_usage[0].request_count, 0);
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
dashboard.totals.request_count,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use gateway_core::engine::credential::OpaqueProviderData;
|
||||
use gateway_core::provider_ports::{
|
||||
ProviderArtifactProfile, ProviderArtifactProfileCachePort, ProviderStoreErrorKind,
|
||||
};
|
||||
use gateway_core::routing::ProviderKind;
|
||||
use gateway_store::redis::RedisProviderArtifactProfileRepository;
|
||||
use redis::aio::ConnectionManager;
|
||||
use serde_json::{Map, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn artifact_profile_adapter_implements_provider_port() {
|
||||
fn assert_port<T: ProviderArtifactProfileCachePort>() {}
|
||||
assert_port::<RedisProviderArtifactProfileRepository>();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn artifact_profile_uses_one_expiring_key_and_rejects_rollback_or_conflict() {
|
||||
let Some((repository, mut connection, namespace)) = repository().await else {
|
||||
return;
|
||||
};
|
||||
let provider = ProviderKind::new("openai").expect("provider kind");
|
||||
let current = profile(provider.clone(), 6_570, "0.148.0-alpha.9");
|
||||
assert!(
|
||||
repository
|
||||
.replace_if_newer(current.clone(), Duration::from_secs(60))
|
||||
.await
|
||||
.expect("store current profile")
|
||||
);
|
||||
assert_eq!(
|
||||
repository.read(&provider).await.expect("read profile"),
|
||||
Some(current.clone())
|
||||
);
|
||||
|
||||
assert!(
|
||||
!repository
|
||||
.replace_if_newer(
|
||||
profile(provider.clone(), 6_415, "0.147.0-alpha.6.6"),
|
||||
Duration::from_secs(60),
|
||||
)
|
||||
.await
|
||||
.expect("reject older profile")
|
||||
);
|
||||
let conflict = repository
|
||||
.replace_if_newer(
|
||||
profile(provider.clone(), 6_570, "0.148.0-alpha.10"),
|
||||
Duration::from_secs(60),
|
||||
)
|
||||
.await
|
||||
.expect_err("same build with different Core must conflict");
|
||||
assert_eq!(conflict.kind(), ProviderStoreErrorKind::Conflict);
|
||||
assert_eq!(
|
||||
repository
|
||||
.read(&provider)
|
||||
.await
|
||||
.expect("read retained profile"),
|
||||
Some(current)
|
||||
);
|
||||
|
||||
let keys = redis::cmd("KEYS")
|
||||
.arg(format!("{namespace}:*"))
|
||||
.query_async::<Vec<String>>(&mut connection)
|
||||
.await
|
||||
.expect("list isolated keys");
|
||||
assert_eq!(keys.len(), 1);
|
||||
assert_eq!(
|
||||
keys[0],
|
||||
format!("{namespace}:provider:openai:artifact-profile:v1")
|
||||
);
|
||||
let ttl = redis::cmd("PTTL")
|
||||
.arg(&keys[0])
|
||||
.query_async::<i64>(&mut connection)
|
||||
.await
|
||||
.expect("read profile TTL");
|
||||
assert!((1..=60_000).contains(&ttl));
|
||||
}
|
||||
|
||||
fn profile(
|
||||
provider: ProviderKind,
|
||||
artifact_sequence: u64,
|
||||
codex_version: &str,
|
||||
) -> ProviderArtifactProfile {
|
||||
let mut fields = Map::new();
|
||||
fields.insert("schema_version".to_owned(), Value::from(1));
|
||||
fields.insert(
|
||||
"codex_version".to_owned(),
|
||||
Value::String(codex_version.to_owned()),
|
||||
);
|
||||
ProviderArtifactProfile::new(
|
||||
provider,
|
||||
artifact_sequence,
|
||||
SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000),
|
||||
OpaqueProviderData::new(fields),
|
||||
)
|
||||
}
|
||||
|
||||
async fn repository() -> Option<(
|
||||
RedisProviderArtifactProfileRepository,
|
||||
ConnectionManager,
|
||||
String,
|
||||
)> {
|
||||
let redis_url = crate::support::test_env("CPR_TEST_REDIS_URL")?;
|
||||
let client = redis::Client::open(redis_url).expect("valid CPR_TEST_REDIS_URL");
|
||||
let connection = client
|
||||
.get_connection_manager()
|
||||
.await
|
||||
.expect("connect test Redis");
|
||||
let namespace = format!("gateway-store-test-{}", Uuid::new_v4());
|
||||
let repository = RedisProviderArtifactProfileRepository::new(connection.clone(), &namespace)
|
||||
.expect("valid test namespace");
|
||||
Some((repository, connection, namespace))
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
mod artifact_profile;
|
||||
mod client_admission;
|
||||
mod coordination_buffer;
|
||||
mod credential_cooldown;
|
||||
|
||||
@@ -17,6 +17,7 @@ cookie = "0.18.1"
|
||||
crypto_box = { version = "0.9.1", features = ["seal"] }
|
||||
ed25519-dalek = { version = "3.0.0", features = ["alloc", "pkcs8"] }
|
||||
futures = "0.3.32"
|
||||
flate2 = "1.1.9"
|
||||
gateway-admin = { path = "../../gateway-admin" }
|
||||
gateway-core = { path = "../../gateway-core" }
|
||||
gateway-protocol = { path = "../../gateway-protocol" }
|
||||
@@ -49,6 +50,7 @@ tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungsteni
|
||||
tracing = "0.1.44"
|
||||
tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "4fffad30fe373adbdcffab9545e9e9bf4f2fc19f", features = ["deflate", "proxy"] }
|
||||
url = "2.5.8"
|
||||
urlencoding = "2.1.3"
|
||||
uuid = { version = "1.23.3", features = ["v4", "v7"] }
|
||||
# 官方 Codex app-server 对 /v1/responses 请求体默认做 zstd 压缩(level 3),
|
||||
# 并携带 Content-Encoding: zstd;保持同版本同级别以对齐上游指纹。
|
||||
|
||||
@@ -51,10 +51,10 @@ use crate::credential::{
|
||||
};
|
||||
use crate::credential::{CodexCredentialCodec, CodexOAuthSecret, oauth_owner_ref};
|
||||
use crate::transport::CodexWebSocketPool;
|
||||
use crate::transport::openai_billing_breakdown;
|
||||
use crate::transport::profile::{
|
||||
CodexDesktopReleaseSnapshot, CodexDesktopReleaseStatus, CodexWireProfile, CodexWireProfileState,
|
||||
};
|
||||
use crate::transport::{OpenAiBillingUsage, openai_billing_breakdown};
|
||||
|
||||
const PROVIDER_NAME: &str = "openai";
|
||||
const PENDING_DOCUMENT_SCHEMA_VERSION: u64 = 3;
|
||||
@@ -215,10 +215,12 @@ impl ProviderAdmin for OpenAiAdminProvider {
|
||||
};
|
||||
let Some(breakdown) = openai_billing_breakdown(
|
||||
&input.upstream_model_id,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
input.cached_tokens.unwrap_or_default(),
|
||||
input.cache_write_tokens.unwrap_or_default(),
|
||||
OpenAiBillingUsage::new(
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
input.cached_tokens.unwrap_or_default(),
|
||||
input.cache_write_tokens.unwrap_or_default(),
|
||||
),
|
||||
input.service_tier.as_deref(),
|
||||
) else {
|
||||
return Ok(None);
|
||||
@@ -1011,6 +1013,7 @@ struct PendingDocument {
|
||||
state: String,
|
||||
nonce: String,
|
||||
code_verifier: String,
|
||||
installation_id: String,
|
||||
reauthorization_account_id: Option<String>,
|
||||
mutation: PendingMutationDocument,
|
||||
}
|
||||
@@ -1071,6 +1074,10 @@ fn encode_pending(pending: &CodexPendingAuthorization) -> Map<String, Value> {
|
||||
"code_verifier".to_owned(),
|
||||
Value::String(pending.code_verifier().expose_secret().to_owned()),
|
||||
);
|
||||
document.insert(
|
||||
"installation_id".to_owned(),
|
||||
Value::String(pending.installation_id().to_owned()),
|
||||
);
|
||||
document.insert(
|
||||
"reauthorization_account_id".to_owned(),
|
||||
pending
|
||||
@@ -1161,6 +1168,7 @@ fn decode_pending(
|
||||
state: SecretString::from(document.state),
|
||||
nonce: SecretString::from(document.nonce),
|
||||
code_verifier: SecretString::from(document.code_verifier),
|
||||
installation_id: document.installation_id,
|
||||
reauthorization_account_id: document.reauthorization_account_id,
|
||||
mutation: decode_mutation(document.mutation)?,
|
||||
})
|
||||
|
||||
@@ -269,7 +269,7 @@ impl CodexAuthSettings {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CodexWireProfileConfig {
|
||||
pub originator: String,
|
||||
/// npm `@openai/codex` 启动版本;运行时发布任务会更新此画像字段。
|
||||
/// 官方 Desktop ZIP 内嵌 Core 的启动基线;运行时按完整制品元组更新。
|
||||
pub codex_version: String,
|
||||
pub desktop_version: String,
|
||||
pub desktop_build: String,
|
||||
@@ -284,9 +284,9 @@ impl Default for CodexWireProfileConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
originator: "Codex Desktop".to_owned(),
|
||||
codex_version: "0.146.0".to_owned(),
|
||||
desktop_version: "26.727.51351".to_owned(),
|
||||
desktop_build: "6119".to_owned(),
|
||||
codex_version: "0.147.0-alpha.6.6".to_owned(),
|
||||
desktop_version: "26.803.81509".to_owned(),
|
||||
desktop_build: "6415".to_owned(),
|
||||
os_type: "Mac OS".to_owned(),
|
||||
os_version: "15.7.1".to_owned(),
|
||||
arch: "arm64".to_owned(),
|
||||
|
||||
@@ -55,6 +55,7 @@ pub struct ImportCodexOAuthCredential {
|
||||
pub(crate) struct UnresolvedCodexOAuthCredential {
|
||||
pub(crate) account_id: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) installation_id: String,
|
||||
pub(crate) secret: CodexOAuthSecret,
|
||||
pub(crate) metadata: CodexOAuthMetadata,
|
||||
pub(crate) access_token_expires_at: Option<DateTime<Utc>>,
|
||||
@@ -68,6 +69,7 @@ impl fmt::Debug for UnresolvedCodexOAuthCredential {
|
||||
.debug_struct("UnresolvedCodexOAuthCredential")
|
||||
.field("account_id", &self.account_id)
|
||||
.field("name", &self.name)
|
||||
.field("installation_id", &"<pseudonymous>")
|
||||
.field("secret", &"<redacted>")
|
||||
.field("metadata", &self.metadata)
|
||||
.field("access_token_expires_at", &self.access_token_expires_at)
|
||||
@@ -473,8 +475,12 @@ impl CodexCredentialAdmin {
|
||||
chatgpt_account_id: upstream_account_id,
|
||||
} = input.metadata;
|
||||
let has_upstream_user_id = upstream_user_id.is_some();
|
||||
let credential = CodexCredentialCodec::encode_unresolved(&input.secret, Vec::new())
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
|
||||
let credential = CodexCredentialCodec::encode_unresolved(
|
||||
&input.secret,
|
||||
input.installation_id,
|
||||
Vec::new(),
|
||||
)
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
|
||||
let account = ProviderAccount::new(
|
||||
account_id,
|
||||
provider,
|
||||
@@ -917,6 +923,7 @@ impl CodexCredentialAdminService {
|
||||
.or_else(|| metadata.email.clone())
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.unwrap_or_else(|| "Codex OAuth".to_owned()),
|
||||
installation_id: uuid::Uuid::new_v4().to_string(),
|
||||
secret,
|
||||
metadata,
|
||||
access_token_expires_at,
|
||||
|
||||
@@ -22,6 +22,8 @@ use url::Url;
|
||||
|
||||
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
|
||||
use super::security::{CodexRuntimeAuthentication, CodexRuntimeCredential};
|
||||
use crate::transport::headers::build_codex_profile_headers;
|
||||
use crate::transport::profile::CodexWireProfileState;
|
||||
use crate::transport::websocket::CodexWebSocketExchangeError;
|
||||
use crate::transport::{CodexClientError, CodexWebSocketPool};
|
||||
|
||||
@@ -167,13 +169,21 @@ pub trait CodexAgentIdentityTaskRegistrar: Send + Sync + 'static {
|
||||
pub struct OfficialCodexAgentIdentityTaskRegistrar {
|
||||
client: reqwest::Client,
|
||||
base_url: Url,
|
||||
profile: CodexWireProfileState,
|
||||
}
|
||||
|
||||
impl OfficialCodexAgentIdentityTaskRegistrar {
|
||||
pub fn new(client: reqwest::Client) -> Result<Self, CodexAgentIdentityError> {
|
||||
pub fn new(
|
||||
client: reqwest::Client,
|
||||
profile: CodexWireProfileState,
|
||||
) -> Result<Self, CodexAgentIdentityError> {
|
||||
let base_url = Url::parse(OFFICIAL_AGENT_IDENTITY_AUTH_BASE_URL)
|
||||
.map_err(|_| CodexAgentIdentityError::InvalidConfiguration)?;
|
||||
Ok(Self { client, base_url })
|
||||
Ok(Self {
|
||||
client,
|
||||
base_url,
|
||||
profile,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,9 +207,13 @@ impl CodexAgentIdentityTaskRegistrar for OfficialCodexAgentIdentityTaskRegistrar
|
||||
.path_segments_mut()
|
||||
.map_err(|_| CodexAgentIdentityError::InvalidConfiguration)?
|
||||
.extend(["v1", "agent", credential.runtime_id(), "task", "register"]);
|
||||
let profile = self.profile.snapshot();
|
||||
let headers = build_codex_profile_headers(&profile)
|
||||
.map_err(|_| CodexAgentIdentityError::InvalidConfiguration)?;
|
||||
let response = self
|
||||
.client
|
||||
.post(endpoint)
|
||||
.headers(headers)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.json(&credential.registration(Utc::now()))
|
||||
.send()
|
||||
|
||||
@@ -36,9 +36,16 @@ use super::token_client::{
|
||||
OFFICIAL_CODEX_OAUTH_CLIENT_ID, OFFICIAL_CODEX_REDIRECT_URI,
|
||||
};
|
||||
use super::types::parse_chatgpt_jwt_claims;
|
||||
use crate::transport::profile::CodexWireProfileState;
|
||||
|
||||
const AUTHORIZATION_ENDPOINT: &str = "https://auth.openai.com/oauth/authorize";
|
||||
const AUTHORIZATION_SCOPE: &str = "openid profile email offline_access";
|
||||
const DESKTOP_AUTH_ENDPOINT: &str = "https://chatgpt.com/codex/desktop-auth";
|
||||
const AUTHORIZATION_SCOPE: &str =
|
||||
"openid profile email offline_access api.connectors.read api.connectors.invoke";
|
||||
const AUTHORIZATION_ORIGINATOR: &str = "Codex Desktop";
|
||||
const STREAMLINED_LOGIN_QUERY: &str = "codex_streamlined_login";
|
||||
const NO_UNIVERSAL_LINKS_QUERY: &str = "no_universal_links";
|
||||
const SURFACE_STABLE_ID_DOMAIN: &[u8] = b"openai/codex-desktop/surface-stable-id/v1\0";
|
||||
const AUTHORIZATION_TTL: TimeDelta = TimeDelta::minutes(10);
|
||||
const AUTHORIZATION_CLAIM_TTL: Duration = Duration::from_secs(90);
|
||||
const MAX_CALLBACK_BYTES: usize = 16 * 1024;
|
||||
@@ -137,6 +144,7 @@ pub struct CodexPendingAuthorization {
|
||||
state: SecretString,
|
||||
nonce: SecretString,
|
||||
code_verifier: SecretString,
|
||||
installation_id: String,
|
||||
reauthorization: Option<ProviderAccountId>,
|
||||
mutation: PendingAuthorizationMutation,
|
||||
}
|
||||
@@ -150,6 +158,7 @@ pub struct StoredCodexPendingAuthorization {
|
||||
pub state: SecretString,
|
||||
pub nonce: SecretString,
|
||||
pub code_verifier: SecretString,
|
||||
pub installation_id: String,
|
||||
pub reauthorization_account_id: Option<String>,
|
||||
pub mutation: PendingAuthorizationMutation,
|
||||
}
|
||||
@@ -172,6 +181,7 @@ impl CodexPendingAuthorization {
|
||||
state: input.state,
|
||||
nonce: input.nonce,
|
||||
code_verifier: input.code_verifier,
|
||||
installation_id: input.installation_id,
|
||||
reauthorization,
|
||||
mutation: input.mutation,
|
||||
};
|
||||
@@ -183,6 +193,7 @@ impl CodexPendingAuthorization {
|
||||
|| !valid_secret(pending.state.expose_secret())
|
||||
|| !valid_secret(pending.nonce.expose_secret())
|
||||
|| !valid_secret(pending.code_verifier.expose_secret())
|
||||
|| !valid_installation_id(&pending.installation_id)
|
||||
|| !pending_mutation_matches(&pending)
|
||||
{
|
||||
return Err(CodexOAuthPendingStoreError::InvalidValue);
|
||||
@@ -230,6 +241,11 @@ impl CodexPendingAuthorization {
|
||||
&self.code_verifier
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn installation_id(&self) -> &str {
|
||||
&self.installation_id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn reauthorization(&self) -> Option<&ProviderAccountId> {
|
||||
self.reauthorization.as_ref()
|
||||
@@ -253,6 +269,7 @@ impl fmt::Debug for CodexPendingAuthorization {
|
||||
.field("state", &"<redacted>")
|
||||
.field("nonce", &"<redacted>")
|
||||
.field("code_verifier", &"<redacted>")
|
||||
.field("installation_id", &"<pseudonymous>")
|
||||
.field("reauthorization", &self.reauthorization)
|
||||
.field("mutation", &self.mutation)
|
||||
.finish()
|
||||
@@ -348,6 +365,7 @@ pub struct CodexOAuthAdminService {
|
||||
store: Arc<dyn ProviderAccountStore>,
|
||||
credentials: CodexCredentialAdmin,
|
||||
oauth_client_id: String,
|
||||
profile: CodexWireProfileState,
|
||||
}
|
||||
|
||||
struct CodexOAuthAuthorizationCommitGuard {
|
||||
@@ -397,6 +415,7 @@ impl CodexOAuthAdminService {
|
||||
exchanger: Arc<dyn AuthorizationCodeExchanger>,
|
||||
store: Arc<dyn ProviderAccountStore>,
|
||||
credentials: CodexCredentialAdmin,
|
||||
profile: CodexWireProfileState,
|
||||
) -> Self {
|
||||
Self {
|
||||
pending,
|
||||
@@ -404,6 +423,7 @@ impl CodexOAuthAdminService {
|
||||
store,
|
||||
credentials,
|
||||
oauth_client_id: OFFICIAL_CODEX_OAUTH_CLIENT_ID.to_owned(),
|
||||
profile,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,8 +440,10 @@ impl CodexOAuthAdmin for CodexOAuthAdminService {
|
||||
&self,
|
||||
command: StartCodexOAuthAuthorization,
|
||||
) -> Result<CodexOAuthAuthorizationStarted, CodexOAuthAdminError> {
|
||||
let (name, reauthorization) = match command.mutation.target() {
|
||||
AuthorizationMutationTarget::Create { name } => (name.clone(), None),
|
||||
let (name, reauthorization, installation_id) = match command.mutation.target() {
|
||||
AuthorizationMutationTarget::Create { name } => {
|
||||
(name.clone(), None, Uuid::new_v4().to_string())
|
||||
}
|
||||
AuthorizationMutationTarget::Reauthorize { account_id } => {
|
||||
let current = self
|
||||
.store
|
||||
@@ -433,10 +455,15 @@ impl CodexOAuthAdmin for CodexOAuthAdminService {
|
||||
{
|
||||
return Err(CodexOAuthAdminError::NotFound);
|
||||
}
|
||||
(current.account.name().to_owned(), Some(account_id.clone()))
|
||||
let installation_id = current_installation_id(¤t)?;
|
||||
(
|
||||
current.account.name().to_owned(),
|
||||
Some(account_id.clone()),
|
||||
installation_id,
|
||||
)
|
||||
}
|
||||
};
|
||||
self.start_pending(command.mutation, name, reauthorization)
|
||||
self.start_pending(command.mutation, name, reauthorization, installation_id)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -488,6 +515,9 @@ impl CodexOAuthAdminService {
|
||||
.load_current_credential(target)
|
||||
.await
|
||||
.map_err(map_store_error)?;
|
||||
if current_installation_id(¤t)? != pending.installation_id() {
|
||||
return Err(CodexOAuthAdminError::Conflict);
|
||||
}
|
||||
Some(current)
|
||||
} else {
|
||||
None
|
||||
@@ -525,6 +555,7 @@ impl CodexOAuthAdminService {
|
||||
.prepare_unresolved_oauth(UnresolvedCodexOAuthCredential {
|
||||
account_id,
|
||||
name: pending.name,
|
||||
installation_id: pending.installation_id,
|
||||
secret,
|
||||
metadata,
|
||||
access_token_expires_at,
|
||||
@@ -544,6 +575,7 @@ impl CodexOAuthAdminService {
|
||||
mutation: PendingAuthorizationMutation,
|
||||
name: String,
|
||||
reauthorization: Option<ProviderAccountId>,
|
||||
installation_id: String,
|
||||
) -> Result<CodexOAuthAuthorizationStarted, CodexOAuthAdminError> {
|
||||
if !valid_text(&name) {
|
||||
return Err(CodexOAuthAdminError::InvalidInput);
|
||||
@@ -562,17 +594,21 @@ impl CodexOAuthAdminService {
|
||||
state: SecretString::from(random_secret()?),
|
||||
nonce: SecretString::from(random_secret()?),
|
||||
code_verifier: SecretString::from(random_secret()?),
|
||||
installation_id,
|
||||
reauthorization_account_id: reauthorization.as_ref().map(ToString::to_string),
|
||||
mutation,
|
||||
})
|
||||
.map_err(map_pending_error)?;
|
||||
let profile = self.profile.snapshot();
|
||||
let authorization_url =
|
||||
authorization_url(&pending, &self.oauth_client_id, &profile.desktop_version)?;
|
||||
self.pending
|
||||
.create(&pending)
|
||||
.await
|
||||
.map_err(map_pending_error)?;
|
||||
Ok(CodexOAuthAuthorizationStarted {
|
||||
flow_id: pending.flow_id.clone(),
|
||||
authorization_url: authorization_url(&pending, &self.oauth_client_id)?,
|
||||
authorization_url,
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
@@ -688,27 +724,70 @@ fn current_oauth_refresh_token(
|
||||
.and_then(|oauth| oauth.refresh_token.clone()))
|
||||
}
|
||||
|
||||
fn current_installation_id(current: &LoadedCredential) -> Result<String, CodexOAuthAdminError> {
|
||||
let runtime = CodexCredentialCodec::decode(¤t.credential)
|
||||
.map_err(|_| CodexOAuthAdminError::Credential)?;
|
||||
if runtime.authentication.oauth().is_none() {
|
||||
return Err(CodexOAuthAdminError::Credential);
|
||||
}
|
||||
Ok(runtime.installation_id)
|
||||
}
|
||||
|
||||
fn derive_surface_stable_id(installation_id: &str) -> Result<String, CodexOAuthAdminError> {
|
||||
let installation_id =
|
||||
Uuid::parse_str(installation_id).map_err(|_| CodexOAuthAdminError::Credential)?;
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(SURFACE_STABLE_ID_DOMAIN);
|
||||
digest.update(installation_id.as_bytes());
|
||||
let digest = digest.finalize();
|
||||
let mut bytes = [0_u8; 16];
|
||||
bytes.copy_from_slice(&digest[..16]);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
Ok(Uuid::from_bytes(bytes).hyphenated().to_string())
|
||||
}
|
||||
|
||||
fn authorization_url(
|
||||
pending: &CodexPendingAuthorization,
|
||||
oauth_client_id: &str,
|
||||
desktop_version: &str,
|
||||
) -> Result<String, CodexOAuthAdminError> {
|
||||
let mut url =
|
||||
if !valid_text(desktop_version) {
|
||||
return Err(CodexOAuthAdminError::InvalidInput);
|
||||
}
|
||||
let mut inner =
|
||||
Url::parse(AUTHORIZATION_ENDPOINT).map_err(|_| CodexOAuthAdminError::InvalidInput)?;
|
||||
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(
|
||||
pending.code_verifier.expose_secret().as_bytes(),
|
||||
));
|
||||
url.query_pairs_mut()
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("client_id", oauth_client_id)
|
||||
.append_pair("redirect_uri", OFFICIAL_CODEX_REDIRECT_URI)
|
||||
.append_pair("scope", AUTHORIZATION_SCOPE)
|
||||
.append_pair("state", pending.state.expose_secret())
|
||||
.append_pair("nonce", pending.nonce.expose_secret())
|
||||
.append_pair("code_challenge", &challenge)
|
||||
.append_pair("code_challenge_method", "S256")
|
||||
.append_pair("id_token_add_organizations", "true")
|
||||
.append_pair("codex_cli_simplified_flow", "true");
|
||||
Ok(url.into())
|
||||
let stable_id = derive_surface_stable_id(pending.installation_id())?;
|
||||
{
|
||||
let mut query = inner.query_pairs_mut();
|
||||
query
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("client_id", oauth_client_id)
|
||||
.append_pair("redirect_uri", OFFICIAL_CODEX_REDIRECT_URI)
|
||||
.append_pair("scope", AUTHORIZATION_SCOPE)
|
||||
.append_pair("code_challenge", challenge.as_str())
|
||||
.append_pair("code_challenge_method", "S256")
|
||||
.append_pair("id_token_add_organizations", "true")
|
||||
.append_pair("codex_cli_simplified_flow", "true")
|
||||
.append_pair("state", pending.state.expose_secret())
|
||||
.append_pair("originator", AUTHORIZATION_ORIGINATOR)
|
||||
.append_pair("codex_app_version", desktop_version)
|
||||
.append_pair("source_surface_stable_id", &stable_id)
|
||||
.append_pair("codex_origin_stable_id", &stable_id)
|
||||
.append_pair(STREAMLINED_LOGIN_QUERY, "true");
|
||||
}
|
||||
|
||||
let mut outer =
|
||||
Url::parse(DESKTOP_AUTH_ENDPOINT).map_err(|_| CodexOAuthAdminError::InvalidInput)?;
|
||||
outer
|
||||
.query_pairs_mut()
|
||||
.append_pair("authorize_url", inner.as_str())
|
||||
.append_pair(STREAMLINED_LOGIN_QUERY, "true")
|
||||
.append_pair(NO_UNIVERSAL_LINKS_QUERY, "1");
|
||||
Ok(outer.into())
|
||||
}
|
||||
|
||||
fn callback_parts(value: &str) -> Result<(SecretString, SecretString), CodexOAuthAdminError> {
|
||||
@@ -760,6 +839,12 @@ fn valid_secret(value: &str) -> bool {
|
||||
valid_text(value) && value.len() >= 16
|
||||
}
|
||||
|
||||
fn valid_installation_id(value: &str) -> bool {
|
||||
Uuid::parse_str(value)
|
||||
.ok()
|
||||
.is_some_and(|uuid| uuid.get_version_num() == 4)
|
||||
}
|
||||
|
||||
pub(crate) fn oauth_owner_ref(owner: &AuthorizationOwner) -> String {
|
||||
let mut digest = Sha256::new();
|
||||
match owner {
|
||||
|
||||
@@ -108,6 +108,7 @@ impl CodexCredentialCodec {
|
||||
oauth_subject: account.oauth_subject.clone(),
|
||||
poid: account.poid.clone(),
|
||||
}),
|
||||
uuid::Uuid::new_v4().to_string(),
|
||||
cookies,
|
||||
)
|
||||
}
|
||||
@@ -115,20 +116,22 @@ impl CodexCredentialCodec {
|
||||
/// 为尚未解析资料的 OAuth 账号编码凭据。
|
||||
pub(crate) fn encode_unresolved(
|
||||
secret: &CodexOAuthSecret,
|
||||
installation_id: String,
|
||||
cookies: Vec<CodexCookie>,
|
||||
) -> Result<PlaintextCredential, CodexCredentialDataError> {
|
||||
Self::encode_oauth(secret, None, cookies)
|
||||
Self::encode_oauth(secret, None, installation_id, cookies)
|
||||
}
|
||||
|
||||
fn encode_oauth(
|
||||
secret: &CodexOAuthSecret,
|
||||
principal: Option<CodexCredentialPrincipal>,
|
||||
installation_id: String,
|
||||
cookies: Vec<CodexCookie>,
|
||||
) -> Result<PlaintextCredential, CodexCredentialDataError> {
|
||||
Self::encode_complete(CodexCredentialData::OAuth(CodexOAuthCredentialData {
|
||||
schema_version: CODEX_CREDENTIAL_SCHEMA_VERSION,
|
||||
principal,
|
||||
installation_id: uuid::Uuid::new_v4().to_string(),
|
||||
installation_id,
|
||||
access_token: secret.access_token.expose_secret().to_owned(),
|
||||
refresh_token: secret
|
||||
.refresh_token
|
||||
|
||||
@@ -22,8 +22,7 @@ use crate::credential::{
|
||||
CodexOAuthAdmin, CodexOAuthAdminService, OfficialCodexAgentIdentityTaskRegistrar,
|
||||
};
|
||||
use crate::transport::profile::{
|
||||
CodexCliReleaseService, CodexDesktopReleaseService, OfficialCodexCliReleaseTransport,
|
||||
OfficialCodexDesktopReleaseTransport,
|
||||
CodexArtifactProfileCache, CodexDesktopReleaseService, OfficialCodexDesktopReleaseTransport,
|
||||
};
|
||||
use crate::transport::{CodexWebSocketPool, build_reqwest_client};
|
||||
|
||||
@@ -37,8 +36,8 @@ pub mod credential;
|
||||
pub mod transport;
|
||||
|
||||
pub use transport::{
|
||||
CodexCanonicalDecoder, CodexCanonicalError, CodexRequestEncodeError, encode_generate_request,
|
||||
openai_billing_breakdown,
|
||||
CodexCanonicalDecoder, CodexCanonicalError, CodexRequestEncodeError, OpenAiBillingUsage,
|
||||
encode_generate_request, openai_billing_breakdown,
|
||||
};
|
||||
|
||||
/// OpenAI 初始化后交给组装根的最小能力集。
|
||||
@@ -63,6 +62,23 @@ pub async fn initialize(
|
||||
let runtime_policy = ports.runtime_policy();
|
||||
let credential_state = ports.credential_state();
|
||||
let profile = config.wire_profile_state();
|
||||
let artifact_cache =
|
||||
CodexArtifactProfileCache::new(provider_kind.clone(), ports.artifact_profiles());
|
||||
let configured_build = profile.snapshot().desktop_build.parse::<u64>().ok();
|
||||
let verified_profile = match artifact_cache.load().await {
|
||||
Ok(cached) => cached.filter(|cached| {
|
||||
cached
|
||||
.desktop_build
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.zip(configured_build)
|
||||
.is_some_and(|(cached, configured)| cached >= configured)
|
||||
}),
|
||||
Err(error) => {
|
||||
tracing::warn!(error = %error, "OpenAI artifact profile cache could not be loaded");
|
||||
None
|
||||
}
|
||||
};
|
||||
let session_identity = config
|
||||
.session_identity()
|
||||
.map_err(|_| OpenAiInitializeError::SessionIdentity)?;
|
||||
@@ -73,15 +89,10 @@ pub async fn initialize(
|
||||
OfficialCodexDesktopReleaseTransport::new()
|
||||
.map_err(|_| OpenAiInitializeError::DesktopRelease)?,
|
||||
),
|
||||
artifact_cache,
|
||||
verified_profile,
|
||||
));
|
||||
let desktop_release_status = desktop_release.status();
|
||||
let cli_release = Arc::new(CodexCliReleaseService::new(
|
||||
profile.clone(),
|
||||
Arc::new(
|
||||
OfficialCodexCliReleaseTransport::new()
|
||||
.map_err(|_| OpenAiInitializeError::CliRelease)?,
|
||||
),
|
||||
));
|
||||
let repository = CodexCredentialRepository::new(Arc::clone(&accounts));
|
||||
let websocket_pool = Arc::new(CodexWebSocketPool::with_config(
|
||||
config.websocket_pool_config(),
|
||||
@@ -89,7 +100,7 @@ pub async fn initialize(
|
||||
let agent_identity = Arc::new(CodexAgentIdentityTaskService::new(
|
||||
repository.clone(),
|
||||
Arc::new(
|
||||
OfficialCodexAgentIdentityTaskRegistrar::new(http.clone())
|
||||
OfficialCodexAgentIdentityTaskRegistrar::new(http.clone(), profile.clone())
|
||||
.map_err(|_| OpenAiInitializeError::AgentIdentity)?,
|
||||
),
|
||||
Arc::clone(&websocket_pool),
|
||||
@@ -165,6 +176,7 @@ pub async fn initialize(
|
||||
exchanger,
|
||||
Arc::clone(&accounts),
|
||||
CodexCredentialAdmin,
|
||||
profile.clone(),
|
||||
)
|
||||
.with_oauth_client_id(config.oauth_client_id()),
|
||||
);
|
||||
@@ -187,7 +199,6 @@ pub async fn initialize(
|
||||
catalog,
|
||||
config.quota_refresh_policy(),
|
||||
config.oauth_refresh_enabled(),
|
||||
cli_release,
|
||||
desktop_release,
|
||||
)
|
||||
.map_err(|_| OpenAiInitializeError::Worker)?;
|
||||
@@ -243,8 +254,6 @@ pub enum OpenAiInitializeError {
|
||||
Refresh,
|
||||
#[error("OpenAI Desktop release service could not initialize")]
|
||||
DesktopRelease,
|
||||
#[error("OpenAI CLI release service could not initialize")]
|
||||
CliRelease,
|
||||
#[error("OpenAI worker plan is invalid")]
|
||||
Worker,
|
||||
}
|
||||
|
||||
@@ -57,8 +57,7 @@ use crate::transport::diagnostics::{
|
||||
CodexFailureCategory, CodexUpstreamFailure, CodexUpstreamSendPhase,
|
||||
};
|
||||
use crate::transport::profile::{
|
||||
APPCAST_POLL_INTERVAL, CodexCliReleaseService, CodexDesktopReleaseService,
|
||||
CodexWireProfileState,
|
||||
APPCAST_POLL_INTERVAL, CodexDesktopReleaseService, CodexWireProfileState,
|
||||
};
|
||||
use crate::transport::protocol::responses::{
|
||||
CodexResponsesRequest, PreviousResponseScope, ResponseEventSignals,
|
||||
@@ -737,6 +736,7 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
// (transport/protocol/websocket.rs),push_frames 抽出的 data 即上游原文。
|
||||
let mut decoder = CodexCanonicalDecoder::new(upstream_model.as_str())
|
||||
.with_requested_service_tier(request.service_tier())
|
||||
.with_request_tool_pricing(upstream_model.as_str(), request.tools())
|
||||
.with_raw_sse_passthrough();
|
||||
let mut pre_commit_events = PreCommitClientEvents::new();
|
||||
loop {
|
||||
@@ -2339,7 +2339,6 @@ const WORKER_LEASE_TTL: Duration = Duration::from_secs(15 * 60);
|
||||
const WORKER_LEASE_RENEWAL: Duration = Duration::from_secs(5 * 60);
|
||||
const OAUTH_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
|
||||
const DESKTOP_RELEASE_WORKER_OWNER: &str = "openai-desktop-release";
|
||||
const CLI_RELEASE_WORKER_OWNER: &str = "openai-cli-release";
|
||||
const MODEL_ETAG_WORKER_OWNER: &str = "openai-model-etag";
|
||||
|
||||
pub(crate) fn worker_contributions(
|
||||
@@ -2348,7 +2347,6 @@ pub(crate) fn worker_contributions(
|
||||
catalog: Arc<CodexCredentialCatalogService>,
|
||||
quota_refresh_policy: CodexQuotaRefreshPolicy,
|
||||
oauth_refresh_enabled: bool,
|
||||
cli_release: Arc<CodexCliReleaseService>,
|
||||
desktop_release: Arc<CodexDesktopReleaseService>,
|
||||
) -> Result<Vec<WorkerContribution>, WorkerDefinitionError> {
|
||||
let refresh_id = WorkerId::try_new(WorkerKind::OAuthRefresh, PROVIDER_NAME)?;
|
||||
@@ -2356,8 +2354,6 @@ pub(crate) fn worker_contributions(
|
||||
let etag_id = WorkerId::try_new(WorkerKind::QuotaCatalogHealth, MODEL_ETAG_WORKER_OWNER)?;
|
||||
let desktop_release_id =
|
||||
WorkerId::try_new(WorkerKind::QuotaCatalogHealth, DESKTOP_RELEASE_WORKER_OWNER)?;
|
||||
let cli_release_id =
|
||||
WorkerId::try_new(WorkerKind::QuotaCatalogHealth, CLI_RELEASE_WORKER_OWNER)?;
|
||||
let mut contributions = Vec::new();
|
||||
if oauth_refresh_enabled {
|
||||
contributions.push(WorkerContribution::Registration(scheduled_registration(
|
||||
@@ -2385,13 +2381,6 @@ pub(crate) fn worker_contributions(
|
||||
task: Box::new(OpenAiCatalogEtagTask { catalog }),
|
||||
},
|
||||
)?),
|
||||
WorkerContribution::Registration(scheduled_registration(
|
||||
cli_release_id,
|
||||
APPCAST_POLL_INTERVAL,
|
||||
Box::new(OpenAiCliReleaseTask {
|
||||
service: cli_release,
|
||||
}),
|
||||
)?),
|
||||
WorkerContribution::Registration(scheduled_registration(
|
||||
desktop_release_id,
|
||||
APPCAST_POLL_INTERVAL,
|
||||
@@ -2512,27 +2501,6 @@ struct OpenAiDesktopReleaseTask {
|
||||
service: Arc<CodexDesktopReleaseService>,
|
||||
}
|
||||
|
||||
struct OpenAiCliReleaseTask {
|
||||
service: Arc<CodexCliReleaseService>,
|
||||
}
|
||||
|
||||
impl ScheduledTask for OpenAiCliReleaseTask {
|
||||
fn run_cycle(&self, context: WorkerCycleContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
Box::pin(async move {
|
||||
let refresh = self.service.refresh();
|
||||
tokio::pin!(refresh);
|
||||
let result = tokio::select! {
|
||||
() = context.cancellation().cancelled() => return Ok(()),
|
||||
result = &mut refresh => result,
|
||||
};
|
||||
if let Err(error) = result {
|
||||
tracing::warn!(error = %error, "OpenAI CLI release check failed");
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ScheduledTask for OpenAiDesktopReleaseTask {
|
||||
fn run_cycle(&self, context: WorkerCycleContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
Box::pin(async move {
|
||||
|
||||
@@ -21,7 +21,10 @@ use thiserror::Error;
|
||||
use super::protocol::responses::{
|
||||
ResponseEventSignals, ResponsesSseFailure, response_event_signals,
|
||||
};
|
||||
use super::usage::{normalize_service_tier, openai_billing_breakdown};
|
||||
use super::usage::{
|
||||
OpenAiBillingUsage, WebSearchPricing, normalize_service_tier, openai_billing_breakdown,
|
||||
web_search_pricing,
|
||||
};
|
||||
|
||||
const CONTENTS_PER_OUTPUT: u32 = 1_024;
|
||||
|
||||
@@ -43,6 +46,7 @@ pub struct CodexCanonicalDecoder {
|
||||
semantic_output_seen: bool,
|
||||
requested_service_tier: Option<String>,
|
||||
response_service_tier: Option<String>,
|
||||
web_search_pricing: Option<WebSearchPricing>,
|
||||
timing_signals: ResponseEventSignals,
|
||||
raw_sse_passthrough: bool,
|
||||
}
|
||||
@@ -139,6 +143,7 @@ impl CodexCanonicalDecoder {
|
||||
semantic_output_seen: false,
|
||||
requested_service_tier: None,
|
||||
response_service_tier: None,
|
||||
web_search_pricing: None,
|
||||
timing_signals: ResponseEventSignals::default(),
|
||||
raw_sse_passthrough: false,
|
||||
}
|
||||
@@ -160,6 +165,13 @@ impl CodexCanonicalDecoder {
|
||||
self
|
||||
}
|
||||
|
||||
/// 记录请求工具类型,用于区分标准与预览 Web Search 的按次价格。
|
||||
#[must_use]
|
||||
pub fn with_request_tool_pricing(mut self, model: &str, tools: Option<&[Value]>) -> Self {
|
||||
self.web_search_pricing = web_search_pricing(model, tools);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn push(&mut self, chunk: &[u8]) -> CodexCanonicalOutcome {
|
||||
self.timing_signals = ResponseEventSignals::default();
|
||||
if self.raw_sse_passthrough {
|
||||
@@ -849,15 +861,15 @@ impl CodexCanonicalDecoder {
|
||||
.requested_service_tier
|
||||
.as_deref()
|
||||
.or(self.response_service_tier.as_deref());
|
||||
let web_search_calls = web_search_call_count(response);
|
||||
if let Some(breakdown) = usage
|
||||
.filter(|usage| billable_usage_is_complete(response, *usage))
|
||||
.and_then(|usage| {
|
||||
let web_search_calls = web_search_calls?;
|
||||
openai_billing_breakdown(
|
||||
&model,
|
||||
usage.input_tokens,
|
||||
usage.output_tokens,
|
||||
usage.cached_tokens,
|
||||
usage.cache_write_tokens,
|
||||
OpenAiBillingUsage::from(usage)
|
||||
.with_web_search_calls(web_search_calls, self.web_search_pricing),
|
||||
service_tier,
|
||||
)
|
||||
})
|
||||
@@ -984,6 +996,28 @@ fn core_usage(usage: TokenUsage) -> Usage {
|
||||
normalized
|
||||
}
|
||||
|
||||
fn web_search_call_count(response: &Value) -> Option<u64> {
|
||||
let mut calls = 0_u64;
|
||||
for item in response
|
||||
.get("output")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|item| item.get("type").and_then(Value::as_str) == Some("web_search_call"))
|
||||
{
|
||||
match item
|
||||
.get("action")
|
||||
.and_then(|action| action.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
Some("search") => calls = calls.checked_add(1)?,
|
||||
Some("open_page" | "find_in_page") => {}
|
||||
Some(_) | None => return None,
|
||||
}
|
||||
}
|
||||
Some(calls)
|
||||
}
|
||||
|
||||
fn billable_usage_is_complete(response: &Value, usage: TokenUsage) -> bool {
|
||||
let Some(raw) = response.get("usage").filter(|value| value.is_object()) else {
|
||||
return false;
|
||||
|
||||
@@ -677,7 +677,13 @@ impl CodexBackendClient {
|
||||
// API 透传黑名单也不能覆盖画像,避免下游客户端暴露不一致指纹。
|
||||
if matches!(
|
||||
name.as_str(),
|
||||
"originator" | "user-agent" | "x-oai-attestation" | "x-oai-is" | "x-oai-is-update"
|
||||
"openai-beta"
|
||||
| "originator"
|
||||
| "user-agent"
|
||||
| "x-oai-attestation"
|
||||
| "x-oai-is"
|
||||
| "x-oai-is-update"
|
||||
| "x-openai-internal-codex-residency"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -696,6 +702,10 @@ impl CodexBackendClient {
|
||||
) -> CodexClientResult<HeaderMap> {
|
||||
let mut headers = self.request_headers_for_http_response(request, context)?;
|
||||
headers.remove(X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER);
|
||||
headers.insert(
|
||||
HeaderName::from_static("openai-beta"),
|
||||
HeaderValue::from_static("responses_websockets=2026-02-06"),
|
||||
);
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
@@ -709,14 +719,6 @@ impl CodexBackendClient {
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
insert_optional_header(&mut headers, "cookie", context.cookie_header)?;
|
||||
headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream"));
|
||||
headers.insert(
|
||||
HeaderName::from_static("openai-beta"),
|
||||
HeaderValue::from_static("responses_websockets=2026-02-06"),
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static("x-openai-internal-codex-residency"),
|
||||
HeaderValue::from_static("us"),
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static("x-client-request-id"),
|
||||
HeaderValue::from_str(context.request_id)?,
|
||||
@@ -788,10 +790,6 @@ impl CodexBackendClient {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(USER_AGENT, HeaderValue::from_str(&profile.user_agent())?);
|
||||
headers.insert(AUTHORIZATION, HeaderValue::from_str(context.authorization)?);
|
||||
headers.insert(
|
||||
HeaderName::from_static("originator"),
|
||||
HeaderValue::from_str(&profile.originator)?,
|
||||
);
|
||||
insert_optional_header(&mut headers, "chatgpt-account-id", context.account_id)?;
|
||||
insert_optional_header(&mut headers, "cookie", context.cookie_header)?;
|
||||
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
/// `/codex/responses`
|
||||
pub const CODEX_RESPONSES_PATH: &str = "/codex/responses";
|
||||
/// `/codex/usage`
|
||||
pub const CODEX_USAGE_PATH: &str = "/codex/usage";
|
||||
/// `/api/codex/usage`
|
||||
pub const CODEX_USAGE_API_PATH: &str = "/api/codex/usage";
|
||||
/// `/wham/usage`
|
||||
@@ -16,20 +14,14 @@ pub fn endpoint_url(base_url: &str, endpoint_path: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// 返回 usage 相关 endpoint 的完整 URL 列表。
|
||||
pub fn usage_endpoint_urls(base_url: &str) -> Vec<String> {
|
||||
usage_endpoint_paths(base_url)
|
||||
.into_iter()
|
||||
.map(|path| endpoint_url(base_url, path))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn usage_endpoint_paths(base_url: &str) -> Vec<&'static str> {
|
||||
if has_backend_api_base_path(base_url) {
|
||||
vec![WHAM_USAGE_PATH, CODEX_USAGE_PATH]
|
||||
/// 返回与 base path 对应的唯一 usage endpoint。
|
||||
pub fn usage_endpoint_url(base_url: &str) -> String {
|
||||
let path = if has_backend_api_base_path(base_url) {
|
||||
WHAM_USAGE_PATH
|
||||
} else {
|
||||
vec![CODEX_USAGE_API_PATH, CODEX_USAGE_PATH]
|
||||
}
|
||||
CODEX_USAGE_API_PATH
|
||||
};
|
||||
endpoint_url(base_url, path)
|
||||
}
|
||||
|
||||
fn has_backend_api_base_path(base_url: &str) -> bool {
|
||||
|
||||
@@ -3,20 +3,33 @@ use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue, USER_AG
|
||||
|
||||
use super::client::CodexClientResult;
|
||||
|
||||
const CODEX_RESIDENCY_HEADER: &str = "x-openai-internal-codex-residency";
|
||||
|
||||
/// 构造 Codex Core 为模型请求设置的稳定身份请求头。
|
||||
pub fn build_codex_base_headers(
|
||||
profile: &CodexWireProfile,
|
||||
authorization: &str,
|
||||
account_id: Option<&str>,
|
||||
) -> CodexClientResult<HeaderMap> {
|
||||
let mut headers = HeaderMap::new();
|
||||
let mut headers = build_codex_profile_headers(profile)?;
|
||||
headers.insert(AUTHORIZATION, HeaderValue::from_str(authorization)?);
|
||||
insert_optional_header(&mut headers, "chatgpt-account-id", account_id)?;
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
/// 构造不依赖访问凭据的 Codex Desktop 身份请求头。
|
||||
pub fn build_codex_profile_headers(profile: &CodexWireProfile) -> CodexClientResult<HeaderMap> {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
HeaderName::from_static("originator"),
|
||||
HeaderValue::from_str(&profile.originator)?,
|
||||
);
|
||||
headers.insert(USER_AGENT, HeaderValue::from_str(&profile.user_agent())?);
|
||||
// 此部署固定采用官方支持的 US residency requirement。
|
||||
headers.insert(
|
||||
HeaderName::from_static(CODEX_RESIDENCY_HEADER),
|
||||
HeaderValue::from_static("us"),
|
||||
);
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,13 +32,13 @@ pub use self::{
|
||||
},
|
||||
diagnostics::{CodexUpstreamDiagnostics, CodexUpstreamSendPhase},
|
||||
endpoints::{
|
||||
CODEX_RESPONSES_PATH, CODEX_USAGE_API_PATH, CODEX_USAGE_PATH, WHAM_USAGE_PATH,
|
||||
endpoint_url, usage_endpoint_urls,
|
||||
CODEX_RESPONSES_PATH, CODEX_USAGE_API_PATH, WHAM_USAGE_PATH, endpoint_url,
|
||||
usage_endpoint_url,
|
||||
},
|
||||
headers::build_codex_base_headers,
|
||||
request::{CodexRequestEncodeError, encode_generate_request},
|
||||
response_meta::CodexResponseMetadata,
|
||||
usage::{MAX_CODEX_USAGE_BODY_BYTES, openai_billing_breakdown},
|
||||
usage::{MAX_CODEX_USAGE_BODY_BYTES, OpenAiBillingUsage, openai_billing_breakdown},
|
||||
websocket::{
|
||||
CodexWebSocketPool, CodexWebSocketPoolConfig, CodexWebSocketPoolKey, WebSocketPoolDecision,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
//! 有界读取 Codex Desktop ZIP 内嵌的 arm64 Core 版本。
|
||||
|
||||
use std::cmp;
|
||||
|
||||
use flate2::{Decompress, FlushDecompress, Status};
|
||||
use futures::StreamExt as _;
|
||||
use reqwest::header::{CONTENT_LENGTH, CONTENT_RANGE, RANGE};
|
||||
use reqwest::{Client, StatusCode};
|
||||
use url::Url;
|
||||
|
||||
const OFFICIAL_ARTIFACT_HOST: &str = "persistent.oaistatic.com";
|
||||
const EOCD_SIGNATURE: &[u8; 4] = b"PK\x05\x06";
|
||||
const CENTRAL_SIGNATURE: &[u8; 4] = b"PK\x01\x02";
|
||||
const LOCAL_SIGNATURE: &[u8; 4] = b"PK\x03\x04";
|
||||
const CORE_PATH_SUFFIX: &[u8] = b"/Contents/Resources/codex";
|
||||
const CORE_VERSION_ANCHOR: &[u8] = b"codex-mcp-client/";
|
||||
const MAX_EOCD_SEARCH_BYTES: u64 = 65_535 + 22;
|
||||
const MAX_CENTRAL_DIRECTORY_BYTES: u64 = 16 * 1024 * 1024;
|
||||
const MAX_RANGE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
const CORE_RANGE_CHUNK_BYTES: u64 = 4 * 1024 * 1024;
|
||||
const MAX_CORE_COMPRESSED_BYTES: u64 = 256 * 1024 * 1024;
|
||||
const MAX_CORE_UNCOMPRESSED_BYTES: u64 = 512 * 1024 * 1024;
|
||||
const MAX_VERSION_BYTES: usize = 64;
|
||||
const MACHO_HEADER_BYTES: usize = 8;
|
||||
const MACHO_64_LE_MAGIC: [u8; 4] = [0xcf, 0xfa, 0xed, 0xfe];
|
||||
const MACHO_ARM64_CPU_TYPE: u32 = 0x0100_000c;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CodexDesktopArtifactError {
|
||||
#[error("Codex Desktop artifact URL is invalid")]
|
||||
InvalidUrl,
|
||||
#[error("Codex Desktop artifact URL is not an approved official ZIP")]
|
||||
UnapprovedUrl,
|
||||
#[error("Codex Desktop artifact size is invalid")]
|
||||
InvalidSize,
|
||||
#[error("Codex Desktop artifact range request failed")]
|
||||
Http(#[from] reqwest::Error),
|
||||
#[error("Codex Desktop artifact did not honor an exact range request")]
|
||||
RangeUnsupported,
|
||||
#[error("Codex Desktop artifact returned an invalid range")]
|
||||
InvalidRange,
|
||||
#[error("Codex Desktop artifact ZIP metadata is invalid or unsupported")]
|
||||
InvalidArchive,
|
||||
#[error("Codex Desktop artifact does not contain exactly one bundled Core")]
|
||||
MissingCore,
|
||||
#[error("Codex Desktop bundled Core format is unsupported")]
|
||||
UnsupportedCore,
|
||||
#[error("Codex Desktop bundled Core exceeds the scan limit")]
|
||||
CoreTooLarge,
|
||||
#[error("Codex Desktop bundled Core version marker is missing or invalid")]
|
||||
MissingCoreVersion,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CentralDirectory {
|
||||
offset: u64,
|
||||
size: u64,
|
||||
entries: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct CoreEntry {
|
||||
pub(super) name: Vec<u8>,
|
||||
flags: u16,
|
||||
compression: u16,
|
||||
pub(super) compressed_size: u64,
|
||||
pub(super) uncompressed_size: u64,
|
||||
pub(super) local_header_offset: u64,
|
||||
}
|
||||
|
||||
pub(super) async fn fetch_codex_core_version(
|
||||
client: &Client,
|
||||
artifact_url: &str,
|
||||
artifact_size: u64,
|
||||
) -> Result<String, CodexDesktopArtifactError> {
|
||||
let url = validate_artifact(artifact_url, artifact_size)?;
|
||||
let tail_start = artifact_size.saturating_sub(MAX_EOCD_SEARCH_BYTES);
|
||||
let tail = read_range(client, &url, artifact_size, tail_start, artifact_size - 1).await?;
|
||||
let directory = parse_eocd(&tail, tail_start, artifact_size)?;
|
||||
let directory_end = directory
|
||||
.offset
|
||||
.checked_add(directory.size)
|
||||
.and_then(|end| end.checked_sub(1))
|
||||
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
|
||||
let central = if directory.offset >= tail_start && directory_end < artifact_size {
|
||||
let start = usize::try_from(directory.offset - tail_start)
|
||||
.map_err(|_| CodexDesktopArtifactError::InvalidArchive)?;
|
||||
let end = usize::try_from(directory_end - tail_start + 1)
|
||||
.map_err(|_| CodexDesktopArtifactError::InvalidArchive)?;
|
||||
tail.get(start..end)
|
||||
.ok_or(CodexDesktopArtifactError::InvalidArchive)?
|
||||
.to_vec()
|
||||
} else {
|
||||
read_range(client, &url, artifact_size, directory.offset, directory_end).await?
|
||||
};
|
||||
let entry = find_core_entry(¢ral, directory.entries)?;
|
||||
let data_offset = core_data_offset(client, &url, artifact_size, &entry).await?;
|
||||
scan_core_version(client, &url, artifact_size, data_offset, &entry).await
|
||||
}
|
||||
|
||||
pub(super) fn validate_codex_artifact(
|
||||
artifact_url: &str,
|
||||
artifact_size: u64,
|
||||
) -> Result<(), CodexDesktopArtifactError> {
|
||||
validate_artifact(artifact_url, artifact_size).map(drop)
|
||||
}
|
||||
|
||||
fn validate_artifact(
|
||||
artifact_url: &str,
|
||||
artifact_size: u64,
|
||||
) -> Result<Url, CodexDesktopArtifactError> {
|
||||
if artifact_size == 0 || artifact_size > u64::from(u32::MAX) {
|
||||
return Err(CodexDesktopArtifactError::InvalidSize);
|
||||
}
|
||||
let url = Url::parse(artifact_url).map_err(|_| CodexDesktopArtifactError::InvalidUrl)?;
|
||||
if url.scheme() != "https"
|
||||
|| url.host_str() != Some(OFFICIAL_ARTIFACT_HOST)
|
||||
|| !url.path().to_ascii_lowercase().ends_with(".zip")
|
||||
|| url.username() != ""
|
||||
|| url.password().is_some()
|
||||
|| url.fragment().is_some()
|
||||
{
|
||||
return Err(CodexDesktopArtifactError::UnapprovedUrl);
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
async fn read_range(
|
||||
client: &Client,
|
||||
url: &Url,
|
||||
artifact_size: u64,
|
||||
start: u64,
|
||||
end: u64,
|
||||
) -> Result<Vec<u8>, CodexDesktopArtifactError> {
|
||||
let expected = end
|
||||
.checked_sub(start)
|
||||
.and_then(|length| length.checked_add(1))
|
||||
.filter(|length| *length <= MAX_RANGE_BYTES)
|
||||
.ok_or(CodexDesktopArtifactError::InvalidRange)?;
|
||||
let response = client
|
||||
.get(url.clone())
|
||||
.header(RANGE, format!("bytes={start}-{end}"))
|
||||
.send()
|
||||
.await?;
|
||||
if response.status() != StatusCode::PARTIAL_CONTENT {
|
||||
return Err(CodexDesktopArtifactError::RangeUnsupported);
|
||||
}
|
||||
let content_range = response
|
||||
.headers()
|
||||
.get(CONTENT_RANGE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.ok_or(CodexDesktopArtifactError::InvalidRange)?;
|
||||
if parse_content_range(content_range) != Some((start, end, artifact_size)) {
|
||||
return Err(CodexDesktopArtifactError::InvalidRange);
|
||||
}
|
||||
if response
|
||||
.headers()
|
||||
.get(CONTENT_LENGTH)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
!= Some(expected)
|
||||
{
|
||||
return Err(CodexDesktopArtifactError::InvalidRange);
|
||||
}
|
||||
|
||||
let capacity =
|
||||
usize::try_from(expected).map_err(|_| CodexDesktopArtifactError::InvalidRange)?;
|
||||
let mut body = Vec::with_capacity(capacity);
|
||||
let mut stream = response.bytes_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk?;
|
||||
if body
|
||||
.len()
|
||||
.checked_add(chunk.len())
|
||||
.is_none_or(|length| length > capacity)
|
||||
{
|
||||
return Err(CodexDesktopArtifactError::InvalidRange);
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
if body.len() != capacity {
|
||||
return Err(CodexDesktopArtifactError::InvalidRange);
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
pub(super) fn parse_content_range(value: &str) -> Option<(u64, u64, u64)> {
|
||||
let value = value.strip_prefix("bytes ")?;
|
||||
let (range, size) = value.split_once('/')?;
|
||||
let (start, end) = range.split_once('-')?;
|
||||
Some((start.parse().ok()?, end.parse().ok()?, size.parse().ok()?))
|
||||
}
|
||||
|
||||
fn parse_eocd(
|
||||
tail: &[u8],
|
||||
tail_start: u64,
|
||||
artifact_size: u64,
|
||||
) -> Result<CentralDirectory, CodexDesktopArtifactError> {
|
||||
let position = tail
|
||||
.windows(EOCD_SIGNATURE.len())
|
||||
.rposition(|window| window == EOCD_SIGNATURE)
|
||||
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
|
||||
let eocd = tail
|
||||
.get(position..)
|
||||
.filter(|value| value.len() >= 22)
|
||||
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
|
||||
let comment_length = usize::from(le_u16(eocd, 20)?);
|
||||
if eocd.len() != 22 + comment_length || le_u16(eocd, 4)? != 0 || le_u16(eocd, 6)? != 0 {
|
||||
return Err(CodexDesktopArtifactError::InvalidArchive);
|
||||
}
|
||||
let entries_on_disk = le_u16(eocd, 8)?;
|
||||
let entries = le_u16(eocd, 10)?;
|
||||
let size = u64::from(le_u32(eocd, 12)?);
|
||||
let offset = u64::from(le_u32(eocd, 16)?);
|
||||
if entries == 0
|
||||
|| entries != entries_on_disk
|
||||
|| entries == u16::MAX
|
||||
|| size == u64::from(u32::MAX)
|
||||
|| offset == u64::from(u32::MAX)
|
||||
|| size == 0
|
||||
|| size > MAX_CENTRAL_DIRECTORY_BYTES
|
||||
{
|
||||
return Err(CodexDesktopArtifactError::InvalidArchive);
|
||||
}
|
||||
let eocd_offset = tail_start
|
||||
.checked_add(
|
||||
u64::try_from(position).map_err(|_| CodexDesktopArtifactError::InvalidArchive)?,
|
||||
)
|
||||
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
|
||||
if offset
|
||||
.checked_add(size)
|
||||
.is_none_or(|end| end > eocd_offset || end > artifact_size)
|
||||
{
|
||||
return Err(CodexDesktopArtifactError::InvalidArchive);
|
||||
}
|
||||
Ok(CentralDirectory {
|
||||
offset,
|
||||
size,
|
||||
entries,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn find_core_entry(
|
||||
central: &[u8],
|
||||
expected_entries: u16,
|
||||
) -> Result<CoreEntry, CodexDesktopArtifactError> {
|
||||
let mut cursor = 0_usize;
|
||||
let mut core = None;
|
||||
for _ in 0..expected_entries {
|
||||
let header = central
|
||||
.get(cursor..cursor.saturating_add(46))
|
||||
.filter(|header| header.get(..4) == Some(CENTRAL_SIGNATURE.as_slice()))
|
||||
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
|
||||
let name_length = usize::from(le_u16(header, 28)?);
|
||||
let extra_length = usize::from(le_u16(header, 30)?);
|
||||
let comment_length = usize::from(le_u16(header, 32)?);
|
||||
let entry_length = 46_usize
|
||||
.checked_add(name_length)
|
||||
.and_then(|length| length.checked_add(extra_length))
|
||||
.and_then(|length| length.checked_add(comment_length))
|
||||
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
|
||||
let entry = central
|
||||
.get(cursor..cursor.saturating_add(entry_length))
|
||||
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
|
||||
let name = entry
|
||||
.get(46..46 + name_length)
|
||||
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
|
||||
if name.ends_with(CORE_PATH_SUFFIX) {
|
||||
if core.is_some() {
|
||||
return Err(CodexDesktopArtifactError::MissingCore);
|
||||
}
|
||||
let flags = le_u16(header, 8)?;
|
||||
let compression = le_u16(header, 10)?;
|
||||
let compressed_size = u64::from(le_u32(header, 20)?);
|
||||
let uncompressed_size = u64::from(le_u32(header, 24)?);
|
||||
let local_header_offset = u64::from(le_u32(header, 42)?);
|
||||
if flags & 1 != 0
|
||||
|| compression != 8
|
||||
|| compressed_size == 0
|
||||
|| compressed_size == u64::from(u32::MAX)
|
||||
|| compressed_size > MAX_CORE_COMPRESSED_BYTES
|
||||
|| uncompressed_size == 0
|
||||
|| uncompressed_size == u64::from(u32::MAX)
|
||||
|| uncompressed_size > MAX_CORE_UNCOMPRESSED_BYTES
|
||||
|| local_header_offset == u64::from(u32::MAX)
|
||||
{
|
||||
return Err(CodexDesktopArtifactError::UnsupportedCore);
|
||||
}
|
||||
core = Some(CoreEntry {
|
||||
name: name.to_vec(),
|
||||
flags,
|
||||
compression,
|
||||
compressed_size,
|
||||
uncompressed_size,
|
||||
local_header_offset,
|
||||
});
|
||||
}
|
||||
cursor = cursor
|
||||
.checked_add(entry_length)
|
||||
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
|
||||
}
|
||||
core.ok_or(CodexDesktopArtifactError::MissingCore)
|
||||
}
|
||||
|
||||
async fn core_data_offset(
|
||||
client: &Client,
|
||||
url: &Url,
|
||||
artifact_size: u64,
|
||||
entry: &CoreEntry,
|
||||
) -> Result<u64, CodexDesktopArtifactError> {
|
||||
let fixed_end = entry
|
||||
.local_header_offset
|
||||
.checked_add(29)
|
||||
.filter(|end| *end < artifact_size)
|
||||
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
|
||||
let fixed = read_range(
|
||||
client,
|
||||
url,
|
||||
artifact_size,
|
||||
entry.local_header_offset,
|
||||
fixed_end,
|
||||
)
|
||||
.await?;
|
||||
if fixed.get(..4) != Some(LOCAL_SIGNATURE.as_slice())
|
||||
|| le_u16(&fixed, 6)? != entry.flags
|
||||
|| le_u16(&fixed, 8)? != entry.compression
|
||||
{
|
||||
return Err(CodexDesktopArtifactError::InvalidArchive);
|
||||
}
|
||||
let name_length = u64::from(le_u16(&fixed, 26)?);
|
||||
let extra_length = u64::from(le_u16(&fixed, 28)?);
|
||||
let variable_length = name_length
|
||||
.checked_add(extra_length)
|
||||
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
|
||||
let variable_start = entry
|
||||
.local_header_offset
|
||||
.checked_add(30)
|
||||
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
|
||||
let data_offset = variable_start
|
||||
.checked_add(variable_length)
|
||||
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
|
||||
let data_end = data_offset
|
||||
.checked_add(entry.compressed_size)
|
||||
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
|
||||
if data_end > artifact_size || name_length == 0 {
|
||||
return Err(CodexDesktopArtifactError::InvalidArchive);
|
||||
}
|
||||
let variable_end = data_offset
|
||||
.checked_sub(1)
|
||||
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
|
||||
let variable = read_range(client, url, artifact_size, variable_start, variable_end).await?;
|
||||
let local_name_length =
|
||||
usize::try_from(name_length).map_err(|_| CodexDesktopArtifactError::InvalidArchive)?;
|
||||
if variable.get(..local_name_length) != Some(entry.name.as_slice()) {
|
||||
return Err(CodexDesktopArtifactError::InvalidArchive);
|
||||
}
|
||||
Ok(data_offset)
|
||||
}
|
||||
|
||||
async fn scan_core_version(
|
||||
client: &Client,
|
||||
url: &Url,
|
||||
artifact_size: u64,
|
||||
data_offset: u64,
|
||||
entry: &CoreEntry,
|
||||
) -> Result<String, CodexDesktopArtifactError> {
|
||||
let mut decompressor = Decompress::new(false);
|
||||
let mut scanner = CoreVersionScanner::default();
|
||||
let mut macho_header = Vec::with_capacity(MACHO_HEADER_BYTES);
|
||||
let mut compressed_read = 0_u64;
|
||||
let mut uncompressed_read = 0_u64;
|
||||
while compressed_read < entry.compressed_size {
|
||||
let chunk_length = cmp::min(
|
||||
CORE_RANGE_CHUNK_BYTES,
|
||||
entry.compressed_size - compressed_read,
|
||||
);
|
||||
let start = data_offset
|
||||
.checked_add(compressed_read)
|
||||
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
|
||||
let end = start
|
||||
.checked_add(chunk_length - 1)
|
||||
.ok_or(CodexDesktopArtifactError::InvalidArchive)?;
|
||||
let compressed = read_range(client, url, artifact_size, start, end).await?;
|
||||
compressed_read = compressed_read
|
||||
.checked_add(chunk_length)
|
||||
.ok_or(CodexDesktopArtifactError::CoreTooLarge)?;
|
||||
let final_chunk = compressed_read == entry.compressed_size;
|
||||
let mut input_offset = 0_usize;
|
||||
loop {
|
||||
if input_offset == compressed.len() && !final_chunk {
|
||||
break;
|
||||
}
|
||||
let mut output = [0_u8; 64 * 1024];
|
||||
let input_before = decompressor.total_in();
|
||||
let output_before = decompressor.total_out();
|
||||
let status = decompressor
|
||||
.decompress(
|
||||
&compressed[input_offset..],
|
||||
&mut output,
|
||||
if final_chunk {
|
||||
FlushDecompress::Finish
|
||||
} else {
|
||||
FlushDecompress::None
|
||||
},
|
||||
)
|
||||
.map_err(|_| CodexDesktopArtifactError::UnsupportedCore)?;
|
||||
let consumed = usize::try_from(decompressor.total_in() - input_before)
|
||||
.map_err(|_| CodexDesktopArtifactError::CoreTooLarge)?;
|
||||
let produced = usize::try_from(decompressor.total_out() - output_before)
|
||||
.map_err(|_| CodexDesktopArtifactError::CoreTooLarge)?;
|
||||
if consumed == 0 && produced == 0 {
|
||||
return Err(CodexDesktopArtifactError::UnsupportedCore);
|
||||
}
|
||||
input_offset = input_offset
|
||||
.checked_add(consumed)
|
||||
.ok_or(CodexDesktopArtifactError::CoreTooLarge)?;
|
||||
uncompressed_read = uncompressed_read
|
||||
.checked_add(
|
||||
u64::try_from(produced).map_err(|_| CodexDesktopArtifactError::CoreTooLarge)?,
|
||||
)
|
||||
.filter(|read| {
|
||||
*read <= entry.uncompressed_size && *read <= MAX_CORE_UNCOMPRESSED_BYTES
|
||||
})
|
||||
.ok_or(CodexDesktopArtifactError::CoreTooLarge)?;
|
||||
let output = &output[..produced];
|
||||
if macho_header.len() < MACHO_HEADER_BYTES {
|
||||
let needed = MACHO_HEADER_BYTES - macho_header.len();
|
||||
macho_header.extend_from_slice(&output[..cmp::min(needed, output.len())]);
|
||||
if macho_header.len() == MACHO_HEADER_BYTES {
|
||||
validate_macho_header(&macho_header)?;
|
||||
}
|
||||
}
|
||||
if let Some(version) = scanner.push(output)? {
|
||||
if macho_header.len() != MACHO_HEADER_BYTES {
|
||||
return Err(CodexDesktopArtifactError::UnsupportedCore);
|
||||
}
|
||||
return Ok(version);
|
||||
}
|
||||
if status == Status::StreamEnd {
|
||||
if !final_chunk
|
||||
|| input_offset != compressed.len()
|
||||
|| uncompressed_read != entry.uncompressed_size
|
||||
|| macho_header.len() != MACHO_HEADER_BYTES
|
||||
{
|
||||
return Err(CodexDesktopArtifactError::UnsupportedCore);
|
||||
}
|
||||
return scanner
|
||||
.finish()?
|
||||
.ok_or(CodexDesktopArtifactError::MissingCoreVersion);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(CodexDesktopArtifactError::UnsupportedCore)
|
||||
}
|
||||
|
||||
fn validate_macho_header(header: &[u8]) -> Result<(), CodexDesktopArtifactError> {
|
||||
if header.get(..4) != Some(MACHO_64_LE_MAGIC.as_slice())
|
||||
|| header
|
||||
.get(4..8)
|
||||
.and_then(|bytes| bytes.try_into().ok())
|
||||
.map(u32::from_le_bytes)
|
||||
!= Some(MACHO_ARM64_CPU_TYPE)
|
||||
{
|
||||
return Err(CodexDesktopArtifactError::UnsupportedCore);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct CoreVersionScanner {
|
||||
carry: Vec<u8>,
|
||||
}
|
||||
|
||||
impl CoreVersionScanner {
|
||||
pub(super) fn push(
|
||||
&mut self,
|
||||
bytes: &[u8],
|
||||
) -> Result<Option<String>, CodexDesktopArtifactError> {
|
||||
let mut window = Vec::with_capacity(self.carry.len() + bytes.len());
|
||||
window.extend_from_slice(&self.carry);
|
||||
window.extend_from_slice(bytes);
|
||||
let mut search_start = 0_usize;
|
||||
while let Some(relative_position) = find_bytes(&window[search_start..], CORE_VERSION_ANCHOR)
|
||||
{
|
||||
let position = search_start + relative_position;
|
||||
if let Some(version) =
|
||||
parse_codex_semver(&window[position + CORE_VERSION_ANCHOR.len()..], false)?
|
||||
{
|
||||
return Ok(Some(version));
|
||||
}
|
||||
search_start = position + CORE_VERSION_ANCHOR.len();
|
||||
}
|
||||
let carry_length = cmp::min(window.len(), CORE_VERSION_ANCHOR.len() + MAX_VERSION_BYTES);
|
||||
self.carry.clear();
|
||||
self.carry
|
||||
.extend_from_slice(&window[window.len() - carry_length..]);
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn finish(&self) -> Result<Option<String>, CodexDesktopArtifactError> {
|
||||
let mut search_start = 0_usize;
|
||||
while let Some(relative_position) =
|
||||
find_bytes(&self.carry[search_start..], CORE_VERSION_ANCHOR)
|
||||
{
|
||||
let position = search_start + relative_position;
|
||||
if let Some(version) =
|
||||
parse_codex_semver(&self.carry[position + CORE_VERSION_ANCHOR.len()..], true)?
|
||||
{
|
||||
return Ok(Some(version));
|
||||
}
|
||||
search_start = position + CORE_VERSION_ANCHOR.len();
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_codex_semver(
|
||||
bytes: &[u8],
|
||||
allow_end: bool,
|
||||
) -> Result<Option<String>, CodexDesktopArtifactError> {
|
||||
let mut cursor = 0_usize;
|
||||
for component in 0..3 {
|
||||
let start = cursor;
|
||||
while bytes.get(cursor).is_some_and(u8::is_ascii_digit) {
|
||||
cursor += 1;
|
||||
}
|
||||
if cursor == start || (cursor - start > 1 && bytes[start] == b'0') {
|
||||
return Ok(None);
|
||||
}
|
||||
if component < 2 {
|
||||
if bytes.get(cursor) != Some(&b'.') {
|
||||
return Ok(None);
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
if bytes.get(cursor) == Some(&b'-') {
|
||||
cursor += 1;
|
||||
let channel = [b"alpha".as_slice(), b"beta".as_slice(), b"rc".as_slice()]
|
||||
.into_iter()
|
||||
.find(|channel| bytes.get(cursor..cursor + channel.len()) == Some(*channel));
|
||||
let Some(channel) = channel else {
|
||||
return Ok(None);
|
||||
};
|
||||
cursor += channel.len();
|
||||
let mut numeric_parts = 0_usize;
|
||||
while bytes.get(cursor) == Some(&b'.') {
|
||||
cursor += 1;
|
||||
let start = cursor;
|
||||
while bytes.get(cursor).is_some_and(u8::is_ascii_digit) {
|
||||
cursor += 1;
|
||||
}
|
||||
if cursor == start || (cursor - start > 1 && bytes[start] == b'0') {
|
||||
return Ok(None);
|
||||
}
|
||||
numeric_parts += 1;
|
||||
}
|
||||
if numeric_parts == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
if cursor == bytes.len() && !allow_end {
|
||||
return Ok(None);
|
||||
}
|
||||
if bytes.get(cursor).is_some_and(u8::is_ascii_digit)
|
||||
|| bytes.get(cursor) == Some(&b'.')
|
||||
|| bytes
|
||||
.get(cursor)
|
||||
.is_some_and(|byte| matches!(*byte, b'+' | b'-'))
|
||||
|| cursor > MAX_VERSION_BYTES
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let version = std::str::from_utf8(&bytes[..cursor])
|
||||
.map_err(|_| CodexDesktopArtifactError::MissingCoreVersion)?;
|
||||
semver::Version::parse(version).map_err(|_| CodexDesktopArtifactError::MissingCoreVersion)?;
|
||||
Ok(Some(version.to_owned()))
|
||||
}
|
||||
|
||||
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
haystack
|
||||
.windows(needle.len())
|
||||
.position(|window| window == needle)
|
||||
}
|
||||
|
||||
fn le_u16(bytes: &[u8], offset: usize) -> Result<u16, CodexDesktopArtifactError> {
|
||||
bytes
|
||||
.get(offset..offset + 2)
|
||||
.and_then(|value| value.try_into().ok())
|
||||
.map(u16::from_le_bytes)
|
||||
.ok_or(CodexDesktopArtifactError::InvalidArchive)
|
||||
}
|
||||
|
||||
fn le_u32(bytes: &[u8], offset: usize) -> Result<u32, CodexDesktopArtifactError> {
|
||||
bytes
|
||||
.get(offset..offset + 4)
|
||||
.and_then(|value| value.try_into().ok())
|
||||
.map(u32::from_le_bytes)
|
||||
.ok_or(CodexDesktopArtifactError::InvalidArchive)
|
||||
}
|
||||
+273
-107
@@ -5,31 +5,42 @@ use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::{StreamExt as _, future::BoxFuture};
|
||||
use gateway_core::engine::credential::OpaqueProviderData;
|
||||
use gateway_core::provider_ports::{
|
||||
ProviderArtifactProfile, ProviderArtifactProfileCachePort, ProviderStoreError,
|
||||
};
|
||||
use gateway_core::routing::ProviderKind;
|
||||
use reqwest::Client;
|
||||
use reqwest::redirect::Policy;
|
||||
use roxmltree::{Document, Node};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
use url::Url;
|
||||
|
||||
use self::desktop_artifact::{CodexDesktopArtifactError, fetch_codex_core_version};
|
||||
|
||||
mod desktop_artifact;
|
||||
|
||||
/// Codex Desktop 官方 appcast 地址。
|
||||
pub const CODEX_DESKTOP_APPCAST_URL: &str =
|
||||
"https://persistent.oaistatic.com/codex-app-prod/appcast.xml";
|
||||
pub const CODEX_CLI_RELEASE_URL: &str = "https://registry.npmjs.org/@openai%2Fcodex/latest";
|
||||
/// 官方发布元数据检查周期。
|
||||
pub const APPCAST_POLL_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
const APPCAST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const MAX_APPCAST_BYTES: usize = 1024 * 1024;
|
||||
const MAX_CLI_RELEASE_BYTES: usize = 64 * 1024;
|
||||
const ARTIFACT_PROFILE_CACHE_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60);
|
||||
const ARTIFACT_PROFILE_SCHEMA_VERSION: u64 = 1;
|
||||
|
||||
/// Codex Desktop 上游请求身份。
|
||||
///
|
||||
/// 启动配置提供经源码审计的 Core、运行环境和 Desktop 启动版本。npm CLI metadata
|
||||
/// 与官方 appcast 检查成功后,分别同步 Core 版本和 Desktop 版本及构建号。
|
||||
/// 启动配置提供经源码审计的 Core、运行环境和 Desktop 启动版本。运行时只会使用
|
||||
/// 同一个官方 Desktop ZIP 中核验出的 Core、Desktop 版本及构建号原子替换版本字段。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodexWireProfile {
|
||||
/// `originator` 请求头及 User-Agent 产品名。
|
||||
pub originator: String,
|
||||
/// npm `@openai/codex` 启动版本;用于 `/codex/models?client_version=` 与 UA。
|
||||
/// Desktop ZIP 内嵌 Core 版本;用于 `/codex/models?client_version=` 与 UA。
|
||||
pub codex_version: String,
|
||||
/// Desktop 应用版本,用于 app-server `clientInfo.version` 对应的 UA 后缀。
|
||||
pub desktop_version: String,
|
||||
@@ -86,124 +97,148 @@ impl CodexWireProfileState {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// 使用官方 appcast 已验证的 Desktop 版本和构建号更新运行时画像。
|
||||
pub fn update_desktop_release(&self, desktop_version: &str, desktop_build: &str) {
|
||||
/// 原子发布同一 Desktop ZIP 中核验出的完整版本元组。
|
||||
pub fn update_bundled_release(&self, release: &CodexBundledReleaseProfile) {
|
||||
let mut profile = self
|
||||
.profile
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if profile.desktop_version == desktop_version && profile.desktop_build == desktop_build {
|
||||
return;
|
||||
profile.codex_version.clone_from(&release.codex_version);
|
||||
profile.desktop_version.clone_from(&release.desktop_version);
|
||||
profile.desktop_build.clone_from(&release.desktop_build);
|
||||
profile.verified_at = release.verified_at;
|
||||
}
|
||||
}
|
||||
|
||||
/// 同一个官方 Desktop ZIP 中核验出的原子版本元组。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodexBundledReleaseProfile {
|
||||
pub codex_version: String,
|
||||
pub desktop_version: String,
|
||||
pub desktop_build: String,
|
||||
pub verified_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl CodexBundledReleaseProfile {
|
||||
fn artifact_sequence(&self) -> Result<u64, CodexDesktopReleaseError> {
|
||||
parse_build_sequence(&self.desktop_build)
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), CodexDesktopReleaseError> {
|
||||
semver::Version::parse(&self.codex_version)
|
||||
.map_err(|_| CodexDesktopReleaseError::InvalidCoreVersion)?;
|
||||
if !numeric_dotted_version(&self.desktop_version) {
|
||||
return Err(CodexDesktopReleaseError::InvalidVersion);
|
||||
}
|
||||
profile.desktop_version = desktop_version.to_owned();
|
||||
profile.desktop_build = desktop_build.to_owned();
|
||||
}
|
||||
|
||||
/// 使用官方 npm metadata 发布的 Codex CLI 版本更新运行时请求画像。
|
||||
pub fn update_cli_release(&self, codex_version: &str) {
|
||||
let mut profile = self
|
||||
.profile
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if profile.codex_version != codex_version {
|
||||
profile.codex_version = codex_version.to_owned();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait CodexCliReleaseTransport: Send + Sync {
|
||||
fn fetch(&self) -> BoxFuture<'_, Result<String, CodexCliReleaseError>>;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OfficialCodexCliReleaseTransport {
|
||||
client: Client,
|
||||
endpoint: Url,
|
||||
}
|
||||
|
||||
impl OfficialCodexCliReleaseTransport {
|
||||
pub fn new() -> Result<Self, CodexCliReleaseError> {
|
||||
let endpoint =
|
||||
Url::parse(CODEX_CLI_RELEASE_URL).map_err(|_| CodexCliReleaseError::InvalidEndpoint)?;
|
||||
let client = Client::builder()
|
||||
.https_only(true)
|
||||
.no_proxy()
|
||||
.redirect(Policy::none())
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(APPCAST_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|_| CodexCliReleaseError::ClientInitialization)?;
|
||||
Ok(Self { client, endpoint })
|
||||
}
|
||||
}
|
||||
|
||||
impl CodexCliReleaseTransport for OfficialCodexCliReleaseTransport {
|
||||
fn fetch(&self) -> BoxFuture<'_, Result<String, CodexCliReleaseError>> {
|
||||
Box::pin(async move {
|
||||
let response = self.client.get(self.endpoint.clone()).send().await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(CodexCliReleaseError::HttpStatus(response.status().as_u16()));
|
||||
}
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|size| size > MAX_CLI_RELEASE_BYTES as u64)
|
||||
{
|
||||
return Err(CodexCliReleaseError::ResponseTooLarge);
|
||||
}
|
||||
let bytes = response.bytes().await?;
|
||||
if bytes.len() > MAX_CLI_RELEASE_BYTES {
|
||||
return Err(CodexCliReleaseError::ResponseTooLarge);
|
||||
}
|
||||
let document: serde_json::Value = serde_json::from_slice(&bytes)
|
||||
.map_err(|_| CodexCliReleaseError::InvalidDocument)?;
|
||||
let version = document
|
||||
.get("version")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or(CodexCliReleaseError::InvalidDocument)?;
|
||||
semver::Version::parse(version).map_err(|_| CodexCliReleaseError::InvalidVersion)?;
|
||||
Ok(version.to_owned())
|
||||
})
|
||||
self.artifact_sequence()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CodexCliReleaseService {
|
||||
transport: Arc<dyn CodexCliReleaseTransport>,
|
||||
profile: CodexWireProfileState,
|
||||
pub struct CodexArtifactProfileCache {
|
||||
provider_kind: ProviderKind,
|
||||
store: Arc<dyn ProviderArtifactProfileCachePort>,
|
||||
}
|
||||
|
||||
impl CodexCliReleaseService {
|
||||
impl CodexArtifactProfileCache {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
profile: CodexWireProfileState,
|
||||
transport: Arc<dyn CodexCliReleaseTransport>,
|
||||
provider_kind: ProviderKind,
|
||||
store: Arc<dyn ProviderArtifactProfileCachePort>,
|
||||
) -> Self {
|
||||
Self { transport, profile }
|
||||
Self {
|
||||
provider_kind,
|
||||
store,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn refresh(&self) -> Result<String, CodexCliReleaseError> {
|
||||
let version = self.transport.fetch().await?;
|
||||
self.profile.update_cli_release(&version);
|
||||
Ok(version)
|
||||
pub async fn load(
|
||||
&self,
|
||||
) -> Result<Option<CodexBundledReleaseProfile>, CodexDesktopReleaseError> {
|
||||
self.store
|
||||
.read(&self.provider_kind)
|
||||
.await
|
||||
.map_err(CodexDesktopReleaseError::ArtifactCache)?
|
||||
.map(decode_artifact_profile)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn replace_if_newer(
|
||||
&self,
|
||||
profile: &CodexBundledReleaseProfile,
|
||||
) -> Result<bool, CodexDesktopReleaseError> {
|
||||
profile.validate()?;
|
||||
self.store
|
||||
.replace_if_newer(
|
||||
encode_artifact_profile(self.provider_kind.clone(), profile)?,
|
||||
ARTIFACT_PROFILE_CACHE_TTL,
|
||||
)
|
||||
.await
|
||||
.map_err(CodexDesktopReleaseError::ArtifactCache)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CodexCliReleaseError {
|
||||
#[error("Codex CLI release client initialization failed")]
|
||||
ClientInitialization,
|
||||
#[error("Codex CLI release endpoint is invalid")]
|
||||
InvalidEndpoint,
|
||||
#[error("Codex CLI release request failed")]
|
||||
Request(#[from] reqwest::Error),
|
||||
#[error("Codex CLI release endpoint returned HTTP {0}")]
|
||||
HttpStatus(u16),
|
||||
#[error("Codex CLI release response exceeded the size limit")]
|
||||
ResponseTooLarge,
|
||||
#[error("Codex CLI release document is invalid")]
|
||||
InvalidDocument,
|
||||
#[error("Codex CLI release version is invalid")]
|
||||
InvalidVersion,
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CachedArtifactProfile {
|
||||
schema_version: u64,
|
||||
codex_version: String,
|
||||
desktop_version: String,
|
||||
desktop_build: String,
|
||||
}
|
||||
|
||||
fn encode_artifact_profile(
|
||||
provider_kind: ProviderKind,
|
||||
profile: &CodexBundledReleaseProfile,
|
||||
) -> Result<ProviderArtifactProfile, CodexDesktopReleaseError> {
|
||||
let artifact_sequence = profile.artifact_sequence()?;
|
||||
let mut fields = Map::new();
|
||||
fields.insert(
|
||||
"schema_version".to_owned(),
|
||||
Value::from(ARTIFACT_PROFILE_SCHEMA_VERSION),
|
||||
);
|
||||
fields.insert(
|
||||
"codex_version".to_owned(),
|
||||
Value::String(profile.codex_version.clone()),
|
||||
);
|
||||
fields.insert(
|
||||
"desktop_version".to_owned(),
|
||||
Value::String(profile.desktop_version.clone()),
|
||||
);
|
||||
fields.insert(
|
||||
"desktop_build".to_owned(),
|
||||
Value::String(profile.desktop_build.clone()),
|
||||
);
|
||||
Ok(ProviderArtifactProfile::new(
|
||||
provider_kind,
|
||||
artifact_sequence,
|
||||
profile.verified_at.into(),
|
||||
OpaqueProviderData::new(fields),
|
||||
))
|
||||
}
|
||||
|
||||
fn decode_artifact_profile(
|
||||
profile: ProviderArtifactProfile,
|
||||
) -> Result<CodexBundledReleaseProfile, CodexDesktopReleaseError> {
|
||||
let wire: CachedArtifactProfile = serde_json::from_value(Value::Object(
|
||||
profile.profile().expose_to_provider().clone(),
|
||||
))
|
||||
.map_err(|_| CodexDesktopReleaseError::InvalidArtifactCache)?;
|
||||
if wire.schema_version != ARTIFACT_PROFILE_SCHEMA_VERSION {
|
||||
return Err(CodexDesktopReleaseError::InvalidArtifactCache);
|
||||
}
|
||||
let decoded = CodexBundledReleaseProfile {
|
||||
codex_version: wire.codex_version,
|
||||
desktop_version: wire.desktop_version,
|
||||
desktop_build: wire.desktop_build,
|
||||
verified_at: DateTime::<Utc>::from(profile.verified_at()),
|
||||
};
|
||||
decoded.validate()?;
|
||||
if decoded.artifact_sequence()? != profile.artifact_sequence() {
|
||||
return Err(CodexDesktopReleaseError::InvalidArtifactCache);
|
||||
}
|
||||
Ok(decoded)
|
||||
}
|
||||
|
||||
/// 官方 appcast 中按顺序出现的首个完整 Desktop 制品。
|
||||
@@ -266,6 +301,11 @@ impl CodexDesktopReleaseStatus {
|
||||
/// Desktop appcast 获取边界。生产实现固定访问官方 HTTPS,测试实现只替换此边界。
|
||||
pub trait CodexDesktopReleaseTransport: Send + Sync {
|
||||
fn fetch(&self) -> BoxFuture<'_, Result<CodexDesktopRelease, CodexDesktopReleaseError>>;
|
||||
|
||||
fn fetch_bundled_core_version<'a>(
|
||||
&'a self,
|
||||
release: &'a CodexDesktopRelease,
|
||||
) -> BoxFuture<'a, Result<String, CodexDesktopReleaseError>>;
|
||||
}
|
||||
|
||||
/// 固定访问官方 Desktop appcast 的生产 transport。
|
||||
@@ -325,6 +365,24 @@ impl CodexDesktopReleaseTransport for OfficialCodexDesktopReleaseTransport {
|
||||
parse_desktop_release(xml)
|
||||
})
|
||||
}
|
||||
|
||||
fn fetch_bundled_core_version<'a>(
|
||||
&'a self,
|
||||
release: &'a CodexDesktopRelease,
|
||||
) -> BoxFuture<'a, Result<String, CodexDesktopReleaseError>> {
|
||||
Box::pin(async move {
|
||||
let artifact_url = release
|
||||
.download_url
|
||||
.as_deref()
|
||||
.ok_or(CodexDesktopReleaseError::MissingArtifactIdentity)?;
|
||||
let artifact_size = release
|
||||
.download_size
|
||||
.ok_or(CodexDesktopReleaseError::MissingArtifactIdentity)?;
|
||||
fetch_codex_core_version(&self.client, artifact_url, artifact_size)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 拉取 appcast 并原子发布请求画像的 Provider 服务。
|
||||
@@ -333,6 +391,8 @@ pub struct CodexDesktopReleaseService {
|
||||
transport: Arc<dyn CodexDesktopReleaseTransport>,
|
||||
status: CodexDesktopReleaseStatus,
|
||||
profile: CodexWireProfileState,
|
||||
cache: CodexArtifactProfileCache,
|
||||
verified: Arc<RwLock<Option<CodexBundledReleaseProfile>>>,
|
||||
}
|
||||
|
||||
impl CodexDesktopReleaseService {
|
||||
@@ -341,11 +401,18 @@ impl CodexDesktopReleaseService {
|
||||
pub fn new(
|
||||
profile: CodexWireProfileState,
|
||||
transport: Arc<dyn CodexDesktopReleaseTransport>,
|
||||
cache: CodexArtifactProfileCache,
|
||||
verified: Option<CodexBundledReleaseProfile>,
|
||||
) -> Self {
|
||||
if let Some(verified) = verified.as_ref() {
|
||||
profile.update_bundled_release(verified);
|
||||
}
|
||||
Self {
|
||||
transport,
|
||||
status: CodexDesktopReleaseStatus::default(),
|
||||
profile,
|
||||
cache,
|
||||
verified: Arc::new(RwLock::new(verified)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,11 +424,9 @@ impl CodexDesktopReleaseService {
|
||||
/// 执行一次有界检查;失败只更新观察状态,不修改上一份成功画像。
|
||||
pub async fn refresh(&self) -> Result<CodexDesktopRelease, CodexDesktopReleaseError> {
|
||||
let checked_at = Utc::now();
|
||||
let result = self.transport.fetch().await;
|
||||
let result = self.refresh_inner(checked_at).await;
|
||||
match result {
|
||||
Ok(release) => {
|
||||
self.profile
|
||||
.update_desktop_release(&release.version, &release.build);
|
||||
self.status.record_success(checked_at, release.clone());
|
||||
Ok(release)
|
||||
}
|
||||
@@ -371,6 +436,91 @@ impl CodexDesktopReleaseService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn refresh_inner(
|
||||
&self,
|
||||
checked_at: DateTime<Utc>,
|
||||
) -> Result<CodexDesktopRelease, CodexDesktopReleaseError> {
|
||||
let release = self.transport.fetch().await?;
|
||||
validate_release_artifact(&release)?;
|
||||
let release_sequence = parse_build_sequence(&release.build)?;
|
||||
let verified = self
|
||||
.verified
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone();
|
||||
if let Some(verified) = verified {
|
||||
let verified_sequence = verified.artifact_sequence()?;
|
||||
if release_sequence < verified_sequence {
|
||||
return Err(CodexDesktopReleaseError::ReleaseRollback);
|
||||
}
|
||||
if release_sequence == verified_sequence {
|
||||
if release.version != verified.desktop_version
|
||||
|| release.build != verified.desktop_build
|
||||
{
|
||||
return Err(CodexDesktopReleaseError::InconsistentRelease);
|
||||
}
|
||||
if !self.cache.replace_if_newer(&verified).await? {
|
||||
return Err(CodexDesktopReleaseError::ReleaseRollback);
|
||||
}
|
||||
self.profile.update_bundled_release(&verified);
|
||||
return Ok(release);
|
||||
}
|
||||
}
|
||||
|
||||
let codex_version = self.transport.fetch_bundled_core_version(&release).await?;
|
||||
let bundled = CodexBundledReleaseProfile {
|
||||
codex_version,
|
||||
desktop_version: release.version.clone(),
|
||||
desktop_build: release.build.clone(),
|
||||
verified_at: checked_at,
|
||||
};
|
||||
bundled.validate()?;
|
||||
let published = if self.cache.replace_if_newer(&bundled).await? {
|
||||
bundled
|
||||
} else {
|
||||
self.cache
|
||||
.load()
|
||||
.await?
|
||||
.filter(|cached| {
|
||||
cached
|
||||
.artifact_sequence()
|
||||
.is_ok_and(|sequence| sequence >= release_sequence)
|
||||
})
|
||||
.ok_or(CodexDesktopReleaseError::ReleaseRollback)?
|
||||
};
|
||||
self.profile.update_bundled_release(&published);
|
||||
*self
|
||||
.verified
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(published);
|
||||
Ok(release)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_release_artifact(
|
||||
release: &CodexDesktopRelease,
|
||||
) -> Result<(), CodexDesktopReleaseError> {
|
||||
if !release.signature_present {
|
||||
return Err(CodexDesktopReleaseError::MissingArtifactSignature);
|
||||
}
|
||||
let artifact_url = release
|
||||
.download_url
|
||||
.as_deref()
|
||||
.ok_or(CodexDesktopReleaseError::MissingArtifactIdentity)?;
|
||||
let artifact_size = release
|
||||
.download_size
|
||||
.ok_or(CodexDesktopReleaseError::MissingArtifactIdentity)?;
|
||||
desktop_artifact::validate_codex_artifact(artifact_url, artifact_size)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_build_sequence(value: &str) -> Result<u64, CodexDesktopReleaseError> {
|
||||
value
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|sequence| *sequence > 0)
|
||||
.ok_or(CodexDesktopReleaseError::InvalidBuild)
|
||||
}
|
||||
|
||||
/// 解析 appcast 中按顺序出现的首个完整发布项。
|
||||
@@ -489,4 +639,20 @@ pub enum CodexDesktopReleaseError {
|
||||
InvalidPublishedAt,
|
||||
#[error("Codex Desktop appcast download size is invalid")]
|
||||
InvalidDownloadSize,
|
||||
#[error("Codex Desktop appcast artifact identity is incomplete")]
|
||||
MissingArtifactIdentity,
|
||||
#[error("Codex Desktop appcast artifact signature is missing")]
|
||||
MissingArtifactSignature,
|
||||
#[error("Codex Desktop bundled Core version is invalid")]
|
||||
InvalidCoreVersion,
|
||||
#[error(transparent)]
|
||||
Artifact(#[from] CodexDesktopArtifactError),
|
||||
#[error("Codex Desktop artifact profile cache operation failed")]
|
||||
ArtifactCache(#[source] ProviderStoreError),
|
||||
#[error("Codex Desktop artifact profile cache is invalid")]
|
||||
InvalidArtifactCache,
|
||||
#[error("Codex Desktop appcast attempted to roll back the verified artifact")]
|
||||
ReleaseRollback,
|
||||
#[error("Codex Desktop appcast release identity conflicts with the verified artifact")]
|
||||
InconsistentRelease,
|
||||
}
|
||||
@@ -2,19 +2,95 @@ use gateway_core::accounting::{
|
||||
CalculatedCostAmounts, CalculatedCostBreakdown, CalculatedCostRates, CurrencyCode, Decimal,
|
||||
Money,
|
||||
};
|
||||
use gateway_protocol::openai::events::retry_after_seconds_from_body;
|
||||
use gateway_protocol::openai::events::{TokenUsage, retry_after_seconds_from_body};
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{
|
||||
CodexBackendClient, CodexClientError, CodexClientResult, CodexRequestContext,
|
||||
CodexUpstreamDiagnostics,
|
||||
client::{read_capped_response_body, retry_after_seconds, truncate_for_error},
|
||||
endpoints::usage_endpoint_urls,
|
||||
endpoints::usage_endpoint_url,
|
||||
response_meta,
|
||||
};
|
||||
|
||||
const LONG_CONTEXT_THRESHOLD: u64 = 272_000;
|
||||
const WEB_SEARCH_CALL_TICKS: u128 = 100_000_000;
|
||||
const WEB_SEARCH_PREVIEW_NON_REASONING_CALL_TICKS: u128 = 250_000_000;
|
||||
|
||||
/// OpenAI 公开 Token 价格计算所需的单次用量事实。
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct OpenAiBillingUsage {
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
cached_tokens: u64,
|
||||
cache_write_tokens: u64,
|
||||
image_input_tokens: u64,
|
||||
image_output_tokens: u64,
|
||||
web_search_calls: u64,
|
||||
web_search_pricing: Option<WebSearchPricing>,
|
||||
}
|
||||
|
||||
impl OpenAiBillingUsage {
|
||||
/// 构造不含托管工具费用的 Token 用量。
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
cached_tokens: u64,
|
||||
cache_write_tokens: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cached_tokens,
|
||||
cache_write_tokens,
|
||||
image_input_tokens: 0,
|
||||
image_output_tokens: 0,
|
||||
web_search_calls: 0,
|
||||
web_search_pricing: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const fn with_web_search_calls(
|
||||
mut self,
|
||||
calls: u64,
|
||||
pricing: Option<WebSearchPricing>,
|
||||
) -> Self {
|
||||
self.web_search_calls = calls;
|
||||
self.web_search_pricing = pricing;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TokenUsage> for OpenAiBillingUsage {
|
||||
fn from(usage: TokenUsage) -> Self {
|
||||
Self {
|
||||
input_tokens: usage.input_tokens,
|
||||
output_tokens: usage.output_tokens,
|
||||
cached_tokens: usage.cached_tokens,
|
||||
cache_write_tokens: usage.cache_write_tokens,
|
||||
image_input_tokens: usage.image_input_tokens,
|
||||
image_output_tokens: usage.image_output_tokens,
|
||||
web_search_calls: 0,
|
||||
web_search_pricing: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum WebSearchPricing {
|
||||
Standard,
|
||||
PreviewNonReasoning,
|
||||
}
|
||||
|
||||
impl WebSearchPricing {
|
||||
const fn price_per_call_ticks(self) -> u128 {
|
||||
match self {
|
||||
Self::Standard => WEB_SEARCH_CALL_TICKS,
|
||||
Self::PreviewNonReasoning => WEB_SEARCH_PREVIEW_NON_REASONING_CALL_TICKS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct TokenRates {
|
||||
@@ -43,9 +119,11 @@ impl TokenRates {
|
||||
#[derive(Clone, Copy)]
|
||||
struct ModelPricing {
|
||||
standard: TokenRates,
|
||||
priority: TokenRates,
|
||||
long: TokenRates,
|
||||
long_priority: TokenRates,
|
||||
flex: TokenRates,
|
||||
fast: TokenRates,
|
||||
long_standard: TokenRates,
|
||||
long_flex: TokenRates,
|
||||
long_fast: TokenRates,
|
||||
cache_write_percent: u32,
|
||||
}
|
||||
|
||||
@@ -53,29 +131,37 @@ impl ModelPricing {
|
||||
const fn new(input: u128, output: u128, cache_read: u128) -> Self {
|
||||
Self {
|
||||
standard: TokenRates::new(input, output, cache_read),
|
||||
priority: TokenRates::ZERO,
|
||||
long: TokenRates::ZERO,
|
||||
long_priority: TokenRates::ZERO,
|
||||
flex: TokenRates::ZERO,
|
||||
fast: TokenRates::ZERO,
|
||||
long_standard: TokenRates::ZERO,
|
||||
long_flex: TokenRates::ZERO,
|
||||
long_fast: TokenRates::ZERO,
|
||||
cache_write_percent: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const fn with_priority(mut self, input: u128, output: u128, cache_read: u128) -> Self {
|
||||
self.priority = TokenRates::new(input, output, cache_read);
|
||||
const fn with_flex(mut self, input: u128, output: u128, cache_read: u128) -> Self {
|
||||
self.flex = TokenRates::new(input, output, cache_read);
|
||||
self
|
||||
}
|
||||
|
||||
const fn with_long_context(
|
||||
mut self,
|
||||
input: u128,
|
||||
output: u128,
|
||||
cache_read: u128,
|
||||
priority_input: u128,
|
||||
priority_output: u128,
|
||||
priority_cache_read: u128,
|
||||
) -> Self {
|
||||
self.long = TokenRates::new(input, output, cache_read);
|
||||
self.long_priority = TokenRates::new(priority_input, priority_output, priority_cache_read);
|
||||
const fn with_fast(mut self, input: u128, output: u128, cache_read: u128) -> Self {
|
||||
self.fast = TokenRates::new(input, output, cache_read);
|
||||
self
|
||||
}
|
||||
|
||||
const fn with_long(mut self, input: u128, output: u128, cache_read: u128) -> Self {
|
||||
self.long_standard = TokenRates::new(input, output, cache_read);
|
||||
self
|
||||
}
|
||||
|
||||
const fn with_long_flex(mut self, input: u128, output: u128, cache_read: u128) -> Self {
|
||||
self.long_flex = TokenRates::new(input, output, cache_read);
|
||||
self
|
||||
}
|
||||
|
||||
const fn with_long_fast(mut self, input: u128, output: u128, cache_read: u128) -> Self {
|
||||
self.long_fast = TokenRates::new(input, output, cache_read);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -83,6 +169,29 @@ impl ModelPricing {
|
||||
self.cache_write_percent = percent;
|
||||
self
|
||||
}
|
||||
|
||||
fn rates(self, tier: PricingTier, long_context: bool) -> Option<TokenRates> {
|
||||
// Only models with a published long-context column switch at the threshold.
|
||||
// A dash in that column is not a second, unavailable price tier for models
|
||||
// whose entire supported context is covered by the short-context price.
|
||||
let uses_long_rates = long_context && self.long_standard.is_configured();
|
||||
let rates = match (tier, uses_long_rates) {
|
||||
(PricingTier::Standard, false) => self.standard,
|
||||
(PricingTier::Flex, false) => self.flex,
|
||||
(PricingTier::Fast, false) => self.fast,
|
||||
(PricingTier::Standard, true) => self.long_standard,
|
||||
(PricingTier::Flex, true) => self.long_flex,
|
||||
(PricingTier::Fast, true) => self.long_fast,
|
||||
};
|
||||
rates.is_configured().then_some(rates)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum PricingTier {
|
||||
Standard,
|
||||
Flex,
|
||||
Fast,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -91,86 +200,178 @@ struct PricingRule {
|
||||
pricing: ModelPricing,
|
||||
}
|
||||
|
||||
// Source: https://developers.openai.com/api/docs/pricing (verified 2026-08-14).
|
||||
const PRICING_RULES: &[PricingRule] = &[
|
||||
PricingRule {
|
||||
model: "gpt-5.6-sol",
|
||||
pricing: ModelPricing::new(50_000, 300_000, 5_000)
|
||||
.with_cache_write(125)
|
||||
.with_priority(100_000, 600_000, 10_000)
|
||||
.with_long_context(100_000, 450_000, 10_000, 0, 0, 0),
|
||||
.with_flex(25_000, 150_000, 2_500)
|
||||
.with_fast(100_000, 600_000, 10_000)
|
||||
.with_long(100_000, 450_000, 10_000)
|
||||
.with_long_flex(50_000, 225_000, 5_000)
|
||||
.with_long_fast(200_000, 900_000, 20_000),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5.6-terra",
|
||||
pricing: ModelPricing::new(20_000, 120_000, 2_000)
|
||||
.with_cache_write(125)
|
||||
.with_priority(40_000, 240_000, 4_000)
|
||||
.with_long_context(40_000, 180_000, 4_000, 0, 0, 0),
|
||||
.with_flex(10_000, 60_000, 1_000)
|
||||
.with_fast(40_000, 240_000, 4_000)
|
||||
.with_long(40_000, 180_000, 4_000)
|
||||
.with_long_flex(20_000, 90_000, 2_000)
|
||||
.with_long_fast(80_000, 360_000, 8_000),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5.6-luna",
|
||||
pricing: ModelPricing::new(2_000, 12_000, 200)
|
||||
.with_cache_write(125)
|
||||
.with_priority(4_000, 24_000, 400)
|
||||
.with_long_context(4_000, 18_000, 400, 0, 0, 0),
|
||||
.with_flex(1_000, 6_000, 100)
|
||||
.with_fast(4_000, 24_000, 400)
|
||||
.with_long(4_000, 18_000, 400)
|
||||
.with_long_flex(2_000, 9_000, 200)
|
||||
.with_long_fast(8_000, 36_000, 800),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5.6",
|
||||
pricing: ModelPricing::new(50_000, 300_000, 5_000)
|
||||
.with_cache_write(125)
|
||||
.with_priority(100_000, 600_000, 10_000)
|
||||
.with_long_context(100_000, 450_000, 10_000, 0, 0, 0),
|
||||
.with_flex(25_000, 150_000, 2_500)
|
||||
.with_fast(100_000, 600_000, 10_000)
|
||||
.with_long(100_000, 450_000, 10_000)
|
||||
.with_long_flex(50_000, 225_000, 5_000)
|
||||
.with_long_fast(200_000, 900_000, 20_000),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5.5-pro",
|
||||
pricing: ModelPricing::new(300_000, 1_800_000, 0)
|
||||
.with_priority(750_000, 4_500_000, 0)
|
||||
.with_long_context(600_000, 2_700_000, 0, 1_500_000, 6_750_000, 0),
|
||||
.with_flex(150_000, 900_000, 0)
|
||||
.with_long(600_000, 2_700_000, 0),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5.5",
|
||||
pricing: ModelPricing::new(50_000, 300_000, 5_000)
|
||||
.with_priority(125_000, 750_000, 12_500)
|
||||
.with_long_context(100_000, 450_000, 10_000, 250_000, 1_125_000, 25_000),
|
||||
.with_flex(25_000, 150_000, 2_500)
|
||||
.with_fast(125_000, 750_000, 12_500)
|
||||
.with_long(100_000, 450_000, 10_000)
|
||||
.with_long_flex(50_000, 225_000, 5_000),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5.4-mini",
|
||||
pricing: ModelPricing::new(7_500, 45_000, 750),
|
||||
pricing: ModelPricing::new(7_500, 45_000, 750)
|
||||
.with_flex(3_750, 22_500, 375)
|
||||
.with_fast(15_000, 90_000, 1_500),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5.4-nano",
|
||||
pricing: ModelPricing::new(2_000, 12_500, 200),
|
||||
pricing: ModelPricing::new(2_000, 12_500, 200).with_flex(1_000, 6_250, 100),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5.4-pro",
|
||||
pricing: ModelPricing::new(300_000, 1_800_000, 0)
|
||||
.with_priority(750_000, 4_500_000, 0)
|
||||
.with_long_context(600_000, 2_700_000, 0, 1_500_000, 6_750_000, 0),
|
||||
.with_flex(150_000, 900_000, 0)
|
||||
.with_long(600_000, 2_700_000, 0)
|
||||
.with_long_flex(300_000, 1_350_000, 0),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5.4",
|
||||
pricing: ModelPricing::new(25_000, 150_000, 2_500)
|
||||
.with_priority(50_000, 300_000, 5_000)
|
||||
.with_long_context(50_000, 225_000, 5_000, 100_000, 450_000, 10_000),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5.3-codex-spark",
|
||||
pricing: ModelPricing::new(12_500, 100_000, 1_250).with_priority(25_000, 200_000, 2_500),
|
||||
.with_flex(12_500, 75_000, 1_300)
|
||||
.with_fast(50_000, 300_000, 5_000)
|
||||
.with_long(50_000, 225_000, 5_000)
|
||||
.with_long_flex(25_000, 112_500, 2_500),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5.3-codex",
|
||||
pricing: ModelPricing::new(17_500, 140_000, 1_750).with_priority(35_000, 280_000, 3_500),
|
||||
pricing: ModelPricing::new(17_500, 140_000, 1_750).with_fast(35_000, 280_000, 3_500),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5.2-pro",
|
||||
pricing: ModelPricing::new(210_000, 1_680_000, 0),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5.2",
|
||||
pricing: ModelPricing::new(17_500, 140_000, 1_750).with_priority(35_000, 280_000, 3_500),
|
||||
pricing: ModelPricing::new(17_500, 140_000, 1_750)
|
||||
.with_flex(8_750, 70_000, 875)
|
||||
.with_fast(35_000, 280_000, 3_500),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5.1",
|
||||
pricing: ModelPricing::new(12_500, 100_000, 1_250)
|
||||
.with_flex(6_250, 50_000, 625)
|
||||
.with_fast(25_000, 200_000, 2_500),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5-mini",
|
||||
pricing: ModelPricing::new(2_500, 20_000, 250)
|
||||
.with_flex(1_250, 10_000, 125)
|
||||
.with_fast(4_500, 36_000, 450),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5-nano",
|
||||
pricing: ModelPricing::new(500, 4_000, 50).with_flex(250, 2_000, 25),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5-pro",
|
||||
pricing: ModelPricing::new(150_000, 1_200_000, 0),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-5",
|
||||
pricing: ModelPricing::new(12_500, 100_000, 1_250)
|
||||
.with_flex(6_250, 50_000, 625)
|
||||
.with_fast(25_000, 200_000, 2_500),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-4.1-mini",
|
||||
pricing: ModelPricing::new(4_000, 16_000, 1_000).with_fast(7_000, 28_000, 1_750),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-4.1-nano",
|
||||
pricing: ModelPricing::new(1_000, 4_000, 250).with_fast(2_000, 8_000, 500),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-4.1",
|
||||
pricing: ModelPricing::new(20_000, 80_000, 5_000).with_fast(35_000, 140_000, 8_750),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-4o-2024-05-13",
|
||||
pricing: ModelPricing::new(50_000, 150_000, 0).with_fast(87_500, 262_500, 0),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-4o-mini",
|
||||
pricing: ModelPricing::new(1_500, 6_000, 750),
|
||||
pricing: ModelPricing::new(1_500, 6_000, 750).with_fast(2_500, 10_000, 1_250),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-4o",
|
||||
pricing: ModelPricing::new(25_000, 100_000, 12_500),
|
||||
pricing: ModelPricing::new(25_000, 100_000, 12_500).with_fast(42_500, 170_000, 21_250),
|
||||
},
|
||||
PricingRule {
|
||||
model: "o1-pro",
|
||||
pricing: ModelPricing::new(1_500_000, 6_000_000, 0),
|
||||
},
|
||||
PricingRule {
|
||||
model: "o1",
|
||||
pricing: ModelPricing::new(150_000, 600_000, 75_000),
|
||||
},
|
||||
PricingRule {
|
||||
model: "o3-pro",
|
||||
pricing: ModelPricing::new(200_000, 800_000, 0),
|
||||
},
|
||||
PricingRule {
|
||||
model: "o3-mini",
|
||||
pricing: ModelPricing::new(11_000, 44_000, 5_500),
|
||||
},
|
||||
PricingRule {
|
||||
model: "o3",
|
||||
pricing: ModelPricing::new(20_000, 80_000, 5_000)
|
||||
.with_flex(10_000, 40_000, 2_500)
|
||||
.with_fast(35_000, 140_000, 8_750),
|
||||
},
|
||||
PricingRule {
|
||||
model: "o4-mini",
|
||||
pricing: ModelPricing::new(11_000, 44_000, 2_750)
|
||||
.with_flex(5_500, 22_000, 1_380)
|
||||
.with_fast(20_000, 80_000, 5_000),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-4-turbo",
|
||||
@@ -180,93 +381,86 @@ const PRICING_RULES: &[PricingRule] = &[
|
||||
model: "gpt-4",
|
||||
pricing: ModelPricing::new(300_000, 600_000, 0),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-3.5-turbo-instruct",
|
||||
pricing: ModelPricing::new(15_000, 20_000, 0),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-3.5-turbo-1106",
|
||||
pricing: ModelPricing::new(10_000, 20_000, 0),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-3.5-turbo",
|
||||
pricing: ModelPricing::new(5_000, 15_000, 0),
|
||||
},
|
||||
];
|
||||
|
||||
const UNPRICED_MODELS: &[&str] = &["gpt-5.3-codex-spark"];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct TokenAmounts {
|
||||
input_ticks: u128,
|
||||
output_ticks: u128,
|
||||
cache_read_ticks: u128,
|
||||
cache_write_ticks: u128,
|
||||
total_ticks: u128,
|
||||
}
|
||||
|
||||
/// 按 OpenAI Provider 当前受控价格规则计算费用明细。
|
||||
#[must_use]
|
||||
pub fn openai_billing_breakdown(
|
||||
model: &str,
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
cached_tokens: u64,
|
||||
cache_write_tokens: u64,
|
||||
usage: OpenAiBillingUsage,
|
||||
service_tier: Option<&str>,
|
||||
) -> Option<CalculatedCostBreakdown> {
|
||||
if cached_tokens > input_tokens {
|
||||
if usage.cached_tokens > usage.input_tokens
|
||||
|| usage.image_input_tokens > 0
|
||||
|| usage.image_output_tokens > 0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let web_search_ticks = web_search_amount_ticks(usage)?;
|
||||
let pricing = model_pricing(model)?;
|
||||
let long_context = input_tokens > LONG_CONTEXT_THRESHOLD;
|
||||
let long_context = usage.input_tokens > LONG_CONTEXT_THRESHOLD;
|
||||
let normalized_tier = normalize_service_tier(service_tier);
|
||||
let priority = matches!(normalized_tier.as_deref(), Some("priority" | "fast"));
|
||||
let mut rates = if long_context && pricing.long.is_configured() {
|
||||
pricing.long
|
||||
} else {
|
||||
pricing.standard
|
||||
};
|
||||
let multiplier_percent = if priority {
|
||||
let priority_rates = if long_context && pricing.long_priority.is_configured() {
|
||||
pricing.long_priority
|
||||
} else {
|
||||
pricing.priority
|
||||
};
|
||||
if priority_rates.is_configured() {
|
||||
rates = priority_rates;
|
||||
100
|
||||
} else {
|
||||
200
|
||||
}
|
||||
} else if normalized_tier.as_deref() == Some("flex") {
|
||||
50
|
||||
} else {
|
||||
100
|
||||
};
|
||||
|
||||
let billed_cache_read = if rates.cache_read_ticks > 0 {
|
||||
cached_tokens.min(input_tokens)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let cache_write_rate = if pricing.cache_write_percent > 0 {
|
||||
apply_percent(rates.input_ticks, pricing.cache_write_percent)?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let billed_cache_write = if cache_write_rate > 0 {
|
||||
cache_write_tokens.min(input_tokens.saturating_sub(billed_cache_read))
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let uncached_input = input_tokens
|
||||
.saturating_sub(billed_cache_read)
|
||||
.saturating_sub(billed_cache_write);
|
||||
let input_amount = u128::from(uncached_input).checked_mul(rates.input_ticks)?;
|
||||
let output_amount = u128::from(output_tokens).checked_mul(rates.output_ticks)?;
|
||||
let cache_read_amount = u128::from(billed_cache_read).checked_mul(rates.cache_read_ticks)?;
|
||||
let cache_write_amount = u128::from(billed_cache_write).checked_mul(cache_write_rate)?;
|
||||
let standard_amount = input_amount
|
||||
.checked_add(output_amount)?
|
||||
.checked_add(cache_read_amount)?
|
||||
.checked_add(cache_write_amount)?;
|
||||
let total_amount = apply_percent(standard_amount, multiplier_percent)?;
|
||||
let tier = pricing_tier(normalized_tier.as_deref())?;
|
||||
let standard_rates = pricing.rates(PricingTier::Standard, long_context)?;
|
||||
let selected_rates = pricing.rates(tier, long_context)?;
|
||||
let mut standard = token_amounts(
|
||||
standard_rates,
|
||||
pricing.cache_write_percent,
|
||||
usage.input_tokens,
|
||||
usage.output_tokens,
|
||||
usage.cached_tokens,
|
||||
usage.cache_write_tokens,
|
||||
)?;
|
||||
let mut selected = token_amounts(
|
||||
selected_rates,
|
||||
pricing.cache_write_percent,
|
||||
usage.input_tokens,
|
||||
usage.output_tokens,
|
||||
usage.cached_tokens,
|
||||
usage.cache_write_tokens,
|
||||
)?;
|
||||
standard.total_ticks = standard.total_ticks.checked_add(web_search_ticks)?;
|
||||
selected.total_ticks = selected.total_ticks.checked_add(web_search_ticks)?;
|
||||
let multiplier_percent =
|
||||
effective_multiplier_percent(selected.total_ticks, standard.total_ticks)?;
|
||||
let cache_write_rate = cache_write_rate(selected_rates, pricing.cache_write_percent)?;
|
||||
|
||||
Some(CalculatedCostBreakdown::new(
|
||||
CalculatedCostAmounts::new(
|
||||
usd_money(input_amount)?,
|
||||
usd_money(output_amount)?,
|
||||
usd_money(cache_read_amount)?,
|
||||
usd_money(cache_write_amount)?,
|
||||
usd_money(standard_amount)?,
|
||||
usd_money(total_amount)?,
|
||||
usd_money(selected.input_ticks)?,
|
||||
usd_money(selected.output_ticks)?,
|
||||
usd_money(selected.cache_read_ticks)?,
|
||||
usd_money(selected.cache_write_ticks)?,
|
||||
usd_money(standard.total_ticks)?,
|
||||
usd_money(selected.total_ticks)?,
|
||||
),
|
||||
CalculatedCostRates::new(
|
||||
usd_price_per_million(rates.input_ticks)?,
|
||||
usd_price_per_million(rates.output_ticks)?,
|
||||
usd_price_per_million(rates.cache_read_ticks)?,
|
||||
usd_price_per_million(selected_rates.input_ticks)?,
|
||||
usd_price_per_million(selected_rates.output_ticks)?,
|
||||
usd_price_per_million(selected_rates.cache_read_ticks)?,
|
||||
usd_price_per_million(cache_write_rate)?,
|
||||
),
|
||||
Some(normalized_tier.unwrap_or_else(|| "default".to_owned())),
|
||||
@@ -276,6 +470,12 @@ pub fn openai_billing_breakdown(
|
||||
|
||||
fn model_pricing(model: &str) -> Option<ModelPricing> {
|
||||
let normalized = normalize_model_name(model);
|
||||
if UNPRICED_MODELS
|
||||
.iter()
|
||||
.any(|rule| model_matches_rule(&normalized, rule))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
PRICING_RULES
|
||||
.iter()
|
||||
.filter(|rule| model_matches_rule(&normalized, rule.model))
|
||||
@@ -283,6 +483,120 @@ fn model_pricing(model: &str) -> Option<ModelPricing> {
|
||||
.map(|rule| rule.pricing)
|
||||
}
|
||||
|
||||
pub(crate) fn web_search_pricing(model: &str, tools: Option<&[Value]>) -> Option<WebSearchPricing> {
|
||||
let mut standard = false;
|
||||
let mut preview = false;
|
||||
for tool_type in tools
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|tool| tool.get("type").and_then(Value::as_str))
|
||||
{
|
||||
if tool_type == "web_search_preview" || tool_type.starts_with("web_search_preview_") {
|
||||
preview = true;
|
||||
} else if tool_type == "web_search" || tool_type.starts_with("web_search_") {
|
||||
standard = true;
|
||||
}
|
||||
}
|
||||
match (standard, preview) {
|
||||
// These two models bill non-preview search content as a fixed 8K input block.
|
||||
// The response does not expose enough detail to replace that block without
|
||||
// double-counting ordinary input, so leave the request unpriced.
|
||||
(true, false) if fixed_block_web_search_model(model) => None,
|
||||
(true, false) => Some(WebSearchPricing::Standard),
|
||||
(false, true) if reasoning_model(model) => Some(WebSearchPricing::Standard),
|
||||
(false, true) => Some(WebSearchPricing::PreviewNonReasoning),
|
||||
(false, false) | (true, true) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn fixed_block_web_search_model(model: &str) -> bool {
|
||||
let normalized = normalize_model_name(model);
|
||||
["gpt-4o-mini", "gpt-4.1-mini"]
|
||||
.iter()
|
||||
.any(|rule| model_matches_rule(&normalized, rule))
|
||||
}
|
||||
|
||||
fn reasoning_model(model: &str) -> bool {
|
||||
let normalized = normalize_model_name(model);
|
||||
normalized.starts_with("gpt-5")
|
||||
|| normalized.starts_with("o1")
|
||||
|| normalized.starts_with("o3")
|
||||
|| normalized.starts_with("o4")
|
||||
}
|
||||
|
||||
fn pricing_tier(service_tier: Option<&str>) -> Option<PricingTier> {
|
||||
match service_tier {
|
||||
None | Some("auto" | "default" | "standard") => Some(PricingTier::Standard),
|
||||
Some("flex") => Some(PricingTier::Flex),
|
||||
Some("fast" | "priority") => Some(PricingTier::Fast),
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn web_search_amount_ticks(usage: OpenAiBillingUsage) -> Option<u128> {
|
||||
if usage.web_search_calls == 0 {
|
||||
return Some(0);
|
||||
}
|
||||
u128::from(usage.web_search_calls).checked_mul(usage.web_search_pricing?.price_per_call_ticks())
|
||||
}
|
||||
|
||||
fn token_amounts(
|
||||
rates: TokenRates,
|
||||
cache_write_percent: u32,
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
cached_tokens: u64,
|
||||
cache_write_tokens: u64,
|
||||
) -> Option<TokenAmounts> {
|
||||
let billed_cache_read = if rates.cache_read_ticks > 0 {
|
||||
cached_tokens.min(input_tokens)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let cache_write_rate = cache_write_rate(rates, cache_write_percent)?;
|
||||
let billed_cache_write = if cache_write_rate > 0 {
|
||||
cache_write_tokens.min(input_tokens.saturating_sub(billed_cache_read))
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let uncached_input = input_tokens
|
||||
.saturating_sub(billed_cache_read)
|
||||
.saturating_sub(billed_cache_write);
|
||||
let input_ticks = u128::from(uncached_input).checked_mul(rates.input_ticks)?;
|
||||
let output_ticks = u128::from(output_tokens).checked_mul(rates.output_ticks)?;
|
||||
let cache_read_ticks = u128::from(billed_cache_read).checked_mul(rates.cache_read_ticks)?;
|
||||
let cache_write_ticks = u128::from(billed_cache_write).checked_mul(cache_write_rate)?;
|
||||
let total_ticks = input_ticks
|
||||
.checked_add(output_ticks)?
|
||||
.checked_add(cache_read_ticks)?
|
||||
.checked_add(cache_write_ticks)?;
|
||||
Some(TokenAmounts {
|
||||
input_ticks,
|
||||
output_ticks,
|
||||
cache_read_ticks,
|
||||
cache_write_ticks,
|
||||
total_ticks,
|
||||
})
|
||||
}
|
||||
|
||||
fn cache_write_rate(rates: TokenRates, percent: u32) -> Option<u128> {
|
||||
if percent == 0 {
|
||||
return Some(0);
|
||||
}
|
||||
apply_percent(rates.input_ticks, percent)
|
||||
}
|
||||
|
||||
fn effective_multiplier_percent(total: u128, standard: u128) -> Option<u32> {
|
||||
if standard == 0 {
|
||||
return Some(100);
|
||||
}
|
||||
let rounded = total
|
||||
.checked_mul(100)?
|
||||
.checked_add(standard / 2)?
|
||||
.checked_div(standard)?;
|
||||
u32::try_from(rounded).ok()
|
||||
}
|
||||
|
||||
fn normalize_model_name(model: &str) -> String {
|
||||
model
|
||||
.trim()
|
||||
@@ -336,78 +650,68 @@ pub const MAX_CODEX_USAGE_BODY_BYTES: usize = 1024 * 1024;
|
||||
impl CodexBackendClient {
|
||||
/// 获取 Codex usage JSON。
|
||||
pub async fn fetch_usage(&self, context: CodexRequestContext<'_>) -> CodexClientResult<Value> {
|
||||
let mut last_invalid_body = None;
|
||||
let headers = self.usage_request_headers(context)?;
|
||||
let response = self
|
||||
.client
|
||||
.get(usage_endpoint_url(&self.base_url))
|
||||
.headers(headers)
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
let diagnostics = response_meta::diagnostics(Some(status.as_u16()), response.headers());
|
||||
let retry_after_seconds = retry_after_seconds(response.headers(), None);
|
||||
let body = read_capped_response_body(response, MAX_CODEX_USAGE_BODY_BYTES).await?;
|
||||
if body.limit_exceeded() {
|
||||
return Err(CodexClientError::Upstream {
|
||||
status: if status.is_success() {
|
||||
StatusCode::BAD_GATEWAY
|
||||
} else {
|
||||
status
|
||||
},
|
||||
retry_after_seconds,
|
||||
body: "upstream usage response exceeded the body limit".to_owned(),
|
||||
client_response: None,
|
||||
diagnostics: Box::new(diagnostics),
|
||||
set_cookie_headers: Vec::new(),
|
||||
rate_limit_headers: Vec::new(),
|
||||
transport: super::client::CodexBackendTransport::HttpSse,
|
||||
transport_metrics: Box::default(),
|
||||
send_phase: super::diagnostics::CodexUpstreamSendPhase::AfterPayload,
|
||||
});
|
||||
}
|
||||
let body = body.into_string();
|
||||
|
||||
for endpoint in usage_endpoint_urls(&self.base_url) {
|
||||
let headers = self.usage_request_headers(context)?;
|
||||
let response = self.client.get(endpoint).headers(headers).send().await?;
|
||||
let status = response.status();
|
||||
let diagnostics = response_meta::diagnostics(Some(status.as_u16()), response.headers());
|
||||
let retry_after_seconds = retry_after_seconds(response.headers(), None);
|
||||
let body = read_capped_response_body(response, MAX_CODEX_USAGE_BODY_BYTES).await?;
|
||||
if body.limit_exceeded() {
|
||||
return Err(CodexClientError::Upstream {
|
||||
status: if status.is_success() {
|
||||
StatusCode::BAD_GATEWAY
|
||||
} else {
|
||||
status
|
||||
},
|
||||
retry_after_seconds,
|
||||
body: "upstream usage response exceeded the body limit".to_owned(),
|
||||
client_response: None,
|
||||
diagnostics: Box::new(diagnostics),
|
||||
set_cookie_headers: Vec::new(),
|
||||
rate_limit_headers: Vec::new(),
|
||||
transport: super::client::CodexBackendTransport::HttpSse,
|
||||
transport_metrics: Box::default(),
|
||||
send_phase: super::diagnostics::CodexUpstreamSendPhase::AfterPayload,
|
||||
});
|
||||
}
|
||||
let body = body.into_string();
|
||||
|
||||
if status == StatusCode::NOT_FOUND {
|
||||
last_invalid_body = Some(body);
|
||||
continue;
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(CodexClientError::Upstream {
|
||||
status,
|
||||
retry_after_seconds: retry_after_seconds
|
||||
.or_else(|| retry_after_seconds_from_body(&body)),
|
||||
body,
|
||||
client_response: None,
|
||||
diagnostics: Box::new(diagnostics),
|
||||
set_cookie_headers: Vec::new(),
|
||||
rate_limit_headers: Vec::new(),
|
||||
transport: super::client::CodexBackendTransport::HttpSse,
|
||||
transport_metrics: Box::default(),
|
||||
send_phase: super::diagnostics::CodexUpstreamSendPhase::AfterPayload,
|
||||
});
|
||||
}
|
||||
|
||||
match serde_json::from_str::<Value>(&body) {
|
||||
Ok(parsed) if is_usage_response(&parsed) => return Ok(parsed),
|
||||
_ => last_invalid_body = Some(body),
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(CodexClientError::Upstream {
|
||||
status,
|
||||
retry_after_seconds: retry_after_seconds
|
||||
.or_else(|| retry_after_seconds_from_body(&body)),
|
||||
body,
|
||||
client_response: None,
|
||||
diagnostics: Box::new(diagnostics),
|
||||
set_cookie_headers: Vec::new(),
|
||||
rate_limit_headers: Vec::new(),
|
||||
transport: super::client::CodexBackendTransport::HttpSse,
|
||||
transport_metrics: Box::default(),
|
||||
send_phase: super::diagnostics::CodexUpstreamSendPhase::AfterPayload,
|
||||
});
|
||||
}
|
||||
|
||||
Err(CodexClientError::Upstream {
|
||||
status: StatusCode::BAD_GATEWAY,
|
||||
retry_after_seconds: None,
|
||||
body: last_invalid_body.map_or_else(
|
||||
|| "usage endpoint is unavailable".to_string(),
|
||||
|body| format!("invalid usage response: {}", truncate_for_error(&body)),
|
||||
),
|
||||
client_response: None,
|
||||
diagnostics: Box::new(CodexUpstreamDiagnostics::with_status(
|
||||
StatusCode::BAD_GATEWAY.as_u16(),
|
||||
)),
|
||||
set_cookie_headers: Vec::new(),
|
||||
rate_limit_headers: Vec::new(),
|
||||
transport: super::client::CodexBackendTransport::HttpSse,
|
||||
transport_metrics: Box::default(),
|
||||
send_phase: super::diagnostics::CodexUpstreamSendPhase::AfterPayload,
|
||||
})
|
||||
match serde_json::from_str::<Value>(&body) {
|
||||
Ok(parsed) if is_usage_response(&parsed) => Ok(parsed),
|
||||
_ => Err(CodexClientError::Upstream {
|
||||
status: StatusCode::BAD_GATEWAY,
|
||||
retry_after_seconds: None,
|
||||
body: format!("invalid usage response: {}", truncate_for_error(&body)),
|
||||
client_response: None,
|
||||
diagnostics: Box::new(diagnostics),
|
||||
set_cookie_headers: Vec::new(),
|
||||
rate_limit_headers: Vec::new(),
|
||||
transport: super::client::CodexBackendTransport::HttpSse,
|
||||
transport_metrics: Box::default(),
|
||||
send_phase: super::diagnostics::CodexUpstreamSendPhase::AfterPayload,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ fn is_sensitive_opening_header(name: &str) -> bool {
|
||||
| "x-codex-turn-metadata"
|
||||
| "x-codex-turn-state"
|
||||
| "x-codex-parent-thread-id"
|
||||
| "x-oai-attestation"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,15 +25,15 @@ use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload};
|
||||
use gateway_core::provider_ports::{
|
||||
NewOAuthPendingFlow, OAuthPendingClaimOutcome, OAuthPendingConsumeOutcome,
|
||||
OAuthPendingFlowPort, OAuthPendingPutOutcome, OAuthPendingReleaseOutcome,
|
||||
ProviderCatalogCacheKey, ProviderCatalogCachePort, ProviderCooldown, ProviderCooldownPort,
|
||||
ProviderCooldownScope, ProviderCredentialState, ProviderCredentialStatePort,
|
||||
ProviderRefreshPolicy, ProviderRuntimePolicyPort, ProviderScopedCooldown, ProviderStoreError,
|
||||
ProviderStorePorts,
|
||||
ProviderArtifactProfile, ProviderArtifactProfileCachePort, ProviderCatalogCacheKey,
|
||||
ProviderCatalogCachePort, ProviderCooldown, ProviderCooldownPort, ProviderCooldownScope,
|
||||
ProviderCredentialState, ProviderCredentialStatePort, ProviderRefreshPolicy,
|
||||
ProviderRuntimePolicyPort, ProviderScopedCooldown, ProviderStoreError, ProviderStorePorts,
|
||||
};
|
||||
use gateway_core::routing::{ProviderKind, UpstreamModelId};
|
||||
use gateway_core::task::{WorkerContribution, WorkerKind, WorkerRunnable};
|
||||
use provider_openai::config::{CodexWireProfileConfig, OpenAiConfig};
|
||||
use provider_openai::credential::ImportCodexOAuthCredential;
|
||||
use provider_openai::credential::{CodexCredentialCodec, ImportCodexOAuthCredential};
|
||||
use provider_openai::transport::profile::APPCAST_POLL_INTERVAL;
|
||||
use secrecy::SecretString;
|
||||
use serde_json::{Map, Value, json};
|
||||
@@ -54,7 +54,7 @@ async fn openai_bundle_exposes_one_core_provider_and_drains_worker_contributions
|
||||
assert_eq!(bundle.core_provider().name(), "openai");
|
||||
assert_eq!(bundle.admin_provider().provider_kind().as_str(), "openai");
|
||||
let contributions = bundle.take_worker_contributions();
|
||||
assert_eq!(contributions.len(), 5);
|
||||
assert_eq!(contributions.len(), 4);
|
||||
assert!(
|
||||
contributions
|
||||
.iter()
|
||||
@@ -81,22 +81,6 @@ async fn openai_bundle_exposes_one_core_provider_and_drains_worker_contributions
|
||||
panic!("Desktop release worker must be scheduled");
|
||||
};
|
||||
assert_eq!(schedule.interval(), APPCAST_POLL_INTERVAL);
|
||||
let cli_release_worker = contributions
|
||||
.iter()
|
||||
.find_map(|contribution| match contribution {
|
||||
WorkerContribution::Registration(registration)
|
||||
if registration.id.owner() == "openai-cli-release" =>
|
||||
{
|
||||
Some(registration)
|
||||
}
|
||||
WorkerContribution::Registration(_) | WorkerContribution::Disabled { .. } => None,
|
||||
})
|
||||
.expect("CLI release worker");
|
||||
assert_eq!(cli_release_worker.id.kind(), WorkerKind::QuotaCatalogHealth);
|
||||
let WorkerRunnable::Scheduled { schedule, .. } = &cli_release_worker.runnable else {
|
||||
panic!("CLI release worker must be scheduled");
|
||||
};
|
||||
assert_eq!(schedule.interval(), APPCAST_POLL_INTERVAL);
|
||||
assert!(contributions.iter().any(|contribution| {
|
||||
matches!(
|
||||
contribution,
|
||||
@@ -164,15 +148,15 @@ async fn openai_admin_provider_exposes_live_wire_profile_and_validated_billing()
|
||||
cache_write_tokens: Some(0),
|
||||
total: CurrencyCost {
|
||||
currency: "USD".to_owned(),
|
||||
amount: "5".parse().expect("fast amount"),
|
||||
amount: "4.25".parse().expect("fast amount"),
|
||||
},
|
||||
})
|
||||
.expect("fast billing")
|
||||
.expect("known fast pricing");
|
||||
assert_eq!(fast_billing.service_tier.as_deref(), Some("priority"));
|
||||
assert_eq!(fast_billing.multiplier_percent, 200);
|
||||
assert_eq!(fast_billing.multiplier_percent, 170);
|
||||
assert_eq!(fast_billing.standard_amount.amount.as_str(), "2.5");
|
||||
assert_eq!(fast_billing.total_amount.amount.as_str(), "5");
|
||||
assert_eq!(fast_billing.total_amount.amount.as_str(), "4.25");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -249,6 +233,14 @@ async fn openai_admin_provider_persists_the_full_pending_envelope_and_binds_owne
|
||||
.get("reauthorization_credential_revision")
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
payload
|
||||
.expose_to_provider()
|
||||
.get("installation_id")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|value| uuid::Uuid::parse_str(value).ok())
|
||||
.is_some_and(|value| value.get_version_num() == 4)
|
||||
);
|
||||
assert_eq!(
|
||||
mutation.get("schema_version").and_then(Value::as_u64),
|
||||
Some(3)
|
||||
@@ -284,7 +276,7 @@ async fn openai_admin_provider_persists_the_full_pending_envelope_and_binds_owne
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_reauthorization_pending_payload_contains_only_stable_account_identity() {
|
||||
async fn openai_reauthorization_pending_payload_reuses_the_account_installation_id() {
|
||||
let accounts = Arc::new(MemoryAccountStore::default());
|
||||
accounts
|
||||
.seed_oauth_credential(ImportCodexOAuthCredential {
|
||||
@@ -296,6 +288,14 @@ async fn openai_reauthorization_pending_payload_contains_only_stable_account_ide
|
||||
enabled: true,
|
||||
})
|
||||
.await;
|
||||
let account_id = ProviderAccountId::new("acct_pending_reauth").expect("account id");
|
||||
let existing = accounts
|
||||
.load_current_credential(&account_id)
|
||||
.await
|
||||
.expect("seeded credential");
|
||||
let expected_installation_id = CodexCredentialCodec::decode(&existing.credential)
|
||||
.expect("decode seeded credential")
|
||||
.installation_id;
|
||||
let pending = Arc::new(TestOAuthPending::default());
|
||||
let config = valid_config();
|
||||
let bundle = provider_openai::initialize(
|
||||
@@ -313,9 +313,7 @@ async fn openai_reauthorization_pending_payload_contains_only_stable_account_ide
|
||||
.admin_provider()
|
||||
.start_authorization(PendingAuthorizationMutation::new(
|
||||
ProviderKind::new("openai").expect("provider"),
|
||||
AuthorizationMutationTarget::Reauthorize {
|
||||
account_id: ProviderAccountId::new("acct_pending_reauth").expect("account id"),
|
||||
},
|
||||
AuthorizationMutationTarget::Reauthorize { account_id },
|
||||
AuthorizationOwnerBinding::from_context(&context),
|
||||
))
|
||||
.await
|
||||
@@ -342,6 +340,10 @@ async fn openai_reauthorization_pending_payload_contains_only_stable_account_ide
|
||||
.and_then(Value::as_str),
|
||||
Some("acct_pending_reauth")
|
||||
);
|
||||
assert_eq!(
|
||||
document.get("installation_id").and_then(Value::as_str),
|
||||
Some(expected_installation_id.as_str())
|
||||
);
|
||||
assert!(
|
||||
document
|
||||
.get("reauthorization_credential_revision")
|
||||
@@ -785,6 +787,7 @@ fn provider_ports_with_catalog(
|
||||
Arc::new(MemorySessionAffinity::default()),
|
||||
Arc::new(MemorySessionExclusions::default()),
|
||||
catalog_cache,
|
||||
Arc::new(TestArtifactProfiles),
|
||||
Arc::new(TestCredentialState),
|
||||
Arc::new(TestCooldown),
|
||||
Arc::new(TestRuntimePolicy),
|
||||
@@ -852,6 +855,25 @@ fn valid_config() -> TestOpenAiConfig {
|
||||
}
|
||||
}
|
||||
|
||||
struct TestArtifactProfiles;
|
||||
|
||||
impl ProviderArtifactProfileCachePort for TestArtifactProfiles {
|
||||
fn replace_if_newer(
|
||||
&self,
|
||||
_profile: ProviderArtifactProfile,
|
||||
_ttl: Duration,
|
||||
) -> BoxFuture<'_, Result<bool, ProviderStoreError>> {
|
||||
Box::pin(async { Ok(true) })
|
||||
}
|
||||
|
||||
fn read<'a>(
|
||||
&'a self,
|
||||
_provider_kind: &'a ProviderKind,
|
||||
) -> BoxFuture<'a, Result<Option<ProviderArtifactProfile>, ProviderStoreError>> {
|
||||
Box::pin(async { Ok(None) })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct TestCatalogCache {
|
||||
values: Mutex<BTreeMap<String, OpaqueProviderData>>,
|
||||
|
||||
@@ -116,7 +116,7 @@ fn openai_config_defaults_to_the_provider_owned_operating_values() {
|
||||
);
|
||||
assert_eq!(
|
||||
config.wire_profile_state().snapshot().user_agent(),
|
||||
"Codex Desktop/0.146.0 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.727.51351)"
|
||||
"Codex Desktop/0.147.0-alpha.6.6 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.803.81509)"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ use gateway_core::engine::credential::{
|
||||
ProviderAccountStore, QuotaState,
|
||||
};
|
||||
use gateway_core::routing::ProviderKind;
|
||||
use provider_openai::OpenAiConfig;
|
||||
use provider_openai::credential::{
|
||||
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CodexAgentIdentityAuthMode,
|
||||
CodexAgentIdentityCredentialData, CodexAgentIdentityError, CodexAgentIdentitySecret,
|
||||
@@ -20,7 +21,9 @@ use provider_openai::credential::{
|
||||
CodexRuntimeAuthentication, is_agent_identity_task_invalid_response,
|
||||
};
|
||||
use provider_openai::transport::CodexWebSocketPool;
|
||||
use provider_openai::transport::headers::build_codex_profile_headers;
|
||||
use reqwest::StatusCode;
|
||||
use reqwest::header::USER_AGENT;
|
||||
use secrecy::ExposeSecret as _;
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -144,6 +147,20 @@ fn agent_identity_task_error_classifier_requires_unauthorized_task_error() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_identity_task_registration_should_use_the_shared_desktop_profile_headers() {
|
||||
let profile = OpenAiConfig::default().wire_profile_state().snapshot();
|
||||
let headers = build_codex_profile_headers(&profile).expect("Desktop profile headers");
|
||||
|
||||
assert_eq!(headers.len(), 3);
|
||||
assert_eq!(headers["originator"], "Codex Desktop");
|
||||
assert_eq!(headers["x-openai-internal-codex-residency"], "us");
|
||||
assert_eq!(
|
||||
headers[USER_AGENT],
|
||||
"Codex Desktop/0.147.0-alpha.6.6 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.803.81509)"
|
||||
);
|
||||
}
|
||||
|
||||
struct SequenceTaskRegistrar {
|
||||
task_ids: Mutex<VecDeque<String>>,
|
||||
calls: AtomicUsize,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
@@ -7,22 +8,26 @@ use gateway_admin::model::provider_credentials::{
|
||||
AuthorizationMutationTarget, AuthorizationOwnerBinding, PendingAuthorizationMutation,
|
||||
};
|
||||
use gateway_admin::model::{MutationActor, MutationContext};
|
||||
use gateway_core::engine::credential::AccountStatus;
|
||||
use gateway_core::engine::credential::{AccountStatus, ProviderAccountId};
|
||||
use gateway_core::routing::ProviderKind;
|
||||
use provider_openai::OpenAiConfig;
|
||||
use provider_openai::credential::token_client::{
|
||||
AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant,
|
||||
AuthorizationTokenSet,
|
||||
};
|
||||
use provider_openai::credential::{
|
||||
CodexCredentialAdmin, CodexOAuthAdmin, CodexOAuthAdminError, CodexOAuthAdminService,
|
||||
CodexOAuthPendingClaimOutcome, CodexOAuthPendingStore, CodexOAuthPendingStoreError,
|
||||
CodexOAuthSecret, CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential,
|
||||
StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
|
||||
CodexCredentialAdmin, CodexCredentialCodec, CodexOAuthAdmin, CodexOAuthAdminError,
|
||||
CodexOAuthAdminService, CodexOAuthPendingClaimOutcome, CodexOAuthPendingStore,
|
||||
CodexOAuthPendingStoreError, CodexOAuthSecret, CompleteCodexOAuthAuthorization,
|
||||
CompletedCodexOAuthCredential, ImportCodexOAuthCredential, StartCodexOAuthAuthorization,
|
||||
StoredCodexPendingAuthorization,
|
||||
};
|
||||
use provider_openai::transport::profile::CodexWireProfileState;
|
||||
use secrecy::SecretString;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::support::MemoryAccountStore;
|
||||
use crate::support::{MemoryAccountStore, profile as account_profile, secret};
|
||||
|
||||
#[derive(Default)]
|
||||
struct PendingStore {
|
||||
@@ -48,6 +53,7 @@ impl CodexOAuthPendingStore for PendingStore {
|
||||
state: pending.state().clone(),
|
||||
nonce: pending.nonce().clone(),
|
||||
code_verifier: pending.code_verifier().clone(),
|
||||
installation_id: pending.installation_id().to_owned(),
|
||||
reauthorization_account_id: pending.reauthorization().map(ToString::to_string),
|
||||
mutation: pending.mutation().clone(),
|
||||
});
|
||||
@@ -125,18 +131,37 @@ fn mutation() -> PendingAuthorizationMutation {
|
||||
)
|
||||
}
|
||||
|
||||
fn reauthorization_mutation(
|
||||
account_id: ProviderAccountId,
|
||||
request_id: &str,
|
||||
) -> PendingAuthorizationMutation {
|
||||
let context = MutationContext {
|
||||
actor: MutationActor::System,
|
||||
request_id: request_id.to_owned(),
|
||||
};
|
||||
PendingAuthorizationMutation::new(
|
||||
ProviderKind::new("openai").expect("provider"),
|
||||
AuthorizationMutationTarget::Reauthorize { account_id },
|
||||
AuthorizationOwnerBinding::from_context(&context),
|
||||
)
|
||||
}
|
||||
|
||||
fn id_token(payload: serde_json::Value) -> String {
|
||||
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload JSON"));
|
||||
// 官方逻辑只读取 payload;header/signature 不参与本地 metadata 解析。
|
||||
format!("unverified-header.{payload}.unverified-signature")
|
||||
}
|
||||
|
||||
async fn complete(id_token: String) -> Result<CompletedCodexOAuthCredential, CodexOAuthAdminError> {
|
||||
async fn complete(
|
||||
id_token: String,
|
||||
) -> Result<(CompletedCodexOAuthCredential, String, String), CodexOAuthAdminError> {
|
||||
let pending_store = Arc::new(PendingStore::default());
|
||||
let service = CodexOAuthAdminService::new(
|
||||
Arc::new(PendingStore::default()),
|
||||
pending_store.clone(),
|
||||
Arc::new(Exchanger { id_token }),
|
||||
Arc::new(MemoryAccountStore::default()),
|
||||
CodexCredentialAdmin,
|
||||
profile(),
|
||||
);
|
||||
let started = service
|
||||
.start_authorization(StartCodexOAuthAuthorization {
|
||||
@@ -144,11 +169,26 @@ async fn complete(id_token: String) -> Result<CompletedCodexOAuthCredential, Cod
|
||||
})
|
||||
.await
|
||||
.expect("start OAuth authorization");
|
||||
let state = Url::parse(&started.authorization_url)
|
||||
.expect("authorization URL")
|
||||
let outer = Url::parse(&started.authorization_url).expect("authorization URL");
|
||||
let inner_url = outer
|
||||
.query_pairs()
|
||||
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
|
||||
.expect("state parameter");
|
||||
.find_map(|(key, value)| (key == "authorize_url").then(|| value.into_owned()))
|
||||
.expect("inner authorization URL");
|
||||
let inner = Url::parse(&inner_url).expect("authorization URL");
|
||||
let parameters = inner.query_pairs().into_owned().collect::<BTreeMap<_, _>>();
|
||||
let state = parameters.get("state").expect("state parameter");
|
||||
let surface_stable_id = parameters
|
||||
.get("source_surface_stable_id")
|
||||
.expect("surface stable ID")
|
||||
.to_owned();
|
||||
let installation_id = pending_store
|
||||
.pending
|
||||
.lock()
|
||||
.expect("pending lock")
|
||||
.as_ref()
|
||||
.expect("stored pending authorization")
|
||||
.installation_id
|
||||
.clone();
|
||||
let completed = service
|
||||
.complete_authorization(CompleteCodexOAuthAuthorization {
|
||||
owner_ref: "test-owner".to_owned(),
|
||||
@@ -158,12 +198,279 @@ async fn complete(id_token: String) -> Result<CompletedCodexOAuthCredential, Cod
|
||||
)),
|
||||
})
|
||||
.await?;
|
||||
Ok(completed.credential)
|
||||
Ok((completed.credential, installation_id, surface_stable_id))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorize_url_matches_the_official_desktop_parameter_contract() {
|
||||
let service = CodexOAuthAdminService::new(
|
||||
Arc::new(PendingStore::default()),
|
||||
Arc::new(Exchanger {
|
||||
id_token: "unused".to_owned(),
|
||||
}),
|
||||
Arc::new(MemoryAccountStore::default()),
|
||||
CodexCredentialAdmin,
|
||||
profile(),
|
||||
);
|
||||
let started = service
|
||||
.start_authorization(StartCodexOAuthAuthorization {
|
||||
mutation: mutation(),
|
||||
})
|
||||
.await
|
||||
.expect("start OAuth authorization");
|
||||
let url = Url::parse(&started.authorization_url).expect("authorization URL");
|
||||
let outer_parameters = url.query_pairs().into_owned().collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
outer_parameters
|
||||
.iter()
|
||||
.map(|(name, _)| name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
"authorize_url",
|
||||
"codex_streamlined_login",
|
||||
"no_universal_links"
|
||||
]
|
||||
);
|
||||
let outer_parameters = outer_parameters.into_iter().collect::<BTreeMap<_, _>>();
|
||||
|
||||
assert_eq!(url.scheme(), "https");
|
||||
assert_eq!(url.host_str(), Some("chatgpt.com"));
|
||||
assert_eq!(url.path(), "/codex/desktop-auth");
|
||||
assert_eq!(outer_parameters.len(), 3);
|
||||
assert_eq!(
|
||||
outer_parameters
|
||||
.get("codex_streamlined_login")
|
||||
.map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
assert_eq!(
|
||||
outer_parameters
|
||||
.get("no_universal_links")
|
||||
.map(String::as_str),
|
||||
Some("1")
|
||||
);
|
||||
let inner = Url::parse(
|
||||
outer_parameters
|
||||
.get("authorize_url")
|
||||
.expect("authorize_url parameter"),
|
||||
)
|
||||
.expect("inner authorization URL");
|
||||
let ordered_parameters = inner.query_pairs().into_owned().collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
ordered_parameters
|
||||
.iter()
|
||||
.map(|(name, _)| name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
"response_type",
|
||||
"client_id",
|
||||
"redirect_uri",
|
||||
"scope",
|
||||
"code_challenge",
|
||||
"code_challenge_method",
|
||||
"id_token_add_organizations",
|
||||
"codex_cli_simplified_flow",
|
||||
"state",
|
||||
"originator",
|
||||
"codex_app_version",
|
||||
"source_surface_stable_id",
|
||||
"codex_origin_stable_id",
|
||||
"codex_streamlined_login",
|
||||
]
|
||||
);
|
||||
let parameters = ordered_parameters.into_iter().collect::<BTreeMap<_, _>>();
|
||||
|
||||
assert_eq!(inner.host_str(), Some("auth.openai.com"));
|
||||
assert_eq!(inner.path(), "/oauth/authorize");
|
||||
assert_eq!(parameters.len(), 14);
|
||||
assert!(
|
||||
inner
|
||||
.query()
|
||||
.is_some_and(|query| query.contains("scope=openid+profile+email+offline_access"))
|
||||
);
|
||||
assert!(
|
||||
!inner
|
||||
.query()
|
||||
.is_some_and(|query| query.contains("scope=openid%20profile"))
|
||||
);
|
||||
assert_eq!(
|
||||
parameters.get("response_type").map(String::as_str),
|
||||
Some("code")
|
||||
);
|
||||
assert_eq!(
|
||||
parameters.get("redirect_uri").map(String::as_str),
|
||||
Some("http://localhost:1455/auth/callback")
|
||||
);
|
||||
assert_eq!(
|
||||
parameters.get("scope").map(String::as_str),
|
||||
Some("openid profile email offline_access api.connectors.read api.connectors.invoke")
|
||||
);
|
||||
assert_eq!(
|
||||
parameters.get("originator").map(String::as_str),
|
||||
Some("Codex Desktop")
|
||||
);
|
||||
assert_eq!(
|
||||
parameters.get("codex_app_version").map(String::as_str),
|
||||
Some("26.803.81509")
|
||||
);
|
||||
let surface_stable_id = parameters
|
||||
.get("source_surface_stable_id")
|
||||
.expect("surface stable ID");
|
||||
let parsed_surface_id = Uuid::parse_str(surface_stable_id).expect("UUID surface stable ID");
|
||||
assert_eq!(parsed_surface_id.get_version_num(), 4);
|
||||
assert_eq!(
|
||||
parameters.get("codex_origin_stable_id"),
|
||||
Some(surface_stable_id)
|
||||
);
|
||||
assert_eq!(
|
||||
parameters
|
||||
.get("codex_streamlined_login")
|
||||
.map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
assert_eq!(
|
||||
parameters.get("code_challenge_method").map(String::as_str),
|
||||
Some("S256")
|
||||
);
|
||||
assert_eq!(
|
||||
parameters
|
||||
.get("id_token_add_organizations")
|
||||
.map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
assert_eq!(
|
||||
parameters
|
||||
.get("codex_cli_simplified_flow")
|
||||
.map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
assert!(
|
||||
parameters
|
||||
.get("client_id")
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
);
|
||||
assert!(
|
||||
parameters
|
||||
.get("state")
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
);
|
||||
assert!(
|
||||
parameters
|
||||
.get("code_challenge")
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
);
|
||||
assert!(!parameters.contains_key("nonce"));
|
||||
}
|
||||
|
||||
fn profile() -> CodexWireProfileState {
|
||||
OpenAiConfig::default().wire_profile_state()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn separate_new_authorizations_use_distinct_surface_stable_ids() {
|
||||
let mut ids = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let service = CodexOAuthAdminService::new(
|
||||
Arc::new(PendingStore::default()),
|
||||
Arc::new(Exchanger {
|
||||
id_token: "unused".to_owned(),
|
||||
}),
|
||||
Arc::new(MemoryAccountStore::default()),
|
||||
CodexCredentialAdmin,
|
||||
profile(),
|
||||
);
|
||||
let started = service
|
||||
.start_authorization(StartCodexOAuthAuthorization {
|
||||
mutation: mutation(),
|
||||
})
|
||||
.await
|
||||
.expect("start OAuth authorization");
|
||||
let outer = Url::parse(&started.authorization_url).expect("outer authorization URL");
|
||||
let inner = outer
|
||||
.query_pairs()
|
||||
.find_map(|(key, value)| (key == "authorize_url").then(|| value.into_owned()))
|
||||
.and_then(|value| Url::parse(&value).ok())
|
||||
.expect("inner authorization URL");
|
||||
let surface_stable_id = inner
|
||||
.query_pairs()
|
||||
.find_map(|(key, value)| {
|
||||
(key == "source_surface_stable_id").then(|| value.into_owned())
|
||||
})
|
||||
.expect("surface stable ID");
|
||||
ids.push(surface_stable_id);
|
||||
}
|
||||
assert_ne!(ids[0], ids[1]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repeated_reauthorization_derives_the_same_surface_id_for_one_account() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
store
|
||||
.seed_oauth_credential(ImportCodexOAuthCredential {
|
||||
account_id: "acct_surface_reauth".to_owned(),
|
||||
name: "surface reauthorization".to_owned(),
|
||||
secret: secret("surface-reauth-access"),
|
||||
verified_account: account_profile("chatgpt-surface-reauth"),
|
||||
next_refresh_at: None,
|
||||
enabled: true,
|
||||
})
|
||||
.await;
|
||||
let account_id = ProviderAccountId::new("acct_surface_reauth").expect("account id");
|
||||
let mut surface_ids = Vec::new();
|
||||
for request_id in ["surface-reauth-first", "surface-reauth-second"] {
|
||||
let service = CodexOAuthAdminService::new(
|
||||
Arc::new(PendingStore::default()),
|
||||
Arc::new(Exchanger {
|
||||
id_token: "unused".to_owned(),
|
||||
}),
|
||||
store.clone(),
|
||||
CodexCredentialAdmin,
|
||||
profile(),
|
||||
);
|
||||
let started = service
|
||||
.start_authorization(StartCodexOAuthAuthorization {
|
||||
mutation: reauthorization_mutation(account_id.clone(), request_id),
|
||||
})
|
||||
.await
|
||||
.expect("start OAuth reauthorization");
|
||||
let outer = Url::parse(&started.authorization_url).expect("outer authorization URL");
|
||||
let inner = outer
|
||||
.query_pairs()
|
||||
.find_map(|(key, value)| (key == "authorize_url").then(|| value.into_owned()))
|
||||
.and_then(|value| Url::parse(&value).ok())
|
||||
.expect("inner authorization URL");
|
||||
surface_ids.push(
|
||||
inner
|
||||
.query_pairs()
|
||||
.find_map(|(key, value)| {
|
||||
(key == "source_surface_stable_id").then(|| value.into_owned())
|
||||
})
|
||||
.expect("surface stable ID"),
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(surface_ids[0], surface_ids[1]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_exchange_persists_the_installation_id_without_reusing_it_as_the_surface_id() {
|
||||
let (credential, pending_installation_id, surface_stable_id) = complete(id_token(
|
||||
serde_json::json!({ "email": "identity@example.com" }),
|
||||
))
|
||||
.await
|
||||
.expect("completed OAuth exchange");
|
||||
let CompletedCodexOAuthCredential::Create(account) = credential else {
|
||||
panic!("expected account creation");
|
||||
};
|
||||
let runtime = CodexCredentialCodec::decode(&account.credential).expect("stored credential");
|
||||
|
||||
assert_eq!(runtime.installation_id, pending_installation_id);
|
||||
assert_ne!(runtime.installation_id, surface_stable_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_exchange_uses_official_id_token_claim_mapping_without_signature_validation() {
|
||||
let credential = complete(id_token(serde_json::json!({
|
||||
let (credential, _, _) = complete(id_token(serde_json::json!({
|
||||
"email": "top@example.com",
|
||||
"https://api.openai.com/profile": { "email": "fallback@example.com" },
|
||||
"https://api.openai.com/auth": {
|
||||
@@ -198,7 +505,7 @@ async fn first_exchange_uses_official_id_token_claim_mapping_without_signature_v
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_exchange_permits_missing_identity_claims_but_requires_a_parseable_id_token() {
|
||||
let credential = complete(id_token(serde_json::json!({
|
||||
let (credential, _, _) = complete(id_token(serde_json::json!({
|
||||
"https://api.openai.com/profile": { "email": "profile@example.com" }
|
||||
})))
|
||||
.await
|
||||
|
||||
@@ -485,8 +485,11 @@ pub(crate) fn agent_identity_service_with_pool(
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("agent task client");
|
||||
let registrar =
|
||||
OfficialCodexAgentIdentityTaskRegistrar::new(client).expect("agent task registrar");
|
||||
let registrar = OfficialCodexAgentIdentityTaskRegistrar::new(
|
||||
client,
|
||||
provider_openai::OpenAiConfig::default().wire_profile_state(),
|
||||
)
|
||||
.expect("agent task registrar");
|
||||
Arc::new(CodexAgentIdentityTaskService::new(
|
||||
store.repository(),
|
||||
Arc::new(registrar),
|
||||
|
||||
@@ -4,6 +4,7 @@ use provider_openai::transport::canonical::{
|
||||
CodexCanonicalDecoder, CodexCanonicalError, CodexCanonicalFailure, CodexCanonicalOutcome,
|
||||
};
|
||||
use provider_openai::transport::protocol::websocket::websocket_event_to_sse_frame;
|
||||
use serde_json::json;
|
||||
|
||||
const METADATA_PREFIX_FIXTURE: &str = include_str!(
|
||||
"../../../../gateway-api/tests/openai/responses/fixtures/http_sse/metadata_only_prefix.sse"
|
||||
@@ -262,6 +263,69 @@ fn decoder_should_use_response_service_tier_when_request_omits_it() {
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_add_standard_web_search_call_cost() {
|
||||
let body = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_search_cost\",\"model\":\"gpt-5.4\"}}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_search_cost\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[{\"type\":\"web_search_call\",\"id\":\"ws_1\",\"status\":\"completed\",\"action\":{\"type\":\"search\"}}],\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"total_tokens\":0}}}\n\n",
|
||||
);
|
||||
let tools = vec![json!({ "type": "web_search" })];
|
||||
let events = CodexCanonicalDecoder::new("fallback")
|
||||
.with_request_tool_pricing("gpt-5.4", Some(&tools))
|
||||
.push(body.as_bytes())
|
||||
.expect("canonical web search response");
|
||||
|
||||
assert!(canonical_facts(&events).into_iter().any(|event| matches!(
|
||||
event,
|
||||
GatewayEvent::CalculatedCost(cost)
|
||||
if cost.total().amount().scaled() == 100_000_000
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_use_non_reasoning_preview_web_search_price() {
|
||||
let body = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_preview_search_cost\",\"model\":\"gpt-4o\"}}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_preview_search_cost\",\"model\":\"gpt-4o\",\"status\":\"completed\",\"output\":[{\"type\":\"web_search_call\",\"id\":\"ws_1\",\"status\":\"completed\",\"action\":{\"type\":\"search\"}}],\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"total_tokens\":0}}}\n\n",
|
||||
);
|
||||
let tools = vec![json!({ "type": "web_search_preview" })];
|
||||
let events = CodexCanonicalDecoder::new("fallback")
|
||||
.with_request_tool_pricing("gpt-4o", Some(&tools))
|
||||
.push(body.as_bytes())
|
||||
.expect("canonical preview web search response");
|
||||
|
||||
assert!(canonical_facts(&events).into_iter().any(|event| matches!(
|
||||
event,
|
||||
GatewayEvent::CalculatedCost(cost)
|
||||
if cost.total().amount().scaled() == 250_000_000
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_fail_closed_for_fixed_block_web_search_content() {
|
||||
let body = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_fixed_search_cost\",\"model\":\"gpt-4o-mini\"}}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_fixed_search_cost\",\"model\":\"gpt-4o-mini\",\"status\":\"completed\",\"output\":[{\"type\":\"web_search_call\",\"id\":\"ws_1\",\"status\":\"completed\",\"action\":{\"type\":\"search\"}}],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n",
|
||||
);
|
||||
let tools = vec![json!({ "type": "web_search" })];
|
||||
let events = CodexCanonicalDecoder::new("fallback")
|
||||
.with_request_tool_pricing("gpt-4o-mini", Some(&tools))
|
||||
.push(body.as_bytes())
|
||||
.expect("canonical fixed-block search response");
|
||||
|
||||
assert!(
|
||||
canonical_facts(&events)
|
||||
.into_iter()
|
||||
.all(|event| !matches!(event, GatewayEvent::CalculatedCost(_)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_decoder_should_prefer_requested_service_tier_over_response_tier_for_billing() {
|
||||
let created = websocket_event_to_sse_frame(
|
||||
@@ -423,6 +487,25 @@ fn decoder_should_preserve_image_tool_tokens_in_canonical_usage() {
|
||||
assert_eq!(image_usage, Some((Some(31), Some(9))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_not_understate_cost_when_image_tool_price_is_ambiguous() {
|
||||
let body = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_image_cost\",\"model\":\"gpt-5.4\"}}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_image_cost\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":5,\"total_tokens\":17},\"tool_usage\":{\"image_gen\":{\"input_tokens\":31,\"output_tokens\":9}}}}\n\n",
|
||||
);
|
||||
let events = CodexCanonicalDecoder::new("fallback")
|
||||
.push(body.as_bytes())
|
||||
.expect("canonical image cost response");
|
||||
|
||||
assert!(
|
||||
canonical_facts(&events)
|
||||
.into_iter()
|
||||
.all(|event| !matches!(event, GatewayEvent::CalculatedCost(_)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_drop_rate_limit_control_and_preserve_metadata_lifecycle_events() {
|
||||
let body = concat!(
|
||||
|
||||
@@ -223,6 +223,7 @@ async fn fetch_should_send_official_catalog_headers_and_query() {
|
||||
"user-agent",
|
||||
"codex_cli_rs/0.144.0 (linux 6.8; x86_64) xterm (codex_cli_rs; 1.0.0)",
|
||||
))
|
||||
.and(header("x-openai-internal-codex-residency", "us"))
|
||||
.and(header("accept", "application/json"))
|
||||
.and(header("x-codex-installation-id", "installation-123"))
|
||||
.respond_with(
|
||||
@@ -254,7 +255,6 @@ async fn fetch_should_send_official_catalog_headers_and_query() {
|
||||
for forbidden in [
|
||||
"content-type",
|
||||
"openai-beta",
|
||||
"x-openai-internal-codex-residency",
|
||||
"x-client-request-id",
|
||||
"session_id",
|
||||
"session-id",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use provider_openai::transport::{endpoint_url, usage_endpoint_urls};
|
||||
use provider_openai::transport::{endpoint_url, usage_endpoint_url};
|
||||
|
||||
#[test]
|
||||
fn endpoints_should_join_origin_and_backend_paths_without_double_slashes() {
|
||||
@@ -9,12 +9,9 @@ fn endpoints_should_join_origin_and_backend_paths_without_double_slashes() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_endpoints_should_preserve_the_official_backend_api_fallback_order() {
|
||||
fn usage_endpoint_should_use_the_official_wham_path_for_backend_api() {
|
||||
assert_eq!(
|
||||
usage_endpoint_urls("https://chatgpt.com/backend-api"),
|
||||
vec![
|
||||
"https://chatgpt.com/backend-api/wham/usage".to_owned(),
|
||||
"https://chatgpt.com/backend-api/codex/usage".to_owned(),
|
||||
]
|
||||
usage_endpoint_url("https://chatgpt.com/backend-api"),
|
||||
"https://chatgpt.com/backend-api/wham/usage"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,6 +178,7 @@ fn websocket_opening_audit_should_redact_sensitive_headers() {
|
||||
"x-codex-turn-metadata".to_owned(),
|
||||
"{\"secret\":true}".to_owned(),
|
||||
),
|
||||
("x-oai-attestation".to_owned(), "device-token".to_owned()),
|
||||
],
|
||||
);
|
||||
let audit = connection.opening_audit_snapshot();
|
||||
@@ -190,6 +191,7 @@ fn websocket_opening_audit_should_redact_sensitive_headers() {
|
||||
"user-agent",
|
||||
"x-client-request-id",
|
||||
"x-codex-turn-metadata",
|
||||
"x-oai-attestation",
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
@@ -313,6 +315,7 @@ async fn backend_websocket_should_forward_context_headers_and_preserve_payload_f
|
||||
("content-type", "application/json"),
|
||||
("accept", "text/event-stream"),
|
||||
("x-codex-installation-id", "install-123"),
|
||||
("openai-beta", "responses_websockets=2026-02-06"),
|
||||
("x-openai-internal-codex-residency", "us"),
|
||||
("x-codex-turn-state", "turn-state"),
|
||||
("x-codex-turn-metadata", "{\"thread_source\":\"subagent\"}"),
|
||||
@@ -382,10 +385,7 @@ async fn backend_http_should_restore_opaque_multivalue_header_bytes_and_lease_id
|
||||
raw_header_values(&raw, "x-openai-future-mode"),
|
||||
vec![b"future-ascii".to_vec(), b"\x80\xff".to_vec()]
|
||||
);
|
||||
assert_eq!(
|
||||
raw_header_values(&raw, "openai-beta"),
|
||||
vec![b"future_responses=v2".to_vec(), b"future_tools=v3".to_vec()]
|
||||
);
|
||||
assert!(raw_header_values(&raw, "openai-beta").is_empty());
|
||||
assert_eq!(
|
||||
raw_header_values(&raw, "x-codex-turn-state"),
|
||||
vec![b"turn-ascii".to_vec(), b"turn-\x80".to_vec()]
|
||||
@@ -396,10 +396,7 @@ async fn backend_http_should_restore_opaque_multivalue_header_bytes_and_lease_id
|
||||
"content-type",
|
||||
b"application/vnd.openai.request+json".as_slice(),
|
||||
),
|
||||
(
|
||||
"x-openai-internal-codex-residency",
|
||||
b"future-region".as_slice(),
|
||||
),
|
||||
("x-openai-internal-codex-residency", b"us".as_slice()),
|
||||
("x-still-valid", b"after-invalid".as_slice()),
|
||||
("authorization", b"Bearer lease-token".as_slice()),
|
||||
("chatgpt-account-id", b"lease-account".as_slice()),
|
||||
@@ -416,7 +413,12 @@ async fn backend_http_should_restore_opaque_multivalue_header_bytes_and_lease_id
|
||||
raw_header_values(&raw, "originator"),
|
||||
vec![b"codex_cli_rs".to_vec()]
|
||||
);
|
||||
for dropped in ["x-oai-attestation", "x-oai-is", "x-oai-is-update"] {
|
||||
for dropped in [
|
||||
"openai-beta",
|
||||
"x-oai-attestation",
|
||||
"x-oai-is",
|
||||
"x-oai-is-update",
|
||||
] {
|
||||
assert!(
|
||||
raw_header_values(&raw, dropped).is_empty(),
|
||||
"unexpected {dropped}"
|
||||
@@ -509,7 +511,7 @@ async fn backend_websocket_should_drop_only_unrepresentable_opaque_header_values
|
||||
);
|
||||
assert_eq!(
|
||||
values("openai-beta"),
|
||||
vec![b"future_responses=v2".to_vec(), b"future_tools=v3".to_vec()]
|
||||
vec![b"responses_websockets=2026-02-06".to_vec()]
|
||||
);
|
||||
assert_eq!(
|
||||
values("accept"),
|
||||
@@ -524,6 +526,10 @@ async fn backend_websocket_should_drop_only_unrepresentable_opaque_header_values
|
||||
vec![test_wire_profile().snapshot().user_agent().into_bytes()]
|
||||
);
|
||||
assert_eq!(values("originator"), vec![b"codex_cli_rs".to_vec()]);
|
||||
assert_eq!(
|
||||
values("x-openai-internal-codex-residency"),
|
||||
vec![b"us".to_vec()]
|
||||
);
|
||||
for dropped in ["x-oai-attestation", "x-oai-is", "x-oai-is-update"] {
|
||||
assert!(values(dropped).is_empty(), "unexpected {dropped}");
|
||||
}
|
||||
@@ -631,7 +637,6 @@ async fn backend_http_should_send_codex_context_without_browser_headers() {
|
||||
"content-type",
|
||||
"cookie",
|
||||
"accept",
|
||||
"openai-beta",
|
||||
"x-openai-internal-codex-residency",
|
||||
"x-client-request-id",
|
||||
"x-codex-installation-id",
|
||||
@@ -654,6 +659,7 @@ async fn backend_http_should_send_codex_context_without_browser_headers() {
|
||||
"sec-fetch-site",
|
||||
"sec-fetch-mode",
|
||||
"sec-fetch-dest",
|
||||
"openai-beta",
|
||||
] {
|
||||
assert!(header_names.iter().all(|name| name != forbidden));
|
||||
}
|
||||
@@ -947,7 +953,12 @@ async fn websocket_should_keep_an_exact_chain_while_new_connections_adopt_the_la
|
||||
)
|
||||
.await
|
||||
.expect("first response");
|
||||
profile.update_cli_release("1.2.4");
|
||||
profile.update_bundled_release(&CodexBundledReleaseProfile {
|
||||
codex_version: "1.2.4".to_owned(),
|
||||
desktop_version: "1.2.4".to_owned(),
|
||||
desktop_build: "124".to_owned(),
|
||||
verified_at: Utc::now(),
|
||||
});
|
||||
|
||||
let mut continuation = request.clone();
|
||||
continuation.set_previous_response_id(Some("resp_profile_first".to_owned()));
|
||||
|
||||
@@ -10,7 +10,9 @@ use std::{
|
||||
use chrono::Utc;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use gateway_protocol::openai::events::{TokenUsage, extract_sse_usage};
|
||||
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
|
||||
use provider_openai::transport::profile::{
|
||||
CodexBundledReleaseProfile, CodexWireProfile, CodexWireProfileState,
|
||||
};
|
||||
use provider_openai::transport::protocol::responses::{
|
||||
CodexResponsesRequest, PreviousResponseScope,
|
||||
};
|
||||
|
||||
@@ -1,60 +1,131 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::provider_ports::{
|
||||
ProviderArtifactProfile, ProviderArtifactProfileCachePort, ProviderStoreError,
|
||||
ProviderStoreErrorKind,
|
||||
};
|
||||
use gateway_core::routing::ProviderKind;
|
||||
use provider_openai::transport::profile::{
|
||||
CodexCliReleaseError, CodexCliReleaseService, CodexCliReleaseTransport, CodexDesktopRelease,
|
||||
CodexArtifactProfileCache, CodexBundledReleaseProfile, CodexDesktopRelease,
|
||||
CodexDesktopReleaseError, CodexDesktopReleaseService, CodexDesktopReleaseTransport,
|
||||
CodexWireProfile, CodexWireProfileState, parse_desktop_release,
|
||||
CodexWireProfile, CodexWireProfileState, OfficialCodexDesktopReleaseTransport,
|
||||
parse_desktop_release,
|
||||
};
|
||||
|
||||
mod desktop_artifact;
|
||||
|
||||
struct ReleaseTransport {
|
||||
outcomes: Mutex<VecDeque<Result<CodexDesktopRelease, CodexDesktopReleaseError>>>,
|
||||
releases: Mutex<VecDeque<Result<CodexDesktopRelease, CodexDesktopReleaseError>>>,
|
||||
core_versions: Mutex<VecDeque<Result<String, CodexDesktopReleaseError>>>,
|
||||
core_probes: Mutex<usize>,
|
||||
}
|
||||
|
||||
impl ReleaseTransport {
|
||||
fn new(
|
||||
outcomes: impl IntoIterator<Item = Result<CodexDesktopRelease, CodexDesktopReleaseError>>,
|
||||
releases: impl IntoIterator<Item = Result<CodexDesktopRelease, CodexDesktopReleaseError>>,
|
||||
core_versions: impl IntoIterator<Item = Result<String, CodexDesktopReleaseError>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
outcomes: Mutex::new(outcomes.into_iter().collect()),
|
||||
releases: Mutex::new(releases.into_iter().collect()),
|
||||
core_versions: Mutex::new(core_versions.into_iter().collect()),
|
||||
core_probes: Mutex::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn core_probes(&self) -> usize {
|
||||
*self.core_probes.lock().expect("core probe count")
|
||||
}
|
||||
}
|
||||
|
||||
impl CodexDesktopReleaseTransport for ReleaseTransport {
|
||||
fn fetch(&self) -> BoxFuture<'_, Result<CodexDesktopRelease, CodexDesktopReleaseError>> {
|
||||
Box::pin(async move {
|
||||
self.outcomes
|
||||
self.releases
|
||||
.lock()
|
||||
.expect("release outcomes")
|
||||
.pop_front()
|
||||
.expect("release outcome")
|
||||
})
|
||||
}
|
||||
|
||||
fn fetch_bundled_core_version<'a>(
|
||||
&'a self,
|
||||
_release: &'a CodexDesktopRelease,
|
||||
) -> BoxFuture<'a, Result<String, CodexDesktopReleaseError>> {
|
||||
Box::pin(async move {
|
||||
*self.core_probes.lock().expect("core probe count") += 1;
|
||||
self.core_versions
|
||||
.lock()
|
||||
.expect("Core version outcomes")
|
||||
.pop_front()
|
||||
.expect("Core version outcome")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct CliReleaseTransport {
|
||||
outcomes: Mutex<VecDeque<Result<String, CodexCliReleaseError>>>,
|
||||
#[derive(Default)]
|
||||
struct ArtifactProfiles {
|
||||
profile: Mutex<Option<ProviderArtifactProfile>>,
|
||||
fail_writes: bool,
|
||||
}
|
||||
|
||||
impl CliReleaseTransport {
|
||||
fn new(outcomes: impl IntoIterator<Item = Result<String, CodexCliReleaseError>>) -> Self {
|
||||
impl ArtifactProfiles {
|
||||
fn failing() -> Self {
|
||||
Self {
|
||||
outcomes: Mutex::new(outcomes.into_iter().collect()),
|
||||
profile: Mutex::new(None),
|
||||
fail_writes: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CodexCliReleaseTransport for CliReleaseTransport {
|
||||
fn fetch(&self) -> BoxFuture<'_, Result<String, CodexCliReleaseError>> {
|
||||
impl ProviderArtifactProfileCachePort for ArtifactProfiles {
|
||||
fn replace_if_newer(
|
||||
&self,
|
||||
profile: ProviderArtifactProfile,
|
||||
_ttl: Duration,
|
||||
) -> BoxFuture<'_, Result<bool, ProviderStoreError>> {
|
||||
Box::pin(async move {
|
||||
self.outcomes
|
||||
if self.fail_writes {
|
||||
return Err(ProviderStoreError::new(
|
||||
ProviderStoreErrorKind::Unavailable,
|
||||
"replace test artifact profile",
|
||||
));
|
||||
}
|
||||
let mut current = self.profile.lock().expect("artifact profile");
|
||||
if let Some(current) = current.as_ref() {
|
||||
if current.artifact_sequence() > profile.artifact_sequence() {
|
||||
return Ok(false);
|
||||
}
|
||||
if current.artifact_sequence() == profile.artifact_sequence()
|
||||
&& current.profile() != profile.profile()
|
||||
{
|
||||
return Err(ProviderStoreError::new(
|
||||
ProviderStoreErrorKind::Conflict,
|
||||
"replace test artifact profile",
|
||||
));
|
||||
}
|
||||
}
|
||||
*current = Some(profile);
|
||||
Ok(true)
|
||||
})
|
||||
}
|
||||
|
||||
fn read<'a>(
|
||||
&'a self,
|
||||
provider_kind: &'a ProviderKind,
|
||||
) -> BoxFuture<'a, Result<Option<ProviderArtifactProfile>, ProviderStoreError>> {
|
||||
Box::pin(async move {
|
||||
Ok(self
|
||||
.profile
|
||||
.lock()
|
||||
.expect("CLI release outcomes")
|
||||
.pop_front()
|
||||
.expect("CLI release outcome")
|
||||
.expect("artifact profile")
|
||||
.as_ref()
|
||||
.filter(|profile| profile.provider_kind() == provider_kind)
|
||||
.cloned())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -63,22 +134,22 @@ impl CodexCliReleaseTransport for CliReleaseTransport {
|
||||
fn wire_profile_should_generate_bundled_core_app_server_user_agent() {
|
||||
let profile = CodexWireProfile {
|
||||
originator: "Codex Desktop".to_owned(),
|
||||
codex_version: "0.144.2".to_owned(),
|
||||
desktop_version: "26.707.72221".to_owned(),
|
||||
desktop_build: "72221".to_owned(),
|
||||
codex_version: "0.147.0-alpha.6.6".to_owned(),
|
||||
desktop_version: "26.803.81509".to_owned(),
|
||||
desktop_build: "6415".to_owned(),
|
||||
os_type: "Mac OS".to_owned(),
|
||||
os_version: "15.7.1".to_owned(),
|
||||
arch: "arm64".to_owned(),
|
||||
terminal: "unknown".to_owned(),
|
||||
verified_at: Utc
|
||||
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
|
||||
.with_ymd_and_hms(2026, 8, 3, 0, 0, 0)
|
||||
.single()
|
||||
.expect("valid fixture time"),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
profile.user_agent(),
|
||||
"Codex Desktop/0.144.2 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.707.72221)"
|
||||
"Codex Desktop/0.147.0-alpha.6.6 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.803.81509)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,7 +166,7 @@ fn desktop_appcast_should_publish_the_first_complete_release() {
|
||||
<pubDate>Sun, 19 Jul 2026 08:00:00 +0000</pubDate>
|
||||
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
|
||||
<sparkle:hardwareRequirements>arm64</sparkle:hardwareRequirements>
|
||||
<enclosure url="https://persistent.oaistatic.com/codex.dmg"
|
||||
<enclosure url="https://persistent.oaistatic.com/codex.zip"
|
||||
length="123456" sparkle:shortVersionString="26.720.1"
|
||||
sparkle:version="72001" sparkle:edSignature="signature" />
|
||||
</item>
|
||||
@@ -135,77 +206,75 @@ fn desktop_appcast_should_reject_invalid_version_and_build() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wire_profile_release_update_should_change_only_desktop_identity() {
|
||||
async fn bundled_release_update_should_change_core_and_desktop_identity_atomically() {
|
||||
let original = wire_profile();
|
||||
let original_user_agent = original.user_agent();
|
||||
let state = CodexWireProfileState::new(original.clone());
|
||||
let service = CodexDesktopReleaseService::new(
|
||||
state.clone(),
|
||||
Arc::new(ReleaseTransport::new([Ok(release("26.720.1", "72001"))])),
|
||||
);
|
||||
let transport = Arc::new(ReleaseTransport::new(
|
||||
[Ok(release("26.810.41047", "6570"))],
|
||||
[Ok("0.148.0-alpha.9".to_owned())],
|
||||
));
|
||||
let cache = Arc::new(ArtifactProfiles::default());
|
||||
let service = service(state.clone(), transport.clone(), cache.clone(), None);
|
||||
|
||||
service.refresh().await.expect("release refresh");
|
||||
|
||||
let updated = state.snapshot();
|
||||
assert_eq!(updated.desktop_version, "26.720.1");
|
||||
assert_eq!(updated.desktop_build, "72001");
|
||||
assert_eq!(updated.codex_version, original.codex_version);
|
||||
assert_eq!(updated.desktop_version, "26.810.41047");
|
||||
assert_eq!(updated.desktop_build, "6570");
|
||||
assert_eq!(updated.codex_version, "0.148.0-alpha.9");
|
||||
assert_eq!(updated.originator, original.originator);
|
||||
assert_eq!(updated.os_type, original.os_type);
|
||||
assert_eq!(updated.os_version, original.os_version);
|
||||
assert_eq!(updated.arch, original.arch);
|
||||
assert_eq!(updated.terminal, original.terminal);
|
||||
assert_eq!(updated.verified_at, original.verified_at);
|
||||
assert!(updated.verified_at > original.verified_at);
|
||||
assert_ne!(updated.user_agent(), original_user_agent);
|
||||
assert_eq!(
|
||||
updated.user_agent(),
|
||||
"Codex Desktop/0.144.2 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.720.1)"
|
||||
"Codex Desktop/0.148.0-alpha.9 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.810.41047)"
|
||||
);
|
||||
assert_eq!(transport.core_probes(), 1);
|
||||
assert!(cache.profile.lock().expect("artifact profile").is_some());
|
||||
let status = service.status().snapshot();
|
||||
assert_eq!(status.latest, Some(release("26.720.1", "72001")));
|
||||
assert_eq!(status.latest, Some(release("26.810.41047", "6570")));
|
||||
assert!(status.checked_at.is_some());
|
||||
assert!(status.last_error.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cli_release_update_should_change_only_codex_core_identity() {
|
||||
let original = wire_profile();
|
||||
let original_user_agent = original.user_agent();
|
||||
let state = CodexWireProfileState::new(original.clone());
|
||||
let service = CodexCliReleaseService::new(
|
||||
state.clone(),
|
||||
Arc::new(CliReleaseTransport::new([Ok("0.145.0".to_owned())])),
|
||||
);
|
||||
async fn unchanged_verified_release_should_refresh_cache_without_downloading_core_again() {
|
||||
let state = CodexWireProfileState::new(wire_profile());
|
||||
let latest = release("26.810.41047", "6570");
|
||||
let transport = Arc::new(ReleaseTransport::new(
|
||||
[Ok(latest.clone()), Ok(latest)],
|
||||
[Ok("0.148.0-alpha.9".to_owned())],
|
||||
));
|
||||
let cache = Arc::new(ArtifactProfiles::default());
|
||||
let service = service(state, transport.clone(), cache, None);
|
||||
|
||||
service.refresh().await.expect("CLI release refresh");
|
||||
service.refresh().await.expect("first release refresh");
|
||||
service.refresh().await.expect("cached release refresh");
|
||||
|
||||
let updated = state.snapshot();
|
||||
assert_eq!(updated.codex_version, "0.145.0");
|
||||
assert_eq!(updated.desktop_version, original.desktop_version);
|
||||
assert_eq!(updated.desktop_build, original.desktop_build);
|
||||
assert_eq!(updated.originator, original.originator);
|
||||
assert_eq!(updated.os_type, original.os_type);
|
||||
assert_eq!(updated.os_version, original.os_version);
|
||||
assert_eq!(updated.arch, original.arch);
|
||||
assert_eq!(updated.terminal, original.terminal);
|
||||
assert_eq!(updated.verified_at, original.verified_at);
|
||||
assert_ne!(updated.user_agent(), original_user_agent);
|
||||
assert_eq!(
|
||||
updated.user_agent(),
|
||||
"Codex Desktop/0.145.0 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.707.72221)"
|
||||
);
|
||||
assert_eq!(transport.core_probes(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_release_refresh_should_preserve_the_last_successful_profile_and_release() {
|
||||
let state = CodexWireProfileState::new(wire_profile());
|
||||
let latest = release("26.720.1", "72001");
|
||||
let service = CodexDesktopReleaseService::new(
|
||||
state.clone(),
|
||||
Arc::new(ReleaseTransport::new([
|
||||
let latest = release("26.810.41047", "6570");
|
||||
let transport = Arc::new(ReleaseTransport::new(
|
||||
[
|
||||
Ok(latest.clone()),
|
||||
Err(CodexDesktopReleaseError::InvalidDocument),
|
||||
])),
|
||||
],
|
||||
[Ok("0.148.0-alpha.9".to_owned())],
|
||||
));
|
||||
let service = service(
|
||||
state.clone(),
|
||||
transport,
|
||||
Arc::new(ArtifactProfiles::default()),
|
||||
None,
|
||||
);
|
||||
service.refresh().await.expect("first release refresh");
|
||||
let successful_profile = state.snapshot();
|
||||
@@ -225,18 +294,74 @@ async fn failed_release_refresh_should_preserve_the_last_successful_profile_and_
|
||||
assert!(status.checked_at.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_failure_should_not_publish_a_partially_verified_profile() {
|
||||
let original = wire_profile();
|
||||
let state = CodexWireProfileState::new(original.clone());
|
||||
let transport = Arc::new(ReleaseTransport::new(
|
||||
[Ok(release("26.810.41047", "6570"))],
|
||||
[Ok("0.148.0-alpha.9".to_owned())],
|
||||
));
|
||||
let service = service(
|
||||
state.clone(),
|
||||
transport,
|
||||
Arc::new(ArtifactProfiles::failing()),
|
||||
None,
|
||||
);
|
||||
|
||||
service
|
||||
.refresh()
|
||||
.await
|
||||
.expect_err("cache failure must fail the refresh");
|
||||
|
||||
assert_eq!(state.snapshot(), original);
|
||||
assert!(service.status().snapshot().latest.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "downloads bounded ranges from the current official macOS artifact"]
|
||||
async fn official_desktop_artifact_should_expose_its_bundled_core_version() {
|
||||
let transport = OfficialCodexDesktopReleaseTransport::new().expect("official transport");
|
||||
let release = transport.fetch().await.expect("official appcast release");
|
||||
let core_version = transport
|
||||
.fetch_bundled_core_version(&release)
|
||||
.await
|
||||
.expect("bundled Core version");
|
||||
|
||||
semver::Version::parse(&core_version).expect("semantic bundled Core version");
|
||||
eprintln!(
|
||||
"official Desktop {} (build {}) bundles Core {core_version}",
|
||||
release.version, release.build
|
||||
);
|
||||
}
|
||||
|
||||
fn service(
|
||||
state: CodexWireProfileState,
|
||||
transport: Arc<ReleaseTransport>,
|
||||
cache: Arc<ArtifactProfiles>,
|
||||
verified: Option<CodexBundledReleaseProfile>,
|
||||
) -> CodexDesktopReleaseService {
|
||||
let provider = ProviderKind::new("openai").expect("provider kind");
|
||||
CodexDesktopReleaseService::new(
|
||||
state,
|
||||
transport,
|
||||
CodexArtifactProfileCache::new(provider, cache),
|
||||
verified,
|
||||
)
|
||||
}
|
||||
|
||||
fn wire_profile() -> CodexWireProfile {
|
||||
CodexWireProfile {
|
||||
originator: "Codex Desktop".to_owned(),
|
||||
codex_version: "0.144.2".to_owned(),
|
||||
desktop_version: "26.707.72221".to_owned(),
|
||||
desktop_build: "72221".to_owned(),
|
||||
codex_version: "0.147.0-alpha.6.6".to_owned(),
|
||||
desktop_version: "26.803.81509".to_owned(),
|
||||
desktop_build: "6415".to_owned(),
|
||||
os_type: "Mac OS".to_owned(),
|
||||
os_version: "15.7.1".to_owned(),
|
||||
arch: "arm64".to_owned(),
|
||||
terminal: "unknown".to_owned(),
|
||||
verified_at: Utc
|
||||
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
|
||||
.with_ymd_and_hms(2026, 8, 3, 0, 0, 0)
|
||||
.single()
|
||||
.expect("valid fixture time"),
|
||||
}
|
||||
@@ -249,8 +374,8 @@ fn release(version: &str, build: &str) -> CodexDesktopRelease {
|
||||
published_at: None,
|
||||
minimum_system_version: Some("14.0".to_owned()),
|
||||
hardware_requirements: Some("arm64".to_owned()),
|
||||
download_url: Some("https://persistent.oaistatic.com/codex.dmg".to_owned()),
|
||||
download_size: Some(123_456),
|
||||
download_url: Some("https://persistent.oaistatic.com/codex.zip".to_owned()),
|
||||
download_size: Some(589_926_710),
|
||||
signature_present: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
#[allow(dead_code)]
|
||||
#[path = "../../../src/transport/profile/desktop_artifact.rs"]
|
||||
mod subject;
|
||||
|
||||
use subject::{CoreVersionScanner, find_core_entry, parse_content_range};
|
||||
|
||||
#[test]
|
||||
fn scanner_accepts_stable_and_multi_part_alpha_versions_across_chunks() {
|
||||
for expected in ["0.148.0", "0.147.0-alpha.6.6"] {
|
||||
let bytes = format!("prefixcodex-mcp-client/{expected}OAuth suffix");
|
||||
let split = bytes.find("client").expect("split point") + 3;
|
||||
let mut scanner = CoreVersionScanner::default();
|
||||
assert_eq!(
|
||||
scanner.push(&bytes.as_bytes()[..split]).expect("scan"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
scanner.push(&bytes.as_bytes()[split..]).expect("scan"),
|
||||
Some(expected.to_owned())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_rejects_unrecognized_prerelease_shapes() {
|
||||
let mut scanner = CoreVersionScanner::default();
|
||||
assert!(
|
||||
scanner
|
||||
.push(b"codex-mcp-client/0.149.0-nightly.1 next")
|
||||
.expect("scan")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_rejects_build_metadata_instead_of_truncating_the_version() {
|
||||
for version in ["0.149.0+desktop.1", "0.149.0-alpha.1+desktop.1"] {
|
||||
let mut scanner = CoreVersionScanner::default();
|
||||
let bytes = format!("codex-mcp-client/{version}OAuth");
|
||||
assert!(scanner.push(bytes.as_bytes()).expect("scan").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_skips_an_invalid_marker_before_the_bundled_version() {
|
||||
let mut scanner = CoreVersionScanner::default();
|
||||
assert_eq!(
|
||||
scanner
|
||||
.push(b"codex-mcp-client/not-a-version codex-mcp-client/0.147.0-alpha.6.6OAuth",)
|
||||
.expect("scan"),
|
||||
Some("0.147.0-alpha.6.6".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn central_directory_requires_one_deflated_core_entry() {
|
||||
let name = b"ChatGPT.app/Contents/Resources/codex";
|
||||
let mut central = vec![0_u8; 46 + name.len()];
|
||||
central[..4].copy_from_slice(b"PK\x01\x02");
|
||||
central[10..12].copy_from_slice(&8_u16.to_le_bytes());
|
||||
central[20..24].copy_from_slice(&100_u32.to_le_bytes());
|
||||
central[24..28].copy_from_slice(&200_u32.to_le_bytes());
|
||||
central[28..30].copy_from_slice(
|
||||
&u16::try_from(name.len())
|
||||
.expect("name length")
|
||||
.to_le_bytes(),
|
||||
);
|
||||
central[42..46].copy_from_slice(&50_u32.to_le_bytes());
|
||||
central[46..].copy_from_slice(name);
|
||||
|
||||
let entry = find_core_entry(¢ral, 1).expect("core entry");
|
||||
assert_eq!(entry.name, name);
|
||||
assert_eq!(entry.compressed_size, 100);
|
||||
assert_eq!(entry.uncompressed_size, 200);
|
||||
assert_eq!(entry.local_header_offset, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_range_parser_is_strict() {
|
||||
assert_eq!(parse_content_range("bytes 10-19/100"), Some((10, 19, 100)));
|
||||
assert_eq!(parse_content_range("bytes */100"), None);
|
||||
assert_eq!(parse_content_range("10-19/100"), None);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use chrono::{TimeZone, Utc};
|
||||
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
|
||||
use provider_openai::transport::{
|
||||
CodexBackendClient, CodexClientError, CodexRequestContext, MAX_CODEX_USAGE_BODY_BYTES,
|
||||
build_reqwest_client, openai_billing_breakdown,
|
||||
OpenAiBillingUsage, build_reqwest_client, openai_billing_breakdown,
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
use wiremock::matchers::{method, path};
|
||||
@@ -16,10 +16,24 @@ use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
const OVERSIZED_BODY_ERROR: &str = "upstream usage response exceeded the body limit";
|
||||
|
||||
fn billing_usage(
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
cached_tokens: u64,
|
||||
cache_write_tokens: u64,
|
||||
) -> OpenAiBillingUsage {
|
||||
OpenAiBillingUsage::new(
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cached_tokens,
|
||||
cache_write_tokens,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_breakdown_should_preserve_input_output_and_cache_components() {
|
||||
let breakdown =
|
||||
openai_billing_breakdown("gpt-5.6-sol", 100, 5, 20, 10, None).expect("known model pricing");
|
||||
let breakdown = openai_billing_breakdown("gpt-5.6-sol", billing_usage(100, 5, 20, 10), None)
|
||||
.expect("known model pricing");
|
||||
|
||||
assert_eq!(breakdown.input_amount().amount().scaled(), 3_500_000);
|
||||
assert_eq!(breakdown.output_amount().amount().scaled(), 1_500_000);
|
||||
@@ -32,50 +46,120 @@ fn billing_breakdown_should_preserve_input_output_and_cache_components() {
|
||||
|
||||
#[test]
|
||||
fn billing_breakdown_should_use_latest_gpt_5_6_and_cached_input_prices() {
|
||||
let terra = openai_billing_breakdown("gpt-5.6-terra", 1, 1, 0, 0, None)
|
||||
let terra = openai_billing_breakdown("gpt-5.6-terra", billing_usage(1, 1, 0, 0), None)
|
||||
.expect("gpt-5.6-terra standard pricing");
|
||||
assert_eq!(terra.total_amount().amount().scaled(), 140_000);
|
||||
|
||||
let terra_fast = openai_billing_breakdown("gpt-5.6-terra", 1, 1, 0, 0, Some("fast"))
|
||||
.expect("gpt-5.6-terra fast pricing");
|
||||
let terra_fast =
|
||||
openai_billing_breakdown("gpt-5.6-terra", billing_usage(1, 1, 0, 0), Some("fast"))
|
||||
.expect("gpt-5.6-terra fast pricing");
|
||||
assert_eq!(terra_fast.total_amount().amount().scaled(), 280_000);
|
||||
|
||||
let terra_long = openai_billing_breakdown("gpt-5.6-terra", 272_001, 0, 0, 0, None)
|
||||
.expect("gpt-5.6-terra long-context pricing");
|
||||
let terra_long =
|
||||
openai_billing_breakdown("gpt-5.6-terra", billing_usage(272_001, 0, 0, 0), None)
|
||||
.expect("gpt-5.6-terra long-context pricing");
|
||||
assert_eq!(terra_long.total_amount().amount().scaled(), 10_880_040_000);
|
||||
|
||||
let luna = openai_billing_breakdown("gpt-5.6-luna", 1, 1, 0, 0, None)
|
||||
let luna = openai_billing_breakdown("gpt-5.6-luna", billing_usage(1, 1, 0, 0), None)
|
||||
.expect("gpt-5.6-luna standard pricing");
|
||||
assert_eq!(luna.total_amount().amount().scaled(), 14_000);
|
||||
|
||||
let gpt_4o =
|
||||
openai_billing_breakdown("gpt-4o", 1, 1, 1, 0, None).expect("gpt-4o cached-input pricing");
|
||||
let gpt_4o = openai_billing_breakdown("gpt-4o", billing_usage(1, 1, 1, 0), None)
|
||||
.expect("gpt-4o cached-input pricing");
|
||||
assert_eq!(gpt_4o.total_amount().amount().scaled(), 112_500);
|
||||
|
||||
let gpt_4o_mini = openai_billing_breakdown("gpt-4o-mini", 1, 1, 1, 0, None)
|
||||
let gpt_4o_mini = openai_billing_breakdown("gpt-4o-mini", billing_usage(1, 1, 1, 0), None)
|
||||
.expect("gpt-4o-mini cached-input pricing");
|
||||
assert_eq!(gpt_4o_mini.total_amount().amount().scaled(), 6_750);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_breakdown_should_apply_fast_and_flex_tiers_without_guessing_unknown_models() {
|
||||
let fast = openai_billing_breakdown("gpt-5.4", 1, 1, 0, 0, Some("fast")).expect("fast pricing");
|
||||
let flex = openai_billing_breakdown("gpt-5.4", 1, 1, 0, 0, Some("flex")).expect("flex pricing");
|
||||
let fast = openai_billing_breakdown("gpt-5.4", billing_usage(1, 1, 0, 0), Some("fast"))
|
||||
.expect("fast pricing");
|
||||
let flex = openai_billing_breakdown("gpt-5.4", billing_usage(1, 1, 0, 0), Some("flex"))
|
||||
.expect("flex pricing");
|
||||
|
||||
assert_eq!(fast.total_amount().amount().scaled(), 350_000);
|
||||
assert_eq!(fast.service_tier(), Some("fast"));
|
||||
assert_eq!(fast.multiplier_percent(), 100);
|
||||
assert_eq!(fast.multiplier_percent(), 200);
|
||||
assert_eq!(flex.total_amount().amount().scaled(), 87_500);
|
||||
assert_eq!(flex.multiplier_percent(), 50);
|
||||
assert!(openai_billing_breakdown("unknown-model", 1, 1, 0, 0, None).is_none());
|
||||
assert!(openai_billing_breakdown("unknown-model", billing_usage(1, 1, 0, 0), None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_breakdown_should_use_official_fast_long_context_prices() {
|
||||
let breakdown =
|
||||
openai_billing_breakdown("gpt-5.6-sol", billing_usage(272_001, 1, 1, 1), Some("fast"))
|
||||
.expect("gpt-5.6-sol fast long-context pricing");
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
breakdown.input_price_per_million().amount().scaled(),
|
||||
breakdown.cache_read_price_per_million().amount().scaled(),
|
||||
breakdown.cache_write_price_per_million().amount().scaled(),
|
||||
breakdown.output_price_per_million().amount().scaled(),
|
||||
),
|
||||
(
|
||||
200_000_000_000,
|
||||
20_000_000_000,
|
||||
250_000_000_000,
|
||||
900_000_000_000,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_breakdown_should_fail_closed_for_unpublished_prices() {
|
||||
assert_eq!(
|
||||
(
|
||||
openai_billing_breakdown("gpt-5.5", billing_usage(272_001, 1, 0, 0), Some("fast"))
|
||||
.is_none(),
|
||||
openai_billing_breakdown("gpt-5.5-pro", billing_usage(1, 1, 0, 0), Some("fast"))
|
||||
.is_none(),
|
||||
openai_billing_breakdown("gpt-5.3-codex-spark", billing_usage(1, 1, 0, 0), None)
|
||||
.is_none(),
|
||||
openai_billing_breakdown("gpt-4o", billing_usage(1, 1, 0, 0), Some("flex")).is_none(),
|
||||
),
|
||||
(true, true, true, true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_breakdown_should_use_official_gpt_4o_fast_prices() {
|
||||
let breakdown = openai_billing_breakdown("gpt-4o", billing_usage(1, 1, 1, 0), Some("priority"))
|
||||
.expect("gpt-4o fast pricing");
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
breakdown.cache_read_price_per_million().amount().scaled(),
|
||||
breakdown.output_price_per_million().amount().scaled(),
|
||||
breakdown.standard_amount().amount().scaled(),
|
||||
breakdown.total_amount().amount().scaled(),
|
||||
breakdown.multiplier_percent(),
|
||||
),
|
||||
(21_250_000_000, 170_000_000_000, 112_500, 191_250, 170,)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_breakdown_should_use_published_flex_cache_price() {
|
||||
let breakdown = openai_billing_breakdown("gpt-5.4", billing_usage(1, 0, 1, 0), Some("flex"))
|
||||
.expect("gpt-5.4 flex pricing");
|
||||
|
||||
assert_eq!(
|
||||
breakdown.cache_read_price_per_million().amount().scaled(),
|
||||
1_300_000_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_breakdown_should_switch_only_after_the_long_context_threshold() {
|
||||
let boundary = openai_billing_breakdown("gpt-5.4", 272_000, 0, 0, 0, None)
|
||||
let boundary = openai_billing_breakdown("gpt-5.4", billing_usage(272_000, 0, 0, 0), None)
|
||||
.expect("short-context boundary");
|
||||
let long =
|
||||
openai_billing_breakdown("gpt-5.4", 272_001, 0, 0, 0, None).expect("long-context pricing");
|
||||
let long = openai_billing_breakdown("gpt-5.4", billing_usage(272_001, 0, 0, 0), None)
|
||||
.expect("long-context pricing");
|
||||
|
||||
assert_eq!(
|
||||
boundary.input_price_per_million().amount().scaled(),
|
||||
@@ -85,7 +169,7 @@ fn billing_breakdown_should_switch_only_after_the_long_context_threshold() {
|
||||
long.input_price_per_million().amount().scaled(),
|
||||
50_000_000_000
|
||||
);
|
||||
assert!(openai_billing_breakdown("gpt-5.4", 1, 0, 2, 0, None).is_none());
|
||||
assert!(openai_billing_breakdown("gpt-5.4", billing_usage(1, 0, 2, 0), None).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -153,7 +237,6 @@ async fn fetch_should_use_wham_usage_headers_only() {
|
||||
for (name, expected) in [
|
||||
("authorization", "Bearer oauth-access"),
|
||||
("chatgpt-account-id", "acct_123"),
|
||||
("originator", "codex_cli_rs"),
|
||||
("accept", "application/json"),
|
||||
("cookie", "session=old"),
|
||||
] {
|
||||
@@ -181,12 +264,12 @@ async fn fetch_should_use_wham_usage_headers_only() {
|
||||
"authorization",
|
||||
"chatgpt-account-id",
|
||||
"cookie",
|
||||
"originator",
|
||||
"user-agent",
|
||||
])
|
||||
);
|
||||
for forbidden in [
|
||||
"content-type",
|
||||
"originator",
|
||||
"sec-ch-ua",
|
||||
"x-openai-internal-codex-residency",
|
||||
"x-client-request-id",
|
||||
@@ -210,6 +293,30 @@ async fn fetch_should_use_wham_usage_headers_only() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_not_found_should_preserve_status_without_a_nonofficial_fallback() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(ResponseTemplate::new(404).set_body_string("missing"))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let error = client(&server.uri())
|
||||
.fetch_usage(context())
|
||||
.await
|
||||
.expect_err("usage 404");
|
||||
let CodexClientError::Upstream { status, .. } = error else {
|
||||
panic!("expected upstream status");
|
||||
};
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
|
||||
let requests = server.received_requests().await.expect("usage requests");
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0].url.path(), "/api/codex/usage");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn content_length_over_limit_success_body_should_be_rejected() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
@@ -109,7 +109,12 @@ async fn exact_continuation_reuses_owning_socket_after_connection_profile_update
|
||||
)
|
||||
.await
|
||||
.expect("connection-local response");
|
||||
profile.update_desktop_release("2.0.0", "200");
|
||||
profile.update_bundled_release(&CodexBundledReleaseProfile {
|
||||
codex_version: "2.0.0".to_owned(),
|
||||
desktop_version: "2.0.0".to_owned(),
|
||||
desktop_build: "200".to_owned(),
|
||||
verified_at: Utc::now(),
|
||||
});
|
||||
let mut continuation = first_request;
|
||||
continuation.set_previous_response_id(Some("resp_connection_local".to_owned()));
|
||||
continuation.previous_response_scope = Some(PreviousResponseScope::ConnectionLocal);
|
||||
|
||||
@@ -41,17 +41,16 @@ use gateway_core::routing::{ProviderKind, UpstreamModelId};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Number, Value};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::XaiWireProfileState;
|
||||
use crate::credential::{
|
||||
AuthorizationCallback, FailureClass, GrokAccountProfile, GrokCredentialAdmin,
|
||||
GrokCredentialCatalogError, GrokCredentialCatalogService, GrokCredentialQuotaService,
|
||||
GrokCredentialRefreshError, GrokCredentialRefreshService, GrokCredentialRepository,
|
||||
GrokCredentialRepositoryError, GrokOAuthClient, GrokOAuthConfig, GrokOAuthImportCandidate,
|
||||
GrokOAuthImportDocument, GrokOAuthImportMetadata, GrokOAuthImportTokens, GrokOAuthSecret,
|
||||
GrokQuotaError, GrokQuotaPeriodKind, GrokQuotaSnapshot, OAuthError, PendingAuthorization,
|
||||
FailureClass, GrokAccountProfile, GrokCredentialAdmin, GrokCredentialCatalogError,
|
||||
GrokCredentialCatalogService, GrokCredentialQuotaService, GrokCredentialRefreshError,
|
||||
GrokCredentialRefreshService, GrokCredentialRepository, GrokCredentialRepositoryError,
|
||||
GrokOAuthClient, GrokOAuthConfig, GrokOAuthImportCandidate, GrokOAuthImportDocument,
|
||||
GrokOAuthImportMetadata, GrokOAuthImportTokens, GrokOAuthSecret, GrokQuotaError,
|
||||
GrokQuotaPeriodKind, GrokQuotaSnapshot, OAuthError, PendingAuthorization,
|
||||
PreparedGrokCredentialRotation, PreparedGrokCredentialRotationGuard, RedirectUriAllowlist,
|
||||
RotateManagedGrokCredential, SecretValue, VerifiedGrokAccount, VerifiedTokenSet,
|
||||
};
|
||||
@@ -771,9 +770,8 @@ impl XaiAdminProvider {
|
||||
&SecretValue::new(stored.server_state),
|
||||
)
|
||||
.map_err(map_oauth_error)?;
|
||||
let callback = callback(callback_url)?;
|
||||
let grant = authorization
|
||||
.accept_callback(callback)
|
||||
.accept_authorization_input(callback_url)
|
||||
.map_err(map_oauth_error)?;
|
||||
let discovery = self.oauth.discover().await.map_err(map_oauth_error)?;
|
||||
let tokens = self
|
||||
@@ -1343,23 +1341,6 @@ fn decode_mutation(
|
||||
))
|
||||
}
|
||||
|
||||
fn callback(value: &str) -> Result<AuthorizationCallback, ProviderAdminError> {
|
||||
let mut callback =
|
||||
Url::parse(value).map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))?;
|
||||
if callback.fragment().is_some() {
|
||||
return Err(provider_error(ProviderAdminErrorKind::Invalid));
|
||||
}
|
||||
let query = callback.query().unwrap_or_default().to_owned();
|
||||
callback.set_query(None);
|
||||
let expected = Url::parse(crate::OFFICIAL_REDIRECT_URI)
|
||||
.map_err(|_| provider_error(ProviderAdminErrorKind::Internal))?;
|
||||
if callback != expected {
|
||||
return Err(provider_error(ProviderAdminErrorKind::Invalid));
|
||||
}
|
||||
AuthorizationCallback::parse(&query)
|
||||
.map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))
|
||||
}
|
||||
|
||||
fn random_flow_id() -> Result<String, ProviderAdminError> {
|
||||
let mut random = [0_u8; 32];
|
||||
getrandom::fill(&mut random)
|
||||
|
||||
@@ -197,6 +197,51 @@ impl PendingAuthorization {
|
||||
return Err(rejection.into());
|
||||
}
|
||||
let code = callback.code.ok_or(CallbackRejection::MissingCode)?;
|
||||
self.accept_code(code)
|
||||
}
|
||||
|
||||
/// 消费一次性流程,并接受管理员粘贴的完整回调 URL、回调 query 或裸授权码。
|
||||
///
|
||||
/// 完整 URL 仍须精确匹配本流程的 redirect URI;query 输入仍须回传正确 state。
|
||||
/// 裸授权码依赖已经绑定 owner 的一次性 flow 与 PKCE verifier,不降低 token 交换约束。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// URL、state 或授权码不满足当前一次性流程约束时返回回调拒绝错误。
|
||||
pub fn accept_authorization_input(
|
||||
self,
|
||||
input: &str,
|
||||
) -> Result<AuthorizationCodeGrant, OAuthError> {
|
||||
let input = input.trim();
|
||||
if let Ok(mut callback_url) = Url::parse(input)
|
||||
&& matches!(callback_url.scheme(), "http" | "https")
|
||||
&& callback_url.has_host()
|
||||
{
|
||||
if callback_url.fragment().is_some() {
|
||||
return Err(CallbackRejection::ProviderRejected.into());
|
||||
}
|
||||
let query = callback_url.query().unwrap_or_default().to_owned();
|
||||
callback_url.set_query(None);
|
||||
if &callback_url != self.redirect_uri.as_url() {
|
||||
return Err(CallbackRejection::ProviderRejected.into());
|
||||
}
|
||||
let callback = AuthorizationCallback::parse(&query)?;
|
||||
return self.accept_callback(callback);
|
||||
}
|
||||
|
||||
if looks_like_callback_url(input) {
|
||||
return Err(CallbackRejection::ProviderRejected.into());
|
||||
}
|
||||
|
||||
if looks_like_callback_query(input) {
|
||||
let callback = AuthorizationCallback::parse(input)?;
|
||||
return self.accept_callback(callback);
|
||||
}
|
||||
|
||||
self.accept_code(SecretValue::new(input.to_owned()))
|
||||
}
|
||||
|
||||
fn accept_code(self, code: SecretValue) -> Result<AuthorizationCodeGrant, OAuthError> {
|
||||
if code.is_empty()
|
||||
|| code.len() > MAX_CALLBACK_VALUE_BYTES
|
||||
|| code.expose().chars().any(char::is_control)
|
||||
@@ -213,6 +258,15 @@ impl PendingAuthorization {
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_callback_url(value: &str) -> bool {
|
||||
let value = value.to_ascii_lowercase();
|
||||
value.starts_with("http:") || value.starts_with("https:") || value.starts_with("//")
|
||||
}
|
||||
|
||||
fn looks_like_callback_query(value: &str) -> bool {
|
||||
value.starts_with('?') || value.starts_with("code=")
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PendingAuthorizationWire<'a> {
|
||||
schema_version: u32,
|
||||
|
||||
@@ -26,12 +26,17 @@ use crate::{
|
||||
SelectedGrokSession,
|
||||
};
|
||||
|
||||
const DEFAULT_RATE_LIMIT_COOLDOWN: Duration = Duration::from_secs(60);
|
||||
const DEFAULT_RATE_LIMIT_COOLDOWN: Duration = Duration::from_secs(2 * 60);
|
||||
const MAX_RATE_LIMIT_COOLDOWN: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const STREAM_INTERRUPTION_COOLDOWN: Duration = Duration::from_secs(30);
|
||||
const UNAUTHORIZED_COOLDOWN: Duration = Duration::from_secs(60);
|
||||
const UNAUTHORIZED_COOLDOWN: Duration = Duration::from_secs(10 * 60);
|
||||
const ACCESS_DENIED_COOLDOWN: Duration = Duration::from_secs(30 * 60);
|
||||
const PAYMENT_REQUIRED_COOLDOWN: Duration = Duration::from_secs(30 * 60);
|
||||
const MODEL_CAPACITY_COOLDOWN: Duration = Duration::from_secs(3 * 60);
|
||||
const EMPTY_UPSTREAM_COOLDOWN: Duration = Duration::from_secs(4 * 60);
|
||||
const UPSTREAM_UNAVAILABLE_COOLDOWN: Duration = Duration::from_secs(2 * 60);
|
||||
const MODEL_ACCESS_DENIED_COOLDOWN: Duration = Duration::from_secs(5 * 60);
|
||||
const MODEL_QUOTA_COOLDOWN: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const MODEL_QUOTA_COOLDOWN: Duration = Duration::from_secs(10 * 60);
|
||||
const MAX_MODEL_COOLDOWN: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -479,7 +484,7 @@ impl GrokSessionSelector for GrokAccountSessionSelector {
|
||||
// 不持久化 QuotaExhausted(结构化 QuotaExhausted/FreeQuotaExhausted 走上面)。
|
||||
GrokCredentialFailure::PaymentRequired { retry_after } => {
|
||||
let retry_after = retry_after
|
||||
.unwrap_or(DEFAULT_RATE_LIMIT_COOLDOWN)
|
||||
.unwrap_or(PAYMENT_REQUIRED_COOLDOWN)
|
||||
.min(MAX_RATE_LIMIT_COOLDOWN);
|
||||
self.record_runtime_cooldown(session, retry_after).await;
|
||||
}
|
||||
@@ -507,6 +512,31 @@ impl GrokSessionSelector for GrokAccountSessionSelector {
|
||||
self.record_model_runtime_cooldown(session, upstream_model, retry_after)
|
||||
.await;
|
||||
}
|
||||
GrokCredentialFailure::AccessDenied => {
|
||||
self.record_runtime_cooldown(session, ACCESS_DENIED_COOLDOWN)
|
||||
.await;
|
||||
}
|
||||
GrokCredentialFailure::ModelCapacity { upstream_model } => {
|
||||
if upstream_model.as_str().contains("multi-agent") {
|
||||
self.record_model_runtime_cooldown(
|
||||
session,
|
||||
upstream_model,
|
||||
MODEL_CAPACITY_COOLDOWN,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
self.record_runtime_cooldown(session, MODEL_CAPACITY_COOLDOWN)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
GrokCredentialFailure::EmptyUpstream => {
|
||||
self.record_runtime_cooldown(session, EMPTY_UPSTREAM_COOLDOWN)
|
||||
.await;
|
||||
}
|
||||
GrokCredentialFailure::UpstreamUnavailable => {
|
||||
self.record_runtime_cooldown(session, UPSTREAM_UNAVAILABLE_COOLDOWN)
|
||||
.await;
|
||||
}
|
||||
GrokCredentialFailure::RateLimited { retry_after } => {
|
||||
let retry_after = retry_after
|
||||
.unwrap_or(DEFAULT_RATE_LIMIT_COOLDOWN)
|
||||
|
||||
@@ -75,7 +75,8 @@ const RESPONSE_NOT_FOUND_CODE: &str = "not_found";
|
||||
|
||||
/// 官方 Grok Build Provider;会话选择与 HTTP SSE transport 均由外部注入。
|
||||
///
|
||||
/// 每次调用只选择一个 OAuth 会话,只准备一次可见的上游 POST。重试、凭据轮换、
|
||||
/// 每次调用只选择一个 OAuth 会话。仅当 xAI 明确拒绝历史 reasoning 密文时,
|
||||
/// 允许在同账号、同凭据、同会话绑定上剥离密文后有界重试一次。凭据轮换、
|
||||
/// endpoint fallback 以及公开 xAI API key 推理都不在该 adapter 内。
|
||||
pub struct GrokBuildProvider {
|
||||
selector: Arc<dyn GrokSessionSelector>,
|
||||
@@ -202,6 +203,13 @@ impl GrokBuildProvider {
|
||||
context.client_api_key_ref(),
|
||||
)
|
||||
.map_err(map_request_error)?;
|
||||
let wire_upstream_model = UpstreamModelId::new(
|
||||
upstream_request
|
||||
.upstream_model()
|
||||
.ok_or_else(protocol_not_sent)?
|
||||
.to_owned(),
|
||||
)
|
||||
.map_err(|_| protocol_not_sent())?;
|
||||
let request_input = upstream_request.input_items();
|
||||
if let Some(previous) = previous_session.as_ref() {
|
||||
upstream_request.inherit_session(previous.session_id.as_deref());
|
||||
@@ -209,6 +217,7 @@ impl GrokBuildProvider {
|
||||
let selected = select_grok_session(
|
||||
self.selector.as_ref(),
|
||||
candidate,
|
||||
&wire_upstream_model,
|
||||
&context,
|
||||
continuation_account,
|
||||
upstream_request.affinity().cloned(),
|
||||
@@ -237,7 +246,7 @@ impl GrokBuildProvider {
|
||||
.or(inherited_replay_session_id)
|
||||
.and_then(|session_id| {
|
||||
self.reasoning_replay.key(
|
||||
candidate.upstream_model().as_str(),
|
||||
wire_upstream_model.as_str(),
|
||||
session_id,
|
||||
selected.account_id().as_str(),
|
||||
)
|
||||
@@ -278,7 +287,7 @@ impl GrokBuildProvider {
|
||||
credential_recovery: Arc::clone(&self.credential_recovery),
|
||||
responses_url: self.responses_url.clone(),
|
||||
request: upstream_request,
|
||||
upstream_model: candidate.upstream_model().clone(),
|
||||
upstream_model: wire_upstream_model,
|
||||
context,
|
||||
session: Arc::clone(&selected),
|
||||
output_started_at,
|
||||
@@ -323,6 +332,13 @@ impl GrokBuildProvider {
|
||||
context.client_api_key_ref(),
|
||||
)
|
||||
.map_err(map_request_error)?;
|
||||
let wire_upstream_model = UpstreamModelId::new(
|
||||
upstream_request
|
||||
.upstream_model()
|
||||
.ok_or_else(protocol_not_sent)?
|
||||
.to_owned(),
|
||||
)
|
||||
.map_err(|_| protocol_not_sent())?;
|
||||
let explicit_replay_session_id = upstream_request
|
||||
.reasoning_replay_session_id()
|
||||
.map(str::to_owned);
|
||||
@@ -333,6 +349,7 @@ impl GrokBuildProvider {
|
||||
select_grok_session(
|
||||
self.selector.as_ref(),
|
||||
candidate,
|
||||
&wire_upstream_model,
|
||||
&context,
|
||||
operation_account,
|
||||
upstream_request.affinity().cloned(),
|
||||
@@ -344,7 +361,7 @@ impl GrokBuildProvider {
|
||||
.or(inherited_session_id.as_deref())
|
||||
.and_then(|session_id| {
|
||||
self.reasoning_replay.key(
|
||||
candidate.upstream_model().as_str(),
|
||||
wire_upstream_model.as_str(),
|
||||
session_id,
|
||||
selected.account_id().as_str(),
|
||||
)
|
||||
@@ -360,7 +377,7 @@ impl GrokBuildProvider {
|
||||
credential_recovery: Arc::clone(&self.credential_recovery),
|
||||
responses_url: self.responses_url.clone(),
|
||||
request: upstream_request,
|
||||
upstream_model: candidate.upstream_model().clone(),
|
||||
upstream_model: wire_upstream_model,
|
||||
upstream_session_id,
|
||||
context,
|
||||
session: Arc::clone(&selected),
|
||||
@@ -380,13 +397,14 @@ impl GrokBuildProvider {
|
||||
async fn select_grok_session(
|
||||
selector: &dyn GrokSessionSelector,
|
||||
candidate: &ProviderCandidate,
|
||||
wire_upstream_model: &UpstreamModelId,
|
||||
context: &AttemptContext,
|
||||
operation_account: Option<ProviderAccountId>,
|
||||
affinity: Option<GrokSessionAffinityKey>,
|
||||
) -> Result<SelectedGrokSession, ProviderError> {
|
||||
let required_account = context.required_account().cloned().or(operation_account);
|
||||
let selection = GrokSessionSelection::new(
|
||||
candidate.upstream_model().clone(),
|
||||
wire_upstream_model.clone(),
|
||||
context.excluded_accounts().clone(),
|
||||
required_account.clone(),
|
||||
context.account_selection_policy(),
|
||||
@@ -941,81 +959,6 @@ struct AcceptedGrokInference {
|
||||
observation: ProviderResponseObservation,
|
||||
}
|
||||
|
||||
struct GrokInferenceStartFailure {
|
||||
observation: Option<ProviderResponseObservation>,
|
||||
error: ProviderError,
|
||||
}
|
||||
|
||||
async fn start_grok_inference(
|
||||
selector: &dyn GrokSessionSelector,
|
||||
transport: &dyn GrokInferenceTransport,
|
||||
credential_recovery: &dyn GrokCredentialRecovery,
|
||||
request: GrokInferenceRequest,
|
||||
upstream_model: &UpstreamModelId,
|
||||
context: &AttemptContext,
|
||||
session: &SelectedGrokSession,
|
||||
) -> Result<AcceptedGrokInference, GrokInferenceStartFailure> {
|
||||
if context.cancellation().is_cancelled() {
|
||||
return Err(GrokInferenceStartFailure {
|
||||
observation: None,
|
||||
error: provider_error(ProviderErrorKind::Cancelled, UpstreamSendState::NotSent),
|
||||
});
|
||||
}
|
||||
let Some(handshake_deadline) = remaining(context.deadline()) else {
|
||||
return Err(GrokInferenceStartFailure {
|
||||
observation: None,
|
||||
error: provider_error(ProviderErrorKind::Timeout, UpstreamSendState::NotSent),
|
||||
});
|
||||
};
|
||||
let cancellation = context.cancellation().clone();
|
||||
let boundary = tokio::select! {
|
||||
biased;
|
||||
_ = cancellation.cancelled() => InferenceBoundary::Cancelled,
|
||||
_ = tokio::time::sleep(handshake_deadline) => InferenceBoundary::Deadline,
|
||||
response = transport.execute(request) => InferenceBoundary::Response(response),
|
||||
};
|
||||
let response = match boundary {
|
||||
InferenceBoundary::Cancelled => {
|
||||
return Err(GrokInferenceStartFailure {
|
||||
observation: None,
|
||||
error: provider_error(ProviderErrorKind::Cancelled, UpstreamSendState::Ambiguous),
|
||||
});
|
||||
}
|
||||
InferenceBoundary::Deadline => {
|
||||
return Err(GrokInferenceStartFailure {
|
||||
observation: None,
|
||||
error: provider_error(ProviderErrorKind::Timeout, UpstreamSendState::Ambiguous),
|
||||
});
|
||||
}
|
||||
InferenceBoundary::Response(Ok(response)) => response,
|
||||
InferenceBoundary::Response(Err(error)) => {
|
||||
let observation = xai_error_observation(&error).ok();
|
||||
let credential_failure = transport_credential_failure(&error, upstream_model);
|
||||
let error =
|
||||
map_continuation_failure(context, map_transport_error_for_context(error, context));
|
||||
let error = recover_or_record_failure(
|
||||
selector,
|
||||
credential_recovery,
|
||||
session,
|
||||
error,
|
||||
credential_failure,
|
||||
context.credential_recovery_attempted(),
|
||||
)
|
||||
.await;
|
||||
return Err(GrokInferenceStartFailure { observation, error });
|
||||
}
|
||||
};
|
||||
let observation =
|
||||
xai_response_observation(&response).map_err(|error| GrokInferenceStartFailure {
|
||||
observation: None,
|
||||
error,
|
||||
})?;
|
||||
Ok(AcceptedGrokInference {
|
||||
response,
|
||||
observation,
|
||||
})
|
||||
}
|
||||
|
||||
async fn next_grok_chunk(
|
||||
body: &mut GrokInferenceChunkStream,
|
||||
selector: &dyn GrokSessionSelector,
|
||||
@@ -1063,7 +1006,7 @@ fn cold_compaction_http_sse_stream(
|
||||
wire_profile,
|
||||
credential_recovery,
|
||||
responses_url,
|
||||
request,
|
||||
mut request,
|
||||
upstream_model,
|
||||
upstream_session_id,
|
||||
context,
|
||||
@@ -1081,32 +1024,90 @@ fn cold_compaction_http_sse_stream(
|
||||
None,
|
||||
&upstream_model,
|
||||
);
|
||||
let body = request.to_json_bytes().map_err(map_request_error)?;
|
||||
let inference_request = GrokInferenceRequest::new(
|
||||
responses_url,
|
||||
headers,
|
||||
body,
|
||||
session.binding().clone(),
|
||||
);
|
||||
let accepted = match start_grok_inference(
|
||||
selector.as_ref(),
|
||||
transport.as_ref(),
|
||||
credential_recovery.as_ref(),
|
||||
inference_request,
|
||||
&upstream_model,
|
||||
&context,
|
||||
&session,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(accepted) => accepted,
|
||||
Err(failure) => {
|
||||
if let Some(observation) = failure.observation {
|
||||
yield ProviderEvent::observation(observation);
|
||||
}
|
||||
Err(mark_transient_compaction_failure(failure.error))?;
|
||||
let mut invalid_encrypted_content_retried = false;
|
||||
let accepted = loop {
|
||||
if context.cancellation().is_cancelled() {
|
||||
Err(provider_error(
|
||||
ProviderErrorKind::Cancelled,
|
||||
UpstreamSendState::NotSent,
|
||||
))?;
|
||||
return;
|
||||
}
|
||||
let body = request.to_json_bytes().map_err(map_request_error)?;
|
||||
let inference_request = GrokInferenceRequest::new(
|
||||
responses_url.clone(),
|
||||
headers.clone(),
|
||||
body,
|
||||
session.binding().clone(),
|
||||
);
|
||||
let Some(handshake_deadline) = remaining(context.deadline()) else {
|
||||
Err(mark_transient_compaction_failure(provider_error(
|
||||
ProviderErrorKind::Timeout,
|
||||
UpstreamSendState::NotSent,
|
||||
)))?;
|
||||
return;
|
||||
};
|
||||
let cancellation = context.cancellation().clone();
|
||||
let boundary = tokio::select! {
|
||||
biased;
|
||||
_ = cancellation.cancelled() => InferenceBoundary::Cancelled,
|
||||
_ = tokio::time::sleep(handshake_deadline) => InferenceBoundary::Deadline,
|
||||
response = transport.execute(inference_request) => InferenceBoundary::Response(response),
|
||||
};
|
||||
match boundary {
|
||||
InferenceBoundary::Cancelled => {
|
||||
Err(provider_error(
|
||||
ProviderErrorKind::Cancelled,
|
||||
UpstreamSendState::Ambiguous,
|
||||
))?;
|
||||
return;
|
||||
}
|
||||
InferenceBoundary::Deadline => {
|
||||
Err(mark_transient_compaction_failure(provider_error(
|
||||
ProviderErrorKind::Timeout,
|
||||
UpstreamSendState::Ambiguous,
|
||||
)))?;
|
||||
return;
|
||||
}
|
||||
InferenceBoundary::Response(Ok(response)) => {
|
||||
let observation = xai_response_observation(&response)
|
||||
.map_err(mark_transient_compaction_failure)?;
|
||||
break AcceptedGrokInference {
|
||||
response,
|
||||
observation,
|
||||
};
|
||||
}
|
||||
InferenceBoundary::Response(Err(error)) => {
|
||||
if !invalid_encrypted_content_retried
|
||||
&& is_invalid_encrypted_content_failure(&error)
|
||||
&& request.strip_invalid_encrypted_reasoning()
|
||||
{
|
||||
invalid_encrypted_content_retried = true;
|
||||
continue;
|
||||
}
|
||||
let observation = xai_error_observation(&error).ok();
|
||||
let credential_failure =
|
||||
transport_credential_failure(&error, &upstream_model);
|
||||
let error = map_continuation_failure(
|
||||
&context,
|
||||
map_transport_error_for_context(error, &context),
|
||||
);
|
||||
let error = recover_or_record_failure(
|
||||
selector.as_ref(),
|
||||
credential_recovery.as_ref(),
|
||||
&session,
|
||||
error,
|
||||
credential_failure,
|
||||
context.credential_recovery_attempted(),
|
||||
)
|
||||
.await;
|
||||
if let Some(observation) = observation {
|
||||
yield ProviderEvent::observation(observation);
|
||||
}
|
||||
Err(mark_transient_compaction_failure(error))?;
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
yield ProviderEvent::observation(accepted.observation);
|
||||
|
||||
@@ -1174,22 +1175,27 @@ fn cold_compaction_http_sse_stream(
|
||||
}
|
||||
}
|
||||
|
||||
let summary = summary.finish().map_err(map_compaction_decode_error)?;
|
||||
let started = facts.started.ok_or_else(protocol_sent)?;
|
||||
let upstream_completed = facts.completed.is_some();
|
||||
let completed = facts.completed.unwrap_or_else(|| started.clone());
|
||||
let started = facts
|
||||
.started
|
||||
.ok_or_else(|| mark_transient_compaction_failure(protocol_sent()))?;
|
||||
let completed = facts
|
||||
.completed
|
||||
.ok_or_else(|| mark_transient_compaction_failure(protocol_sent()))?;
|
||||
let (summary, encrypted_content) = summary
|
||||
.finish_with_encrypted_content()
|
||||
.map_err(map_compaction_decode_error)?;
|
||||
let (created, output_done, terminal) = crate::transport::compaction::compaction_wire_events(
|
||||
&started,
|
||||
&completed,
|
||||
&summary,
|
||||
summary.as_deref(),
|
||||
&encrypted_content,
|
||||
facts.created_response.as_ref(),
|
||||
facts.terminal_response.as_ref(),
|
||||
facts.terminal_was_incomplete,
|
||||
)
|
||||
.map_err(|_| protocol_sent())?
|
||||
.map_err(|_| mark_transient_compaction_failure(protocol_sent()))?
|
||||
.into_parts();
|
||||
ensure_sent_context(&context)?;
|
||||
if upstream_completed && session.allows_account_state_mutation() {
|
||||
if session.allows_account_state_mutation() {
|
||||
selector.record_success(&session).await;
|
||||
}
|
||||
if let Some(key) = reasoning_replay_key.as_ref() {
|
||||
@@ -1210,7 +1216,6 @@ struct CompactionFacts {
|
||||
accounting: Vec<GatewayEvent>,
|
||||
created_response: Option<Value>,
|
||||
terminal_response: Option<Value>,
|
||||
terminal_was_incomplete: bool,
|
||||
}
|
||||
|
||||
impl CompactionFacts {
|
||||
@@ -1249,7 +1254,6 @@ impl CompactionFacts {
|
||||
Some("response.completed" | "response.incomplete")
|
||||
if self.terminal_response.is_none() =>
|
||||
{
|
||||
self.terminal_was_incomplete = event_type == Some("response.incomplete");
|
||||
self.terminal_response = response;
|
||||
}
|
||||
_ => {}
|
||||
@@ -1259,7 +1263,9 @@ impl CompactionFacts {
|
||||
|
||||
fn map_compaction_decode_error(error: GrokCompactionDecodeError) -> ProviderError {
|
||||
match error {
|
||||
GrokCompactionDecodeError::Degenerate => mark_transient_compaction_failure(protocol_sent()),
|
||||
GrokCompactionDecodeError::MissingEncryptedContent => {
|
||||
mark_transient_compaction_failure(protocol_sent())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1288,7 +1294,7 @@ fn cold_http_sse_stream(
|
||||
wire_profile,
|
||||
credential_recovery,
|
||||
responses_url,
|
||||
request,
|
||||
mut request,
|
||||
upstream_model,
|
||||
context,
|
||||
session,
|
||||
@@ -1312,54 +1318,69 @@ fn cold_http_sse_stream(
|
||||
None,
|
||||
&upstream_model,
|
||||
);
|
||||
let body = request.to_json_bytes().map_err(map_request_error)?;
|
||||
let inference_request = GrokInferenceRequest::new(
|
||||
responses_url,
|
||||
headers,
|
||||
body,
|
||||
session.binding().clone(),
|
||||
);
|
||||
let Some(handshake_deadline) = remaining(context.deadline()) else {
|
||||
Err(provider_error(
|
||||
ProviderErrorKind::Timeout,
|
||||
UpstreamSendState::NotSent,
|
||||
))?;
|
||||
return;
|
||||
};
|
||||
let cancellation = context.cancellation().clone();
|
||||
let boundary = tokio::select! {
|
||||
biased;
|
||||
_ = cancellation.cancelled() => InferenceBoundary::Cancelled,
|
||||
_ = tokio::time::sleep(handshake_deadline) => InferenceBoundary::Deadline,
|
||||
response = transport.execute(inference_request) => InferenceBoundary::Response(response),
|
||||
};
|
||||
let response = match boundary {
|
||||
InferenceBoundary::Cancelled => {
|
||||
Err(provider_error(ProviderErrorKind::Cancelled, UpstreamSendState::Ambiguous))?;
|
||||
return;
|
||||
}
|
||||
InferenceBoundary::Deadline => {
|
||||
Err(provider_error(ProviderErrorKind::Timeout, UpstreamSendState::Ambiguous))?;
|
||||
return;
|
||||
}
|
||||
InferenceBoundary::Response(Ok(response)) => response,
|
||||
InferenceBoundary::Response(Err(error)) => {
|
||||
let observation = xai_error_observation(&error)?;
|
||||
let credential_failure = transport_credential_failure(&error, &upstream_model);
|
||||
let error =
|
||||
map_continuation_failure(&context, map_transport_error_for_context(error, &context));
|
||||
let error = recover_or_record_failure(
|
||||
selector.as_ref(),
|
||||
credential_recovery.as_ref(),
|
||||
&session,
|
||||
error,
|
||||
credential_failure,
|
||||
context.credential_recovery_attempted(),
|
||||
)
|
||||
.await;
|
||||
yield ProviderEvent::observation(observation);
|
||||
Err(error)?;
|
||||
let mut invalid_encrypted_content_retried = false;
|
||||
let response = loop {
|
||||
let body = request.to_json_bytes().map_err(map_request_error)?;
|
||||
let inference_request = GrokInferenceRequest::new(
|
||||
responses_url.clone(),
|
||||
headers.clone(),
|
||||
body,
|
||||
session.binding().clone(),
|
||||
);
|
||||
let Some(handshake_deadline) = remaining(context.deadline()) else {
|
||||
Err(provider_error(
|
||||
ProviderErrorKind::Timeout,
|
||||
UpstreamSendState::NotSent,
|
||||
))?;
|
||||
return;
|
||||
};
|
||||
let boundary = tokio::select! {
|
||||
biased;
|
||||
_ = cancellation.cancelled() => InferenceBoundary::Cancelled,
|
||||
_ = tokio::time::sleep(handshake_deadline) => InferenceBoundary::Deadline,
|
||||
response = transport.execute(inference_request) => InferenceBoundary::Response(response),
|
||||
};
|
||||
match boundary {
|
||||
InferenceBoundary::Cancelled => {
|
||||
Err(provider_error(ProviderErrorKind::Cancelled, UpstreamSendState::Ambiguous))?;
|
||||
return;
|
||||
}
|
||||
InferenceBoundary::Deadline => {
|
||||
Err(provider_error(ProviderErrorKind::Timeout, UpstreamSendState::Ambiguous))?;
|
||||
return;
|
||||
}
|
||||
InferenceBoundary::Response(Ok(response)) => break response,
|
||||
InferenceBoundary::Response(Err(error)) => {
|
||||
if !invalid_encrypted_content_retried
|
||||
&& is_invalid_encrypted_content_failure(&error)
|
||||
&& request.strip_invalid_encrypted_reasoning()
|
||||
{
|
||||
invalid_encrypted_content_retried = true;
|
||||
if let Some(capture) = session_capture.as_mut() {
|
||||
capture.request_input = request.input_items();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let observation = xai_error_observation(&error)?;
|
||||
let credential_failure = transport_credential_failure(&error, &upstream_model);
|
||||
let error = map_continuation_failure(
|
||||
&context,
|
||||
map_transport_error_for_context(error, &context),
|
||||
);
|
||||
let error = recover_or_record_failure(
|
||||
selector.as_ref(),
|
||||
credential_recovery.as_ref(),
|
||||
&session,
|
||||
error,
|
||||
credential_failure,
|
||||
context.credential_recovery_attempted(),
|
||||
)
|
||||
.await;
|
||||
yield ProviderEvent::observation(observation);
|
||||
Err(error)?;
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1498,6 +1519,17 @@ fn cold_http_sse_stream(
|
||||
})
|
||||
}
|
||||
|
||||
fn is_invalid_encrypted_content_failure(error: &GrokInferenceTransportError) -> bool {
|
||||
error.kind() == GrokInferenceTransportErrorKind::InvalidRequest
|
||||
&& error.status() == Some(400)
|
||||
&& error.upstream_code().is_some_and(|code| {
|
||||
matches!(
|
||||
code.as_str(),
|
||||
REASONING_DECODE_FAILED_CODE | "invalid_encrypted_content"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
enum InferenceBoundary {
|
||||
Response(Result<crate::transport::GrokInferenceResponse, GrokInferenceTransportError>),
|
||||
Cancelled,
|
||||
@@ -1689,6 +1721,14 @@ fn transport_credential_failure(
|
||||
) -> Option<GrokCredentialFailure> {
|
||||
match error.kind() {
|
||||
GrokInferenceTransportErrorKind::Unauthorized => Some(GrokCredentialFailure::Unauthorized),
|
||||
GrokInferenceTransportErrorKind::PermissionDenied => {
|
||||
Some(GrokCredentialFailure::AccessDenied)
|
||||
}
|
||||
GrokInferenceTransportErrorKind::RateLimited if model_capacity_failure(error) => {
|
||||
Some(GrokCredentialFailure::ModelCapacity {
|
||||
upstream_model: upstream_model.clone(),
|
||||
})
|
||||
}
|
||||
GrokInferenceTransportErrorKind::RateLimited => Some(GrokCredentialFailure::RateLimited {
|
||||
retry_after: error.retry_after(),
|
||||
}),
|
||||
@@ -1715,10 +1755,58 @@ fn transport_credential_failure(
|
||||
retry_after: error.retry_after(),
|
||||
})
|
||||
}
|
||||
GrokInferenceTransportErrorKind::Unavailable if empty_upstream_failure(error) => {
|
||||
Some(GrokCredentialFailure::EmptyUpstream)
|
||||
}
|
||||
GrokInferenceTransportErrorKind::Unavailable => {
|
||||
Some(GrokCredentialFailure::UpstreamUnavailable)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn model_capacity_failure(error: &GrokInferenceTransportError) -> bool {
|
||||
transport_error_contains_any(
|
||||
error,
|
||||
&[
|
||||
"model_capacity",
|
||||
"capacity",
|
||||
"overloaded",
|
||||
"server_busy",
|
||||
"too many concurrent",
|
||||
"engine_overloaded",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn empty_upstream_failure(error: &GrokInferenceTransportError) -> bool {
|
||||
transport_error_contains_any(
|
||||
error,
|
||||
&[
|
||||
"empty_upstream",
|
||||
"empty model output",
|
||||
"no content/tool_calls",
|
||||
"no client-visible content",
|
||||
"empty upstream",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn transport_error_contains_any(error: &GrokInferenceTransportError, signals: &[&str]) -> bool {
|
||||
let code = error
|
||||
.upstream_code()
|
||||
.map(|code| code.as_str())
|
||||
.unwrap_or_default();
|
||||
let message = error
|
||||
.client_visible_upstream_error()
|
||||
.map(ClientVisibleUpstreamError::message)
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
signals
|
||||
.iter()
|
||||
.any(|signal| code.contains(signal) || message.contains(signal))
|
||||
}
|
||||
|
||||
async fn map_and_record_stream_transport_failure(
|
||||
selector: &dyn GrokSessionSelector,
|
||||
session: &SelectedGrokSession,
|
||||
@@ -1916,7 +2004,7 @@ fn map_transport_error_with_state(
|
||||
mapped = mapped.with_status(status);
|
||||
if allow_explicit_replay
|
||||
&& forced_send_state.is_none()
|
||||
&& explicit_rejection_is_replay_safe(transport_kind, status)
|
||||
&& explicit_rejection_is_replay_safe(&error, status)
|
||||
{
|
||||
mapped = mapped.with_replay_safe();
|
||||
}
|
||||
@@ -1942,26 +2030,24 @@ fn map_transport_error_with_state(
|
||||
mapped
|
||||
}
|
||||
|
||||
fn explicit_rejection_is_replay_safe(kind: GrokInferenceTransportErrorKind, status: u16) -> bool {
|
||||
matches!(
|
||||
(kind, status),
|
||||
(GrokInferenceTransportErrorKind::Unauthorized, 401)
|
||||
| (
|
||||
GrokInferenceTransportErrorKind::QuotaExhausted,
|
||||
402 | 403 | 429
|
||||
)
|
||||
| (
|
||||
GrokInferenceTransportErrorKind::FreeQuotaExhausted,
|
||||
402 | 403 | 429
|
||||
)
|
||||
| (
|
||||
GrokInferenceTransportErrorKind::ModelQuotaExhausted,
|
||||
402 | 403 | 429
|
||||
)
|
||||
| (GrokInferenceTransportErrorKind::PaymentRequired, 402)
|
||||
| (GrokInferenceTransportErrorKind::ModelAccessDenied, 403)
|
||||
| (GrokInferenceTransportErrorKind::RateLimited, 429)
|
||||
)
|
||||
fn explicit_rejection_is_replay_safe(error: &GrokInferenceTransportError, status: u16) -> bool {
|
||||
let kind = error.kind();
|
||||
if kind == GrokInferenceTransportErrorKind::SafetyRejected {
|
||||
return false;
|
||||
}
|
||||
if matches!(status, 401 | 402 | 403 | 405 | 429 | 529) || (500..=599).contains(&status) {
|
||||
return true;
|
||||
}
|
||||
status == 400
|
||||
&& (matches!(
|
||||
kind,
|
||||
GrokInferenceTransportErrorKind::QuotaExhausted
|
||||
| GrokInferenceTransportErrorKind::FreeQuotaExhausted
|
||||
| GrokInferenceTransportErrorKind::ModelQuotaExhausted
|
||||
| GrokInferenceTransportErrorKind::PaymentRequired
|
||||
| GrokInferenceTransportErrorKind::Unavailable
|
||||
) || (kind == GrokInferenceTransportErrorKind::RateLimited
|
||||
&& model_capacity_failure(error)))
|
||||
}
|
||||
|
||||
fn provider_error(kind: ProviderErrorKind, send_state: UpstreamSendState) -> ProviderError {
|
||||
@@ -1981,9 +2067,9 @@ const WORKER_LEASE_TTL: Duration = Duration::from_secs(15 * 60);
|
||||
const WORKER_LEASE_RENEWAL: Duration = Duration::from_secs(5 * 60);
|
||||
const OAUTH_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
|
||||
const QUOTA_CATALOG_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
||||
// grok2api 对 Free/未知额度耗尽采用 24 小时恢复探测;当前公共账号状态没有
|
||||
// 独立的 paid period-end 字段,因此 Build 账号统一使用同一保守下限。
|
||||
const EXHAUSTED_QUOTA_FALLBACK_RECHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
// rolling 24h 描述的是上游用量窗口,不代表从本次观测起封禁 24 小时;
|
||||
// 缺少可信 reset 时间时按 sub2api 的短周期探测策略恢复检查。
|
||||
const EXHAUSTED_QUOTA_FALLBACK_RECHECK_INTERVAL: Duration = Duration::from_secs(10 * 60);
|
||||
const EXHAUSTED_QUOTA_REFRESH_RETRY_INTERVAL: Duration = QUOTA_CATALOG_INTERVAL;
|
||||
const CLI_RELEASE_WORKER_OWNER: &str = "xai-cli-release";
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ use super::{classify_grok_quota_failure, scrub_account_fingerprints};
|
||||
|
||||
const CONTENTS_PER_OUTPUT: u32 = 1_024;
|
||||
const LONG_CONTEXT_THRESHOLD: u64 = 200_000;
|
||||
const GROK_PING_SSE_COMMENT: &[u8] = b": ping\n\n";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct TokenRates {
|
||||
@@ -198,27 +199,64 @@ impl GrokCanonicalDecoder {
|
||||
fn decode(&mut self, events: Vec<SseEvent>) -> Result<Vec<ProviderEvent>, ProviderError> {
|
||||
let mut output = Vec::new();
|
||||
for event in events {
|
||||
if event
|
||||
.event
|
||||
.as_deref()
|
||||
.is_some_and(|event_type| event_type.trim() == "response.doom_loop_check")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if event.data.trim() == "[DONE]" {
|
||||
if !self.completed {
|
||||
return Err(protocol_error_marker());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Ok(value) = serde_json::from_str::<Value>(&event.data) else {
|
||||
continue;
|
||||
};
|
||||
let body_type = value.get("type").and_then(Value::as_str);
|
||||
let Some(event_type) = body_type.or(event.event.as_deref()) else {
|
||||
let wire = ProtocolWireEvent::json_with_sse_metadata(
|
||||
let parsed = serde_json::from_str::<Value>(&event.data);
|
||||
if event
|
||||
.event
|
||||
.as_deref()
|
||||
.is_some_and(|event_type| event_type.trim() == "ping")
|
||||
&& parsed
|
||||
.as_ref()
|
||||
.ok()
|
||||
.and_then(|value| value.get("type").and_then(Value::as_str))
|
||||
.is_none_or(|event_type| event_type == "ping")
|
||||
{
|
||||
let wire = ProtocolWireEvent::raw_sse(
|
||||
"openai",
|
||||
event.event,
|
||||
value,
|
||||
event.id,
|
||||
event.retry,
|
||||
bytes::Bytes::from_static(GROK_PING_SSE_COMMENT),
|
||||
)
|
||||
.map_err(|_| protocol_error_marker())?;
|
||||
output.push(ProviderEvent::wire(wire));
|
||||
continue;
|
||||
}
|
||||
let Ok(value) = parsed else {
|
||||
continue;
|
||||
};
|
||||
let body_type = value.get("type").and_then(Value::as_str);
|
||||
if body_type == Some("response.doom_loop_check") {
|
||||
continue;
|
||||
}
|
||||
let Some(event_type) = body_type.or(event.event.as_deref()) else {
|
||||
let Ok(transformed) = self.response_transform.rewrite_stream_event("", value)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for (index, transformed) in transformed.into_iter().enumerate() {
|
||||
let mut value = transformed.into_value();
|
||||
self.response_transform.resequence_stream_value(&mut value);
|
||||
let wire = ProtocolWireEvent::json_with_sse_metadata(
|
||||
"openai",
|
||||
None,
|
||||
value,
|
||||
(index == 0).then(|| event.id.clone()).flatten(),
|
||||
(index == 0).then_some(event.retry).flatten(),
|
||||
)
|
||||
.map_err(|_| protocol_error_marker())?;
|
||||
output.push(ProviderEvent::wire(wire));
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let event_type = event_type.to_owned();
|
||||
// 无法转换的上游帧(多为畸形帧)直接丢弃,保持已开始的客户端流不中断。
|
||||
@@ -238,7 +276,8 @@ impl GrokCanonicalDecoder {
|
||||
transformed_type.as_str(),
|
||||
"response.created" | "response.in_progress" | "response.failed" | "error"
|
||||
);
|
||||
let value = transformed.into_value();
|
||||
let mut value = transformed.into_value();
|
||||
self.response_transform.resequence_stream_value(&mut value);
|
||||
let mut canonical = Vec::new();
|
||||
// 终态事件(completed/incomplete)fail-closed:用量/计费校验失败即断流。
|
||||
// 其余内容事件容忍字段校验失败——正常上游变体(空 delta、重复 index、
|
||||
@@ -611,7 +650,8 @@ impl GrokCanonicalDecoder {
|
||||
}
|
||||
|
||||
fn client_visible_event(event_type: &str) -> bool {
|
||||
event_type == "error" || event_type.starts_with("response.")
|
||||
event_type == "error"
|
||||
|| (event_type.starts_with("response.") && event_type != "response.doom_loop_check")
|
||||
}
|
||||
|
||||
fn response_object(value: &Value) -> Option<&Value> {
|
||||
|
||||
@@ -2,16 +2,15 @@ use std::fmt;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use gateway_core::error::IdentifierError;
|
||||
use gateway_core::event::{
|
||||
FinishReason, GatewayEvent, ProtocolWireEvent, ProviderEvent, ResponseMeta,
|
||||
};
|
||||
use gateway_core::event::{GatewayEvent, ProtocolWireEvent, ProviderEvent, ResponseMeta};
|
||||
use gateway_core::operation::GenerateRequest;
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
use serde_json::{Map, Value, json};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::request::strip_invalid_encrypted_reasoning_from_body;
|
||||
use super::{GrokRequestEncodeError, GrokResponsesRequest, GrokSessionAffinityKey};
|
||||
|
||||
const MIN_GROK_COMPACTION_SUMMARY_CHARS: usize = 500;
|
||||
// 来自 xAI grok-build 的 full-replace compaction 模板;当前协议没有额外的
|
||||
// `/compact <text>` 用户上下文,因此不保留上游模板中的占位符。
|
||||
const GROK_COMPACTION_PROMPT: &str = r#"Your task is to produce a faithful, concise summary of the conversation so far so that a successor assistant can continue the work seamlessly after the earlier turns are discarded. The successor will see the user's original query plus this summary. Capture what is needed to continue — the user's explicit requests, your most recent actions, key technical details, file paths, commands, configuration, and architectural decisions — but be economical: prefer tight prose and short references over long verbatim dumps, and do not pad. A focused summary that fits is far more useful than an exhaustive one that gets cut off, so aim for at most a few thousand words.
|
||||
@@ -68,27 +67,16 @@ impl GrokCompactionRequest {
|
||||
input.push(summary_prompt_item());
|
||||
body.insert("input".to_owned(), Value::Array(input));
|
||||
|
||||
for field in [
|
||||
"background",
|
||||
"include",
|
||||
"max_output_tokens",
|
||||
"parallel_tool_calls",
|
||||
"previous_response_id",
|
||||
"prompt_cache_key",
|
||||
"response_format",
|
||||
"service_tier",
|
||||
"text",
|
||||
"truncation",
|
||||
] {
|
||||
body.remove(field);
|
||||
}
|
||||
body.insert("temperature".to_owned(), json!(1.0));
|
||||
// 压缩请求已经携带规范化后的完整历史,不能让账号派生的缓存身份
|
||||
// 把它重新变成依赖原账号状态的原生续接请求。
|
||||
body.remove("prompt_cache_key");
|
||||
body.insert("include".to_owned(), json!(["reasoning.encrypted_content"]));
|
||||
if body
|
||||
.get("tools")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|tools| !tools.is_empty())
|
||||
{
|
||||
body.insert("tool_choice".to_owned(), Value::String("auto".to_owned()));
|
||||
body.insert("tool_choice".to_owned(), Value::String("none".to_owned()));
|
||||
} else {
|
||||
body.remove("tool_choice");
|
||||
}
|
||||
@@ -120,6 +108,11 @@ impl GrokCompactionRequest {
|
||||
self.reasoning_replay_session_id.as_deref()
|
||||
}
|
||||
|
||||
/// 返回经 sub2api 别名表归一化后的 xAI wire 模型。
|
||||
pub(crate) fn upstream_model(&self) -> Option<&str> {
|
||||
self.body.get("model").and_then(Value::as_str)
|
||||
}
|
||||
|
||||
/// 序列化上游请求正文。
|
||||
///
|
||||
/// # Errors
|
||||
@@ -128,6 +121,11 @@ impl GrokCompactionRequest {
|
||||
pub fn to_json_bytes(&self) -> Result<Vec<u8>, GrokRequestEncodeError> {
|
||||
serde_json::to_vec(&self.body).map_err(|_| GrokRequestEncodeError::Serialization)
|
||||
}
|
||||
|
||||
/// 为同账号的一次 `invalid_encrypted_content` 恢复请求剥离被拒绝密文。
|
||||
pub(crate) fn strip_invalid_encrypted_reasoning(&mut self) -> bool {
|
||||
strip_invalid_encrypted_reasoning_from_body(&mut self.body)
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回 xAI 专用全历史压缩是否由客户端请求。
|
||||
@@ -166,6 +164,8 @@ impl fmt::Debug for GrokCompactionRequest {
|
||||
#[derive(Default)]
|
||||
pub struct GrokCompactionSummaryDecoder {
|
||||
text: String,
|
||||
terminal_text: Option<String>,
|
||||
encrypted_content: Option<String>,
|
||||
}
|
||||
|
||||
impl GrokCompactionSummaryDecoder {
|
||||
@@ -174,6 +174,8 @@ impl GrokCompactionSummaryDecoder {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
text: String::new(),
|
||||
terminal_text: None,
|
||||
encrypted_content: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,20 +190,48 @@ impl GrokCompactionSummaryDecoder {
|
||||
self.text.push_str(&delta.text);
|
||||
}
|
||||
}
|
||||
if let Some(wire) = event.wire_event().filter(|wire| wire.has_json_data()) {
|
||||
capture_encrypted_reasoning(wire.data(), &mut self.encrypted_content);
|
||||
capture_completed_summary(wire.data(), &mut self.terminal_text);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 完成摘要解码并返回清理后的摘要文本。
|
||||
/// 完成摘要解码并返回上游摘要文本。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 摘要为空或过短时返回错误。
|
||||
/// 保留此 `Result` 签名以稳定 decoder contract。
|
||||
pub fn finish(self) -> Result<String, GrokCompactionDecodeError> {
|
||||
let summary = clean_summary(&self.text);
|
||||
if summary.is_empty() || summary.chars().count() < MIN_GROK_COMPACTION_SUMMARY_CHARS {
|
||||
return Err(GrokCompactionDecodeError::Degenerate);
|
||||
Ok(self.summary_text().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// 完成摘要解码,并返回可见摘要与 xAI 真实 reasoning 密文。
|
||||
pub(crate) fn finish_with_encrypted_content(
|
||||
self,
|
||||
) -> Result<(Option<String>, String), GrokCompactionDecodeError> {
|
||||
let summary = self.summary_text();
|
||||
let encrypted_content = self
|
||||
.encrypted_content
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or(GrokCompactionDecodeError::MissingEncryptedContent)?;
|
||||
Ok((summary, encrypted_content))
|
||||
}
|
||||
|
||||
fn summary_text(&self) -> Option<String> {
|
||||
if let Some(text) = self
|
||||
.terminal_text
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|text| !text.is_empty())
|
||||
{
|
||||
return Some(text.to_owned());
|
||||
}
|
||||
Ok(summary)
|
||||
let text = self.text.trim();
|
||||
if !text.is_empty() {
|
||||
return Some(text.to_owned());
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +240,8 @@ impl fmt::Debug for GrokCompactionSummaryDecoder {
|
||||
formatter
|
||||
.debug_struct("GrokCompactionSummaryDecoder")
|
||||
.field("summary_bytes", &self.text.len())
|
||||
.field("has_terminal_summary", &self.terminal_text.is_some())
|
||||
.field("has_encrypted_content", &self.encrypted_content.is_some())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -217,8 +249,8 @@ impl fmt::Debug for GrokCompactionSummaryDecoder {
|
||||
/// Grok 摘要响应不满足压缩 contract。
|
||||
#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GrokCompactionDecodeError {
|
||||
#[error("Grok compaction response summary was too short")]
|
||||
Degenerate,
|
||||
#[error("Grok compaction response omitted reasoning.encrypted_content")]
|
||||
MissingEncryptedContent,
|
||||
}
|
||||
|
||||
pub(crate) struct GrokCompactionWireEvents {
|
||||
@@ -240,10 +272,10 @@ impl GrokCompactionWireEvents {
|
||||
pub(crate) fn compaction_wire_events(
|
||||
started: &ResponseMeta,
|
||||
completed: &ResponseMeta,
|
||||
summary: &str,
|
||||
summary: Option<&str>,
|
||||
encrypted_content: &str,
|
||||
created_source: Option<&Value>,
|
||||
terminal_source: Option<&Value>,
|
||||
terminal_was_incomplete: bool,
|
||||
) -> Result<GrokCompactionWireEvents, IdentifierError> {
|
||||
let created_response = compaction_response(
|
||||
started,
|
||||
@@ -253,17 +285,14 @@ pub(crate) fn compaction_wire_events(
|
||||
Vec::new(),
|
||||
Value::Null,
|
||||
);
|
||||
let item = compaction_item(summary);
|
||||
let (status, terminal_event, incomplete_details) = terminal_shape(
|
||||
completed.finish_reason(),
|
||||
terminal_was_incomplete,
|
||||
terminal_source,
|
||||
);
|
||||
let item = compaction_item(summary, encrypted_content);
|
||||
// sub2api 的 compact contract 以真实密文为成功依据,不向客户端延续
|
||||
// 摘要生成阶段的 incomplete 状态。
|
||||
let terminal_response = compaction_response(
|
||||
completed,
|
||||
terminal_source.or(created_source),
|
||||
status,
|
||||
incomplete_details,
|
||||
"completed",
|
||||
Value::Null,
|
||||
vec![item.clone()],
|
||||
terminal_source
|
||||
.and_then(|source| source.get("usage"))
|
||||
@@ -294,9 +323,9 @@ pub(crate) fn compaction_wire_events(
|
||||
)?,
|
||||
terminal: ProtocolWireEvent::json_with_sse_metadata(
|
||||
"openai",
|
||||
Some(terminal_event.to_owned()),
|
||||
Some("response.completed".to_owned()),
|
||||
json!({
|
||||
"type": terminal_event,
|
||||
"type": "response.completed",
|
||||
"response": terminal_response,
|
||||
}),
|
||||
None,
|
||||
@@ -333,38 +362,96 @@ fn compaction_response(
|
||||
response.insert("model".to_owned(), Value::String(meta.model().to_owned()));
|
||||
response.insert("output".to_owned(), Value::Array(output));
|
||||
response.insert("usage".to_owned(), usage);
|
||||
response.remove("output_text");
|
||||
Value::Object(response)
|
||||
}
|
||||
|
||||
fn compaction_item(summary: &str) -> Value {
|
||||
json!({
|
||||
"type": "compaction",
|
||||
"encrypted_content": summary,
|
||||
})
|
||||
fn compaction_item(summary: Option<&str>, encrypted_content: &str) -> Value {
|
||||
let mut item = Map::from_iter([
|
||||
(
|
||||
"id".to_owned(),
|
||||
Value::String(format!("cmp_{}", Uuid::new_v4().simple())),
|
||||
),
|
||||
("type".to_owned(), Value::String("compaction".to_owned())),
|
||||
("status".to_owned(), Value::String("completed".to_owned())),
|
||||
(
|
||||
"encrypted_content".to_owned(),
|
||||
Value::String(encrypted_content.to_owned()),
|
||||
),
|
||||
]);
|
||||
if let Some(summary) = summary.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
item.insert(
|
||||
"summary".to_owned(),
|
||||
json!([{
|
||||
"type": "summary_text",
|
||||
"text": summary,
|
||||
}]),
|
||||
);
|
||||
}
|
||||
Value::Object(item)
|
||||
}
|
||||
|
||||
fn terminal_shape(
|
||||
finish_reason: Option<FinishReason>,
|
||||
terminal_was_incomplete: bool,
|
||||
terminal_source: Option<&Value>,
|
||||
) -> (&'static str, &'static str, Value) {
|
||||
let incomplete = terminal_was_incomplete
|
||||
|| matches!(
|
||||
finish_reason,
|
||||
Some(FinishReason::Length | FinishReason::ContentFilter)
|
||||
);
|
||||
if !incomplete {
|
||||
return ("completed", "response.completed", Value::Null);
|
||||
fn capture_encrypted_reasoning(value: &Value, captured: &mut Option<String>) {
|
||||
if let Some(item) = value.get("item") {
|
||||
capture_encrypted_reasoning_item(item, captured);
|
||||
}
|
||||
for output in [value.get("output"), value.pointer("/response/output")]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_array)
|
||||
{
|
||||
for item in output {
|
||||
capture_encrypted_reasoning_item(item, captured);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn capture_encrypted_reasoning_item(item: &Value, captured: &mut Option<String>) {
|
||||
let Some(item) = item.as_object() else {
|
||||
return;
|
||||
};
|
||||
if item.get("type").and_then(Value::as_str) != Some("reasoning") {
|
||||
return;
|
||||
}
|
||||
if let Some(encrypted_content) = item
|
||||
.get("encrypted_content")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
*captured = Some(encrypted_content.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
fn capture_completed_summary(value: &Value, captured: &mut Option<String>) {
|
||||
let mut parts = Vec::new();
|
||||
for output in [value.get("output"), value.pointer("/response/output")]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_array)
|
||||
{
|
||||
for item in output {
|
||||
let Some(item) = item.as_object() else {
|
||||
continue;
|
||||
};
|
||||
if item.get("type").and_then(Value::as_str) != Some("message") {
|
||||
continue;
|
||||
}
|
||||
let Some(content) = item.get("content").and_then(Value::as_array) else {
|
||||
continue;
|
||||
};
|
||||
parts.extend(content.iter().filter_map(|part| {
|
||||
part.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(str::to_owned)
|
||||
}));
|
||||
}
|
||||
}
|
||||
if !parts.is_empty() {
|
||||
*captured = Some(parts.join("\n"));
|
||||
}
|
||||
let details = terminal_source
|
||||
.and_then(|source| source.get("incomplete_details"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| match finish_reason {
|
||||
Some(FinishReason::Length) => json!({ "reason": "max_output_tokens" }),
|
||||
Some(FinishReason::ContentFilter) => json!({ "reason": "content_filter" }),
|
||||
_ => json!({ "reason": "other" }),
|
||||
});
|
||||
("incomplete", "response.incomplete", details)
|
||||
}
|
||||
|
||||
fn unix_seconds() -> u64 {
|
||||
@@ -384,71 +471,3 @@ fn summary_prompt_item() -> Value {
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
fn clean_summary(raw: &str) -> String {
|
||||
let mut summary = raw.to_owned();
|
||||
while let Some(start) = summary.find("<analysis>") {
|
||||
let leading = match summary.find("<summary>") {
|
||||
Some(summary_start) => {
|
||||
start < summary_start
|
||||
|| summary[summary_start + "<summary>".len()..start]
|
||||
.trim()
|
||||
.is_empty()
|
||||
}
|
||||
None => summary[..start].trim().is_empty(),
|
||||
};
|
||||
if !leading {
|
||||
break;
|
||||
}
|
||||
if let Some(relative_end) = summary[start..].find("</analysis>") {
|
||||
let end = start + relative_end + "</analysis>".len();
|
||||
summary.replace_range(start..end, "");
|
||||
} else {
|
||||
let drop_end = summary[start..]
|
||||
.find("<summary>")
|
||||
.map_or(summary.len(), |relative| start + relative);
|
||||
summary.replace_range(start..drop_end, "");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(start) = summary.find("<summary>")
|
||||
&& let Some(end) = summary.rfind("</summary>")
|
||||
&& end > start
|
||||
{
|
||||
let before = summary[..start].to_owned();
|
||||
let after = summary[end + "</summary>".len()..].to_owned();
|
||||
let inner = strip_leading_scratchpad(summary[start + "<summary>".len()..end].trim());
|
||||
summary = format!("{before}Summary:\n{inner}{after}");
|
||||
}
|
||||
|
||||
summary = neutralize_compaction_control_tokens(&summary);
|
||||
while summary.contains("\n\n\n") {
|
||||
summary = summary.replace("\n\n\n", "\n\n");
|
||||
}
|
||||
summary.trim().to_owned()
|
||||
}
|
||||
|
||||
fn strip_leading_scratchpad(inner: &str) -> String {
|
||||
let mut summary = inner.trim();
|
||||
let lead = summary.trim_start_matches(['#', '*', '-', '>', ' ', '\t']);
|
||||
if !lead.starts_with(|character: char| character.is_ascii_digit())
|
||||
&& let Some(end) = summary.rfind("</analysis>")
|
||||
{
|
||||
summary = summary[end + "</analysis>".len()..].trim_start();
|
||||
}
|
||||
if let Some(inner) = summary.strip_prefix("<summary>") {
|
||||
summary = inner.trim_start();
|
||||
}
|
||||
summary.to_owned()
|
||||
}
|
||||
|
||||
fn neutralize_compaction_control_tokens(summary: &str) -> String {
|
||||
summary
|
||||
.replace("</summary>", "<\u{200b}/summary>")
|
||||
.replace("<summary>", "<\u{200b}summary>")
|
||||
.replace("</analysis>", "<\u{200b}/analysis>")
|
||||
.replace("<analysis>", "<\u{200b}analysis>")
|
||||
.replace("</summary_request>", "<\u{200b}/summary_request>")
|
||||
.replace("<summary_request>", "<\u{200b}summary_request>")
|
||||
}
|
||||
|
||||
@@ -61,8 +61,26 @@ const FREE_USAGE_SIGNALS: &[&str] = &[
|
||||
"subscription_free_usage_exhausted",
|
||||
"free-usage-exhausted",
|
||||
"free_usage_exhausted",
|
||||
"usage-limit-exceeded",
|
||||
"usage_limit_exceeded",
|
||||
"free-tier-limit",
|
||||
"free_tier_limit",
|
||||
"used all the included free usage",
|
||||
"used all your free usage",
|
||||
"free quota",
|
||||
"no remaining free",
|
||||
"out of free",
|
||||
"usage resets over a rolling",
|
||||
"额度耗尽",
|
||||
"额度用完",
|
||||
"额度不足",
|
||||
"额度已用尽",
|
||||
"免费额度",
|
||||
"免费用量",
|
||||
"用量耗尽",
|
||||
"配额耗尽",
|
||||
"配额不足",
|
||||
"模型额度",
|
||||
];
|
||||
const ACCOUNT_QUOTA_SIGNALS: &[&str] = &[
|
||||
"personal-team-blocked:spending-limit",
|
||||
|
||||
@@ -50,7 +50,7 @@ const HTTP2_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(15);
|
||||
const HTTP2_KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const MAX_ERROR_BODY_BYTES: usize = 64 * 1024;
|
||||
const MAX_INFERENCE_BODY_BYTES: usize = 256 * 1024 * 1024;
|
||||
const MAX_RETRY_AFTER_SECONDS: u64 = 120;
|
||||
const MAX_RETRY_AFTER_SECONDS: u64 = 24 * 60 * 60;
|
||||
const TRUSTED_DOH_HOST: &str = "dns.google";
|
||||
const TRUSTED_DOH_URL: &str = "https://dns.google/resolve";
|
||||
const MAX_DOH_RESPONSE_BYTES: usize = 64 * 1024;
|
||||
@@ -992,19 +992,32 @@ async fn classify_inference_status(response: Response) -> GrokInferenceTransport
|
||||
Ok(BoundedBody::TooLarge) | Err(_) => Vec::new(),
|
||||
};
|
||||
let metadata = inference_error_metadata(&body);
|
||||
let body_failure = classify_grok_body_failure(&metadata, &body);
|
||||
let kind = match status {
|
||||
StatusCode::BAD_REQUEST
|
||||
| StatusCode::NOT_FOUND
|
||||
| StatusCode::CONFLICT
|
||||
| StatusCode::UNPROCESSABLE_ENTITY => GrokInferenceTransportErrorKind::InvalidRequest,
|
||||
StatusCode::UNAUTHORIZED => GrokInferenceTransportErrorKind::Unauthorized,
|
||||
StatusCode::PAYMENT_REQUIRED => classify_payment_required(&metadata),
|
||||
StatusCode::FORBIDDEN => classify_forbidden(&metadata, &body),
|
||||
StatusCode::REQUEST_TIMEOUT | StatusCode::GATEWAY_TIMEOUT => {
|
||||
GrokInferenceTransportErrorKind::Timeout
|
||||
StatusCode::BAD_REQUEST => match body_failure {
|
||||
Some(GrokBodyFailure::RateLimit) | None => {
|
||||
GrokInferenceTransportErrorKind::InvalidRequest
|
||||
}
|
||||
Some(failure) => failure.transport_kind(),
|
||||
},
|
||||
StatusCode::NOT_FOUND | StatusCode::CONFLICT | StatusCode::UNPROCESSABLE_ENTITY => {
|
||||
GrokInferenceTransportErrorKind::InvalidRequest
|
||||
}
|
||||
StatusCode::TOO_MANY_REQUESTS => classify_too_many_requests(&metadata),
|
||||
status if status.is_server_error() => GrokInferenceTransportErrorKind::Unavailable,
|
||||
StatusCode::UNAUTHORIZED => GrokInferenceTransportErrorKind::Unauthorized,
|
||||
StatusCode::PAYMENT_REQUIRED => body_failure
|
||||
.map(GrokBodyFailure::transport_kind)
|
||||
.unwrap_or_else(|| classify_payment_required(&metadata)),
|
||||
StatusCode::FORBIDDEN => classify_forbidden(&metadata, &body, body_failure),
|
||||
StatusCode::REQUEST_TIMEOUT => GrokInferenceTransportErrorKind::Timeout,
|
||||
StatusCode::TOO_MANY_REQUESTS => body_failure
|
||||
.map(GrokBodyFailure::transport_kind)
|
||||
.unwrap_or_else(|| classify_too_many_requests(&metadata)),
|
||||
_ if status_code == 529 => body_failure
|
||||
.map(GrokBodyFailure::transport_kind)
|
||||
.unwrap_or(GrokInferenceTransportErrorKind::Unavailable),
|
||||
status if status.is_server_error() => body_failure
|
||||
.map(GrokBodyFailure::transport_kind)
|
||||
.unwrap_or(GrokInferenceTransportErrorKind::Unavailable),
|
||||
_ => GrokInferenceTransportErrorKind::Protocol,
|
||||
};
|
||||
let credential_recovery_required = kind == GrokInferenceTransportErrorKind::Unauthorized;
|
||||
@@ -1015,7 +1028,11 @@ async fn classify_inference_status(response: Response) -> GrokInferenceTransport
|
||||
let upstream_code = if status == StatusCode::BAD_REQUEST && reasoning_decode_failed(&metadata) {
|
||||
Some("reasoning_decode_failed".to_owned())
|
||||
} else {
|
||||
metadata.code.as_deref().and_then(normalize_failure_code)
|
||||
metadata
|
||||
.code
|
||||
.as_deref()
|
||||
.and_then(normalize_failure_code)
|
||||
.or_else(|| body_failure.map(GrokBodyFailure::marker).map(str::to_owned))
|
||||
};
|
||||
if let Some(message) = metadata.client_message.as_deref() {
|
||||
error = error.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
|
||||
@@ -1045,6 +1062,115 @@ struct InferenceErrorMetadata {
|
||||
client_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum GrokBodyFailure {
|
||||
Quota(GrokQuotaFailureKind),
|
||||
Billing,
|
||||
EmptyUpstream,
|
||||
ModelCapacity,
|
||||
RateLimit,
|
||||
}
|
||||
|
||||
impl GrokBodyFailure {
|
||||
fn transport_kind(self) -> GrokInferenceTransportErrorKind {
|
||||
match self {
|
||||
Self::Quota(failure) => quota_transport_error_kind(failure),
|
||||
Self::Billing => GrokInferenceTransportErrorKind::PaymentRequired,
|
||||
Self::EmptyUpstream => GrokInferenceTransportErrorKind::Unavailable,
|
||||
Self::ModelCapacity | Self::RateLimit => GrokInferenceTransportErrorKind::RateLimited,
|
||||
}
|
||||
}
|
||||
|
||||
const fn marker(self) -> &'static str {
|
||||
match self {
|
||||
Self::Quota(GrokQuotaFailureKind::Account) => "quota_exhausted",
|
||||
Self::Quota(GrokQuotaFailureKind::FreeAccount) => "free_usage_exhausted",
|
||||
Self::Quota(GrokQuotaFailureKind::FreeModelUsage) => "model_quota_exhausted",
|
||||
Self::Billing => "billing_quota",
|
||||
Self::EmptyUpstream => "empty_upstream",
|
||||
Self::ModelCapacity => "model_capacity",
|
||||
Self::RateLimit => "rate_limit",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_grok_body_failure(
|
||||
metadata: &InferenceErrorMetadata,
|
||||
body: &[u8],
|
||||
) -> Option<GrokBodyFailure> {
|
||||
let text = format!(
|
||||
"{} {}",
|
||||
inference_metadata_text(metadata),
|
||||
String::from_utf8_lossy(body).to_ascii_lowercase()
|
||||
);
|
||||
let quota_failure = classify_grok_quota_failure(
|
||||
metadata.code.as_deref(),
|
||||
metadata.error_type.as_deref(),
|
||||
Some(&text),
|
||||
);
|
||||
if let Some(
|
||||
failure @ (GrokQuotaFailureKind::FreeAccount | GrokQuotaFailureKind::FreeModelUsage),
|
||||
) = quota_failure
|
||||
{
|
||||
return Some(GrokBodyFailure::Quota(failure));
|
||||
}
|
||||
if contains_any(
|
||||
&text,
|
||||
&[
|
||||
"billing quota",
|
||||
"insufficient_quota",
|
||||
"payment required",
|
||||
"payment failed",
|
||||
"spending limit",
|
||||
"run out of credits",
|
||||
"out of credits",
|
||||
"余额不足",
|
||||
"欠费",
|
||||
"需要付费",
|
||||
],
|
||||
) {
|
||||
return Some(GrokBodyFailure::Billing);
|
||||
}
|
||||
if let Some(failure) = quota_failure {
|
||||
return Some(GrokBodyFailure::Quota(failure));
|
||||
}
|
||||
if contains_any(
|
||||
&text,
|
||||
&[
|
||||
"empty model output",
|
||||
"no content/tool_calls",
|
||||
"no client-visible content",
|
||||
"empty_upstream",
|
||||
"empty upstream",
|
||||
],
|
||||
) {
|
||||
return Some(GrokBodyFailure::EmptyUpstream);
|
||||
}
|
||||
if contains_any(
|
||||
&text,
|
||||
&[
|
||||
"capacity",
|
||||
"overloaded",
|
||||
"server_busy",
|
||||
"too many concurrent",
|
||||
"engine_overloaded",
|
||||
],
|
||||
) {
|
||||
return Some(GrokBodyFailure::ModelCapacity);
|
||||
}
|
||||
contains_any(
|
||||
&text,
|
||||
&[
|
||||
"rate limit",
|
||||
"rate_limit",
|
||||
"too many requests",
|
||||
"请求过于频繁",
|
||||
"速率限制",
|
||||
],
|
||||
)
|
||||
.then_some(GrokBodyFailure::RateLimit)
|
||||
}
|
||||
|
||||
fn inference_error_metadata(body: &[u8]) -> InferenceErrorMetadata {
|
||||
let Ok(value) = serde_json::from_slice::<Value>(body) else {
|
||||
let message = String::from_utf8_lossy(body).trim().to_owned();
|
||||
@@ -1091,13 +1217,18 @@ fn classify_payment_required(metadata: &InferenceErrorMetadata) -> GrokInference
|
||||
fn classify_forbidden(
|
||||
metadata: &InferenceErrorMetadata,
|
||||
body: &[u8],
|
||||
body_failure: Option<GrokBodyFailure>,
|
||||
) -> GrokInferenceTransportErrorKind {
|
||||
let text = inference_metadata_text(metadata);
|
||||
let body_text = String::from_utf8_lossy(body).to_ascii_lowercase();
|
||||
if safety_rejected(&text) || safety_rejected(&body_text) {
|
||||
if account_access_rejected(&text) || account_access_rejected(&body_text) {
|
||||
GrokInferenceTransportErrorKind::PermissionDenied
|
||||
} else if safety_rejected(&text) || safety_rejected(&body_text) {
|
||||
GrokInferenceTransportErrorKind::SafetyRejected
|
||||
} else if credential_rejected(&text) {
|
||||
GrokInferenceTransportErrorKind::Unauthorized
|
||||
} else if let Some(failure) = body_failure {
|
||||
failure.transport_kind()
|
||||
} else if let Some(failure) = quota_failure(metadata) {
|
||||
quota_transport_error_kind(failure)
|
||||
} else if model_access_denied(metadata) {
|
||||
@@ -1117,10 +1248,27 @@ fn classify_too_many_requests(
|
||||
}
|
||||
|
||||
fn reasoning_decode_failed(metadata: &InferenceErrorMetadata) -> bool {
|
||||
metadata.message.as_deref().is_some_and(|message| {
|
||||
message.contains("could not decode the compaction blob")
|
||||
|| message.contains("could not decrypt the provided encrypted_content")
|
||||
})
|
||||
let code = metadata.code.as_deref().map(str::trim).unwrap_or_default();
|
||||
let message = metadata
|
||||
.message
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if message.contains("could not decode the compaction blob") {
|
||||
return true;
|
||||
}
|
||||
if code.eq_ignore_ascii_case("invalid_encrypted_content") {
|
||||
return !message.is_empty();
|
||||
}
|
||||
if !code.is_empty() && !code.eq_ignore_ascii_case("invalid-argument") {
|
||||
return false;
|
||||
}
|
||||
if code.is_empty() && !message.contains("decrypt") {
|
||||
return false;
|
||||
}
|
||||
message.contains("encrypted_content")
|
||||
&& (message.contains("decrypt") || message.contains("unmodified"))
|
||||
}
|
||||
|
||||
fn quota_failure(metadata: &InferenceErrorMetadata) -> Option<GrokQuotaFailureKind> {
|
||||
@@ -1152,7 +1300,48 @@ fn model_access_denied(metadata: &InferenceErrorMetadata) -> bool {
|
||||
fn safety_rejected(text: &str) -> bool {
|
||||
contains_any(
|
||||
text,
|
||||
&["content violates usage guidelines", "safety_check_type_"],
|
||||
&[
|
||||
"content violates usage guidelines",
|
||||
"safety_check_type_",
|
||||
"content_filter",
|
||||
"content_policy",
|
||||
"content moderation",
|
||||
"cyber_policy",
|
||||
"new_sensitive",
|
||||
"moderation feature is not available",
|
||||
"image is sensitive",
|
||||
"text is sensitive",
|
||||
"prohibited content",
|
||||
"forbidden content",
|
||||
"request blocked by policy",
|
||||
"request rejected by policy",
|
||||
"request violates policy",
|
||||
"prompt violates policy",
|
||||
"input violates policy",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn account_access_rejected(text: &str) -> bool {
|
||||
contains_any(
|
||||
text,
|
||||
&[
|
||||
"account_suspended",
|
||||
"account_disabled",
|
||||
"user_suspended",
|
||||
"user_disabled",
|
||||
"subscription_required",
|
||||
"entitlement_required",
|
||||
"not_entitled",
|
||||
"plan_required",
|
||||
"permission_denied",
|
||||
"account suspended",
|
||||
"account disabled",
|
||||
"user suspended",
|
||||
"subscription required",
|
||||
"entitlement required",
|
||||
"not entitled",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -347,6 +347,14 @@ pub enum GrokCredentialFailure {
|
||||
upstream_model: UpstreamModelId,
|
||||
retry_after: Option<Duration>,
|
||||
},
|
||||
/// 非内容策略的 403:当前账号的访问资格暂不可用。
|
||||
AccessDenied,
|
||||
/// 上游明确报告模型容量不足;重模型按模型隔离,其余账号短暂冷却。
|
||||
ModelCapacity { upstream_model: UpstreamModelId },
|
||||
/// 上游返回空模型输出。
|
||||
EmptyUpstream,
|
||||
/// 上游 5xx/529 暂时不可用。
|
||||
UpstreamUnavailable,
|
||||
/// 成功的 SSE 响应在终止 Responses 事件前结束。
|
||||
StreamInterrupted,
|
||||
}
|
||||
|
||||
@@ -27,10 +27,11 @@ use gateway_core::policy::ClientApiKeyId;
|
||||
use gateway_core::provider_ports::{
|
||||
NewOAuthPendingFlow, OAuthPendingBinding, OAuthPendingClaimOutcome, OAuthPendingConsumeOutcome,
|
||||
OAuthPendingFlowPort, OAuthPendingPutOutcome, OAuthPendingReleaseOutcome,
|
||||
ProviderCatalogCacheKey, ProviderCatalogCachePort, ProviderCooldown, ProviderCooldownPort,
|
||||
ProviderCooldownScope, ProviderCredentialState, ProviderCredentialStatePort,
|
||||
ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest, ProviderRefreshPolicy,
|
||||
ProviderRuntimePolicyPort, ProviderScopedCooldown, ProviderStoreError, ProviderStorePorts,
|
||||
ProviderArtifactProfile, ProviderArtifactProfileCachePort, ProviderCatalogCacheKey,
|
||||
ProviderCatalogCachePort, ProviderCooldown, ProviderCooldownPort, ProviderCooldownScope,
|
||||
ProviderCredentialState, ProviderCredentialStatePort, ProviderLeaseAcquisition,
|
||||
ProviderLeasePort, ProviderLeaseRequest, ProviderRefreshPolicy, ProviderRuntimePolicyPort,
|
||||
ProviderScopedCooldown, ProviderStoreError, ProviderStorePorts,
|
||||
};
|
||||
use gateway_core::routing::{ProviderKind, UpstreamModelId};
|
||||
use gateway_core::task::{
|
||||
@@ -448,6 +449,7 @@ fn provider_ports_with_catalog(
|
||||
Arc::new(TestSessionAffinity),
|
||||
Arc::new(TestSessionExclusions),
|
||||
catalog_cache,
|
||||
Arc::new(TestArtifactProfiles),
|
||||
Arc::new(TestCredentialState),
|
||||
Arc::new(TestCooldown),
|
||||
Arc::new(TestRuntimePolicy),
|
||||
@@ -529,6 +531,25 @@ impl ProviderLeasePort for TestLeases {
|
||||
}
|
||||
}
|
||||
|
||||
struct TestArtifactProfiles;
|
||||
|
||||
impl ProviderArtifactProfileCachePort for TestArtifactProfiles {
|
||||
fn replace_if_newer(
|
||||
&self,
|
||||
_profile: ProviderArtifactProfile,
|
||||
_ttl: Duration,
|
||||
) -> BoxFuture<'_, Result<bool, ProviderStoreError>> {
|
||||
Box::pin(async { Ok(true) })
|
||||
}
|
||||
|
||||
fn read<'a>(
|
||||
&'a self,
|
||||
_provider_kind: &'a ProviderKind,
|
||||
) -> BoxFuture<'a, Result<Option<ProviderArtifactProfile>, ProviderStoreError>> {
|
||||
Box::pin(async { Ok(None) })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct TestCatalogCache {
|
||||
values: Mutex<BTreeMap<String, OpaqueProviderData>>,
|
||||
|
||||
@@ -3,6 +3,8 @@ use provider_xai::{
|
||||
PendingAuthorization, RedirectUriAllowlist,
|
||||
};
|
||||
|
||||
const REDIRECT_URI: &str = "https://gateway.example/admin/xai/callback";
|
||||
|
||||
#[test]
|
||||
fn callback_should_reject_duplicate_state() {
|
||||
let result = AuthorizationCallback::parse("code=fake&state=one&state=two");
|
||||
@@ -28,10 +30,9 @@ fn pending_authorization_should_round_trip_only_through_server_state() {
|
||||
let config = GrokOAuthConfig::official().expect("fixture config");
|
||||
let discovery = DiscoveryDocument::parse(&config, include_bytes!("fixtures/discovery.json"))
|
||||
.expect("fixture discovery");
|
||||
let redirect = "https://gateway.example/admin/xai/callback";
|
||||
let allowed = RedirectUriAllowlist::new([redirect])
|
||||
let allowed = RedirectUriAllowlist::new([REDIRECT_URI])
|
||||
.expect("redirect allowlist")
|
||||
.authorize(redirect)
|
||||
.authorize(REDIRECT_URI)
|
||||
.expect("allowlisted redirect");
|
||||
let pending = PendingAuthorization::start(&config, &discovery, allowed, None)
|
||||
.expect("start pending flow");
|
||||
@@ -61,3 +62,121 @@ fn pending_authorization_should_round_trip_only_through_server_state() {
|
||||
.accept_callback(callback)
|
||||
.expect("restored state should validate callback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_input_should_accept_callback_query_and_bare_code() {
|
||||
let (pending, state) = pending_authorization();
|
||||
pending
|
||||
.accept_authorization_input(&format!("{REDIRECT_URI}?code=full-url-code&state={state}"))
|
||||
.expect("official callback URL");
|
||||
|
||||
let (pending, state) = pending_authorization();
|
||||
pending
|
||||
.accept_authorization_input(&format!("?code=query-code&state={state}"))
|
||||
.expect("callback query");
|
||||
|
||||
let (pending, state) = pending_authorization();
|
||||
pending
|
||||
.accept_authorization_input(&format!("code=query-code&state={state}"))
|
||||
.expect("callback query without question mark");
|
||||
|
||||
let (pending, _) = pending_authorization();
|
||||
pending
|
||||
.accept_authorization_input(" bare-code ")
|
||||
.expect("bare authorization code");
|
||||
|
||||
for code in [
|
||||
"opaque&state=still-code",
|
||||
"opaque&code=still-code",
|
||||
"opaque-error=value",
|
||||
"opaque://still-code",
|
||||
] {
|
||||
let (pending, _) = pending_authorization();
|
||||
pending
|
||||
.accept_authorization_input(code)
|
||||
.expect("opaque bare authorization code");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_input_should_keep_url_state_and_code_validation_strict() {
|
||||
let (pending, state) = pending_authorization();
|
||||
pending
|
||||
.accept_authorization_input(&format!(
|
||||
"https://attacker.invalid/callback?code=code&state={state}"
|
||||
))
|
||||
.expect_err("foreign callback host");
|
||||
|
||||
let (pending, _) = pending_authorization();
|
||||
pending
|
||||
.accept_authorization_input(&format!("{REDIRECT_URI}?code=code&state=wrong-state"))
|
||||
.expect_err("callback state mismatch");
|
||||
|
||||
let (pending, state) = pending_authorization();
|
||||
pending
|
||||
.accept_authorization_input(&format!("{REDIRECT_URI}?code=code&state={state}#fragment"))
|
||||
.expect_err("callback fragment");
|
||||
|
||||
let (pending, _) = pending_authorization();
|
||||
pending
|
||||
.accept_authorization_input("https:// malformed callback")
|
||||
.expect_err("malformed callback URL");
|
||||
|
||||
let (pending, _) = pending_authorization();
|
||||
pending
|
||||
.accept_authorization_input("//attacker.invalid/callback?code=code")
|
||||
.expect_err("scheme-relative callback URL");
|
||||
|
||||
let (pending, _) = pending_authorization();
|
||||
pending
|
||||
.accept_authorization_input("?unrelated=value")
|
||||
.expect_err("query input without code or state");
|
||||
|
||||
let (pending, _) = pending_authorization();
|
||||
pending
|
||||
.accept_authorization_input("code=query-code")
|
||||
.expect_err("query input without state");
|
||||
|
||||
let (pending, state) = pending_authorization();
|
||||
pending
|
||||
.accept_authorization_input(&format!("code=one&code=two&state={state}"))
|
||||
.expect_err("duplicate code");
|
||||
|
||||
let (pending, _) = pending_authorization();
|
||||
pending
|
||||
.accept_authorization_input("code-with\n-control")
|
||||
.expect_err("control character");
|
||||
|
||||
let (pending, state) = pending_authorization();
|
||||
pending
|
||||
.accept_authorization_input(&format!("?code=line%0Abreak&state={state}"))
|
||||
.expect_err("percent-decoded control character");
|
||||
|
||||
let (pending, _) = pending_authorization();
|
||||
pending
|
||||
.accept_authorization_input(&"x".repeat(64 * 1024))
|
||||
.expect("maximum-size code");
|
||||
|
||||
let (pending, _) = pending_authorization();
|
||||
pending
|
||||
.accept_authorization_input(&"x".repeat(64 * 1024 + 1))
|
||||
.expect_err("oversized code");
|
||||
}
|
||||
|
||||
fn pending_authorization() -> (PendingAuthorization, String) {
|
||||
let config = GrokOAuthConfig::official().expect("fixture config");
|
||||
let discovery = DiscoveryDocument::parse(&config, include_bytes!("fixtures/discovery.json"))
|
||||
.expect("fixture discovery");
|
||||
let redirect = RedirectUriAllowlist::new([REDIRECT_URI])
|
||||
.expect("redirect allowlist")
|
||||
.authorize(REDIRECT_URI)
|
||||
.expect("allowlisted redirect");
|
||||
let pending = PendingAuthorization::start(&config, &discovery, redirect, None)
|
||||
.expect("start pending flow");
|
||||
let state = pending
|
||||
.authorization_url()
|
||||
.query_pairs()
|
||||
.find_map(|(name, value)| (name == "state").then(|| value.into_owned()))
|
||||
.expect("authorization state");
|
||||
(pending, state)
|
||||
}
|
||||
|
||||
@@ -560,7 +560,7 @@ async fn model_quota_feedback_writes_model_scoped_cooldown_without_blocking_othe
|
||||
|
||||
let account = fixture.store.account(&selected).expect("selected account");
|
||||
assert_eq!(account.credential_state(), CredentialState::Ready);
|
||||
let minimum_until = SystemTime::now() + Duration::from_secs(23 * 60 * 60);
|
||||
let minimum_until = SystemTime::now() + Duration::from_secs(9 * 60);
|
||||
let scope = ProviderCooldownScope::upstream_model(failed_model);
|
||||
// model-scoped cooldown:目标模型被排除,账号级无 cooldown。
|
||||
assert!(
|
||||
|
||||
@@ -42,7 +42,7 @@ use provider_xai::{
|
||||
GrokSessionSelection, GrokSessionSelector, GrokSessionSelectorError, GrokSessionSelectorFuture,
|
||||
SecretValue, SelectedGrokSession,
|
||||
};
|
||||
use serde_json::{Map, json};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::support::{
|
||||
MemoryGrokCatalogCache, MemoryProviderAccountStore, account_id, create_input, seed_input,
|
||||
@@ -208,6 +208,8 @@ fn compaction_sse(summary: &str, reasoning: Option<&str>) -> Vec<u8> {
|
||||
"event: response.created\n",
|
||||
"data: {{\"type\":\"response.created\",\"response\":{{\"id\":\"resp_compaction\",\"model\":\"grok-4.5\"}}}}\n\n",
|
||||
"{reasoning}",
|
||||
"event: response.output_item.done\n",
|
||||
"data: {{\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{{\"id\":\"reason_compaction\",\"type\":\"reasoning\",\"status\":\"completed\",\"summary\":[],\"encrypted_content\":\"grok-compaction-ciphertext\"}}}}\n\n",
|
||||
"event: response.content_part.added\n",
|
||||
"data: {{\"type\":\"response.content_part.added\",\"output_index\":1,\"content_index\":0,\"part\":{{\"type\":\"output_text\"}}}}\n\n",
|
||||
"event: response.output_text.delta\n",
|
||||
@@ -221,7 +223,53 @@ fn compaction_sse(summary: &str, reasoning: Option<&str>) -> Vec<u8> {
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
fn terminal_only_compaction_sse(summary: &str) -> Vec<u8> {
|
||||
let summary = serde_json::to_string(summary).expect("summary JSON string");
|
||||
format!(
|
||||
concat!(
|
||||
"event: response.created\n",
|
||||
"data: {{\"type\":\"response.created\",\"response\":{{\"id\":\"resp_compaction\",\"model\":\"grok-4.5\"}}}}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {{\"type\":\"response.completed\",\"response\":{{\"id\":\"resp_compaction\",\"model\":\"grok-4.5\",\"status\":\"completed\",\"output\":[{{\"id\":\"reason_compaction\",\"type\":\"reasoning\",\"status\":\"completed\",\"encrypted_content\":\"grok-compaction-ciphertext\"}},{{\"id\":\"msg_compaction\",\"type\":\"message\",\"status\":\"completed\",\"role\":\"assistant\",\"content\":[{{\"type\":\"output_text\",\"text\":{summary}}}]}}],\"usage\":{{\"input_tokens\":100,\"output_tokens\":20,\"total_tokens\":120}}}}}}\n\n",
|
||||
),
|
||||
summary = summary,
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
fn incomplete_compaction_sse(summary: &str) -> Vec<u8> {
|
||||
let summary = serde_json::to_string(summary).expect("summary JSON string");
|
||||
format!(
|
||||
concat!(
|
||||
"event: response.created\n",
|
||||
"data: {{\"type\":\"response.created\",\"response\":{{\"id\":\"resp_compaction\",\"model\":\"grok-4.5\"}}}}\n\n",
|
||||
"event: response.incomplete\n",
|
||||
"data: {{\"type\":\"response.incomplete\",\"response\":{{\"id\":\"resp_compaction\",\"model\":\"grok-4.5\",\"status\":\"incomplete\",\"incomplete_details\":{{\"reason\":\"max_output_tokens\"}},\"output_text\":{summary},\"output\":[{{\"id\":\"reason_compaction\",\"type\":\"reasoning\",\"status\":\"completed\",\"encrypted_content\":\"grok-compaction-ciphertext\"}},{{\"id\":\"msg_compaction\",\"type\":\"message\",\"status\":\"incomplete\",\"role\":\"assistant\",\"content\":[{{\"type\":\"output_text\",\"text\":{summary}}}]}}],\"usage\":{{\"input_tokens\":100,\"output_tokens\":20,\"total_tokens\":120}}}}}}\n\n",
|
||||
),
|
||||
summary = summary,
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
fn compaction_sse_without_terminal(summary: &str) -> Vec<u8> {
|
||||
let summary = serde_json::to_string(summary).expect("summary JSON string");
|
||||
format!(
|
||||
concat!(
|
||||
"event: response.created\n",
|
||||
"data: {{\"type\":\"response.created\",\"response\":{{\"id\":\"resp_compaction\",\"model\":\"grok-4.5\"}}}}\n\n",
|
||||
"event: response.output_item.done\n",
|
||||
"data: {{\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{{\"id\":\"reason_compaction\",\"type\":\"reasoning\",\"status\":\"completed\",\"summary\":[],\"encrypted_content\":\"grok-compaction-ciphertext\"}}}}\n\n",
|
||||
"event: response.content_part.added\n",
|
||||
"data: {{\"type\":\"response.content_part.added\",\"output_index\":0,\"content_index\":0,\"part\":{{\"type\":\"output_text\"}}}}\n\n",
|
||||
"event: response.output_text.delta\n",
|
||||
"data: {{\"type\":\"response.output_text.delta\",\"output_index\":0,\"content_index\":0,\"delta\":{summary}}}\n\n",
|
||||
),
|
||||
summary = summary,
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
fn compaction_sse_without_encrypted_content(summary: &str) -> Vec<u8> {
|
||||
let summary = serde_json::to_string(summary).expect("summary JSON string");
|
||||
format!(
|
||||
concat!(
|
||||
@@ -231,6 +279,8 @@ fn compaction_sse_without_terminal(summary: &str) -> Vec<u8> {
|
||||
"data: {{\"type\":\"response.content_part.added\",\"output_index\":0,\"content_index\":0,\"part\":{{\"type\":\"output_text\"}}}}\n\n",
|
||||
"event: response.output_text.delta\n",
|
||||
"data: {{\"type\":\"response.output_text.delta\",\"output_index\":0,\"content_index\":0,\"delta\":{summary}}}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {{\"type\":\"response.completed\",\"response\":{{\"id\":\"resp_compaction\",\"model\":\"grok-4.5\",\"status\":\"completed\"}}}}\n\n",
|
||||
),
|
||||
summary = summary,
|
||||
)
|
||||
@@ -260,6 +310,7 @@ struct StubSelector {
|
||||
feedback: Mutex<Vec<GrokCredentialFailure>>,
|
||||
error: Mutex<Option<GrokSessionSelectorError>>,
|
||||
required_accounts: Mutex<Vec<Option<gateway_core::engine::credential::ProviderAccountId>>>,
|
||||
upstream_models: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl StubSelector {
|
||||
@@ -269,6 +320,7 @@ impl StubSelector {
|
||||
feedback: Mutex::new(Vec::new()),
|
||||
error: Mutex::new(None),
|
||||
required_accounts: Mutex::new(Vec::new()),
|
||||
upstream_models: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -278,6 +330,7 @@ impl StubSelector {
|
||||
feedback: Mutex::new(Vec::new()),
|
||||
error: Mutex::new(Some(error)),
|
||||
required_accounts: Mutex::new(Vec::new()),
|
||||
upstream_models: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -285,6 +338,10 @@ impl StubSelector {
|
||||
impl GrokSessionSelector for StubSelector {
|
||||
fn select(&self, request: GrokSessionSelection) -> GrokSessionSelectorFuture<'_> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
self.upstream_models
|
||||
.lock()
|
||||
.expect("upstream models")
|
||||
.push(request.upstream_model().as_str().to_owned());
|
||||
let error = self.error.lock().expect("selector error").take();
|
||||
let required_account = request.required_account().cloned();
|
||||
self.required_accounts
|
||||
@@ -630,6 +687,70 @@ async fn mapped_transport_error(
|
||||
next_provider_error(&mut stream).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_encrypted_reasoning_retries_once_on_the_same_account_without_ciphertext() {
|
||||
let transport = StubInferenceTransport::sequence([
|
||||
InferenceMode::Error(
|
||||
GrokInferenceTransportError::new(
|
||||
GrokInferenceTransportErrorKind::InvalidRequest,
|
||||
UpstreamSendState::Sent,
|
||||
)
|
||||
.with_status(400)
|
||||
.with_upstream_code(OpaqueUpstreamValue::new("reasoning_decode_failed")),
|
||||
),
|
||||
InferenceMode::Success,
|
||||
]);
|
||||
let provider = provider(StubSelector::success(), transport.clone()).await;
|
||||
let operation = Operation::Generate(GenerateRequest::from_protocol_payload(
|
||||
ProtocolPayload::json_object(
|
||||
"openai",
|
||||
Map::from_iter([
|
||||
("model".to_owned(), json!("client-model")),
|
||||
(
|
||||
"input".to_owned(),
|
||||
json!([
|
||||
{
|
||||
"type": "reasoning",
|
||||
"id": "reason_stale",
|
||||
"summary": [{"type": "summary_text", "text": "keep summary"}],
|
||||
"content": null,
|
||||
"encrypted_content": "stale-ciphertext"
|
||||
},
|
||||
{"type": "message", "role": "user", "content": "continue"}
|
||||
]),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.expect("OpenAI payload"),
|
||||
));
|
||||
let mut stream = provider
|
||||
.execute(
|
||||
provider_request_with_operation("xai", operation),
|
||||
context(CancellationToken::new(), None),
|
||||
)
|
||||
.await
|
||||
.expect("retrying stream");
|
||||
while let Some(event) = stream.next().await {
|
||||
event.expect("recovered response");
|
||||
}
|
||||
|
||||
let requests = transport.requests.lock().expect("requests");
|
||||
assert_eq!(requests.len(), 2);
|
||||
let first: serde_json::Value = serde_json::from_slice(requests[0].body()).expect("first body");
|
||||
let second: serde_json::Value = serde_json::from_slice(requests[1].body()).expect("retry body");
|
||||
assert_eq!(
|
||||
first.pointer("/input/0/encrypted_content"),
|
||||
Some(&json!("stale-ciphertext"))
|
||||
);
|
||||
assert_eq!(second.pointer("/input/0/encrypted_content"), None);
|
||||
assert_eq!(second.pointer("/input/0/content"), None);
|
||||
assert_eq!(
|
||||
second.pointer("/input/0/summary/0/text"),
|
||||
Some(&json!("keep summary"))
|
||||
);
|
||||
assert_eq!(requests[0].binding(), requests[1].binding());
|
||||
}
|
||||
|
||||
async fn next_provider_error(
|
||||
stream: &mut gateway_core::engine::provider::ProviderStream,
|
||||
) -> ProviderError {
|
||||
@@ -803,10 +924,18 @@ fn provider_request(provider_kind: &str) -> ProviderRequest {
|
||||
}
|
||||
|
||||
fn provider_request_with_operation(provider_kind: &str, operation: Operation) -> ProviderRequest {
|
||||
provider_request_with_upstream_model(provider_kind, MODEL, operation)
|
||||
}
|
||||
|
||||
fn provider_request_with_upstream_model(
|
||||
provider_kind: &str,
|
||||
upstream_model: &str,
|
||||
operation: Operation,
|
||||
) -> ProviderRequest {
|
||||
let provider = ProviderKind::new(provider_kind).expect("provider");
|
||||
let provider_model = ProviderModel::new(
|
||||
provider.clone(),
|
||||
UpstreamModelId::new(MODEL).expect("model"),
|
||||
UpstreamModelId::new(upstream_model).expect("model"),
|
||||
ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), Some(131_072))
|
||||
.with_feature(Feature::Tools, SupportLevel::Native)
|
||||
.with_feature(Feature::NativeContinuation, SupportLevel::Native),
|
||||
@@ -828,7 +957,7 @@ fn provider_request_with_operation(provider_kind: &str, operation: Operation) ->
|
||||
.expect("snapshot");
|
||||
let plan = snapshot
|
||||
.plan(
|
||||
&PublicModelId::new(MODEL).expect("model"),
|
||||
&PublicModelId::new(upstream_model).expect("model"),
|
||||
&operation,
|
||||
account_scope,
|
||||
&RoutingContext::default(),
|
||||
@@ -904,6 +1033,31 @@ async fn execute_successfully(provider: &GrokBuildProvider, operation: Operation
|
||||
assert!(events.iter().all(Result::is_ok));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_alias_should_be_canonical_before_account_selection() {
|
||||
let selector = StubSelector::success();
|
||||
let provider = provider(selector.clone(), StubInferenceTransport::success()).await;
|
||||
let mut stream = provider
|
||||
.execute(
|
||||
provider_request_with_upstream_model("xai", "grok-4.6-latest", operation()),
|
||||
context(CancellationToken::new(), None),
|
||||
)
|
||||
.await
|
||||
.expect("provider stream");
|
||||
while let Some(event) = stream.next().await {
|
||||
event.expect("provider event");
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
selector
|
||||
.upstream_models
|
||||
.lock()
|
||||
.expect("upstream models")
|
||||
.as_slice(),
|
||||
["grok-4.6"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_observation_preserves_raw_xai_reasoning_effort() {
|
||||
let provider = provider(StubSelector::success(), StubInferenceTransport::success()).await;
|
||||
@@ -1125,6 +1279,145 @@ async fn compaction_stream_should_emit_openai_wire_and_keep_only_accounting_cano
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compaction_should_use_terminal_output_when_upstream_omits_text_deltas() {
|
||||
let summary = valid_compaction_summary("terminal-only summary");
|
||||
let transport = StubInferenceTransport::sequence([InferenceMode::SuccessBody(
|
||||
terminal_only_compaction_sse(&summary),
|
||||
)]);
|
||||
let provider = provider(StubSelector::success(), transport).await;
|
||||
let mut stream = provider
|
||||
.execute(
|
||||
provider_request_with_operation("xai", compaction_operation()),
|
||||
context(CancellationToken::new(), None),
|
||||
)
|
||||
.await
|
||||
.expect("compaction stream");
|
||||
let events = stream.by_ref().collect::<Vec<_>>().await;
|
||||
let compact_item = events
|
||||
.iter()
|
||||
.map(|event| event.as_ref().expect("successful compaction event"))
|
||||
.filter_map(|event| event.wire_event())
|
||||
.find(|wire| wire.event_type() == Some("response.output_item.done"))
|
||||
.and_then(|wire| wire.data().get("item"))
|
||||
.expect("compaction output item");
|
||||
|
||||
assert_eq!(
|
||||
compact_item.pointer("/summary/0/text"),
|
||||
Some(&Value::String(summary))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compaction_should_complete_incomplete_upstream_when_ciphertext_is_present() {
|
||||
let summary = valid_compaction_summary("incomplete upstream summary");
|
||||
let transport = StubInferenceTransport::sequence([InferenceMode::SuccessBody(
|
||||
incomplete_compaction_sse(&summary),
|
||||
)]);
|
||||
let provider = provider(StubSelector::success(), transport).await;
|
||||
let mut stream = provider
|
||||
.execute(
|
||||
provider_request_with_operation("xai", compaction_operation()),
|
||||
context(CancellationToken::new(), None),
|
||||
)
|
||||
.await
|
||||
.expect("compaction stream");
|
||||
let events = stream.by_ref().collect::<Vec<_>>().await;
|
||||
let terminal = events
|
||||
.iter()
|
||||
.map(|event| event.as_ref().expect("successful compaction event"))
|
||||
.filter_map(|event| event.wire_event())
|
||||
.find(|wire| wire.event_type() == Some("response.completed"))
|
||||
.expect("completed compaction response");
|
||||
let response = terminal
|
||||
.data()
|
||||
.get("response")
|
||||
.expect("terminal response object");
|
||||
|
||||
assert_eq!(
|
||||
terminal.data().get("type"),
|
||||
Some(&json!("response.completed"))
|
||||
);
|
||||
assert_eq!(response.get("status"), Some(&json!("completed")));
|
||||
assert_eq!(response.get("incomplete_details"), Some(&Value::Null));
|
||||
assert!(
|
||||
!response
|
||||
.as_object()
|
||||
.expect("response object")
|
||||
.contains_key("output_text")
|
||||
);
|
||||
assert_eq!(
|
||||
response.pointer("/output/0/encrypted_content"),
|
||||
Some(&json!("grok-compaction-ciphertext"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compaction_invalid_encrypted_reasoning_should_retry_once_on_the_same_account() {
|
||||
let transport = StubInferenceTransport::sequence([
|
||||
InferenceMode::Error(
|
||||
GrokInferenceTransportError::new(
|
||||
GrokInferenceTransportErrorKind::InvalidRequest,
|
||||
UpstreamSendState::Sent,
|
||||
)
|
||||
.with_status(400)
|
||||
.with_upstream_code(OpaqueUpstreamValue::new("reasoning_decode_failed")),
|
||||
),
|
||||
InferenceMode::SuccessBody(compaction_sse(
|
||||
&valid_compaction_summary("recovered compaction"),
|
||||
None,
|
||||
)),
|
||||
]);
|
||||
let provider = provider(StubSelector::success(), transport.clone()).await;
|
||||
let payload = ProtocolPayload::json_object(
|
||||
"openai",
|
||||
object(json!({
|
||||
"model": "client-model",
|
||||
"input": [
|
||||
{
|
||||
"type": "reasoning",
|
||||
"summary": [{"type": "summary_text", "text": "keep summary"}],
|
||||
"content": null,
|
||||
"encrypted_content": "stale-compaction-ciphertext"
|
||||
},
|
||||
{"type": "message", "role": "user", "content": "history"},
|
||||
{"type": "compaction_trigger"}
|
||||
],
|
||||
"stream": true
|
||||
})),
|
||||
)
|
||||
.expect("OpenAI payload");
|
||||
let mut stream = provider
|
||||
.execute(
|
||||
provider_request_with_operation(
|
||||
"xai",
|
||||
Operation::Generate(GenerateRequest::from_protocol_payload(payload)),
|
||||
),
|
||||
context(CancellationToken::new(), None),
|
||||
)
|
||||
.await
|
||||
.expect("retrying compaction stream");
|
||||
while let Some(event) = stream.next().await {
|
||||
event.expect("recovered compaction response");
|
||||
}
|
||||
|
||||
let requests = transport.requests.lock().expect("requests");
|
||||
assert_eq!(requests.len(), 2);
|
||||
let first: serde_json::Value = serde_json::from_slice(requests[0].body()).expect("first body");
|
||||
let second: serde_json::Value = serde_json::from_slice(requests[1].body()).expect("retry body");
|
||||
assert_eq!(
|
||||
first.pointer("/input/0/encrypted_content"),
|
||||
Some(&json!("stale-compaction-ciphertext"))
|
||||
);
|
||||
assert_eq!(second.pointer("/input/0/encrypted_content"), None);
|
||||
assert_eq!(second.pointer("/input/0/content"), None);
|
||||
assert_eq!(
|
||||
second.pointer("/input/0/summary/0/text"),
|
||||
Some(&json!("keep summary"))
|
||||
);
|
||||
assert_eq!(requests[0].binding(), requests[1].binding());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compaction_should_pin_recorded_account_and_forward_session_headers() {
|
||||
let state = ProviderSessionState::new(
|
||||
@@ -1180,7 +1473,7 @@ async fn compaction_should_pin_recorded_account_and_forward_session_headers() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compaction_should_accept_summary_when_upstream_stream_ends_without_terminal() {
|
||||
async fn compaction_should_reject_upstream_stream_that_ends_without_terminal() {
|
||||
let transport = StubInferenceTransport::sequence([InferenceMode::SuccessBody(
|
||||
compaction_sse_without_terminal(&valid_compaction_summary("usable eof summary")),
|
||||
)]);
|
||||
@@ -1192,32 +1485,10 @@ async fn compaction_should_accept_summary_when_upstream_stream_ends_without_term
|
||||
)
|
||||
.await
|
||||
.expect("compaction stream");
|
||||
let events = stream
|
||||
.by_ref()
|
||||
.map(|event| event.expect("successful compaction event"))
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
let facts = events
|
||||
.iter()
|
||||
.flat_map(|event| event.canonical_facts())
|
||||
.collect::<Vec<_>>();
|
||||
let wire_types = events
|
||||
.iter()
|
||||
.filter_map(|event| event.wire_event().and_then(|wire| wire.event_type()))
|
||||
.collect::<Vec<_>>();
|
||||
let error = next_provider_error(&mut stream).await;
|
||||
|
||||
assert!(matches!(
|
||||
facts.as_slice(),
|
||||
[GatewayEvent::Started(_), GatewayEvent::Completed(_)]
|
||||
));
|
||||
assert_eq!(
|
||||
wire_types,
|
||||
[
|
||||
"response.created",
|
||||
"response.output_item.done",
|
||||
"response.completed"
|
||||
]
|
||||
);
|
||||
assert_eq!(error.kind(), ProviderErrorKind::Protocol);
|
||||
assert!(error.allows_pre_delivery_retry());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1236,21 +1507,34 @@ async fn compaction_wire_should_exclude_reasoning_from_summary_content() {
|
||||
.await
|
||||
.expect("compaction stream");
|
||||
let events = stream.by_ref().collect::<Vec<_>>().await;
|
||||
let summary = events
|
||||
let item = events
|
||||
.iter()
|
||||
.map(|event| event.as_ref().expect("successful compaction event"))
|
||||
.filter_map(|event| event.wire_event())
|
||||
.find(|wire| wire.event_type() == Some("response.output_item.done"))
|
||||
.and_then(|wire| wire.data().pointer("/item/encrypted_content"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.and_then(|wire| wire.data().get("item"))
|
||||
.expect("compaction output wire");
|
||||
let summary = item
|
||||
.pointer("/summary/0/text")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.expect("visible compaction summary");
|
||||
|
||||
assert!(summary.contains("continuation marker"));
|
||||
assert!(!summary.contains("private reasoning"));
|
||||
assert_eq!(
|
||||
item.get("encrypted_content"),
|
||||
Some(&json!("grok-compaction-ciphertext"))
|
||||
);
|
||||
assert_eq!(item.get("status"), Some(&json!("completed")));
|
||||
assert!(
|
||||
item.get("id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|id| id.starts_with("cmp_"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compaction_stream_should_reject_degenerate_summary_before_commit() {
|
||||
async fn compaction_stream_should_accept_short_summary_like_sub2api() {
|
||||
let transport = StubInferenceTransport::sequence([InferenceMode::SuccessBody(compaction_sse(
|
||||
"<summary>too short</summary>",
|
||||
None,
|
||||
@@ -1264,12 +1548,77 @@ async fn compaction_stream_should_reject_degenerate_summary_before_commit() {
|
||||
.await
|
||||
.expect("compaction stream");
|
||||
|
||||
let events = stream
|
||||
.by_ref()
|
||||
.map(|event| event.expect("successful compaction event"))
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
let item = events
|
||||
.iter()
|
||||
.filter_map(|event| event.wire_event())
|
||||
.find(|wire| wire.event_type() == Some("response.output_item.done"))
|
||||
.and_then(|wire| wire.data().get("item"))
|
||||
.expect("compaction item");
|
||||
|
||||
assert_eq!(
|
||||
item.pointer("/summary/0/text"),
|
||||
Some(&json!("<summary>too short</summary>"))
|
||||
);
|
||||
assert_eq!(
|
||||
item.get("encrypted_content"),
|
||||
Some(&json!("grok-compaction-ciphertext"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compaction_stream_should_allow_ciphertext_without_visible_summary() {
|
||||
let transport =
|
||||
StubInferenceTransport::sequence([InferenceMode::SuccessBody(compaction_sse(" ", None))]);
|
||||
let provider = provider(StubSelector::success(), transport).await;
|
||||
let mut stream = provider
|
||||
.execute(
|
||||
provider_request_with_operation("xai", compaction_operation()),
|
||||
context(CancellationToken::new(), None),
|
||||
)
|
||||
.await
|
||||
.expect("compaction stream");
|
||||
let events = stream
|
||||
.by_ref()
|
||||
.map(|event| event.expect("successful compaction event"))
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
let item = events
|
||||
.iter()
|
||||
.filter_map(|event| event.wire_event())
|
||||
.find(|wire| wire.event_type() == Some("response.output_item.done"))
|
||||
.and_then(|wire| wire.data().get("item"))
|
||||
.expect("compaction item");
|
||||
|
||||
assert_eq!(item.get("summary"), None);
|
||||
assert_eq!(
|
||||
item.get("encrypted_content"),
|
||||
Some(&json!("grok-compaction-ciphertext"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compaction_stream_should_require_real_reasoning_ciphertext_before_commit() {
|
||||
let transport = StubInferenceTransport::sequence([InferenceMode::SuccessBody(
|
||||
compaction_sse_without_encrypted_content(&valid_compaction_summary("visible summary")),
|
||||
)]);
|
||||
let provider = provider(StubSelector::success(), transport).await;
|
||||
let mut stream = provider
|
||||
.execute(
|
||||
provider_request_with_operation("xai", compaction_operation()),
|
||||
context(CancellationToken::new(), None),
|
||||
)
|
||||
.await
|
||||
.expect("compaction stream");
|
||||
|
||||
let error = next_provider_error(&mut stream).await;
|
||||
|
||||
assert_eq!(error.kind(), ProviderErrorKind::Protocol);
|
||||
assert!(!error.replay_is_safe());
|
||||
assert!(error.allows_pre_delivery_retry());
|
||||
assert!(!error.retries_same_account());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1701,7 +2050,7 @@ async fn explicit_http_408_does_not_mark_provider_error_replay_safe() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generic_http_403_does_not_mutate_or_switch_credential() {
|
||||
async fn generic_http_403_cools_account_and_allows_next_account_replay() {
|
||||
let selector = StubSelector::success();
|
||||
let transport = StubInferenceTransport::error(
|
||||
GrokInferenceTransportError::new(
|
||||
@@ -1721,14 +2070,17 @@ async fn generic_http_403_does_not_mutate_or_switch_credential() {
|
||||
let error = next_provider_error(&mut stream).await;
|
||||
|
||||
assert_eq!(error.kind(), ProviderErrorKind::PermissionDenied);
|
||||
assert!(!error.replay_is_safe());
|
||||
assert!(error.replay_is_safe());
|
||||
assert_eq!(selector.calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(transport.calls.load(Ordering::SeqCst), 1);
|
||||
assert!(selector.feedback.lock().expect("feedback").is_empty());
|
||||
assert_eq!(
|
||||
selector.feedback.lock().expect("feedback").as_slice(),
|
||||
&[GrokCredentialFailure::AccessDenied]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generic_http_500_does_not_mark_provider_error_replay_safe() {
|
||||
async fn generic_http_500_allows_next_account_replay() {
|
||||
let error = mapped_transport_error(
|
||||
GrokInferenceTransportError::new(
|
||||
GrokInferenceTransportErrorKind::Unavailable,
|
||||
@@ -1740,7 +2092,64 @@ async fn generic_http_500_does_not_mark_provider_error_replay_safe() {
|
||||
.await;
|
||||
|
||||
assert_eq!(error.kind(), ProviderErrorKind::Unavailable);
|
||||
assert!(error.replay_is_safe());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_wording_on_http_400_stays_request_scoped() {
|
||||
let selector = StubSelector::success();
|
||||
let transport = StubInferenceTransport::error(
|
||||
GrokInferenceTransportError::new(
|
||||
GrokInferenceTransportErrorKind::InvalidRequest,
|
||||
UpstreamSendState::Sent,
|
||||
)
|
||||
.with_status(400)
|
||||
.with_upstream_code(OpaqueUpstreamValue::new("rate_limit")),
|
||||
);
|
||||
let provider = provider(selector.clone(), transport).await;
|
||||
let mut stream = provider
|
||||
.execute(
|
||||
provider_request("xai"),
|
||||
context(CancellationToken::new(), None),
|
||||
)
|
||||
.await
|
||||
.expect("stream");
|
||||
let error = next_provider_error(&mut stream).await;
|
||||
|
||||
assert_eq!(error.kind(), ProviderErrorKind::InvalidRequest);
|
||||
assert!(!error.replay_is_safe());
|
||||
assert!(selector.feedback.lock().expect("feedback").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_capacity_on_http_400_cools_scope_and_allows_replay() {
|
||||
let selector = StubSelector::success();
|
||||
let transport = StubInferenceTransport::error(
|
||||
GrokInferenceTransportError::new(
|
||||
GrokInferenceTransportErrorKind::RateLimited,
|
||||
UpstreamSendState::Sent,
|
||||
)
|
||||
.with_status(400)
|
||||
.with_upstream_code(OpaqueUpstreamValue::new("model_capacity")),
|
||||
);
|
||||
let provider = provider(selector.clone(), transport).await;
|
||||
let mut stream = provider
|
||||
.execute(
|
||||
provider_request("xai"),
|
||||
context(CancellationToken::new(), None),
|
||||
)
|
||||
.await
|
||||
.expect("stream");
|
||||
let error = next_provider_error(&mut stream).await;
|
||||
|
||||
assert_eq!(error.kind(), ProviderErrorKind::RateLimited);
|
||||
assert!(error.replay_is_safe());
|
||||
assert_eq!(
|
||||
selector.feedback.lock().expect("feedback").as_slice(),
|
||||
&[GrokCredentialFailure::ModelCapacity {
|
||||
upstream_model: UpstreamModelId::new("grok-4.5").expect("model"),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -2,6 +2,7 @@ use gateway_core::error::ProviderError;
|
||||
use gateway_core::event::{ContentKind, FinishReason, GatewayEvent, ProviderEvent};
|
||||
use gateway_core::operation::{GenerateRequest, ProtocolPayload};
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
use gateway_protocol::openai::sse::{encode_sse_event, encode_sse_event_with_metadata};
|
||||
use serde_json::Value;
|
||||
|
||||
use provider_xai::{GrokCanonicalDecoder, GrokResponsesRequest, grok_billing_breakdown};
|
||||
@@ -579,6 +580,274 @@ fn decoder_should_buffer_only_custom_arguments_until_the_done_event() {
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_normalize_schema_integer_arguments_and_resequence_split_events() {
|
||||
let request = tool_request(serde_json::json!({
|
||||
"model": "client",
|
||||
"input": "wait",
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "wait",
|
||||
"parameters": {
|
||||
"$ref": "#/$defs/Args",
|
||||
"$defs": {
|
||||
"Args": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"timeout_ms": {"allOf": [{"type": "integer"}]},
|
||||
"fractional": {"type": "integer"},
|
||||
"unsafe": {"type": "integer"},
|
||||
"list": {
|
||||
"type": "array",
|
||||
"prefixItems": [{"type": "integer"}],
|
||||
"items": {"type": "number"}
|
||||
},
|
||||
"extra": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Unused": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}));
|
||||
let arguments = concat!(
|
||||
"{\"timeout_ms\":60000.0,\"fractional\":1.5,",
|
||||
"\"unsafe\":9007199254740992.0,\"list\":[2.0,3.0],",
|
||||
"\"extra\":{\"retries\":4.0},\"untyped\":7.0}"
|
||||
);
|
||||
let body = [
|
||||
encode_sse_event(
|
||||
"response.created",
|
||||
&serde_json::json!({
|
||||
"type": "response.created",
|
||||
"response": {"id": "resp_integer"}
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
encode_sse_event(
|
||||
"response.output_item.added",
|
||||
&serde_json::json!({
|
||||
"type": "response.output_item.added",
|
||||
"sequence_number": 40.0,
|
||||
"output_index": 0,
|
||||
"item": {
|
||||
"id": "item_wait",
|
||||
"type": "function_call",
|
||||
"call_id": "call_wait",
|
||||
"name": "wait"
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
encode_sse_event(
|
||||
"",
|
||||
&serde_json::json!({
|
||||
"sequence_number": "invalid",
|
||||
"future": true
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
encode_sse_event(
|
||||
"response.function_call_arguments.delta",
|
||||
&serde_json::json!({
|
||||
"type": "response.function_call_arguments.delta",
|
||||
"sequence_number": 91,
|
||||
"item_id": "item_wait",
|
||||
"call_id": "call_wait",
|
||||
"output_index": 0,
|
||||
"delta": arguments
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
encode_sse_event_with_metadata(
|
||||
"response.function_call_arguments.done",
|
||||
&serde_json::json!({
|
||||
"type": "response.function_call_arguments.done",
|
||||
"sequence_number": 120,
|
||||
"item_id": "item_wait",
|
||||
"call_id": "call_wait",
|
||||
"output_index": 0,
|
||||
"arguments": arguments
|
||||
})
|
||||
.to_string(),
|
||||
Some("evt_arguments_done"),
|
||||
Some(250),
|
||||
),
|
||||
encode_sse_event(
|
||||
"response.output_item.done",
|
||||
&serde_json::json!({
|
||||
"type": "response.output_item.done",
|
||||
"sequence_number": 200,
|
||||
"output_index": 0,
|
||||
"item": {
|
||||
"id": "item_wait",
|
||||
"type": "function_call",
|
||||
"call_id": "call_wait",
|
||||
"name": "wait",
|
||||
"arguments": arguments
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
]
|
||||
.concat();
|
||||
|
||||
let events = GrokCanonicalDecoder::for_request("grok-4.5", &request)
|
||||
.push(body.as_bytes())
|
||||
.expect("integer argument stream");
|
||||
let wire = wire_events(&events);
|
||||
let sequences = wire
|
||||
.iter()
|
||||
.filter_map(|event| event.data().get("sequence_number")?.as_u64())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(sequences, vec![40, 41, 42, 43, 44]);
|
||||
assert!(wire.iter().any(|event| {
|
||||
event.event_type().is_none()
|
||||
&& event.data().get("future") == Some(&serde_json::json!(true))
|
||||
&& event.data().get("sequence_number") == Some(&serde_json::json!(41))
|
||||
}));
|
||||
|
||||
let argument_events = wire
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
matches!(
|
||||
event.event_type(),
|
||||
Some(
|
||||
"response.function_call_arguments.delta"
|
||||
| "response.function_call_arguments.done"
|
||||
| "response.output_item.done"
|
||||
)
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(argument_events.len(), 3);
|
||||
for event in &argument_events {
|
||||
let raw = event
|
||||
.data()
|
||||
.pointer(if event.event_type() == Some("response.output_item.done") {
|
||||
"/item/arguments"
|
||||
} else if event.event_type() == Some("response.function_call_arguments.delta") {
|
||||
"/delta"
|
||||
} else {
|
||||
"/arguments"
|
||||
})
|
||||
.and_then(Value::as_str)
|
||||
.expect("normalized arguments");
|
||||
let normalized = serde_json::from_str::<Value>(raw).expect("argument JSON");
|
||||
assert_eq!(
|
||||
normalized.pointer("/timeout_ms"),
|
||||
Some(&serde_json::json!(60000))
|
||||
);
|
||||
assert_eq!(
|
||||
normalized.pointer("/fractional"),
|
||||
Some(&serde_json::json!(1.5))
|
||||
);
|
||||
assert_eq!(
|
||||
normalized.pointer("/unsafe"),
|
||||
Some(&serde_json::json!(9007199254740992.0))
|
||||
);
|
||||
assert_eq!(normalized.pointer("/list/0"), Some(&serde_json::json!(2)));
|
||||
assert_eq!(normalized.pointer("/list/1"), Some(&serde_json::json!(3.0)));
|
||||
assert_eq!(
|
||||
normalized.pointer("/extra/retries"),
|
||||
Some(&serde_json::json!(4))
|
||||
);
|
||||
assert_eq!(
|
||||
normalized.pointer("/untyped"),
|
||||
Some(&serde_json::json!(7.0))
|
||||
);
|
||||
}
|
||||
assert_eq!(argument_events[0].sse_id(), Some("evt_arguments_done"));
|
||||
assert_eq!(argument_events[0].sse_retry(), Some(250));
|
||||
assert_eq!(argument_events[1].sse_id(), None);
|
||||
assert_eq!(argument_events[1].sse_retry(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_fail_open_after_the_function_argument_buffer_limit() {
|
||||
let request = tool_request(serde_json::json!({
|
||||
"model": "client",
|
||||
"input": "wait",
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "wait",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"timeout_ms": {"type": "integer"}}
|
||||
}
|
||||
}]
|
||||
}));
|
||||
let arguments = format!(
|
||||
"{{\"timeout_ms\":1.0,\"padding\":\"{}\"}}",
|
||||
"x".repeat((1 << 20) + 1)
|
||||
);
|
||||
let mut decoder = GrokCanonicalDecoder::for_request("grok-4.5", &request);
|
||||
let added = encode_sse_event(
|
||||
"response.output_item.added",
|
||||
&serde_json::json!({
|
||||
"type": "response.output_item.added",
|
||||
"output_index": 0,
|
||||
"item": {
|
||||
"id": "item_large",
|
||||
"type": "function_call",
|
||||
"call_id": "call_large",
|
||||
"name": "wait"
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
decoder
|
||||
.push(added.as_bytes())
|
||||
.expect("large function item added");
|
||||
|
||||
let delta = encode_sse_event(
|
||||
"response.function_call_arguments.delta",
|
||||
&serde_json::json!({
|
||||
"type": "response.function_call_arguments.delta",
|
||||
"item_id": "item_large",
|
||||
"call_id": "call_large",
|
||||
"output_index": 0,
|
||||
"delta": arguments
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
let delta = decoder
|
||||
.push(delta.as_bytes())
|
||||
.expect("oversized arguments should pass through");
|
||||
let delta_wire = wire_events(&delta);
|
||||
assert_eq!(delta_wire.len(), 1);
|
||||
assert_eq!(
|
||||
delta_wire[0].data().get("delta").and_then(Value::as_str),
|
||||
Some(arguments.as_str())
|
||||
);
|
||||
|
||||
let done = encode_sse_event(
|
||||
"response.function_call_arguments.done",
|
||||
&serde_json::json!({
|
||||
"type": "response.function_call_arguments.done",
|
||||
"item_id": "item_large",
|
||||
"call_id": "call_large",
|
||||
"output_index": 0,
|
||||
"arguments": arguments
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
let done = decoder
|
||||
.push(done.as_bytes())
|
||||
.expect("oversized arguments done");
|
||||
let normalized = wire_events(&done)[0]
|
||||
.data()
|
||||
.get("arguments")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|arguments| serde_json::from_str::<Value>(arguments).ok())
|
||||
.expect("normalized final arguments");
|
||||
assert_eq!(normalized.get("timeout_ms"), Some(&serde_json::json!(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_coalesce_reasoning_item_part_and_summary_index() {
|
||||
let body = concat!(
|
||||
@@ -612,6 +881,10 @@ fn decoder_should_preserve_unknown_events_as_openai_wire() {
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n",
|
||||
"event: xai.internal.trace\n",
|
||||
"data: {\"type\":\"xai.internal.trace\",\"secret\":\"must-not-reach-client\"}\n\n",
|
||||
"event: response.doom_loop_check \n",
|
||||
"data: {\"type\":\"response.backend_tool_call.started\",\"secret\":\"drop-header\"}\n\n",
|
||||
"event: response.backend_tool_call.started\n",
|
||||
"data: {\"type\":\"response.doom_loop_check\",\"secret\":\"drop-body\"}\n\n",
|
||||
"event: response.backend_tool_call.started\n",
|
||||
"id: evt_future\n",
|
||||
"retry: 1250\n",
|
||||
@@ -639,6 +912,32 @@ fn decoder_should_preserve_unknown_events_as_openai_wire() {
|
||||
assert!(events[1].canonical_facts().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_rewrite_grok_ping_frames_as_sse_comments() {
|
||||
let body = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_ping\"}}\n\n",
|
||||
"event: ping\n",
|
||||
"data: {\"type\":\"ping\",\"x-opencode-type\":\"inference-cost\",\"cost\":2.75}\n\n",
|
||||
"event: ping\n",
|
||||
"data: {not-json}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ping\",\"status\":\"completed\"}}\n\n",
|
||||
);
|
||||
|
||||
let events = GrokCanonicalDecoder::new("fallback")
|
||||
.push(body.as_bytes())
|
||||
.expect("ping-filtered response");
|
||||
let wire = wire_events(&events);
|
||||
let ping_comments = wire
|
||||
.iter()
|
||||
.filter(|event| event.raw_sse_frame().map(AsRef::as_ref) == Some(b": ping\n\n".as_slice()))
|
||||
.count();
|
||||
|
||||
assert_eq!(ping_comments, 2);
|
||||
assert!(wire.iter().all(|event| event.event_type() != Some("ping")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_preserve_image_and_hosted_tool_items_without_inventing_facts() {
|
||||
let body = concat!(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use gateway_core::event::{GatewayEvent, ProviderEvent, ReasoningDelta, TextDelta, ToolCallDelta};
|
||||
use gateway_core::event::{
|
||||
GatewayEvent, ProtocolWireEvent, ProviderEvent, ReasoningDelta, TextDelta, ToolCallDelta,
|
||||
};
|
||||
use gateway_core::operation::{GenerateRequest, ProtocolPayload};
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
use provider_xai::{
|
||||
@@ -74,7 +76,7 @@ fn encoder_should_preserve_history_order_and_append_summary_prompt() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_should_preserve_tools_and_remove_non_prefix_constraints() {
|
||||
fn encoder_should_preserve_sub2api_compaction_constraints() {
|
||||
let request = encode(json!({
|
||||
"model": "client-model",
|
||||
"input": [
|
||||
@@ -89,21 +91,24 @@ fn encoder_should_preserve_tools_and_remove_non_prefix_constraints() {
|
||||
"prompt_cache_key": "cache_private",
|
||||
"service_tier": "priority",
|
||||
"max_output_tokens": 16,
|
||||
"background": true,
|
||||
"truncation": "auto",
|
||||
"stream": false,
|
||||
"store": true
|
||||
}))
|
||||
.expect("compaction request");
|
||||
|
||||
let body = request.body();
|
||||
let forbidden = [
|
||||
"parallel_tool_calls",
|
||||
"text",
|
||||
"previous_response_id",
|
||||
"prompt_cache_key",
|
||||
"service_tier",
|
||||
"max_output_tokens",
|
||||
];
|
||||
let forbidden = ["previous_response_id", "prompt_cache_key", "service_tier"];
|
||||
assert!(forbidden.into_iter().all(|field| !body.contains_key(field)));
|
||||
assert_eq!(body.get("parallel_tool_calls"), Some(&Value::Bool(true)));
|
||||
assert_eq!(
|
||||
body.get("text"),
|
||||
Some(&json!({"format": {"type": "json_object"}}))
|
||||
);
|
||||
assert_eq!(body.get("max_output_tokens"), Some(&json!(16)));
|
||||
assert_eq!(body.get("background"), Some(&Value::Bool(true)));
|
||||
assert_eq!(body.get("truncation"), Some(&json!("auto")));
|
||||
assert_eq!(
|
||||
body.get("tools"),
|
||||
Some(&json!([{
|
||||
@@ -112,8 +117,12 @@ fn encoder_should_preserve_tools_and_remove_non_prefix_constraints() {
|
||||
"parameters": {"type": "object"}
|
||||
}]))
|
||||
);
|
||||
assert_eq!(body.get("tool_choice"), Some(&json!("auto")));
|
||||
assert_eq!(body.get("temperature"), Some(&json!(1.0)));
|
||||
assert_eq!(body.get("tool_choice"), Some(&json!("none")));
|
||||
assert_eq!(
|
||||
body.get("include"),
|
||||
Some(&json!(["reasoning.encrypted_content"]))
|
||||
);
|
||||
assert!(!body.contains_key("temperature"));
|
||||
assert_eq!(body.get("stream"), Some(&Value::Bool(true)));
|
||||
assert_eq!(body.get("store"), Some(&Value::Bool(false)));
|
||||
}
|
||||
@@ -181,54 +190,56 @@ fn encoder_should_consume_only_the_terminal_compaction_trigger() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_return_clean_typed_summary_after_normal_terminal() {
|
||||
let summary = decode_summary(&valid_summary("checkpoint-only-secret")).expect("summary");
|
||||
fn decoder_should_preserve_sub2api_summary_text_after_trimming_edges() {
|
||||
let raw = valid_summary("checkpoint-only-secret");
|
||||
let summary = decode_summary(&format!(" {raw} \n")).expect("summary");
|
||||
|
||||
assert!(summary.as_str().starts_with("Summary:\nPrivate state:"));
|
||||
assert!(!summary.as_str().contains("<summary>"));
|
||||
assert_eq!(summary, raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_remove_nested_leading_scratchpad() {
|
||||
fn decoder_should_not_interpret_nested_summary_or_analysis_tokens() {
|
||||
let raw = format!(
|
||||
"<summary>\n<analysis>private scratchpad</analysis>\nActual state\n{}\n</summary>",
|
||||
"preserved implementation context ".repeat(20)
|
||||
);
|
||||
let summary = decode_summary(&raw).expect("clean summary");
|
||||
let summary = decode_summary(&raw).expect("summary");
|
||||
|
||||
assert!(summary.as_str().contains("Actual state"));
|
||||
assert!(!summary.as_str().contains("private scratchpad"));
|
||||
assert!(summary.as_str().contains("private scratchpad"));
|
||||
assert!(summary.as_str().starts_with("<summary>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_remove_top_level_leading_scratchpad() {
|
||||
fn decoder_should_preserve_top_level_analysis_as_upstream_text() {
|
||||
let raw = format!(
|
||||
"<analysis>private scratchpad</analysis>\n{}",
|
||||
valid_summary("actual state")
|
||||
);
|
||||
let summary = decode_summary(&raw).expect("clean summary");
|
||||
let summary = decode_summary(&raw).expect("summary");
|
||||
|
||||
assert!(!summary.as_str().contains("private scratchpad"));
|
||||
assert!(summary.as_str().contains("private scratchpad"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_keep_summary_after_unclosed_leading_scratchpad() {
|
||||
fn decoder_should_preserve_unclosed_leading_analysis() {
|
||||
let raw = format!(
|
||||
"<analysis>truncated private scratchpad\n{}",
|
||||
valid_summary("actual state")
|
||||
);
|
||||
let summary = decode_summary(&raw).expect("clean summary");
|
||||
let summary = decode_summary(&raw).expect("summary");
|
||||
|
||||
assert!(summary.as_str().contains("truncated private scratchpad"));
|
||||
assert!(summary.as_str().contains("actual state"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_neutralize_live_compaction_control_tokens_in_body() {
|
||||
fn decoder_should_preserve_compaction_control_tokens_like_sub2api() {
|
||||
let raw = valid_summary(
|
||||
"Quoted <summary> and </summary>, <analysis> and </analysis>, \
|
||||
plus <summary_request> and </summary_request>.",
|
||||
);
|
||||
let summary = decode_summary(&raw).expect("clean summary");
|
||||
let summary = decode_summary(&raw).expect("summary");
|
||||
|
||||
for token in [
|
||||
"<summary>",
|
||||
@@ -238,20 +249,20 @@ fn decoder_should_neutralize_live_compaction_control_tokens_in_body() {
|
||||
"<summary_request>",
|
||||
"</summary_request>",
|
||||
] {
|
||||
assert!(!summary.as_str().contains(token), "live token {token}");
|
||||
assert!(summary.as_str().contains(token), "token {token}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_preserve_unclosed_summary_body_without_live_token() {
|
||||
fn decoder_should_preserve_unclosed_summary_token() {
|
||||
let raw = format!(
|
||||
"<summary>\nActual state\n{}",
|
||||
"preserved implementation context ".repeat(20)
|
||||
);
|
||||
let summary = decode_summary(&raw).expect("clean summary");
|
||||
let summary = decode_summary(&raw).expect("summary");
|
||||
|
||||
assert!(summary.as_str().contains("Actual state"));
|
||||
assert!(!summary.as_str().contains("<summary>"));
|
||||
assert!(summary.as_str().contains("<summary>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -267,17 +278,95 @@ fn decoder_should_preserve_numbered_sections_that_quote_analysis_close() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_reject_degenerate_summary() {
|
||||
let error = decode_summary("<summary>too short</summary>").expect_err("short summary");
|
||||
fn decoder_should_accept_short_summary_like_sub2api() {
|
||||
let summary = decode_summary("<summary>too short</summary>").expect("short summary");
|
||||
|
||||
assert_eq!(error, GrokCompactionDecodeError::Degenerate);
|
||||
assert_eq!(summary, "<summary>too short</summary>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_reject_empty_cleaned_summary_as_degenerate() {
|
||||
let error = decode_summary("<analysis>draft only</analysis>").expect_err("empty summary");
|
||||
fn decoder_should_accept_empty_summary_text() {
|
||||
let summary = decode_summary(" \n\t ").expect("empty summary");
|
||||
|
||||
assert_eq!(error, GrokCompactionDecodeError::Degenerate);
|
||||
assert!(summary.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_fall_back_to_completed_output_without_text_deltas() {
|
||||
let mut decoder = GrokCompactionSummaryDecoder::new();
|
||||
let wire = ProtocolWireEvent::json_with_sse_metadata(
|
||||
"openai",
|
||||
Some("response.completed".to_owned()),
|
||||
json!({
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"output": [
|
||||
{
|
||||
"type": "reasoning",
|
||||
"encrypted_content": "real-ciphertext"
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"content": [
|
||||
{"type": "output_text", "text": "first part"},
|
||||
{"type": "output_text", "text": "second part"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("terminal wire event");
|
||||
decoder
|
||||
.observe(&ProviderEvent::wire(wire))
|
||||
.expect("terminal response");
|
||||
|
||||
assert_eq!(
|
||||
decoder.finish().expect("summary"),
|
||||
"first part\nsecond part"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_prefer_complete_terminal_output_over_partial_deltas() {
|
||||
let mut decoder = GrokCompactionSummaryDecoder::new();
|
||||
decoder
|
||||
.observe(&ProviderEvent::canonical(GatewayEvent::TextDelta(
|
||||
TextDelta {
|
||||
content_index: 0,
|
||||
text: "partial summary".to_owned(),
|
||||
},
|
||||
)))
|
||||
.expect("partial summary delta");
|
||||
let wire = ProtocolWireEvent::json_with_sse_metadata(
|
||||
"openai",
|
||||
Some("response.completed".to_owned()),
|
||||
json!({
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"content": [{
|
||||
"type": "output_text",
|
||||
"text": "complete terminal summary"
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("terminal wire event");
|
||||
decoder
|
||||
.observe(&ProviderEvent::wire(wire))
|
||||
.expect("terminal response");
|
||||
|
||||
assert_eq!(
|
||||
decoder.finish().expect("summary"),
|
||||
"complete terminal summary"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -26,8 +26,10 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"tool_choice": "auto",
|
||||
"temperature": 1.0,
|
||||
"tool_choice": "none",
|
||||
"include": [
|
||||
"reasoning.encrypted_content"
|
||||
],
|
||||
"store": false,
|
||||
"stream": true
|
||||
}
|
||||
|
||||
@@ -262,6 +262,8 @@ async fn inference_transport_should_classify_http_failures_without_retaining_bod
|
||||
(408, GrokInferenceTransportErrorKind::Timeout),
|
||||
(429, GrokInferenceTransportErrorKind::RateLimited),
|
||||
(500, GrokInferenceTransportErrorKind::Unavailable),
|
||||
(504, GrokInferenceTransportErrorKind::Unavailable),
|
||||
(529, GrokInferenceTransportErrorKind::Unavailable),
|
||||
(418, GrokInferenceTransportErrorKind::Protocol),
|
||||
];
|
||||
|
||||
@@ -332,7 +334,7 @@ async fn inference_transport_should_expose_safe_flat_json_error_details() {
|
||||
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
GrokInferenceTransportErrorKind::QuotaExhausted
|
||||
GrokInferenceTransportErrorKind::PaymentRequired
|
||||
);
|
||||
assert_eq!(
|
||||
(detail.message(), detail.code(), detail.error_type()),
|
||||
@@ -395,16 +397,22 @@ async fn inference_transport_should_scope_forbidden_failures_from_safe_metadata(
|
||||
),
|
||||
(
|
||||
json!({"error": {"code": "permission_denied", "message": "access to the chat endpoint is denied"}}),
|
||||
GrokInferenceTransportErrorKind::ModelAccessDenied,
|
||||
GrokInferenceTransportErrorKind::PermissionDenied,
|
||||
false,
|
||||
Some("permission_denied"),
|
||||
),
|
||||
(
|
||||
json!({"error": {"code": "permission_denied", "message": "request rejected", "details": {"check": "SAFETY_CHECK_TYPE_INPUT"}}}),
|
||||
GrokInferenceTransportErrorKind::SafetyRejected,
|
||||
GrokInferenceTransportErrorKind::PermissionDenied,
|
||||
false,
|
||||
Some("permission_denied"),
|
||||
),
|
||||
(
|
||||
json!({"error": {"code": "invalid_token", "message": "request blocked by policy"}}),
|
||||
GrokInferenceTransportErrorKind::SafetyRejected,
|
||||
false,
|
||||
Some("invalid_token"),
|
||||
),
|
||||
(
|
||||
json!({"error": {"code": "policy_denied", "message": "request rejected"}}),
|
||||
GrokInferenceTransportErrorKind::PermissionDenied,
|
||||
@@ -558,11 +566,70 @@ async fn inference_transport_should_classify_free_usage_429_as_account_free_quot
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inference_transport_should_apply_sub2api_body_aware_400_failures() {
|
||||
let cases = [
|
||||
(
|
||||
json!({"error": {"code": "subscription:free-usage-exhausted", "message": "free usage exhausted"}}),
|
||||
GrokInferenceTransportErrorKind::FreeQuotaExhausted,
|
||||
Some("subscription_free_usage_exhausted"),
|
||||
),
|
||||
(
|
||||
json!({"error": {"message": "selected model is at capacity"}}),
|
||||
GrokInferenceTransportErrorKind::RateLimited,
|
||||
Some("model_capacity"),
|
||||
),
|
||||
(
|
||||
json!({"error": {"message": "spending limit reached"}}),
|
||||
GrokInferenceTransportErrorKind::PaymentRequired,
|
||||
Some("billing_quota"),
|
||||
),
|
||||
(
|
||||
json!({"error": {"message": "empty model output: no content/tool_calls"}}),
|
||||
GrokInferenceTransportErrorKind::Unavailable,
|
||||
Some("empty_upstream"),
|
||||
),
|
||||
(
|
||||
json!({"error": {"message": "rate limit exceeded"}}),
|
||||
GrokInferenceTransportErrorKind::InvalidRequest,
|
||||
Some("rate_limit"),
|
||||
),
|
||||
(
|
||||
json!({"error": {"message": "invalid tool schema"}}),
|
||||
GrokInferenceTransportErrorKind::InvalidRequest,
|
||||
None,
|
||||
),
|
||||
];
|
||||
|
||||
for (body, expected_kind, expected_code) in cases {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.respond_with(ResponseTemplate::new(400).set_body_json(body))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let origin = Url::parse(&server.uri()).expect("wiremock origin");
|
||||
let error = inference_transport(&origin)
|
||||
.execute(inference_request(&origin))
|
||||
.await
|
||||
.expect_err("400 response must be classified");
|
||||
|
||||
assert_eq!(error.kind(), expected_kind);
|
||||
assert_eq!(
|
||||
error.upstream_code().map(|code| code.as_str()),
|
||||
expected_code
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inference_transport_should_bound_retry_after_to_the_safe_window() {
|
||||
for (header, expected) in [
|
||||
("120", Some(Duration::from_secs(120))),
|
||||
("121", None),
|
||||
("121", Some(Duration::from_secs(121))),
|
||||
("86400", Some(Duration::from_secs(86_400))),
|
||||
("86401", None),
|
||||
("0", None),
|
||||
] {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
@@ -51,6 +51,10 @@ fn encoder_should_preserve_raw_images_hosted_tools_and_unknown_fields() {
|
||||
assert_eq!(body.pointer("/model"), Some(&json!("grok-routed")));
|
||||
assert_eq!(body.pointer("/stream"), Some(&json!(true)));
|
||||
assert_eq!(body.pointer("/store"), Some(&json!(true)));
|
||||
assert_eq!(
|
||||
body.pointer("/include"),
|
||||
Some(&json!(["reasoning.encrypted_content"]))
|
||||
);
|
||||
assert_eq!(
|
||||
body.pointer("/input/0/content/1/detail"),
|
||||
Some(&json!("high"))
|
||||
@@ -71,25 +75,230 @@ fn encoder_should_preserve_raw_images_hosted_tools_and_unknown_fields() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_should_strip_openai_only_client_metadata_before_grok_build() {
|
||||
fn encoder_should_strip_all_client_metadata_before_grok_build() {
|
||||
let request = raw_request(json!({
|
||||
"model": "client-model",
|
||||
"input": "hello",
|
||||
"client_metadata": {
|
||||
"x-openai-subagent": "review",
|
||||
"application_tag": "preserve-me"
|
||||
}
|
||||
},
|
||||
"metadata": {"application_tag": "keep-this"}
|
||||
}));
|
||||
|
||||
let encoded = GrokResponsesRequest::encode(&request, "grok-routed", &client_key())
|
||||
.expect("sanitized request");
|
||||
let body = Value::Object(encoded.body().clone());
|
||||
|
||||
assert_eq!(body.pointer("/client_metadata/x-openai-subagent"), None);
|
||||
assert_eq!(body.pointer("/client_metadata"), None);
|
||||
assert_eq!(
|
||||
body.pointer("/client_metadata/application_tag"),
|
||||
Some(&json!("preserve-me"))
|
||||
body.pointer("/metadata/application_tag"),
|
||||
Some(&json!("keep-this"))
|
||||
);
|
||||
|
||||
let request = raw_request(json!({
|
||||
"model": "client-model",
|
||||
"input": "hello",
|
||||
"client_metadata": "opaque-local-envelope"
|
||||
}));
|
||||
let encoded = GrokResponsesRequest::encode(&request, "grok-routed", &client_key())
|
||||
.expect("scalar client metadata");
|
||||
assert_eq!(encoded.body().get("client_metadata"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_should_apply_build_defaults_and_normalize_reasoning_effort() {
|
||||
let request = raw_request(json!({
|
||||
"model": "client-model",
|
||||
"input": "hello",
|
||||
"include": ["web_search_call.action.sources"],
|
||||
"reasoning": {"effort": "XHIGH", "summary": "auto"}
|
||||
}));
|
||||
|
||||
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
|
||||
.expect("normalized request");
|
||||
let body = Value::Object(encoded.body().clone());
|
||||
|
||||
assert_eq!(body.pointer("/store"), Some(&json!(false)));
|
||||
assert_eq!(
|
||||
body.pointer("/include"),
|
||||
Some(&json!([
|
||||
"web_search_call.action.sources",
|
||||
"reasoning.encrypted_content"
|
||||
]))
|
||||
);
|
||||
assert_eq!(body.pointer("/reasoning/effort"), Some(&json!("high")));
|
||||
assert_eq!(body.pointer("/reasoning/summary"), Some(&json!("auto")));
|
||||
|
||||
let request = raw_request(json!({
|
||||
"model": "client-model",
|
||||
"input": "hello",
|
||||
"reasoning": {"effort": "max"},
|
||||
"reasoning_effort": "extra_high",
|
||||
"reasoningEffort": "minimal"
|
||||
}));
|
||||
let encoded = GrokResponsesRequest::encode(&request, "xai/grok-4.6-latest", &client_key())
|
||||
.expect("aliased request");
|
||||
let body = Value::Object(encoded.body().clone());
|
||||
assert_eq!(body.pointer("/model"), Some(&json!("grok-4.6")));
|
||||
assert_eq!(body.pointer("/reasoning/effort"), Some(&json!("high")));
|
||||
assert_eq!(body.pointer("/reasoning_effort"), Some(&json!("high")));
|
||||
assert_eq!(body.pointer("/reasoningEffort"), None);
|
||||
|
||||
let request = raw_request(json!({
|
||||
"model": "client-model",
|
||||
"input": "hello",
|
||||
"reasoning": {"effort": "high", "summary": "auto"}
|
||||
}));
|
||||
let encoded = GrokResponsesRequest::encode(&request, "grok-composer-2.5-fast", &client_key())
|
||||
.expect("Composer request");
|
||||
let body = Value::Object(encoded.body().clone());
|
||||
assert_eq!(body.pointer("/reasoning"), None);
|
||||
|
||||
let request = raw_request(json!({
|
||||
"model": "client-model",
|
||||
"input": "hello",
|
||||
"store": null,
|
||||
"include": null,
|
||||
"reasoning": {"effort": "max"}
|
||||
}));
|
||||
let encoded = GrokResponsesRequest::encode(&request, "grok-composer-2.5-fast", &client_key())
|
||||
.expect("empty Composer reasoning");
|
||||
let body = Value::Object(encoded.body().clone());
|
||||
assert_eq!(body.pointer("/store"), Some(&json!(false)));
|
||||
assert_eq!(
|
||||
body.pointer("/include"),
|
||||
Some(&json!(["reasoning.encrypted_content"]))
|
||||
);
|
||||
assert_eq!(body.pointer("/reasoning"), None);
|
||||
|
||||
let request = raw_request(json!({
|
||||
"model": "client-model",
|
||||
"input": "hello",
|
||||
"reasoning": {}
|
||||
}));
|
||||
let encoded = GrokResponsesRequest::encode(&request, "grok-composer-2.5-fast", &client_key())
|
||||
.expect("Composer reasoning without effort");
|
||||
assert_eq!(
|
||||
Value::Object(encoded.body().clone()).pointer("/reasoning"),
|
||||
None
|
||||
);
|
||||
|
||||
let request = raw_request(json!({
|
||||
"model": "client-model",
|
||||
"input": "hello",
|
||||
"reasoning": {"effort": "max"}
|
||||
}));
|
||||
let encoded = GrokResponsesRequest::encode(&request, "tenant/foo/grok-4.6", &client_key())
|
||||
.expect("unknown provider prefix");
|
||||
assert_eq!(
|
||||
Value::Object(encoded.body().clone()).pointer("/reasoning/effort"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_should_strip_sub2api_grok_unsupported_fields() {
|
||||
let request = raw_request(json!({
|
||||
"model": "client-model",
|
||||
"input": "hello",
|
||||
"prompt_cache_retention": "24h",
|
||||
"safety_identifier": "user-1",
|
||||
"presence_penalty": 0.1,
|
||||
"presencePenalty": 0.2,
|
||||
"frequency_penalty": 0.3,
|
||||
"frequencyPenalty": 0.4,
|
||||
"stop": ["done"],
|
||||
"external_web_access": true,
|
||||
"metadata": {"external_web_access": false},
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "lookup",
|
||||
"external_web_access": true,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"q": {"type": "string", "external_web_access": true}}
|
||||
}
|
||||
}]
|
||||
}));
|
||||
|
||||
let encoded = GrokResponsesRequest::encode(&request, "grok-latest", &client_key())
|
||||
.expect("sanitized 4.5 request");
|
||||
let body = Value::Object(encoded.body().clone());
|
||||
for pointer in [
|
||||
"/prompt_cache_retention",
|
||||
"/safety_identifier",
|
||||
"/presence_penalty",
|
||||
"/presencePenalty",
|
||||
"/frequency_penalty",
|
||||
"/frequencyPenalty",
|
||||
"/stop",
|
||||
"/external_web_access",
|
||||
"/metadata/external_web_access",
|
||||
"/tools/0/external_web_access",
|
||||
"/tools/0/parameters/properties/q/external_web_access",
|
||||
] {
|
||||
assert_eq!(body.pointer(pointer), None, "field survived at {pointer}");
|
||||
}
|
||||
|
||||
let request = raw_request(json!({
|
||||
"model": "client-model",
|
||||
"input": "hello",
|
||||
"logprobs": true,
|
||||
"top_logprobs": 5
|
||||
}));
|
||||
let encoded = GrokResponsesRequest::encode(&request, "grok-4.20-reasoning", &client_key())
|
||||
.expect("sanitized 4.20 request");
|
||||
let body = Value::Object(encoded.body().clone());
|
||||
assert_eq!(
|
||||
body.pointer("/model"),
|
||||
Some(&json!("grok-4.20-0309-reasoning"))
|
||||
);
|
||||
assert_eq!(body.pointer("/logprobs"), None);
|
||||
assert_eq!(body.pointer("/top_logprobs"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_web_access_false_should_strip_only_the_unsupported_field() {
|
||||
let request = raw_request(json!({
|
||||
"model": "client-model",
|
||||
"input": "hello",
|
||||
"tools": [{
|
||||
"type": "web_search",
|
||||
"external_web_access": false
|
||||
}],
|
||||
"tool_choice": {"type": "web_search"}
|
||||
}));
|
||||
|
||||
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
|
||||
.expect("web search survives field sanitization");
|
||||
let body = Value::Object(encoded.body().clone());
|
||||
|
||||
assert_eq!(
|
||||
body.pointer("/tools/0"),
|
||||
Some(&json!({"type": "web_search"}))
|
||||
);
|
||||
assert_eq!(
|
||||
body.pointer("/tool_choice"),
|
||||
Some(&json!({"type": "web_search"}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_should_reject_non_string_build_include_values() {
|
||||
for include in [json!("reasoning.encrypted_content"), json!([1])] {
|
||||
let request = raw_request(json!({
|
||||
"model": "client-model",
|
||||
"input": "hello",
|
||||
"include": include
|
||||
}));
|
||||
let error = GrokResponsesRequest::encode(&request, "grok-routed", &client_key())
|
||||
.expect_err("invalid include");
|
||||
assert!(matches!(
|
||||
error,
|
||||
GrokRequestEncodeError::InvalidRequestField { field: "include" }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -326,6 +535,28 @@ fn function_parameters_should_remove_only_nullable_object_roots() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_parameters_should_default_missing_or_null_schemas() {
|
||||
let request = raw_request(json!({
|
||||
"model": "client",
|
||||
"input": "hello",
|
||||
"tools": [
|
||||
{"type": "function", "name": "lookup"},
|
||||
{"type": "function", "name": "wait", "parameters": null}
|
||||
]
|
||||
}));
|
||||
|
||||
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
|
||||
.expect("default function schemas");
|
||||
let body = Value::Object(encoded.body().clone());
|
||||
for index in 0..2 {
|
||||
assert_eq!(
|
||||
body.pointer(&format!("/tools/{index}/parameters")),
|
||||
Some(&json!({"type": "object", "properties": {}}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_parameters_should_keep_local_object_refs_after_removing_null() {
|
||||
let request = raw_request(json!({
|
||||
@@ -411,6 +642,8 @@ fn explicit_session_should_add_x_search_after_codex_additional_tools_normalizati
|
||||
let body = Value::Object(encoded.body().clone());
|
||||
|
||||
assert_eq!(body.pointer("/input/0/type"), Some(&json!("message")));
|
||||
assert_eq!(body.pointer("/input/0/role"), Some(&json!("user")));
|
||||
assert_eq!(body.pointer("/input/1"), None);
|
||||
assert_eq!(body.pointer("/tools/0/name"), Some(&json!("apply_patch")));
|
||||
assert_eq!(
|
||||
body.pointer("/tools/0/parameters/required"),
|
||||
@@ -422,7 +655,55 @@ fn explicit_session_should_add_x_search_after_codex_additional_tools_normalizati
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_session_should_add_x_search_to_allowed_tools_choice() {
|
||||
fn additional_tools_should_keep_top_level_definition_and_drop_unsupported_types() {
|
||||
let request = raw_request(json!({
|
||||
"model": "client",
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "existing",
|
||||
"description": "top-level wins",
|
||||
"parameters": {"type": "object"}
|
||||
},
|
||||
{"type": "image_generation", "model": "gpt-image-2"}
|
||||
],
|
||||
"tool_choice": "auto",
|
||||
"input": [
|
||||
{
|
||||
"type": "additional_tools",
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "existing",
|
||||
"description": "carrier duplicate",
|
||||
"parameters": {"type": "object"}
|
||||
},
|
||||
{"type": "function", "name": "wait"},
|
||||
{"type": "future_client_tool", "name": "drop-me"}
|
||||
]
|
||||
},
|
||||
{"type": "message", "role": "user", "content": "hello"}
|
||||
]
|
||||
}));
|
||||
|
||||
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
|
||||
.expect("promoted additional tools");
|
||||
let body = Value::Object(encoded.body().clone());
|
||||
|
||||
assert_eq!(body.pointer("/input/0/role"), Some(&json!("user")));
|
||||
assert_eq!(body.pointer("/input/1"), None);
|
||||
assert_eq!(body.pointer("/tools/0/name"), Some(&json!("existing")));
|
||||
assert_eq!(
|
||||
body.pointer("/tools/0/description"),
|
||||
Some(&json!("top-level wins"))
|
||||
);
|
||||
assert_eq!(body.pointer("/tools/1/name"), Some(&json!("wait")));
|
||||
assert_eq!(body.pointer("/tools/2"), None);
|
||||
assert_eq!(body.pointer("/tool_choice"), Some(&json!("auto")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_session_should_drop_xai_unsupported_allowed_tools_choice() {
|
||||
let request = raw_request(json!({
|
||||
"model": "client",
|
||||
"prompt_cache_key": "conversation-42",
|
||||
@@ -443,10 +724,8 @@ fn explicit_session_should_add_x_search_to_allowed_tools_choice() {
|
||||
GrokResponsesRequest::encode(&request, "grok-4.5", &client_key()).expect("request");
|
||||
let body = Value::Object(encoded.body().clone());
|
||||
|
||||
assert_eq!(
|
||||
body.pointer("/tool_choice/tools/1"),
|
||||
Some(&json!({"type": "x_search"}))
|
||||
);
|
||||
assert_eq!(body.pointer("/tool_choice"), None);
|
||||
assert_eq!(body.pointer("/tools/1"), Some(&json!({"type": "x_search"})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -664,7 +943,7 @@ fn custom_apply_patch_declaration_should_use_patch_parameter() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_tool_choice_should_narrow_visible_tools_and_require_the_selected_kind() {
|
||||
fn hosted_tool_choice_should_preserve_the_sub2api_specific_choice() {
|
||||
let request = raw_request(json!({
|
||||
"model": "client",
|
||||
"input": "search",
|
||||
@@ -679,13 +958,19 @@ fn hosted_tool_choice_should_narrow_visible_tools_and_require_the_selected_kind(
|
||||
GrokResponsesRequest::encode(&request, "grok-4.5", &client_key()).expect("hosted choice");
|
||||
let body = Value::Object(encoded.body().clone());
|
||||
|
||||
assert_eq!(body.pointer("/tool_choice"), Some(&json!("required")));
|
||||
assert_eq!(
|
||||
body.pointer("/tool_choice"),
|
||||
Some(&json!({"type": "web_search"}))
|
||||
);
|
||||
assert_eq!(
|
||||
body.pointer("/tools"),
|
||||
Some(&json!([{
|
||||
"type": "web_search",
|
||||
"filters": {"allowed_domains": ["example.com"]}
|
||||
}]))
|
||||
Some(&json!([
|
||||
{
|
||||
"type": "web_search",
|
||||
"filters": {"allowed_domains": ["example.com"]}
|
||||
},
|
||||
{"type": "x_search"}
|
||||
]))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -927,7 +1212,6 @@ fn compaction_history_should_become_plaintext_user_continuation_in_place() {
|
||||
{"type": "message", "role": "user", "content": "before compaction"},
|
||||
{
|
||||
"type": "compaction",
|
||||
"id": "cmp_1",
|
||||
"encrypted_content": "Repository state and pending work."
|
||||
},
|
||||
{"type": "message", "role": "user", "content": "continue"}
|
||||
@@ -960,7 +1244,53 @@ fn compaction_history_should_become_plaintext_user_continuation_in_place() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compaction_history_should_reject_missing_empty_or_non_string_plaintext() {
|
||||
fn sub2api_compaction_history_should_restore_reasoning_ciphertext_and_visible_summary() {
|
||||
for item_type in ["compaction", "compaction_summary"] {
|
||||
let request = raw_request(json!({
|
||||
"model": "client",
|
||||
"input": [
|
||||
{
|
||||
"type": item_type,
|
||||
"id": "cmp_1",
|
||||
"status": "completed",
|
||||
"encrypted_content": "grok-encrypted-state",
|
||||
"summary": [
|
||||
{"type": "summary_text", "text": "first part"},
|
||||
{"type": "summary_text", "text": "second part"}
|
||||
]
|
||||
},
|
||||
{"type": "message", "role": "user", "content": "continue"}
|
||||
]
|
||||
}));
|
||||
|
||||
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
|
||||
.expect("sub2api compaction continuation");
|
||||
let body = Value::Object(encoded.body().clone());
|
||||
|
||||
assert_eq!(
|
||||
body.pointer("/input"),
|
||||
Some(&json!([
|
||||
{
|
||||
"type": "reasoning",
|
||||
"summary": [],
|
||||
"encrypted_content": "grok-encrypted-state"
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
"text": "<conversation_summary>\nfirst part\nsecond part\n</conversation_summary>"
|
||||
}]
|
||||
},
|
||||
{"type": "message", "role": "user", "content": "continue"}
|
||||
]))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_compaction_history_should_be_dropped_like_sub2api() {
|
||||
for encrypted_content in [
|
||||
None,
|
||||
Some(json!(null)),
|
||||
@@ -978,12 +1308,9 @@ fn compaction_history_should_reject_missing_empty_or_non_string_plaintext() {
|
||||
"model": "client",
|
||||
"input": [Value::Object(compaction)]
|
||||
}));
|
||||
|
||||
assert_eq!(
|
||||
GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
|
||||
.expect_err("invalid compaction plaintext"),
|
||||
GrokRequestEncodeError::InvalidRequestField { field: "input" }
|
||||
);
|
||||
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
|
||||
.expect("malformed compaction item is omitted");
|
||||
assert_eq!(encoded.body().get("input"), Some(&json!([])));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1025,12 +1352,34 @@ fn tool_search_history_should_load_returned_tools_at_the_original_turn() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_or_ambiguous_tool_contracts_should_fail_before_upstream_io() {
|
||||
fn unsupported_tools_should_be_filtered_with_their_orphaned_choice() {
|
||||
let request = raw_request(json!({
|
||||
"model": "client",
|
||||
"input": "x",
|
||||
"tools": [{"type": "future_tool"}],
|
||||
"tool_choice": {"type": "future_tool"}
|
||||
}));
|
||||
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
|
||||
.expect("unsupported tool filter");
|
||||
assert_eq!(encoded.body().get("tools"), None);
|
||||
assert_eq!(encoded.body().get("tool_choice"), None);
|
||||
|
||||
let request = raw_request(json!({
|
||||
"model": "client",
|
||||
"input": "x",
|
||||
"tools": [{"type": "function", "name": "kept"}],
|
||||
"tool_choice": {"type": "function", "name": "missing"}
|
||||
}));
|
||||
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
|
||||
.expect("orphaned function choice filter");
|
||||
let body = Value::Object(encoded.body().clone());
|
||||
assert_eq!(body.pointer("/tools/0/name"), Some(&json!("kept")));
|
||||
assert_eq!(encoded.body().get("tool_choice"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_tool_contracts_should_fail_before_upstream_io() {
|
||||
for (body, field) in [
|
||||
(
|
||||
json!({"model": "client", "input": "x", "tools": [{"type": "future_tool"}]}),
|
||||
"tools",
|
||||
),
|
||||
(
|
||||
json!({"model": "client", "input": "x", "tools": [
|
||||
{"type": "tool_search", "execution": "client"},
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@ UID/GID 和 mode,因此配置由当前用户持有,并只向容器组 `10001
|
||||
|
||||
模板中的 `openai` / `xai` 只保留请求画像启动基线。OpenAI 的上游地址、WebSocket 池、额度刷新
|
||||
与 OAuth 设置,以及 xAI 的 OAuth、额度和模型目录策略,均由各自 Provider 使用代码内默认值管理;
|
||||
模板不重复列出这些默认项。运行后,OpenAI CLI/Desktop 与 xAI CLI 的官方发布检查只更新进程内
|
||||
模板不重复列出这些默认项。运行后,OpenAI Desktop 与 xAI CLI 的官方发布检查只更新进程内
|
||||
版本字段,不回写 `config.yaml`;检查失败时继续使用上一份有效画像。
|
||||
|
||||
## 启动
|
||||
|
||||
@@ -57,17 +57,17 @@ api:
|
||||
openai:
|
||||
# 上游地址、WebSocket 池、额度刷新和 OAuth 使用 Provider 内置默认值;模板只保留画像启动基线。
|
||||
wire_profile:
|
||||
# codex_version 是 npm CLI 的启动基线,并用于模型目录与请求 UA;Provider 每 24 小时更新它。
|
||||
# Desktop appcast 同期只更新 Desktop 版本与构建号。
|
||||
# 版本元组来自同一个官方 macOS Desktop ZIP;Provider 每 24 小时检查 appcast,
|
||||
# 仅在包内 Core 核验和 Redis 条件覆盖都成功后原子更新。
|
||||
originator: 'Codex Desktop'
|
||||
codex_version: '0.146.0'
|
||||
desktop_version: '26.727.51351'
|
||||
desktop_build: '6119'
|
||||
codex_version: '0.147.0-alpha.6.6'
|
||||
desktop_version: '26.803.81509'
|
||||
desktop_build: '6415'
|
||||
os_type: 'Mac OS'
|
||||
os_version: '15.7.1'
|
||||
arch: 'arm64'
|
||||
terminal: 'unknown'
|
||||
verified_at: '2026-08-04T00:00:00+08:00'
|
||||
verified_at: '2026-08-03T00:00:00+08:00'
|
||||
|
||||
xai:
|
||||
# OAuth 端点、额度与模型目录策略由 xAI Provider 固定管理;这里只声明请求画像启动基线。
|
||||
|
||||
+13
-4
@@ -10,8 +10,8 @@ Codex Proxy RS 是单进程、单副本部署的多 Provider AI 网关:
|
||||
- 管理面提供 `/api/admin/*` 和 Vue 静态管理端。
|
||||
- 当前 Provider 为 OpenAI 与 xAI;账号 credential 均由所属 Provider 独占解释。
|
||||
- OpenAI 行为以重构前正式实现为语义基准;xAI/Grok 行为以 `grok2api` 验证结果为基准。
|
||||
- 两个 Provider 的请求画像都由启动配置提供基线,并通过进程级共享状态向每次新请求发布一致快照。OpenAI CLI、OpenAI Desktop 与 xAI CLI 的官方版本检查会分别原子更新自己负责的版本字段。
|
||||
- OpenAI CLI 使用官方 npm 包 `@openai/codex`,OpenAI Desktop 使用官方 appcast,xAI CLI 使用官方 npm 包 `@xai-official/grok`。检查失败保留上一份成功画像;检查成功后 OAuth、catalog、quota/billing、inference 与 Dashboard 自动采用最新版本。
|
||||
- 两个 Provider 的请求画像都由启动配置提供基线,并通过进程级共享状态向每次新请求发布一致快照。OpenAI Desktop 与 xAI CLI 的官方版本检查会分别原子更新自己负责的版本字段。
|
||||
- OpenAI Desktop 使用官方 appcast,并从同一 ZIP 的内嵌 Core 做有界 Range 探测;xAI CLI 使用官方 npm 包 `@xai-official/grok`。检查失败保留上一份成功画像;检查成功后 OAuth、catalog、quota/billing、inference 与 Dashboard 自动采用最新版本。OpenAI 制品画像写入 Redis 单键并使用 TTL,避免按发布次数堆积。
|
||||
- PostgreSQL 是业务与配置事实的唯一权威存储。
|
||||
- Redis 只保存可丢失、可重建或有自然过期时间的协调状态。
|
||||
|
||||
@@ -148,7 +148,16 @@ OpenAI OAuth 导入先取得 access token(仅有 refresh token 时先交换)
|
||||
由 `accessToken` 补齐。解析器只读取 `email`、`https://api.openai.com/profile` 与
|
||||
`https://api.openai.com/auth` 中的官方 claims,不调用 `whoami`,也不使用导入文档顶层的
|
||||
`userId/accountId` 补身份。两个令牌都没有用户身份 claims 时,账号以未验证错误状态入库。首次 OAuth
|
||||
在服务端 `state`、PKCE 与官方 token exchange 成功后解析并保存同一 token set。重新授权的目标绑定
|
||||
返回与当前 Codex Desktop 一致的两层登录地址:内层使用 `auth.openai.com/oauth/authorize` 的
|
||||
官方参数顺序,外层使用 `https://chatgpt.com/codex/desktop-auth` 并开启
|
||||
`codex_streamlined_login`。`codex_app_version` 每次从共享 Desktop 画像快照读取;
|
||||
首次授权在 10 分钟 pending flow 中生成账号级 `installation_id`,换码成功后把同一个值写入 credential。
|
||||
`source_surface_stable_id` 与 `codex_origin_stable_id` 由该 ID 经过带版本域的 SHA-256 确定性派生,并映射为
|
||||
UUIDv4;重新授权读取既有 `installation_id`,因此得到相同的 surface ID。系统不额外持久化 surface ID,
|
||||
也不再从全局 HMAC 身份锚点派生;它不等同于 DeviceCheck attestation。为了对齐 macOS Desktop
|
||||
交给外部浏览器的最终 `chatgpt.com` URL,Provider 同样携带 `no_universal_links=1` 浏览器路由参数。
|
||||
服务端随后仍以 `state`、PKCE
|
||||
与官方 token exchange 成功后解析并保存同一 token set。重新授权的目标绑定
|
||||
只保存目标账号 ID,不接受或冻结客户端提供的 credential revision;complete 阶段重新读取目标账号及其当前 revision,并以当前 revision 做最终 CAS。
|
||||
重新授权和手工或后台 RT refresh 只轮换目标账号的 token,保留既有账号资料与 OAuth principal。
|
||||
|
||||
@@ -262,7 +271,7 @@ HTTP serve/drain 与 worker 运行时都在 `gateway-host`:Host 负责注册
|
||||
Cron 游标、恢复中间状态、完成删除收敛、领取并执行一个 queued 任务、执行一小批保留清理;长时间
|
||||
`pg_dump` 与 multipart 上传由 daemon 自身持有,Host 只负责 panic 后重启、健康与关闭。它不使用
|
||||
Redis leader lease(单副本无竞争),只依赖 PostgreSQL 部分唯一约束防御并发 API/调度创建。
|
||||
- Provider:credential refresh、quota/catalog 健康,以及模型 etag 与官方版本 release 检查(OpenAI CLI、OpenAI Desktop、xAI CLI)。
|
||||
- Provider:credential refresh、quota/catalog 健康,以及模型 etag 与官方版本 release 检查(OpenAI Desktop、xAI CLI)。
|
||||
|
||||
带 lease 的周期任务在每个周期开始向 Redis 申请一次 leader lease,周期内续租,周期结束即释放。这只是单周期互斥,不是跨周期 leader 选举:各副本独立计时,N 副本部署下同一任务的实际执行频率最坏可达单副本的 N 倍。
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ const props = withDefaults(
|
||||
variant?: ButtonVariant
|
||||
size?: ButtonSize
|
||||
loading?: boolean
|
||||
spinIconOnLoading?: boolean
|
||||
disabled?: boolean
|
||||
active?: boolean
|
||||
iconOnly?: boolean
|
||||
@@ -21,6 +22,7 @@ const props = withDefaults(
|
||||
variant: 'primary',
|
||||
size: 'default',
|
||||
loading: false,
|
||||
spinIconOnLoading: false,
|
||||
disabled: false,
|
||||
active: false,
|
||||
iconOnly: false,
|
||||
@@ -80,6 +82,7 @@ const loadingIndicatorStyle = computed(() => {
|
||||
const classes = computed(() => [
|
||||
'inline-flex items-center justify-center border-0 font-bold leading-[1.15] transition-[background-color,box-shadow,color,opacity,transform] duration-150 cursor-pointer outline-none motion-safe:active:translate-y-px motion-safe:active:scale-[0.985]',
|
||||
props.iconOnly ? iconOnlySizeClasses[props.size] : sizeClasses[props.size],
|
||||
props.iconOnly ? 'shrink-0' : undefined,
|
||||
props.iconOnly ? '' : 'rounded-(--cp-button-radius-base)',
|
||||
'focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)',
|
||||
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-(--cp-disabled-bg) disabled:text-(--cp-disabled-text) disabled:shadow-none',
|
||||
@@ -124,7 +127,7 @@ const loadingIconClasses = computed(() => [
|
||||
:aria-busy="loading"
|
||||
>
|
||||
<span
|
||||
v-if="loading"
|
||||
v-if="loading && !spinIconOnLoading"
|
||||
:class="loadingIconClasses"
|
||||
:style="loadingIndicatorStyle"
|
||||
aria-hidden="true"
|
||||
@@ -140,7 +143,7 @@ const loadingIconClasses = computed(() => [
|
||||
<slot />
|
||||
</span>
|
||||
<span
|
||||
v-if="iconOnly && !$slots.icon && !loading"
|
||||
v-if="iconOnly && !$slots.icon && (!loading || spinIconOnLoading)"
|
||||
:class="loadingIconClasses"
|
||||
aria-hidden="true"
|
||||
>
|
||||
|
||||
@@ -27,10 +27,12 @@ export function usePagedQuery<Result extends PageResult>(options: {
|
||||
const error = shallowRef('')
|
||||
let requestSequence = 0
|
||||
|
||||
async function execute() {
|
||||
async function execute(execution: { silent?: boolean } = {}) {
|
||||
const requestId = ++requestSequence
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
if (!execution.silent) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await options.load({
|
||||
@@ -42,7 +44,7 @@ export function usePagedQuery<Result extends PageResult>(options: {
|
||||
|
||||
if (result.items.length === 0 && result.page.total > 0 && result.page.page > 1) {
|
||||
page.value = clamp(result.page.totalPages, 1, Number.POSITIVE_INFINITY)
|
||||
return execute()
|
||||
return execute(execution)
|
||||
}
|
||||
|
||||
items.value = result.items
|
||||
@@ -55,8 +57,10 @@ export function usePagedQuery<Result extends PageResult>(options: {
|
||||
catch (cause: unknown) {
|
||||
if (requestId !== requestSequence)
|
||||
return false
|
||||
error.value = errorMessage(cause, '加载失败')
|
||||
options.onError?.(cause)
|
||||
if (!execution.silent) {
|
||||
error.value = errorMessage(cause, '加载失败')
|
||||
options.onError?.(cause)
|
||||
}
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
|
||||
@@ -91,8 +91,16 @@ const statusView = computed(() => {
|
||||
iconClass: 'text-(--cp-success)',
|
||||
}
|
||||
}
|
||||
if (updateInfo.value) {
|
||||
return {
|
||||
label: '已是最新',
|
||||
icon: CheckCircle2,
|
||||
badge: 'bg-(--cp-success-bg) text-(--cp-success-text)',
|
||||
iconClass: 'text-(--cp-success)',
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: updateInfo.value ? '已是最新' : '未检查',
|
||||
label: '未检查',
|
||||
icon: CheckCircle2,
|
||||
badge: 'bg-(--cp-bg-muted) text-(--cp-text-secondary)',
|
||||
iconClass: 'text-(--cp-text-muted)',
|
||||
@@ -318,6 +326,7 @@ watch(
|
||||
<BaseModal
|
||||
v-model="open"
|
||||
title="系统更新"
|
||||
description="检查版本、查看发布说明并执行在线更新"
|
||||
variant="success"
|
||||
width="820px"
|
||||
body-max-height="72dvh"
|
||||
|
||||
@@ -66,8 +66,8 @@ export const accountGroupColumns = [
|
||||
{
|
||||
key: 'actions',
|
||||
label: '操作',
|
||||
width: '100px',
|
||||
minWidth: '100px',
|
||||
width: '136px',
|
||||
minWidth: '136px',
|
||||
cellClass,
|
||||
},
|
||||
] satisfies BaseTableColumn<AccountGroup>[]
|
||||
|
||||
@@ -116,13 +116,13 @@ const modalTitle = computed(() => {
|
||||
const oauthPanelTitle = computed(() => {
|
||||
if (props.reauthorizing)
|
||||
return accountName.value
|
||||
return isXai.value ? '使用 xAI OAuth 完成账号接入' : '使用 OpenAI OAuth 完成账号接入'
|
||||
return isXai.value ? 'xAI OAuth 授权' : 'OpenAI OAuth 授权'
|
||||
})
|
||||
|
||||
const oauthPanelDescription = computed(() => {
|
||||
if (props.reauthorizing)
|
||||
return '生成新的授权链接,完成后粘贴回调地址更新账号凭据'
|
||||
return '复制授权链接到浏览器打开,完成后把回调地址粘贴到下方即可导入'
|
||||
return isXai.value
|
||||
? '生成并打开授权链接 → 完成浏览器授权 → 粘贴回调地址、查询字符串或授权码'
|
||||
: '生成并打开授权链接 → 完成浏览器授权 → 粘贴回调地址'
|
||||
})
|
||||
|
||||
const canGenerateOauth = computed(() =>
|
||||
@@ -145,20 +145,23 @@ const canSubmit = computed(() => {
|
||||
const description = computed<string | undefined>(() => {
|
||||
if (isChoosingProvider.value)
|
||||
return undefined
|
||||
if (props.reauthorizing)
|
||||
return '完成授权后粘贴回调地址,系统会更新账号凭据'
|
||||
if (props.reauthorizing) {
|
||||
return isXai.value
|
||||
? '完成新的 xAI 授权并替换此账号凭据'
|
||||
: '完成新的 OpenAI 授权并替换此账号凭据'
|
||||
}
|
||||
if (isBatch.value)
|
||||
return '导入 CPR 多平台账号包,系统会按文档中的平台自动分流'
|
||||
return '粘贴或上传 CPR 账号包,一次导入多个平台账号'
|
||||
if (isXai.value) {
|
||||
return mode.value === 'oauth'
|
||||
? '复制 xAI 授权链接,完成后粘贴回调地址,不使用 xAI API Key'
|
||||
: '导入 xAI 账号文件,已存在账号会更新'
|
||||
? '通过浏览器授权导入 xAI 账号'
|
||||
: '粘贴或上传 xAI 账号文件,匹配已有账号时更新凭据'
|
||||
}
|
||||
if (mode.value === 'oauth')
|
||||
return '复制 OpenAI 授权链接,完成后粘贴回调地址,系统会自动写入或更新账号'
|
||||
return '通过浏览器授权导入 OpenAI 账号'
|
||||
if (mode.value === 'agent_identity')
|
||||
return '导入 Agent 身份文件,系统会按身份信息写入或更新账号'
|
||||
return '导入 CPR、Sub2API 或 CPA 账号文件,已存在账号会更新'
|
||||
return '粘贴或上传 Agent 身份文件,匹配已有账号时更新凭据'
|
||||
return '粘贴或上传 CPR、Sub2API 或 CPA 账号文件,匹配已有账号时更新凭据'
|
||||
})
|
||||
|
||||
function selectProvider(value: 'openai' | 'xai' | 'batch') {
|
||||
@@ -275,12 +278,12 @@ async function copyText(value: string, successText: string) {
|
||||
</BaseForm>
|
||||
|
||||
<BaseForm>
|
||||
<BaseFormItem label="回调地址" required>
|
||||
<BaseFormItem :label="isXai ? '回调地址或授权码' : '回调地址'" required>
|
||||
<BaseTextarea
|
||||
v-model="oauthCallback"
|
||||
aria-label="回调地址"
|
||||
:aria-label="isXai ? '回调地址或授权码' : '回调地址'"
|
||||
size="sm"
|
||||
:placeholder="isXai ? 'http://127.0.0.1:56121/callback?code=...&state=...' : 'http://localhost:1455/auth/callback?code=...&state=...'"
|
||||
:placeholder="isXai ? '回调地址、?code=...&state=... 或授权码' : 'http://localhost:1455/auth/callback?code=...&state=...'"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</BaseFormItem>
|
||||
|
||||
@@ -45,6 +45,7 @@ const quotaWindows = computed(() => orderedPanelQuotaWindows(props.account.quota
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="刷新额度"
|
||||
spin-icon-on-loading
|
||||
:loading="refreshing"
|
||||
:disabled="refreshing"
|
||||
@click="emit('refreshQuota', account.id)"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccountRow } from '../constants'
|
||||
import { modelSuccessRateTextClass } from '../constants'
|
||||
|
||||
defineProps<{
|
||||
account: AccountRow
|
||||
@@ -62,7 +63,7 @@ defineProps<{
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-[1.5fr_0.7fr_0.8fr_1fr_1fr_1fr_1fr_1fr_1.4fr] gap-3 pb-2 text-[11px] font-heavy text-(--cp-text-secondary) shadow-[inset_0_-1px_0_var(--cp-divider-subtle)]"
|
||||
class="grid grid-cols-[1.5fr_0.7fr_0.8fr_1fr_1fr_1fr_1fr_1fr_1.2fr] gap-3 pb-2 text-[11px] font-heavy text-(--cp-text-secondary) shadow-[inset_0_-1px_0_var(--cp-divider-subtle)]"
|
||||
>
|
||||
<span>模型</span>
|
||||
<span>调用</span>
|
||||
@@ -84,11 +85,13 @@ defineProps<{
|
||||
<div
|
||||
v-for="model in account.usage.models"
|
||||
:key="model.model"
|
||||
class="grid grid-cols-[1.5fr_0.7fr_0.8fr_1fr_1fr_1fr_1fr_1fr_1.4fr] gap-3 pt-3 text-[12px] font-emphasis text-(--cp-text-primary)"
|
||||
class="grid grid-cols-[1.5fr_0.7fr_0.8fr_1fr_1fr_1fr_1fr_1fr_1.2fr] gap-3 pt-3 text-[12px] font-emphasis text-(--cp-text-primary)"
|
||||
>
|
||||
<span class="truncate">{{ model.model }}</span>
|
||||
<span>{{ model.requestCountDisplay }}</span>
|
||||
<span class="text-(--cp-warning-text)">{{ model.successRateDisplay }}</span>
|
||||
<span :class="modelSuccessRateTextClass(model.successRate)">
|
||||
{{ model.successRateDisplay }}
|
||||
</span>
|
||||
<span>{{ model.inputTokensDisplay }}</span>
|
||||
<span>{{ model.outputTokensDisplay }}</span>
|
||||
<span>{{ model.cachedTokensDisplay }}</span>
|
||||
|
||||
@@ -103,7 +103,7 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
|
||||
if (connectionTestStatus.value === 'running') {
|
||||
return {
|
||||
label: '正在测试',
|
||||
description: '正在发送一条真实 Responses 流式请求',
|
||||
description: '正在向所选模型发送请求并接收流式响应',
|
||||
icon: Clock3,
|
||||
badge: 'bg-(--cp-info-bg) text-(--cp-info-text)',
|
||||
iconClass: 'text-(--cp-info)',
|
||||
@@ -112,7 +112,7 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
|
||||
if (connectionTestStatus.value === 'success') {
|
||||
return {
|
||||
label: '连接正常',
|
||||
description: '账号凭据可用,已完成 Responses 流式验证',
|
||||
description: '请求已完成,可在下方查看模型、耗时和事件轨迹',
|
||||
icon: CheckCircle2,
|
||||
badge: 'bg-(--cp-success-bg) text-(--cp-success-text)',
|
||||
iconClass: 'text-(--cp-success)',
|
||||
@@ -121,7 +121,7 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
|
||||
if (connectionTestStatus.value === 'error') {
|
||||
return {
|
||||
label: '测试失败',
|
||||
description: '真实请求未完成,优先检查令牌状态、账号权限或上游网络',
|
||||
description: '请求未完成,请在下方查看上游错误与事件轨迹',
|
||||
icon: XCircle,
|
||||
badge: 'bg-(--cp-danger-bg) text-(--cp-danger-text)',
|
||||
iconClass: 'text-(--cp-danger)',
|
||||
@@ -129,7 +129,7 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
|
||||
}
|
||||
return {
|
||||
label: '准备测试',
|
||||
description: '点击开始后发送一条轻量 Responses 流式请求',
|
||||
description: '选择模型后,点击“开始测试”发送真实请求',
|
||||
icon: Wifi,
|
||||
badge: 'bg-(--cp-bg-subtle) text-(--cp-text-secondary)',
|
||||
iconClass: 'text-(--cp-text-muted)',
|
||||
|
||||
@@ -98,8 +98,11 @@ export function useAccountOnboarding(options: {
|
||||
throw new Error('请先生成授权链接')
|
||||
|
||||
const callbackUrl = createForm.value.oauthCallback.trim()
|
||||
if (!callbackUrl)
|
||||
throw new Error('请粘贴 OAuth 回调地址')
|
||||
if (!callbackUrl) {
|
||||
throw new Error(createForm.value.provider === 'xai'
|
||||
? '请粘贴 OAuth 回调地址、含 code 和 state 的查询字符串或授权码'
|
||||
: '请粘贴 OAuth 回调地址')
|
||||
}
|
||||
await completeAccountOAuth({
|
||||
provider: createForm.value.provider,
|
||||
flowId: createForm.value.oauthFlowId,
|
||||
@@ -186,11 +189,11 @@ export function useAccountOnboarding(options: {
|
||||
}
|
||||
|
||||
if (importedCount === 0) {
|
||||
throw new Error(failures.length > 0 ? `批量导入失败:${failures.join(';')}` : '批量文件没有可导入的账号')
|
||||
throw new Error(failures.length > 0 ? `批量导入失败:${failures.join('、')}` : '批量文件没有可导入的账号')
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
return `已导入 ${importedCount} 个账号,${failures.length} 个文档失败:${failures.join(';')}`
|
||||
return `已导入 ${importedCount} 个账号,${failures.length} 个文档失败:${failures.join('、')}`
|
||||
}
|
||||
return `已导入 ${importedCount} 个账号`
|
||||
}
|
||||
|
||||
@@ -141,6 +141,7 @@ export function useAccountsQuery() {
|
||||
loading: query.loading,
|
||||
accounts: query.items,
|
||||
loadAccounts: query.execute,
|
||||
refreshAccountsSilently: () => query.execute({ silent: true }),
|
||||
searchQuery,
|
||||
providerQuery,
|
||||
statusQuery,
|
||||
|
||||
@@ -121,8 +121,8 @@ export const accountColumns = [
|
||||
{
|
||||
key: 'actions',
|
||||
label: '操作',
|
||||
width: '120px',
|
||||
minWidth: '120px',
|
||||
width: '136px',
|
||||
minWidth: '136px',
|
||||
},
|
||||
] satisfies BaseTableColumn<AccountRow>[]
|
||||
|
||||
@@ -211,6 +211,18 @@ export function quotaWindowPercentTextClass(window: AccountQuotaWindow) {
|
||||
return 'text-(--cp-success-text)'
|
||||
}
|
||||
|
||||
export function modelSuccessRateTextClass(successRate: number | null) {
|
||||
if (successRate === null)
|
||||
return 'text-(--cp-text-muted)'
|
||||
if (successRate >= 99.5)
|
||||
return 'text-(--cp-success-text)'
|
||||
if (successRate >= 98)
|
||||
return 'text-(--cp-normal-text)'
|
||||
if (successRate >= 95)
|
||||
return 'text-(--cp-warning-text)'
|
||||
return 'text-(--cp-danger-text)'
|
||||
}
|
||||
|
||||
function compareQuotaWindows(left: AccountQuotaWindow, right: AccountQuotaWindow) {
|
||||
const groupDifference = groupOrder(left) - groupOrder(right)
|
||||
if (groupDifference !== 0)
|
||||
|
||||
@@ -37,6 +37,7 @@ const {
|
||||
loading,
|
||||
accounts,
|
||||
loadAccounts,
|
||||
refreshAccountsSilently,
|
||||
searchQuery,
|
||||
providerQuery,
|
||||
statusQuery,
|
||||
@@ -108,7 +109,7 @@ const {
|
||||
openConnectionTest,
|
||||
handleRefreshConnectionTestModels,
|
||||
handleTestConnection,
|
||||
} = useAccountConnectionTest({ reload: loadAccounts })
|
||||
} = useAccountConnectionTest({ reload: refreshAccountsSilently })
|
||||
|
||||
const {
|
||||
expandedAccountIds,
|
||||
@@ -262,9 +263,9 @@ const {
|
||||
</template>
|
||||
|
||||
<template #groups="{ row }">
|
||||
<AccountGroupMarks
|
||||
:groups="row.groups"
|
||||
/>
|
||||
<div class="flex w-full justify-center">
|
||||
<AccountGroupMarks :groups="row.groups" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #lastUsedAt="{ row }">
|
||||
|
||||
@@ -21,7 +21,7 @@ export function parseAccountSchedulingForm(
|
||||
|| parsedLimit < 1
|
||||
|| parsedLimit > MAX_ACCOUNT_CONCURRENCY)
|
||||
) {
|
||||
return { valid: false, message: `并发限制必须留空或填写 1 到 ${MAX_ACCOUNT_CONCURRENCY} 的整数` }
|
||||
return { valid: false, message: `并发限制必须留空,或为 1 到 ${MAX_ACCOUNT_CONCURRENCY} 的整数` }
|
||||
}
|
||||
|
||||
const parsedWeight = Number(weight.trim())
|
||||
|
||||
@@ -27,16 +27,13 @@ const open = defineModel<boolean>({ default: false })
|
||||
const createdOpen = defineModel<boolean>('createdOpen', { default: false })
|
||||
const form = defineModel<ApiKeyFormValue>('form', { required: true })
|
||||
const title = computed(() => props.editing ? '编辑 API Key' : '创建 API Key')
|
||||
const description = computed(() => props.editing
|
||||
? '调整访问范围与调度限制,分组替换原子生效。'
|
||||
: '为调用方选择一个或多个账号分组。')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
v-model="open"
|
||||
:title="title"
|
||||
:description="description"
|
||||
description="设置调用方可用的账号分组、并发和每分钟请求上限"
|
||||
variant="info"
|
||||
width="680px"
|
||||
body-max-height="min(680px, calc(100dvh - 190px))"
|
||||
@@ -47,7 +44,7 @@ const description = computed(() => props.editing
|
||||
<BaseInput
|
||||
v-model="form.name"
|
||||
aria-label="名称"
|
||||
placeholder="例如:生产环境、测试调用方..."
|
||||
placeholder="例如:生产环境"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</BaseFormItem>
|
||||
@@ -56,7 +53,7 @@ const description = computed(() => props.editing
|
||||
<BaseInput
|
||||
v-model="form.label"
|
||||
aria-label="标签(可选)"
|
||||
placeholder="备注信息..."
|
||||
placeholder="例如:后端服务"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</BaseFormItem>
|
||||
@@ -108,7 +105,7 @@ const description = computed(() => props.editing
|
||||
<BaseModal
|
||||
v-model="createdOpen"
|
||||
title="API Key 已创建"
|
||||
description="可以立即复制或导入 CCSwitch,之后也可从密钥列表再次操作"
|
||||
description="复制密钥,或直接导入 CCSwitch"
|
||||
variant="success"
|
||||
width="540px"
|
||||
>
|
||||
|
||||
@@ -9,8 +9,8 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-1.5">
|
||||
<div v-if="apiKey.routingScope === 'all'" class="flex items-center gap-2">
|
||||
<div class="grid w-full justify-items-center gap-1.5">
|
||||
<div v-if="apiKey.routingScope === 'all'" class="flex items-center justify-center gap-2">
|
||||
<span class="inline-flex h-6 items-center rounded-lg bg-(--cp-warning-bg) px-2 text-[11px] font-bold text-(--cp-warning-text)">
|
||||
全部账号权限
|
||||
</span>
|
||||
|
||||
@@ -38,13 +38,13 @@ const authPath = computed(() =>
|
||||
activePlatform.value === 'windows' ? '%userprofile%\\.codex\\auth.json' : '~/.codex/auth.json',
|
||||
)
|
||||
const codexAuthJson = computed(() => JSON.stringify({ OPENAI_API_KEY: keyValue.value }, null, 2))
|
||||
const defaultModel = 'gpt-5.5'
|
||||
const defaultModel = 'gpt-5.6-terra'
|
||||
|
||||
const codexConfigToml = computed(
|
||||
() => `model_provider = "OpenAI"
|
||||
model = "${defaultModel}"
|
||||
review_model = "${defaultModel}"
|
||||
model_reasoning_effort = "xhigh"
|
||||
model_reasoning_effort = "max"
|
||||
service_tier = "default"
|
||||
disable_response_storage = true
|
||||
network_access = "enabled"
|
||||
@@ -70,7 +70,7 @@ const visibleFiles = computed(() => [
|
||||
<BaseModal
|
||||
v-model="open"
|
||||
title="使用密钥"
|
||||
description="将以下配置文件添加到 Codex CLI 配置目录中"
|
||||
description="将下方内容分别保存到显示的 Codex CLI 配置文件"
|
||||
width="760px"
|
||||
>
|
||||
<div class="flex flex-col gap-5">
|
||||
|
||||
@@ -43,8 +43,8 @@ export const apiKeyColumns = [
|
||||
{
|
||||
key: 'actions',
|
||||
label: '操作',
|
||||
width: '172px',
|
||||
minWidth: '172px',
|
||||
width: '208px',
|
||||
minWidth: '208px',
|
||||
headerClass: relaxedActionCellClass,
|
||||
cellClass: relaxedActionCellClass,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { dashboardSnapshotView, MetricTone } from '../composables/presenter'
|
||||
import { CircleCheck, CircleOff, RefreshCw, ShieldAlert, TriangleAlert } from '@lucide/vue'
|
||||
import { CircleCheck, RefreshCw, ShieldAlert, TriangleAlert } from '@lucide/vue'
|
||||
import { clamp } from 'es-toolkit'
|
||||
|
||||
import { computed } from 'vue'
|
||||
@@ -106,8 +106,7 @@ const statusRows = computed(() => {
|
||||
{ label: '正常', description: '暂无账号池观测', value: '—', tone: 'success', icon: CircleCheck },
|
||||
{ label: '配额耗尽', description: '暂无账号池观测', value: '—', tone: 'warning', icon: TriangleAlert },
|
||||
{ label: '限流中', description: '暂无账号池观测', value: '—', tone: 'normal', icon: RefreshCw },
|
||||
{ label: '已停用', description: '暂无账号池观测', value: '—', tone: 'normal', icon: CircleOff },
|
||||
{ label: '错误', description: '暂无账号池观测', value: '—', tone: 'danger', icon: ShieldAlert },
|
||||
{ label: '待处理', description: '停用 — · 错误 —', value: '—', tone: 'danger', icon: ShieldAlert },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -134,16 +133,9 @@ const statusRows = computed(() => {
|
||||
icon: RefreshCw,
|
||||
},
|
||||
{
|
||||
label: '已停用',
|
||||
description: '账号已停用',
|
||||
value: String(counts.disabled),
|
||||
tone: 'normal',
|
||||
icon: CircleOff,
|
||||
},
|
||||
{
|
||||
label: '错误',
|
||||
description: '账号状态异常',
|
||||
value: String(counts.error),
|
||||
label: '待处理',
|
||||
description: `停用 ${counts.disabled} · 错误 ${counts.error}`,
|
||||
value: String(counts.disabled + counts.error),
|
||||
tone: 'danger',
|
||||
icon: ShieldAlert,
|
||||
},
|
||||
@@ -165,8 +157,7 @@ const statusBars = computed(() => {
|
||||
{ pct: (counts.normal / counts.total) * 100, cls: 'bg-(--cp-success)' },
|
||||
{ pct: (counts.quotaExhausted / counts.total) * 100, cls: 'bg-(--cp-warning)' },
|
||||
{ pct: (counts.rateLimited / counts.total) * 100, cls: 'bg-(--cp-normal)' },
|
||||
{ pct: (counts.disabled / counts.total) * 100, cls: 'bg-(--cp-text-secondary)' },
|
||||
{ pct: (counts.error / counts.total) * 100, cls: 'bg-(--cp-danger)' },
|
||||
{ pct: ((counts.disabled + counts.error) / counts.total) * 100, cls: 'bg-(--cp-danger)' },
|
||||
].filter(b => b.pct > 0)
|
||||
})
|
||||
</script>
|
||||
@@ -333,11 +324,11 @@ const statusBars = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid h-67 w-full grid-rows-5 gap-2">
|
||||
<div class="mt-6.5 grid h-65.5 w-full gap-2.5">
|
||||
<div
|
||||
v-for="row in statusRows"
|
||||
:key="row.label"
|
||||
class="grid min-h-0 grid-cols-[28px_14px_minmax(0,1fr)_76px] items-center rounded-[14px] bg-(--cp-bg-subtle) px-3.5"
|
||||
class="grid h-14.5 grid-cols-[28px_14px_minmax(0,1fr)_76px] items-center rounded-[14px] bg-(--cp-bg-subtle) px-3.5"
|
||||
>
|
||||
<span
|
||||
class="inline-flex size-7 items-center justify-center rounded-[9px]"
|
||||
|
||||
@@ -50,6 +50,18 @@ const profile = computed(() =>
|
||||
props.profiles.find(item => item.provider === activeProvider.value) ?? props.profiles[0] ?? null,
|
||||
)
|
||||
|
||||
const versionParts = computed(() => {
|
||||
const version = profile.value?.version ?? ''
|
||||
const separator = version.indexOf('-')
|
||||
if (separator <= 0 || separator === version.length - 1)
|
||||
return { release: version, prerelease: '' }
|
||||
|
||||
return {
|
||||
release: version.slice(0, separator),
|
||||
prerelease: version.slice(separator + 1),
|
||||
}
|
||||
})
|
||||
|
||||
const releaseLabel = computed(() => {
|
||||
const release = profile.value?.release
|
||||
if (!release?.latestVersion)
|
||||
@@ -221,10 +233,20 @@ function providerLabel(provider: string) {
|
||||
<div class="grid min-h-0 content-center">
|
||||
<div class="flex min-w-0 flex-wrap items-baseline gap-x-2.5 gap-y-1.5">
|
||||
<strong
|
||||
class="block max-w-full wrap-break-word font-mono text-[27px] leading-[1.05] font-heavy tabular-nums text-(--cp-text-primary)"
|
||||
:aria-label="profile.version"
|
||||
class="inline-flex max-w-full min-w-0 items-baseline gap-2 font-mono leading-none tabular-nums"
|
||||
:title="profile.version"
|
||||
>
|
||||
{{ profile.version }}
|
||||
<span class="wrap-break-word text-[27px] leading-[1.05] font-heavy text-(--cp-text-primary)">
|
||||
{{ versionParts.release }}
|
||||
</span>
|
||||
<span
|
||||
v-if="versionParts.prerelease"
|
||||
aria-hidden="true"
|
||||
class="truncate text-[12px] font-bold text-(--cp-text-secondary)"
|
||||
>
|
||||
{{ versionParts.prerelease }}
|
||||
</span>
|
||||
</strong>
|
||||
<span
|
||||
v-if="profile.build"
|
||||
|
||||
@@ -46,6 +46,7 @@ const {
|
||||
class="text-(--cp-normal)"
|
||||
size="md"
|
||||
label="刷新概览"
|
||||
spin-icon-on-loading
|
||||
:loading="loading || refreshing"
|
||||
:disabled="loading || refreshing"
|
||||
@click="refresh"
|
||||
|
||||
@@ -45,7 +45,7 @@ const columns: BaseTableColumn<BackupRecord>[] = [
|
||||
{ key: 'expiresAt', label: '过期时间', width: 170 },
|
||||
{ key: 'trigger', label: '触发方式', width: 100 },
|
||||
{ key: 'startedAt', label: '开始时间', width: 170 },
|
||||
{ key: 'actions', label: '操作', width: 96, fixed: 'right' },
|
||||
{ key: 'actions', label: '操作', width: 112, minWidth: 112, fixed: 'right' },
|
||||
]
|
||||
|
||||
function triggerLabel(value: string): string {
|
||||
|
||||
@@ -69,6 +69,7 @@ function showDetail(record: Awaited<ReturnType<typeof getOpsErrors>>['items'][nu
|
||||
variant="ghost"
|
||||
size="md"
|
||||
label="刷新错误明细"
|
||||
spin-icon-on-loading
|
||||
:loading="refreshing"
|
||||
:disabled="loading || refreshing"
|
||||
@click="refresh"
|
||||
|
||||
@@ -139,7 +139,6 @@ function diagnosticNameDisplay(name: string) {
|
||||
compact
|
||||
row-key="key"
|
||||
empty-text="暂无诊断数据"
|
||||
max-height="230px"
|
||||
min-width="640px"
|
||||
>
|
||||
<template #header-nameDisplay>
|
||||
|
||||
@@ -32,6 +32,7 @@ const search = defineModel<string>('search', { required: true })
|
||||
variant="ghost"
|
||||
size="md"
|
||||
label="刷新使用记录"
|
||||
spin-icon-on-loading
|
||||
:loading="refreshing"
|
||||
:disabled="loading || refreshing"
|
||||
@click="emit('refresh')"
|
||||
|
||||
@@ -1 +1 @@
|
||||
version: "3.2.1"
|
||||
version: "3.2.5"
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
- 换号只由 Core 在下游 commit 前按重放安全性决定。
|
||||
- fallback 只允许同一 Provider kind 内的账号;不跨 Provider,也不存在 Provider Instance 层。
|
||||
- continuation 升级链由 Core 冻结:native(原生 handle 绑定原账号)→ replay owner(原账号完整 transcript 重放)→ replay any(transcript 已可携带,允许换账号);两个 Provider 都实现全链,xAI 的 transcript 由不透明 session state 承载,跨账号重放前做脱敏投影。
|
||||
- Provider wire profile 以配置为启动基线,并由共享运行时状态统一发布。OpenAI CLI 读取 `@openai/codex`、Desktop 读取官方 appcast、xAI CLI 读取 `@xai-official/grok`;发现新版本后自动更新对应版本字段,所有消费边界不得维护独立常量。
|
||||
- Provider wire profile 以配置为启动基线,并由共享运行时状态统一发布。OpenAI Desktop 读取官方 appcast,并从同一 ZIP 的内嵌 Core 做有界 Range 探测;xAI CLI 读取 `@xai-official/grok`。发现新版本后自动更新对应版本字段,所有消费边界不得维护独立常量。
|
||||
- `downstream_committed_at` 是不可撤回交付承诺,不是首字节已经写达的证明。
|
||||
|
||||
## 存储
|
||||
|
||||
Reference in New Issue
Block a user