mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-08 16:05:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e30aad4755 | ||
|
|
945f7912ac | ||
|
|
19038f264a | ||
|
|
71c3b03878 |
@@ -558,6 +558,9 @@ pub struct UsageListRecord {
|
||||
pub provider_account_email: Option<String>,
|
||||
/// 账号当前备注,按内部账号 ID 关联,不属于请求历史快照。
|
||||
pub provider_account_notes: Option<String>,
|
||||
/// 账号当前套餐,不属于请求历史快照。
|
||||
pub provider_account_plan_type: Option<String>,
|
||||
pub provider_account_plan_type_display: Option<String>,
|
||||
pub provider_account_authentication_kind: Option<String>,
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
@@ -757,11 +760,20 @@ pub struct UsageSummary {
|
||||
pub average_latency_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// 诊断聚合结果,分母包含截取展示项之前的全部匹配请求。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct DiagnosticsObservation {
|
||||
pub total_request_count: u64,
|
||||
pub items: Vec<DiagnosticObservation>,
|
||||
}
|
||||
|
||||
/// 单个诊断维度值的聚合结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DiagnosticObservation {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub account_provider_kind: Option<String>,
|
||||
pub account_plan_type: Option<String>,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub failure_count: u64,
|
||||
@@ -772,6 +784,7 @@ pub struct DiagnosticObservation {
|
||||
pub first_token_p95_ms: Option<u64>,
|
||||
pub non_completion_count: u64,
|
||||
pub retry_count: u64,
|
||||
pub retried_request_count: u64,
|
||||
pub cost_coverage: CostCoverage,
|
||||
pub costs: Vec<CurrencyCost>,
|
||||
}
|
||||
@@ -1142,6 +1155,8 @@ pub struct UsageInsights {
|
||||
pub struct DiagnosticsItem {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub account_plan_type: Option<String>,
|
||||
pub account_plan_type_display: Option<String>,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub error_count: u64,
|
||||
|
||||
@@ -28,9 +28,6 @@ pub struct AccountQuotaForecast {
|
||||
pub incomplete_tokens: bool,
|
||||
pub estimated_tokens: Option<u64>,
|
||||
pub estimated_usd: Option<f64>,
|
||||
/// 剩余估算始终属于源窗口,不随目标周期折算。
|
||||
pub remaining_tokens: Option<u64>,
|
||||
pub remaining_usd: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -81,8 +78,6 @@ pub fn account_quota_forecasts(
|
||||
incomplete_tokens: false,
|
||||
estimated_tokens: None,
|
||||
estimated_usd: None,
|
||||
remaining_tokens: None,
|
||||
remaining_usd: None,
|
||||
};
|
||||
if let Some((window, source_period)) = selected {
|
||||
forecast.project(
|
||||
@@ -191,14 +186,13 @@ impl AccountQuotaForecast {
|
||||
let remaining_tokens = Some(sample.usage.tokens)
|
||||
.filter(|tokens| *tokens > 0)
|
||||
.and_then(|value| estimate(value as f64, remaining_factor));
|
||||
self.remaining_tokens = remaining_tokens.and_then(|value| estimate_tokens(value, 1.0));
|
||||
self.estimated_tokens = remaining_tokens
|
||||
.and_then(|remaining| estimate_tokens(usage.tokens as f64 + remaining, factor));
|
||||
self.remaining_usd = Some(&sample.usage)
|
||||
let remaining_usd = Some(&sample.usage)
|
||||
.filter(|usage| usage.known_cost_count > 0 && usage.usd.is_finite() && usage.usd >= 0.0)
|
||||
.and_then(|usage| estimate(usage.usd, remaining_factor));
|
||||
self.estimated_usd = usd
|
||||
.zip(self.remaining_usd)
|
||||
.zip(remaining_usd)
|
||||
.and_then(|(used, remaining)| estimate(used + remaining, factor));
|
||||
self.unavailable_reason = if self.estimated_tokens.is_none() && self.estimated_usd.is_none()
|
||||
{
|
||||
|
||||
@@ -27,7 +27,7 @@ use crate::model::{
|
||||
UpdateClientKey, UpdateClientKeyBudgetLimits,
|
||||
},
|
||||
observability::{
|
||||
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation,
|
||||
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticsObservation,
|
||||
OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange, UsageCalculatedBillingFact,
|
||||
UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
|
||||
},
|
||||
@@ -444,7 +444,7 @@ pub trait ObservabilityStore: Send + Sync {
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
dimension: DiagnosticDimension,
|
||||
) -> AdminStoreResult<Vec<DiagnosticObservation>>;
|
||||
) -> AdminStoreResult<DiagnosticsObservation>;
|
||||
|
||||
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage>;
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ impl DefaultObservabilityService {
|
||||
self.store.dashboard_runtime_slots(observed_at),
|
||||
)
|
||||
.map_err(|error| map_store_error(error, "dashboard"))?;
|
||||
self.enrich_list_billing(&mut observation.recent_requests);
|
||||
self.enrich_list_records(&mut observation.recent_requests);
|
||||
self.enrich_dashboard_quotas(&mut observation.account_usage)
|
||||
.await;
|
||||
for account in &mut observation.account_usage {
|
||||
@@ -268,7 +268,7 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
.list_usage_records(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage records"))?;
|
||||
self.enrich_list_billing(&mut page.items);
|
||||
self.enrich_list_records(&mut page.items);
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
@@ -334,29 +334,37 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
filter: UsageFilter,
|
||||
dimension: DiagnosticDimension,
|
||||
) -> Result<DiagnosticsResult, AdminError> {
|
||||
let items = self
|
||||
let observation = self
|
||||
.store
|
||||
.usage_diagnostics(range, filter, dimension)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage diagnostics"))?;
|
||||
let total_requests = items.iter().fold(0_u64, |total, item| {
|
||||
total.saturating_add(item.request_count)
|
||||
});
|
||||
let items = items
|
||||
let items = observation
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let error_rate = rate_or_zero(item.failure_count, item.request_count);
|
||||
let non_completion_rate =
|
||||
rate_or_zero(item.non_completion_count, item.request_count);
|
||||
let retry_rate = rate_or_zero(item.retry_count, item.request_count);
|
||||
let retry_rate = rate_or_zero(item.retried_request_count, item.request_count);
|
||||
DiagnosticsItem {
|
||||
key: item.key,
|
||||
name: item.name,
|
||||
account_plan_type_display: item.account_provider_kind.as_deref().and_then(
|
||||
|provider| {
|
||||
self.providers
|
||||
.plan_type_display(provider, item.account_plan_type.as_deref())
|
||||
},
|
||||
),
|
||||
account_plan_type: item.account_plan_type,
|
||||
request_count: item.request_count,
|
||||
success_count: item.success_count,
|
||||
error_count: item.failure_count,
|
||||
error_rate,
|
||||
request_share: rate_or_zero(item.request_count, total_requests),
|
||||
request_share: rate_or_zero(
|
||||
item.request_count,
|
||||
observation.total_request_count,
|
||||
),
|
||||
average_latency_ms: item.average_latency_ms,
|
||||
latency_p95_ms: item.latency_p95_ms,
|
||||
first_token_p95_ms: item.first_token_p95_ms,
|
||||
@@ -772,10 +780,15 @@ impl DefaultObservabilityService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 逐条尽力把可校验的总额升级为完整分解;单条脏数据(非法 Provider kind、
|
||||
/// 补充当前套餐展示名称,逐条尽力把可校验的总额升级为完整分解;单条脏数据(非法 Provider kind、
|
||||
/// 不支持的来源或费用规则失败)只保留该条已存的总额,不影响整页返回。
|
||||
fn enrich_list_billing(&self, records: &mut [crate::model::observability::UsageListRecord]) {
|
||||
fn enrich_list_records(&self, records: &mut [crate::model::observability::UsageListRecord]) {
|
||||
for record in records {
|
||||
record.provider_account_plan_type_display =
|
||||
record.provider_kind.as_deref().and_then(|provider| {
|
||||
self.providers
|
||||
.plan_type_display(provider, record.provider_account_plan_type.as_deref())
|
||||
});
|
||||
let Some(UsageBilling::Total { source, total }) = record.billing.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -114,18 +114,14 @@ fn samples(quota: &ProviderQuota) -> Vec<QuotaForecastSample> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forecasts_use_actual_periods_and_do_not_extrapolate_remaining_capacity() {
|
||||
fn forecasts_use_actual_periods_and_extrapolate_total_capacity() {
|
||||
let [week, month] = forecast("a(vec![window("week", 7)]));
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.remaining_tokens, Some(4_000));
|
||||
assert_eq!(week.remaining_usd, Some(8.0));
|
||||
assert!(!week.extrapolated);
|
||||
assert!(month.extrapolated);
|
||||
assert_eq!(month.estimated_tokens, Some(21_429));
|
||||
assert!((month.estimated_usd.unwrap() - 10.0 * 30.0 / 7.0).abs() < 1e-10);
|
||||
assert_eq!(month.remaining_tokens, week.remaining_tokens);
|
||||
assert_eq!(month.remaining_usd, week.remaining_usd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -203,8 +199,8 @@ fn low_samples_warn_and_exhaustion_uses_raw_not_display_percentages() {
|
||||
source.windows[0].used_percent = Some(100.0);
|
||||
let [week, _] = forecast(&source);
|
||||
assert!(!week.low_sample);
|
||||
assert_eq!(week.remaining_tokens, Some(0));
|
||||
assert_eq!(week.remaining_usd, Some(0.0));
|
||||
assert_eq!(week.estimated_tokens, Some(1_000));
|
||||
assert_eq!(week.estimated_usd, Some(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -257,8 +253,7 @@ fn incremental_sample_supports_mid_cycle_accounts_and_remaining_uses_current_per
|
||||
let [week, month] =
|
||||
account_quota_forecasts(&source, now() - Duration::days(1), now(), &[sample]);
|
||||
assert_eq!(week.estimated_tokens, Some(9_000));
|
||||
assert_eq!(week.remaining_tokens, Some(8_000));
|
||||
assert_eq!(month.remaining_tokens, Some(8_000));
|
||||
assert_eq!(month.estimated_tokens, Some(38_571));
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert!(!week.low_sample);
|
||||
}
|
||||
@@ -273,7 +268,6 @@ fn missing_tokens_keep_estimates_from_recorded_usage() {
|
||||
assert!(week.incomplete_tokens);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.remaining_tokens, Some(4_000));
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.source.unwrap().tokens, Some(1_000));
|
||||
}
|
||||
@@ -308,13 +302,12 @@ fn partial_costs_keep_estimates_from_known_amounts() {
|
||||
assert!(week.incomplete_cost);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.remaining_usd, Some(8.0));
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_tokens_and_costs_do_not_block_recorded_capacity_or_remaining_estimates() {
|
||||
fn missing_tokens_and_costs_do_not_block_recorded_capacity_estimates() {
|
||||
let source = quota(vec![window("week", 7)]);
|
||||
let mut sample = samples(&source).remove(0);
|
||||
// 完整请求之外混有缺少计量的成功请求,不把已有用量和费用一并作废。
|
||||
@@ -331,11 +324,7 @@ fn missing_tokens_and_costs_do_not_block_recorded_capacity_or_remaining_estimate
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.remaining_tokens, Some(4_000));
|
||||
assert_eq!(week.remaining_usd, Some(8.0));
|
||||
assert_eq!(month.estimated_tokens, Some(21_429));
|
||||
assert_eq!(month.remaining_tokens, week.remaining_tokens);
|
||||
assert_eq!(month.remaining_usd, week.remaining_usd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -352,7 +341,6 @@ fn entirely_unknown_costs_leave_only_money_estimates_unavailable() {
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.estimated_usd, None);
|
||||
assert_eq!(week.remaining_usd, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -370,8 +358,6 @@ fn entirely_unknown_tokens_and_costs_do_not_invent_zero_estimates() {
|
||||
assert!(week.unavailable_reason.is_some());
|
||||
assert_eq!(week.estimated_tokens, None);
|
||||
assert_eq!(week.estimated_usd, None);
|
||||
assert_eq!(week.remaining_tokens, None);
|
||||
assert_eq!(week.remaining_usd, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -420,11 +406,8 @@ fn slower_recent_consumption_preserves_recorded_cycle_usage_in_total_forecast()
|
||||
account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert_eq!(cycle.source.as_ref().unwrap().tokens, Some(1_084_786_565));
|
||||
assert_eq!(cycle.source.as_ref().unwrap().usd, Some(1_412.57957384));
|
||||
assert_eq!(cycle.remaining_tokens, Some(66_459_719));
|
||||
assert_eq!(cycle.estimated_tokens, Some(1_151_246_284));
|
||||
assert!((cycle.remaining_usd.unwrap() - 111.628272395294).abs() < 1e-8);
|
||||
assert!((cycle.estimated_usd.unwrap() - 1_524.207846235294).abs() < 1e-8);
|
||||
assert_eq!(month.remaining_tokens, cycle.remaining_tokens);
|
||||
assert!(
|
||||
(month.estimated_usd.unwrap() - cycle.estimated_usd.unwrap() * 30.0 / 7.0).abs() < 1e-8
|
||||
);
|
||||
@@ -445,6 +428,4 @@ fn exhausted_cycle_total_is_recorded_usage_even_when_recent_sample_is_small() {
|
||||
let [cycle, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert_eq!(cycle.estimated_tokens, Some(1_000));
|
||||
assert_eq!(cycle.estimated_usd, Some(2.0));
|
||||
assert_eq!(cycle.remaining_tokens, Some(0));
|
||||
assert_eq!(cycle.remaining_usd, Some(0.0));
|
||||
}
|
||||
|
||||
@@ -2430,7 +2430,6 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
|
||||
.unwrap();
|
||||
assert!(result.forecasts[0].unavailable_reason.is_none());
|
||||
assert_eq!(result.forecasts[0].estimated_tokens, Some(5_000));
|
||||
assert_eq!(result.forecasts[0].remaining_tokens, Some(3_000));
|
||||
assert_eq!(store.quota_window_queries()[0].range.start, added);
|
||||
store
|
||||
.quota_forecast_history
|
||||
@@ -2471,7 +2470,6 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
|
||||
let cycle = &result.forecasts[0];
|
||||
assert!(cycle.unavailable_reason.is_none());
|
||||
assert_eq!(cycle.source.as_ref().unwrap().tokens, Some(250));
|
||||
assert_eq!(cycle.remaining_tokens, Some(429));
|
||||
assert_eq!(cycle.estimated_tokens, Some(679));
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ use gateway_admin::{
|
||||
observability::{
|
||||
DashboardDesktopRelease, DashboardObservation, DashboardWireAttribute,
|
||||
DashboardWireProfile, DashboardWireTarget, DesktopReleaseStatus, DiagnosticDimension,
|
||||
DiagnosticObservation, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange,
|
||||
DiagnosticsObservation, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange,
|
||||
UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
|
||||
},
|
||||
provider_credentials::{
|
||||
@@ -770,7 +770,7 @@ impl ObservabilityStore for UnavailableStore {
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
_: DiagnosticDimension,
|
||||
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
|
||||
) -> AdminStoreResult<DiagnosticsObservation> {
|
||||
Err(unavailable("usage diagnostics"))
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,11 @@ use gateway_admin::{
|
||||
MutationContext, PageSize, Revision,
|
||||
observability::{
|
||||
AccountPoolMetrics, AttemptMetrics, CostCoverage, CurrencyCost, DashboardObservation,
|
||||
DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation, Granularity,
|
||||
HealthStatus, LatencyPercentiles, OpsErrorPage, OpsErrorQuery, PercentileMilliseconds,
|
||||
RequestMetricPoint, RequestMetrics, TimeRange, TrendKind, UsageBilling,
|
||||
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageListRecord, UsageOverview,
|
||||
UsagePage, UsageQuery, china_day_start,
|
||||
DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation,
|
||||
DiagnosticsObservation, Granularity, HealthStatus, LatencyPercentiles, OpsErrorPage,
|
||||
OpsErrorQuery, PercentileMilliseconds, RequestMetricPoint, RequestMetrics, TimeRange,
|
||||
TrendKind, UsageBilling, UsageCalculatedBillingFact, UsageDetail, UsageFilter,
|
||||
UsageListRecord, UsageOverview, UsagePage, UsageQuery, china_day_start,
|
||||
},
|
||||
settings::{
|
||||
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
|
||||
@@ -421,7 +421,10 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
|
||||
.expect("calculated total"),
|
||||
},
|
||||
}]);
|
||||
store.replace_diagnostics(vec![diagnostic("openai", 3), diagnostic("xai", 1)]);
|
||||
store.replace_diagnostics(DiagnosticsObservation {
|
||||
total_request_count: 4,
|
||||
items: vec![diagnostic("openai", 3), diagnostic("xai", 1)],
|
||||
});
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
|
||||
let insights = services
|
||||
@@ -513,7 +516,10 @@ async fn diagnostics_should_preserve_request_order_when_a_small_sample_fails() {
|
||||
let mut failure = diagnostic("single_failure", 1);
|
||||
failure.success_count = 0;
|
||||
failure.failure_count = 1;
|
||||
store.replace_diagnostics(vec![success, failure]);
|
||||
store.replace_diagnostics(DiagnosticsObservation {
|
||||
total_request_count: 101,
|
||||
items: vec![success, failure],
|
||||
});
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
|
||||
let result = services
|
||||
@@ -529,6 +535,68 @@ async fn diagnostics_should_preserve_request_order_when_a_small_sample_fails() {
|
||||
assert_eq!(result.items[1].error_rate, 1.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostics_should_use_the_total_before_truncating_groups() {
|
||||
let range = observation_range(Utc::now());
|
||||
let store = Arc::new(FixtureObservabilityStore::new(range));
|
||||
store.replace_diagnostics(DiagnosticsObservation {
|
||||
total_request_count: 200,
|
||||
items: (0..100)
|
||||
.map(|index| diagnostic(&format!("model-{index}"), 1))
|
||||
.collect(),
|
||||
});
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
|
||||
let result = services
|
||||
.observability()
|
||||
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Model)
|
||||
.await
|
||||
.expect("limited diagnostics");
|
||||
|
||||
assert!(result.items.iter().all(|item| item.request_share == 0.005));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostics_should_count_each_retried_request_once() {
|
||||
let range = observation_range(Utc::now());
|
||||
let store = Arc::new(FixtureObservabilityStore::new(range));
|
||||
let mut item = diagnostic("model", 8);
|
||||
item.attempt_count = 14;
|
||||
item.retry_count = 6;
|
||||
item.retried_request_count = 2;
|
||||
store.replace_diagnostics(DiagnosticsObservation {
|
||||
total_request_count: 8,
|
||||
items: vec![item],
|
||||
});
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
|
||||
let result = services
|
||||
.observability()
|
||||
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Model)
|
||||
.await
|
||||
.expect("retry diagnostics");
|
||||
|
||||
assert_eq!(
|
||||
(result.items[0].retry_count, result.items[0].retry_rate),
|
||||
(6, 0.25)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostics_should_preserve_empty_results() {
|
||||
let range = observation_range(Utc::now());
|
||||
let store = Arc::new(FixtureObservabilityStore::new(range));
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
|
||||
let result = services
|
||||
.observability()
|
||||
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Account)
|
||||
.await
|
||||
.expect("empty diagnostics");
|
||||
|
||||
assert!(result.items.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_records_should_tolerate_records_that_fail_billing_enrichment() {
|
||||
let now = Utc::now();
|
||||
@@ -632,7 +700,7 @@ struct FixtureObservabilityStore {
|
||||
overview: Mutex<UsageOverview>,
|
||||
calculated_billing_facts: Mutex<Vec<UsageCalculatedBillingFact>>,
|
||||
billing_stream_fails: AtomicBool,
|
||||
diagnostics: Mutex<Vec<DiagnosticObservation>>,
|
||||
diagnostics: Mutex<DiagnosticsObservation>,
|
||||
runtime_slots: Mutex<Option<DashboardRuntimeSlots>>,
|
||||
summary_observed_at: Mutex<Option<DateTime<Utc>>>,
|
||||
slots_observed_at: Mutex<Option<DateTime<Utc>>>,
|
||||
@@ -653,7 +721,7 @@ impl FixtureObservabilityStore {
|
||||
}),
|
||||
calculated_billing_facts: Mutex::new(Vec::new()),
|
||||
billing_stream_fails: AtomicBool::new(false),
|
||||
diagnostics: Mutex::new(Vec::new()),
|
||||
diagnostics: Mutex::new(DiagnosticsObservation::default()),
|
||||
runtime_slots: Mutex::new(None),
|
||||
summary_observed_at: Mutex::new(None),
|
||||
slots_observed_at: Mutex::new(None),
|
||||
@@ -686,7 +754,7 @@ impl FixtureObservabilityStore {
|
||||
.expect("calculated billing facts") = facts;
|
||||
}
|
||||
|
||||
fn replace_diagnostics(&self, diagnostics: Vec<DiagnosticObservation>) {
|
||||
fn replace_diagnostics(&self, diagnostics: DiagnosticsObservation) {
|
||||
*self.diagnostics.lock().expect("diagnostics") = diagnostics;
|
||||
}
|
||||
|
||||
@@ -798,7 +866,7 @@ impl ObservabilityStore for FixtureObservabilityStore {
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
_: DiagnosticDimension,
|
||||
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
|
||||
) -> AdminStoreResult<DiagnosticsObservation> {
|
||||
Ok(self.diagnostics.lock().expect("diagnostics").clone())
|
||||
}
|
||||
|
||||
@@ -948,6 +1016,8 @@ fn total_record(
|
||||
provider_account_name: None,
|
||||
provider_account_email: None,
|
||||
provider_account_notes: None,
|
||||
provider_account_plan_type: None,
|
||||
provider_account_plan_type_display: None,
|
||||
provider_account_authentication_kind: None,
|
||||
upstream_model_id: Some("gpt-5.5".to_owned()),
|
||||
upstream_transport: None,
|
||||
@@ -1010,6 +1080,9 @@ fn diagnostic(name: &str, request_count: u64) -> DiagnosticObservation {
|
||||
first_token_p95_ms: None,
|
||||
non_completion_count: 0,
|
||||
retry_count: 0,
|
||||
retried_request_count: 0,
|
||||
account_provider_kind: None,
|
||||
account_plan_type: None,
|
||||
key: name.to_owned(),
|
||||
name: name.to_owned(),
|
||||
request_count,
|
||||
|
||||
@@ -180,10 +180,6 @@ fn quota_forecast_view(forecast: AccountQuotaForecast) -> AccountQuotaForecastVi
|
||||
estimated_tokens_display: display_optional_tokens(forecast.estimated_tokens),
|
||||
estimated_usd: forecast.estimated_usd,
|
||||
estimated_usd_display: forecast_usd_display(forecast.estimated_usd),
|
||||
remaining_tokens: forecast.remaining_tokens,
|
||||
remaining_tokens_display: display_optional_tokens(forecast.remaining_tokens),
|
||||
remaining_usd: forecast.remaining_usd,
|
||||
remaining_usd_display: forecast_usd_display(forecast.remaining_usd),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -362,10 +362,6 @@ pub struct AccountQuotaForecastView {
|
||||
pub estimated_tokens_display: String,
|
||||
pub estimated_usd: Option<f64>,
|
||||
pub estimated_usd_display: String,
|
||||
pub remaining_tokens: Option<u64>,
|
||||
pub remaining_tokens_display: String,
|
||||
pub remaining_usd: Option<f64>,
|
||||
pub remaining_usd_display: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
|
||||
@@ -287,6 +287,8 @@ pub(crate) fn usage_list_record_view(record: domain::UsageListRecord) -> UsageLi
|
||||
account_email: record.provider_account_email,
|
||||
account_name: record.provider_account_name,
|
||||
account_notes: record.provider_account_notes,
|
||||
account_plan_type: record.provider_account_plan_type,
|
||||
account_plan_type_display: record.provider_account_plan_type_display,
|
||||
route: record.endpoint,
|
||||
model,
|
||||
requested_model: record.requested_model_id,
|
||||
@@ -1283,6 +1285,8 @@ pub(crate) fn diagnostics_view(
|
||||
item.name
|
||||
},
|
||||
request_count: item.request_count,
|
||||
account_plan_type: item.account_plan_type,
|
||||
account_plan_type_display: item.account_plan_type_display,
|
||||
success_count: item.success_count,
|
||||
error_count: item.error_count,
|
||||
error_rate: item.error_rate,
|
||||
|
||||
@@ -94,6 +94,8 @@ pub struct UsageListRecordView {
|
||||
pub account_email: Option<String>,
|
||||
pub account_name: Option<String>,
|
||||
pub account_notes: Option<String>,
|
||||
pub account_plan_type: Option<String>,
|
||||
pub account_plan_type_display: Option<String>,
|
||||
pub route: String,
|
||||
pub model: Option<String>,
|
||||
pub requested_model: Option<String>,
|
||||
@@ -746,6 +748,8 @@ pub struct UsageInsightsOverviewView {
|
||||
pub struct DiagnosticItemView {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub account_plan_type: Option<String>,
|
||||
pub account_plan_type_display: Option<String>,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub error_count: u64,
|
||||
|
||||
@@ -29,13 +29,13 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
|
||||
incomplete_tokens: false,
|
||||
estimated_tokens: Some(1_000_000),
|
||||
estimated_usd: None,
|
||||
remaining_tokens: Some(0),
|
||||
remaining_usd: None,
|
||||
};
|
||||
let mut monthly = forecast.clone();
|
||||
monthly.period = AccountUsagePeriod::Monthly;
|
||||
monthly.extrapolated = true;
|
||||
monthly.target_seconds = 30 * 86_400;
|
||||
monthly.estimated_tokens = Some(0);
|
||||
monthly.estimated_usd = Some(0.0);
|
||||
let view = AccountQuotaForecastData::from(AccountQuotaForecastReport {
|
||||
account_id: "acct_forecast".to_owned(),
|
||||
generated_at: now,
|
||||
@@ -49,7 +49,16 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
|
||||
assert_eq!(week["estimatedTokensDisplay"], "1M");
|
||||
assert!(week["estimatedUsd"].is_null());
|
||||
assert_eq!(week["estimatedUsdDisplay"], "—");
|
||||
assert_eq!(week["remainingTokensDisplay"], "0");
|
||||
for forecast in value["forecasts"].as_array().unwrap() {
|
||||
for field in [
|
||||
"remainingTokens",
|
||||
"remainingTokensDisplay",
|
||||
"remainingUsd",
|
||||
"remainingUsdDisplay",
|
||||
] {
|
||||
assert!(forecast.get(field).is_none(), "unexpected field: {field}");
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
week["source"],
|
||||
serde_json::json!({
|
||||
@@ -66,6 +75,10 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
|
||||
assert!(week.get("method").is_none());
|
||||
assert!(week.get("methodDisplay").is_none());
|
||||
assert!(value.get("generatedAtDisplay").is_none());
|
||||
assert_eq!(value["forecasts"][1]["estimatedTokens"], 0);
|
||||
assert_eq!(value["forecasts"][1]["estimatedTokensDisplay"], "0");
|
||||
assert_eq!(value["forecasts"][1]["estimatedUsd"], 0.0);
|
||||
assert_eq!(value["forecasts"][1]["estimatedUsdDisplay"], "$0.00");
|
||||
assert_eq!(value["forecasts"][1]["period"], "monthly");
|
||||
assert_eq!(value["forecasts"][1]["targetDays"], 30.0);
|
||||
assert_eq!(value["forecasts"][1]["extrapolated"], true);
|
||||
|
||||
@@ -35,7 +35,7 @@ use gateway_admin::{
|
||||
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
|
||||
},
|
||||
observability::{
|
||||
DashboardObservation, DecimalAmount, DiagnosticDimension, DiagnosticObservation,
|
||||
DashboardObservation, DecimalAmount, DiagnosticDimension, DiagnosticsObservation,
|
||||
OpsError, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange, UsageDetail,
|
||||
UsageFilter, UsageListRecord, UsageOverview, UsagePage, UsageQuery,
|
||||
},
|
||||
@@ -100,7 +100,7 @@ pub(super) struct AdminTestFixture {
|
||||
pub settings: Arc<MemorySettingsStore>,
|
||||
pub usage_records: Arc<Mutex<Vec<UsageListRecord>>>,
|
||||
pub usage_detail: Arc<Mutex<Option<UsageDetail>>>,
|
||||
pub diagnostics: Arc<Mutex<Vec<DiagnosticObservation>>>,
|
||||
pub diagnostics: Arc<Mutex<DiagnosticsObservation>>,
|
||||
pub ops_errors: Arc<Mutex<Vec<OpsError>>>,
|
||||
pub dashboard_observation: Arc<Mutex<Option<DashboardObservation>>>,
|
||||
pub dashboard_summary_range: Arc<Mutex<Option<TimeRange>>>,
|
||||
@@ -137,7 +137,7 @@ impl AdminTestFixture {
|
||||
let account_groups = Arc::new(MemoryAccountGroupStore::new());
|
||||
let usage_records = Arc::new(Mutex::new(Vec::new()));
|
||||
let usage_detail = Arc::new(Mutex::new(None));
|
||||
let diagnostics = Arc::new(Mutex::new(Vec::new()));
|
||||
let diagnostics = Arc::new(Mutex::new(DiagnosticsObservation::default()));
|
||||
let ops_errors = Arc::new(Mutex::new(Vec::new()));
|
||||
let dashboard_observation = Arc::new(Mutex::new(None));
|
||||
let dashboard_summary_range = Arc::new(Mutex::new(None));
|
||||
@@ -1000,7 +1000,7 @@ struct UnusedStore {
|
||||
observations: Arc<Mutex<MemoryObservations>>,
|
||||
usage_records: Arc<Mutex<Vec<UsageListRecord>>>,
|
||||
usage_detail: Arc<Mutex<Option<UsageDetail>>>,
|
||||
diagnostics: Arc<Mutex<Vec<DiagnosticObservation>>>,
|
||||
diagnostics: Arc<Mutex<DiagnosticsObservation>>,
|
||||
ops_errors: Arc<Mutex<Vec<OpsError>>>,
|
||||
dashboard_observation: Arc<Mutex<Option<DashboardObservation>>>,
|
||||
dashboard_summary_range: Arc<Mutex<Option<TimeRange>>>,
|
||||
@@ -1312,7 +1312,7 @@ impl ObservabilityStore for UnusedStore {
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
_: DiagnosticDimension,
|
||||
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
|
||||
) -> AdminStoreResult<DiagnosticsObservation> {
|
||||
Ok(self.diagnostics.lock().expect("diagnostics").clone())
|
||||
}
|
||||
|
||||
|
||||
@@ -550,7 +550,9 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use gateway_admin::model::observability::{CostCoverage, DiagnosticObservation};
|
||||
use gateway_admin::model::observability::{
|
||||
CostCoverage, DiagnosticObservation, DiagnosticsObservation,
|
||||
};
|
||||
use gateway_api::admin::observability;
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
@@ -558,26 +560,28 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
|
||||
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
fixture
|
||||
.diagnostics
|
||||
.lock()
|
||||
.expect("diagnostics")
|
||||
.push(DiagnosticObservation {
|
||||
*fixture.diagnostics.lock().expect("diagnostics") = DiagnosticsObservation {
|
||||
total_request_count: 4,
|
||||
items: vec![DiagnosticObservation {
|
||||
key: "acct_diag".to_owned(),
|
||||
account_provider_kind: Some("openai".to_owned()),
|
||||
account_plan_type: Some("pro".to_owned()),
|
||||
name: "diag@example.invalid".to_owned(),
|
||||
request_count: 2,
|
||||
success_count: 2,
|
||||
failure_count: 0,
|
||||
attempt_count: 2,
|
||||
attempt_count: 5,
|
||||
total_tokens: 200,
|
||||
average_latency_ms: Some(100),
|
||||
latency_p95_ms: Some(3800),
|
||||
first_token_p95_ms: Some(1200),
|
||||
non_completion_count: 0,
|
||||
retry_count: 0,
|
||||
retry_count: 3,
|
||||
retried_request_count: 1,
|
||||
cost_coverage: CostCoverage::default(),
|
||||
costs: Vec::new(),
|
||||
});
|
||||
}],
|
||||
};
|
||||
let response = observability::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
@@ -596,6 +600,11 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
|
||||
.expect("diagnostics body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("diagnostics JSON");
|
||||
assert!(value["data"]["items"][0].get("impactScore").is_none());
|
||||
assert_eq!(value["data"]["items"][0]["requestShare"], 0.5);
|
||||
assert_eq!(value["data"]["items"][0]["retryCount"], 3);
|
||||
assert_eq!(value["data"]["items"][0]["retryRate"], 0.5);
|
||||
assert_eq!(value["data"]["items"][0]["accountPlanType"], "pro");
|
||||
assert_eq!(value["data"]["items"][0]["accountPlanTypeDisplay"], "Pro");
|
||||
assert_eq!(
|
||||
(
|
||||
&value["data"]["items"][0]["key"],
|
||||
@@ -766,6 +775,8 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
|
||||
provider_account_name: Some("Snapshot Alpha".to_owned()),
|
||||
provider_account_email: Some("alpha@example.invalid".to_owned()),
|
||||
provider_account_notes: Some("Team workspace".to_owned()),
|
||||
provider_account_plan_type: Some("supergrok_plus".to_owned()),
|
||||
provider_account_plan_type_display: None,
|
||||
provider_account_authentication_kind: Some("oauth".to_owned()),
|
||||
upstream_model_id: Some("grok-4.5".to_owned()),
|
||||
upstream_transport: Some("http_sse".to_owned()),
|
||||
@@ -858,6 +869,14 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
|
||||
|
||||
assert_eq!(value["data"]["items"][0]["clientApiKeyName"], "Production");
|
||||
assert_eq!(value["data"]["items"][0]["accountNotes"], "Team workspace");
|
||||
assert_eq!(
|
||||
value["data"]["items"][0]["accountPlanType"],
|
||||
"supergrok_plus"
|
||||
);
|
||||
assert_eq!(
|
||||
value["data"]["items"][0]["accountPlanTypeDisplay"],
|
||||
"SupergrokPlus"
|
||||
);
|
||||
assert_eq!(
|
||||
value["data"]["items"][0]["billing"]["longContextBillingApplied"],
|
||||
true
|
||||
|
||||
@@ -100,6 +100,8 @@ fn usage_record() -> UsageListRecord {
|
||||
provider_account_name: Some("private-sentinel".to_owned()),
|
||||
provider_account_email: Some("private-sentinel".to_owned()),
|
||||
provider_account_notes: Some("private-sentinel".to_owned()),
|
||||
provider_account_plan_type: Some("private-sentinel".to_owned()),
|
||||
provider_account_plan_type_display: Some("private-sentinel".to_owned()),
|
||||
provider_account_authentication_kind: Some("private-sentinel".to_owned()),
|
||||
upstream_model_id: Some("private-sentinel".to_owned()),
|
||||
upstream_transport: Some("websocket".to_owned()),
|
||||
|
||||
@@ -261,7 +261,7 @@ impl ObservabilityRepository for PgObservabilityRepository {
|
||||
range: ObservabilityRange,
|
||||
filter: UsageRecordFilter,
|
||||
dimension: DiagnosticDimension,
|
||||
) -> StoreResult<Vec<DiagnosticObservation>> {
|
||||
) -> StoreResult<DiagnosticsObservation> {
|
||||
self.query_budget
|
||||
.run(
|
||||
"load usage diagnostics",
|
||||
@@ -441,18 +441,17 @@ impl AdminObservabilityStore for PgAdminObservabilityStore {
|
||||
range: admin_observability::TimeRange,
|
||||
filter: admin_observability::UsageFilter,
|
||||
dimension: admin_observability::DiagnosticDimension,
|
||||
) -> AdminStoreResult<Vec<admin_observability::DiagnosticObservation>> {
|
||||
self.repository
|
||||
) -> AdminStoreResult<admin_observability::DiagnosticsObservation> {
|
||||
let observation = self
|
||||
.repository
|
||||
.usage_diagnostics(
|
||||
store_range(range)?,
|
||||
store_usage_filter(filter),
|
||||
store_diagnostic_dimension(dimension),
|
||||
)
|
||||
.await
|
||||
.map_err(observability_error)?
|
||||
.into_iter()
|
||||
.map(admin_diagnostic_observation)
|
||||
.collect()
|
||||
.map_err(observability_error)?;
|
||||
admin_diagnostics_observation(observation)
|
||||
}
|
||||
|
||||
async fn list_ops_errors(
|
||||
|
||||
@@ -459,6 +459,8 @@ pub(crate) fn admin_usage_list_record(
|
||||
provider_account_name: record.provider_account_name,
|
||||
provider_account_email: record.provider_account_email,
|
||||
provider_account_notes: record.provider_account_notes,
|
||||
provider_account_plan_type: record.provider_account_plan_type,
|
||||
provider_account_plan_type_display: None,
|
||||
provider_account_authentication_kind: record.provider_account_authentication_kind,
|
||||
upstream_model_id: record.upstream_model_id,
|
||||
upstream_transport: record.upstream_transport,
|
||||
@@ -688,12 +690,27 @@ pub(crate) fn admin_provider_observation(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn admin_diagnostic_observation(
|
||||
pub(crate) fn admin_diagnostics_observation(
|
||||
observation: DiagnosticsObservation,
|
||||
) -> AdminStoreResult<admin_observability::DiagnosticsObservation> {
|
||||
Ok(admin_observability::DiagnosticsObservation {
|
||||
total_request_count: observation.total_request_count,
|
||||
items: observation
|
||||
.items
|
||||
.into_iter()
|
||||
.map(admin_diagnostic_observation)
|
||||
.collect::<AdminStoreResult<_>>()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn admin_diagnostic_observation(
|
||||
observation: DiagnosticObservation,
|
||||
) -> AdminStoreResult<admin_observability::DiagnosticObservation> {
|
||||
Ok(admin_observability::DiagnosticObservation {
|
||||
key: observation.key,
|
||||
name: observation.name,
|
||||
account_provider_kind: observation.account_provider_kind,
|
||||
account_plan_type: observation.account_plan_type,
|
||||
request_count: observation.request_count,
|
||||
success_count: observation.success_count,
|
||||
failure_count: observation.failure_count,
|
||||
@@ -704,6 +721,7 @@ pub(crate) fn admin_diagnostic_observation(
|
||||
first_token_p95_ms: observation.first_token_p95_ms,
|
||||
non_completion_count: observation.non_completion_count,
|
||||
retry_count: observation.retry_count,
|
||||
retried_request_count: observation.retried_request_count,
|
||||
cost_coverage: admin_cost_coverage(observation.cost_coverage),
|
||||
costs: admin_currency_costs(observation.costs)?,
|
||||
})
|
||||
@@ -791,6 +809,7 @@ pub(crate) fn usage_list_record_from_row(
|
||||
provider_account_name: get(row, "provider_account_name")?,
|
||||
provider_account_email: get(row, "provider_account_email")?,
|
||||
provider_account_notes: get(row, "provider_account_notes")?,
|
||||
provider_account_plan_type: get(row, "provider_account_plan_type")?,
|
||||
provider_account_authentication_kind: get(row, "provider_account_authentication_kind")?,
|
||||
upstream_model_id: get(row, "upstream_model_id")?,
|
||||
upstream_transport: get(row, "upstream_transport")?,
|
||||
|
||||
@@ -491,6 +491,7 @@ pub struct UsageListRecord {
|
||||
pub provider_account_name: Option<String>,
|
||||
pub provider_account_email: Option<String>,
|
||||
pub provider_account_notes: Option<String>,
|
||||
pub provider_account_plan_type: Option<String>,
|
||||
pub provider_account_authentication_kind: Option<String>,
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
@@ -675,10 +676,18 @@ pub struct UsageOverview {
|
||||
pub providers: Vec<ProviderObservation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct DiagnosticsObservation {
|
||||
pub total_request_count: u64,
|
||||
pub items: Vec<DiagnosticObservation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DiagnosticObservation {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub account_provider_kind: Option<String>,
|
||||
pub account_plan_type: Option<String>,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub failure_count: u64,
|
||||
@@ -689,6 +698,7 @@ pub struct DiagnosticObservation {
|
||||
pub first_token_p95_ms: Option<u64>,
|
||||
pub non_completion_count: u64,
|
||||
pub retry_count: u64,
|
||||
pub retried_request_count: u64,
|
||||
pub cost_coverage: CostCoverage,
|
||||
pub costs: Vec<CurrencyCostTotal>,
|
||||
}
|
||||
@@ -793,6 +803,6 @@ pub trait ObservabilityRepository: Send + Sync {
|
||||
range: ObservabilityRange,
|
||||
filter: UsageRecordFilter,
|
||||
dimension: DiagnosticDimension,
|
||||
) -> StoreResult<Vec<DiagnosticObservation>>;
|
||||
) -> StoreResult<DiagnosticsObservation>;
|
||||
async fn list_ops_errors(&self, query: OpsErrorQuery) -> StoreResult<OpsErrorPage>;
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ pub(crate) const USAGE_LIST_RECORD_SELECT: &str =
|
||||
mr.provider_account_name_snapshot as provider_account_name,
|
||||
mr.provider_account_email_snapshot as provider_account_email,
|
||||
account.notes as provider_account_notes,
|
||||
account.plan_type as provider_account_plan_type,
|
||||
mr.provider_account_authentication_kind_snapshot
|
||||
as provider_account_authentication_kind,
|
||||
mr.upstream_model_id, mr.upstream_transport, mr.upstream_response_model, mr.service_tier,
|
||||
@@ -384,7 +385,7 @@ pub(crate) async fn usage_diagnostics(
|
||||
range: ObservabilityRange,
|
||||
filter: &UsageRecordFilter,
|
||||
dimension: DiagnosticDimension,
|
||||
) -> StoreResult<Vec<DiagnosticObservation>> {
|
||||
) -> StoreResult<DiagnosticsObservation> {
|
||||
filter.validate()?;
|
||||
let dimension_sql = diagnostic_dimension_sql(dimension);
|
||||
let completed_usage = completed_usage_fact_predicate("mr");
|
||||
@@ -424,6 +425,7 @@ pub(crate) async fn usage_diagnostics(
|
||||
count(*) filter (where outcome in ('cancelled', 'incomplete'))::bigint
|
||||
as non_completion_count,
|
||||
coalesce(sum(greatest(attempt_count - 1, 0)), 0)::bigint as retry_count,
|
||||
count(*) filter (where attempt_count > 1)::bigint as retried_request_count,
|
||||
count(*) filter (
|
||||
where is_completed_usage and cost_source = 'provider_reported'
|
||||
)::bigint
|
||||
@@ -443,6 +445,7 @@ pub(crate) async fn usage_diagnostics(
|
||||
group by dimension_name, grouping sets ((), (cost_currency))
|
||||
), selected_dimensions as (
|
||||
select dimension_name,
|
||||
sum(request_count) over ()::bigint as total_request_count,
|
||||
row_number() over (order by request_count desc, dimension_name)
|
||||
as sort_position
|
||||
from aggregated
|
||||
@@ -452,16 +455,25 @@ pub(crate) async fn usage_diagnostics(
|
||||
statement.push_bind(DIAGNOSTIC_LIMIT);
|
||||
statement.push(
|
||||
")
|
||||
select aggregated.*
|
||||
select aggregated.*, selected.total_request_count,
|
||||
account.provider_kind as account_provider_kind, account.plan_type as account_plan_type
|
||||
from aggregated
|
||||
join selected_dimensions selected using (dimension_name)
|
||||
order by selected.sort_position, currency_grouping desc, cost_currency nulls last",
|
||||
left join provider_accounts account on account.id = aggregated.dimension_name and ",
|
||||
);
|
||||
statement.push_bind(dimension == DiagnosticDimension::Account);
|
||||
statement
|
||||
.push(" order by selected.sort_position, currency_grouping desc, cost_currency nulls last");
|
||||
let rows = statement
|
||||
.build()
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("load usage diagnostics"))?;
|
||||
let total_request_count = rows
|
||||
.first()
|
||||
.map(|row| unsigned(row, "total_request_count"))
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let mut observations = Vec::with_capacity(DIAGNOSTIC_LIMIT as usize);
|
||||
let mut costs = HashMap::<String, Vec<CurrencyCostTotal>>::new();
|
||||
for row in &rows {
|
||||
@@ -469,6 +481,8 @@ pub(crate) async fn usage_diagnostics(
|
||||
1 => observations.push(DiagnosticObservation {
|
||||
key: get(row, "dimension_name")?,
|
||||
name: get(row, "dimension_name")?,
|
||||
account_provider_kind: get(row, "account_provider_kind")?,
|
||||
account_plan_type: get(row, "account_plan_type")?,
|
||||
request_count: unsigned(row, "request_count")?,
|
||||
success_count: unsigned(row, "success_count")?,
|
||||
failure_count: unsigned(row, "failure_count")?,
|
||||
@@ -479,6 +493,7 @@ pub(crate) async fn usage_diagnostics(
|
||||
first_token_p95_ms: optional_unsigned(row, "first_token_p95_ms")?,
|
||||
non_completion_count: unsigned(row, "non_completion_count")?,
|
||||
retry_count: unsigned(row, "retry_count")?,
|
||||
retried_request_count: unsigned(row, "retried_request_count")?,
|
||||
cost_coverage: coverage_from_row(row)?,
|
||||
costs: Vec::new(),
|
||||
}),
|
||||
@@ -523,7 +538,10 @@ pub(crate) async fn usage_diagnostics(
|
||||
}
|
||||
observation.costs = costs.remove(&observation.key).unwrap_or_default();
|
||||
}
|
||||
Ok(observations)
|
||||
Ok(DiagnosticsObservation {
|
||||
total_request_count,
|
||||
items: observations,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn diagnostic_account_display_names(
|
||||
|
||||
@@ -160,7 +160,7 @@ async fn usage_page_should_always_return_total() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_list_should_resolve_current_notes_by_account_id() {
|
||||
async fn usage_list_and_diagnostics_should_resolve_current_account_metadata_by_id() {
|
||||
let Some(database) = TestDatabase::create("usage_account_notes").await else {
|
||||
return;
|
||||
};
|
||||
@@ -197,12 +197,20 @@ async fn usage_list_should_resolve_current_notes_by_account_id() {
|
||||
.expect("admin observability range");
|
||||
let store = admin_observability_store(&database.pool);
|
||||
|
||||
for notes in [None, Some("个人主号"), Some("个人备用号"), None] {
|
||||
sqlx::query("update provider_accounts set notes = $1 where id = 'acct_observe'")
|
||||
.bind(notes)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.expect("update current account notes");
|
||||
for (notes, plan) in [
|
||||
(None, Some("pro")),
|
||||
(Some("个人主号"), Some("plus")),
|
||||
(Some("个人备用号"), Some("pro")),
|
||||
(None, None),
|
||||
] {
|
||||
sqlx::query(
|
||||
"update provider_accounts set notes = $1, plan_type = $2 where id = 'acct_observe'",
|
||||
)
|
||||
.bind(notes)
|
||||
.bind(plan)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.expect("update current account notes");
|
||||
let page = store
|
||||
.list_usage_records(admin_observability::UsageQuery {
|
||||
range,
|
||||
@@ -230,6 +238,36 @@ async fn usage_list_should_resolve_current_notes_by_account_id() {
|
||||
);
|
||||
assert_eq!(personal.provider_account_email, team.provider_account_email);
|
||||
assert_eq!(personal.provider_account_notes.as_deref(), notes);
|
||||
assert_eq!(personal.provider_account_plan_type.as_deref(), plan);
|
||||
assert_eq!(team.provider_account_plan_type.as_deref(), Some("team"));
|
||||
let diagnostics = store
|
||||
.usage_diagnostics(
|
||||
range,
|
||||
admin_observability::UsageFilter::default(),
|
||||
admin_observability::DiagnosticDimension::Account,
|
||||
)
|
||||
.await
|
||||
.expect("account plans");
|
||||
assert_eq!(
|
||||
diagnostics
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.key == "acct_observe")
|
||||
.expect("personal diagnostics")
|
||||
.account_plan_type
|
||||
.as_deref(),
|
||||
plan
|
||||
);
|
||||
assert_eq!(
|
||||
diagnostics
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.key == "acct_team")
|
||||
.expect("team diagnostics")
|
||||
.account_plan_type
|
||||
.as_deref(),
|
||||
Some("team")
|
||||
);
|
||||
assert_eq!(team.provider_account_notes.as_deref(), Some("团队工作区"));
|
||||
}
|
||||
|
||||
@@ -274,6 +312,19 @@ async fn usage_list_should_resolve_current_notes_by_account_id() {
|
||||
Some("account@example.invalid")
|
||||
);
|
||||
assert_eq!(page.items[0].provider_account_notes, None);
|
||||
assert_eq!(page.items[0].provider_account_plan_type, None);
|
||||
let diagnostics = store
|
||||
.usage_diagnostics(
|
||||
range,
|
||||
admin_observability::UsageFilter {
|
||||
provider_account_ref: Some("acct_team".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
admin_observability::DiagnosticDimension::Account,
|
||||
)
|
||||
.await
|
||||
.expect("deleted account diagnostics");
|
||||
assert_eq!(diagnostics.items[0].account_plan_type, None);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
@@ -765,7 +816,8 @@ async fn recovered_continuation_failure_should_be_visible_in_ops_but_hidden_from
|
||||
DiagnosticDimension::Account,
|
||||
)
|
||||
.await
|
||||
.expect("diagnostics without recovered intermediates");
|
||||
.expect("diagnostics without recovered intermediates")
|
||||
.items;
|
||||
assert_eq!(diagnostics[0].request_count, 2);
|
||||
assert_eq!(diagnostics[0].failure_count, 0);
|
||||
|
||||
@@ -1358,7 +1410,8 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
|
||||
admin_observability::DiagnosticDimension::Account,
|
||||
)
|
||||
.await
|
||||
.expect("admin diagnostics");
|
||||
.expect("admin diagnostics")
|
||||
.items;
|
||||
assert_eq!(diagnostics[0].key, "acct_observe");
|
||||
assert_eq!(diagnostics[0].name, "account@example.invalid");
|
||||
assert_eq!(diagnostics[0].cost_coverage.provider_reported_count, 1);
|
||||
@@ -1775,7 +1828,8 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
|
||||
DiagnosticDimension::Account,
|
||||
)
|
||||
.await
|
||||
.expect("usage diagnostics");
|
||||
.expect("usage diagnostics")
|
||||
.items;
|
||||
assert_eq!(diagnostics[0].key, "acct_observe");
|
||||
assert_eq!(diagnostics[0].name, "account@example.invalid");
|
||||
assert_eq!(diagnostics[0].request_count, 3);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use gateway_admin::ports::store::ObservabilityStore as _;
|
||||
use gateway_store::postgres::{
|
||||
DiagnosticDimension, ModelRequestAttemptStart, ModelRequestRepository, NewModelRequest,
|
||||
ObservabilityPageSize, ObservabilityRange, ObservabilityRepository, OpsErrorFilter,
|
||||
@@ -111,7 +112,8 @@ async fn completed_usage_projections_should_accept_statusless_websocket_but_reje
|
||||
DiagnosticDimension::Account,
|
||||
)
|
||||
.await
|
||||
.expect("load statusless usage diagnostics");
|
||||
.expect("load statusless usage diagnostics")
|
||||
.items;
|
||||
assert_eq!(
|
||||
(
|
||||
diagnostics[0].request_count,
|
||||
@@ -515,7 +517,8 @@ async fn diagnostics_should_group_same_email_accounts_by_stable_ref() {
|
||||
DiagnosticDimension::Account,
|
||||
)
|
||||
.await
|
||||
.expect("account diagnostics");
|
||||
.expect("account diagnostics")
|
||||
.items;
|
||||
assert_eq!(diagnostics.len(), 2);
|
||||
assert_eq!(
|
||||
diagnostics
|
||||
@@ -587,7 +590,8 @@ async fn diagnostics_should_fallback_to_name_then_ref_for_missing_snapshots() {
|
||||
DiagnosticDimension::Account,
|
||||
)
|
||||
.await
|
||||
.expect("account diagnostics");
|
||||
.expect("account diagnostics")
|
||||
.items;
|
||||
let by_key = diagnostics
|
||||
.iter()
|
||||
.map(|item| (item.key.as_str(), item.name.as_str()))
|
||||
@@ -652,7 +656,8 @@ async fn diagnostics_should_prefer_the_latest_non_null_email_snapshot() {
|
||||
DiagnosticDimension::Account,
|
||||
)
|
||||
.await
|
||||
.expect("account diagnostics");
|
||||
.expect("account diagnostics")
|
||||
.items;
|
||||
|
||||
assert_eq!(diagnostics.len(), 1);
|
||||
assert_eq!(diagnostics[0].key, "acct_snapshot_history");
|
||||
@@ -713,7 +718,8 @@ async fn failure_diagnostics_should_only_include_errored_requests() {
|
||||
DiagnosticDimension::Failure,
|
||||
)
|
||||
.await
|
||||
.expect("failure diagnostics");
|
||||
.expect("failure diagnostics")
|
||||
.items;
|
||||
assert_eq!(diagnostics.len(), 1);
|
||||
assert_eq!(diagnostics[0].key, "rate_limited");
|
||||
assert_eq!(diagnostics[0].request_count, 1);
|
||||
@@ -777,7 +783,8 @@ async fn model_diagnostics_should_exclude_provider_endpoints_without_a_model() {
|
||||
DiagnosticDimension::Model,
|
||||
)
|
||||
.await
|
||||
.expect("model diagnostics");
|
||||
.expect("model diagnostics")
|
||||
.items;
|
||||
|
||||
assert_eq!(diagnostics.len(), 1);
|
||||
assert_eq!(diagnostics[0].key, "upstream-model");
|
||||
@@ -838,6 +845,137 @@ async fn tokenless_provider_endpoints_should_remain_in_health_but_not_usage_reco
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostics_should_keep_filtered_totals_before_limiting_groups() {
|
||||
let Some(database) = TestDatabase::create("diagnostic_totals").await else {
|
||||
return;
|
||||
};
|
||||
let now = Utc::now();
|
||||
seed_account(&database.pool, "acct_totals", "Totals", None, "oauth", now).await;
|
||||
let store = PgExecutionStore::new(database.pool.clone());
|
||||
for index in 0..201 {
|
||||
let id = format!("req_total_{index:03}");
|
||||
let mut first_attempt = attempt(&id, 1, "acct_totals");
|
||||
first_attempt.upstream_model_id = Some(format!("model_{:03}", index % 200));
|
||||
store
|
||||
.insert_model_request_with_first_attempt(new_request(&id, now), first_attempt)
|
||||
.await
|
||||
.expect("insert model group");
|
||||
finalize_request(&database.pool, &id, now).await;
|
||||
}
|
||||
sqlx::query(
|
||||
"update model_requests set cost_source = 'provider_reported', cost_amount = 1,
|
||||
cost_currency = case when id = 'req_total_200' then 'EUR' else 'USD' end",
|
||||
)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.expect("set currency groups");
|
||||
let repository = observability_repository(&database.pool);
|
||||
let result = repository
|
||||
.usage_diagnostics(
|
||||
range_around(now),
|
||||
UsageRecordFilter::default(),
|
||||
DiagnosticDimension::Model,
|
||||
)
|
||||
.await
|
||||
.expect("limited groups");
|
||||
assert_eq!(
|
||||
(
|
||||
result.total_request_count,
|
||||
result.items.len(),
|
||||
result
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.request_count)
|
||||
.sum::<u64>()
|
||||
),
|
||||
(201, 100, 101)
|
||||
);
|
||||
assert_eq!(
|
||||
(result.items[0].key.as_str(), result.items[0].costs.len()),
|
||||
("model_000", 2)
|
||||
);
|
||||
|
||||
let filtered = repository
|
||||
.usage_diagnostics(
|
||||
range_around(now),
|
||||
UsageRecordFilter {
|
||||
model: Some("model_000".to_owned()),
|
||||
..UsageRecordFilter::default()
|
||||
},
|
||||
DiagnosticDimension::Model,
|
||||
)
|
||||
.await
|
||||
.expect("filtered denominator");
|
||||
assert_eq!((filtered.total_request_count, filtered.items.len()), (2, 1));
|
||||
let empty = repository
|
||||
.usage_diagnostics(
|
||||
range_around(now),
|
||||
UsageRecordFilter::default(),
|
||||
DiagnosticDimension::Failure,
|
||||
)
|
||||
.await
|
||||
.expect("empty error dimension");
|
||||
assert_eq!((empty.total_request_count, empty.items.len()), (0, 0));
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostics_should_distinguish_retry_attempts_from_retried_requests() {
|
||||
let Some(database) = TestDatabase::create("diagnostic_retries").await else {
|
||||
return;
|
||||
};
|
||||
let now = Utc::now();
|
||||
seed_account(
|
||||
&database.pool,
|
||||
"acct_retries",
|
||||
"Retries",
|
||||
None,
|
||||
"oauth",
|
||||
now,
|
||||
)
|
||||
.await;
|
||||
let store = PgExecutionStore::new(database.pool.clone());
|
||||
for (index, attempts) in [0, 1, 1, 1, 1, 1, 3, 5].into_iter().enumerate() {
|
||||
let id = format!("req_retry_{index}");
|
||||
store
|
||||
.insert_model_request_with_first_attempt(
|
||||
new_request(&id, now),
|
||||
attempt(&id, 1, "acct_retries"),
|
||||
)
|
||||
.await
|
||||
.expect("insert retry request");
|
||||
finalize_request(&database.pool, &id, now).await;
|
||||
sqlx::query("update model_requests set attempt_count = $2 where id = $1")
|
||||
.bind(id)
|
||||
.bind(attempts)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.expect("set attempts");
|
||||
}
|
||||
let result = super::admin_observability_store(&database.pool)
|
||||
.usage_diagnostics(
|
||||
gateway_admin::model::observability::TimeRange::new(
|
||||
now - TimeDelta::hours(1),
|
||||
now + TimeDelta::hours(1),
|
||||
)
|
||||
.expect("range"),
|
||||
gateway_admin::model::observability::UsageFilter::default(),
|
||||
gateway_admin::model::observability::DiagnosticDimension::Model,
|
||||
)
|
||||
.await
|
||||
.expect("retry metrics through admin adapter");
|
||||
assert_eq!(
|
||||
(
|
||||
result.total_request_count,
|
||||
result.items[0].retry_count,
|
||||
result.items[0].retried_request_count
|
||||
),
|
||||
(8, 6, 2)
|
||||
);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_key_diagnostics_should_display_key_name_and_fallback_to_ref() {
|
||||
let Some(database) = TestDatabase::create("snapshot_api_key_dimension").await else {
|
||||
@@ -886,7 +1024,8 @@ async fn api_key_diagnostics_should_display_key_name_and_fallback_to_ref() {
|
||||
DiagnosticDimension::ApiKey,
|
||||
)
|
||||
.await
|
||||
.expect("api key diagnostics");
|
||||
.expect("api key diagnostics")
|
||||
.items;
|
||||
assert_eq!(diagnostics.len(), 1);
|
||||
assert_eq!(diagnostics[0].key, "key_diag");
|
||||
assert_eq!(diagnostics[0].name, "My Key");
|
||||
@@ -904,7 +1043,8 @@ async fn api_key_diagnostics_should_display_key_name_and_fallback_to_ref() {
|
||||
DiagnosticDimension::ApiKey,
|
||||
)
|
||||
.await
|
||||
.expect("api key diagnostics after deletion");
|
||||
.expect("api key diagnostics after deletion")
|
||||
.items;
|
||||
assert_eq!(diagnostics.len(), 1);
|
||||
assert_eq!(diagnostics[0].key, "key_diag");
|
||||
assert_eq!(diagnostics[0].name, "key_diag");
|
||||
|
||||
@@ -228,7 +228,7 @@ struct PricingRule {
|
||||
|
||||
// 价格来源:https://developers.openai.com/api/docs/pricing。
|
||||
// 只登记已核验的常规价格及服务档位;临时优惠不写入内置价目。
|
||||
// 原有规则于 2026-09-09 核验,新增型号的核验日期见对应条目。
|
||||
// 原有规则于 2026-09-09 核验,GPT-6.1 Sol 于 2026-09-29 核验,其余新增型号见对应条目。
|
||||
// 已按 https://developers.openai.com/api/docs/deprecations 核验至 2026-09-13,
|
||||
// 移除已关闭的型号;仅宣布弃用但尚未到关闭日期的型号继续保留。
|
||||
const PRICING_RULES: &[PricingRule] = &[
|
||||
@@ -244,6 +244,16 @@ const PRICING_RULES: &[PricingRule] = &[
|
||||
.with_long_flex(100_000, 375_000, 10_000)
|
||||
.with_long_fast(400_000, 1_500_000, 40_000),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-6.1-sol",
|
||||
pricing: ModelPricing::new(20_000, 100_000, 1_000)
|
||||
.with_cache_write(125)
|
||||
.with_flex(10_000, 50_000, 500)
|
||||
.with_fast(40_000, 200_000, 2_000)
|
||||
.with_long(40_000, 150_000, 2_000)
|
||||
.with_long_flex(20_000, 75_000, 1_000)
|
||||
.with_long_fast(80_000, 300_000, 4_000),
|
||||
},
|
||||
// https://developers.openai.com/api/docs/models/gpt-6-sol,核验日期 2026-09-24。
|
||||
PricingRule {
|
||||
model: "gpt-6-sol",
|
||||
@@ -864,7 +874,10 @@ fn reasoning_model(model: &str) -> bool {
|
||||
let normalized = normalize_model_name(model);
|
||||
let model = pricing_model_name(&normalized);
|
||||
model.starts_with("gpt-5")
|
||||
|| matches!(model, "gpt-6-astra" | "gpt-6-sol" | "gpt-6-luna")
|
||||
|| matches!(
|
||||
model,
|
||||
"gpt-6-astra" | "gpt-6.1-sol" | "gpt-6-sol" | "gpt-6-luna"
|
||||
)
|
||||
|| model.starts_with("o1")
|
||||
|| model.starts_with("o3")
|
||||
|| model.starts_with("o4")
|
||||
|
||||
@@ -352,7 +352,14 @@ async fn copying_builtin_prices_keeps_cache_read_and_write_fallback_costs() {
|
||||
.await
|
||||
.unwrap();
|
||||
let prices = bundle.admin_provider().pricing_catalog();
|
||||
for model in ["gpt-4", "gpt-4o", "gpt-6-astra", "gpt-6-sol", "gpt-6-luna"] {
|
||||
for model in [
|
||||
"gpt-4",
|
||||
"gpt-4o",
|
||||
"gpt-6-astra",
|
||||
"gpt-6.1-sol",
|
||||
"gpt-6-sol",
|
||||
"gpt-6-luna",
|
||||
] {
|
||||
let usage = OpenAiBillingUsage::new(100, 10, 20, 15);
|
||||
let inherited = openai_billing_breakdown(model, usage, None).unwrap();
|
||||
let copied =
|
||||
|
||||
@@ -399,7 +399,7 @@ fn decoder_should_use_non_reasoning_preview_web_search_price() {
|
||||
#[test]
|
||||
fn gpt_6_decoders_should_use_reasoning_preview_web_search_price() {
|
||||
let tools = vec![json!({ "type": "web_search_preview" })];
|
||||
for model in ["gpt-6-astra", "gpt-6-sol", "gpt-6-luna"] {
|
||||
for model in ["gpt-6-astra", "gpt-6.1-sol", "gpt-6-sol", "gpt-6-luna"] {
|
||||
let created = json!({
|
||||
"type":"response.created",
|
||||
"response":{"id":"resp_preview_search_cost","model":model}
|
||||
|
||||
@@ -80,6 +80,17 @@ fn astra_billing_should_preserve_components_across_tiers_and_context_boundary()
|
||||
fn new_gpt_6_models_should_use_published_prices_across_tiers_and_context_boundary() {
|
||||
// 每档依次核对每百万 Token 的输入、缓存读取、缓存写入和输出价格。
|
||||
for (model, cases) in [
|
||||
(
|
||||
"gpt-6.1-sol",
|
||||
[
|
||||
(None, 272_000, ["2", "0.1", "2.5", "10"]),
|
||||
(None, 272_001, ["4", "0.2", "5", "15"]),
|
||||
(Some("flex"), 272_000, ["1", "0.05", "1.25", "5"]),
|
||||
(Some("flex"), 272_001, ["2", "0.1", "2.5", "7.5"]),
|
||||
(Some("fast"), 272_000, ["4", "0.2", "5", "20"]),
|
||||
(Some("priority"), 272_001, ["8", "0.4", "10", "30"]),
|
||||
],
|
||||
),
|
||||
(
|
||||
"gpt-6-sol",
|
||||
[
|
||||
|
||||
+6
-2
@@ -911,8 +911,6 @@ OAuth start 使用:
|
||||
- `estimatedTokens` / `estimatedUsd` 及对应 `*Display`:本周期已记录用量加预计剩余量,
|
||||
公式为 `(本周期已记录用量 + 样本用量 × (100 - usedPercent) / sampledPercent) × 目标窗口秒数 / 源窗口秒数`。
|
||||
真实周期由上游额度重置边界定义,不按自然周/月累计;只有折算结果才乘以目标与源窗口的时长比
|
||||
- `remainingTokens` / `remainingUsd` 及对应 `*Display`:**额度快照时源窗口**的剩余估算,
|
||||
公式为 `样本用量 × (100 - usedPercent) / sampledPercent`;不随目标周期折算,不代表当前可消费余额
|
||||
- `source`:源窗口名称 `label`、已用比例 `usedPercent` / `usedPercentDisplay`、
|
||||
额度观测时间 `observedAt` / `observedAtDisplay`、用于过期检查的 `resetAt`,以及本周期累计
|
||||
已记录的 `tokensDisplay` / `usdDisplay`。`source: null` 表示没有可选的源窗口。
|
||||
@@ -1561,6 +1559,12 @@ request/response/upstream ID、outcome 与搜索文本。诊断 `dimension` 可
|
||||
`apiKey`、`provider`、`transport`、`failureClass`、`status`。诊断按请求量降序返回最多 100 项,
|
||||
同请求量按维度标识稳定排列
|
||||
|
||||
`requestShare` 的分母为该维度筛选后、截取前的全部请求数;`failureClass` 只在带错误类型的请求内计算占比
|
||||
`retryCount` 为额外执行尝试次数之和,`retryRate` 为发生过重试的请求数占该组请求数的比例,同一请求多次重试只计一次
|
||||
|
||||
账号维度诊断项和使用记录列表的 `accountPlanType` / `accountPlanTypeDisplay` 返回账号当前套餐及展示名称,
|
||||
按内部账号 ID 关联;套餐未知或账号已删除时为 `null`,不作为请求发生时的套餐快照
|
||||
|
||||
管理端请求列表及 Dashboard 最近请求中的 `accountNotes` 为账号当前备注,按内部账号 ID 关联。
|
||||
备注不写入请求历史快照;无备注或账号已删除时返回 `null`,修改备注不改变历史请求的账号归属
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@boxicons/vue": "1.0.1",
|
||||
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.3.0",
|
||||
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.3.1",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
"@lucide/vue": "^1.47.0",
|
||||
|
||||
Generated
+6
-6
@@ -170,8 +170,8 @@ importers:
|
||||
specifier: 1.0.1
|
||||
version: 1.0.1(vue@3.5.43(typescript@6.0.3))
|
||||
'@codex-proxy/ui':
|
||||
specifier: github:zyycn/codex-proxy-ui#v0.3.0
|
||||
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/e1d4a164e7cfe72272301ebcb1b1d9c54b786522(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))
|
||||
specifier: github:zyycn/codex-proxy-ui#v0.3.1
|
||||
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/6f6cc09264e5382bf926551f313f7e7c4b7a1b01(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))
|
||||
'@fontsource-variable/inter':
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
@@ -384,9 +384,9 @@ packages:
|
||||
resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==}
|
||||
engines: {node: '>= 20.12.0'}
|
||||
|
||||
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/e1d4a164e7cfe72272301ebcb1b1d9c54b786522':
|
||||
resolution: {gitHosted: true, integrity: sha512-I2h13qjmqABk4IviUXdiZXPU9o//qO6WGbBBvav9rXKXyEjfFXvwvz0ofa/4UWOmTRBhmcRFmEIE0qUTzHZThQ==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/e1d4a164e7cfe72272301ebcb1b1d9c54b786522}
|
||||
version: 0.3.0
|
||||
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/6f6cc09264e5382bf926551f313f7e7c4b7a1b01':
|
||||
resolution: {gitHosted: true, integrity: sha512-TcVbsvwVd+19Ex+o0z3Q6XYx+k2p/lsAWFpCN704zujhz2qqBh9hEp8+aCsA/1kUhLASQki3sXFa5cmpYpTO2g==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/6f6cc09264e5382bf926551f313f7e7c4b7a1b01}
|
||||
version: 0.3.1
|
||||
engines: {node: '>=24'}
|
||||
peerDependencies:
|
||||
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -2783,7 +2783,7 @@ snapshots:
|
||||
fast-wrap-ansi: 0.2.2
|
||||
sisteransi: 1.0.5
|
||||
|
||||
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/e1d4a164e7cfe72272301ebcb1b1d9c54b786522(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))':
|
||||
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/6f6cc09264e5382bf926551f313f7e7c4b7a1b01(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))':
|
||||
dependencies:
|
||||
'@ant-design/colors': 8.0.1
|
||||
'@ant-design/fast-color': 3.0.1
|
||||
|
||||
@@ -190,10 +190,6 @@ export interface AccountQuotaForecast {
|
||||
estimatedTokensDisplay: string
|
||||
estimatedUsd: number | null
|
||||
estimatedUsdDisplay: string
|
||||
remainingTokens: number | null
|
||||
remainingTokensDisplay: string
|
||||
remainingUsd: number | null
|
||||
remainingUsdDisplay: string
|
||||
}
|
||||
|
||||
export interface AccountQuotaForecastResponse {
|
||||
|
||||
@@ -83,6 +83,8 @@ export interface UsageListRecord {
|
||||
accountEmail: string | null
|
||||
accountName: string | null
|
||||
accountNotes: string | null
|
||||
accountPlanType: string | null
|
||||
accountPlanTypeDisplay: string | null
|
||||
route: string
|
||||
model: string | null
|
||||
requestedModel: string | null
|
||||
@@ -436,6 +438,8 @@ export interface UsageInsightsOverviewResponse {
|
||||
export interface UsageDiagnosticItem {
|
||||
key: string
|
||||
name: string
|
||||
accountPlanType: string | null
|
||||
accountPlanTypeDisplay: string | null
|
||||
requestCount: number
|
||||
successCount: number
|
||||
errorCount: number
|
||||
|
||||
@@ -15,14 +15,13 @@ import SystemUpdateModal from './components/SystemUpdateModal/index.vue'
|
||||
const uiStore = useUiStore()
|
||||
const systemUpdateStore = useSystemUpdateStore()
|
||||
const { sidebarCollapsed } = storeToRefs(uiStore)
|
||||
const { loadedOnce, version } = storeToRefs(systemUpdateStore)
|
||||
const { version } = storeToRefs(systemUpdateStore)
|
||||
const { toggleSidebar } = uiStore
|
||||
const route = useRoute()
|
||||
const pageScrollbarRef = ref<InstanceType<typeof BaseScrollbar> | null>(null)
|
||||
const mobileSidebarOpen = shallowRef(false)
|
||||
const aboutOpen = shallowRef(false)
|
||||
const systemUpdateOpen = shallowRef(false)
|
||||
const systemUpdateOpening = shallowRef(false)
|
||||
|
||||
function openMobileSidebar() {
|
||||
mobileSidebarOpen.value = true
|
||||
@@ -32,24 +31,6 @@ function closeMobileSidebar() {
|
||||
mobileSidebarOpen.value = false
|
||||
}
|
||||
|
||||
async function openSystemUpdate() {
|
||||
if (systemUpdateOpen.value || systemUpdateOpening.value)
|
||||
return
|
||||
|
||||
systemUpdateOpening.value = true
|
||||
try {
|
||||
if (!loadedOnce.value)
|
||||
await systemUpdateStore.loadSystem(false)
|
||||
}
|
||||
catch {
|
||||
// 弹窗打开后由弹窗内的加载逻辑提示失败原因。
|
||||
}
|
||||
finally {
|
||||
systemUpdateOpening.value = false
|
||||
systemUpdateOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void systemUpdateStore.loadVersion().catch(() => undefined)
|
||||
})
|
||||
@@ -75,7 +56,7 @@ watch(
|
||||
:collapsed="sidebarCollapsed"
|
||||
@toggle="toggleSidebar"
|
||||
@open-about="aboutOpen = true"
|
||||
@open-system-update="openSystemUpdate"
|
||||
@open-system-update="systemUpdateOpen = true"
|
||||
/>
|
||||
<FloatingSidebarToggle v-if="!mobileSidebarOpen" @open="openMobileSidebar" />
|
||||
<main class="relative isolate h-dvh min-w-0 flex-1 overflow-hidden">
|
||||
@@ -103,7 +84,7 @@ watch(
|
||||
@close="closeMobileSidebar"
|
||||
@navigate="closeMobileSidebar"
|
||||
@open-about="aboutOpen = true"
|
||||
@open-system-update="openSystemUpdate"
|
||||
@open-system-update="systemUpdateOpen = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,6 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
|
||||
const updateError = shallowRef('')
|
||||
const statusAvailable = shallowRef(false)
|
||||
const policy = shallowRef<SystemUpdatePolicy | null>(null)
|
||||
const loadedOnce = shallowRef(false)
|
||||
const updateLogs = ref<SystemUpdateEvent[]>([])
|
||||
const updateStreaming = shallowRef(false)
|
||||
const updateStreamError = shallowRef('')
|
||||
@@ -282,7 +281,6 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
|
||||
return
|
||||
updateInfo.value = detail
|
||||
policy.value = detail.policy
|
||||
loadedOnce.value = true
|
||||
if (!version.value)
|
||||
await loadVersion()
|
||||
if (generation !== detailGeneration)
|
||||
@@ -501,7 +499,6 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
|
||||
updateError,
|
||||
lastFailedOperation,
|
||||
needRestart,
|
||||
loadedOnce,
|
||||
updateLogs,
|
||||
updateStreaming,
|
||||
updateStreamError,
|
||||
|
||||
@@ -18,6 +18,7 @@ const props = withDefaults(
|
||||
|
||||
const planPalettes: Record<string, string> = {
|
||||
free: 'bg-cp-cyan-container text-cp-cyan-on-container',
|
||||
plus: 'bg-cp-blue-container-strong text-cp-blue-on-container',
|
||||
pro: 'bg-cp-purple-container-strong text-cp-purple-on-container',
|
||||
prolite: 'bg-cp-purple-container text-cp-purple-on-container',
|
||||
}
|
||||
|
||||
+2
-12
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccountQuotaForecast } from '@/api'
|
||||
import { ArrowRight, Info } from '@lucide/vue'
|
||||
import { ArrowRight } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{ forecast: AccountQuotaForecast }>()
|
||||
@@ -26,8 +26,7 @@ const metrics = computed(() => [
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="text-cp-xs text-cp-text-secondary">本周期已用</span>
|
||||
<span class="inline-flex items-center gap-1 rounded-cp-sm bg-cp-info-container px-2 py-1 text-cp-xs font-emphasis text-cp-info-on-container">
|
||||
<Info class="size-3" aria-hidden="true" />
|
||||
<span class="inline-flex items-center rounded-cp-sm bg-cp-info-container px-2 py-1 text-cp-xs font-emphasis text-cp-info-on-container">
|
||||
{{ forecast.lowSample ? '初步估算 · 仅供参考' : '估算值 · 仅供参考' }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -70,14 +69,5 @@ const metrics = computed(() => [
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-2 text-cp-xs">
|
||||
<span class="text-cp-text-secondary">更新时的剩余额度</span>
|
||||
<span class="font-mono font-emphasis text-cp-text [html[data-theme=light]_&]:font-medium">
|
||||
{{ forecast.remainingTokensDisplay }} Tokens
|
||||
<span class="mx-1 text-cp-text-tertiary">/</span>
|
||||
{{ forecast.remainingUsdDisplay }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { BaseSkeleton } from '@codex-proxy/ui'
|
||||
<div role="status" aria-busy="true">
|
||||
<span class="sr-only">正在加载额度预测</span>
|
||||
<div aria-hidden="true">
|
||||
<div class="flex min-h-96 flex-col justify-between gap-5 rounded-cp-card bg-cp-fill-tertiary/70 p-4 [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
|
||||
<div class="flex flex-col gap-5 rounded-cp-card bg-cp-fill-tertiary/70 p-4 [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
|
||||
<div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<BaseSkeleton shape="text" class="w-16" />
|
||||
@@ -33,11 +33,6 @@ import { BaseSkeleton } from '@codex-proxy/ui'
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between gap-3">
|
||||
<BaseSkeleton shape="text" class="w-28" />
|
||||
<BaseSkeleton shape="text" class="w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@ const { now, pause, resume } = useNow({
|
||||
scheduler: callback => useIntervalFn(callback, 30_000),
|
||||
})
|
||||
const options = computed(() => report.value?.forecasts.map(item => ({
|
||||
label: item.extrapolated ? `${item.targetDays}天折算` : item.source?.label ?? (item.period === 'weekly' ? '周额度' : '月额度'),
|
||||
label: item.period === 'weekly' ? '周额度' : '月额度',
|
||||
value: item.period,
|
||||
})) ?? [
|
||||
{ label: '周额度', value: 'weekly' },
|
||||
@@ -75,9 +75,9 @@ function handleExplanationKeydown(event: KeyboardEvent) {
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<AccountIdentityCell :account="account" show-plan title-mode="email" class="max-w-full">
|
||||
<AccountIdentityCell :account="account" show-plan title-mode="email" meta-position="secondary" meta-size="xs" class="max-w-full">
|
||||
<template #meta>
|
||||
<ProviderIconGroup :provider="account.provider" size="sm" />
|
||||
<ProviderIconGroup :provider="account.provider" size="xs" />
|
||||
</template>
|
||||
</AccountIdentityCell>
|
||||
<BaseSegmented v-model="period" label="预测周期" :options="options" class="w-48" />
|
||||
@@ -104,7 +104,10 @@ function handleExplanationKeydown(event: KeyboardEvent) {
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="mr-auto flex min-w-0 items-center gap-2">
|
||||
<div class="mr-auto flex min-w-0 items-center gap-1">
|
||||
<span v-if="forecast?.source?.observedAt" class="hidden text-cp-xs text-cp-text-tertiary sm:block">
|
||||
更新于 {{ forecast.source.observedAtDisplay }}
|
||||
</span>
|
||||
<BasePopover v-model="explanationOpen" trigger="hover-click" placement="top-start" :hover-delay="240">
|
||||
<template #trigger>
|
||||
<BaseIconButton
|
||||
@@ -132,16 +135,13 @@ function handleExplanationKeydown(event: KeyboardEvent) {
|
||||
目前数据还较少,结果可能有较大波动
|
||||
</p>
|
||||
<p v-if="forecast?.extrapolated" class="m-0">
|
||||
本页预测为折算值,剩余量仍按{{ forecast.source?.label ?? '当前周期' }}计算
|
||||
本页预测由{{ forecast.source?.label ?? '当前周期' }}按 {{ forecast.targetDays }} 天折算
|
||||
</p>
|
||||
<p class="m-0">
|
||||
剩余量以数据更新时间为准,等价费用不是实际账单或账户余额
|
||||
预测以数据更新时间为准,等价费用不是实际账单或账户余额
|
||||
</p>
|
||||
</section>
|
||||
</BasePopover>
|
||||
<span v-if="forecast?.source?.observedAt" class="hidden text-cp-xs text-cp-text-tertiary sm:block">
|
||||
更新于 {{ forecast.source.observedAtDisplay }}
|
||||
</span>
|
||||
</div>
|
||||
<BaseButton variant="secondary" @click="open = false">
|
||||
关闭
|
||||
|
||||
@@ -15,7 +15,7 @@ const emit = defineEmits<{
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="block min-w-0 truncate font-mono text-cp-sm font-emphasis text-cp-text">
|
||||
<code class="block min-w-0 overflow-hidden whitespace-nowrap font-mono text-cp-sm font-emphasis text-cp-text">
|
||||
{{ prefix }}••••••••••••••••
|
||||
</code>
|
||||
<BaseIconButton
|
||||
|
||||
@@ -118,6 +118,8 @@ function previewUsageRecord(options: PreviewUsageRecordOptions): UsageListRecord
|
||||
accountEmail: options.accountEmail,
|
||||
accountName: options.accountEmail.split('@')[0] ?? null,
|
||||
accountNotes: null,
|
||||
accountPlanType: null,
|
||||
accountPlanTypeDisplay: null,
|
||||
route: stream ? '/v1/responses' : '/v1/chat/completions',
|
||||
model: options.model,
|
||||
requestedModel: options.model,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { BaseCard, BaseEmpty, BaseSegmented, BaseTable, defineTableColumns } fro
|
||||
import { CornerDownRight } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
import { formatLocalizedCompactNumber as formatCompactNumber } from '@/utils/format'
|
||||
import AccountPlanBadge from '@/views/accounts/components/AccountPlanBadge.vue'
|
||||
|
||||
import { formatDuration, formatPercent, formatUsd } from '../utils/format'
|
||||
|
||||
@@ -31,38 +32,46 @@ const dimensionOptions = [
|
||||
{ label: '错误', value: 'failureClass' },
|
||||
]
|
||||
|
||||
const diagnosticColumns = defineTableColumns<DiagnosticDisplayItem>([
|
||||
const resultDimension = computed(() => props.diagnostics.dimension || dimension.value)
|
||||
const resultDimensionLabel = computed(
|
||||
() => dimensionOptions.find(option => option.value === resultDimension.value)?.label ?? '维度',
|
||||
)
|
||||
|
||||
const diagnosticColumns = computed(() => defineTableColumns<DiagnosticDisplayItem>([
|
||||
{
|
||||
key: 'nameDisplay',
|
||||
label: '维度',
|
||||
kind: 'custom',
|
||||
size: 'xl',
|
||||
},
|
||||
...(resultDimension.value === 'account'
|
||||
? defineTableColumns<DiagnosticDisplayItem>([
|
||||
{ key: 'accountPlanType', label: '套餐', kind: 'status', size: 'sm', fixedWidth: true },
|
||||
])
|
||||
: []),
|
||||
{
|
||||
key: 'requestCount',
|
||||
label: '请求',
|
||||
kind: 'numeric',
|
||||
size: 'sm',
|
||||
size: 'xs',
|
||||
fixedWidth: true,
|
||||
},
|
||||
{
|
||||
key: 'errorCount',
|
||||
label: '失败',
|
||||
kind: 'numeric',
|
||||
size: 'sm',
|
||||
size: 'xs',
|
||||
fixedWidth: true,
|
||||
},
|
||||
{ key: 'firstTokenP95Ms', label: '性能', kind: 'numeric', size: 'lg' },
|
||||
{ key: 'firstTokenP95Ms', label: '性能', kind: 'numeric', size: 'lg', fixedWidth: true },
|
||||
{
|
||||
key: 'estimatedCost',
|
||||
label: '费用',
|
||||
kind: 'numeric',
|
||||
size: 'sm',
|
||||
fixedWidth: true,
|
||||
},
|
||||
])
|
||||
|
||||
const resultDimension = computed(() => props.diagnostics.dimension || dimension.value)
|
||||
const resultDimensionLabel = computed(
|
||||
() => dimensionOptions.find(option => option.value === resultDimension.value)?.label ?? '维度',
|
||||
)
|
||||
]))
|
||||
|
||||
type DiagnosticDisplayItem = Diagnostics['items'][number] & {
|
||||
nameDisplay: ReturnType<typeof diagnosticNameDisplay>
|
||||
@@ -98,7 +107,7 @@ function diagnosticNameDisplay(name: string) {
|
||||
as="article"
|
||||
title="热点诊断"
|
||||
:description="`按${resultDimensionLabel}聚合`"
|
||||
class="h-105 min-h-105 max-h-105 min-w-0 w-full lg:h-full lg:min-h-90 lg:max-h-105"
|
||||
class="max-h-105 min-w-0 w-full xl:h-full"
|
||||
>
|
||||
<template #actions>
|
||||
<BaseSegmented
|
||||
@@ -114,7 +123,7 @@ function diagnosticNameDisplay(name: string) {
|
||||
<BaseTable
|
||||
v-if="hasData"
|
||||
:key="resultDimension"
|
||||
class="min-h-0 w-full xl:contain-[size]"
|
||||
class="min-h-0 max-h-80 w-full xl:max-h-none xl:contain-[size]"
|
||||
:columns="diagnosticColumns"
|
||||
:rows="displayItems"
|
||||
density="compact"
|
||||
@@ -148,6 +157,16 @@ function diagnosticNameDisplay(name: string) {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #accountPlanType="{ row }">
|
||||
<AccountPlanBadge
|
||||
v-if="row.accountPlanType"
|
||||
:plan-type="row.accountPlanType"
|
||||
:plan-type-display="row.accountPlanTypeDisplay || row.accountPlanType"
|
||||
size="sm"
|
||||
/>
|
||||
<span v-else class="text-cp-text-quaternary">—</span>
|
||||
</template>
|
||||
|
||||
<template #requestCount="{ row }">
|
||||
<span
|
||||
class="grid justify-items-end gap-1 font-mono leading-none tabular-nums"
|
||||
@@ -205,7 +224,7 @@ function diagnosticNameDisplay(name: string) {
|
||||
surface="none"
|
||||
:title="loading ? '正在加载热点诊断数据' : '暂无诊断数据'"
|
||||
description="当前范围没有可诊断的请求记录"
|
||||
class="h-full place-content-center"
|
||||
class="h-full min-h-40 place-content-center"
|
||||
/>
|
||||
</template>
|
||||
</BaseCard>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { UsageDisplayRecord } from '../utils/records'
|
||||
import { BaseTable } from '@codex-proxy/ui'
|
||||
import { Minimize2 } from '@lucide/vue'
|
||||
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
|
||||
import AccountPlanBadge from '@/views/accounts/components/AccountPlanBadge.vue'
|
||||
import {
|
||||
usageAccountText,
|
||||
usageAuthenticationKind,
|
||||
@@ -72,6 +73,16 @@ withDefaults(
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #accountPlanType="{ row }">
|
||||
<AccountPlanBadge
|
||||
v-if="row.accountPlanType"
|
||||
:plan-type="row.accountPlanType"
|
||||
:plan-type-display="row.accountPlanTypeDisplay || row.accountPlanType"
|
||||
size="sm"
|
||||
/>
|
||||
<span v-else class="text-cp-text-quaternary">—</span>
|
||||
</template>
|
||||
|
||||
<template #clientIp="{ row }">
|
||||
<UsageClientIpCell :record="row" />
|
||||
</template>
|
||||
|
||||
@@ -14,14 +14,16 @@ export const usageRecordColumns = defineTableColumns<UsageDisplayRecord>([
|
||||
kind: 'identity',
|
||||
size: '3xl',
|
||||
},
|
||||
{ key: 'accountPlanType', label: '套餐', kind: 'status', size: 'sm', fixedWidth: true },
|
||||
{
|
||||
key: 'provider',
|
||||
label: '平台/类型',
|
||||
kind: 'status',
|
||||
size: 'sm',
|
||||
size: 'md',
|
||||
fixedWidth: true,
|
||||
format: (value: unknown) => formatProvider(typeof value === 'string' ? value : null),
|
||||
},
|
||||
{ key: 'model', label: '模型', kind: 'custom', size: 'lg' },
|
||||
{ key: 'model', label: '模型', kind: 'custom', size: 'xl' },
|
||||
{ key: 'reasoningEffort', label: '推理强度', kind: 'status', size: 'md', align: 'left' },
|
||||
{ key: 'route', label: '端点', kind: 'mono' },
|
||||
{ key: 'upstreamTransport', label: '上游', kind: 'status', size: 'md' },
|
||||
|
||||
+1
-1
Submodule modules/plugins updated: 073ad98cd0...7a04904ffc
+1
-1
Submodule modules/ui updated: e1d4a164e7...6f6cc09264
+13
-9
@@ -1,15 +1,19 @@
|
||||
# v3.18.1
|
||||
# v3.18.2
|
||||
|
||||
## 问题修复
|
||||
|
||||
- 在线更新不再因插件不兼容而阻止安装,点击重启时检查已安装目标版本,列出不兼容插件的名称与原因,确认后停用这些插件并重启
|
||||
- 确认绑定目标发行和插件配置,检查后发生变更时要求重新确认;取消不修改插件状态,批量停用保留配置、密钥与私有数据
|
||||
- 插件管理使用黄色警告三角展示不兼容原因,禁止启动不兼容插件,安装兼容版本后可重新启用
|
||||
- 兼容更新文件已安装、旧进程尚未重启时的管理端操作,保留旧版本的重启入口
|
||||
- 修复热点诊断在分组超过 100 项时请求占比偏高的问题,分母使用筛选范围内全部匹配请求;重试率按发生重试的请求数计算,同一请求多次重试只计一次
|
||||
- 系统更新弹窗点击后立即打开,加载期间显示骨架屏;修复关闭时发布说明提前消失、面板高度跳变的问题
|
||||
|
||||
## 计费与管理界面
|
||||
|
||||
- 补充 GPT-6.1 Sol 内置计费规则,覆盖长上下文、服务档位、缓存与搜索费用
|
||||
- 热点诊断的账号维度和使用记录增加当前套餐列,调整列宽、对齐及套餐徽标对比度,减少诊断卡片空白
|
||||
- 精简额度预测弹窗,移除剩余额度行,周期标签统一为“周额度”和“月额度”;平台与套餐放到邮箱下方,说明图标移到更新时间后
|
||||
- API 密钥前缀超出列宽时直接裁切,保留脱敏显示与复制按钮
|
||||
|
||||
## 升级说明
|
||||
|
||||
- 从 v3.17.0 升级:插件已卸载或全部停用时,可直接在线更新到本版;仍启用旧合同插件时,需先停用一次,旧进程不能提前使用新版确认流程
|
||||
- 本版发行清单保留 v3.17.0 可解析的传输格式,实际支持范围仍为清单 2、进程协议 2、中间件能力 3,不宣称兼容旧插件
|
||||
- v3.18.0 的清单解析规则与 v3.17.0 互斥,无法读取此过渡格式;已运行 v3.18.0 的实例需用完整平台发行包或容器镜像升级一次。二进制部署须成套替换程序、Web 资源和 `plugins/official` 目录
|
||||
- 进入本版后的后续在线更新使用重启前检查与确认流程,Workbench 可安装 [v0.2.0](https://github.com/zyycn/codex-proxy-plugins/releases/tag/v0.2.0)
|
||||
- 额度预测接口不再返回 `remainingTokens`、`remainingUsd` 及对应的 `*Display` 字段;直接使用该接口的客户端需同步调整,预计总量的计算规则保持不变
|
||||
- 套餐列展示账号当前套餐,不是请求发生时的套餐快照;月额度缺少对应上游窗口时仍按 30 天折算,并保留折算提示
|
||||
- v3.18.0 的更新清单解析限制仍适用,需使用完整平台发行包或容器镜像升级;二进制部署须成套替换程序、Web 资源和 `plugins/official` 目录
|
||||
|
||||
@@ -1 +1 @@
|
||||
version: "3.18.1"
|
||||
version: "3.18.2"
|
||||
|
||||
Reference in New Issue
Block a user