mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-13 08:06:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3579c28b75 | ||
|
|
2143de2791 | ||
|
|
298357f97c | ||
|
|
07829ca385 | ||
|
|
617bd676f0 | ||
|
|
0c18b44246 | ||
|
|
73658238e5 |
@@ -1,11 +1,13 @@
|
||||
//! 管理端账号 quota 展示视图。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::infra::{
|
||||
format::format_percent,
|
||||
format::{format_compact_percent, format_tokens},
|
||||
time::{china_datetime, china_relative_time},
|
||||
};
|
||||
|
||||
@@ -29,8 +31,33 @@ struct AdminAccountQuotaWindowData {
|
||||
label_display: String,
|
||||
used_percent: Option<f64>,
|
||||
used_percent_display: String,
|
||||
token_usage_display: String,
|
||||
reset_at_display: String,
|
||||
window_used_display: String,
|
||||
#[serde(skip)]
|
||||
reset_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct AdminAccountQuotaUsageWindow {
|
||||
pub(crate) key: String,
|
||||
pub(crate) start: DateTime<Utc>,
|
||||
pub(crate) end: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub(crate) struct AdminAccountQuotaWindowTokenUsage {
|
||||
total_tokens: u64,
|
||||
}
|
||||
|
||||
impl AdminAccountQuotaWindowTokenUsage {
|
||||
pub(crate) fn add_tokens(&mut self, tokens: u64) {
|
||||
self.total_tokens = self.total_tokens.saturating_add(tokens);
|
||||
}
|
||||
|
||||
fn display(self) -> String {
|
||||
format_tokens(self.total_tokens)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AdminAccountQuotaData {
|
||||
@@ -48,6 +75,40 @@ impl AdminAccountQuotaData {
|
||||
.iter()
|
||||
.any(|window| window.used_percent.is_some_and(|percent| percent >= 80.0))
|
||||
}
|
||||
|
||||
pub(crate) fn usage_windows(&self) -> Vec<AdminAccountQuotaUsageWindow> {
|
||||
self.windows
|
||||
.iter()
|
||||
.filter_map(AdminAccountQuotaWindowData::usage_window)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn apply_token_usage(
|
||||
&mut self,
|
||||
usage_by_window: &HashMap<String, AdminAccountQuotaWindowTokenUsage>,
|
||||
) {
|
||||
for window in &mut self.windows {
|
||||
window.token_usage_display =
|
||||
match (window.usage_window(), usage_by_window.get(&window.key)) {
|
||||
(Some(_), Some(usage)) => usage.display(),
|
||||
(Some(_), None) => AdminAccountQuotaWindowTokenUsage::default().display(),
|
||||
(None, _) => "-".to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AdminAccountQuotaWindowData {
|
||||
fn usage_window(&self) -> Option<AdminAccountQuotaUsageWindow> {
|
||||
let end = self.reset_at?;
|
||||
let seconds = i64::try_from(self.window_seconds?).ok()?;
|
||||
let start = end.checked_sub_signed(Duration::seconds(seconds))?;
|
||||
(start <= end).then(|| AdminAccountQuotaUsageWindow {
|
||||
key: self.key.clone(),
|
||||
start,
|
||||
end,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn quota_data(
|
||||
@@ -111,11 +172,13 @@ fn push_monthly_quota_window(
|
||||
window_seconds,
|
||||
label_display: "月限额".to_string(),
|
||||
used_percent,
|
||||
used_percent_display: used_percent.map_or_else(|| "-".to_string(), format_percent),
|
||||
used_percent_display: used_percent.map_or_else(|| "-".to_string(), format_compact_percent),
|
||||
token_usage_display: "-".to_string(),
|
||||
reset_at_display: reset_at
|
||||
.as_ref()
|
||||
.map_or_else(|| "-".to_string(), china_datetime),
|
||||
window_used_display: quota_window_used_display(reset_at, window_seconds),
|
||||
reset_at,
|
||||
});
|
||||
true
|
||||
}
|
||||
@@ -198,11 +261,13 @@ fn push_quota_window(
|
||||
window_seconds,
|
||||
label_display,
|
||||
used_percent,
|
||||
used_percent_display: used_percent.map_or_else(|| "-".to_string(), format_percent),
|
||||
used_percent_display: used_percent.map_or_else(|| "-".to_string(), format_compact_percent),
|
||||
token_usage_display: "-".to_string(),
|
||||
reset_at_display: reset_at
|
||||
.as_ref()
|
||||
.map_or_else(|| "-".to_string(), china_datetime),
|
||||
window_used_display: quota_window_used_display(reset_at, window_seconds),
|
||||
reset_at,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -25,7 +25,10 @@ use crate::{
|
||||
},
|
||||
admin::update_payload::{parse_editable_update, EditableUpdateMessages},
|
||||
admin::{
|
||||
accounts::quota_view::{quota_data, AdminAccountQuotaData},
|
||||
accounts::quota_view::{
|
||||
quota_data, AdminAccountQuotaData, AdminAccountQuotaUsageWindow,
|
||||
AdminAccountQuotaWindowTokenUsage,
|
||||
},
|
||||
accounts::service::{
|
||||
AdminAccountError, AdminAccountHealthCheck, AdminAccountMetadata,
|
||||
AdminAccountRefreshOutcome, AdminAccountRefreshResult, AdminAccountUpdate,
|
||||
@@ -38,7 +41,10 @@ use crate::{
|
||||
format_cost, format_percent, format_plain_number, format_tokens, nonnegative_i64_to_u64,
|
||||
},
|
||||
json::{clamp_limit, clamp_page, total_pages, Page},
|
||||
time::{china_datetime, china_quarter_hour_start, china_relative_time, china_rfc3339},
|
||||
time::{
|
||||
china_datetime, china_quarter_hour_start, china_relative_time, china_rfc3339,
|
||||
parse_rfc3339_utc,
|
||||
},
|
||||
},
|
||||
runtime::state::AppState,
|
||||
upstream::accounts::model::AccountStatus,
|
||||
@@ -297,6 +303,12 @@ struct AccountModelUsageRecord {
|
||||
last_used_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct AccountQuotaUsageWindow {
|
||||
account_id: String,
|
||||
window: AdminAccountQuotaUsageWindow,
|
||||
}
|
||||
|
||||
impl AccountListStats {
|
||||
fn data_for(&self, account: AdminAccountMetadata) -> AdminAccountData {
|
||||
let account_id = account.id.clone();
|
||||
@@ -642,7 +654,8 @@ pub(crate) async fn account_quota(
|
||||
let quota = data.get("quota").cloned().unwrap_or(Value::Null);
|
||||
let raw = data.get("raw").cloned().unwrap_or(Value::Null);
|
||||
let quota_json = quota.to_string();
|
||||
let quota_data = quota_data("a_json, Some(Utc::now()));
|
||||
let mut quota_data = quota_data("a_json, Some(Utc::now()));
|
||||
apply_account_quota_window_token_usage(&state, &account_id, &mut quota_data).await;
|
||||
let plan_type = quota
|
||||
.get("plan_type")
|
||||
.and_then(Value::as_str)
|
||||
@@ -858,6 +871,8 @@ async fn account_list_stats(
|
||||
.list_by_account_ids(&account_ids)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut quota_by_account = quota_by_account.clone();
|
||||
apply_quota_window_token_usage(state, accounts, &mut quota_by_account).await;
|
||||
let models_by_account = list_current_window_model_usage(state, &usage_records).await;
|
||||
|
||||
AccountListStats {
|
||||
@@ -865,11 +880,138 @@ async fn account_list_stats(
|
||||
.into_iter()
|
||||
.map(|usage| (usage.account_id.clone(), usage))
|
||||
.collect(),
|
||||
quota_by_account: quota_by_account.clone(),
|
||||
quota_by_account,
|
||||
models_by_account,
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_account_quota_window_token_usage(
|
||||
state: &AppState,
|
||||
account_id: &str,
|
||||
quota: &mut AdminAccountQuotaData,
|
||||
) {
|
||||
let windows = quota
|
||||
.usage_windows()
|
||||
.into_iter()
|
||||
.map(|window| AccountQuotaUsageWindow {
|
||||
account_id: account_id.to_string(),
|
||||
window,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let usage_by_account = quota_window_token_usage_by_account(state, &windows).await;
|
||||
let empty_usage = HashMap::new();
|
||||
quota.apply_token_usage(usage_by_account.get(account_id).unwrap_or(&empty_usage));
|
||||
}
|
||||
|
||||
async fn apply_quota_window_token_usage(
|
||||
state: &AppState,
|
||||
accounts: &[AdminAccountMetadata],
|
||||
quota_by_account: &mut HashMap<String, AdminAccountQuotaData>,
|
||||
) {
|
||||
let windows = accounts
|
||||
.iter()
|
||||
.filter_map(|account| {
|
||||
quota_by_account
|
||||
.get(&account.id)
|
||||
.map(|quota| (account.id.as_str(), quota))
|
||||
})
|
||||
.flat_map(|(account_id, quota)| {
|
||||
quota
|
||||
.usage_windows()
|
||||
.into_iter()
|
||||
.map(move |window| AccountQuotaUsageWindow {
|
||||
account_id: account_id.to_string(),
|
||||
window,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let usage_by_account = quota_window_token_usage_by_account(state, &windows).await;
|
||||
|
||||
for account in accounts {
|
||||
if let Some(quota) = quota_by_account.get_mut(&account.id) {
|
||||
let empty_usage = HashMap::new();
|
||||
quota.apply_token_usage(usage_by_account.get(&account.id).unwrap_or(&empty_usage));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn quota_window_token_usage_by_account(
|
||||
state: &AppState,
|
||||
windows: &[AccountQuotaUsageWindow],
|
||||
) -> HashMap<String, HashMap<String, AdminAccountQuotaWindowTokenUsage>> {
|
||||
let Some(min_start) = windows.iter().map(|window| window.window.start).min() else {
|
||||
return HashMap::new();
|
||||
};
|
||||
let Some(max_end) = windows.iter().map(|window| window.window.end).max() else {
|
||||
return HashMap::new();
|
||||
};
|
||||
|
||||
let account_ids = windows
|
||||
.iter()
|
||||
.map(|window| window.account_id.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
let mut windows_by_account = HashMap::<&str, Vec<&AdminAccountQuotaUsageWindow>>::new();
|
||||
for window in windows {
|
||||
windows_by_account
|
||||
.entry(window.account_id.as_str())
|
||||
.or_default()
|
||||
.push(&window.window);
|
||||
}
|
||||
let mut builder = QueryBuilder::<Sqlite>::new(
|
||||
"select
|
||||
account_id,
|
||||
bucket_start,
|
||||
coalesce(sum(input_tokens), 0) as input_tokens,
|
||||
coalesce(sum(output_tokens), 0) as output_tokens
|
||||
from usage_time_buckets
|
||||
where account_id in (",
|
||||
);
|
||||
let mut separated = builder.separated(", ");
|
||||
for account_id in &account_ids {
|
||||
separated.push_bind(*account_id);
|
||||
}
|
||||
separated.push_unseparated(")");
|
||||
builder.push(" and bucket_start >= ");
|
||||
builder.push_bind(min_start.to_rfc3339());
|
||||
builder.push(" and bucket_start <= ");
|
||||
builder.push_bind(max_end.to_rfc3339());
|
||||
builder.push(" group by account_id, bucket_start");
|
||||
|
||||
let rows = builder
|
||||
.build()
|
||||
.fetch_all(state.services.background_tasks.accounts.pool())
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut usage_by_account =
|
||||
HashMap::<String, HashMap<String, AdminAccountQuotaWindowTokenUsage>>::new();
|
||||
|
||||
for row in rows {
|
||||
let account_id: String = row.get("account_id");
|
||||
let Ok(bucket_start) = parse_rfc3339_utc(row.get::<&str, _>("bucket_start")) else {
|
||||
continue;
|
||||
};
|
||||
let total_tokens = nonnegative_i64_to_u64(row.get::<i64, _>("input_tokens"))
|
||||
.saturating_add(nonnegative_i64_to_u64(row.get::<i64, _>("output_tokens")));
|
||||
let Some(account_windows) = windows_by_account.get(account_id.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for window in account_windows
|
||||
.iter()
|
||||
.filter(|window| bucket_start >= window.start && bucket_start <= window.end)
|
||||
{
|
||||
usage_by_account
|
||||
.entry(account_id.clone())
|
||||
.or_default()
|
||||
.entry(window.key.clone())
|
||||
.or_default()
|
||||
.add_tokens(total_tokens);
|
||||
}
|
||||
}
|
||||
|
||||
usage_by_account
|
||||
}
|
||||
|
||||
async fn account_summary_data(
|
||||
state: &AppState,
|
||||
quota_by_account: &HashMap<String, AdminAccountQuotaData>,
|
||||
|
||||
@@ -219,10 +219,10 @@ struct UsageWindow {
|
||||
errors: u64,
|
||||
first_token_latency_sum: u64,
|
||||
first_token_latency_count: u64,
|
||||
max_first_token_bucket_latency: u64,
|
||||
min_first_token_bucket_latency: u64,
|
||||
latency_sum: u64,
|
||||
latency_count: u64,
|
||||
max_latency: u64,
|
||||
min_latency: u64,
|
||||
}
|
||||
|
||||
impl UsageWindow {
|
||||
@@ -243,6 +243,14 @@ impl UsageWindow {
|
||||
.checked_div(self.first_token_latency_count)
|
||||
}
|
||||
|
||||
fn max_first_token_bucket_latency(self) -> Option<u64> {
|
||||
(self.max_first_token_bucket_latency > 0).then_some(self.max_first_token_bucket_latency)
|
||||
}
|
||||
|
||||
fn min_first_token_bucket_latency(self) -> Option<u64> {
|
||||
(self.min_first_token_bucket_latency > 0).then_some(self.min_first_token_bucket_latency)
|
||||
}
|
||||
|
||||
fn avg_latency(self) -> Option<u64> {
|
||||
self.latency_sum.checked_div(self.latency_count)
|
||||
}
|
||||
@@ -252,6 +260,7 @@ impl UsageWindow {
|
||||
pub(crate) async fn dashboard_summary(
|
||||
State(state): State<AppState>,
|
||||
_auth: AdminAuth,
|
||||
Query(query): Query<DashboardTrendQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let accounts = state
|
||||
.services
|
||||
@@ -288,7 +297,7 @@ pub(crate) async fn dashboard_summary(
|
||||
let now = Utc::now();
|
||||
let time_buckets = dashboard_time_buckets(&state, now).await;
|
||||
let pool_summary = account_pool_summary(&accounts);
|
||||
let trend = dashboard_trend_data(&time_buckets, DashboardTrendKind::Usage);
|
||||
let trend = dashboard_trend_data(&time_buckets, query.kind.unwrap_or_default());
|
||||
let quota_used_by_account = account_quota_used_percent_by_id(&state, &usage_records).await;
|
||||
let settings = state.services.settings.current();
|
||||
let recent_usage_records = recent_events.into_iter().take(10).collect::<Vec<_>>();
|
||||
@@ -483,7 +492,9 @@ fn dashboard_trend_data_at(
|
||||
let points = buckets
|
||||
.iter()
|
||||
.map(|(bucket_start, bucket)| {
|
||||
let latency = bucket.avg_latency().unwrap_or(0);
|
||||
let latency = bucket.avg_first_token_latency().unwrap_or(0);
|
||||
let max_latency = bucket.max_first_token_bucket_latency().unwrap_or(0);
|
||||
let min_latency = bucket.min_first_token_bucket_latency().unwrap_or(0);
|
||||
let tokens = bucket.tokens();
|
||||
let success_rate = if bucket.requests > 0 {
|
||||
((bucket.requests - bucket.errors) as f64 / bucket.requests as f64 * 1000.0).round()
|
||||
@@ -507,10 +518,10 @@ fn dashboard_trend_data_at(
|
||||
errors_value: bucket.errors,
|
||||
latency: format_optional_duration_ms(Some(latency)),
|
||||
latency_value: latency,
|
||||
max_latency: format_optional_duration_ms(Some(bucket.max_latency)),
|
||||
max_latency_value: bucket.max_latency,
|
||||
min_latency: format_optional_duration_ms(Some(bucket.min_latency)),
|
||||
min_latency_value: bucket.min_latency,
|
||||
max_latency: format_optional_duration_ms(Some(max_latency)),
|
||||
max_latency_value: max_latency,
|
||||
min_latency: format_optional_duration_ms(Some(min_latency)),
|
||||
min_latency_value: min_latency,
|
||||
success_rate: format_percent(success_rate),
|
||||
success_rate_value: success_rate,
|
||||
}
|
||||
@@ -772,20 +783,23 @@ fn apply_bucket(window: &mut UsageWindow, record: &AdminUsageTimeBucketRecord) {
|
||||
window.errors += nonnegative_i64_to_u64(record.error_count);
|
||||
window.first_token_latency_sum += nonnegative_i64_to_u64(record.first_token_latency_sum);
|
||||
window.first_token_latency_count += nonnegative_i64_to_u64(record.first_token_latency_count);
|
||||
window.latency_sum += nonnegative_i64_to_u64(record.latency_sum);
|
||||
window.latency_count += nonnegative_i64_to_u64(record.latency_count);
|
||||
let max_latency = nonnegative_i64_to_u64(record.max_latency_ms);
|
||||
if max_latency > 0 {
|
||||
window.max_latency = window.max_latency.max(max_latency);
|
||||
}
|
||||
let min_latency = nonnegative_i64_to_u64(record.min_latency_ms);
|
||||
if min_latency > 0 {
|
||||
window.min_latency = if window.min_latency == 0 {
|
||||
min_latency
|
||||
let bucket_first_token_latency = first_token_bucket_latency(record);
|
||||
if let Some(latency) = bucket_first_token_latency {
|
||||
window.max_first_token_bucket_latency = window.max_first_token_bucket_latency.max(latency);
|
||||
window.min_first_token_bucket_latency = if window.min_first_token_bucket_latency == 0 {
|
||||
latency
|
||||
} else {
|
||||
window.min_latency.min(min_latency)
|
||||
window.min_first_token_bucket_latency.min(latency)
|
||||
};
|
||||
}
|
||||
window.latency_sum += nonnegative_i64_to_u64(record.latency_sum);
|
||||
window.latency_count += nonnegative_i64_to_u64(record.latency_count);
|
||||
}
|
||||
|
||||
fn first_token_bucket_latency(record: &AdminUsageTimeBucketRecord) -> Option<u64> {
|
||||
let sum = nonnegative_i64_to_u64(record.first_token_latency_sum);
|
||||
let count = nonnegative_i64_to_u64(record.first_token_latency_count);
|
||||
sum.checked_div(count).filter(|latency| *latency > 0)
|
||||
}
|
||||
|
||||
fn account_pool_summary(accounts: &[Account]) -> AccountPoolDiagnostics {
|
||||
|
||||
@@ -29,8 +29,8 @@ use crate::{
|
||||
},
|
||||
infra::{
|
||||
format::{
|
||||
format_compact_number, format_duration_ms, format_duration_ms_f64, format_multiplier,
|
||||
format_number, format_percent, format_precise_cost, format_rate, format_token_price,
|
||||
format_compact_number, format_cost, format_duration_ms, format_duration_ms_f64,
|
||||
format_multiplier, format_number, format_percent, format_rate, format_token_price,
|
||||
format_tokens,
|
||||
},
|
||||
json::{clamp_limit, clamp_page, NumberedPage, Page},
|
||||
@@ -607,11 +607,11 @@ fn usage_cost_details(
|
||||
service_tier: cost.service_tier,
|
||||
service_tier_display: cost.service_tier_display,
|
||||
multiplier: cost.multiplier,
|
||||
input_cost_display: format_precise_cost(cost.input_cost),
|
||||
output_cost_display: format_precise_cost(cost.output_cost),
|
||||
cache_read_cost_display: format_precise_cost(cost.cache_read_cost),
|
||||
total_cost_display: format_precise_cost(cost.total_cost),
|
||||
original_cost_display: format_precise_cost(cost.original_cost),
|
||||
input_cost_display: format_cost(cost.input_cost),
|
||||
output_cost_display: format_cost(cost.output_cost),
|
||||
cache_read_cost_display: format_cost(cost.cache_read_cost),
|
||||
total_cost_display: format_cost(cost.total_cost),
|
||||
original_cost_display: format_cost(cost.original_cost),
|
||||
input_price_display: format_token_price(cost.input_price_per_mtoken),
|
||||
output_price_display: format_token_price(cost.output_price_per_mtoken),
|
||||
cache_read_price_display: format_token_price(cost.cache_read_price_per_mtoken),
|
||||
@@ -673,9 +673,9 @@ impl UsageRecordBreakdownData {
|
||||
total_tokens_value: item.total_tokens,
|
||||
total_tokens_total: format_tokens(total_tokens_total),
|
||||
total_tokens_total_value: total_tokens_total,
|
||||
cost: format_precise_cost(item.cost),
|
||||
actual_cost: format_precise_cost(item.actual_cost),
|
||||
account_cost: format_precise_cost(item.account_cost),
|
||||
cost: format_cost(item.cost),
|
||||
actual_cost: format_cost(item.actual_cost),
|
||||
account_cost: format_cost(item.account_cost),
|
||||
average_latency_ms: format_duration_ms_f64(item.average_latency_ms),
|
||||
}
|
||||
}
|
||||
@@ -717,8 +717,8 @@ impl From<UsageRecordTrendPoint> for UsageRecordTrendPointData {
|
||||
total_tokens_value: point.total_tokens,
|
||||
cache_hit_rate: format_percent(cache_hit_rate_value),
|
||||
cache_hit_rate_value,
|
||||
cost: format_precise_cost(point.cost),
|
||||
actual_cost: format_precise_cost(point.actual_cost),
|
||||
cost: format_cost(point.cost),
|
||||
actual_cost: format_cost(point.actual_cost),
|
||||
average_latency_ms: format_duration_ms_f64(point.average_latency_ms),
|
||||
average_latency_ms_value: point.average_latency_ms,
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ pub struct ServerConfig {
|
||||
pub host: String,
|
||||
/// 监听端口。
|
||||
pub port: u16,
|
||||
/// 可信反向代理 IP 或 CIDR。只有这些 peer 的转发 IP 头会被采信。
|
||||
/// 可信反向代理 IP 或 CIDR。非空时只有这些 peer 的转发 IP 头会被采信。
|
||||
#[serde(default)]
|
||||
pub trusted_proxies: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -214,8 +214,9 @@ fn forwarded_for_public_ip(headers: &HeaderMap) -> Option<String> {
|
||||
|
||||
fn trusted_forwarded_client_ip(headers: &HeaderMap) -> Option<String> {
|
||||
header_string(headers, CF_CONNECTING_IP_HEADER)
|
||||
.or_else(|| first_forwarded_for_value(headers))
|
||||
.or_else(|| header_string(headers, REAL_IP_HEADER))
|
||||
.or_else(|| forwarded_for_public_ip(headers))
|
||||
.or_else(|| first_forwarded_for_value(headers))
|
||||
}
|
||||
|
||||
fn header_string(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
|
||||
+11
-10
@@ -63,6 +63,15 @@ pub fn format_percent(value: f64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// 展示百分比,并去掉无意义的尾随 0。
|
||||
pub fn format_compact_percent(value: f64) -> String {
|
||||
if value.is_finite() {
|
||||
format!("{}%", trim_trailing_zeroes(&format!("{value:.1}")))
|
||||
} else {
|
||||
"—".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 将 0 到 1 的比例展示为百分比。
|
||||
pub fn format_rate(value: Option<f64>) -> String {
|
||||
value.map_or_else(|| "—".to_string(), |value| format_percent(value * 100.0))
|
||||
@@ -86,15 +95,6 @@ pub fn format_cost(value: f64) -> String {
|
||||
format!("${value:.2}")
|
||||
}
|
||||
|
||||
/// 使用六位小数展示美元成本。
|
||||
pub fn format_precise_cost(value: f64) -> String {
|
||||
if value.is_finite() {
|
||||
format!("${value:.6}")
|
||||
} else {
|
||||
"—".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 展示每百万 token 的美元单价。
|
||||
pub fn format_token_price(value: f64) -> String {
|
||||
if value.is_finite() {
|
||||
@@ -160,7 +160,8 @@ fn format_compact_scaled(value: f64, unit: &str) -> String {
|
||||
format!("{}{unit}", trim_trailing_zeroes(&rounded))
|
||||
}
|
||||
|
||||
fn trim_trailing_zeroes(value: &str) -> String {
|
||||
/// 去掉小数字符串中无意义的尾随 0 和小数点。
|
||||
pub fn trim_trailing_zeroes(value: &str) -> String {
|
||||
value
|
||||
.trim_end_matches('0')
|
||||
.trim_end_matches('.')
|
||||
|
||||
@@ -10,7 +10,7 @@ const MONTH_WINDOW_SECONDS: u64 = 2_592_000;
|
||||
|
||||
/// 判断配额快照是否已经触顶。
|
||||
pub fn quota_snapshot_limit_reached(quota: &Value) -> bool {
|
||||
core_snapshot_blocked(quota)
|
||||
snapshot_blocked(quota)
|
||||
|| spend_control_limit_reached(quota)
|
||||
|| monthly_limit_reached(quota)
|
||||
|| credits_overage_limit_reached(quota)
|
||||
@@ -20,8 +20,10 @@ pub fn quota_snapshot_limit_reached(quota: &Value) -> bool {
|
||||
pub fn quota_snapshot_reset_at(quota: &Value) -> Option<DateTime<Utc>> {
|
||||
let reset_at = if monthly_limit_reached(quota) || spend_control_limit_reached(quota) {
|
||||
monthly_limit_reset_at(quota)
|
||||
.or_else(|| blocking_core_bucket(quota).and_then(bucket_reset_at))
|
||||
.or_else(|| blocking_bucket(quota).and_then(bucket_reset_at))
|
||||
.or_else(|| core_bucket_with_reset_at(quota).and_then(bucket_reset_at))
|
||||
} else if let Some(reset_at) = blocking_bucket(quota).and_then(bucket_reset_at) {
|
||||
Some(reset_at)
|
||||
} else {
|
||||
core_bucket_with_reset_at(quota)
|
||||
.and_then(bucket_reset_at)
|
||||
@@ -35,9 +37,14 @@ pub fn quota_snapshot_limit_window_seconds(quota: &Value) -> Option<u64> {
|
||||
if monthly_limit_reached(quota) || spend_control_limit_reached(quota) {
|
||||
return monthly_limit_window_seconds(quota).or(Some(MONTH_WINDOW_SECONDS));
|
||||
}
|
||||
core_bucket_with_window_minutes(quota)
|
||||
blocking_bucket(quota)
|
||||
.and_then(bucket_window_minutes)
|
||||
.and_then(|minutes| minutes.checked_mul(60))
|
||||
.or_else(|| {
|
||||
core_bucket_with_window_minutes(quota)
|
||||
.and_then(bucket_window_minutes)
|
||||
.and_then(|minutes| minutes.checked_mul(60))
|
||||
})
|
||||
.or_else(|| monthly_limit_window_seconds(quota))
|
||||
}
|
||||
|
||||
@@ -132,6 +139,8 @@ fn window_from_rate_limit(window: Option<&Value>) -> Value {
|
||||
"reset_at": window.get("reset_at").and_then(positive_i64),
|
||||
"window_minutes": window_minutes,
|
||||
"limit_reached": used_percent >= 100.0,
|
||||
"used": first_present(window, &["used_tokens", "used", "used_credits", "usage", "consumed_tokens", "consumed"]),
|
||||
"limit": first_present(window, &["limit_tokens", "limit", "limit_credits", "quota", "total_tokens", "total"]),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -201,8 +210,10 @@ fn monthly_limit_from_snapshots(snapshots: &[Value]) -> Option<Value> {
|
||||
"reset_at": bucket.get("reset_at").cloned().unwrap_or(Value::Null),
|
||||
"window_minutes": bucket.get("window_minutes").cloned().unwrap_or(Value::Null),
|
||||
"limit_reached": bucket.get("limit_reached").cloned().unwrap_or(Value::Bool(false)),
|
||||
"used_credits": Value::Null,
|
||||
"limit_credits": Value::Null,
|
||||
"used": bucket.get("used").cloned().unwrap_or(Value::Null),
|
||||
"limit": bucket.get("limit").cloned().unwrap_or(Value::Null),
|
||||
"used_credits": bucket.get("used_credits").cloned().unwrap_or(Value::Null),
|
||||
"limit_credits": bucket.get("limit_credits").cloned().unwrap_or(Value::Null),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -214,6 +225,12 @@ fn remaining_percent(used_percent: f64) -> i64 {
|
||||
(100.0 - used_percent.clamp(0.0, 100.0)).round() as i64
|
||||
}
|
||||
|
||||
fn first_present(value: &Value, keys: &[&str]) -> Value {
|
||||
keys.iter()
|
||||
.find_map(|key| value.get(*key).filter(|value| !value.is_null()).cloned())
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
fn rate_limit_blocked(rate_limit: &Value) -> bool {
|
||||
rate_limit
|
||||
.get("limit_reached")
|
||||
@@ -256,41 +273,66 @@ fn monthly_limit_reached(quota: &Value) -> bool {
|
||||
.pointer("/monthly_limit/limit_reached")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
|| quota
|
||||
.pointer("/monthly_limit/used_percent")
|
||||
.and_then(number_value)
|
||||
.is_some_and(|used_percent| used_percent >= 100.0)
|
||||
}
|
||||
|
||||
fn core_snapshot_blocked(quota: &Value) -> bool {
|
||||
fn quota_bucket_limit_reached(bucket: &Value) -> bool {
|
||||
!bucket.is_null()
|
||||
&& (bucket
|
||||
.get("limit_reached")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
|| bucket
|
||||
.get("used_percent")
|
||||
.and_then(number_value)
|
||||
.is_some_and(|used_percent| used_percent >= 100.0))
|
||||
}
|
||||
|
||||
fn quota_snapshot_blocked(snapshot: &Value) -> bool {
|
||||
snapshot
|
||||
.get("blocked")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
|| snapshot
|
||||
.get("allowed")
|
||||
.and_then(Value::as_bool)
|
||||
.is_some_and(|allowed| !allowed)
|
||||
|| snapshot_buckets(snapshot).any(quota_bucket_limit_reached)
|
||||
}
|
||||
|
||||
fn snapshot_blocked(quota: &Value) -> bool {
|
||||
quota
|
||||
.get("snapshots")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|snapshot| {
|
||||
snapshot.get("source").and_then(Value::as_str) == Some("core")
|
||||
&& snapshot
|
||||
.get("blocked")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
.any(quota_snapshot_blocked)
|
||||
}
|
||||
|
||||
fn blocking_bucket(quota: &Value) -> Option<&Value> {
|
||||
let snapshots = quota.get("snapshots").and_then(Value::as_array)?;
|
||||
snapshots
|
||||
.iter()
|
||||
.filter(|snapshot| quota_snapshot_blocked(snapshot))
|
||||
.flat_map(snapshot_buckets)
|
||||
.find(|bucket| quota_bucket_limit_reached(bucket))
|
||||
.or_else(|| {
|
||||
snapshots
|
||||
.iter()
|
||||
.filter(|snapshot| quota_snapshot_blocked(snapshot))
|
||||
.flat_map(snapshot_buckets)
|
||||
.find(|bucket| bucket_reset_at(bucket).is_some())
|
||||
})
|
||||
}
|
||||
|
||||
fn blocking_core_bucket(quota: &Value) -> Option<&Value> {
|
||||
quota
|
||||
.get("snapshots")
|
||||
.and_then(Value::as_array)?
|
||||
.iter()
|
||||
.filter(|snapshot| snapshot.get("source").and_then(Value::as_str) == Some("core"))
|
||||
.flat_map(|snapshot| {
|
||||
["primary", "secondary"]
|
||||
.into_iter()
|
||||
.filter_map(|key| snapshot.get(key))
|
||||
})
|
||||
.find(|bucket| {
|
||||
!bucket.is_null()
|
||||
&& bucket
|
||||
.get("limit_reached")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
fn snapshot_buckets(snapshot: &Value) -> impl Iterator<Item = &Value> {
|
||||
["primary", "secondary"]
|
||||
.into_iter()
|
||||
.filter_map(|key| snapshot.get(key))
|
||||
.filter(|bucket| !bucket.is_null())
|
||||
}
|
||||
|
||||
fn core_bucket_with_reset_at(quota: &Value) -> Option<&Value> {
|
||||
@@ -308,12 +350,7 @@ fn core_buckets<'a>(quota: &'a Value) -> impl Iterator<Item = &'a Value> + 'a {
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|snapshot| snapshot.get("source").and_then(Value::as_str) == Some("core"))
|
||||
.flat_map(|snapshot| {
|
||||
["primary", "secondary"]
|
||||
.into_iter()
|
||||
.filter_map(|key| snapshot.get(key))
|
||||
})
|
||||
.filter(|bucket| !bucket.is_null())
|
||||
.flat_map(snapshot_buckets)
|
||||
}
|
||||
|
||||
fn bucket_reset_at(bucket: &Value) -> Option<i64> {
|
||||
|
||||
@@ -540,11 +540,10 @@ fn quota_snapshot_from_windows(
|
||||
if primary.is_none() && secondary.is_none() {
|
||||
return None;
|
||||
}
|
||||
let blocked = limit_reached.unwrap_or_else(|| {
|
||||
allowed.is_some_and(|allowed| !allowed)
|
||||
|| primary.is_some_and(|window| window.used_percent >= 100.0)
|
||||
|| secondary.is_some_and(|window| window.used_percent >= 100.0)
|
||||
});
|
||||
let blocked = limit_reached.unwrap_or(false)
|
||||
|| allowed.is_some_and(|allowed| !allowed)
|
||||
|| primary.is_some_and(|window| window.used_percent >= 100.0)
|
||||
|| secondary.is_some_and(|window| window.used_percent >= 100.0);
|
||||
Some(json!({
|
||||
"source": source,
|
||||
"limit_name": limit_name,
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod tls;
|
||||
pub mod usage;
|
||||
pub mod websocket;
|
||||
pub mod websocket_pool;
|
||||
pub(crate) mod websocket_pump;
|
||||
|
||||
pub use self::{
|
||||
client::{
|
||||
|
||||
@@ -10,8 +10,9 @@ use std::{
|
||||
|
||||
use bytes::Bytes;
|
||||
use chrono::Utc;
|
||||
use futures::{channel::mpsc, SinkExt, Stream, StreamExt};
|
||||
use futures::Stream;
|
||||
use thiserror::Error;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::{sync::Mutex, time::timeout};
|
||||
use tokio_tungstenite::{connect_async_tls_with_config, Connector};
|
||||
use tungstenite::{
|
||||
@@ -37,9 +38,10 @@ use crate::upstream::protocol::websocket::{
|
||||
|
||||
use super::response_meta;
|
||||
use super::websocket_pool::{
|
||||
CodexWebSocketConnectionMetadata, CodexWebSocketPool, CodexWebSocketPoolKey, CodexWsStream,
|
||||
CodexWebSocketConnectionMetadata, CodexWebSocketPool, CodexWebSocketPoolKey,
|
||||
PooledWebSocketConnection, WebSocketPoolAcquire, WebSocketPoolDecision,
|
||||
};
|
||||
use super::websocket_pump::{PumpKeepalive, PumpedWebSocket, RawWsStream};
|
||||
use uuid::Uuid;
|
||||
|
||||
const REDACTED_HEADER_VALUE: &str = "<redacted>";
|
||||
@@ -317,7 +319,7 @@ fn websocket_host_header(endpoint: &str) -> Option<String> {
|
||||
|
||||
async fn connect_websocket(
|
||||
connection: &CodexWebSocketConnection,
|
||||
) -> Result<(CodexWsStream, WsResponse<Option<Vec<u8>>>), CodexWebSocketExchangeError> {
|
||||
) -> Result<(RawWsStream, WsResponse<Option<Vec<u8>>>), CodexWebSocketExchangeError> {
|
||||
let request = websocket_handshake_request(connection)?;
|
||||
let connector = super::tls::maybe_build_rustls_client_config_with_custom_ca()
|
||||
.map_err(|error| tungstenite::Error::Io(std::io::Error::other(error)))?
|
||||
@@ -329,6 +331,18 @@ async fn connect_websocket(
|
||||
}
|
||||
}
|
||||
|
||||
/// 建立连接并交给后台 pump 托管;`keepalive` 决定该连接是否做主动保活。
|
||||
///
|
||||
/// 非池化(即用即弃)连接用 [`PumpKeepalive::disabled`];池化连接用连接池派生的保活策略,
|
||||
/// 让空闲期在后台完成 ping/pong 与失活检测,从而复用前可零成本判活。
|
||||
async fn connect_pumped_websocket(
|
||||
connection: &CodexWebSocketConnection,
|
||||
keepalive: PumpKeepalive,
|
||||
) -> Result<(PumpedWebSocket, WsResponse<Option<Vec<u8>>>), CodexWebSocketExchangeError> {
|
||||
let (raw, response) = connect_websocket(connection).await?;
|
||||
Ok((PumpedWebSocket::new(raw, keepalive), response))
|
||||
}
|
||||
|
||||
fn websocket_handshake_request(
|
||||
connection: &CodexWebSocketConnection,
|
||||
) -> Result<WsRequest, tungstenite::http::Error> {
|
||||
@@ -399,7 +413,8 @@ async fn execute_fresh_response_create_request(
|
||||
request: &CodexWebSocketRequest,
|
||||
started_at: Instant,
|
||||
) -> Result<CodexWebSocketExchange, CodexWebSocketExchangeError> {
|
||||
let (mut websocket, response) = connect_websocket(request.connection()).await?;
|
||||
let (websocket, response) =
|
||||
connect_pumped_websocket(request.connection(), PumpKeepalive::disabled()).await?;
|
||||
websocket
|
||||
.send(Message::Text(request.payload_text().to_string().into()))
|
||||
.await?;
|
||||
@@ -413,7 +428,8 @@ async fn execute_fresh_response_create_request(
|
||||
async fn execute_fresh_response_create_request_stream(
|
||||
request: &CodexWebSocketRequest,
|
||||
) -> Result<CodexWebSocketStreamingExchange, CodexWebSocketExchangeError> {
|
||||
let (mut websocket, response) = connect_websocket(request.connection()).await?;
|
||||
let (websocket, response) =
|
||||
connect_pumped_websocket(request.connection(), PumpKeepalive::disabled()).await?;
|
||||
websocket
|
||||
.send(Message::Text(request.payload_text().to_string().into()))
|
||||
.await?;
|
||||
@@ -542,7 +558,8 @@ async fn execute_fresh_pooled_response_create_request(
|
||||
key: CodexWebSocketPoolKey,
|
||||
started_at: Instant,
|
||||
) -> Result<CodexWebSocketExchange, CodexWebSocketExchangeError> {
|
||||
let (mut websocket, response) = connect_websocket(request.connection()).await?;
|
||||
let (websocket, response) =
|
||||
connect_pumped_websocket(request.connection(), pool.keepalive()).await?;
|
||||
websocket
|
||||
.send(Message::Text(request.payload_text().to_string().into()))
|
||||
.await?;
|
||||
@@ -551,15 +568,12 @@ async fn execute_fresh_pooled_response_create_request(
|
||||
let metadata = websocket_connection_metadata(&response);
|
||||
let (exchange, websocket, metadata) =
|
||||
collect_websocket_response(websocket, metadata, false, started_at).await?;
|
||||
let last_activity_at = Instant::now();
|
||||
pool.put(
|
||||
key,
|
||||
PooledWebSocketConnection {
|
||||
websocket,
|
||||
metadata,
|
||||
created_at: now,
|
||||
last_activity_at,
|
||||
last_ping_at: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -571,7 +585,8 @@ async fn execute_fresh_pooled_response_create_request_stream(
|
||||
pool: CodexWebSocketPool,
|
||||
key: CodexWebSocketPoolKey,
|
||||
) -> Result<CodexWebSocketStreamingExchange, CodexWebSocketExchangeError> {
|
||||
let (mut websocket, response) = connect_websocket(request.connection()).await?;
|
||||
let (websocket, response) =
|
||||
connect_pumped_websocket(request.connection(), pool.keepalive()).await?;
|
||||
websocket
|
||||
.send(Message::Text(request.payload_text().to_string().into()))
|
||||
.await?;
|
||||
@@ -585,7 +600,6 @@ async fn execute_fresh_pooled_response_create_request_stream(
|
||||
pool,
|
||||
key,
|
||||
created_at: now,
|
||||
last_ping_at: None,
|
||||
}),
|
||||
None,
|
||||
))
|
||||
@@ -595,7 +609,7 @@ async fn execute_pooled_response_create_request(
|
||||
request: &CodexWebSocketRequest,
|
||||
pool: &CodexWebSocketPool,
|
||||
key: CodexWebSocketPoolKey,
|
||||
mut connection: PooledWebSocketConnection,
|
||||
connection: PooledWebSocketConnection,
|
||||
started_at: Instant,
|
||||
) -> Result<CodexWebSocketExchange, CodexWebSocketExchangeError> {
|
||||
if let Err(error) = connection
|
||||
@@ -612,20 +626,16 @@ async fn execute_pooled_response_create_request(
|
||||
}
|
||||
|
||||
let created_at = connection.created_at;
|
||||
let last_ping_at = connection.last_ping_at;
|
||||
match collect_websocket_response(connection.websocket, connection.metadata, true, started_at)
|
||||
.await
|
||||
{
|
||||
Ok((exchange, websocket, metadata)) => {
|
||||
let last_activity_at = Instant::now();
|
||||
pool.put(
|
||||
key,
|
||||
PooledWebSocketConnection {
|
||||
websocket,
|
||||
metadata,
|
||||
created_at,
|
||||
last_activity_at,
|
||||
last_ping_at,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -666,14 +676,13 @@ async fn execute_pooled_response_create_request_stream(
|
||||
pool,
|
||||
key,
|
||||
created_at: connection.created_at,
|
||||
last_ping_at: connection.last_ping_at,
|
||||
}),
|
||||
Some(prefetched_text),
|
||||
))
|
||||
}
|
||||
|
||||
async fn first_reused_stream_text(
|
||||
websocket: &mut CodexWsStream,
|
||||
websocket: &mut PumpedWebSocket,
|
||||
) -> Result<String, CodexWebSocketExchangeError> {
|
||||
loop {
|
||||
let message = match next_websocket_message(websocket, WEBSOCKET_RECEIVE_IDLE_TIMEOUT).await
|
||||
@@ -719,14 +728,14 @@ pub async fn execute_response_create_request(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn collect_websocket_response(
|
||||
mut websocket: CodexWsStream,
|
||||
mut websocket: PumpedWebSocket,
|
||||
mut metadata: CodexWebSocketConnectionMetadata,
|
||||
reused_connection: bool,
|
||||
started_at: Instant,
|
||||
) -> Result<
|
||||
(
|
||||
CodexWebSocketExchange,
|
||||
CodexWsStream,
|
||||
PumpedWebSocket,
|
||||
CodexWebSocketConnectionMetadata,
|
||||
),
|
||||
CodexWebSocketExchangeError,
|
||||
@@ -848,11 +857,10 @@ struct WebSocketStreamPoolReturn {
|
||||
pool: CodexWebSocketPool,
|
||||
key: CodexWebSocketPoolKey,
|
||||
created_at: Instant,
|
||||
last_ping_at: Option<Instant>,
|
||||
}
|
||||
|
||||
fn stream_websocket_response(
|
||||
websocket: CodexWsStream,
|
||||
websocket: PumpedWebSocket,
|
||||
metadata: CodexWebSocketConnectionMetadata,
|
||||
pool_return: Option<WebSocketStreamPoolReturn>,
|
||||
prefetched_text: Option<String>,
|
||||
@@ -876,8 +884,12 @@ fn stream_websocket_response(
|
||||
.await;
|
||||
});
|
||||
|
||||
let body = futures::stream::unfold(rx, |mut rx| async move {
|
||||
rx.recv().await.map(|item| (item, rx))
|
||||
});
|
||||
|
||||
CodexWebSocketStreamingExchange {
|
||||
body: Box::pin(rx),
|
||||
body: Box::pin(body),
|
||||
turn_state: response_metadata.turn_state,
|
||||
set_cookie_headers: response_metadata.set_cookie_headers,
|
||||
rate_limit_headers: response_metadata.rate_limit_headers,
|
||||
@@ -888,13 +900,13 @@ fn stream_websocket_response(
|
||||
}
|
||||
|
||||
async fn forward_websocket_response_stream(
|
||||
mut websocket: CodexWsStream,
|
||||
mut websocket: PumpedWebSocket,
|
||||
mut metadata: CodexWebSocketConnectionMetadata,
|
||||
pool_return: Option<WebSocketStreamPoolReturn>,
|
||||
prefetched_text: Option<String>,
|
||||
rate_limit_header_updates: CodexWebSocketRateLimitHeaderUpdates,
|
||||
turn_state_update: CodexWebSocketTurnStateUpdate,
|
||||
mut tx: mpsc::Sender<Result<Bytes, CodexWebSocketExchangeError>>,
|
||||
tx: mpsc::Sender<Result<Bytes, CodexWebSocketExchangeError>>,
|
||||
) {
|
||||
let mut pool_return = pool_return;
|
||||
let mut prefetched_text = prefetched_text;
|
||||
@@ -903,12 +915,20 @@ async fn forward_websocket_response_stream(
|
||||
let raw = if let Some(text) = prefetched_text.take() {
|
||||
text
|
||||
} else {
|
||||
let message = match next_websocket_message(
|
||||
&mut websocket,
|
||||
receive_idle_timeout(saw_response_frame),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let message = tokio::select! {
|
||||
biased;
|
||||
// 客户端断开下游 SSE 流:立即丢弃连接并释放池 slot,
|
||||
// 不再傻等上游 idle 超时(否则同会话后续请求会一直 bypass/busy)。
|
||||
() = tx.closed() => {
|
||||
discard_stream_websocket(websocket, pool_return).await;
|
||||
return;
|
||||
}
|
||||
message = next_websocket_message(
|
||||
&mut websocket,
|
||||
receive_idle_timeout(saw_response_frame),
|
||||
) => message,
|
||||
};
|
||||
let message = match message {
|
||||
Ok(message) => message,
|
||||
Err(error) => {
|
||||
discard_stream_websocket(websocket, pool_return).await;
|
||||
@@ -1011,7 +1031,7 @@ fn receive_idle_timeout(saw_response_frame: bool) -> Duration {
|
||||
}
|
||||
|
||||
async fn next_websocket_message(
|
||||
websocket: &mut CodexWsStream,
|
||||
websocket: &mut PumpedWebSocket,
|
||||
receive_timeout: Duration,
|
||||
) -> Result<Option<Message>, CodexWebSocketExchangeError> {
|
||||
match timeout(receive_timeout, websocket.next()).await {
|
||||
@@ -1023,12 +1043,12 @@ async fn next_websocket_message(
|
||||
}
|
||||
|
||||
async fn finish_stream_websocket(
|
||||
mut websocket: CodexWsStream,
|
||||
mut websocket: PumpedWebSocket,
|
||||
metadata: CodexWebSocketConnectionMetadata,
|
||||
pool_return: Option<WebSocketStreamPoolReturn>,
|
||||
) {
|
||||
let Some(pool_return) = pool_return else {
|
||||
let _ = websocket.close(None).await;
|
||||
websocket.close().await;
|
||||
return;
|
||||
};
|
||||
pool_return
|
||||
@@ -1039,21 +1059,19 @@ async fn finish_stream_websocket(
|
||||
websocket,
|
||||
metadata,
|
||||
created_at: pool_return.created_at,
|
||||
last_activity_at: Instant::now(),
|
||||
last_ping_at: pool_return.last_ping_at,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn discard_stream_websocket(
|
||||
mut websocket: CodexWsStream,
|
||||
mut websocket: PumpedWebSocket,
|
||||
pool_return: Option<WebSocketStreamPoolReturn>,
|
||||
) {
|
||||
if let Some(pool_return) = pool_return {
|
||||
pool_return.pool.discard(&pool_return.key).await;
|
||||
}
|
||||
let _ = websocket.close(None).await;
|
||||
websocket.close().await;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -6,18 +6,17 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::{net::TcpStream, sync::Mutex, time::timeout};
|
||||
use tokio_tungstenite::{tungstenite::Message, MaybeTlsStream, WebSocketStream};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::websocket_pump::{PumpKeepalive, PumpedWebSocket};
|
||||
|
||||
const DEFAULT_MAX_PER_ACCOUNT: usize = 8;
|
||||
const DEFAULT_MAX_AGE: Duration = Duration::from_mins(55);
|
||||
const DEFAULT_MAINTENANCE_INTERVAL: Duration = Duration::from_secs(25);
|
||||
const DEFAULT_PING_INTERVAL: Duration = Duration::from_secs(25);
|
||||
const DEFAULT_PING_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const DEFAULT_LIVENESS_TIMEOUT: Duration = Duration::from_millis(62_500);
|
||||
|
||||
/// WebSocket 连接池 key。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
@@ -99,7 +98,19 @@ impl Default for CodexWebSocketPoolConfig {
|
||||
maintenance_interval: Some(DEFAULT_MAINTENANCE_INTERVAL),
|
||||
ping_interval: Some(DEFAULT_PING_INTERVAL),
|
||||
ping_timeout: DEFAULT_PING_TIMEOUT,
|
||||
liveness_timeout: Some(DEFAULT_LIVENESS_TIMEOUT),
|
||||
// idle 连接不设失活截断:靠 ping/pong 保活,只在 max_age(55 分钟)
|
||||
// 或 ping 失败时关闭,最大化跨轮复用(对齐 Codex CLI 的长连接策略)。
|
||||
liveness_timeout: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CodexWebSocketPoolConfig {
|
||||
/// pump 后台任务的保活策略:从连接池配置派生出 ping 间隔与 liveness 超时。
|
||||
pub(crate) fn keepalive(&self) -> PumpKeepalive {
|
||||
PumpKeepalive {
|
||||
ping_interval: self.ping_interval,
|
||||
liveness_timeout: self.liveness_timeout,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,6 +138,11 @@ impl CodexWebSocketPool {
|
||||
pool
|
||||
}
|
||||
|
||||
/// pump 后台任务的保活策略(供建连时传入)。
|
||||
pub(crate) fn keepalive(&self) -> PumpKeepalive {
|
||||
self.config.keepalive()
|
||||
}
|
||||
|
||||
pub(crate) async fn acquire(&self, key: &CodexWebSocketPoolKey) -> WebSocketPoolAcquire {
|
||||
let mut expired_connection = None;
|
||||
let acquire = {
|
||||
@@ -135,14 +151,18 @@ impl CodexWebSocketPool {
|
||||
return WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::Disabled);
|
||||
}
|
||||
match state.slots.get(key) {
|
||||
Some(WebSocketPoolSlot::Busy | WebSocketPoolSlot::Checking) => {
|
||||
Some(WebSocketPoolSlot::Busy) => {
|
||||
return WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::Busy);
|
||||
}
|
||||
Some(WebSocketPoolSlot::Idle(_)) => {
|
||||
let Some(WebSocketPoolSlot::Idle(connection)) = state.slots.remove(key) else {
|
||||
return WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::Busy);
|
||||
};
|
||||
if connection.created_at.elapsed() < self.config.max_age {
|
||||
// 零成本探活:后台 pump 已实时感知连接死亡(RST/Close/EOF/失活),
|
||||
// 复用前只需读取 is_closed 标志,避免复用到静默死连接后卡到超时。
|
||||
if connection.created_at.elapsed() < self.config.max_age
|
||||
&& !connection.websocket.is_closed()
|
||||
{
|
||||
state.slots.insert(key.clone(), WebSocketPoolSlot::Busy);
|
||||
return WebSocketPoolAcquire::Reused(connection);
|
||||
}
|
||||
@@ -199,7 +219,7 @@ impl CodexWebSocketPool {
|
||||
let mut state = self.inner.lock().await;
|
||||
match state.slots.remove(key) {
|
||||
Some(WebSocketPoolSlot::Idle(connection)) => Some(*connection),
|
||||
Some(WebSocketPoolSlot::Busy | WebSocketPoolSlot::Checking) | None => None,
|
||||
Some(WebSocketPoolSlot::Busy) | None => None,
|
||||
}
|
||||
};
|
||||
if let Some(connection) = idle_connection {
|
||||
@@ -237,21 +257,21 @@ impl CodexWebSocketPool {
|
||||
.drain()
|
||||
.filter_map(|(_, slot)| match slot {
|
||||
WebSocketPoolSlot::Idle(connection) => Some(*connection),
|
||||
WebSocketPoolSlot::Busy | WebSocketPoolSlot::Checking => None,
|
||||
WebSocketPoolSlot::Busy => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
close_pooled_connections(idle_connections).await;
|
||||
}
|
||||
|
||||
/// 维护 idle 连接:关闭过期/失活连接,并按需 ping 探活。
|
||||
/// 维护 idle 连接:清扫已被后台 pump 判定死亡或已超龄的连接。
|
||||
///
|
||||
/// 保活(ping/pong)与失活检测已下沉到每条连接的 pump 任务内部,此处不再做
|
||||
/// 同步 ping 探活,只负责把「后台已标记 closed」或「超过 max_age」的 idle
|
||||
/// 连接从池中摘除并关闭,避免它们占用 slot。
|
||||
pub async fn maintain_idle_connections(&self) {
|
||||
let maintenance = self.take_idle_connections_for_maintenance().await;
|
||||
close_pooled_connections(maintenance.close).await;
|
||||
for (key, connection) in maintenance.probe {
|
||||
let connection = probe_idle_connection(connection, self.config.ping_timeout).await;
|
||||
self.return_maintained_connection(key, connection).await;
|
||||
}
|
||||
let close = self.take_dead_idle_connections().await;
|
||||
close_pooled_connections(close).await;
|
||||
}
|
||||
|
||||
fn spawn_maintenance_task(&self) {
|
||||
@@ -284,107 +304,31 @@ impl CodexWebSocketPool {
|
||||
self.inner.lock().await.shutting_down
|
||||
}
|
||||
|
||||
async fn take_idle_connections_for_maintenance(&self) -> WebSocketPoolMaintenance {
|
||||
async fn take_dead_idle_connections(&self) -> Vec<PooledWebSocketConnection> {
|
||||
let mut close = Vec::new();
|
||||
let mut probe = Vec::new();
|
||||
let now = Instant::now();
|
||||
let Some(ping_interval) = self.config.ping_interval else {
|
||||
let mut state = self.inner.lock().await;
|
||||
if state.shutting_down || !self.config.enabled {
|
||||
return WebSocketPoolMaintenance { close, probe };
|
||||
}
|
||||
let keys = state
|
||||
.slots
|
||||
.iter()
|
||||
.filter_map(|(key, slot)| match slot {
|
||||
WebSocketPoolSlot::Idle(connection)
|
||||
if should_close_idle_connection(
|
||||
connection,
|
||||
now,
|
||||
self.config.max_age,
|
||||
self.config.liveness_timeout,
|
||||
) =>
|
||||
{
|
||||
Some(key.clone())
|
||||
}
|
||||
WebSocketPoolSlot::Idle(_)
|
||||
| WebSocketPoolSlot::Busy
|
||||
| WebSocketPoolSlot::Checking => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for key in keys {
|
||||
if let Some(WebSocketPoolSlot::Idle(connection)) = state.slots.remove(&key) {
|
||||
close.push(*connection);
|
||||
}
|
||||
}
|
||||
return WebSocketPoolMaintenance { close, probe };
|
||||
};
|
||||
|
||||
let mut state = self.inner.lock().await;
|
||||
if state.shutting_down || !self.config.enabled {
|
||||
return WebSocketPoolMaintenance { close, probe };
|
||||
return close;
|
||||
}
|
||||
let keys = state
|
||||
.slots
|
||||
.iter()
|
||||
.filter_map(|(key, slot)| match slot {
|
||||
WebSocketPoolSlot::Idle(connection)
|
||||
if should_close_idle_connection(
|
||||
connection,
|
||||
now,
|
||||
self.config.max_age,
|
||||
self.config.liveness_timeout,
|
||||
) || should_probe_idle_connection(connection, now, ping_interval) =>
|
||||
if should_close_idle_connection(connection, now, self.config.max_age) =>
|
||||
{
|
||||
Some(key.clone())
|
||||
}
|
||||
WebSocketPoolSlot::Idle(_)
|
||||
| WebSocketPoolSlot::Busy
|
||||
| WebSocketPoolSlot::Checking => None,
|
||||
WebSocketPoolSlot::Idle(_) | WebSocketPoolSlot::Busy => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for key in keys {
|
||||
let Some(WebSocketPoolSlot::Idle(connection)) = state.slots.remove(&key) else {
|
||||
continue;
|
||||
};
|
||||
if should_close_idle_connection(
|
||||
&connection,
|
||||
now,
|
||||
self.config.max_age,
|
||||
self.config.liveness_timeout,
|
||||
) {
|
||||
if let Some(WebSocketPoolSlot::Idle(connection)) = state.slots.remove(&key) {
|
||||
close.push(*connection);
|
||||
} else {
|
||||
state.slots.insert(key.clone(), WebSocketPoolSlot::Checking);
|
||||
probe.push((key, *connection));
|
||||
}
|
||||
}
|
||||
WebSocketPoolMaintenance { close, probe }
|
||||
}
|
||||
|
||||
async fn return_maintained_connection(
|
||||
&self,
|
||||
key: CodexWebSocketPoolKey,
|
||||
connection: Option<PooledWebSocketConnection>,
|
||||
) {
|
||||
let mut connection = connection;
|
||||
{
|
||||
let mut state = self.inner.lock().await;
|
||||
if state.shutting_down || !self.config.enabled {
|
||||
state.slots.remove(&key);
|
||||
} else if matches!(state.slots.get(&key), Some(WebSocketPoolSlot::Checking)) {
|
||||
if let Some(connection) = connection.take() {
|
||||
state
|
||||
.slots
|
||||
.insert(key, WebSocketPoolSlot::Idle(Box::new(connection)));
|
||||
} else {
|
||||
state.slots.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(connection) = connection {
|
||||
close_pooled_connection(connection).await;
|
||||
}
|
||||
close
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,8 +338,6 @@ struct WebSocketPoolState {
|
||||
shutting_down: bool,
|
||||
}
|
||||
|
||||
pub(crate) type CodexWsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct CodexWebSocketConnectionMetadata {
|
||||
pub(crate) turn_state: Option<String>,
|
||||
@@ -404,17 +346,14 @@ pub(crate) struct CodexWebSocketConnectionMetadata {
|
||||
}
|
||||
|
||||
pub(crate) struct PooledWebSocketConnection {
|
||||
pub(crate) websocket: CodexWsStream,
|
||||
pub(crate) websocket: PumpedWebSocket,
|
||||
pub(crate) metadata: CodexWebSocketConnectionMetadata,
|
||||
pub(crate) created_at: Instant,
|
||||
pub(crate) last_activity_at: Instant,
|
||||
pub(crate) last_ping_at: Option<Instant>,
|
||||
}
|
||||
|
||||
enum WebSocketPoolSlot {
|
||||
Idle(Box<PooledWebSocketConnection>),
|
||||
Busy,
|
||||
Checking,
|
||||
}
|
||||
|
||||
pub(crate) enum WebSocketPoolAcquire {
|
||||
@@ -537,7 +476,7 @@ fn account_slot_count(
|
||||
}
|
||||
|
||||
async fn close_pooled_connection(mut connection: PooledWebSocketConnection) {
|
||||
let _ = connection.websocket.send(Message::Close(None)).await;
|
||||
connection.websocket.close().await;
|
||||
}
|
||||
|
||||
async fn close_pooled_connections(connections: Vec<PooledWebSocketConnection>) {
|
||||
@@ -546,59 +485,11 @@ async fn close_pooled_connections(connections: Vec<PooledWebSocketConnection>) {
|
||||
}
|
||||
}
|
||||
|
||||
struct WebSocketPoolMaintenance {
|
||||
close: Vec<PooledWebSocketConnection>,
|
||||
probe: Vec<(CodexWebSocketPoolKey, PooledWebSocketConnection)>,
|
||||
}
|
||||
|
||||
/// idle 连接是否应从池中摘除:被后台 pump 标记死亡,或已超过 `max_age`。
|
||||
fn should_close_idle_connection(
|
||||
connection: &PooledWebSocketConnection,
|
||||
now: Instant,
|
||||
max_age: Duration,
|
||||
liveness_timeout: Option<Duration>,
|
||||
) -> bool {
|
||||
now.duration_since(connection.created_at) >= max_age
|
||||
|| liveness_timeout.is_some_and(|timeout| {
|
||||
!timeout.is_zero() && now.duration_since(connection.last_activity_at) >= timeout
|
||||
})
|
||||
}
|
||||
|
||||
fn should_probe_idle_connection(
|
||||
connection: &PooledWebSocketConnection,
|
||||
now: Instant,
|
||||
ping_interval: Duration,
|
||||
) -> bool {
|
||||
!ping_interval.is_zero()
|
||||
&& connection
|
||||
.last_ping_at
|
||||
.is_none_or(|last_ping_at| now.duration_since(last_ping_at) >= ping_interval)
|
||||
}
|
||||
|
||||
async fn probe_idle_connection(
|
||||
mut connection: PooledWebSocketConnection,
|
||||
ping_timeout: Duration,
|
||||
) -> Option<PooledWebSocketConnection> {
|
||||
connection.last_ping_at = Some(Instant::now());
|
||||
if connection
|
||||
.websocket
|
||||
.send(Message::Ping(Vec::new().into()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
close_pooled_connection(connection).await;
|
||||
return None;
|
||||
}
|
||||
if ping_timeout.is_zero() {
|
||||
return Some(connection);
|
||||
}
|
||||
match timeout(ping_timeout, connection.websocket.next()).await {
|
||||
Ok(Some(Ok(Message::Close(_)) | Err(_)) | None) | Err(_) => {
|
||||
close_pooled_connection(connection).await;
|
||||
None
|
||||
}
|
||||
Ok(Some(Ok(_message))) => {
|
||||
connection.last_activity_at = Instant::now();
|
||||
Some(connection)
|
||||
}
|
||||
}
|
||||
connection.websocket.is_closed() || now.duration_since(connection.created_at) >= max_age
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
//! 常驻后台的 WebSocket 泵(对齐官方 Codex CLI 的连接管理)。
|
||||
//!
|
||||
//! 每条上游 WebSocket 连接都由一个后台 pump 任务独占:
|
||||
//! - 持续读取 socket:一旦观察到 `Close` / EOF / 传输错误,立即把连接标记为 `closed`。
|
||||
//! - 自动回应上游 `Ping`(保活),并按 `ping_interval` 主动 `Ping`(穿透 NAT/中间盒空闲计时器)。
|
||||
//! - 可选 `liveness_timeout`:长时间无任何入站活动时判定连接失活并退出。
|
||||
//!
|
||||
//! 因此空闲连接的“死没死”在后台被实时感知;复用方只需零成本读取 [`PumpedWebSocket::is_closed`],
|
||||
//! 无需在请求路径上做同步探活 ping(消除“复用到静默死连接才卡住超时”的长尾)。
|
||||
//!
|
||||
//! 收发都通过 channel 与 pump 任务交互:
|
||||
//! - 发送:`send` 走 command channel,等待 pump 回执。
|
||||
//! - 接收:`next` 从 message channel 取出 pump 转发的入站帧(`Ping`/`Pong` 已被 pump 吞掉)。
|
||||
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use tokio::{
|
||||
net::TcpStream,
|
||||
sync::{mpsc, oneshot},
|
||||
task::JoinHandle,
|
||||
time::MissedTickBehavior,
|
||||
};
|
||||
use tokio_tungstenite::{tungstenite::Message, MaybeTlsStream, WebSocketStream};
|
||||
|
||||
/// 底层 tungstenite WebSocket 流。
|
||||
pub(crate) type RawWsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
|
||||
|
||||
const PUMP_COMMAND_BUFFER: usize = 32;
|
||||
|
||||
/// pump 保活策略。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct PumpKeepalive {
|
||||
/// 主动 `Ping` 间隔;`None` 表示 pump 不主动 ping(仅被动读取 + 回应上游 ping)。
|
||||
pub(crate) ping_interval: Option<Duration>,
|
||||
/// 无入站活动多久后判定失活;`None` 表示只靠显式 close/传输错误发现死亡。
|
||||
pub(crate) liveness_timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
impl PumpKeepalive {
|
||||
/// 不做主动保活(用于即用即弃的非池化连接)。
|
||||
pub(crate) fn disabled() -> Self {
|
||||
Self {
|
||||
ping_interval: None,
|
||||
liveness_timeout: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PumpCommand {
|
||||
Send {
|
||||
message: Message,
|
||||
ack: oneshot::Sender<Result<(), tungstenite::Error>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 由后台 pump 任务托管的 WebSocket 连接句柄。
|
||||
pub(crate) struct PumpedWebSocket {
|
||||
tx_command: mpsc::Sender<PumpCommand>,
|
||||
rx_message: mpsc::UnboundedReceiver<Result<Message, tungstenite::Error>>,
|
||||
closed: Arc<AtomicBool>,
|
||||
pump: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PumpedWebSocket {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("PumpedWebSocket")
|
||||
.field("closed", &self.closed.load(Ordering::Acquire))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl PumpedWebSocket {
|
||||
/// 用底层流启动一个 pump 任务并返回句柄。
|
||||
pub(crate) fn new(inner: RawWsStream, keepalive: PumpKeepalive) -> Self {
|
||||
let (tx_command, rx_command) = mpsc::channel::<PumpCommand>(PUMP_COMMAND_BUFFER);
|
||||
let (tx_message, rx_message) = mpsc::unbounded_channel();
|
||||
let closed = Arc::new(AtomicBool::new(false));
|
||||
let closed_for_task = Arc::clone(&closed);
|
||||
let pump = tokio::spawn(async move {
|
||||
pump_loop(inner, rx_command, tx_message, keepalive).await;
|
||||
closed_for_task.store(true, Ordering::Release);
|
||||
});
|
||||
Self {
|
||||
tx_command,
|
||||
rx_message,
|
||||
closed,
|
||||
pump: Some(pump),
|
||||
}
|
||||
}
|
||||
|
||||
/// 通过 pump 发送一帧,返回底层 `send` 的结果。
|
||||
pub(crate) async fn send(&self, message: Message) -> Result<(), tungstenite::Error> {
|
||||
let (ack, rx_ack) = oneshot::channel();
|
||||
if self
|
||||
.tx_command
|
||||
.send(PumpCommand::Send { message, ack })
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(tungstenite::Error::ConnectionClosed);
|
||||
}
|
||||
rx_ack
|
||||
.await
|
||||
.unwrap_or(Err(tungstenite::Error::ConnectionClosed))
|
||||
}
|
||||
|
||||
/// 取出下一帧入站消息(`Ping`/`Pong` 已被 pump 处理,不会到达这里)。
|
||||
///
|
||||
/// 返回 `None` 表示连接已结束(pump 已退出且缓冲已排空)。
|
||||
pub(crate) async fn next(&mut self) -> Option<Result<Message, tungstenite::Error>> {
|
||||
self.rx_message.recv().await
|
||||
}
|
||||
|
||||
/// 连接是否已被后台 pump 判定关闭/失活。零成本,用于复用前探活。
|
||||
pub(crate) fn is_closed(&self) -> bool {
|
||||
self.closed.load(Ordering::Acquire) || self.tx_command.is_closed()
|
||||
}
|
||||
|
||||
/// 主动关闭连接(best-effort 发送 Close 帧)。
|
||||
pub(crate) async fn close(&mut self) {
|
||||
let _ = self.send(Message::Close(None)).await;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PumpedWebSocket {
|
||||
fn drop(&mut self) {
|
||||
if let Some(pump) = self.pump.take() {
|
||||
pump.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn pump_loop(
|
||||
mut inner: RawWsStream,
|
||||
mut rx_command: mpsc::Receiver<PumpCommand>,
|
||||
tx_message: mpsc::UnboundedSender<Result<Message, tungstenite::Error>>,
|
||||
keepalive: PumpKeepalive,
|
||||
) {
|
||||
let mut last_activity = Instant::now();
|
||||
let ping_interval = keepalive.ping_interval.filter(|d| !d.is_zero());
|
||||
let liveness_timeout = keepalive.liveness_timeout.filter(|d| !d.is_zero());
|
||||
// ticker 由 ping 间隔或 liveness 检查间隔中较小者驱动:即使不主动 ping,
|
||||
// 也要周期性醒来检查 liveness。两者都未配置时不启动 ticker。
|
||||
let tick_interval = match (ping_interval, liveness_timeout) {
|
||||
(Some(ping), Some(live)) => Some(ping.min(live)),
|
||||
(Some(ping), None) => Some(ping),
|
||||
(None, Some(live)) => Some(live),
|
||||
(None, None) => None,
|
||||
};
|
||||
let mut ticker = tick_interval.map(|d| {
|
||||
// 首个 tick 推迟一整个间隔:tokio::time::interval 默认会让首个 tick 立即就绪,
|
||||
// 否则连接一建立就会立刻发一帧 Ping,与首个请求 Text 抢跑、打乱帧序。
|
||||
let mut ticker = tokio::time::interval_at(tokio::time::Instant::now() + d, d);
|
||||
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
ticker
|
||||
});
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
command = rx_command.recv() => {
|
||||
match command {
|
||||
// 所有句柄已丢弃:连接不再被使用,退出 pump。
|
||||
None => break,
|
||||
Some(PumpCommand::Send { message, ack }) => {
|
||||
let result = inner.send(message).await;
|
||||
let should_break = result.is_err();
|
||||
let _ = ack.send(result);
|
||||
if should_break {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
message = inner.next() => {
|
||||
match message {
|
||||
None => break,
|
||||
Some(Ok(Message::Ping(payload))) => {
|
||||
last_activity = Instant::now();
|
||||
if inner.send(Message::Pong(payload)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Pong(_))) => {
|
||||
last_activity = Instant::now();
|
||||
}
|
||||
Some(Ok(message)) => {
|
||||
last_activity = Instant::now();
|
||||
let is_close = matches!(message, Message::Close(_));
|
||||
if tx_message.send(Ok(message)).is_err() {
|
||||
break;
|
||||
}
|
||||
if is_close {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Err(err)) => {
|
||||
let _ = tx_message.send(Err(err));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = tick(&mut ticker) => {
|
||||
// 先检查 liveness:长时间无入站活动则判定失活退出。
|
||||
if let Some(timeout) = liveness_timeout {
|
||||
if last_activity.elapsed() >= timeout {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 再按需主动 ping 保活(穿透 NAT/中间盒空闲计时器)。
|
||||
if ping_interval.is_some()
|
||||
&& inner.send(Message::Ping(Vec::new().into())).await.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// best-effort:尝试通知对端关闭,随后 drop 底层流释放 socket。
|
||||
let _ = inner.send(Message::Close(None)).await;
|
||||
}
|
||||
|
||||
/// 等待 ping ticker;`None` 时永远挂起,让 `select!` 分支实际禁用。
|
||||
async fn tick(ticker: &mut Option<tokio::time::Interval>) {
|
||||
match ticker {
|
||||
Some(ticker) => {
|
||||
ticker.tick().await;
|
||||
}
|
||||
None => std::future::pending::<()>().await,
|
||||
}
|
||||
}
|
||||
@@ -89,8 +89,8 @@ async fn admin_accounts_list_should_include_usage_quota_and_model_stats() {
|
||||
window_input_tokens: 120_000,
|
||||
window_output_tokens: 4_000,
|
||||
window_cached_tokens: 40_000,
|
||||
window_started_at: "2026-06-20T00:00:00Z",
|
||||
window_reset_at: "2026-06-27T00:00:00Z",
|
||||
window_started_at: "2026-06-22T12:51:00Z",
|
||||
window_reset_at: "2026-06-29T12:51:00Z",
|
||||
limit_window_seconds: 604_800,
|
||||
last_used_at: "2026-06-23T08:50:13Z",
|
||||
},
|
||||
@@ -217,6 +217,20 @@ async fn admin_accounts_list_should_include_usage_quota_and_model_stats() {
|
||||
sqlx::query(
|
||||
"insert into usage_time_buckets (bucket_start, account_id, model, request_count, input_tokens, output_tokens, cached_tokens, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind("2026-06-22T10:00:00+00:00")
|
||||
.bind("acct_stats")
|
||||
.bind("gpt-five-hour-window")
|
||||
.bind(1)
|
||||
.bind(2_000)
|
||||
.bind(500)
|
||||
.bind(100)
|
||||
.bind("2026-06-22T10:00:00Z")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"insert into usage_time_buckets (bucket_start, account_id, model, request_count, input_tokens, output_tokens, cached_tokens, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind("2026-06-19T23:45:00+00:00")
|
||||
.bind("acct_stats")
|
||||
.bind("gpt-outside-window")
|
||||
@@ -253,10 +267,13 @@ async fn admin_accounts_list_should_include_usage_quota_and_model_stats() {
|
||||
assert_eq!(item["quota"]["windows"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(item["quota"]["windows"][0]["labelDisplay"], "月限额");
|
||||
assert_eq!(item["quota"]["windows"][0]["group"], "monthly");
|
||||
assert_eq!(item["quota"]["windows"][0]["tokenUsageDisplay"], "1.12M");
|
||||
assert_eq!(item["quota"]["windows"][1]["labelDisplay"], "5小时限额");
|
||||
assert_eq!(item["quota"]["windows"][1]["group"], "shortTerm");
|
||||
assert_eq!(item["quota"]["windows"][1]["tokenUsageDisplay"], "2.5K");
|
||||
assert_eq!(item["quota"]["windows"][2]["labelDisplay"], "周限额");
|
||||
assert_eq!(item["quota"]["windows"][2]["group"], "shortTerm");
|
||||
assert_eq!(item["quota"]["windows"][2]["tokenUsageDisplay"], "124K");
|
||||
assert!(item["quota"]["windows"][2]["windowUsedDisplay"]
|
||||
.as_str()
|
||||
.is_some_and(|value| value.contains(" / 7.0d")));
|
||||
|
||||
@@ -18,6 +18,11 @@ async fn admin_account_quota_should_send_usage_cookie() {
|
||||
"used_percent": 12,
|
||||
"reset_at": 1_800_000_000,
|
||||
"limit_window_seconds": 18_000
|
||||
},
|
||||
"secondary_window": {
|
||||
"used_percent": 34,
|
||||
"reset_at": 1_800_604_800,
|
||||
"limit_window_seconds": 604_800
|
||||
}
|
||||
}
|
||||
})))
|
||||
@@ -46,6 +51,29 @@ async fn admin_account_quota_should_send_usage_cookie() {
|
||||
},
|
||||
)
|
||||
.await;
|
||||
sqlx::query(
|
||||
"insert into usage_time_buckets (
|
||||
bucket_start,
|
||||
account_id,
|
||||
model,
|
||||
request_count,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cached_tokens,
|
||||
updated_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind("2027-01-15T04:00:00+00:00")
|
||||
.bind("acct_quota_cookie")
|
||||
.bind("gpt-5")
|
||||
.bind(1)
|
||||
.bind(1_500)
|
||||
.bind(500)
|
||||
.bind(300)
|
||||
.bind("2027-01-15T04:00:00Z")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
SqliteCookieStore::new(pool.clone())
|
||||
.capture_set_cookie(
|
||||
"acct_quota_cookie",
|
||||
@@ -87,4 +115,16 @@ async fn admin_account_quota_should_send_usage_cookie() {
|
||||
body["data"]["quotaData"]["windows"][0]["group"],
|
||||
"shortTerm"
|
||||
);
|
||||
assert_eq!(
|
||||
body["data"]["quotaData"]["windows"][0]["tokenUsageDisplay"],
|
||||
"2K"
|
||||
);
|
||||
assert_eq!(
|
||||
body["data"]["quotaData"]["windows"][1]["labelDisplay"],
|
||||
"周限额"
|
||||
);
|
||||
assert_eq!(
|
||||
body["data"]["quotaData"]["windows"][1]["tokenUsageDisplay"],
|
||||
"0"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use codex_proxy_rs::{
|
||||
},
|
||||
infra::{
|
||||
database::connect_sqlite,
|
||||
time::{china_day_start, china_hour},
|
||||
time::{china_day_start, china_hour, china_hour_start},
|
||||
},
|
||||
proxy::dispatch::session_affinity::SqliteSessionAffinityStore,
|
||||
runtime::{
|
||||
@@ -155,7 +155,7 @@ async fn dashboard_trend_should_bucket_usage_by_china_hour() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = dashboard_trend(app).await;
|
||||
let body = dashboard_trend(app, "usage").await;
|
||||
let point = body["data"]["points"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
@@ -167,6 +167,78 @@ async fn dashboard_trend_should_bucket_usage_by_china_hour() {
|
||||
assert_eq!(point["tokensValue"], 10);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_latency_trend_should_use_first_token_latency() {
|
||||
let (app, store, _pool, _dir) = dashboard_test_app(
|
||||
"dashboard-trend-first-token-latency.sqlite",
|
||||
crate::support::fingerprint::test_fingerprint(),
|
||||
)
|
||||
.await;
|
||||
let now = Utc::now();
|
||||
let current_hour_start = china_hour_start(now);
|
||||
let sample_hour_start = if now - current_hour_start > Duration::minutes(31) {
|
||||
current_hour_start
|
||||
} else {
|
||||
current_hour_start - Duration::hours(1)
|
||||
};
|
||||
let mut slow_completion =
|
||||
usage_record_with_tokens(sample_hour_start + Duration::minutes(1), 10);
|
||||
slow_completion.latency_ms = Some(10_000);
|
||||
slow_completion.metadata["firstTokenMs"] = json!(100_i64);
|
||||
let mut fast_completion =
|
||||
usage_record_with_tokens(sample_hour_start + Duration::minutes(16), 10);
|
||||
fast_completion.latency_ms = Some(1_000);
|
||||
fast_completion.metadata["firstTokenMs"] = json!(500_i64);
|
||||
store.append(&slow_completion).await.unwrap();
|
||||
store.append(&fast_completion).await.unwrap();
|
||||
|
||||
let body = dashboard_trend(app, "latency").await;
|
||||
let point = body["data"]["points"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|point| point["requestsValue"] == 2)
|
||||
.expect("latency trend should include the inserted usage bucket");
|
||||
let summary = body["data"]["summary"].as_array().unwrap();
|
||||
|
||||
assert_eq!(body["data"]["kind"], "latency");
|
||||
assert_eq!(point["latencyValue"], 300);
|
||||
assert_eq!(point["latency"], "300 ms");
|
||||
assert_eq!(point["maxLatencyValue"], 500);
|
||||
assert_eq!(point["maxLatency"], "500 ms");
|
||||
assert_eq!(point["minLatencyValue"], 100);
|
||||
assert_eq!(point["minLatency"], "100 ms");
|
||||
assert_eq!(summary[0]["value"], "300 ms");
|
||||
assert_eq!(summary[1]["value"], "500 ms");
|
||||
assert_eq!(summary[2]["value"], "100 ms");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_return_requested_trend_kind() {
|
||||
let (app, store, _pool, _dir) = dashboard_test_app(
|
||||
"dashboard-summary-requested-trend-kind.sqlite",
|
||||
crate::support::fingerprint::test_fingerprint(),
|
||||
)
|
||||
.await;
|
||||
let now = Utc::now();
|
||||
let mut record = usage_record_with_tokens(now - Duration::seconds(1), 10);
|
||||
record.latency_ms = Some(10_000);
|
||||
record.metadata["firstTokenMs"] = json!(250_i64);
|
||||
store.append(&record).await.unwrap();
|
||||
|
||||
let body = dashboard_summary_with_kind(app, "latency").await;
|
||||
|
||||
assert_eq!(body["data"]["trend"]["kind"], "latency");
|
||||
assert!(
|
||||
body["data"]["trend"]["summary"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["value"] == "250 ms"),
|
||||
"summary should include first-token latency trend values: {body:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_calculate_priority_cost_from_event_service_tier() {
|
||||
let cost = dashboard_summary_total_cost_for_usage_record(
|
||||
@@ -408,11 +480,19 @@ async fn dashboard_test_app(
|
||||
}
|
||||
|
||||
async fn dashboard_summary(app: axum::Router) -> Value {
|
||||
dashboard_summary_uri(app, "/api/admin/dashboard/summary").await
|
||||
}
|
||||
|
||||
async fn dashboard_summary_with_kind(app: axum::Router, kind: &str) -> Value {
|
||||
dashboard_summary_uri(app, &format!("/api/admin/dashboard/summary?kind={kind}")).await
|
||||
}
|
||||
|
||||
async fn dashboard_summary_uri(app: axum::Router, uri: &str) -> Value {
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/admin/dashboard/summary")
|
||||
.uri(uri)
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", "req_dashboard_summary")
|
||||
.body(Body::empty())
|
||||
@@ -424,12 +504,12 @@ async fn dashboard_summary(app: axum::Router) -> Value {
|
||||
response_json(response).await
|
||||
}
|
||||
|
||||
async fn dashboard_trend(app: axum::Router) -> Value {
|
||||
async fn dashboard_trend(app: axum::Router, kind: &str) -> Value {
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/admin/dashboard/trend?kind=usage")
|
||||
.uri(format!("/api/admin/dashboard/trend?kind={kind}"))
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", "req_dashboard_trend")
|
||||
.body(Body::empty())
|
||||
|
||||
@@ -183,10 +183,10 @@ async fn admin_usage_records_should_return_table_display_fields() {
|
||||
assert_eq!(row["tokenDetails"]["reasoningTokens"], 12);
|
||||
assert_eq!(row["tokenDetails"]["totalTokens"], 1200);
|
||||
assert_eq!(row["tokenDetails"]["inputTokensDisplay"], "1,000");
|
||||
assert_eq!(row["costDetails"]["inputCostDisplay"], "$0.004500");
|
||||
assert_eq!(row["costDetails"]["outputCostDisplay"], "$0.006000");
|
||||
assert_eq!(row["costDetails"]["cacheReadCostDisplay"], "$0.000050");
|
||||
assert_eq!(row["costDetails"]["totalCostDisplay"], "$0.010550");
|
||||
assert_eq!(row["costDetails"]["inputCostDisplay"], "$0.00");
|
||||
assert_eq!(row["costDetails"]["outputCostDisplay"], "$0.01");
|
||||
assert_eq!(row["costDetails"]["cacheReadCostDisplay"], "$0.00");
|
||||
assert_eq!(row["costDetails"]["totalCostDisplay"], "$0.01");
|
||||
assert_eq!(
|
||||
row["costDetails"]["inputPriceDisplay"],
|
||||
"$5.0000 / 1M Token"
|
||||
@@ -343,7 +343,7 @@ async fn admin_usage_record_insight_cards_should_aggregate_filtered_usage_dimens
|
||||
assert_eq!(requested_body["data"][0]["totalTokensValue"], 42);
|
||||
assert_eq!(requested_body["data"][0]["totalTokensTotal"], "48");
|
||||
assert_eq!(requested_body["data"][0]["totalTokensTotalValue"], 48);
|
||||
assert_eq!(requested_body["data"][0]["actualCost"], "$0.000483");
|
||||
assert_eq!(requested_body["data"][0]["actualCost"], "$0.00");
|
||||
|
||||
let upstream_models = app
|
||||
.clone()
|
||||
@@ -422,8 +422,8 @@ async fn admin_usage_record_insight_cards_should_aggregate_filtered_usage_dimens
|
||||
assert_eq!(token_body["data"][0]["outputTokens"], "13");
|
||||
assert_eq!(token_body["data"][0]["cacheCreationTokens"], "0");
|
||||
assert_eq!(token_body["data"][0]["cachedTokens"], "6");
|
||||
assert_eq!(token_body["data"][0]["actualCost"], "$0.000538");
|
||||
assert_eq!(token_body["data"][0]["cost"], "$0.000538");
|
||||
assert_eq!(token_body["data"][0]["actualCost"], "$0.00");
|
||||
assert_eq!(token_body["data"][0]["cost"], "$0.00");
|
||||
|
||||
let latency_trend = app
|
||||
.oneshot(
|
||||
|
||||
@@ -384,6 +384,37 @@ async fn request_context_should_accept_real_ip_from_trusted_proxy_when_forwarded
|
||||
assert_eq!(std::str::from_utf8(&bytes).unwrap(), "198.51.100.23");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_context_should_prefer_real_ip_over_forwarded_for_from_trusted_proxy() {
|
||||
let trusted_proxies =
|
||||
TrustedProxyConfig::from_entries(&["127.0.0.1".to_string()]).expect("trusted proxy");
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/ip",
|
||||
get(|Extension(client_ip): Extension<ClientIp>| async move {
|
||||
client_ip.as_str().to_string()
|
||||
}),
|
||||
)
|
||||
.layer(from_fn_with_state(
|
||||
trusted_proxies,
|
||||
attach_request_id_with_proxy_config,
|
||||
));
|
||||
let mut request = Request::builder()
|
||||
.uri("/ip")
|
||||
.header("x-forwarded-for", "198.51.100.99, 203.0.113.42")
|
||||
.header("x-real-ip", "203.0.113.42")
|
||||
.body(Body::empty())
|
||||
.expect("request should build");
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 61234))));
|
||||
|
||||
let response = app.oneshot(request).await.expect("response");
|
||||
let bytes = to_bytes(response.into_body(), 1024).await.unwrap();
|
||||
|
||||
assert_eq!(std::str::from_utf8(&bytes).unwrap(), "203.0.113.42");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_context_should_ignore_forwarded_ip_from_untrusted_peer() {
|
||||
let trusted_proxies =
|
||||
|
||||
@@ -29,7 +29,8 @@ use codex_proxy_rs::{
|
||||
runtime::services::{BackgroundTaskStores, Services, UsageRecordOptions},
|
||||
runtime::state::AppState,
|
||||
upstream::accounts::{
|
||||
cookies::SqliteCookieStore, store::SqliteAccountStore, token_refresh::RefreshLeaseStore,
|
||||
cookies::SqliteCookieStore, model::AccountStatus, store::SqliteAccountStore,
|
||||
token_refresh::RefreshLeaseStore,
|
||||
},
|
||||
upstream::fingerprint::FingerprintRepository,
|
||||
};
|
||||
@@ -549,6 +550,19 @@ async fn test_app_with_two_accounts_and_affinity_status(
|
||||
async fn test_app_with_two_accounts(
|
||||
base_url: String,
|
||||
) -> (axum::Router, String, SqlitePool, tempfile::TempDir) {
|
||||
let (app, _state, api_key, pool, dir) = test_app_with_two_accounts_and_state(base_url).await;
|
||||
(app, api_key, pool, dir)
|
||||
}
|
||||
|
||||
async fn test_app_with_two_accounts_and_state(
|
||||
base_url: String,
|
||||
) -> (
|
||||
axum::Router,
|
||||
AppState,
|
||||
String,
|
||||
SqlitePool,
|
||||
tempfile::TempDir,
|
||||
) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join("openai-responses-fallback.sqlite");
|
||||
let url = format!("sqlite://{}", db.display());
|
||||
@@ -574,7 +588,13 @@ async fn test_app_with_two_accounts(
|
||||
.restore_from_repository()
|
||||
.await
|
||||
.expect("account pool should restore");
|
||||
(router::router().with_state(state), api_key, pool, dir)
|
||||
(
|
||||
router::router().with_state(state.clone()),
|
||||
state,
|
||||
api_key,
|
||||
pool,
|
||||
dir,
|
||||
)
|
||||
}
|
||||
|
||||
async fn seed_openai_admin_session(pool: &SqlitePool, session_id: &str) {
|
||||
|
||||
@@ -781,15 +781,37 @@ async fn responses_websocket_should_implicitly_resume_full_history_with_reasonin
|
||||
|
||||
#[tokio::test]
|
||||
async fn responses_websocket_should_strip_implicit_disabled_affinity_when_switching_accounts() {
|
||||
responses_websocket_should_strip_implicit_affinity_when_switching_accounts(
|
||||
AccountStatus::Disabled,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn responses_websocket_should_strip_implicit_banned_affinity_when_switching_accounts() {
|
||||
responses_websocket_should_strip_implicit_affinity_when_switching_accounts(
|
||||
AccountStatus::Banned,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn responses_websocket_should_strip_implicit_affinity_when_switching_accounts(
|
||||
status: AccountStatus,
|
||||
) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let status_name = status.as_str();
|
||||
let first_response_id = format!("resp_implicit_{status_name}_first");
|
||||
let second_response_id = format!("resp_implicit_{status_name}_second");
|
||||
let upstream_first_response_id = first_response_id.clone();
|
||||
let upstream_second_response_id = second_response_id.clone();
|
||||
let upstream = tokio::spawn(async move {
|
||||
let (first_stream, _) = listener.accept().await.unwrap();
|
||||
let mut first_websocket =
|
||||
accept_websocket_with_authorization(first_stream, "Bearer access-primary").await;
|
||||
let first_payload = send_websocket_response_and_capture_payload(
|
||||
&mut first_websocket,
|
||||
websocket_completed_response("resp_implicit_ban_first", 4, 1),
|
||||
websocket_completed_response(&upstream_first_response_id, 4, 1),
|
||||
)
|
||||
.await;
|
||||
first_websocket.close(None).await.unwrap();
|
||||
@@ -804,7 +826,7 @@ async fn responses_websocket_should_strip_implicit_disabled_affinity_when_switch
|
||||
.unwrap();
|
||||
let second_payload = send_websocket_response_and_capture_payload(
|
||||
&mut second_websocket,
|
||||
websocket_completed_response("resp_implicit_ban_second", 3, 1),
|
||||
websocket_completed_response(&upstream_second_response_id, 3, 1),
|
||||
)
|
||||
.await;
|
||||
second_websocket.close(None).await.unwrap();
|
||||
@@ -812,8 +834,7 @@ async fn responses_websocket_should_strip_implicit_disabled_affinity_when_switch
|
||||
|
||||
(first_payload, second_payload, captured_second_headers)
|
||||
});
|
||||
let (app, api_key, pool, _dir) = test_app_with_two_accounts(base_url).await;
|
||||
seed_openai_admin_session(&pool, "session_status_cycle").await;
|
||||
let (app, state, api_key, pool, _dir) = test_app_with_two_accounts_and_state(base_url).await;
|
||||
|
||||
let first_response = app
|
||||
.clone()
|
||||
@@ -831,9 +852,15 @@ async fn responses_websocket_should_strip_implicit_disabled_affinity_when_switch
|
||||
assert_eq!(first_response.status(), StatusCode::OK);
|
||||
assert!(response_text(first_response)
|
||||
.await
|
||||
.contains("\"id\":\"resp_implicit_ban_first\""));
|
||||
.contains(&format!("\"id\":\"{first_response_id}\"")));
|
||||
|
||||
update_admin_account_status(&app, "acct_primary", "disabled").await;
|
||||
assert!(
|
||||
state
|
||||
.services
|
||||
.account_pool
|
||||
.set_status("acct_primary", status)
|
||||
.await
|
||||
);
|
||||
|
||||
let second_response = app
|
||||
.oneshot(responses_json_request(
|
||||
@@ -854,11 +881,11 @@ async fn responses_websocket_should_strip_implicit_disabled_affinity_when_switch
|
||||
assert_eq!(second_response.status(), StatusCode::OK);
|
||||
assert!(response_text(second_response)
|
||||
.await
|
||||
.contains("\"id\":\"resp_implicit_ban_second\""));
|
||||
.contains(&format!("\"id\":\"{second_response_id}\"")));
|
||||
let (first_payload, second_payload, second_headers) = upstream.await.unwrap();
|
||||
let affinity_count: (i64,) =
|
||||
sqlx::query_as("select count(*) from session_affinities where response_id = ?")
|
||||
.bind("resp_implicit_ban_first")
|
||||
.bind(&first_response_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -3,10 +3,12 @@ use codex_proxy_rs::{
|
||||
infra::database::connect_sqlite,
|
||||
upstream::accounts::{
|
||||
model::{AccountStatus, AccountUsageDelta},
|
||||
quota::{quota_snapshot_limit_reached, quota_snapshot_reset_at},
|
||||
store::{AccountClaimsUpdate, AccountStore, NewAccount, SqliteAccountStore},
|
||||
},
|
||||
};
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use serde_json::json;
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_repository_should_store_plain_tokens_and_load_secret_wrappers() {
|
||||
@@ -518,6 +520,48 @@ async fn account_repository_should_map_quota_snapshot_to_account_status() {
|
||||
assert!(restored_status.2.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_repository_should_mark_quota_exhausted_for_any_exhausted_window() {
|
||||
let (pool, _dir) = sqlite_account_store_parts("accounts.sqlite", 47).await;
|
||||
let repo = SqliteAccountStore::new(pool.clone());
|
||||
seed_repo_account(&pool, "acct_a", "2026-06-11T00:00:00Z").await;
|
||||
let quota = json!({
|
||||
"plan_type": "plus",
|
||||
"snapshots": [{
|
||||
"source": "additional",
|
||||
"blocked": false,
|
||||
"allowed": true,
|
||||
"primary": {
|
||||
"used_percent": 100,
|
||||
"remaining_percent": 0,
|
||||
"reset_at": 1_893_456_300,
|
||||
"window_minutes": 300,
|
||||
"limit_reached": false
|
||||
},
|
||||
"secondary": null
|
||||
}]
|
||||
});
|
||||
let limit_reached = quota_snapshot_limit_reached("a);
|
||||
let cooldown_until = quota_snapshot_reset_at("a);
|
||||
|
||||
let updated = repo
|
||||
.apply_quota_snapshot("acct_a", "a.to_string(), limit_reached, cooldown_until)
|
||||
.await
|
||||
.unwrap();
|
||||
let status: (String, i64, Option<String>) = sqlx::query_as(
|
||||
"select status, quota_limit_reached, quota_cooldown_until from accounts where id = ?",
|
||||
)
|
||||
.bind("acct_a")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
(updated, status.0, status.1, status.2.is_some()),
|
||||
(true, "quota_exhausted".to_string(), 1, true)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_repository_runtime_state_sync_should_not_clear_newer_future_quota_cooldown() {
|
||||
let (pool, _dir) = sqlite_account_store_parts("accounts.sqlite", 42).await;
|
||||
|
||||
@@ -227,6 +227,53 @@ fn quota_snapshot_limit_reached_should_block_explicit_credit_overage_limit() {
|
||||
assert!(quota_snapshot_limit_reached("a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quota_snapshot_limit_reached_should_block_any_exhausted_window() {
|
||||
let quota = json!({
|
||||
"snapshots": [{
|
||||
"source": "additional",
|
||||
"blocked": false,
|
||||
"allowed": true,
|
||||
"primary": {
|
||||
"used_percent": 100,
|
||||
"remaining_percent": 0,
|
||||
"reset_at": 1_893_456_300,
|
||||
"window_minutes": 300,
|
||||
"limit_reached": false
|
||||
},
|
||||
"secondary": null
|
||||
}]
|
||||
});
|
||||
|
||||
assert!(quota_snapshot_limit_reached("a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quota_snapshot_window_should_follow_blocking_window() {
|
||||
let quota = json!({
|
||||
"snapshots": [{
|
||||
"source": "additional",
|
||||
"blocked": false,
|
||||
"primary": {
|
||||
"used_percent": 100,
|
||||
"remaining_percent": 0,
|
||||
"reset_at": 1_893_456_300,
|
||||
"window_minutes": 10_080,
|
||||
"limit_reached": false
|
||||
},
|
||||
"secondary": null
|
||||
}]
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
quota_snapshot_reset_at("a).map(|reset_at| reset_at.timestamp()),
|
||||
quota_snapshot_limit_window_seconds("a),
|
||||
),
|
||||
(Some(1_893_456_300), Some(604_800))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_refresh_service_should_send_usage_cookie_when_cookie_store_is_configured() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
@@ -344,6 +344,26 @@ fn rate_limit_quota_should_preserve_existing_monthly_and_credits_when_passive_da
|
||||
assert_eq!(quota["credits"]["balance"], 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_quota_should_block_when_window_is_exhausted_even_if_flag_is_false() {
|
||||
let event = json!({
|
||||
"type": "codex.rate_limits",
|
||||
"rate_limits": {
|
||||
"limit_reached": false,
|
||||
"primary": {
|
||||
"used_percent": 100,
|
||||
"window_minutes": 300,
|
||||
"reset_at": 1893456300
|
||||
}
|
||||
}
|
||||
});
|
||||
let parsed = parse_rate_limits_event(&event).expect("event should parse");
|
||||
|
||||
let quota = rate_limit_quota(&parsed, Some("plus"), None);
|
||||
|
||||
assert_eq!(quota["snapshots"][0]["blocked"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_scheduler_should_refresh_before_expiry_and_preserve_refresh_token() {
|
||||
use async_trait::async_trait;
|
||||
|
||||
@@ -74,6 +74,87 @@ async fn codex_backend_client_should_reuse_pooled_websocket_for_same_account_and
|
||||
assert_eq!(accepted_connections.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
/// idle 连接被上游静默关闭后,后台 pump 会实时把它标记为 closed。
|
||||
/// 复用前的零成本 `is_closed` 检查应直接丢弃它并新建连接,不经过
|
||||
/// “发请求 → 等首帧超时 → stale-reuse 重试” 的长尾(无需任何 maintenance sweep)。
|
||||
#[tokio::test]
|
||||
async fn codex_backend_client_should_open_fresh_socket_when_idle_pooled_websocket_died_silently() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let accepted_connections = Arc::new(AtomicUsize::new(0));
|
||||
let accepted_connections_for_server = Arc::clone(&accepted_connections);
|
||||
let server = tokio::spawn(async move {
|
||||
// 第一条连接:完成一次响应后由服务端主动关闭(模拟 idle 期间被上游/中间盒断开)。
|
||||
let (first_stream, _) = listener.accept().await.unwrap();
|
||||
accepted_connections_for_server.fetch_add(1, Ordering::SeqCst);
|
||||
let mut first_websocket = accept_codex_test_websocket(first_stream).await;
|
||||
let _first_message = first_websocket.next().await.unwrap().unwrap();
|
||||
first_websocket
|
||||
.send(Message::Text(
|
||||
completed_websocket_response("resp_silent_first", 3, 1).into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
first_websocket.close(None).await.unwrap();
|
||||
|
||||
// 第二条连接:证明复用被跳过、直接新建。
|
||||
let (second_stream, _) = listener.accept().await.unwrap();
|
||||
accepted_connections_for_server.fetch_add(1, Ordering::SeqCst);
|
||||
let mut second_websocket = accept_codex_test_websocket(second_stream).await;
|
||||
let _second_message = second_websocket.next().await.unwrap().unwrap();
|
||||
second_websocket
|
||||
.send(Message::Text(
|
||||
completed_websocket_response("resp_silent_second", 4, 1).into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
second_websocket.close(None).await.unwrap();
|
||||
});
|
||||
// 无 maintenance、无主动 ping:完全依赖 pump 后台读取感知连接死亡。
|
||||
let pool = Arc::new(CodexWebSocketPool::with_config(
|
||||
websocket_pool_config_for_tests(None, None, None),
|
||||
));
|
||||
let backend = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
format!("http://{addr}"),
|
||||
crate::support::fingerprint::test_fingerprint(),
|
||||
)
|
||||
.with_websocket_pool(Arc::clone(&pool));
|
||||
let mut request =
|
||||
codex_proxy_rs::upstream::protocol::responses::CodexResponsesRequest::new_http_sse(
|
||||
"gpt-5.5",
|
||||
"be brief",
|
||||
Vec::new(),
|
||||
);
|
||||
request.set_previous_response_id(Some("resp_previous".to_string()));
|
||||
request.set_prompt_cache_key(Some("conversation-pool-silent".to_string()));
|
||||
|
||||
let first = backend
|
||||
.create_response(
|
||||
&request,
|
||||
request_context("req_pool_silent_first", Some("chatgpt-account")),
|
||||
)
|
||||
.await
|
||||
.expect("first pooled websocket response should succeed");
|
||||
// 给后台 pump 一点时间观察到服务端的 close 帧并标记连接死亡。
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
let second = backend
|
||||
.create_response(
|
||||
&request,
|
||||
request_context("req_pool_silent_second", Some("chatgpt-account")),
|
||||
)
|
||||
.await
|
||||
.expect("second websocket response should open a fresh socket");
|
||||
server.await.unwrap();
|
||||
|
||||
assert!(first.body.contains("resp_silent_first"));
|
||||
assert!(second.body.contains("resp_silent_second"));
|
||||
// 死连接在 acquire 处被零成本识别 → 直接新建,而非 stale-reuse 重试。
|
||||
assert_eq!(first.websocket_pool_decision.unwrap().kind(), "new");
|
||||
assert_eq!(second.websocket_pool_decision.unwrap().kind(), "new");
|
||||
assert_eq!(accepted_connections.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_backend_client_should_not_reuse_pooled_websocket_across_local_accounts() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
@@ -246,6 +327,92 @@ async fn websocket_pool_should_bypass_busy_key_with_one_shot_connections() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_pool_should_release_slot_when_client_drops_stream() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
// 第一个连接:发一帧后保持沉默(模拟上游不再发帧、也不发 terminal)。
|
||||
// slot 只能靠客户端断开来释放,隔离验证 tx.closed() 机制。
|
||||
let (first_stream, _) = listener.accept().await.unwrap();
|
||||
let mut first_websocket = accept_codex_test_websocket(first_stream).await;
|
||||
let _first_message = first_websocket.next().await.unwrap().unwrap();
|
||||
first_websocket
|
||||
.send(Message::Text(
|
||||
json!({
|
||||
"type": "response.output_text.delta",
|
||||
"delta": "streaming has begun"
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 第二个连接:客户端断开释放 slot 后,同 key 请求应新建连接。
|
||||
let (second_stream, _) = listener.accept().await.unwrap();
|
||||
let mut second_websocket = accept_codex_test_websocket(second_stream).await;
|
||||
let _second_message = second_websocket.next().await.unwrap().unwrap();
|
||||
second_websocket
|
||||
.send(Message::Text(
|
||||
completed_websocket_response("resp_released_second", 2, 1).into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
second_websocket.close(None).await.unwrap();
|
||||
drop(first_websocket);
|
||||
});
|
||||
let pool = Arc::new(CodexWebSocketPool::default());
|
||||
let backend = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
format!("http://{addr}"),
|
||||
crate::support::fingerprint::test_fingerprint(),
|
||||
)
|
||||
.with_websocket_pool(pool);
|
||||
let request = pooled_websocket_request("conversation-drop");
|
||||
|
||||
// 起流式请求:slot 变 Busy。
|
||||
let mut stream = backend
|
||||
.create_response_stream(
|
||||
&request,
|
||||
request_context("req_drop_first", Some("chatgpt-account")),
|
||||
)
|
||||
.await
|
||||
.expect("first pooled websocket stream should start")
|
||||
.body;
|
||||
let first_chunk = stream
|
||||
.next()
|
||||
.await
|
||||
.expect("stream should yield an initial chunk")
|
||||
.expect("stream chunk should be valid");
|
||||
assert!(std::str::from_utf8(&first_chunk)
|
||||
.unwrap()
|
||||
.contains("streaming has begun"));
|
||||
|
||||
// 客户端断开:drop stream → rx 被 drop → tx.closed() 完成 →
|
||||
// 代理丢弃上游连接并释放 slot(不再等 idle 超时)。
|
||||
drop(stream);
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// 同 key 的后续请求:slot 已释放 → 新建连接(new),而非 bypass(busy)。
|
||||
let second = backend
|
||||
.create_response(
|
||||
&request,
|
||||
request_context("req_drop_second", Some("chatgpt-account")),
|
||||
)
|
||||
.await
|
||||
.expect("second request should succeed after slot release");
|
||||
server.await.unwrap();
|
||||
|
||||
assert!(second.body.contains("resp_released_second"));
|
||||
let decision = second.websocket_pool_decision.unwrap();
|
||||
assert_eq!(
|
||||
decision.kind(),
|
||||
"new",
|
||||
"client-drop must release the pool slot so the next same-key request builds a fresh connection instead of bypassing as busy"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_pool_should_bypass_new_keys_after_account_cap() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
@@ -317,112 +484,10 @@ async fn websocket_pool_should_bypass_new_keys_after_account_cap() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_backend_client_should_ping_idle_pooled_websocket_during_maintenance() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let ping_count = Arc::new(AtomicUsize::new(0));
|
||||
let ping_count_for_server = Arc::clone(&ping_count);
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut websocket = accept_codex_test_websocket(stream).await;
|
||||
let _first_message = websocket.next().await.unwrap().unwrap();
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
json!({
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "resp_pool_keepalive_first",
|
||||
"object": "response",
|
||||
"output": [],
|
||||
"usage": {
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 1,
|
||||
"total_tokens": 4
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let ping = timeout(Duration::from_secs(1), websocket.next())
|
||||
.await
|
||||
.expect("pool maintenance should probe the idle websocket")
|
||||
.expect("pool maintenance should send a websocket frame")
|
||||
.expect("pool maintenance frame should be valid");
|
||||
let Message::Ping(payload) = ping else {
|
||||
panic!("expected pool maintenance ping frame, got {ping:?}");
|
||||
};
|
||||
ping_count_for_server.fetch_add(1, Ordering::SeqCst);
|
||||
websocket.send(Message::Pong(payload)).await.unwrap();
|
||||
|
||||
let _second_message = websocket.next().await.unwrap().unwrap();
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
json!({
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "resp_pool_keepalive_second",
|
||||
"object": "response",
|
||||
"output": [],
|
||||
"usage": {
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 1,
|
||||
"total_tokens": 4
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
websocket.close(None).await.unwrap();
|
||||
});
|
||||
let pool = Arc::new(CodexWebSocketPool::with_config(
|
||||
websocket_pool_config_for_tests(None, Some(Duration::from_millis(1)), None),
|
||||
));
|
||||
let backend = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
format!("http://{addr}"),
|
||||
crate::support::fingerprint::test_fingerprint(),
|
||||
)
|
||||
.with_websocket_pool(Arc::clone(&pool));
|
||||
let mut request =
|
||||
codex_proxy_rs::upstream::protocol::responses::CodexResponsesRequest::new_http_sse(
|
||||
"gpt-5.5",
|
||||
"be brief",
|
||||
Vec::new(),
|
||||
);
|
||||
request.set_previous_response_id(Some("resp_previous".to_string()));
|
||||
request.set_prompt_cache_key(Some("conversation-pool-keepalive".to_string()));
|
||||
|
||||
let first = backend
|
||||
.create_response(
|
||||
&request,
|
||||
request_context("req_pool_keepalive_first", Some("chatgpt-account")),
|
||||
)
|
||||
.await
|
||||
.expect("first pooled websocket response should succeed");
|
||||
pool.maintain_idle_connections().await;
|
||||
let second = backend
|
||||
.create_response(
|
||||
&request,
|
||||
request_context("req_pool_keepalive_second", Some("chatgpt-account")),
|
||||
)
|
||||
.await
|
||||
.expect("second pooled websocket response should reuse the probed socket");
|
||||
server.await.unwrap();
|
||||
|
||||
assert!(first.body.contains("resp_pool_keepalive_first"));
|
||||
assert!(second.body.contains("resp_pool_keepalive_second"));
|
||||
assert_eq!(ping_count.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_pool_should_evict_idle_connection_when_ping_times_out() {
|
||||
async fn websocket_pool_should_evict_idle_connection_when_liveness_lapses_despite_pings() {
|
||||
// 不变量:pump 自己发出的 keepalive ping 不算“入站活动”,因此一个只收 ping、
|
||||
// 从不回 pong / 从不发帧的静默连接,仍会被 liveness watchdog 判定失活并驱逐;
|
||||
// 复用前 acquire 读到 is_closed 即开新连接。
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let accepted_connections = Arc::new(AtomicUsize::new(0));
|
||||
@@ -438,6 +503,7 @@ async fn websocket_pool_should_evict_idle_connection_when_ping_times_out() {
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
// 故意不再读取该连接:既不回 pong,也不发任何帧,模拟静默失活。
|
||||
let (second_stream, _) = listener.accept().await.unwrap();
|
||||
accepted_connections_for_server.fetch_add(1, Ordering::SeqCst);
|
||||
let mut second_websocket = accept_codex_test_websocket(second_stream).await;
|
||||
@@ -453,7 +519,7 @@ async fn websocket_pool_should_evict_idle_connection_when_ping_times_out() {
|
||||
});
|
||||
let pool = Arc::new(CodexWebSocketPool::with_config(CodexWebSocketPoolConfig {
|
||||
ping_interval: Some(Duration::from_millis(1)),
|
||||
ping_timeout: Duration::from_millis(20),
|
||||
liveness_timeout: Some(Duration::from_millis(20)),
|
||||
maintenance_interval: None,
|
||||
..websocket_pool_config_for_tests(None, None, None)
|
||||
}));
|
||||
@@ -472,7 +538,8 @@ async fn websocket_pool_should_evict_idle_connection_when_ping_times_out() {
|
||||
)
|
||||
.await
|
||||
.expect("first websocket response should succeed");
|
||||
pool.maintain_idle_connections().await;
|
||||
// 等待 pump 的 liveness watchdog 判定静默连接失活。
|
||||
tokio::time::sleep(Duration::from_millis(60)).await;
|
||||
let second = backend
|
||||
.create_response(
|
||||
&request,
|
||||
@@ -562,7 +629,7 @@ async fn websocket_pool_should_gc_expired_idle_connections() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_backend_client_should_ping_idle_pooled_websocket_from_background_maintenance() {
|
||||
async fn codex_backend_client_should_keep_idle_pooled_websocket_alive_across_repeated_pings() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let ping_count = Arc::new(AtomicUsize::new(0));
|
||||
@@ -592,18 +659,23 @@ async fn codex_backend_client_should_ping_idle_pooled_websocket_from_background_
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let ping = timeout(Duration::from_secs(1), websocket.next())
|
||||
.await
|
||||
.expect("background maintenance should probe the idle websocket")
|
||||
.expect("background maintenance should send a websocket frame")
|
||||
.expect("background maintenance frame should be valid");
|
||||
let Message::Ping(payload) = ping else {
|
||||
panic!("expected background maintenance ping frame, got {ping:?}");
|
||||
};
|
||||
ping_count_for_server.fetch_add(1, Ordering::SeqCst);
|
||||
websocket.send(Message::Pong(payload)).await.unwrap();
|
||||
|
||||
let _second_message = websocket.next().await.unwrap().unwrap();
|
||||
// pump 会在 idle 期间反复发送 keepalive ping;服务端计数并回 pong,
|
||||
// 直到下一个业务请求(response.create)到达为止。
|
||||
loop {
|
||||
let message = timeout(Duration::from_secs(1), websocket.next())
|
||||
.await
|
||||
.expect("pump keepalive / second request should arrive")
|
||||
.expect("frame should be present")
|
||||
.expect("frame should be valid");
|
||||
match message {
|
||||
Message::Ping(payload) => {
|
||||
ping_count_for_server.fetch_add(1, Ordering::SeqCst);
|
||||
websocket.send(Message::Pong(payload)).await.unwrap();
|
||||
}
|
||||
Message::Text(_) => break,
|
||||
other => panic!("unexpected frame while idle: {other:?}"),
|
||||
}
|
||||
}
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
json!({
|
||||
@@ -627,11 +699,7 @@ async fn codex_backend_client_should_ping_idle_pooled_websocket_from_background_
|
||||
websocket.close(None).await.unwrap();
|
||||
});
|
||||
let pool = Arc::new(CodexWebSocketPool::with_config(
|
||||
websocket_pool_config_for_tests(
|
||||
Some(Duration::from_millis(20)),
|
||||
Some(Duration::from_mins(1)),
|
||||
None,
|
||||
),
|
||||
websocket_pool_config_for_tests(None, Some(Duration::from_millis(10)), None),
|
||||
));
|
||||
let backend = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
@@ -655,6 +723,7 @@ async fn codex_backend_client_should_ping_idle_pooled_websocket_from_background_
|
||||
)
|
||||
.await
|
||||
.expect("first pooled websocket response should succeed");
|
||||
// 让 pump 有时间发出多轮 keepalive ping。
|
||||
tokio::time::sleep(Duration::from_millis(80)).await;
|
||||
let second = backend
|
||||
.create_response(
|
||||
@@ -662,13 +731,13 @@ async fn codex_backend_client_should_ping_idle_pooled_websocket_from_background_
|
||||
request_context("req_pool_background_second", Some("chatgpt-account")),
|
||||
)
|
||||
.await
|
||||
.expect("second pooled websocket response should reuse the background-probed socket");
|
||||
.expect("second pooled websocket response should reuse the kept-alive socket");
|
||||
server.await.unwrap();
|
||||
pool.shutdown().await;
|
||||
|
||||
assert!(first.body.contains("resp_pool_background_first"));
|
||||
assert!(second.body.contains("resp_pool_background_second"));
|
||||
assert_eq!(ping_count.load(Ordering::SeqCst), 1);
|
||||
assert!(ping_count.load(Ordering::SeqCst) >= 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+3
-1
@@ -18,7 +18,9 @@ cp deploy/config.example.yaml .runtime/config.yaml
|
||||
- `.runtime/logs` -> `/app/logs`
|
||||
|
||||
复制后必须设置 `admin.default_password`,空值或常见弱口令会被后端拒绝启动。
|
||||
如果前面还有 Nginx、Caddy、Cloudflare Tunnel 等反向代理,只在该代理会覆盖或清理 `X-Forwarded-For` / `X-Real-IP` / `CF-Connecting-IP` 时,把代理的 IP 或 CIDR 写入 `server.trusted_proxies`;默认空列表会剥离这些客户端可伪造的头并使用 TCP peer IP。
|
||||
如果前面还有 Nginx、Caddy、Cloudflare Tunnel 等反向代理,建议把后端实际看到的代理 peer IP 或 CIDR 写入 `server.trusted_proxies`。Docker 端口映射经宿主反代访问时,这个 peer 通常是 Compose 网络网关,例如 `172.18.0.1/32`,不是宿主 `127.0.0.1/32`。
|
||||
|
||||
`server.trusted_proxies` 非空时,只有这些 peer 的 `CF-Connecting-IP` / `X-Real-IP` / `X-Forwarded-For` 会被采信;为空时进入兼容自动模式,按同一组头解析真实 IP。
|
||||
|
||||
构建并启动:
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ server:
|
||||
host: '0.0.0.0'
|
||||
# 容器内服务端口,需与 docker-compose.yml 的 target port 一致。
|
||||
port: 8080
|
||||
# 可信反向代理 IP 或 CIDR。为空时会忽略并剥离客户端传入的转发 IP 头。
|
||||
# 可信反向代理 IP 或 CIDR。非空时只有这些 peer 的转发 IP 头会被采信。
|
||||
# Docker 端口映射经宿主反代访问时,后端看到的 peer 通常是 Compose 网络网关
|
||||
# (例如 172.18.0.1),不是宿主 127.0.0.1。
|
||||
# 为空时进入兼容自动模式,按 CF-Connecting-IP / X-Real-IP / X-Forwarded-For 解析。
|
||||
trusted_proxies: []
|
||||
|
||||
api:
|
||||
|
||||
+4
-1
@@ -2,7 +2,10 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Codex Proxy RS</title>
|
||||
</head>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import request from '../request'
|
||||
|
||||
export function getDashboardSummary() {
|
||||
export function getDashboardSummary(params?: any) {
|
||||
return request({
|
||||
url: '/api/admin/dashboard/summary',
|
||||
method: 'GET',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ const props = defineProps<{
|
||||
scrollable?: boolean
|
||||
bodyMaxHeight?: string
|
||||
bodyViewClass?: string
|
||||
hideFooter?: boolean
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
@@ -123,7 +124,7 @@ function closeModal() {
|
||||
</BaseScrollbar>
|
||||
<slot v-else />
|
||||
</div>
|
||||
<footer class="flex justify-end gap-3 px-7 pb-7">
|
||||
<footer v-if="!hideFooter" class="flex justify-end gap-3 px-7 pb-7">
|
||||
<slot name="footer">
|
||||
<BaseButton variant="default" @click="open = false">取消</BaseButton>
|
||||
<BaseButton variant="primary" @click="open = false">确认</BaseButton>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { clamp } from 'es-toolkit'
|
||||
import { computed } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
interface SegmentedOption {
|
||||
label: string
|
||||
value: string
|
||||
icon?: Component
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -54,7 +56,7 @@ function selectOption(value: string) {
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="relative z-10 h-7 min-w-0 rounded-(--cp-input-radius-base) border-0 bg-transparent px-3 text-xs leading-none font-[650] transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
|
||||
class="relative z-10 inline-flex h-7 min-w-0 items-center justify-center gap-1.5 rounded-(--cp-input-radius-base) border-0 bg-transparent px-3 text-xs leading-none font-[650] transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
|
||||
:class="[
|
||||
model === option.value
|
||||
? 'text-(--cp-text-primary)'
|
||||
@@ -67,6 +69,7 @@ function selectOption(value: string) {
|
||||
:disabled="disabled"
|
||||
@click="selectOption(option.value)"
|
||||
>
|
||||
<component :is="option.icon" v-if="option.icon" class="size-3.5 shrink-0" />
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,6 @@ export function resolveColumns(columns: BaseTableColumn[], minWidth?: number | s
|
||||
const minWidthPixels = minWidth === undefined ? 0 : numericPixelWidth(minWidth)
|
||||
const flexibleColumns = columns.filter((column) => column.width === undefined)
|
||||
const flexTotal = sumBy(flexibleColumns, (column) => column.flex ?? 1)
|
||||
const flexibleColumnCount = clamp(flexibleColumns.length, 1, Number.POSITIVE_INFINITY)
|
||||
const available = clamp(100 - fixedPercentTotal, 0, Number.POSITIVE_INFINITY)
|
||||
const availablePixels = clamp(minWidthPixels - fixedPixelTotal, 0, Number.POSITIVE_INFINITY)
|
||||
|
||||
@@ -41,10 +40,12 @@ export function resolveColumns(columns: BaseTableColumn[], minWidth?: number | s
|
||||
const width =
|
||||
column.width === undefined
|
||||
? minWidthPixels > 0 && fixedPercentTotal === 0
|
||||
? `${availablePixels / flexibleColumnCount}px`
|
||||
? flexTotal > 0
|
||||
? `${(availablePixels * flex) / flexTotal}px`
|
||||
: `${availablePixels / clamp(flexibleColumns.length, 1, Number.POSITIVE_INFINITY)}px`
|
||||
: flexTotal > 0
|
||||
? `${(available * flex) / flexTotal}%`
|
||||
: `${available / flexibleColumnCount}%`
|
||||
: `${available / clamp(flexibleColumns.length, 1, Number.POSITIVE_INFINITY)}%`
|
||||
: normalizeWidth(column.width)
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { ExternalLink, GitBranch } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import BaseModal from '@/components/base/BaseModal.vue'
|
||||
import { useSystemUpdate } from '@/composables/useSystemUpdate'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
|
||||
const { version } = useSystemUpdate()
|
||||
|
||||
const author = 'Zyy'
|
||||
const githubUrl = 'https://github.com/zyycn/codex-proxy-rs'
|
||||
|
||||
function normalizeBuildValue(value: string | undefined) {
|
||||
const normalized = value?.trim()
|
||||
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return ['unknown', 'null'].includes(normalized.toLowerCase()) ? '' : normalized
|
||||
}
|
||||
|
||||
const versionDisplay = computed(() => {
|
||||
const versionText = normalizeBuildValue(version.value?.version).replace(/^v/i, '')
|
||||
return versionText ? `v${versionText}` : ''
|
||||
})
|
||||
const gitShaDisplay = computed(() => {
|
||||
const gitSha = normalizeBuildValue(version.value?.gitSha)
|
||||
return gitSha ? gitSha.slice(0, 8) : ''
|
||||
})
|
||||
|
||||
const versionLine = computed(() => {
|
||||
const parts = [versionDisplay.value, gitShaDisplay.value].filter(Boolean)
|
||||
return parts.length ? `版本 ${parts.join(' · ')}` : '版本信息不可用'
|
||||
})
|
||||
|
||||
const linkItems = [
|
||||
{
|
||||
label: 'GitHub',
|
||||
value: 'codex-proxy-rs',
|
||||
href: githubUrl,
|
||||
icon: GitBranch,
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal v-model="open" title="关于" width="420px" hide-footer>
|
||||
<div class="grid gap-5">
|
||||
<section class="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
class="inline-flex size-9 shrink-0 items-center justify-center rounded-(--cp-icon-button-radius) bg-(--cp-bg-subtle) font-mono text-[15px] leading-none font-[820] text-(--cp-text-primary)"
|
||||
>
|
||||
Z
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 truncate text-[15px] leading-none font-[780] text-(--cp-text-primary)">
|
||||
{{ author }}
|
||||
</p>
|
||||
<p class="mt-1.5 mb-0 text-[12px] leading-none font-[650] text-(--cp-text-secondary)">
|
||||
Built by Zyy · Codex Proxy RS
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-1">
|
||||
<div
|
||||
v-for="item in linkItems"
|
||||
:key="item.label"
|
||||
class="group flex min-w-0 items-center justify-between gap-3 rounded-(--cp-input-radius-base) px-1 py-2.5 transition-colors hover:bg-(--cp-bg-subtle)"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
class="inline-flex size-8 shrink-0 items-center justify-center rounded-(--cp-icon-button-radius) bg-(--cp-bg-muted) text-(--cp-text-secondary)"
|
||||
>
|
||||
<component :is="item.icon" class="size-4" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 text-[11px] leading-none font-[760] text-(--cp-text-muted)">
|
||||
{{ item.label }}
|
||||
</p>
|
||||
<p
|
||||
class="mt-2 mb-0 truncate font-mono text-[12px] leading-none font-[720] text-(--cp-text-primary)"
|
||||
:title="item.value"
|
||||
>
|
||||
{{ item.value }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
:href="item.href"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="inline-flex size-7 shrink-0 items-center justify-center rounded-(--cp-icon-button-radius) text-(--cp-text-muted) transition-colors hover:bg-(--cp-bg-muted) hover:text-(--cp-info)"
|
||||
:aria-label="`打开 ${item.label}`"
|
||||
>
|
||||
<ExternalLink class="size-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="m-0 font-mono text-[11px] leading-none font-[650] text-(--cp-text-muted)">
|
||||
{{ versionLine }}
|
||||
</p>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Cat,
|
||||
ChartNoAxesColumn,
|
||||
ArrowUpCircle,
|
||||
Info,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
@@ -24,6 +25,7 @@ import { useSystemUpdate } from '@/composables/useSystemUpdate'
|
||||
import { useAuthStore } from '@/stores/modules/auth'
|
||||
import { useUiStore } from '@/stores/modules/ui'
|
||||
|
||||
import AppAboutModal from './AppAboutModal.vue'
|
||||
import SystemUpdateModal from './SystemUpdateModal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -35,6 +37,23 @@ const { toggleTheme } = uiStore
|
||||
const preferredMotion = usePreferredReducedMotion()
|
||||
const { version, hasUpdate, loadedOnce, loadVersion, loadSystem } = useSystemUpdate()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
collapsed?: boolean
|
||||
mobile?: boolean
|
||||
}>(),
|
||||
{
|
||||
collapsed: false,
|
||||
mobile: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
navigate: []
|
||||
toggle: []
|
||||
}>()
|
||||
|
||||
const navItems = [
|
||||
{ label: '概览', icon: LayoutDashboard, path: '/' },
|
||||
{ label: '账号管理', icon: Users, path: '/accounts' },
|
||||
@@ -49,7 +68,8 @@ const isActive = (path: string) => {
|
||||
}
|
||||
|
||||
function navigate(path: string) {
|
||||
router.push(path)
|
||||
void router.push(path)
|
||||
emit('navigate')
|
||||
}
|
||||
|
||||
const systemUpdateOpening = shallowRef(false)
|
||||
@@ -72,26 +92,17 @@ async function openSystemUpdate() {
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.logout()
|
||||
router.push('/login')
|
||||
await router.push('/login')
|
||||
emit('navigate')
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
collapsed?: boolean
|
||||
}>(),
|
||||
{
|
||||
collapsed: false,
|
||||
},
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
toggle: []
|
||||
}>()
|
||||
|
||||
const sidebarEl = ref<HTMLElement | null>(null)
|
||||
const brandLabelEl = ref<HTMLElement | null>(null)
|
||||
const brandLabelVisible = shallowRef(!props.collapsed)
|
||||
const systemUpdateOpen = shallowRef(false)
|
||||
const aboutOpen = shallowRef(false)
|
||||
const isCollapsed = computed(() => !props.mobile && Boolean(props.collapsed))
|
||||
const sidebarWidth = computed(() => (isCollapsed.value ? 88 : 256))
|
||||
const brandLabelVisible = shallowRef(!isCollapsed.value)
|
||||
const themeToggleLabel = computed(() =>
|
||||
effectiveTheme.value === 'dark' ? '切换浅色模式' : '切换暗黑模式',
|
||||
)
|
||||
@@ -208,10 +219,10 @@ function animateSidebarWidth(collapsed: boolean) {
|
||||
|
||||
onMounted(() => {
|
||||
gsap.set(sidebarEl.value, {
|
||||
width: props.collapsed ? 88 : 256,
|
||||
flexBasis: props.collapsed ? 88 : 256,
|
||||
width: sidebarWidth.value,
|
||||
flexBasis: sidebarWidth.value,
|
||||
})
|
||||
if (props.collapsed) {
|
||||
if (isCollapsed.value) {
|
||||
hideBrandLabel()
|
||||
} else {
|
||||
gsap.set(brandLabelEl.value, {
|
||||
@@ -219,12 +230,12 @@ onMounted(() => {
|
||||
x: 0,
|
||||
})
|
||||
}
|
||||
animateSidebarLabels(Boolean(props.collapsed))
|
||||
animateSidebarLabels(isCollapsed.value)
|
||||
void loadVersion().catch(() => undefined)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.collapsed,
|
||||
() => isCollapsed.value,
|
||||
async (collapsed) => {
|
||||
if (collapsed) {
|
||||
hideBrandLabel()
|
||||
@@ -245,12 +256,15 @@ watch(
|
||||
<template>
|
||||
<aside
|
||||
ref="sidebarEl"
|
||||
class="z-20 hidden h-screen shrink-0 flex-col overflow-hidden bg-(--cp-bg-surface) px-4 shadow-(--cp-shadow-sidebar) min-[961px]:flex"
|
||||
:class="collapsed ? 'w-22 basis-22 items-center' : 'w-64 basis-64'"
|
||||
class="z-20 h-dvh shrink-0 flex-col overflow-hidden bg-(--cp-bg-surface) px-4 shadow-(--cp-shadow-sidebar)"
|
||||
:class="[
|
||||
mobile ? 'flex' : 'hidden min-[961px]:flex',
|
||||
isCollapsed ? 'w-22 basis-22 items-center' : 'w-64 basis-64',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="mt-6 grid h-12 grid-cols-[44px_minmax(0,1fr)] items-center"
|
||||
:class="collapsed ? 'w-11 justify-start' : 'w-full gap-3'"
|
||||
:class="isCollapsed ? 'w-11 justify-start' : 'w-full gap-3'"
|
||||
>
|
||||
<span
|
||||
class="inline-flex size-11 items-center justify-center relative -top-0.5 rounded-(--cp-icon-button-radius) bg-(--cp-bg-muted) text-(--cp-text-primary)"
|
||||
@@ -290,14 +304,14 @@ watch(
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav class="mt-7 grid gap-3" :class="collapsed ? '' : 'w-full'" aria-label="主导航">
|
||||
<nav class="mt-7 grid gap-3" :class="isCollapsed ? '' : 'w-full'" aria-label="主导航">
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.label"
|
||||
type="button"
|
||||
class="inline-flex h-11.5 items-center rounded-(--cp-icon-button-radius) text-sm leading-[1.15] border-0 cursor-pointer transition-colors duration-200"
|
||||
:class="[
|
||||
collapsed ? 'w-11.5 justify-center' : 'w-full gap-3 px-4',
|
||||
isCollapsed ? 'w-11.5 justify-center' : 'w-full gap-3 px-4',
|
||||
isActive(item.path)
|
||||
? 'bg-(--cp-bg-nav-active) font-bold text-(--cp-text-primary)'
|
||||
: 'bg-transparent font-semibold text-(--cp-text-secondary) hover:bg-(--cp-bg-subtle) hover:text-(--cp-text-primary)',
|
||||
@@ -307,32 +321,32 @@ watch(
|
||||
<component :is="item.icon" class="shrink-0" :size="20" />
|
||||
<span
|
||||
class="sidebar-label overflow-hidden whitespace-nowrap transition-[opacity,transform] duration-200"
|
||||
:class="collapsed ? 'pointer-events-none w-0' : 'w-auto'"
|
||||
:class="isCollapsed ? 'pointer-events-none w-0' : 'w-auto'"
|
||||
>{{ item.label }}</span
|
||||
>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="mt-auto mb-8" :class="collapsed ? 'w-11' : 'w-full'">
|
||||
<div class="mt-auto mb-6" :class="isCollapsed ? 'w-11' : 'w-full'">
|
||||
<div
|
||||
class="bg-(--cp-bg-subtle)"
|
||||
:class="
|
||||
collapsed
|
||||
isCollapsed
|
||||
? 'grid gap-1 rounded-(--cp-icon-button-radius) p-1'
|
||||
: 'flex h-11 items-center justify-between rounded-(--cp-panel-radius) px-2'
|
||||
"
|
||||
>
|
||||
<span
|
||||
v-if="!collapsed"
|
||||
v-if="!isCollapsed"
|
||||
class="inline-flex h-7 items-center gap-1.5 rounded-lg bg-(--cp-success-bg) px-2.5 text-xs leading-none font-[650] text-(--cp-success-text)"
|
||||
>
|
||||
<i class="size-1.5 rounded-full bg-(--cp-success)" />
|
||||
在线
|
||||
</span>
|
||||
|
||||
<div class="flex items-center" :class="collapsed ? 'grid gap-1' : 'gap-1'">
|
||||
<div class="flex items-center" :class="isCollapsed ? 'grid gap-1' : 'gap-1'">
|
||||
<BaseButton
|
||||
v-if="collapsed"
|
||||
v-if="isCollapsed && hasUpdate"
|
||||
icon-only
|
||||
:variant="hasUpdate ? 'success' : 'ghost'"
|
||||
size="default"
|
||||
@@ -346,34 +360,56 @@ watch(
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
:size="collapsed ? 'default' : 'sm'"
|
||||
:size="isCollapsed ? 'default' : 'sm'"
|
||||
label="退出登录"
|
||||
class="hover:bg-(--cp-danger-bg) hover:text-(--cp-danger)"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<LogOut :size="collapsed ? 19 : 18" />
|
||||
<LogOut :size="isCollapsed ? 19 : 18" />
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
:size="collapsed ? 'default' : 'sm'"
|
||||
:size="isCollapsed ? 'default' : 'sm'"
|
||||
:label="themeToggleLabel"
|
||||
@click="toggleTheme($event)"
|
||||
>
|
||||
<Sun v-if="effectiveTheme === 'dark'" :size="collapsed ? 19 : 18" />
|
||||
<Moon v-else :size="collapsed ? 19 : 18" />
|
||||
<Sun v-if="effectiveTheme === 'dark'" :size="isCollapsed ? 19 : 18" />
|
||||
<Moon v-else :size="isCollapsed ? 19 : 18" />
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
:size="collapsed ? 'default' : 'sm'"
|
||||
data-sidebar-toggle
|
||||
:label="collapsed ? '展开侧边栏' : '收缩侧边栏'"
|
||||
@click="$emit('toggle')"
|
||||
:size="isCollapsed ? 'default' : 'sm'"
|
||||
label="关于"
|
||||
@click="aboutOpen = true"
|
||||
>
|
||||
<PanelLeftOpen v-if="collapsed" :size="19" />
|
||||
<Info :size="isCollapsed ? 19 : 18" />
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
v-if="mobile"
|
||||
icon-only
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="关闭侧边栏"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<PanelLeftClose :size="18" />
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
v-else
|
||||
icon-only
|
||||
variant="ghost"
|
||||
:size="isCollapsed ? 'default' : 'sm'"
|
||||
data-sidebar-toggle
|
||||
:label="isCollapsed ? '展开侧边栏' : '收缩侧边栏'"
|
||||
@click="emit('toggle')"
|
||||
>
|
||||
<PanelLeftOpen v-if="isCollapsed" :size="19" />
|
||||
<PanelLeftClose v-else :size="18" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
@@ -381,5 +417,6 @@ watch(
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<AppAboutModal v-model="aboutOpen" />
|
||||
<SystemUpdateModal v-model="systemUpdateOpen" />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
<script setup lang="ts">
|
||||
import { Menu } from '@lucide/vue'
|
||||
import {
|
||||
useDraggable,
|
||||
useElementSize,
|
||||
useScreenSafeArea,
|
||||
useStorage,
|
||||
useWindowSize,
|
||||
} from '@vueuse/core'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onScopeDispose,
|
||||
shallowRef,
|
||||
useTemplateRef,
|
||||
watch,
|
||||
} from 'vue'
|
||||
|
||||
import type { CSSProperties } from 'vue'
|
||||
|
||||
interface StoredPosition {
|
||||
initialized: boolean
|
||||
side: 'left' | 'right'
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: []
|
||||
}>()
|
||||
|
||||
const edgeGap = 12
|
||||
const defaultButtonSize = 44
|
||||
const dragClickThreshold = 6
|
||||
const storageKey = 'codex-proxy:mobile-sidebar-toggle-position'
|
||||
|
||||
const toggleEl = useTemplateRef<HTMLElement>('toggleEl')
|
||||
const { width: viewportWidth, height: viewportHeight } = useWindowSize({
|
||||
initialWidth: 390,
|
||||
initialHeight: 760,
|
||||
type: 'visual',
|
||||
})
|
||||
const { width: toggleWidth, height: toggleHeight } = useElementSize(toggleEl, {
|
||||
width: defaultButtonSize,
|
||||
height: defaultButtonSize,
|
||||
})
|
||||
const safeArea = useScreenSafeArea()
|
||||
const storedPosition = useStorage<StoredPosition>(
|
||||
storageKey,
|
||||
{
|
||||
initialized: false,
|
||||
side: 'left',
|
||||
x: edgeGap,
|
||||
y: 136,
|
||||
},
|
||||
undefined,
|
||||
{ mergeDefaults: true, shallow: true },
|
||||
)
|
||||
const dragStart = shallowRef({ x: edgeGap, y: 136 })
|
||||
const pointerStart = shallowRef({ x: edgeGap, y: 136 })
|
||||
const ignoreNextClick = shallowRef(false)
|
||||
const positionReady = shallowRef(false)
|
||||
let ignoreClickTimer: number | undefined
|
||||
|
||||
const bounds = computed(() => {
|
||||
const buttonWidth = positiveNumber(toggleWidth.value, defaultButtonSize)
|
||||
const buttonHeight = positiveNumber(toggleHeight.value, defaultButtonSize)
|
||||
const windowWidth = positiveNumber(viewportWidth.value, 390)
|
||||
const windowHeight = positiveNumber(viewportHeight.value, 760)
|
||||
const minX = parseCssPixel(safeArea.left.value) + edgeGap
|
||||
const maxX = Math.max(
|
||||
minX,
|
||||
windowWidth - parseCssPixel(safeArea.right.value) - edgeGap - buttonWidth,
|
||||
)
|
||||
const minY = parseCssPixel(safeArea.top.value) + edgeGap
|
||||
const maxY = Math.max(
|
||||
minY,
|
||||
windowHeight - parseCssPixel(safeArea.bottom.value) - edgeGap - buttonHeight,
|
||||
)
|
||||
|
||||
return { minX, maxX, minY, maxY }
|
||||
})
|
||||
|
||||
const { x, y, isDragging } = useDraggable(toggleEl, {
|
||||
initialValue: {
|
||||
x: storedPosition.value.x,
|
||||
y: storedPosition.value.y,
|
||||
},
|
||||
preventDefault: false,
|
||||
stopPropagation: false,
|
||||
onStart(position) {
|
||||
dragStart.value = { x: position.x, y: position.y }
|
||||
},
|
||||
onEnd(position) {
|
||||
applyPosition(snapPosition(position.x, position.y))
|
||||
},
|
||||
})
|
||||
|
||||
const toggleStyle = computed<CSSProperties>(() => ({
|
||||
transform: `translate3d(${x.value}px, ${y.value}px, 0)`,
|
||||
touchAction: 'none',
|
||||
}))
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
pointerStart.value = { x: event.clientX, y: event.clientY }
|
||||
event.currentTarget instanceof HTMLElement &&
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId)
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent) {
|
||||
event.currentTarget instanceof HTMLElement &&
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId)
|
||||
|
||||
const moved = Math.hypot(
|
||||
event.clientX - pointerStart.value.x,
|
||||
event.clientY - pointerStart.value.y,
|
||||
)
|
||||
ignoreNextClick.value = true
|
||||
window.clearTimeout(ignoreClickTimer)
|
||||
ignoreClickTimer = window.setTimeout(() => {
|
||||
ignoreNextClick.value = false
|
||||
}, 320)
|
||||
|
||||
if (moved <= dragClickThreshold) {
|
||||
emit('open')
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (isDragging.value || ignoreNextClick.value) {
|
||||
ignoreNextClick.value = false
|
||||
return
|
||||
}
|
||||
|
||||
emit('open')
|
||||
}
|
||||
|
||||
function defaultPosition() {
|
||||
const targetY = Math.round(positiveNumber(viewportHeight.value, 760) * 0.36)
|
||||
return snapPosition(bounds.value.minX, targetY)
|
||||
}
|
||||
|
||||
function restorePosition() {
|
||||
if (!storedPosition.value.initialized) {
|
||||
applyPosition(defaultPosition())
|
||||
return
|
||||
}
|
||||
|
||||
const sideX = storedPosition.value.side === 'right' ? bounds.value.maxX : bounds.value.minX
|
||||
applyPosition({
|
||||
initialized: true,
|
||||
side: storedPosition.value.side,
|
||||
x: sideX,
|
||||
y: storedPosition.value.y,
|
||||
})
|
||||
}
|
||||
|
||||
function snapPosition(rawX: number, rawY: number) {
|
||||
const { minX, maxX, minY, maxY } = bounds.value
|
||||
const side = rawX <= (minX + maxX) / 2 ? 'left' : 'right'
|
||||
|
||||
return {
|
||||
initialized: true,
|
||||
side,
|
||||
x: side === 'left' ? minX : maxX,
|
||||
y: clamp(rawY, minY, maxY),
|
||||
} satisfies StoredPosition
|
||||
}
|
||||
|
||||
function applyPosition(position: StoredPosition) {
|
||||
const { minX, maxX, minY, maxY } = bounds.value
|
||||
const next = {
|
||||
initialized: true,
|
||||
side: position.side,
|
||||
x: clamp(position.x, minX, maxX),
|
||||
y: clamp(position.y, minY, maxY),
|
||||
} satisfies StoredPosition
|
||||
|
||||
x.value = next.x
|
||||
y.value = next.y
|
||||
storedPosition.value = next
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
function positiveNumber(value: number, fallback: number) {
|
||||
return Number.isFinite(value) && value > 0 ? value : fallback
|
||||
}
|
||||
|
||||
function parseCssPixel(value: string) {
|
||||
const parsed = Number.parseFloat(value)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
safeArea.update()
|
||||
restorePosition()
|
||||
positionReady.value = true
|
||||
})
|
||||
|
||||
watch(
|
||||
bounds,
|
||||
() => {
|
||||
if (!positionReady.value) return
|
||||
restorePosition()
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
onScopeDispose(() => {
|
||||
window.clearTimeout(ignoreClickTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="toggleEl"
|
||||
class="fixed top-0 left-0 z-40 min-[961px]:hidden"
|
||||
:class="positionReady ? 'opacity-100' : 'opacity-0'"
|
||||
:style="toggleStyle"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-11.5 items-center justify-center rounded-(--cp-icon-button-radius) border-0 bg-(--cp-bg-surface) text-(--cp-text-primary) shadow-[0_18px_34px_-18px_var(--cp-shadow-sticky)] outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 hover:bg-(--cp-default-bg-hover) hover:text-(--cp-normal) active:scale-95 focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-page)"
|
||||
:class="
|
||||
isDragging
|
||||
? 'scale-95 cursor-grabbing shadow-[0_14px_28px_-20px_var(--cp-shadow-sticky)]'
|
||||
: 'cursor-grab'
|
||||
"
|
||||
aria-label="打开侧边栏"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointerup="handlePointerUp"
|
||||
@click="handleClick"
|
||||
>
|
||||
<Menu class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -356,7 +356,7 @@ onUnmounted(() => {
|
||||
variant="success"
|
||||
width="820px"
|
||||
scrollable
|
||||
body-max-height="72vh"
|
||||
body-max-height="72dvh"
|
||||
body-view-class="pr-3"
|
||||
:close-disabled="updating || restarting"
|
||||
>
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { nextTick, ref, shallowRef, watch } from 'vue'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
|
||||
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
|
||||
import { useUiStore } from '@/stores/modules/ui'
|
||||
|
||||
import AppSidebar from './components/AppSidebar.vue'
|
||||
import FloatingSidebarToggle from './components/FloatingSidebarToggle.vue'
|
||||
|
||||
const uiStore = useUiStore()
|
||||
const { sidebarCollapsed } = storeToRefs(uiStore)
|
||||
const { toggleSidebar } = uiStore
|
||||
const route = useRoute()
|
||||
const pageScrollbarRef = ref<InstanceType<typeof BaseScrollbar> | null>(null)
|
||||
const mobileSidebarOpen = shallowRef(false)
|
||||
|
||||
function openMobileSidebar() {
|
||||
mobileSidebarOpen.value = true
|
||||
}
|
||||
|
||||
function closeMobileSidebar() {
|
||||
mobileSidebarOpen.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
async () => {
|
||||
closeMobileSidebar()
|
||||
await nextTick()
|
||||
await pageScrollbarRef.value?.scrollToTop()
|
||||
},
|
||||
@@ -25,14 +36,65 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen overflow-hidden bg-(--cp-bg-page)">
|
||||
<div class="relative flex h-dvh overflow-hidden bg-(--cp-bg-page)">
|
||||
<AppSidebar :collapsed="sidebarCollapsed" @toggle="toggleSidebar" />
|
||||
<main class="h-screen min-w-0 flex-1 overflow-hidden">
|
||||
<BaseScrollbar ref="pageScrollbarRef" view-class="flex min-h-full min-w-0 flex-col p-6">
|
||||
<FloatingSidebarToggle v-if="!mobileSidebarOpen" @open="openMobileSidebar" />
|
||||
<main class="h-dvh min-w-0 flex-1 overflow-hidden">
|
||||
<BaseScrollbar
|
||||
ref="pageScrollbarRef"
|
||||
view-class="flex min-h-full min-w-0 flex-col p-4 min-[961px]:p-6"
|
||||
>
|
||||
<RouterView v-slot="{ Component }">
|
||||
<component :is="Component" class="min-h-0 flex-1" />
|
||||
</RouterView>
|
||||
</BaseScrollbar>
|
||||
</main>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="mobile-sidebar">
|
||||
<div v-if="mobileSidebarOpen" class="fixed inset-0 z-50 min-[961px]:hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 border-0 bg-black/32 backdrop-blur-[1px] cursor-default"
|
||||
aria-label="关闭侧边栏"
|
||||
@click="closeMobileSidebar"
|
||||
/>
|
||||
<div class="mobile-sidebar-panel absolute inset-y-0 left-0 flex">
|
||||
<AppSidebar mobile @close="closeMobileSidebar" @navigate="closeMobileSidebar" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mobile-sidebar-enter-active,
|
||||
.mobile-sidebar-leave-active {
|
||||
transition: opacity 180ms ease;
|
||||
}
|
||||
|
||||
.mobile-sidebar-enter-active .mobile-sidebar-panel,
|
||||
.mobile-sidebar-leave-active .mobile-sidebar-panel {
|
||||
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.mobile-sidebar-enter-from,
|
||||
.mobile-sidebar-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mobile-sidebar-enter-from .mobile-sidebar-panel,
|
||||
.mobile-sidebar-leave-to .mobile-sidebar-panel {
|
||||
transform: translate3d(-100%, 0, 0);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mobile-sidebar-enter-active,
|
||||
.mobile-sidebar-leave-active,
|
||||
.mobile-sidebar-enter-active .mobile-sidebar-panel,
|
||||
.mobile-sidebar-leave-active .mobile-sidebar-panel {
|
||||
transition-duration: 1ms;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,12 +6,23 @@ const props = defineProps<{
|
||||
account: any
|
||||
}>()
|
||||
|
||||
const FIVE_HOUR_WINDOW_SECONDS = 18_000
|
||||
const WEEK_WINDOW_SECONDS = 604_800
|
||||
const MONTH_WINDOW_SECONDS = 2_592_000
|
||||
|
||||
const quotaWindows = computed(() => props.account.quota.windows as any[])
|
||||
const visibleQuotaWindows = computed(() => quotaWindows.value.slice(0, 2))
|
||||
const summaryClass = computed(() =>
|
||||
visibleQuotaWindows.value.length === 1
|
||||
? 'flex h-13 w-full max-w-31 min-w-0 flex-col justify-center whitespace-normal py-0.5'
|
||||
: 'grid h-13 w-full max-w-31 min-w-0 grid-rows-2 gap-1.5 whitespace-normal py-0.5',
|
||||
const visibleQuotaWindows = computed(() => {
|
||||
const knownWindows = [...quotaWindows.value]
|
||||
.filter((window) => window.group === 'shortTerm' || window.group === 'monthly')
|
||||
.sort((a, b) => quotaWindowOrder(a) - quotaWindowOrder(b))
|
||||
|
||||
return knownWindows.length > 0 ? knownWindows : quotaWindows.value
|
||||
})
|
||||
const summaryClass = computed(
|
||||
() =>
|
||||
`grid w-full max-w-40 min-w-0 gap-2 whitespace-normal py-0.5 ${
|
||||
visibleQuotaWindows.value.length === 1 ? 'min-h-13 content-center' : ''
|
||||
}`,
|
||||
)
|
||||
|
||||
function quotaWindowPercent(window?: any) {
|
||||
@@ -38,17 +49,65 @@ function quotaWindowBarClass(window?: any) {
|
||||
}
|
||||
return 'bg-(--cp-success)'
|
||||
}
|
||||
|
||||
function windowUsageDisplay(window?: any) {
|
||||
const display = window?.tokenUsageDisplay
|
||||
return typeof display === 'string' && display.trim() ? display : '-'
|
||||
}
|
||||
|
||||
function windowPercentDisplay(window?: any) {
|
||||
const display = window?.usedPercentDisplay
|
||||
return typeof display === 'string' && display.trim() ? display : '-'
|
||||
}
|
||||
|
||||
function quotaWindowPercentTextClass(window?: any) {
|
||||
if (window?.usedPercent === null || window?.usedPercent === undefined) {
|
||||
return 'text-(--cp-text-muted)'
|
||||
}
|
||||
if (window.usedPercent >= 95) {
|
||||
return 'text-(--cp-danger-text)'
|
||||
}
|
||||
if (window.usedPercent >= 80) {
|
||||
return 'text-(--cp-warning-text)'
|
||||
}
|
||||
return 'text-(--cp-success-text)'
|
||||
}
|
||||
|
||||
function quotaWindowOrder(window?: any) {
|
||||
const seconds = window?.windowSeconds
|
||||
if (quotaWindowMatches(seconds, FIVE_HOUR_WINDOW_SECONDS)) return 0
|
||||
if (quotaWindowMatches(seconds, WEEK_WINDOW_SECONDS)) return 1
|
||||
if (quotaWindowMatches(seconds, MONTH_WINDOW_SECONDS)) return 2
|
||||
return 3
|
||||
}
|
||||
|
||||
function quotaWindowMatches(actual: unknown, expected: number) {
|
||||
return (
|
||||
typeof actual === 'number' &&
|
||||
Number.isFinite(actual) &&
|
||||
actual > 0 &&
|
||||
Math.abs(actual - expected) <= expected / 20
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="quotaWindows.length > 0" :class="summaryClass">
|
||||
<div v-for="window in visibleQuotaWindows" :key="window.key" class="min-w-0">
|
||||
<div class="mb-1 flex items-center justify-between gap-2 text-[11px] leading-none font-[760]">
|
||||
<span class="truncate text-(--cp-text-secondary)">
|
||||
<div class="mb-1 flex items-center justify-between gap-2 text-[10px] leading-none font-[760]">
|
||||
<span class="min-w-0 text-(--cp-text-secondary)">
|
||||
{{ window.labelDisplay }}
|
||||
</span>
|
||||
<span class="shrink-0 font-mono text-(--cp-text-primary)">
|
||||
{{ window.usedPercentDisplay }}
|
||||
<span class="flex shrink-0 items-baseline justify-end gap-1.5 font-mono tabular-nums">
|
||||
<span
|
||||
class="text-[10px] leading-none font-[780]"
|
||||
:class="quotaWindowPercentTextClass(window)"
|
||||
>
|
||||
{{ windowPercentDisplay(window) }}
|
||||
</span>
|
||||
<span class="text-[10px] leading-none font-[680] text-(--cp-text-muted)">
|
||||
{{ windowUsageDisplay(window) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-1 w-full overflow-hidden rounded-full bg-(--cp-default-border)">
|
||||
|
||||
@@ -5,6 +5,7 @@ export const accountColumns = [
|
||||
key: 'expander',
|
||||
label: '',
|
||||
width: '40px',
|
||||
minWidth: '40px',
|
||||
align: 'center' as const,
|
||||
headerClass: '!px-2',
|
||||
cellClass: `!px-2 ${relaxedCellClass}`,
|
||||
@@ -13,31 +14,42 @@ export const accountColumns = [
|
||||
key: 'selection',
|
||||
label: '',
|
||||
width: '40px',
|
||||
minWidth: '40px',
|
||||
align: 'center' as const,
|
||||
headerClass: '!px-2',
|
||||
cellClass: `!px-2 ${relaxedCellClass}`,
|
||||
},
|
||||
{ key: 'identity', label: '邮箱', flex: 2.6, minWidth: '260px', cellClass: relaxedCellClass },
|
||||
{ key: 'status', label: '状态', flex: 0.8, cellClass: relaxedCellClass },
|
||||
{ key: 'planType', label: '套餐', flex: 0.8, cellClass: relaxedCellClass },
|
||||
{ key: 'usage', label: '用量', flex: 1.4, minWidth: '210px', cellClass: relaxedCellClass },
|
||||
{ key: 'quota', label: '额度', flex: 0.8, minWidth: '112px', cellClass: relaxedCellClass },
|
||||
{
|
||||
key: 'identity',
|
||||
label: '邮箱',
|
||||
width: '340px',
|
||||
minWidth: '340px',
|
||||
fixed: 'left' as const,
|
||||
cellClass: relaxedCellClass,
|
||||
},
|
||||
{ key: 'status', label: '状态', flex: 0.6, minWidth: '60px', cellClass: relaxedCellClass },
|
||||
{ key: 'planType', label: '套餐', flex: 0.8, minWidth: '112px', cellClass: relaxedCellClass },
|
||||
{ key: 'usage', label: '用量', flex: 1.3, minWidth: '220px', cellClass: relaxedCellClass },
|
||||
{
|
||||
key: 'updatedAtDisplay',
|
||||
label: '最后使用',
|
||||
flex: 1.2,
|
||||
minWidth: '160px',
|
||||
cellClass: `${relaxedCellClass} text-(--cp-text-secondary)`,
|
||||
},
|
||||
{
|
||||
key: 'accessTokenExpiresAtDisplay',
|
||||
label: '过期时间',
|
||||
flex: 1.2,
|
||||
minWidth: '160px',
|
||||
cellClass: `${relaxedCellClass} text-(--cp-text-secondary)`,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '操作',
|
||||
width: '116px',
|
||||
minWidth: '116px',
|
||||
fixed: 'right' as const,
|
||||
headerClass: '!pr-3',
|
||||
cellClass: `!px-2 ${relaxedCellClass}`,
|
||||
},
|
||||
|
||||
@@ -107,11 +107,6 @@ const {
|
||||
} = useAccountsTable(accounts, selectedIds)
|
||||
|
||||
bindAccountLoader(loadAccounts)
|
||||
|
||||
function quotaTokenDisplay(account: any) {
|
||||
const display = account?.usage?.totalTokensDisplay
|
||||
return typeof display === 'string' && display.trim() ? display : '0'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -131,7 +126,7 @@ function quotaTokenDisplay(account: any) {
|
||||
|
||||
<BaseCard
|
||||
:padded="false"
|
||||
class="mt-4 flex h-[calc(100vh-248px)] min-h-125 flex-col"
|
||||
class="mt-4 flex h-[calc(100dvh-248px)] min-h-125 flex-col"
|
||||
header-class="px-4 pt-4 pb-2 md:px-5"
|
||||
body-class="flex min-h-0 flex-1 px-4 pb-3 md:px-5"
|
||||
>
|
||||
@@ -187,6 +182,7 @@ function quotaTokenDisplay(account: any) {
|
||||
:expanded-row-keys="expandedRowKeys"
|
||||
:pagination="accountPagination"
|
||||
empty-text="暂无账号数据"
|
||||
min-width="1480px"
|
||||
@page-change="handlePageChange"
|
||||
@page-size-change="handlePageSizeChange"
|
||||
>
|
||||
@@ -237,12 +233,6 @@ function quotaTokenDisplay(account: any) {
|
||||
<AccountQuotaSummaryCell :account="row" />
|
||||
</template>
|
||||
|
||||
<template #quota="{ row }">
|
||||
<span class="font-mono text-[12px] font-[760] text-(--cp-text-primary)">
|
||||
{{ quotaTokenDisplay(row) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #actions="{ row }">
|
||||
<AccountTableActions
|
||||
:account="row"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Copy } from '@lucide/vue'
|
||||
import { Apple, Copy, Monitor } from '@lucide/vue'
|
||||
import { computed, shallowRef } from 'vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
@@ -22,136 +22,57 @@ const emit = defineEmits<{
|
||||
copy: [text: string]
|
||||
}>()
|
||||
|
||||
const activeTab = shallowRef('codex')
|
||||
const activePlatform = shallowRef('unix')
|
||||
|
||||
const tabOptions = [
|
||||
{ label: 'Codex', value: 'codex' },
|
||||
{ label: '环境变量', value: 'env' },
|
||||
{ label: 'curl', value: 'curl' },
|
||||
const platformOptions = [
|
||||
{ label: 'MacOS / Linux', value: 'unix', icon: Apple },
|
||||
{ label: 'Windows', value: 'windows', icon: Monitor },
|
||||
]
|
||||
|
||||
const keyValue = computed(() => props.apiKey?.key ?? '')
|
||||
const displayName = computed(() => props.apiKey?.name || 'API Key')
|
||||
const configPath = computed(() =>
|
||||
activePlatform.value === 'windows' ? '%userprofile%\\.codex/config.toml' : '~/.codex/config.toml',
|
||||
)
|
||||
const authPath = computed(() =>
|
||||
activePlatform.value === 'windows' ? '%userprofile%\\.codex/auth.json' : '~/.codex/auth.json',
|
||||
)
|
||||
const codexAuthJson = computed(() => JSON.stringify({ OPENAI_API_KEY: keyValue.value }, null, 2))
|
||||
|
||||
const codexConfigToml = computed(
|
||||
() => `model_provider = "codex-proxy-rs"
|
||||
() => `model_provider = "OpenAI"
|
||||
model = "gpt-5.5"
|
||||
review_model = "gpt-5.5"
|
||||
model_reasoning_effort = "xhigh"
|
||||
disable_response_storage = true
|
||||
network_access = "enabled"
|
||||
windows_wsl_setup_acknowledged = true
|
||||
|
||||
[model_providers.codex-proxy-rs]
|
||||
name = "codex-proxy-rs"
|
||||
base_url = "${props.apiBaseUrl}"
|
||||
[model_providers.OpenAI]
|
||||
name = "OpenAI"
|
||||
base_url = "${props.serviceRootUrl}"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = true`,
|
||||
requires_openai_auth = true
|
||||
|
||||
[features]
|
||||
goals = true`,
|
||||
)
|
||||
|
||||
const envConfig = computed(
|
||||
() => `export OPENAI_API_KEY="${keyValue.value}"
|
||||
export OPENAI_BASE_URL="${props.apiBaseUrl}"`,
|
||||
)
|
||||
|
||||
const curlExample = computed(
|
||||
() => `curl "${props.apiBaseUrl}/responses" \\
|
||||
-H "Authorization: Bearer ${keyValue.value}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"model": "gpt-5.5",
|
||||
"input": "ping",
|
||||
"stream": false
|
||||
}'`,
|
||||
)
|
||||
|
||||
const visibleFiles = computed(() => {
|
||||
if (activeTab.value === 'env') {
|
||||
return [{ path: 'Shell', content: envConfig.value }]
|
||||
}
|
||||
|
||||
if (activeTab.value === 'curl') {
|
||||
return [{ path: '/v1/responses', content: curlExample.value }]
|
||||
}
|
||||
|
||||
return [
|
||||
{ path: '~/.codex/config.toml', content: codexConfigToml.value },
|
||||
{ path: '~/.codex/auth.json', content: codexAuthJson.value },
|
||||
]
|
||||
})
|
||||
const visibleFiles = computed(() => [
|
||||
{ path: configPath.value, content: codexConfigToml.value },
|
||||
{ path: authPath.value, content: codexAuthJson.value },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal v-model="open" title="使用密钥" :description="displayName" width="760px">
|
||||
<BaseModal
|
||||
v-model="open"
|
||||
title="使用密钥"
|
||||
description="将以下配置文件添加到 Codex CLI 配置目录中"
|
||||
width="760px"
|
||||
>
|
||||
<div class="flex flex-col gap-5">
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<section class="rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5">
|
||||
<p class="m-0 text-[11px] leading-none font-bold text-(--cp-text-muted)">服务根地址</p>
|
||||
<div class="mt-2 flex min-w-0 items-center gap-2">
|
||||
<code
|
||||
class="min-w-0 flex-1 truncate font-mono text-[12px] font-[650] text-(--cp-text-primary)"
|
||||
:title="serviceRootUrl"
|
||||
>
|
||||
{{ serviceRootUrl }}
|
||||
</code>
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="复制服务根地址"
|
||||
@click="emit('copy', serviceRootUrl)"
|
||||
>
|
||||
<Copy class="size-3.5" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5">
|
||||
<p class="m-0 text-[11px] leading-none font-bold text-(--cp-text-muted)">
|
||||
OpenAI Base URL
|
||||
</p>
|
||||
<div class="mt-2 flex min-w-0 items-center gap-2">
|
||||
<code
|
||||
class="min-w-0 flex-1 truncate font-mono text-[12px] font-[650] text-(--cp-text-primary)"
|
||||
:title="apiBaseUrl"
|
||||
>
|
||||
{{ apiBaseUrl }}
|
||||
</code>
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="复制 Base URL"
|
||||
@click="emit('copy', apiBaseUrl)"
|
||||
>
|
||||
<Copy class="size-3.5" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5">
|
||||
<p class="m-0 text-[11px] leading-none font-bold text-(--cp-text-muted)">Bearer Key</p>
|
||||
<div class="mt-2 flex min-w-0 items-center gap-2">
|
||||
<code
|
||||
class="min-w-0 flex-1 truncate font-mono text-[12px] font-[650] text-(--cp-text-primary)"
|
||||
:title="keyValue"
|
||||
>
|
||||
{{ keyValue }}
|
||||
</code>
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="复制 Bearer Key"
|
||||
@click="emit('copy', keyValue)"
|
||||
>
|
||||
<Copy class="size-3.5" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<BaseSegmented v-model="activeTab" :options="tabOptions" />
|
||||
<BaseSegmented v-model="activePlatform" :options="platformOptions" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
@@ -177,7 +98,7 @@ const visibleFiles = computed(() => {
|
||||
</BaseButton>
|
||||
</div>
|
||||
<BaseScrollbar
|
||||
max-height="180px"
|
||||
max-height="360px"
|
||||
view-class="mx-3 mb-3 rounded-(--cp-input-radius-base) bg-(--cp-bg-surface) px-3.5 py-3 shadow-(--cp-shadow-input)"
|
||||
>
|
||||
<pre
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
const relaxedActionCellClass = '!px-4'
|
||||
|
||||
export const apiKeyColumns = [
|
||||
{ key: 'selection', label: '', width: '48px', align: 'center' as const },
|
||||
{ key: 'identity', label: '名称', minWidth: '280px', flex: 1.25 },
|
||||
{ key: 'selection', label: '', width: '48px', minWidth: '48px', align: 'center' as const },
|
||||
{ key: 'identity', label: '名称', minWidth: '220px', flex: 0.9, fixed: 'left' as const },
|
||||
{ key: 'prefix', label: '密钥前缀', minWidth: '300px', flex: 1.35 },
|
||||
{ key: 'enabled', label: '状态', width: '112px', align: 'center' as const },
|
||||
{ key: 'enabled', label: '状态', width: '112px', minWidth: '112px', align: 'center' as const },
|
||||
{
|
||||
key: 'createdAtDisplay',
|
||||
label: '创建时间',
|
||||
width: '176px',
|
||||
minWidth: '176px',
|
||||
cellClass: 'font-mono text-[12px] font-[650] tabular-nums text-(--cp-text-secondary)',
|
||||
},
|
||||
{
|
||||
key: 'lastUsedAtDisplay',
|
||||
label: '最后使用',
|
||||
width: '176px',
|
||||
minWidth: '176px',
|
||||
cellClass: 'font-mono text-[12px] font-[650] tabular-nums text-(--cp-text-secondary)',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '操作',
|
||||
width: '184px',
|
||||
minWidth: '184px',
|
||||
fixed: 'right' as const,
|
||||
headerClass: relaxedActionCellClass,
|
||||
cellClass: relaxedActionCellClass,
|
||||
},
|
||||
|
||||
@@ -113,7 +113,7 @@ function importToCcs(apiKey: any) {
|
||||
|
||||
<BaseCard
|
||||
:padded="false"
|
||||
class="mt-5 flex h-[calc(100vh-136px)] min-h-125 flex-col"
|
||||
class="mt-5 flex h-[calc(100dvh-136px)] min-h-125 flex-col"
|
||||
header-class="px-5 pt-4"
|
||||
body-class="flex min-h-0 flex-1 px-5 py-3"
|
||||
>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CircleCheck, RefreshCw, ShieldAlert, TriangleAlert } from '@lucide/vue'
|
||||
|
||||
import BaseCard from '../../../components/base/BaseCard.vue'
|
||||
import BaseEmpty from '../../../components/base/BaseEmpty.vue'
|
||||
import AccountPlanBadge from '../../accounts/components/AccountPlanBadge.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
accounts: any[]
|
||||
@@ -216,16 +217,13 @@ const quotaToneClasses: Record<string, string> = {
|
||||
{{ account.name[0] }}
|
||||
</span>
|
||||
<span class="flex min-w-0 max-w-full flex-col gap-1.5 xl:gap-4.25">
|
||||
<span class="flex min-w-0 max-w-full items-start gap-1.5">
|
||||
<span class="flex min-w-0 max-w-full items-center gap-2">
|
||||
<strong
|
||||
class="min-w-0 truncate text-sm leading-[1.15] font-[650] text-(--cp-text-primary)"
|
||||
:title="account.name"
|
||||
>{{ account.name }}</strong
|
||||
>
|
||||
<small
|
||||
class="mt-0.75 shrink-0 text-[11px] leading-[1.15] font-[650] text-(--cp-text-muted)"
|
||||
>{{ account.plan }}</small
|
||||
>
|
||||
<AccountPlanBadge class="shrink-0" :plan-type="account.planType" />
|
||||
</span>
|
||||
<span
|
||||
class="block min-w-0 max-w-full truncate text-xs leading-[1.15] font-semibold text-(--cp-text-secondary)"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { TransitionPresets, usePreferredReducedMotion, useTransition } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
type MetricValueFormatter = (value: number) => string
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
value: string
|
||||
rawValue?: number | null
|
||||
formatter?: MetricValueFormatter
|
||||
duration?: number
|
||||
}>(),
|
||||
{
|
||||
rawValue: null,
|
||||
duration: 520,
|
||||
},
|
||||
)
|
||||
|
||||
const preferredMotion = usePreferredReducedMotion()
|
||||
const hasAnimatedValue = computed(
|
||||
() =>
|
||||
typeof props.rawValue === 'number' &&
|
||||
Number.isFinite(props.rawValue) &&
|
||||
Boolean(props.formatter),
|
||||
)
|
||||
const sourceValue = computed(() => (hasAnimatedValue.value ? (props.rawValue ?? 0) : 0))
|
||||
const disabled = computed(() => preferredMotion.value === 'reduce' || !hasAnimatedValue.value)
|
||||
const animatedValue = useTransition(sourceValue, {
|
||||
duration: computed(() => props.duration),
|
||||
easing: TransitionPresets.easeOutCubic,
|
||||
disabled,
|
||||
})
|
||||
|
||||
const displayValue = computed(() =>
|
||||
hasAnimatedValue.value && props.formatter ? props.formatter(animatedValue.value) : props.value,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span>{{ displayValue }}</span>
|
||||
</template>
|
||||
@@ -1,15 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import type { Component } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import BaseCard from '../../../components/base/BaseCard.vue'
|
||||
import BaseChart from '../../../components/charts/BaseChart.vue'
|
||||
|
||||
import AnimatedMetricValue from './AnimatedMetricValue.vue'
|
||||
|
||||
type MetricTone = 'normal' | 'info' | 'success' | 'warning' | 'danger'
|
||||
|
||||
interface MetricDetail {
|
||||
label: string
|
||||
value: string
|
||||
tone?: MetricTone
|
||||
}
|
||||
|
||||
interface Metric {
|
||||
title: string
|
||||
value: string
|
||||
valueRaw?: number | null
|
||||
valueFormatter?: (value: number) => string
|
||||
icon: Component
|
||||
tone: MetricTone
|
||||
sparkline?: {
|
||||
values: number[]
|
||||
tone: MetricTone
|
||||
}
|
||||
trend?: {
|
||||
direction: 'up' | 'down' | 'flat'
|
||||
tone: MetricTone
|
||||
}
|
||||
details: MetricDetail[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
metric: any
|
||||
metric: Metric
|
||||
}>()
|
||||
|
||||
const iconToneClasses: Record<string, string> = {
|
||||
const iconToneClasses: Record<MetricTone, string> = {
|
||||
normal: 'bg-(--cp-normal-bg) text-(--cp-normal)',
|
||||
info: 'bg-(--cp-info-bg) text-(--cp-info)',
|
||||
success: 'bg-(--cp-success-bg) text-(--cp-success)',
|
||||
@@ -17,7 +46,7 @@ const iconToneClasses: Record<string, string> = {
|
||||
danger: 'bg-(--cp-danger-bg) text-(--cp-danger)',
|
||||
}
|
||||
|
||||
const detailToneClasses: Record<string, string> = {
|
||||
const detailToneClasses: Record<MetricTone, string> = {
|
||||
normal: 'text-(--cp-normal-text)',
|
||||
info: 'text-(--cp-info-text)',
|
||||
success: 'text-(--cp-success-text)',
|
||||
@@ -25,7 +54,7 @@ const detailToneClasses: Record<string, string> = {
|
||||
danger: 'text-(--cp-danger-text)',
|
||||
}
|
||||
|
||||
const trendToneClasses: Record<string, string> = {
|
||||
const trendToneClasses: Record<MetricTone, string> = {
|
||||
normal: 'bg-(--cp-normal-text)',
|
||||
info: 'bg-(--cp-info-text)',
|
||||
success: 'bg-(--cp-success-text)',
|
||||
@@ -33,7 +62,7 @@ const trendToneClasses: Record<string, string> = {
|
||||
danger: 'bg-(--cp-danger-text)',
|
||||
}
|
||||
|
||||
const sparklineColors: Record<string, string> = {
|
||||
const sparklineColors: Record<MetricTone, string> = {
|
||||
normal: '#94A3B8',
|
||||
info: '#60A5FA',
|
||||
success: '#5CCB8A',
|
||||
@@ -96,7 +125,11 @@ const sparklineOption = computed<EChartsOption | null>(() => {
|
||||
<strong
|
||||
class="font-mono text-[28px] leading-[1.05] font-[780] tabular-nums text-(--cp-text-primary)"
|
||||
>
|
||||
{{ metric.value }}
|
||||
<AnimatedMetricValue
|
||||
:value="metric.value"
|
||||
:raw-value="metric.valueRaw"
|
||||
:formatter="metric.valueFormatter"
|
||||
/>
|
||||
</strong>
|
||||
<i
|
||||
v-if="metric.trend && metric.trend.direction !== 'flat'"
|
||||
|
||||
@@ -73,16 +73,16 @@ function cellClass(point: string) {
|
||||
|
||||
<template #body>
|
||||
<div class="mt-4.25">
|
||||
<div class="grid w-full grid-cols-[repeat(24,minmax(0,1fr))] items-end gap-[3px]">
|
||||
<div class="grid w-full grid-cols-24 items-end gap-0.75">
|
||||
<div
|
||||
v-for="(group, groupIndex) in pointGroups"
|
||||
:key="groupIndex"
|
||||
class="grid grid-cols-4 items-end gap-[2px]"
|
||||
class="grid grid-cols-4 items-end gap-0.5"
|
||||
>
|
||||
<i
|
||||
v-for="(point, pointIndex) in group"
|
||||
:key="groupIndex * 4 + pointIndex"
|
||||
class="h-3.5 min-w-[2px] rounded-[2px]"
|
||||
class="h-3.5 min-w-0.5 rounded-xs"
|
||||
:class="cellClass(point)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import BaseCard from '../../../components/base/BaseCard.vue'
|
||||
@@ -8,6 +8,8 @@ import BaseEmpty from '../../../components/base/BaseEmpty.vue'
|
||||
import BaseSegmented from '../../../components/base/BaseSegmented.vue'
|
||||
import { useUiStore } from '../../../stores/modules/ui'
|
||||
|
||||
type TrendKind = 'usage' | 'latency' | 'errors'
|
||||
|
||||
const props = defineProps<{
|
||||
points: any[]
|
||||
summary: any[]
|
||||
@@ -15,15 +17,15 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
trendChange: [tab: string]
|
||||
trendChange: [kind: TrendKind]
|
||||
}>()
|
||||
|
||||
const tabs = [
|
||||
{ label: '用量', value: '用量' },
|
||||
{ label: '延迟', value: '延迟' },
|
||||
{ label: '错误', value: '错误' },
|
||||
{ label: '用量', value: 'usage' },
|
||||
{ label: '延迟', value: 'latency' },
|
||||
{ label: '错误', value: 'errors' },
|
||||
]
|
||||
const activeTab = ref('用量')
|
||||
const activeKind = defineModel<TrendKind>('kind', { required: true })
|
||||
const { themeRevision } = storeToRefs(useUiStore())
|
||||
|
||||
const hasSamples = computed(() =>
|
||||
@@ -104,12 +106,12 @@ function formatTooltip(params: unknown) {
|
||||
}
|
||||
|
||||
function tooltipDisplayValue(point: any, name: string, value: string): string {
|
||||
if (activeTab.value === '用量') {
|
||||
if (activeKind.value === 'usage') {
|
||||
if (name === '输入') return point?.inputTokens ?? value
|
||||
if (name === '输出') return point?.outputTokens ?? value
|
||||
if (name === '缓存') return point?.cachedTokens ?? value
|
||||
}
|
||||
if (activeTab.value === '延迟') {
|
||||
if (activeKind.value === 'latency') {
|
||||
if (name === '平均') return point?.latency ?? value
|
||||
if (name === '最高') return point?.maxLatency ?? value
|
||||
if (name === '最低') return point?.minLatency ?? value
|
||||
@@ -133,7 +135,7 @@ function tooltipIndex(source: unknown) {
|
||||
}
|
||||
|
||||
function getSeries() {
|
||||
if (activeTab.value === '用量') {
|
||||
if (activeKind.value === 'usage') {
|
||||
return [
|
||||
lineSeries(
|
||||
'输入',
|
||||
@@ -153,7 +155,7 @@ function getSeries() {
|
||||
),
|
||||
]
|
||||
}
|
||||
if (activeTab.value === '延迟') {
|
||||
if (activeKind.value === 'latency') {
|
||||
return [
|
||||
lineSeries(
|
||||
'平均',
|
||||
@@ -226,16 +228,20 @@ function lineSeries(name: string, data: number[], color: string, area = false, y
|
||||
function summaryMarkerStyle(item: any) {
|
||||
return item.colorVar ? { backgroundColor: `var(${item.colorVar})` } : undefined
|
||||
}
|
||||
|
||||
function handleTrendChange(value: string) {
|
||||
emit('trendChange', value as TrendKind)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseCard as="article" variant="dashboard" title="使用趋势" class="min-h-95 w-full">
|
||||
<template #actions>
|
||||
<BaseSegmented
|
||||
v-model="activeTab"
|
||||
v-model="activeKind"
|
||||
:options="tabs"
|
||||
class="w-full max-w-61.5 sm:w-61.5"
|
||||
@update:model-value="emit('trendChange', $event)"
|
||||
@update:model-value="handleTrendChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -42,14 +42,14 @@ const filteredRows = computed(() => {
|
||||
variant="dashboard"
|
||||
title="使用记录"
|
||||
description="最近 10 条网关请求"
|
||||
class="h-[468px] w-full"
|
||||
class="h-117 w-full"
|
||||
>
|
||||
<template #actions>
|
||||
<BaseSegmented v-model="activeFilter" :options="filters" class="w-38" />
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="mt-4.25 flex h-[364px] w-full overflow-hidden">
|
||||
<div class="mt-4.25 flex h-91 w-full overflow-hidden">
|
||||
<BaseTable
|
||||
class="min-w-0 flex-1"
|
||||
:columns="dashboardUsageRecordColumns"
|
||||
|
||||
@@ -17,7 +17,10 @@ import { getDashboardSummary, getDashboardTrend } from '@/api'
|
||||
import { withMinimumDuration } from '@/utils/async'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
|
||||
type DashboardTrendKind = 'usage' | 'latency' | 'errors'
|
||||
|
||||
export function useDashboard(): any {
|
||||
const activeTrendKind = ref<DashboardTrendKind>('usage')
|
||||
const metrics = ref<any[]>(metricCards(emptyDashboardSummary()))
|
||||
const trendPoints = ref<any[]>([])
|
||||
const trendSummary = ref<any[]>([])
|
||||
@@ -32,6 +35,7 @@ export function useDashboard(): any {
|
||||
const refreshing = ref(false)
|
||||
const trendLoading = ref(false)
|
||||
const lastRefreshedAt = ref('')
|
||||
let trendRequestId = 0
|
||||
const { resume: startAutoRefresh } = useIntervalFn(
|
||||
() => {
|
||||
void loadDashboardData()
|
||||
@@ -41,10 +45,10 @@ export function useDashboard(): any {
|
||||
)
|
||||
|
||||
async function loadDashboardData() {
|
||||
if (loading.value || refreshing.value) return
|
||||
try {
|
||||
loading.value = true
|
||||
const summary = await getDashboardSummary()
|
||||
applySummary(summary)
|
||||
await loadDashboardSnapshot()
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard data:', error)
|
||||
} finally {
|
||||
@@ -57,8 +61,7 @@ export function useDashboard(): any {
|
||||
refreshing.value = true
|
||||
try {
|
||||
await withMinimumDuration(async () => {
|
||||
const summary = await getDashboardSummary()
|
||||
applySummary(summary)
|
||||
await loadDashboardSnapshot()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh dashboard data:', error)
|
||||
@@ -67,21 +70,43 @@ export function useDashboard(): any {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTrend(tab: string) {
|
||||
const kind = trendKindFromTab(tab)
|
||||
async function loadTrend(kind: string) {
|
||||
const trendKind = normalizeTrendKind(kind)
|
||||
activeTrendKind.value = trendKind
|
||||
const requestId = nextTrendRequestId()
|
||||
try {
|
||||
trendLoading.value = true
|
||||
applyTrend(await getDashboardTrend({ kind }))
|
||||
const trend = await getDashboardTrend({ kind: trendKind })
|
||||
if (isCurrentTrendRequest(requestId, trendKind)) {
|
||||
applyTrend(trend)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard trend:', error)
|
||||
} finally {
|
||||
trendLoading.value = false
|
||||
if (isCurrentTrendRequest(requestId, trendKind)) {
|
||||
trendLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDashboardSnapshot() {
|
||||
const trendKind = activeTrendKind.value
|
||||
const requestId = nextTrendRequestId()
|
||||
try {
|
||||
const summary = await getDashboardSummary({ kind: trendKind })
|
||||
applySummary(summary)
|
||||
if (isCurrentTrendRequest(requestId, trendKind)) {
|
||||
applyTrend(summary.trend)
|
||||
}
|
||||
} finally {
|
||||
if (isCurrentTrendRequest(requestId, trendKind)) {
|
||||
trendLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applySummary(summary: any) {
|
||||
metrics.value = metricCards(summary)
|
||||
applyTrend(summary.trend)
|
||||
healthTimeline.value = summary.healthTimeline
|
||||
accountUsage.value = summary.accountUsage.map(accountUsageItem)
|
||||
serviceStatuses.value = summary.serviceStatuses.map(serviceStatusItem)
|
||||
@@ -177,6 +202,8 @@ export function useDashboard(): any {
|
||||
{
|
||||
title: '账号',
|
||||
value: accounts.total,
|
||||
valueRaw: accounts.totalValue,
|
||||
valueFormatter: formatDashboardCompactNumber,
|
||||
icon: Users,
|
||||
tone: 'normal',
|
||||
details: [
|
||||
@@ -186,7 +213,7 @@ export function useDashboard(): any {
|
||||
tone: accounts.enabledValue > 0 ? 'success' : 'normal',
|
||||
},
|
||||
{
|
||||
label: '异常',
|
||||
label: '不可用',
|
||||
value: accounts.abnormal,
|
||||
tone: accounts.abnormalValue > 0 ? 'danger' : 'normal',
|
||||
},
|
||||
@@ -195,6 +222,8 @@ export function useDashboard(): any {
|
||||
{
|
||||
title: '今日请求',
|
||||
value: traffic.todayRequests,
|
||||
valueRaw: traffic.todayRequestsValue,
|
||||
valueFormatter: formatDashboardCompactNumber,
|
||||
icon: Activity,
|
||||
tone: 'info',
|
||||
sparkline: sparkline(
|
||||
@@ -214,6 +243,8 @@ export function useDashboard(): any {
|
||||
{
|
||||
title: '今日 Token',
|
||||
value: tokens.todayTokens,
|
||||
valueRaw: tokens.todayTokensValue,
|
||||
valueFormatter: formatDashboardCompactNumber,
|
||||
icon: FileText,
|
||||
tone: 'success',
|
||||
sparkline: sparkline(
|
||||
@@ -225,7 +256,7 @@ export function useDashboard(): any {
|
||||
{ label: '总 Token', value: tokens.totalTokens, tone: 'success' },
|
||||
{
|
||||
label: '计费',
|
||||
value: `${tokens.totalCostUsd} / ${tokens.todayCostUsd}`,
|
||||
value: tokens.todayCostUsd,
|
||||
tone: 'success',
|
||||
},
|
||||
],
|
||||
@@ -233,6 +264,8 @@ export function useDashboard(): any {
|
||||
{
|
||||
title: '今日缓存命中',
|
||||
value: cache.todayHitRate,
|
||||
valueRaw: cache.todayHitRateValue,
|
||||
valueFormatter: formatDashboardRate,
|
||||
icon: Timer,
|
||||
tone: cache.todayHitRateValue && cache.todayHitRateValue > 0 ? 'warning' : 'normal',
|
||||
sparkline: sparkline(
|
||||
@@ -260,6 +293,37 @@ export function useDashboard(): any {
|
||||
return values.some((value) => value > 0) ? { values, tone } : undefined
|
||||
}
|
||||
|
||||
function formatDashboardCompactNumber(value: number) {
|
||||
const normalized = Math.max(0, Math.round(value))
|
||||
|
||||
if (normalized < 1_000) {
|
||||
return new Intl.NumberFormat('zh-CN').format(normalized)
|
||||
}
|
||||
|
||||
for (const [unit, threshold] of [
|
||||
['P', 1_000_000_000_000_000],
|
||||
['T', 1_000_000_000_000],
|
||||
['B', 1_000_000_000],
|
||||
['M', 1_000_000],
|
||||
['K', 1_000],
|
||||
] as const) {
|
||||
if (normalized >= threshold) {
|
||||
return `${formatDashboardCompactScaled(normalized / threshold)}${unit}`
|
||||
}
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat('zh-CN').format(normalized)
|
||||
}
|
||||
|
||||
function formatDashboardCompactScaled(value: number) {
|
||||
const rounded = value >= 10 ? value.toFixed(1) : value.toFixed(2)
|
||||
return rounded.replace(/\.?0+$/, '')
|
||||
}
|
||||
|
||||
function formatDashboardRate(value: number) {
|
||||
return Number.isFinite(value) ? `${(value * 100).toFixed(1)}%` : '—'
|
||||
}
|
||||
|
||||
function applyTrend(trend: any) {
|
||||
trendPoints.value = trend.points
|
||||
trendSummary.value = (trend.summary as any[]).map((item) => {
|
||||
@@ -294,7 +358,7 @@ export function useDashboard(): any {
|
||||
return {
|
||||
name: item.name,
|
||||
email: item.email || '-',
|
||||
plan: item.plan || 'free',
|
||||
planType: item.plan || 'free',
|
||||
requests: item.requests,
|
||||
tokens: item.tokens,
|
||||
lastUsed: item.lastUsed || '-',
|
||||
@@ -333,12 +397,20 @@ export function useDashboard(): any {
|
||||
return previous > 0 || current > 0 ? { direction: 'flat', tone: fallbackTone } : undefined
|
||||
}
|
||||
|
||||
function trendKindFromTab(tab: string) {
|
||||
if (tab === '延迟') return 'latency'
|
||||
if (tab === '错误') return 'errors'
|
||||
function normalizeTrendKind(kind: string): DashboardTrendKind {
|
||||
if (kind === 'latency' || kind === 'errors') return kind
|
||||
return 'usage'
|
||||
}
|
||||
|
||||
function nextTrendRequestId() {
|
||||
trendRequestId += 1
|
||||
return trendRequestId
|
||||
}
|
||||
|
||||
function isCurrentTrendRequest(requestId: number, kind: DashboardTrendKind) {
|
||||
return requestId === trendRequestId && activeTrendKind.value === kind
|
||||
}
|
||||
|
||||
function trendSummaryTone(label: string) {
|
||||
if (label.includes('错误')) return 'danger'
|
||||
if (label.includes('最高')) return 'warning'
|
||||
@@ -380,6 +452,7 @@ export function useDashboard(): any {
|
||||
loading,
|
||||
refreshing,
|
||||
trendLoading,
|
||||
activeTrendKind,
|
||||
lastRefreshedAt,
|
||||
metrics,
|
||||
trendPoints,
|
||||
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
loading,
|
||||
refreshing,
|
||||
trendLoading,
|
||||
activeTrendKind,
|
||||
metrics,
|
||||
trendPoints,
|
||||
trendSummary,
|
||||
@@ -65,6 +66,7 @@ const {
|
||||
class="mt-6 grid grid-cols-1 gap-6 2xl:grid-cols-[minmax(0,948fr)_minmax(0,608fr)] 2xl:gap-7"
|
||||
>
|
||||
<RequestTrendCard
|
||||
v-model:kind="activeTrendKind"
|
||||
:points="trendPoints"
|
||||
:summary="trendSummary"
|
||||
:loading="trendLoading"
|
||||
|
||||
@@ -41,7 +41,7 @@ async function handleSubmit(): Promise<void> {
|
||||
<LoginBackground />
|
||||
|
||||
<section
|
||||
class="grid min-h-dvh items-center justify-items-center px-5 py-[clamp(24px,5vh,64px)] min-[980px]:justify-items-end min-[980px]:pr-[clamp(48px,17.3vw,332px)] max-[560px]:p-4.5"
|
||||
class="grid min-h-dvh items-center justify-items-center px-5 py-[clamp(24px,5dvh,64px)] min-[980px]:justify-items-end min-[980px]:pr-[clamp(48px,17.3vw,332px)] max-[560px]:p-4.5"
|
||||
aria-label="Codex Proxy RS 登录"
|
||||
>
|
||||
<LoginPanel
|
||||
|
||||
@@ -36,8 +36,8 @@ const billingItems = computed(() => {
|
||||
|
||||
function itemValueClass(tone?: string, accent?: boolean) {
|
||||
if (tone === 'success') return 'text-(--cp-success-text)'
|
||||
if (tone === 'info' || accent) return 'text-[#93c5fd]'
|
||||
return 'text-white'
|
||||
if (tone === 'info' || accent) return 'text-(--cp-info-text)'
|
||||
return 'text-(--cp-text-primary)'
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -52,7 +52,7 @@ function itemValueClass(tone?: string, accent?: boolean) {
|
||||
trigger="hover"
|
||||
placement="right"
|
||||
width="248px"
|
||||
panel-class="!bg-[#111827] !p-3 text-white shadow-(--cp-shadow-popover)"
|
||||
panel-class="!p-3 text-(--cp-text-primary)"
|
||||
>
|
||||
<template #trigger>
|
||||
<button
|
||||
@@ -64,9 +64,11 @@ function itemValueClass(tone?: string, accent?: boolean) {
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div class="grid gap-2 text-[12px] leading-none [&_span:first-child]:whitespace-nowrap">
|
||||
<p class="m-0 font-[760] text-white">成本明细</p>
|
||||
<div class="grid gap-1.5 text-[#cbd5e1]">
|
||||
<div
|
||||
class="grid gap-2 text-[12px] leading-none [&_span:first-child]:whitespace-nowrap [&_span:last-child]:whitespace-nowrap"
|
||||
>
|
||||
<p class="m-0 font-[760] text-(--cp-text-primary)">成本明细</p>
|
||||
<div class="grid gap-1.5 text-(--cp-text-secondary)">
|
||||
<div v-for="item in mainCostItems" :key="item.label" class="flex justify-between gap-4">
|
||||
<span>{{ item.label }}</span>
|
||||
<span class="font-mono font-[760]" :class="itemValueClass(undefined, item.accent)">
|
||||
@@ -74,7 +76,9 @@ function itemValueClass(tone?: string, accent?: boolean) {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 grid gap-1.5 border-t border-white/12 pt-2 text-[#cbd5e1]">
|
||||
<div
|
||||
class="mt-1 grid gap-1.5 border-t border-(--cp-divider-subtle) pt-2 text-(--cp-text-secondary)"
|
||||
>
|
||||
<div v-for="item in billingItems" :key="item.label" class="flex justify-between gap-4">
|
||||
<span>{{ item.label }}</span>
|
||||
<span class="font-mono font-[760]" :class="itemValueClass(item.tone)">
|
||||
|
||||
@@ -216,7 +216,7 @@ function themeColor(name: string, fallback: string) {
|
||||
variant="info"
|
||||
width="960px"
|
||||
scrollable
|
||||
body-max-height="min(76vh,820px)"
|
||||
body-max-height="min(76dvh,820px)"
|
||||
body-view-class="grid gap-3"
|
||||
>
|
||||
<template v-if="record">
|
||||
@@ -365,7 +365,7 @@ function themeColor(name: string, fallback: string) {
|
||||
|
||||
<section v-if="record.metadata" :class="[panelClass, 'min-h-0']">
|
||||
<h3 class="mb-3" :class="panelTitleClass">元数据</h3>
|
||||
<BaseScrollbar max-height="min(32vh, 340px)" :view-class="codeBlockViewClass">
|
||||
<BaseScrollbar max-height="min(32dvh, 340px)" :view-class="codeBlockViewClass">
|
||||
<pre :class="codeBlockClass">{{ JSON.stringify(record.metadata, null, 2) }}</pre>
|
||||
</BaseScrollbar>
|
||||
</section>
|
||||
|
||||
@@ -40,8 +40,8 @@ const tokenItems = computed(() => [
|
||||
<BasePopover
|
||||
trigger="hover"
|
||||
placement="right"
|
||||
width="168px"
|
||||
panel-class="!bg-[#111827] !p-3 text-white shadow-(--cp-shadow-popover)"
|
||||
width="196px"
|
||||
panel-class="!p-3 text-(--cp-text-primary)"
|
||||
>
|
||||
<template #trigger>
|
||||
<button
|
||||
@@ -54,18 +54,18 @@ const tokenItems = computed(() => [
|
||||
</template>
|
||||
|
||||
<div class="grid gap-2 text-[12px] leading-none">
|
||||
<p class="m-0 font-[760] text-white">Token 明细</p>
|
||||
<div class="grid gap-1.5 text-[#cbd5e1]">
|
||||
<p class="m-0 font-[760] text-(--cp-text-primary)">Token 明细</p>
|
||||
<div class="grid gap-1.5 text-(--cp-text-secondary)">
|
||||
<div v-for="item in tokenItems" :key="item.label" class="flex justify-between gap-4">
|
||||
<span>{{ item.label }}</span>
|
||||
<span class="font-mono font-[760] text-white">
|
||||
<span class="whitespace-nowrap">{{ item.label }}</span>
|
||||
<span class="whitespace-nowrap font-mono font-[760] text-(--cp-text-primary)">
|
||||
{{ item.value }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 flex justify-between border-t border-white/12 pt-2">
|
||||
<span class="text-[#cbd5e1]">总 Token</span>
|
||||
<span class="font-mono font-[760] text-[#93c5fd]">
|
||||
<div class="mt-1 flex justify-between border-t border-(--cp-divider-subtle) pt-2">
|
||||
<span class="whitespace-nowrap text-(--cp-text-secondary)">总 Token</span>
|
||||
<span class="whitespace-nowrap font-mono font-[760] text-(--cp-info-text)">
|
||||
{{ tokenDetails.totalTokensDisplay }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -153,7 +153,7 @@ export function useUsageRecordsTable(options: {
|
||||
if (refreshingList.value || loading.value) return
|
||||
refreshingList.value = true
|
||||
try {
|
||||
await withMinimumDuration(loadUsageRecords)
|
||||
await withMinimumDuration(() => loadUsageRecords('table'))
|
||||
} finally {
|
||||
refreshingList.value = false
|
||||
}
|
||||
|
||||
@@ -170,14 +170,6 @@ export function formatLatencyAverage(value: number | null) {
|
||||
return `${Math.round(value)} ms`
|
||||
}
|
||||
|
||||
export function formatCostMetric(value: number) {
|
||||
if (!value) {
|
||||
return '$0.000000'
|
||||
}
|
||||
|
||||
return `$${Number(value || 0).toFixed(6)}`
|
||||
}
|
||||
|
||||
export function usageRecordType(record: any) {
|
||||
if (record?.transport === 'websocket') {
|
||||
return 'WS'
|
||||
|
||||
@@ -1 +1 @@
|
||||
version: "1.0.1"
|
||||
version: "1.0.3"
|
||||
|
||||
Reference in New Issue
Block a user