mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-13 08:06:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4127886584 | ||
|
|
ee3e5db6c2 | ||
|
|
153cb67b9c | ||
|
|
f370d355b6 | ||
|
|
a31dad992c | ||
|
|
55a88f5f6b | ||
|
|
5d90a92582 | ||
|
|
93abe8efcd | ||
|
|
f4b75c42ed | ||
|
|
36ff038c78 | ||
|
|
545009590c | ||
|
|
5b7b9b21ee | ||
|
|
44a2a87b69 | ||
|
|
184e88083b | ||
|
|
53b40aea46 | ||
|
|
aa328b6dbf | ||
|
|
64231ff403 | ||
|
|
d7b80a2066 | ||
|
|
08d63ea3f3 | ||
|
|
ac9c4c85ad | ||
|
|
492c401599 | ||
|
|
a063258a40 | ||
|
|
dc590f2b5f |
@@ -21,6 +21,11 @@ updates:
|
||||
day: monday
|
||||
time: '09:10'
|
||||
timezone: Asia/Shanghai
|
||||
# 固定官方 Codex Desktop 的 TLS 指纹;升级前必须抓取握手并比对扩展顺序。
|
||||
ignore:
|
||||
- dependency-name: hyper-rustls
|
||||
- dependency-name: reqwest
|
||||
- dependency-name: rustls
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
${{ hashFiles('trivy-results.sarif') != '' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
uses: github/codeql-action/upload-sarif@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: runtime-container
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
components: rustfmt,clippy
|
||||
|
||||
- name: Cache Rust
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: backend -> target
|
||||
|
||||
@@ -157,7 +157,7 @@ jobs:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with:
|
||||
version: 11.7.0
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
- name: Detect affected checks
|
||||
id: filter
|
||||
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
|
||||
with:
|
||||
filters: |
|
||||
backend:
|
||||
|
||||
@@ -375,7 +375,7 @@ jobs:
|
||||
PY
|
||||
|
||||
- name: Generate release asset provenance
|
||||
uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-path: dist/release/*
|
||||
|
||||
@@ -581,7 +581,7 @@ jobs:
|
||||
echo "digest=${digest}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Generate container provenance attestation
|
||||
uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.manifest.outputs.digest }}
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
|
||||
|
||||
- name: Cache Rust
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: backend -> target
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with:
|
||||
version: 11.7.0
|
||||
|
||||
|
||||
Generated
+9
-9
@@ -216,9 +216,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-s3"
|
||||
version = "1.140.0"
|
||||
version = "1.141.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9660cf991e512fbe6094f1041ff3d3282bc5aeeeb6184e8de437ec2de024a10"
|
||||
checksum = "d9f9420d3a2467eed22ed3635ca653653162c386a0b0f65c78189f9bd3c1379e"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
@@ -578,9 +578,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.0"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "base64-simd"
|
||||
@@ -1493,7 +1493,7 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"base64 0.23.0",
|
||||
"base64 0.23.1",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"cron",
|
||||
@@ -1519,7 +1519,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.23.0",
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures",
|
||||
@@ -2575,7 +2575,7 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"base64 0.23.0",
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"cookie",
|
||||
@@ -2619,7 +2619,7 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"base64 0.23.0",
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures",
|
||||
@@ -3623,7 +3623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
|
||||
@@ -564,6 +564,15 @@ impl fmt::Debug for RotateCredential {
|
||||
}
|
||||
}
|
||||
|
||||
/// 通用账号用量能否可靠归属到一个 Provider quota 窗口。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum QuotaLocalUsageAttribution {
|
||||
/// 窗口覆盖账号的全部请求,可按账号与时间范围聚合。
|
||||
AccountWide,
|
||||
/// 窗口需要 Provider / 模型级归属,通用账号聚合不可用。
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// 一个 Provider quota 窗口的公共投影。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProviderQuotaWindow {
|
||||
@@ -571,6 +580,7 @@ pub struct ProviderQuotaWindow {
|
||||
pub group: String,
|
||||
pub label: String,
|
||||
pub source: Option<String>,
|
||||
pub local_usage_attribution: QuotaLocalUsageAttribution,
|
||||
pub window_seconds: Option<u64>,
|
||||
pub used_percent: Option<f64>,
|
||||
pub reset_at: Option<DateTime<Utc>>,
|
||||
|
||||
@@ -25,7 +25,7 @@ use crate::{
|
||||
provider_credentials::{
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
|
||||
PrepareCredentialRefresh, ProviderModels, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderQuotaWindow,
|
||||
ProviderQuotaWindow, QuotaLocalUsageAttribution,
|
||||
},
|
||||
},
|
||||
ports::{
|
||||
@@ -160,7 +160,9 @@ impl DefaultAccountsService {
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
for (account, quota) in accounts.iter().zip(quotas) {
|
||||
for window in &mut quota.windows {
|
||||
if window.local_usage.is_none() {
|
||||
if window.local_usage.is_none()
|
||||
&& window.local_usage_attribution == QuotaLocalUsageAttribution::AccountWide
|
||||
{
|
||||
let key = (account.id.clone(), window.key.clone());
|
||||
if let Some(usage) = usage_by_window.get(&key) {
|
||||
window.local_usage = Some(usage.clone());
|
||||
@@ -568,7 +570,9 @@ fn quota_usage_window(
|
||||
account_id: &str,
|
||||
window: &ProviderQuotaWindow,
|
||||
) -> Option<AccountUsageWindowQuery> {
|
||||
if window.local_usage.is_some() {
|
||||
if window.local_usage.is_some()
|
||||
|| window.local_usage_attribution != QuotaLocalUsageAttribution::AccountWide
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let reset_at = window.reset_at?;
|
||||
|
||||
@@ -11,6 +11,7 @@ use gateway_admin::{
|
||||
observability::{DecimalAmount, RequestOutcome, TimeRange},
|
||||
provider_credentials::{
|
||||
CredentialCommitGuard, ProviderDocument, ProviderQuota, ProviderQuotaWindow,
|
||||
QuotaLocalUsageAttribution,
|
||||
},
|
||||
settings::AdminApiKey,
|
||||
},
|
||||
@@ -145,6 +146,7 @@ fn quota_window(
|
||||
group: group.to_owned(),
|
||||
label: group.to_owned(),
|
||||
source: None,
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds,
|
||||
used_percent,
|
||||
reset_at: None,
|
||||
|
||||
@@ -33,7 +33,7 @@ use gateway_admin::{
|
||||
PreparedAuthorizationCredential, PreparedCredentialCreate, PreparedCredentialImport,
|
||||
PreparedCredentialRotation, PreparedCredentialRotationFacts, ProviderDocument,
|
||||
ProviderExport, ProviderExportCredentialInput, ProviderModels, ProviderQuota,
|
||||
ProviderQuotaRequest, ProviderQuotaWindow,
|
||||
ProviderQuotaRequest, ProviderQuotaWindow, QuotaLocalUsageAttribution,
|
||||
},
|
||||
settings::{
|
||||
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
|
||||
@@ -1007,6 +1007,7 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop
|
||||
group: "shortTerm".to_owned(),
|
||||
label: "5小时限额".to_owned(),
|
||||
source: None,
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds: Some(5 * 60 * 60),
|
||||
used_percent: Some(97.0),
|
||||
reset_at: Some(Utc::now() + TimeDelta::hours(1)),
|
||||
@@ -1131,6 +1132,7 @@ async fn accounts_list_should_map_rate_limited_from_quota_limit_reached() {
|
||||
group: "shortTerm".to_owned(),
|
||||
label: "5小时限额".to_owned(),
|
||||
source: None,
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds: Some(5 * 60 * 60),
|
||||
used_percent: Some(100.0),
|
||||
reset_at: Some(Utc::now() + TimeDelta::hours(1)),
|
||||
@@ -1176,6 +1178,7 @@ async fn accounts_list_should_map_quota_exhausted_when_only_limit_reached() {
|
||||
group: "shortTerm".to_owned(),
|
||||
label: "5小时限额".to_owned(),
|
||||
source: None,
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds: Some(5 * 60 * 60),
|
||||
used_percent: Some(100.0),
|
||||
reset_at: Some(Utc::now() + TimeDelta::hours(1)),
|
||||
@@ -1221,6 +1224,7 @@ async fn accounts_list_should_attach_local_usage_to_quota_windows() {
|
||||
group: "shortTerm".to_owned(),
|
||||
label: "5小时限额".to_owned(),
|
||||
source: None,
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds: Some(5 * 60 * 60),
|
||||
used_percent: Some(97.0),
|
||||
reset_at: Some(reset_at),
|
||||
@@ -1260,6 +1264,96 @@ async fn accounts_list_should_attach_local_usage_to_quota_windows() {
|
||||
assert_eq!(usage.total_tokens, Some(4_330_000));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_list_should_not_attach_account_usage_to_model_specific_quota_windows() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let reset_at = Utc::now() + TimeDelta::days(7);
|
||||
provider.set_quota(ProviderQuota {
|
||||
observed_at: Some(Utc::now()),
|
||||
refresh_token_expires_at: None,
|
||||
windows: vec![
|
||||
ProviderQuotaWindow {
|
||||
key: "core-primary".to_owned(),
|
||||
group: "weekly".to_owned(),
|
||||
label: "周额度".to_owned(),
|
||||
source: Some("codex".to_owned()),
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds: Some(7 * 24 * 60 * 60),
|
||||
used_percent: Some(1.0),
|
||||
reset_at: Some(reset_at),
|
||||
limit_reached: false,
|
||||
local_usage: None,
|
||||
provider_data: None,
|
||||
},
|
||||
ProviderQuotaWindow {
|
||||
key: "codex-bengalfox-primary".to_owned(),
|
||||
group: "weekly".to_owned(),
|
||||
label: "GPT-5.3-Codex-Spark · 周额度".to_owned(),
|
||||
source: Some("codex_bengalfox".to_owned()),
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::Unavailable,
|
||||
window_seconds: Some(7 * 24 * 60 * 60),
|
||||
used_percent: Some(0.0),
|
||||
reset_at: Some(reset_at),
|
||||
limit_reached: false,
|
||||
local_usage: None,
|
||||
provider_data: None,
|
||||
},
|
||||
],
|
||||
limit_reached: false,
|
||||
rate_limited_until: None,
|
||||
provider_data: None,
|
||||
});
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
store.set_quota_window_usage(vec![
|
||||
AccountUsageWindowResult {
|
||||
account_id: "acct_test".to_owned(),
|
||||
key: "core-primary".to_owned(),
|
||||
usage: quota_local_usage("acct_test", 12_818_806),
|
||||
},
|
||||
AccountUsageWindowResult {
|
||||
account_id: "acct_test".to_owned(),
|
||||
key: "codex-bengalfox-primary".to_owned(),
|
||||
usage: quota_local_usage("acct_test", 127_926),
|
||||
},
|
||||
]);
|
||||
|
||||
let page = accounts_service(provider, store)
|
||||
.await
|
||||
.accounts()
|
||||
.list(AccountListQuery {
|
||||
page: 1,
|
||||
page_size: gateway_admin::model::PageSize::new(20).expect("page size"),
|
||||
provider_kind: None,
|
||||
search: None,
|
||||
status: None,
|
||||
sort: None,
|
||||
})
|
||||
.await
|
||||
.expect("quota window usage");
|
||||
|
||||
let local_tokens = page.items[0]
|
||||
.quota
|
||||
.windows
|
||||
.iter()
|
||||
.map(|window| {
|
||||
(
|
||||
window.key.as_str(),
|
||||
window
|
||||
.local_usage
|
||||
.as_ref()
|
||||
.and_then(|usage| usage.total_tokens),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
local_tokens,
|
||||
[
|
||||
("core-primary", Some(12_818_806)),
|
||||
("codex-bengalfox-primary", None),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_refresh_provider_failure_should_not_call_store_commit() {
|
||||
let events = events();
|
||||
|
||||
@@ -1438,6 +1438,7 @@ fn quota_window_view(window: ProviderQuotaWindow) -> AccountQuotaWindowView {
|
||||
group,
|
||||
label,
|
||||
source: _,
|
||||
local_usage_attribution: _,
|
||||
window_seconds,
|
||||
used_percent,
|
||||
reset_at,
|
||||
|
||||
@@ -321,6 +321,10 @@ impl ProviderAccount {
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置 RT 存在性与失败后的最早重试时刻。
|
||||
///
|
||||
/// 正常 OAuth 预刷新由 worker 使用 AT 原始过期时间与当前运行时策略动态判断,
|
||||
/// 不应把提前量物化到 `next_refresh_at`。
|
||||
#[must_use]
|
||||
pub const fn with_refresh_schedule(
|
||||
mut self,
|
||||
@@ -392,6 +396,7 @@ impl ProviderAccount {
|
||||
self.access_token_expires_at
|
||||
}
|
||||
|
||||
/// 返回瞬态 OAuth 刷新失败后的最早重试时刻;正常账号为 `None`。
|
||||
#[must_use]
|
||||
pub const fn next_refresh_at(&self) -> Option<SystemTime> {
|
||||
self.next_refresh_at
|
||||
|
||||
@@ -674,22 +674,20 @@ impl ProviderRefreshPolicy {
|
||||
self.concurrency
|
||||
}
|
||||
|
||||
/// 按配置的精确提前量计算刷新时刻。
|
||||
pub fn next_attempt_at(
|
||||
/// 判断 AT 是否已经进入当前配置的提前刷新窗口。
|
||||
///
|
||||
/// `margin` 是运行时策略,不投影为账号的持久化时间字段;已过期 AT 也需要
|
||||
/// 尝试用 RT 恢复,因此视为到期。
|
||||
#[must_use]
|
||||
pub fn is_refresh_due(
|
||||
self,
|
||||
_account_id: &ProviderAccountId,
|
||||
access_token_expires_at: SystemTime,
|
||||
observed_at: SystemTime,
|
||||
) -> Result<SystemTime, ProviderStoreError> {
|
||||
let remaining = access_token_expires_at
|
||||
.duration_since(observed_at)
|
||||
.map_err(|_| invalid_refresh_policy("schedule expired access token"))?;
|
||||
if self.margin >= remaining {
|
||||
return Ok(observed_at);
|
||||
) -> bool {
|
||||
match access_token_expires_at.duration_since(observed_at) {
|
||||
Ok(remaining) => remaining <= self.margin,
|
||||
Err(_) => true,
|
||||
}
|
||||
access_token_expires_at
|
||||
.checked_sub(self.margin)
|
||||
.ok_or_else(|| invalid_refresh_policy("schedule access token refresh"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,8 +57,7 @@ fn refresh_policy_requires_a_positive_margin() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_schedule_should_use_the_exact_configured_margin() {
|
||||
let account = ProviderAccountId::new("acct_stable").expect("valid account");
|
||||
fn refresh_policy_should_mark_tokens_due_at_the_exact_configured_margin() {
|
||||
let policy = ProviderRefreshPolicy::try_new(
|
||||
Duration::from_secs(3_600),
|
||||
NonZeroU32::new(2).expect("positive concurrency"),
|
||||
@@ -67,15 +66,12 @@ fn refresh_schedule_should_use_the_exact_configured_margin() {
|
||||
let observed_at = SystemTime::UNIX_EPOCH + Duration::from_secs(10_000);
|
||||
let expires_at = observed_at + Duration::from_secs(7_200);
|
||||
|
||||
let first = policy
|
||||
.next_attempt_at(&account, expires_at, observed_at)
|
||||
.expect("schedule refresh");
|
||||
assert_eq!(first, expires_at - Duration::from_secs(3_600));
|
||||
assert!(!policy.is_refresh_due(expires_at, observed_at));
|
||||
assert!(policy.is_refresh_due(observed_at + Duration::from_secs(3_600), observed_at));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_schedule_uses_observed_time_when_token_is_inside_safe_window() {
|
||||
let account = ProviderAccountId::new("acct_boundary").expect("valid account");
|
||||
fn refresh_policy_should_mark_expired_tokens_due() {
|
||||
let policy = ProviderRefreshPolicy::try_new(
|
||||
Duration::from_secs(3_600),
|
||||
NonZeroU32::new(1).expect("positive concurrency"),
|
||||
@@ -83,12 +79,7 @@ fn refresh_schedule_uses_observed_time_when_token_is_inside_safe_window() {
|
||||
.expect("valid policy");
|
||||
let observed_at = SystemTime::UNIX_EPOCH + Duration::from_secs(10_000);
|
||||
|
||||
assert_eq!(
|
||||
policy
|
||||
.next_attempt_at(&account, observed_at + Duration::from_secs(1), observed_at,)
|
||||
.expect("schedule immediate refresh"),
|
||||
observed_at
|
||||
);
|
||||
assert!(policy.is_refresh_due(observed_at - Duration::from_secs(1), observed_at));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -13,8 +13,9 @@ use tracing_subscriber::{Layer as _, layer::SubscriberExt as _, util::Subscriber
|
||||
use crate::config::LoggingConfig;
|
||||
|
||||
const LOG_FILE_PREFIX: &str = "codex-proxy-rs";
|
||||
/// OpenAI OAuth 恢复记录含原始 AT/RT,文件日志开启时不能被常规级别过滤掉。
|
||||
const OPENAI_OAUTH_RECOVERY_LOG_TARGET: &str = "openai_oauth_recovery";
|
||||
const OAUTH_RECOVERY_LOG_FILE_PREFIX: &str = "codex-proxy-rs-oath";
|
||||
/// OAuth 恢复记录含原始 AT/RT,文件日志开启时必须落到独立文件。
|
||||
const OAUTH_RECOVERY_LOG_TARGET: &str = "oauth_recovery";
|
||||
|
||||
/// non-blocking 日志 writer 的进程级守卫。
|
||||
pub struct LogGuard {
|
||||
@@ -36,7 +37,8 @@ pub enum LogError {
|
||||
/// 按自然日、单文件大小、保留天数和文件总数初始化结构化日志。
|
||||
pub fn initialize_logging(config: &LoggingConfig) -> Result<LogGuard, LogError> {
|
||||
let directive = env::var("RUST_LOG").unwrap_or_else(|_| config.level.clone());
|
||||
let file_filter = file_filter(&directive)?;
|
||||
let file_filter = application_file_filter(&directive)?;
|
||||
let recovery_file_filter = oauth_recovery_file_filter();
|
||||
let stdout_filter =
|
||||
EnvFilter::try_new(stdout_filter_directive(&directive, config.file.enabled))
|
||||
.map_err(|_| LogError::InvalidFilter)?;
|
||||
@@ -49,25 +51,37 @@ pub fn initialize_logging(config: &LoggingConfig) -> Result<LogGuard, LogError>
|
||||
guards.push(guard);
|
||||
writer
|
||||
});
|
||||
let file_writer = if config.file.enabled {
|
||||
let (file_writer, recovery_file_writer) = if config.file.enabled {
|
||||
let maximum_bytes = config
|
||||
.file
|
||||
.max_file_size_mb
|
||||
.checked_mul(1024 * 1024)
|
||||
.ok_or(LogError::SizeOverflow)?;
|
||||
let writer = RotatingLogWriter::open(
|
||||
let file_writer = RotatingLogWriter::open(
|
||||
config.file.directory.clone(),
|
||||
LOG_FILE_PREFIX,
|
||||
maximum_bytes,
|
||||
config.file.retention_days,
|
||||
config.file.max_files,
|
||||
)?;
|
||||
let (writer, guard) = NonBlockingBuilder::default()
|
||||
let recovery_file_writer = RotatingLogWriter::open(
|
||||
config.file.directory.clone(),
|
||||
OAUTH_RECOVERY_LOG_FILE_PREFIX,
|
||||
maximum_bytes,
|
||||
config.file.retention_days,
|
||||
config.file.max_files,
|
||||
)?;
|
||||
let (file_writer, file_guard) = NonBlockingBuilder::default()
|
||||
.thread_name("gateway-log-file")
|
||||
.finish(writer);
|
||||
guards.push(guard);
|
||||
Some(writer)
|
||||
.finish(file_writer);
|
||||
let (recovery_file_writer, recovery_file_guard) = NonBlockingBuilder::default()
|
||||
.thread_name("gateway-log-oauth-recovery-file")
|
||||
.finish(recovery_file_writer);
|
||||
guards.push(file_guard);
|
||||
guards.push(recovery_file_guard);
|
||||
(Some(file_writer), Some(recovery_file_writer))
|
||||
} else {
|
||||
None
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let stdout_layer = stdout_writer.map(|writer| {
|
||||
@@ -91,23 +105,43 @@ pub fn initialize_logging(config: &LoggingConfig) -> Result<LogGuard, LogError>
|
||||
.with_span_list(true)
|
||||
.with_filter(file_filter)
|
||||
});
|
||||
let recovery_file_layer = recovery_file_writer.map(|writer| {
|
||||
tracing_subscriber::fmt::layer()
|
||||
.json()
|
||||
.with_writer(writer)
|
||||
.with_target(true)
|
||||
.with_file(true)
|
||||
.with_line_number(true)
|
||||
.with_thread_ids(true)
|
||||
.with_thread_names(true)
|
||||
.with_current_span(true)
|
||||
.with_span_list(true)
|
||||
.with_filter(recovery_file_filter)
|
||||
});
|
||||
tracing_subscriber::registry()
|
||||
.with(stdout_layer)
|
||||
.with(file_layer)
|
||||
.with(recovery_file_layer)
|
||||
.try_init()
|
||||
.map_err(|_| LogError::AlreadyInitialized)?;
|
||||
Ok(LogGuard { _writers: guards })
|
||||
}
|
||||
|
||||
fn file_filter(directive: &str) -> Result<EnvFilter, LogError> {
|
||||
let recovery_directive = format!("{OPENAI_OAUTH_RECOVERY_LOG_TARGET}=info")
|
||||
fn application_file_filter(directive: &str) -> Result<EnvFilter, LogError> {
|
||||
let recovery_directive = format!("{OAUTH_RECOVERY_LOG_TARGET}=off")
|
||||
.parse()
|
||||
.expect("static OpenAI OAuth recovery log directive is valid");
|
||||
.expect("static OAuth recovery log directive is valid");
|
||||
EnvFilter::try_new(directive)
|
||||
.map(|filter| filter.add_directive(recovery_directive))
|
||||
.map_err(|_| LogError::InvalidFilter)
|
||||
}
|
||||
|
||||
fn oauth_recovery_file_filter() -> EnvFilter {
|
||||
format!("off,{OAUTH_RECOVERY_LOG_TARGET}=info")
|
||||
.parse()
|
||||
.expect("static OAuth recovery log filter is valid")
|
||||
}
|
||||
|
||||
fn stdout_filter_directive(directive: &str, persistent_log_enabled: bool) -> String {
|
||||
if !persistent_log_enabled {
|
||||
directive.to_owned()
|
||||
@@ -118,6 +152,7 @@ fn stdout_filter_directive(directive: &str, persistent_log_enabled: bool) -> Str
|
||||
|
||||
struct RotatingLogWriter {
|
||||
directory: PathBuf,
|
||||
file_prefix: &'static str,
|
||||
maximum_bytes: u64,
|
||||
retention_days: usize,
|
||||
maximum_files: usize,
|
||||
@@ -130,17 +165,20 @@ struct RotatingLogWriter {
|
||||
impl RotatingLogWriter {
|
||||
fn open(
|
||||
directory: PathBuf,
|
||||
file_prefix: &'static str,
|
||||
maximum_bytes: u64,
|
||||
retention_days: usize,
|
||||
maximum_files: usize,
|
||||
) -> io::Result<Self> {
|
||||
fs::create_dir_all(&directory)?;
|
||||
let date = Utc::now().date_naive();
|
||||
cleanup_log_files(&directory, date, retention_days, maximum_files)?;
|
||||
let (segment, bytes_written) = writable_log_segment(&directory, date, maximum_bytes)?;
|
||||
let file = open_log_segment(&directory, date, segment)?;
|
||||
cleanup_log_files(&directory, file_prefix, date, retention_days, maximum_files)?;
|
||||
let (segment, bytes_written) =
|
||||
writable_log_segment(&directory, file_prefix, date, maximum_bytes)?;
|
||||
let file = open_log_segment(&directory, file_prefix, date, segment)?;
|
||||
Ok(Self {
|
||||
directory,
|
||||
file_prefix,
|
||||
maximum_bytes,
|
||||
retention_days,
|
||||
maximum_files,
|
||||
@@ -168,10 +206,11 @@ impl RotatingLogWriter {
|
||||
} else {
|
||||
self.segment = self.segment.saturating_add(1);
|
||||
}
|
||||
self.file = open_log_segment(&self.directory, self.date, self.segment)?;
|
||||
self.file = open_log_segment(&self.directory, self.file_prefix, self.date, self.segment)?;
|
||||
self.bytes_written = self.file.metadata()?.len();
|
||||
cleanup_log_files(
|
||||
&self.directory,
|
||||
self.file_prefix,
|
||||
self.date,
|
||||
self.retention_days,
|
||||
self.maximum_files,
|
||||
@@ -203,10 +242,11 @@ struct ManagedLogFile {
|
||||
|
||||
fn writable_log_segment(
|
||||
directory: &Path,
|
||||
file_prefix: &str,
|
||||
date: chrono::NaiveDate,
|
||||
maximum_bytes: u64,
|
||||
) -> io::Result<(usize, u64)> {
|
||||
let latest = managed_log_files(directory)?
|
||||
let latest = managed_log_files(directory, file_prefix)?
|
||||
.into_iter()
|
||||
.filter(|entry| entry.date == date)
|
||||
.max_by_key(|entry| entry.segment);
|
||||
@@ -221,26 +261,31 @@ fn writable_log_segment(
|
||||
}
|
||||
}
|
||||
|
||||
fn open_log_segment(directory: &Path, date: chrono::NaiveDate, segment: usize) -> io::Result<File> {
|
||||
fn open_log_segment(
|
||||
directory: &Path,
|
||||
file_prefix: &str,
|
||||
date: chrono::NaiveDate,
|
||||
segment: usize,
|
||||
) -> io::Result<File> {
|
||||
OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(directory.join(log_file_name(date, segment)))
|
||||
.open(directory.join(log_file_name(file_prefix, date, segment)))
|
||||
}
|
||||
|
||||
fn log_file_name(date: chrono::NaiveDate, segment: usize) -> String {
|
||||
fn log_file_name(file_prefix: &str, date: chrono::NaiveDate, segment: usize) -> String {
|
||||
if segment == 0 {
|
||||
format!("{LOG_FILE_PREFIX}.{date}.log")
|
||||
format!("{file_prefix}.{date}.log")
|
||||
} else {
|
||||
format!("{LOG_FILE_PREFIX}.{date}.{segment}.log")
|
||||
format!("{file_prefix}.{date}.{segment}.log")
|
||||
}
|
||||
}
|
||||
|
||||
fn managed_log_files(directory: &Path) -> io::Result<Vec<ManagedLogFile>> {
|
||||
fn managed_log_files(directory: &Path, file_prefix: &str) -> io::Result<Vec<ManagedLogFile>> {
|
||||
Ok(fs::read_dir(directory)?
|
||||
.filter_map(Result::ok)
|
||||
.filter_map(|entry| {
|
||||
parse_log_file_name(entry.file_name().to_string_lossy().as_ref()).map(
|
||||
parse_log_file_name(entry.file_name().to_string_lossy().as_ref(), file_prefix).map(
|
||||
|(date, segment)| ManagedLogFile {
|
||||
date,
|
||||
segment,
|
||||
@@ -251,9 +296,9 @@ fn managed_log_files(directory: &Path) -> io::Result<Vec<ManagedLogFile>> {
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn parse_log_file_name(name: &str) -> Option<(chrono::NaiveDate, usize)> {
|
||||
fn parse_log_file_name(name: &str, file_prefix: &str) -> Option<(chrono::NaiveDate, usize)> {
|
||||
let body = name
|
||||
.strip_prefix(&format!("{LOG_FILE_PREFIX}."))?
|
||||
.strip_prefix(&format!("{file_prefix}."))?
|
||||
.strip_suffix(".log")?;
|
||||
let (date, segment) = body.split_once('.').map_or((body, 0), |(date, segment)| {
|
||||
(date, segment.parse().ok().unwrap_or(usize::MAX))
|
||||
@@ -268,6 +313,7 @@ fn parse_log_file_name(name: &str) -> Option<(chrono::NaiveDate, usize)> {
|
||||
|
||||
fn cleanup_log_files(
|
||||
directory: &Path,
|
||||
file_prefix: &str,
|
||||
today: chrono::NaiveDate,
|
||||
retention_days: usize,
|
||||
maximum_files: usize,
|
||||
@@ -277,7 +323,7 @@ fn cleanup_log_files(
|
||||
.checked_sub_signed(chrono::Duration::days(retention_offset))
|
||||
.unwrap_or(chrono::NaiveDate::MIN);
|
||||
let mut retained = Vec::new();
|
||||
for entry in managed_log_files(directory)? {
|
||||
for entry in managed_log_files(directory, file_prefix)? {
|
||||
if entry.date < cutoff {
|
||||
fs::remove_file(entry.path)?;
|
||||
} else {
|
||||
|
||||
@@ -8,8 +8,13 @@ use gateway_host::system_update::SystemUpdateConfig;
|
||||
|
||||
const LOG_DIRECTORY_ENV: &str = "CPR_LOGGING_TEST_DIRECTORY";
|
||||
const CHILD_PROCESS_ENV: &str = "CPR_LOGGING_TEST_CHILD";
|
||||
const OAUTH_RECOVERY_LOG_TARGET: &str = "openai_oauth_recovery";
|
||||
const APPLICATION_LOG_FILE_PREFIX: &str = "codex-proxy-rs.";
|
||||
const OAUTH_RECOVERY_LOG_FILE_PREFIX: &str = "codex-proxy-rs-oath.";
|
||||
const APPLICATION_LOG_TARGET: &str = "logging_test_application";
|
||||
const APPLICATION_LOG_MARKER: &str = "application-file-filter-test";
|
||||
const OAUTH_RECOVERY_LOG_TARGET: &str = "oauth_recovery";
|
||||
const OAUTH_RECOVERY_LOG_MARKER: &str = "oauth-recovery-file-filter-test";
|
||||
const OAUTH_RECOVERY_PROVIDER: &str = "openai";
|
||||
|
||||
#[test]
|
||||
fn logging_requires_at_least_one_sink() {
|
||||
@@ -42,7 +47,7 @@ fn logging_requires_at_least_one_sink() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_recovery_file_logging_overrides_global_log_level() {
|
||||
fn oauth_recovery_file_logging_is_separate_and_overrides_global_log_level() {
|
||||
if env::var_os(CHILD_PROCESS_ENV).is_some() {
|
||||
write_oauth_recovery_log(PathBuf::from(
|
||||
env::var_os(LOG_DIRECTORY_ENV).expect("child log directory"),
|
||||
@@ -54,12 +59,15 @@ fn oauth_recovery_file_logging_overrides_global_log_level() {
|
||||
let output = Command::new(env::current_exe().expect("current test executable"))
|
||||
.args([
|
||||
"--exact",
|
||||
"logging::oauth_recovery_file_logging_overrides_global_log_level",
|
||||
"logging::oauth_recovery_file_logging_is_separate_and_overrides_global_log_level",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD_PROCESS_ENV, "1")
|
||||
.env(LOG_DIRECTORY_ENV, directory.path())
|
||||
.env("RUST_LOG", "off,openai_oauth_recovery=off")
|
||||
.env(
|
||||
"RUST_LOG",
|
||||
"off,logging_test_application=info,oauth_recovery=off",
|
||||
)
|
||||
.output()
|
||||
.expect("run logging child process");
|
||||
|
||||
@@ -70,9 +78,18 @@ fn oauth_recovery_file_logging_overrides_global_log_level() {
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
|
||||
let log = read_log_directory(directory.path());
|
||||
assert!(log.contains(OAUTH_RECOVERY_LOG_TARGET));
|
||||
assert!(log.contains(OAUTH_RECOVERY_LOG_MARKER));
|
||||
let application_log = read_log_file_set(directory.path(), APPLICATION_LOG_FILE_PREFIX);
|
||||
assert!(application_log.contains(&json_target_field(APPLICATION_LOG_TARGET)));
|
||||
assert!(application_log.contains(APPLICATION_LOG_MARKER));
|
||||
assert!(!application_log.contains(&json_target_field(OAUTH_RECOVERY_LOG_TARGET)));
|
||||
assert!(!application_log.contains(OAUTH_RECOVERY_LOG_MARKER));
|
||||
|
||||
let recovery_log = read_log_file_set(directory.path(), OAUTH_RECOVERY_LOG_FILE_PREFIX);
|
||||
assert!(recovery_log.contains(&json_target_field(OAUTH_RECOVERY_LOG_TARGET)));
|
||||
assert!(recovery_log.contains(OAUTH_RECOVERY_LOG_MARKER));
|
||||
assert!(!recovery_log.contains(&json_target_field(APPLICATION_LOG_TARGET)));
|
||||
assert!(!recovery_log.contains(APPLICATION_LOG_MARKER));
|
||||
assert!(recovery_log.contains(&format!(r#""provider":"{OAUTH_RECOVERY_PROVIDER}""#)));
|
||||
}
|
||||
|
||||
fn write_oauth_recovery_log(directory: PathBuf) {
|
||||
@@ -104,23 +121,39 @@ fn write_oauth_recovery_log(directory: PathBuf) {
|
||||
.block_on(gateway_host::initialize(config))
|
||||
.expect("initialize logging");
|
||||
|
||||
tracing::info!(
|
||||
target: APPLICATION_LOG_TARGET,
|
||||
marker = APPLICATION_LOG_MARKER,
|
||||
"application file test record"
|
||||
);
|
||||
tracing::info!(
|
||||
target: OAUTH_RECOVERY_LOG_TARGET,
|
||||
provider = OAUTH_RECOVERY_PROVIDER,
|
||||
marker = OAUTH_RECOVERY_LOG_MARKER,
|
||||
"OpenAI OAuth recovery test record"
|
||||
"OAuth recovery test record"
|
||||
);
|
||||
drop(bundle);
|
||||
}
|
||||
|
||||
fn read_log_directory(directory: &Path) -> String {
|
||||
fn read_log_file_set(directory: &Path, file_prefix: &str) -> String {
|
||||
let mut files = fs::read_dir(directory)
|
||||
.expect("read log directory")
|
||||
.map(|entry| entry.expect("read log entry").path())
|
||||
.filter(|path| {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.starts_with(file_prefix))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
files.sort();
|
||||
|
||||
assert!(!files.is_empty(), "expected log file set {file_prefix}");
|
||||
files
|
||||
.into_iter()
|
||||
.map(|path| fs::read_to_string(path).expect("read log file"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn json_target_field(target: &str) -> String {
|
||||
format!(r#""target":"{target}""#)
|
||||
}
|
||||
|
||||
@@ -315,6 +315,26 @@ pub fn is_rate_limit_header_name(name: &str) -> bool {
|
||||
|| rate_limit_id_from_header_name(&normalized).is_some()
|
||||
}
|
||||
|
||||
/// 判断响应头是否为仅供代理本地观测的 Codex 额度信号。
|
||||
///
|
||||
/// 这类头会让下游官方 Codex 客户端显示账户额度提示,因此不能穿过账号隔离边界。
|
||||
/// 通用限流头(例如 `retry-after`)和非额度的 Codex 头仍允许下发。
|
||||
pub fn is_codex_quota_header_name(name: &str) -> bool {
|
||||
let normalized = name.trim().to_ascii_lowercase();
|
||||
matches!(
|
||||
normalized.as_str(),
|
||||
"x-codex-credits-has-credits"
|
||||
| "x-codex-credits-unlimited"
|
||||
| "x-codex-credits-balance"
|
||||
| "x-codex-active-limit"
|
||||
| "x-codex-plan-type"
|
||||
| "x-codex-promo-message"
|
||||
| "x-codex-rate-limit-reached-type"
|
||||
| "x-codex-primary-over-secondary-limit-percent"
|
||||
) || rate_limit_id_from_header_name(&normalized)
|
||||
.is_some_and(|limit_id| limit_id == "codex" || limit_id.starts_with("codex_"))
|
||||
}
|
||||
|
||||
/// 从内部 `codex.rate_limits` 事件中解析限流信息。
|
||||
pub fn parse_rate_limits_event(value: &Value) -> Option<ParsedRateLimits> {
|
||||
if value.get("type").and_then(Value::as_str) != Some("codex.rate_limits") {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use gateway_protocol::openai::events::{
|
||||
RateLimitWindow, TokenUsage, extract_sse_usage, extract_usage, is_rate_limit_header_name,
|
||||
parse_rate_limit_headers, parse_rate_limits_event, rate_limits_to_header_pairs,
|
||||
retry_after_seconds_from_body,
|
||||
RateLimitWindow, TokenUsage, extract_sse_usage, extract_usage, is_codex_quota_header_name,
|
||||
is_rate_limit_header_name, parse_rate_limit_headers, parse_rate_limits_event,
|
||||
rate_limits_to_header_pairs, retry_after_seconds_from_body,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -537,3 +537,21 @@ fn rate_limit_header_filter_should_accept_only_supported_domain_headers() {
|
||||
[true, true, true, false, false]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_quota_header_filter_should_only_match_client_quota_signals() {
|
||||
assert_eq!(
|
||||
[
|
||||
"X-CoDeX-Primary-Used-Percent",
|
||||
"x-codex-code-review-primary-reset-at",
|
||||
"x-codex-credits-balance",
|
||||
"x-codex-primary-over-secondary-limit-percent",
|
||||
"retry-after",
|
||||
"x-ratelimit-remaining",
|
||||
"x-codex-safety-buffering-enabled",
|
||||
"x-codex-turn-state",
|
||||
]
|
||||
.map(is_codex_quota_header_name),
|
||||
[true, true, true, true, false, false, false, false]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -225,19 +225,6 @@ pub(crate) async fn update_runtime_settings_in_transaction(
|
||||
update.validate()?;
|
||||
let refresh_margin_seconds =
|
||||
i64::try_from(update.refresh_margin_seconds).map_err(|_| invalid_numeric())?;
|
||||
let previous_refresh_margin_seconds = sqlx::query_scalar::<_, i64>(
|
||||
"select refresh_margin_seconds
|
||||
from runtime_settings
|
||||
where id = 1
|
||||
for update",
|
||||
)
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("lock runtime settings for update"))?
|
||||
.ok_or_else(|| StoreError::NotFound {
|
||||
entity: "runtime settings",
|
||||
id: "1".to_owned(),
|
||||
})?;
|
||||
let next = sqlx::query_scalar::<_, i64>(
|
||||
"update runtime_settings
|
||||
set config_revision = config_revision + 1,
|
||||
@@ -272,35 +259,9 @@ pub(crate) async fn update_runtime_settings_in_transaction(
|
||||
entity: "runtime settings",
|
||||
id: "1".to_owned(),
|
||||
})?;
|
||||
if previous_refresh_margin_seconds != refresh_margin_seconds {
|
||||
reschedule_provider_refreshes_in_transaction(transaction, refresh_margin_seconds).await?;
|
||||
}
|
||||
Revision::new(u64::try_from(next).map_err(|_| invalid_numeric())?)
|
||||
}
|
||||
|
||||
async fn reschedule_provider_refreshes_in_transaction(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
refresh_margin_seconds: i64,
|
||||
) -> StoreResult<()> {
|
||||
sqlx::query(
|
||||
"update provider_accounts
|
||||
set next_refresh_at = case
|
||||
when access_token_expires_at <= now()
|
||||
or extract(epoch from (access_token_expires_at - now())) <= $1
|
||||
then now()
|
||||
else access_token_expires_at - ($1::double precision * interval '1 second')
|
||||
end,
|
||||
updated_at = now()
|
||||
where has_refresh_token
|
||||
and access_token_expires_at is not null",
|
||||
)
|
||||
.bind(refresh_margin_seconds)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("reschedule provider credential refreshes"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn bump_config_revision_in_transaction(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
) -> StoreResult<Revision> {
|
||||
|
||||
@@ -42,7 +42,7 @@ fn runtime_settings_reject_invalid_model_mapping() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_margin_change_should_reschedule_existing_refreshable_accounts() {
|
||||
async fn refresh_margin_change_should_preserve_existing_account_refresh_facts() {
|
||||
let Some(database) = TestDatabase::create("refresh_margin_reschedule").await else {
|
||||
return;
|
||||
};
|
||||
@@ -55,6 +55,7 @@ async fn refresh_margin_change_should_reschedule_existing_refreshable_accounts()
|
||||
)
|
||||
.await;
|
||||
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
||||
let before = account_refresh_facts(&database.pool, "acct_refresh_margin_changed").await;
|
||||
|
||||
repository
|
||||
.update_runtime_settings(settings_with_margin(1_800))
|
||||
@@ -62,14 +63,14 @@ async fn refresh_margin_change_should_reschedule_existing_refreshable_accounts()
|
||||
.expect("update refresh margin");
|
||||
|
||||
assert_eq!(
|
||||
account_next_refresh_at(&database.pool, "acct_refresh_margin_changed").await,
|
||||
expires_at - TimeDelta::seconds(1_800)
|
||||
account_refresh_facts(&database.pool, "acct_refresh_margin_changed").await,
|
||||
before
|
||||
);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unchanged_refresh_margin_should_preserve_existing_retry_schedule() {
|
||||
async fn unchanged_refresh_margin_should_preserve_existing_account_refresh_facts() {
|
||||
let Some(database) = TestDatabase::create("refresh_margin_unchanged").await else {
|
||||
return;
|
||||
};
|
||||
@@ -83,6 +84,7 @@ async fn unchanged_refresh_margin_should_preserve_existing_retry_schedule() {
|
||||
)
|
||||
.await;
|
||||
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
||||
let before = account_refresh_facts(&database.pool, "acct_refresh_margin_unchanged").await;
|
||||
|
||||
repository
|
||||
.update_runtime_settings(settings_with_margin(3_600))
|
||||
@@ -90,8 +92,8 @@ async fn unchanged_refresh_margin_should_preserve_existing_retry_schedule() {
|
||||
.expect("update unrelated runtime settings");
|
||||
|
||||
assert_eq!(
|
||||
account_next_refresh_at(&database.pool, "acct_refresh_margin_unchanged").await,
|
||||
retry_at
|
||||
account_refresh_facts(&database.pool, "acct_refresh_margin_unchanged").await,
|
||||
before
|
||||
);
|
||||
database.close().await;
|
||||
}
|
||||
@@ -118,12 +120,31 @@ async fn insert_refreshable_account(
|
||||
.expect("insert refreshable account");
|
||||
}
|
||||
|
||||
async fn account_next_refresh_at(pool: &sqlx::PgPool, account_id: &str) -> DateTime<Utc> {
|
||||
sqlx::query_scalar("select next_refresh_at from provider_accounts where id = $1")
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct AccountRefreshFacts {
|
||||
access_token_expires_at: DateTime<Utc>,
|
||||
next_refresh_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
credential_revision: i64,
|
||||
}
|
||||
|
||||
async fn account_refresh_facts(pool: &sqlx::PgPool, account_id: &str) -> AccountRefreshFacts {
|
||||
let (access_token_expires_at, next_refresh_at, updated_at, credential_revision) =
|
||||
sqlx::query_as(
|
||||
"select access_token_expires_at, next_refresh_at, updated_at, credential_revision
|
||||
from provider_accounts
|
||||
where id = $1",
|
||||
)
|
||||
.bind(account_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("load next refresh time")
|
||||
.expect("load account refresh facts");
|
||||
AccountRefreshFacts {
|
||||
access_token_expires_at,
|
||||
next_refresh_at,
|
||||
updated_at,
|
||||
credential_revision,
|
||||
}
|
||||
}
|
||||
|
||||
fn timestamp_micros(value: DateTime<Utc>) -> DateTime<Utc> {
|
||||
|
||||
@@ -19,7 +19,7 @@ use gateway_admin::model::provider_credentials::{
|
||||
PreparedCredentialCreate, PreparedCredentialImport, PreparedCredentialRotation,
|
||||
PreparedCredentialRotationFacts, ProviderDocument, ProviderExport,
|
||||
ProviderExportCredentialInput, ProviderModel, ProviderModels, ProviderQuota,
|
||||
ProviderQuotaRequest, ProviderQuotaWindow,
|
||||
ProviderQuotaRequest, ProviderQuotaWindow, QuotaLocalUsageAttribution,
|
||||
};
|
||||
use gateway_admin::model::{MutationActor, MutationContext, Revision};
|
||||
use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind};
|
||||
@@ -744,6 +744,11 @@ fn project_quota_snapshot(snapshot: CodexAccountQuotaSnapshot) -> ProviderQuota
|
||||
window.window_seconds(),
|
||||
),
|
||||
source: Some(window.source().to_owned()),
|
||||
local_usage_attribution: if window.is_account_wide() {
|
||||
QuotaLocalUsageAttribution::AccountWide
|
||||
} else {
|
||||
QuotaLocalUsageAttribution::Unavailable
|
||||
},
|
||||
window_seconds: window.window_seconds(),
|
||||
used_percent: window.used_percent(),
|
||||
reset_at: window.reset_at(),
|
||||
@@ -802,6 +807,7 @@ fn force_confirmed_exhaustion_projection(quota: &mut ProviderQuota) {
|
||||
group: "other".to_owned(),
|
||||
label: "额度".to_owned(),
|
||||
source: None,
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::Unavailable,
|
||||
window_seconds: None,
|
||||
used_percent: Some(100.0),
|
||||
reset_at: None,
|
||||
|
||||
@@ -16,8 +16,7 @@ use gateway_core::engine::credential::{
|
||||
};
|
||||
use gateway_core::provider_ports::{
|
||||
ProviderLeaseAcquisition, ProviderLeaseGuard, ProviderLeasePort, ProviderLeaseRequest,
|
||||
ProviderRefreshCapacityRequest, ProviderRefreshLeaseRequest, ProviderRefreshPolicy,
|
||||
ProviderRuntimePolicyPort,
|
||||
ProviderRefreshCapacityRequest, ProviderRefreshLeaseRequest, ProviderRuntimePolicyPort,
|
||||
};
|
||||
use gateway_core::routing::ProviderKind;
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
@@ -31,7 +30,7 @@ use super::token_client::{RefreshFailure, TokenRefresher};
|
||||
use super::types::{
|
||||
CODEX_AUTHENTICATION_KIND_AGENT_IDENTITY, CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile,
|
||||
CodexAgentIdentityAuthMode, CodexAgentIdentityCredentialData, CodexCredentialData,
|
||||
CodexCredentialPrincipal, CodexOAuthMetadata, CodexOAuthSecret,
|
||||
CodexCredentialPrincipal, CodexOAuthMetadata, CodexOAuthSecret, parse_access_token_expiration,
|
||||
};
|
||||
|
||||
const PROVIDER_NAME: &str = "openai";
|
||||
@@ -803,12 +802,9 @@ impl CodexCredentialAdminService {
|
||||
.expires_in
|
||||
.and_then(|expires_in| SystemTime::now().checked_add(expires_in))
|
||||
.map(DateTime::<Utc>::from);
|
||||
let next_refresh_at = refresh_time(
|
||||
policy,
|
||||
&account_id,
|
||||
access_token_expires_at,
|
||||
secret.refresh_token.is_some(),
|
||||
)?;
|
||||
// 正常预刷新由 worker 读取当时的 runtime policy 动态判断;这里仅清除
|
||||
// 之前瞬态失败留下的 retry-not-before。
|
||||
let next_refresh_at = None;
|
||||
let mut prepared = CodexCredentialAdmin.prepare_refreshed_oauth_rotation(
|
||||
current,
|
||||
secret,
|
||||
@@ -875,13 +871,6 @@ impl CodexCredentialAdminService {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
apply_import_metadata(&mut metadata, &candidate);
|
||||
let next_refresh_at = self
|
||||
.best_effort_import_refresh_time(
|
||||
&typed_account_id,
|
||||
access_token_expires_at,
|
||||
secret.refresh_token.is_some(),
|
||||
)
|
||||
.await;
|
||||
CodexCredentialAdmin.prepare_unresolved_oauth(
|
||||
UnresolvedCodexOAuthCredential {
|
||||
account_id,
|
||||
@@ -895,7 +884,7 @@ impl CodexCredentialAdminService {
|
||||
secret,
|
||||
metadata,
|
||||
access_token_expires_at,
|
||||
next_refresh_at,
|
||||
next_refresh_at: None,
|
||||
enabled: true,
|
||||
},
|
||||
)?
|
||||
@@ -944,6 +933,7 @@ impl CodexCredentialAdminService {
|
||||
) -> Result<(CodexOAuthSecret, Option<DateTime<Utc>>), CodexCredentialAdminError> {
|
||||
let id_token = id_token.map(SecretString::from);
|
||||
if let Some(access_token) = access_token {
|
||||
let access_token_expires_at = parse_access_token_expiration(&access_token);
|
||||
let secret = CodexOAuthSecret {
|
||||
access_token: SecretString::from(access_token),
|
||||
refresh_token: refresh_token.map(SecretString::from),
|
||||
@@ -954,7 +944,7 @@ impl CodexCredentialAdminService {
|
||||
Some(account_id.as_str()),
|
||||
&secret,
|
||||
);
|
||||
return Ok((secret, None));
|
||||
return Ok((secret, access_token_expires_at));
|
||||
}
|
||||
let refresh_token = refresh_token.ok_or(CodexCredentialAdminError::InvalidCredential)?;
|
||||
let tokens = self
|
||||
@@ -983,23 +973,6 @@ impl CodexCredentialAdminService {
|
||||
Ok((secret, access_token_expires_at))
|
||||
}
|
||||
|
||||
async fn best_effort_import_refresh_time(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
access_token_expires_at: Option<DateTime<Utc>>,
|
||||
has_refresh_token: bool,
|
||||
) -> Option<DateTime<Utc>> {
|
||||
let policy = self.runtime_policy.load_refresh_policy().await.ok()?;
|
||||
refresh_time(
|
||||
policy,
|
||||
account_id,
|
||||
access_token_expires_at,
|
||||
has_refresh_token,
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn record_recovery_log(
|
||||
operation: CodexOAuthRecoveryOperation,
|
||||
account_id: Option<&str>,
|
||||
@@ -1088,26 +1061,6 @@ fn optional_time(value: Option<chrono::DateTime<chrono::Utc>>) -> Option<SystemT
|
||||
value.map(SystemTime::from)
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_time(
|
||||
policy: ProviderRefreshPolicy,
|
||||
account_id: &ProviderAccountId,
|
||||
access_token_expires_at: Option<DateTime<Utc>>,
|
||||
has_refresh_token: bool,
|
||||
) -> Result<Option<DateTime<Utc>>, CodexCredentialAdminError> {
|
||||
if !has_refresh_token {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(expires_at) = access_token_expires_at.map(SystemTime::from) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let observed_at = SystemTime::now();
|
||||
policy
|
||||
.next_attempt_at(account_id, expires_at, observed_at)
|
||||
.map(DateTime::<Utc>::from)
|
||||
.map(Some)
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidCredential)
|
||||
}
|
||||
|
||||
fn cpr_status(account: &ProviderAccount) -> &'static str {
|
||||
if !account.enabled() {
|
||||
return "disabled";
|
||||
|
||||
@@ -19,7 +19,6 @@ use gateway_admin::model::{
|
||||
use gateway_core::engine::credential::{
|
||||
LoadedCredential, NewProviderAccount, ProviderAccountId, ProviderAccountStore,
|
||||
};
|
||||
use gateway_core::provider_ports::ProviderRuntimePolicyPort;
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use sha2::{Digest, Sha256};
|
||||
use subtle::ConstantTimeEq as _;
|
||||
@@ -28,10 +27,9 @@ use uuid::Uuid;
|
||||
|
||||
use super::admin::{
|
||||
CodexCredentialAdmin, CodexCredentialAdminError, PreparedCodexCredentialRotation,
|
||||
UnresolvedCodexOAuthCredential, refresh_time,
|
||||
UnresolvedCodexOAuthCredential,
|
||||
};
|
||||
use super::recovery_log::{CodexOAuthRecoveryOperation, record_oauth_recovery};
|
||||
use super::refresh::initial_oauth_refresh_at;
|
||||
use super::security::CodexCredentialCodec;
|
||||
use super::token_client::{
|
||||
AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant,
|
||||
@@ -348,7 +346,6 @@ pub struct CodexOAuthAdminService {
|
||||
pending: Arc<dyn CodexOAuthPendingStore>,
|
||||
exchanger: Arc<dyn AuthorizationCodeExchanger>,
|
||||
store: Arc<dyn ProviderAccountStore>,
|
||||
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
|
||||
credentials: CodexCredentialAdmin,
|
||||
oauth_client_id: String,
|
||||
}
|
||||
@@ -399,14 +396,12 @@ impl CodexOAuthAdminService {
|
||||
pending: Arc<dyn CodexOAuthPendingStore>,
|
||||
exchanger: Arc<dyn AuthorizationCodeExchanger>,
|
||||
store: Arc<dyn ProviderAccountStore>,
|
||||
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
|
||||
credentials: CodexCredentialAdmin,
|
||||
) -> Self {
|
||||
Self {
|
||||
pending,
|
||||
exchanger,
|
||||
store,
|
||||
runtime_policy,
|
||||
credentials,
|
||||
oauth_client_id: OFFICIAL_CODEX_OAUTH_CLIENT_ID.to_owned(),
|
||||
}
|
||||
@@ -513,31 +508,18 @@ impl CodexOAuthAdminService {
|
||||
.map_err(|_| CodexOAuthAdminError::TokenRejected)?;
|
||||
secret.id_token = Some(id_token);
|
||||
let credential = if let Some(current) = current {
|
||||
let next_refresh_at = self
|
||||
.best_effort_refresh_time(
|
||||
current.account.id(),
|
||||
access_token_expires_at,
|
||||
secret.refresh_token.is_some(),
|
||||
)
|
||||
.await;
|
||||
CompletedCodexOAuthCredential::Reauthorize(
|
||||
self.credentials
|
||||
.prepare_refreshed_oauth_rotation(
|
||||
current,
|
||||
secret,
|
||||
access_token_expires_at,
|
||||
next_refresh_at,
|
||||
None,
|
||||
)
|
||||
.map_err(map_admin_error)?,
|
||||
)
|
||||
} else {
|
||||
let account_id = format!("acct_{}", Uuid::now_v7().simple());
|
||||
let next_refresh_at = self
|
||||
.best_effort_initial_refresh_time(
|
||||
access_token_expires_at,
|
||||
secret.refresh_token.is_some(),
|
||||
)
|
||||
.await;
|
||||
CompletedCodexOAuthCredential::Create(
|
||||
self.credentials
|
||||
.prepare_unresolved_oauth(UnresolvedCodexOAuthCredential {
|
||||
@@ -546,7 +528,7 @@ impl CodexOAuthAdminService {
|
||||
secret,
|
||||
metadata,
|
||||
access_token_expires_at,
|
||||
next_refresh_at,
|
||||
next_refresh_at: None,
|
||||
enabled: true,
|
||||
})
|
||||
.map_err(map_admin_error)?,
|
||||
@@ -554,38 +536,6 @@ impl CodexOAuthAdminService {
|
||||
};
|
||||
Ok((mutation, credential))
|
||||
}
|
||||
|
||||
async fn best_effort_refresh_time(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
access_token_expires_at: Option<DateTime<Utc>>,
|
||||
has_refresh_token: bool,
|
||||
) -> Option<DateTime<Utc>> {
|
||||
let policy = self.runtime_policy.load_refresh_policy().await.ok()?;
|
||||
refresh_time(
|
||||
policy,
|
||||
account_id,
|
||||
access_token_expires_at,
|
||||
has_refresh_token,
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
async fn best_effort_initial_refresh_time(
|
||||
&self,
|
||||
access_token_expires_at: Option<DateTime<Utc>>,
|
||||
has_refresh_token: bool,
|
||||
) -> Option<DateTime<Utc>> {
|
||||
let policy = self.runtime_policy.load_refresh_policy().await.ok()?;
|
||||
initial_oauth_refresh_at(
|
||||
policy,
|
||||
access_token_expires_at.map(SystemTime::from),
|
||||
SystemTime::now(),
|
||||
has_refresh_token,
|
||||
)
|
||||
.map(DateTime::<Utc>::from)
|
||||
}
|
||||
}
|
||||
|
||||
impl CodexOAuthAdminService {
|
||||
|
||||
@@ -36,7 +36,10 @@ use crate::transport::{CodexBackendClient, CodexClientError, CodexRequestContext
|
||||
|
||||
use super::agent_identity::{CodexAgentIdentityTaskService, PreparedCodexRuntimeCredential};
|
||||
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
|
||||
use recovery::{quota_state_reason, quota_state_transition, quota_success_state};
|
||||
use recovery::{
|
||||
authoritative_quota_snapshot_state, 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,
|
||||
@@ -337,7 +340,7 @@ fn quota_refresh_candidate(
|
||||
now: SystemTime,
|
||||
limit_reached: bool,
|
||||
) -> Option<ProviderAccount> {
|
||||
// 定时器只兜底「已耗尽(402)」或「任一窗口触顶(limit_reached 死锁)」账号,
|
||||
// 定时器只兜底已耗尽或任一窗口触顶的账号;
|
||||
// 正常账号由真实请求的响应头被动同步。
|
||||
(eligible_periodic_quota_refresh(&account, now)
|
||||
&& (account.availability() == AccountAvailability::QuotaExhausted || limit_reached))
|
||||
@@ -397,7 +400,7 @@ impl CodexCredentialQuotaService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 写入新快照前读取已确认耗尽窗口的 reset,用于判断窗口是否真的推进。
|
||||
/// 写入新快照前读取 core primary 已确认耗尽窗口的 reset,用于判断该窗口是否真的推进。
|
||||
async fn previous_confirmed_reset(&self, account: &ProviderAccount) -> Option<SystemTime> {
|
||||
let observations = self
|
||||
.store
|
||||
@@ -409,7 +412,10 @@ impl CodexCredentialQuotaService {
|
||||
&& observation.expected_revision == account.revision()
|
||||
})?;
|
||||
let snapshot = quota_snapshot_from_observation(&observation)?;
|
||||
snapshot.fact().resets_at().map(SystemTime::from)
|
||||
snapshot
|
||||
.core_primary_window()
|
||||
.and_then(|window| window.reset_at())
|
||||
.map(SystemTime::from)
|
||||
}
|
||||
|
||||
/// 将已确认的额度拒绝投影为 100%,使展示、调度和恢复共用同一窗口事实。
|
||||
@@ -578,7 +584,7 @@ impl CodexCredentialQuotaService {
|
||||
)
|
||||
.await;
|
||||
}
|
||||
// 429/503 等临时拒绝:不写 availability(限流不改变账号可用性),仅记失败待重试。
|
||||
// usage 401/403 与 429/503 都不写 availability;前者等待 OAuth 或真实请求确认。
|
||||
None | Some(_) => {
|
||||
summary.transient += 1;
|
||||
tracing::warn!(
|
||||
@@ -633,9 +639,7 @@ impl CodexCredentialQuotaService {
|
||||
observed_at,
|
||||
value,
|
||||
)?;
|
||||
let refreshed_fact = refreshed_snapshot.fact();
|
||||
let previous_reset_at = self.previous_confirmed_reset(account).await;
|
||||
let now = SystemTime::now();
|
||||
let object = normalize_quota_window_placeholders(
|
||||
value
|
||||
.as_object()
|
||||
@@ -645,15 +649,14 @@ impl CodexCredentialQuotaService {
|
||||
let object = if account.availability() == AccountAvailability::QuotaExhausted
|
||||
&& quota_success_state(
|
||||
account.availability(),
|
||||
refreshed_fact,
|
||||
&refreshed_snapshot,
|
||||
previous_reset_at,
|
||||
now,
|
||||
)
|
||||
.is_none()
|
||||
{
|
||||
confirmed_exhaustion_projection(
|
||||
object,
|
||||
confirmed_exhaustion_reset_at(previous_reset_at, refreshed_fact),
|
||||
confirmed_exhaustion_reset_at(previous_reset_at, &refreshed_snapshot),
|
||||
)
|
||||
} else {
|
||||
object
|
||||
@@ -693,11 +696,10 @@ impl CodexCredentialQuotaService {
|
||||
} else {
|
||||
summary.updated += 1;
|
||||
}
|
||||
if let Some(availability) = quota_success_state(
|
||||
if let Some(availability) = authoritative_quota_snapshot_state(
|
||||
current.availability(),
|
||||
refreshed_fact,
|
||||
&refreshed_snapshot,
|
||||
previous_reset_at,
|
||||
now,
|
||||
) {
|
||||
let _ = self
|
||||
.repository
|
||||
@@ -764,13 +766,18 @@ impl CodexCredentialQuotaService {
|
||||
}
|
||||
let quota = merge_passive_quota(existing, &rate_limits);
|
||||
let observed_at = SystemTime::now();
|
||||
let refreshed_fact = parse_codex_quota_usage(&Value::Object(quota.clone()))?;
|
||||
let refreshed_snapshot = parse_account_quota_snapshot(
|
||||
account.id().clone(),
|
||||
account.revision(),
|
||||
observed_at,
|
||||
&Value::Object(quota.clone()),
|
||||
)?;
|
||||
let quota = if account.availability() == AccountAvailability::QuotaExhausted {
|
||||
// 被动成功响应不能解除确认额度耗尽;继续展示 100%,直到权威 usage 快照
|
||||
// 证明旧 reset 已跨过且新窗口已经推进。
|
||||
confirmed_exhaustion_projection(
|
||||
quota,
|
||||
confirmed_exhaustion_reset_at(previous_reset_at, refreshed_fact),
|
||||
confirmed_exhaustion_reset_at(previous_reset_at, &refreshed_snapshot),
|
||||
)
|
||||
} else {
|
||||
quota
|
||||
@@ -950,7 +957,7 @@ impl CodexCredentialQuotaService {
|
||||
});
|
||||
}
|
||||
Err(CodexQuotaFetchError::Upstream { account, error }) => {
|
||||
// 402/deactivated → 写终态;429/503 等临时拒绝不写 availability(限流不改变账号可用性)。
|
||||
// 402 与明确的停用错误写对应状态;usage 401/403 不写 availability。
|
||||
if let Some(availability) = quota_state_transition(&error) {
|
||||
self.persist_upstream_state(&account, availability, &error, observed_at)
|
||||
.await;
|
||||
@@ -966,9 +973,7 @@ impl CodexCredentialQuotaService {
|
||||
observed_at,
|
||||
&value,
|
||||
)?;
|
||||
let refreshed_fact = refreshed_snapshot.fact();
|
||||
let previous_reset_at = self.previous_confirmed_reset(&account).await;
|
||||
let now = SystemTime::now();
|
||||
let object = normalize_quota_window_placeholders(
|
||||
value
|
||||
.as_object()
|
||||
@@ -978,15 +983,14 @@ impl CodexCredentialQuotaService {
|
||||
let object = if account.availability() == AccountAvailability::QuotaExhausted
|
||||
&& quota_success_state(
|
||||
account.availability(),
|
||||
refreshed_fact,
|
||||
&refreshed_snapshot,
|
||||
previous_reset_at,
|
||||
now,
|
||||
)
|
||||
.is_none()
|
||||
{
|
||||
confirmed_exhaustion_projection(
|
||||
object,
|
||||
confirmed_exhaustion_reset_at(previous_reset_at, refreshed_fact),
|
||||
confirmed_exhaustion_reset_at(previous_reset_at, &refreshed_snapshot),
|
||||
)
|
||||
} else {
|
||||
object
|
||||
@@ -1020,11 +1024,10 @@ impl CodexCredentialQuotaService {
|
||||
return Err(CodexCredentialQuotaError::RevisionConflict);
|
||||
}
|
||||
self.scheduling.observe(&snapshot);
|
||||
if let Some(availability) = quota_success_state(
|
||||
if let Some(availability) = authoritative_quota_snapshot_state(
|
||||
current.availability(),
|
||||
refreshed_fact,
|
||||
&refreshed_snapshot,
|
||||
previous_reset_at,
|
||||
now,
|
||||
) {
|
||||
self.repository
|
||||
.apply_state(¤t, availability, observed_at)
|
||||
@@ -1332,11 +1335,16 @@ fn confirmed_exhaustion_projection(
|
||||
/// 否则保留已确认失败的旧窗口,避免 98/99% 快照反向解锁。
|
||||
fn confirmed_exhaustion_reset_at(
|
||||
previous_reset_at: Option<SystemTime>,
|
||||
refreshed_fact: CodexQuotaFact,
|
||||
refreshed_snapshot: &CodexAccountQuotaSnapshot,
|
||||
) -> Option<i64> {
|
||||
let refreshed_reset_at = refreshed_fact.resets_at().map(SystemTime::from);
|
||||
let refreshed_reset_at = refreshed_snapshot
|
||||
.core_primary_window()
|
||||
.and_then(|window| window.reset_at())
|
||||
.map(SystemTime::from);
|
||||
let reset_at = match (previous_reset_at, refreshed_reset_at) {
|
||||
(Some(previous), Some(refreshed)) if refreshed_fact.exhausted() && refreshed > previous => {
|
||||
(Some(previous), Some(refreshed))
|
||||
if refreshed_snapshot.fact().exhausted() && refreshed > previous =>
|
||||
{
|
||||
refreshed
|
||||
}
|
||||
(Some(previous), _) => previous,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! 确认额度耗尽后的恢复证据与 availability 转换。
|
||||
//! 权威额度快照的 availability 转换。
|
||||
//!
|
||||
//! `QuotaExhausted -> Ready` 只能由同一 credential revision 的权威 usage 快照证明:
|
||||
//! 已确认的旧窗口 reset 已到期,且新快照的 reset 已推进到下一窗口。
|
||||
//! 已触顶的 core primary 会立即把可调度账号标为 `QuotaExhausted`;
|
||||
//! `QuotaExhausted -> Ready` 则只能由同一 credential revision 的权威 usage 快照证明:
|
||||
//! 新快照未耗尽、core primary reset 已推进到下一窗口,且该窗口用量低于 10%。
|
||||
|
||||
use std::time::SystemTime;
|
||||
|
||||
@@ -9,26 +10,54 @@ use gateway_core::engine::credential::AccountAvailability;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::snapshot::CodexQuotaFact;
|
||||
use super::snapshot::CodexAccountQuotaSnapshot;
|
||||
use crate::transport::CodexClientError;
|
||||
|
||||
/// 只处理 `QuotaExhausted` 的恢复。`allowed=true`、成功响应和百分比回落都不能单独
|
||||
/// 覆盖一次已确认的额度拒绝;它们在 reset 未推进时可能只是上游滞后的观测。
|
||||
const RESET_RECOVERY_MAX_USED_PERCENT: f64 = 10.0;
|
||||
|
||||
/// 基于成功的权威 usage 快照收敛可调度账号的额度状态。
|
||||
///
|
||||
/// `previous_reset_at` 是写入新快照前读取的已确认耗尽窗口。恢复必须同时满足:
|
||||
/// 旧窗口已经到期,且权威快照的 reset 指向比旧窗口更晚的新周期。
|
||||
/// 只接受上游明确拒绝使用且 core primary 触顶的证据;secondary、附加桶、
|
||||
/// credits 与 spend control 都不能单独把整个账号标成额度耗尽。
|
||||
pub(crate) fn authoritative_quota_snapshot_state(
|
||||
current: AccountAvailability,
|
||||
snapshot: &CodexAccountQuotaSnapshot,
|
||||
previous_reset_at: Option<SystemTime>,
|
||||
) -> Option<AccountAvailability> {
|
||||
match current {
|
||||
AccountAvailability::Ready | AccountAvailability::Unknown
|
||||
if snapshot.authoritative_core_primary_exhausted() =>
|
||||
{
|
||||
Some(AccountAvailability::QuotaExhausted)
|
||||
}
|
||||
AccountAvailability::QuotaExhausted => {
|
||||
quota_success_state(current, snapshot, previous_reset_at)
|
||||
}
|
||||
AccountAvailability::Ready | AccountAvailability::Unknown => None,
|
||||
AccountAvailability::Expired
|
||||
| AccountAvailability::Banned
|
||||
| AccountAvailability::Invalid => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 只处理 `QuotaExhausted` 的恢复。`allowed=true`、成功响应和百分比回落都不能单独覆盖
|
||||
/// 一次已确认的额度拒绝;它们在 reset 未推进时可能只是上游滞后的观测。
|
||||
///
|
||||
/// `previous_reset_at` 是写入新快照前读取的 core primary 已确认耗尽窗口。上游可能只
|
||||
/// 调整 reset 时间而尚未重置额度,因此还要求同一新鲜 primary 窗口的用量低于 10%。
|
||||
pub(crate) fn quota_success_state(
|
||||
current: AccountAvailability,
|
||||
fact: CodexQuotaFact,
|
||||
snapshot: &CodexAccountQuotaSnapshot,
|
||||
previous_reset_at: Option<SystemTime>,
|
||||
now: SystemTime,
|
||||
) -> Option<AccountAvailability> {
|
||||
if current != AccountAvailability::QuotaExhausted || fact.exhausted() {
|
||||
if current != AccountAvailability::QuotaExhausted || snapshot.fact().exhausted() {
|
||||
return None;
|
||||
}
|
||||
let previous_reset_at = previous_reset_at?;
|
||||
let refreshed_reset_at = fact.resets_at().map(SystemTime::from)?;
|
||||
(previous_reset_at <= now && refreshed_reset_at > previous_reset_at)
|
||||
let primary_window = snapshot.core_primary_window()?;
|
||||
let refreshed_reset_at = primary_window.reset_at().map(SystemTime::from)?;
|
||||
let used_percent = primary_window.used_percent()?;
|
||||
(refreshed_reset_at > previous_reset_at && used_percent < RESET_RECOVERY_MAX_USED_PERCENT)
|
||||
.then_some(AccountAvailability::Ready)
|
||||
}
|
||||
|
||||
@@ -42,10 +71,12 @@ pub(crate) fn quota_state_transition(error: &CodexClientError) -> Option<Account
|
||||
match *status {
|
||||
// 402:真额度耗尽 → QuotaExhausted(worker 按 reset_at 自动恢复)。
|
||||
StatusCode::PAYMENT_REQUIRED => Some(AccountAvailability::QuotaExhausted),
|
||||
// 429/503:临时限流,不写 availability(限流不改变账号可用性),由 quota 数据驱动。
|
||||
StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE => None,
|
||||
// quota/usage 接口只能产生额度域状态;鉴权拒绝不具备判定 RT
|
||||
// 永久失效的证据,终态只能由 OAuth refresh/身份校验状态机写入。
|
||||
// usage 401/403 不能单独判定账号不可用:保持现有状态,交给 OAuth 或真实请求确认。
|
||||
// 429/503 同样不写 availability,仅记失败待重试。
|
||||
StatusCode::UNAUTHORIZED
|
||||
| StatusCode::FORBIDDEN
|
||||
| StatusCode::TOO_MANY_REQUESTS
|
||||
| StatusCode::SERVICE_UNAVAILABLE => None,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ use serde_json::{Map, Value};
|
||||
|
||||
use super::{CodexCredentialQuotaError, QUOTA_SCHEDULING_TTL};
|
||||
|
||||
const CORE_PRIMARY_WINDOW_KEY: &str = "core-primary";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CodexQuotaFact {
|
||||
remaining_percent: Option<u8>,
|
||||
@@ -39,6 +41,7 @@ pub enum CodexQuotaWindowRole {
|
||||
pub struct CodexQuotaWindow {
|
||||
key: String,
|
||||
source: String,
|
||||
account_wide: bool,
|
||||
/// 上游提供的限流桶名称(`metered_feature`/`limit_name`/`limitId`);用于非核心桶的展示前缀。
|
||||
limit_name: Option<String>,
|
||||
kind: CodexQuotaWindowKind,
|
||||
@@ -60,6 +63,12 @@ impl CodexQuotaWindow {
|
||||
&self.source
|
||||
}
|
||||
|
||||
/// 该窗口是否覆盖账号全部 Codex 请求,可使用账号级本地用量聚合。
|
||||
#[must_use]
|
||||
pub const fn is_account_wide(&self) -> bool {
|
||||
self.account_wide
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn limit_name(&self) -> Option<&str> {
|
||||
self.limit_name.as_deref()
|
||||
@@ -103,6 +112,7 @@ pub struct CodexAccountQuotaSnapshot {
|
||||
credential_revision: CredentialRevision,
|
||||
observed_at: SystemTime,
|
||||
fact: CodexQuotaFact,
|
||||
authoritative_core_primary_exhausted: bool,
|
||||
windows: Vec<CodexQuotaWindow>,
|
||||
}
|
||||
|
||||
@@ -132,6 +142,19 @@ impl CodexAccountQuotaSnapshot {
|
||||
&self.windows
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn core_primary_window(&self) -> Option<&CodexQuotaWindow> {
|
||||
self.windows
|
||||
.iter()
|
||||
.find(|window| window.key() == CORE_PRIMARY_WINDOW_KEY)
|
||||
}
|
||||
|
||||
/// 上游成功 usage 明确拒绝请求,且 canonical core primary 已确认触顶。
|
||||
#[must_use]
|
||||
pub(crate) const fn authoritative_core_primary_exhausted(&self) -> bool {
|
||||
self.authoritative_core_primary_exhausted
|
||||
}
|
||||
|
||||
/// 投影滚动已过期窗口:`reset_at` 已过的窗口 `used_percent=0`、
|
||||
/// `limit_reached=false`、`reset_at` 滚到下一个周期(reset 到期自动归零)。
|
||||
/// 只作用于展示/调度投影,不写回持久化 raw JSON。
|
||||
@@ -307,30 +330,90 @@ pub(crate) fn parse_account_quota_snapshot(
|
||||
let object = usage
|
||||
.as_object()
|
||||
.ok_or(CodexCredentialQuotaError::InvalidCredentialData)?;
|
||||
let authoritative_core_primary_exhausted = authoritative_core_primary_exhausted(object)?;
|
||||
let mut windows = Vec::new();
|
||||
for limit in canonical_rate_limits(object)? {
|
||||
parse_rate_limit_windows(
|
||||
&limit.key,
|
||||
&limit.source,
|
||||
limit.account_wide,
|
||||
limit.limit_name.as_deref(),
|
||||
limit.rate_limit,
|
||||
&mut windows,
|
||||
)?;
|
||||
}
|
||||
windows.sort_by(|left, right| left.key.cmp(&right.key));
|
||||
windows.sort_by(|left, right| {
|
||||
quota_source_order(left.source())
|
||||
.cmp("a_source_order(right.source()))
|
||||
.then_with(|| left.key.cmp(&right.key))
|
||||
});
|
||||
Ok(CodexAccountQuotaSnapshot {
|
||||
account_id,
|
||||
credential_revision,
|
||||
observed_at,
|
||||
fact,
|
||||
authoritative_core_primary_exhausted,
|
||||
windows,
|
||||
})
|
||||
}
|
||||
|
||||
fn quota_source_order(source: &str) -> u8 {
|
||||
match source {
|
||||
"codex" => 0,
|
||||
"code_review" => 1,
|
||||
_ => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// 账号状态只能由顶层 canonical Codex 额度桶的明确拒绝来改变。
|
||||
///
|
||||
/// `fact.exhausted()` 和窗口 `limit_reached` 都会聚合或继承 secondary、附加桶等事实,
|
||||
/// 因此不能单独用来将整个账号标为 `QuotaExhausted`。
|
||||
fn authoritative_core_primary_exhausted(
|
||||
usage: &Map<String, Value>,
|
||||
) -> Result<bool, CodexCredentialQuotaError> {
|
||||
let Some(rate_limit) = usage.get("rate_limit") else {
|
||||
return Ok(false);
|
||||
};
|
||||
if rate_limit.is_null() {
|
||||
return Ok(false);
|
||||
}
|
||||
let rate_limit = rate_limit
|
||||
.as_object()
|
||||
.ok_or(CodexCredentialQuotaError::InvalidCredentialData)?;
|
||||
if optional_bool(rate_limit, "allowed")? != Some(false) {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(primary_window) = rate_limit.get("primary_window") else {
|
||||
return Ok(false);
|
||||
};
|
||||
if primary_window.is_null() {
|
||||
return Ok(false);
|
||||
}
|
||||
let primary_window = primary_window
|
||||
.as_object()
|
||||
.ok_or(CodexCredentialQuotaError::InvalidCredentialData)?;
|
||||
let primary_window_reached = optional_bool(rate_limit, "limit_reached")?.unwrap_or(false)
|
||||
|| optional_bool(primary_window, "limit_reached")?.unwrap_or(false)
|
||||
|| primary_window
|
||||
.get("used_percent")
|
||||
.map(|value| {
|
||||
value
|
||||
.as_f64()
|
||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
||||
.map(|value| value >= 100.0)
|
||||
.ok_or(CodexCredentialQuotaError::InvalidCredentialData)
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(false);
|
||||
Ok(primary_window_reached)
|
||||
}
|
||||
|
||||
/// 一个规范化后的限流桶:`key` 用于稳定区分窗口,`source` 用于展示分组。
|
||||
struct CanonicalRateLimit<'a> {
|
||||
key: String,
|
||||
source: String,
|
||||
account_wide: bool,
|
||||
limit_name: Option<String>,
|
||||
rate_limit: &'a Value,
|
||||
}
|
||||
@@ -348,6 +431,7 @@ fn canonical_rate_limits(
|
||||
limits.push(CanonicalRateLimit {
|
||||
key: "core".to_owned(),
|
||||
source: "codex".to_owned(),
|
||||
account_wide: true,
|
||||
limit_name: None,
|
||||
rate_limit,
|
||||
});
|
||||
@@ -359,6 +443,7 @@ fn canonical_rate_limits(
|
||||
limits.push(CanonicalRateLimit {
|
||||
key: "code-review".to_owned(),
|
||||
source: "code_review".to_owned(),
|
||||
account_wide: false,
|
||||
limit_name: Some("code_review".to_owned()),
|
||||
rate_limit,
|
||||
});
|
||||
@@ -389,6 +474,7 @@ fn canonical_rate_limits(
|
||||
// 避免同名 additional 产生重复前端 key。
|
||||
limits.push(CanonicalRateLimit {
|
||||
key: format!("additional-{index}-{source}"),
|
||||
account_wide: is_codex_limit_id(&source),
|
||||
source,
|
||||
limit_name,
|
||||
rate_limit,
|
||||
@@ -429,6 +515,7 @@ fn canonical_rate_limit_source(
|
||||
fn parse_rate_limit_windows(
|
||||
key_source: &str,
|
||||
source: &str,
|
||||
account_wide: bool,
|
||||
limit_name: Option<&str>,
|
||||
value: &Value,
|
||||
output: &mut Vec<CodexQuotaWindow>,
|
||||
@@ -487,6 +574,7 @@ fn parse_rate_limit_windows(
|
||||
output.push(CodexQuotaWindow {
|
||||
key: format!("{}-{}", quota_key(key_source), quota_role_name(role, kind)),
|
||||
source: source.to_owned(),
|
||||
account_wide,
|
||||
limit_name: limit_name.map(str::to_owned),
|
||||
kind,
|
||||
role,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! 成功取得 OpenAI OAuth token 后写入普通应用日志的恢复记录。
|
||||
//! 成功取得 OAuth token 后写入独立恢复日志的记录。
|
||||
//!
|
||||
//! 此日志会刻意保存原始 AT/RT,避免后续数据库写入或校验失败时,已成功
|
||||
//! 交换的账号无法恢复。记录直接进入现有结构化日志,沿用其滚动和清理策略。
|
||||
//! 交换的账号无法恢复。记录独立落盘,但沿用普通结构化日志的滚动和清理策略。
|
||||
|
||||
const RECOVERY_LOG_TARGET: &str = "openai_oauth_recovery";
|
||||
const OAUTH_RECOVERY_LOG_TARGET: &str = "oauth_recovery";
|
||||
const OAUTH_RECOVERY_PROVIDER: &str = "openai";
|
||||
|
||||
/// 产生可恢复 token 组的 OAuth 操作。
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -32,10 +33,10 @@ impl CodexOAuthRecoveryOperation {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将已取得的 AT/RT 写入普通结构化日志。
|
||||
/// 将已取得的 AT/RT 写入独立恢复结构化日志。
|
||||
///
|
||||
/// 调用方必须在任何可能拒绝响应的本地校验、画像补全或数据库写入之前调用。
|
||||
/// 该调用不执行额外 I/O,也不返回业务错误。Host 负责普通日志文件的分割、清理和
|
||||
/// 该调用不执行额外 I/O,也不返回业务错误。Host 负责恢复日志文件的分割、清理和
|
||||
/// 输出;日志系统不可用时按其既有策略处理,不能阻断 OAuth 业务流程。
|
||||
pub(crate) fn record_oauth_recovery(
|
||||
operation: CodexOAuthRecoveryOperation,
|
||||
@@ -46,13 +47,14 @@ pub(crate) fn record_oauth_recovery(
|
||||
let has_refresh_token = refresh_token.is_some();
|
||||
let refresh_token = refresh_token.unwrap_or_default();
|
||||
tracing::info!(
|
||||
target: RECOVERY_LOG_TARGET,
|
||||
event = "openai_oauth_recovery",
|
||||
target: OAUTH_RECOVERY_LOG_TARGET,
|
||||
event = "oauth_recovery",
|
||||
provider = OAUTH_RECOVERY_PROVIDER,
|
||||
operation = operation.as_str(),
|
||||
account_id = account_id.unwrap_or_default(),
|
||||
access_token,
|
||||
refresh_token,
|
||||
has_refresh_token,
|
||||
"OpenAI OAuth recovery token record"
|
||||
"OAuth recovery token record"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,32 +31,6 @@ const REFRESH_BACKOFF_MAX_ATTEMPTS: u32 = 5;
|
||||
const REFRESH_RECOVERY_DELAY: Duration = Duration::from_secs(10 * 60);
|
||||
/// 过期 AT 仍允许 RT 恢复的最长窗口。
|
||||
const REFRESH_RECOVERY_WINDOW: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
/// 首次 OAuth 入库最多提前五分钟刷新,避免一小时 token 配合一小时安全边际立刻消耗 RT。
|
||||
const INITIAL_OAUTH_REFRESH_MAX_MARGIN: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
/// 计算首次 OAuth account 写入时的刷新时间。
|
||||
///
|
||||
/// 首次 code exchange 已经拿到一组新的 AT/RT;即使全局安全边际大于等于 AT
|
||||
/// 生命周期,也不能让它立刻进入 RT 刷新队列。后续刷新仍使用
|
||||
/// [`ProviderRefreshPolicy::next_attempt_at`] 的原有语义。
|
||||
#[must_use]
|
||||
pub(crate) fn initial_oauth_refresh_at(
|
||||
policy: ProviderRefreshPolicy,
|
||||
access_token_expires_at: Option<SystemTime>,
|
||||
observed_at: SystemTime,
|
||||
has_refresh_token: bool,
|
||||
) -> Option<SystemTime> {
|
||||
if !has_refresh_token {
|
||||
return None;
|
||||
}
|
||||
let expires_at = access_token_expires_at?;
|
||||
let remaining = expires_at.duration_since(observed_at).ok()?;
|
||||
let initial_margin = policy
|
||||
.margin()
|
||||
.min(INITIAL_OAUTH_REFRESH_MAX_MARGIN)
|
||||
.min(remaining / 2);
|
||||
expires_at.checked_sub(initial_margin)
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_recovery_deadline(
|
||||
access_token_expires_at: Option<SystemTime>,
|
||||
@@ -129,12 +103,26 @@ fn log_refresh_deferred(
|
||||
);
|
||||
}
|
||||
|
||||
fn refresh_due_at(account: &ProviderAccount, now: SystemTime) -> Option<SystemTime> {
|
||||
fn refresh_due_at(
|
||||
account: &ProviderAccount,
|
||||
policy: ProviderRefreshPolicy,
|
||||
now: SystemTime,
|
||||
) -> Option<SystemTime> {
|
||||
if refresh_recovery_window_exhausted(account.access_token_expires_at(), now) {
|
||||
// 即使旧 next_refresh_at 异常地落在 deadline 之后,也必须进入终态化路径。
|
||||
// 即使 retry-not-before 异常地落在 deadline 之后,也必须进入终态化路径。
|
||||
return refresh_recovery_deadline(account.access_token_expires_at());
|
||||
}
|
||||
account.next_refresh_at().filter(|next| *next <= now)
|
||||
let access_token_expires_at = account.access_token_expires_at()?;
|
||||
if !policy.is_refresh_due(access_token_expires_at, now) {
|
||||
return None;
|
||||
}
|
||||
if account
|
||||
.next_refresh_at()
|
||||
.is_some_and(|retry_at| retry_at > now)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(access_token_expires_at)
|
||||
}
|
||||
|
||||
pub struct DueCodexCredential {
|
||||
@@ -224,7 +212,7 @@ impl CodexCredentialRefreshService {
|
||||
excluded: &BTreeSet<ProviderAccountId>,
|
||||
) -> Result<Vec<CodexCredentialRefreshOutcome>, CodexCredentialRefreshError> {
|
||||
let policy = self.runtime_policy.load_refresh_policy().await?;
|
||||
let (due, mut outcomes) = self.list_due_refresh(excluded).await?;
|
||||
let (due, mut outcomes) = self.list_due_refresh(excluded, policy).await?;
|
||||
outcomes.reserve(due.len());
|
||||
let refreshed = stream::iter(due.into_iter().map(|credential| async move {
|
||||
let account_id = credential.account.id().to_string();
|
||||
@@ -301,7 +289,7 @@ impl CodexCredentialRefreshService {
|
||||
.as_ref()
|
||||
.ok_or(CodexCredentialRefreshError::InvalidRefreshResponse)?;
|
||||
match self.refresher.refresh(refresh_token.expose_secret()).await {
|
||||
Ok(tokens) => self.persist_success(due, tokens, policy).await,
|
||||
Ok(tokens) => self.persist_success(due, tokens).await,
|
||||
Err(RefreshFailure::InvalidGrant) => {
|
||||
self.persist_terminal(
|
||||
&due.account,
|
||||
@@ -348,6 +336,7 @@ impl CodexCredentialRefreshService {
|
||||
async fn list_due_refresh(
|
||||
&self,
|
||||
excluded: &BTreeSet<ProviderAccountId>,
|
||||
policy: ProviderRefreshPolicy,
|
||||
) -> Result<
|
||||
(Vec<DueCodexCredential>, Vec<CodexCredentialRefreshOutcome>),
|
||||
CodexCredentialRefreshError,
|
||||
@@ -370,9 +359,10 @@ impl CodexCredentialRefreshService {
|
||||
| AccountAvailability::Banned
|
||||
| AccountAvailability::Invalid
|
||||
)
|
||||
&& refresh_due_at(account, now).is_some()
|
||||
&& refresh_due_at(account, policy, now).is_some()
|
||||
});
|
||||
accounts.sort_by_key(|account| (refresh_due_at(account, now), account.id().clone()));
|
||||
accounts
|
||||
.sort_by_key(|account| (refresh_due_at(account, policy, now), account.id().clone()));
|
||||
accounts.truncate(MAX_REFRESH_BATCH as usize);
|
||||
let mut due = Vec::with_capacity(accounts.len());
|
||||
let mut failures = Vec::new();
|
||||
@@ -405,7 +395,6 @@ impl CodexCredentialRefreshService {
|
||||
&self,
|
||||
due: DueCodexCredential,
|
||||
tokens: TokenPair,
|
||||
policy: ProviderRefreshPolicy,
|
||||
) -> Result<CodexCredentialRefreshOutcome, CodexCredentialRefreshError> {
|
||||
let TokenPair {
|
||||
access_token,
|
||||
@@ -431,21 +420,16 @@ impl CodexCredentialRefreshService {
|
||||
);
|
||||
let observed_at = SystemTime::now();
|
||||
let access_token_expires_at = expires_in.and_then(|value| observed_at.checked_add(value));
|
||||
let next_refresh_at = match access_token_expires_at {
|
||||
Some(expires_at) => Some(DateTime::<Utc>::from(
|
||||
policy
|
||||
.next_attempt_at(due.account.id(), expires_at, observed_at)
|
||||
.map_err(CodexCredentialRefreshError::from)?,
|
||||
)),
|
||||
None => None,
|
||||
};
|
||||
// 成功轮换清掉仅用于失败退避的 retry-not-before;正常预刷新窗口由
|
||||
// worker 结合当前 runtime policy 动态判断,不写入账号时间字段。
|
||||
let next_refresh_at = None;
|
||||
let result = self
|
||||
.repository
|
||||
.rotate_refreshed_oauth_secret(
|
||||
&due.account,
|
||||
secret,
|
||||
access_token_expires_at,
|
||||
next_refresh_at.map(SystemTime::from),
|
||||
next_refresh_at,
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
@@ -459,7 +443,7 @@ impl CodexCredentialRefreshService {
|
||||
account_id = %due.account.id(),
|
||||
credential_revision = revision.get(),
|
||||
access_token_expires_at = ?access_token_expires_at.map(DateTime::<Utc>::from),
|
||||
next_refresh_at = ?next_refresh_at,
|
||||
refresh_retry_not_before = ?next_refresh_at.map(DateTime::<Utc>::from),
|
||||
"OpenAI OAuth refresh succeeded"
|
||||
);
|
||||
Ok(CodexCredentialRefreshOutcome::Refreshed {
|
||||
|
||||
@@ -94,6 +94,17 @@ pub(crate) fn parse_id_token_metadata(jwt: &str) -> Result<CodexOAuthMetadata, (
|
||||
})
|
||||
}
|
||||
|
||||
/// 按官方 Codex 的方式,从 access token JWT payload 读取调度所需的过期时刻。
|
||||
///
|
||||
/// 此处只投影未验证的 `exp`,不把它当作身份或签名验证结果。无法解析或缺少
|
||||
/// `exp` 的直接导入仍可保留为没有已知过期时刻的 OAuth 凭据。
|
||||
pub(crate) fn parse_access_token_expiration(jwt: &str) -> Option<DateTime<Utc>> {
|
||||
let claims = decode_jwt_payload::<StandardJwtClaims>(jwt).ok()?;
|
||||
claims
|
||||
.exp
|
||||
.and_then(|seconds| DateTime::<Utc>::from_timestamp(seconds, 0))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct IdTokenClaims {
|
||||
#[serde(default)]
|
||||
@@ -104,6 +115,12 @@ struct IdTokenClaims {
|
||||
auth: Option<AuthClaims>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct StandardJwtClaims {
|
||||
#[serde(default)]
|
||||
exp: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProfileClaims {
|
||||
#[serde(default)]
|
||||
|
||||
@@ -139,8 +139,6 @@ pub async fn initialize(
|
||||
.map_err(OpenAiInitializeError::Provider)?
|
||||
.with_session_identity(session_identity),
|
||||
);
|
||||
|
||||
tracing::info!(target: "openai_oauth_recovery", "OpenAI OAuth recovery records use normal application logs");
|
||||
let token_client = Arc::new(
|
||||
credential::token_client::openai_token_client(config.token_client_config())
|
||||
.map_err(|_| OpenAiInitializeError::TokenClient)?,
|
||||
@@ -168,7 +166,6 @@ pub async fn initialize(
|
||||
pending,
|
||||
exchanger,
|
||||
Arc::clone(&accounts),
|
||||
Arc::clone(&runtime_policy),
|
||||
CodexCredentialAdmin,
|
||||
)
|
||||
.with_oauth_client_id(config.oauth_client_id()),
|
||||
|
||||
@@ -270,6 +270,11 @@ impl CodexCanonicalDecoder {
|
||||
.event
|
||||
.as_deref()
|
||||
.or_else(|| value.get("type").and_then(Value::as_str));
|
||||
if event_type == Some("codex.rate_limits") {
|
||||
// HTTP transport has already projected this control frame into local quota facts.
|
||||
// It must not become client output or start first-output timing.
|
||||
return Ok(());
|
||||
}
|
||||
self.observe_response_service_tier(&value);
|
||||
let signals = response_event_signals(event_type, &value);
|
||||
self.merge_timing_signals(signals);
|
||||
@@ -411,7 +416,7 @@ impl CodexCanonicalDecoder {
|
||||
self.complete(event_type, value, output)
|
||||
}
|
||||
"response.failed" | "error" => Err(protocol_error_marker()),
|
||||
"response.rate_limits.updated" | "codex.rate_limits" | "response.metadata" => Ok(()),
|
||||
"response.rate_limits.updated" | "response.metadata" => Ok(()),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::fmt;
|
||||
|
||||
use bytes::Bytes;
|
||||
use gateway_protocol::openai::events::is_rate_limit_header_name;
|
||||
use gateway_protocol::openai::events::{is_codex_quota_header_name, is_rate_limit_header_name};
|
||||
use reqwest::header::{HeaderMap, SET_COOKIE};
|
||||
|
||||
use super::diagnostics::CodexUpstreamDiagnostics;
|
||||
@@ -155,6 +155,9 @@ fn client_response_header_is_forwardable(name: &str, connection_options: &[Strin
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if is_codex_quota_header_name(&name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
!matches!(
|
||||
name.as_str(),
|
||||
|
||||
@@ -15,7 +15,7 @@ use gateway_admin::model::provider_credentials::{
|
||||
AuthorizationMutationTarget, AuthorizationOwnerBinding, CompleteAuthorization,
|
||||
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
|
||||
PrepareCredentialRotation, ProviderDocument, ProviderExportCredentialInput,
|
||||
ProviderQuotaRequest,
|
||||
ProviderQuotaRequest, QuotaLocalUsageAttribution,
|
||||
};
|
||||
use gateway_admin::model::{MutationActor, MutationContext, Revision};
|
||||
use gateway_admin::ports::provider::ProviderAdminErrorKind;
|
||||
@@ -503,6 +503,16 @@ async fn openai_admin_provider_projects_official_codex_quota_and_independent_buc
|
||||
"limit_window_seconds": 604_800
|
||||
}
|
||||
}
|
||||
}, {
|
||||
"limit_name": "GPT-5.3-Codex-Spark",
|
||||
"metered_feature": "codex_bengalfox",
|
||||
"rate_limit": {
|
||||
"primary_window": {
|
||||
"used_percent": 0,
|
||||
"reset_at": 1_900_000_000,
|
||||
"limit_window_seconds": 604_800
|
||||
}
|
||||
}
|
||||
}]
|
||||
});
|
||||
store
|
||||
@@ -566,6 +576,23 @@ async fn openai_admin_provider_projects_official_codex_quota_and_independent_buc
|
||||
.find(|window| window.source.as_deref() == Some("code_review"))
|
||||
.expect("code review quota");
|
||||
assert_eq!(review.label, "代码审查 · 周额度");
|
||||
let spark = quota
|
||||
.windows
|
||||
.iter()
|
||||
.find(|window| window.source.as_deref() == Some("codex_bengalfox"))
|
||||
.expect("Spark quota");
|
||||
assert_eq!(
|
||||
(
|
||||
monthly[0].local_usage_attribution,
|
||||
review.local_usage_attribution,
|
||||
spark.local_usage_attribution,
|
||||
),
|
||||
(
|
||||
QuotaLocalUsageAttribution::AccountWide,
|
||||
QuotaLocalUsageAttribution::Unavailable,
|
||||
QuotaLocalUsageAttribution::Unavailable,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{TimeZone as _, Utc};
|
||||
use provider_openai::credential::token_client::{RefreshFailure, TokenPair, TokenRefresher};
|
||||
use provider_openai::credential::{CodexCredentialAdminService, CodexCredentialCodec};
|
||||
use secrecy::ExposeSecret as _;
|
||||
@@ -53,4 +55,43 @@ async fn direct_import_persists_opaque_tokens_without_profile_or_token_validatio
|
||||
"not.a.parseable.jwt"
|
||||
);
|
||||
assert!(account.account.upstream_user_id().is_none());
|
||||
assert!(account.account.access_token_expires_at().is_none());
|
||||
assert!(account.account.next_refresh_at().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_import_projects_access_token_jwt_expiry_without_persisting_refresh_margin() {
|
||||
let expires_at = Utc
|
||||
.timestamp_opt(2_000_000_000, 0)
|
||||
.single()
|
||||
.expect("valid test timestamp");
|
||||
let access_token = format!(
|
||||
"unverified-header.{}.unverified-signature",
|
||||
URL_SAFE_NO_PAD.encode(
|
||||
serde_json::to_vec(&serde_json::json!({"exp": expires_at.timestamp()}))
|
||||
.expect("test JWT payload"),
|
||||
)
|
||||
);
|
||||
let service = CodexCredentialAdminService::new(
|
||||
Arc::new(UnusedRefresher),
|
||||
Arc::new(TestLeaseCoordinator::default()),
|
||||
runtime_policy(),
|
||||
);
|
||||
let prepared = service
|
||||
.prepare_import_document(serde_json::json!({
|
||||
"token": access_token,
|
||||
"refresh_token": "refresh-token"
|
||||
}))
|
||||
.await
|
||||
.expect("direct JWT import must be accepted");
|
||||
|
||||
let account = prepared.accounts().first().expect("one prepared account");
|
||||
assert_eq!(
|
||||
account
|
||||
.account
|
||||
.access_token_expires_at()
|
||||
.map(chrono::DateTime::<Utc>::from),
|
||||
Some(expires_at)
|
||||
);
|
||||
assert!(account.account.next_refresh_at().is_none());
|
||||
}
|
||||
|
||||
@@ -5,5 +5,6 @@ mod contract;
|
||||
mod cookie;
|
||||
mod oauth;
|
||||
mod quota;
|
||||
mod refresh;
|
||||
mod token_client;
|
||||
mod types;
|
||||
|
||||
@@ -21,7 +21,7 @@ use provider_openai::credential::{
|
||||
use secrecy::SecretString;
|
||||
use url::Url;
|
||||
|
||||
use crate::support::{MemoryAccountStore, runtime_policy};
|
||||
use crate::support::MemoryAccountStore;
|
||||
|
||||
#[derive(Default)]
|
||||
struct PendingStore {
|
||||
@@ -135,7 +135,6 @@ async fn complete(id_token: String) -> Result<CompletedCodexOAuthCredential, Cod
|
||||
Arc::new(PendingStore::default()),
|
||||
Arc::new(Exchanger { id_token }),
|
||||
Arc::new(MemoryAccountStore::default()),
|
||||
runtime_policy(),
|
||||
CodexCredentialAdmin,
|
||||
);
|
||||
let started = service
|
||||
@@ -185,8 +184,8 @@ async fn first_exchange_uses_official_id_token_claim_mapping_without_signature_v
|
||||
assert_eq!(account.account.plan_type(), Some("pro"));
|
||||
assert!(account.account.is_schedulable(SystemTime::now()));
|
||||
assert!(
|
||||
account.account.next_refresh_at() > Some(SystemTime::now() + Duration::from_secs(30 * 60)),
|
||||
"a one-hour first AT must not immediately trigger RT refresh"
|
||||
account.account.next_refresh_at().is_none(),
|
||||
"normal OAuth creation must not persist a margin-derived refresh time"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -283,7 +283,7 @@ async fn manual_quota_refresh_preserves_disabled_account_state() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_quota_auth_rejection_does_not_invalidate_refreshable_credential() {
|
||||
async fn manual_quota_unauthorized_rejection_preserves_current_availability() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_manual_quota_auth_rejected";
|
||||
create_account(&store, account_id).await;
|
||||
@@ -320,7 +320,51 @@ async fn manual_quota_auth_rejection_does_not_invalidate_refreshable_credential(
|
||||
assert_eq!(
|
||||
store
|
||||
.account(account_id)
|
||||
.expect("preserved account")
|
||||
.expect("account after rejection")
|
||||
.availability(),
|
||||
AccountAvailability::QuotaExhausted
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_quota_forbidden_rejection_preserves_current_availability() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_manual_quota_forbidden";
|
||||
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(403))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = quota_service_with_base_url(
|
||||
&store,
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("client"),
|
||||
server.uri(),
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
service.refresh_account(account.id()).await,
|
||||
Err(CodexCredentialQuotaError::Upstream { .. })
|
||||
));
|
||||
assert_eq!(
|
||||
store
|
||||
.account(account_id)
|
||||
.expect("account after rejection")
|
||||
.availability(),
|
||||
AccountAvailability::QuotaExhausted
|
||||
);
|
||||
@@ -443,7 +487,7 @@ async fn passive_rate_limit_headers_replace_stale_secondary_window() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_quota_refresh_drops_empty_secondary_window() {
|
||||
async fn manual_quota_refresh_marks_ready_account_exhausted_and_drops_empty_secondary_window() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_manual_secondary_placeholder";
|
||||
create_account(&store, account_id).await;
|
||||
@@ -489,6 +533,13 @@ async fn manual_quota_refresh_drops_empty_secondary_window() {
|
||||
|
||||
assert_eq!(snapshot.windows().len(), 1);
|
||||
assert_eq!(snapshot.windows()[0].used_percent(), Some(100.0));
|
||||
assert_eq!(
|
||||
store
|
||||
.account(account_id)
|
||||
.expect("account after refresh")
|
||||
.availability(),
|
||||
AccountAvailability::QuotaExhausted
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -780,7 +831,7 @@ async fn periodic_quota_synchronization_does_not_scan_stale_ready_accounts() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn periodic_quota_synchronization_preserves_availability_when_usage_peaks() {
|
||||
async fn periodic_quota_synchronization_marks_ready_account_exhausted_when_primary_is_exhausted() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_periodic_stuck_cooldown").await;
|
||||
let account = store
|
||||
@@ -827,6 +878,50 @@ async fn periodic_quota_synchronization_preserves_availability_when_usage_peaks(
|
||||
"primary_window": {"used_percent": 100, "reset_at": reset_at}
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = quota_service_with_base_url(
|
||||
&store,
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("client"),
|
||||
server.uri(),
|
||||
);
|
||||
service
|
||||
.prepare_scheduling(std::slice::from_ref(&account))
|
||||
.await;
|
||||
|
||||
let summary = service.synchronize().await.expect("quota synchronization");
|
||||
|
||||
assert_eq!(summary.exhausted, 1);
|
||||
let reconciled = store
|
||||
.account("acct_periodic_stuck_cooldown")
|
||||
.expect("reconciled account");
|
||||
assert_eq!(
|
||||
reconciled.availability(),
|
||||
AccountAvailability::QuotaExhausted
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn periodic_quota_synchronization_keeps_ready_when_only_secondary_window_is_exhausted() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_periodic_secondary_only").await;
|
||||
let reset_at = Utc::now().timestamp() + 24 * 60 * 60;
|
||||
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": 5, "reset_at": reset_at},
|
||||
"secondary_window": {"used_percent": 100, "reset_at": reset_at}
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = quota_service_with_base_url(
|
||||
@@ -838,13 +933,99 @@ async fn periodic_quota_synchronization_preserves_availability_when_usage_peaks(
|
||||
server.uri(),
|
||||
);
|
||||
|
||||
service.synchronize().await.expect("quota synchronization");
|
||||
let summary = service.synchronize().await.expect("quota synchronization");
|
||||
|
||||
// 429/触顶不再写入 availability(限流不改变账号可用性),worker 只更新 quota 数据。
|
||||
let reconciled = store
|
||||
.account("acct_periodic_stuck_cooldown")
|
||||
.expect("reconciled account");
|
||||
assert_eq!(reconciled.availability(), AccountAvailability::Ready);
|
||||
assert_eq!(summary.exhausted, 1);
|
||||
assert_eq!(
|
||||
store
|
||||
.account("acct_periodic_secondary_only")
|
||||
.expect("reconciled account")
|
||||
.availability(),
|
||||
AccountAvailability::Ready
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn periodic_quota_synchronization_keeps_ready_when_primary_is_full_but_requests_are_allowed()
|
||||
{
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_periodic_primary_full_but_allowed").await;
|
||||
let reset_at = Utc::now().timestamp() + 24 * 60 * 60;
|
||||
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": 100, "reset_at": reset_at}
|
||||
}
|
||||
})))
|
||||
.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("quota synchronization");
|
||||
|
||||
assert_eq!(summary.exhausted, 1);
|
||||
assert_eq!(
|
||||
store
|
||||
.account("acct_periodic_primary_full_but_allowed")
|
||||
.expect("reconciled account")
|
||||
.availability(),
|
||||
AccountAvailability::Ready
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn periodic_quota_synchronization_keeps_ready_without_primary_exhaustion_evidence() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(
|
||||
&store,
|
||||
"acct_periodic_disallowed_without_primary_exhaustion",
|
||||
)
|
||||
.await;
|
||||
let reset_at = Utc::now().timestamp() + 24 * 60 * 60;
|
||||
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": false,
|
||||
"limit_reached": false,
|
||||
"primary_window": {"used_percent": 5, "reset_at": reset_at}
|
||||
}
|
||||
})))
|
||||
.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("quota synchronization");
|
||||
|
||||
assert_eq!(summary.exhausted, 1);
|
||||
assert_eq!(
|
||||
store
|
||||
.account("acct_periodic_disallowed_without_primary_exhaustion")
|
||||
.expect("reconciled account")
|
||||
.availability(),
|
||||
AccountAvailability::Ready
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1210,7 +1391,7 @@ async fn periodic_quota_synchronization_rechecks_exhausted_accounts_without_wait
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn periodic_quota_synchronization_does_not_recover_from_percent_drop_before_reset() {
|
||||
async fn periodic_quota_synchronization_keeps_exhaustion_at_ten_percent_after_advanced_reset() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_periodic_recovery_evidence";
|
||||
create_account(&store, account_id).await;
|
||||
@@ -1289,8 +1470,8 @@ async fn periodic_quota_synchronization_does_not_recover_from_percent_drop_befor
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"rate_limit": {
|
||||
// 无 allowed/limit_reached 标记的模糊快照。
|
||||
"primary_window": {"used_percent": 99, "reset_at": reset_at}
|
||||
// reset 已后移,但 10% 仍不能证明这是刚重置的新窗口。
|
||||
"primary_window": {"used_percent": 10, "reset_at": reset_at + 3600}
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
@@ -1306,19 +1487,19 @@ async fn periodic_quota_synchronization_does_not_recover_from_percent_drop_befor
|
||||
server.uri(),
|
||||
);
|
||||
|
||||
let lower_percent = next_cycle
|
||||
let ten_percent = next_cycle
|
||||
.synchronize()
|
||||
.await
|
||||
.expect("lower percent quota recheck");
|
||||
.expect("ten-percent quota recheck");
|
||||
|
||||
assert_eq!(lower_percent.exhausted, 1);
|
||||
assert_eq!(ten_percent.exhausted, 1);
|
||||
assert_eq!(
|
||||
store
|
||||
.account(account_id)
|
||||
.expect("account after lower percent snapshot")
|
||||
.expect("account after ten-percent snapshot")
|
||||
.availability(),
|
||||
AccountAvailability::QuotaExhausted,
|
||||
"a provider percentage change is not sufficient to release a confirmed exhaustion"
|
||||
"an advanced reset at exactly 10% is not sufficient to release confirmed exhaustion"
|
||||
);
|
||||
let snapshot = next_cycle
|
||||
.read_account(account.id())
|
||||
@@ -1329,12 +1510,12 @@ async fn periodic_quota_synchronization_does_not_recover_from_percent_drop_befor
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn periodic_quota_synchronization_recovers_after_recorded_reset() {
|
||||
async fn periodic_quota_synchronization_recovers_with_advanced_reset_and_low_primary_usage() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_periodic_reset_elapsed";
|
||||
create_account(&store, account_id).await;
|
||||
let account = store.account(account_id).expect("created account");
|
||||
let elapsed_reset = Utc::now().timestamp() - 60;
|
||||
let old_reset = Utc::now().timestamp() + 3_600;
|
||||
store
|
||||
.compare_and_swap_quota(QuotaObservation {
|
||||
account_id: account.id().clone(),
|
||||
@@ -1344,7 +1525,7 @@ async fn periodic_quota_synchronization_recovers_after_recorded_reset() {
|
||||
"rate_limit": {
|
||||
"allowed": false,
|
||||
"limit_reached": true,
|
||||
"primary_window": {"used_percent": 100, "reset_at": elapsed_reset}
|
||||
"primary_window": {"used_percent": 100, "reset_at": old_reset}
|
||||
}
|
||||
})
|
||||
.as_object()
|
||||
@@ -1355,7 +1536,7 @@ async fn periodic_quota_synchronization_recovers_after_recorded_reset() {
|
||||
limit_reached: Some(true),
|
||||
})
|
||||
.await
|
||||
.expect("record elapsed exhausted reset");
|
||||
.expect("record future exhausted reset");
|
||||
store
|
||||
.apply_state_change(AccountStateChange {
|
||||
message: None,
|
||||
@@ -1365,9 +1546,9 @@ async fn periodic_quota_synchronization_recovers_after_recorded_reset() {
|
||||
observed_at: SystemTime::now(),
|
||||
})
|
||||
.await
|
||||
.expect("mark account exhausted with an elapsed reset");
|
||||
.expect("mark account exhausted with a future reset");
|
||||
let server = MockServer::start().await;
|
||||
let next_reset = Utc::now().timestamp() + 3600;
|
||||
let next_reset = old_reset + 3_600;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
@@ -1389,7 +1570,7 @@ async fn periodic_quota_synchronization_recovers_after_recorded_reset() {
|
||||
server.uri(),
|
||||
);
|
||||
|
||||
// 定时复核可提前执行,但只有已记录的 reset 到期后才释放额度耗尽状态。
|
||||
// 定时复核可在旧 reset 前执行;新主窗口已推进且用量低于 10% 才释放额度耗尽状态。
|
||||
let summary = service.synchronize().await.expect("quota synchronization");
|
||||
|
||||
assert_eq!(summary.updated, 1);
|
||||
@@ -1541,7 +1722,7 @@ async fn invalid_quota_json_for_one_account_does_not_abort_the_batch() {
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"primary_window": {"used_percent": 25.0, "reset_at": next_reset}
|
||||
"primary_window": {"used_percent": 1.0, "reset_at": next_reset}
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
@@ -1570,5 +1751,5 @@ async fn invalid_quota_json_for_one_account_does_not_abort_the_batch() {
|
||||
.await
|
||||
.expect("read good quota")
|
||||
.expect("good quota persisted");
|
||||
assert_eq!(snapshot.fact().remaining_percent(), Some(75));
|
||||
assert_eq!(snapshot.fact().remaining_percent(), Some(99));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 402 恢复回归。
|
||||
//!
|
||||
//! 覆盖 `QuotaExhausted -> Ready` 的证据规则:旧 reset 已到期 + 新快照未耗尽。
|
||||
//! 覆盖 `QuotaExhausted -> Ready` 的证据规则:新 core primary reset 后移 + 新快照用量低于 10%。
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -127,14 +127,13 @@ async fn manual_quota_refresh_keeps_confirmed_exhaustion_until_the_recorded_rese
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_old_reset_with_fresh_allowed_snapshot_recovers_ready() {
|
||||
// 旧快照 reset_at < now,新快照 allowed=true、
|
||||
// limit_reached=false、reset_at > now;最终必须恢复 Ready。
|
||||
async fn advanced_reset_with_primary_usage_below_ten_percent_recovers_ready() {
|
||||
// 旧 reset 仍在未来;同一主窗口 reset 后移且用量低于 10% 时才恢复 Ready。
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_cross_window_recovery";
|
||||
create_account(&store, account_id).await;
|
||||
let account = store.account(account_id).expect("created account");
|
||||
let old_reset = Utc::now().timestamp() - 60;
|
||||
let old_reset = Utc::now().timestamp() + 3_600;
|
||||
store
|
||||
.compare_and_swap_quota(QuotaObservation {
|
||||
account_id: account.id().clone(),
|
||||
@@ -171,7 +170,7 @@ async fn expired_old_reset_with_fresh_allowed_snapshot_recovers_ready() {
|
||||
.await
|
||||
.expect("mark account exhausted");
|
||||
let server = MockServer::start().await;
|
||||
let future_reset = Utc::now().timestamp() + 3600;
|
||||
let future_reset = old_reset + 3_600;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.and(header(
|
||||
@@ -182,7 +181,7 @@ async fn expired_old_reset_with_fresh_allowed_snapshot_recovers_ready() {
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"primary_window": {"used_percent": 5, "reset_at": future_reset}
|
||||
"primary_window": {"used_percent": 9.9, "reset_at": future_reset}
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
@@ -208,7 +207,91 @@ async fn expired_old_reset_with_fresh_allowed_snapshot_recovers_ready() {
|
||||
.expect("account after cross-window recovery")
|
||||
.availability(),
|
||||
AccountAvailability::Ready,
|
||||
"old reset elapsed + fresh allowed snapshot must recover QuotaExhausted"
|
||||
"an advanced reset with a nearly unused primary window must recover QuotaExhausted"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn advanced_reset_with_primary_usage_at_ten_percent_keeps_confirmed_exhaustion() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_cross_window_ten_percent";
|
||||
create_account(&store, account_id).await;
|
||||
let account = store.account(account_id).expect("created account");
|
||||
let old_reset = Utc::now().timestamp() + 3_600;
|
||||
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": old_reset,
|
||||
"limit_window_seconds": 3600
|
||||
}
|
||||
}
|
||||
})
|
||||
.as_object()
|
||||
.expect("quota object")
|
||||
.clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
limit_reached: None,
|
||||
})
|
||||
.await
|
||||
.expect("seed old exhausted snapshot");
|
||||
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;
|
||||
let future_reset = old_reset + 3_600;
|
||||
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": 10.0, "reset_at": future_reset}
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = quota_service_with_base_url(
|
||||
&store,
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("client"),
|
||||
server.uri(),
|
||||
);
|
||||
|
||||
let held = service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("refresh non-empty new window quota");
|
||||
|
||||
assert!(held.fact().exhausted());
|
||||
assert_eq!(
|
||||
store
|
||||
.account(account_id)
|
||||
.expect("account after ten-percent snapshot")
|
||||
.availability(),
|
||||
AccountAvailability::QuotaExhausted,
|
||||
"a reset advance at exactly 10% must not release confirmed exhaustion"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -364,6 +364,58 @@ async fn persisted_codex_alias_keeps_the_top_level_rate_limit_canonical() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persisted_quota_orders_core_window_before_additional_limit() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_quota_order").await;
|
||||
let account = store.account("acct_quota_order").expect("account");
|
||||
let raw = json!({
|
||||
"rate_limit": {
|
||||
"secondary_window": {
|
||||
"used_percent": 42,
|
||||
"reset_at": 1_900_604_800,
|
||||
"limit_window_seconds": 604_800
|
||||
}
|
||||
},
|
||||
"additional_rate_limits": [{
|
||||
"limit_name": "GPT-5.3-Codex-Spark",
|
||||
"metered_feature": "gpt-5.3-codex-spark",
|
||||
"rate_limit": {
|
||||
"primary_window": {
|
||||
"used_percent": 17,
|
||||
"reset_at": 1_900_604_800,
|
||||
"limit_window_seconds": 604_800
|
||||
}
|
||||
}
|
||||
}]
|
||||
});
|
||||
store
|
||||
.compare_and_swap_quota(QuotaObservation {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
quota: Some(OpaqueProviderData::new(
|
||||
raw.as_object().expect("quota object").clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
limit_reached: None,
|
||||
})
|
||||
.await
|
||||
.expect("persist quota");
|
||||
|
||||
let snapshot = quota_service(&store)
|
||||
.read_account(account.id())
|
||||
.await
|
||||
.expect("read quota")
|
||||
.expect("quota snapshot");
|
||||
let sources = snapshot
|
||||
.windows()
|
||||
.iter()
|
||||
.map(|window| window.source())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(sources, ["codex", "gpt-5.3-codex-spark"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn code_review_limit_projects_as_independent_window_with_limit_name() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
use std::num::NonZeroU32;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::engine::credential::ProviderAccountId;
|
||||
use gateway_core::provider_ports::{
|
||||
ProviderCredentialState, ProviderCredentialStatePort, ProviderLeaseAcquisition,
|
||||
ProviderLeasePort, ProviderLeaseRequest, ProviderRefreshPolicy, ProviderRuntimePolicyPort,
|
||||
ProviderSchedulingState, ProviderStoreError,
|
||||
};
|
||||
use gateway_core::routing::ProviderKind;
|
||||
use provider_openai::credential::token_client::{RefreshFailure, TokenPair, TokenRefresher};
|
||||
use provider_openai::credential::{
|
||||
CodexCredentialRefreshOutcome, CodexCredentialRefreshService, ImportCodexOAuthCredential,
|
||||
};
|
||||
|
||||
use crate::support::{MemoryAccountStore, profile, secret};
|
||||
|
||||
struct SingleUseRefresher {
|
||||
calls: AtomicUsize,
|
||||
response: Mutex<Option<TokenPair>>,
|
||||
}
|
||||
|
||||
impl SingleUseRefresher {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
calls: AtomicUsize::new(0),
|
||||
response: Mutex::new(Some(TokenPair {
|
||||
access_token: "refreshed-access-token".to_owned(),
|
||||
refresh_token: None,
|
||||
expires_in: Some(Duration::from_secs(60 * 60)),
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
fn calls(&self) -> usize {
|
||||
self.calls.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TokenRefresher for SingleUseRefresher {
|
||||
async fn refresh(&self, _: &str) -> Result<TokenPair, RefreshFailure> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
self.response
|
||||
.lock()
|
||||
.expect("refresh response lock")
|
||||
.take()
|
||||
.ok_or(RefreshFailure::Transport)
|
||||
}
|
||||
}
|
||||
|
||||
struct RefreshLeases;
|
||||
|
||||
impl ProviderLeasePort for RefreshLeases {
|
||||
fn load_state<'a>(
|
||||
&'a self,
|
||||
_: &'a ProviderKind,
|
||||
_: &'a [ProviderAccountId],
|
||||
) -> BoxFuture<'a, Result<ProviderSchedulingState, ProviderStoreError>> {
|
||||
Box::pin(async { panic!("scheduled credential refresh does not load scheduling state") })
|
||||
}
|
||||
|
||||
fn try_acquire(
|
||||
&self,
|
||||
request: ProviderLeaseRequest,
|
||||
) -> BoxFuture<'_, Result<ProviderLeaseAcquisition, ProviderStoreError>> {
|
||||
Box::pin(async move {
|
||||
match request {
|
||||
ProviderLeaseRequest::RefreshCapacity(_) | ProviderLeaseRequest::Refresh(_) => {
|
||||
Ok(ProviderLeaseAcquisition::Acquired(Box::new(())))
|
||||
}
|
||||
ProviderLeaseRequest::Scheduling(_) => {
|
||||
panic!("scheduled credential refresh must not acquire a scheduling lease")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct RefreshCredentialState;
|
||||
|
||||
impl ProviderCredentialStatePort for RefreshCredentialState {
|
||||
fn replace(&self, _: ProviderCredentialState) -> BoxFuture<'_, Result<(), ProviderStoreError>> {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
|
||||
fn read<'a>(
|
||||
&'a self,
|
||||
_: &'a ProviderAccountId,
|
||||
) -> BoxFuture<'a, Result<Option<ProviderCredentialState>, ProviderStoreError>> {
|
||||
Box::pin(async { Ok(None) })
|
||||
}
|
||||
|
||||
fn clear<'a>(
|
||||
&'a self,
|
||||
_: &'a ProviderAccountId,
|
||||
) -> BoxFuture<'a, Result<bool, ProviderStoreError>> {
|
||||
Box::pin(async { Ok(false) })
|
||||
}
|
||||
|
||||
fn record_refresh_backoff<'a>(
|
||||
&'a self,
|
||||
_: &'a ProviderAccountId,
|
||||
_: Duration,
|
||||
) -> BoxFuture<'a, Result<u32, ProviderStoreError>> {
|
||||
Box::pin(async { Ok(1) })
|
||||
}
|
||||
|
||||
fn clear_refresh_backoff<'a>(
|
||||
&'a self,
|
||||
_: &'a ProviderAccountId,
|
||||
) -> BoxFuture<'a, Result<(), ProviderStoreError>> {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
}
|
||||
|
||||
struct MutableRuntimePolicy {
|
||||
policy: Mutex<ProviderRefreshPolicy>,
|
||||
}
|
||||
|
||||
impl MutableRuntimePolicy {
|
||||
fn new(margin: Duration) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
policy: Mutex::new(refresh_policy(margin)),
|
||||
})
|
||||
}
|
||||
|
||||
fn set_margin(&self, margin: Duration) {
|
||||
*self.policy.lock().expect("runtime policy lock") = refresh_policy(margin);
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderRuntimePolicyPort for MutableRuntimePolicy {
|
||||
fn load_refresh_policy(
|
||||
&self,
|
||||
) -> BoxFuture<'_, Result<ProviderRefreshPolicy, ProviderStoreError>> {
|
||||
Box::pin(async { Ok(*self.policy.lock().expect("runtime policy lock")) })
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_policy(margin: Duration) -> ProviderRefreshPolicy {
|
||||
ProviderRefreshPolicy::try_new(margin, NonZeroU32::new(1).expect("positive concurrency"))
|
||||
.expect("valid refresh policy")
|
||||
}
|
||||
|
||||
fn refresh_service(
|
||||
store: &Arc<MemoryAccountStore>,
|
||||
refresher: Arc<SingleUseRefresher>,
|
||||
runtime_policy: Arc<MutableRuntimePolicy>,
|
||||
) -> CodexCredentialRefreshService {
|
||||
CodexCredentialRefreshService::new(
|
||||
store.repository(),
|
||||
refresher,
|
||||
Arc::new(RefreshLeases),
|
||||
Arc::new(RefreshCredentialState),
|
||||
runtime_policy,
|
||||
)
|
||||
}
|
||||
|
||||
async fn seed_refreshable_account(
|
||||
store: &Arc<MemoryAccountStore>,
|
||||
account_id: &str,
|
||||
access_token_expires_at: SystemTime,
|
||||
retry_not_before: Option<SystemTime>,
|
||||
) {
|
||||
let mut verified_account = profile(&format!("chatgpt-{account_id}"));
|
||||
verified_account.access_token_expires_at = Some(DateTime::<Utc>::from(access_token_expires_at));
|
||||
store
|
||||
.seed_oauth_credential(ImportCodexOAuthCredential {
|
||||
account_id: account_id.to_owned(),
|
||||
name: account_id.to_owned(),
|
||||
secret: secret(&format!("access-{account_id}")),
|
||||
verified_account,
|
||||
next_refresh_at: retry_not_before.map(DateTime::<Utc>::from),
|
||||
enabled: true,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduled_refresh_uses_the_current_margin_without_persisting_a_normal_schedule() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let policy = MutableRuntimePolicy::new(Duration::from_secs(1));
|
||||
let refresher = SingleUseRefresher::new();
|
||||
let service = refresh_service(&store, Arc::clone(&refresher), Arc::clone(&policy));
|
||||
let account_id = "acct_dynamic_margin";
|
||||
seed_refreshable_account(
|
||||
&store,
|
||||
account_id,
|
||||
SystemTime::now()
|
||||
.checked_add(Duration::from_secs(120))
|
||||
.expect("test expiry"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
service
|
||||
.refresh_due()
|
||||
.await
|
||||
.expect("refresh cycle below margin")
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(refresher.calls(), 0);
|
||||
assert!(
|
||||
store
|
||||
.account(account_id)
|
||||
.expect("seeded account")
|
||||
.next_refresh_at()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
policy.set_margin(Duration::from_secs(5 * 60));
|
||||
let outcomes = service
|
||||
.refresh_due()
|
||||
.await
|
||||
.expect("refresh cycle inside margin");
|
||||
|
||||
assert!(matches!(
|
||||
outcomes.as_slice(),
|
||||
[CodexCredentialRefreshOutcome::Refreshed {
|
||||
account_id: refreshed_account_id,
|
||||
..
|
||||
}] if refreshed_account_id == account_id
|
||||
));
|
||||
assert_eq!(refresher.calls(), 1);
|
||||
assert!(
|
||||
store
|
||||
.account(account_id)
|
||||
.expect("refreshed account")
|
||||
.next_refresh_at()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduled_refresh_respects_retry_not_before_until_it_has_elapsed() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60));
|
||||
let refresher = SingleUseRefresher::new();
|
||||
let service = refresh_service(&store, Arc::clone(&refresher), policy);
|
||||
let expires_at = SystemTime::now()
|
||||
.checked_add(Duration::from_secs(120))
|
||||
.expect("test expiry");
|
||||
seed_refreshable_account(
|
||||
&store,
|
||||
"acct_future_retry",
|
||||
expires_at,
|
||||
Some(
|
||||
SystemTime::now()
|
||||
.checked_add(Duration::from_secs(60))
|
||||
.expect("future retry"),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
service
|
||||
.refresh_due()
|
||||
.await
|
||||
.expect("refresh cycle before retry")
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(refresher.calls(), 0);
|
||||
|
||||
seed_refreshable_account(
|
||||
&store,
|
||||
"acct_elapsed_retry",
|
||||
expires_at,
|
||||
Some(
|
||||
SystemTime::now()
|
||||
.checked_sub(Duration::from_secs(1))
|
||||
.expect("past retry"),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let outcomes = service
|
||||
.refresh_due()
|
||||
.await
|
||||
.expect("refresh cycle after retry");
|
||||
|
||||
assert!(matches!(
|
||||
outcomes.as_slice(),
|
||||
[CodexCredentialRefreshOutcome::Refreshed {
|
||||
account_id,
|
||||
..
|
||||
}] if account_id == "acct_elapsed_retry"
|
||||
));
|
||||
assert_eq!(refresher.calls(), 1);
|
||||
}
|
||||
@@ -10,8 +10,9 @@ const METADATA_PREFIX_FIXTURE: &str = include_str!(
|
||||
);
|
||||
|
||||
#[test]
|
||||
fn decoder_should_preserve_existing_metadata_fixture_as_openai_wire() {
|
||||
fn decoder_should_not_forward_codex_rate_limit_metadata_fixture_as_openai_wire() {
|
||||
let events = CodexCanonicalDecoder::new("fallback")
|
||||
.with_raw_sse_passthrough()
|
||||
.push(METADATA_PREFIX_FIXTURE.as_bytes())
|
||||
.expect("metadata fixture should remain open-world");
|
||||
let wire_types = events
|
||||
@@ -20,16 +21,32 @@ fn decoder_should_preserve_existing_metadata_fixture_as_openai_wire() {
|
||||
.filter_map(|wire| wire.event_type())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
wire_types,
|
||||
vec!["response.created", "codex.rate_limits", "response.metadata"]
|
||||
);
|
||||
assert_eq!(wire_types, vec!["response.created", "response.metadata"]);
|
||||
assert!(matches!(
|
||||
canonical_facts(&events).as_slice(),
|
||||
[GatewayEvent::Started(_)]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_sse_passthrough_should_drop_rate_limit_control_without_timing_side_effects() {
|
||||
let frame = concat!(
|
||||
"event: codex.rate_limits\n",
|
||||
"data: {\"type\":\"codex.rate_limits\",\"rate_limits\":{\"primary\":{\"used_percent\":90}}}\n\n",
|
||||
);
|
||||
let mut decoder = CodexCanonicalDecoder::new("fallback").with_raw_sse_passthrough();
|
||||
|
||||
let events = decoder
|
||||
.push(frame.as_bytes())
|
||||
.expect("quota control frame should be accepted");
|
||||
let signals = decoder.take_timing_signals();
|
||||
|
||||
assert!(events.is_empty());
|
||||
assert!(!signals.protocol_progress);
|
||||
assert!(!signals.output_start);
|
||||
assert!(!signals.semantic_output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_preserve_empty_opaque_response_id_in_canonical_facts() {
|
||||
let body = concat!(
|
||||
@@ -407,7 +424,7 @@ fn decoder_should_preserve_image_tool_tokens_in_canonical_usage() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_accept_official_codex_metadata_lifecycle_events() {
|
||||
fn decoder_should_drop_rate_limit_control_and_preserve_metadata_lifecycle_events() {
|
||||
let body = concat!(
|
||||
"event: codex.rate_limits\n",
|
||||
"data: {\"type\":\"codex.rate_limits\",\"rate_limits\":{\"primary\":{\"used_percent\":42.0}}}\n\n",
|
||||
@@ -422,10 +439,25 @@ fn decoder_should_accept_official_codex_metadata_lifecycle_events() {
|
||||
);
|
||||
|
||||
let events = CodexCanonicalDecoder::new("fallback")
|
||||
.with_raw_sse_passthrough()
|
||||
.push(body.as_bytes())
|
||||
.expect("official Codex metadata lifecycle events");
|
||||
let canonical = canonical_facts(&events);
|
||||
let wire_types = events
|
||||
.iter()
|
||||
.filter_map(ProviderEvent::wire_event)
|
||||
.filter_map(|wire| wire.event_type())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
wire_types,
|
||||
vec![
|
||||
"response.metadata",
|
||||
"response.in_progress",
|
||||
"response.output_text.delta",
|
||||
"response.completed"
|
||||
]
|
||||
);
|
||||
assert!(matches!(
|
||||
canonical.as_slice(),
|
||||
[
|
||||
|
||||
@@ -385,6 +385,11 @@ async fn codex_backend_client_should_capture_forwardable_response_metadata() {
|
||||
.insert_header("connection", "x-hop-secret")
|
||||
.insert_header("x-hop-secret", "hop-secret")
|
||||
.insert_header("x-codex-primary-used-percent", "15")
|
||||
.insert_header("x-codex-code-review-primary-used-percent", "20")
|
||||
.insert_header("x-codex-promo-message", "internal quota notice")
|
||||
.insert_header("x-codex-safety-buffering-enabled", "true")
|
||||
.insert_header("retry-after", "17")
|
||||
.insert_header("x-ratelimit-remaining", "23")
|
||||
.set_body_string(body),
|
||||
)
|
||||
.mount(&server)
|
||||
@@ -445,11 +450,13 @@ async fn codex_backend_client_should_capture_forwardable_response_metadata() {
|
||||
"date",
|
||||
"openai-model",
|
||||
"openai-processing-ms",
|
||||
"x-codex-primary-used-percent",
|
||||
"retry-after",
|
||||
"x-codex-safety-buffering-enabled",
|
||||
"x-future-bytes",
|
||||
"x-future-multi",
|
||||
"x-future-multi",
|
||||
"x-models-etag",
|
||||
"x-ratelimit-remaining",
|
||||
"x-reasoning-included",
|
||||
"x-request-id"
|
||||
]
|
||||
@@ -486,10 +493,22 @@ async fn codex_backend_client_should_capture_forwardable_response_metadata() {
|
||||
);
|
||||
}
|
||||
assert_eq!(response.set_cookie_headers, vec!["secret=value"]);
|
||||
assert_eq!(
|
||||
response.rate_limit_headers,
|
||||
vec![("x-codex-primary-used-percent".to_string(), "15".to_string())]
|
||||
);
|
||||
for expected in [
|
||||
("x-codex-primary-used-percent", "15"),
|
||||
("x-codex-code-review-primary-used-percent", "20"),
|
||||
("x-codex-promo-message", "internal quota notice"),
|
||||
("retry-after", "17"),
|
||||
("x-ratelimit-remaining", "23"),
|
||||
] {
|
||||
assert!(
|
||||
response
|
||||
.rate_limit_headers
|
||||
.iter()
|
||||
.any(|(name, value)| name == expected.0 && value == expected.1),
|
||||
"missing locally observed rate-limit header {}",
|
||||
expected.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -21,7 +21,7 @@ use gateway_admin::model::provider_credentials::{
|
||||
PreparedCredentialCreate, PreparedCredentialImport, PreparedCredentialRotation,
|
||||
PreparedCredentialRotationFacts, ProviderDocument, ProviderExport,
|
||||
ProviderExportCredentialInput, ProviderModel, ProviderModels, ProviderQuota,
|
||||
ProviderQuotaRequest, ProviderQuotaWindow,
|
||||
ProviderQuotaRequest, ProviderQuotaWindow, QuotaLocalUsageAttribution,
|
||||
};
|
||||
use gateway_admin::model::{AdminError, MutationActor, MutationContext, Revision};
|
||||
use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind};
|
||||
@@ -35,7 +35,7 @@ use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload};
|
||||
use gateway_core::provider_ports::{
|
||||
NewOAuthPendingFlow, OAuthPendingBinding, OAuthPendingClaimOutcome, OAuthPendingConsumeOutcome,
|
||||
OAuthPendingFlowPort, OAuthPendingPutOutcome, OAuthPendingReleaseOutcome, ProviderCooldownPort,
|
||||
ProviderRefreshPolicy, ProviderRuntimePolicyPort, ProviderStoreError, ProviderStoreErrorKind,
|
||||
ProviderStoreError, ProviderStoreErrorKind,
|
||||
};
|
||||
use gateway_core::routing::{ProviderKind, UpstreamModelId};
|
||||
use serde::Deserialize;
|
||||
@@ -142,7 +142,6 @@ pub(crate) struct XaiAdminProvider {
|
||||
refresh: Arc<GrokCredentialRefreshService>,
|
||||
quota: Arc<GrokCredentialQuotaService>,
|
||||
catalog: Arc<GrokCredentialCatalogService>,
|
||||
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
|
||||
cooldowns: Arc<dyn ProviderCooldownPort>,
|
||||
}
|
||||
|
||||
@@ -154,7 +153,6 @@ pub(crate) struct XaiAdminServices {
|
||||
pub(crate) refresh: Arc<GrokCredentialRefreshService>,
|
||||
pub(crate) quota: Arc<GrokCredentialQuotaService>,
|
||||
pub(crate) catalog: Arc<GrokCredentialCatalogService>,
|
||||
pub(crate) runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
|
||||
pub(crate) cooldowns: Arc<dyn ProviderCooldownPort>,
|
||||
}
|
||||
|
||||
@@ -179,7 +177,6 @@ impl XaiAdminProvider {
|
||||
refresh: services.refresh,
|
||||
quota: services.quota,
|
||||
catalog: services.catalog,
|
||||
runtime_policy: services.runtime_policy,
|
||||
cooldowns: services.cooldowns,
|
||||
}
|
||||
}
|
||||
@@ -430,11 +427,6 @@ impl ProviderAdmin for XaiAdminProvider {
|
||||
let document = GrokOAuthImportDocument::parse_json(&document)
|
||||
.map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))?;
|
||||
let discovery = self.oauth.discover().await.map_err(map_oauth_error)?;
|
||||
let policy = self
|
||||
.runtime_policy
|
||||
.load_refresh_policy()
|
||||
.await
|
||||
.map_err(map_provider_store_error)?;
|
||||
let mut subjects = BTreeSet::new();
|
||||
let mut credentials = Vec::new();
|
||||
// 逐条目独立成败:verify 会真实轮换 RT,后续条目的任何失败都不得
|
||||
@@ -484,7 +476,7 @@ impl ProviderAdmin for XaiAdminProvider {
|
||||
tokens,
|
||||
enabled: true,
|
||||
};
|
||||
let prepared = match GrokCredentialAdmin.prepare_verified_account(&account, policy) {
|
||||
let prepared = match GrokCredentialAdmin.prepare_verified_account(&account) {
|
||||
Ok(prepared) => prepared,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
@@ -632,12 +624,7 @@ impl ProviderAdmin for XaiAdminProvider {
|
||||
.verify_imported_credential(&discovery, candidate)
|
||||
.await
|
||||
.map_err(|error| map_failure_class(error.class()))?;
|
||||
let policy = self
|
||||
.runtime_policy
|
||||
.load_refresh_policy()
|
||||
.await
|
||||
.map_err(map_provider_store_error)?;
|
||||
let prepared = verified_rotation(current, tokens, policy)?;
|
||||
let prepared = verified_rotation(current, tokens)?;
|
||||
prepared_rotation(prepared, command.account.provider_kind)
|
||||
}
|
||||
|
||||
@@ -787,30 +774,22 @@ impl XaiAdminProvider {
|
||||
.exchange_authorization_code(&discovery, grant)
|
||||
.await
|
||||
.map_err(map_oauth_error)?;
|
||||
let policy = self
|
||||
.runtime_policy
|
||||
.load_refresh_policy()
|
||||
.await
|
||||
.map_err(map_provider_store_error)?;
|
||||
let credential = match stored.mutation.target() {
|
||||
AuthorizationMutationTarget::Create { name } => {
|
||||
let prepared = GrokCredentialAdmin
|
||||
.prepare_verified_account(
|
||||
&VerifiedGrokAccount {
|
||||
account_id: ProviderAccountId::new(format!(
|
||||
"acct_{}",
|
||||
Uuid::now_v7().simple()
|
||||
))
|
||||
.map_err(|_| provider_error(ProviderAdminErrorKind::Internal))?,
|
||||
name: name.clone(),
|
||||
email: None,
|
||||
upstream_account_id: None,
|
||||
plan_type: None,
|
||||
tokens,
|
||||
enabled: true,
|
||||
},
|
||||
policy,
|
||||
)
|
||||
.prepare_verified_account(&VerifiedGrokAccount {
|
||||
account_id: ProviderAccountId::new(format!(
|
||||
"acct_{}",
|
||||
Uuid::now_v7().simple()
|
||||
))
|
||||
.map_err(|_| provider_error(ProviderAdminErrorKind::Internal))?,
|
||||
name: name.clone(),
|
||||
email: None,
|
||||
upstream_account_id: None,
|
||||
plan_type: None,
|
||||
tokens,
|
||||
enabled: true,
|
||||
})
|
||||
.map_err(map_repository_error)?;
|
||||
PreparedAuthorizationCredential::Create(prepared_create(prepared, Utc::now())?)
|
||||
}
|
||||
@@ -823,7 +802,7 @@ impl XaiAdminProvider {
|
||||
if current.account.provider() != &self.provider_kind {
|
||||
return Err(provider_error(ProviderAdminErrorKind::NotFound));
|
||||
}
|
||||
let prepared = verified_rotation(current, tokens, policy)?;
|
||||
let prepared = verified_rotation(current, tokens)?;
|
||||
PreparedAuthorizationCredential::Reauthorize(prepared_rotation(
|
||||
prepared,
|
||||
self.provider_kind.clone(),
|
||||
@@ -858,7 +837,6 @@ impl CredentialCommitGuard for XaiCredentialCommitGuard {
|
||||
fn verified_rotation(
|
||||
current: LoadedCredential,
|
||||
tokens: VerifiedTokenSet,
|
||||
policy: ProviderRefreshPolicy,
|
||||
) -> Result<PreparedGrokCredentialRotation, ProviderAdminError> {
|
||||
let expires_in = tokens
|
||||
.expires_in()
|
||||
@@ -867,13 +845,9 @@ fn verified_rotation(
|
||||
.refresh_token()
|
||||
.cloned()
|
||||
.ok_or_else(|| provider_error(ProviderAdminErrorKind::Invalid))?;
|
||||
let now = SystemTime::now();
|
||||
let access_token_expires_at = now
|
||||
let access_token_expires_at = SystemTime::now()
|
||||
.checked_add(expires_in)
|
||||
.ok_or_else(|| provider_error(ProviderAdminErrorKind::Invalid))?;
|
||||
let next_refresh_at = policy
|
||||
.next_attempt_at(current.account.id(), access_token_expires_at, now)
|
||||
.map_err(map_provider_store_error)?;
|
||||
GrokCredentialAdmin
|
||||
.prepare_rotation(&RotateManagedGrokCredential {
|
||||
secret: GrokOAuthSecret {
|
||||
@@ -891,7 +865,6 @@ fn verified_rotation(
|
||||
refresh_token_expires_at: None,
|
||||
},
|
||||
current,
|
||||
next_refresh_at: next_refresh_at.into(),
|
||||
})
|
||||
.map_err(map_repository_error)
|
||||
}
|
||||
@@ -1086,6 +1059,7 @@ fn project_quota(
|
||||
group: quota_group(period_kind).to_owned(),
|
||||
label: xai_quota_window_label(period_kind, window_seconds),
|
||||
source: None,
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds,
|
||||
used_percent: billing.used_percent(),
|
||||
reset_at: billing.period_end().and_then(parse_utc),
|
||||
@@ -1101,6 +1075,7 @@ fn project_quota(
|
||||
group: "shortTerm".to_owned(),
|
||||
label: "日限额".to_owned(),
|
||||
source: None,
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds: Some(crate::GROK_FREE_ROLLING_WINDOW_SECONDS),
|
||||
used_percent: None,
|
||||
reset_at: None,
|
||||
|
||||
@@ -444,9 +444,8 @@ impl GrokCredentialRefreshService {
|
||||
if refresh_token.is_empty() || refresh_token.len() > MAX_SECRET_BYTES {
|
||||
return Err(GrokCredentialRefreshError::InvalidRefreshResponse);
|
||||
}
|
||||
let (access_token_expires_at, next_refresh_at) =
|
||||
refreshed_deadlines(&account_id, tokens.expires_in, policy)
|
||||
.ok_or(GrokCredentialRefreshError::InvalidRefreshResponse)?;
|
||||
let access_token_expires_at = refreshed_access_token_expiry(tokens.expires_in)
|
||||
.ok_or(GrokCredentialRefreshError::InvalidRefreshResponse)?;
|
||||
let subject = loaded
|
||||
.account
|
||||
.upstream_user_id()
|
||||
@@ -473,7 +472,6 @@ impl GrokCredentialRefreshService {
|
||||
loaded.refresh_token_expires_at
|
||||
},
|
||||
},
|
||||
next_refresh_at,
|
||||
})
|
||||
.map_err(GrokCredentialRefreshError::from)?;
|
||||
Ok(prepared.with_refresh_guards(capacity_guard, account_guard))
|
||||
@@ -486,7 +484,7 @@ impl GrokCredentialRefreshService {
|
||||
let policy = self.runtime_policy.load_refresh_policy().await?;
|
||||
let batch = self
|
||||
.repository
|
||||
.list_due_refresh_batch_excluding(excluded_account_ids)
|
||||
.list_due_refresh_batch_excluding(excluded_account_ids, policy)
|
||||
.await?;
|
||||
let mut outcomes = batch
|
||||
.failed_account_ids
|
||||
@@ -585,7 +583,7 @@ impl GrokCredentialRefreshService {
|
||||
};
|
||||
|
||||
match self.refresher.refresh(&credential.refresh_token).await {
|
||||
Ok(tokens) => self.persist_success(credential, tokens, policy).await,
|
||||
Ok(tokens) => self.persist_success(credential, tokens).await,
|
||||
Err(GrokRefreshFailure::InvalidGrant) => {
|
||||
self.persist_terminal_failure(
|
||||
credential,
|
||||
@@ -639,7 +637,6 @@ impl GrokCredentialRefreshService {
|
||||
&self,
|
||||
credential: DueGrokCredential,
|
||||
tokens: GrokRefreshTokens,
|
||||
policy: ProviderRefreshPolicy,
|
||||
) -> Result<GrokCredentialRefreshOutcome, GrokCredentialRefreshError> {
|
||||
if tokens.access_token.is_empty() || tokens.access_token.len() > MAX_SECRET_BYTES {
|
||||
return Err(GrokCredentialRefreshError::InvalidRefreshResponse);
|
||||
@@ -652,9 +649,8 @@ impl GrokCredentialRefreshService {
|
||||
return Err(GrokCredentialRefreshError::InvalidRefreshResponse);
|
||||
}
|
||||
let account_id = credential.account_id.clone();
|
||||
let (access_expires_at, next_refresh_at) =
|
||||
refreshed_deadlines(&account_id, tokens.expires_in, policy)
|
||||
.ok_or(GrokCredentialRefreshError::InvalidRefreshResponse)?;
|
||||
let access_expires_at = refreshed_access_token_expiry(tokens.expires_in)
|
||||
.ok_or(GrokCredentialRefreshError::InvalidRefreshResponse)?;
|
||||
let access_token = tokens.access_token.clone();
|
||||
let subject = credential.subject.clone();
|
||||
let email = credential.email.clone();
|
||||
@@ -681,7 +677,6 @@ impl GrokCredentialRefreshService {
|
||||
credential.refresh_token_expires_at
|
||||
},
|
||||
},
|
||||
next_refresh_at,
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -711,7 +706,6 @@ impl GrokCredentialRefreshService {
|
||||
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 {
|
||||
@@ -854,6 +848,7 @@ impl GrokCredentialRepository {
|
||||
async fn list_due_refresh_batch_excluding(
|
||||
&self,
|
||||
excluded_account_ids: &[ProviderAccountId],
|
||||
policy: ProviderRefreshPolicy,
|
||||
) -> Result<DueGrokRefreshBatch, GrokCredentialRepositoryError> {
|
||||
if excluded_account_ids.len() > MAX_REFRESH_EXCLUSIONS {
|
||||
return Err(GrokCredentialRepositoryError::InvalidInput(
|
||||
@@ -869,12 +864,12 @@ impl GrokCredentialRepository {
|
||||
.list_all_accounts()
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|account| !excluded.contains(account.id()) && account_due(account, now))
|
||||
.filter(|account| !excluded.contains(account.id()) && account_due(account, policy, now))
|
||||
.collect::<Vec<_>>();
|
||||
accounts.sort_by_key(|account| {
|
||||
(
|
||||
account.next_refresh_at(),
|
||||
account.access_token_expires_at(),
|
||||
account.next_refresh_at(),
|
||||
account.id().clone(),
|
||||
)
|
||||
});
|
||||
@@ -917,6 +912,7 @@ impl GrokCredentialRepository {
|
||||
|
||||
fn account_due(
|
||||
account: &gateway_core::engine::credential::ProviderAccount,
|
||||
policy: ProviderRefreshPolicy,
|
||||
now: SystemTime,
|
||||
) -> bool {
|
||||
let availability = account.availability();
|
||||
@@ -929,7 +925,18 @@ fn account_due(
|
||||
| AccountAvailability::Invalid
|
||||
)
|
||||
&& (refresh_recovery_window_exhausted(account.access_token_expires_at(), now)
|
||||
|| account.next_refresh_at().is_some_and(|next| next <= now))
|
||||
|| (access_token_refresh_due(account.access_token_expires_at(), policy, now)
|
||||
&& account
|
||||
.next_refresh_at()
|
||||
.is_none_or(|retry_at| retry_at <= now)))
|
||||
}
|
||||
|
||||
fn access_token_refresh_due(
|
||||
access_token_expires_at: Option<SystemTime>,
|
||||
policy: ProviderRefreshPolicy,
|
||||
now: SystemTime,
|
||||
) -> bool {
|
||||
access_token_expires_at.is_some_and(|expires_at| policy.is_refresh_due(expires_at, now))
|
||||
}
|
||||
|
||||
fn refreshed_tokens(tokens: RefreshedTokenSet) -> Result<GrokRefreshTokens, GrokRefreshFailure> {
|
||||
@@ -963,22 +970,13 @@ fn classify_oauth_refresh_error(error: OAuthError) -> GrokRefreshFailure {
|
||||
}
|
||||
}
|
||||
|
||||
fn refreshed_deadlines(
|
||||
account_id: &ProviderAccountId,
|
||||
expires_in: Duration,
|
||||
policy: ProviderRefreshPolicy,
|
||||
) -> Option<(DateTime<Utc>, DateTime<Utc>)> {
|
||||
fn refreshed_access_token_expiry(expires_in: Duration) -> Option<DateTime<Utc>> {
|
||||
if expires_in.is_zero() {
|
||||
return None;
|
||||
}
|
||||
let expires = chrono::Duration::from_std(expires_in).ok()?;
|
||||
let observed_at = SystemTime::now();
|
||||
let access_expires_at = DateTime::<Utc>::from(observed_at).checked_add_signed(expires)?;
|
||||
let next_refresh_at = policy
|
||||
.next_attempt_at(account_id, access_expires_at.into(), observed_at)
|
||||
.ok()
|
||||
.map(DateTime::<Utc>::from)?;
|
||||
Some((access_expires_at, next_refresh_at))
|
||||
DateTime::<Utc>::from(observed_at).checked_add_signed(expires)
|
||||
}
|
||||
|
||||
fn stale_repository_error(error: &GrokCredentialRepositoryError) -> bool {
|
||||
|
||||
@@ -12,7 +12,6 @@ use gateway_core::engine::credential::{
|
||||
ProviderAccountUpdate, QuotaObservation, QuotaWriteOutcome,
|
||||
};
|
||||
use gateway_core::error::StoreErrorKind;
|
||||
use gateway_core::provider_ports::ProviderRefreshPolicy;
|
||||
use gateway_core::routing::ProviderKind;
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
@@ -169,7 +168,7 @@ impl GrokCredentialAdmin {
|
||||
input.account.plan_type.clone(),
|
||||
)
|
||||
.with_runtime_state(input.enabled, input.initial_availability.into())
|
||||
.with_refresh_schedule(true, Some(to_system_time(input.next_refresh_at)));
|
||||
.with_refresh_schedule(true, None);
|
||||
Ok(NewProviderAccount {
|
||||
account,
|
||||
credential: encode_secret(&input.secret, &input.account)?,
|
||||
@@ -180,7 +179,6 @@ impl GrokCredentialAdmin {
|
||||
pub fn prepare_verified_account(
|
||||
&self,
|
||||
input: &VerifiedGrokAccount,
|
||||
policy: ProviderRefreshPolicy,
|
||||
) -> Result<NewProviderAccount, GrokCredentialRepositoryError> {
|
||||
let expires_in = input
|
||||
.tokens
|
||||
@@ -194,9 +192,6 @@ impl GrokCredentialAdmin {
|
||||
let access_token_expires_at = now
|
||||
.checked_add(expires_in)
|
||||
.ok_or(GrokCredentialRepositoryError::InvalidInput("expires_in"))?;
|
||||
let next_refresh_at = policy
|
||||
.next_attempt_at(&input.account_id, access_token_expires_at, now)
|
||||
.map_err(|_| GrokCredentialRepositoryError::InvalidInput("next_refresh_at"))?;
|
||||
self.prepare_import(&CreateGrokCredential {
|
||||
account_id: input.account_id.clone(),
|
||||
name: input.name.clone(),
|
||||
@@ -214,7 +209,6 @@ impl GrokCredentialAdmin {
|
||||
access_token_expires_at: access_token_expires_at.into(),
|
||||
refresh_token_expires_at: None,
|
||||
},
|
||||
next_refresh_at: next_refresh_at.into(),
|
||||
enabled: input.enabled,
|
||||
initial_availability: GrokCredentialAvailability::Ready,
|
||||
initial_availability_reason: None,
|
||||
@@ -308,7 +302,6 @@ impl GrokCredentialAdmin {
|
||||
account.revision(),
|
||||
&input.secret,
|
||||
&input.verified_account,
|
||||
input.next_refresh_at,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -364,7 +357,6 @@ impl GrokCredentialRepository {
|
||||
input.expected_revision,
|
||||
&input.secret,
|
||||
&input.verified_account,
|
||||
input.next_refresh_at,
|
||||
)?;
|
||||
let outcome = self
|
||||
.store
|
||||
@@ -644,10 +636,8 @@ fn prepare_rotation(
|
||||
expected_revision: CredentialRevision,
|
||||
secret: &GrokOAuthSecret,
|
||||
verified_account: &GrokAccountProfile,
|
||||
next_refresh_at: DateTime<Utc>,
|
||||
) -> Result<PreparedGrokCredentialRotation, GrokCredentialRepositoryError> {
|
||||
validate_profile(verified_account)?;
|
||||
validate_refresh_schedule(next_refresh_at, verified_account.access_token_expires_at)?;
|
||||
validate_secret(secret)?;
|
||||
ensure_xai(current)?;
|
||||
if current.revision() != expected_revision {
|
||||
@@ -681,7 +671,7 @@ fn prepare_rotation(
|
||||
encode_secret(&merged_secret, verified_account)?,
|
||||
true,
|
||||
Some(to_system_time(verified_account.access_token_expires_at)),
|
||||
Some(to_system_time(next_refresh_at)),
|
||||
None,
|
||||
)
|
||||
.map_err(|_| GrokCredentialRepositoryError::InvalidCredentialData)?;
|
||||
Ok(PreparedGrokCredentialRotation::new(profile, credential))
|
||||
@@ -707,7 +697,6 @@ fn decode_secret(
|
||||
fn validate_create(input: &CreateGrokCredential) -> Result<(), GrokCredentialRepositoryError> {
|
||||
validate_name(&input.name)?;
|
||||
validate_profile(&input.account)?;
|
||||
validate_refresh_schedule(input.next_refresh_at, input.account.access_token_expires_at)?;
|
||||
validate_secret(&input.secret)?;
|
||||
validate_reason(input.initial_availability_reason.as_deref())
|
||||
}
|
||||
@@ -733,18 +722,6 @@ fn validate_profile(profile: &GrokAccountProfile) -> Result<(), GrokCredentialRe
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_refresh_schedule(
|
||||
next_refresh_at: DateTime<Utc>,
|
||||
access_token_expires_at: DateTime<Utc>,
|
||||
) -> Result<(), GrokCredentialRepositoryError> {
|
||||
if next_refresh_at >= access_token_expires_at {
|
||||
return Err(GrokCredentialRepositoryError::InvalidInput(
|
||||
"next_refresh_at",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_secret(secret: &GrokOAuthSecret) -> Result<(), GrokCredentialRepositoryError> {
|
||||
for value in [&secret.access_token, &secret.refresh_token] {
|
||||
let exposed = value.expose();
|
||||
|
||||
@@ -97,7 +97,6 @@ pub struct CreateGrokCredential {
|
||||
pub name: String,
|
||||
pub secret: GrokOAuthSecret,
|
||||
pub account: GrokAccountProfile,
|
||||
pub next_refresh_at: DateTime<Utc>,
|
||||
pub enabled: bool,
|
||||
pub initial_availability: GrokCredentialAvailability,
|
||||
pub initial_availability_reason: Option<String>,
|
||||
@@ -111,7 +110,6 @@ impl fmt::Debug for CreateGrokCredential {
|
||||
.field("name", &self.name)
|
||||
.field("secret", &"[REDACTED]")
|
||||
.field("account", &self.account)
|
||||
.field("next_refresh_at", &self.next_refresh_at)
|
||||
.field("enabled", &self.enabled)
|
||||
.field("initial_availability", &self.initial_availability)
|
||||
.finish_non_exhaustive()
|
||||
@@ -124,7 +122,6 @@ pub struct RotateGrokCredential {
|
||||
pub expected_revision: CredentialRevision,
|
||||
pub secret: GrokOAuthSecret,
|
||||
pub verified_account: GrokAccountProfile,
|
||||
pub next_refresh_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// App 已读取的当前账号与 xAI 已验证的新 OAuth 身份材料。
|
||||
@@ -132,7 +129,6 @@ pub struct RotateManagedGrokCredential {
|
||||
pub current: LoadedCredential,
|
||||
pub secret: GrokOAuthSecret,
|
||||
pub verified_account: GrokAccountProfile,
|
||||
pub next_refresh_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for RotateManagedGrokCredential {
|
||||
@@ -142,7 +138,6 @@ impl fmt::Debug for RotateManagedGrokCredential {
|
||||
.field("current", &self.current)
|
||||
.field("secret", &"[REDACTED]")
|
||||
.field("verified_account", &self.verified_account)
|
||||
.field("next_refresh_at", &self.next_refresh_at)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -231,7 +226,6 @@ impl fmt::Debug for RotateGrokCredential {
|
||||
.field("expected_revision", &self.expected_revision)
|
||||
.field("secret", &"[REDACTED]")
|
||||
.field("verified_account", &self.verified_account)
|
||||
.field("next_refresh_at", &self.next_refresh_at)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +198,6 @@ pub async fn initialize(
|
||||
refresh: Arc::clone(&refresh),
|
||||
quota: Arc::clone("a),
|
||||
catalog: Arc::clone(&catalog),
|
||||
runtime_policy,
|
||||
cooldowns: Arc::clone(&cooldowns),
|
||||
},
|
||||
cli_release_status,
|
||||
|
||||
@@ -15,7 +15,7 @@ use provider_xai::{
|
||||
VerifiedGrokAccount,
|
||||
};
|
||||
|
||||
use crate::support::{account_id, refresh_policy};
|
||||
use crate::support::account_id;
|
||||
|
||||
const DISCOVERY: &[u8] = include_bytes!("fixtures/discovery.json");
|
||||
const TOKEN_SUCCESS: &[u8] = include_bytes!("fixtures/token_success.json");
|
||||
@@ -118,21 +118,18 @@ async fn credential_import_should_require_exact_metadata_and_official_userinfo()
|
||||
|
||||
assert_eq!(verified.evidence().method(), VerificationMethod::UserInfo);
|
||||
let prepared = GrokCredentialAdmin
|
||||
.prepare_verified_account(
|
||||
&VerifiedGrokAccount {
|
||||
account_id: account_id("verified-import"),
|
||||
name: "verified import".to_owned(),
|
||||
email: Some("verified@example.com".to_owned()),
|
||||
upstream_account_id: None,
|
||||
plan_type: None,
|
||||
tokens: verified,
|
||||
enabled: true,
|
||||
},
|
||||
refresh_policy(),
|
||||
)
|
||||
.prepare_verified_account(&VerifiedGrokAccount {
|
||||
account_id: account_id("verified-import"),
|
||||
name: "verified import".to_owned(),
|
||||
email: Some("verified@example.com".to_owned()),
|
||||
upstream_account_id: None,
|
||||
plan_type: None,
|
||||
tokens: verified,
|
||||
enabled: true,
|
||||
})
|
||||
.expect("Provider projects verified token lifetime");
|
||||
assert_eq!(prepared.account.availability(), AccountAvailability::Ready);
|
||||
assert!(prepared.account.next_refresh_at().is_some());
|
||||
assert!(prepared.account.next_refresh_at().is_none());
|
||||
assert!(
|
||||
prepared
|
||||
.credential
|
||||
|
||||
@@ -99,7 +99,6 @@ async fn rotate_uses_revision_cas_and_replaces_plaintext_tokens() {
|
||||
scope: provider_xai::OFFICIAL_SCOPES.join(" "),
|
||||
},
|
||||
verified_account: profile("subject-rotate"),
|
||||
next_refresh_at: chrono::Utc::now() + chrono::Duration::minutes(30),
|
||||
})
|
||||
.expect("rotate");
|
||||
assert!(matches!(
|
||||
@@ -146,7 +145,6 @@ async fn stale_rotate_does_not_modify_tokens() {
|
||||
scope: provider_xai::OFFICIAL_SCOPES.join(" "),
|
||||
},
|
||||
verified_account: profile("subject-stale"),
|
||||
next_refresh_at: chrono::Utc::now() + chrono::Duration::minutes(30),
|
||||
})
|
||||
.expect("winning rotation");
|
||||
let stale = GrokCredentialAdmin
|
||||
@@ -159,7 +157,6 @@ async fn stale_rotate_does_not_modify_tokens() {
|
||||
scope: provider_xai::OFFICIAL_SCOPES.join(" "),
|
||||
},
|
||||
verified_account: profile("subject-stale"),
|
||||
next_refresh_at: chrono::Utc::now() + chrono::Duration::minutes(30),
|
||||
})
|
||||
.expect("stale command");
|
||||
assert!(matches!(
|
||||
@@ -206,7 +203,6 @@ async fn rotate_rejects_verified_identity_rebind() {
|
||||
scope: provider_xai::OFFICIAL_SCOPES.join(" "),
|
||||
},
|
||||
verified_account: profile("subject-b"),
|
||||
next_refresh_at: chrono::Utc::now() + chrono::Duration::minutes(30),
|
||||
});
|
||||
assert!(matches!(
|
||||
result,
|
||||
@@ -304,7 +300,6 @@ async fn repository_rejects_account_owned_by_another_provider() {
|
||||
scope: provider_xai::OFFICIAL_SCOPES.join(" "),
|
||||
},
|
||||
verified_account: profile("subject"),
|
||||
next_refresh_at: chrono::Utc::now() + chrono::Duration::minutes(30),
|
||||
}),
|
||||
Err(GrokCredentialRepositoryError::WrongProviderKind)
|
||||
));
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use std::future::ready;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use provider_xai::{
|
||||
@@ -117,7 +116,7 @@ async fn missing_required_scope_should_fail_closed() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verified_import_inside_refresh_margin_should_schedule_immediate_refresh() {
|
||||
async fn verified_import_inside_refresh_margin_should_not_persist_a_refresh_schedule() {
|
||||
let now = Utc::now();
|
||||
let client = GrokOAuthClient::new(
|
||||
GrokOAuthConfig::official().expect("official config"),
|
||||
@@ -138,32 +137,19 @@ async fn verified_import_inside_refresh_margin_should_schedule_immediate_refresh
|
||||
)
|
||||
.await
|
||||
.expect("still-valid imported credential");
|
||||
let before = SystemTime::now();
|
||||
let prepared = GrokCredentialAdmin
|
||||
.prepare_verified_account(
|
||||
&VerifiedGrokAccount {
|
||||
account_id: account_id("refresh-window-import"),
|
||||
name: "refresh-window-import".to_owned(),
|
||||
email: None,
|
||||
upstream_account_id: None,
|
||||
plan_type: None,
|
||||
tokens,
|
||||
enabled: true,
|
||||
},
|
||||
gateway_core::provider_ports::ProviderRefreshPolicy::try_new(
|
||||
std::time::Duration::from_secs(60 * 60),
|
||||
std::num::NonZeroU32::new(2).expect("positive concurrency"),
|
||||
)
|
||||
.expect("valid refresh policy"),
|
||||
)
|
||||
.prepare_verified_account(&VerifiedGrokAccount {
|
||||
account_id: account_id("refresh-window-import"),
|
||||
name: "refresh-window-import".to_owned(),
|
||||
email: None,
|
||||
upstream_account_id: None,
|
||||
plan_type: None,
|
||||
tokens,
|
||||
enabled: true,
|
||||
})
|
||||
.expect("valid credential inside refresh window must be imported");
|
||||
let after = SystemTime::now();
|
||||
let next_refresh_at = prepared
|
||||
.account
|
||||
.next_refresh_at()
|
||||
.expect("refreshable credential must be scheduled");
|
||||
|
||||
assert!((before..=after).contains(&next_refresh_at));
|
||||
assert!(prepared.account.next_refresh_at().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
use std::num::NonZeroU32;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime};
|
||||
@@ -11,7 +12,8 @@ use gateway_core::engine::credential::{
|
||||
};
|
||||
use gateway_core::provider_ports::{
|
||||
ProviderCredentialState, ProviderCredentialStatePort, ProviderLeaseAcquisition,
|
||||
ProviderLeasePort, ProviderLeaseRequest, ProviderStoreError,
|
||||
ProviderLeasePort, ProviderLeaseRequest, ProviderRefreshPolicy, ProviderRuntimePolicyPort,
|
||||
ProviderStoreError,
|
||||
};
|
||||
use provider_xai::{
|
||||
GrokCredentialCatalogCache, GrokCredentialRecovery, GrokCredentialRecoveryOutcome,
|
||||
@@ -80,6 +82,31 @@ struct TestRefreshLeases {
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
|
||||
struct MutableRuntimePolicy {
|
||||
policy: Mutex<ProviderRefreshPolicy>,
|
||||
}
|
||||
|
||||
impl MutableRuntimePolicy {
|
||||
fn new(policy: ProviderRefreshPolicy) -> Self {
|
||||
Self {
|
||||
policy: Mutex::new(policy),
|
||||
}
|
||||
}
|
||||
|
||||
fn set(&self, policy: ProviderRefreshPolicy) {
|
||||
*self.policy.lock().expect("runtime policy lock") = policy;
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderRuntimePolicyPort for MutableRuntimePolicy {
|
||||
fn load_refresh_policy(
|
||||
&self,
|
||||
) -> futures::future::BoxFuture<'_, Result<ProviderRefreshPolicy, ProviderStoreError>> {
|
||||
let policy = *self.policy.lock().expect("runtime policy lock");
|
||||
Box::pin(async move { Ok(policy) })
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderLeasePort for TestRefreshLeases {
|
||||
fn load_state<'a>(
|
||||
&'a self,
|
||||
@@ -116,6 +143,11 @@ impl ProviderLeasePort for TestRefreshLeases {
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_policy_with_margin(margin: Duration) -> ProviderRefreshPolicy {
|
||||
ProviderRefreshPolicy::try_new(margin, NonZeroU32::new(2).expect("positive concurrency"))
|
||||
.expect("valid refresh policy")
|
||||
}
|
||||
|
||||
fn success_tokens(rotated: Option<&str>) -> GrokRefreshTokens {
|
||||
GrokRefreshTokens {
|
||||
access_token: SecretValue::new("new-access"),
|
||||
@@ -127,7 +159,6 @@ fn success_tokens(rotated: Option<&str>) -> GrokRefreshTokens {
|
||||
fn due_input(suffix: &str) -> provider_xai::CreateGrokCredential {
|
||||
let mut input = create_input(suffix, &format!("subject-{suffix}"));
|
||||
input.account.access_token_expires_at = Utc::now() + chrono::Duration::minutes(2);
|
||||
input.next_refresh_at = Utc::now() - chrono::Duration::seconds(1);
|
||||
input
|
||||
}
|
||||
|
||||
@@ -253,6 +284,28 @@ async fn fixture_many_with_state(
|
||||
GrokCredentialRepository,
|
||||
Arc<QueueRefresher>,
|
||||
GrokCredentialRefreshService,
|
||||
) {
|
||||
fixture_many_with_state_and_runtime_policy(
|
||||
inputs,
|
||||
responses,
|
||||
lease_available,
|
||||
credential_state,
|
||||
runtime_policy(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fixture_many_with_state_and_runtime_policy(
|
||||
inputs: impl IntoIterator<Item = provider_xai::CreateGrokCredential>,
|
||||
responses: impl IntoIterator<Item = Result<GrokRefreshTokens, GrokRefreshFailure>>,
|
||||
lease_available: bool,
|
||||
credential_state: Arc<dyn ProviderCredentialStatePort>,
|
||||
runtime_policy: Arc<dyn ProviderRuntimePolicyPort>,
|
||||
) -> (
|
||||
Arc<MemoryProviderAccountStore>,
|
||||
GrokCredentialRepository,
|
||||
Arc<QueueRefresher>,
|
||||
GrokCredentialRefreshService,
|
||||
) {
|
||||
let store = MemoryProviderAccountStore::shared();
|
||||
let account_store: Arc<dyn ProviderAccountStore> = store.clone();
|
||||
@@ -278,7 +331,7 @@ async fn fixture_many_with_state(
|
||||
catalog,
|
||||
leases,
|
||||
credential_state,
|
||||
runtime_policy(),
|
||||
runtime_policy,
|
||||
);
|
||||
(store, repository, refresher, service)
|
||||
}
|
||||
@@ -370,6 +423,66 @@ async fn successful_refresh_rotates_plaintext_tokens_once() {
|
||||
!credential_object(&credential).contains_key("refresh_token_expires_at"),
|
||||
"rotated RT has no authoritative expiry in the refresh response"
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.account(&id)
|
||||
.expect("account after refresh")
|
||||
.next_refresh_at()
|
||||
.is_none(),
|
||||
"successful refresh must not persist a policy-derived schedule"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_due_uses_the_current_margin_without_persisting_a_normal_schedule() {
|
||||
let mut input = create_input("runtime-margin", "subject-runtime-margin");
|
||||
input.account.access_token_expires_at = Utc::now() + chrono::Duration::minutes(2);
|
||||
let access_token_expires_at: SystemTime = input.account.access_token_expires_at.into();
|
||||
let id = input.account_id.clone();
|
||||
let policy = Arc::new(MutableRuntimePolicy::new(refresh_policy_with_margin(
|
||||
Duration::from_secs(60),
|
||||
)));
|
||||
let (store, _, refresher, service) = fixture_many_with_state_and_runtime_policy(
|
||||
[input],
|
||||
[Ok(success_tokens(None))],
|
||||
true,
|
||||
Arc::new(CountingCredentialState::default()),
|
||||
policy.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
service
|
||||
.refresh_due()
|
||||
.await
|
||||
.expect("refresh cycle before margin change")
|
||||
.is_empty()
|
||||
);
|
||||
let account = store.account(&id).expect("account before margin change");
|
||||
assert_eq!(
|
||||
account.access_token_expires_at(),
|
||||
Some(access_token_expires_at)
|
||||
);
|
||||
assert!(account.next_refresh_at().is_none());
|
||||
assert_eq!(refresher.prepare_calls.load(Ordering::SeqCst), 0);
|
||||
|
||||
policy.set(refresh_policy_with_margin(Duration::from_secs(5 * 60)));
|
||||
assert!(matches!(
|
||||
service
|
||||
.refresh_due()
|
||||
.await
|
||||
.expect("refresh cycle after margin change")
|
||||
.as_slice(),
|
||||
[GrokCredentialRefreshOutcome::Refreshed { account_id, .. }] if account_id == &id
|
||||
));
|
||||
assert!(
|
||||
store
|
||||
.account(&id)
|
||||
.expect("account after refresh")
|
||||
.next_refresh_at()
|
||||
.is_none(),
|
||||
"the runtime policy must not be persisted as a normal schedule"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -859,14 +972,11 @@ async fn invalid_refresh_lifetime_is_rejected_without_cas_write() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_account_refresh_does_not_stop_later_accounts() {
|
||||
let due_at = Utc::now() - chrono::Duration::seconds(1);
|
||||
let access_expires_at = Utc::now() + chrono::Duration::minutes(2);
|
||||
let mut bad = due_input("bad");
|
||||
bad.next_refresh_at = due_at;
|
||||
bad.account.access_token_expires_at = access_expires_at;
|
||||
let bad_id = bad.account_id.clone();
|
||||
let mut good = due_input("good");
|
||||
good.next_refresh_at = due_at;
|
||||
good.account.access_token_expires_at = access_expires_at;
|
||||
let good_id = good.account_id.clone();
|
||||
let (store, _, _, service) = fixture_many(
|
||||
@@ -925,7 +1035,13 @@ async fn scheduled_oauth_refresh_runs_after_credential_rotation() {
|
||||
assert_eq!(outcome, GrokCredentialRecoveryOutcome::Recovered);
|
||||
assert_eq!(store.account(&id).expect("account").revision().get(), 2);
|
||||
|
||||
force_due(&store, &id).await;
|
||||
set_token_deadlines(
|
||||
&store,
|
||||
&id,
|
||||
(Utc::now() + chrono::Duration::minutes(2)).into(),
|
||||
SystemTime::now() - Duration::from_secs(1),
|
||||
)
|
||||
.await;
|
||||
let outcomes = service.refresh_due().await.expect("refresh due");
|
||||
assert!(matches!(
|
||||
outcomes.as_slice(),
|
||||
|
||||
@@ -358,7 +358,6 @@ async fn account_scoped_cooldown_survives_credential_rotation() {
|
||||
scope: provider_xai::OFFICIAL_SCOPES.join(" "),
|
||||
},
|
||||
verified_account: crate::support::profile("subject-revision-fence"),
|
||||
next_refresh_at: chrono::Utc::now() + chrono::Duration::minutes(30),
|
||||
})
|
||||
.expect("rotate");
|
||||
assert!(matches!(
|
||||
@@ -660,7 +659,6 @@ async fn quota_feedback_should_follow_the_account_across_a_credential_rotation()
|
||||
scope: provider_xai::OFFICIAL_SCOPES.join(" "),
|
||||
},
|
||||
verified_account: crate::support::profile("subject-quota-rotation"),
|
||||
next_refresh_at: chrono::Utc::now() + chrono::Duration::minutes(30),
|
||||
})
|
||||
.expect("rotate");
|
||||
fixture
|
||||
@@ -773,7 +771,6 @@ async fn stale_catalog_revision_does_not_block_transparent_request() {
|
||||
scope: provider_xai::OFFICIAL_SCOPES.join(" "),
|
||||
},
|
||||
verified_account: crate::support::profile("subject-catalog-stale"),
|
||||
next_refresh_at: chrono::Utc::now() + chrono::Duration::minutes(30),
|
||||
})
|
||||
.expect("rotate");
|
||||
assert!(matches!(
|
||||
@@ -930,7 +927,6 @@ async fn smart_strategy_never_reuses_quota_projection_after_credential_rotation(
|
||||
scope: provider_xai::OFFICIAL_SCOPES.join(" "),
|
||||
},
|
||||
verified_account: crate::support::profile("subject-aaa-stale-high"),
|
||||
next_refresh_at: chrono::Utc::now() + chrono::Duration::minutes(30),
|
||||
})
|
||||
.expect("rotate");
|
||||
let outcome = fixture
|
||||
|
||||
@@ -538,7 +538,6 @@ pub fn profile(subject: &str) -> GrokAccountProfile {
|
||||
}
|
||||
|
||||
pub fn create_input(suffix: &str, subject: &str) -> CreateGrokCredential {
|
||||
let now = Utc::now();
|
||||
CreateGrokCredential {
|
||||
account_id: account_id(suffix),
|
||||
name: format!("xAI {suffix}"),
|
||||
@@ -549,7 +548,6 @@ pub fn create_input(suffix: &str, subject: &str) -> CreateGrokCredential {
|
||||
scope: provider_xai::OFFICIAL_SCOPES.join(" "),
|
||||
},
|
||||
account: profile(subject),
|
||||
next_refresh_at: now + chrono::Duration::minutes(30),
|
||||
enabled: true,
|
||||
initial_availability: GrokCredentialAvailability::Unknown,
|
||||
initial_availability_reason: None,
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
5f79e74c9a7a7366c39cd1afe43175eff6c44afe9217c0f26f33f173bd40c781 0001_initial.sql
|
||||
f4168f7e23d42cf9d37096339e931b0e0c823f7a8dbfc6580a04f096c4c3d8e3 0002_provider_accounts_nullable_upstream_user_id.sql
|
||||
4163f61183d9e90f1368c08f614040d6a1a0048de3886a77dd708ce21430b2d0 0003_backfill_openai_access_token_expiry.sql
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
-- Direct OpenAI OAuth imports between 3.1.15 and 3.1.16 persisted JWT access
|
||||
-- tokens but accidentally omitted their derived expiry. Recover only future,
|
||||
-- standard JWT expiries; malformed/opaque tokens remain unchanged so this
|
||||
-- one-shot repair never fabricates a credential lifetime or refresh schedule.
|
||||
|
||||
create function _cpr_openai_access_token_expiry(access_token text)
|
||||
returns timestamptz
|
||||
language plpgsql
|
||||
immutable
|
||||
strict
|
||||
as $$
|
||||
declare
|
||||
payload text;
|
||||
claims jsonb;
|
||||
epoch_seconds bigint;
|
||||
begin
|
||||
if access_token !~ '^[^.]+[.][^.]+[.][^.]+$' then
|
||||
return null;
|
||||
end if;
|
||||
|
||||
payload := split_part(access_token, '.', 2);
|
||||
payload := rpad(
|
||||
translate(payload, '-_', '+/'),
|
||||
((length(payload) + 3) / 4) * 4,
|
||||
'='
|
||||
);
|
||||
claims := convert_from(decode(payload, 'base64'), 'UTF8')::jsonb;
|
||||
if jsonb_typeof(claims -> 'exp') <> 'number' then
|
||||
return null;
|
||||
end if;
|
||||
|
||||
epoch_seconds := (claims ->> 'exp')::bigint;
|
||||
return to_timestamp(epoch_seconds::double precision);
|
||||
exception
|
||||
when others then
|
||||
return null;
|
||||
end;
|
||||
$$;
|
||||
|
||||
with candidates as (
|
||||
select
|
||||
account.id,
|
||||
clock_timestamp() as observed_at,
|
||||
_cpr_openai_access_token_expiry(
|
||||
account.provider_credentials_json ->> 'access_token'
|
||||
) as access_token_expires_at
|
||||
from provider_accounts as account
|
||||
where account.provider_kind = 'openai'
|
||||
and account.authentication_kind = 'oauth'
|
||||
and account.has_refresh_token
|
||||
and account.access_token_expires_at is null
|
||||
and account.next_refresh_at is null
|
||||
and account.availability not in ('expired', 'invalid', 'banned')
|
||||
and jsonb_typeof(account.provider_credentials_json -> 'access_token') = 'string'
|
||||
)
|
||||
update provider_accounts as account
|
||||
set
|
||||
access_token_expires_at = candidates.access_token_expires_at,
|
||||
updated_at = greatest(account.updated_at, candidates.observed_at)
|
||||
from candidates
|
||||
where account.id = candidates.id
|
||||
and candidates.access_token_expires_at > candidates.observed_at
|
||||
and account.provider_kind = 'openai'
|
||||
and account.authentication_kind = 'oauth'
|
||||
and account.has_refresh_token
|
||||
and account.access_token_expires_at is null
|
||||
and account.next_refresh_at is null
|
||||
and account.availability not in ('expired', 'invalid', 'banned');
|
||||
|
||||
drop function _cpr_openai_access_token_expiry(text);
|
||||
+1
-1
@@ -40,7 +40,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
# 备份客户端来源:与 compose 服务端同一固定镜像,版本一致。
|
||||
FROM ${POSTGRES_IMAGE} AS pg-client
|
||||
|
||||
FROM debian:bookworm-slim@sha256:7b140f374b289a7c2befc338f42ebe6441b7ea838a042bbd5acbfca6ec875818 AS runtime-base
|
||||
FROM debian:bookworm-slim@sha256:abd67ffcfa541b485a3dff59865ab629aa048a6c613e639d36e7456b0b229241 AS runtime-base
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
|
||||
+9
-7
@@ -111,9 +111,9 @@ PostgreSQL 是账号、Client Key、运行设置、请求记录与审计的权
|
||||
Provider schema 以明文 JSON 保存于 PostgreSQL。Redis 只保存可重建、可过期的协调状态,例如
|
||||
会话亲和、lease、cooldown、OAuth pending flow 与套餐模型目录 cache。
|
||||
|
||||
备份必须包含 `.runtime/postgres`。要保留 OpenAI OAuth 的 AT/RT 恢复能力,必须保持
|
||||
`host.logging.file.enabled: true` 并备份 `.runtime/logs`;该记录受普通日志的 `retention_days` 和
|
||||
`max_files` 约束。若希望保留短期 Redis 状态和会话锚点,也同时备份 `.runtime/redis` 与
|
||||
备份必须包含 `.runtime/postgres`。要保留 OAuth 的 AT/RT 恢复能力,必须保持
|
||||
`host.logging.file.enabled: true` 并备份 `.runtime/logs`;恢复记录位于独立文件集,仍按普通日志的
|
||||
`retention_days` 和 `max_files` 分别约束。若希望保留短期 Redis 状态和会话锚点,也同时备份 `.runtime/redis` 与
|
||||
`.runtime/data`。
|
||||
|
||||
完整运行时、Provider、revision 与恢复边界见 [架构文档](../docs/architecture.md)。
|
||||
@@ -203,10 +203,12 @@ Release 必须提供当前 OS/架构的 `codex-proxy-rs_<version>_<os>_<arch>.ta
|
||||
Debian 官方源补 `libpq5` 依赖,不引入第三方 APT 源,也不依赖运行时动态安装。
|
||||
- 备份暂存目录为 `/app/.runtime/data/backup-staging`,由 `.runtime/data` 卷持久化,权限
|
||||
`0700`(仅 `cpr` 用户可读写)。部署卷至少预留一个最大数据库归档的空间。
|
||||
- OpenAI OAuth 每次成功取得 AT/RT 后,都会在账号资料补全、过期时间计算和数据库写入前,以
|
||||
`openai_oauth_recovery` 结构化日志事件写入普通应用日志。事件含原始 AT/RT,完全沿用
|
||||
`.runtime/logs` 的按日、按大小分割和保留清理策略;文件日志开启时不会被普通日志级别筛掉。
|
||||
它遵循普通日志现有的非阻塞写入机制,不会阻断导入、授权或刷新。
|
||||
- OAuth 恢复记录通道使用 `oauth_recovery` 结构化日志事件;当前 OpenAI provider 每次成功取得
|
||||
AT/RT 后,都会在账号资料补全、过期时间计算和数据库写入前写入独立的
|
||||
`codex-proxy-rs-oath.YYYY-MM-DD[.N].log` 文件集。事件含原始 AT/RT,并以 `provider`
|
||||
字段标记来源,且与普通 `.runtime/logs` 完全相同地按日、按大小分割,并分别按相同 `retention_days` 与 `max_files`
|
||||
清理;文件日志开启时不会被普通日志级别筛掉。它遵循普通日志现有的非阻塞写入机制,不会阻断
|
||||
导入、授权或刷新。
|
||||
`.runtime/logs` 因此属于敏感数据,必须按现有普通日志的访问控制和加密备份策略处理。
|
||||
- S3/R2 存储、Cron 计划、保留策略与备份记录都保存在 PostgreSQL(`backup_settings` /
|
||||
`backup_records`),备份记录行在删除成功后硬删除,操作历史进入 `admin_audit_events`。
|
||||
|
||||
@@ -16,32 +16,32 @@
|
||||
"@boxicons/vue": "1.0.1",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
"@lucide/vue": "^1.28.0",
|
||||
"@lucide/vue": "^1.30.0",
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"axios": "^1.19.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"dompurify": "^3.4.12",
|
||||
"dompurify": "^3.4.13",
|
||||
"echarts": "^6.1.0",
|
||||
"es-toolkit": "^1.50.0",
|
||||
"gsap": "^3.15.0",
|
||||
"marked": "^18.0.7",
|
||||
"marked": "^18.0.9",
|
||||
"pinia": "^4.0.2",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"vue": "^3.5.40",
|
||||
"vue": "^3.5.41",
|
||||
"vue-router": "^5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^9.2.0",
|
||||
"@antfu/eslint-config": "^9.3.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/node": "^26.2.0",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-plugin-format": "^2.0.1",
|
||||
"eslint-plugin-vuejs-accessibility": "^2.5.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.2.0",
|
||||
"vite": "^8.2.1",
|
||||
"vue-tsc": "^3.3.9"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+541
-628
File diff suppressed because it is too large
Load Diff
@@ -69,6 +69,14 @@ const loadingIconSize: Record<ButtonSize, number> = {
|
||||
lg: 17,
|
||||
}
|
||||
|
||||
const loadingIndicatorStyle = computed(() => {
|
||||
const iconSize = loadingIconSize[props.size]
|
||||
return {
|
||||
width: `${iconSize}px`,
|
||||
height: `${iconSize}px`,
|
||||
}
|
||||
})
|
||||
|
||||
const classes = computed(() => [
|
||||
'inline-flex items-center justify-center border-0 font-bold leading-[1.15] transition-[background-color,box-shadow,color,opacity,transform] duration-150 cursor-pointer outline-none motion-safe:active:translate-y-px motion-safe:active:scale-[0.985]',
|
||||
props.iconOnly ? iconOnlySizeClasses[props.size] : sizeClasses[props.size],
|
||||
@@ -110,12 +118,25 @@ const labelClasses = computed(() => [
|
||||
>
|
||||
<span
|
||||
v-if="loading"
|
||||
class="inline-flex shrink-0 items-center justify-center leading-none"
|
||||
class="relative inline-flex shrink-0"
|
||||
:style="loadingIndicatorStyle"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
:width="loadingIconSize[size]"
|
||||
:height="loadingIconSize[size]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
class="absolute left-0 top-0 opacity-50"
|
||||
>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
</svg>
|
||||
<LoaderCircle
|
||||
:size="loadingIconSize[size]"
|
||||
class="block animate-spin origin-center [transform-box:view-box] will-change-transform"
|
||||
class="absolute left-0 top-0 animate-spin origin-center transform-view will-change-transform"
|
||||
/>
|
||||
</span>
|
||||
<span v-if="$slots.icon && !loading" class="inline-flex shrink-0">
|
||||
|
||||
@@ -13,7 +13,7 @@ const quotaWindows = computed(() => props.account.quota.windows)
|
||||
const visibleQuotaWindows = computed(() => visibleSummaryQuotaWindows(quotaWindows.value))
|
||||
const summaryClass = computed(
|
||||
() =>
|
||||
`grid w-full max-w-40 min-w-0 gap-2 whitespace-normal py-0.5 ${
|
||||
`grid w-full min-w-0 gap-1.5 py-0.5 ${
|
||||
visibleQuotaWindows.value.length <= 1 ? 'min-h-13 content-center' : ''
|
||||
}`,
|
||||
)
|
||||
|
||||
@@ -66,25 +66,29 @@ function variantValue(metric: string, compact: string, detail: string) {
|
||||
|
||||
const rootClass = computed(() =>
|
||||
variantValue(
|
||||
'grid min-w-0 grid-rows-[11px_14px] gap-1',
|
||||
'grid min-w-0 grid-rows-[14px_14px] gap-1',
|
||||
'flex min-w-0 flex-col',
|
||||
'rounded-lg bg-(--cp-bg-subtle) p-2',
|
||||
),
|
||||
)
|
||||
const headerClass = computed(() =>
|
||||
variantValue(
|
||||
'flex items-center justify-between gap-2 text-[11px] leading-none font-bold',
|
||||
'mb-1 flex items-center justify-between gap-2 text-[11px] leading-none font-bold',
|
||||
'flex items-center justify-between gap-2 text-[11px] leading-[14px] font-bold',
|
||||
'mb-1 flex items-baseline justify-between gap-2 text-[11px] leading-[14px] font-bold',
|
||||
'flex items-center justify-between gap-3 text-[12px] font-bold',
|
||||
),
|
||||
)
|
||||
const labelClass = computed(() =>
|
||||
isCompact.value || isMetric.value ? 'text-(--cp-text-muted)' : 'text-(--cp-text-secondary)',
|
||||
)
|
||||
const labelClass = computed(() => {
|
||||
if (isMetric.value)
|
||||
return 'truncate text-(--cp-text-muted)'
|
||||
if (isCompact.value)
|
||||
return 'truncate text-(--cp-text-muted)'
|
||||
return 'text-(--cp-text-secondary)'
|
||||
})
|
||||
const valueClass = computed(() =>
|
||||
variantValue(
|
||||
'text-[11px] leading-none font-bold',
|
||||
'text-[10px] leading-none font-heavy',
|
||||
'text-[10px] leading-[14px] font-heavy',
|
||||
'text-[12px] font-heavy',
|
||||
),
|
||||
)
|
||||
@@ -94,7 +98,7 @@ const trackOffsetClass = computed(() =>
|
||||
const trackShapeClass = computed(() =>
|
||||
variantValue(
|
||||
'h-1.5 w-full overflow-hidden rounded-full',
|
||||
'h-1 w-full overflow-hidden rounded-full',
|
||||
'h-[3px] w-full overflow-hidden rounded-full',
|
||||
'h-2 overflow-hidden rounded-full',
|
||||
),
|
||||
)
|
||||
@@ -104,7 +108,7 @@ const barStyle = computed(() => {
|
||||
return undefined
|
||||
return quotaWindowBarStyle(
|
||||
props.window,
|
||||
variantValue('6px', '4px', '8px'),
|
||||
variantValue('6px', '3px', '8px'),
|
||||
)
|
||||
})
|
||||
const barClass = computed(() => props.window ? quotaWindowBarClass(props.window) : undefined)
|
||||
@@ -167,10 +171,38 @@ const requestBars = computed(() => {
|
||||
<div :class="rootClass">
|
||||
<template v-if="mode === 'quota' && window">
|
||||
<div :class="headerClass">
|
||||
<span class="min-w-0" :class="labelClass">
|
||||
<span
|
||||
class="min-w-0"
|
||||
:class="labelClass"
|
||||
:title="isCompact || isMetric ? window.labelDisplay : undefined"
|
||||
>
|
||||
{{ window.labelDisplay }}
|
||||
</span>
|
||||
<span class="flex shrink-0 items-baseline justify-end gap-1.5 font-mono tabular-nums">
|
||||
<span
|
||||
v-if="isCompact"
|
||||
class="flex shrink-0 items-baseline justify-end gap-1.5 font-mono tabular-nums"
|
||||
>
|
||||
<span
|
||||
v-if="quotaLocalUsageVisible"
|
||||
class="text-right text-(--cp-text-muted)"
|
||||
:class="valueClass"
|
||||
:title="`本地 Token 用量:${quotaLocalUsageDisplay}`"
|
||||
>
|
||||
{{ quotaLocalUsageDisplay }}
|
||||
</span>
|
||||
<span
|
||||
v-if="quotaValueVisible"
|
||||
class="text-right"
|
||||
:class="[valueClass, percentTextClass]"
|
||||
:title="`额度已使用:${window.usedPercentDisplay}`"
|
||||
>
|
||||
{{ window.usedPercentDisplay }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="flex shrink-0 items-baseline justify-end gap-1.5 font-mono tabular-nums"
|
||||
>
|
||||
<span
|
||||
v-if="quotaLocalUsageVisible"
|
||||
class="text-(--cp-text-muted)"
|
||||
|
||||
@@ -25,7 +25,7 @@ const quotaGroupOrder = new Map([
|
||||
['other', 2],
|
||||
])
|
||||
|
||||
const relaxedCellClass = 'py-3 align-middle'
|
||||
const relaxedCellClass = 'py-2 align-middle'
|
||||
|
||||
export const accountColumns = [
|
||||
{
|
||||
@@ -76,8 +76,8 @@ export const accountColumns = [
|
||||
key: 'planType',
|
||||
label: '套餐',
|
||||
sortable: true,
|
||||
flex: 0.8,
|
||||
minWidth: '112px',
|
||||
flex: 0.5,
|
||||
minWidth: '90px',
|
||||
cellClass: relaxedCellClass,
|
||||
},
|
||||
{
|
||||
@@ -96,7 +96,8 @@ export const accountColumns = [
|
||||
minWidth: '160px',
|
||||
format: (_value: unknown, row: AccountRow) => optionalAccountCell(row.usage.lastUsedAtDisplay),
|
||||
emptyText: '',
|
||||
cellClass: `${relaxedCellClass} text-(--cp-text-secondary)`,
|
||||
headerClass: '!pl-8',
|
||||
cellClass: `${relaxedCellClass} !pl-8 text-(--cp-text-secondary)`,
|
||||
},
|
||||
{
|
||||
key: 'accessTokenExpiresAtDisplay',
|
||||
@@ -212,7 +213,8 @@ function compareQuotaWindows(left: AccountQuotaWindow, right: AccountQuotaWindow
|
||||
if (secondsDifference !== 0)
|
||||
return secondsDifference
|
||||
|
||||
return left.key.localeCompare(right.key)
|
||||
// 同组同周期保留 Provider 的投影顺序,避免内部 key 把 additional 排到 core 前面。
|
||||
return 0
|
||||
}
|
||||
|
||||
function groupOrder(window: AccountQuotaWindow) {
|
||||
|
||||
@@ -60,7 +60,7 @@ function togglePasswordVisible(): void {
|
||||
<img
|
||||
src="/favicon.svg"
|
||||
alt=""
|
||||
class="block size-[38px] select-none"
|
||||
class="block size-9.5 select-none"
|
||||
draggable="false"
|
||||
>
|
||||
</BaseMotionIcon>
|
||||
@@ -116,7 +116,7 @@ function togglePasswordVisible(): void {
|
||||
v-model="username"
|
||||
name="username"
|
||||
aria-label="管理员账号"
|
||||
placeholder="admin@example.com"
|
||||
placeholder="输入会话账号"
|
||||
autocomplete="username"
|
||||
>
|
||||
<template #prefix>
|
||||
|
||||
@@ -1 +1 @@
|
||||
version: "3.1.16"
|
||||
version: "3.1.21"
|
||||
|
||||
Reference in New Issue
Block a user