Compare commits

...
37 Commits
Author SHA1 Message Date
ZyyandCodex 5ce36931a8 chore: bump version to 3.2.9
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 11:38:49 +08:00
ZyyandCodex 0d826d9eba fix(openai): align OAuth token refresh
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 11:14:03 +08:00
ZyyandCodex 304242957a fix(accounts): preserve upstream error messages
Co-authored-by: Codex <noreply@openai.com>
2026-08-16 13:28:28 +08:00
ZyyandCodex ef37ae8d9e chore: bump version to 3.2.8
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 23:31:04 +08:00
ZyyandCodex fc13d4f2b6 fix(frontend): restore responsive component behavior
Co-authored-by: Codex <noreply@openai.com>
2026-08-15 20:38:48 +08:00
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
ZyyandCodex 58bc438992 chore: bump version to 3.2.1
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 00:13:58 +08:00
ZyyandCodex b04616f59f refactor(migrations): squash schema into initial baseline
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 00:13:09 +08:00
ZyyandCodex 82ba4fcbba fix(observability): align dashboard and quota window usage
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 00:11:56 +08:00
ZyyandCodex 819f428ac3 fix(frontend): align loading and daily usage display
Co-authored-by: Codex <noreply@openai.com>
2026-08-14 00:10:13 +08:00
227 changed files with 10435 additions and 5039 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))
}
}
@@ -31,23 +31,51 @@ pub enum ProviderAdminErrorKind {
Internal,
}
/// 不泄漏 OAuth material 与 Provider 响应体的管理错误。
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
/// 不携带 OAuth 请求材料的管理错误。
///
/// `message` 用于当前认证管理请求展示;上游没有标准 `error.message` 时,
/// Provider 可以按其官方契约回退为完整的非成功响应正文。
#[derive(Clone, PartialEq, Eq, thiserror::Error)]
#[error("provider admin operation failed: {kind:?}")]
pub struct ProviderAdminError {
kind: ProviderAdminErrorKind,
message: Option<String>,
}
impl std::fmt::Debug for ProviderAdminError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ProviderAdminError")
.field("kind", &self.kind)
.field("message", &self.message.as_ref().map(|_| "<redacted>"))
.finish()
}
}
impl ProviderAdminError {
#[must_use]
pub const fn new(kind: ProviderAdminErrorKind) -> Self {
Self { kind }
Self {
kind,
message: None,
}
}
#[must_use]
pub fn with_message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
#[must_use]
pub const fn kind(&self) -> ProviderAdminErrorKind {
self.kind
}
#[must_use]
pub fn message(&self) -> Option<&str> {
self.message.as_deref()
}
}
/// 一个具体 Provider 对管理控制面提供的解析、验证、上游交互与运行时资源回收能力。
@@ -565,11 +565,7 @@ impl AccountsService for DefaultAccountsService {
let upstream_body = error
.upstream_response()
.map(|response| String::from_utf8_lossy(response.body()).into_owned());
let message = upstream_body
.as_deref()
.filter(|body| !body.is_empty())
.unwrap_or_else(|| error.client_message())
.to_owned();
let message = error.client_message().to_owned();
vec![AccountConnectionTestEvent::Failed {
message,
provider_error_code: error.client_error_code().map(ToOwned::to_owned),
@@ -59,7 +59,11 @@ fn map_provider_error(
ProviderAdminErrorKind::Unavailable => AdminErrorKind::Unavailable,
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
};
AdminError::new(kind, format!("{resource} operation failed"))
let message = error
.message()
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("{resource} operation failed"));
AdminError::new(kind, message)
}
async fn publish_committed(
@@ -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>,
@@ -60,7 +60,7 @@ pub(super) type EventLog = Arc<Mutex<Vec<&'static str>>>;
pub(super) struct FakeProviderAdmin {
kind: ProviderKind,
events: EventLog,
failure: Mutex<Option<ProviderAdminErrorKind>>,
failure: Mutex<Option<ProviderAdminError>>,
quota_failure: Mutex<Option<ProviderAdminErrorKind>>,
pending: Arc<Mutex<Option<PendingAuthorizationMutation>>>,
retry_authorization_after_abort: Mutex<bool>,
@@ -89,7 +89,12 @@ impl FakeProviderAdmin {
}
pub(super) fn fail_next(&self, kind: ProviderAdminErrorKind) {
*self.failure.lock().expect("provider failure") = Some(kind);
*self.failure.lock().expect("provider failure") = Some(ProviderAdminError::new(kind));
}
fn fail_next_with_message(&self, kind: ProviderAdminErrorKind, message: &str) {
*self.failure.lock().expect("provider failure") =
Some(ProviderAdminError::new(kind).with_message(message));
}
pub(super) fn fail_next_quota(&self, kind: ProviderAdminErrorKind) {
@@ -145,7 +150,7 @@ impl FakeProviderAdmin {
fn require_available(&self) -> Result<(), ProviderAdminError> {
match self.failure.lock().expect("provider failure").take() {
Some(kind) => Err(ProviderAdminError::new(kind)),
Some(error) => Err(error),
None => Ok(()),
}
}
@@ -1650,6 +1655,32 @@ async fn accounts_refresh_provider_failure_should_not_call_store_commit() {
);
}
#[tokio::test]
async fn accounts_refresh_should_preserve_the_provider_failure_message() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let upstream_message = "Your refresh token has already been used.";
provider.fail_next_with_message(ProviderAdminErrorKind::Conflict, upstream_message);
let store = FakeAccountStore::new("openai", events.clone());
let services = accounts_service(provider, store).await;
let error = services
.accounts()
.refresh(
&context("refresh-provider-message"),
ProviderAccountId::new("acct_test").expect("account ID"),
)
.await
.expect_err("Provider preparation must fail");
assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Conflict);
assert_eq!(error.to_string(), upstream_message);
assert_eq!(
recorded(&events),
["store.load_account", "provider.prepare_refresh"]
);
}
#[tokio::test]
async fn accounts_refresh_store_failure_should_drop_guard_after_commit_attempt() {
let events = events();
@@ -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));
@@ -1119,7 +1119,8 @@ pub struct AccountStateChange {
pub observed_at: SystemTime,
/// 受控错误原因;恢复 Ready 时必须清空。
pub error_reason: Option<AccountErrorReason>,
/// 已脱敏的上游诊断信息;恢复 Ready 时必须清空。
/// 供管理端展示的错误消息;结构化上游失败应保留原始 message,不能写入整个正文。
/// 恢复 Ready 时必须清空。
pub message: Option<String>,
}
@@ -1588,14 +1589,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 +1617,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 +1691,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 +1710,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 +1735,7 @@ impl SmartNormalization {
.filter(|latency| *latency > 0)
.max();
Self {
max_in_flight,
max_capacity_utilization,
min_quota,
max_quota,
min_latency_ms,
@@ -1721,12 +1746,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 +1773,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 +1815,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,
@@ -101,12 +101,8 @@ impl ObservabilityRepository for PgObservabilityRepository {
observed_at: DateTime<Utc>,
) -> StoreResult<DashboardObservation> {
let filter = UsageRecordFilter::default();
let account_usage_range = ObservabilityRange::new(
range.end - TimeDelta::hours(ACCOUNT_USAGE_TIMELINE_HOURS),
range.end,
)?;
let account_usage_query =
ProviderAccountUsageQuery::recent(account_usage_range, DASHBOARD_ACCOUNT_LIMIT)?
ProviderAccountUsageQuery::recent(range, DASHBOARD_ACCOUNT_LIMIT)?
.with_hourly_request_buckets()?;
let recent_query = UsageRecordQuery {
range,
@@ -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()
@@ -97,22 +97,59 @@ impl PgAdminAccountStore {
.iter()
.map(|window| window.account_id.clone())
.collect::<Vec<_>>();
sqlx::query(ACCOUNT_USAGE_BY_WINDOWS_SQL)
.bind(account_ids)
.bind(keys)
.bind(starts)
.bind(ends)
.fetch_all(&self.pool)
.await
.map_err(|_| {
admin_store_error(
ENTITY,
postgres_unavailable("load provider account quota window usage"),
)
})?
let (usage_rows, model_rows, model_cost_rows) = futures::try_join!(
sqlx::query(ACCOUNT_USAGE_BY_WINDOWS_SQL)
.bind(account_ids.clone())
.bind(keys.clone())
.bind(starts.clone())
.bind(ends.clone())
.fetch_all(&self.pool),
sqlx::query(ACCOUNT_USAGE_MODELS_BY_WINDOWS_SQL)
.bind(account_ids.clone())
.bind(keys.clone())
.bind(starts.clone())
.bind(ends.clone())
.fetch_all(&self.pool),
sqlx::query(ACCOUNT_USAGE_MODEL_COSTS_BY_WINDOWS_SQL)
.bind(account_ids)
.bind(keys)
.bind(starts)
.bind(ends)
.fetch_all(&self.pool),
)
.map_err(|_| {
admin_store_error(
ENTITY,
postgres_unavailable("load provider account quota window usage"),
)
})?;
let mut model_costs = BTreeMap::<(String, String, String), Vec<AccountCost>>::new();
for row in &model_cost_rows {
let (key, cost) = admin_account_usage_window_model_cost(row)?;
model_costs.entry(key).or_default().push(cost);
}
let mut models_by_window = BTreeMap::<(String, String), Vec<AccountModelUsage>>::new();
for row in &model_rows {
let ((account_id, window_key, model), mut usage) =
admin_account_usage_window_model(row)?;
usage.costs = model_costs
.remove(&(account_id.clone(), window_key.clone(), model))
.unwrap_or_default();
models_by_window
.entry((account_id, window_key))
.or_default()
.push(usage);
}
let mut results = usage_rows
.iter()
.map(admin_account_usage_window)
.collect()
.collect::<AdminStoreResult<Vec<_>>>()?;
for result in &mut results {
result.usage.models = models_by_window
.remove(&(result.account_id.clone(), result.key.clone()))
.unwrap_or_default();
}
Ok(results)
}
async fn required_scope(
@@ -276,6 +276,69 @@ pub(crate) fn admin_account_usage_window(
})
}
pub(crate) fn admin_account_usage_window_model(
row: &sqlx::postgres::PgRow,
) -> AdminStoreResult<((String, String, String), AccountModelUsage)> {
let account_id: String = window_usage_value(row, "account_id")?;
let window_key: String = window_usage_value(row, "window_key")?;
let model: String = window_usage_value(row, "model")?;
let provider_reported_count = window_usage_count(row, "provider_reported_count")?;
let calculated_count = window_usage_count(row, "calculated_count")?;
let unavailable_count = window_usage_count(row, "unavailable_count")?;
Ok((
(account_id, window_key, model.clone()),
AccountModelUsage {
model,
request_count: window_usage_count(row, "request_count")?,
success_count: window_usage_count(row, "success_count")?,
input_tokens: optional_window_usage_count(row, "input_tokens")?,
output_tokens: optional_window_usage_count(row, "output_tokens")?,
cached_tokens: optional_window_usage_count(row, "cached_tokens")?,
cache_write_tokens: optional_window_usage_count(row, "cache_write_tokens")?,
reasoning_tokens: optional_window_usage_count(row, "reasoning_tokens")?,
image_input_tokens: optional_window_usage_count(row, "image_input_tokens")?,
image_output_tokens: optional_window_usage_count(row, "image_output_tokens")?,
image_request_count: window_usage_count(row, "image_request_count")?,
image_request_failed_count: window_usage_count(row, "image_request_failed_count")?,
total_tokens: optional_window_usage_count(row, "total_tokens")?,
cost_coverage: AdminCostCoverage {
provider_reported_count,
calculated_count,
partial_count: 0,
unavailable_count,
not_billable_count: 0,
},
costs: Vec::new(),
last_used_at: window_usage_value(row, "last_used_at")?,
},
))
}
pub(crate) fn admin_account_usage_window_model_cost(
row: &sqlx::postgres::PgRow,
) -> AdminStoreResult<((String, String, String), AccountCost)> {
let key = (
window_usage_value(row, "account_id")?,
window_usage_value(row, "window_key")?,
window_usage_value(row, "model")?,
);
let amount = window_usage_value::<String>(row, "amount")?;
let amount = AdminDecimalAmount::from_str(&amount).map_err(|_| {
AdminStoreError::new(
AdminStoreErrorKind::Invalid,
ENTITY,
"persisted account model cost is invalid",
)
})?;
Ok((
key,
AccountCost {
currency: window_usage_value(row, "cost_currency")?,
amount,
},
))
}
pub(crate) fn window_usage_count(
row: &sqlx::postgres::PgRow,
column: &'static str,
@@ -51,6 +51,74 @@ pub(crate) const ACCOUNT_USAGE_BY_WINDOWS_SQL: &str = "with requested_windows as
group by requested.account_id, requested.window_key
order by requested.account_id, requested.window_key";
pub(crate) const ACCOUNT_USAGE_MODELS_BY_WINDOWS_SQL: &str = "with requested_windows as (
select *
from unnest($1::text[], $2::text[], $3::timestamptz[], $4::timestamptz[])
as requested(account_id, window_key, window_start, window_end)
)
select requested.account_id,
requested.window_key,
coalesce(mr.upstream_model_id, mr.requested_model_id) as model,
count(mr.id)::bigint as request_count,
count(mr.id) filter (where mr.outcome = 'succeeded')::bigint as success_count,
sum(mr.input_tokens)::bigint as input_tokens,
sum(mr.output_tokens)::bigint as output_tokens,
sum(mr.cached_tokens)::bigint as cached_tokens,
sum(mr.cache_write_tokens)::bigint as cache_write_tokens,
sum(mr.reasoning_tokens)::bigint as reasoning_tokens,
sum(mr.image_input_tokens)::bigint as image_input_tokens,
sum(mr.image_output_tokens)::bigint as image_output_tokens,
count(mr.id) filter (where mr.image_generation_succeeded is true)::bigint
as image_request_count,
count(mr.id) filter (where mr.image_generation_succeeded is false)::bigint
as image_request_failed_count,
coalesce(sum(coalesce(
mr.total_tokens,
coalesce(mr.input_tokens, 0) + coalesce(mr.output_tokens, 0)
)), 0)::bigint as total_tokens,
count(mr.id) filter (where mr.cost_source = 'provider_reported')::bigint
as provider_reported_count,
count(mr.id) filter (where mr.cost_source = 'calculated')::bigint
as calculated_count,
count(mr.id) filter (where mr.cost_source = 'unavailable')::bigint
as unavailable_count,
max(mr.started_at) as last_used_at
from requested_windows requested
join model_requests mr
on mr.provider_account_ref = requested.account_id
and mr.started_at >= requested.window_start
and mr.started_at < requested.window_end
and mr.outcome = 'succeeded'
and mr.downstream_committed_at is not null
and mr.client_status_code between 200 and 399
group by requested.account_id, requested.window_key,
coalesce(mr.upstream_model_id, mr.requested_model_id)
order by requested.account_id, requested.window_key, request_count desc, model";
pub(crate) const ACCOUNT_USAGE_MODEL_COSTS_BY_WINDOWS_SQL: &str = "with requested_windows as (
select *
from unnest($1::text[], $2::text[], $3::timestamptz[], $4::timestamptz[])
as requested(account_id, window_key, window_start, window_end)
)
select requested.account_id,
requested.window_key,
coalesce(mr.upstream_model_id, mr.requested_model_id) as model,
mr.cost_currency,
sum(mr.cost_amount)::text as amount
from requested_windows requested
join model_requests mr
on mr.provider_account_ref = requested.account_id
and mr.started_at >= requested.window_start
and mr.started_at < requested.window_end
and mr.outcome = 'succeeded'
and mr.downstream_committed_at is not null
and mr.client_status_code between 200 and 399
and mr.cost_amount is not null
and mr.cost_currency is not null
group by requested.account_id, requested.window_key,
coalesce(mr.upstream_model_id, mr.requested_model_id), mr.cost_currency
order by requested.account_id, requested.window_key, model, mr.cost_currency";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderAccountAdminScope {
pub provider_kind: String,
@@ -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::*;
@@ -433,7 +433,7 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
assert_eq!(dashboard.attempts.costs[0].amount.as_str(), "1.25");
assert_eq!(dashboard.provider_accounts.total, 1);
assert_eq!(dashboard.account_usage[0].request_count, 1);
assert_eq!(dashboard.account_usage[0].request_buckets.len(), 24);
assert_eq!(dashboard.account_usage[0].request_buckets.len(), 2);
assert_eq!(
dashboard.account_usage[0]
.request_buckets
@@ -714,7 +714,7 @@ async fn dashboard_summary_totals_include_history_outside_selected_range() {
.expect("seed observability facts");
sqlx::query(
"update model_requests
set started_at = $1 - interval '2 days'
set started_at = $1 - interval '2 hours'
where id = 'req_observe_success'",
)
.bind(now)
@@ -730,6 +730,18 @@ async fn dashboard_summary_totals_include_history_outside_selected_range() {
.await
.expect("dashboard summary");
assert_eq!(dashboard.requests.request_count, 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,
@@ -813,7 +825,7 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
);
assert_eq!(dashboard.provider_accounts.total, 1);
assert_eq!(dashboard.account_usage[0].request_count, 1);
assert_eq!(dashboard.account_usage[0].request_buckets.len(), 24);
assert_eq!(dashboard.account_usage[0].request_buckets.len(), 2);
assert_eq!(
dashboard.account_usage[0]
.request_buckets
@@ -656,9 +656,9 @@ async fn terminal_admin_quota_window_usage_prefers_provider_total_and_falls_back
request_id: "req_quota_window_recent",
account_id: "acct_quota_window",
provider_kind: "openai",
model: "gpt-window",
model: "gpt-window-recent",
total_tokens: 10,
cost_amount: "0",
cost_amount: "1.25",
started_at: now - TimeDelta::minutes(30),
},
)
@@ -670,9 +670,9 @@ async fn terminal_admin_quota_window_usage_prefers_provider_total_and_falls_back
request_id: "req_quota_window_older",
account_id: "acct_quota_window",
provider_kind: "openai",
model: "gpt-window",
model: "gpt-window-older",
total_tokens: 20,
cost_amount: "0",
cost_amount: "2.50",
started_at: now - TimeDelta::hours(6),
},
)
@@ -723,8 +723,26 @@ async fn terminal_admin_quota_window_usage_prefers_provider_total_and_falls_back
assert_eq!(usage[0].key, "long");
assert_eq!(usage[0].usage.total_tokens, Some(720));
assert_eq!(usage[0].usage.models.len(), 2);
assert_eq!(
usage[0]
.usage
.models
.iter()
.map(|model| (model.model.as_str(), model.total_tokens))
.collect::<Vec<_>>(),
[
("gpt-window-older", Some(20)),
("gpt-window-recent", Some(700)),
],
);
assert_eq!(usage[0].usage.models[0].costs[0].amount.as_str(), "2.5");
assert_eq!(usage[0].usage.models[1].costs[0].amount.as_str(), "1.25");
assert_eq!(usage[1].key, "short");
assert_eq!(usage[1].usage.total_tokens, Some(700));
assert_eq!(usage[1].usage.models.len(), 1);
assert_eq!(usage[1].usage.models[0].model, "gpt-window-recent");
assert_eq!(usage[1].usage.models[0].total_tokens, Some(700));
database.close().await;
}
@@ -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;保持同版本同级别以对齐上游指纹。
+30 -18
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);
@@ -256,7 +258,7 @@ impl ProviderAdmin for OpenAiAdminProvider {
))
.await
.inspect_err(|error| {
log_import_failure("prepare_document", credential_admin_error_code(*error));
log_import_failure("prepare_document", credential_admin_error_code(error));
})
.map_err(map_credential_admin_error)?;
let observed_at = Utc::now();
@@ -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)?,
})
@@ -1267,31 +1275,37 @@ fn map_store_error(error: gateway_core::error::StoreError) -> ProviderAdminError
fn map_credential_admin_error(error: CodexCredentialAdminError) -> ProviderAdminError {
use CodexCredentialAdminError as Error;
provider_admin_error(match error {
let upstream_message = error.upstream_message().map(ToOwned::to_owned);
let kind = match error {
Error::InvalidInput
| Error::InvalidCredential
| Error::MissingRefreshToken
| Error::RefreshRejected
| Error::AccountBanned => ProviderAdminErrorKind::Invalid,
| Error::RefreshRejected { .. }
| Error::AccountBanned { .. } => ProviderAdminErrorKind::Invalid,
Error::NotFound => ProviderAdminErrorKind::NotFound,
Error::RefreshLeaseUnavailable | Error::RefreshAmbiguous => {
Error::RefreshLeaseUnavailable | Error::RefreshAmbiguous { .. } => {
ProviderAdminErrorKind::Conflict
}
Error::RefreshUnavailable => ProviderAdminErrorKind::Unavailable,
})
};
let error = provider_admin_error(kind);
match upstream_message {
Some(message) => error.with_message(message),
None => error,
}
}
const fn credential_admin_error_code(error: CodexCredentialAdminError) -> &'static str {
const fn credential_admin_error_code(error: &CodexCredentialAdminError) -> &'static str {
match error {
CodexCredentialAdminError::InvalidInput => "invalid_input",
CodexCredentialAdminError::InvalidCredential => "invalid_credential",
CodexCredentialAdminError::NotFound => "not_found",
CodexCredentialAdminError::MissingRefreshToken => "missing_refresh_token",
CodexCredentialAdminError::RefreshLeaseUnavailable => "refresh_lease_unavailable",
CodexCredentialAdminError::RefreshRejected => "refresh_rejected",
CodexCredentialAdminError::AccountBanned => "account_banned",
CodexCredentialAdminError::RefreshRejected { .. } => "refresh_rejected",
CodexCredentialAdminError::AccountBanned { .. } => "account_banned",
CodexCredentialAdminError::RefreshUnavailable => "refresh_unavailable",
CodexCredentialAdminError::RefreshAmbiguous => "refresh_ambiguous",
CodexCredentialAdminError::RefreshAmbiguous { .. } => "refresh_ambiguous",
}
}
@@ -1360,9 +1374,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)
@@ -374,7 +376,7 @@ impl fmt::Debug for PreparedCodexCredentialRotationGuard {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum CodexCredentialAdminError {
#[error("Codex account input is invalid")]
InvalidInput,
@@ -387,13 +389,30 @@ pub enum CodexCredentialAdminError {
#[error("Codex refresh lease is unavailable")]
RefreshLeaseUnavailable,
#[error("Codex refresh token was rejected")]
RefreshRejected,
RefreshRejected { message: Option<String> },
#[error("Codex account is banned")]
AccountBanned,
AccountBanned { message: Option<String> },
#[error("Codex refresh service is unavailable")]
RefreshUnavailable,
#[error("Codex refresh send state is ambiguous")]
RefreshAmbiguous,
RefreshAmbiguous { message: Option<String> },
}
impl CodexCredentialAdminError {
#[must_use]
pub fn upstream_message(&self) -> Option<&str> {
match self {
Self::RefreshRejected { message }
| Self::AccountBanned { message }
| Self::RefreshAmbiguous { message } => message.as_deref(),
Self::InvalidInput
| Self::InvalidCredential
| Self::NotFound
| Self::MissingRefreshToken
| Self::RefreshLeaseUnavailable
| Self::RefreshUnavailable => None,
}
}
}
/// 无状态的 Codex Admin command preparer。
@@ -473,8 +492,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,
@@ -794,6 +817,7 @@ impl CodexCredentialAdminService {
.refresher
.refresh(refresh_token.expose_secret())
.await
.inspect_err(|error| log_manual_refresh_failure(&account_id, error))
.map_err(map_refresh_failure)?;
let access_token_rotated = tokens.access_token.is_some();
let secret = CodexOAuthSecret {
@@ -917,6 +941,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,
@@ -1126,15 +1151,33 @@ fn china_rfc3339(value: DateTime<Utc>) -> String {
.to_rfc3339()
}
const fn map_refresh_failure(error: RefreshFailure) -> CodexCredentialAdminError {
fn map_refresh_failure(error: RefreshFailure) -> CodexCredentialAdminError {
match error {
RefreshFailure::InvalidGrant => CodexCredentialAdminError::RefreshRejected,
RefreshFailure::Banned => CodexCredentialAdminError::AccountBanned,
RefreshFailure::RetryableTransport => CodexCredentialAdminError::RefreshUnavailable,
RefreshFailure::Transport => CodexCredentialAdminError::RefreshAmbiguous,
RefreshFailure::InvalidGrant { message, .. } => {
CodexCredentialAdminError::RefreshRejected { message }
}
RefreshFailure::Banned { message, .. } => {
CodexCredentialAdminError::AccountBanned { message }
}
RefreshFailure::RetryableTransport { .. } => CodexCredentialAdminError::RefreshUnavailable,
RefreshFailure::Transport { message, .. } => {
CodexCredentialAdminError::RefreshAmbiguous { message }
}
}
}
fn log_manual_refresh_failure(account_id: &ProviderAccountId, error: &RefreshFailure) {
let upstream = error.upstream();
tracing::warn!(
account_id = %account_id,
failure_class = error.classification(),
upstream_message = ?error.message(),
upstream_status = ?upstream.map(super::token_client::RefreshUpstreamFailure::status),
upstream_body = ?upstream.map(super::token_client::RefreshUpstreamFailure::body),
"OpenAI OAuth manual refresh failed"
);
}
fn parse_import_document(
payload: &Value,
) -> Result<Vec<ParsedCodexImportAccount>, CodexCredentialAdminError> {
@@ -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 {
@@ -20,7 +20,7 @@ use thiserror::Error;
use super::recovery_log::{CodexOAuthRecoveryOperation, record_oauth_recovery};
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
use super::token_client::{RefreshFailure, TokenPair, TokenRefresher};
use super::token_client::{RefreshFailure, RefreshUpstreamFailure, TokenPair, TokenRefresher};
use super::types::CodexOAuthSecret;
const PROVIDER_NAME: &str = "openai";
@@ -91,12 +91,17 @@ fn log_refresh_deferred(
access_token_expires_at: Option<SystemTime>,
attempt: u32,
reason: &'static str,
upstream_message: Option<&str>,
upstream: Option<&RefreshUpstreamFailure>,
retry_at: SystemTime,
) {
tracing::warn!(
account_id = %account_id,
attempt,
reason,
upstream_message = ?upstream_message,
upstream_status = ?upstream.map(RefreshUpstreamFailure::status),
upstream_body = ?upstream.map(RefreshUpstreamFailure::body),
retry_at = %DateTime::<Utc>::from(retry_at),
access_token_expires_at = ?access_token_expires_at.map(DateTime::<Utc>::from),
recovery_deadline = ?refresh_recovery_deadline(access_token_expires_at)
@@ -105,6 +110,26 @@ fn log_refresh_deferred(
);
}
struct RefreshFailureContext<'a> {
reason: &'static str,
message: Option<String>,
upstream: Option<&'a RefreshUpstreamFailure>,
}
impl<'a> RefreshFailureContext<'a> {
const fn new(
reason: &'static str,
message: Option<String>,
upstream: Option<&'a RefreshUpstreamFailure>,
) -> Self {
Self {
reason,
message,
upstream,
}
}
}
fn refresh_due_at(
account: &ProviderAccount,
policy: ProviderRefreshPolicy,
@@ -257,7 +282,7 @@ impl CodexCredentialRefreshService {
&due.account,
CredentialState::Expired,
AccountErrorReason::CredentialExpired,
"refresh_recovery_window_exhausted",
RefreshFailureContext::new("refresh_recovery_window_exhausted", None, None),
CodexCredentialRefreshOutcome::Invalidated { account_id },
)
.await;
@@ -293,29 +318,33 @@ impl CodexCredentialRefreshService {
.ok_or(CodexCredentialRefreshError::InvalidRefreshResponse)?;
match self.refresher.refresh(refresh_token.expose_secret()).await {
Ok(tokens) => self.persist_success(due, tokens).await,
Err(RefreshFailure::InvalidGrant) => {
Err(RefreshFailure::InvalidGrant { message, upstream }) => {
self.persist_terminal(
&due.account,
CredentialState::Expired,
AccountErrorReason::CredentialExpired,
"refresh_invalid_grant",
RefreshFailureContext::new(
"refresh_invalid_grant",
message,
upstream.as_deref(),
),
CodexCredentialRefreshOutcome::Invalidated { account_id },
)
.await
}
Err(RefreshFailure::Banned) => {
Err(RefreshFailure::Banned { message, upstream }) => {
self.persist_terminal(
&due.account,
CredentialState::Banned,
AccountErrorReason::AccountBanned,
"account_banned",
RefreshFailureContext::new("account_banned", message, upstream.as_deref()),
CodexCredentialRefreshOutcome::Banned { account_id },
)
.await
}
Err(RefreshFailure::RetryableTransport) => {
Err(RefreshFailure::RetryableTransport { message }) => {
if self
.defer_refresh(&due.account, "transport-not-sent")
.defer_refresh(&due.account, "transport-not-sent", Some(&message), None)
.await?
{
Ok(CodexCredentialRefreshOutcome::Transient { account_id })
@@ -323,11 +352,16 @@ impl CodexCredentialRefreshService {
Ok(CodexCredentialRefreshOutcome::Stale { account_id })
}
}
Err(RefreshFailure::Transport) => {
Err(RefreshFailure::Transport { message, upstream }) => {
// 上游瞬态(429/5xx/超时/畸形响应等,发送状态未知)保留现有凭据、
// 推进退避重试;仅 invalid_grant/banned 才终态失效账号。
if self
.defer_refresh(&due.account, "transport-ambiguous")
.defer_refresh(
&due.account,
"transport-ambiguous",
message.as_deref(),
upstream.as_deref(),
)
.await?
{
Ok(CodexCredentialRefreshOutcome::Transient { account_id })
@@ -483,9 +517,14 @@ impl CodexCredentialRefreshService {
account: &ProviderAccount,
credential_state: CredentialState,
error_reason: AccountErrorReason,
reason: &'static str,
failure: RefreshFailureContext<'_>,
outcome: CodexCredentialRefreshOutcome,
) -> Result<CodexCredentialRefreshOutcome, CodexCredentialRefreshError> {
let RefreshFailureContext {
reason,
message,
upstream,
} = failure;
match self.repository.load_runtime_credential(account).await {
Ok(_) => {}
Err(CredentialRepositoryError::RevisionConflict) => {
@@ -495,6 +534,7 @@ impl CodexCredentialRefreshService {
}
Err(error) => return Err(error.into()),
}
let persisted_message = message.as_deref().unwrap_or(reason).to_owned();
match self
.repository
.apply_state_with_reason(
@@ -502,7 +542,7 @@ impl CodexCredentialRefreshService {
credential_state,
SystemTime::now(),
Some(error_reason),
Some(reason.to_owned()),
Some(persisted_message),
)
.await
{
@@ -511,6 +551,9 @@ impl CodexCredentialRefreshService {
account_id = %account.id(),
?credential_state,
reason,
upstream_message = ?message.as_deref(),
upstream_status = ?upstream.map(RefreshUpstreamFailure::status),
upstream_body = ?upstream.map(RefreshUpstreamFailure::body),
access_token_expires_at = ?account.access_token_expires_at()
.map(DateTime::<Utc>::from),
recovery_deadline = ?refresh_recovery_deadline(account.access_token_expires_at())
@@ -532,6 +575,8 @@ impl CodexCredentialRefreshService {
&self,
account: &ProviderAccount,
reason: &'static str,
upstream_message: Option<&str>,
upstream: Option<&RefreshUpstreamFailure>,
) -> Result<bool, CodexCredentialRefreshError> {
let attempt = self
.credential_state
@@ -552,6 +597,8 @@ impl CodexCredentialRefreshService {
account.access_token_expires_at(),
attempt,
reason,
upstream_message,
upstream,
retry_at,
);
Ok(true)
@@ -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
@@ -3,7 +3,7 @@
use async_trait::async_trait;
use reqwest::{Client, StatusCode, redirect::Policy};
use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::time::Duration;
@@ -51,16 +51,106 @@ impl fmt::Debug for TokenPair {
}
/// Codex token 刷新的稳定失败分类。
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[derive(Clone, PartialEq, Eq, thiserror::Error)]
pub enum RefreshFailure {
#[error("refresh token is invalid or expired")]
InvalidGrant,
InvalidGrant {
message: Option<String>,
upstream: Option<Box<RefreshUpstreamFailure>>,
},
#[error("account is banned")]
Banned,
Banned {
message: Option<String>,
upstream: Option<Box<RefreshUpstreamFailure>>,
},
#[error("refresh transport failed before server processing")]
RetryableTransport,
RetryableTransport { message: String },
#[error("refresh transport failed after possible server processing")]
Transport,
Transport {
message: Option<String>,
upstream: Option<Box<RefreshUpstreamFailure>>,
},
}
impl fmt::Debug for RefreshFailure {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("RefreshFailure")
.field("classification", &self.classification())
.field("message", &self.message().map(|_| "<redacted>"))
.field("upstream", &self.upstream())
.finish()
}
}
impl RefreshFailure {
#[must_use]
pub fn message(&self) -> Option<&str> {
match self {
Self::InvalidGrant { message, .. }
| Self::Banned { message, .. }
| Self::Transport { message, .. } => message.as_deref(),
Self::RetryableTransport { message } => Some(message),
}
}
#[must_use]
pub fn upstream(&self) -> Option<&RefreshUpstreamFailure> {
match self {
Self::InvalidGrant { upstream, .. }
| Self::Banned { upstream, .. }
| Self::Transport { upstream, .. } => upstream.as_deref(),
Self::RetryableTransport { .. } => None,
}
}
#[must_use]
pub const fn classification(&self) -> &'static str {
match self {
Self::InvalidGrant { .. } => "invalid-grant",
Self::Banned { .. } => "account-banned",
Self::RetryableTransport { .. } => "transport-not-sent",
Self::Transport { .. } => "transport-ambiguous",
}
}
}
/// 当前 OAuth 刷新请求收到的完整非成功响应。
///
/// 该值不持久化;`Debug` 不输出正文。调用方仅在受控刷新失败日志中显式记录正文。
#[derive(Clone, PartialEq, Eq)]
pub struct RefreshUpstreamFailure {
status: u16,
body: String,
}
impl RefreshUpstreamFailure {
fn new(status: StatusCode, body: &[u8]) -> Self {
Self {
status: status.as_u16(),
body: String::from_utf8_lossy(body).into_owned(),
}
}
#[must_use]
pub const fn status(&self) -> u16 {
self.status
}
#[must_use]
pub fn body(&self) -> &str {
&self.body
}
}
impl fmt::Debug for RefreshUpstreamFailure {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("RefreshUpstreamFailure")
.field("status", &self.status)
.field("body", &"<redacted>")
.finish()
}
}
/// Codex token 刷新端口。
@@ -186,6 +276,51 @@ struct RefreshTokenResponse {
expires_in: Option<u64>,
}
#[derive(Serialize)]
struct RefreshTokenRequest<'a> {
client_id: &'a str,
grant_type: &'static str,
refresh_token: &'a str,
}
#[derive(Deserialize)]
struct RefreshErrorResponse {
error: Option<RefreshError>,
code: Option<String>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum RefreshError {
Details(RefreshErrorDetails),
Code(String),
}
#[derive(Deserialize)]
struct RefreshErrorDetails {
code: Option<String>,
message: Option<String>,
}
impl RefreshErrorResponse {
fn code(&self) -> Option<&str> {
match self.error.as_ref() {
Some(RefreshError::Details(error)) => error.code.as_deref(),
Some(RefreshError::Code(code)) => Some(code.as_str()),
None => None,
}
.or(self.code.as_deref())
}
fn message(&self) -> Option<String> {
match self.error.as_ref() {
Some(RefreshError::Details(error)) => error.message.as_deref(),
Some(RefreshError::Code(_)) | None => None,
}
.map(ToOwned::to_owned)
}
}
#[derive(Deserialize)]
struct AuthorizationCodeResponse {
access_token: String,
@@ -200,11 +335,11 @@ impl TokenRefresher for OpenAiTokenClient {
let response = self
.client
.post(&self.config.token_endpoint)
.form(&[
("grant_type", "refresh_token"),
("client_id", self.config.client_id.as_str()),
("refresh_token", refresh_token),
])
.json(&RefreshTokenRequest {
client_id: self.config.client_id.as_str(),
grant_type: "refresh_token",
refresh_token,
})
.send()
.await
.map_err(|error| refresh_transport_failure(&error))?;
@@ -212,7 +347,10 @@ impl TokenRefresher for OpenAiTokenClient {
if !status.is_success() {
return Err(classify_refresh_failure(status, &body));
}
parse_token_pair(&body).map_err(|()| RefreshFailure::Transport)
parse_token_pair(&body).map_err(|()| RefreshFailure::Transport {
message: Some("OpenAI OAuth refresh returned an invalid success response".to_owned()),
upstream: None,
})
}
}
@@ -273,13 +411,21 @@ async fn read_bounded_response(
while let Some(chunk) = response
.chunk()
.await
.map_err(|_| RefreshFailure::Transport)?
.map_err(|error| RefreshFailure::Transport {
message: Some(error.to_string()),
upstream: None,
})?
{
let next_len = body
.len()
.checked_add(chunk.len())
.filter(|length| *length <= MAX_OAUTH_RESPONSE_BYTES)
.ok_or(RefreshFailure::Transport)?;
.ok_or_else(|| RefreshFailure::Transport {
message: Some(format!(
"OpenAI OAuth response exceeded {MAX_OAUTH_RESPONSE_BYTES} bytes"
)),
upstream: None,
})?;
body.reserve(next_len.saturating_sub(body.len()));
body.extend_from_slice(&chunk);
}
@@ -312,32 +458,77 @@ fn optional_expiry(value: Option<u64>) -> Option<Duration> {
}
fn classify_refresh_failure(status: StatusCode, body: &[u8]) -> RefreshFailure {
// 官方刷新错误的消息与错误码分别位于 `error.message`、`error.code`;
// `error` 字符串与顶层 `code` 仅用于兼容官方客户端自身的错误码提取契约。
let error = serde_json::from_slice::<RefreshErrorResponse>(body).ok();
let message = Some(refresh_failure_message(error.as_ref(), body));
let upstream = || Some(Box::new(RefreshUpstreamFailure::new(status, body)));
// 5xx 与 429 按状态码判为瞬态:其正文(CDN/网关页等)不是权威 OAuth 错误响应,
// 不据此终态失效账号。
// 不据此终态失效账号,但官方 `error.message` 仍用于本次诊断。
if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS {
return RefreshFailure::Transport;
return RefreshFailure::Transport {
message,
upstream: upstream(),
};
}
// 4xx 正文才是权威 OAuth 错误响应,据其区分真过期/封禁与其余瞬态。
let lower = String::from_utf8_lossy(body).to_ascii_lowercase();
if lower.contains("account has been deactivated") {
return RefreshFailure::Banned;
let Some(error) = error else {
return RefreshFailure::Transport {
message,
upstream: upstream(),
};
};
if message.as_deref().is_some_and(|message| {
message
.to_ascii_lowercase()
.contains("account has been deactivated")
}) {
return RefreshFailure::Banned {
message,
upstream: upstream(),
};
}
if lower.contains("invalid_grant")
|| lower.contains("invalid_token")
|| lower.contains("access_denied")
|| lower.contains("refresh_token_expired")
|| lower.contains("refresh_token_reused")
{
return RefreshFailure::InvalidGrant;
let normalized_code = error.code().map(str::to_ascii_lowercase);
if matches!(
normalized_code.as_deref(),
Some(
"invalid_grant"
| "invalid_token"
| "access_denied"
| "refresh_token_expired"
| "refresh_token_reused"
| "refresh_token_invalidated"
)
) {
return RefreshFailure::InvalidGrant {
message,
upstream: upstream(),
};
}
RefreshFailure::Transport
RefreshFailure::Transport {
message,
upstream: upstream(),
}
}
fn refresh_failure_message(error: Option<&RefreshErrorResponse>, body: &[u8]) -> String {
if let Some(message) = error.and_then(RefreshErrorResponse::message) {
return message;
}
if body.is_empty() {
return "Unknown error".to_owned();
}
String::from_utf8_lossy(body).into_owned()
}
fn refresh_transport_failure(error: &reqwest::Error) -> RefreshFailure {
let message = error.to_string();
if is_safe_to_retry_refresh_transport(error) {
RefreshFailure::RetryableTransport
RefreshFailure::RetryableTransport { message }
} else {
RefreshFailure::Transport
RefreshFailure::Transport {
message: Some(message),
upstream: None,
}
}
}
+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
@@ -78,7 +78,21 @@ impl TokenRefresher for SingleUseRefresher {
.lock()
.expect("refresh response lock")
.take()
.ok_or(RefreshFailure::Transport)
.ok_or_else(|| RefreshFailure::Transport {
message: Some("test refresh response is exhausted".to_owned()),
upstream: None,
})
}
}
struct FailingRefresher {
failure: RefreshFailure,
}
#[async_trait]
impl TokenRefresher for FailingRefresher {
async fn refresh(&self, _: &str) -> Result<TokenPair, RefreshFailure> {
Err(self.failure.clone())
}
}
@@ -267,6 +281,51 @@ async fn scheduled_refresh_uses_the_current_margin_without_persisting_a_normal_s
);
}
#[tokio::test]
async fn scheduled_refresh_persists_the_original_upstream_error_message() {
let store = Arc::new(MemoryAccountStore::default());
let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60));
let upstream_message = "Refresh token has already been used.";
let service = CodexCredentialRefreshService::new(
store.repository(),
Arc::new(FailingRefresher {
failure: RefreshFailure::InvalidGrant {
message: Some(upstream_message.to_owned()),
upstream: None,
},
}),
Arc::new(RefreshLeases),
Arc::new(RefreshCredentialState),
policy,
);
let account_id = "acct_invalid_refresh_token";
seed_refreshable_account(
&store,
account_id,
SystemTime::now()
.checked_add(Duration::from_secs(120))
.expect("test expiry"),
None,
)
.await;
let outcomes = service.refresh_due().await.expect("refresh cycle");
assert!(matches!(
outcomes.as_slice(),
[CodexCredentialRefreshOutcome::Invalidated {
account_id: invalidated_account_id,
}] if invalidated_account_id == account_id
));
assert_eq!(
store
.account(account_id)
.expect("invalidated account")
.last_error_message(),
Some(upstream_message)
);
}
#[tokio::test]
async fn scheduled_refresh_persists_a_returned_id_token() {
let store = Arc::new(MemoryAccountStore::default());
@@ -46,7 +46,10 @@ async fn oversized_chunked_oauth_response_should_fail_closed_and_redact_body() {
.expect_err("oversized response must fail closed before body classification");
let diagnostic = format!("{failure:?} {failure}");
assert_eq!(failure, RefreshFailure::Transport);
assert!(matches!(
failure,
RefreshFailure::Transport { upstream: None, .. }
));
assert!(!diagnostic.contains(marker));
assert!(!diagnostic.contains("refresh-secret-request-marker"));
}
@@ -95,7 +98,10 @@ async fn refresh_response_should_reject_a_malformed_rotated_id_token() {
.await
.expect_err("malformed ID token must not replace the stored token set");
assert_eq!(failure, RefreshFailure::Transport);
assert!(matches!(
failure,
RefreshFailure::Transport { upstream: None, .. }
));
}
#[tokio::test]
@@ -142,7 +148,7 @@ async fn refresh_response_keeps_the_upstream_rotated_token_verbatim() {
}
#[tokio::test]
async fn refresh_should_exchange_the_official_form_fields() {
async fn refresh_should_exchange_the_official_json_fields() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
@@ -160,11 +166,24 @@ async fn refresh_should_exchange_the_official_form_fields() {
.await
.expect("refresh succeeds");
let requests = server.received_requests().await.expect("received request");
let body = String::from_utf8(requests[0].body.clone()).expect("form body is UTF-8");
let body: serde_json::Value =
serde_json::from_slice(&requests[0].body).expect("JSON request body");
assert!(body.contains("grant_type=refresh_token"));
assert!(body.contains("client_id=test-public-client"));
assert!(body.contains("refresh_token=refresh+secret"));
assert_eq!(
body,
serde_json::json!({
"client_id": "test-public-client",
"grant_type": "refresh_token",
"refresh_token": "refresh secret"
})
);
assert_eq!(
requests[0]
.headers
.get("content-type")
.and_then(|value| value.to_str().ok()),
Some("application/json")
);
}
#[tokio::test]
@@ -273,52 +292,77 @@ async fn authorization_code_exchange_requires_id_token_and_json_response() {
#[tokio::test]
async fn refresh_token_reuse_should_be_classified_as_invalid_grant() {
let upstream_message = "Your refresh token has already been used to generate a new access token. Please try signing in again.";
let failure = refresh_failure(
400,
r#"{"error":"invalid_grant","error_description":"refresh_token_reused"}"#,
r#"{"error":{"message":"Your refresh token has already been used to generate a new access token. Please try signing in again.","type":"invalid_request_error","param":null,"code":"refresh_token_reused"}}"#,
)
.await;
assert_eq!(failure, RefreshFailure::InvalidGrant);
let RefreshFailure::InvalidGrant { message, upstream } = failure else {
panic!("refresh-token reuse must be terminal");
};
assert_eq!(message.as_deref(), Some(upstream_message));
let upstream = upstream.expect("complete upstream failure");
assert_eq!(upstream.status(), 400);
assert!(upstream.body().contains(r#""code":"refresh_token_reused""#));
}
#[tokio::test]
async fn oauth_error_without_nested_message_should_use_the_complete_body() {
let body = r#"{"error":"invalid_grant","error_description":"not the official refresh error message field"}"#;
let failure = refresh_failure(400, body).await;
let diagnostic = format!("{failure:?} {failure}");
let RefreshFailure::InvalidGrant { message, upstream } = failure else {
panic!("invalid_grant must be terminal");
};
assert_eq!(message.as_deref(), Some(body));
assert_eq!(upstream.expect("complete upstream failure").body(), body);
assert!(!diagnostic.contains("error_description"));
}
#[tokio::test]
async fn deactivated_account_should_be_classified_as_banned() {
let failure = refresh_failure(
403,
r#"{"error":"access_denied","error_description":"account has been deactivated"}"#,
r#"{"error":{"code":"access_denied","message":"account has been deactivated"}}"#,
)
.await;
assert_eq!(failure, RefreshFailure::Banned);
let RefreshFailure::Banned { message, upstream } = failure else {
panic!("deactivated account must be banned");
};
assert_eq!(message.as_deref(), Some("account has been deactivated"));
assert_eq!(upstream.expect("complete upstream failure").status(), 403);
}
#[tokio::test]
async fn generic_banned_text_should_not_impersonate_the_deactivation_contract() {
let failure = refresh_failure(403, "account is banned").await;
assert_eq!(failure, RefreshFailure::Transport);
assert_transport_failure(&failure, 403, "account is banned", "account is banned");
}
#[tokio::test]
async fn unregistered_disabled_account_text_should_remain_a_transport_failure() {
let failure = refresh_failure(400, "account disabled").await;
assert_eq!(failure, RefreshFailure::Transport);
assert_transport_failure(&failure, 400, "account disabled", "account disabled");
}
#[tokio::test]
async fn quota_text_should_not_disable_the_oauth_credential() {
let failure = refresh_failure(400, "quota exceeded").await;
assert_eq!(failure, RefreshFailure::Transport);
assert_transport_failure(&failure, 400, "quota exceeded", "quota exceeded");
}
#[tokio::test]
async fn token_revoked_text_without_invalid_grant_should_remain_temporary() {
let failure = refresh_failure(400, "token_revoked").await;
assert_eq!(failure, RefreshFailure::Transport);
assert_transport_failure(&failure, 400, "token_revoked", "token_revoked");
}
#[tokio::test]
@@ -328,17 +372,32 @@ async fn server_error_or_rate_limit_must_stay_transient_even_with_oauth_error_bo
for status in [500, 502, 503, 429] {
let failure = refresh_failure(
status,
r#"{"error":"invalid_grant","error_description":"refresh_token_expired"}"#,
r#"{"error":{"code":"refresh_token_expired","message":"Refresh token expired."}}"#,
)
.await;
assert_eq!(
failure,
RefreshFailure::Transport,
"status {status} must classify as transient"
assert_transport_failure(
&failure,
status,
"Refresh token expired.",
r#"{"error":{"code":"refresh_token_expired","message":"Refresh token expired."}}"#,
);
}
}
fn assert_transport_failure(failure: &RefreshFailure, status: u16, message: &str, body: &str) {
let RefreshFailure::Transport {
message: actual_message,
upstream,
} = failure
else {
panic!("status {status} must classify as transient");
};
assert_eq!(actual_message.as_deref(), Some(message));
let upstream = upstream.as_deref().expect("complete upstream failure");
assert_eq!(upstream.status(), status);
assert_eq!(upstream.body(), body);
}
async fn refresh_failure(status: u16, body: &str) -> RefreshFailure {
let server = MockServer::start().await;
Mock::given(method("POST"))
@@ -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 -8
View File
@@ -1,8 +1 @@
5f79e74c9a7a7366c39cd1afe43175eff6c44afe9217c0f26f33f173bd40c781 0001_initial.sql
f4168f7e23d42cf9d37096339e931b0e0c823f7a8dbfc6580a04f096c4c3d8e3 0002_provider_accounts_nullable_upstream_user_id.sql
4163f61183d9e90f1368c08f614040d6a1a0048de3886a77dd708ce21430b2d0 0003_backfill_openai_access_token_expiry.sql
b41bb3be5a84b95852158d82267456f8415811a1137025581eefd1c96c0882d1 0004_normalize_account_state_facts.sql
00ef40c9cd404a09afe916262e894eec361039a60a93d6c46e6c96104b1346b9 0005_account_groups.sql
73846775d2c533fc17dc12164efd9ce6beaa99e2a67a064589ebadd50cd9b349 0006_account_group_color.sql
4e3a367b1bf75de82e7706127080627e0bb8f190b4d3bbc69be954de38eb1ead 0007_account_group_color_alpha.sql
3f07a905e7413f0be519825a10c73effb392df4e6069a5df7901bcd0ec2ff1af 0008_provider_account_scheduling.sql
bebf8c786b08ca3b2852d542d8d88049034a1fb566cc6efcc958dd3fc97241af 0001_initial.sql
+157 -22
View File
@@ -51,7 +51,6 @@ create table client_api_keys (
id text primary key,
name text not null,
label text,
provider_kind text not null,
key text not null unique,
enabled boolean not null default true,
max_concurrency bigint not null default 0,
@@ -62,9 +61,6 @@ create table client_api_keys (
constraint client_api_keys_key_ck check (
key ~ '^sk_[A-Za-z0-9_-]{43}$'
),
constraint client_api_keys_provider_kind_ck check (
provider_kind ~ '^[a-z][a-z0-9]*(-[a-z0-9]+)*$'
),
constraint client_api_keys_limits_ck check (
max_concurrency >= 0
and requests_per_minute >= 0
@@ -120,7 +116,7 @@ create table provider_accounts (
provider_kind text not null,
name text not null,
email text,
upstream_user_id text not null,
upstream_user_id text,
upstream_account_id text,
plan_type text,
authentication_kind text not null,
@@ -130,14 +126,20 @@ create table provider_accounts (
access_token_expires_at timestamptz,
next_refresh_at timestamptz,
enabled boolean not null default true,
availability text not null default 'unknown',
credential_state text not null default 'unknown',
provider_quota_json jsonb,
quota_limit_reached boolean not null default false,
last_error_message text,
availability_observed_at timestamptz not null,
credential_observed_at timestamptz not null,
quota_observed_at timestamptz,
created_at timestamptz not null,
updated_at timestamptz not null,
quota_access_state text not null default 'unknown',
quota_evidence text,
quota_access_observed_at timestamptz,
quota_reset_at timestamptz,
last_error_reason text,
concurrency_limit bigint,
weight smallint not null default 1,
constraint provider_accounts_revision_ck check (credential_revision > 0),
constraint provider_accounts_authentication_kind_ck check (
authentication_kind ~ '^[a-z][a-z0-9_]{0,63}$'
@@ -153,25 +155,61 @@ create table provider_accounts (
and octet_length(provider_quota_json::text) <= 131072
)
),
constraint provider_accounts_quota_observation_ck check (
(provider_quota_json is null) = (quota_observed_at is null)
),
constraint provider_accounts_refresh_ck check (
has_refresh_token or next_refresh_at is null
),
constraint provider_accounts_availability_ck check (
availability in (
'unknown',
'ready',
'quota_exhausted',
'expired',
'banned',
'invalid'
constraint provider_accounts_credential_state_ck check (
credential_state in ('unknown', 'ready', 'expired', 'banned', 'invalid')
),
constraint provider_accounts_quota_access_state_ck check (
quota_access_state in ('unknown', 'allowed', 'exhausted')
),
constraint provider_accounts_quota_evidence_ck check (
quota_evidence is null
or quota_evidence in (
'provider_denied',
'account_limit_reached',
'usage_limit_reached',
'payment_required'
)
),
constraint provider_accounts_quota_fact_ck check (
(quota_access_state = 'unknown' and quota_evidence is null and quota_reset_at is null)
or (
quota_access_state = 'allowed'
and quota_evidence is null
and quota_access_observed_at is not null
and quota_reset_at is null
)
or (
quota_access_state = 'exhausted'
and quota_evidence is not null
and quota_access_observed_at is not null
)
),
constraint provider_accounts_quota_observation_ck check (
(provider_quota_json is null) = (quota_observed_at is null)
),
constraint provider_accounts_error_reason_ck check (
last_error_reason is null
or last_error_reason in (
'account_unverified',
'access_token_expired',
'credential_expired',
'credential_invalid',
'account_banned'
)
),
constraint provider_accounts_concurrency_limit_ck check (
concurrency_limit is null or concurrency_limit between 1 and 4294967295
),
constraint provider_accounts_weight_ck check (
weight between 1 and 100
),
constraint provider_accounts_time_ck check (
created_at <= updated_at
and availability_observed_at <= updated_at
and credential_observed_at <= updated_at
and (quota_access_observed_at is null or quota_access_observed_at <= updated_at)
and (quota_observed_at is null or quota_observed_at <= updated_at)
)
);
@@ -184,8 +222,10 @@ create unique index provider_accounts_upstream_identity_uq
);
create index provider_accounts_runtime_idx
on provider_accounts (provider_kind, enabled, id);
create index provider_accounts_availability_idx
on provider_accounts (availability, id);
create index provider_accounts_credential_state_idx
on provider_accounts (credential_state, id);
create index provider_accounts_quota_access_idx
on provider_accounts (quota_access_state, quota_reset_at, id);
create index provider_accounts_access_expiry_idx
on provider_accounts (access_token_expires_at, id);
create index provider_accounts_refresh_due_idx
@@ -195,6 +235,76 @@ create index provider_accounts_email_idx
on provider_accounts (provider_kind, lower(email))
where email is not null;
-- 与 Provider 无关的账号组,以及账号和客户端 API 密钥的组成员关系。
create table account_groups (
id text primary key,
name text not null,
description text,
enabled boolean not null default true,
created_at timestamptz not null,
updated_at timestamptz not null,
color text not null,
constraint account_groups_id_ck check (
id ~ '^grp_[0-9a-f]{32}$'
),
constraint account_groups_name_ck check (
char_length(btrim(name)) between 1 and 100
and name = btrim(name)
and name !~ '[[:cntrl:]]'
),
constraint account_groups_description_ck check (
description is null or (
octet_length(description) <= 4096
and description !~ '[[:cntrl:]]'
)
),
constraint account_groups_time_ck check (
created_at <= updated_at
),
constraint account_groups_color_ck check (color ~ '^#[0-9A-F]{8}$')
);
create unique index account_groups_name_uq
on account_groups (lower(name));
create index account_groups_list_idx
on account_groups (enabled, created_at desc, id desc);
create table account_group_accounts (
account_group_id text not null,
provider_account_id text not null,
created_at timestamptz not null,
primary key (account_group_id, provider_account_id),
constraint account_group_accounts_group_fk foreign key (account_group_id)
references account_groups (id)
on update restrict
on delete cascade,
constraint account_group_accounts_account_fk foreign key (provider_account_id)
references provider_accounts (id)
on update restrict
on delete cascade
);
create index account_group_accounts_account_idx
on account_group_accounts (provider_account_id, account_group_id);
create table client_api_key_groups (
client_api_key_id text not null,
account_group_id text not null,
created_at timestamptz not null,
primary key (client_api_key_id, account_group_id),
constraint client_api_key_groups_key_fk foreign key (client_api_key_id)
references client_api_keys (id)
on update restrict
on delete cascade,
constraint client_api_key_groups_group_fk foreign key (account_group_id)
references account_groups (id)
on update restrict
on delete restrict
);
create index client_api_key_groups_group_idx
on client_api_key_groups (account_group_id, client_api_key_id);
-- 模型请求、上游尝试、用量、计费与结果。
create table model_requests (
id text primary key,
@@ -263,6 +373,9 @@ create table model_requests (
started_at timestamptz not null,
deadline_at timestamptz not null,
completed_at timestamptz,
routing_scope text not null,
routing_group_refs text[] not null default '{}',
routing_group_names_snapshot jsonb not null default '[]'::jsonb,
constraint model_requests_client_ref_ck check (
client_api_key_id is null or client_api_key_id = client_api_key_ref
),
@@ -332,6 +445,26 @@ create table model_requests (
and service_tier !~ '[[:cntrl:]]'
)
),
constraint model_requests_routing_scope_ck check (
routing_scope in ('legacy_provider', 'all', 'groups')
),
constraint model_requests_routing_group_names_ck check (
jsonb_typeof(routing_group_names_snapshot) = 'array'
and (
(
routing_scope in ('legacy_provider', 'all')
and cardinality(routing_group_refs) = 0
and jsonb_array_length(routing_group_names_snapshot) = 0
)
or (
routing_scope = 'groups'
and cardinality(routing_group_refs) > 0
and array_position(routing_group_refs, null) is null
and jsonb_array_length(routing_group_names_snapshot)
= cardinality(routing_group_refs)
)
)
),
constraint model_requests_cost_ck check (
cost_source in ('provider_reported', 'calculated', 'unavailable')
and (
@@ -425,6 +558,8 @@ create index model_requests_running_deadline_idx
create index model_requests_retention_idx
on model_requests (completed_at)
where outcome <> 'running';
create index model_requests_routing_group_refs_idx
on model_requests using gin (routing_group_refs);
-- 运行期警告与错误事件。
create table ops_events (
@@ -1,3 +0,0 @@
-- Initial OAuth exchange requires an id_token, but its ChatGPT user claim is optional.
alter table provider_accounts
alter column upstream_user_id drop not null;
@@ -1,70 +0,0 @@
-- Direct OpenAI OAuth imports between 3.1.15 and 3.1.16 persisted JWT access
-- tokens but accidentally omitted their derived expiry. Recover only future,
-- standard JWT expiries; malformed/opaque tokens remain unchanged so this
-- one-shot repair never fabricates a credential lifetime or refresh schedule.
create function _cpr_openai_access_token_expiry(access_token text)
returns timestamptz
language plpgsql
immutable
strict
as $$
declare
payload text;
claims jsonb;
epoch_seconds bigint;
begin
if access_token !~ '^[^.]+[.][^.]+[.][^.]+$' then
return null;
end if;
payload := split_part(access_token, '.', 2);
payload := rpad(
translate(payload, '-_', '+/'),
((length(payload) + 3) / 4) * 4,
'='
);
claims := convert_from(decode(payload, 'base64'), 'UTF8')::jsonb;
if jsonb_typeof(claims -> 'exp') <> 'number' then
return null;
end if;
epoch_seconds := (claims ->> 'exp')::bigint;
return to_timestamp(epoch_seconds::double precision);
exception
when others then
return null;
end;
$$;
with candidates as (
select
account.id,
clock_timestamp() as observed_at,
_cpr_openai_access_token_expiry(
account.provider_credentials_json ->> 'access_token'
) as access_token_expires_at
from provider_accounts as account
where account.provider_kind = 'openai'
and account.authentication_kind = 'oauth'
and account.has_refresh_token
and account.access_token_expires_at is null
and account.next_refresh_at is null
and account.availability not in ('expired', 'invalid', 'banned')
and jsonb_typeof(account.provider_credentials_json -> 'access_token') = 'string'
)
update provider_accounts as account
set
access_token_expires_at = candidates.access_token_expires_at,
updated_at = greatest(account.updated_at, candidates.observed_at)
from candidates
where account.id = candidates.id
and candidates.access_token_expires_at > candidates.observed_at
and account.provider_kind = 'openai'
and account.authentication_kind = 'oauth'
and account.has_refresh_token
and account.access_token_expires_at is null
and account.next_refresh_at is null
and account.availability not in ('expired', 'invalid', 'banned');
drop function _cpr_openai_access_token_expiry(text);
@@ -1,165 +0,0 @@
-- Separate credential identity facts from quota access facts. The public account
-- status remains derived at read time because Redis cooldowns and reset times are
-- time-dependent.
alter table provider_accounts
rename column availability to credential_state;
alter table provider_accounts
rename column availability_observed_at to credential_observed_at;
alter table provider_accounts
drop constraint provider_accounts_availability_ck;
alter table provider_accounts
add constraint provider_accounts_credential_state_ck check (
credential_state in ('unknown', 'ready', 'expired', 'banned', 'invalid')
) not valid;
alter table provider_accounts
add column quota_access_state text not null default 'unknown',
add column quota_evidence text,
add column quota_access_observed_at timestamptz,
add column quota_reset_at timestamptz,
add column last_error_reason text;
alter table provider_accounts
drop constraint provider_accounts_quota_observation_ck;
-- Old quota_exhausted credential states are quota facts. Re-derive OpenAI quota
-- access from the raw authoritative response and never trust the old percentage-
-- derived quota_limit_reached boolean.
update provider_accounts
set
quota_access_state = case
when provider_kind = 'openai'
and provider_quota_json #>> '{rate_limit,allowed}' = 'true'
then 'allowed'
when provider_kind = 'openai'
and (
provider_quota_json #>> '{rate_limit,allowed}' = 'false'
or (
coalesce(provider_quota_json #>> '{rate_limit,allowed}', '') <> 'true'
and (
provider_quota_json #>> '{rate_limit,limit_reached}' = 'true'
or provider_quota_json #>> '{rate_limit,primary_window,limit_reached}' = 'true'
)
)
)
then 'exhausted'
when provider_kind = 'xai' and credential_state = 'quota_exhausted'
then 'exhausted'
when provider_quota_json is not null then 'unknown'
else 'unknown'
end,
quota_evidence = case
when provider_kind = 'openai'
and provider_quota_json #>> '{rate_limit,allowed}' = 'false'
then 'provider_denied'
when provider_kind = 'openai'
and coalesce(provider_quota_json #>> '{rate_limit,allowed}', '') <> 'true'
and (
provider_quota_json #>> '{rate_limit,limit_reached}' = 'true'
or provider_quota_json #>> '{rate_limit,primary_window,limit_reached}' = 'true'
)
then 'account_limit_reached'
when provider_kind = 'xai' and credential_state = 'quota_exhausted'
then 'usage_limit_reached'
else null
end,
quota_access_observed_at = case
when provider_kind = 'openai' and provider_quota_json is not null
then quota_observed_at
when provider_kind = 'xai' and credential_state = 'quota_exhausted'
then credential_observed_at
else null
end,
quota_reset_at = case
when provider_kind = 'openai'
and jsonb_typeof(provider_quota_json #> '{rate_limit,primary_window,reset_at}') = 'number'
then to_timestamp((provider_quota_json #>> '{rate_limit,primary_window,reset_at}')::double precision)
else null
end,
credential_state = case
when credential_state = 'quota_exhausted' and upstream_user_id is not null then 'ready'
when credential_state = 'quota_exhausted' then 'unknown'
else credential_state
end,
last_error_reason = case credential_state
when 'unknown' then 'account_unverified'
when 'expired' then 'credential_expired'
when 'invalid' then 'credential_invalid'
when 'banned' then 'account_banned'
else null
end,
last_error_message = case
when credential_state in ('unknown', 'expired', 'invalid', 'banned') then last_error_message
else null
end;
alter table provider_accounts
validate constraint provider_accounts_credential_state_ck;
alter table provider_accounts
add constraint provider_accounts_quota_access_state_ck check (
quota_access_state in ('unknown', 'allowed', 'exhausted')
),
add constraint provider_accounts_quota_evidence_ck check (
quota_evidence is null
or quota_evidence in (
'provider_denied',
'account_limit_reached',
'usage_limit_reached',
'payment_required'
)
),
add constraint provider_accounts_quota_fact_ck check (
(quota_access_state = 'unknown' and quota_evidence is null and quota_reset_at is null)
or (
quota_access_state = 'allowed'
and quota_evidence is null
and quota_access_observed_at is not null
and quota_reset_at is null
)
or (
quota_access_state = 'exhausted'
and quota_evidence is not null
and quota_access_observed_at is not null
)
),
add constraint provider_accounts_quota_observation_ck check (
(provider_quota_json is null) = (quota_observed_at is null)
),
add constraint provider_accounts_error_reason_ck check (
last_error_reason is null
or last_error_reason in (
'account_unverified',
'access_token_expired',
'credential_expired',
'credential_invalid',
'account_banned'
)
);
drop index provider_accounts_availability_idx;
create index provider_accounts_credential_state_idx
on provider_accounts (credential_state, id);
create index provider_accounts_quota_access_idx
on provider_accounts (quota_access_state, quota_reset_at, id);
alter table provider_accounts
drop column quota_limit_reached;
alter table provider_accounts
rename constraint provider_accounts_time_ck to provider_accounts_time_ck_old;
alter table provider_accounts
drop constraint provider_accounts_time_ck_old;
alter table provider_accounts
add constraint provider_accounts_time_ck check (
created_at <= updated_at
and credential_observed_at <= updated_at
and (quota_access_observed_at is null or quota_access_observed_at <= updated_at)
and (quota_observed_at is null or quota_observed_at <= updated_at)
);
-135
View File
@@ -1,135 +0,0 @@
-- Account groups are provider-neutral account sets. Existing provider-scoped
-- keys are placed in ordinary migration groups before their provider column is
-- removed, preserving their pre-upgrade account scope.
create table account_groups (
id text primary key,
name text not null,
description text,
enabled boolean not null default true,
created_at timestamptz not null,
updated_at timestamptz not null,
constraint account_groups_id_ck check (
id ~ '^grp_[0-9a-f]{32}$'
),
constraint account_groups_name_ck check (
char_length(btrim(name)) between 1 and 100
and name = btrim(name)
and name !~ '[[:cntrl:]]'
),
constraint account_groups_description_ck check (
description is null or (
octet_length(description) <= 4096
and description !~ '[[:cntrl:]]'
)
),
constraint account_groups_time_ck check (
created_at <= updated_at
)
);
create unique index account_groups_name_uq
on account_groups (lower(name));
create index account_groups_list_idx
on account_groups (enabled, created_at desc, id desc);
create table account_group_accounts (
account_group_id text not null,
provider_account_id text not null,
created_at timestamptz not null,
primary key (account_group_id, provider_account_id),
constraint account_group_accounts_group_fk foreign key (account_group_id)
references account_groups (id)
on update restrict
on delete cascade,
constraint account_group_accounts_account_fk foreign key (provider_account_id)
references provider_accounts (id)
on update restrict
on delete cascade
);
create index account_group_accounts_account_idx
on account_group_accounts (provider_account_id, account_group_id);
create table client_api_key_groups (
client_api_key_id text not null,
account_group_id text not null,
created_at timestamptz not null,
primary key (client_api_key_id, account_group_id),
constraint client_api_key_groups_key_fk foreign key (client_api_key_id)
references client_api_keys (id)
on update restrict
on delete cascade,
constraint client_api_key_groups_group_fk foreign key (account_group_id)
references account_groups (id)
on update restrict
on delete restrict
);
create index client_api_key_groups_group_idx
on client_api_key_groups (account_group_id, client_api_key_id);
with provider_kinds as (
select provider_kind from provider_accounts
union
select provider_kind from client_api_keys
)
insert into account_groups (id, name, description, enabled, created_at, updated_at)
select
'grp_' || md5('codex-proxy-rs:migration-group:' || provider_kind),
'迁移池-' || provider_kind,
'升级时按原 Provider 权限自动生成',
true,
now(),
now()
from provider_kinds;
insert into account_group_accounts (account_group_id, provider_account_id, created_at)
select
'grp_' || md5('codex-proxy-rs:migration-group:' || provider_kind),
id,
clock_timestamp()
from provider_accounts;
insert into client_api_key_groups (client_api_key_id, account_group_id, created_at)
select
id,
'grp_' || md5('codex-proxy-rs:migration-group:' || provider_kind),
clock_timestamp()
from client_api_keys;
alter table model_requests
add column routing_scope text not null default 'legacy_provider',
add column routing_group_refs text[] not null default '{}',
add column routing_group_names_snapshot jsonb not null default '[]'::jsonb,
add constraint model_requests_routing_scope_ck check (
routing_scope in ('legacy_provider', 'all', 'groups')
),
add constraint model_requests_routing_group_names_ck check (
jsonb_typeof(routing_group_names_snapshot) = 'array'
and (
(
routing_scope in ('legacy_provider', 'all')
and cardinality(routing_group_refs) = 0
and jsonb_array_length(routing_group_names_snapshot) = 0
)
or (
routing_scope = 'groups'
and cardinality(routing_group_refs) > 0
and array_position(routing_group_refs, null) is null
and jsonb_array_length(routing_group_names_snapshot)
= cardinality(routing_group_refs)
)
)
);
create index model_requests_routing_group_refs_idx
on model_requests using gin (routing_group_refs);
alter table model_requests
alter column routing_scope drop default;
alter table client_api_keys
drop constraint client_api_keys_provider_kind_ck,
drop column provider_kind;
@@ -1,9 +0,0 @@
alter table account_groups
add column color text;
update account_groups
set color = '#2563EB';
alter table account_groups
alter column color set not null,
add constraint account_groups_color_ck check (color ~ '^#[0-9A-F]{6}$');
@@ -1,8 +0,0 @@
alter table account_groups
drop constraint account_groups_color_ck;
update account_groups
set color = color || 'FF';
alter table account_groups
add constraint account_groups_color_ck check (color ~ '^#[0-9A-F]{8}$');
@@ -1,7 +0,0 @@
alter table provider_accounts
add column concurrency_limit bigint,
add column weight smallint not null default 1,
add constraint provider_accounts_concurrency_limit_ck
check (concurrency_limit is null or concurrency_limit between 1 and 4294967295),
add constraint provider_accounts_weight_ck
check (weight between 1 and 100);
+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>

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