Compare commits

...
28 Commits
Author SHA1 Message Date
ZyyandCodex 78f9c0c800 chore: bump version to 3.2.7
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:54:52 +08:00
ZyyandCodex a3bc7bc445 fix(frontend): increase account table row height
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:54:02 +08:00
ZyyandCodex 0132114285 fix(openai): let upstream validate catalog models
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:45:06 +08:00
ZyyandCodex 5970efbb7c fix(container): refresh runtime packages before scans
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 18:34:41 +08:00
ZyyandCodex 7bf28b571d chore: bump version to 3.2.6
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 15:47:32 +08:00
ZyyandCodex bc3bd6bc43 refactor(frontend): unify component design system
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 15:39:31 +08:00
ZyyandCodex 9acfab0a86 chore(frontend): enforce template interpolation layout
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 12:31:10 +08:00
ZyyandCodex 3e5a84d2a0 fix(frontend): refine dashboard capacity display
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 12:31:06 +08:00
ZyyandCodex 92948f51a9 fix(frontend): remove table bottom gap
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 11:59:58 +08:00
ZyyandCodex 776637fccb chore: bump version to 3.2.5
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 01:52:54 +08:00
ZyyandCodex 82acb08d26 fix(frontend): restore diagnostic table scrolling
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 01:51:56 +08:00
ZyyandCodex 7744e1b0b4 fix(accounts): preserve rolling window usage
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 01:28:44 +08:00
ZyyandCodex 0b8a1add18 feat(frontend): refresh account setup defaults and copy
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 01:09:31 +08:00
ZyyandCodex 2dd60c08b6 chore: bump version to 3.2.4
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 00:29:44 +08:00
ZyyandCodex cce9880937 fix(xai): align Grok behavior with sub2api
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 23:36:12 +08:00
Zyy b8ea1cd076 fix(core): normalize account scheduling load 2026-08-14 23:27:06 +08:00
ZyyandCodex 4117840f75 fix(xai): align OAuth and Codex compatibility
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 22:24:08 +08:00
ZyyandCodex f642764f34 fix(openai): align billing with official pricing
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 21:14:36 +08:00
ZyyandCodex 482e192e2d fix(openai): align desktop oauth identity
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 17:43:07 +08:00
ZyyandCodex ee51a07de4 fix(openai): align desktop wire profile
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 16:13:11 +08:00
ZyyandCodex bf23658aaf chore: bump version to 3.2.3
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 01:25:44 +08:00
ZyyandCodex bc4728332e fix(frontend): polish dashboard and table interactions
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 01:24:49 +08:00
ZyyandCodex 2cb5cda077 fix(quota): report exhausted usage as full
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 01:07:20 +08:00
ZyyandCodex 9d90dedb81 fix(frontend): refresh account data without table loading
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 01:07:12 +08:00
ZyyandCodex 6074cff68c chore: bump version to 3.2.2
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 00:43:13 +08:00
ZyyandCodex 6ab8e7d0a6 fix(frontend): grade model success rate colors
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 00:42:20 +08:00
ZyyandCodex 17f9ef8915 fix(observability): omit inactive accounts from daily usage
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 00:39:59 +08:00
ZyyandCodex 15e0a28bd0 fix(frontend): refine refresh loading and group alignment
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 00:39:50 +08:00
204 changed files with 9464 additions and 4433 deletions
+3
View File
@@ -55,6 +55,9 @@ jobs:
target: runtime
load: true
tags: ${{ env.CPR_IMAGE }}
no-cache-filters: |
runtime-base
runtime
cache-from: type=gha,scope=runtime-ci
cache-to: type=gha,mode=max,scope=runtime-ci
+3
View File
@@ -468,6 +468,9 @@ jobs:
push: true
build-args: |
CPR_PREBUILT_DIR=dist/docker
no-cache-filters: |
runtime-base
runtime-prebuilt
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.os }}-${{ matrix.arch }}
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.os }}-${{ matrix.arch }},mode=min
provenance: mode=max
+9 -1
View File
@@ -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"
+5 -2
View File
@@ -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;保持同版本同级别以对齐上游指纹。
+14 -8
View File
@@ -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)?,
})
@@ -1360,9 +1368,7 @@ fn map_quota_error(error: CodexCredentialQuotaError) -> ProviderAdminError {
fn map_catalog_error(error: CodexCredentialCatalogError) -> ProviderAdminError {
use CodexCredentialCatalogError as Error;
provider_admin_error(match error {
Error::InvalidCredentialData | Error::ConflictingModelFacts | Error::InvalidEtag => {
ProviderAdminErrorKind::Invalid
}
Error::InvalidCredentialData | Error::InvalidEtag => ProviderAdminErrorKind::Invalid,
Error::NoEligibleCredential => ProviderAdminErrorKind::NotFound,
Error::ConcurrentUpdate => ProviderAdminErrorKind::Conflict,
Error::Upstream { .. } | Error::Cache => ProviderAdminErrorKind::Unavailable,
@@ -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()
@@ -138,8 +138,6 @@ pub enum CodexCredentialCatalogError {
InvalidCredentialData,
#[error("Codex model catalog upstream query failed: {detail}")]
Upstream { detail: String },
#[error("Codex model catalog contains conflicting account facts")]
ConflictingModelFacts,
#[error("Codex model catalog cache is unavailable")]
Cache,
#[error("Codex model catalog ETag is invalid")]
@@ -359,11 +357,6 @@ impl CodexCredentialCatalogService {
union_order.push(entry.key().clone());
entry.insert(model.clone());
}
// 展示与元数据允许跨套餐漂移,union 取首见值;只有路由
// 事实冲突才让整轮刷新失败。
Entry::Occupied(entry) if conflicting_routing_facts(entry.get(), model) => {
return Err(CodexCredentialCatalogError::ConflictingModelFacts);
}
Entry::Occupied(_) => {}
}
}
@@ -661,13 +654,6 @@ fn model_ids(models: &[CodexCatalogModel]) -> Vec<String> {
.collect()
}
/// 路由正确性只依赖 Responses API 支持证据(决定 Generate operation 是否
/// 编译进 `ModelCapabilities`);display_name/limits/metadata 等展示事实
/// 跨套餐漂移不构成冲突。
fn conflicting_routing_facts(existing: &CodexCatalogModel, candidate: &CodexCatalogModel) -> bool {
existing.capabilities().responses_api() != candidate.capabilities().responses_api()
}
fn catalog_candidates_by_scope(
accounts: Vec<ProviderAccount>,
) -> Result<BTreeMap<CodexCatalogScope, Vec<ProviderAccount>>, CodexCredentialCatalogError> {
@@ -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(&current)?;
(
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(&current)? != 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(&current.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
+24 -15
View File
@@ -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 {
@@ -1492,12 +1492,10 @@ fn nonnegative_millis(value: Option<i64>) -> Option<u64> {
}
fn compile_model_capabilities(model: &CodexCatalogModel) -> ProviderModelCapabilities {
let evidence = model.capabilities();
let mut operations = BTreeSet::new();
if evidence.responses_api() == CodexCatalogCapabilityEvidence::DeclaredNative {
operations.insert(OperationKind::Generate);
}
let capabilities = ModelCapabilities::new(operations, None).with_upstream_feature_validation();
// Catalog membership is enough to publish Generate. `supported_in_api` is advisory;
// the upstream response is authoritative for normal and diagnostic requests.
let capabilities = ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), None)
.with_upstream_feature_validation();
ProviderModelCapabilities::new(model.request_model().clone(), capabilities)
.with_presentation(codex_model_presentation(model))
}
@@ -2339,7 +2337,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 +2345,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 +2352,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 +2379,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 +2499,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(&central, 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)
}
@@ -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"
)
}
+51 -29
View File
@@ -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,
@@ -253,7 +253,7 @@ async fn cross_plan_presentation_drift_unions_on_the_first_seen_model() {
}
#[tokio::test]
async fn cross_plan_responses_api_conflict_still_fails_the_union() {
async fn cross_plan_supported_in_api_drift_unions_on_the_first_seen_model() {
let store = Arc::new(MemoryAccountStore::default());
seed_account_with_plan(&store, "acct_conflict_plus", "plus").await;
seed_account_with_plan(&store, "acct_conflict_pro", "pro").await;
@@ -272,15 +272,12 @@ async fn cross_plan_responses_api_conflict_still_fails_the_union() {
.await;
let service = service_with_catalog_cache(&store, server.uri(), catalog_cache());
let error = service
let snapshot = service
.synchronize()
.await
.expect_err("routing facts must still agree across plans");
.expect("supported_in_api drift must not block catalog publication");
assert!(matches!(
error,
CodexCredentialCatalogError::ConflictingModelFacts
));
assert_eq!(snapshot.models().len(), 1);
server.verify().await;
}
@@ -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
@@ -18,7 +18,10 @@ use gateway_core::engine::{
};
use gateway_core::error::ProviderErrorKind;
use gateway_core::event::GatewayEvent;
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload, ProviderSessionState};
use gateway_core::operation::{
CapabilityRequirements, GenerateRequest, Operation, OperationKind, ProtocolPayload,
ProviderSessionState,
};
use gateway_core::policy::ClientApiKeyId;
use gateway_core::routing::{
ClientRoutingScope, ConfigRevision, FrozenAccountScope, ModelCapabilities, ProviderKind,
@@ -2424,3 +2427,36 @@ async fn provider_compiles_catalog_presentation_for_codex_models() {
assert!(presentation.verbosity());
assert!(!presentation.hidden());
}
#[tokio::test]
async fn provider_routes_catalog_model_when_supported_in_api_is_false() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_non_api_model").await;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/codex/models"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/json")
.set_body_raw(
br#"{"models":[{"slug":"gpt-5.6-sol-wm","display_name":"GPT-5.6-Sol-WM","supported_in_api":false,"visibility":"hide"}]}"#,
"application/json",
),
)
.mount(&server)
.await;
let provider = provider_with_base_url(&store, server.uri());
let capabilities = provider
.query_model_capabilities()
.await
.expect("catalog model capabilities");
let model = capabilities.first().expect("catalog model");
assert!(
model
.capabilities()
.match_requirements(&CapabilityRequirements::new(OperationKind::Generate))
.is_some()
);
}
@@ -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(&central, 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);
+7 -26
View File
@@ -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)
+274 -188
View File
@@ -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,
}
+25 -4
View File
@@ -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
View File
@@ -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`;检查失败时继续使用上一份有效画像。
## 启动
+6 -6
View File
@@ -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
View File
@@ -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 倍。
+62
View File
@@ -1,5 +1,48 @@
import antfu from '@antfu/eslint-config'
const templateStylePlugin = {
rules: {
'single-line-simple-interpolation': {
meta: {
type: 'layout',
fixable: 'whitespace',
schema: [],
messages: {
expectedSingleLine: 'Expected a simple mustache interpolation to stay on one line.',
},
},
create(context) {
const { parserServices } = context.sourceCode
if (!parserServices.defineTemplateBodyVisitor)
return {}
return parserServices.defineTemplateBodyVisitor({
'VExpressionContainer[expression!=null]': function (node) {
if (node.parent.type !== 'VElement')
return
const expression = node.expression
if (
expression.loc.start.line !== expression.loc.end.line
|| node.loc.start.line === node.loc.end.line
) {
return
}
context.report({
node,
messageId: 'expectedSingleLine',
fix(fixer) {
return fixer.replaceText(node, `{{ ${context.sourceCode.getText(expression)} }}`)
},
})
},
})
},
},
},
}
export default antfu(
{
type: 'app',
@@ -18,4 +61,23 @@ export default antfu(
a11y: true,
},
},
{
files: ['**/*.vue'],
plugins: {
'template-style': templateStylePlugin,
},
rules: {
'template-style/single-line-simple-interpolation': 'error',
'vue/html-closing-bracket-newline': ['error', {
singleline: 'never',
multiline: 'always',
}],
'vue/multiline-html-element-content-newline': ['error', {
ignores: ['pre', 'textarea'],
ignoreWhenEmpty: true,
allowEmptyLines: false,
}],
'vue/mustache-interpolation-spacing': ['error', 'always'],
},
},
)
@@ -32,11 +32,11 @@ function updateGroup(groupId: string, selected: boolean) {
<div
v-for="group in groups"
:key="group.id"
class="flex min-h-11 items-center justify-between gap-3 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3.5 py-2.5"
class="flex min-h-11 items-center justify-between gap-3 rounded-cp-control bg-cp-subtle px-3.5 py-2.5"
>
<span class="flex min-w-0 items-center gap-2.5">
<span
class="size-3.5 shrink-0 rounded-[4px]"
class="size-3.5 shrink-0 rounded-sm"
:style="{ backgroundColor: group.color }"
aria-hidden="true"
/>
@@ -50,7 +50,7 @@ function updateGroup(groupId: string, selected: boolean) {
</span>
<span
v-if="!group.enabled"
class="shrink-0 text-[11px] font-emphasis text-(--cp-text-muted)"
class="shrink-0 text-[11px] font-emphasis text-cp-muted-text"
>
已禁用
</span>
@@ -58,7 +58,7 @@ function updateGroup(groupId: string, selected: boolean) {
</div>
<p
v-else
class="m-0 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3.5 py-3 text-[12px] font-emphasis text-(--cp-text-secondary)"
class="m-0 rounded-cp-control bg-cp-subtle px-3.5 py-3 text-[12px] font-emphasis text-cp-secondary"
>
{{ loading ? '正在加载分组...' : '暂无分组,请先在分组管理中创建。' }}
</p>
@@ -21,11 +21,11 @@ const fullNames = computed(() => props.groups
<span
v-for="group in groups"
:key="group.id"
class="size-4 shrink-0 rounded-[4px]"
class="size-4 shrink-0 rounded-sm"
:style="{ backgroundColor: group.color }"
:title="group.name"
aria-hidden="true"
/>
</div>
<span v-else class="text-xs text-(--cp-text-muted)">—</span>
<span v-else class="text-xs text-cp-muted-text">—</span>
</template>
@@ -90,72 +90,73 @@ function providerSummary(group: AccountGroup) {
:disabled="disabled || loading"
>
<template #prefix>
<Search class="size-4 text-(--cp-text-tertiary)" />
<Search class="size-4 text-cp-tertiary" />
</template>
</BaseInput>
<BaseScrollbar
v-if="visibleGroups.length > 0"
max-height="260px"
view-class="grid gap-2 pr-2"
>
<div
v-for="group in visibleGroups"
:key="group.id"
class="flex w-full items-start gap-3 rounded-(--cp-input-radius-base) border-0 px-3 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
:class="selectedIds.includes(group.id)
? 'bg-(--cp-info-bg)'
: 'bg-(--cp-bg-subtle) hover:bg-(--cp-default-bg-hover)'"
>
<BaseCheckbox
:model-value="selectedIds.includes(group.id)"
:disabled="disabled || loading"
:label="`选择${group.name}`"
@update:model-value="toggle(group.id)"
/>
<span class="min-w-0 flex-1">
<span class="flex min-w-0 items-center gap-2">
<strong class="min-w-0 truncate text-[13px] text-(--cp-text-primary)">
{{ group.name }}
</strong>
<span
v-if="!group.enabled"
class="shrink-0 rounded-md bg-(--cp-warning-bg) px-1.5 py-1 text-[10px] leading-none font-bold text-(--cp-warning-text)"
>
已禁用
<div class="grid gap-2">
<div
v-for="group in visibleGroups"
:key="group.id"
class="flex w-full items-start gap-3 rounded-cp-control border-0 px-3 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-cp-info-border"
:class="selectedIds.includes(group.id)
? 'bg-cp-info-bg'
: 'bg-cp-subtle hover:bg-cp-default-hover'"
>
<BaseCheckbox
:model-value="selectedIds.includes(group.id)"
:disabled="disabled || loading"
:label="`选择${group.name}`"
@update:model-value="toggle(group.id)"
/>
<span class="min-w-0 flex-1">
<span class="flex min-w-0 items-center gap-2">
<strong class="min-w-0 truncate text-[13px] text-cp-primary">
{{ group.name }}
</strong>
<span
v-if="!group.enabled"
class="shrink-0 rounded-md bg-cp-warning-bg px-1.5 py-1 text-[10px] leading-none font-bold text-cp-warning-text"
>
已禁用
</span>
</span>
<span class="mt-1 block text-[11px] font-emphasis text-cp-secondary">
{{ group.memberCount }} 个账号<template v-if="providerSummary(group)"> · {{ providerSummary(group) }}</template>
</span>
</span>
<span class="mt-1 block text-[11px] font-emphasis text-(--cp-text-secondary)">
{{ group.memberCount }} 个账号<template v-if="providerSummary(group)"> · {{ providerSummary(group) }}</template>
</span>
</span>
</div>
</div>
</BaseScrollbar>
<BaseEmpty
v-else-if="groups.length === 0 && !loading"
compact
size="sm"
title="暂无可选分组"
description="请先在分组管理中创建分组。"
/>
<BaseEmpty
v-else-if="!loading"
compact
size="sm"
title="没有匹配分组"
description="请调整搜索关键词。"
/>
<div
v-if="selectedIds.length === 0"
class="rounded-(--cp-input-radius-base) px-3.5 py-3"
class="rounded-cp-control px-3.5 py-3"
:class="emptyCopy.tone === 'warning'
? 'bg-(--cp-warning-bg) text-(--cp-warning-text)'
: 'bg-(--cp-bg-subtle) text-(--cp-text-secondary)'"
? 'bg-cp-warning-bg text-cp-warning-text'
: 'bg-cp-subtle text-cp-secondary'"
>
<p class="m-0 text-[12px] font-bold">
{{ emptyCopy.title }}
</p>
<p class="mt-1 mb-0 text-[11px] leading-[1.5] font-emphasis">
<p class="mt-1 mb-0 text-[11px] leading-normal font-emphasis">
{{ emptyCopy.description }}
</p>
</div>
@@ -25,10 +25,9 @@ const providerOptions = [
<template>
<BaseSegmented
v-model="provider"
label="按平台筛选"
:options="providerOptions"
:disabled="disabled"
icon-only
size="input"
aria-label="按平台筛选"
display="icon"
/>
</template>
@@ -41,18 +41,18 @@ const authenticationLabel = computed(() => {
<template>
<span class="inline-flex items-center whitespace-nowrap" :class="groupGapClass">
<span
class="inline-flex shrink-0 items-center justify-center bg-(--cp-bg-muted) text-(--cp-text-primary)"
class="inline-flex shrink-0 items-center justify-center bg-cp-muted text-cp-primary"
:class="iconContainerClass"
:title="providerLabel"
>
<Openai v-if="normalizedProvider === 'openai'" :class="iconClass" aria-hidden="true" />
<Xai v-else-if="normalizedProvider === 'xai'" :class="iconClass" aria-hidden="true" />
<span v-else class="text-[10px] font-heavy text-(--cp-text-muted)">?</span>
<span v-else class="text-[10px] font-heavy text-cp-muted-text">?</span>
</span>
<span
v-if="showAuthenticationKind"
class="inline-flex shrink-0 items-center justify-center bg-(--cp-bg-muted) text-(--cp-text-primary)"
class="inline-flex shrink-0 items-center justify-center bg-cp-muted text-cp-primary"
:class="iconContainerClass"
:title="authenticationLabel"
>
@@ -66,7 +66,7 @@ const authenticationLabel = computed(() => {
:class="iconClass"
aria-hidden="true"
/>
<span v-else class="text-[10px] font-heavy text-(--cp-text-muted)">?</span>
<span v-else class="text-[10px] font-heavy text-cp-muted-text">?</span>
</span>
<span class="sr-only">
+37 -98
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { LoaderCircle } from '@lucide/vue'
import { computed, useSlots } from 'vue'
import { computed } from 'vue'
type ButtonVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'ghost'
type ButtonSize = 'sm' | 'default' | 'md' | 'lg'
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive'
type ButtonSize = 'sm' | 'md' | 'lg'
const props = withDefaults(
defineProps<{
@@ -11,106 +11,52 @@ const props = withDefaults(
size?: ButtonSize
loading?: boolean
disabled?: boolean
active?: boolean
iconOnly?: boolean
label?: string
title?: string
type?: 'button' | 'submit' | 'reset'
}>(),
{
variant: 'primary',
size: 'default',
variant: 'secondary',
size: 'md',
loading: false,
disabled: false,
active: false,
iconOnly: false,
type: 'button',
},
)
const slots = useSlots()
defineSlots<{
icon?: () => unknown
loading?: () => unknown
default: () => unknown
}>()
const variantClasses = {
default:
'bg-(--cp-bg-muted) text-(--cp-text-primary) shadow-(--cp-shadow-control) hover:bg-(--cp-default-bg-active) active:bg-(--cp-default-bg-active)',
const variantClasses: Record<ButtonVariant, string> = {
primary:
'bg-(--cp-info) text-(--cp-info-on) hover:bg-(--cp-info-hover) active:bg-(--cp-info-pressed)',
success:
'bg-(--cp-success-bg) text-(--cp-success-text) hover:bg-(--cp-success-bg-hover) active:bg-(--cp-success-bg-active)',
warning:
'bg-(--cp-warning-bg) text-(--cp-warning-text) hover:bg-(--cp-warning-bg-hover) active:bg-(--cp-warning-bg-active)',
danger:
'bg-(--cp-danger-bg) text-(--cp-danger-text) hover:bg-(--cp-danger-bg-hover) active:bg-(--cp-danger-bg-active)',
'bg-cp-accent text-cp-accent-on shadow-cp-control hover:bg-cp-accent-hover active:bg-cp-accent-pressed',
secondary:
'bg-cp-muted text-cp-primary shadow-cp-control hover:bg-cp-default-active active:bg-cp-nav-active',
ghost:
'bg-transparent text-(--cp-text-secondary) hover:bg-(--cp-bg-subtle) active:bg-(--cp-bg-muted)',
'bg-transparent text-cp-secondary shadow-none hover:bg-cp-subtle hover:text-cp-primary active:bg-cp-muted',
destructive:
'bg-cp-danger-bg text-cp-danger-text shadow-none hover:bg-cp-danger-bg-hover active:bg-cp-danger-bg-active',
}
const iconOnlyDefaultClasses
= 'bg-(--cp-bg-surface) text-(--cp-text-secondary) shadow-(--cp-shadow-control) hover:bg-(--cp-default-bg-hover) hover:text-(--cp-normal) active:bg-(--cp-default-bg-active)'
const sizeClasses: Record<ButtonSize, string> = {
sm: 'h-7 gap-1.5 px-3 text-xs',
default: 'h-(--cp-input-height-default) gap-2 px-4 text-[13px]',
md: 'h-11 gap-2 px-4 text-[13px]',
lg: 'h-11.5 gap-2.5 px-5 text-[15px]',
sm: 'h-cp-control-sm gap-1.5 px-3 text-xs',
md: 'h-cp-control-md gap-2 px-4 text-[13px]',
lg: 'h-cp-control-lg gap-2.5 px-5 text-[14px]',
}
const iconOnlySizeClasses: Record<ButtonSize, string> = {
sm: 'size-8 rounded-(--cp-icon-button-radius)',
default: 'size-(--cp-input-height-default) rounded-(--cp-icon-button-radius)',
md: 'size-11 rounded-(--cp-icon-button-radius)',
lg: 'size-11.5 rounded-(--cp-icon-button-radius)',
}
const loadingIconSize: Record<ButtonSize, number> = {
const spinnerSizes: Record<ButtonSize, number> = {
sm: 14,
default: 15,
md: 16,
md: 15,
lg: 17,
}
const loadingIndicatorStyle = computed(() => {
const iconSize = loadingIconSize[props.size]
return {
width: `${iconSize}px`,
height: `${iconSize}px`,
}
})
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 ? '' : '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',
props.iconOnly && (props.variant === 'default' || props.variant === 'primary')
? iconOnlyDefaultClasses
: variantClasses[props.variant],
props.active ? 'bg-(--cp-default-bg-hover) text-(--cp-normal)' : undefined,
])
const ariaLabel = computed(() => props.label || props.title)
const labelText = computed(() => {
const defaultSlot = slots.default?.()
const text = defaultSlot
?.map(node => (typeof node.children === 'string' ? node.children : ''))
.join('')
.trim()
return text || ''
})
const labelClasses = computed(() => [
'inline-flex min-w-0 items-center justify-center gap-2 [&>svg]:shrink-0',
labelText.value.length === 2 ? 'tracking-[0.12em]' : undefined,
])
const loadingIconClasses = computed(() => [
'inline-grid shrink-0 place-items-center leading-none [&>svg]:block',
props.loading
? '[&>svg]:origin-center [&>svg]:animate-spin [&>svg]:transform-view [&>svg]:will-change-transform motion-reduce:[&>svg]:animate-none'
: undefined,
'inline-flex shrink-0 touch-manipulation items-center justify-center rounded-cp-control-sm border-0 font-bold leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:translate-y-px motion-safe:active:scale-[0.985] motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-disabled disabled:text-cp-disabled-text disabled:shadow-none',
sizeClasses[props.size],
variantClasses[props.variant],
])
</script>
@@ -119,31 +65,24 @@ const loadingIconClasses = computed(() => [
:type="type"
:class="classes"
:disabled="disabled || loading"
:aria-label="iconOnly ? ariaLabel : undefined"
:title="title || (iconOnly ? label : undefined)"
:aria-busy="loading"
:aria-busy="loading || undefined"
>
<span
v-if="loading"
:class="loadingIconClasses"
:style="loadingIndicatorStyle"
class="inline-grid shrink-0 place-items-center leading-none"
aria-hidden="true"
>
<LoaderCircle
:size="loadingIconSize[size]"
/>
<slot name="loading">
<LoaderCircle
class="animate-spin motion-reduce:animate-none"
:size="spinnerSizes[size]"
/>
</slot>
</span>
<span v-else-if="$slots.icon" :class="loadingIconClasses" aria-hidden="true">
<span v-else-if="$slots.icon" class="inline-grid shrink-0 place-items-center" aria-hidden="true">
<slot name="icon" />
</span>
<span v-if="!iconOnly && $slots.default" :class="labelClasses">
<slot />
</span>
<span
v-if="iconOnly && !$slots.icon && !loading"
:class="loadingIconClasses"
aria-hidden="true"
>
<span class="inline-flex min-w-0 items-center justify-center gap-2">
<slot />
</span>
</button>
+17 -51
View File
@@ -1,30 +1,19 @@
<script setup lang="ts">
import { computed } from 'vue'
type CardPadding = 'default' | 'compact'
type CardLayout = 'content' | 'flush'
type HeaderCollapseAt = 'sm' | 'lg' | 'none'
type CardPadding = 'none' | 'compact' | 'default'
const props = withDefaults(
defineProps<{
as?: keyof HTMLElementTagNameMap
padding?: CardPadding
layout?: CardLayout
title?: string
description?: string
headerCollapseAt?: HeaderCollapseAt
headerClass?: string
bodyClass?: string
}>(),
{
as: 'section',
padding: 'default',
layout: 'content',
title: undefined,
description: undefined,
headerCollapseAt: 'sm',
headerClass: undefined,
bodyClass: '',
},
)
@@ -37,55 +26,36 @@ const slots = defineSlots<{
default?: () => unknown
}>()
const paddingClasses: Record<CardPadding, string> = {
default: 'p-5.5',
const paddingClasses: Record<CardPadding, string | undefined> = {
none: undefined,
compact: 'p-4',
default: 'p-5.5',
}
const hasManagedHeader = computed(
() =>
!!props.title || !!props.description || !!slots.actions || !!slots.title || !!slots.description,
() => Boolean(props.title || props.description || slots.actions || slots.title || slots.description),
)
const outerPaddingClass = computed(() => {
if (props.layout === 'flush')
return undefined
return paddingClasses[props.padding]
})
const managedHeaderLayoutClasses = computed(() => {
if (props.headerCollapseAt === 'none') {
return 'flex items-start justify-between gap-3'
}
if (props.headerCollapseAt === 'lg') {
return 'flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between'
}
return 'flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'
})
const hasHeader = computed(() => Boolean(slots.header || hasManagedHeader.value))
const contentClasses = computed(() => [
hasHeader.value ? 'mt-4' : undefined,
'flex min-h-0 min-w-0 flex-1 flex-col',
])
</script>
<template>
<component
:is="props.as"
class="[--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] overflow-hidden rounded-(--cp-card-radius) bg-(--cp-bg-surface) shadow-(--cp-shadow-card)"
:class="[
outerPaddingClass,
]"
class="[--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] overflow-hidden rounded-cp-surface bg-cp-surface shadow-cp-card"
:class="[paddingClasses[padding], hasHeader ? 'flex min-h-0 flex-col' : undefined]"
>
<template v-if="$slots.header || hasManagedHeader || $slots.body">
<header
v-if="$slots.header || hasManagedHeader"
class="shrink-0"
:class="props.headerClass"
>
<header v-if="$slots.header || hasManagedHeader" class="shrink-0">
<slot name="header">
<div :class="managedHeaderLayoutClasses">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 pt-0.5">
<h2
v-if="props.title || $slots.title"
class="m-0 text-xl leading-[1.15] font-heavy text-(--cp-text-primary)"
class="m-0 text-xl leading-[1.15] font-heavy text-cp-primary text-balance"
>
<slot name="title">
{{ props.title }}
@@ -93,14 +63,13 @@ const managedHeaderLayoutClasses = computed(() => {
</h2>
<p
v-if="props.description || $slots.description"
class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-emphasis text-(--cp-text-secondary)"
class="mt-1.75 mb-0 text-[13px] leading-[1.3] font-emphasis text-cp-secondary text-pretty"
>
<slot name="description">
{{ props.description }}
</slot>
</p>
</div>
<div v-if="$slots.actions" class="shrink-0">
<slot name="actions" />
</div>
@@ -108,10 +77,7 @@ const managedHeaderLayoutClasses = computed(() => {
</slot>
</header>
<div
v-if="$slots.body || $slots.default"
:class="props.bodyClass"
>
<div v-if="$slots.body || $slots.default" :class="contentClasses">
<slot name="body">
<slot />
</slot>
+35 -36
View File
@@ -1,70 +1,69 @@
<script setup lang="ts">
import { Check, Minus } from '@lucide/vue'
import { computed } from 'vue'
import { computed, useTemplateRef, watchEffect } from 'vue'
const props = withDefaults(
defineProps<{
label: string
indeterminate?: boolean
disabled?: boolean
label?: string
showLabel?: boolean
}>(),
{
indeterminate: false,
disabled: false,
label: undefined,
showLabel: false,
},
)
const model = defineModel<boolean>({ default: false })
const inputRef = useTemplateRef<HTMLInputElement>('input')
const checked = computed(() => model.value || props.indeterminate)
watchEffect(() => {
if (inputRef.value)
inputRef.value.indeterminate = props.indeterminate
})
const boxClasses = computed(() => [
'relative inline-flex size-4 shrink-0 items-center justify-center rounded-(--cp-checkbox-radius) border-0 transition-[background-color,box-shadow,color] duration-150',
'relative inline-flex size-4 shrink-0 items-center justify-center rounded-cp-control-sm border-0 transition-[background-color,box-shadow,color] duration-150 motion-reduce:transition-none',
props.disabled
? 'bg-(--cp-disabled-bg) text-(--cp-disabled-icon)'
? 'bg-cp-disabled text-cp-disabled-icon'
: checked.value
? 'bg-(--cp-info-bg) text-(--cp-info)'
: 'bg-(--cp-bg-surface) text-transparent shadow-[inset_0_0_0_1px_var(--cp-default-border-hover)]',
? 'bg-cp-accent text-cp-accent-on'
: 'bg-cp-surface text-transparent shadow-[inset_0_0_0_1px_var(--cp-default-border-hover)]',
])
function toggle() {
if (props.disabled) {
return
}
model.value = !model.value
}
</script>
<template>
<button
type="button"
class="group inline-flex min-h-4 min-w-4 appearance-none items-center gap-2.5 border-0 bg-transparent p-0 text-[13px] leading-none font-emphasis transition-opacity outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)"
<!-- The native checkbox is nested; the rule does not resolve this Vue template pattern. -->
<!-- eslint-disable-next-line vue-a11y/label-has-for -->
<label
class="group relative inline-flex min-h-4 min-w-4 items-center gap-2.5 text-[13px] leading-none font-emphasis"
:class="disabled ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'"
role="checkbox"
:aria-checked="indeterminate ? 'mixed' : model"
:aria-label="label"
:disabled="disabled"
@click="toggle"
>
<span :class="boxClasses">
<input
ref="input"
v-model="model"
type="checkbox"
class="peer sr-only"
:aria-label="label"
:disabled="disabled"
>
<span
:class="boxClasses"
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-accent-border peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-surface"
aria-hidden="true"
>
<Minus
class="absolute transition-opacity duration-150 size-3"
:class="[indeterminate ? 'opacity-100' : 'opacity-0']"
class="absolute size-3 transition-opacity duration-150 motion-reduce:transition-none"
:class="indeterminate ? 'opacity-100' : 'opacity-0'"
/>
<Check
class="absolute transition-opacity duration-150 size-3"
:class="[!indeterminate && model ? 'opacity-100' : 'opacity-0']"
class="absolute size-3 transition-opacity duration-150 motion-reduce:transition-none"
:class="!indeterminate && model ? 'opacity-100' : 'opacity-0'"
/>
</span>
<span
v-if="showLabel && label"
class="text-[13px] leading-none font-emphasis"
>
{{ label }}
</span>
</button>
<span v-if="showLabel" class="text-[13px] leading-none font-emphasis">{{ label }}</span>
</label>
</template>
@@ -244,31 +244,29 @@ function clamp(value: number, minimum: number, maximum: number) {
v-model="open"
class="p-0.75"
placement="bottom-start"
:width="304"
:disabled="props.disabled"
panel-class="p-3!"
>
<template #trigger>
<button
type="button"
class="group inline-flex size-8 items-center justify-center rounded-[4px] border-0 bg-transparent p-0 outline-none transition-opacity duration-150 hover:opacity-90 focus-visible:ring-2 focus-visible:ring-(--cp-info-border) disabled:cursor-not-allowed disabled:opacity-60"
class="group inline-flex size-8 items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none transition-opacity duration-150 hover:opacity-90 focus-visible:ring-2 focus-visible:ring-cp-info-border disabled:cursor-not-allowed disabled:opacity-60"
:disabled="props.disabled"
:aria-label="props.label"
aria-haspopup="dialog"
:aria-expanded="open"
>
<span class="base-color-picker__checker relative flex size-full items-center justify-center overflow-hidden rounded-[4px]" aria-hidden="true">
<span class="base-color-picker__checker relative flex size-full items-center justify-center overflow-hidden rounded-sm" aria-hidden="true">
<span class="absolute inset-0" :style="{ backgroundColor: normalizedModel }" />
<ChevronDown class="relative size-3.5 text-white drop-shadow-[0_1px_2px_rgb(0_0_0/0.65)]" />
</span>
</button>
</template>
<div class="grid gap-3" role="dialog" :aria-label="props.label">
<div class="grid w-76 gap-3 p-3" role="dialog" :aria-label="props.label">
<div class="grid h-43 grid-cols-[1fr_14px] gap-2">
<div
ref="svPanel"
class="base-color-picker__sv relative touch-none overflow-hidden rounded-[4px] outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
class="base-color-picker__sv relative touch-none overflow-hidden rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
:style="{ backgroundColor: hueColor }"
role="slider"
tabindex="0"
@@ -290,7 +288,7 @@ function clamp(value: number, minimum: number, maximum: number) {
<div
ref="hueSlider"
class="base-color-picker__hue relative touch-none rounded-[4px] outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
class="base-color-picker__hue relative touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
role="slider"
tabindex="0"
aria-label="色相"
@@ -303,7 +301,7 @@ function clamp(value: number, minimum: number, maximum: number) {
@keydown="handleHueKeydown"
>
<span
class="absolute left-1/2 h-1.5 w-4.5 -translate-x-1/2 -translate-y-1/2 rounded-[2px] bg-white shadow-[0_0_0_1px_#64748b,0_1px_2px_#0000004d]"
class="absolute left-1/2 h-1.5 w-4.5 -translate-x-1/2 -translate-y-1/2 rounded-xs bg-white shadow-[0_0_0_1px_#64748b,0_1px_2px_#0000004d]"
:style="hueThumbStyle"
aria-hidden="true"
/>
@@ -313,7 +311,7 @@ function clamp(value: number, minimum: number, maximum: number) {
<div class="grid grid-cols-[1fr_36px] items-center gap-2">
<div
ref="alphaSlider"
class="base-color-picker__alpha relative h-3 touch-none rounded-[4px] outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
class="base-color-picker__alpha relative h-3 touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
:style="alphaTrackStyle"
role="slider"
tabindex="0"
@@ -333,7 +331,7 @@ function clamp(value: number, minimum: number, maximum: number) {
aria-hidden="true"
/>
</div>
<span class="text-right font-mono text-[11px] tabular-nums text-(--cp-text-secondary)">
<span class="text-right font-mono text-[11px] tabular-nums text-cp-secondary">
{{ Math.round(alpha * 100) }}%
</span>
</div>
@@ -348,7 +346,7 @@ function clamp(value: number, minimum: number, maximum: number) {
v-for="color in props.presets"
:key="color"
type="button"
class="relative flex size-6.5 items-center justify-center rounded-[4px] border-0 outline-none transition-transform duration-150 hover:scale-105 focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)"
class="relative flex size-6.5 items-center justify-center rounded-sm border-0 outline-none transition-transform duration-150 hover:scale-105 focus-visible:ring-2 focus-visible:ring-cp-info-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface"
:style="{ backgroundColor: color }"
role="radio"
:aria-label="color"
@@ -366,18 +364,28 @@ function clamp(value: number, minimum: number, maximum: number) {
</div>
<div class="flex items-start gap-2">
<BaseInput
v-model="customInput"
class="min-w-0 flex-1"
aria-label="RGBA 颜色"
placeholder="rgba(96, 165, 250, 1)"
autocomplete="off"
:disabled="props.disabled"
:error="inputError"
@change="updateFromInput"
@keydown.enter="confirm"
/>
<BaseButton size="default" variant="default" :disabled="!parseRgbaColor(customInput)" @click="confirm">
<div class="min-w-0 flex-1">
<BaseInput
v-model="customInput"
aria-label="RGBA 颜色"
placeholder="rgba(96, 165, 250, 1)"
autocomplete="off"
:disabled="props.disabled"
:aria-invalid="Boolean(inputError) || undefined"
aria-describedby="color-input-error"
@change="updateFromInput"
@keydown.enter="confirm"
/>
<p
v-if="inputError"
id="color-input-error"
class="mt-1.5 mb-0 text-xs font-emphasis text-cp-danger-text"
aria-live="polite"
>
{{ inputError }}
</p>
</div>
<BaseButton size="md" variant="secondary" :disabled="!parseRgbaColor(customInput)" @click="confirm">
确定
</BaseButton>
</div>
@@ -2,27 +2,22 @@
import BaseButton from './BaseButton.vue'
import BaseModal from './BaseModal.vue'
type ConfirmVariant = 'info' | 'warning' | 'danger' | 'success'
type ButtonVariant = 'primary' | 'success' | 'warning' | 'danger'
const props = withDefaults(
defineProps<{
title: string
description?: string
variant?: ConfirmVariant
destructive?: boolean
confirmText?: string
cancelText?: string
loading?: boolean
confirmDisabled?: boolean
width?: string | number
}>(),
{
variant: 'warning',
destructive: false,
confirmText: '确认',
cancelText: '取消',
loading: false,
confirmDisabled: false,
width: '480px',
},
)
@@ -31,13 +26,6 @@ const emit = defineEmits<{
cancel: []
}>()
const open = defineModel<boolean>({ default: false })
const confirmVariantMap: Record<ConfirmVariant, ButtonVariant> = {
info: 'primary',
warning: 'warning',
danger: 'danger',
success: 'success',
}
function handleCancel() {
if (props.loading)
return
@@ -57,23 +45,24 @@ function handleConfirm() {
v-model="open"
:title="title"
:description="description"
:variant="variant"
:width="width"
:close-disabled="loading"
size="sm"
:tone="destructive ? 'danger' : 'warning'"
role="alertdialog"
:dismissible="!loading"
>
<div
v-if="$slots.default"
class="text-[14px] leading-[1.55] font-emphasis text-(--cp-text-secondary)"
class="text-[14px] leading-[1.55] font-emphasis text-cp-secondary"
>
<slot />
</div>
<template #footer>
<BaseButton variant="default" :disabled="loading" @click="handleCancel">
<BaseButton variant="secondary" :disabled="loading" @click="handleCancel">
{{ cancelText }}
</BaseButton>
<BaseButton
:variant="confirmVariantMap[variant]"
:variant="destructive ? 'destructive' : 'primary'"
:loading="loading"
:disabled="confirmDisabled"
@click="handleConfirm"
+23 -14
View File
@@ -3,13 +3,22 @@ import type { Component } from 'vue'
import { Inbox } from '@lucide/vue'
import { computed } from 'vue'
const props = defineProps<{
title?: string
description?: string
icon?: Component
compact?: boolean
plain?: boolean
}>()
const props = withDefaults(
defineProps<{
title?: string
description?: string
icon?: Component
size?: 'sm' | 'md'
surface?: 'subtle' | 'none'
}>(),
{
title: undefined,
description: undefined,
icon: undefined,
size: 'md',
surface: 'subtle',
},
)
const resolvedTitle = computed(() => props.title ?? '暂无数据')
const resolvedIcon = computed(() => props.icon ?? Inbox)
@@ -19,22 +28,22 @@ const resolvedIcon = computed(() => props.icon ?? Inbox)
<div
class="grid justify-items-center text-center"
:class="[
plain ? 'bg-transparent' : 'rounded-(--cp-panel-radius) bg-(--cp-bg-subtle)',
compact ? 'gap-2 px-4 py-5' : 'gap-3 px-6 py-8',
surface === 'none' ? 'bg-transparent' : 'rounded-cp-overlay bg-cp-subtle',
size === 'sm' ? 'gap-2 px-4 py-5' : 'gap-3 px-6 py-8',
]"
>
<span
class="inline-flex items-center justify-center rounded-(--cp-icon-button-radius) bg-(--cp-bg-muted) text-(--cp-text-muted)"
:class="compact ? 'size-8' : 'size-10'"
class="inline-flex items-center justify-center rounded-cp-control bg-cp-muted text-cp-muted-text"
:class="size === 'sm' ? 'size-8' : 'size-10'"
>
<component :is="resolvedIcon" :size="compact ? 16 : 18" />
<component :is="resolvedIcon" :size="size === 'sm' ? 16 : 18" />
</span>
<p class="m-0 text-[13px] leading-[1.15] font-heavy text-(--cp-text-primary)">
<p class="m-0 text-[13px] leading-[1.15] font-heavy text-cp-primary">
{{ resolvedTitle }}
</p>
<p
v-if="description"
class="m-0 max-w-72 text-xs leading-[1.45] font-semibold text-(--cp-text-secondary)"
class="m-0 max-w-72 text-xs leading-[1.45] font-semibold text-cp-secondary"
>
{{ description }}
</p>
@@ -1,18 +1,36 @@
<script setup lang="ts">
withDefaults(
import { computed, provide, useId } from 'vue'
import { formFieldKey } from './context'
const props = withDefaults(
defineProps<{
label?: string
description?: string
error?: string
required?: boolean
controlId?: string
}>(),
{
label: undefined,
description: undefined,
error: undefined,
required: false,
controlId: undefined,
},
)
const generatedId = useId()
const resolvedControlId = computed(() => props.controlId ?? `field-${generatedId}`)
const descriptionId = computed(() => props.description ? `${resolvedControlId.value}-description` : undefined)
const errorId = computed(() => props.error ? `${resolvedControlId.value}-error` : undefined)
const describedBy = computed(() => [descriptionId.value, errorId.value].filter(Boolean).join(' ') || undefined)
provide(formFieldKey, {
controlId: resolvedControlId,
describedBy,
invalid: computed(() => Boolean(props.error)),
required: computed(() => props.required),
})
</script>
<template>
@@ -21,33 +39,36 @@ withDefaults(
v-if="label || $slots.label || $slots.extra"
class="mb-2 flex min-w-0 items-center justify-between gap-3"
>
<div class="flex min-w-0 items-center gap-1.5">
<slot name="label">
<span
v-if="label"
class="min-w-0 text-[13px] leading-none font-medium text-(--cp-text-secondary)"
>
{{ label }}
</span>
</slot>
<span v-if="required" class="text-[13px] leading-none font-bold text-(--cp-danger)">
*
</span>
</div>
<!-- The runtime id comes from the injected field context. -->
<!-- eslint-disable-next-line vue-a11y/label-has-for -->
<label
:for="resolvedControlId"
class="flex min-w-0 items-center gap-1.5 text-[13px] leading-none font-medium text-cp-secondary"
>
<slot name="label">{{ label }}</slot>
<span v-if="required" class="font-bold text-cp-danger" aria-hidden="true">*</span>
<span v-if="required" class="sr-only">必填</span>
</label>
<div v-if="$slots.extra" class="inline-flex shrink-0 items-center">
<slot name="extra" />
</div>
</div>
<p
v-if="description"
class="mt-0 mb-2 text-xs leading-[1.35] font-emphasis text-(--cp-text-muted)"
:id="descriptionId"
class="mt-0 mb-2 text-xs leading-[1.35] font-emphasis text-cp-muted-text"
>
{{ description }}
</p>
<div class="min-w-0">
<slot />
</div>
<p v-if="error" class="mt-2 mb-0 text-xs leading-[1.35] font-emphasis text-(--cp-danger-text)">
<p
v-if="error"
:id="errorId"
class="mt-2 mb-0 text-xs leading-[1.35] font-emphasis text-cp-danger-text"
aria-live="polite"
>
{{ error }}
</p>
</div>
@@ -0,0 +1,10 @@
import type { ComputedRef, InjectionKey } from 'vue'
interface FormFieldContext {
controlId: ComputedRef<string>
describedBy: ComputedRef<string | undefined>
invalid: ComputedRef<boolean>
required: ComputedRef<boolean>
}
export const formFieldKey: InjectionKey<FormFieldContext> = Symbol('form-field')
@@ -1,52 +1,5 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import { computed } from 'vue'
type FormGap = 'compact' | 'default' | 'loose'
const props = withDefaults(
defineProps<{
columns?: number | string
gap?: FormGap
}>(),
{
columns: 1,
gap: 'default',
},
)
const gapClasses: Record<FormGap, string> = {
compact: 'gap-3',
default: 'gap-4',
loose: 'gap-5',
}
const formClasses = computed(() => ['base-form grid min-w-0 grid-cols-1', gapClasses[props.gap]])
const formStyle = computed<CSSProperties>(() => {
if (typeof props.columns === 'number') {
return {
'--cp-form-columns':
props.columns > 1 ? `repeat(${props.columns}, minmax(0, 1fr))` : 'minmax(0, 1fr)',
} as CSSProperties
}
return {
'--cp-form-columns': props.columns,
} as CSSProperties
})
</script>
<template>
<form :class="formClasses" :style="formStyle" @submit.prevent>
<form class="grid min-w-0 gap-4" @submit.prevent>
<slot />
</form>
</template>
<style scoped>
@media (min-width: 640px) {
.base-form {
grid-template-columns: var(--cp-form-columns);
}
}
</style>
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { LoaderCircle } from '@lucide/vue'
import { computed } from 'vue'
type IconButtonVariant = 'primary' | 'secondary' | 'success' | 'ghost' | 'destructive'
type IconButtonSize = 'sm' | 'md' | 'lg'
const props = withDefaults(
defineProps<{
label: string
variant?: IconButtonVariant
size?: IconButtonSize
loading?: boolean
disabled?: boolean
pressed?: boolean
type?: 'button' | 'submit' | 'reset'
}>(),
{
variant: 'ghost',
size: 'md',
loading: false,
disabled: false,
pressed: false,
type: 'button',
},
)
defineSlots<{
loading?: () => unknown
default: () => unknown
}>()
const variantClasses: Record<IconButtonVariant, string> = {
primary:
'bg-cp-accent text-cp-accent-on shadow-cp-control hover:bg-cp-accent-hover active:bg-cp-accent-pressed',
secondary:
'bg-cp-surface text-cp-secondary shadow-cp-control hover:bg-cp-default-hover hover:text-cp-primary active:bg-cp-default-active',
success:
'bg-cp-success-bg text-cp-success-text shadow-none hover:bg-cp-success-bg-hover active:bg-cp-success-bg-active',
ghost:
'bg-transparent text-cp-secondary shadow-none hover:bg-cp-subtle hover:text-cp-primary active:bg-cp-muted',
destructive:
'bg-transparent text-cp-danger-text shadow-none hover:bg-cp-danger-bg-hover active:bg-cp-danger-bg-active',
}
const sizeClasses: Record<IconButtonSize, string> = {
sm: 'size-cp-control-sm [&>svg]:size-3.5',
md: 'size-cp-control-md [&>svg]:size-4',
lg: 'size-cp-control-lg [&>svg]:size-4.5',
}
const spinnerSizes: Record<IconButtonSize, number> = {
sm: 14,
md: 16,
lg: 18,
}
const classes = computed(() => [
'inline-grid shrink-0 touch-manipulation place-items-center rounded-cp-control border-0 leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:scale-[0.96] motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-disabled disabled:text-cp-disabled-text disabled:shadow-none',
sizeClasses[props.size],
variantClasses[props.variant],
props.pressed ? 'bg-cp-default-active text-cp-primary' : undefined,
])
</script>
<template>
<button
:type="type"
:class="classes"
:disabled="disabled || loading"
:aria-label="label"
:aria-pressed="pressed || undefined"
:aria-busy="loading || undefined"
:title="label"
>
<span v-if="loading" class="inline-grid place-items-center" aria-hidden="true">
<slot name="loading">
<LoaderCircle
class="animate-spin motion-reduce:animate-none"
:size="spinnerSizes[size]"
/>
</slot>
</span>
<span v-else class="inline-grid place-items-center" aria-hidden="true">
<slot />
</span>
</button>
</template>
+58 -48
View File
@@ -1,99 +1,109 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, inject, useAttrs } from 'vue'
import { formFieldKey } from './BaseForm/context'
type InputSize = 'sm' | 'md' | 'lg'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
placeholder?: string
type?: string
disabled?: boolean
error?: string
autocomplete?: string
name?: string
ariaLabel?: string
min?: number | string
max?: number | string
size?: InputSize
}>(),
{
placeholder: '',
type: 'text',
disabled: false,
error: undefined,
autocomplete: undefined,
name: undefined,
ariaLabel: undefined,
min: undefined,
max: undefined,
size: 'md',
},
)
const model = defineModel<string>({ default: '' })
const attrs = useAttrs()
const field = inject(formFieldKey, null)
// number 输入也保持字符串契约,避免原生 v-model 隐式转换为 number。
function updateModel(event: Event) {
model.value = (event.target as HTMLInputElement).value
const controlId = computed(() => typeof attrs.id === 'string' ? attrs.id : field?.controlId.value)
const invalid = computed(() => Boolean(
field?.invalid.value || attrs['aria-invalid'] === true || attrs['aria-invalid'] === 'true',
))
const describedBy = computed(() => [
typeof attrs['aria-describedby'] === 'string' ? attrs['aria-describedby'] : undefined,
field?.describedBy.value,
].filter(Boolean).join(' ') || undefined)
const rootAttrs = computed(() => ({ class: attrs.class, style: attrs.style }))
const controlAttrs = computed(() => Object.fromEntries(
Object.entries(attrs).filter(([key]) => ![
'class',
'style',
'id',
'aria-describedby',
'aria-invalid',
'aria-required',
].includes(key)),
))
const sizeClasses: Record<InputSize, string> = {
sm: 'h-cp-control-sm gap-2 px-3 text-xs',
md: 'h-cp-control-md gap-2.5 px-3.5 text-[13px]',
lg: 'h-cp-control-lg gap-3 px-4 text-[14px]',
}
const containerClasses = computed(() => [
'relative inline-flex h-(--cp-input-height-default) w-full min-w-0 items-center gap-2.5 overflow-visible rounded-(--cp-input-radius-base) border-0 px-3.5 text-[13px] text-(--cp-text-primary) shadow-(--cp-shadow-input) transition-[background-color,box-shadow,color] duration-[160ms]',
'relative inline-flex w-full min-w-0 items-center overflow-visible rounded-cp-control border-0 text-cp-primary shadow-cp-input transition-[background-color,box-shadow,color] duration-[160ms] motion-reduce:transition-none',
sizeClasses[props.size],
props.disabled
? 'cursor-not-allowed bg-(--cp-disabled-bg) text-(--cp-disabled-text) shadow-none'
: props.error
? 'bg-(--cp-input-error-soft-bg) shadow-(--cp-shadow-input-error)'
? 'cursor-not-allowed bg-cp-disabled text-cp-disabled-text shadow-none'
: invalid.value
? 'bg-(--cp-input-error-soft-bg) shadow-cp-input-error'
: [
'bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))]',
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-(--cp-shadow-input-hover)',
'focus-within:bg-(--cp-input-soft-bg-focus) focus-within:shadow-(--cp-shadow-input-focus)',
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-cp-input-hover',
'focus-within:bg-(--cp-input-soft-bg-focus) focus-within:shadow-cp-input-focus',
],
])
const iconClasses = computed(() => [
'inline-flex shrink-0',
props.disabled
? 'text-(--cp-disabled-icon)'
: props.error
? 'text-(--cp-danger)'
: 'text-(--cp-text-muted)',
? 'text-cp-disabled-icon'
: invalid.value
? 'text-cp-danger'
: 'text-cp-muted-text',
])
const inputClasses = computed(() => [
'h-full min-w-0 flex-1 border-0 bg-transparent text-[13px] font-emphasis leading-[1.15] outline-0',
'placeholder:text-(--cp-text-muted) disabled:cursor-not-allowed disabled:text-(--cp-disabled-text)',
props.error
? 'text-(--cp-danger-text)'
: props.disabled
? 'text-(--cp-disabled-text)'
: 'text-(--cp-text-primary)',
])
function updateModel(event: Event) {
model.value = (event.target as HTMLInputElement).value
}
</script>
<template>
<div class="grid min-w-0 gap-2">
<div v-bind="rootAttrs" class="min-w-0">
<span class="base-input__control" :class="containerClasses">
<span v-if="$slots.prefix" :class="iconClasses">
<span v-if="$slots.prefix" :class="iconClasses" aria-hidden="true">
<slot name="prefix" />
</span>
<input
v-bind="controlAttrs"
:id="controlId"
:value="model"
class="base-input__field"
:class="inputClasses"
:name="name"
class="base-input__field h-full min-w-0 flex-1 border-0 bg-transparent font-emphasis leading-[1.15] text-cp-primary outline-0 placeholder:text-cp-muted-text disabled:cursor-not-allowed disabled:text-cp-disabled-text"
:placeholder="placeholder"
:type="type"
:disabled="disabled"
:autocomplete="autocomplete"
:min="min"
:max="max"
:aria-label="ariaLabel"
:aria-invalid="error ? 'true' : undefined"
:required="field?.required.value || undefined"
:aria-describedby="describedBy"
:aria-invalid="invalid || undefined"
:aria-required="field?.required.value || undefined"
@input="updateModel"
>
<span v-if="$slots.suffix" :class="iconClasses">
<slot name="suffix" />
</span>
</span>
<span v-if="error" class="text-xs font-emphasis leading-[1.15] text-(--cp-danger-text)">{{
error
}}</span>
</div>
</template>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { LoaderCircle } from '@lucide/vue'
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
tone?: 'neutral' | 'destructive'
disabled?: boolean
loading?: boolean
}>(),
{
tone: 'neutral',
disabled: false,
loading: false,
},
)
defineSlots<{
icon?: () => unknown
loading?: () => unknown
default: () => unknown
}>()
const classes = computed(() => [
'flex min-h-9 w-full touch-manipulation items-center gap-2 rounded-cp-control-sm border-0 bg-transparent px-3 text-left text-[13px] leading-none font-emphasis outline-none transition-[background-color,color] duration-150 motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cp-accent-border',
'disabled:cursor-not-allowed disabled:text-cp-disabled-text',
props.tone === 'destructive'
? 'text-cp-danger-text hover:bg-cp-danger-bg-hover'
: 'text-cp-primary hover:bg-cp-default-hover',
])
</script>
<template>
<button
type="button"
:class="classes"
:disabled="disabled || loading"
:aria-busy="loading || undefined"
>
<span v-if="loading" class="inline-grid size-3.5 shrink-0 place-items-center" aria-hidden="true">
<slot name="loading">
<LoaderCircle class="size-3.5 animate-spin motion-reduce:animate-none" />
</slot>
</span>
<span v-else-if="$slots.icon" class="inline-grid shrink-0 place-items-center" aria-hidden="true">
<slot name="icon" />
</span>
<span class="min-w-0 flex-1 truncate"><slot /></span>
</button>
</template>
+71 -78
View File
@@ -1,26 +1,36 @@
<script setup lang="ts">
import { AlertCircle, AlertTriangle, CheckCircle2, Info, X } from '@lucide/vue'
import { computed, nextTick, onBeforeUnmount, useTemplateRef, watch } from 'vue'
import { nextTick, onBeforeUnmount, useId, useTemplateRef, watch } from 'vue'
import { lockBodyScroll, unlockBodyScroll } from '@/utils/body-scroll-lock'
import BaseButton from './BaseButton.vue'
import BaseIconButton from './BaseIconButton.vue'
import BaseScrollbar from './BaseScrollbar.vue'
type ModalVariant = 'default' | 'info' | 'warning' | 'danger' | 'success'
type ModalSize = 'sm' | 'md' | 'lg' | 'xl'
type ModalTone = 'neutral' | 'info' | 'warning' | 'danger' | 'success'
const props = defineProps<{
title: string
description?: string
width?: string | number
variant?: ModalVariant
closeDisabled?: boolean
bodyMaxHeight?: string
bodyViewClass?: string
hideFooter?: boolean
}>()
const props = withDefaults(
defineProps<{
title: string
description?: string
size?: ModalSize
tone?: ModalTone
dismissible?: boolean
role?: 'dialog' | 'alertdialog'
}>(),
{
description: undefined,
size: 'md',
tone: 'neutral',
dismissible: true,
role: 'dialog',
},
)
const open = defineModel<boolean>({ default: false })
const panel = useTemplateRef<HTMLElement>('panel')
const titleId = useId()
const descriptionId = useId()
let previouslyFocused: HTMLElement | null = null
let ownsScrollLock = false
@@ -33,57 +43,46 @@ const focusableSelector = [
'[tabindex]:not([tabindex="-1"])',
].join(',')
const variant = computed(() => props.variant ?? 'default')
const modalStyle = computed(() => {
const preferredWidth
= typeof props.width === 'number' ? `${props.width}px` : (props.width ?? '560px')
return {
width: `min(${preferredWidth}, calc(100dvw - 1.5rem))`,
}
})
const bodyClass = computed(() => [
'min-h-0 overflow-hidden py-4 pr-4 pl-3.25 sm:py-7 sm:pr-7 sm:pl-6.25',
props.description || variant.value !== 'default' ? 'pt-4 sm:pt-5' : 'pt-4 sm:pt-6',
])
const scrollViewClass = computed(() =>
['pb-0.75 pl-0.75 pr-3 sm:pr-4', props.bodyViewClass].filter(Boolean).join(' '),
)
const sizeClasses: Record<ModalSize, string> = {
sm: 'max-w-md',
md: 'max-w-xl',
lg: 'max-w-3xl',
xl: 'max-w-5xl',
}
const iconMap = {
default: Info,
neutral: Info,
info: Info,
warning: AlertTriangle,
danger: AlertCircle,
success: CheckCircle2,
}
const variantClasses: Record<ModalVariant, { iconBg: string, icon: string }> = {
default: {
iconBg: 'bg-(--cp-info-bg)',
icon: 'text-(--cp-info)',
const toneClasses: Record<ModalTone, { iconBg: string, icon: string }> = {
neutral: {
iconBg: 'bg-cp-info-bg',
icon: 'text-cp-info',
},
info: {
iconBg: 'bg-(--cp-info-bg)',
icon: 'text-(--cp-info)',
iconBg: 'bg-cp-info-bg',
icon: 'text-cp-info',
},
warning: {
iconBg: 'bg-(--cp-warning-bg)',
icon: 'text-(--cp-warning)',
iconBg: 'bg-cp-warning-bg',
icon: 'text-cp-warning',
},
danger: {
iconBg: 'bg-(--cp-danger-bg)',
icon: 'text-(--cp-danger)',
iconBg: 'bg-cp-danger-bg',
icon: 'text-cp-danger',
},
success: {
iconBg: 'bg-(--cp-success-bg)',
icon: 'text-(--cp-success)',
iconBg: 'bg-cp-success-bg',
icon: 'text-cp-success',
},
}
function closeModal() {
if (props.closeDisabled)
if (!props.dismissible)
return
open.value = false
}
@@ -187,74 +186,68 @@ onBeforeUnmount(() => {
type="button"
tabindex="-1"
aria-label="关闭弹窗"
class="absolute inset-0 cursor-default border-0 bg-(--cp-overlay-scrim) p-0"
class="absolute inset-0 cursor-default border-0 bg-cp-overlay p-0"
@click="closeModal"
/>
<section
ref="panel"
class="cp-modal-panel [--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] relative grid max-h-[calc(100dvh-1.5rem)] min-w-0 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden rounded-(--cp-card-radius) bg-(--cp-bg-surface) shadow-(--cp-shadow-popover) sm:max-h-[calc(100dvh-3rem)]"
:style="modalStyle"
role="dialog"
class="cp-modal-panel [--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] relative grid max-h-[calc(100dvh-1.5rem)] w-full min-w-0 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden rounded-cp-surface bg-cp-surface shadow-cp-popover sm:max-h-[calc(100dvh-3rem)]"
:class="sizeClasses[size]"
:role="role"
aria-modal="true"
:aria-label="title"
:aria-labelledby="titleId"
:aria-describedby="description ? descriptionId : undefined"
tabindex="-1"
>
<header
class="grid shrink-0 grid-cols-[auto_minmax(0,1fr)_28px] gap-3 p-4 pb-0 sm:gap-4 sm:p-7 sm:pb-0"
class="grid shrink-0 items-start gap-3 p-4 pb-0 sm:gap-4 sm:p-7 sm:pb-0"
:class="tone === 'neutral'
? 'grid-cols-[minmax(0,1fr)_28px]'
: 'grid-cols-[auto_minmax(0,1fr)_28px]'"
>
<span
v-if="variant !== 'default'"
class="inline-flex size-11 items-center justify-center rounded-(--cp-icon-button-radius)"
:class="$slots.icon ? 'bg-(--cp-bg-subtle)' : variantClasses[variant].iconBg"
v-if="tone !== 'neutral'"
class="inline-flex size-11 items-center justify-center rounded-cp-control"
:class="$slots.icon ? 'bg-cp-subtle' : toneClasses[tone].iconBg"
>
<slot name="icon">
<component :is="iconMap[variant]" :size="18" :class="variantClasses[variant].icon" />
<component :is="iconMap[tone]" :size="18" :class="toneClasses[tone].icon" />
</slot>
</span>
<div class="min-w-0" :class="variant === 'default' ? 'col-span-2' : ''">
<h2 class="m-0 text-lg leading-[1.15] font-heavy text-(--cp-text-primary)">
<div class="min-w-0">
<h2 :id="titleId" class="m-0 text-lg leading-[1.15] font-heavy text-cp-primary">
{{ title }}
</h2>
<p
v-if="description"
class="mt-2 mb-0 text-[13px] leading-[1.45] font-semibold text-(--cp-text-secondary)"
:id="descriptionId"
class="mt-2 mb-0 text-[13px] leading-[1.45] font-semibold text-cp-secondary"
>
{{ description }}
</p>
</div>
<BaseButton
icon-only
class="col-start-3"
<BaseIconButton
label="关闭"
size="sm"
variant="ghost"
:disabled="closeDisabled"
:disabled="!dismissible"
@click="closeModal"
>
<X :size="16" />
</BaseButton>
</BaseIconButton>
</header>
<div :class="bodyClass">
<BaseScrollbar
class="h-full -mr-3 sm:-mr-4"
:max-height="bodyMaxHeight"
:view-class="scrollViewClass"
>
<slot />
<div class="min-h-0 overflow-hidden px-4 py-4 sm:px-7 sm:py-6">
<BaseScrollbar class="h-full">
<div class="p-0.75">
<slot />
</div>
</BaseScrollbar>
</div>
<footer
v-if="!hideFooter"
v-if="$slots.footer"
class="flex shrink-0 flex-wrap justify-end gap-2 px-4 pb-4 sm:gap-3 sm:px-7 sm:pb-7"
>
<slot name="footer">
<BaseButton variant="default" @click="open = false">
取消
</BaseButton>
<BaseButton variant="primary" @click="open = false">
确认
</BaseButton>
</slot>
<slot name="footer" />
</footer>
</section>
</div>
@@ -1,27 +1,21 @@
<script setup lang="ts">
const props = defineProps<{
defineProps<{
title: string
description?: string
actionsInline?: boolean
}>()
</script>
<template>
<header
class="grid min-h-17 shrink-0 items-start"
:class="
props.actionsInline
? 'grid-cols-[minmax(0,1fr)_auto] gap-x-4'
: 'grid-cols-1 gap-y-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:gap-x-4'
"
class="flex min-h-17 shrink-0 flex-wrap items-start justify-between gap-x-4 gap-y-3"
>
<div class="min-w-0">
<h1 class="m-0 text-[34px] leading-[1.15] font-extrabold text-(--cp-text-primary)">
<div class="min-w-0 flex-1 basis-64">
<h1 class="m-0 text-[34px] leading-[1.15] font-extrabold text-cp-primary">
{{ title }}
</h1>
<p
v-if="description || $slots.description"
class="mt-2.5 mb-0 flex items-center gap-2 text-[15px] leading-[1.15] font-semibold text-(--cp-text-secondary)"
class="mt-2.5 mb-0 flex items-center gap-2 text-[15px] leading-[1.15] font-semibold text-cp-secondary"
>
<slot name="description">
{{ description }}
@@ -31,7 +25,6 @@ const props = defineProps<{
<div
v-if="$slots.actions"
class="mt-0.5 flex shrink-0 items-center justify-end gap-2"
:class="props.actionsInline ? undefined : 'max-sm:w-full'"
>
<slot name="actions" />
</div>
+9 -40
View File
@@ -27,12 +27,9 @@ const props = withDefaults(
placement?: PopoverPlacement
trigger?: PopoverTrigger
offset?: number
width?: number | string
disabled?: boolean
/** 包含 hover 的触发方式打开弹窗的延迟(毫秒);click 触发不生效。 */
hoverDelay?: number
panelClass?: string
triggerClass?: string
anchorElement?: HTMLElement | null
animatePosition?: boolean
}>(),
@@ -42,8 +39,6 @@ const props = withDefaults(
offset: 6,
disabled: false,
hoverDelay: 400,
panelClass: '',
triggerClass: '',
anchorElement: null,
animatePosition: false,
},
@@ -53,7 +48,6 @@ const open = defineModel<boolean>({ default: false })
const attrs = useAttrs()
const rootRef = ref<HTMLElement | null>(null)
const triggerRef = ref<HTMLElement | null>(null)
const popoverRef = ref<HTMLElement | null>(null)
const popoverStyle = ref<CSSProperties>({})
const popoverArrowStyle = ref<CSSProperties>({})
@@ -62,39 +56,21 @@ const hoverOpenTimer = shallowRef<number>()
const viewportTarget = computed(() => (open.value && typeof window !== 'undefined' ? window : null))
const popoverClasses = computed(() => [
'fixed z-50 overflow-visible rounded-(--cp-popover-radius) border-0 bg-(--cp-bg-surface) p-1.5 text-left shadow-(--cp-shadow-popover)',
'fixed z-50 overflow-visible rounded-cp-overlay border-0 bg-cp-surface text-left text-cp-primary shadow-cp-popover',
props.animatePosition
? 'transition-[left,top] duration-150 ease-out motion-reduce:transition-none'
: undefined,
props.panelClass,
])
const sizeStyle = computed<CSSProperties>(() => {
const width = toCssLength(props.width)
return width ? { width } : {}
})
function toCssLength(value?: number | string) {
if (typeof value === 'number') {
return `${value}px`
}
const trimmed = value?.trim()
return trimmed || undefined
}
function updatePopoverPosition() {
const anchorElement = props.anchorElement ?? triggerRef.value
const anchorElement = props.anchorElement ?? rootRef.value
if (!open.value || !anchorElement)
return
const viewportPadding = 8
const triggerRect = anchorElement.getBoundingClientRect()
const panelRect = popoverRef.value?.getBoundingClientRect()
const panelWidth = Math.max(
panelRect?.width ?? 0,
toCssLength(props.width) ? 0 : triggerRect.width,
)
const panelWidth = panelRect?.width ?? 0
const panelHeight = panelRect?.height ?? 0
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding)
const maxTop = Math.max(viewportPadding, window.innerHeight - panelHeight - viewportPadding)
@@ -113,7 +89,6 @@ function updatePopoverPosition() {
left: `${left}px`,
top: `${top}px`,
maxWidth: `calc(100vw - ${viewportPadding * 2}px)`,
...(toCssLength(props.width) ? {} : { minWidth: `${triggerRect.width}px` }),
}
popoverArrowStyle.value = popoverArrowPosition({
placement: position.placement,
@@ -355,12 +330,12 @@ watch(
)
onClickOutside(rootRef, closePopover, { ignore: [popoverRef] })
useEventListener(triggerRef, 'click', (event) => {
useEventListener(rootRef, 'click', (event) => {
event.stopPropagation()
togglePopover()
})
useEventListener(triggerRef, ['mouseenter', 'focusin'], handleHoverEnter)
useEventListener(triggerRef, ['mouseleave', 'focusout'], handleHoverLeave)
useEventListener(rootRef, ['mouseenter', 'focusin'], handleHoverEnter)
useEventListener(rootRef, ['mouseleave', 'focusout'], handleHoverLeave)
useEventListener(popoverRef, ['mouseenter', 'focusin'], handleHoverEnter)
useEventListener(popoverRef, ['mouseleave', 'focusout'], handleHoverLeave)
useEventListener(viewportTarget, 'keydown', (event) => {
@@ -377,14 +352,8 @@ onBeforeUnmount(() => {
</script>
<template>
<div ref="rootRef" class="relative inline-block overflow-visible" v-bind="attrs">
<div
ref="triggerRef"
class="inline-flex"
:class="props.triggerClass"
>
<slot name="trigger" :open="open" :close="closePopover" :toggle="togglePopover" />
</div>
<div ref="rootRef" class="relative inline-flex overflow-visible" v-bind="attrs">
<slot name="trigger" :open="open" :close="closePopover" :toggle="togglePopover" />
<Teleport to="body">
<Transition
@@ -399,7 +368,7 @@ onBeforeUnmount(() => {
v-if="open"
ref="popoverRef"
:class="popoverClasses"
:style="[sizeStyle, popoverStyle]"
:style="popoverStyle"
>
<span
class="pointer-events-none absolute size-2 rotate-45 bg-inherit"
+51 -26
View File
@@ -5,20 +5,16 @@ import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, useTemplate
const props = withDefaults(
defineProps<{
viewClass?: string
maxHeight?: string
height?: string
horizontal?: boolean
vertical?: boolean
trackInset?: 'default' | 'none'
}>(),
{
viewClass: '',
maxHeight: undefined,
height: undefined,
horizontal: false,
vertical: true,
trackInset: 'default',
},
)
@@ -61,7 +57,6 @@ let horizontalDragStartScrollLeft = 0
let scrollTopPosition = 0
let scrollLeftPosition = 0
let scrollFrameId: number | undefined
let rootHovering = false
const { start: startHideTimer, stop: stopHideTimer } = useTimeoutFn(hideScrollbar, 1600, {
immediate: false,
})
@@ -91,16 +86,10 @@ const wrapClasses = computed(() => [
props.maxHeight ? undefined : 'h-full',
])
const verticalTrackClass = computed(() =>
props.horizontal && canScrollX.value ? 'bottom-3' : 'bottom-1',
props.horizontal && canScrollX.value ? 'bottom-1.5' : 'bottom-0',
)
const horizontalTrackClass = computed(() =>
props.vertical && canScrollY.value ? 'right-3' : 'right-1',
)
const verticalTrackInsetClass = computed(() =>
props.trackInset === 'none' ? 'right-0' : 'right-1',
)
const horizontalTrackInsetClass = computed(() =>
props.trackInset === 'none' ? 'bottom-0 left-0' : 'bottom-1 left-1',
props.vertical && canScrollY.value ? 'right-1.5' : 'right-0',
)
function showScrollbar() {
@@ -113,7 +102,10 @@ function clearHideTimer() {
function scheduleHideScrollbar() {
clearHideTimer()
if (!rootHovering) {
if (!dragging.value
&& !horizontalDragging.value
&& !verticalTrackHovering.value
&& !horizontalTrackHovering.value) {
startHideTimer()
}
}
@@ -129,12 +121,10 @@ function activateScrollbar() {
}
function handleRootMouseEnter() {
rootHovering = true
activateScrollbar()
}
function handleRootMouseLeave() {
rootHovering = false
hideScrollbar()
}
@@ -152,7 +142,7 @@ function setTrackHovering(axis: ScrollbarAxis, hovering: boolean) {
function handleTrackMouseEnter(axis: ScrollbarAxis) {
setTrackHovering(axis, true)
showScrollbar()
scheduleHideScrollbar()
clearHideTimer()
}
function handleTrackMouseLeave(axis: ScrollbarAxis) {
@@ -182,7 +172,12 @@ function measureVerticalScrollbar(wrap: HTMLElement) {
const clientHeight = wrap.clientHeight
const scrollHeight = wrap.scrollHeight
const scrollRange = clamp(scrollHeight - clientHeight, 0, Number.POSITIVE_INFINITY)
const availableTrackHeight = clamp(clientHeight - 8, 0, Number.POSITIVE_INFINITY)
const availableTrackHeight = trackLength(
verticalTrackRef.value,
clientHeight,
'top',
'bottom',
)
if (scrollRange <= overflowTolerance || availableTrackHeight <= 0) {
wrap.scrollTop = 0
verticalMetrics.scrollRange = 0
@@ -209,7 +204,12 @@ function measureHorizontalScrollbar(wrap: HTMLElement) {
const clientWidth = wrap.clientWidth
const scrollWidth = wrap.scrollWidth
const scrollRange = clamp(scrollWidth - clientWidth, 0, Number.POSITIVE_INFINITY)
const availableTrackWidth = clamp(clientWidth - 8, 0, Number.POSITIVE_INFINITY)
const availableTrackWidth = trackLength(
horizontalTrackRef.value,
clientWidth,
'left',
'right',
)
if (scrollRange <= overflowTolerance || availableTrackWidth <= 0) {
wrap.scrollLeft = 0
horizontalMetrics.scrollRange = 0
@@ -225,6 +225,28 @@ function measureHorizontalScrollbar(wrap: HTMLElement) {
horizontalThumbWidth.value = nextThumbWidth
}
function trackLength(
track: HTMLElement | null,
viewportLength: number,
startProperty: 'top' | 'left',
endProperty: 'bottom' | 'right',
) {
if (!track)
return clamp(viewportLength - 8, 0, Number.POSITIVE_INFINITY)
const style = getComputedStyle(track)
const start = Number.parseFloat(style[startProperty])
const end = Number.parseFloat(style[endProperty])
const inset = (Number.isFinite(start) ? start : 4) + (Number.isFinite(end) ? end : 4)
const root = rootRef.value
const containerLength = root
? startProperty === 'top'
? root.clientHeight
: root.clientWidth
: viewportLength
return clamp(containerLength - inset, 0, Number.POSITIVE_INFINITY)
}
function updateVerticalThumbPosition(scrollTop: number) {
const { scrollRange, thumbRange } = verticalMetrics
thumbTop.value
@@ -448,6 +470,7 @@ useEventListener(wrapRef, 'scroll', handleScroll, { passive: true })
useEventListener(dragDocument, 'pointermove', handleDocumentPointerMove)
useEventListener(dragDocument, ['pointerup', 'pointercancel'], handleDocumentPointerUp)
useEventListener(rootRef, 'mouseenter', handleRootMouseEnter)
useEventListener(rootRef, 'mousemove', activateScrollbar, { passive: true })
useEventListener(rootRef, 'mouseleave', handleRootMouseLeave)
useEventListener(verticalTrackRef, 'mouseenter', () => handleTrackMouseEnter('vertical'))
useEventListener(verticalTrackRef, 'mouseleave', () => handleTrackMouseLeave('vertical'))
@@ -471,7 +494,7 @@ defineExpose({
:style="{ maxHeight, height }"
>
<div ref="wrap" :class="wrapClasses" tabindex="0">
<div ref="view" :class="viewClass">
<div ref="view">
<slot />
</div>
</div>
@@ -480,15 +503,14 @@ defineExpose({
v-show="canScrollY"
ref="verticalTrack"
aria-hidden="true"
class="absolute top-1 z-40 w-1.5 rounded-full transition-opacity duration-200"
class="base-scrollbar-track-y absolute right-0 bottom-0 z-40 flex w-3 justify-end transition-opacity duration-200"
:class="[
verticalTrackInsetClass,
verticalTrackClass,
verticalScrollbarVisible ? 'opacity-100' : 'opacity-0',
]"
>
<div
class="w-full rounded-full bg-(--cp-scrollbar-thumb) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover)"
class="w-1.5 rounded-full bg-(--cp-scrollbar-thumb) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover)"
:class="dragging ? 'bg-(--cp-scrollbar-thumb-hover)' : ''"
:style="thumbStyle"
@pointerdown="handleThumbPointerDown"
@@ -499,15 +521,14 @@ defineExpose({
v-show="canScrollX"
ref="horizontalTrack"
aria-hidden="true"
class="absolute z-40 h-1.5 rounded-full transition-opacity duration-200"
class="base-scrollbar-track-x absolute bottom-0 left-0 z-40 flex h-3 items-end transition-opacity duration-200"
:class="[
horizontalTrackInsetClass,
horizontalTrackClass,
horizontalScrollbarVisible ? 'opacity-100' : 'opacity-0',
]"
>
<div
class="h-full rounded-full bg-(--cp-scrollbar-thumb) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover)"
class="h-1.5 rounded-full bg-(--cp-scrollbar-thumb) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover)"
:class="horizontalDragging ? 'bg-(--cp-scrollbar-thumb-hover)' : ''"
:style="horizontalThumbStyle"
@pointerdown="handleHorizontalThumbPointerDown"
@@ -526,4 +547,8 @@ defineExpose({
width: 0;
height: 0;
}
.base-scrollbar-track-y {
top: var(--cp-scrollbar-track-top, 0.25rem);
}
</style>
+83 -47
View File
@@ -1,27 +1,30 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { clamp } from 'es-toolkit'
import { computed } from 'vue'
interface SegmentedOption {
import { clamp } from 'es-toolkit'
import { computed, nextTick } from 'vue'
type SegmentedSize = 'sm' | 'md' | 'lg'
export interface SegmentedOption {
label: string
value: string
icon?: Component
disabled?: boolean
}
type SegmentedSize = 'default' | 'input'
const props = withDefaults(
defineProps<{
label: string
options: SegmentedOption[]
disabled?: boolean
iconOnly?: boolean
display?: 'label' | 'icon'
size?: SegmentedSize
}>(),
{
disabled: false,
iconOnly: false,
size: 'default',
display: 'label',
size: 'md',
},
)
@@ -31,73 +34,106 @@ const activeIndex = computed(() => {
const index = props.options.findIndex(option => option.value === model.value)
return index >= 0 ? index : 0
})
const optionCount = computed(() => clamp(props.options.length, 1, Number.POSITIVE_INFINITY))
const gridStyle = computed(() => ({
gridTemplateColumns: `repeat(${optionCount.value}, minmax(0, 1fr))`,
}))
const isInputSize = computed(() => props.size === 'input')
const indicatorStyle = computed(() => ({
width: isInputSize.value
? `calc((100% - 6px) / ${optionCount.value})`
: `calc((100% - 8px) / ${optionCount.value})`,
width: `calc((100% - 6px) / ${optionCount.value})`,
transform: `translateX(${activeIndex.value * 100}%)`,
}))
const rootClasses = computed(() => isInputSize.value
? 'h-(--cp-input-height-default) rounded-(--cp-input-radius-base) p-0.75'
: 'h-9 rounded-(--cp-icon-button-radius) p-1')
const rootClasses: Record<SegmentedSize, string> = {
sm: 'h-cp-control-sm',
md: 'h-cp-control-md',
lg: 'h-cp-control-lg',
}
const optionClasses: Record<SegmentedSize, string> = {
sm: 'h-6.5 px-2 text-[11px]',
md: 'h-8 px-3 text-xs',
lg: 'h-9.5 px-4 text-[13px]',
}
const indicatorClasses = computed(() => isInputSize.value
? 'inset-y-0.75 left-0.75 rounded-(--cp-input-radius-base)'
: 'top-1 bottom-1 left-1 rounded-(--cp-input-radius-base)')
function enabledIndexes() {
return props.options.flatMap((option, index) => option.disabled ? [] : [index])
}
const optionHeightClass = computed(() => isInputSize.value ? 'h-8' : 'h-7')
function selectOption(value: string) {
if (props.disabled || value === model.value)
function selectOption(index: number) {
const option = props.options[index]
if (props.disabled || !option || option.disabled || option.value === model.value)
return
model.value = value
model.value = option.value
}
async function handleKeydown(event: KeyboardEvent, index: number) {
const indexes = enabledIndexes()
if (indexes.length === 0)
return
const current = indexes.indexOf(index)
let nextIndex: number | undefined
if (event.key === 'ArrowRight' || event.key === 'ArrowDown')
nextIndex = indexes[(current + 1 + indexes.length) % indexes.length]
else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp')
nextIndex = indexes[(current - 1 + indexes.length) % indexes.length]
else if (event.key === 'Home')
nextIndex = indexes[0]
else if (event.key === 'End')
nextIndex = indexes[indexes.length - 1]
else
return
if (nextIndex === undefined)
return
event.preventDefault()
selectOption(nextIndex)
await nextTick()
const buttons = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLButtonElement>('button')
buttons?.[nextIndex]?.focus()
}
</script>
<template>
<div
class="relative inline-grid items-center bg-(--cp-bg-muted)"
:class="rootClasses"
class="relative inline-grid items-center rounded-cp-control bg-cp-muted p-0.75"
:class="rootClasses[size]"
:style="gridStyle"
role="tablist"
role="radiogroup"
:aria-label="label"
:aria-disabled="disabled || undefined"
>
<span
class="pointer-events-none absolute bg-(--cp-bg-surface) shadow-(--cp-shadow-control) transition-transform duration-200 ease-out"
:class="indicatorClasses"
v-if="options.length > 0"
class="pointer-events-none absolute inset-y-0.75 left-0.75 rounded-cp-control bg-cp-surface shadow-cp-control transition-transform duration-200 ease-out motion-reduce:transition-none"
:style="indicatorStyle"
aria-hidden="true"
/>
<button
v-for="option in options"
v-for="(option, index) in options"
:key="option.value"
class="relative z-10 inline-flex min-w-0 items-center justify-center gap-1.5 rounded-(--cp-input-radius-base) border-0 bg-transparent px-3 text-xs leading-none font-emphasis transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
class="relative z-10 inline-flex min-w-0 touch-manipulation items-center justify-center gap-1.5 rounded-cp-control border-0 bg-transparent leading-none font-emphasis outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-cp-accent-border motion-reduce:transition-none"
:class="[
optionHeightClass,
optionClasses[size],
model === option.value
? 'text-(--cp-text-primary)'
: 'text-(--cp-text-secondary) hover:text-(--cp-text-primary)',
disabled ? 'cursor-not-allowed opacity-60 hover:text-(--cp-text-secondary)' : undefined,
iconOnly ? 'px-0' : undefined,
? 'text-cp-primary'
: 'text-cp-secondary hover:text-cp-primary',
disabled || option.disabled ? 'cursor-not-allowed opacity-60 hover:text-cp-secondary' : undefined,
display === 'icon' ? 'px-0' : undefined,
]"
type="button"
role="tab"
:aria-selected="model === option.value"
:aria-label="iconOnly ? option.label : undefined"
:title="iconOnly ? option.label : undefined"
:disabled="disabled"
@click="selectOption(option.value)"
role="radio"
:aria-checked="model === option.value"
:aria-label="display === 'icon' ? option.label : undefined"
:title="display === 'icon' ? option.label : undefined"
:tabindex="model === option.value || (activeIndex === index && !model) ? 0 : -1"
:disabled="disabled || option.disabled"
@click="selectOption(index)"
@keydown="handleKeydown($event, index)"
>
<component :is="option.icon" v-if="option.icon" class="size-3.5 shrink-0" />
<span v-if="!iconOnly">{{ option.label }}</span>
<component :is="option.icon" v-if="option.icon" class="size-3.5 shrink-0" aria-hidden="true" />
<span v-if="display === 'label'" class="truncate">{{ option.label }}</span>
</button>
</div>
</template>
+71 -39
View File
@@ -1,18 +1,20 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import { Check, ChevronDown } from '@lucide/vue'
import { onClickOutside, useEventListener, useThrottleFn, whenever } from '@vueuse/core'
import { clamp } from 'es-toolkit'
import { computed, nextTick, ref, useAttrs, watch } from 'vue'
import { computed, inject, nextTick, ref, useAttrs, useId, watch } from 'vue'
import { formFieldKey } from './BaseForm/context'
interface SelectOption {
type SelectSize = 'sm' | 'md' | 'lg'
export interface SelectOption {
label: string
value: string
disabled?: boolean
}
type SelectSize = 'default' | 'compact'
defineOptions({
inheritAttrs: false,
})
@@ -24,19 +26,18 @@ const props = withDefaults(
disabled?: boolean
placeholder?: string
emptyText?: string
ariaLabel?: string
}>(),
{
size: 'default',
size: 'md',
disabled: false,
placeholder: '请选择',
emptyText: '暂无选项',
ariaLabel: undefined,
},
)
const model = defineModel<string>({ required: true })
const attrs = useAttrs()
const field = inject(formFieldKey, null)
const rootRef = ref<HTMLElement | null>(null)
const triggerRef = ref<HTMLButtonElement | null>(null)
@@ -44,7 +45,26 @@ const popoverRef = ref<HTMLElement | null>(null)
const open = ref(false)
const activeIndex = ref(-1)
const popoverStyle = ref<CSSProperties>({})
const selectId = `base-select-${Math.random().toString(36).slice(2)}`
const selectId = `base-select-${useId()}`
const controlId = computed(() => typeof attrs.id === 'string' ? attrs.id : (field?.controlId.value ?? selectId))
const invalid = computed(() => Boolean(
field?.invalid.value || attrs['aria-invalid'] === true || attrs['aria-invalid'] === 'true',
))
const describedBy = computed(() => [
typeof attrs['aria-describedby'] === 'string' ? attrs['aria-describedby'] : undefined,
field?.describedBy.value,
].filter(Boolean).join(' ') || undefined)
const rootAttrs = computed(() => ({ class: attrs.class, style: attrs.style }))
const triggerAttrs = computed(() => Object.fromEntries(
Object.entries(attrs).filter(([key]) => ![
'class',
'style',
'id',
'aria-describedby',
'aria-invalid',
'aria-required',
].includes(key)),
))
const sizeConfig: Record<
SelectSize,
@@ -54,37 +74,44 @@ const sizeConfig: Record<
icon: number
}
> = {
default: {
md: {
trigger:
'h-(--cp-input-height-default) px-3.5 pr-9 text-[13px] rounded-(--cp-input-radius-base)',
'h-cp-control-md px-3.5 pr-9 text-[13px] rounded-cp-control',
option: 'h-8.5 px-3 text-[13px]',
icon: 16,
},
compact: {
trigger: 'h-8 px-2.5 pr-7 text-xs rounded-(--cp-input-radius-base)',
sm: {
trigger: 'h-cp-control-sm px-2.5 pr-7 text-xs rounded-cp-control',
option: 'h-8 px-2.5 text-xs',
icon: 14,
},
lg: {
trigger: 'h-cp-control-lg px-4 pr-10 text-[14px] rounded-cp-control',
option: 'h-10 px-3.5 text-[14px]',
icon: 17,
},
}
const selectedOption = computed(() => props.options.find(option => option.value === model.value))
const triggerClasses = computed(() => [
'relative inline-flex w-full min-w-0 items-center gap-2 overflow-visible border-0 text-left font-emphasis leading-none shadow-(--cp-shadow-input) outline-none transition-[background-color,box-shadow,color] duration-[160ms]',
'relative inline-flex w-full min-w-0 items-center gap-2 overflow-visible border-0 text-left font-emphasis leading-none shadow-cp-input outline-none transition-[background-color,box-shadow,color] duration-[160ms]',
sizeConfig[props.size].trigger,
props.disabled
? 'cursor-not-allowed bg-(--cp-disabled-bg) text-(--cp-disabled-text) shadow-none'
: open.value
? 'cursor-pointer bg-(--cp-input-soft-bg-focus) text-(--cp-text-primary) shadow-(--cp-shadow-input-focus)'
: [
'cursor-pointer bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] text-(--cp-text-primary)',
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-(--cp-shadow-input-hover)',
'focus-visible:bg-(--cp-input-soft-bg-focus) focus-visible:shadow-(--cp-shadow-input-focus)',
],
? 'cursor-not-allowed bg-cp-disabled text-cp-disabled-text shadow-none'
: invalid.value
? 'cursor-pointer bg-(--cp-input-error-soft-bg) text-cp-danger-text shadow-cp-input-error'
: open.value
? 'cursor-pointer bg-(--cp-input-soft-bg-focus) text-cp-primary shadow-cp-input-focus'
: [
'cursor-pointer bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] text-cp-primary',
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-cp-input-hover',
'focus-visible:bg-(--cp-input-soft-bg-focus) focus-visible:shadow-cp-input-focus',
],
])
const popoverClasses = computed(() => [
'fixed z-50 flex flex-col gap-1 rounded-(--cp-popover-radius) border-0 bg-(--cp-bg-surface) p-1 shadow-(--cp-shadow-popover)',
'fixed z-50 flex flex-col gap-1 rounded-cp-overlay border-0 bg-cp-surface p-1 shadow-cp-popover',
props.options.length > 6 ? 'cp-scrollbar overflow-y-auto' : 'overflow-visible',
])
@@ -228,15 +255,15 @@ function handleTriggerKeydown(event: KeyboardEvent) {
function optionClasses(option: SelectOption, index: number) {
return [
'flex w-full items-center gap-2 rounded-(--cp-input-radius-small) border-0 px-3 text-left font-emphasis leading-none outline-none transition-colors',
'flex w-full touch-manipulation items-center gap-2 rounded-cp-control-sm border-0 px-3 text-left font-emphasis leading-none outline-none transition-colors motion-reduce:transition-none',
sizeConfig[props.size].option,
option.disabled
? 'cursor-not-allowed bg-transparent text-(--cp-disabled-text)'
? 'cursor-not-allowed bg-transparent text-cp-disabled-text'
: option.value === model.value
? 'cursor-pointer bg-(--cp-info-bg) text-(--cp-info-text)'
? 'cursor-pointer bg-cp-info-bg text-cp-info-text'
: activeIndex.value === index
? 'cursor-pointer bg-(--cp-default-bg-hover) text-(--cp-text-primary)'
: 'cursor-pointer bg-transparent text-(--cp-text-primary) hover:bg-(--cp-default-bg-hover)',
? 'cursor-pointer bg-cp-default-hover text-cp-primary'
: 'cursor-pointer bg-transparent text-cp-primary hover:bg-cp-default-hover',
]
}
@@ -263,19 +290,22 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
<div
ref="rootRef"
class="relative inline-block text-left"
v-bind="attrs"
v-bind="rootAttrs"
>
<button
:id="selectId"
v-bind="triggerAttrs"
:id="controlId"
ref="triggerRef"
type="button"
:class="triggerClasses"
:disabled="disabled"
role="combobox"
:aria-label="ariaLabel"
:aria-expanded="open"
:aria-controls="`${selectId}-listbox`"
:aria-activedescendant="open && activeIndex >= 0 ? optionId(activeIndex) : undefined"
:aria-describedby="describedBy"
:aria-invalid="invalid || undefined"
:aria-required="field?.required.value || undefined"
@click="toggleMenu"
@keydown="handleTriggerKeydown"
>
@@ -286,21 +316,22 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
class="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 transition-transform"
:class="
disabled
? 'text-(--cp-disabled-icon)'
? 'text-cp-disabled-icon'
: open
? 'rotate-180 text-(--cp-info)'
: 'text-(--cp-text-muted)'
? 'rotate-180 text-cp-info'
: 'text-cp-muted-text'
"
:size="sizeConfig[size].icon"
aria-hidden="true"
/>
</button>
<Teleport to="body">
<Transition
enter-active-class="transition-[opacity,transform] duration-150 ease-out"
enter-active-class="transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none"
enter-from-class="-translate-y-1 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition-opacity duration-150 ease-in"
leave-active-class="transition-opacity duration-150 ease-in motion-reduce:transition-none"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
@@ -311,11 +342,11 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
:class="popoverClasses"
:style="popoverStyle"
role="listbox"
:aria-labelledby="selectId"
:aria-labelledby="controlId"
>
<div
v-if="options.length === 0"
class="flex h-8.5 items-center rounded-(--cp-input-radius-small) px-3 text-[13px] leading-none font-emphasis text-(--cp-text-muted)"
class="flex h-8.5 items-center rounded-cp-control-sm px-3 text-[13px] leading-none font-emphasis text-cp-muted-text"
>
{{ emptyText }}
</div>
@@ -338,8 +369,9 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
<span class="min-w-0 flex-1 truncate">{{ option.label }}</span>
<Check
v-if="option.value === model"
class="shrink-0 text-(--cp-info)"
:size="size === 'compact' ? 13 : 15"
class="shrink-0 text-cp-info"
:size="size === 'sm' ? 13 : size === 'lg' ? 17 : 15"
aria-hidden="true"
/>
</button>
</template>

Some files were not shown because too many files have changed in this diff Show More