mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-11 16:06:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
194fb20d5f | ||
|
|
30f528f084 | ||
|
|
28d024ceda | ||
|
|
6b9c98c59f | ||
|
|
1294e50b76 | ||
|
|
a0e540642b | ||
|
|
14d6a7de31 | ||
|
|
cd7b60a3ba | ||
|
|
ebbed22f36 | ||
|
|
f6e5d8f673 | ||
|
|
bf1c6211b8 | ||
|
|
d388fa344a | ||
|
|
eabb61b08c | ||
|
|
a5c3438927 | ||
|
|
b6948542bb |
@@ -134,7 +134,7 @@ fn config_loader_should_reject_removed_fingerprint_section() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_invalid_codex_core_version() {
|
||||
fn config_loader_should_reject_invalid_codex_cli_version() {
|
||||
assert_rejected(valid_config().replace("codex_version: '0.146.0'", "codex_version: 'latest'"));
|
||||
}
|
||||
|
||||
|
||||
@@ -310,16 +310,6 @@ pub(crate) fn usage_range(
|
||||
domain::TimeRange::new(start, end).map_err(|_| WireValidationError::new("timeRange"))
|
||||
}
|
||||
|
||||
pub(crate) fn dashboard_range(
|
||||
start: Option<&str>,
|
||||
end: Option<&str>,
|
||||
) -> Result<domain::TimeRange, WireValidationError> {
|
||||
let end = parse_datetime(end)?.unwrap_or_else(Utc::now);
|
||||
let start =
|
||||
parse_datetime(start)?.unwrap_or_else(|| domain::china_day_start(end) - Duration::days(1));
|
||||
domain::TimeRange::new(start, end).map_err(|_| WireValidationError::new("timeRange"))
|
||||
}
|
||||
|
||||
pub(crate) fn dashboard_today_range(
|
||||
start: Option<&str>,
|
||||
end: Option<&str>,
|
||||
|
||||
@@ -38,7 +38,8 @@ where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let kind = query.trend_kind().map_err(map_wire_error)?;
|
||||
let range = dashboard_range(query.start_time.as_deref(), query.end_time.as_deref())
|
||||
// 概览默认按中国时区当日统计,与单独趋势接口保持同一口径。
|
||||
let range = dashboard_today_range(query.start_time.as_deref(), query.end_time.as_deref())
|
||||
.map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
|
||||
@@ -83,6 +83,7 @@ pub(super) struct AdminTestFixture {
|
||||
pub diagnostics: Arc<Mutex<Vec<DiagnosticObservation>>>,
|
||||
pub ops_errors: Arc<Mutex<Vec<OpsError>>>,
|
||||
pub dashboard_observation: Arc<Mutex<Option<DashboardObservation>>>,
|
||||
pub dashboard_summary_range: Arc<Mutex<Option<TimeRange>>>,
|
||||
}
|
||||
|
||||
impl AdminTestFixture {
|
||||
@@ -100,12 +101,14 @@ impl AdminTestFixture {
|
||||
let diagnostics = Arc::new(Mutex::new(Vec::new()));
|
||||
let ops_errors = Arc::new(Mutex::new(Vec::new()));
|
||||
let dashboard_observation = Arc::new(Mutex::new(None));
|
||||
let dashboard_summary_range = Arc::new(Mutex::new(None));
|
||||
let unused = Arc::new(UnusedStore {
|
||||
usage_records: Arc::clone(&usage_records),
|
||||
usage_detail: Arc::clone(&usage_detail),
|
||||
diagnostics: Arc::clone(&diagnostics),
|
||||
ops_errors: Arc::clone(&ops_errors),
|
||||
dashboard_observation: Arc::clone(&dashboard_observation),
|
||||
dashboard_summary_range: Arc::clone(&dashboard_summary_range),
|
||||
});
|
||||
let stores = AdminStorePorts::new(
|
||||
unused.clone(),
|
||||
@@ -142,6 +145,7 @@ impl AdminTestFixture {
|
||||
diagnostics,
|
||||
ops_errors,
|
||||
dashboard_observation,
|
||||
dashboard_summary_range,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,6 +417,7 @@ struct UnusedStore {
|
||||
diagnostics: Arc<Mutex<Vec<DiagnosticObservation>>>,
|
||||
ops_errors: Arc<Mutex<Vec<OpsError>>>,
|
||||
dashboard_observation: Arc<Mutex<Option<DashboardObservation>>>,
|
||||
dashboard_summary_range: Arc<Mutex<Option<TimeRange>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -523,7 +528,11 @@ impl AccountStore for UnusedStore {
|
||||
|
||||
#[async_trait]
|
||||
impl ObservabilityStore for UnusedStore {
|
||||
async fn dashboard_summary(&self, _: TimeRange) -> AdminStoreResult<DashboardObservation> {
|
||||
async fn dashboard_summary(&self, range: TimeRange) -> AdminStoreResult<DashboardObservation> {
|
||||
*self
|
||||
.dashboard_summary_range
|
||||
.lock()
|
||||
.expect("dashboard summary range") = Some(range);
|
||||
self.dashboard_observation
|
||||
.lock()
|
||||
.expect("dashboard observation")
|
||||
|
||||
@@ -270,6 +270,61 @@ async fn dashboard_summary_should_include_quota_exhaustion_in_unavailable_headli
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_default_to_current_china_day() {
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use gateway_admin::model::observability::{
|
||||
AccountPoolMetrics, AttemptMetrics, DashboardObservation, RequestMetrics, TimeRange,
|
||||
china_day_start,
|
||||
};
|
||||
use gateway_api::admin::observability;
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
use crate::admin::{AdminTestFixture, AdminTestState};
|
||||
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let now = Utc::now();
|
||||
*fixture
|
||||
.dashboard_observation
|
||||
.lock()
|
||||
.expect("dashboard observation") = Some(DashboardObservation {
|
||||
range: TimeRange::new(now - Duration::hours(1), now).expect("dashboard range"),
|
||||
requests: RequestMetrics::default(),
|
||||
attempts: AttemptMetrics::default(),
|
||||
provider_accounts: AccountPoolMetrics::default(),
|
||||
trend: Vec::new(),
|
||||
account_usage: Vec::new(),
|
||||
recent_requests: Vec::new(),
|
||||
});
|
||||
|
||||
let response = observability::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/dashboard/summary")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header("x-request-id", "req_dashboard_today_range")
|
||||
.body(Body::empty())
|
||||
.expect("dashboard request"),
|
||||
)
|
||||
.await
|
||||
.expect("dashboard response");
|
||||
let range = fixture
|
||||
.dashboard_summary_range
|
||||
.lock()
|
||||
.expect("dashboard summary range")
|
||||
.take()
|
||||
.expect("recorded dashboard summary range");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(range.start, china_day_start(range.end));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_detail_should_keep_attempt_snapshot_contract() {
|
||||
use axum::{
|
||||
|
||||
@@ -215,6 +215,20 @@ impl AccountAvailability {
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回账号进入不可恢复错误态、但 Provider 未给出更具体上游原因时的稳定兜底。
|
||||
///
|
||||
/// Provider 应优先持久化已脱敏的上游 message 或原因码;该值只避免错误态沿用
|
||||
/// 上一次无关的错误信息。
|
||||
#[must_use]
|
||||
pub const fn fallback_error_reason(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Expired => Some("credential_expired"),
|
||||
Self::Banned => Some("account_banned"),
|
||||
Self::Invalid => Some("credential_invalid"),
|
||||
Self::Unknown | Self::Ready | Self::QuotaExhausted => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析数据库稳定值。
|
||||
#[must_use]
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
|
||||
@@ -194,12 +194,13 @@ impl ProviderAdmin for OpenAiAdminProvider {
|
||||
let profile = self.profile.snapshot();
|
||||
let release = self.desktop_release.snapshot();
|
||||
let user_agent = profile.user_agent();
|
||||
let client_identity = format!("{}; {}", profile.originator, profile.desktop_version);
|
||||
let release = dashboard_desktop_release(&profile, release);
|
||||
Some(DashboardWireProfile {
|
||||
provider: self.provider_kind.as_str().to_owned(),
|
||||
product: profile.originator,
|
||||
version: profile.desktop_version,
|
||||
build: Some(profile.desktop_build),
|
||||
version: profile.codex_version,
|
||||
build: None,
|
||||
target: DashboardWireTarget {
|
||||
os_type: profile.os_type,
|
||||
os_version: profile.os_version,
|
||||
@@ -208,8 +209,8 @@ impl ProviderAdmin for OpenAiAdminProvider {
|
||||
},
|
||||
user_agent,
|
||||
attributes: vec![DashboardWireAttribute {
|
||||
label: "Codex Core".to_owned(),
|
||||
value: profile.codex_version,
|
||||
label: "客户端标识".to_owned(),
|
||||
value: client_identity,
|
||||
}],
|
||||
verified_at: Some(profile.verified_at),
|
||||
release: Some(release),
|
||||
|
||||
@@ -278,6 +278,7 @@ impl CodexAuthSettings {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CodexWireProfileConfig {
|
||||
pub originator: String,
|
||||
/// npm `@openai/codex` 启动版本;运行时发布任务会更新此画像字段。
|
||||
pub codex_version: String,
|
||||
pub desktop_version: String,
|
||||
pub desktop_build: String,
|
||||
@@ -292,10 +293,10 @@ impl Default for CodexWireProfileConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
originator: "Codex Desktop".to_owned(),
|
||||
codex_version: "0.145.0".to_owned(),
|
||||
desktop_version: "26.721.31836".to_owned(),
|
||||
desktop_build: "5828".to_owned(),
|
||||
os_type: "macOS".to_owned(),
|
||||
codex_version: "0.146.0".to_owned(),
|
||||
desktop_version: "26.727.51351".to_owned(),
|
||||
desktop_build: "6119".to_owned(),
|
||||
os_type: "Mac OS".to_owned(),
|
||||
os_version: "15.7.1".to_owned(),
|
||||
arch: "arm64".to_owned(),
|
||||
terminal: "unknown".to_owned(),
|
||||
|
||||
@@ -8,6 +8,10 @@ use std::fmt;
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use base64::{
|
||||
Engine as _,
|
||||
engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD},
|
||||
};
|
||||
use chrono::{DateTime, FixedOffset, Utc};
|
||||
use gateway_core::engine::credential::{
|
||||
AccountAvailability, CredentialCasUpdate, CredentialRevision, LoadedCredential,
|
||||
@@ -25,9 +29,7 @@ use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
use super::identity::{
|
||||
CodexAccountIdentityVerifier, CodexIdentityExpectation, CodexIdentityVerificationError,
|
||||
};
|
||||
use super::identity::CodexAccountIdentityVerifier;
|
||||
use super::security::CodexCredentialCodec;
|
||||
use super::token_client::{RefreshFailure, TokenRefresher};
|
||||
use super::types::{
|
||||
@@ -40,6 +42,9 @@ const PROVIDER_NAME: &str = "openai";
|
||||
const CODEX_CREDENTIAL_SCHEMA_VERSION: u32 = 1;
|
||||
const MAX_BATCH: usize = 200;
|
||||
const MAX_IMPORT_DOCUMENT_BYTES: usize = 64 * 1024 * 1024;
|
||||
const MAX_IMPORT_IDENTITY_BYTES: usize = 2_048;
|
||||
const MAX_IMPORT_EMAIL_BYTES: usize = 512;
|
||||
const MAX_IMPORT_PLAN_BYTES: usize = 128;
|
||||
|
||||
pub struct ImportCodexOAuthCredential {
|
||||
pub account_id: String,
|
||||
@@ -114,6 +119,15 @@ enum ParsedCodexAuthentication {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct UnverifiedOAuthClaims {
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
chatgpt_account_id: Option<String>,
|
||||
chatgpt_user_id: Option<String>,
|
||||
email: Option<String>,
|
||||
plan_type: Option<String>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ParsedCodexAuthentication {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
@@ -499,12 +513,60 @@ impl CodexCredentialAdmin {
|
||||
self.prepare_oauth_rotation(input, false)
|
||||
}
|
||||
|
||||
/// 完整 OAuth 授权按目标数据 ID 替换凭据与上游账号身份。
|
||||
pub(crate) fn prepare_reauthorization(
|
||||
/// 构建一次成功 RT exchange 的 CAS 写入,保留已存账号身份资料。
|
||||
///
|
||||
/// Refresh endpoint 已经完成本次 token 交换的授权;这里不对新 access
|
||||
/// token 重新执行身份验证。
|
||||
pub(crate) fn prepare_refreshed_oauth_rotation(
|
||||
&self,
|
||||
input: RotateManagedCodexCredential,
|
||||
current: LoadedCredential,
|
||||
secret: CodexOAuthSecret,
|
||||
access_token_expires_at: DateTime<Utc>,
|
||||
next_refresh_at: Option<DateTime<Utc>>,
|
||||
) -> Result<PreparedCodexCredentialRotation, CodexCredentialAdminError> {
|
||||
self.prepare_oauth_rotation(input, true)
|
||||
if current.account.provider().as_str() != PROVIDER_NAME
|
||||
|| current.account.authentication_kind() != CODEX_AUTHENTICATION_KIND_OAUTH
|
||||
{
|
||||
return Err(CodexCredentialAdminError::IdentityMismatch);
|
||||
}
|
||||
let mut data = CodexCredentialCodec::decode_complete(¤t.credential)
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
|
||||
let oauth = data
|
||||
.oauth_mut()
|
||||
.ok_or(CodexCredentialAdminError::IdentityMismatch)?;
|
||||
oauth.access_token = secret.access_token.expose_secret().to_owned();
|
||||
oauth.refresh_token = secret
|
||||
.refresh_token
|
||||
.as_ref()
|
||||
.map(|value| value.expose_secret().to_owned());
|
||||
oauth.id_token = secret
|
||||
.id_token
|
||||
.as_ref()
|
||||
.map(|value| value.expose_secret().to_owned());
|
||||
let credential = CodexCredentialCodec::encode_complete(data)
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
|
||||
let profile = ProviderAccountUpdate {
|
||||
account_id: current.account.id().clone(),
|
||||
name: current.account.name().to_owned(),
|
||||
email: current.account.email().map(str::to_owned),
|
||||
plan_type: current.account.plan_type().map(str::to_owned),
|
||||
};
|
||||
let credential = CredentialCasUpdate::new(
|
||||
current.account.id().clone(),
|
||||
current.account.revision(),
|
||||
profile.clone(),
|
||||
credential,
|
||||
secret.refresh_token.is_some(),
|
||||
Some(SystemTime::from(access_token_expires_at)),
|
||||
optional_time(next_refresh_at),
|
||||
)
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
|
||||
Ok(PreparedCodexCredentialRotation {
|
||||
profile,
|
||||
credential,
|
||||
replacement_identity: None,
|
||||
refresh_guards: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn prepare_oauth_rotation(
|
||||
@@ -588,7 +650,6 @@ impl CodexCredentialAdmin {
|
||||
/// 有状态的 Codex 手工刷新边界;消费调用方刚读取的当前 credential 并准备 CAS。
|
||||
pub struct CodexCredentialAdminService {
|
||||
refresher: Arc<dyn TokenRefresher>,
|
||||
verifier: Arc<dyn CodexAccountIdentityVerifier>,
|
||||
leases: Arc<dyn ProviderLeasePort>,
|
||||
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
|
||||
}
|
||||
@@ -598,7 +659,6 @@ impl fmt::Debug for CodexCredentialAdminService {
|
||||
formatter
|
||||
.debug_struct("CodexCredentialAdminService")
|
||||
.field("refresher", &"TokenRefresher")
|
||||
.field("verifier", &"CodexAccountIdentityVerifier")
|
||||
.field("leases", &"ProviderLeasePort")
|
||||
.field("runtime_policy", &"ProviderRuntimePolicyPort")
|
||||
.finish()
|
||||
@@ -608,19 +668,18 @@ impl fmt::Debug for CodexCredentialAdminService {
|
||||
impl CodexCredentialAdminService {
|
||||
pub fn new(
|
||||
refresher: Arc<dyn TokenRefresher>,
|
||||
verifier: Arc<dyn CodexAccountIdentityVerifier>,
|
||||
_verifier: Arc<dyn CodexAccountIdentityVerifier>,
|
||||
leases: Arc<dyn ProviderLeasePort>,
|
||||
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
|
||||
) -> Self {
|
||||
Self {
|
||||
refresher,
|
||||
verifier,
|
||||
leases,
|
||||
runtime_policy,
|
||||
}
|
||||
}
|
||||
|
||||
/// 官方 RT exchange + AT 身份验证;结果由 App 在同一 revision/audit 事务中提交。
|
||||
/// 官方 RT exchange;结果由 App 在同一 revision/audit 事务中提交。
|
||||
pub async fn manual_refresh(
|
||||
&self,
|
||||
current: LoadedCredential,
|
||||
@@ -632,7 +691,6 @@ impl CodexCredentialAdminService {
|
||||
}
|
||||
let runtime = CodexCredentialCodec::decode(¤t.credential)
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
|
||||
let expectation = current_identity_expectation(¤t.account, &runtime)?;
|
||||
let oauth = runtime
|
||||
.authentication
|
||||
.oauth()
|
||||
@@ -680,6 +738,10 @@ impl CodexCredentialAdminService {
|
||||
if tokens.access_token.trim().is_empty() || tokens.expires_in.is_zero() {
|
||||
return Err(CodexCredentialAdminError::InvalidCredential);
|
||||
}
|
||||
let access_token_expires_at = SystemTime::now()
|
||||
.checked_add(tokens.expires_in)
|
||||
.map(DateTime::<Utc>::from)
|
||||
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
|
||||
let secret = CodexOAuthSecret {
|
||||
access_token: SecretString::from(tokens.access_token),
|
||||
refresh_token: tokens
|
||||
@@ -688,24 +750,18 @@ impl CodexCredentialAdminService {
|
||||
.or_else(|| oauth.refresh_token.clone()),
|
||||
id_token: oauth.id_token.clone(),
|
||||
};
|
||||
let verified_account = self
|
||||
.verifier
|
||||
.verify(&secret, &expectation)
|
||||
.await
|
||||
.and_then(super::identity::CodexIdentityVerification::into_complete)
|
||||
.map_err(map_identity_error)?;
|
||||
let next_refresh_at = refresh_time(
|
||||
policy,
|
||||
&account_id,
|
||||
verified_account.access_token_expires_at,
|
||||
Some(access_token_expires_at),
|
||||
secret.refresh_token.is_some(),
|
||||
)?;
|
||||
let mut prepared = CodexCredentialAdmin.prepare_rotation(RotateManagedCodexCredential {
|
||||
let mut prepared = CodexCredentialAdmin.prepare_refreshed_oauth_rotation(
|
||||
current,
|
||||
secret,
|
||||
verified_account,
|
||||
access_token_expires_at,
|
||||
next_refresh_at,
|
||||
})?;
|
||||
)?;
|
||||
prepared.refresh_guards = Some(ProviderRefreshGuards {
|
||||
_capacity: capacity_guard,
|
||||
_account: account_guard,
|
||||
@@ -713,7 +769,7 @@ impl CodexCredentialAdminService {
|
||||
Ok(prepared)
|
||||
}
|
||||
|
||||
/// 从 OAuth token 对补全账号事实,并归一到唯一 `NewProviderAccount` 写入路径。
|
||||
/// 对导入 token 做轻量 payload 检查,并归一到唯一 `NewProviderAccount` 写入路径。
|
||||
pub async fn prepare_import_document(
|
||||
&self,
|
||||
payload: Value,
|
||||
@@ -743,9 +799,12 @@ impl CodexCredentialAdminService {
|
||||
if !account_ids.insert(account_id.clone()) {
|
||||
return Err(CodexCredentialAdminError::InvalidInput);
|
||||
}
|
||||
let (secret, verified_account) = self
|
||||
.verify_import_tokens(access_token.clone(), refresh_token.clone())
|
||||
let (secret, access_token_expires_at) = self
|
||||
.resolve_import_tokens(access_token.clone(), refresh_token.clone())
|
||||
.await?;
|
||||
let mut verified_account =
|
||||
unverified_oauth_profile(&secret, &account_id, access_token_expires_at);
|
||||
apply_import_metadata(&mut verified_account, &candidate);
|
||||
if !upstream_identities.insert((
|
||||
verified_account.chatgpt_user_id.clone(),
|
||||
verified_account.chatgpt_account_id.clone(),
|
||||
@@ -772,9 +831,11 @@ impl CodexCredentialAdminService {
|
||||
)?;
|
||||
CodexCredentialAdmin.prepare_import(ImportCodexOAuthCredential {
|
||||
account_id,
|
||||
name: verified_account
|
||||
.email
|
||||
name: candidate
|
||||
.name
|
||||
.clone()
|
||||
.or_else(|| candidate.email.clone())
|
||||
.or_else(|| verified_account.email.clone())
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.unwrap_or_else(|| "Codex OAuth".to_owned()),
|
||||
secret,
|
||||
@@ -815,11 +876,11 @@ impl CodexCredentialAdminService {
|
||||
Ok(PreparedCodexAccountImport { accounts })
|
||||
}
|
||||
|
||||
async fn verify_import_tokens(
|
||||
async fn resolve_import_tokens(
|
||||
&self,
|
||||
access_token: Option<String>,
|
||||
refresh_token: Option<String>,
|
||||
) -> Result<(CodexOAuthSecret, CodexAccountProfile), CodexCredentialAdminError> {
|
||||
) -> Result<(CodexOAuthSecret, Option<DateTime<Utc>>), CodexCredentialAdminError> {
|
||||
let access_token = access_token
|
||||
.as_deref()
|
||||
.map(normalize_bearer)
|
||||
@@ -829,21 +890,13 @@ impl CodexCredentialAdminService {
|
||||
.map(normalize_bearer)
|
||||
.filter(|token| !token.is_empty());
|
||||
if let Some(access_token) = access_token {
|
||||
validate_unverified_import_access_token(&access_token)?;
|
||||
let secret = CodexOAuthSecret {
|
||||
access_token: SecretString::from(access_token),
|
||||
refresh_token: refresh_token.clone().map(SecretString::from),
|
||||
access_token: SecretString::from(access_token.clone()),
|
||||
refresh_token: refresh_token.map(SecretString::from),
|
||||
id_token: None,
|
||||
};
|
||||
match self.verifier.verify_import(&secret).await {
|
||||
Ok(profile) => return Ok((secret, profile)),
|
||||
Err(CodexIdentityVerificationError::Unavailable) => {
|
||||
return Err(CodexCredentialAdminError::IdentityUnavailable);
|
||||
}
|
||||
Err(CodexIdentityVerificationError::Rejected) if refresh_token.is_none() => {
|
||||
return Err(CodexCredentialAdminError::IdentityRejected);
|
||||
}
|
||||
Err(CodexIdentityVerificationError::Rejected) => {}
|
||||
}
|
||||
return Ok((secret, unverified_access_token_expiry(&access_token)));
|
||||
}
|
||||
let refresh_token = refresh_token.ok_or(CodexCredentialAdminError::InvalidCredential)?;
|
||||
let tokens = self
|
||||
@@ -854,6 +907,7 @@ impl CodexCredentialAdminService {
|
||||
if tokens.access_token.trim().is_empty() || tokens.expires_in.is_zero() {
|
||||
return Err(CodexCredentialAdminError::InvalidCredential);
|
||||
}
|
||||
validate_unverified_import_access_token(&tokens.access_token)?;
|
||||
let secret = CodexOAuthSecret {
|
||||
access_token: SecretString::from(tokens.access_token),
|
||||
// RT 未轮换时仍保留导入提供的 RT,保证本次补全不会丢失后续刷新能力。
|
||||
@@ -863,12 +917,11 @@ impl CodexCredentialAdminService {
|
||||
.or_else(|| Some(SecretString::from(refresh_token))),
|
||||
id_token: None,
|
||||
};
|
||||
let profile = self
|
||||
.verifier
|
||||
.verify_import(&secret)
|
||||
.await
|
||||
.map_err(map_identity_error)?;
|
||||
Ok((secret, profile))
|
||||
let access_token_expires_at = SystemTime::now()
|
||||
.checked_add(tokens.expires_in)
|
||||
.map(DateTime::<Utc>::from)
|
||||
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
|
||||
Ok((secret, Some(access_token_expires_at)))
|
||||
}
|
||||
|
||||
fn prepare_agent_identity_import(
|
||||
@@ -939,27 +992,6 @@ impl CodexCredentialAdminService {
|
||||
}
|
||||
}
|
||||
|
||||
fn current_identity_expectation(
|
||||
account: &ProviderAccount,
|
||||
credential: &super::security::CodexRuntimeCredential,
|
||||
) -> Result<CodexIdentityExpectation, CodexCredentialAdminError> {
|
||||
let account_id = account
|
||||
.upstream_account_id()
|
||||
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
|
||||
let principal = credential
|
||||
.principal
|
||||
.as_ref()
|
||||
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
|
||||
CodexIdentityExpectation::current(
|
||||
principal.oauth_subject.clone(),
|
||||
principal.poid.clone(),
|
||||
account_id.to_owned(),
|
||||
account.upstream_user_id().to_owned(),
|
||||
credential.installation_id.clone(),
|
||||
)
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidCredential)
|
||||
}
|
||||
|
||||
fn required_time(
|
||||
value: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<SystemTime, CodexCredentialAdminError> {
|
||||
@@ -1016,15 +1048,6 @@ const fn map_refresh_failure(error: RefreshFailure) -> CodexCredentialAdminError
|
||||
}
|
||||
}
|
||||
|
||||
const fn map_identity_error(error: CodexIdentityVerificationError) -> CodexCredentialAdminError {
|
||||
match error {
|
||||
CodexIdentityVerificationError::Rejected => CodexCredentialAdminError::IdentityRejected,
|
||||
CodexIdentityVerificationError::Unavailable => {
|
||||
CodexCredentialAdminError::IdentityUnavailable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_import_document(
|
||||
payload: &Value,
|
||||
) -> Result<Vec<ParsedCodexImportAccount>, CodexCredentialAdminError> {
|
||||
@@ -1037,20 +1060,56 @@ fn parse_import_document(
|
||||
if !is_openai_oauth_candidate(value) {
|
||||
continue;
|
||||
}
|
||||
accounts.push(ParsedCodexImportAccount {
|
||||
id: None,
|
||||
name: None,
|
||||
email: None,
|
||||
plan_type: None,
|
||||
chatgpt_account_id: None,
|
||||
chatgpt_user_id: None,
|
||||
authentication: parse_oauth_import_tokens(value)?,
|
||||
status: None,
|
||||
});
|
||||
accounts.push(parse_oauth_import_account(value)?);
|
||||
}
|
||||
Ok(accounts)
|
||||
}
|
||||
|
||||
fn parse_oauth_import_account(
|
||||
value: &Value,
|
||||
) -> Result<ParsedCodexImportAccount, CodexCredentialAdminError> {
|
||||
let credentials = value.get("credentials").unwrap_or(value);
|
||||
Ok(ParsedCodexImportAccount {
|
||||
id: first_string(value, &["id"]),
|
||||
name: first_string(value, &["name", "label"]),
|
||||
email: first_string(value, &["email"]).or_else(|| first_string(credentials, &["email"])),
|
||||
plan_type: first_string(value, &["plan_type", "planType"])
|
||||
.or_else(|| first_string(credentials, &["plan_type", "planType"])),
|
||||
chatgpt_account_id: first_string(
|
||||
value,
|
||||
&[
|
||||
"chatgpt_account_id",
|
||||
"chatgptAccountId",
|
||||
"account_id",
|
||||
"accountId",
|
||||
],
|
||||
)
|
||||
.or_else(|| {
|
||||
first_string(
|
||||
credentials,
|
||||
&[
|
||||
"chatgpt_account_id",
|
||||
"chatgptAccountId",
|
||||
"account_id",
|
||||
"accountId",
|
||||
],
|
||||
)
|
||||
}),
|
||||
chatgpt_user_id: first_string(
|
||||
value,
|
||||
&["chatgpt_user_id", "chatgptUserId", "user_id", "userId"],
|
||||
)
|
||||
.or_else(|| {
|
||||
first_string(
|
||||
credentials,
|
||||
&["chatgpt_user_id", "chatgptUserId", "user_id", "userId"],
|
||||
)
|
||||
}),
|
||||
authentication: parse_oauth_import_tokens(value)?,
|
||||
status: first_string(value, &["status"]),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_agent_identity_account(
|
||||
value: &Value,
|
||||
) -> Result<ParsedCodexImportAccount, CodexCredentialAdminError> {
|
||||
@@ -1212,6 +1271,122 @@ fn normalize_bearer(value: &str) -> String {
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
pub(crate) fn unverified_oauth_profile(
|
||||
secret: &CodexOAuthSecret,
|
||||
fallback_account_id: &str,
|
||||
access_token_expires_at: Option<DateTime<Utc>>,
|
||||
) -> CodexAccountProfile {
|
||||
let claims =
|
||||
unverified_access_token_claims(secret.access_token.expose_secret()).unwrap_or_default();
|
||||
let fallback_identity = imported_identity(fallback_account_id);
|
||||
let chatgpt_account_id = claims
|
||||
.chatgpt_account_id
|
||||
.as_deref()
|
||||
.and_then(normalize_import_identity)
|
||||
.unwrap_or_else(|| fallback_identity.clone());
|
||||
let chatgpt_user_id = claims
|
||||
.chatgpt_user_id
|
||||
.as_deref()
|
||||
.and_then(normalize_import_identity)
|
||||
.unwrap_or_else(|| chatgpt_account_id.clone());
|
||||
CodexAccountProfile {
|
||||
email: claims
|
||||
.email
|
||||
.as_deref()
|
||||
.and_then(|value| normalize_import_text(value, MAX_IMPORT_EMAIL_BYTES)),
|
||||
oauth_subject: chatgpt_user_id.clone(),
|
||||
poid: None,
|
||||
chatgpt_account_id,
|
||||
chatgpt_user_id,
|
||||
plan_type: claims
|
||||
.plan_type
|
||||
.as_deref()
|
||||
.and_then(|value| normalize_import_text(value, MAX_IMPORT_PLAN_BYTES)),
|
||||
access_token_expires_at: access_token_expires_at.or(claims.expires_at),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_import_metadata(profile: &mut CodexAccountProfile, candidate: &ParsedCodexImportAccount) {
|
||||
if profile.email.is_none() {
|
||||
profile.email = candidate
|
||||
.email
|
||||
.as_deref()
|
||||
.and_then(|value| normalize_import_text(value, MAX_IMPORT_EMAIL_BYTES));
|
||||
}
|
||||
if profile.plan_type.is_none() {
|
||||
profile.plan_type = candidate
|
||||
.plan_type
|
||||
.as_deref()
|
||||
.and_then(|value| normalize_import_text(value, MAX_IMPORT_PLAN_BYTES));
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_unverified_import_access_token(token: &str) -> Result<(), CodexCredentialAdminError> {
|
||||
let claims = unverified_access_token_claims(token)
|
||||
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
|
||||
if claims
|
||||
.expires_at
|
||||
.is_none_or(|expires_at| expires_at <= Utc::now())
|
||||
|| claims
|
||||
.chatgpt_account_id
|
||||
.as_deref()
|
||||
.is_none_or(|account_id| account_id.trim().is_empty())
|
||||
{
|
||||
return Err(CodexCredentialAdminError::InvalidCredential);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unverified_access_token_expiry(token: &str) -> Option<DateTime<Utc>> {
|
||||
unverified_access_token_claims(token).and_then(|claims| claims.expires_at)
|
||||
}
|
||||
|
||||
fn unverified_access_token_claims(token: &str) -> Option<UnverifiedOAuthClaims> {
|
||||
let payload = token.split('.').nth(1)?;
|
||||
let decoded = URL_SAFE_NO_PAD
|
||||
.decode(payload)
|
||||
.or_else(|_| URL_SAFE.decode(payload))
|
||||
.ok()?;
|
||||
let claims = serde_json::from_slice::<Value>(&decoded).ok()?;
|
||||
let claims = claims.as_object()?;
|
||||
let auth = claims
|
||||
.get("https://api.openai.com/auth")
|
||||
.and_then(Value::as_object);
|
||||
let profile = claims
|
||||
.get("https://api.openai.com/profile")
|
||||
.and_then(Value::as_object);
|
||||
let text = |object: Option<&serde_json::Map<String, Value>>, key| {
|
||||
object
|
||||
.and_then(|object| object.get(key))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
};
|
||||
Some(UnverifiedOAuthClaims {
|
||||
expires_at: claims
|
||||
.get("exp")
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|seconds| DateTime::<Utc>::from_timestamp(seconds, 0)),
|
||||
chatgpt_account_id: text(auth, "chatgpt_account_id"),
|
||||
chatgpt_user_id: text(auth, "chatgpt_user_id").or_else(|| text(profile, "chatgpt_user_id")),
|
||||
email: text(profile, "email"),
|
||||
plan_type: text(auth, "chatgpt_plan_type").or_else(|| text(profile, "chatgpt_plan_type")),
|
||||
})
|
||||
}
|
||||
|
||||
fn imported_identity(account_id: &str) -> String {
|
||||
format!("imported_{}", account_id.trim_start_matches("acct_"))
|
||||
}
|
||||
|
||||
fn normalize_import_identity(value: &str) -> Option<String> {
|
||||
normalize_import_text(value, MAX_IMPORT_IDENTITY_BYTES)
|
||||
}
|
||||
|
||||
fn normalize_import_text(value: &str, max_bytes: usize) -> Option<String> {
|
||||
let value = value.trim();
|
||||
(!value.is_empty() && value.len() <= max_bytes && !value.chars().any(char::is_control))
|
||||
.then(|| value.to_owned())
|
||||
}
|
||||
|
||||
fn import_runtime_state(
|
||||
status: Option<&str>,
|
||||
) -> Result<(bool, AccountAvailability), CodexCredentialAdminError> {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
//! Codex Authorization Code + PKCE/OIDC 管理流。
|
||||
|
||||
use std::{fmt, sync::Arc, time::Duration};
|
||||
use std::{
|
||||
fmt,
|
||||
sync::Arc,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
@@ -24,11 +28,9 @@ use uuid::Uuid;
|
||||
|
||||
use super::admin::{
|
||||
CodexCredentialAdmin, CodexCredentialAdminError, ImportCodexOAuthCredential,
|
||||
PreparedCodexCredentialRotation, RotateManagedCodexCredential, refresh_time,
|
||||
};
|
||||
use super::identity::{
|
||||
CodexAccountIdentityVerifier, CodexIdentityExpectation, CodexIdentityVerificationError,
|
||||
PreparedCodexCredentialRotation, refresh_time, unverified_oauth_profile,
|
||||
};
|
||||
use super::identity::CodexAccountIdentityVerifier;
|
||||
use super::token_client::{
|
||||
AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant,
|
||||
OFFICIAL_CODEX_OAUTH_CLIENT_ID, OFFICIAL_CODEX_REDIRECT_URI,
|
||||
@@ -344,7 +346,6 @@ pub trait CodexOAuthAdmin: Send + Sync {
|
||||
pub struct CodexOAuthAdminService {
|
||||
pending: Arc<dyn CodexOAuthPendingStore>,
|
||||
exchanger: Arc<dyn AuthorizationCodeExchanger>,
|
||||
verifier: Arc<dyn CodexAccountIdentityVerifier>,
|
||||
store: Arc<dyn ProviderAccountStore>,
|
||||
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
|
||||
credentials: CodexCredentialAdmin,
|
||||
@@ -396,7 +397,7 @@ impl CodexOAuthAdminService {
|
||||
pub fn new(
|
||||
pending: Arc<dyn CodexOAuthPendingStore>,
|
||||
exchanger: Arc<dyn AuthorizationCodeExchanger>,
|
||||
verifier: Arc<dyn CodexAccountIdentityVerifier>,
|
||||
_verifier: Arc<dyn CodexAccountIdentityVerifier>,
|
||||
store: Arc<dyn ProviderAccountStore>,
|
||||
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
|
||||
credentials: CodexCredentialAdmin,
|
||||
@@ -404,7 +405,6 @@ impl CodexOAuthAdminService {
|
||||
Self {
|
||||
pending,
|
||||
exchanger,
|
||||
verifier,
|
||||
store,
|
||||
runtime_policy,
|
||||
credentials,
|
||||
@@ -487,7 +487,8 @@ impl CodexOAuthAdminService {
|
||||
callback_url: &str,
|
||||
) -> Result<(PendingAuthorizationMutation, CompletedCodexOAuthCredential), CodexOAuthAdminError>
|
||||
{
|
||||
let (mut secret, id_token) = self.exchange_pending(&pending, callback_url).await?;
|
||||
let (mut secret, id_token, expires_in) =
|
||||
self.exchange_pending(&pending, callback_url).await?;
|
||||
let mutation = pending.mutation.clone();
|
||||
let current = if let Some(target) = pending.reauthorization() {
|
||||
let current = self
|
||||
@@ -499,13 +500,10 @@ impl CodexOAuthAdminService {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let expectation = CodexIdentityExpectation::default();
|
||||
let profile = self
|
||||
.verifier
|
||||
.verify_authorization(&secret, &id_token, &pending.nonce, &expectation)
|
||||
.await
|
||||
.and_then(super::identity::CodexIdentityVerification::into_complete)
|
||||
.map_err(map_identity_error)?;
|
||||
let access_token_expires_at = SystemTime::now()
|
||||
.checked_add(expires_in)
|
||||
.map(DateTime::<Utc>::from)
|
||||
.ok_or(CodexOAuthAdminError::Credential)?;
|
||||
secret.id_token = Some(id_token);
|
||||
let policy = self
|
||||
.runtime_policy
|
||||
@@ -516,28 +514,33 @@ impl CodexOAuthAdminService {
|
||||
let next_refresh_at = refresh_time(
|
||||
policy,
|
||||
current.account.id(),
|
||||
profile.access_token_expires_at,
|
||||
Some(access_token_expires_at),
|
||||
secret.refresh_token.is_some(),
|
||||
)
|
||||
.map_err(map_admin_error)?;
|
||||
CompletedCodexOAuthCredential::Reauthorize(
|
||||
self.credentials
|
||||
.prepare_reauthorization(RotateManagedCodexCredential {
|
||||
.prepare_refreshed_oauth_rotation(
|
||||
current,
|
||||
secret,
|
||||
verified_account: profile,
|
||||
access_token_expires_at,
|
||||
next_refresh_at,
|
||||
})
|
||||
)
|
||||
.map_err(map_admin_error)?,
|
||||
)
|
||||
} else {
|
||||
let account_id = format!("acct_{}", Uuid::now_v7().simple());
|
||||
let typed_account_id = ProviderAccountId::new(account_id.clone())
|
||||
.map_err(|_| CodexOAuthAdminError::Credential)?;
|
||||
let profile = unverified_oauth_profile(
|
||||
&secret,
|
||||
typed_account_id.as_str(),
|
||||
Some(access_token_expires_at),
|
||||
);
|
||||
let next_refresh_at = refresh_time(
|
||||
policy,
|
||||
&typed_account_id,
|
||||
profile.access_token_expires_at,
|
||||
Some(access_token_expires_at),
|
||||
secret.refresh_token.is_some(),
|
||||
)
|
||||
.map_err(map_admin_error)?;
|
||||
@@ -654,7 +657,8 @@ impl CodexOAuthAdminService {
|
||||
&self,
|
||||
pending: &CodexPendingAuthorization,
|
||||
callback_url: &str,
|
||||
) -> Result<(super::types::CodexOAuthSecret, SecretString), CodexOAuthAdminError> {
|
||||
) -> Result<(super::types::CodexOAuthSecret, SecretString, Duration), CodexOAuthAdminError>
|
||||
{
|
||||
let (code, callback_state) = callback_parts(callback_url)?;
|
||||
if !constant_time_equal(
|
||||
pending.state.expose_secret().as_bytes(),
|
||||
@@ -670,7 +674,7 @@ impl CodexOAuthAdminService {
|
||||
})
|
||||
.await
|
||||
.map_err(map_exchange_error)?;
|
||||
Ok((tokens.secret, tokens.id_token))
|
||||
Ok((tokens.secret, tokens.id_token, tokens.expires_in))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,27 +703,19 @@ fn authorization_url(
|
||||
|
||||
fn callback_parts(value: &str) -> Result<(SecretString, SecretString), CodexOAuthAdminError> {
|
||||
let url = Url::parse(value).map_err(|_| CodexOAuthAdminError::CallbackRejected)?;
|
||||
if url.scheme() != "http"
|
||||
|| url.host_str() != Some("localhost")
|
||||
|| url.port() != Some(1455)
|
||||
|| url.path() != "/auth/callback"
|
||||
|| url.fragment().is_some()
|
||||
{
|
||||
return Err(CodexOAuthAdminError::CallbackRejected);
|
||||
}
|
||||
let mut code = None;
|
||||
let mut state = None;
|
||||
for (key, value) in url.query_pairs() {
|
||||
match key.as_ref() {
|
||||
"code" => set_unique_callback_parameter(&mut code, value.into_owned())?,
|
||||
"state" => set_unique_callback_parameter(&mut state, value.into_owned())?,
|
||||
// OAuth 服务可能附加 `scope`、`iss` 等标准参数。
|
||||
// 固定回调地址与唯一的 `code`/`state` 才是安全边界,无关参数不参与校验。
|
||||
// 回调地址只承载 query 参数;安全绑定由唯一的 code/state、
|
||||
// 服务端保存的 state 和 PKCE token exchange 共同完成。
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let code = code.filter(|value| valid_secret(value));
|
||||
let state = state.filter(|value| valid_secret(value));
|
||||
let code = code.filter(|value| !value.is_empty());
|
||||
let state = state.filter(|value| !value.is_empty());
|
||||
match (code, state) {
|
||||
(Some(code), Some(state)) => Ok((SecretString::from(code), SecretString::from(state))),
|
||||
_ => Err(CodexOAuthAdminError::CallbackRejected),
|
||||
@@ -813,13 +809,6 @@ fn map_exchange_error(error: AuthorizationCodeExchangeError) -> CodexOAuthAdminE
|
||||
}
|
||||
}
|
||||
|
||||
fn map_identity_error(error: CodexIdentityVerificationError) -> CodexOAuthAdminError {
|
||||
match error {
|
||||
CodexIdentityVerificationError::Rejected => CodexOAuthAdminError::IdentityRejected,
|
||||
CodexIdentityVerificationError::Unavailable => CodexOAuthAdminError::UpstreamUnavailable,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_admin_error(_: CodexCredentialAdminError) -> CodexOAuthAdminError {
|
||||
CodexOAuthAdminError::Credential
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ use crate::transport::{CodexBackendClient, CodexClientError, CodexRequestContext
|
||||
|
||||
use super::agent_identity::{CodexAgentIdentityTaskService, PreparedCodexRuntimeCredential};
|
||||
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
|
||||
use recovery::{quota_state_transition, quota_success_state};
|
||||
use recovery::{quota_state_reason, quota_state_transition, quota_success_state};
|
||||
use snapshot::{
|
||||
parse_account_quota_snapshot, quota_projection_ttl, quota_snapshot_from_observation,
|
||||
scheduling_signals_from_snapshot,
|
||||
@@ -430,7 +430,10 @@ impl CodexCredentialQuotaService {
|
||||
.and_then(|observation| observation.quota)
|
||||
.map(OpaqueProviderData::into_inner)
|
||||
.unwrap_or_default();
|
||||
let quota = confirmed_exhaustion_projection(existing, reset_at);
|
||||
let quota = confirmed_exhaustion_projection(
|
||||
normalize_quota_window_placeholders(existing),
|
||||
reset_at,
|
||||
);
|
||||
let observed_at = SystemTime::now();
|
||||
let outcome = self
|
||||
.store
|
||||
@@ -545,21 +548,23 @@ impl CodexCredentialQuotaService {
|
||||
// 402:真额度耗尽,写入 QuotaExhausted(worker 按 reset_at 自动恢复)。
|
||||
Some(AccountAvailability::QuotaExhausted) => {
|
||||
summary.exhausted += 1;
|
||||
let _ = self
|
||||
.repository
|
||||
.apply_state(
|
||||
&account,
|
||||
AccountAvailability::QuotaExhausted,
|
||||
observed_at,
|
||||
)
|
||||
.await;
|
||||
self.persist_upstream_state(
|
||||
&account,
|
||||
AccountAvailability::QuotaExhausted,
|
||||
&error,
|
||||
observed_at,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Some(AccountAvailability::Banned) => {
|
||||
summary.banned += 1;
|
||||
let _ = self
|
||||
.repository
|
||||
.apply_state(&account, AccountAvailability::Banned, observed_at)
|
||||
.await;
|
||||
self.persist_upstream_state(
|
||||
&account,
|
||||
AccountAvailability::Banned,
|
||||
&error,
|
||||
observed_at,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
// 429/503 等临时拒绝:不写 availability(限流不改变账号可用性),仅记失败待重试。
|
||||
None | Some(_) => {
|
||||
@@ -594,10 +599,12 @@ impl CodexCredentialQuotaService {
|
||||
let refreshed_fact = refreshed_snapshot.fact();
|
||||
let previous_reset_at = self.previous_confirmed_reset(account).await;
|
||||
let now = SystemTime::now();
|
||||
let object = value
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or(CodexCredentialQuotaError::InvalidCredentialData)?;
|
||||
let object = normalize_quota_window_placeholders(
|
||||
value
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or(CodexCredentialQuotaError::InvalidCredentialData)?,
|
||||
);
|
||||
let object = if account.availability() == AccountAvailability::QuotaExhausted
|
||||
&& quota_success_state(
|
||||
account.availability(),
|
||||
@@ -908,9 +915,7 @@ impl CodexCredentialQuotaService {
|
||||
Err(CodexQuotaFetchError::Upstream { account, error }) => {
|
||||
// 402/deactivated → 写终态;429/503 等临时拒绝不写 availability(限流不改变账号可用性)。
|
||||
if let Some(availability) = quota_state_transition(&error) {
|
||||
let _ = self
|
||||
.repository
|
||||
.apply_state(&account, availability, observed_at)
|
||||
self.persist_upstream_state(&account, availability, &error, observed_at)
|
||||
.await;
|
||||
}
|
||||
return Err(CodexCredentialQuotaError::Upstream {
|
||||
@@ -927,10 +932,12 @@ impl CodexCredentialQuotaService {
|
||||
let refreshed_fact = refreshed_snapshot.fact();
|
||||
let previous_reset_at = self.previous_confirmed_reset(&account).await;
|
||||
let now = SystemTime::now();
|
||||
let object = value
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or(CodexCredentialQuotaError::InvalidCredentialData)?;
|
||||
let object = normalize_quota_window_placeholders(
|
||||
value
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or(CodexCredentialQuotaError::InvalidCredentialData)?,
|
||||
);
|
||||
let object = if account.availability() == AccountAvailability::QuotaExhausted
|
||||
&& quota_success_state(
|
||||
account.availability(),
|
||||
@@ -989,6 +996,29 @@ impl CodexCredentialQuotaService {
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
async fn persist_upstream_state(
|
||||
&self,
|
||||
account: &ProviderAccount,
|
||||
availability: AccountAvailability,
|
||||
upstream_error: &CodexClientError,
|
||||
observed_at: SystemTime,
|
||||
) {
|
||||
let reason = quota_state_reason(upstream_error).map(str::to_owned);
|
||||
if let Err(error) = self
|
||||
.repository
|
||||
.apply_state_with_message(account, availability, observed_at, reason.clone())
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
account_id = %account.id(),
|
||||
?availability,
|
||||
reason = reason.as_deref().unwrap_or_default(),
|
||||
error = %error,
|
||||
"OpenAI quota upstream state write failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_usage_with_recovery(
|
||||
&self,
|
||||
client: &CodexBackendClient,
|
||||
@@ -1181,6 +1211,43 @@ fn merge_passive_quota(
|
||||
quota
|
||||
}
|
||||
|
||||
/// 丢弃 core 限额中上游给出的无事实 `secondary_window` 占位。
|
||||
///
|
||||
/// 生产响应头可能携带 `used_percent=0`、零时长和空 reset 的占位,`/usage` 也会
|
||||
/// 返回 `secondary_window: null`。正常被动同步保留该响应事实,Admin 展示会将其
|
||||
/// 隐藏;但主动 `/usage` 刷新或 402 确认投影会把一个存在但无事实的字段写成
|
||||
/// 100%,从而显示并不存在的“次级额度”。因此这些非被动写入口在落库前移除
|
||||
/// 无事实值。带 reset、时长、正用量、触顶、未知或非法字段的次级窗口均完全按
|
||||
/// 原有额度逻辑保留。
|
||||
fn normalize_quota_window_placeholders(mut quota: Map<String, Value>) -> Map<String, Value> {
|
||||
if let Some(rate_limit) = quota.get_mut("rate_limit").and_then(Value::as_object_mut) {
|
||||
drop_secondary_window_placeholder(rate_limit);
|
||||
}
|
||||
quota
|
||||
}
|
||||
|
||||
fn drop_secondary_window_placeholder(rate_limit: &mut Map<String, Value>) {
|
||||
let placeholder = rate_limit.get("secondary_window").is_some_and(|window| {
|
||||
window.is_null()
|
||||
|| window
|
||||
.as_object()
|
||||
.is_some_and(secondary_window_is_placeholder)
|
||||
});
|
||||
if placeholder {
|
||||
rate_limit.remove("secondary_window");
|
||||
}
|
||||
}
|
||||
|
||||
fn secondary_window_is_placeholder(window: &Map<String, Value>) -> bool {
|
||||
window.iter().all(|(field, value)| match field.as_str() {
|
||||
"used_percent" => value
|
||||
.as_f64()
|
||||
.is_some_and(|used_percent| used_percent.is_finite() && used_percent == 0.0),
|
||||
"limit_reached" => value.as_bool() == Some(false),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// 用已确认的上游额度拒绝覆盖 core 限额投影。
|
||||
///
|
||||
/// 上游 usage 快照可能在结算期间短暂滞后为 98/99% 或 `allowed=true`;保留其他
|
||||
|
||||
@@ -50,6 +50,18 @@ pub(crate) fn quota_state_transition(error: &CodexClientError) -> Option<Account
|
||||
}
|
||||
}
|
||||
|
||||
/// 配额接口将账号变为非 ready 状态时可安全持久化的上游原因码。
|
||||
pub(crate) fn quota_state_reason(error: &CodexClientError) -> Option<&'static str> {
|
||||
match quota_state_transition(error)? {
|
||||
AccountAvailability::Banned => Some("deactivated_workspace"),
|
||||
AccountAvailability::QuotaExhausted => Some("payment_required"),
|
||||
AccountAvailability::Unknown
|
||||
| AccountAvailability::Ready
|
||||
| AccountAvailability::Expired
|
||||
| AccountAvailability::Invalid => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_deactivated_workspace(body: &str) -> bool {
|
||||
serde_json::from_str::<Value>(body)
|
||||
.ok()
|
||||
|
||||
@@ -11,29 +11,102 @@ use gateway_core::provider_ports::{
|
||||
ProviderCredentialStatePort, ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest,
|
||||
ProviderRefreshCapacityRequest, ProviderRefreshLeaseRequest, ProviderRefreshPolicy,
|
||||
ProviderRuntimePolicyPort, ProviderStoreError, provider_refresh_backoff_at,
|
||||
provider_refresh_retry_at,
|
||||
};
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use thiserror::Error;
|
||||
|
||||
use super::identity::{
|
||||
CodexAccountIdentityVerifier, CodexIdentityExpectation, CodexIdentityVerification,
|
||||
CodexIdentityVerificationError,
|
||||
};
|
||||
use super::identity::CodexAccountIdentityVerifier;
|
||||
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
|
||||
use super::token_client::{RefreshFailure, TokenPair, TokenRefresher};
|
||||
use super::types::{CodexCredentialPrincipal, CodexOAuthSecret, RotateCodexCredential};
|
||||
use super::types::CodexOAuthSecret;
|
||||
|
||||
const PROVIDER_NAME: &str = "openai";
|
||||
const MAX_REFRESH_BATCH: u32 = 1_000;
|
||||
/// 连续失败计数窗口;每次瞬态失败刷新该 TTL,静默满窗后计数过期归零。
|
||||
const REFRESH_BACKOFF_WINDOW: Duration = Duration::from_secs(30 * 60);
|
||||
/// 窗口内先执行五次指数退避,再进入固定恢复周期。
|
||||
const REFRESH_BACKOFF_MAX_ATTEMPTS: u32 = 5;
|
||||
/// 耗尽指数退避后的 OAuth 恢复周期。
|
||||
const REFRESH_RECOVERY_DELAY: Duration = Duration::from_secs(10 * 60);
|
||||
/// 过期 AT 仍允许 RT 恢复的最长窗口。
|
||||
const REFRESH_RECOVERY_WINDOW: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
pub(crate) fn refresh_recovery_deadline(
|
||||
access_token_expires_at: Option<SystemTime>,
|
||||
) -> Option<SystemTime> {
|
||||
access_token_expires_at.and_then(|expires_at| expires_at.checked_add(REFRESH_RECOVERY_WINDOW))
|
||||
}
|
||||
|
||||
fn refresh_recovery_window_exhausted(
|
||||
access_token_expires_at: Option<SystemTime>,
|
||||
now: SystemTime,
|
||||
) -> bool {
|
||||
refresh_recovery_deadline(access_token_expires_at).is_some_and(|deadline| deadline <= now)
|
||||
}
|
||||
|
||||
fn bounded_refresh_retry_at(
|
||||
retry_at: SystemTime,
|
||||
access_token_expires_at: Option<SystemTime>,
|
||||
observed_at: SystemTime,
|
||||
) -> SystemTime {
|
||||
let mut bounded = retry_at;
|
||||
if let Some(expires_at) = access_token_expires_at
|
||||
&& expires_at > observed_at
|
||||
&& bounded > expires_at
|
||||
{
|
||||
bounded = expires_at;
|
||||
}
|
||||
if let Some(deadline) = refresh_recovery_deadline(access_token_expires_at)
|
||||
&& bounded > deadline
|
||||
{
|
||||
bounded = deadline;
|
||||
}
|
||||
bounded
|
||||
}
|
||||
|
||||
fn oauth_refresh_retry_at(
|
||||
account_id: &ProviderAccountId,
|
||||
access_token_expires_at: Option<SystemTime>,
|
||||
observed_at: SystemTime,
|
||||
attempt: u32,
|
||||
reason: &'static str,
|
||||
) -> Result<SystemTime, ProviderStoreError> {
|
||||
let retry_at = if attempt <= REFRESH_BACKOFF_MAX_ATTEMPTS {
|
||||
provider_refresh_backoff_at(account_id, observed_at, attempt, reason)?
|
||||
} else {
|
||||
provider_refresh_retry_at(account_id, observed_at, REFRESH_RECOVERY_DELAY, reason)?
|
||||
};
|
||||
Ok(bounded_refresh_retry_at(
|
||||
retry_at,
|
||||
access_token_expires_at,
|
||||
observed_at,
|
||||
))
|
||||
}
|
||||
|
||||
fn log_refresh_deferred(
|
||||
account_id: &ProviderAccountId,
|
||||
access_token_expires_at: Option<SystemTime>,
|
||||
attempt: u32,
|
||||
reason: &'static str,
|
||||
retry_at: SystemTime,
|
||||
) {
|
||||
tracing::warn!(
|
||||
account_id = %account_id,
|
||||
attempt,
|
||||
reason,
|
||||
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)
|
||||
.map(DateTime::<Utc>::from),
|
||||
"OpenAI OAuth refresh deferred"
|
||||
);
|
||||
}
|
||||
|
||||
fn refresh_due_at(account: &ProviderAccount, now: SystemTime) -> Option<SystemTime> {
|
||||
if let Some(expires_at) = account.access_token_expires_at()
|
||||
&& expires_at <= now
|
||||
{
|
||||
// AT 已过期时不能继续等待持久退避;后续额度/请求探测都必须先用 RT 续出可用 AT。
|
||||
return Some(expires_at);
|
||||
if refresh_recovery_window_exhausted(account.access_token_expires_at(), now) {
|
||||
// 即使旧 next_refresh_at 异常地落在 deadline 之后,也必须进入终态化路径。
|
||||
return refresh_recovery_deadline(account.access_token_expires_at());
|
||||
}
|
||||
account.next_refresh_at().filter(|next| *next <= now)
|
||||
}
|
||||
@@ -41,8 +114,6 @@ fn refresh_due_at(account: &ProviderAccount, now: SystemTime) -> Option<SystemTi
|
||||
pub struct DueCodexCredential {
|
||||
pub account: ProviderAccount,
|
||||
pub secret: CodexOAuthSecret,
|
||||
principal: CodexCredentialPrincipal,
|
||||
installation_id: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DueCodexCredential {
|
||||
@@ -94,7 +165,6 @@ pub enum CodexCredentialRefreshError {
|
||||
pub struct CodexCredentialRefreshService {
|
||||
repository: CodexCredentialRepository,
|
||||
refresher: Arc<dyn TokenRefresher>,
|
||||
identity: Arc<dyn CodexAccountIdentityVerifier>,
|
||||
leases: Arc<dyn ProviderLeasePort>,
|
||||
credential_state: Arc<dyn ProviderCredentialStatePort>,
|
||||
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
|
||||
@@ -104,7 +174,7 @@ impl CodexCredentialRefreshService {
|
||||
pub fn new(
|
||||
repository: CodexCredentialRepository,
|
||||
refresher: Arc<dyn TokenRefresher>,
|
||||
identity: Arc<dyn CodexAccountIdentityVerifier>,
|
||||
_identity: Arc<dyn CodexAccountIdentityVerifier>,
|
||||
leases: Arc<dyn ProviderLeasePort>,
|
||||
credential_state: Arc<dyn ProviderCredentialStatePort>,
|
||||
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
|
||||
@@ -112,7 +182,6 @@ impl CodexCredentialRefreshService {
|
||||
Self {
|
||||
repository,
|
||||
refresher,
|
||||
identity,
|
||||
leases,
|
||||
credential_state,
|
||||
runtime_policy,
|
||||
@@ -145,7 +214,14 @@ impl CodexCredentialRefreshService {
|
||||
for (account_id, result) in refreshed {
|
||||
match result {
|
||||
Ok(outcome) => outcomes.push(outcome),
|
||||
Err(_) => outcomes.push(CodexCredentialRefreshOutcome::Failed { account_id }),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
account_id = %account_id,
|
||||
error = %error,
|
||||
"OpenAI OAuth refresh attempt failed"
|
||||
);
|
||||
outcomes.push(CodexCredentialRefreshOutcome::Failed { account_id });
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(outcomes)
|
||||
@@ -157,6 +233,19 @@ impl CodexCredentialRefreshService {
|
||||
policy: ProviderRefreshPolicy,
|
||||
) -> Result<CodexCredentialRefreshOutcome, CodexCredentialRefreshError> {
|
||||
let account_id = due.account.id().to_string();
|
||||
if refresh_recovery_window_exhausted(
|
||||
due.account.access_token_expires_at(),
|
||||
SystemTime::now(),
|
||||
) {
|
||||
return self
|
||||
.persist_terminal(
|
||||
&due.account,
|
||||
AccountAvailability::Expired,
|
||||
"refresh_recovery_window_exhausted",
|
||||
CodexCredentialRefreshOutcome::Invalidated { account_id },
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let capacity = self
|
||||
.leases
|
||||
.try_acquire(ProviderLeaseRequest::RefreshCapacity(
|
||||
@@ -192,6 +281,7 @@ impl CodexCredentialRefreshService {
|
||||
self.persist_terminal(
|
||||
&due.account,
|
||||
AccountAvailability::Expired,
|
||||
"refresh_invalid_grant",
|
||||
CodexCredentialRefreshOutcome::Invalidated { account_id },
|
||||
)
|
||||
.await
|
||||
@@ -200,6 +290,7 @@ impl CodexCredentialRefreshService {
|
||||
self.persist_terminal(
|
||||
&due.account,
|
||||
AccountAvailability::Banned,
|
||||
"account_banned",
|
||||
CodexCredentialRefreshOutcome::Banned { account_id },
|
||||
)
|
||||
.await
|
||||
@@ -272,15 +363,9 @@ impl CodexCredentialRefreshService {
|
||||
let Some(secret) = runtime.authentication.oauth() else {
|
||||
unreachable!("OAuth runtime authentication was checked above")
|
||||
};
|
||||
let Some(principal) = runtime.principal else {
|
||||
failures.push(CodexCredentialRefreshOutcome::Failed { account_id });
|
||||
continue;
|
||||
};
|
||||
due.push(DueCodexCredential {
|
||||
account,
|
||||
secret: secret.clone(),
|
||||
principal,
|
||||
installation_id: runtime.installation_id,
|
||||
});
|
||||
}
|
||||
Ok(_) | Err(_) => {
|
||||
@@ -297,113 +382,38 @@ impl CodexCredentialRefreshService {
|
||||
tokens: TokenPair,
|
||||
policy: ProviderRefreshPolicy,
|
||||
) -> Result<CodexCredentialRefreshOutcome, CodexCredentialRefreshError> {
|
||||
if tokens.access_token.is_empty() || tokens.expires_in.is_zero() {
|
||||
let TokenPair {
|
||||
access_token,
|
||||
refresh_token: rotated_refresh_token,
|
||||
expires_in,
|
||||
} = tokens;
|
||||
if access_token.is_empty() || expires_in.is_zero() {
|
||||
return Err(CodexCredentialRefreshError::InvalidRefreshResponse);
|
||||
}
|
||||
let refresh_token = tokens
|
||||
.refresh_token
|
||||
let observed_at = SystemTime::now();
|
||||
let access_token_expires_at = observed_at
|
||||
.checked_add(expires_in)
|
||||
.ok_or(CodexCredentialRefreshError::InvalidRefreshResponse)?;
|
||||
let refresh_token = rotated_refresh_token
|
||||
.map(SecretString::from)
|
||||
.or(due.secret.refresh_token);
|
||||
let account_id = due
|
||||
.account
|
||||
.upstream_account_id()
|
||||
.ok_or(CodexCredentialRefreshError::InvalidRefreshResponse)?;
|
||||
let expectation = CodexIdentityExpectation::current(
|
||||
due.principal.oauth_subject,
|
||||
due.principal.poid,
|
||||
account_id.to_owned(),
|
||||
due.account.upstream_user_id().to_owned(),
|
||||
due.installation_id,
|
||||
)
|
||||
.map_err(|_| CodexCredentialRefreshError::InvalidRefreshResponse)?;
|
||||
let secret = CodexOAuthSecret {
|
||||
access_token: SecretString::from(tokens.access_token),
|
||||
access_token: SecretString::from(access_token),
|
||||
refresh_token,
|
||||
id_token: due.secret.id_token,
|
||||
};
|
||||
let verification = match self.identity.verify(&secret, &expectation).await {
|
||||
Ok(verification) => verification,
|
||||
Err(CodexIdentityVerificationError::Rejected) => {
|
||||
return self
|
||||
.persist_terminal(
|
||||
&due.account,
|
||||
AccountAvailability::Invalid,
|
||||
CodexCredentialRefreshOutcome::Invalidated {
|
||||
account_id: due.account.id().to_string(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(CodexIdentityVerificationError::Unavailable) => {
|
||||
// JWKS/签名边界短暂不可用属瞬态:保留现有凭据、推进退避重试,
|
||||
// 不终态失效账号。
|
||||
return if self
|
||||
.defer_refresh(&due.account, "identity-unavailable")
|
||||
.await?
|
||||
{
|
||||
Ok(CodexCredentialRefreshOutcome::Transient {
|
||||
account_id: due.account.id().to_string(),
|
||||
})
|
||||
} else {
|
||||
Ok(CodexCredentialRefreshOutcome::Stale {
|
||||
account_id: due.account.id().to_string(),
|
||||
})
|
||||
};
|
||||
}
|
||||
};
|
||||
let profile = match verification {
|
||||
CodexIdentityVerification::Complete(profile) => profile,
|
||||
CodexIdentityVerification::SignedOnly(signed) => {
|
||||
let attempt = self
|
||||
.credential_state
|
||||
.record_refresh_backoff(due.account.id(), REFRESH_BACKOFF_WINDOW)
|
||||
.await
|
||||
.unwrap_or(1);
|
||||
let retry_at = provider_refresh_backoff_at(
|
||||
due.account.id(),
|
||||
SystemTime::now(),
|
||||
attempt,
|
||||
"identity-completion",
|
||||
)?;
|
||||
match self
|
||||
.repository
|
||||
.rotate_signed_secret(&due.account, secret, &signed, retry_at)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
return Ok(CodexCredentialRefreshOutcome::Transient {
|
||||
account_id: due.account.id().to_string(),
|
||||
});
|
||||
}
|
||||
Err(CredentialRepositoryError::RevisionConflict) => {
|
||||
return Ok(CodexCredentialRefreshOutcome::Stale {
|
||||
account_id: due.account.id().to_string(),
|
||||
});
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
};
|
||||
let next_refresh_at = policy
|
||||
.next_attempt_at(
|
||||
due.account.id(),
|
||||
profile
|
||||
.access_token_expires_at
|
||||
.map(SystemTime::from)
|
||||
.ok_or(CodexCredentialRefreshError::InvalidRefreshResponse)?,
|
||||
SystemTime::now(),
|
||||
)
|
||||
.next_attempt_at(due.account.id(), access_token_expires_at, observed_at)
|
||||
.map(DateTime::<Utc>::from)
|
||||
.map_err(CodexCredentialRefreshError::from)?;
|
||||
let result = self
|
||||
.repository
|
||||
.rotate_oauth_secret(RotateCodexCredential {
|
||||
account_id: due.account.id().to_string(),
|
||||
expected_credential_revision: due.account.revision().get(),
|
||||
.rotate_refreshed_oauth_secret(
|
||||
&due.account,
|
||||
secret,
|
||||
verified_account: profile,
|
||||
next_refresh_at: Some(next_refresh_at),
|
||||
})
|
||||
access_token_expires_at,
|
||||
SystemTime::from(next_refresh_at),
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(revision) => {
|
||||
@@ -412,6 +422,13 @@ impl CodexCredentialRefreshService {
|
||||
.credential_state
|
||||
.clear_refresh_backoff(due.account.id())
|
||||
.await;
|
||||
tracing::info!(
|
||||
account_id = %due.account.id(),
|
||||
credential_revision = revision.get(),
|
||||
access_token_expires_at = %DateTime::<Utc>::from(access_token_expires_at),
|
||||
next_refresh_at = %next_refresh_at,
|
||||
"OpenAI OAuth refresh succeeded"
|
||||
);
|
||||
Ok(CodexCredentialRefreshOutcome::Refreshed {
|
||||
account_id: due.account.id().to_string(),
|
||||
credential_revision: revision.get(),
|
||||
@@ -430,6 +447,7 @@ impl CodexCredentialRefreshService {
|
||||
&self,
|
||||
account: &ProviderAccount,
|
||||
availability: AccountAvailability,
|
||||
reason: &'static str,
|
||||
outcome: CodexCredentialRefreshOutcome,
|
||||
) -> Result<CodexCredentialRefreshOutcome, CodexCredentialRefreshError> {
|
||||
match self.repository.load_runtime_credential(account).await {
|
||||
@@ -443,10 +461,27 @@ impl CodexCredentialRefreshService {
|
||||
}
|
||||
match self
|
||||
.repository
|
||||
.apply_state(account, availability, SystemTime::now())
|
||||
.apply_state_with_message(
|
||||
account,
|
||||
availability,
|
||||
SystemTime::now(),
|
||||
Some(reason.to_owned()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(outcome),
|
||||
Ok(()) => {
|
||||
tracing::warn!(
|
||||
account_id = %account.id(),
|
||||
?availability,
|
||||
reason,
|
||||
access_token_expires_at = ?account.access_token_expires_at()
|
||||
.map(DateTime::<Utc>::from),
|
||||
recovery_deadline = ?refresh_recovery_deadline(account.access_token_expires_at())
|
||||
.map(DateTime::<Utc>::from),
|
||||
"OpenAI OAuth refresh marked account terminal"
|
||||
);
|
||||
Ok(outcome)
|
||||
}
|
||||
Err(CredentialRepositoryError::RevisionConflict) => {
|
||||
Ok(CodexCredentialRefreshOutcome::Stale {
|
||||
account_id: account.id().to_string(),
|
||||
@@ -466,10 +501,24 @@ impl CodexCredentialRefreshService {
|
||||
.record_refresh_backoff(account.id(), REFRESH_BACKOFF_WINDOW)
|
||||
.await
|
||||
.unwrap_or(1);
|
||||
let retry_at =
|
||||
provider_refresh_backoff_at(account.id(), SystemTime::now(), attempt, reason)?;
|
||||
let retry_at = oauth_refresh_retry_at(
|
||||
account.id(),
|
||||
account.access_token_expires_at(),
|
||||
SystemTime::now(),
|
||||
attempt,
|
||||
reason,
|
||||
)?;
|
||||
match self.repository.defer_refresh(account, retry_at).await {
|
||||
Ok(_) => Ok(true),
|
||||
Ok(_) => {
|
||||
log_refresh_deferred(
|
||||
account.id(),
|
||||
account.access_token_expires_at(),
|
||||
attempt,
|
||||
reason,
|
||||
retry_at,
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
Err(CredentialRepositoryError::RevisionConflict) => Ok(false),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ use gateway_core::routing::ProviderKind;
|
||||
use secrecy::ExposeSecret;
|
||||
use thiserror::Error;
|
||||
|
||||
use super::identity::CodexSignedIdentity;
|
||||
use super::security::{CodexCredentialCodec, CodexCredentialDataError, CodexRuntimeCredential};
|
||||
use super::types::{
|
||||
CodexAccountProfile, CodexCredentialData, CodexOAuthSecret, RotateCodexCredential,
|
||||
@@ -83,12 +82,15 @@ impl CodexCredentialRepository {
|
||||
cas_revision(self.store.compare_and_swap_credential(update).await?)
|
||||
}
|
||||
|
||||
/// usage 暂时不可用时,只在签名 principal 与现有绑定完全一致后保存已轮换 token。
|
||||
pub async fn rotate_signed_secret(
|
||||
/// 持久化成功 RT exchange 的 token,同时保留既有账号身份投影。
|
||||
///
|
||||
/// Refresh endpoint 已经是这次 AT/RT 轮换的授权边界;这里仅以 revision
|
||||
/// CAS 保护并发写入,不重新验证新 access token 的身份声明。
|
||||
pub async fn rotate_refreshed_oauth_secret(
|
||||
&self,
|
||||
account: &ProviderAccount,
|
||||
secret: CodexOAuthSecret,
|
||||
signed: &CodexSignedIdentity,
|
||||
access_token_expires_at: SystemTime,
|
||||
next_refresh_at: SystemTime,
|
||||
) -> Result<CredentialRevision, CredentialRepositoryError> {
|
||||
let current = self
|
||||
@@ -102,17 +104,6 @@ impl CodexCredentialRepository {
|
||||
let oauth = data
|
||||
.oauth_mut()
|
||||
.ok_or(CredentialRepositoryError::InvalidCredentialData)?;
|
||||
if oauth.principal.oauth_subject != signed.oauth_subject()
|
||||
|| oauth.principal.poid.as_deref() != signed.poid()
|
||||
|| signed
|
||||
.claimed_account_id()
|
||||
.is_some_and(|value| account.upstream_account_id() != Some(value))
|
||||
|| signed
|
||||
.claimed_user_id()
|
||||
.is_some_and(|value| account.upstream_user_id() != value)
|
||||
{
|
||||
return Err(CredentialRepositoryError::IdentityMismatch);
|
||||
}
|
||||
oauth.access_token = secret.access_token.expose_secret().to_owned();
|
||||
oauth.refresh_token = secret
|
||||
.refresh_token
|
||||
@@ -129,7 +120,7 @@ impl CodexCredentialRepository {
|
||||
unchanged_profile(account),
|
||||
credential,
|
||||
secret.refresh_token.is_some(),
|
||||
Some(SystemTime::from(signed.access_token_expires_at())),
|
||||
Some(access_token_expires_at),
|
||||
Some(next_refresh_at),
|
||||
)
|
||||
.map_err(|_| CredentialRepositoryError::InvalidCredentialData)?;
|
||||
@@ -239,7 +230,8 @@ impl CodexCredentialRepository {
|
||||
.await
|
||||
}
|
||||
|
||||
/// 带上游错误描述的状态写入;`message` 为 `Some` 时持久化最近错误,恢复 `Ready` 时清空。
|
||||
/// 带上游错误描述的状态写入;上游 message 优先,终态缺失 message 时写稳定兜底,
|
||||
/// 恢复 `Ready` 时清空。
|
||||
pub async fn apply_state_with_message(
|
||||
&self,
|
||||
account: &ProviderAccount,
|
||||
@@ -250,6 +242,9 @@ impl CodexCredentialRepository {
|
||||
if !account.enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
let message = message
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| availability.fallback_error_reason().map(str::to_owned));
|
||||
self.store
|
||||
.apply_state_change(AccountStateChange {
|
||||
account_id: account.id().clone(),
|
||||
|
||||
@@ -25,11 +25,12 @@ use super::agent_identity::CodexAgentIdentityTaskService;
|
||||
use super::catalog::CodexCredentialCatalogService;
|
||||
use super::cookie::CodexCookiePolicy;
|
||||
use super::quota::CodexCredentialQuotaService;
|
||||
use super::refresh::refresh_recovery_deadline;
|
||||
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
|
||||
use super::security::CodexRuntimeAuthentication;
|
||||
use super::types::{
|
||||
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CodexCookie, CodexCookieCaptureOutcome,
|
||||
RuntimeCodexCookie,
|
||||
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CODEX_AUTHENTICATION_KIND_OAUTH, CodexCookie,
|
||||
CodexCookieCaptureOutcome, RuntimeCodexCookie,
|
||||
};
|
||||
|
||||
const CLOUDFLARE_RECOVERY_STALE_AFTER: Duration = Duration::from_secs(60 * 60);
|
||||
@@ -73,6 +74,21 @@ pub enum CodexAccountFailure {
|
||||
CloudflarePathBlocked,
|
||||
}
|
||||
|
||||
impl CodexAccountFailure {
|
||||
/// 上游未提供可展示错误文本时,保留已分类的稳定原因。
|
||||
const fn fallback_error_reason(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::CredentialExpired => Some("upstream_credential_expired"),
|
||||
Self::IdentityVerificationRequired => Some("upstream_identity_verification_required"),
|
||||
Self::Banned => Some("upstream_account_banned"),
|
||||
Self::QuotaExhausted => Some("upstream_quota_exhausted"),
|
||||
Self::UsageLimitExhausted { .. } => Some("upstream_usage_limit_exhausted"),
|
||||
Self::CloudflarePathBlocked => Some("upstream_cloudflare_path_blocked"),
|
||||
Self::RateLimited { .. } | Self::CloudflareChallenge { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct RiskRecoveryState {
|
||||
challenge_count: u32,
|
||||
@@ -733,8 +749,29 @@ impl CodexCredentialSelector {
|
||||
message: Option<String>,
|
||||
) -> Result<(), CredentialSelectionError> {
|
||||
let now = SystemTime::now();
|
||||
let message = message
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| failure.fallback_error_reason().map(str::to_owned));
|
||||
match failure {
|
||||
CodexAccountFailure::CredentialExpired => {
|
||||
if account.authentication_kind() == CODEX_AUTHENTICATION_KIND_OAUTH
|
||||
&& account.has_refresh_token()
|
||||
&& account
|
||||
.access_token_expires_at()
|
||||
.is_some_and(|expires_at| expires_at <= now)
|
||||
&& refresh_recovery_deadline(account.access_token_expires_at())
|
||||
.is_some_and(|deadline| deadline > now)
|
||||
{
|
||||
tracing::info!(
|
||||
account_id = %account.id(),
|
||||
access_token_expires_at = ?account.access_token_expires_at()
|
||||
.map(chrono::DateTime::<chrono::Utc>::from),
|
||||
recovery_deadline = ?refresh_recovery_deadline(account.access_token_expires_at())
|
||||
.map(chrono::DateTime::<chrono::Utc>::from),
|
||||
"OpenAI access token expired; retaining account for bounded OAuth refresh recovery"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
self.apply_state_with_message(account, AccountAvailability::Expired, now, message)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -76,10 +76,11 @@ impl fmt::Debug for AuthorizationCodeGrant {
|
||||
}
|
||||
}
|
||||
|
||||
/// 官方 token endpoint 返回、尚待 OIDC/JWKS 绑定校验的 token set。
|
||||
/// 官方 token endpoint 完成协议校验后返回的 token set。
|
||||
pub struct AuthorizationTokenSet {
|
||||
pub secret: crate::credential::CodexOAuthSecret,
|
||||
pub id_token: SecretString,
|
||||
pub expires_in: Duration,
|
||||
}
|
||||
|
||||
impl fmt::Debug for AuthorizationTokenSet {
|
||||
@@ -88,6 +89,7 @@ impl fmt::Debug for AuthorizationTokenSet {
|
||||
.debug_struct("AuthorizationTokenSet")
|
||||
.field("secret", &"[REDACTED]")
|
||||
.field("id_token", &"[REDACTED]")
|
||||
.field("expires_in", &self.expires_in)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -267,6 +269,7 @@ impl AuthorizationCodeExchanger for OpenAiTokenClient {
|
||||
id_token: None,
|
||||
},
|
||||
id_token: SecretString::from(tokens.id_token),
|
||||
expires_in: Duration::from_secs(tokens.expires_in),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ impl fmt::Debug for CodexOAuthSecret {
|
||||
}
|
||||
}
|
||||
|
||||
/// 已由官方 JWT/OIDC 与认证 usage 响应共同确认的完整账号投影。
|
||||
/// OAuth credential 持久化时使用的账号投影。
|
||||
#[derive(Clone)]
|
||||
pub struct CodexAccountProfile {
|
||||
pub email: Option<String>,
|
||||
|
||||
@@ -82,7 +82,7 @@ pub async fn initialize(
|
||||
profile.clone(),
|
||||
Arc::new(
|
||||
OfficialCodexCliReleaseTransport::new()
|
||||
.map_err(|_| OpenAiInitializeError::DesktopRelease)?,
|
||||
.map_err(|_| OpenAiInitializeError::CliRelease)?,
|
||||
),
|
||||
));
|
||||
let repository = CodexCredentialRepository::new(Arc::clone(&accounts));
|
||||
@@ -268,6 +268,8 @@ 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,
|
||||
}
|
||||
|
||||
@@ -2435,11 +2435,10 @@ impl ScheduledTask for OpenAiOAuthRefreshTask {
|
||||
if context.cancellation().is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
let outcomes = self
|
||||
.service
|
||||
.refresh_due()
|
||||
.await
|
||||
.map_err(|_| WorkerTaskError::safe("OpenAI OAuth refresh failed"))?;
|
||||
let outcomes = self.service.refresh_due().await.map_err(|error| {
|
||||
tracing::error!(error = %error, "OpenAI OAuth refresh cycle failed");
|
||||
WorkerTaskError::safe("OpenAI OAuth refresh failed")
|
||||
})?;
|
||||
let mut refreshed = 0_u64;
|
||||
let mut invalidated = 0_u64;
|
||||
let mut banned = 0_u64;
|
||||
@@ -2468,6 +2467,18 @@ impl ScheduledTask for OpenAiOAuthRefreshTask {
|
||||
}
|
||||
}
|
||||
}
|
||||
if !outcomes.is_empty() {
|
||||
tracing::info!(
|
||||
refreshed,
|
||||
invalidated,
|
||||
banned,
|
||||
transient,
|
||||
lease_unavailable,
|
||||
stale,
|
||||
failed,
|
||||
"OpenAI OAuth refresh cycle completed"
|
||||
);
|
||||
}
|
||||
if transient > 0 || failed > 0 {
|
||||
tracing::warn!(
|
||||
refreshed,
|
||||
|
||||
@@ -14,21 +14,22 @@ use url::Url;
|
||||
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;
|
||||
|
||||
/// Codex Desktop 上游请求身份。
|
||||
///
|
||||
/// 启动配置提供经源码审计的 Core、运行环境和初始 Desktop 版本。官方 appcast
|
||||
/// 检查成功后,会仅同步 Desktop 版本和构建号。
|
||||
/// 启动配置提供经源码审计的 Core、运行环境和 Desktop 启动版本。npm CLI metadata
|
||||
/// 与官方 appcast 检查成功后,分别同步 Core 版本和 Desktop 版本及构建号。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodexWireProfile {
|
||||
/// `originator` 请求头及 User-Agent 产品名。
|
||||
pub originator: String,
|
||||
/// bundled Codex Core 版本;同时用于 `/codex/models?client_version=`。
|
||||
/// npm `@openai/codex` 启动版本;用于 `/codex/models?client_version=` 与 UA。
|
||||
pub codex_version: String,
|
||||
/// Desktop 应用版本,用于 app-server `clientInfo.version` 对应的 UA 后缀。
|
||||
pub desktop_version: String,
|
||||
@@ -47,16 +48,18 @@ pub struct CodexWireProfile {
|
||||
}
|
||||
|
||||
impl CodexWireProfile {
|
||||
/// 按 Codex Core 的官方格式生成最终 User-Agent。
|
||||
/// 按 bundled Core app-server 的官方格式生成最终 User-Agent。
|
||||
pub fn user_agent(&self) -> String {
|
||||
format!(
|
||||
"{}/{} ({} {}; {}) {}",
|
||||
"{}/{} ({} {}; {}) {} ({}; {})",
|
||||
self.originator,
|
||||
self.codex_version,
|
||||
self.os_type,
|
||||
self.os_version,
|
||||
self.arch,
|
||||
self.terminal,
|
||||
self.originator,
|
||||
self.desktop_version,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -96,7 +99,7 @@ impl CodexWireProfileState {
|
||||
profile.desktop_build = desktop_build.to_owned();
|
||||
}
|
||||
|
||||
/// 使用官方 npm metadata 发布的 Codex CLI 版本更新运行时 Core 画像。
|
||||
/// 使用官方 npm metadata 发布的 Codex CLI 版本更新运行时请求画像。
|
||||
pub fn update_cli_release(&self, codex_version: &str) {
|
||||
let mut profile = self
|
||||
.profile
|
||||
@@ -143,12 +146,12 @@ impl CodexCliReleaseTransport for OfficialCodexCliReleaseTransport {
|
||||
}
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|size| size > 64 * 1024)
|
||||
.is_some_and(|size| size > MAX_CLI_RELEASE_BYTES as u64)
|
||||
{
|
||||
return Err(CodexCliReleaseError::ResponseTooLarge);
|
||||
}
|
||||
let bytes = response.bytes().await?;
|
||||
if bytes.len() > 64 * 1024 {
|
||||
if bytes.len() > MAX_CLI_RELEASE_BYTES {
|
||||
return Err(CodexCliReleaseError::ResponseTooLarge);
|
||||
}
|
||||
let document: serde_json::Value = serde_json::from_slice(&bytes)
|
||||
|
||||
@@ -10,6 +10,8 @@ use sha2::{Digest, Sha256};
|
||||
use crate::transport::protocol::responses::CodexResponsesRequest;
|
||||
|
||||
const PASSTHROUGH_HEADERS_CONTEXT_KEY: &str = "opaque_request_headers";
|
||||
const THREAD_SPAWN_SUBAGENT_KIND: &str = "thread_spawn";
|
||||
const THREAD_SPAWN_CONVERSATION_PREFIX: &str = "thread-spawn:";
|
||||
|
||||
const CROSS_ACCOUNT_IDENTITY_KEYS: &[&str] = &[
|
||||
"authorization",
|
||||
@@ -160,28 +162,52 @@ fn string_value(value: Option<&Value>) -> Option<String> {
|
||||
pub(crate) fn derive_conversation_anchor(
|
||||
request: &CodexResponsesRequest,
|
||||
) -> Option<(&'static str, String)> {
|
||||
thread_spawn_conversation_anchor(request).or_else(|| {
|
||||
request
|
||||
.client_session_id
|
||||
.as_deref()
|
||||
.map(|value| ("session", value.to_owned()))
|
||||
.or_else(|| {
|
||||
request
|
||||
.client_conversation_id
|
||||
.as_deref()
|
||||
.map(|value| ("conversation", value.to_owned()))
|
||||
})
|
||||
.or_else(|| {
|
||||
request
|
||||
.client_thread_id
|
||||
.as_deref()
|
||||
.map(|value| ("thread", value.to_owned()))
|
||||
})
|
||||
.or_else(|| {
|
||||
request
|
||||
.prompt_cache_key()
|
||||
.map(|value| ("prompt-cache", value.to_owned()))
|
||||
})
|
||||
.or_else(|| derive_stable_conversation_key(request).map(|value| ("request", value)))
|
||||
})
|
||||
}
|
||||
|
||||
/// A `thread_spawn` shares its parent session but is a separate child execution.
|
||||
/// Its child thread must therefore own the affinity and WebSocket pool identity.
|
||||
fn thread_spawn_conversation_anchor(
|
||||
request: &CodexResponsesRequest,
|
||||
) -> Option<(&'static str, String)> {
|
||||
if request.subagent_kind().as_deref() != Some(THREAD_SPAWN_SUBAGENT_KIND) {
|
||||
return None;
|
||||
}
|
||||
|
||||
request
|
||||
.client_session_id
|
||||
.client_thread_id
|
||||
.as_deref()
|
||||
.map(|value| ("session", value.to_owned()))
|
||||
.or_else(|| {
|
||||
request
|
||||
.client_conversation_id
|
||||
.as_deref()
|
||||
.map(|value| ("conversation", value.to_owned()))
|
||||
.map(str::trim)
|
||||
.filter(|thread_id| !thread_id.is_empty())
|
||||
.map(|thread_id| {
|
||||
(
|
||||
"subagent-thread",
|
||||
format!("{THREAD_SPAWN_CONVERSATION_PREFIX}{thread_id}"),
|
||||
)
|
||||
})
|
||||
.or_else(|| {
|
||||
request
|
||||
.client_thread_id
|
||||
.as_deref()
|
||||
.map(|value| ("thread", value.to_owned()))
|
||||
})
|
||||
.or_else(|| {
|
||||
request
|
||||
.prompt_cache_key()
|
||||
.map(|value| ("prompt-cache", value.to_owned()))
|
||||
})
|
||||
.or_else(|| derive_stable_conversation_key(request).map(|value| ("request", value)))
|
||||
}
|
||||
|
||||
const LEADING_SYSTEM_REMINDER_OPEN: &str = "<system-reminder>";
|
||||
|
||||
@@ -82,6 +82,22 @@ 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,
|
||||
@@ -101,13 +117,21 @@ async fn openai_admin_provider_exposes_live_wire_profile_and_validated_billing()
|
||||
.expect("OpenAI bundle");
|
||||
let admin = bundle.admin_provider();
|
||||
let profile = admin.dashboard_wire_profile().expect("wire profile");
|
||||
assert_eq!(profile.version, "0.102.0");
|
||||
assert_eq!(profile.build, None);
|
||||
assert_eq!(profile.target.os_type, "Mac OS");
|
||||
assert_eq!(profile.target.os_version, "15.5.0");
|
||||
assert_eq!(
|
||||
profile.user_agent,
|
||||
"Codex Desktop/0.102.0 (Mac OS 15.5.0; arm64) xterm-256color (Codex Desktop; 1.2026.190)"
|
||||
);
|
||||
assert_eq!(
|
||||
profile
|
||||
.attributes
|
||||
.iter()
|
||||
.find(|attribute| attribute.label == "Codex Core")
|
||||
.find(|attribute| attribute.label == "客户端标识")
|
||||
.map(|attribute| attribute.value.as_str()),
|
||||
Some("0.102.0")
|
||||
Some("Codex Desktop; 1.2026.190")
|
||||
);
|
||||
assert_eq!(
|
||||
profile.release.as_ref().map(|release| release.status),
|
||||
@@ -771,7 +795,7 @@ fn valid_config() -> TestOpenAiConfig {
|
||||
codex_version: "0.102.0".to_owned(),
|
||||
desktop_version: "1.2026.190".to_owned(),
|
||||
desktop_build: "19012345678".to_owned(),
|
||||
os_type: "macOS".to_owned(),
|
||||
os_type: "Mac OS".to_owned(),
|
||||
os_version: "15.5.0".to_owned(),
|
||||
arch: "arm64".to_owned(),
|
||||
terminal: "xterm-256color".to_owned(),
|
||||
|
||||
@@ -13,7 +13,7 @@ fn openai_config_builds_the_audited_wire_profile() {
|
||||
|
||||
assert_eq!(
|
||||
profile.user_agent(),
|
||||
"Codex Desktop/0.102.0 (macOS 15.5.0; arm64) xterm-256color"
|
||||
"Codex Desktop/0.102.0 (Mac OS 15.5.0; arm64) xterm-256color (Codex Desktop; 1.2026.190)"
|
||||
);
|
||||
assert_eq!(profile.desktop_build, "19012345678");
|
||||
}
|
||||
@@ -124,6 +124,10 @@ fn openai_config_defaults_to_the_provider_owned_operating_values() {
|
||||
"https://auth.openai.com/oauth/token",
|
||||
)
|
||||
);
|
||||
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)"
|
||||
);
|
||||
}
|
||||
|
||||
fn valid_config() -> OpenAiConfig {
|
||||
@@ -133,7 +137,7 @@ fn valid_config() -> OpenAiConfig {
|
||||
codex_version: "0.102.0".to_owned(),
|
||||
desktop_version: "1.2026.190".to_owned(),
|
||||
desktop_build: "19012345678".to_owned(),
|
||||
os_type: "macOS".to_owned(),
|
||||
os_type: "Mac OS".to_owned(),
|
||||
os_version: "15.5.0".to_owned(),
|
||||
arch: "arm64".to_owned(),
|
||||
terminal: "xterm-256color".to_owned(),
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
|
||||
use chrono::{TimeZone, Utc};
|
||||
use ed25519_dalek::SigningKey;
|
||||
use ed25519_dalek::pkcs8::EncodePrivateKey as _;
|
||||
@@ -46,6 +46,22 @@ fn encoded_credential(account_id: &str, token: &str) -> PlaintextCredential {
|
||||
.expect("encode current credential")
|
||||
}
|
||||
|
||||
fn unverified_import_access_token(account_id: &str, user_id: &str) -> String {
|
||||
let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#);
|
||||
let payload = URL_SAFE_NO_PAD.encode(
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"sub": format!("subject-{user_id}"),
|
||||
"exp": Utc::now().timestamp() + 3_600,
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": account_id,
|
||||
"chatgpt_user_id": user_id,
|
||||
},
|
||||
}))
|
||||
.expect("serialize unsigned import JWT"),
|
||||
);
|
||||
format!("{header}.{payload}.")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_import_returns_core_account_with_plaintext_json() {
|
||||
let prepared = CodexCredentialAdmin
|
||||
@@ -315,11 +331,10 @@ fn cpr_export_preserves_agent_identity_material_without_oauth_fields() {
|
||||
#[tokio::test]
|
||||
async fn cpr_export_and_import_support_mixed_oauth_and_agent_identity_accounts() {
|
||||
let (agent, _) = agent_export_item("acct_mixed_agent");
|
||||
let access_token =
|
||||
unverified_import_access_token("chatgpt-acct_mixed_oauth", "user-acct_mixed_oauth");
|
||||
let value = CodexCredentialAdmin
|
||||
.format_cpr_export(vec![
|
||||
export_item("acct_mixed_oauth", "token-acct_mixed_oauth"),
|
||||
agent,
|
||||
])
|
||||
.format_cpr_export(vec![export_item("acct_mixed_oauth", &access_token), agent])
|
||||
.expect("mixed export")
|
||||
.into_json()
|
||||
.expect("serialize mixed export");
|
||||
@@ -360,21 +375,16 @@ impl TokenRefresher for ManualRefresher {
|
||||
}
|
||||
}
|
||||
|
||||
struct ManualVerifier;
|
||||
struct RejectingManualVerifier;
|
||||
|
||||
#[async_trait]
|
||||
impl CodexAccountIdentityVerifier for ManualVerifier {
|
||||
impl CodexAccountIdentityVerifier for RejectingManualVerifier {
|
||||
async fn verify(
|
||||
&self,
|
||||
secret: &provider_openai::credential::CodexOAuthSecret,
|
||||
_secret: &provider_openai::credential::CodexOAuthSecret,
|
||||
_expectation: &CodexIdentityExpectation,
|
||||
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
|
||||
if secret.access_token.expose_secret() != "refreshed-access" {
|
||||
return Err(CodexIdentityVerificationError::Rejected);
|
||||
}
|
||||
Ok(CodexIdentityVerification::Complete(profile(
|
||||
"chatgpt-acct_manual_refresh",
|
||||
)))
|
||||
Err(CodexIdentityVerificationError::Rejected)
|
||||
}
|
||||
|
||||
async fn verify_authorization(
|
||||
@@ -473,7 +483,7 @@ async fn manual_refresh_fixture(
|
||||
});
|
||||
let service = CodexCredentialAdminService::new(
|
||||
refresher.clone(),
|
||||
Arc::new(ManualVerifier),
|
||||
Arc::new(RejectingManualVerifier),
|
||||
leases.clone(),
|
||||
runtime_policy(),
|
||||
);
|
||||
@@ -489,7 +499,7 @@ fn refreshed_tokens() -> TokenPair {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_refresh_prepares_revision_fenced_rotation_without_store_mutation() {
|
||||
async fn manual_refresh_prepares_rotation_without_identity_verification() {
|
||||
let (store, refresher, leases, service) =
|
||||
manual_refresh_fixture(Ok(refreshed_tokens()), true).await;
|
||||
let account_id = ProviderAccountId::new("acct_manual_refresh").expect("account id");
|
||||
@@ -569,35 +579,16 @@ async fn manual_refresh_missing_lease_fails_before_exchange() {
|
||||
);
|
||||
}
|
||||
|
||||
struct ImportVerifier;
|
||||
struct RejectingImportVerifier;
|
||||
|
||||
#[async_trait]
|
||||
impl CodexAccountIdentityVerifier for ImportVerifier {
|
||||
impl CodexAccountIdentityVerifier for RejectingImportVerifier {
|
||||
async fn verify(
|
||||
&self,
|
||||
secret: &provider_openai::credential::CodexOAuthSecret,
|
||||
expectation: &CodexIdentityExpectation,
|
||||
_secret: &provider_openai::credential::CodexOAuthSecret,
|
||||
_expectation: &CodexIdentityExpectation,
|
||||
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
|
||||
let suffix = secret
|
||||
.access_token
|
||||
.expose_secret()
|
||||
.strip_prefix("token-")
|
||||
.ok_or(CodexIdentityVerificationError::Rejected)?;
|
||||
let mut profile = profile(&format!("chatgpt-{suffix}"));
|
||||
if let Some(user) = suffix.strip_prefix("shared-") {
|
||||
profile.chatgpt_account_id = "chatgpt-shared".to_owned();
|
||||
profile.chatgpt_user_id = format!("user-{user}");
|
||||
}
|
||||
if expectation
|
||||
.chatgpt_account_id()
|
||||
.is_some_and(|expected| expected != profile.chatgpt_account_id)
|
||||
|| expectation
|
||||
.chatgpt_user_id()
|
||||
.is_some_and(|expected| expected != profile.chatgpt_user_id)
|
||||
{
|
||||
return Err(CodexIdentityVerificationError::Rejected);
|
||||
}
|
||||
Ok(CodexIdentityVerification::Complete(profile))
|
||||
Err(CodexIdentityVerificationError::Rejected)
|
||||
}
|
||||
|
||||
async fn verify_authorization(
|
||||
@@ -616,7 +607,7 @@ pub(super) fn import_service(
|
||||
) -> provider_openai::credential::CodexCredentialAdminService {
|
||||
CodexCredentialAdminService::new(
|
||||
refresher,
|
||||
Arc::new(ImportVerifier),
|
||||
Arc::new(RejectingImportVerifier),
|
||||
Arc::new(ManualLeases {
|
||||
requests: Mutex::new(Vec::new()),
|
||||
drops: Arc::new(AtomicUsize::new(0)),
|
||||
@@ -634,19 +625,20 @@ pub(super) fn unused_import_refresher() -> Arc<ManualRefresher> {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oauth_import_uses_only_tokens_and_verified_identity() {
|
||||
async fn oauth_import_uses_token_identity_and_source_display_metadata() {
|
||||
let refresher = unused_import_refresher();
|
||||
let access_token = unverified_import_access_token("chatgpt-cpr", "user-token-cpr");
|
||||
let prepared = import_service(refresher.clone())
|
||||
.prepare_import_document(serde_json::json!({
|
||||
"sourceFormat": "cpr",
|
||||
"accounts": [{
|
||||
"id": "acct_cpr_import",
|
||||
"email": "cpr@example.com",
|
||||
"accountId": "chatgpt-cpr",
|
||||
"userId": "user-chatgpt-cpr",
|
||||
"accountId": "source-account-cpr",
|
||||
"userId": "source-user-cpr",
|
||||
"label": "CPR account",
|
||||
"planType": "pro",
|
||||
"token": "Bearer token-cpr",
|
||||
"token": format!("Bearer {access_token}"),
|
||||
"refreshToken": "refresh-cpr",
|
||||
"accessTokenExpiresAt": "2100-01-01T00:00:00+00:00",
|
||||
"status": "disabled",
|
||||
@@ -662,6 +654,10 @@ async fn oauth_import_uses_only_tokens_and_verified_identity() {
|
||||
assert!(account.account.id().as_str().starts_with("acct_"));
|
||||
assert!(account.account.enabled());
|
||||
assert_eq!(account.account.upstream_account_id(), Some("chatgpt-cpr"));
|
||||
assert_eq!(account.account.upstream_user_id(), "user-token-cpr");
|
||||
assert_eq!(account.account.name(), "CPR account");
|
||||
assert_eq!(account.account.email(), Some("cpr@example.com"));
|
||||
assert_eq!(account.account.plan_type(), Some("pro"));
|
||||
let runtime = CodexCredentialCodec::decode(&account.credential).expect("credential");
|
||||
assert_eq!(
|
||||
runtime
|
||||
@@ -670,13 +666,64 @@ async fn oauth_import_uses_only_tokens_and_verified_identity() {
|
||||
.expect("OAuth credential")
|
||||
.access_token
|
||||
.expose_secret(),
|
||||
"token-cpr"
|
||||
access_token
|
||||
);
|
||||
assert!(refresher.seen.lock().expect("seen tokens").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_token_only_import_persists_exchanged_tokens_without_identity_verification() {
|
||||
let refreshed_access =
|
||||
unverified_import_access_token("chatgpt-refreshed-import", "user-refreshed-import");
|
||||
let expected_access = refreshed_access.clone();
|
||||
let refresher = Arc::new(ManualRefresher {
|
||||
outcome: Mutex::new(Some(Ok(TokenPair {
|
||||
access_token: refreshed_access,
|
||||
refresh_token: Some("rotated-import-refresh".to_owned()),
|
||||
expires_in: Duration::from_secs(3_600),
|
||||
}))),
|
||||
seen: Mutex::new(Vec::new()),
|
||||
});
|
||||
|
||||
let prepared = import_service(Arc::clone(&refresher))
|
||||
.prepare_import_document(serde_json::json!({
|
||||
"refreshToken": "refresh-only"
|
||||
}))
|
||||
.await
|
||||
.expect("refresh-token-only import");
|
||||
|
||||
assert_eq!(prepared.accounts().len(), 1);
|
||||
let account = &prepared.accounts()[0];
|
||||
assert_eq!(
|
||||
account.account.upstream_account_id(),
|
||||
Some("chatgpt-refreshed-import")
|
||||
);
|
||||
assert_eq!(account.account.upstream_user_id(), "user-refreshed-import");
|
||||
let runtime = CodexCredentialCodec::decode(&account.credential).expect("credential");
|
||||
let oauth = runtime.authentication.oauth().expect("OAuth credential");
|
||||
assert_eq!(oauth.access_token.expose_secret(), expected_access);
|
||||
assert_eq!(
|
||||
oauth
|
||||
.refresh_token
|
||||
.as_ref()
|
||||
.expect("rotated RT")
|
||||
.expose_secret(),
|
||||
"rotated-import-refresh"
|
||||
);
|
||||
assert_eq!(
|
||||
refresher
|
||||
.seen
|
||||
.lock()
|
||||
.expect("seen refresh tokens")
|
||||
.as_slice(),
|
||||
["refresh-only"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cpr_batch_allows_distinct_users_in_the_same_workspace() {
|
||||
let shared_a = unverified_import_access_token("chatgpt-shared", "user-a");
|
||||
let shared_b = unverified_import_access_token("chatgpt-shared", "user-b");
|
||||
let prepared = import_service(unused_import_refresher())
|
||||
.prepare_import_document(serde_json::json!({
|
||||
"sourceFormat": "cpr",
|
||||
@@ -684,12 +731,12 @@ async fn cpr_batch_allows_distinct_users_in_the_same_workspace() {
|
||||
"id": "acct_shared_a",
|
||||
"accountId": "chatgpt-shared",
|
||||
"userId": "user-a",
|
||||
"token": "token-shared-a"
|
||||
"token": shared_a
|
||||
}, {
|
||||
"id": "acct_shared_b",
|
||||
"accountId": "chatgpt-shared",
|
||||
"userId": "user-b",
|
||||
"token": "token-shared-b"
|
||||
"token": shared_b
|
||||
}]
|
||||
}))
|
||||
.await
|
||||
@@ -703,6 +750,7 @@ async fn cpr_batch_allows_distinct_users_in_the_same_workspace() {
|
||||
.all(|account| { account.account.upstream_account_id() == Some("chatgpt-shared") })
|
||||
);
|
||||
|
||||
let duplicate_token = unverified_import_access_token("chatgpt-shared", "user-a");
|
||||
let duplicate = import_service(unused_import_refresher())
|
||||
.prepare_import_document(serde_json::json!({
|
||||
"sourceFormat": "cpr",
|
||||
@@ -710,12 +758,12 @@ async fn cpr_batch_allows_distinct_users_in_the_same_workspace() {
|
||||
"id": "acct_duplicate_a",
|
||||
"accountId": "chatgpt-shared",
|
||||
"userId": "user-a",
|
||||
"token": "token-shared-a"
|
||||
"token": duplicate_token.clone()
|
||||
}, {
|
||||
"id": "acct_duplicate_b",
|
||||
"accountId": "chatgpt-shared",
|
||||
"userId": "user-a",
|
||||
"token": "token-shared-a"
|
||||
"token": duplicate_token
|
||||
}]
|
||||
}))
|
||||
.await
|
||||
@@ -724,7 +772,8 @@ async fn cpr_batch_allows_distinct_users_in_the_same_workspace() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oauth_import_extracts_tokens_without_parsing_source_projections() {
|
||||
async fn oauth_import_extracts_tokens_and_preserves_source_display_metadata() {
|
||||
let bundle_token = unverified_import_access_token("chatgpt-token-bundle", "user-token-bundle");
|
||||
let bundle = import_service(unused_import_refresher())
|
||||
.prepare_import_document(serde_json::json!({
|
||||
"exported_at": "2026-07-03T15:46:38.717Z",
|
||||
@@ -734,10 +783,12 @@ async fn oauth_import_extracts_tokens_without_parsing_source_projections() {
|
||||
"platform": "openai",
|
||||
"type": "oauth",
|
||||
"credentials": {
|
||||
"access_token": "token-bundle",
|
||||
"access_token": bundle_token,
|
||||
"refresh_token": "refresh-bundle",
|
||||
"chatgpt_account_id": "chatgpt-bundle",
|
||||
"chatgpt_user_id": "user-chatgpt-bundle"
|
||||
"chatgpt_account_id": "source-bundle",
|
||||
"chatgpt_user_id": "source-user-bundle",
|
||||
"email": "bundle-source@example.com",
|
||||
"plan_type": "team"
|
||||
},
|
||||
"concurrency": 3,
|
||||
"priority": 50
|
||||
@@ -745,48 +796,62 @@ async fn oauth_import_extracts_tokens_without_parsing_source_projections() {
|
||||
}))
|
||||
.await
|
||||
.expect("credential bundle import");
|
||||
let auth_document_token =
|
||||
unverified_import_access_token("chatgpt-token-auth-document", "user-token-auth-document");
|
||||
let auth_document = import_service(unused_import_refresher())
|
||||
.prepare_import_document(serde_json::json!({
|
||||
"accounts": [{
|
||||
"type": "openai",
|
||||
"access_token": "token-auth-document",
|
||||
"access_token": auth_document_token,
|
||||
"refresh_token": "refresh-auth-document",
|
||||
"chatgpt_account_id": "chatgpt-auth-document",
|
||||
"chatgpt_user_id": "user-chatgpt-auth-document",
|
||||
"chatgpt_account_id": "source-auth-document",
|
||||
"chatgpt_user_id": "source-user-auth-document",
|
||||
"email": "auth-document@example.com",
|
||||
"label": "Auth document"
|
||||
"label": "Auth document",
|
||||
"planType": "plus"
|
||||
}]
|
||||
}))
|
||||
.await
|
||||
.expect("auth document import");
|
||||
|
||||
for (prepared, expected) in [
|
||||
(&bundle, "chatgpt-bundle"),
|
||||
(&auth_document, "chatgpt-auth-document"),
|
||||
] {
|
||||
assert_eq!(prepared.accounts().len(), 1);
|
||||
assert_eq!(
|
||||
prepared.accounts()[0].account.upstream_account_id(),
|
||||
Some(expected)
|
||||
);
|
||||
assert!(
|
||||
prepared.accounts()[0]
|
||||
.account
|
||||
.id()
|
||||
.as_str()
|
||||
.starts_with("acct_")
|
||||
);
|
||||
}
|
||||
let bundle_account = &bundle.accounts()[0].account;
|
||||
assert_eq!(bundle.accounts().len(), 1);
|
||||
assert_eq!(
|
||||
bundle_account.upstream_account_id(),
|
||||
Some("chatgpt-token-bundle")
|
||||
);
|
||||
assert_eq!(bundle_account.upstream_user_id(), "user-token-bundle");
|
||||
assert_eq!(bundle_account.name(), "bundle@example.com");
|
||||
assert_eq!(bundle_account.email(), Some("bundle-source@example.com"));
|
||||
assert_eq!(bundle_account.plan_type(), Some("team"));
|
||||
|
||||
let auth_document_account = &auth_document.accounts()[0].account;
|
||||
assert_eq!(auth_document.accounts().len(), 1);
|
||||
assert_eq!(
|
||||
auth_document_account.upstream_account_id(),
|
||||
Some("chatgpt-token-auth-document")
|
||||
);
|
||||
assert_eq!(
|
||||
auth_document_account.upstream_user_id(),
|
||||
"user-token-auth-document"
|
||||
);
|
||||
assert_eq!(auth_document_account.name(), "Auth document");
|
||||
assert_eq!(
|
||||
auth_document_account.email(),
|
||||
Some("auth-document@example.com")
|
||||
);
|
||||
assert_eq!(auth_document_account.plan_type(), Some("plus"));
|
||||
assert!(!format!("{bundle:?} {auth_document:?}").contains("refresh-bundle"));
|
||||
assert!(!format!("{bundle:?} {auth_document:?}").contains("refresh-auth-document"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cliproxyapi_codex_auth_file_is_recognized_as_an_openai_auth_document() {
|
||||
let access_token = unverified_import_access_token("chatgpt-token-cpa", "user-token-cpa");
|
||||
let prepared = import_service(unused_import_refresher())
|
||||
.prepare_import_document(serde_json::json!({
|
||||
"type": "codex",
|
||||
"access_token": "token-cpa",
|
||||
"access_token": access_token,
|
||||
"refresh_token": "refresh-cpa",
|
||||
"id_token": "id-cpa",
|
||||
"account_id": "chatgpt-cpa",
|
||||
@@ -798,7 +863,8 @@ async fn cliproxyapi_codex_auth_file_is_recognized_as_an_openai_auth_document()
|
||||
|
||||
assert_eq!(prepared.accounts().len(), 1);
|
||||
let account = &prepared.accounts()[0].account;
|
||||
assert_eq!(account.upstream_account_id(), Some("chatgpt-cpa"));
|
||||
assert_eq!(account.email(), Some("chatgpt-cpa@example.com"));
|
||||
assert_eq!(account.upstream_account_id(), Some("chatgpt-token-cpa"));
|
||||
assert_eq!(account.upstream_user_id(), "user-token-cpa");
|
||||
assert_eq!(account.email(), Some("cpa@example.com"));
|
||||
assert_eq!(account.authentication_kind(), "oauth");
|
||||
}
|
||||
|
||||
@@ -688,6 +688,43 @@ fn credential_expired_failure_marks_unified_account_expired() {
|
||||
.availability(),
|
||||
AccountAvailability::Expired
|
||||
);
|
||||
assert_eq!(
|
||||
store.last_error_message("acct_primary").as_deref(),
|
||||
Some("upstream_credential_expired")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_expired_failure_keeps_expired_oauth_for_bounded_refresh_recovery() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let mut expired_profile = profile("chatgpt-acct_primary");
|
||||
expired_profile.access_token_expires_at =
|
||||
Some(chrono::Utc::now() - chrono::Duration::minutes(1));
|
||||
block_on(store.seed_oauth_credential(ImportCodexOAuthCredential {
|
||||
account_id: "acct_primary".to_owned(),
|
||||
name: "acct_primary".to_owned(),
|
||||
secret: secret("at-primary"),
|
||||
verified_account: expired_profile,
|
||||
next_refresh_at: Some(chrono::Utc::now() + chrono::Duration::minutes(10)),
|
||||
enabled: true,
|
||||
}));
|
||||
let account = store.account("acct_primary").expect("account");
|
||||
let selector = selector(&store, Arc::new(TestLeaseCoordinator::default()));
|
||||
|
||||
block_on(selector.record_failure(
|
||||
&account,
|
||||
CodexAccountFailure::CredentialExpired,
|
||||
Some("token_expired".to_owned()),
|
||||
))
|
||||
.expect("record credential expiry");
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.account("acct_primary")
|
||||
.expect("account retained for refresh")
|
||||
.availability(),
|
||||
AccountAvailability::Ready
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::{
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
@@ -158,19 +164,30 @@ impl AuthorizationCodeExchanger for Exchanger {
|
||||
id_token: None,
|
||||
},
|
||||
id_token: SecretString::from("oauth-id-token"),
|
||||
expires_in: Duration::from_secs(3_600),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct Verifier;
|
||||
#[derive(Default)]
|
||||
struct RejectingVerifier {
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
|
||||
impl RejectingVerifier {
|
||||
fn call_count(&self) -> usize {
|
||||
self.calls.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CodexAccountIdentityVerifier for Verifier {
|
||||
impl CodexAccountIdentityVerifier for RejectingVerifier {
|
||||
async fn verify(
|
||||
&self,
|
||||
_secret: &CodexOAuthSecret,
|
||||
_expectation: &CodexIdentityExpectation,
|
||||
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
|
||||
self.calls.fetch_add(1, Ordering::Relaxed);
|
||||
Err(CodexIdentityVerificationError::Rejected)
|
||||
}
|
||||
|
||||
@@ -181,18 +198,17 @@ impl CodexAccountIdentityVerifier for Verifier {
|
||||
expected_nonce: &SecretString,
|
||||
_expectation: &CodexIdentityExpectation,
|
||||
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
|
||||
if id_token.expose_secret() != "oauth-id-token" || expected_nonce.expose_secret().len() < 16
|
||||
{
|
||||
return Err(CodexIdentityVerificationError::Rejected);
|
||||
}
|
||||
Ok(CodexIdentityVerification::Complete(profile(
|
||||
"chatgpt-oauth",
|
||||
)))
|
||||
let _ = (id_token, expected_nonce);
|
||||
self.calls.fetch_add(1, Ordering::Relaxed);
|
||||
Err(CodexIdentityVerificationError::Rejected)
|
||||
}
|
||||
}
|
||||
|
||||
fn service() -> CodexOAuthAdminService {
|
||||
service_with_store(Arc::new(MemoryAccountStore::default()), Arc::new(Verifier))
|
||||
service_with_store(
|
||||
Arc::new(MemoryAccountStore::default()),
|
||||
Arc::new(RejectingVerifier::default()),
|
||||
)
|
||||
}
|
||||
|
||||
fn service_with_store(
|
||||
@@ -283,8 +299,10 @@ async fn start_authorization_uses_pkce_nonce_and_fixed_official_redirect() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completion_returns_verified_core_account_without_writing_store() {
|
||||
let service = service();
|
||||
async fn completion_prepares_core_account_without_identity_verification() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let verifier = Arc::new(RejectingVerifier::default());
|
||||
let service = service_with_store(store, verifier.clone());
|
||||
let started = started(&service).await;
|
||||
let url = Url::parse(&started.authorization_url).expect("authorization URL");
|
||||
let state = url
|
||||
@@ -322,10 +340,11 @@ async fn completion_returns_verified_core_account_without_writing_store() {
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some("oauth-id-token")
|
||||
);
|
||||
assert_eq!(verifier.call_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completion_accepts_unrelated_oauth_callback_parameters() {
|
||||
async fn completion_accepts_parseable_non_localhost_callback_url() {
|
||||
let service = service();
|
||||
let started = started(&service).await;
|
||||
let state = Url::parse(&started.authorization_url)
|
||||
@@ -339,11 +358,11 @@ async fn completion_accepts_unrelated_oauth_callback_parameters() {
|
||||
owner_ref: owner_ref(),
|
||||
flow_id: started.flow_id,
|
||||
callback_url: SecretString::from(format!(
|
||||
"http://localhost:1455/auth/callback?code=authorization-code&state={state}&scope=openid%20profile&iss=https%3A%2F%2Fauth.openai.com"
|
||||
"https://relay-next.aivify.cc/relay/openai/callback?code=authorization-code&state={state}&scope=openid%20profile&iss=https%3A%2F%2Fauth.openai.com"
|
||||
)),
|
||||
})
|
||||
.await
|
||||
.expect("complete OAuth with standard callback parameters");
|
||||
.expect("complete OAuth from a relayed callback URL");
|
||||
|
||||
assert!(matches!(
|
||||
prepared.credential,
|
||||
@@ -399,7 +418,16 @@ async fn completion_rejects_repeated_state_callback_parameter() {
|
||||
assert_eq!(error, CodexOAuthAdminError::CallbackRejected);
|
||||
}
|
||||
|
||||
struct UnavailableVerifier;
|
||||
#[derive(Default)]
|
||||
struct UnavailableVerifier {
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
|
||||
impl UnavailableVerifier {
|
||||
fn call_count(&self) -> usize {
|
||||
self.calls.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CodexAccountIdentityVerifier for UnavailableVerifier {
|
||||
@@ -408,6 +436,7 @@ impl CodexAccountIdentityVerifier for UnavailableVerifier {
|
||||
_: &CodexOAuthSecret,
|
||||
_: &CodexIdentityExpectation,
|
||||
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
|
||||
self.calls.fetch_add(1, Ordering::Relaxed);
|
||||
Err(CodexIdentityVerificationError::Unavailable)
|
||||
}
|
||||
|
||||
@@ -418,14 +447,16 @@ impl CodexAccountIdentityVerifier for UnavailableVerifier {
|
||||
_: &SecretString,
|
||||
_: &CodexIdentityExpectation,
|
||||
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
|
||||
self.calls.fetch_add(1, Ordering::Relaxed);
|
||||
Err(CodexIdentityVerificationError::Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completion_classifies_unavailable_identity_verification_as_upstream_unavailable() {
|
||||
async fn completion_ignores_unavailable_identity_verifier() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let service = service_with_store(store, Arc::new(UnavailableVerifier));
|
||||
let verifier = Arc::new(UnavailableVerifier::default());
|
||||
let service = service_with_store(store, verifier.clone());
|
||||
let started = started(&service).await;
|
||||
let state = Url::parse(&started.authorization_url)
|
||||
.expect("authorization URL")
|
||||
@@ -433,7 +464,7 @@ async fn completion_classifies_unavailable_identity_verification_as_upstream_una
|
||||
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
|
||||
.expect("state");
|
||||
|
||||
let error = service
|
||||
let prepared = service
|
||||
.complete_authorization(CompleteCodexOAuthAuthorization {
|
||||
owner_ref: owner_ref(),
|
||||
flow_id: started.flow_id,
|
||||
@@ -442,9 +473,13 @@ async fn completion_classifies_unavailable_identity_verification_as_upstream_una
|
||||
)),
|
||||
})
|
||||
.await
|
||||
.expect_err("identity verification unavailable");
|
||||
.expect("complete OAuth without identity verification");
|
||||
|
||||
assert_eq!(error, CodexOAuthAdminError::UpstreamUnavailable);
|
||||
assert!(matches!(
|
||||
prepared.credential,
|
||||
CompletedCodexOAuthCredential::Create(_)
|
||||
));
|
||||
assert_eq!(verifier.call_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -523,7 +558,7 @@ fn oauth_command_debug_redacts_owner_flow_and_callback() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reauthorization_uses_revision_advanced_after_start_for_prepared_rotation() {
|
||||
async fn reauthorization_preserves_existing_principal_without_identity_verification() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
store
|
||||
.seed_oauth_credential(ImportCodexOAuthCredential {
|
||||
@@ -538,13 +573,15 @@ async fn reauthorization_uses_revision_advanced_after_start_for_prepared_rotatio
|
||||
let original_account = store
|
||||
.account("acct_oauth_reauth")
|
||||
.expect("original account");
|
||||
let original_installation_id = store
|
||||
let original_runtime = store
|
||||
.repository()
|
||||
.load_runtime_credential(&original_account)
|
||||
.await
|
||||
.expect("original credential")
|
||||
.installation_id;
|
||||
let service = service_with_store(store.clone(), Arc::new(Verifier));
|
||||
.expect("original credential");
|
||||
let original_installation_id = original_runtime.installation_id;
|
||||
let original_principal = original_runtime.principal;
|
||||
let verifier = Arc::new(RejectingVerifier::default());
|
||||
let service = service_with_store(store.clone(), verifier.clone());
|
||||
let started = service
|
||||
.start_authorization(StartCodexOAuthAuthorization {
|
||||
mutation: reauthorization_mutation("request-reauth", "acct_oauth_reauth"),
|
||||
@@ -590,6 +627,9 @@ async fn reauthorization_uses_revision_advanced_after_start_for_prepared_rotatio
|
||||
"oauth-access-token"
|
||||
);
|
||||
assert_eq!(runtime.installation_id, original_installation_id);
|
||||
assert_eq!(runtime.principal, original_principal);
|
||||
assert!(prepared.replacement_identity.is_none());
|
||||
assert_eq!(verifier.call_count(), 0);
|
||||
assert_eq!(
|
||||
store
|
||||
.account("acct_oauth_reauth")
|
||||
@@ -599,158 +639,3 @@ async fn reauthorization_uses_revision_advanced_after_start_for_prepared_rotatio
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
struct ReplacementIdentityVerifier;
|
||||
|
||||
#[async_trait]
|
||||
impl CodexAccountIdentityVerifier for ReplacementIdentityVerifier {
|
||||
async fn verify(
|
||||
&self,
|
||||
_: &CodexOAuthSecret,
|
||||
_: &CodexIdentityExpectation,
|
||||
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
|
||||
Err(CodexIdentityVerificationError::Rejected)
|
||||
}
|
||||
|
||||
async fn verify_authorization(
|
||||
&self,
|
||||
_: &CodexOAuthSecret,
|
||||
_: &SecretString,
|
||||
_: &SecretString,
|
||||
expectation: &CodexIdentityExpectation,
|
||||
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
|
||||
assert_eq!(expectation, &CodexIdentityExpectation::default());
|
||||
Ok(CodexIdentityVerification::Complete(profile(
|
||||
"chatgpt-different-owner",
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
struct RotatedPrincipalVerifier;
|
||||
|
||||
#[async_trait]
|
||||
impl CodexAccountIdentityVerifier for RotatedPrincipalVerifier {
|
||||
async fn verify(
|
||||
&self,
|
||||
_: &CodexOAuthSecret,
|
||||
_: &CodexIdentityExpectation,
|
||||
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
|
||||
Err(CodexIdentityVerificationError::Rejected)
|
||||
}
|
||||
|
||||
async fn verify_authorization(
|
||||
&self,
|
||||
_: &CodexOAuthSecret,
|
||||
_: &SecretString,
|
||||
_: &SecretString,
|
||||
_: &CodexIdentityExpectation,
|
||||
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
|
||||
let mut verified = profile("chatgpt-oauth");
|
||||
verified.oauth_subject = "rotated-oauth-subject".to_owned();
|
||||
verified.poid = None;
|
||||
Ok(CodexIdentityVerification::Complete(verified))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reauthorization_updates_changed_free_principal_for_same_account() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
store
|
||||
.seed_oauth_credential(ImportCodexOAuthCredential {
|
||||
account_id: "acct_oauth_principal".to_owned(),
|
||||
name: "reauthorize".to_owned(),
|
||||
secret: secret("old-oauth-access"),
|
||||
verified_account: profile("chatgpt-oauth"),
|
||||
next_refresh_at: Some(chrono::Utc::now() + chrono::Duration::minutes(30)),
|
||||
enabled: true,
|
||||
})
|
||||
.await;
|
||||
let service = service_with_store(store, Arc::new(RotatedPrincipalVerifier));
|
||||
let started = service
|
||||
.start_authorization(StartCodexOAuthAuthorization {
|
||||
mutation: reauthorization_mutation("request-rotated-principal", "acct_oauth_principal"),
|
||||
})
|
||||
.await
|
||||
.expect("start reauthorization");
|
||||
let state = Url::parse(&started.authorization_url)
|
||||
.expect("authorization URL")
|
||||
.query_pairs()
|
||||
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
|
||||
.expect("state");
|
||||
let prepared = service
|
||||
.complete_authorization(CompleteCodexOAuthAuthorization {
|
||||
owner_ref: owner_ref(),
|
||||
flow_id: started.flow_id,
|
||||
callback_url: SecretString::from(format!(
|
||||
"http://localhost:1455/auth/callback?code=authorization-code&state={state}"
|
||||
)),
|
||||
})
|
||||
.await
|
||||
.expect("complete reauthorization");
|
||||
let CompletedCodexOAuthCredential::Reauthorize(prepared) = prepared.credential else {
|
||||
panic!("expected prepared reauthorization");
|
||||
};
|
||||
let runtime =
|
||||
provider_openai::credential::CodexCredentialCodec::decode(prepared.credential.credential())
|
||||
.expect("prepared credential");
|
||||
let principal = runtime.principal.expect("OAuth principal");
|
||||
|
||||
assert_eq!(principal.oauth_subject, "rotated-oauth-subject");
|
||||
assert_eq!(principal.poid, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reauthorization_rebinds_target_record_to_authorized_identity() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
store
|
||||
.seed_oauth_credential(ImportCodexOAuthCredential {
|
||||
account_id: "acct_oauth_rebind".to_owned(),
|
||||
name: "reauthorize".to_owned(),
|
||||
secret: secret("old-oauth-access"),
|
||||
verified_account: profile("chatgpt-oauth"),
|
||||
next_refresh_at: Some(chrono::Utc::now() + chrono::Duration::minutes(30)),
|
||||
enabled: true,
|
||||
})
|
||||
.await;
|
||||
let service = service_with_store(store, Arc::new(ReplacementIdentityVerifier));
|
||||
let started = service
|
||||
.start_authorization(StartCodexOAuthAuthorization {
|
||||
mutation: reauthorization_mutation("request-rebind", "acct_oauth_rebind"),
|
||||
})
|
||||
.await
|
||||
.expect("start reauthorization");
|
||||
let state = Url::parse(&started.authorization_url)
|
||||
.expect("authorization URL")
|
||||
.query_pairs()
|
||||
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
|
||||
.expect("state");
|
||||
let completed = service
|
||||
.complete_authorization(CompleteCodexOAuthAuthorization {
|
||||
owner_ref: owner_ref(),
|
||||
flow_id: started.flow_id,
|
||||
callback_url: SecretString::from(format!(
|
||||
"http://localhost:1455/auth/callback?code=authorization-code&state={state}"
|
||||
)),
|
||||
})
|
||||
.await
|
||||
.expect("complete identity replacement");
|
||||
let CompletedCodexOAuthCredential::Reauthorize(prepared) = completed.credential else {
|
||||
panic!("expected prepared reauthorization");
|
||||
};
|
||||
assert_eq!(
|
||||
prepared.credential.account_id().as_str(),
|
||||
"acct_oauth_rebind"
|
||||
);
|
||||
let replacement = prepared
|
||||
.replacement_identity
|
||||
.as_ref()
|
||||
.expect("replacement identity");
|
||||
assert_eq!(
|
||||
replacement.upstream_account_id(),
|
||||
Some("chatgpt-different-owner")
|
||||
);
|
||||
assert_eq!(
|
||||
replacement.upstream_user_id(),
|
||||
"user-chatgpt-different-owner"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ use provider_openai::credential::{
|
||||
CodexQuotaWindowKind, ImportCodexOAuthCredential,
|
||||
};
|
||||
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
use wiremock::matchers::{header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
@@ -424,14 +424,182 @@ async fn passive_rate_limit_headers_replace_stale_secondary_window() {
|
||||
.await
|
||||
.expect("read quota")
|
||||
.expect("quota snapshot");
|
||||
let raw = store
|
||||
.quota_json("acct_passive_quota_snapshot")
|
||||
.expect("stored passive quota");
|
||||
let monthly = snapshot
|
||||
.windows()
|
||||
.iter()
|
||||
.filter(|window| window.kind() == CodexQuotaWindowKind::Monthly)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(snapshot.windows().len(), 1);
|
||||
assert_eq!(monthly.len(), 1);
|
||||
assert_eq!(monthly[0].used_percent(), Some(92.0));
|
||||
assert!(
|
||||
raw.pointer("/rate_limit/secondary_window").is_none(),
|
||||
"a normal response without a secondary window must delete stale raw state"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_quota_refresh_drops_empty_secondary_window() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_manual_secondary_placeholder";
|
||||
create_account(&store, account_id).await;
|
||||
let account = store.account(account_id).expect("created account");
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.and(header(
|
||||
"authorization",
|
||||
format!("Bearer token-{account_id}"),
|
||||
))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"rate_limit": {
|
||||
"allowed": false,
|
||||
"limit_reached": true,
|
||||
"primary_window": {
|
||||
"used_percent": 100,
|
||||
"reset_at": 1_900_000_000,
|
||||
"limit_window_seconds": 2_592_000
|
||||
},
|
||||
"secondary_window": {
|
||||
"used_percent": 0,
|
||||
"limit_reached": false
|
||||
}
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = quota_service_with_base_url(
|
||||
&store,
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("client"),
|
||||
server.uri(),
|
||||
);
|
||||
|
||||
let snapshot = service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("manual quota refresh");
|
||||
|
||||
assert_eq!(snapshot.windows().len(), 1);
|
||||
assert_eq!(snapshot.windows()[0].used_percent(), Some(100.0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_quota_refresh_drops_null_secondary_window_during_confirmed_exhaustion() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_manual_null_secondary_placeholder";
|
||||
create_account(&store, account_id).await;
|
||||
let account = store.account(account_id).expect("created account");
|
||||
let reset_at = 1_900_000_000;
|
||||
store
|
||||
.compare_and_swap_quota(QuotaObservation {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
quota: Some(OpaqueProviderData::new(
|
||||
json!({
|
||||
"rate_limit": {
|
||||
"allowed": false,
|
||||
"limit_reached": true,
|
||||
"primary_window": {
|
||||
"used_percent": 100,
|
||||
"reset_at": reset_at,
|
||||
"limit_window_seconds": 2_592_000
|
||||
}
|
||||
}
|
||||
})
|
||||
.as_object()
|
||||
.expect("quota object")
|
||||
.clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
limit_reached: None,
|
||||
})
|
||||
.await
|
||||
.expect("seed confirmed exhaustion");
|
||||
store
|
||||
.apply_state_change(AccountStateChange {
|
||||
message: None,
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
availability: AccountAvailability::QuotaExhausted,
|
||||
observed_at: SystemTime::now(),
|
||||
})
|
||||
.await
|
||||
.expect("mark account exhausted");
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.and(header(
|
||||
"authorization",
|
||||
format!("Bearer token-{account_id}"),
|
||||
))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"primary_window": {
|
||||
"used_percent": 86,
|
||||
"reset_at": reset_at,
|
||||
"reset_after_seconds": 2_133_000,
|
||||
"limit_window_seconds": 2_592_000
|
||||
},
|
||||
"secondary_window": null
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = quota_service_with_base_url(
|
||||
&store,
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("client"),
|
||||
server.uri(),
|
||||
);
|
||||
|
||||
let snapshot = service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("manual quota refresh");
|
||||
let persisted = store
|
||||
.get_quotas(&[account.id().clone()])
|
||||
.await
|
||||
.expect("read persisted quota")
|
||||
.into_iter()
|
||||
.next()
|
||||
.and_then(|observation| observation.quota)
|
||||
.map(OpaqueProviderData::into_inner)
|
||||
.map(Value::Object)
|
||||
.expect("persisted quota");
|
||||
|
||||
assert_eq!(snapshot.windows().len(), 1);
|
||||
assert_eq!(snapshot.windows()[0].used_percent(), Some(100.0));
|
||||
assert_eq!(
|
||||
snapshot.windows()[0]
|
||||
.reset_at()
|
||||
.map(|value| value.timestamp()),
|
||||
Some(reset_at)
|
||||
);
|
||||
assert!(snapshot.fact().exhausted());
|
||||
assert_eq!(
|
||||
store
|
||||
.account(account_id)
|
||||
.expect("account after refresh")
|
||||
.availability(),
|
||||
AccountAvailability::QuotaExhausted
|
||||
);
|
||||
assert!(
|
||||
persisted.pointer("/rate_limit/secondary_window").is_none(),
|
||||
"a null secondary window must not be projected as 100%"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -719,6 +887,183 @@ async fn periodic_quota_synchronization_does_not_downgrade_exhaustion_on_rate_li
|
||||
assert_eq!(account.availability(), AccountAvailability::QuotaExhausted);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn periodic_quota_refresh_drops_empty_secondary_window() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_periodic_secondary_placeholder";
|
||||
create_account(&store, account_id).await;
|
||||
let account = store.account(account_id).expect("created account");
|
||||
store
|
||||
.apply_state_change(AccountStateChange {
|
||||
message: None,
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
availability: AccountAvailability::QuotaExhausted,
|
||||
observed_at: SystemTime::now(),
|
||||
})
|
||||
.await
|
||||
.expect("mark account exhausted");
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"primary_window": {
|
||||
"used_percent": 99,
|
||||
"reset_at": 1_900_000_000,
|
||||
"limit_window_seconds": 2_592_000
|
||||
},
|
||||
"secondary_window": {}
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = quota_service_with_base_url(
|
||||
&store,
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("client"),
|
||||
server.uri(),
|
||||
);
|
||||
|
||||
let summary = service.synchronize().await.expect("periodic quota refresh");
|
||||
let snapshot = service
|
||||
.read_account(account.id())
|
||||
.await
|
||||
.expect("read quota")
|
||||
.expect("quota snapshot");
|
||||
|
||||
assert_eq!(summary.exhausted, 1);
|
||||
assert_eq!(snapshot.windows().len(), 1);
|
||||
assert_eq!(snapshot.windows()[0].used_percent(), Some(100.0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn periodic_quota_refresh_drops_null_secondary_window() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_periodic_null_secondary_placeholder";
|
||||
create_account(&store, account_id).await;
|
||||
let account = store.account(account_id).expect("created account");
|
||||
store
|
||||
.apply_state_change(AccountStateChange {
|
||||
message: None,
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
availability: AccountAvailability::QuotaExhausted,
|
||||
observed_at: SystemTime::now(),
|
||||
})
|
||||
.await
|
||||
.expect("mark account exhausted");
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"primary_window": {
|
||||
"used_percent": 86,
|
||||
"reset_at": 1_900_000_000,
|
||||
"reset_after_seconds": 2_133_000,
|
||||
"limit_window_seconds": 2_592_000
|
||||
},
|
||||
"secondary_window": null
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = quota_service_with_base_url(
|
||||
&store,
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("client"),
|
||||
server.uri(),
|
||||
);
|
||||
|
||||
let summary = service.synchronize().await.expect("periodic quota refresh");
|
||||
let snapshot = service
|
||||
.read_account(account.id())
|
||||
.await
|
||||
.expect("read quota")
|
||||
.expect("quota snapshot");
|
||||
|
||||
assert_eq!(summary.exhausted, 1);
|
||||
assert_eq!(snapshot.windows().len(), 1);
|
||||
assert_eq!(snapshot.windows()[0].used_percent(), Some(100.0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn periodic_quota_refresh_keeps_nonempty_secondary_window_during_confirmed_exhaustion() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_periodic_real_secondary";
|
||||
create_account(&store, account_id).await;
|
||||
let account = store.account(account_id).expect("created account");
|
||||
store
|
||||
.apply_state_change(AccountStateChange {
|
||||
message: None,
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
availability: AccountAvailability::QuotaExhausted,
|
||||
observed_at: SystemTime::now(),
|
||||
})
|
||||
.await
|
||||
.expect("mark account exhausted");
|
||||
let secondary_reset_at = 1_900_604_800;
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"primary_window": {
|
||||
"used_percent": 99,
|
||||
"reset_at": 1_900_000_000,
|
||||
"limit_window_seconds": 2_592_000
|
||||
},
|
||||
"secondary_window": {
|
||||
"used_percent": 0,
|
||||
"reset_at": secondary_reset_at,
|
||||
"limit_window_seconds": 604_800
|
||||
}
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = quota_service_with_base_url(
|
||||
&store,
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("client"),
|
||||
server.uri(),
|
||||
);
|
||||
|
||||
let summary = service.synchronize().await.expect("periodic quota refresh");
|
||||
let snapshot = service
|
||||
.read_account(account.id())
|
||||
.await
|
||||
.expect("read quota")
|
||||
.expect("quota snapshot");
|
||||
|
||||
assert_eq!(summary.exhausted, 1);
|
||||
assert_eq!(snapshot.windows().len(), 2);
|
||||
assert!(snapshot.windows().iter().any(|window| {
|
||||
window.window_seconds() == Some(604_800)
|
||||
&& window.used_percent() == Some(100.0)
|
||||
&& window
|
||||
.reset_at()
|
||||
.is_some_and(|reset_at| reset_at.timestamp() == secondary_reset_at)
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn periodic_quota_synchronization_respects_retry_after_on_service_unavailable() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
|
||||
@@ -211,3 +211,47 @@ async fn expired_old_reset_with_fresh_allowed_snapshot_recovers_ready() {
|
||||
"old reset elapsed + fresh allowed snapshot must recover QuotaExhausted"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deactivated_workspace_quota_response_persists_the_upstream_reason() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_deactivated_workspace";
|
||||
create_account(&store, account_id).await;
|
||||
let account = store.account(account_id).expect("created account");
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.and(header(
|
||||
"authorization",
|
||||
format!("Bearer token-{account_id}"),
|
||||
))
|
||||
.respond_with(ResponseTemplate::new(402).set_body_json(json!({
|
||||
"detail": {
|
||||
"code": "deactivated_workspace",
|
||||
"message": "workspace disabled"
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = quota_service_with_base_url(
|
||||
&store,
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("client"),
|
||||
server.uri(),
|
||||
);
|
||||
|
||||
assert!(service.refresh_account(account.id()).await.is_err());
|
||||
assert_eq!(
|
||||
store
|
||||
.account(account_id)
|
||||
.expect("account after deactivation")
|
||||
.availability(),
|
||||
AccountAvailability::Banned
|
||||
);
|
||||
assert_eq!(
|
||||
store.last_error_message(account_id).as_deref(),
|
||||
Some("deactivated_workspace")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ use secrecy::{ExposeSecret, SecretString};
|
||||
|
||||
use crate::support::{MemoryAccountStore, profile, runtime_policy, secret};
|
||||
|
||||
const OAUTH_BACKOFF_ATTEMPTS: u32 = 5;
|
||||
|
||||
struct Refresher {
|
||||
outcomes: Mutex<VecDeque<Result<TokenPair, RefreshFailure>>>,
|
||||
seen: Mutex<Vec<String>>,
|
||||
@@ -377,7 +379,7 @@ async fn successful_refresh_uses_redis_lease_and_cas_rotates_plaintext_tokens()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refreshed_identity_rejection_revision_fences_account_as_invalid() {
|
||||
async fn refreshed_tokens_persist_when_identity_verification_rejects() {
|
||||
let (store, _, _) = setup(Ok(success_tokens()), true).await;
|
||||
let service = CodexCredentialRefreshService::new(
|
||||
store.repository(),
|
||||
@@ -395,19 +397,31 @@ async fn refreshed_identity_rejection_revision_fences_account_as_invalid() {
|
||||
|
||||
assert!(matches!(
|
||||
outcomes.as_slice(),
|
||||
[CodexCredentialRefreshOutcome::Invalidated { .. }]
|
||||
[CodexCredentialRefreshOutcome::Refreshed {
|
||||
account_id,
|
||||
credential_revision: 2
|
||||
}] if account_id == "acct_refresh"
|
||||
));
|
||||
let account = store.account("acct_refresh").expect("refreshed account");
|
||||
assert_eq!(account.availability(), AccountAvailability::Ready);
|
||||
let runtime = store
|
||||
.repository()
|
||||
.load_runtime_credential(&account)
|
||||
.await
|
||||
.expect("refreshed credential");
|
||||
assert_eq!(
|
||||
store
|
||||
.account("acct_refresh")
|
||||
.expect("invalid account")
|
||||
.availability(),
|
||||
AccountAvailability::Invalid
|
||||
runtime
|
||||
.authentication
|
||||
.oauth()
|
||||
.expect("OAuth credential")
|
||||
.access_token
|
||||
.expose_secret(),
|
||||
"new-access"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unavailable_signature_verification_persists_refresh_backoff() {
|
||||
async fn refreshed_tokens_persist_when_identity_verification_is_unavailable() {
|
||||
let (store, _, _) = setup(Ok(success_tokens()), true).await;
|
||||
let service = CodexCredentialRefreshService::new(
|
||||
store.repository(),
|
||||
@@ -426,11 +440,10 @@ async fn unavailable_signature_verification_persists_refresh_backoff() {
|
||||
|
||||
assert!(matches!(
|
||||
outcomes.as_slice(),
|
||||
[CodexCredentialRefreshOutcome::Transient { .. }]
|
||||
[CodexCredentialRefreshOutcome::Refreshed { .. }]
|
||||
));
|
||||
let account = store.account("acct_refresh").expect("deferred account");
|
||||
let account = store.account("acct_refresh").expect("refreshed account");
|
||||
assert_eq!(account.revision().get(), 2);
|
||||
// JWKS/签名边界短暂不可用是瞬态:账号保持可用、不被永久失效,仅推进退避重试。
|
||||
assert_eq!(account.availability(), AccountAvailability::Ready);
|
||||
assert!(
|
||||
account
|
||||
@@ -440,7 +453,7 @@ async fn unavailable_signature_verification_persists_refresh_backoff() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unavailable_usage_preserves_rotated_tokens_and_persists_backoff() {
|
||||
async fn refreshed_tokens_do_not_require_identity_completion() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let signed = super::identity::signed_identity_fixture().await;
|
||||
let mut account_profile = profile("account-signed");
|
||||
@@ -474,7 +487,7 @@ async fn unavailable_usage_preserves_rotated_tokens_and_persists_backoff() {
|
||||
|
||||
assert!(matches!(
|
||||
outcomes.as_slice(),
|
||||
[CodexCredentialRefreshOutcome::Transient { .. }]
|
||||
[CodexCredentialRefreshOutcome::Refreshed { .. }]
|
||||
));
|
||||
let account = store.account("acct_signed_only").expect("rotated account");
|
||||
assert_eq!(account.revision().get(), 2);
|
||||
@@ -553,6 +566,10 @@ async fn upstream_ban_marks_unified_account_banned() {
|
||||
.availability(),
|
||||
AccountAvailability::Banned
|
||||
);
|
||||
assert_eq!(
|
||||
store.last_error_message("acct_refresh").as_deref(),
|
||||
Some("account_banned")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -597,7 +614,7 @@ async fn transient_refresh_defers_without_invalidating_account() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_access_token_refreshes_even_when_next_refresh_is_deferred() {
|
||||
async fn expired_access_token_respects_persisted_refresh_backoff() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
seed_due_account(&store).await;
|
||||
let original = store.account("acct_refresh").expect("seeded account");
|
||||
@@ -615,16 +632,6 @@ async fn expired_access_token_refreshes_even_when_next_refresh_is_deferred() {
|
||||
})
|
||||
.await
|
||||
.expect("seed expired access token with deferred refresh");
|
||||
let deferred = store.account("acct_refresh").expect("deferred account");
|
||||
store
|
||||
.repository()
|
||||
.apply_state(
|
||||
&deferred,
|
||||
AccountAvailability::QuotaExhausted,
|
||||
SystemTime::now(),
|
||||
)
|
||||
.await
|
||||
.expect("mark account quota exhausted");
|
||||
let refresher = Arc::new(Refresher::new(Ok(success_tokens())));
|
||||
let service = CodexCredentialRefreshService::new(
|
||||
store.repository(),
|
||||
@@ -640,31 +647,19 @@ async fn expired_access_token_refreshes_even_when_next_refresh_is_deferred() {
|
||||
|
||||
let outcomes = service.refresh_due().await.expect("refresh due");
|
||||
|
||||
assert!(matches!(
|
||||
outcomes.as_slice(),
|
||||
[CodexCredentialRefreshOutcome::Refreshed {
|
||||
account_id,
|
||||
credential_revision: 3,
|
||||
}] if account_id == "acct_refresh"
|
||||
));
|
||||
assert_eq!(
|
||||
refresher.seen.lock().expect("seen tokens lock").as_slice(),
|
||||
["rt-old-access"]
|
||||
);
|
||||
let refreshed = store.account("acct_refresh").expect("refreshed account");
|
||||
assert_eq!(
|
||||
refreshed.availability(),
|
||||
AccountAvailability::QuotaExhausted
|
||||
);
|
||||
assert!(outcomes.is_empty());
|
||||
assert!(refresher.seen.lock().expect("seen tokens lock").is_empty());
|
||||
assert!(
|
||||
refreshed
|
||||
.access_token_expires_at()
|
||||
.is_some_and(|expires_at| expires_at > SystemTime::now())
|
||||
store
|
||||
.account("acct_refresh")
|
||||
.expect("deferred account")
|
||||
.next_refresh_at()
|
||||
.is_some_and(|next_refresh_at| next_refresh_at > SystemTime::now())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_access_token_refreshes_during_active_cooldown() {
|
||||
async fn expired_access_token_refreshes_when_persisted_refresh_is_due() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
seed_due_account(&store).await;
|
||||
let original = store.account("acct_refresh").expect("seeded account");
|
||||
@@ -678,7 +673,7 @@ async fn expired_access_token_refreshes_during_active_cooldown() {
|
||||
expected_credential_revision: original.revision().get(),
|
||||
secret: secret("old-access"),
|
||||
verified_account: expired_profile,
|
||||
next_refresh_at: Some(chrono::Utc::now() + chrono::Duration::hours(12)),
|
||||
next_refresh_at: Some(chrono::Utc::now() - chrono::Duration::seconds(1)),
|
||||
})
|
||||
.await
|
||||
.expect("seed expired access token with deferred refresh");
|
||||
@@ -825,6 +820,15 @@ async fn seed_due_account(store: &MemoryAccountStore) {
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn force_due_account(store: &Arc<MemoryAccountStore>) {
|
||||
let account = store.account("acct_refresh").expect("account to reset");
|
||||
store
|
||||
.repository()
|
||||
.defer_refresh(&account, SystemTime::now() - Duration::from_secs(1))
|
||||
.await
|
||||
.expect("reset next_refresh_at to the past");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_backoff_grows_exponentially_across_attempts() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
@@ -891,6 +895,175 @@ async fn refresh_backoff_grows_exponentially_across_attempts() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exhausted_oauth_backoff_continues_after_access_token_expiry() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let mut expired_profile = profile("chatgpt-acct_refresh");
|
||||
expired_profile.access_token_expires_at =
|
||||
Some(chrono::Utc::now() - chrono::Duration::minutes(1));
|
||||
store
|
||||
.seed_oauth_credential(ImportCodexOAuthCredential {
|
||||
account_id: "acct_refresh".to_owned(),
|
||||
name: "refresh".to_owned(),
|
||||
secret: secret("old-access"),
|
||||
verified_account: expired_profile,
|
||||
next_refresh_at: Some(chrono::Utc::now() - chrono::Duration::seconds(1)),
|
||||
enabled: true,
|
||||
})
|
||||
.await;
|
||||
let credential_state = Arc::new(CountingCredentialState::default());
|
||||
let state_port: Arc<dyn ProviderCredentialStatePort> = credential_state.clone();
|
||||
let refresher = Arc::new(Refresher::scripted([
|
||||
Err(RefreshFailure::Transport),
|
||||
Err(RefreshFailure::Transport),
|
||||
Err(RefreshFailure::Transport),
|
||||
Err(RefreshFailure::Transport),
|
||||
Err(RefreshFailure::Transport),
|
||||
Err(RefreshFailure::Transport),
|
||||
]));
|
||||
let service = CodexCredentialRefreshService::new(
|
||||
store.repository(),
|
||||
refresher.clone(),
|
||||
Arc::new(VerifiedIdentity),
|
||||
Arc::new(RefreshLeases {
|
||||
available: true,
|
||||
requests: Mutex::new(Vec::new()),
|
||||
}),
|
||||
state_port,
|
||||
runtime_policy(),
|
||||
);
|
||||
|
||||
for _ in 0..OAUTH_BACKOFF_ATTEMPTS {
|
||||
service.refresh_due().await.expect("backoff attempt");
|
||||
force_due_account(&store).await;
|
||||
}
|
||||
|
||||
let observed_at = SystemTime::now();
|
||||
service.refresh_due().await.expect("recovery attempt");
|
||||
let recovery_delay = store
|
||||
.account("acct_refresh")
|
||||
.expect("account after recovery defer")
|
||||
.next_refresh_at()
|
||||
.expect("recovery scheduled")
|
||||
.duration_since(observed_at)
|
||||
.expect("recovery is in the future");
|
||||
assert!(
|
||||
(Duration::from_secs(8 * 60)..=Duration::from_secs(12 * 60 + 1)).contains(&recovery_delay),
|
||||
"recovery delay should be about ten minutes, got {recovery_delay:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
credential_state.count("acct_refresh"),
|
||||
OAUTH_BACKOFF_ATTEMPTS + 1
|
||||
);
|
||||
assert_eq!(refresher.seen.lock().expect("seen tokens lock").len(), 6);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oauth_backoff_never_passes_an_unexpired_access_token_expiry() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let mut account_profile = profile("chatgpt-acct_refresh");
|
||||
account_profile.access_token_expires_at =
|
||||
Some(chrono::Utc::now() + chrono::Duration::seconds(30));
|
||||
let access_token_expires_at: SystemTime = account_profile
|
||||
.access_token_expires_at
|
||||
.expect("access expiry")
|
||||
.into();
|
||||
store
|
||||
.seed_oauth_credential(ImportCodexOAuthCredential {
|
||||
account_id: "acct_refresh".to_owned(),
|
||||
name: "refresh".to_owned(),
|
||||
secret: secret("old-access"),
|
||||
verified_account: account_profile,
|
||||
next_refresh_at: Some(chrono::Utc::now() - chrono::Duration::seconds(1)),
|
||||
enabled: true,
|
||||
})
|
||||
.await;
|
||||
let credential_state = Arc::new(CountingCredentialState::default());
|
||||
let state_port: Arc<dyn ProviderCredentialStatePort> = credential_state.clone();
|
||||
let service = CodexCredentialRefreshService::new(
|
||||
store.repository(),
|
||||
Arc::new(Refresher::scripted([
|
||||
Err(RefreshFailure::Transport),
|
||||
Err(RefreshFailure::Transport),
|
||||
Err(RefreshFailure::Transport),
|
||||
Err(RefreshFailure::Transport),
|
||||
Err(RefreshFailure::Transport),
|
||||
Err(RefreshFailure::Transport),
|
||||
])),
|
||||
Arc::new(VerifiedIdentity),
|
||||
Arc::new(RefreshLeases {
|
||||
available: true,
|
||||
requests: Mutex::new(Vec::new()),
|
||||
}),
|
||||
state_port,
|
||||
runtime_policy(),
|
||||
);
|
||||
|
||||
for attempt in 0..=OAUTH_BACKOFF_ATTEMPTS {
|
||||
service.refresh_due().await.expect("backoff attempt");
|
||||
if attempt < OAUTH_BACKOFF_ATTEMPTS {
|
||||
force_due_account(&store).await;
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.account("acct_refresh")
|
||||
.expect("account after capped retry")
|
||||
.next_refresh_at(),
|
||||
Some(access_token_expires_at)
|
||||
);
|
||||
assert_eq!(
|
||||
credential_state.count("acct_refresh"),
|
||||
OAUTH_BACKOFF_ATTEMPTS + 1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recovery_window_exhaustion_marks_account_expired_without_token_exchange() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let mut expired_profile = profile("chatgpt-acct_refresh");
|
||||
expired_profile.access_token_expires_at =
|
||||
Some(chrono::Utc::now() - chrono::Duration::hours(25));
|
||||
store
|
||||
.seed_oauth_credential(ImportCodexOAuthCredential {
|
||||
account_id: "acct_refresh".to_owned(),
|
||||
name: "refresh".to_owned(),
|
||||
secret: secret("old-access"),
|
||||
verified_account: expired_profile,
|
||||
next_refresh_at: Some(chrono::Utc::now() + chrono::Duration::hours(12)),
|
||||
enabled: true,
|
||||
})
|
||||
.await;
|
||||
let refresher = Arc::new(Refresher::new(Ok(success_tokens())));
|
||||
let service = CodexCredentialRefreshService::new(
|
||||
store.repository(),
|
||||
refresher.clone(),
|
||||
Arc::new(VerifiedIdentity),
|
||||
Arc::new(RefreshLeases {
|
||||
available: true,
|
||||
requests: Mutex::new(Vec::new()),
|
||||
}),
|
||||
Arc::new(CountingCredentialState::default()),
|
||||
runtime_policy(),
|
||||
);
|
||||
|
||||
let outcomes = service.refresh_due().await.expect("refresh due");
|
||||
|
||||
assert!(matches!(
|
||||
outcomes.as_slice(),
|
||||
[CodexCredentialRefreshOutcome::Invalidated { account_id }] if account_id == "acct_refresh"
|
||||
));
|
||||
assert!(refresher.seen.lock().expect("seen tokens lock").is_empty());
|
||||
assert_eq!(
|
||||
store
|
||||
.account("acct_refresh")
|
||||
.expect("expired account")
|
||||
.availability(),
|
||||
AccountAvailability::Expired
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn successful_refresh_clears_backoff_counter() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
|
||||
@@ -214,6 +214,27 @@ fn generate_operation() -> Operation {
|
||||
))
|
||||
}
|
||||
|
||||
fn generate_with_session_context(
|
||||
session_id: &str,
|
||||
thread_id: Option<&str>,
|
||||
turn_metadata: Option<&str>,
|
||||
) -> GenerateRequest {
|
||||
let mut body = Map::from_iter([
|
||||
("model".to_owned(), json!("gpt-5.4")),
|
||||
("input".to_owned(), json!("hello")),
|
||||
("session_id".to_owned(), json!(session_id)),
|
||||
]);
|
||||
if let Some(thread_id) = thread_id {
|
||||
body.insert("thread_id".to_owned(), json!(thread_id));
|
||||
}
|
||||
if let Some(turn_metadata) = turn_metadata {
|
||||
body.insert("turnMetadata".to_owned(), json!(turn_metadata));
|
||||
}
|
||||
GenerateRequest::from_protocol_payload(
|
||||
ProtocolPayload::json_object("openai", body).expect("OpenAI payload"),
|
||||
)
|
||||
}
|
||||
|
||||
fn http_generate_operation() -> Operation {
|
||||
let payload = ProtocolPayload::json_object(
|
||||
"openai",
|
||||
@@ -1173,6 +1194,58 @@ async fn explicit_session_id_should_override_turn_specific_prompt_cache_keys_for
|
||||
assert_eq!(keys[0], keys[1]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_spawn_children_should_have_independent_session_affinity_keys() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_thread_spawn_affinity").await;
|
||||
let affinity = Arc::new(MemorySessionAffinity::default());
|
||||
let provider = provider_with_affinity(&store, Arc::clone(&affinity));
|
||||
let thread_spawn = r#"{"subagent_kind":"thread_spawn"}"#;
|
||||
|
||||
for (request_id, thread_id, turn_metadata) in [
|
||||
(
|
||||
"req_thread_spawn_first",
|
||||
Some("child-one"),
|
||||
Some(thread_spawn),
|
||||
),
|
||||
(
|
||||
"req_thread_spawn_second",
|
||||
Some("child-two"),
|
||||
Some(thread_spawn),
|
||||
),
|
||||
(
|
||||
"req_thread_spawn_repeat",
|
||||
Some("child-one"),
|
||||
Some(thread_spawn),
|
||||
),
|
||||
("req_thread_spawn_fallback", None, Some(thread_spawn)),
|
||||
("req_thread_spawn_parent", None, None),
|
||||
] {
|
||||
let stream = provider
|
||||
.execute(
|
||||
planned_request(
|
||||
"openai",
|
||||
Operation::Generate(generate_with_session_context(
|
||||
"parent-session",
|
||||
thread_id,
|
||||
turn_metadata,
|
||||
)),
|
||||
),
|
||||
context(request_id, CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare provider stream");
|
||||
drop(stream);
|
||||
}
|
||||
|
||||
let keys = affinity.lookup_keys();
|
||||
assert_eq!(keys.len(), 5);
|
||||
assert_ne!(keys[0], keys[1]);
|
||||
assert_eq!(keys[0], keys[2]);
|
||||
assert_ne!(keys[3], keys[4]);
|
||||
assert_ne!(keys[0], keys[3]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_local_conversation_id_should_fall_back_to_an_opaque_affinity_key() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
@@ -1364,7 +1437,8 @@ async fn official_usage_limit_failure_projects_100_percent_and_holds_until_reset
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"primary_window": {"used_percent": 99, "reset_at": reset_at}
|
||||
"primary_window": {"used_percent": 99, "reset_at": reset_at},
|
||||
"secondary_window": {"used_percent": 0}
|
||||
}
|
||||
})
|
||||
.as_object()
|
||||
@@ -1390,7 +1464,8 @@ async fn official_usage_limit_failure_projects_100_percent_and_holds_until_reset
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"primary_window": {"used_percent": 99, "reset_at": reset_at}
|
||||
"primary_window": {"used_percent": 99, "reset_at": reset_at},
|
||||
"secondary_window": {"used_percent": 0}
|
||||
}
|
||||
})),
|
||||
)
|
||||
@@ -1439,16 +1514,22 @@ async fn official_usage_limit_failure_projects_100_percent_and_holds_until_reset
|
||||
.next()
|
||||
.expect("confirmed quota observation");
|
||||
let projected_observed_at = projected.observed_at.expect("projection observed at");
|
||||
let projected = projected
|
||||
.quota
|
||||
.map(OpaqueProviderData::into_inner)
|
||||
.expect("confirmed quota projection");
|
||||
let projected = Value::Object(
|
||||
projected
|
||||
.quota
|
||||
.map(OpaqueProviderData::into_inner)
|
||||
.expect("confirmed quota projection"),
|
||||
);
|
||||
assert_eq!(
|
||||
Value::Object(projected)
|
||||
projected
|
||||
.pointer("/rate_limit/primary_window/used_percent")
|
||||
.and_then(Value::as_u64),
|
||||
Some(100)
|
||||
);
|
||||
assert!(
|
||||
projected.pointer("/rate_limit/secondary_window").is_none(),
|
||||
"request failure must not manufacture a secondary quota"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
let requests = server.received_requests().await.expect("received requests");
|
||||
assert_eq!(
|
||||
@@ -1499,6 +1580,10 @@ async fn official_usage_limit_failure_projects_100_percent_and_holds_until_reset
|
||||
.and_then(Value::as_i64),
|
||||
Some(reset_at)
|
||||
);
|
||||
assert!(
|
||||
quota.pointer("/rate_limit/secondary_window").is_none(),
|
||||
"background quota refresh must retain the same window projection"
|
||||
);
|
||||
let requests = server.received_requests().await.expect("received requests");
|
||||
assert_eq!(
|
||||
requests
|
||||
|
||||
@@ -38,6 +38,7 @@ struct StoredAccount {
|
||||
credential: gateway_core::engine::credential::PlaintextCredential,
|
||||
quota: Option<QuotaObservation>,
|
||||
state_observed_at: Option<SystemTime>,
|
||||
last_error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -70,6 +71,15 @@ impl MemoryAccountStore {
|
||||
.map(|stored| stored.account.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn last_error_message(&self, id: &str) -> Option<String> {
|
||||
let id = ProviderAccountId::new(id).ok()?;
|
||||
self.accounts
|
||||
.lock()
|
||||
.expect("account store lock")
|
||||
.get(&id)
|
||||
.and_then(|stored| stored.last_error_message.clone())
|
||||
}
|
||||
|
||||
pub(crate) async fn advance_credential_revision(&self, id: &str) -> CredentialRevision {
|
||||
let account_id = ProviderAccountId::new(id).expect("test account id");
|
||||
let current = self
|
||||
@@ -148,6 +158,7 @@ impl ProviderAccountStore for MemoryAccountStore {
|
||||
credential: input.credential,
|
||||
quota: None,
|
||||
state_observed_at: None,
|
||||
last_error_message: None,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
@@ -321,6 +332,11 @@ impl ProviderAccountStore for MemoryAccountStore {
|
||||
},
|
||||
);
|
||||
stored.state_observed_at = Some(change.observed_at);
|
||||
if change.availability == AccountAvailability::Ready {
|
||||
stored.last_error_message = None;
|
||||
} else if let Some(message) = change.message {
|
||||
stored.last_error_message = Some(message);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -219,6 +219,10 @@ async fn fetch_should_send_official_catalog_headers_and_query() {
|
||||
.and(header("authorization", "Bearer oauth-access"))
|
||||
.and(header("chatgpt-account-id", "acct_123"))
|
||||
.and(header("originator", "codex_cli_rs"))
|
||||
.and(header(
|
||||
"user-agent",
|
||||
"codex_cli_rs/0.144.0 (linux 6.8; x86_64) xterm (codex_cli_rs; 1.0.0)",
|
||||
))
|
||||
.and(header("accept", "application/json"))
|
||||
.and(header("x-codex-installation-id", "installation-123"))
|
||||
.respond_with(
|
||||
|
||||
@@ -60,13 +60,13 @@ impl CodexCliReleaseTransport for CliReleaseTransport {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_profile_should_generate_codex_core_user_agent() {
|
||||
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(),
|
||||
os_type: "macOS".to_owned(),
|
||||
os_type: "Mac OS".to_owned(),
|
||||
os_version: "15.7.1".to_owned(),
|
||||
arch: "arm64".to_owned(),
|
||||
terminal: "unknown".to_owned(),
|
||||
@@ -78,7 +78,7 @@ fn wire_profile_should_generate_codex_core_user_agent() {
|
||||
|
||||
assert_eq!(
|
||||
profile.user_agent(),
|
||||
"Codex Desktop/0.144.2 (macOS 15.7.1; arm64) unknown"
|
||||
"Codex Desktop/0.144.2 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.707.72221)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ fn desktop_appcast_should_reject_invalid_version_and_build() {
|
||||
#[tokio::test]
|
||||
async fn wire_profile_release_update_should_change_only_desktop_identity() {
|
||||
let original = wire_profile();
|
||||
let original_user_agent = original.user_agent();
|
||||
let state = CodexWireProfileState::new(original.clone());
|
||||
let service = CodexDesktopReleaseService::new(
|
||||
state.clone(),
|
||||
@@ -155,6 +156,11 @@ async fn wire_profile_release_update_should_change_only_desktop_identity() {
|
||||
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.144.2 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.720.1)"
|
||||
);
|
||||
let status = service.status().snapshot();
|
||||
assert_eq!(status.latest, Some(release("26.720.1", "72001")));
|
||||
assert!(status.checked_at.is_some());
|
||||
@@ -164,6 +170,7 @@ async fn wire_profile_release_update_should_change_only_desktop_identity() {
|
||||
#[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(),
|
||||
@@ -182,6 +189,11 @@ async fn cli_release_update_should_change_only_codex_core_identity() {
|
||||
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)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -219,7 +231,7 @@ fn wire_profile() -> CodexWireProfile {
|
||||
codex_version: "0.144.2".to_owned(),
|
||||
desktop_version: "26.707.72221".to_owned(),
|
||||
desktop_build: "72221".to_owned(),
|
||||
os_type: "macOS".to_owned(),
|
||||
os_type: "Mac OS".to_owned(),
|
||||
os_version: "15.7.1".to_owned(),
|
||||
arch: "arm64".to_owned(),
|
||||
terminal: "unknown".to_owned(),
|
||||
|
||||
@@ -163,7 +163,12 @@ async fn fetch_should_use_wham_usage_headers_only() {
|
||||
"unexpected {name} header"
|
||||
);
|
||||
}
|
||||
assert!(headers.get("user-agent").is_some());
|
||||
assert_eq!(
|
||||
headers
|
||||
.get("user-agent")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("codex_cli_rs/0.144.0 (linux 6.8; x86_64) xterm (codex_cli_rs; 1.0.0)")
|
||||
);
|
||||
let quota_header_names = headers
|
||||
.keys()
|
||||
.map(|name| name.as_str())
|
||||
|
||||
@@ -221,7 +221,7 @@ impl GrokOAuthClient {
|
||||
/// # Errors
|
||||
///
|
||||
/// 返回已分类的 OAuth 错误。refresh token 可能已轮换,Ambiguous 类
|
||||
/// transport 失败不得自动重试。
|
||||
/// transport 失败不在本次 exchange 内重试,后续交由 refresh scheduler 退避协调。
|
||||
pub async fn refresh(
|
||||
&self,
|
||||
discovery: &DiscoveryDocument,
|
||||
|
||||
@@ -37,7 +37,7 @@ pub enum OAuthErrorCode {
|
||||
pub enum FailureClass {
|
||||
/// 瞬时失败,允许后续独立协调的重试。
|
||||
Transient,
|
||||
/// 发送状态不确定,一次性凭据不得重放。
|
||||
/// 发送状态不确定,不应在同一 exchange 内重放一次性凭据。
|
||||
Ambiguous,
|
||||
/// 当前 revision 的凭据被永久拒绝。
|
||||
CredentialPermanent,
|
||||
|
||||
@@ -12,10 +12,10 @@ use gateway_core::engine::credential::{
|
||||
AccountAvailability, CredentialRevision, LoadedCredential, ProviderAccountId,
|
||||
};
|
||||
use gateway_core::provider_ports::{
|
||||
ProviderCooldown, ProviderCooldownPort, ProviderCredentialStatePort, ProviderLeaseAcquisition,
|
||||
ProviderLeasePort, ProviderLeaseRequest, ProviderRefreshCapacityRequest,
|
||||
ProviderRefreshLeaseRequest, ProviderRefreshPolicy, ProviderRuntimePolicyPort,
|
||||
ProviderStoreError, provider_refresh_backoff_at,
|
||||
ProviderCredentialStatePort, ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest,
|
||||
ProviderRefreshCapacityRequest, ProviderRefreshLeaseRequest, ProviderRefreshPolicy,
|
||||
ProviderRuntimePolicyPort, ProviderStoreError, provider_refresh_backoff_at,
|
||||
provider_refresh_retry_at,
|
||||
};
|
||||
|
||||
use super::catalog::GrokCredentialCatalogService;
|
||||
@@ -38,12 +38,49 @@ const MAX_SECRET_BYTES: usize = 64 * 1_024;
|
||||
const DISCOVERY_NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(5);
|
||||
/// 连续失败计数窗口;静默满窗后计数过期归零。
|
||||
const REFRESH_BACKOFF_WINDOW: Duration = Duration::from_secs(30 * 60);
|
||||
const REFRESH_AMBIGUITY_COOLDOWN: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
/// 窗口内先执行五次指数退避,再进入固定恢复周期。
|
||||
const REFRESH_BACKOFF_MAX_ATTEMPTS: u32 = 5;
|
||||
/// 耗尽指数退避后的 OAuth 恢复周期。
|
||||
const REFRESH_RECOVERY_DELAY: Duration = Duration::from_secs(10 * 60);
|
||||
/// 过期 AT 仍允许 RT 恢复的最长窗口。
|
||||
const REFRESH_RECOVERY_WINDOW: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
fn refresh_recovery_deadline(access_token_expires_at: Option<SystemTime>) -> Option<SystemTime> {
|
||||
access_token_expires_at.and_then(|expires_at| expires_at.checked_add(REFRESH_RECOVERY_WINDOW))
|
||||
}
|
||||
|
||||
fn refresh_recovery_window_exhausted(
|
||||
access_token_expires_at: Option<SystemTime>,
|
||||
now: SystemTime,
|
||||
) -> bool {
|
||||
refresh_recovery_deadline(access_token_expires_at).is_some_and(|deadline| deadline <= now)
|
||||
}
|
||||
|
||||
fn bounded_refresh_retry_at(
|
||||
retry_at: SystemTime,
|
||||
access_token_expires_at: Option<SystemTime>,
|
||||
observed_at: SystemTime,
|
||||
) -> SystemTime {
|
||||
let mut bounded = retry_at;
|
||||
if let Some(expires_at) = access_token_expires_at
|
||||
&& expires_at > observed_at
|
||||
&& bounded > expires_at
|
||||
{
|
||||
bounded = expires_at;
|
||||
}
|
||||
if let Some(deadline) = refresh_recovery_deadline(access_token_expires_at)
|
||||
&& bounded > deadline
|
||||
{
|
||||
bounded = deadline;
|
||||
}
|
||||
bounded
|
||||
}
|
||||
|
||||
/// 一个到期且已按 revision 读取明文 RT 的 xAI account。
|
||||
pub struct DueGrokCredential {
|
||||
account_id: ProviderAccountId,
|
||||
credential_revision: CredentialRevision,
|
||||
access_token_expires_at: Option<SystemTime>,
|
||||
refresh_token: SecretValue,
|
||||
id_token: Option<SecretValue>,
|
||||
scope: String,
|
||||
@@ -77,6 +114,7 @@ impl fmt::Debug for DueGrokCredential {
|
||||
.debug_struct("DueGrokCredential")
|
||||
.field("account_id", &self.account_id)
|
||||
.field("credential_revision", &self.credential_revision)
|
||||
.field("access_token_expires_at", &self.access_token_expires_at)
|
||||
.field("refresh_token", &"[REDACTED]")
|
||||
.field("id_token", &self.id_token.as_ref().map(|_| "[REDACTED]"))
|
||||
.field("scope", &"[REDACTED]")
|
||||
@@ -277,7 +315,6 @@ pub struct GrokCredentialRefreshService {
|
||||
refresher: Arc<dyn GrokCredentialRefresher>,
|
||||
catalog: Arc<GrokCredentialCatalogService>,
|
||||
leases: Arc<dyn ProviderLeasePort>,
|
||||
cooldowns: Arc<dyn ProviderCooldownPort>,
|
||||
credential_state: Arc<dyn ProviderCredentialStatePort>,
|
||||
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
|
||||
}
|
||||
@@ -288,7 +325,6 @@ impl GrokCredentialRefreshService {
|
||||
refresher: Arc<dyn GrokCredentialRefresher>,
|
||||
catalog: Arc<GrokCredentialCatalogService>,
|
||||
leases: Arc<dyn ProviderLeasePort>,
|
||||
cooldowns: Arc<dyn ProviderCooldownPort>,
|
||||
credential_state: Arc<dyn ProviderCredentialStatePort>,
|
||||
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
|
||||
) -> Self {
|
||||
@@ -297,7 +333,6 @@ impl GrokCredentialRefreshService {
|
||||
refresher,
|
||||
catalog,
|
||||
leases,
|
||||
cooldowns,
|
||||
credential_state,
|
||||
runtime_policy,
|
||||
}
|
||||
@@ -331,6 +366,7 @@ impl GrokCredentialRefreshService {
|
||||
let credential = DueGrokCredential {
|
||||
account_id: account_id.clone(),
|
||||
credential_revision,
|
||||
access_token_expires_at: loaded.account.access_token_expires_at(),
|
||||
refresh_token: loaded.refresh_token,
|
||||
id_token: loaded.id_token,
|
||||
scope: loaded.scope,
|
||||
@@ -447,12 +483,7 @@ impl GrokCredentialRefreshService {
|
||||
.into_iter()
|
||||
.map(|account_id| GrokCredentialRefreshOutcome::Failed { account_id })
|
||||
.collect::<Vec<_>>();
|
||||
let mut credentials = Vec::with_capacity(batch.credentials.len());
|
||||
for credential in batch.credentials {
|
||||
if !self.runtime_cooldown_active(&credential).await {
|
||||
credentials.push(credential);
|
||||
}
|
||||
}
|
||||
let credentials = batch.credentials;
|
||||
if credentials.is_empty() {
|
||||
return Ok(outcomes);
|
||||
}
|
||||
@@ -474,7 +505,14 @@ impl GrokCredentialRefreshService {
|
||||
for (account_id, result) in refreshed {
|
||||
match result {
|
||||
Ok(outcome) => outcomes.push(outcome),
|
||||
Err(_) => outcomes.push(GrokCredentialRefreshOutcome::Failed { account_id }),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
account_id = %account_id,
|
||||
error = %error,
|
||||
"xAI OAuth refresh attempt failed"
|
||||
);
|
||||
outcomes.push(GrokCredentialRefreshOutcome::Failed { account_id });
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(outcomes)
|
||||
@@ -486,9 +524,10 @@ impl GrokCredentialRefreshService {
|
||||
policy: ProviderRefreshPolicy,
|
||||
) -> Result<GrokCredentialRefreshOutcome, GrokCredentialRefreshError> {
|
||||
let account_id = credential.account_id.clone();
|
||||
let observed_at = SystemTime::now();
|
||||
if credential
|
||||
.refresh_token_expires_at
|
||||
.is_some_and(|expires_at| expires_at <= Utc::now())
|
||||
.is_some_and(|expires_at| expires_at <= DateTime::<Utc>::from(observed_at))
|
||||
{
|
||||
return self
|
||||
.persist_terminal_failure(
|
||||
@@ -498,6 +537,15 @@ impl GrokCredentialRefreshService {
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if refresh_recovery_window_exhausted(credential.access_token_expires_at, observed_at) {
|
||||
return self
|
||||
.persist_terminal_failure(
|
||||
credential,
|
||||
GrokCredentialAvailability::Expired,
|
||||
"refresh_recovery_window_exhausted",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let capacity = self
|
||||
.leases
|
||||
.try_acquire(ProviderLeaseRequest::RefreshCapacity(
|
||||
@@ -545,9 +593,14 @@ impl GrokCredentialRefreshService {
|
||||
.await
|
||||
}
|
||||
Err(GrokRefreshFailure::Ambiguous) => {
|
||||
self.record_runtime_cooldown(&credential, REFRESH_AMBIGUITY_COOLDOWN)
|
||||
.await;
|
||||
Ok(GrokCredentialRefreshOutcome::Ambiguous { account_id })
|
||||
if self
|
||||
.persist_backoff(&credential, "refresh-ambiguous")
|
||||
.await?
|
||||
{
|
||||
Ok(GrokCredentialRefreshOutcome::Ambiguous { account_id })
|
||||
} else {
|
||||
Ok(GrokCredentialRefreshOutcome::Stale { account_id })
|
||||
}
|
||||
}
|
||||
Err(GrokRefreshFailure::Transient) => {
|
||||
if self
|
||||
@@ -644,35 +697,19 @@ impl GrokCredentialRefreshService {
|
||||
.credential_state
|
||||
.clear_refresh_backoff(&account_id)
|
||||
.await;
|
||||
tracing::info!(
|
||||
account_id = %account_id,
|
||||
credential_revision = record.credential_revision.get(),
|
||||
access_token_expires_at = %access_expires_at,
|
||||
next_refresh_at = %next_refresh_at,
|
||||
"xAI OAuth refresh succeeded"
|
||||
);
|
||||
Ok(GrokCredentialRefreshOutcome::Refreshed {
|
||||
account_id,
|
||||
credential_revision: record.credential_revision,
|
||||
})
|
||||
}
|
||||
|
||||
async fn runtime_cooldown_active(&self, credential: &DueGrokCredential) -> bool {
|
||||
// runtime cooldown 是账号/model 级生命周期,与 credential revision 无关:
|
||||
// 轮换不清除、不因 revision 变化判失效(与 selector 的读取语义一致)。
|
||||
let Ok(Some(cooldown)) = self.cooldowns.read(credential.account_id()).await else {
|
||||
return false;
|
||||
};
|
||||
cooldown.until() > SystemTime::now()
|
||||
}
|
||||
|
||||
async fn record_runtime_cooldown(&self, credential: &DueGrokCredential, duration: Duration) {
|
||||
let cooldown_until = SystemTime::now()
|
||||
.checked_add(duration)
|
||||
.unwrap_or_else(SystemTime::now);
|
||||
let _ = self
|
||||
.cooldowns
|
||||
.put_if_later(ProviderCooldown::new(
|
||||
credential.account_id.clone(),
|
||||
credential.credential_revision,
|
||||
cooldown_until,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn persist_terminal_failure(
|
||||
&self,
|
||||
credential: DueGrokCredential,
|
||||
@@ -702,7 +739,19 @@ impl GrokCredentialRefreshService {
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(GrokCredentialRefreshOutcome::Invalidated { account_id }),
|
||||
Ok(()) => {
|
||||
tracing::warn!(
|
||||
account_id = %account_id,
|
||||
?availability,
|
||||
reason,
|
||||
access_token_expires_at = ?credential.access_token_expires_at
|
||||
.map(DateTime::<Utc>::from),
|
||||
recovery_deadline = ?refresh_recovery_deadline(credential.access_token_expires_at)
|
||||
.map(DateTime::<Utc>::from),
|
||||
"xAI OAuth refresh marked account terminal"
|
||||
);
|
||||
Ok(GrokCredentialRefreshOutcome::Invalidated { account_id })
|
||||
}
|
||||
Err(error) if stale_repository_error(&error) => {
|
||||
Ok(GrokCredentialRefreshOutcome::Stale { account_id })
|
||||
}
|
||||
@@ -720,12 +769,19 @@ impl GrokCredentialRefreshService {
|
||||
.record_refresh_backoff(&credential.account_id, REFRESH_BACKOFF_WINDOW)
|
||||
.await
|
||||
.unwrap_or(1);
|
||||
let retry_at = provider_refresh_backoff_at(
|
||||
&credential.account_id,
|
||||
SystemTime::now(),
|
||||
attempt,
|
||||
reason,
|
||||
)?;
|
||||
let observed_at = SystemTime::now();
|
||||
let retry_at = if attempt <= REFRESH_BACKOFF_MAX_ATTEMPTS {
|
||||
provider_refresh_backoff_at(&credential.account_id, observed_at, attempt, reason)?
|
||||
} else {
|
||||
provider_refresh_retry_at(
|
||||
&credential.account_id,
|
||||
observed_at,
|
||||
REFRESH_RECOVERY_DELAY,
|
||||
reason,
|
||||
)?
|
||||
};
|
||||
let retry_at =
|
||||
bounded_refresh_retry_at(retry_at, credential.access_token_expires_at, observed_at);
|
||||
match self
|
||||
.repository
|
||||
.defer_refresh(
|
||||
@@ -735,7 +791,20 @@ impl GrokCredentialRefreshService {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(true),
|
||||
Ok(()) => {
|
||||
tracing::warn!(
|
||||
account_id = %credential.account_id,
|
||||
attempt,
|
||||
reason,
|
||||
retry_at = %DateTime::<Utc>::from(retry_at),
|
||||
access_token_expires_at = ?credential.access_token_expires_at
|
||||
.map(DateTime::<Utc>::from),
|
||||
recovery_deadline = ?refresh_recovery_deadline(credential.access_token_expires_at)
|
||||
.map(DateTime::<Utc>::from),
|
||||
"xAI OAuth refresh deferred"
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
Err(error) if stale_repository_error(&error) => Ok(false),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
@@ -814,6 +883,7 @@ impl GrokCredentialRepository {
|
||||
due.push(DueGrokCredential {
|
||||
account_id: account.id().clone(),
|
||||
credential_revision: account.revision(),
|
||||
access_token_expires_at: account.access_token_expires_at(),
|
||||
refresh_token: loaded.refresh_token,
|
||||
id_token: loaded.id_token,
|
||||
scope: loaded.scope,
|
||||
@@ -844,7 +914,8 @@ fn account_due(
|
||||
| AccountAvailability::Banned
|
||||
| AccountAvailability::Invalid
|
||||
)
|
||||
&& account.next_refresh_at().is_some_and(|next| next <= now)
|
||||
&& (refresh_recovery_window_exhausted(account.access_token_expires_at(), now)
|
||||
|| account.next_refresh_at().is_some_and(|next| next <= now))
|
||||
}
|
||||
|
||||
fn refreshed_tokens(tokens: RefreshedTokenSet) -> Result<GrokRefreshTokens, GrokRefreshFailure> {
|
||||
|
||||
@@ -6,10 +6,10 @@ use std::time::SystemTime;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::engine::credential::{
|
||||
AccountStateChange, CredentialCasOutcome, CredentialCasUpdate, CredentialRevision,
|
||||
LoadedCredential, NewProviderAccount, OpaqueProviderData, PlaintextCredential, ProviderAccount,
|
||||
ProviderAccountId, ProviderAccountStore, ProviderAccountUpdate, QuotaObservation,
|
||||
QuotaWriteOutcome,
|
||||
AccountAvailability, AccountStateChange, CredentialCasOutcome, CredentialCasUpdate,
|
||||
CredentialRevision, LoadedCredential, NewProviderAccount, OpaqueProviderData,
|
||||
PlaintextCredential, ProviderAccount, ProviderAccountId, ProviderAccountStore,
|
||||
ProviderAccountUpdate, QuotaObservation, QuotaWriteOutcome,
|
||||
};
|
||||
use gateway_core::error::StoreErrorKind;
|
||||
use gateway_core::provider_ports::ProviderRefreshPolicy;
|
||||
@@ -428,15 +428,21 @@ impl GrokCredentialRepository {
|
||||
&self,
|
||||
input: &UpdateGrokCredentialState,
|
||||
) -> Result<(), GrokCredentialRepositoryError> {
|
||||
validate_reason(input.availability_reason.as_deref())?;
|
||||
let availability = AccountAvailability::from(input.availability);
|
||||
let availability_reason = input
|
||||
.availability_reason
|
||||
.clone()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| availability.fallback_error_reason().map(str::to_owned));
|
||||
validate_reason(availability_reason.as_deref())?;
|
||||
self.ensure_xai_account(&input.account_id).await?;
|
||||
self.store
|
||||
.apply_state_change(AccountStateChange {
|
||||
account_id: input.account_id.clone(),
|
||||
expected_revision: input.expected_revision,
|
||||
availability: input.availability.into(),
|
||||
availability,
|
||||
observed_at: to_system_time(input.observed_at),
|
||||
message: input.availability_reason.clone(),
|
||||
message: availability_reason,
|
||||
})
|
||||
.await
|
||||
.map_err(map_store_error)
|
||||
|
||||
@@ -171,7 +171,6 @@ pub async fn initialize(
|
||||
refresher,
|
||||
Arc::clone(&catalog),
|
||||
Arc::clone(&leases),
|
||||
cooldowns.clone(),
|
||||
ports.credential_state(),
|
||||
Arc::clone(&runtime_policy),
|
||||
));
|
||||
|
||||
@@ -2040,11 +2040,10 @@ impl ScheduledTask for XaiOAuthRefreshTask {
|
||||
if context.cancellation().is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
let outcomes = self
|
||||
.service
|
||||
.refresh_due()
|
||||
.await
|
||||
.map_err(|_| WorkerTaskError::safe("xAI OAuth refresh failed"))?;
|
||||
let outcomes = self.service.refresh_due().await.map_err(|error| {
|
||||
tracing::error!(error = %error, "xAI OAuth refresh cycle failed");
|
||||
WorkerTaskError::safe("xAI OAuth refresh failed")
|
||||
})?;
|
||||
let failures = outcomes
|
||||
.iter()
|
||||
.filter(|outcome| {
|
||||
@@ -2056,6 +2055,13 @@ impl ScheduledTask for XaiOAuthRefreshTask {
|
||||
)
|
||||
})
|
||||
.count();
|
||||
if !outcomes.is_empty() {
|
||||
tracing::info!(
|
||||
accounts = outcomes.len(),
|
||||
failures,
|
||||
"xAI OAuth refresh cycle completed"
|
||||
);
|
||||
}
|
||||
if failures > 0 {
|
||||
tracing::warn!(failures, "xAI OAuth refresh cycle contained failures");
|
||||
}
|
||||
|
||||
@@ -233,6 +233,29 @@ async fn state_update_uses_credential_revision_fence() {
|
||||
assert_eq!(account.availability(), AccountAvailability::Ready);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_state_without_upstream_message_uses_stable_error_reason() {
|
||||
let (store, repository) = repository();
|
||||
let input = create_input("state-fallback", "subject-state-fallback");
|
||||
seed_input(&store, &input).await.expect("create");
|
||||
|
||||
repository
|
||||
.update_state(&UpdateGrokCredentialState {
|
||||
account_id: input.account_id.clone(),
|
||||
expected_revision: CredentialRevision::new(1).expect("revision"),
|
||||
availability: GrokCredentialAvailability::Banned,
|
||||
availability_reason: None,
|
||||
observed_at: Utc::now(),
|
||||
})
|
||||
.await
|
||||
.expect("state update");
|
||||
|
||||
assert_eq!(
|
||||
store.last_error_message(&input.account_id).as_deref(),
|
||||
Some("account_banned")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_prepare_does_not_mutate_provider_account_store() {
|
||||
let (store, _) = repository();
|
||||
|
||||
@@ -10,8 +10,8 @@ use gateway_core::engine::credential::{
|
||||
ProviderAccountId, ProviderAccountStore, ProviderAccountUpdate,
|
||||
};
|
||||
use gateway_core::provider_ports::{
|
||||
ProviderCooldown, ProviderCooldownPort, ProviderCredentialState, ProviderCredentialStatePort,
|
||||
ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest, ProviderStoreError,
|
||||
ProviderCredentialState, ProviderCredentialStatePort, ProviderLeaseAcquisition,
|
||||
ProviderLeasePort, ProviderLeaseRequest, ProviderStoreError,
|
||||
};
|
||||
use provider_xai::{
|
||||
GrokCredentialCatalogCache, GrokCredentialRecovery, GrokCredentialRecoveryOutcome,
|
||||
@@ -22,12 +22,13 @@ use provider_xai::{
|
||||
};
|
||||
|
||||
use crate::support::{
|
||||
MemoryCooldownPort, MemoryGrokCatalogCache, MemoryProviderAccountStore, create_input,
|
||||
credential_object, runtime_policy, seed_input,
|
||||
MemoryGrokCatalogCache, MemoryProviderAccountStore, create_input, credential_object,
|
||||
runtime_policy, seed_input,
|
||||
};
|
||||
|
||||
const OFFICIAL_FIXTURE: &[u8] =
|
||||
include_bytes!("../transport/catalog/fixtures/official_grok_models_snapshot.json");
|
||||
const OAUTH_BACKOFF_ATTEMPTS: u32 = 5;
|
||||
|
||||
struct StaticCatalogTransport;
|
||||
|
||||
@@ -143,48 +144,6 @@ async fn fixture(
|
||||
fixture_many([input], responses, lease_available).await
|
||||
}
|
||||
|
||||
/// 带外部注入 cooldown 端口的 fixture(AUD-05 组合测试用)。
|
||||
#[allow(clippy::type_complexity)]
|
||||
async fn fixture_with_cooldowns(
|
||||
input: provider_xai::CreateGrokCredential,
|
||||
responses: impl IntoIterator<Item = Result<GrokRefreshTokens, GrokRefreshFailure>>,
|
||||
lease_available: bool,
|
||||
cooldowns: Arc<MemoryCooldownPort>,
|
||||
) -> (
|
||||
Arc<MemoryProviderAccountStore>,
|
||||
GrokCredentialRepository,
|
||||
Arc<QueueRefresher>,
|
||||
GrokCredentialRefreshService,
|
||||
Arc<MemoryCooldownPort>,
|
||||
) {
|
||||
let store = MemoryProviderAccountStore::shared();
|
||||
let account_store: Arc<dyn ProviderAccountStore> = store.clone();
|
||||
let repository = GrokCredentialRepository::new(account_store);
|
||||
seed_input(&store, &input).await.expect("create account");
|
||||
let refresher = QueueRefresher::new(responses);
|
||||
let refresher_port: Arc<dyn GrokCredentialRefresher> = refresher.clone();
|
||||
let cache: Arc<dyn GrokCredentialCatalogCache> = MemoryGrokCatalogCache::shared();
|
||||
let catalog = Arc::new(crate::support::grok_catalog_service(
|
||||
repository.clone(),
|
||||
Arc::new(StaticCatalogTransport),
|
||||
cache,
|
||||
));
|
||||
let leases = Arc::new(TestRefreshLeases {
|
||||
available: lease_available,
|
||||
calls: AtomicUsize::new(0),
|
||||
});
|
||||
let service = GrokCredentialRefreshService::new(
|
||||
repository.clone(),
|
||||
refresher_port,
|
||||
catalog,
|
||||
leases,
|
||||
cooldowns.clone(),
|
||||
Arc::new(CountingCredentialState::default()),
|
||||
runtime_policy(),
|
||||
);
|
||||
(store, repository, refresher, service, cooldowns)
|
||||
}
|
||||
|
||||
/// 真实累加连续失败计数的测试 double,用于断言退避的指数增长与成功清零。
|
||||
#[derive(Default)]
|
||||
struct CountingCredentialState {
|
||||
@@ -313,13 +272,11 @@ async fn fixture_many_with_state(
|
||||
available: lease_available,
|
||||
calls: AtomicUsize::new(0),
|
||||
});
|
||||
let cooldowns = Arc::new(MemoryCooldownPort::default());
|
||||
let service = GrokCredentialRefreshService::new(
|
||||
repository.clone(),
|
||||
refresher_port,
|
||||
catalog,
|
||||
leases,
|
||||
cooldowns,
|
||||
credential_state,
|
||||
runtime_policy(),
|
||||
);
|
||||
@@ -354,6 +311,38 @@ async fn force_due(store: &MemoryProviderAccountStore, id: &ProviderAccountId) {
|
||||
));
|
||||
}
|
||||
|
||||
async fn set_token_deadlines(
|
||||
store: &MemoryProviderAccountStore,
|
||||
id: &ProviderAccountId,
|
||||
access_token_expires_at: SystemTime,
|
||||
next_refresh_at: SystemTime,
|
||||
) {
|
||||
let account = store.account(id).expect("account to update");
|
||||
let credential = store.credential(id).expect("credential to update");
|
||||
let update = CredentialCasUpdate::new(
|
||||
id.clone(),
|
||||
account.revision(),
|
||||
ProviderAccountUpdate {
|
||||
account_id: id.clone(),
|
||||
name: account.name().to_owned(),
|
||||
email: account.email().map(str::to_owned),
|
||||
plan_type: account.plan_type().map(str::to_owned),
|
||||
},
|
||||
credential,
|
||||
true,
|
||||
Some(access_token_expires_at),
|
||||
Some(next_refresh_at),
|
||||
)
|
||||
.expect("valid token deadline update");
|
||||
assert!(matches!(
|
||||
store
|
||||
.compare_and_swap_credential(update)
|
||||
.await
|
||||
.expect("update token deadlines"),
|
||||
CredentialCasOutcome::Updated(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn successful_refresh_rotates_plaintext_tokens_once() {
|
||||
let input = due_input("success");
|
||||
@@ -415,6 +404,10 @@ async fn unauthorized_recovery_marks_a_permanently_rejected_refresh_expired() {
|
||||
store.account(&id).expect("account").availability(),
|
||||
AccountAvailability::Expired
|
||||
);
|
||||
assert_eq!(
|
||||
store.last_error_message(&id).as_deref(),
|
||||
Some("refresh_invalid_grant")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -573,14 +566,25 @@ async fn banned_failure_marks_account_banned() {
|
||||
store.account(&id).expect("account").availability(),
|
||||
AccountAvailability::Banned
|
||||
);
|
||||
assert_eq!(
|
||||
store.last_error_message(&id).as_deref(),
|
||||
Some("account_banned")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ambiguous_refresh_uses_runtime_cooldown_without_persisting_account_state() {
|
||||
async fn ambiguous_refresh_applies_bounded_oauth_backoff() {
|
||||
let input = due_input("ambiguous");
|
||||
let id = input.account_id.clone();
|
||||
let (store, _, refresher, service) =
|
||||
fixture(input, [Err(GrokRefreshFailure::Ambiguous)], true).await;
|
||||
let counting = Arc::new(CountingCredentialState::default());
|
||||
let (store, _, refresher, service) = fixture_many_with_state(
|
||||
[input],
|
||||
[Err(GrokRefreshFailure::Ambiguous)],
|
||||
true,
|
||||
counting.clone(),
|
||||
)
|
||||
.await;
|
||||
let started_at = SystemTime::now();
|
||||
let outcomes = service.refresh_due().await.expect("refresh");
|
||||
assert!(matches!(
|
||||
outcomes.as_slice(),
|
||||
@@ -590,7 +594,18 @@ async fn ambiguous_refresh_uses_runtime_cooldown_without_persisting_account_stat
|
||||
store.account(&id).expect("account").availability(),
|
||||
AccountAvailability::Unknown
|
||||
);
|
||||
assert_eq!(store.account(&id).expect("account").revision().get(), 1);
|
||||
let account = store.account(&id).expect("account");
|
||||
assert_eq!(account.revision().get(), 2);
|
||||
let retry_delay = account
|
||||
.next_refresh_at()
|
||||
.expect("OAuth retry scheduled")
|
||||
.duration_since(started_at)
|
||||
.expect("OAuth retry is in the future");
|
||||
assert!(
|
||||
retry_delay <= Duration::from_secs(7),
|
||||
"first retry must use bounded OAuth backoff, got {retry_delay:?}"
|
||||
);
|
||||
assert_eq!(counting.count(&id), 1);
|
||||
assert!(
|
||||
service
|
||||
.refresh_due()
|
||||
@@ -602,7 +617,7 @@ async fn ambiguous_refresh_uses_runtime_cooldown_without_persisting_account_stat
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_send_transient_failure_applies_bounded_cooldown() {
|
||||
async fn pre_send_transient_failure_applies_bounded_oauth_backoff() {
|
||||
let input = due_input("transient");
|
||||
let id = input.account_id.clone();
|
||||
let (store, _, _, service) = fixture(input, [Err(GrokRefreshFailure::Transient)], true).await;
|
||||
@@ -672,6 +687,121 @@ async fn refresh_backoff_grows_exponentially_across_attempts() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exhausted_oauth_backoff_continues_after_access_token_expiry() {
|
||||
let input = due_input("backoff-recovery");
|
||||
let id = input.account_id.clone();
|
||||
let counting = Arc::new(CountingCredentialState::default());
|
||||
let (store, _, refresher, service) = fixture_many_with_state(
|
||||
[input],
|
||||
[
|
||||
Err(GrokRefreshFailure::Transient),
|
||||
Err(GrokRefreshFailure::Transient),
|
||||
Err(GrokRefreshFailure::Transient),
|
||||
Err(GrokRefreshFailure::Transient),
|
||||
Err(GrokRefreshFailure::Transient),
|
||||
Err(GrokRefreshFailure::Transient),
|
||||
],
|
||||
true,
|
||||
counting.clone(),
|
||||
)
|
||||
.await;
|
||||
set_token_deadlines(
|
||||
&store,
|
||||
&id,
|
||||
(Utc::now() - chrono::Duration::minutes(1)).into(),
|
||||
SystemTime::now() - Duration::from_secs(1),
|
||||
)
|
||||
.await;
|
||||
|
||||
for _ in 0..OAUTH_BACKOFF_ATTEMPTS {
|
||||
service.refresh_due().await.expect("backoff attempt");
|
||||
force_due(&store, &id).await;
|
||||
}
|
||||
|
||||
let observed_at = SystemTime::now();
|
||||
service.refresh_due().await.expect("recovery attempt");
|
||||
let recovery_delay = store
|
||||
.account(&id)
|
||||
.expect("account after recovery defer")
|
||||
.next_refresh_at()
|
||||
.expect("recovery scheduled")
|
||||
.duration_since(observed_at)
|
||||
.expect("recovery is in the future");
|
||||
assert!(
|
||||
(Duration::from_secs(8 * 60)..=Duration::from_secs(12 * 60 + 1)).contains(&recovery_delay),
|
||||
"recovery delay should be about ten minutes, got {recovery_delay:?}"
|
||||
);
|
||||
assert_eq!(counting.count(&id), OAUTH_BACKOFF_ATTEMPTS + 1);
|
||||
assert_eq!(refresher.prepare_calls.load(Ordering::SeqCst), 6);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oauth_backoff_never_passes_an_unexpired_access_token_expiry() {
|
||||
let mut input = due_input("backoff-expiry-cap");
|
||||
input.account.access_token_expires_at = Utc::now() + chrono::Duration::seconds(30);
|
||||
let access_expires_at: SystemTime = input.account.access_token_expires_at.into();
|
||||
let id = input.account_id.clone();
|
||||
let counting = Arc::new(CountingCredentialState::default());
|
||||
let (store, _, _, service) = fixture_many_with_state(
|
||||
[input],
|
||||
[
|
||||
Err(GrokRefreshFailure::Transient),
|
||||
Err(GrokRefreshFailure::Transient),
|
||||
Err(GrokRefreshFailure::Transient),
|
||||
Err(GrokRefreshFailure::Transient),
|
||||
Err(GrokRefreshFailure::Transient),
|
||||
Err(GrokRefreshFailure::Transient),
|
||||
],
|
||||
true,
|
||||
counting.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
for attempt in 0..=OAUTH_BACKOFF_ATTEMPTS {
|
||||
service.refresh_due().await.expect("backoff attempt");
|
||||
if attempt < OAUTH_BACKOFF_ATTEMPTS {
|
||||
force_due(&store, &id).await;
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.account(&id)
|
||||
.expect("account after capped retry")
|
||||
.next_refresh_at(),
|
||||
Some(access_expires_at)
|
||||
);
|
||||
assert_eq!(counting.count(&id), OAUTH_BACKOFF_ATTEMPTS + 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recovery_window_exhaustion_marks_account_expired_without_token_exchange() {
|
||||
let input = due_input("recovery-window-exhausted");
|
||||
let id = input.account_id.clone();
|
||||
let (store, _, refresher, service) = fixture(input, [Ok(success_tokens(None))], true).await;
|
||||
set_token_deadlines(
|
||||
&store,
|
||||
&id,
|
||||
(Utc::now() - chrono::Duration::hours(25)).into(),
|
||||
(Utc::now() + chrono::Duration::hours(12)).into(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let outcomes = service.refresh_due().await.expect("refresh due");
|
||||
|
||||
assert!(matches!(
|
||||
outcomes.as_slice(),
|
||||
[GrokCredentialRefreshOutcome::Invalidated { account_id }] if account_id == &id
|
||||
));
|
||||
assert_eq!(
|
||||
store.account(&id).expect("expired account").availability(),
|
||||
AccountAvailability::Expired
|
||||
);
|
||||
assert_eq!(refresher.prepare_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(refresher.responses.lock().expect("queue").len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn successful_refresh_clears_backoff_counter() {
|
||||
let input = due_input("backoff-clear");
|
||||
@@ -776,52 +906,33 @@ async fn malformed_account_refresh_does_not_stop_later_accounts() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_cooldown_survives_credential_rotation_and_blocks_refresh() {
|
||||
// cooldown 写于 revision N,轮换到 N+1 后 refresh worker 不得清除
|
||||
// 或失效它;cooldown 活跃期间账号被跳过刷新。
|
||||
let input = create_input(
|
||||
"cooldown-survives-rotation",
|
||||
"subject-cooldown-survives-rotation",
|
||||
);
|
||||
async fn scheduled_oauth_refresh_runs_after_credential_rotation() {
|
||||
let input = due_input("refresh-after-rotation");
|
||||
let id = input.account_id.clone();
|
||||
let cooldowns = Arc::new(MemoryCooldownPort::default());
|
||||
let (store, _, _, service, cooldowns) = fixture_with_cooldowns(
|
||||
let (store, _, refresher, service) = fixture(
|
||||
input,
|
||||
[Ok(success_tokens(Some("rotated-refresh")))],
|
||||
[
|
||||
Ok(success_tokens(Some("recovered-refresh"))),
|
||||
Ok(success_tokens(Some("scheduled-refresh"))),
|
||||
],
|
||||
true,
|
||||
cooldowns,
|
||||
)
|
||||
.await;
|
||||
// 先写 account cooldown(revision 1,未来到期)。
|
||||
let until = SystemTime::now() + Duration::from_secs(3600);
|
||||
cooldowns
|
||||
.put_if_later(ProviderCooldown::new(
|
||||
id.clone(),
|
||||
CredentialRevision::new(1).expect("revision"),
|
||||
until,
|
||||
))
|
||||
.await
|
||||
.expect("write cooldown");
|
||||
|
||||
// 轮换到 revision 2。
|
||||
let outcome = service
|
||||
.recover_unauthorized(&id, CredentialRevision::new(1).expect("revision"))
|
||||
.await;
|
||||
assert_eq!(outcome, GrokCredentialRecoveryOutcome::Recovered);
|
||||
assert_eq!(store.account(&id).expect("account").revision().get(), 2);
|
||||
|
||||
// cooldown 仍有效(轮换不清除)。
|
||||
assert!(
|
||||
cooldowns
|
||||
.cooldown(&id)
|
||||
.is_some_and(|cooldown| cooldown.until() > SystemTime::now()),
|
||||
"rotation must not clear account runtime cooldown"
|
||||
);
|
||||
// cooldown 活跃期间 refresh worker 跳过该账号(不清除、不刷新)。
|
||||
force_due(&store, &id).await;
|
||||
let outcomes = service.refresh_due().await.expect("refresh due");
|
||||
assert!(
|
||||
outcomes.is_empty(),
|
||||
"cooldown-active account must be skipped by refresh worker"
|
||||
);
|
||||
assert_eq!(store.account(&id).expect("account").revision().get(), 2);
|
||||
assert!(matches!(
|
||||
outcomes.as_slice(),
|
||||
[GrokCredentialRefreshOutcome::Refreshed {
|
||||
account_id,
|
||||
credential_revision,
|
||||
}] if account_id == &id && credential_revision.get() == 4
|
||||
));
|
||||
assert_eq!(refresher.prepare_calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
@@ -194,6 +194,7 @@ struct StoredAccount {
|
||||
account: ProviderAccount,
|
||||
credential: PlaintextCredential,
|
||||
quota: Option<QuotaObservation>,
|
||||
last_error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -214,6 +215,12 @@ impl MemoryProviderAccountStore {
|
||||
.map(|stored| stored.account.clone())
|
||||
}
|
||||
|
||||
pub fn last_error_message(&self, id: &ProviderAccountId) -> Option<String> {
|
||||
lock(&self.accounts)
|
||||
.get(id)
|
||||
.and_then(|stored| stored.last_error_message.clone())
|
||||
}
|
||||
|
||||
pub fn credential(&self, id: &ProviderAccountId) -> Option<PlaintextCredential> {
|
||||
lock(&self.accounts)
|
||||
.get(id)
|
||||
@@ -246,6 +253,7 @@ impl ProviderAccountStore for MemoryProviderAccountStore {
|
||||
account: account.account,
|
||||
credential: account.credential,
|
||||
quota: None,
|
||||
last_error_message: None,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
@@ -388,6 +396,11 @@ impl ProviderAccountStore for MemoryProviderAccountStore {
|
||||
plan_type: stored.account.plan_type().map(str::to_owned),
|
||||
},
|
||||
);
|
||||
if change.availability == AccountAvailability::Ready {
|
||||
stored.last_error_message = None;
|
||||
} else if let Some(message) = change.message {
|
||||
stored.last_error_message = Some(message);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -57,16 +57,17 @@ api:
|
||||
openai:
|
||||
# 上游地址、WebSocket 池、额度刷新和 OAuth 使用 Provider 内置默认值;模板只保留画像启动基线。
|
||||
wire_profile:
|
||||
# Provider 每 24 小时读取官方 npm metadata 与 Desktop appcast,仅在进程内更新版本字段。
|
||||
# codex_version 是 npm CLI 的启动基线,并用于模型目录与请求 UA;Provider 每 24 小时更新它。
|
||||
# Desktop appcast 同期只更新 Desktop 版本与构建号。
|
||||
originator: 'Codex Desktop'
|
||||
codex_version: '0.146.0'
|
||||
desktop_version: '26.727.51351'
|
||||
desktop_build: '6119'
|
||||
os_type: 'macOS'
|
||||
os_type: 'Mac OS'
|
||||
os_version: '15.7.1'
|
||||
arch: 'arm64'
|
||||
terminal: 'unknown'
|
||||
verified_at: '2026-07-25T00:00:00+08:00'
|
||||
verified_at: '2026-08-04T00:00:00+08:00'
|
||||
|
||||
xai:
|
||||
# OAuth 端点、额度与模型目录策略由 xAI Provider 固定管理;这里只声明请求画像启动基线。
|
||||
|
||||
+6
-6
@@ -171,13 +171,13 @@ OAuth start 使用:
|
||||
|
||||
### OpenAI 身份、额度与状态
|
||||
|
||||
- 导入会校验签名 access token 和稳定账号身份;`/wham/usage` 用于补全账号/套餐事实和额度,
|
||||
不是判断 token 是否有效的唯一依据。
|
||||
- 文件导入只轻量解析 access token 的未验签 JWT payload:要求 `exp` 未过期且存在
|
||||
`chatgpt_account_id`;不调用 JWKS 或 `/wham/usage`,也不做账号身份一致性比对。
|
||||
- 首次 OAuth 和重新授权保留回调 `state`、PKCE 与官方 token exchange。回调地址只承载 `code`/`state`,
|
||||
不以 host/path 形式作为拒绝条件。
|
||||
- 账号文件导入和首次 OAuth 创建在 credential 提交后立即尝试一次额度观测。观测失败只记录告警,
|
||||
不回滚已提交的账号;重新授权只轮换目标账号 credential,不隐式等同于手工额度刷新。
|
||||
- 重新授权在 start 时绑定目标账号,complete 时重新读取目标的当前 credential revision,并验证上游
|
||||
account/user 身份没有换绑。Free token 缺少 `chatgpt_account_id` 时,后端使用目标账号 header 调用
|
||||
`/wham/usage` 交叉确认,而不是把该账号推断成 K12。
|
||||
不回滚已提交的账号;重新授权只轮换目标账号 credential,不隐式等同于手工额度刷新,也不更新既有
|
||||
upstream account/user 或 OAuth principal。
|
||||
- OAuth pending flow 先取得带过期时间的独占 claim,只有账号事务提交成功后才消费。失败会释放 claim,
|
||||
但上游 authorization code 本身通常只能交换一次;已完成过 token exchange 时应重新创建 OAuth flow。
|
||||
- `GET /accounts/quota` 只读取最后一次落库快照;`POST /accounts/quota/refresh` 才访问上游。access token
|
||||
|
||||
@@ -116,15 +116,15 @@ Provider 独占以下职责:
|
||||
- OpenAI 是逐字节透明代理:请求 body 的未知字段和字段顺序保持不变(受控模型映射除外),上游 wire 是客户端可见的事实来源,HTTP SSE 帧以原始字节原样下发,WebSocket 上游 JSON 文本原样下发;response ID 以 opaque bytes 存储,不假设 UUID 或固定长度。canonical facts 只从同一帧旁路解析,用于观测、亲和与计费。仅网关内部帧(rate limits、metadata)被就地消费,不向客户端转发。
|
||||
- xAI 是翻译层:把 Grok wire 解码后重新合成 Responses wire 事件,不承诺字节透明;上游错误按结构化 message/code/type 透出,message 中的 UUID 账号指纹替换为占位符。
|
||||
|
||||
credential 更新使用 `credential_revision` CAS。认证永久失败、封禁、额度耗尽和带截止时间的 cooldown 是账号运行时事实。cooldown 到期后,Core 的有效调度谓词会自然允许账号重新参与选择;Redis cooldown 只是热缓存。成功调用或成功额度观测才可把账号状态重新观测为 `ready`。自动 refresh 的瞬态失败(限流、5xx、超时、畸形响应)保留现有凭据并按指数退避推迟下一次刷新,不把账号标记为失效;只有上游明确的永久错误(`invalid_grant`、封禁)才写入终态失效。
|
||||
credential 更新使用 `credential_revision` CAS。认证永久失败、封禁、额度耗尽和带截止时间的 cooldown 是账号运行时事实。cooldown 到期后,Core 的有效调度谓词会自然允许账号重新参与选择;Redis cooldown 只是热缓存。成功调用或成功额度观测才可把账号状态重新观测为 `ready`。推理请求的账号/model cooldown 只约束数据面调度,不参与 OAuth refresh;OpenAI 与 xAI 的 OAuth refresh 请求状态不明确时只按 OAuth 自身的有界退避推进该 credential 的 `next_refresh_at`:前五次约为 5/15/45/135/300 秒,之后约每 10 分钟恢复;仍有效 AT 时不晚于其到期时刻,AT 到期后仍按该节奏恢复,至 `AT 到期 + 24h` 才终态化。自动 refresh 的瞬态失败(限流、5xx、超时、畸形响应)保留现有凭据并按指数退避推迟下一次刷新,不把账号标记为失效;上游明确的永久错误(`invalid_grant`、封禁)仍立即写入终态失效。
|
||||
|
||||
`refresh_token_expires_at` 不是公共 SQL 列或 Core 权威状态。xAI 可在 `provider_credentials_json` 内保存它作为 Provider 私有提示;真正失效以 refresh endpoint 返回的永久错误为准。
|
||||
|
||||
OpenAI 导入、首次 OAuth 和重新授权都先验证签名 token 与稳定账号身份。重新授权的目标绑定只保存
|
||||
目标账号 ID,不接受或冻结客户端提供的 credential revision;complete 阶段重新读取目标账号及其当前
|
||||
revision,并以当前 revision 做最终 CAS。上游账号或用户身份发生换绑时拒绝提交;Free token 缺少
|
||||
账号 claim 时,用目标账号 header 下的 usage 身份做交叉确认。认证主体投影可以随同一上游账号的
|
||||
合法重新授权更新,但目标 upstream account/user 不得改变。
|
||||
OpenAI 导入只对 access token 做轻量、未验签的 JWT payload 检查:payload 可解析、`exp` 未过期且
|
||||
带有 `chatgpt_account_id`。首次 OAuth 和重新授权保留服务端 `state`、PKCE 与官方 token exchange;
|
||||
它们不再调用 JWKS、usage 或账号身份一致性校验。重新授权的目标绑定只保存目标账号 ID,不接受或
|
||||
冻结客户端提供的 credential revision;complete 阶段重新读取目标账号及其当前 revision,并以当前
|
||||
revision 做最终 CAS。重新授权只轮换目标账号的 token,保留既有 upstream account/user 与 principal。
|
||||
|
||||
credential 与 quota 是两套状态机。主动 quota refresh 会拒绝过期 access token;quota 探测的
|
||||
401/403 不具备判定 refresh token 永久失效的证据。成功主动观测和正常推理响应中的 rate-limit header
|
||||
|
||||
@@ -124,8 +124,7 @@ const clientIdentity = computed(() => {
|
||||
const current = profile.value
|
||||
if (!current)
|
||||
return '—'
|
||||
const label = current.provider === 'openai' ? 'Codex Core' : '客户端标识'
|
||||
const value = current.attributes.find(attribute => attribute.label === label)?.value ?? '—'
|
||||
const value = current.attributes.find(attribute => attribute.label === '客户端标识')?.value ?? '—'
|
||||
return current.provider === 'xai' ? toPascalCase(value) : value
|
||||
})
|
||||
|
||||
@@ -144,7 +143,6 @@ const runtimeEnvironment = computed(() => {
|
||||
const present = (value: string) => value !== '—' && value.toLowerCase() !== 'unknown'
|
||||
const primary = [target.osType, target.osVersion]
|
||||
.filter(present)
|
||||
.map(value => value.toLowerCase() === 'linux' ? 'Linux' : value)
|
||||
.join(' ')
|
||||
const details = [target.arch, target.terminal].filter(present)
|
||||
return {
|
||||
@@ -286,7 +284,7 @@ function providerLabel(provider: string) {
|
||||
class="flex items-center gap-1.5 text-[10px] leading-none font-bold text-(--cp-text-muted)"
|
||||
>
|
||||
<Terminal aria-hidden="true" class="size-3.25 text-(--cp-info)" />
|
||||
{{ profile.provider === 'openai' ? 'Codex Core' : 'Grok Client' }}
|
||||
客户端标识
|
||||
</dt>
|
||||
<dd
|
||||
class="mt-2 mb-0 truncate font-mono text-[16px] leading-none font-heavy tabular-nums text-(--cp-text-primary)"
|
||||
|
||||
@@ -1 +1 @@
|
||||
version: "3.1.7"
|
||||
version: "3.1.12"
|
||||
|
||||
Reference in New Issue
Block a user