mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53739ebe5c | ||
|
|
7f420257a6 | ||
|
|
4f85209dc9 | ||
|
|
cd89e2e8fc |
@@ -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)
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 @@
|
||||
version: "3.2.12"
|
||||
version: "3.2.13"
|
||||
|
||||
Reference in New Issue
Block a user