mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4127886584 | ||
|
|
ee3e5db6c2 | ||
|
|
153cb67b9c | ||
|
|
f370d355b6 | ||
|
|
a31dad992c | ||
|
|
55a88f5f6b | ||
|
|
5d90a92582 | ||
|
|
93abe8efcd | ||
|
|
f4b75c42ed |
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -41,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,
|
||||
@@ -62,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()
|
||||
@@ -329,12 +336,17 @@ pub(crate) fn parse_account_quota_snapshot(
|
||||
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,
|
||||
@@ -345,6 +357,14 @@ pub(crate) fn parse_account_quota_snapshot(
|
||||
})
|
||||
}
|
||||
|
||||
fn quota_source_order(source: &str) -> u8 {
|
||||
match source {
|
||||
"codex" => 0,
|
||||
"code_review" => 1,
|
||||
_ => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// 账号状态只能由顶层 canonical Codex 额度桶的明确拒绝来改变。
|
||||
///
|
||||
/// `fact.exhausted()` 和窗口 `limit_reached` 都会聚合或继承 secondary、附加桶等事实,
|
||||
@@ -393,6 +413,7 @@ fn authoritative_core_primary_exhausted(
|
||||
struct CanonicalRateLimit<'a> {
|
||||
key: String,
|
||||
source: String,
|
||||
account_wide: bool,
|
||||
limit_name: Option<String>,
|
||||
rate_limit: &'a Value,
|
||||
}
|
||||
@@ -410,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,
|
||||
});
|
||||
@@ -421,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,
|
||||
});
|
||||
@@ -451,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,
|
||||
@@ -491,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>,
|
||||
@@ -549,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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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};
|
||||
@@ -1059,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),
|
||||
@@ -1074,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,
|
||||
|
||||
+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`。
|
||||
|
||||
@@ -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,10 +118,26 @@ const labelClasses = computed(() => [
|
||||
>
|
||||
<span
|
||||
v-if="loading"
|
||||
class="inline-flex shrink-0 animate-spin items-center justify-center origin-center leading-none will-change-transform"
|
||||
class="relative inline-flex shrink-0"
|
||||
:style="loadingIndicatorStyle"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<LoaderCircle :size="loadingIconSize[size]" class="block" />
|
||||
<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="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">
|
||||
<slot name="icon" />
|
||||
|
||||
@@ -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.19"
|
||||
version: "3.1.21"
|
||||
|
||||
Reference in New Issue
Block a user