Compare commits

...
4 Commits
Author SHA1 Message Date
ZyyandCodex 53739ebe5c chore: bump version to 3.2.13
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 15:20:29 +08:00
ZyyandCodex 7f420257a6 fix(api): keep provider degradation out of healthz
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 15:14:28 +08:00
ZyyandCodex 4f85209dc9 fix(config): centralize runtime data paths
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 15:14:28 +08:00
ZyyandCodex cd89e2e8fc fix(admin): align quota metadata and usage layout
Co-authored-by: Codex <noreply@openai.com>
2026-08-20 15:14:28 +08:00
42 changed files with 873 additions and 172 deletions
+3 -2
View File
@@ -27,8 +27,9 @@ impl LoadableConfig for GatewayConfig {
return Err(ConfigError::InvalidField("schema_version"));
}
self.host.resolve_and_validate(source_dir)?;
let runtime_data_dir = self.host.runtime_data_dir().to_path_buf();
self.store
.resolve_and_validate(source_dir)
.resolve_and_validate(&runtime_data_dir)
.map_err(|_| ConfigError::InvalidField("store"))?;
self.admin
.resolve_and_validate(source_dir)
@@ -37,7 +38,7 @@ impl LoadableConfig for GatewayConfig {
.resolve_and_validate(source_dir)
.map_err(|_| ConfigError::InvalidField("api"))?;
self.openai
.resolve_and_validate(source_dir)
.resolve_and_validate(&runtime_data_dir)
.map_err(|_| ConfigError::InvalidField("openai"))?;
self.xai
.resolve_and_validate(source_dir)
+8
View File
@@ -18,10 +18,18 @@ fn config_loader_should_load_complete_terminal_example() {
fn config_loader_should_resolve_paths_relative_to_config_file() {
let (config, _directory) = parse_config(&valid_config()).expect("resolved config");
let debug = format!("{config:?}");
assert!(debug.contains(".runtime/data"));
assert!(debug.contains(".runtime/logs"));
assert!(debug.contains("frontend/dist"));
}
#[test]
fn config_loader_should_reject_missing_runtime_data_dir() {
let config = valid_config().replace(" runtime_data_dir: '../.runtime/data'\n", "");
assert!(parse_config(&config).is_err());
}
#[test]
fn config_loader_should_inject_connection_passwords_into_urls() {
parse_config(&valid_config()).expect("Store validates password injection into both URLs");
@@ -570,13 +570,34 @@ pub enum QuotaLocalUsageAttribution {
Unavailable,
}
/// Provider quota bucket 中窗口的官方位置语义。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderQuotaWindowRole {
Primary,
Secondary,
Monthly,
}
impl ProviderQuotaWindowRole {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Primary => "primary",
Self::Secondary => "secondary",
Self::Monthly => "monthly",
}
}
}
/// 一个 Provider quota 窗口的公共投影。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderQuotaWindow {
pub key: String,
pub group: String,
pub label: String,
pub source: Option<String>,
pub limit_id: Option<String>,
pub limit_name: Option<String>,
pub role: Option<ProviderQuotaWindowRole>,
pub local_usage_attribution: QuotaLocalUsageAttribution,
pub window_seconds: Option<u64>,
pub used_percent: Option<f64>,
+6 -2
View File
@@ -165,7 +165,9 @@ fn representative_window_usage_should_accept_account_wide_rolling_window_without
key: "free-rolling-24h".to_owned(),
group: "shortTerm".to_owned(),
label: "日限额".to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(86_400),
used_percent: None,
@@ -234,7 +236,9 @@ fn quota_window(
key: group.to_owned(),
group: group.to_owned(),
label: group.to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds,
used_percent,
@@ -1262,7 +1262,9 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop
key: "primary".to_owned(),
group: "shortTerm".to_owned(),
label: "5小时限额".to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(5 * 60 * 60),
used_percent: Some(97.0),
@@ -1391,7 +1393,9 @@ async fn accounts_list_should_not_derive_rate_limited_from_provider_quota_view()
key: "primary".to_owned(),
group: "shortTerm".to_owned(),
label: "5小时限额".to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(5 * 60 * 60),
used_percent: Some(100.0),
@@ -1436,7 +1440,9 @@ async fn accounts_list_should_not_derive_exhaustion_from_provider_quota_view() {
key: "primary".to_owned(),
group: "shortTerm".to_owned(),
label: "5小时限额".to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(5 * 60 * 60),
used_percent: Some(100.0),
@@ -1482,7 +1488,9 @@ async fn accounts_list_should_attach_local_usage_to_quota_windows() {
key: "primary".to_owned(),
group: "shortTerm".to_owned(),
label: "5小时限额".to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(5 * 60 * 60),
used_percent: Some(97.0),
@@ -1547,7 +1555,9 @@ async fn accounts_list_should_not_attach_account_usage_to_model_specific_quota_w
key: "core-primary".to_owned(),
group: "weekly".to_owned(),
label: "周额度".to_owned(),
source: Some("codex".to_owned()),
limit_id: Some("codex".to_owned()),
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(7 * 24 * 60 * 60),
used_percent: Some(1.0),
@@ -1559,8 +1569,10 @@ async fn accounts_list_should_not_attach_account_usage_to_model_specific_quota_w
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()),
label: "周额度".to_owned(),
limit_id: Some("codex_bengalfox".to_owned()),
limit_name: Some("GPT-5.3-Codex-Spark".to_owned()),
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::Unavailable,
window_seconds: Some(7 * 24 * 60 * 60),
used_percent: Some(0.0),
@@ -305,8 +305,12 @@ pub struct AccountQuotaView {
pub struct AccountQuotaWindowView {
pub key: String,
pub group: String,
pub limit_id: Option<String>,
pub limit_name: Option<String>,
pub role: Option<String>,
pub window_seconds: Option<u64>,
pub label_display: String,
pub window_label_display: String,
pub used_percent: Option<f64>,
pub used_percent_display: String,
pub limit_reached: bool,
@@ -1603,7 +1607,9 @@ fn quota_window_view(window: ProviderQuotaWindow) -> AccountQuotaWindowView {
key,
group,
label,
source: _,
limit_id,
limit_name,
role,
local_usage_attribution: _,
window_seconds,
used_percent,
@@ -1612,10 +1618,17 @@ fn quota_window_view(window: ProviderQuotaWindow) -> AccountQuotaWindowView {
local_usage,
provider_data: _,
} = window;
let label_display = limit_name
.as_ref()
.map_or_else(|| label.clone(), |name| format!("{name} · {label}"));
AccountQuotaWindowView {
label_display: label,
label_display,
window_label_display: label,
key,
group,
limit_id,
limit_name,
role: role.map(|value| value.as_str().to_owned()),
window_seconds,
used_percent,
used_percent_display: used_percent
+21 -3
View File
@@ -1,4 +1,4 @@
//! `/healthz` 对 Core、Store 与 Host worker 健康事实的聚合。
//! `/healthz` 对 Core、Store 与 Host 关键 worker 健康事实的聚合。
use std::sync::Arc;
use std::time::Duration;
@@ -6,8 +6,10 @@ use std::time::Duration;
use axum::{extract::State, http::StatusCode};
use futures::future::join_all;
use gateway_core::health::{
HealthProbe, HealthState, WorkerHealthSnapshot, WorkerHealthSource, WorkerRuntimeState,
HealthProbe, HealthState, WorkerHealthKey, WorkerHealthSnapshot, WorkerHealthSource,
WorkerRuntimeState,
};
use gateway_core::task::WorkerKind;
use crate::ApiState;
@@ -32,7 +34,13 @@ impl HealthStatus {
}
pub(crate) async fn healthy(&self) -> bool {
if !self.workers.snapshot().iter().all(worker_is_healthy) {
if !self
.workers
.snapshot()
.iter()
.filter(|worker| worker_affects_healthz(worker))
.all(worker_is_healthy)
{
return false;
}
let checks = self.probes.iter().map(|probe| probe.check());
@@ -54,6 +62,16 @@ pub(crate) async fn healthz(State(state): State<ApiState>) -> StatusCode {
}
}
fn worker_affects_healthz(worker: &WorkerHealthSnapshot) -> bool {
let WorkerHealthKey::Task(id) = &worker.key else {
return false;
};
!matches!(
id.kind(),
WorkerKind::OAuthRefresh | WorkerKind::QuotaCatalogHealth
)
}
fn worker_is_healthy(worker: &WorkerHealthSnapshot) -> bool {
match worker.state {
WorkerRuntimeState::Disabled | WorkerRuntimeState::Standby => true,
@@ -11,7 +11,9 @@ use gateway_core::{
StartedExecution,
},
error::GatewayError,
health::{WorkerHealthKey, WorkerHealthSnapshot, WorkerHealthSource, WorkerRuntimeState},
routing::PublicModelId,
task::{WorkerId, WorkerKind},
};
use tower::ServiceExt;
@@ -31,6 +33,75 @@ async fn healthz_should_return_no_content_when_all_inputs_are_healthy() {
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn healthz_should_ignore_provider_maintenance_worker_failure() {
let worker_health = StaticWorkerHealth(vec![worker_snapshot(
WorkerKind::QuotaCatalogHealth,
WorkerRuntimeState::BackingOff,
)]);
let response = crate::openai::api_router_with_worker_health(
Arc::new(UnusedExecution),
Arc::new(worker_health),
)
.await
.oneshot(
Request::builder()
.uri("/healthz")
.body(Body::empty())
.expect("health request"),
)
.await
.expect("health response");
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn healthz_should_reject_critical_worker_failure() {
let worker_health = StaticWorkerHealth(vec![worker_snapshot(
WorkerKind::RuntimeSnapshotReconciliation,
WorkerRuntimeState::BackingOff,
)]);
let response = crate::openai::api_router_with_worker_health(
Arc::new(UnusedExecution),
Arc::new(worker_health),
)
.await
.oneshot(
Request::builder()
.uri("/healthz")
.body(Body::empty())
.expect("health request"),
)
.await
.expect("health response");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[derive(Clone)]
struct StaticWorkerHealth(Vec<WorkerHealthSnapshot>);
impl WorkerHealthSource for StaticWorkerHealth {
fn snapshot(&self) -> Vec<WorkerHealthSnapshot> {
self.0.clone()
}
}
fn worker_snapshot(kind: WorkerKind, state: WorkerRuntimeState) -> WorkerHealthSnapshot {
let id = WorkerId::try_new(kind, "health-test").expect("worker ID");
WorkerHealthSnapshot {
key: WorkerHealthKey::Task(id),
state,
consecutive_failures: 1,
completed_cycles: 0,
last_fencing_token: None,
last_success_at: None,
last_failure_at: None,
last_error: Some("test worker failure".to_owned()),
}
}
struct UnusedExecution;
impl ExecutionService for UnusedExecution {
+21 -1
View File
@@ -44,9 +44,29 @@ pub(super) async fn api_router(execution: Arc<dyn ExecutionService>) -> axum::Ro
api_router_with_origins(execution, Vec::new()).await
}
pub(super) async fn api_router_with_worker_health(
execution: Arc<dyn ExecutionService>,
worker_health: Arc<dyn WorkerHealthSource>,
) -> axum::Router {
api_router_with_origins_and_worker_health(execution, Vec::new(), worker_health).await
}
pub(super) async fn api_router_with_origins(
execution: Arc<dyn ExecutionService>,
cors_allowed_origins: Vec<String>,
) -> axum::Router {
api_router_with_origins_and_worker_health(
execution,
cors_allowed_origins,
Arc::new(EmptyWorkerHealth),
)
.await
}
async fn api_router_with_origins_and_worker_health(
execution: Arc<dyn ExecutionService>,
cors_allowed_origins: Vec<String>,
worker_health: Arc<dyn WorkerHealthSource>,
) -> axum::Router {
let admin = crate::admin::AdminTestFixture::new().await;
gateway_api::initialize(
@@ -59,7 +79,7 @@ pub(super) async fn api_router_with_origins(
execution,
admin.services,
Vec::new(),
Arc::new(EmptyWorkerHealth),
worker_health,
Arc::new(TestLifecycle::default()),
)
.expect("API bundle")
+12 -1
View File
@@ -41,6 +41,7 @@ pub fn load_config<T: LoadableConfig>() -> Result<T, ConfigError> {
#[serde(deny_unknown_fields)]
pub struct HostConfig {
pub listen: ListenConfig,
pub runtime_data_dir: PathBuf,
pub logging: LoggingConfig,
#[serde(default)]
pub system_update: SystemUpdateConfig,
@@ -76,11 +77,21 @@ impl HostConfig {
"host.worker_shutdown_timeout_seconds",
));
}
if self.runtime_data_dir.as_os_str().is_empty() {
return Err(ConfigError::InvalidField("host.runtime_data_dir"));
}
resolve_relative_path(source_dir, &mut self.runtime_data_dir);
self.logging.resolve_and_validate(source_dir)?;
self.system_update.resolve_and_validate(source_dir)?;
self.system_update
.resolve_and_validate(source_dir, &self.runtime_data_dir)?;
Ok(())
}
#[must_use]
pub fn runtime_data_dir(&self) -> &Path {
&self.runtime_data_dir
}
#[must_use]
pub const fn drain_timeout(&self) -> Duration {
Duration::from_secs(self.drain_timeout_seconds)
@@ -81,10 +81,10 @@ impl Default for SystemUpdateConfig {
});
let update_state_file = environment_value("CPR_UPDATE_STATE_FILE")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/app/.runtime/data/update-state.json"));
.unwrap_or_else(|| PathBuf::from("update-state.json"));
let update_lock_file = environment_value("CPR_UPDATE_LOCK_FILE")
.map(PathBuf::from)
.unwrap_or_else(|| update_state_file.with_extension("lock"));
.unwrap_or_else(|| update_state_file.with_file_name("update.lock"));
let update_temp_dir = environment_value("CPR_UPDATE_TEMP_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| state::default_temp_dir(&update_state_file));
@@ -120,21 +120,28 @@ impl Default for SystemUpdateConfig {
}
impl SystemUpdateConfig {
pub(crate) fn resolve_and_validate(&mut self, source_dir: &Path) -> Result<(), ConfigError> {
for path in [
self.executable_path.as_mut(),
Some(&mut self.web_dist_dir),
Some(&mut self.update_state_file),
Some(&mut self.update_lock_file),
Some(&mut self.update_temp_dir),
]
.into_iter()
.flatten()
pub(crate) fn resolve_and_validate(
&mut self,
source_dir: &Path,
runtime_data_dir: &Path,
) -> Result<(), ConfigError> {
for path in [self.executable_path.as_mut(), Some(&mut self.web_dist_dir)]
.into_iter()
.flatten()
{
if path.is_relative() {
*path = source_dir.join(&*path);
}
}
for path in [
&mut self.update_state_file,
&mut self.update_lock_file,
&mut self.update_temp_dir,
] {
if path.is_relative() {
*path = runtime_data_dir.join(&*path);
}
}
if self.version.trim().is_empty() {
return Err(ConfigError::InvalidField("host.system_update.version"));
}
+38 -3
View File
@@ -34,6 +34,40 @@ fn host_config_resolves_only_host_owned_relative_paths() {
);
}
#[test]
fn host_config_resolves_runtime_data_dir_relative_to_configuration() {
let mut config = valid_config();
config
.resolve_and_validate(std::path::Path::new("/srv/gateway"))
.expect("valid host config");
assert_eq!(
config.runtime_data_dir(),
PathBuf::from("/srv/gateway/runtime-data")
);
}
#[test]
fn host_config_derives_update_paths_from_runtime_data_dir() {
let mut config = valid_config();
config
.resolve_and_validate(std::path::Path::new("/srv/gateway"))
.expect("valid host config");
assert_eq!(
(
config.system_update.update_state_file,
config.system_update.update_lock_file,
config.system_update.update_temp_dir,
),
(
PathBuf::from("/srv/gateway/runtime-data/update-state.json"),
PathBuf::from("/srv/gateway/runtime-data/update.lock"),
PathBuf::from("/srv/gateway/runtime-data/update-tmp"),
)
);
}
#[test]
fn host_config_rejects_zero_drain_window() {
let mut config = valid_config();
@@ -48,9 +82,9 @@ fn host_config_rejects_zero_drain_window() {
fn valid_config() -> HostConfig {
let system_update = SystemUpdateConfig {
update_state_file: PathBuf::from(".runtime/update-state.json"),
update_lock_file: PathBuf::from(".runtime/update-state.lock"),
update_temp_dir: PathBuf::from(".runtime/update-tmp"),
update_state_file: PathBuf::from("update-state.json"),
update_lock_file: PathBuf::from("update.lock"),
update_temp_dir: PathBuf::from("update-tmp"),
..SystemUpdateConfig::default()
};
HostConfig {
@@ -58,6 +92,7 @@ fn valid_config() -> HostConfig {
host: "127.0.0.1".to_owned(),
port: 8080,
},
runtime_data_dir: PathBuf::from("runtime-data"),
logging: LoggingConfig {
level: "info".to_owned(),
stdout: true,
@@ -23,6 +23,7 @@ fn logging_requires_at_least_one_sink() {
host: "127.0.0.1".to_owned(),
port: 8080,
},
runtime_data_dir: PathBuf::from("/tmp/runtime-data"),
logging: LoggingConfig {
level: "info".to_owned(),
stdout: false,
@@ -98,6 +99,7 @@ fn write_oauth_recovery_log(directory: PathBuf) {
host: "127.0.0.1".to_owned(),
port: 8080,
},
runtime_data_dir: PathBuf::from("/tmp/runtime-data"),
logging: LoggingConfig {
level: "off".to_owned(),
stdout: false,
+2 -2
View File
@@ -50,7 +50,7 @@ impl StoreBundle {
pub async fn initialize(mut config: StoreConfig) -> StoreResult<StoreBundle> {
const REDIS_NAMESPACE: &str = "codex-proxy-rs";
config.resolve_and_validate(std::path::Path::new("."))?;
config.validate_resolved()?;
let pool = postgres::connect_and_migrate(&config.database_url()?, config.pool).await?;
let redis_client = ::redis::Client::open(config.redis_url()?)
.map_err(|_| redis_unavailable("create Redis client"))?;
@@ -209,7 +209,7 @@ pub(crate) fn backup_ports(
config: &StoreConfig,
) -> StoreResult<BackupStorePorts> {
let staging = Arc::new(backup::staging::StagingArea::open(
std::path::PathBuf::from(".runtime/data/backup-staging"),
config.backup_staging_dir().to_path_buf(),
backup::staging::DEFAULT_MAX_ARCHIVE_BYTES,
)?);
let repository = Arc::new(postgres::PgBackupRepository::new(pool));
+28 -1
View File
@@ -1,5 +1,7 @@
//! Store 启动配置、环境变量解析与校验。
use std::path::{Path, PathBuf};
use super::*;
pub(crate) const DATABASE_URL_ENV: &str = "CPR_DATABASE_URL";
@@ -15,6 +17,8 @@ pub struct StoreConfig {
pub(crate) redis: StoreConnectionConfig,
#[serde(default)]
pub(crate) pool: StorePoolConfig,
#[serde(skip)]
backup_staging_dir: PathBuf,
}
/// PostgreSQL 连接池预算;acquire 超时决定池耗尽时快速失败而非排队积压。
@@ -47,7 +51,18 @@ impl StorePoolConfig {
}
impl StoreConfig {
pub fn resolve_and_validate(&mut self, _source_dir: &std::path::Path) -> StoreResult<()> {
pub fn resolve_and_validate(&mut self, runtime_data_dir: &Path) -> StoreResult<()> {
if runtime_data_dir.as_os_str().is_empty() {
return Err(StoreError::InvalidData {
entity: "store config",
message: "runtime_data_dir must not be empty".to_owned(),
});
}
self.backup_staging_dir = runtime_data_dir.join("backup-staging");
self.validate_resolved()
}
pub(crate) fn validate_resolved(&mut self) -> StoreResult<()> {
if let Some(url) = optional_environment_value(DATABASE_URL_ENV)? {
self.database.url = url;
}
@@ -63,6 +78,12 @@ impl StoreConfig {
self.database.validate("database")?;
self.redis.validate("redis")?;
self.pool.validate()?;
if self.backup_staging_dir.as_os_str().is_empty() {
return Err(StoreError::InvalidData {
entity: "store config",
message: "runtime_data_dir was not resolved".to_owned(),
});
}
Ok(())
}
@@ -73,6 +94,12 @@ impl StoreConfig {
pub(crate) fn redis_url(&self) -> StoreResult<String> {
self.redis.connection_url("redis")
}
/// 返回由统一运行数据根目录派生的备份暂存目录。
#[must_use]
pub fn backup_staging_dir(&self) -> &Path {
&self.backup_staging_dir
}
}
pub(crate) fn optional_environment_value(name: &'static str) -> StoreResult<Option<String>> {
@@ -0,0 +1,32 @@
use gateway_store::StoreConfig;
const PASSWORD: &str = "111111111111111111111111111111111111111111111111";
#[test]
fn store_config_should_derive_backup_staging_from_runtime_data_dir() {
let root = tempfile::tempdir().expect("runtime data root");
let mut config = valid_config();
config
.resolve_and_validate(root.path())
.expect("valid Store configuration");
assert_eq!(
config.backup_staging_dir(),
root.path().join("backup-staging")
);
}
fn valid_config() -> StoreConfig {
serde_json::from_value(serde_json::json!({
"database": {
"url": "postgres://codex_proxy@127.0.0.1:5432/codex_proxy",
"password": PASSWORD,
},
"redis": {
"url": "redis://127.0.0.1:6379/",
"password": PASSWORD,
},
}))
.expect("test Store configuration")
}
@@ -1,3 +1,4 @@
mod config;
mod postgres;
mod redis;
mod s3;
@@ -21,12 +21,18 @@ async fn store_bundle_worker_plan_and_leader_lease_are_single_use_and_fenced() {
return;
};
let database = TestDatabase::create(&database_url).await;
let config = store_config(&database.url, &redis_url);
let runtime_data = tempfile::tempdir().expect("Store runtime data");
let mut config = store_config(&database.url, &redis_url);
config
.resolve_and_validate(runtime_data.path())
.expect("resolved Store config");
let mut first = initialize(config.clone())
.await
.expect("first Store bundle");
let second = initialize(config).await.expect("second Store bundle");
assert!(runtime_data.path().join("backup-staging").is_dir());
let kinds = first
.take_worker_contributions()
.into_iter()
+25 -64
View File
@@ -19,7 +19,7 @@ use gateway_admin::model::provider_credentials::{
PreparedCredentialCreate, PreparedCredentialImport, PreparedCredentialRotation,
PreparedCredentialRotationFacts, ProviderDocument, ProviderExport,
ProviderExportCredentialInput, ProviderModel, ProviderModels, ProviderQuota,
ProviderQuotaRequest, ProviderQuotaWindow, QuotaLocalUsageAttribution,
ProviderQuotaRequest, ProviderQuotaWindow, ProviderQuotaWindowRole, QuotaLocalUsageAttribution,
};
use gateway_admin::model::{MutationActor, MutationContext, Revision};
use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind};
@@ -734,39 +734,24 @@ fn project_quota_snapshot(snapshot: CodexAccountQuotaSnapshot) -> ProviderQuota
.windows()
.iter()
.filter(|window| should_project_quota_window(window))
.map(|window| {
let mut data = Map::new();
data.insert(
"source".to_owned(),
Value::String(window.source().to_owned()),
);
data.insert(
"role".to_owned(),
Value::String(quota_role(window.role()).to_owned()),
);
ProviderQuotaWindow {
key: window.key().to_owned(),
group: quota_group(window.kind()).to_owned(),
label: codex_quota_window_label(
window.limit_name(),
window.kind(),
window.role(),
window.source(),
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(),
limit_reached: window.limit_reached(),
local_usage: None,
provider_data: Some(ProviderDocument::new(OpaqueProviderData::new(data))),
}
.map(|window| ProviderQuotaWindow {
key: window.key().to_owned(),
group: quota_group(window.kind()).to_owned(),
label: codex_quota_window_label(window.kind(), window.role(), window.window_seconds()),
limit_id: Some(window.source().to_owned()),
limit_name: window.limit_name().map(str::to_owned),
role: Some(quota_role(window.role())),
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(),
limit_reached: window.limit_reached(),
local_usage: None,
provider_data: None,
})
.collect();
// 快照级 limit_reached 只看滚动后的窗口触顶:顶层标记是观测事实,不能
@@ -806,23 +791,21 @@ const fn quota_group(kind: CodexQuotaWindowKind) -> &'static str {
}
}
const fn quota_role(role: CodexQuotaWindowRole) -> &'static str {
const fn quota_role(role: CodexQuotaWindowRole) -> ProviderQuotaWindowRole {
match role {
CodexQuotaWindowRole::Primary => "primary",
CodexQuotaWindowRole::Secondary => "secondary",
CodexQuotaWindowRole::Monthly => "monthly",
CodexQuotaWindowRole::Primary => ProviderQuotaWindowRole::Primary,
CodexQuotaWindowRole::Secondary => ProviderQuotaWindowRole::Secondary,
CodexQuotaWindowRole::Monthly => ProviderQuotaWindowRole::Monthly,
}
}
/// 按窗口时长显示汉化额度名;非核心桶补上上游桶名称作为前缀。
/// 按窗口时长显示汉化额度名;额度桶名称通过独立字段投影。
fn codex_quota_window_label(
limit_name: Option<&str>,
kind: CodexQuotaWindowKind,
role: CodexQuotaWindowRole,
source: &str,
window_seconds: Option<u64>,
) -> String {
let base = match kind {
match kind {
CodexQuotaWindowKind::Monthly => "月额度".to_owned(),
CodexQuotaWindowKind::Weekly => "周额度".to_owned(),
CodexQuotaWindowKind::ShortTerm => {
@@ -833,29 +816,7 @@ fn codex_quota_window_label(
}
}
CodexQuotaWindowKind::Other => custom_quota_window_label(window_seconds, role),
};
if let Some(prefix) = codex_quota_window_prefix(limit_name, source) {
return format!("{prefix} · {base}");
}
base
}
fn codex_quota_window_prefix(limit_name: Option<&str>, source: &str) -> Option<String> {
let source_identifier = quota_identifier(source);
if matches!(source_identifier.as_str(), "core" | "codex") {
return None;
}
let label = limit_name.unwrap_or(source);
match quota_identifier(label).as_str() {
"premium" => Some("Premium".to_owned()),
"code_review" => Some("代码审查".to_owned()),
"spend_control" => Some("消费控制".to_owned()),
_ => Some(label.replace('_', " ")),
}
}
fn quota_identifier(value: &str) -> String {
value.trim().to_ascii_lowercase().replace(['-', ' '], "_")
}
fn custom_quota_window_label(window_seconds: Option<u64>, role: CodexQuotaWindowRole) -> String {
@@ -36,15 +36,17 @@ pub struct OpenAiConfig {
}
impl OpenAiConfig {
/// 校验 Provider-owned 字段,并定位旧版会话锚点持续使用的身份密钥。
pub fn resolve_and_validate(&mut self, source_dir: &Path) -> Result<(), OpenAiConfigError> {
/// 校验 Provider-owned 字段,并从统一运行数据目录定位会话身份密钥。
pub fn resolve_and_validate(
&mut self,
runtime_data_dir: &Path,
) -> Result<(), OpenAiConfigError> {
self.api.validate()?;
self.ws_pool.validate()?;
self.quota.validate()?;
self.auth.validate()?;
self.wire_profile.validate()?;
let project_root = source_dir.parent().unwrap_or(source_dir);
self.identity_secret_path = project_root.join(".runtime/data/identity_hmac_secret");
self.identity_secret_path = runtime_data_dir.join("identity_hmac_secret");
Ok(())
}
@@ -101,7 +103,7 @@ impl Default for OpenAiConfig {
quota: CodexQuotaSettings::default(),
auth: CodexAuthSettings::default(),
wire_profile: CodexWireProfileConfig::default(),
identity_secret_path: PathBuf::from(".runtime/data/identity_hmac_secret"),
identity_secret_path: PathBuf::new(),
}
}
}
@@ -1,6 +1,6 @@
//! Codex 本地会话锚点的稳定派生。
//!
//! 旧版 Redis 会话亲和与 WebSocket 复用以 `.runtime/data/identity_hmac_secret`
//! 旧版 Redis 会话亲和与 WebSocket 复用以运行数据目录下的 `identity_hmac_secret`
//! 生成的 `lc_` 值为键。该密钥是持久运行数据,不是账号凭据;保留它可避免
//! 架构迁移后同一客户端会话全部换键。
@@ -14,7 +14,7 @@ use gateway_admin::model::provider_credentials::{
AuthorizationMutationTarget, AuthorizationOwnerBinding, CompleteAuthorization,
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
PrepareCredentialRotation, ProviderDocument, ProviderExportCredentialInput,
ProviderQuotaRequest, QuotaLocalUsageAttribution,
ProviderQuotaRequest, ProviderQuotaWindowRole, QuotaLocalUsageAttribution,
};
use gateway_admin::model::{MutationActor, MutationContext, Revision};
use gateway_admin::ports::provider::ProviderAdminErrorKind;
@@ -566,7 +566,7 @@ async fn openai_admin_provider_projects_official_codex_quota_and_independent_buc
assert_eq!(monthly.len(), 1);
assert!(monthly.iter().any(|window| {
window.label == "月额度"
&& window.source.as_deref() == Some("codex")
&& window.limit_id.as_deref() == Some("codex")
&& window.used_percent == Some(91.0)
}));
assert!(
@@ -586,13 +586,15 @@ async fn openai_admin_provider_projects_official_codex_quota_and_independent_buc
let review = quota
.windows
.iter()
.find(|window| window.source.as_deref() == Some("code_review"))
.find(|window| window.limit_id.as_deref() == Some("code_review"))
.expect("code review quota");
assert_eq!(review.label, "代码审查 · 周额度");
assert_eq!(review.label, "周额度");
assert_eq!(review.limit_name.as_deref(), Some("code_review"));
assert_eq!(review.role, Some(ProviderQuotaWindowRole::Primary));
let spark = quota
.windows
.iter()
.find(|window| window.source.as_deref() == Some("codex_bengalfox"))
.find(|window| window.limit_id.as_deref() == Some("codex_bengalfox"))
.expect("Spark quota");
assert_eq!(
(
@@ -18,6 +18,16 @@ fn openai_config_builds_the_audited_wire_profile() {
assert_eq!(profile.desktop_build, "19012345678");
}
#[test]
fn openai_config_derives_identity_secret_from_runtime_data_dir() {
let mut config = valid_config();
config
.resolve_and_validate(Path::new("/srv/gateway/runtime-data"))
.expect("valid OpenAI config");
assert!(format!("{config:?}").contains("/srv/gateway/runtime-data/identity_hmac_secret"));
}
#[test]
fn openai_config_rejects_noncanonical_versions_and_empty_fields() {
let mut config = valid_config();
+6 -2
View File
@@ -1071,7 +1071,9 @@ fn project_quota(
key: billing.period_type().unwrap_or("billing").to_owned(),
group: quota_group(period_kind).to_owned(),
label: xai_quota_window_label(period_kind, window_seconds),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds,
used_percent: billing.used_percent(),
@@ -1087,7 +1089,9 @@ fn project_quota(
key: "free-rolling-24h".to_owned(),
group: "shortTerm".to_owned(),
label: "日限额".to_owned(),
source: None,
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(crate::GROK_FREE_ROLLING_WINDOW_SECONDS),
used_percent: None,
-1
View File
@@ -55,7 +55,6 @@ RUN apt-get update \
&& useradd --system --uid 10001 --gid cpr --home-dir /app --shell /usr/sbin/nologin cpr \
&& install -d -o cpr -g cpr \
/app/bin /app/deploy /app/.runtime/data /app/.runtime/logs /app/web \
&& install -d -o cpr -g cpr -m 0700 /app/.runtime/data/backup-staging \
&& rm -rf /var/lib/apt/lists/*
COPY --from=pg-client /usr/lib/postgresql/18/bin/pg_dump /usr/lib/postgresql/18/bin/pg_dump
+11 -9
View File
@@ -91,16 +91,17 @@ cd backend
cargo run -p codex-proxy-rs
```
后端会从当前目录向上查找 `deploy/config.yaml`。相对数据和日志目录以该文件所在目录解析;
Compose 把监听地址和数据库、Redis 地址固定覆盖为容器内部服务名,并把前端静态目录指向
容器内构建产物。PG/Redis 集成测试所需的 `CPR_TEST_DATABASE_URL` / `CPR_TEST_REDIS_URL`
约定见 [迁移文档](../backend/migrations/README.md)。
后端会从当前目录向上查找 `deploy/config.yaml`。`host.runtime_data_dir` 是运行数据的统一根目录,
相对数据和日志目录均以该配置文件所在目录解析;Compose 把监听地址和数据库、Redis 地址固定
覆盖为容器内部服务名,并把前端静态目录指向容器内构建产物。PG/Redis 集成测试所需的
`CPR_TEST_DATABASE_URL` / `CPR_TEST_REDIS_URL` 约定见
[迁移文档](../backend/migrations/README.md)。
## 持久化与备份
Compose 使用以下绑定目录:
- `.runtime/data` → OpenAI 会话锚点密钥、更新状态与临时更新目录
- `.runtime/data` → OpenAI 会话锚点密钥、更新状态、临时更新目录与备份暂存区
- `.runtime/logs` → 应用文件日志
- `.runtime/postgres` → PostgreSQL
- `.runtime/redis` → Redis AOF
@@ -177,8 +178,8 @@ Compose 已显式装配正式发布构建所需的运行参数:
- `CPR_GITHUB_API_BASE`:正式环境必须为 `https://api.github.com/repos`。
- `CPR_UPDATE_CHANNEL`:`stable` 会拒绝 prerelease。
- `CPR_UPDATE_EXE_PATH`、`CPR_WEB_DIST_DIR`:分别指向容器内二进制和前端静态目录。
- `CPR_UPDATE_TEMP_DIR`、`CPR_UPDATE_STATE_FILE`、`CPR_UPDATE_LOCK_FILE`:全部位于持久化的
`.runtime/data`。
- 更新临时目录、状态文件和锁文件默认由 `host.runtime_data_dir` 派生;
`CPR_UPDATE_TEMP_DIR`、`CPR_UPDATE_STATE_FILE`、`CPR_UPDATE_LOCK_FILE` 仅用于显式覆盖。
- `CPR_ENABLE_SELF_RESTART=true`:更新或回滚完成后允许管理端请求重启;Docker 进程退出后由
Compose 的 `restart: unless-stopped` 拉起新进程。
@@ -201,8 +202,9 @@ Release 必须提供当前 OS/架构的 `codex-proxy-rs_<version>_<os>_<arch>.ta
`postgres:18-bookworm` 镜像(`Dockerfile` 的 `POSTGRES_IMAGE`)COPY 到
`/usr/lib/postgresql/18/bin` 并加入容器 PATH,版本与服务端严格一致;运行时只从
Debian 官方源补 `libpq5` 依赖,不引入第三方 APT 源,也不依赖运行时动态安装。
- 备份暂存目录为 `/app/.runtime/data/backup-staging`,由 `.runtime/data` 卷持久化,权限
`0700`(仅 `cpr` 用户可读写)。部署卷至少预留一个最大数据库归档的空间。
- 备份暂存目录为 `host.runtime_data_dir/backup-staging`;Compose 默认对应
`/app/.runtime/data/backup-staging`,由 `.runtime/data` 卷持久化,权限 `0700`(仅 `cpr`
用户可读写)。部署卷至少预留一个最大数据库归档的空间。
- OAuth 恢复记录通道使用 `oauth_recovery` 结构化日志事件;当前 OpenAI provider 每次成功取得
AT/RT 后,都会在账号资料补全、过期时间计算和数据库写入前写入独立的
`codex-proxy-rs-oath.YYYY-MM-DD[.N].log` 文件集。事件含原始 AT/RT,并以 `provider`
-3
View File
@@ -127,9 +127,6 @@ services:
CPR_UPDATE_CHANNEL: stable
CPR_GITHUB_API_BASE: https://api.github.com/repos
CPR_UPDATE_EXE_PATH: /app/bin/codex-proxy-rs
CPR_UPDATE_TEMP_DIR: /app/.runtime/data/update-tmp
CPR_UPDATE_STATE_FILE: /app/.runtime/data/update-state.json
CPR_UPDATE_LOCK_FILE: /app/.runtime/data/update.lock
CPR_WEB_DIST_DIR: /app/web/dist
CPR_ENABLE_SELF_RESTART: 'true'
logging: *json-logging
+3
View File
@@ -14,6 +14,9 @@ host:
# 本地 cargo run 使用;Docker 内由 CPR_SERVER_HOST/PORT 覆盖。
host: '127.0.0.1'
port: 8080
# OpenAI 会话身份、系统更新状态和备份暂存区的统一根目录。
# 相对路径以 deploy/config.yaml 所在目录为基准。
runtime_data_dir: '../.runtime/data'
logging:
level: 'info'
stdout: true
+4
View File
@@ -14,8 +14,12 @@ export type AccountErrorReason
export interface AccountQuotaWindow {
key: string
group: string
limitId: string | null
limitName: string | null
role: 'primary' | 'secondary' | 'monthly' | null
windowSeconds: number | null
labelDisplay: string
windowLabelDisplay: string
usedPercent: number | null
usedPercentDisplay: string
limitReached: boolean
@@ -4,9 +4,9 @@ import { RefreshCw } from '@lucide/vue'
import { computed } from 'vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import { orderedPanelQuotaWindows } from '../constants'
import { groupedAccountQuotaWindows, orderedPanelQuotaWindows } from '../constants'
import AccountPlanBadge from './AccountPlanBadge.vue'
import AccountUsageWindow from './AccountUsageWindow/index.vue'
import AccountQuotaPanelEntry from './AccountQuotaPanelEntry.vue'
const props = defineProps<{
account: AccountRow
@@ -17,23 +17,24 @@ const emit = defineEmits<{
refreshQuota: [accountId: string]
}>()
const quotaWindows = computed(() => orderedPanelQuotaWindows(props.account.quota.windows))
const quotaEntries = computed(() => groupedAccountQuotaWindows(
orderedPanelQuotaWindows(props.account.quota.windows),
))
</script>
<template>
<section class="rounded-lg bg-cp-surface p-4 shadow-cp-control">
<div class="mb-3 flex items-center justify-between gap-3">
<div>
<section class="flex min-h-0 flex-col rounded-lg bg-cp-surface p-4 shadow-cp-control">
<div class="mb-3 flex shrink-0 items-start justify-between gap-3">
<div class="min-w-0">
<h3 class="m-0 text-[14px] font-heavy text-cp-primary">
账号额度
</h3>
<p
class="m-0 mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-[12px] font-emphasis text-cp-secondary"
class="m-0 mt-1 flex min-w-0 items-center gap-1.5 text-[11px] font-emphasis text-cp-secondary"
>
<span>{{ account.provider === 'xai' ? 'xAI 用量窗口' : 'Codex 额度' }}</span>
<template v-if="account.provider === 'openai'">
<span>·</span>
<span>套餐:</span>
<AccountPlanBadge :plan-type="account.planType" size="sm" />
</template>
<span>·</span>
@@ -55,9 +56,16 @@ const quotaWindows = computed(() => orderedPanelQuotaWindows(props.account.quota
</BaseIconButton>
</div>
<div class="grid gap-3">
<AccountUsageWindow v-for="window in quotaWindows" :key="window.key" :window="window" />
<AccountUsageWindow v-if="quotaWindows.length === 0" />
<div class="grid max-h-48 min-h-0 gap-3 overflow-y-auto pr-1">
<AccountQuotaPanelEntry
v-for="entry in quotaEntries"
:key="entry.key"
:label="entry.label"
:windows="entry.windows"
/>
<p v-if="quotaEntries.length === 0" class="m-0 text-[12px] font-emphasis text-cp-secondary">
额度待观测
</p>
</div>
</section>
</template>
@@ -0,0 +1,97 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '../constants'
import { computed } from 'vue'
import {
quotaWindowLocalUsageDisplay,
quotaWindowPresentation,
} from './AccountUsageWindow/presenter'
const props = defineProps<{
label: string | null
windows: AccountQuotaWindow[]
}>()
const grouped = computed(() => props.windows.length > 1 && Boolean(props.label))
const items = computed(() => props.windows.map((window) => {
const presentation = quotaWindowPresentation(window, '4px')
return {
key: window.key,
label: grouped.value
? window.windowLabelDisplay
: window.labelDisplay,
ariaLabel: window.labelDisplay,
usedPercent: window.usedPercent,
usedPercentDisplay: window.usedPercentDisplay,
localUsageDisplay: quotaWindowLocalUsageDisplay(window),
resetAtDisplay: window.resetAtDisplay,
percentTextClass: presentation.percentTextClass,
barClass: presentation.barClass,
barStyle: presentation.barStyle,
}
}))
</script>
<template>
<section class="grid min-w-0 gap-2">
<h4
v-if="grouped && label"
class="m-0 truncate text-[11px] leading-4 font-heavy text-cp-secondary"
:title="label"
>
{{ label }}
</h4>
<div
class="grid min-w-0 gap-3"
:class="grouped ? 'grid-cols-2' : 'grid-cols-1'"
>
<div v-for="item in items" :key="item.key" class="grid min-w-0 gap-1.5">
<div class="flex min-w-0 items-baseline justify-between gap-2 text-[11px] leading-3.5">
<span class="min-w-0 truncate font-bold text-cp-muted-text">
{{ item.label }}
</span>
<span class="flex shrink-0 items-baseline gap-1.5 font-mono font-heavy tabular-nums">
<strong
v-if="item.localUsageDisplay"
class="text-cp-muted-text"
:title="`窗口消耗:${item.localUsageDisplay}`"
>
{{ item.localUsageDisplay }}
</strong>
<strong
:class="item.percentTextClass"
:title="`额度已使用:${item.usedPercentDisplay}`"
>
{{ item.usedPercentDisplay }}
</strong>
</span>
</div>
<div
class="h-1.5 w-full overflow-hidden rounded-full bg-cp-default-border"
role="progressbar"
:aria-label="item.ariaLabel"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="item.usedPercent ?? undefined"
:aria-valuetext="item.usedPercentDisplay"
>
<div
class="h-full rounded-full transition-[width,background-color] duration-200 motion-reduce:transition-none"
:class="item.barClass"
:style="item.barStyle"
/>
</div>
<p class="m-0 flex min-w-0 items-center justify-between gap-2 text-[10px] leading-3.5 text-cp-tertiary">
<span class="shrink-0 font-emphasis">重置</span>
<span class="min-w-0 truncate font-mono font-emphasis tabular-nums" :title="item.resetAtDisplay">
{{ item.resetAtDisplay }}
</span>
</p>
</div>
</div>
</section>
</template>
@@ -2,7 +2,8 @@
import type { AccountRow } from '../constants'
import { computed } from 'vue'
import { visibleSummaryQuotaWindows } from '../constants'
import { groupedAccountQuotaWindows, visibleSummaryQuotaWindows } from '../constants'
import AccountQuotaSummaryEntry from './AccountQuotaSummaryEntry.vue'
import AccountUsageWindow from './AccountUsageWindow/index.vue'
const props = defineProps<{
@@ -11,10 +12,11 @@ const props = defineProps<{
const quotaWindows = computed(() => props.account.quota.windows)
const visibleQuotaWindows = computed(() => visibleSummaryQuotaWindows(quotaWindows.value))
const summaryEntries = computed(() => groupedAccountQuotaWindows(visibleQuotaWindows.value))
const summaryClass = computed(
() =>
`grid w-full min-w-0 gap-1.5 py-0.5 ${
visibleQuotaWindows.value.length <= 1
`box-border grid w-full min-w-0 gap-1 py-1.5 ${
summaryEntries.value.length <= 1
? 'min-h-13 content-center'
: 'min-h-16.5 content-center'
}`,
@@ -23,12 +25,12 @@ const summaryClass = computed(
<template>
<div :class="summaryClass">
<AccountUsageWindow
v-for="window in visibleQuotaWindows"
:key="window.key"
:window="window"
variant="compact"
<AccountQuotaSummaryEntry
v-for="entry in summaryEntries"
:key="entry.key"
:label="entry.label"
:windows="entry.windows"
/>
<AccountUsageWindow v-if="quotaWindows.length === 0" variant="compact" />
<AccountUsageWindow v-if="summaryEntries.length === 0" variant="compact" />
</div>
</template>
@@ -0,0 +1,188 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '../constants'
import { computed } from 'vue'
import BasePopover from '@/components/base/BasePopover.vue'
import { useUiClock } from '@/composables/useUiClock'
import AccountQuotaWindowGroup from './AccountQuotaWindowGroup.vue'
import AccountUsageWindow from './AccountUsageWindow/index.vue'
import { resolveAccountUsageWindowPresentation } from './AccountUsageWindow/presenter'
const props = defineProps<{
label: string | null
windows: AccountQuotaWindow[]
}>()
const now = useUiClock()
const grouped = computed(() => (
props.windows.length > 1
&& Boolean(props.label)
))
const detailHeading = computed(() => (
props.label
))
const detailTitle = computed(() => (
detailHeading.value
?? props.windows[0]?.labelDisplay
?? '额度详情'
))
const detailItems = computed(() => props.windows.map((window) => {
const view = resolveAccountUsageWindowPresentation({
window,
variant: 'detail',
showLocalValue: true,
now: now.value.getTime(),
})
return {
key: window.key,
code: quotaWindowCode(window.windowSeconds, window.role),
label: window.windowLabelDisplay,
usedPercent: window.usedPercent,
usedPercentDisplay: window.usedPercentDisplay,
resetAtDisplay: window.resetAtDisplay,
localUsageDisplay: view.quota.localUsageVisible
? view.quota.localUsageDisplay
: '—',
percentTextClass: view.quota.percentTextClass,
barClass: view.quota.barClass,
barStyle: view.quota.barStyle,
}
}))
function quotaWindowCode(
windowSeconds: number | null,
role: AccountQuotaWindow['role'],
) {
if (typeof windowSeconds === 'number' && Number.isFinite(windowSeconds)) {
const daySeconds = 24 * 60 * 60
const hourSeconds = 60 * 60
if (windowSeconds >= daySeconds && windowSeconds % daySeconds === 0)
return `${windowSeconds / daySeconds}D`
if (windowSeconds >= hourSeconds && windowSeconds % hourSeconds === 0)
return `${windowSeconds / hourSeconds}H`
}
switch (role) {
case 'primary':
return 'P'
case 'secondary':
return 'S'
case 'monthly':
return 'M'
default:
return 'Q'
}
}
</script>
<template>
<BasePopover
class="w-full"
trigger="hover-click"
placement="right"
:hover-delay="240"
>
<template #trigger="{ open }">
<button
type="button"
class="block w-full cursor-help rounded-sm border-0 bg-transparent p-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
:aria-label="`查看${detailTitle}详情`"
:aria-expanded="open"
aria-haspopup="dialog"
>
<AccountQuotaWindowGroup
v-if="grouped && label"
:label="label"
:windows="windows"
/>
<AccountUsageWindow
v-else
:window="windows[0]"
variant="compact"
:show-local-value="true"
:show-percentage="false"
:show-native-tooltip="false"
/>
</button>
</template>
<section
class="w-84 overflow-hidden rounded-cp-overlay"
role="dialog"
:aria-label="`${detailTitle}详情`"
>
<header
v-if="detailHeading"
class="bg-cp-muted p-3"
>
<h3
class="m-0 truncate text-[13px] leading-5 font-heavy text-cp-primary"
:title="detailHeading"
>
{{ detailHeading }}
</h3>
</header>
<div
class="grid gap-4"
:class="detailHeading ? 'p-3' : 'p-4'"
>
<article
v-for="item in detailItems"
:key="item.key"
>
<div class="grid grid-cols-[32px_minmax(0,1fr)_auto] items-center gap-2.5">
<span
class="grid h-5 w-8 place-items-center rounded-sm bg-cp-muted font-mono text-[9px] leading-none font-heavy tracking-[0.04em] text-cp-info-text"
aria-hidden="true"
>
{{ item.code }}
</span>
<h4 class="m-0 min-w-0 truncate text-[12px] leading-4 font-heavy text-cp-secondary">
{{ item.label }}
</h4>
<strong
class="font-mono text-[11px] leading-none font-heavy tabular-nums"
:class="item.percentTextClass"
>
{{ item.usedPercentDisplay }}
</strong>
</div>
<div
class="mt-2 h-1 w-full overflow-hidden rounded-full bg-cp-default-border"
role="progressbar"
:aria-label="item.label"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="item.usedPercent ?? undefined"
:aria-valuetext="item.usedPercentDisplay"
>
<div
class="h-full rounded-full transition-[width,background-color] duration-200 motion-reduce:transition-none"
:class="item.barClass"
:style="item.barStyle"
/>
</div>
<dl class="mt-2.5 grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1.5 text-[10px] leading-4">
<dt class="font-emphasis text-cp-tertiary">
窗口消耗
</dt>
<dd class="m-0 text-right font-mono font-heavy tabular-nums text-cp-primary">
{{ item.localUsageDisplay }}
</dd>
<dt class="font-emphasis text-cp-tertiary">
重置时间
</dt>
<dd class="m-0 truncate text-right font-mono font-emphasis tabular-nums text-cp-secondary">
{{ item.resetAtDisplay }}
</dd>
</dl>
</article>
</div>
</section>
</BasePopover>
</template>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '../constants'
import { computed } from 'vue'
import { quotaWindowPresentation } from './AccountUsageWindow/presenter'
const props = defineProps<{
label: string
windows: AccountQuotaWindow[]
}>()
const windowItems = computed(() => props.windows.map(window => ({
key: window.key,
labelTooltip: window.labelDisplay,
usedPercent: window.usedPercent,
usedPercentDisplay: window.usedPercentDisplay,
presentation: quotaWindowPresentation(window, '2px'),
})))
const trackGridStyle = computed(() => ({
gridTemplateColumns: `repeat(${Math.max(windowItems.value.length, 1)}, minmax(0, 1fr))`,
}))
</script>
<template>
<div class="flex min-w-0 flex-col">
<div class="mb-1 min-w-0 truncate text-[11px] leading-3.5 font-bold text-cp-muted-text">
{{ label }}
</div>
<div class="grid min-w-0 gap-1" :style="trackGridStyle">
<div
v-for="item in windowItems"
:key="item.key"
class="h-0.75 w-full overflow-hidden rounded-full bg-cp-default-border"
role="progressbar"
:aria-label="item.labelTooltip"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="item.usedPercent ?? undefined"
:aria-valuetext="item.usedPercentDisplay"
>
<div
class="h-full rounded-full transition-[width,background-color] duration-200"
:class="item.presentation.barClass"
:style="item.presentation.barStyle"
/>
</div>
</div>
</div>
</template>
@@ -69,7 +69,7 @@ const modelUsageColumns = defineTableColumns<AccountModelUsage>([
</div>
<div
class="min-w-0 pt-4 shadow-[inset_0_1px_0_var(--cp-divider-subtle)] xl:pt-0 xl:pl-4 xl:shadow-[inset_1px_0_0_var(--cp-divider-subtle)]"
class="min-w-0 pt-4 xl:pt-0 xl:pl-4"
>
<div class="mb-3 flex items-center justify-between">
<h3 class="m-0 text-[14px] font-heavy text-cp-primary">
@@ -10,10 +10,14 @@ const props = withDefaults(
window?: AccountQuotaWindow
variant?: AccountUsageWindowVariant
showLocalValue?: boolean
showPercentage?: boolean
showNativeTooltip?: boolean
}>(),
{
variant: 'detail',
showLocalValue: true,
showPercentage: true,
showNativeTooltip: true,
},
)
@@ -33,7 +37,7 @@ const view = computed(() => resolveAccountUsageWindowPresentation({
<span
class="min-w-0"
:class="view.classes.label"
:title="view.labelTooltip"
:title="showNativeTooltip ? view.labelTooltip : undefined"
>
{{ window.labelDisplay }}
</span>
@@ -45,15 +49,15 @@ const view = computed(() => resolveAccountUsageWindowPresentation({
v-if="view.quota.localUsageVisible"
class="text-right text-cp-muted-text"
:class="view.classes.value"
:title="`本地 Token 用量:${view.quota.localUsageDisplay}`"
:title="showNativeTooltip ? `窗口消耗:${view.quota.localUsageDisplay}` : undefined"
>
{{ view.quota.localUsageDisplay }}
</span>
<span
v-if="view.quota.valueVisible"
v-if="showPercentage && view.quota.valueVisible"
class="text-right"
:class="[view.classes.value, view.quota.percentTextClass]"
:title="`额度已使用:${window.usedPercentDisplay}`"
:title="showNativeTooltip ? `额度已使用:${window.usedPercentDisplay}` : undefined"
>
{{ window.usedPercentDisplay }}
</span>
@@ -70,7 +74,7 @@ const view = computed(() => resolveAccountUsageWindowPresentation({
{{ view.quota.localUsageDisplay }}
</span>
<span
v-if="view.quota.valueVisible"
v-if="showPercentage && view.quota.valueVisible"
:class="[view.classes.value, view.quota.percentTextClass]"
>
{{ window.usedPercentDisplay }}
@@ -119,9 +119,7 @@ export function resolveAccountUsageWindowPresentation(
const localUsage = accountLocalUsage(input.window?.localUsage)
const mode = usageWindowMode(input.window, localUsage)
const quotaLocalUsageDisplay = localTokenDisplay(localUsage)
const quotaLocalUsageVisible = input.showLocalValue
&& typeof localUsage?.totalTokens === 'number'
&& localUsage.totalTokens > 0
const quotaLocalUsageVisible = input.showLocalValue && Boolean(quotaLocalUsageDisplay)
const localRequestDisplay = requestCountDisplay(localUsage)
const localRequestValueVisible = input.showLocalValue
&& typeof localUsage?.requestCount === 'number'
@@ -146,7 +144,7 @@ export function resolveAccountUsageWindowPresentation(
},
quota: {
valueVisible: typeof input.window?.usedPercent === 'number'
&& input.window.usedPercent > 0,
&& (input.variant !== 'compact' || input.window.usedPercent > 0),
localUsageDisplay: quotaLocalUsageDisplay,
localUsageVisible: quotaLocalUsageVisible,
barStyle: quota?.barStyle,
@@ -221,11 +219,19 @@ function usageWindowMode(
}
function localTokenDisplay(localUsage: AccountLocalUsage | null) {
const total = localUsage?.totalTokens
if (typeof total !== 'number' || total <= 0)
return ''
const display = localUsage?.totalTokensDisplay
if (typeof display === 'string' && display.trim())
return display.trim()
const total = localUsage?.totalTokens
return typeof total === 'number' && total > 0 ? formatInteger(total) : ''
return formatInteger(total)
}
export function quotaWindowLocalUsageDisplay(window: AccountQuotaWindow) {
const localUsage = accountLocalUsage(window.localUsage)
return localTokenDisplay(localUsage) || null
}
function requestCountDisplay(localUsage: AccountLocalUsage | null) {
@@ -236,7 +242,7 @@ function requestCountDisplay(localUsage: AccountLocalUsage | null) {
return typeof count === 'number' ? formatInteger(count) : '0'
}
function quotaWindowPresentation(window: AccountQuotaWindow, minimumWidth: string) {
export function quotaWindowPresentation(window: AccountQuotaWindow, minimumWidth: string) {
const percent = clamp(window.usedPercent ?? 0, 0, 100)
const tone = quotaWindowTone(window.usedPercent)
return {
+70 -6
View File
@@ -5,6 +5,12 @@ import { formatProviderLabel } from '@/utils/providers'
export type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
export type AccountQuotaWindow = AccountRow['quota']['windows'][number]
export interface AccountQuotaWindowEntry {
key: string
label: string | null
windows: AccountQuotaWindow[]
}
const quotaGroupOrder = new Map([
['shortTerm', 0],
['monthly', 1],
@@ -104,6 +110,37 @@ export function orderedPanelQuotaWindows(windows: AccountQuotaWindow[]) {
return [...windows].sort(compareQuotaWindows)
}
export function groupedAccountQuotaWindows(windows: AccountQuotaWindow[]) {
const entries: AccountQuotaWindowEntry[] = []
const limitEntryIndexes = new Map<string, number>()
for (const window of windows) {
const limitLabel = quotaLimitLabel(window)
if (!window.limitId || !limitLabel) {
entries.push({ key: window.key, label: null, windows: [window] })
continue
}
const existingIndex = limitEntryIndexes.get(window.limitId)
if (existingIndex !== undefined) {
entries[existingIndex]?.windows.push(window)
continue
}
limitEntryIndexes.set(window.limitId, entries.length)
entries.push({
key: `limit:${window.limitId}`,
label: limitLabel,
windows: [window],
})
}
for (const entry of entries)
entry.windows.sort(compareQuotaLimitWindows)
return entries
}
export function modelSuccessRateTextClass(successRate: number | null) {
if (successRate === null)
return 'text-cp-muted-text'
@@ -121,12 +158,7 @@ function compareQuotaWindows(left: AccountQuotaWindow, right: AccountQuotaWindow
if (groupDifference !== 0)
return groupDifference
const secondsDifference = (left.windowSeconds ?? Number.MAX_SAFE_INTEGER)
- (right.windowSeconds ?? Number.MAX_SAFE_INTEGER)
if (secondsDifference !== 0)
return secondsDifference
// 同组同周期保留 Provider 的投影顺序,避免内部 key 把 additional 排到 core 前面。
// 同组保留 Provider 的投影顺序,避免按窗口时长打散 core 与模型专属额度。
return 0
}
@@ -134,6 +166,38 @@ function groupOrder(window: AccountQuotaWindow) {
return quotaGroupOrder.get(window.group) ?? quotaGroupOrder.size
}
function compareQuotaLimitWindows(left: AccountQuotaWindow, right: AccountQuotaWindow) {
const durationDifference = windowDurationOrder(left.windowSeconds)
- windowDurationOrder(right.windowSeconds)
if (durationDifference !== 0)
return durationDifference
return windowRoleOrder(left.role) - windowRoleOrder(right.role)
}
function windowDurationOrder(windowSeconds: number | null) {
return windowSeconds ?? Number.POSITIVE_INFINITY
}
function windowRoleOrder(role: AccountQuotaWindow['role']) {
switch (role) {
case 'primary':
return 0
case 'secondary':
return 1
case 'monthly':
return 2
default:
return 3
}
}
function quotaLimitLabel(window: AccountQuotaWindow) {
if (window.limitId === 'codex')
return '核心额度'
return window.limitName
}
function accountProviderLabel(value?: string | null) {
return formatProviderLabel(value)
}
@@ -462,7 +462,11 @@ function dashboardUsageWindow(item: DashboardSummary['accountUsage'][number]) {
return {
key: 'quota',
group: 'other',
limitId: null,
limitName: null,
role: null,
labelDisplay: '额度',
windowLabelDisplay: '额度',
usedPercent,
usedPercentDisplay: usedPercent === null ? '—' : `${usedPercent}%`,
limitReached: (usedPercent ?? 0) >= 100,
@@ -484,7 +488,11 @@ function dashboardUsageWindow(item: DashboardSummary['accountUsage'][number]) {
return {
key: 'dailyRequests',
group: 'other',
limitId: null,
limitName: null,
role: null,
labelDisplay: '日请求',
windowLabelDisplay: '日请求',
usedPercent: null,
usedPercentDisplay: '—',
limitReached: false,
@@ -15,7 +15,7 @@ const latencyDetails = computed(() => usageLatencyDetails(props.record))
<template>
<div class="flex items-center justify-end gap-1.5">
<div
class="grid grid-cols-[auto_auto] items-center justify-end gap-x-2 gap-y-1.5 font-mono text-[12px] leading-none font-heavy tabular-nums"
class="grid grid-cols-[auto_auto] items-center justify-end gap-x-2 gap-y-1.5 whitespace-nowrap font-mono text-[12px] leading-none font-heavy tabular-nums"
>
<span class="text-[11px] text-cp-muted-text">{{ latencyDetails.firstOutputLabel }}</span>
<span class="text-cp-secondary">{{ latencyDetails.firstOutputDisplay }}</span>
+3 -3
View File
@@ -19,15 +19,15 @@ export const usageRecordColumns = defineTableColumns<UsageDisplayRecord>([
size: 'sm',
format: (value: unknown) => formatProvider(typeof value === 'string' ? value : null),
},
{ key: 'model', label: '模型', kind: 'custom' },
{ key: 'model', label: '模型', kind: 'custom', size: 'xl' },
{ key: 'reasoningEffort', label: '推理强度', kind: 'status' },
{ key: 'route', label: '端点', kind: 'mono' },
{ key: 'recordType', label: '类型', kind: 'status', size: 'sm' },
{ key: 'tokenDetails', label: 'TOKEN', kind: 'numeric', size: 'xl' },
{ key: 'billing', label: '费用', kind: 'numeric', size: 'lg' },
{ key: 'latency', label: '延迟', kind: 'numeric', size: 'lg' },
{ key: 'latency', label: '延迟', kind: 'numeric', size: 'xl' },
{ key: 'createdAtDisplay', label: '时间', kind: 'datetime' },
{ key: 'clientIp', label: 'IP', kind: 'custom', size: '2xl' },
{ key: 'clientIp', label: 'IP', kind: 'custom', size: '3xl' },
{ key: 'userAgent', label: 'User-Agent', kind: 'custom', size: '4xl' },
{ key: 'actions', label: '操作', kind: 'actions', size: 'sm' },
])
+1 -1
View File
@@ -1 +1 @@
version: "3.2.12"
version: "3.2.13"