feat(observability): expose runtime pressure insights

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-08-30 00:46:01 +08:00
co-authored by Codex
parent 1d862922db
commit d4855cca9c
51 changed files with 1549 additions and 488 deletions
@@ -402,6 +402,16 @@ pub struct RequestMetrics {
pub max_latency_ms: Option<u64>,
pub latency_percentiles: LatencyPercentiles,
pub first_token_latency_percentiles: LatencyPercentiles,
pub admission_decision_count: u64,
pub admission_decision_percentiles: LatencyPercentiles,
pub account_selection_wait_count: u64,
pub account_selection_wait_percentiles: LatencyPercentiles,
pub output_throughput_p10: Option<u64>,
pub output_throughput_p50: Option<u64>,
pub output_throughput_p90: Option<u64>,
pub capacity_sample_count: u64,
pub capacity_utilization_avg_basis_points: Option<u64>,
pub capacity_utilization_p95_basis_points: Option<u64>,
pub cache_eligible_request_count: u64,
pub cache_hit_request_count: u64,
}
@@ -604,6 +614,10 @@ pub struct UsageRecord {
pub first_token_ms: Option<u64>,
pub provider_processing_ms: Option<u64>,
pub latency_ms: Option<u64>,
pub admission_decision_ms: Option<u64>,
pub account_selection_wait_ms: Option<u64>,
pub capacity_used_slots: Option<u64>,
pub capacity_total_slots: Option<u64>,
pub client_ip: Option<String>,
pub user_agent: Option<String>,
pub reasoning_effort: Option<String>,
@@ -708,6 +722,9 @@ pub struct DiagnosticObservation {
pub total_tokens: u64,
pub average_latency_ms: Option<u64>,
pub latency_p95_ms: Option<u64>,
pub first_token_p95_ms: Option<u64>,
pub non_completion_count: u64,
pub retry_count: u64,
pub cost_coverage: CostCoverage,
pub costs: Vec<CurrencyCost>,
}
@@ -787,6 +804,9 @@ pub struct TrendSummary {
pub average_latency_ms: Option<u64>,
pub max_latency_ms: Option<u64>,
pub min_latency_ms: Option<u64>,
pub peak_first_token_p95_ms: Option<f64>,
pub peak_latency_p95_ms: Option<f64>,
pub minimum_output_throughput_p50: Option<u64>,
pub success_rate: Option<f64>,
pub cache_hit_request_rate: Option<f64>,
pub costs: Vec<CurrencyCost>,
@@ -935,6 +955,7 @@ pub struct DashboardResult {
/// 洞察健康时间点的语义结果。
#[derive(Debug, Clone, PartialEq)]
pub struct UsageInsightsHealthPoint {
pub total_requests: u64,
pub bucket_start: DateTime<Utc>,
pub success_requests: u64,
pub failed_requests: u64,
@@ -954,6 +975,7 @@ pub struct UsageInsightsHealth {
pub incomplete_requests: u64,
pub caller_error_requests: u64,
pub success_rate: f64,
pub completion_rate: f64,
pub points: Vec<UsageInsightsHealthPoint>,
}
@@ -963,6 +985,13 @@ pub struct UsageInsightsPerformancePoint {
pub bucket_start: DateTime<Utc>,
pub latency_percentiles: LatencyPercentiles,
pub first_token_latency_percentiles: LatencyPercentiles,
pub admission_decision_percentiles: LatencyPercentiles,
pub account_selection_wait_percentiles: LatencyPercentiles,
pub output_throughput_p10: Option<u64>,
pub output_throughput_p50: Option<u64>,
pub output_throughput_p90: Option<u64>,
pub capacity_utilization_avg_basis_points: Option<u64>,
pub capacity_utilization_p95_basis_points: Option<u64>,
}
/// 洞察性能汇总与可观测覆盖率。
@@ -972,6 +1001,16 @@ pub struct UsageInsightsPerformance {
pub first_token_latency_percentiles: LatencyPercentiles,
pub latency_coverage: f64,
pub first_token_coverage: f64,
pub admission_decision_percentiles: LatencyPercentiles,
pub account_selection_wait_percentiles: LatencyPercentiles,
pub admission_decision_coverage: f64,
pub account_selection_wait_coverage: f64,
pub output_throughput_p10: Option<u64>,
pub output_throughput_p50: Option<u64>,
pub output_throughput_p90: Option<u64>,
pub capacity_utilization_avg_basis_points: Option<u64>,
pub capacity_utilization_p95_basis_points: Option<u64>,
pub capacity_coverage: f64,
pub points: Vec<UsageInsightsPerformancePoint>,
}
@@ -985,6 +1024,8 @@ pub struct UsageInsightsCostPoint {
pub total_tokens: u64,
pub estimated_cost: Option<DecimalAmount>,
pub standard_cost: Option<DecimalAmount>,
pub no_cache_cost: Option<DecimalAmount>,
pub cache_savings: Option<DecimalAmount>,
pub cached_token_rate: f64,
pub cache_hit_request_rate: Option<f64>,
}
@@ -994,7 +1035,11 @@ pub struct UsageInsightsCostPoint {
pub struct UsageInsightsCost {
pub estimated_cost: Option<DecimalAmount>,
pub standard_cost: Option<DecimalAmount>,
pub no_cache_cost: Option<DecimalAmount>,
pub cache_savings: Option<DecimalAmount>,
pub tier_premium: Option<DecimalAmount>,
pub cost_per_request: Option<DecimalAmount>,
pub cost_per_successful_request: Option<DecimalAmount>,
pub tokens_per_request: f64,
pub cached_token_rate: f64,
pub cache_hit_request_rate: Option<f64>,
@@ -1030,6 +1075,12 @@ pub struct DiagnosticsItem {
pub request_share: f64,
pub average_latency_ms: Option<u64>,
pub latency_p95_ms: Option<u64>,
pub first_token_p95_ms: Option<u64>,
pub non_completion_count: u64,
pub non_completion_rate: f64,
pub retry_count: u64,
pub retry_rate: f64,
pub impact_score: f64,
pub estimated_cost: Option<DecimalAmount>,
pub attempt_count: u64,
pub total_tokens: u64,
@@ -2,6 +2,7 @@
use std::{
collections::{BTreeMap, btree_map::Entry},
str::FromStr,
sync::Arc,
};
@@ -246,26 +247,50 @@ impl ObservabilityService for DefaultObservabilityService {
let total_requests = items.iter().fold(0_u64, |total, item| {
total.saturating_add(item.request_count)
});
Ok(DiagnosticsResult {
dimension,
items: items
.into_iter()
.map(|item| DiagnosticsItem {
let mut items = 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 impact_score = diagnostic_impact_score(
item.request_count,
total_requests,
error_rate,
non_completion_rate,
retry_rate,
item.first_token_p95_ms,
);
DiagnosticsItem {
key: item.key,
name: item.name,
request_count: item.request_count,
success_count: item.request_count.saturating_sub(item.failure_count),
success_count: item.success_count,
error_count: item.failure_count,
error_rate: rate_or_zero(item.failure_count, item.request_count),
error_rate,
request_share: rate_or_zero(item.request_count, total_requests),
average_latency_ms: item.average_latency_ms,
latency_p95_ms: item.latency_p95_ms,
first_token_p95_ms: item.first_token_p95_ms,
non_completion_count: item.non_completion_count,
non_completion_rate,
retry_count: item.retry_count,
retry_rate,
impact_score,
estimated_cost: usd_cost(&item.costs),
attempt_count: item.attempt_count,
total_tokens: item.total_tokens,
})
.collect(),
})
}
})
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.impact_score
.total_cmp(&left.impact_score)
.then_with(|| right.request_count.cmp(&left.request_count))
});
Ok(DiagnosticsResult { dimension, items })
}
async fn ops_errors(&self, query: OpsErrorQuery) -> Result<OpsErrorPage, AdminError> {
@@ -279,7 +304,7 @@ impl ObservabilityService for DefaultObservabilityService {
fn build_usage_insights(
overview: UsageOverview,
trend: Vec<RequestMetricPoint>,
standard_costs: UsageStandardCosts,
standard_costs: UsageCostScenarios,
) -> Result<UsageInsights, AdminError> {
let granularity = trend.first().map_or(
crate::model::observability::Granularity::FifteenMinutes,
@@ -293,19 +318,26 @@ fn build_usage_insights(
let failed_requests = service_failure_count(requests);
let health_requests = requests.success_count.saturating_add(failed_requests);
let health = UsageInsightsHealth {
total_requests: health_requests,
total_requests: requests.request_count,
success_requests: requests.success_count,
failed_requests,
cancelled_requests: requests.cancelled_count,
incomplete_requests: requests.incomplete_count,
caller_error_requests: requests.caller_error_count,
success_rate: rate_or_zero(requests.success_count, health_requests),
completion_rate: rate_or_zero(
requests
.success_count
.saturating_add(requests.failure_count),
requests.request_count,
),
points: trend
.iter()
.map(|point| {
let failed_requests = service_failure_count(&point.metrics);
let health_requests = point.metrics.success_count.saturating_add(failed_requests);
UsageInsightsHealthPoint {
total_requests: point.metrics.request_count,
bucket_start: point.bucket_start,
success_requests: point.metrics.success_count,
failed_requests,
@@ -325,27 +357,74 @@ fn build_usage_insights(
requests.first_token_latency_count,
requests.request_count,
),
admission_decision_percentiles: requests.admission_decision_percentiles,
account_selection_wait_percentiles: requests.account_selection_wait_percentiles,
admission_decision_coverage: rate_or_zero(
requests.admission_decision_count,
requests.request_count,
),
account_selection_wait_coverage: rate_or_zero(
requests.account_selection_wait_count,
requests.request_count,
),
output_throughput_p10: requests.output_throughput_p10,
output_throughput_p50: requests.output_throughput_p50,
output_throughput_p90: requests.output_throughput_p90,
capacity_utilization_avg_basis_points: requests.capacity_utilization_avg_basis_points,
capacity_utilization_p95_basis_points: requests.capacity_utilization_p95_basis_points,
capacity_coverage: rate_or_zero(requests.capacity_sample_count, requests.request_count),
points: trend
.iter()
.map(|point| UsageInsightsPerformancePoint {
bucket_start: point.bucket_start,
latency_percentiles: point.metrics.latency_percentiles,
first_token_latency_percentiles: point.metrics.first_token_latency_percentiles,
admission_decision_percentiles: point.metrics.admission_decision_percentiles,
account_selection_wait_percentiles: point
.metrics
.account_selection_wait_percentiles,
output_throughput_p10: point.metrics.output_throughput_p10,
output_throughput_p50: point.metrics.output_throughput_p50,
output_throughput_p90: point.metrics.output_throughput_p90,
capacity_utilization_avg_basis_points: point
.metrics
.capacity_utilization_avg_basis_points,
capacity_utilization_p95_basis_points: point
.metrics
.capacity_utilization_p95_basis_points,
})
.collect(),
};
let estimated_cost = usd_cost(&overview.attempts.costs);
let UsageStandardCosts {
total: standard_cost,
by_bucket: standard_costs_by_bucket,
let UsageCostScenarios {
standard:
ScenarioCosts {
total: standard_cost,
by_bucket: standard_costs_by_bucket,
},
no_cache:
ScenarioCosts {
total: no_cache_cost,
by_bucket: no_cache_costs_by_bucket,
},
} = standard_costs;
let cache_savings = amount_difference(no_cache_cost.as_ref(), estimated_cost.as_ref());
let tier_premium = amount_difference(estimated_cost.as_ref(), standard_cost.as_ref());
let cost_per_request = estimated_cost
.as_ref()
.and_then(|cost| cost.checked_div_u64(requests.request_count));
let cost_per_successful_request = estimated_cost
.as_ref()
.and_then(|cost| cost.checked_div_u64(requests.success_count));
let cost = UsageInsightsCost {
cost_per_request: estimated_cost
.as_ref()
.and_then(|cost| cost.checked_div_u64(requests.request_count)),
cost_per_request,
estimated_cost,
standard_cost,
no_cache_cost,
cache_savings,
tier_premium,
tokens_per_request: rate_or_zero(requests.total_tokens, requests.request_count),
cost_per_successful_request,
cached_token_rate: rate_or_zero(requests.cached_tokens, requests.input_tokens),
cache_hit_request_rate: ratio(
requests.cache_hit_request_count,
@@ -365,6 +444,11 @@ fn build_usage_insights(
total_tokens: point.metrics.total_tokens,
estimated_cost: usd_cost(&point.costs),
standard_cost: standard_costs_by_bucket.get(&point.bucket_start).cloned(),
no_cache_cost: no_cache_costs_by_bucket.get(&point.bucket_start).cloned(),
cache_savings: amount_difference(
no_cache_costs_by_bucket.get(&point.bucket_start),
usd_cost(&point.costs).as_ref(),
),
cached_token_rate: rate_or_zero(
point.metrics.cached_tokens,
point.metrics.input_tokens,
@@ -389,12 +473,12 @@ fn build_usage_insights(
}
#[derive(Debug, Default)]
struct UsageStandardCosts {
struct ScenarioCosts {
total: Option<DecimalAmount>,
by_bucket: BTreeMap<DateTime<Utc>, DecimalAmount>,
}
impl UsageStandardCosts {
impl ScenarioCosts {
fn add(
&mut self,
bucket_start: DateTime<Utc>,
@@ -422,23 +506,29 @@ impl UsageStandardCosts {
}
}
#[derive(Debug, Default)]
struct UsageCostScenarios {
standard: ScenarioCosts,
no_cache: ScenarioCosts,
}
fn recover_standard_costs(
providers: &ProviderAdminRegistry,
facts: Vec<UsageCalculatedBillingFact>,
) -> Result<UsageStandardCosts, AdminError> {
let mut standard_costs = UsageStandardCosts::default();
) -> Result<UsageCostScenarios, AdminError> {
let mut scenarios = UsageCostScenarios::default();
for fact in facts {
let Ok(provider_kind) = ProviderKind::new(fact.provider_kind) else {
let Ok(provider_kind) = ProviderKind::new(fact.provider_kind.clone()) else {
continue;
};
let input = ProviderBillingInput {
upstream_model_id: fact.upstream_model_id,
service_tier: fact.service_tier,
upstream_model_id: fact.upstream_model_id.clone(),
service_tier: fact.service_tier.clone(),
input_tokens: fact.input_tokens,
output_tokens: fact.output_tokens,
cached_tokens: fact.cached_tokens,
cache_write_tokens: fact.cache_write_tokens,
total: fact.total,
total: fact.total.clone(),
};
let breakdown = match providers.calculated_billing(&provider_kind, &input) {
Ok(breakdown) => breakdown,
@@ -455,9 +545,80 @@ fn recover_standard_costs(
{
continue;
}
standard_costs.add(fact.bucket_start, breakdown.standard_amount.amount)?;
scenarios
.standard
.add(fact.bucket_start, breakdown.standard_amount.amount.clone())?;
if let Some(no_cache_cost) = no_cache_cost(&fact, &breakdown) {
scenarios.no_cache.add(fact.bucket_start, no_cache_cost)?;
}
}
Ok(standard_costs)
Ok(scenarios)
}
fn no_cache_cost(
fact: &UsageCalculatedBillingFact,
breakdown: &crate::model::observability::CalculatedBillingBreakdown,
) -> Option<DecimalAmount> {
let input_tokens = fact.input_tokens?;
let cached_tokens = fact.cached_tokens.unwrap_or_default().min(input_tokens);
let cache_write_tokens = fact
.cache_write_tokens
.unwrap_or_default()
.min(input_tokens.saturating_sub(cached_tokens));
let input_rate = decimal(&breakdown.input_price_per_million.amount)?;
let replaced_input_amount = input_rate
.scaled()
.checked_mul(u128::from(cached_tokens.saturating_add(cache_write_tokens)))?
.checked_div(1_000_000)?;
let total = decimal(&breakdown.total_amount.amount)?
.scaled()
.checked_sub(decimal(&breakdown.cache_read_amount.amount)?.scaled())?
.checked_sub(decimal(&breakdown.cache_write_amount.amount)?.scaled())?
.checked_add(replaced_input_amount)?;
DecimalAmount::from_str(
&gateway_core::accounting::Decimal::from_scaled(total)
.ok()?
.canonical(),
)
.ok()
}
fn amount_difference(
larger: Option<&DecimalAmount>,
smaller: Option<&DecimalAmount>,
) -> Option<DecimalAmount> {
let difference = decimal(larger?)?
.scaled()
.checked_sub(decimal(smaller?)?.scaled())?;
DecimalAmount::from_str(
&gateway_core::accounting::Decimal::from_scaled(difference)
.ok()?
.canonical(),
)
.ok()
}
fn decimal(value: &DecimalAmount) -> Option<gateway_core::accounting::Decimal> {
value.as_str().parse().ok()
}
fn diagnostic_impact_score(
request_count: u64,
total_requests: u64,
error_rate: f64,
non_completion_rate: f64,
retry_rate: f64,
first_token_p95_ms: Option<u64>,
) -> f64 {
let request_share = rate_or_zero(request_count, total_requests);
let slow_score = first_token_p95_ms
.map_or(0.0, |value| value as f64 / 30_000.0)
.min(1.0);
error_rate * 0.35
+ non_completion_rate * 0.25
+ retry_rate.min(1.0) * 0.20
+ request_share * 0.10
+ slow_score * 0.10
}
fn usd_cost(costs: &[CurrencyCost]) -> Option<DecimalAmount> {
@@ -629,6 +790,20 @@ fn trend(kind: TrendKind, points: Vec<RequestMetricPoint>) -> Result<Trend, Admi
metrics.cache_hit_request_count,
metrics.cache_eligible_request_count,
);
let peak_first_token_p95_ms = points
.iter()
.filter_map(|point| point.metrics.first_token_latency_percentiles.p95_ms)
.map(|value| value.as_f64())
.reduce(f64::max);
let peak_latency_p95_ms = points
.iter()
.filter_map(|point| point.metrics.latency_percentiles.p95_ms)
.map(|value| value.as_f64())
.reduce(f64::max);
let minimum_output_throughput_p50 = points
.iter()
.filter_map(|point| point.metrics.output_throughput_p50)
.min();
Ok(Trend {
kind,
points: points.into_iter().map(trend_point).collect(),
@@ -642,6 +817,9 @@ fn trend(kind: TrendKind, points: Vec<RequestMetricPoint>) -> Result<Trend, Admi
average_latency_ms,
max_latency_ms: metrics.max_latency_ms,
min_latency_ms: metrics.min_latency_ms,
peak_first_token_p95_ms,
peak_latency_p95_ms,
minimum_output_throughput_p50,
success_rate,
cache_hit_request_rate,
costs: costs
@@ -14,10 +14,10 @@ use gateway_admin::{
observability::{
AccountPoolMetrics, AttemptMetrics, CostCoverage, CurrencyCost, DashboardObservation,
DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation, Granularity,
HealthStatus, OpsErrorPage, OpsErrorQuery, PageNumber, RequestMetricPoint,
RequestMetrics, RequestOutcome, TimeRange, TrendKind, UsageBilling,
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageOverview, UsagePage,
UsageQuery, UsageRecord, china_day_start,
HealthStatus, LatencyPercentiles, OpsErrorPage, OpsErrorQuery, PageNumber,
PercentileMilliseconds, RequestMetricPoint, RequestMetrics, RequestOutcome, TimeRange,
TrendKind, UsageBilling, UsageCalculatedBillingFact, UsageDetail, UsageFilter,
UsageOverview, UsagePage, UsageQuery, UsageRecord, china_day_start,
},
settings::{
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
@@ -234,6 +234,24 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
output_tokens: 200,
total_tokens: 1_000,
latency_count: 5,
admission_decision_count: 10,
admission_decision_percentiles: LatencyPercentiles {
p50_ms: Some(PercentileMilliseconds::new(2.0).expect("admission P50")),
p95_ms: Some(PercentileMilliseconds::new(5.0).expect("admission P95")),
p99_ms: Some(PercentileMilliseconds::new(8.0).expect("admission P99")),
},
account_selection_wait_count: 8,
account_selection_wait_percentiles: LatencyPercentiles {
p50_ms: Some(PercentileMilliseconds::new(10.0).expect("selection P50")),
p95_ms: Some(PercentileMilliseconds::new(40.0).expect("selection P95")),
p99_ms: Some(PercentileMilliseconds::new(70.0).expect("selection P99")),
},
output_throughput_p10: Some(40),
output_throughput_p50: Some(50),
output_throughput_p90: Some(60),
capacity_sample_count: 8,
capacity_utilization_avg_basis_points: Some(6_500),
capacity_utilization_p95_basis_points: Some(8_000),
..RequestMetrics::default()
};
store.replace_overview(UsageOverview {
@@ -294,10 +312,16 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
.await
.expect("usage diagnostics");
assert_eq!(insights.health.total_requests, 8);
assert_eq!(insights.health.total_requests, 10);
assert_eq!(insights.health.failed_requests, 2);
assert_eq!(insights.health.success_rate, 0.75);
assert_eq!(insights.health.completion_rate, 1.0);
assert_eq!(insights.granularity, Granularity::FifteenMinutes);
assert_eq!(insights.performance.latency_coverage, 0.5);
assert_eq!(insights.performance.admission_decision_coverage, 1.0);
assert_eq!(insights.performance.account_selection_wait_coverage, 0.8);
assert_eq!(insights.performance.capacity_coverage, 0.8);
assert_eq!(insights.performance.output_throughput_p50, Some(50));
assert_eq!(insights.cost.tokens_per_request, 100.0);
assert_eq!(
insights
@@ -337,8 +361,25 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
.map(gateway_admin::model::observability::DecimalAmount::as_str),
Some("1")
);
assert_eq!(
insights
.cost
.no_cache_cost
.as_ref()
.map(gateway_admin::model::observability::DecimalAmount::as_str),
Some("1.25")
);
assert_eq!(
insights
.cost
.tier_premium
.as_ref()
.map(gateway_admin::model::observability::DecimalAmount::as_str),
Some("0.25")
);
assert_eq!(diagnostics.items[0].request_share, 0.75);
assert_eq!(diagnostics.items[1].request_share, 0.25);
assert!((diagnostics.items[0].impact_score - 0.075).abs() < 1e-9);
}
#[tokio::test]
@@ -715,6 +756,10 @@ fn total_record(
first_token_ms: None,
provider_processing_ms: None,
latency_ms: Some(100),
admission_decision_ms: None,
account_selection_wait_ms: None,
capacity_used_slots: None,
capacity_total_slots: None,
client_ip: None,
user_agent: None,
reasoning_effort: None,
@@ -742,6 +787,9 @@ fn health_metric_point(bucket_start: DateTime<Utc>, metrics: RequestMetrics) ->
fn diagnostic(name: &str, request_count: u64) -> DiagnosticObservation {
DiagnosticObservation {
first_token_p95_ms: None,
non_completion_count: 0,
retry_count: 0,
key: name.to_owned(),
name: name.to_owned(),
request_count,
@@ -17,6 +17,21 @@ pub(crate) fn display_duration(value: Option<u64>) -> String {
}
}
fn display_duration_f64(value: Option<f64>) -> String {
let Some(value) = value.filter(|value| value.is_finite() && *value >= 0.0) else {
return "—".to_owned();
};
if value < 1_000.0 {
format!("{value:.0} ms")
} else if value < 60_000.0 {
format!("{:.2} s", value / 1_000.0)
} else if value < 3_600_000.0 {
format!("{:.1} min", value / 60_000.0)
} else {
format!("{:.1} h", value / 3_600_000.0)
}
}
pub(crate) fn display_rate(value: f64) -> String {
if value.is_finite() {
format!("{:.1}%", value * 100.0)
@@ -25,6 +40,10 @@ pub(crate) fn display_rate(value: f64) -> String {
}
}
fn basis_points_rate(value: u64) -> f64 {
value as f64 / 10_000.0
}
pub(crate) fn china_datetime(value: &DateTime<Utc>) -> String {
(*value + Duration::hours(8))
.format("%Y-%m-%d %H:%M:%S")
@@ -267,6 +286,10 @@ pub(crate) fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView
image_generation_requested: record.image_generation_requested,
image_generation_succeeded: record.image_generation_succeeded,
latency_details: UsageLatencyDetailsView {
admission_decision_ms: record.admission_decision_ms,
account_selection_wait_ms: record.account_selection_wait_ms,
capacity_used_slots: record.capacity_used_slots,
capacity_total_slots: record.capacity_total_slots,
transport_decision_wait_ms: record.transport_decision_wait_ms,
ws_connect_ms: record.connect_ms,
upstream_headers_ms: record.headers_ms,
@@ -514,6 +537,36 @@ pub(crate) fn trend_point_view(point: domain::TrendPoint) -> TrendPointView {
success_rate: success_rate_value
.map_or_else(|| "—".to_owned(), |value| format!("{value:.1}%")),
success_rate_value,
first_token_p50_ms: point
.metrics
.first_token_latency_percentiles
.p50_ms
.map(|value| value.as_f64()),
first_token_p95_ms: point
.metrics
.first_token_latency_percentiles
.p95_ms
.map(|value| value.as_f64()),
latency_p95_ms: point
.metrics
.latency_percentiles
.p95_ms
.map(|value| value.as_f64()),
output_throughput_p50: point.metrics.output_throughput_p50,
admission_decision_p95_ms: point
.metrics
.admission_decision_percentiles
.p95_ms
.map(|value| value.as_f64()),
account_selection_wait_p95_ms: point
.metrics
.account_selection_wait_percentiles
.p95_ms
.map(|value| value.as_f64()),
capacity_utilization: point
.metrics
.capacity_utilization_p95_basis_points
.map(basis_points_rate),
}
}
@@ -541,18 +594,20 @@ pub(crate) fn trend_summary_view(
],
TrendKind::Latency => vec![
TrendSummaryView {
label: "平均".to_owned(),
value: display_duration(summary.average_latency_ms),
label: "首字 P95".to_owned(),
value: display_duration_f64(summary.peak_first_token_p95_ms),
ratio: None,
},
TrendSummaryView {
label: "最高".to_owned(),
value: display_duration(summary.max_latency_ms),
label: "总耗时 P95".to_owned(),
value: display_duration_f64(summary.peak_latency_p95_ms),
ratio: None,
},
TrendSummaryView {
label: "最低".to_owned(),
value: display_duration(summary.min_latency_ms),
label: "吞吐 P50".to_owned(),
value: summary
.minimum_output_throughput_p50
.map_or_else(|| "—".to_owned(), |value| format!("{value} tok/s")),
ratio: None,
},
],
@@ -821,6 +876,7 @@ pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsig
.map(|point| OverviewHealthPointView {
bucket: point.bucket_start,
label: china_label(point.bucket_start, "%m-%d %H:%M"),
total_requests: point.total_requests,
success_requests: point.success_requests,
failed_requests: point.failed_requests,
cancelled_requests: point.cancelled_requests,
@@ -851,6 +907,31 @@ pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsig
.first_token_latency_percentiles
.p99_ms
.map(|value| value.as_f64()),
admission_decision_p50_ms: point
.admission_decision_percentiles
.p50_ms
.map(|value| value.as_f64()),
admission_decision_p95_ms: point
.admission_decision_percentiles
.p95_ms
.map(|value| value.as_f64()),
account_selection_wait_p50_ms: point
.account_selection_wait_percentiles
.p50_ms
.map(|value| value.as_f64()),
account_selection_wait_p95_ms: point
.account_selection_wait_percentiles
.p95_ms
.map(|value| value.as_f64()),
output_throughput_p10: point.output_throughput_p10,
output_throughput_p50: point.output_throughput_p50,
output_throughput_p90: point.output_throughput_p90,
capacity_utilization: point
.capacity_utilization_avg_basis_points
.map(basis_points_rate),
capacity_utilization_p95: point
.capacity_utilization_p95_basis_points
.map(basis_points_rate),
})
.collect();
let cost_points = insights
@@ -866,6 +947,8 @@ pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsig
total_tokens: point.total_tokens,
estimated_cost: point.estimated_cost.as_ref().map(ToString::to_string),
standard_cost: point.standard_cost.as_ref().map(ToString::to_string),
no_cache_cost: point.no_cache_cost.as_ref().map(ToString::to_string),
cache_savings: point.cache_savings.as_ref().map(ToString::to_string),
cached_token_rate: point.cached_token_rate,
cache_hit_request_rate: point.cache_hit_request_rate,
})
@@ -880,6 +963,7 @@ pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsig
incomplete_requests: insights.health.incomplete_requests,
caller_error_requests: insights.health.caller_error_requests,
success_rate: insights.health.success_rate,
completion_rate: insights.health.completion_rate,
request_change_rate: None,
success_rate_change: None,
points: health_points,
@@ -915,8 +999,42 @@ pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsig
.first_token_latency_percentiles
.p99_ms
.map(|value| value.as_f64()),
admission_decision_p50_ms: insights
.performance
.admission_decision_percentiles
.p50_ms
.map(|value| value.as_f64()),
admission_decision_p95_ms: insights
.performance
.admission_decision_percentiles
.p95_ms
.map(|value| value.as_f64()),
account_selection_wait_p50_ms: insights
.performance
.account_selection_wait_percentiles
.p50_ms
.map(|value| value.as_f64()),
account_selection_wait_p95_ms: insights
.performance
.account_selection_wait_percentiles
.p95_ms
.map(|value| value.as_f64()),
output_throughput_p10: insights.performance.output_throughput_p10,
output_throughput_p50: insights.performance.output_throughput_p50,
output_throughput_p90: insights.performance.output_throughput_p90,
capacity_utilization: insights
.performance
.capacity_utilization_avg_basis_points
.map(basis_points_rate),
capacity_utilization_p95: insights
.performance
.capacity_utilization_p95_basis_points
.map(basis_points_rate),
latency_coverage: insights.performance.latency_coverage,
first_token_coverage: insights.performance.first_token_coverage,
admission_decision_coverage: insights.performance.admission_decision_coverage,
account_selection_wait_coverage: insights.performance.account_selection_wait_coverage,
capacity_coverage: insights.performance.capacity_coverage,
points: performance_points,
},
cost: OverviewCostView {
@@ -930,11 +1048,27 @@ pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsig
.standard_cost
.as_ref()
.map(ToString::to_string),
no_cache_cost: insights
.cost
.no_cache_cost
.as_ref()
.map(ToString::to_string),
cache_savings: insights
.cost
.cache_savings
.as_ref()
.map(ToString::to_string),
tier_premium: insights.cost.tier_premium.as_ref().map(ToString::to_string),
cost_per_request: insights
.cost
.cost_per_request
.as_ref()
.map(ToString::to_string),
cost_per_successful_request: insights
.cost
.cost_per_successful_request
.as_ref()
.map(ToString::to_string),
tokens_per_request: insights.cost.tokens_per_request,
cached_token_rate: insights.cost.cached_token_rate,
cache_hit_request_rate: insights.cost.cache_hit_request_rate,
@@ -992,6 +1126,12 @@ pub(crate) fn diagnostics_view(
request_share: item.request_share,
average_latency_ms: item.average_latency_ms,
latency_p95_ms: item.latency_p95_ms,
first_token_p95_ms: item.first_token_p95_ms,
non_completion_count: item.non_completion_count,
non_completion_rate: item.non_completion_rate,
retry_count: item.retry_count,
retry_rate: item.retry_rate,
impact_score: item.impact_score,
estimated_cost: item.estimated_cost.as_ref().map(ToString::to_string),
attempt_count: item.attempt_count,
total_tokens: item.total_tokens,
@@ -157,6 +157,14 @@ pub struct WebSocketPoolMetadataView {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageLatencyDetailsView {
#[serde(skip_serializing_if = "Option::is_none")]
pub admission_decision_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub account_selection_wait_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub capacity_used_slots: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub capacity_total_slots: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub transport_decision_wait_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -256,6 +264,13 @@ pub struct TrendPointView {
pub min_latency_value: Option<u64>,
pub success_rate: String,
pub success_rate_value: Option<f64>,
pub first_token_p50_ms: Option<f64>,
pub first_token_p95_ms: Option<f64>,
pub latency_p95_ms: Option<f64>,
pub output_throughput_p50: Option<u64>,
pub admission_decision_p95_ms: Option<f64>,
pub account_selection_wait_p95_ms: Option<f64>,
pub capacity_utilization: Option<f64>,
}
/// Dashboard 趋势摘要。
@@ -537,6 +552,7 @@ pub struct UsageSummaryView {
pub struct OverviewHealthPointView {
pub bucket: DateTime<Utc>,
pub label: String,
pub total_requests: u64,
pub success_requests: u64,
pub failed_requests: u64,
pub cancelled_requests: u64,
@@ -556,6 +572,7 @@ pub struct OverviewHealthView {
pub incomplete_requests: u64,
pub caller_error_requests: u64,
pub success_rate: f64,
pub completion_rate: f64,
pub request_change_rate: Option<f64>,
pub success_rate_change: Option<f64>,
pub points: Vec<OverviewHealthPointView>,
@@ -573,6 +590,15 @@ pub struct OverviewPerformancePointView {
pub first_token_p50_ms: Option<f64>,
pub first_token_p95_ms: Option<f64>,
pub first_token_p99_ms: Option<f64>,
pub admission_decision_p50_ms: Option<f64>,
pub admission_decision_p95_ms: Option<f64>,
pub account_selection_wait_p50_ms: Option<f64>,
pub account_selection_wait_p95_ms: Option<f64>,
pub output_throughput_p10: Option<u64>,
pub output_throughput_p50: Option<u64>,
pub output_throughput_p90: Option<u64>,
pub capacity_utilization: Option<f64>,
pub capacity_utilization_p95: Option<f64>,
}
/// 洞察性能摘要。
@@ -585,8 +611,20 @@ pub struct OverviewPerformanceView {
pub first_token_p50_ms: Option<f64>,
pub first_token_p95_ms: Option<f64>,
pub first_token_p99_ms: Option<f64>,
pub admission_decision_p50_ms: Option<f64>,
pub admission_decision_p95_ms: Option<f64>,
pub account_selection_wait_p50_ms: Option<f64>,
pub account_selection_wait_p95_ms: Option<f64>,
pub output_throughput_p10: Option<u64>,
pub output_throughput_p50: Option<u64>,
pub output_throughput_p90: Option<u64>,
pub capacity_utilization: Option<f64>,
pub capacity_utilization_p95: Option<f64>,
pub latency_coverage: f64,
pub first_token_coverage: f64,
pub admission_decision_coverage: f64,
pub account_selection_wait_coverage: f64,
pub capacity_coverage: f64,
pub points: Vec<OverviewPerformancePointView>,
}
@@ -602,6 +640,8 @@ pub struct OverviewCostPointView {
pub total_tokens: u64,
pub estimated_cost: Option<String>,
pub standard_cost: Option<String>,
pub no_cache_cost: Option<String>,
pub cache_savings: Option<String>,
pub cached_token_rate: f64,
pub cache_hit_request_rate: Option<f64>,
}
@@ -612,7 +652,11 @@ pub struct OverviewCostPointView {
pub struct OverviewCostView {
pub estimated_cost: Option<String>,
pub standard_cost: Option<String>,
pub no_cache_cost: Option<String>,
pub cache_savings: Option<String>,
pub tier_premium: Option<String>,
pub cost_per_request: Option<String>,
pub cost_per_successful_request: Option<String>,
pub tokens_per_request: f64,
pub cached_token_rate: f64,
pub cache_hit_request_rate: Option<f64>,
@@ -661,6 +705,12 @@ pub struct DiagnosticItemView {
pub request_share: f64,
pub average_latency_ms: Option<u64>,
pub latency_p95_ms: Option<u64>,
pub first_token_p95_ms: Option<u64>,
pub non_completion_count: u64,
pub non_completion_rate: f64,
pub retry_count: u64,
pub retry_rate: f64,
pub impact_score: f64,
pub estimated_cost: Option<String>,
pub attempt_count: u64,
pub total_tokens: u64,
@@ -110,6 +110,13 @@ fn trend_wire_should_serialize_kind_and_values_without_store_types() {
min_latency_value: Some(1),
success_rate: "100.0%".to_owned(),
success_rate_value: Some(100.0),
first_token_p50_ms: Some(1.0),
first_token_p95_ms: Some(2.0),
latency_p95_ms: Some(3.0),
output_throughput_p50: Some(50),
admission_decision_p95_ms: Some(4.0),
account_selection_wait_p95_ms: Some(5.0),
capacity_utilization: Some(0.7),
}],
summary: vec![TrendSummaryView {
label: "输入".to_owned(),
@@ -636,6 +643,9 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
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,
cost_coverage: CostCoverage::default(),
costs: Vec::new(),
});
@@ -739,6 +749,10 @@ fn usage_record_with_account(
first_token_ms: None,
provider_processing_ms: None,
latency_ms: None,
admission_decision_ms: None,
account_selection_wait_ms: None,
capacity_used_slots: None,
capacity_total_slots: None,
client_ip: None,
user_agent: None,
reasoning_effort: None,
@@ -890,6 +904,10 @@ async fn usage_route_should_expose_image_and_websocket_facts() {
first_token_ms: Some(19),
provider_processing_ms: Some(29),
latency_ms: Some(31),
admission_decision_ms: Some(3),
account_selection_wait_ms: Some(5),
capacity_used_slots: Some(7),
capacity_total_slots: Some(10),
client_ip: Some("127.0.0.1".to_owned()),
user_agent: Some("usage-metadata-test".to_owned()),
reasoning_effort: Some("max".to_owned()),
@@ -753,6 +753,8 @@ where
}
let metadata = stream.metadata().clone();
let selection_observation = metadata.selection_observation();
let capacity = selection_observation.and_then(|observation| observation.capacity());
if !self.account_selection.is_diagnostic()
&& !candidate
.account_scope()
@@ -830,6 +832,10 @@ where
upstream_model_id: metadata.upstream_model().cloned(),
upstream_transport: metadata.transport().as_str().to_owned(),
http_version: None,
account_selection_wait_ms: selection_observation
.map(|observation| observation.account_selection_wait_ms()),
capacity_used_slots: capacity.map(|snapshot| snapshot.used_slots()),
capacity_total_slots: capacity.map(|snapshot| snapshot.total_slots()),
};
if self.request_persisted {
best_effort_store_write(
@@ -1525,6 +1525,36 @@ pub struct AccountCandidate {
pub signals: AccountRuntimeSignals,
}
/// 一次账号选择看到的可调度并发槽快照。
///
/// `used_slots` 包含刚刚获取成功的当前请求;快照只覆盖本次请求范围内、
/// 模型可用且未被显式排除的账号池。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct AccountCapacitySnapshot {
used_slots: u64,
total_slots: u64,
}
impl AccountCapacitySnapshot {
#[must_use]
pub const fn used_slots(self) -> u64 {
self.used_slots
}
#[must_use]
pub const fn total_slots(self) -> u64 {
self.total_slots
}
#[must_use]
pub fn with_acquired_request(self) -> Self {
Self {
used_slots: self.used_slots.saturating_add(1).min(self.total_slots),
total_slots: self.total_slots,
}
}
}
/// 一次账号选择使用的全局策略快照。
#[derive(Debug, Clone)]
pub struct AccountSelectionContext {
@@ -1600,6 +1630,43 @@ impl<'a> AccountSelection<'a> {
pub struct AccountSelector;
impl AccountSelector {
/// 汇总与本次调度约束一致的并发容量,供请求级观测使用。
#[must_use]
pub fn capacity_snapshot(
&self,
candidates: &[AccountCandidate],
context: &AccountSelectionContext,
) -> Option<AccountCapacitySnapshot> {
let (used_slots, total_slots) = candidates
.iter()
.filter(|candidate| {
!matches!(
self.scheduling_blocker(candidate, context),
Some(
AccountSchedulingBlocker::OutsideClientScope
| AccountSchedulingBlocker::LocalAvailability
| AccountSchedulingBlocker::Excluded
)
)
})
.fold((0_u64, 0_u64), |(used, total), candidate| {
let capacity = u64::from(
candidate
.account
.effective_concurrency(context.policy.max_concurrent_per_account())
.get(),
);
(
used.saturating_add(u64::from(candidate.signals.in_flight)),
total.saturating_add(capacity),
)
});
(total_slots > 0).then_some(AccountCapacitySnapshot {
used_slots: used_slots.min(total_slots),
total_slots,
})
}
/// 从可调度账号中确定一个候选;这里只消费 Provider 已解析的额度投影。
#[must_use]
pub fn select<'a>(
@@ -5,7 +5,7 @@ use std::fmt;
use std::net::IpAddr;
use std::num::NonZeroU32;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use std::time::{Duration, Instant, SystemTime};
use futures::{FutureExt, future::BoxFuture, pin_mut, select_biased};
use futures_timer::Delay;
@@ -473,6 +473,7 @@ impl DefaultExecutionService {
lease_ttl: MODEL_REQUEST_DEADLINE,
limits: client.policy.limits(),
};
let admission_started_at = Instant::now();
match self
.admissions
.admit(admission_request)
@@ -494,6 +495,7 @@ impl DefaultExecutionService {
));
}
}
let admission_decision_ms = duration_ms(admission_started_at.elapsed());
let admission = AdmissionLease {
port: Arc::clone(&self.admissions),
client_api_key_id: client.policy.key_id().clone(),
@@ -525,6 +527,7 @@ impl DefaultExecutionService {
subagent_kind: observation.subagent_kind,
compact: observation.compact,
image_generation_requested: operation.image_generation_requested(),
admission_decision_ms: Some(admission_decision_ms),
started_at,
deadline_at,
};
@@ -666,6 +669,7 @@ impl DefaultExecutionService {
subagent_kind: None,
compact: false,
image_generation_requested: false,
admission_decision_ms: None,
started_at,
deadline_at,
};
@@ -1184,6 +1188,10 @@ fn constant_time_equal(left: &str, right: &str) -> bool {
== 0
}
fn duration_ms(duration: Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}
fn new_request_id() -> Result<ModelRequestId, GatewayError> {
ModelRequestId::new(format!("req_{}", Uuid::now_v7().simple()))
.map_err(|_| GatewayError::new(GatewayErrorKind::Internal, "failed to allocate request ID"))
@@ -569,6 +569,8 @@ pub struct NewModelRequest {
pub subagent_kind: Option<String>,
pub compact: bool,
pub image_generation_requested: bool,
/// Client Key 准入判定的完整耗时;内部探测不经过该阶段。
pub admission_decision_ms: Option<u64>,
pub started_at: SystemTime,
pub deadline_at: SystemTime,
}
@@ -585,6 +587,11 @@ pub struct AttemptRecord {
pub upstream_model_id: Option<UpstreamModelId>,
pub upstream_transport: String,
pub http_version: Option<String>,
/// 本 attempt 从进入 Provider 选择器到持有账号 lease 的等待。
pub account_selection_wait_ms: Option<u64>,
/// 选择成功后(含当前请求)的请求级账号池容量快照。
pub capacity_used_slots: Option<u64>,
pub capacity_total_slots: Option<u64>,
}
/// 需要解释换号的中间失败。
@@ -11,7 +11,9 @@ use async_trait::async_trait;
use futures::Stream;
use thiserror::Error;
use crate::engine::credential::{AccountAttemptFeedback, AccountFeedbackStats, ProviderAccountId};
use crate::engine::credential::{
AccountAttemptFeedback, AccountCapacitySnapshot, AccountFeedbackStats, ProviderAccountId,
};
use crate::engine::{AttemptContext, UpstreamSendState};
use crate::error::{
IdentifierError, OpaqueUpstreamValue, ProviderError, ProviderErrorKind, validate_text,
@@ -57,6 +59,37 @@ pub struct ProviderCallMetadata {
provider_account_id: ProviderAccountId,
upstream_request_id: Option<OpaqueUpstreamValue>,
transport: UpstreamTransport,
selection_observation: Option<ProviderSelectionObservation>,
}
/// Provider 账号选择阶段输出的中立运行压力事实。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProviderSelectionObservation {
account_selection_wait_ms: u64,
capacity: Option<AccountCapacitySnapshot>,
}
impl ProviderSelectionObservation {
#[must_use]
pub const fn new(
account_selection_wait_ms: u64,
capacity: Option<AccountCapacitySnapshot>,
) -> Self {
Self {
account_selection_wait_ms,
capacity,
}
}
#[must_use]
pub const fn account_selection_wait_ms(self) -> u64 {
self.account_selection_wait_ms
}
#[must_use]
pub const fn capacity(self) -> Option<AccountCapacitySnapshot> {
self.capacity
}
}
impl ProviderCallMetadata {
@@ -74,6 +107,7 @@ impl ProviderCallMetadata {
provider_account_id,
upstream_request_id: None,
transport,
selection_observation: None,
}
}
@@ -90,6 +124,7 @@ impl ProviderCallMetadata {
provider_account_id,
upstream_request_id: None,
transport,
selection_observation: None,
}
}
@@ -100,6 +135,16 @@ impl ProviderCallMetadata {
self
}
/// 附加 Provider 选择账号时测得的等待与容量快照。
#[must_use]
pub const fn with_selection_observation(
mut self,
observation: ProviderSelectionObservation,
) -> Self {
self.selection_observation = Some(observation);
self
}
/// 返回 Provider。
#[must_use]
pub const fn provider(&self) -> &ProviderKind {
@@ -130,6 +175,11 @@ impl ProviderCallMetadata {
&self.transport
}
#[must_use]
pub const fn selection_observation(&self) -> Option<ProviderSelectionObservation> {
self.selection_observation
}
/// 确认 metadata 没有替换请求计划中冻结的 Provider 候选。
#[must_use]
pub fn confirms(&self, candidate: &ProviderCandidate) -> bool {
@@ -603,6 +603,7 @@ fn plan_with_policy(
fn model_request(operation: &Operation, deadline: SystemTime) -> NewModelRequest {
let client_key = ClientApiKeyId::new("key_client_1").expect("client key id");
NewModelRequest {
admission_decision_ms: None,
id: ModelRequestId::new("req_core_1").expect("request id"),
client_api_key_id: Some(client_key.clone()),
client_api_key_ref: client_key,
+1 -1
View File
@@ -1,6 +1,6 @@
//! 多 Provider 网关的 PostgreSQL 持久化与 Redis 协调 adapter。
//!
//! 业务规则与 port 由 `gateway-core` / `gateway-admin` 拥有。本 crate 只负责把当前十二张业务表
//! 业务规则与 port 由 `gateway-core` / `gateway-admin` 拥有。本 crate 只负责把 PostgreSQL 业务表
//! 和可丢失 Redis 状态映射为明确的基础设施操作。
use std::sync::Arc;
@@ -100,6 +100,7 @@ pub struct NewModelRequest {
pub subagent_kind: Option<String>,
pub compact: bool,
pub image_generation_requested: bool,
pub admission_decision_ms: Option<u64>,
pub started_at: DateTime<Utc>,
pub deadline_at: DateTime<Utc>,
}
@@ -146,6 +147,9 @@ pub struct ModelRequestAttemptStart {
pub upstream_model_id: Option<String>,
pub upstream_transport: String,
pub http_version: Option<String>,
pub account_selection_wait_ms: Option<u64>,
pub capacity_used_slots: Option<u64>,
pub capacity_total_slots: Option<u64>,
}
impl ModelRequestAttemptStart {
@@ -163,6 +167,11 @@ impl ModelRequestAttemptStart {
if self.attempt_count == 0 {
return Err(invalid("attempt_count must be positive"));
}
match (self.capacity_used_slots, self.capacity_total_slots) {
(None, None) => {}
(Some(used), Some(total)) if total > 0 && used <= total => {}
_ => return Err(invalid("capacity snapshot is invalid")),
}
if self
.provider_account_id
.as_ref()
@@ -379,11 +388,11 @@ impl ModelRequestRepository for PgExecutionStore {
operation, endpoint, client_transport, requested_model_id,
client_ip, user_agent, reasoning_effort,
reasoning_preset, request_kind, subagent_kind, compact,
image_generation_requested, started_at, deadline_at
image_generation_requested, admission_decision_ms, started_at, deadline_at
) values (
$1, $2, $3, $4, $5, $6, $7, $8,
$9, $10, $11, $12, $13::inet, $14, $15,
$16, $17, $18, $19, $20, $21, $22
$16, $17, $18, $19, $20, $21, $22, $23
)",
)
.bind(request.id)
@@ -406,6 +415,10 @@ impl ModelRequestRepository for PgExecutionStore {
.bind(request.subagent_kind)
.bind(request.compact)
.bind(request.image_generation_requested)
.bind(optional_i64(
request.admission_decision_ms,
"admission_decision_ms",
)?)
.bind(request.started_at)
.bind(request.deadline_at)
.execute(&self.pool)
@@ -431,21 +444,22 @@ impl ModelRequestRepository for PgExecutionStore {
operation, endpoint, client_transport, requested_model_id,
client_ip, user_agent, reasoning_effort,
reasoning_preset, request_kind, subagent_kind, compact,
image_generation_requested, started_at, deadline_at,
image_generation_requested, admission_decision_ms, started_at, deadline_at,
provider_kind, provider_account_id, provider_account_ref,
provider_account_name_snapshot, provider_account_email_snapshot,
provider_account_authentication_kind_snapshot,
upstream_model_id, upstream_transport, http_version,
attempt_count, upstream_send_state
attempt_count, upstream_send_state, account_selection_wait_ms,
capacity_used_slots, capacity_total_slots
) values (
$1, $2, $3, $4, $5, $6, $7, $8,
$9, $10, $11, $12, $13::inet, $14, $15,
$16, $17, $18, $19, $20, $21, $22,
$23, $24, $25,
(select name from provider_accounts where id = $24),
(select email from provider_accounts where id = $24),
(select authentication_kind from provider_accounts where id = $24),
$26, $27, $28, 1, 'not_sent'
$16, $17, $18, $19, $20, $21, $22, $23,
$24, $25, $26,
(select name from provider_accounts where id = $25),
(select email from provider_accounts where id = $25),
(select authentication_kind from provider_accounts where id = $25),
$27, $28, $29, 1, 'not_sent', $30, $31, $32
)",
)
.bind(request.id)
@@ -468,6 +482,10 @@ impl ModelRequestRepository for PgExecutionStore {
.bind(request.subagent_kind)
.bind(request.compact)
.bind(request.image_generation_requested)
.bind(optional_i64(
request.admission_decision_ms,
"admission_decision_ms",
)?)
.bind(request.started_at)
.bind(request.deadline_at)
.bind(attempt.provider_kind)
@@ -476,6 +494,18 @@ impl ModelRequestRepository for PgExecutionStore {
.bind(attempt.upstream_model_id)
.bind(attempt.upstream_transport)
.bind(attempt.http_version)
.bind(optional_i64(
attempt.account_selection_wait_ms,
"account_selection_wait_ms",
)?)
.bind(optional_i64(
attempt.capacity_used_slots,
"capacity_used_slots",
)?)
.bind(optional_i64(
attempt.capacity_total_slots,
"capacity_total_slots",
)?)
.execute(&self.pool)
.await
.map_err(|_| postgres_unavailable("insert model request with first attempt"))?;
@@ -507,7 +537,13 @@ impl ModelRequestRepository for PgExecutionStore {
upstream_model_id = $5,
upstream_transport = $6,
http_version = $7,
attempt_count = $8
attempt_count = $8,
account_selection_wait_ms = case
when $9::bigint is null then account_selection_wait_ms
else coalesce(account_selection_wait_ms, 0) + $9
end,
capacity_used_slots = coalesce($10, capacity_used_slots),
capacity_total_slots = coalesce($11, capacity_total_slots)
where id = $1 and outcome = 'running' and downstream_committed_at is null
and $8 = attempt_count + 1
returning attempt_count",
@@ -523,6 +559,18 @@ impl ModelRequestRepository for PgExecutionStore {
i32::try_from(attempt.attempt_count)
.map_err(|_| invalid("attempt_count is too large"))?,
)
.bind(optional_i64(
attempt.account_selection_wait_ms,
"account_selection_wait_ms",
)?)
.bind(optional_i64(
attempt.capacity_used_slots,
"capacity_used_slots",
)?)
.bind(optional_i64(
attempt.capacity_total_slots,
"capacity_total_slots",
)?)
.fetch_optional(&self.pool)
.await
.map_err(|_| postgres_unavailable("begin model request attempt"))?
@@ -1099,6 +1147,7 @@ fn new_model_request_row(request: CoreNewModelRequest) -> NewModelRequest {
subagent_kind: request.subagent_kind,
compact: request.compact,
image_generation_requested: request.image_generation_requested,
admission_decision_ms: request.admission_decision_ms,
started_at: DateTime::<Utc>::from(request.started_at),
deadline_at: DateTime::<Utc>::from(request.deadline_at),
}
@@ -1164,6 +1213,9 @@ fn attempt_start_row(attempt: CoreAttemptRecord) -> ModelRequestAttemptStart {
.map(|model| model.as_str().to_owned()),
upstream_transport: attempt.upstream_transport,
http_version: attempt.http_version,
account_selection_wait_ms: attempt.account_selection_wait_ms,
capacity_used_slots: attempt.capacity_used_slots,
capacity_total_slots: attempt.capacity_total_slots,
}
}
@@ -1,4 +1,4 @@
//! 当前十二张业务表的 PostgreSQL adapters。
//! PostgreSQL 业务表的 adapters。
use async_trait::async_trait;
use sqlx::{PgPool, postgres::PgPoolOptions};
@@ -175,6 +175,20 @@ pub(crate) fn admin_request_metrics(
first_token_latency_percentiles: admin_latency_percentiles(
metrics.first_token_latency_percentiles,
)?,
admission_decision_count: metrics.admission_decision_count,
admission_decision_percentiles: admin_latency_percentiles(
metrics.admission_decision_percentiles,
)?,
account_selection_wait_count: metrics.account_selection_wait_count,
account_selection_wait_percentiles: admin_latency_percentiles(
metrics.account_selection_wait_percentiles,
)?,
output_throughput_p10: metrics.output_throughput_p10,
output_throughput_p50: metrics.output_throughput_p50,
output_throughput_p90: metrics.output_throughput_p90,
capacity_sample_count: metrics.capacity_sample_count,
capacity_utilization_avg_basis_points: metrics.capacity_utilization_avg_basis_points,
capacity_utilization_p95_basis_points: metrics.capacity_utilization_p95_basis_points,
cache_eligible_request_count: metrics.cache_eligible_request_count,
cache_hit_request_count: metrics.cache_hit_request_count,
})
@@ -489,6 +503,10 @@ pub(crate) fn admin_usage_record(
first_token_ms: record.first_token_ms,
provider_processing_ms: record.provider_processing_ms,
latency_ms: record.latency_ms,
admission_decision_ms: record.admission_decision_ms,
account_selection_wait_ms: record.account_selection_wait_ms,
capacity_used_slots: record.capacity_used_slots,
capacity_total_slots: record.capacity_total_slots,
client_ip: record.client_ip,
user_agent: record.user_agent,
reasoning_effort: record.reasoning_effort,
@@ -607,6 +625,9 @@ pub(crate) fn admin_diagnostic_observation(
total_tokens: observation.total_tokens,
average_latency_ms: observation.average_latency_ms,
latency_p95_ms: observation.latency_p95_ms,
first_token_p95_ms: observation.first_token_p95_ms,
non_completion_count: observation.non_completion_count,
retry_count: observation.retry_count,
cost_coverage: admin_cost_coverage(observation.cost_coverage),
costs: admin_currency_costs(observation.costs)?,
})
@@ -717,6 +738,10 @@ pub(crate) fn usage_record_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<
first_token_ms: optional_unsigned(row, "first_token_ms")?,
provider_processing_ms: optional_unsigned(row, "provider_processing_ms")?,
latency_ms: optional_unsigned(row, "latency_ms")?,
admission_decision_ms: optional_unsigned(row, "admission_decision_ms")?,
account_selection_wait_ms: optional_unsigned(row, "account_selection_wait_ms")?,
capacity_used_slots: optional_unsigned(row, "capacity_used_slots")?,
capacity_total_slots: optional_unsigned(row, "capacity_total_slots")?,
client_ip: get(row, "client_ip")?,
user_agent: get(row, "user_agent")?,
reasoning_effort: get(row, "reasoning_effort")?,
@@ -255,6 +255,16 @@ pub struct RequestMetrics {
pub cache_hit_request_count: u64,
pub latency_percentiles: LatencyPercentiles,
pub first_token_latency_percentiles: LatencyPercentiles,
pub admission_decision_count: u64,
pub admission_decision_percentiles: LatencyPercentiles,
pub account_selection_wait_count: u64,
pub account_selection_wait_percentiles: LatencyPercentiles,
pub output_throughput_p10: Option<u64>,
pub output_throughput_p50: Option<u64>,
pub output_throughput_p90: Option<u64>,
pub capacity_sample_count: u64,
pub capacity_utilization_avg_basis_points: Option<u64>,
pub capacity_utilization_p95_basis_points: Option<u64>,
}
impl RequestMetrics {
@@ -549,6 +559,10 @@ pub struct UsageRecord {
pub first_token_ms: Option<u64>,
pub provider_processing_ms: Option<u64>,
pub latency_ms: Option<u64>,
pub admission_decision_ms: Option<u64>,
pub account_selection_wait_ms: Option<u64>,
pub capacity_used_slots: Option<u64>,
pub capacity_total_slots: Option<u64>,
pub client_ip: Option<String>,
pub user_agent: Option<String>,
pub reasoning_effort: Option<String>,
@@ -640,6 +654,9 @@ pub struct DiagnosticObservation {
pub total_tokens: u64,
pub average_latency_ms: Option<u64>,
pub latency_p95_ms: Option<u64>,
pub first_token_p95_ms: Option<u64>,
pub non_completion_count: u64,
pub retry_count: u64,
pub cost_coverage: CostCoverage,
pub costs: Vec<CurrencyCostTotal>,
}
@@ -50,7 +50,44 @@ pub(crate) async fn request_metrics(
percentile_cont(0.95) within group (order by first_token_ms)
filter (where {fact}) as first_token_p95_ms,
percentile_cont(0.99) within group (order by first_token_ms)
filter (where {fact}) as first_token_p99_ms
filter (where {fact}) as first_token_p99_ms,
count(admission_decision_ms)::bigint as admission_decision_count,
percentile_cont(0.50) within group (order by admission_decision_ms)
as admission_decision_p50_ms,
percentile_cont(0.95) within group (order by admission_decision_ms)
as admission_decision_p95_ms,
percentile_cont(0.99) within group (order by admission_decision_ms)
as admission_decision_p99_ms,
count(account_selection_wait_ms)::bigint as account_selection_wait_count,
percentile_cont(0.50) within group (order by account_selection_wait_ms)
as account_selection_wait_p50_ms,
percentile_cont(0.95) within group (order by account_selection_wait_ms)
as account_selection_wait_p95_ms,
percentile_cont(0.99) within group (order by account_selection_wait_ms)
as account_selection_wait_p99_ms,
round((percentile_cont(0.10) within group (
order by output_tokens::double precision * 1000.0
/ greatest(latency_ms - first_token_ms, 1)
) filter (where {fact} and output_tokens > 0
and latency_ms > first_token_ms)))::bigint as output_throughput_p10,
round((percentile_cont(0.50) within group (
order by output_tokens::double precision * 1000.0
/ greatest(latency_ms - first_token_ms, 1)
) filter (where {fact} and output_tokens > 0
and latency_ms > first_token_ms)))::bigint as output_throughput_p50,
round((percentile_cont(0.90) within group (
order by output_tokens::double precision * 1000.0
/ greatest(latency_ms - first_token_ms, 1)
) filter (where {fact} and output_tokens > 0
and latency_ms > first_token_ms)))::bigint as output_throughput_p90,
count(capacity_total_slots)::bigint as capacity_sample_count,
round(avg(capacity_used_slots::double precision
/ nullif(capacity_total_slots, 0)) * 10000)::bigint
as capacity_utilization_avg_basis_points,
round(percentile_cont(0.95) within group (
order by capacity_used_slots::double precision
/ nullif(capacity_total_slots, 0)
) * 10000)::bigint as capacity_utilization_p95_basis_points
from model_requests mr where mr.started_at >= "
));
query.push_bind(range.start);
@@ -145,6 +182,43 @@ pub(crate) async fn request_metric_series(
filter (where {fact}) as first_token_p95_ms,
percentile_cont(0.99) within group (order by first_token_ms)
filter (where {fact}) as first_token_p99_ms,
count(admission_decision_ms)::bigint as admission_decision_count,
percentile_cont(0.50) within group (order by admission_decision_ms)
as admission_decision_p50_ms,
percentile_cont(0.95) within group (order by admission_decision_ms)
as admission_decision_p95_ms,
percentile_cont(0.99) within group (order by admission_decision_ms)
as admission_decision_p99_ms,
count(account_selection_wait_ms)::bigint as account_selection_wait_count,
percentile_cont(0.50) within group (order by account_selection_wait_ms)
as account_selection_wait_p50_ms,
percentile_cont(0.95) within group (order by account_selection_wait_ms)
as account_selection_wait_p95_ms,
percentile_cont(0.99) within group (order by account_selection_wait_ms)
as account_selection_wait_p99_ms,
round((percentile_cont(0.10) within group (
order by output_tokens::double precision * 1000.0
/ greatest(latency_ms - first_token_ms, 1)
) filter (where {fact} and output_tokens > 0
and latency_ms > first_token_ms)))::bigint as output_throughput_p10,
round((percentile_cont(0.50) within group (
order by output_tokens::double precision * 1000.0
/ greatest(latency_ms - first_token_ms, 1)
) filter (where {fact} and output_tokens > 0
and latency_ms > first_token_ms)))::bigint as output_throughput_p50,
round((percentile_cont(0.90) within group (
order by output_tokens::double precision * 1000.0
/ greatest(latency_ms - first_token_ms, 1)
) filter (where {fact} and output_tokens > 0
and latency_ms > first_token_ms)))::bigint as output_throughput_p90,
count(capacity_total_slots)::bigint as capacity_sample_count,
round(avg(capacity_used_slots::double precision
/ nullif(capacity_total_slots, 0)) * 10000)::bigint
as capacity_utilization_avg_basis_points,
round(percentile_cont(0.95) within group (
order by capacity_used_slots::double precision
/ nullif(capacity_total_slots, 0)
) * 10000)::bigint as capacity_utilization_p95_basis_points,
count(*) filter (where {fact} and cost_source = 'provider_reported')::bigint
as provider_reported_count,
count(*) filter (where {fact} and cost_source = 'calculated')::bigint
@@ -438,6 +512,30 @@ pub(crate) fn request_metrics_from_row(row: &sqlx::postgres::PgRow) -> StoreResu
p95_ms: optional_percentile(row, "first_token_p95_ms")?,
p99_ms: optional_percentile(row, "first_token_p99_ms")?,
},
admission_decision_count: unsigned(row, "admission_decision_count")?,
admission_decision_percentiles: LatencyPercentiles {
p50_ms: optional_percentile(row, "admission_decision_p50_ms")?,
p95_ms: optional_percentile(row, "admission_decision_p95_ms")?,
p99_ms: optional_percentile(row, "admission_decision_p99_ms")?,
},
account_selection_wait_count: unsigned(row, "account_selection_wait_count")?,
account_selection_wait_percentiles: LatencyPercentiles {
p50_ms: optional_percentile(row, "account_selection_wait_p50_ms")?,
p95_ms: optional_percentile(row, "account_selection_wait_p95_ms")?,
p99_ms: optional_percentile(row, "account_selection_wait_p99_ms")?,
},
output_throughput_p10: optional_unsigned(row, "output_throughput_p10")?,
output_throughput_p50: optional_unsigned(row, "output_throughput_p50")?,
output_throughput_p90: optional_unsigned(row, "output_throughput_p90")?,
capacity_sample_count: unsigned(row, "capacity_sample_count")?,
capacity_utilization_avg_basis_points: optional_unsigned(
row,
"capacity_utilization_avg_basis_points",
)?,
capacity_utilization_p95_basis_points: optional_unsigned(
row,
"capacity_utilization_p95_basis_points",
)?,
})
}
@@ -123,7 +123,9 @@ pub(crate) const USAGE_RECORD_SELECT: &str =
mr.total_tokens, mr.cost_source, mr.cost_amount::text,
mr.cost_currency, mr.transport_decision_wait_ms, mr.connect_ms, mr.headers_ms,
mr.first_event_ms, mr.first_reasoning_ms, mr.first_text_ms, mr.first_token_ms,
mr.provider_processing_ms, mr.latency_ms, mr.client_ip::text as client_ip,
mr.provider_processing_ms, mr.latency_ms, mr.admission_decision_ms,
mr.account_selection_wait_ms, mr.capacity_used_slots, mr.capacity_total_slots,
mr.client_ip::text as client_ip,
mr.user_agent, mr.reasoning_effort, mr.reasoning_preset, mr.request_kind,
mr.subagent_kind, mr.compact, mr.image_generation_requested,
mr.image_generation_succeeded, mr.started_at, mr.deadline_at, mr.completed_at
@@ -351,6 +353,11 @@ pub(crate) async fn usage_diagnostics(
round(avg(mr.latency_ms))::bigint as average_latency_ms,
round(percentile_cont(0.95) within group (order by mr.latency_ms))::bigint
as latency_p95_ms,
round(percentile_cont(0.95) within group (order by mr.first_token_ms))::bigint
as first_token_p95_ms,
count(*) filter (where mr.outcome in ('cancelled', 'incomplete'))::bigint
as non_completion_count,
coalesce(sum(greatest(mr.attempt_count - 1, 0)), 0)::bigint as retry_count,
count(*) filter (where mr.cost_source = 'provider_reported')::bigint
as provider_reported_count,
count(*) filter (where mr.cost_source = 'calculated')::bigint
@@ -362,7 +369,7 @@ pub(crate) async fn usage_diagnostics(
statement.push_bind(range.start);
statement.push(" and mr.started_at < ");
statement.push_bind(range.end);
push_diagnostic_fact_filter(&mut statement, dimension);
push_diagnostic_dimension_filter(&mut statement, dimension);
push_usage_filter(&mut statement, filter, "mr");
statement.push(" group by dimension_name order by request_count desc, dimension_name limit ");
statement.push_bind(DIAGNOSTIC_LIMIT);
@@ -384,6 +391,9 @@ pub(crate) async fn usage_diagnostics(
total_tokens: unsigned(row, "total_tokens")?,
average_latency_ms: optional_unsigned(row, "average_latency_ms")?,
latency_p95_ms: optional_unsigned(row, "latency_p95_ms")?,
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")?,
cost_coverage: coverage_from_row(row)?,
costs: Vec::new(),
})
@@ -501,7 +511,8 @@ pub(crate) async fn diagnostic_costs(
statement.push(" and mr.started_at < ");
statement.push_bind(range.end);
statement.push(" and mr.cost_amount is not null and mr.cost_currency is not null");
push_diagnostic_fact_filter(&mut statement, dimension);
push_completed_usage_fact_filter(&mut statement, "mr");
push_diagnostic_dimension_filter(&mut statement, dimension);
push_usage_filter(&mut statement, filter, "mr");
statement.push(
" group by dimension_name, mr.cost_currency order by dimension_name, mr.cost_currency",
@@ -539,13 +550,22 @@ pub(crate) fn diagnostic_dimension_sql(dimension: DiagnosticDimension) -> &'stat
}
}
pub(crate) fn push_diagnostic_fact_filter(
pub(crate) fn push_diagnostic_dimension_filter(
statement: &mut QueryBuilder<Postgres>,
dimension: DiagnosticDimension,
) {
if dimension == DiagnosticDimension::Failure {
statement.push(" and mr.error_kind is not null");
} else {
push_completed_usage_fact_filter(statement, "mr");
match dimension {
DiagnosticDimension::Failure => {
statement.push(" and mr.error_kind is not null");
}
DiagnosticDimension::Status => {
statement
.push(" and coalesce(mr.upstream_status_code, mr.client_status_code) is not null");
}
DiagnosticDimension::Provider
| DiagnosticDimension::Model
| DiagnosticDimension::Account
| DiagnosticDimension::ApiKey
| DiagnosticDimension::Transport => {}
}
}
@@ -20,6 +20,7 @@ fn postgres_execution_adapter_implements_core_port() {
fn model_request_rejects_mismatched_client_key_live_id() {
let started_at = Utc::now();
let request = NewModelRequest {
admission_decision_ms: None,
id: "request-1".to_owned(),
client_api_key_id: Some("key-live".to_owned()),
client_api_key_ref: "key-history".to_owned(),
@@ -70,6 +71,7 @@ async fn merged_model_less_first_attempt_should_match_sequential_semantics() {
.await
.expect("seed provider account");
let request = NewModelRequest {
admission_decision_ms: None,
id: "req_merged".to_owned(),
client_api_key_id: None,
client_api_key_ref: "key_merged".to_owned(),
@@ -94,6 +96,9 @@ async fn merged_model_less_first_attempt_should_match_sequential_semantics() {
deadline_at: started_at + Duration::seconds(30),
};
let attempt = ModelRequestAttemptStart {
account_selection_wait_ms: None,
capacity_used_slots: None,
capacity_total_slots: None,
model_request_id: "req_merged".to_owned(),
attempt_count: 1,
provider_kind: "openai".to_owned(),
@@ -146,6 +151,9 @@ async fn merged_model_less_first_attempt_should_match_sequential_semantics() {
.expect("mark sent before retry");
let second = repository
.begin_model_request_attempt(ModelRequestAttemptStart {
account_selection_wait_ms: None,
capacity_used_slots: None,
capacity_total_slots: None,
model_request_id: "req_merged".to_owned(),
attempt_count: 2,
provider_kind: "openai".to_owned(),
@@ -177,6 +185,7 @@ async fn model_request_persists_group_routing_snapshot_without_live_group_foreig
let started_at = Utc::now();
repository
.insert_model_request(NewModelRequest {
admission_decision_ms: None,
id: "req_group_history".to_owned(),
client_api_key_id: None,
client_api_key_ref: "key_group_history".to_owned(),
@@ -983,7 +983,10 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
.expect("usage diagnostics");
assert_eq!(diagnostics[0].key, "acct_observe");
assert_eq!(diagnostics[0].name, "account@example.invalid");
assert_eq!(diagnostics[0].request_count, 1);
assert_eq!(diagnostics[0].request_count, 3);
assert_eq!(diagnostics[0].success_count, 2);
assert_eq!(diagnostics[0].failure_count, 1);
assert_eq!(diagnostics[0].retry_count, 1);
assert_eq!(diagnostics[0].costs[0].amount.as_str(), "1.25");
let errors = repository
@@ -431,6 +431,9 @@ async fn diagnostics_should_fallback_to_name_then_ref_for_missing_snapshots() {
.insert_model_request_with_first_attempt(
new_request("req_snap_legacy", started_at + chrono::Duration::seconds(1)),
ModelRequestAttemptStart {
account_selection_wait_ms: None,
capacity_used_slots: None,
capacity_total_slots: None,
model_request_id: "req_snap_legacy".to_owned(),
attempt_count: 1,
provider_kind: "openai".to_owned(),
@@ -542,6 +545,9 @@ async fn api_key_diagnostics_should_display_key_name_and_fallback_to_ref() {
request.client_api_key_id = Some("key_diag".to_owned());
request.client_api_key_ref = "key_diag".to_owned();
let attempt = ModelRequestAttemptStart {
account_selection_wait_ms: None,
capacity_used_slots: None,
capacity_total_slots: None,
model_request_id: id.to_owned(),
attempt_count: 1,
provider_kind: "openai".to_owned(),
@@ -656,6 +662,7 @@ async fn renaming_account_should_not_rewrite_historical_snapshots() {
fn new_request(id: &str, started_at: DateTime<Utc>) -> NewModelRequest {
NewModelRequest {
admission_decision_ms: None,
id: id.to_owned(),
client_api_key_id: None,
client_api_key_ref: "key_snapshot".to_owned(),
@@ -683,6 +690,9 @@ fn new_request(id: &str, started_at: DateTime<Utc>) -> NewModelRequest {
fn attempt(id: &str, count: u32, account_id: &str) -> ModelRequestAttemptStart {
ModelRequestAttemptStart {
account_selection_wait_ms: None,
capacity_used_slots: None,
capacity_total_slots: None,
model_request_id: id.to_owned(),
attempt_count: count,
provider_kind: "openai".to_owned(),
@@ -6,10 +6,10 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use gateway_core::engine::credential::{
AccountCandidate, AccountEligibilityPolicy, AccountErrorReason, AccountFeedbackStats,
AccountRuntimeSignals, AccountSchedulingBlocker, AccountSelectionContext, AccountSelector,
AccountStatus, CredentialState, PreferredAccountSelection, ProviderAccount, ProviderAccountId,
QuotaEvidence,
AccountCandidate, AccountCapacitySnapshot, AccountEligibilityPolicy, AccountErrorReason,
AccountFeedbackStats, AccountRuntimeSignals, AccountSchedulingBlocker, AccountSelectionContext,
AccountSelector, AccountStatus, CredentialState, PreferredAccountSelection, ProviderAccount,
ProviderAccountId, QuotaEvidence,
};
use gateway_core::engine::{AttemptContext, ContinuationAttempt};
use gateway_core::provider_ports::{
@@ -498,6 +498,7 @@ impl CodexCredentialSelector {
},
account_scope: request.attempt.account_scope().cloned(),
};
let capacity = AccountSelector.capacity_snapshot(&candidates, &context);
let Some(selection) = AccountSelector.select(&candidates, &context) else {
return match shortest_retry {
Some(retry_after) => Err(CredentialSelectionError::CapacityUnavailable {
@@ -619,6 +620,7 @@ impl CodexCredentialSelector {
allows_account_state_mutation,
affinity_telemetry,
affinity_expected_account_id,
capacity: capacity.map(AccountCapacitySnapshot::with_acquired_request),
_guard: guard,
});
}
@@ -1224,6 +1226,7 @@ pub struct CodexCredentialLease {
allows_account_state_mutation: bool,
affinity_telemetry: AffinityTelemetry,
affinity_expected_account_id: ProviderAccountId,
capacity: Option<AccountCapacitySnapshot>,
_guard: Box<dyn ProviderLeaseGuard>,
}
@@ -1282,6 +1285,11 @@ impl CodexCredentialLease {
self.affinity_telemetry.account_switch
}
#[must_use]
pub const fn capacity_snapshot(&self) -> Option<AccountCapacitySnapshot> {
self.capacity
}
/// 成功反馈只能从选择时观察到的绑定迁移,避免迟到请求覆盖较新 winner。
#[must_use]
pub(crate) const fn affinity_expected_account_id(&self) -> &ProviderAccountId {
@@ -12,8 +12,8 @@ use gateway_core::engine::continuation::{ContinuationBinding, NativeContinuation
use gateway_core::engine::credential::{AccountFeedbackStats, ProviderAccount};
use gateway_core::engine::provider::{
EventStream, Provider, ProviderCallMetadata, ProviderCatalogGeneration,
ProviderModelCapabilities, ProviderRequest, ProviderRequestObservation, ProviderStream,
UpstreamTransport,
ProviderModelCapabilities, ProviderRequest, ProviderRequestObservation,
ProviderSelectionObservation, ProviderStream, UpstreamTransport,
};
use gateway_core::engine::{
AttemptContext, CancellationToken, ContinuationAttempt, UpstreamSendState,
@@ -292,6 +292,7 @@ impl Provider for CodexProvider {
let transport = selected_transport(&upstream_request);
apply_transport(&mut upstream_request, transport);
let selection_started_at = Instant::now();
let lease = self
.selector
.select_with_cyber_policy(
@@ -306,6 +307,8 @@ impl Provider for CodexProvider {
)
.await
.map_err(map_selection_error)?;
let account_selection_wait_ms =
u64::try_from(selection_started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
let lease = Arc::new(lease);
// 首字计时的起点:账号选择完成之后、上游建立之前。
let output_started_at = Instant::now();
@@ -390,7 +393,11 @@ impl Provider for CodexProvider {
UpstreamTransport::new(transport_name(transport)).map_err(|_| {
provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent)
})?,
);
)
.with_selection_observation(ProviderSelectionObservation::new(
account_selection_wait_ms,
lease.capacity_snapshot(),
));
let response_store = upstream_request.store();
let session_capture =
(!continuation_requested || previous_session.is_some()).then(|| OpenAiSessionCapture {
@@ -447,6 +454,7 @@ impl CodexProvider {
),
ImageRequestKind::Edit => (CODEX_IMAGE_EDITS_PATH, self.image_edits_url.clone()),
};
let selection_started_at = Instant::now();
let lease = self
.selector
.select_for_provider_endpoint(&SelectCodexProviderEndpointCredential {
@@ -455,6 +463,8 @@ impl CodexProvider {
})
.await
.map_err(map_selection_error)?;
let account_selection_wait_ms =
u64::try_from(selection_started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
let lease = Arc::new(lease);
let allows_account_state_mutation = lease.allows_account_state_mutation();
let provider_kind = ProviderKind::new(PROVIDER_NAME)
@@ -465,7 +475,11 @@ impl CodexProvider {
UpstreamTransport::new(HTTP_JSON_TRANSPORT).map_err(|_| {
provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent)
})?,
);
)
.with_selection_observation(ProviderSelectionObservation::new(
account_selection_wait_ms,
lease.capacity_snapshot(),
));
let image_turn_id = image
.payload()
.context()
@@ -6,9 +6,9 @@ use std::time::{Duration, SystemTime};
use chrono::Utc;
use gateway_core::engine::credential::{
AccountCandidate, AccountEligibilityPolicy, AccountFeedbackStats, AccountSelectionContext,
AccountSelector, CredentialState, ProviderAccount, ProviderAccountId, QuotaAccessState,
QuotaEvidence, QuotaState,
AccountCandidate, AccountCapacitySnapshot, AccountEligibilityPolicy, AccountFeedbackStats,
AccountSelectionContext, AccountSelector, CredentialState, ProviderAccount, ProviderAccountId,
QuotaAccessState, QuotaEvidence, QuotaState,
};
use gateway_core::provider_ports::{
ProviderCooldown, ProviderCooldownPort, ProviderCooldownScope, ProviderLeaseAcquisition,
@@ -200,7 +200,11 @@ impl GrokAccountSessionSelector {
};
let mut capacity_denied = false;
let mut retry_after = None;
while let Some(selection) = AccountSelector.select(&candidates, &context) {
loop {
let capacity = AccountSelector.capacity_snapshot(&candidates, &context);
let Some(selection) = AccountSelector.select(&candidates, &context) else {
break;
};
let selected = selection.candidate();
let selected_id = selected.account.id().clone();
let selected_revision = selected.account.revision();
@@ -265,7 +269,8 @@ impl GrokAccountSessionSelector {
binding,
guard,
)
.map_err(|_| GrokSessionSelectorError::InvalidSession)?;
.map_err(|_| GrokSessionSelectorError::InvalidSession)?
.with_capacity_snapshot(capacity.map(AccountCapacitySnapshot::with_acquired_request));
return Ok(if allows_account_state_mutation {
session
} else {
+16 -5
View File
@@ -13,8 +13,8 @@ use gateway_core::engine::credential::{
};
use gateway_core::engine::provider::{
EventStream, Provider, ProviderCallMetadata, ProviderCatalogGeneration,
ProviderModelCapabilities, ProviderRequest, ProviderRequestObservation, ProviderStream,
UpstreamTransport,
ProviderModelCapabilities, ProviderRequest, ProviderRequestObservation,
ProviderSelectionObservation, ProviderStream, UpstreamTransport,
};
use gateway_core::engine::{AttemptContext, ContinuationAttempt, UpstreamSendState};
use gateway_core::error::{
@@ -215,6 +215,7 @@ impl GrokBuildProvider {
if let Some(previous) = previous_session.as_ref() {
upstream_request.inherit_session(previous.session_id.as_deref());
}
let selection_started_at = Instant::now();
let selected = select_grok_session(
self.selector.as_ref(),
candidate,
@@ -224,6 +225,8 @@ impl GrokBuildProvider {
upstream_request.affinity().cloned(),
)
.await?;
let account_selection_wait_ms =
u64::try_from(selection_started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
// 首字计时的起点:账号选择完成之后、上游建立之前。
let output_started_at = Instant::now();
apply_continuation(
@@ -278,7 +281,7 @@ impl GrokBuildProvider {
});
let selected = Arc::new(selected);
let allows_account_state_mutation = selected.allows_account_state_mutation();
let metadata = provider_call_metadata(candidate, &selected)?;
let metadata = provider_call_metadata(candidate, &selected, account_selection_wait_ms)?;
let events = cold_http_sse_stream(
Arc::clone(&self.selector),
Arc::clone(&self.transport),
@@ -347,6 +350,7 @@ impl GrokBuildProvider {
let upstream_session_id = inherited_session_id
.clone()
.or_else(|| explicit_replay_session_id.clone());
let selection_started_at = Instant::now();
let selected = Arc::new(
select_grok_session(
self.selector.as_ref(),
@@ -358,6 +362,8 @@ impl GrokBuildProvider {
)
.await?,
);
let account_selection_wait_ms =
u64::try_from(selection_started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
let reasoning_replay_key = explicit_replay_session_id
.as_deref()
.or(inherited_session_id.as_deref())
@@ -369,7 +375,7 @@ impl GrokBuildProvider {
)
});
let allows_account_state_mutation = selected.allows_account_state_mutation();
let metadata = provider_call_metadata(candidate, &selected)?;
let metadata = provider_call_metadata(candidate, &selected, account_selection_wait_ms)?;
let events = cold_compaction_http_sse_stream(
Arc::clone(&self.selector),
Arc::clone(&self.transport),
@@ -451,13 +457,18 @@ async fn select_grok_session(
fn provider_call_metadata(
candidate: &ProviderCandidate,
selected: &SelectedGrokSession,
account_selection_wait_ms: u64,
) -> Result<ProviderCallMetadata, ProviderError> {
Ok(ProviderCallMetadata::new(
ProviderKind::new(XAI_PROVIDER_NAME).map_err(|_| protocol_not_sent())?,
candidate_upstream_model(candidate)?.clone(),
selected.account_id().clone(),
UpstreamTransport::new(HTTP_SSE_TRANSPORT).map_err(|_| protocol_not_sent())?,
))
)
.with_selection_observation(ProviderSelectionObservation::new(
account_selection_wait_ms,
selected.capacity_snapshot(),
)))
}
fn candidate_upstream_model(
@@ -6,7 +6,8 @@ use std::sync::Arc;
use std::time::{Duration, SystemTime};
use gateway_core::engine::credential::{
AccountEligibilityPolicy, AccountSelectionPolicy, CredentialRevision, ProviderAccountId,
AccountCapacitySnapshot, AccountEligibilityPolicy, AccountSelectionPolicy, CredentialRevision,
ProviderAccountId,
};
use gateway_core::policy::ClientApiKeyId;
use gateway_core::routing::{FrozenAccountScope, UpstreamModelId};
@@ -89,6 +90,7 @@ pub struct SelectedGrokSession {
email: Option<SecretValue>,
binding: GrokSessionBinding,
allows_account_state_mutation: bool,
capacity: Option<AccountCapacitySnapshot>,
_guard: Box<dyn GrokSessionLeaseGuard>,
}
@@ -123,6 +125,7 @@ impl SelectedGrokSession {
email,
binding,
allows_account_state_mutation: true,
capacity: None,
_guard: Box::new(guard),
})
}
@@ -134,6 +137,15 @@ impl SelectedGrokSession {
self
}
#[must_use]
pub(crate) const fn with_capacity_snapshot(
mut self,
capacity: Option<AccountCapacitySnapshot>,
) -> Self {
self.capacity = capacity;
self
}
/// 返回选中的账号 ID。
#[must_use]
pub const fn account_id(&self) -> &ProviderAccountId {
@@ -174,6 +186,11 @@ impl SelectedGrokSession {
pub(crate) const fn allows_account_state_mutation(&self) -> bool {
self.allows_account_state_mutation
}
#[must_use]
pub(crate) const fn capacity_snapshot(&self) -> Option<AccountCapacitySnapshot> {
self.capacity
}
}
impl fmt::Debug for SelectedGrokSession {
+1
View File
@@ -1 +1,2 @@
60bcece284f2d6a2cd3e8dea67833dfc1c7da625cdd5f0b63884c725fa00a3d4 0001_initial.sql
93219760c733616e7bba9a8933c207b26752b2ebf7e6edfe1ef42ba227c50a7d 0002_runtime_pressure_observability.sql
@@ -0,0 +1,20 @@
-- 请求热路径的准入判定、账号选择等待与账号池容量观测。
alter table model_requests
add column admission_decision_ms bigint,
add column account_selection_wait_ms bigint,
add column capacity_used_slots bigint,
add column capacity_total_slots bigint;
alter table model_requests
add constraint model_requests_runtime_pressure_ck check (
(admission_decision_ms is null or admission_decision_ms >= 0)
and (account_selection_wait_ms is null or account_selection_wait_ms >= 0)
and (
(capacity_used_slots is null and capacity_total_slots is null)
or (
capacity_used_slots >= 0
and capacity_total_slots > 0
and capacity_used_slots <= capacity_total_slots
)
)
);
+1 -3
View File
@@ -4,14 +4,12 @@ sqlx 在服务启动时(`serve` 监听之前)按编号顺序执行本目录
文件的 checksum 记入 `_sqlx_migrations`。此后每次启动都会重新校验:**已应用
迁移的文件字节与记录不一致时,服务直接拒绝启动**。
当前大版本只有一份冻结迁移:`0001_initial.sql` 作为完整的新库基线。
## 冻结规则
- 同一大版本内,已合入 main 的迁移文件**永久冻结**,一个字节都不能改——包括注释和
空白。任何改动都会让该大版本的已部署实例在下次重启时因 checksum 失配而无法启动,
且没有自动恢复路径。
- 同一大版本内的 schema 变更一律新增编号迁移(下一份从 `0003_...` 开始),永远不回改
- 同一大版本内的 schema 变更一律新增编号迁移,永远不回改
旧文件。切换大版本时允许把已完成升级的 schema 折叠成新的 `0001` 基线,因为跨大版本
不支持在线升级,目标版本必须使用全新数据目录部署。
- `.frozen-sha256` 是冻结清单(`sha256sum` 格式)。CI 会校验:清单内文件
+2 -3
View File
@@ -195,9 +195,8 @@ PostgreSQL 周期对账才是正确性基础。
`normal`、`quota_exhausted`、`rate_limited`、`disabled`、`error`。只有明确上游证据才能恢复或终态化账号,
本地时钟和不确定响应不能伪造事实。
当前大版本的十二张业务表由 `0001_initial.sql` 建立;`0002_nullable_requested_model.sql` 允许 Images
等无模型端点把 `requested_model_id` 保存为 `NULL`。已应用迁移按字节冻结,后续 schema 变化只能新增
编号迁移,详见 [迁移规则](../backend/migrations/README.md)。
PostgreSQL schema 由迁移目录按编号管理。已应用迁移按字节冻结,后续 schema 变化只能新增编号迁移,
详见 [迁移规则](../backend/migrations/README.md)。
## 9. Credential、额度与主动重置
+7
View File
@@ -30,6 +30,13 @@ export interface DashboardTrendPoint {
minLatencyValue: number | null
successRate: string
successRateValue: number | null
firstTokenP50Ms: number | null
firstTokenP95Ms: number | null
latencyP95Ms: number | null
outputThroughputP50: number | null
admissionDecisionP95Ms: number | null
accountSelectionWaitP95Ms: number | null
capacityUtilization: number | null
}
export interface DashboardTrendSummaryItem {
+40
View File
@@ -54,6 +54,10 @@ export interface UsageBilling {
}
export interface UsageLatencyDetails {
admissionDecisionMs?: number
accountSelectionWaitMs?: number
capacityUsedSlots?: number
capacityTotalSlots?: number
transportDecisionWaitMs?: number
wsConnectMs?: number
upstreamHeadersMs?: number
@@ -221,6 +225,7 @@ export interface UsageSummaryResponse {
export interface UsageOverviewHealthPoint {
bucket: string
label: string
totalRequests: number
successRequests: number
failedRequests: number
cancelledRequests: number
@@ -237,6 +242,7 @@ export interface UsageOverviewHealth {
incompleteRequests: number
callerErrorRequests: number
successRate: number
completionRate: number
requestChangeRate: number | null
successRateChange: number | null
points: UsageOverviewHealthPoint[]
@@ -251,6 +257,15 @@ export interface UsageOverviewPerformancePoint {
firstTokenP50Ms: number | null
firstTokenP95Ms: number | null
firstTokenP99Ms: number | null
admissionDecisionP50Ms: number | null
admissionDecisionP95Ms: number | null
accountSelectionWaitP50Ms: number | null
accountSelectionWaitP95Ms: number | null
outputThroughputP10: number | null
outputThroughputP50: number | null
outputThroughputP90: number | null
capacityUtilization: number | null
capacityUtilizationP95: number | null
}
export interface UsageOverviewPerformance {
@@ -260,8 +275,20 @@ export interface UsageOverviewPerformance {
firstTokenP50Ms: number | null
firstTokenP95Ms: number | null
firstTokenP99Ms: number | null
admissionDecisionP50Ms: number | null
admissionDecisionP95Ms: number | null
accountSelectionWaitP50Ms: number | null
accountSelectionWaitP95Ms: number | null
outputThroughputP10: number | null
outputThroughputP50: number | null
outputThroughputP90: number | null
capacityUtilization: number | null
capacityUtilizationP95: number | null
latencyCoverage: number
firstTokenCoverage: number
admissionDecisionCoverage: number
accountSelectionWaitCoverage: number
capacityCoverage: number
points: UsageOverviewPerformancePoint[]
}
@@ -274,6 +301,8 @@ export interface UsageOverviewCostPoint {
totalTokens: number
estimatedCost: string | null
standardCost: string | null
noCacheCost: string | null
cacheSavings: string | null
cachedTokenRate: number
cacheHitRequestRate: number | null
}
@@ -281,7 +310,11 @@ export interface UsageOverviewCostPoint {
export interface UsageOverviewCost {
estimatedCost: string | null
standardCost: string | null
noCacheCost: string | null
cacheSavings: string | null
tierPremium: string | null
costPerRequest: string | null
costPerSuccessfulRequest: string | null
tokensPerRequest: number
cachedTokenRate: number
cacheHitRequestRate: number | null
@@ -290,6 +323,7 @@ export interface UsageOverviewCost {
cachedTokens: number
totalTokens: number
points: UsageOverviewCostPoint[]
coverage: UsageCostCoverage
}
export interface UsageInsightsOverviewResponse {
@@ -309,6 +343,12 @@ export interface UsageDiagnosticItem {
requestShare: number
averageLatencyMs: number | null
latencyP95Ms: number | null
firstTokenP95Ms: number | null
nonCompletionCount: number
nonCompletionRate: number
retryCount: number
retryRate: number
impactScore: number
estimatedCost: string | null
attemptCount: number
totalTokens: number
@@ -67,13 +67,10 @@ onBeforeUnmount(() => {
<template>
<span class="inline-flex items-center gap-1.75" :aria-label="statusLabel" :title="statusLabel">
<span
aria-hidden="true"
class="relative inline-flex size-2 shrink-0 items-center justify-center"
>
<span class="relative inline-flex size-2 shrink-0 items-center justify-center">
<span ref="heartbeatRing" class="absolute size-2 rounded-full bg-cp-success opacity-0" />
<span ref="heartbeatDot" class="relative size-1.5 rounded-full bg-cp-success" />
</span>
<span aria-hidden="true">自动刷新 30s</span>
<span>自动刷新 30s</span>
</span>
</template>
@@ -2,12 +2,7 @@
import type { HealthTimelinePoint } from '../constants'
import { computed } from 'vue'
import {
formatHealthCount,
healthReliabilityValueClass,
healthStatusMeta,
} from '../constants'
import { formatHealthCount, healthReliabilityValueClass, healthStatusMeta } from '../constants'
const props = defineProps<{
point: HealthTimelinePoint
@@ -80,9 +75,7 @@ const metricItems = outcomeMeta
{{ status.label }}
</span>
</div>
<span class="shrink-0 text-[10px] leading-none font-emphasis text-cp-text-quaternary">
15 分钟
</span>
<span class="shrink-0 text-[10px] leading-none font-emphasis text-cp-text-quaternary"> 15 分钟 </span>
</header>
<div class="grid gap-3.5 px-3.5 py-3.5">
@@ -99,21 +92,14 @@ const metricItems = outcomeMeta
</strong>
</div>
<div class="text-right">
<span class="block text-[10px] leading-none font-emphasis text-cp-text-quaternary">
有效请求
</span>
<strong
class="mt-2 block font-mono text-cp-xl leading-none font-heavy tabular-nums text-cp-text"
>
<span class="block text-[10px] leading-none font-emphasis text-cp-text-quaternary"> 有效请求 </span>
<strong class="mt-2 block font-mono text-cp-xl leading-none font-heavy tabular-nums text-cp-text">
{{ formatHealthCount(eligibleRequests) }}
</strong>
</div>
</div>
<div
class="flex h-1.5 w-full overflow-hidden rounded-full bg-cp-fill-tertiary"
aria-hidden="true"
>
<div class="flex h-1.5 w-full overflow-hidden rounded-full bg-cp-fill-tertiary">
<span
v-for="segment in outcomeSegments"
:key="segment.label"
@@ -133,18 +119,13 @@ const metricItems = outcomeMeta
<span class="truncate text-[10px] leading-none font-emphasis text-cp-text-secondary">
{{ item.label }}
</span>
<strong
class="font-mono text-cp-sm leading-none font-heavy tabular-nums"
:class="item.valueClass"
>
<strong class="font-mono text-cp-sm leading-none font-heavy tabular-nums" :class="item.valueClass">
{{ formatHealthCount(item.value) }}
</strong>
</div>
</div>
<p
class="m-0 border-t border-cp-split pt-3 text-[10px] leading-[1.45] font-emphasis text-cp-text-quaternary"
>
<p class="m-0 border-t border-cp-split pt-3 text-[10px] leading-[1.45] font-emphasis text-cp-text-quaternary">
客户端取消与调用方错误单独记录,不计入可用性。
</p>
</div>
@@ -75,7 +75,6 @@ const sparklineOption = computed<EChartsOption | null>(() => {
<BaseCard as="article" padding="compact" class="relative h-38.5 w-full">
<div class="flex items-start gap-3">
<BaseMotionIcon
aria-hidden="true"
class="inline-flex size-8.5 shrink-0 items-center justify-center rounded-cp-lg"
:class="metricToneIconClasses[metric.tone]"
>
@@ -85,14 +84,8 @@ const sparklineOption = computed<EChartsOption | null>(() => {
</div>
<div class="mt-3.25 flex h-7.75 items-end gap-2">
<strong
class="font-mono text-[28px] leading-[1.05] font-heavy tabular-nums text-cp-text"
>
<AnimatedMetricValue
:value="metric.value"
:raw-value="metric.valueRaw"
:formatter="metric.valueFormatter"
/>
<strong class="font-mono text-[28px] leading-[1.05] font-heavy tabular-nums text-cp-text">
<AnimatedMetricValue :value="metric.value" :raw-value="metric.valueRaw" :formatter="metric.valueFormatter" />
</strong>
<i
v-if="metric.trend && metric.trend.direction !== 'flat'"
@@ -113,10 +106,10 @@ const sparklineOption = computed<EChartsOption | null>(() => {
<div
class="mt-3 grid h-7.5 w-full grid-cols-[minmax(0,1fr)_minmax(0,1fr)] items-center rounded-cp-lg bg-cp-fill-alter/70 px-3"
>
<span
class="inline-grid min-w-0 w-full grid-cols-[auto_minmax(0,1fr)] items-baseline gap-2.5"
>
<span class="shrink-0 text-cp-xs leading-none font-emphasis text-cp-text-quaternary">{{ metric.details[0]?.label }}</span>
<span class="inline-grid min-w-0 w-full grid-cols-[auto_minmax(0,1fr)] items-baseline gap-2.5">
<span class="shrink-0 text-cp-xs leading-none font-emphasis text-cp-text-quaternary">
{{ metric.details[0]?.label }}
</span>
<b
class="min-w-0 truncate font-mono text-xs leading-none font-bold tabular-nums"
:class="metric.details[0]?.tone ? metricToneValueClasses[metric.details[0].tone] : undefined"
@@ -124,10 +117,10 @@ const sparklineOption = computed<EChartsOption | null>(() => {
{{ metric.details[0]?.value }}
</b>
</span>
<span
class="inline-grid min-w-0 w-full grid-cols-[auto_minmax(0,auto)] items-baseline justify-end gap-2.5"
>
<span class="shrink-0 text-cp-xs leading-none font-emphasis text-cp-text-quaternary">{{ metric.details[1]?.label }}</span>
<span class="inline-grid min-w-0 w-full grid-cols-[auto_minmax(0,auto)] items-baseline justify-end gap-2.5">
<span class="shrink-0 text-cp-xs leading-none font-emphasis text-cp-text-quaternary">
{{ metric.details[1]?.label }}
</span>
<b
class="min-w-0 truncate font-mono text-xs leading-none font-bold tabular-nums"
:class="metric.details[1]?.tone ? metricToneValueClasses[metric.details[1].tone] : undefined"
@@ -6,13 +6,7 @@ import { gsap } from 'gsap'
import { computed, onBeforeUnmount, shallowRef, useTemplateRef, watch } from 'vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BasePopover from '@/components/base/BasePopover.vue'
import {
formatHealthCount,
healthLegend,
healthReliabilityValueClass,
healthStatusMeta,
} from '../constants'
import { formatHealthCount, healthLegend, healthReliabilityValueClass, healthStatusMeta } from '../constants'
import HealthTimelinePointPopover from './HealthTimelinePointPopover.vue'
const props = defineProps<{
@@ -35,12 +29,7 @@ const highlightedPointTime = shallowRef<string>()
let wavedCellIndexes = new Set<number>()
function observedRequests(point: HealthTimelinePoint) {
return (
point.successRequests
+ point.failedRequests
+ point.cancelledRequests
+ point.callerErrorRequests
)
return point.successRequests + point.failedRequests + point.cancelledRequests + point.callerErrorRequests
}
function isInteractivePoint(point: HealthTimelinePoint) {
@@ -79,9 +68,7 @@ function resetPointInteraction() {
}
function timelineButtons() {
return Array.from(
timelineGrid.value?.querySelectorAll<HTMLButtonElement>('[data-health-timeline-point]') ?? [],
)
return Array.from(timelineGrid.value?.querySelectorAll<HTMLButtonElement>('[data-health-timeline-point]') ?? [])
}
function timelineCell(button?: HTMLButtonElement) {
@@ -99,20 +86,13 @@ function animatePointWave(centerIndex: number) {
const cellsByDistance: HTMLElement[][] = [[], [], []]
for (let distance = 0; distance <= 2; distance += 1) {
const candidateIndexes
= distance === 0 ? [centerIndex] : [centerIndex - distance, centerIndex + distance]
const candidateIndexes = distance === 0 ? [centerIndex] : [centerIndex - distance, centerIndex + distance]
for (const index of candidateIndexes) {
const button = buttons[index]
const point = points.value[index]
const cell = timelineCell(button)
if (
!button
|| !point
|| !cell
|| button.offsetTop !== centerRow
|| !isInteractivePoint(point)
) {
if (!button || !point || !cell || button.offsetTop !== centerRow || !isInteractivePoint(point)) {
continue
}
@@ -140,13 +120,7 @@ function animatePointWave(centerIndex: number) {
wavedCellIndexes = nextCellIndexes
}
function animateWaveCells(
cells: HTMLElement[],
y: number,
scaleY: number,
duration: number,
ease: string,
) {
function animateWaveCells(cells: HTMLElement[], y: number, scaleY: number, duration: number, ease: string) {
if (cells.length === 0)
return
@@ -210,12 +184,7 @@ onBeforeUnmount(() => {
</script>
<template>
<BaseCard
as="article"
:title="timeline.title"
:description="timeline.description"
class="w-full"
>
<BaseCard as="article" :title="timeline.title" :description="timeline.description" class="w-full">
<template #actions>
<div class="flex w-full flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div
@@ -226,11 +195,7 @@ onBeforeUnmount(() => {
:key="item.status"
class="inline-flex h-3.5 items-center gap-1 align-middle leading-none"
>
<span
aria-hidden="true"
class="block size-2 shrink-0 rounded-xs"
:class="healthStatusMeta[item.status].cellClass"
/>
<span class="block size-2 shrink-0 rounded-xs" :class="healthStatusMeta[item.status].cellClass" />
<span class="block leading-none">{{ item.label }}</span>
</span>
</div>
@@ -259,10 +224,7 @@ onBeforeUnmount(() => {
class="min-w-0 w-full"
>
<template #trigger>
<div
ref="timelineGrid"
class="grid w-full grid-cols-48 items-end gap-x-0.5 gap-y-1 sm:grid-cols-96"
>
<div ref="timelineGrid" class="grid w-full grid-cols-48 items-end gap-x-0.5 gap-y-1 sm:grid-cols-96">
<button
v-for="(point, pointIndex) in points"
:key="point.time"
@@ -281,7 +243,6 @@ onBeforeUnmount(() => {
@click="activatePoint(point, pointIndex, $event)"
>
<span
aria-hidden="true"
data-health-timeline-cell
class="block h-3.5 w-full origin-bottom rounded-xs transition-[filter,box-shadow] duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none group-focus-visible:shadow-[0_0_0_2px_var(--cp-color-bg-container),0_0_0_4px_var(--cp-control-outline)]"
:class="[
@@ -52,9 +52,7 @@ const {
<template #body>
<div class="grid gap-3.5">
<div
class="grid h-14.25 min-w-0 grid-cols-3 gap-1.5 rounded-xl bg-cp-fill-quaternary/45 p-1.5"
>
<div class="grid h-14.25 min-w-0 grid-cols-3 gap-1.5 rounded-xl bg-cp-fill-quaternary/45 p-1.5">
<button
v-for="item in props.summary"
:key="item.label"
@@ -65,7 +63,6 @@ const {
@click="toggleSummarySeries(item.label)"
>
<i
aria-hidden="true"
class="size-2 rounded-full transition-transform duration-200 ease-out group-hover:scale-125 motion-reduce:transition-none"
:style="summaryMarkerStyle(item)"
:class="[
@@ -79,7 +76,7 @@ const {
},
]"
/>
<span class="grid min-w-0 gap-1">
<span class="grid min-w-0 gap-1.5">
<span class="truncate text-[10px] leading-none font-bold text-cp-text-secondary">
{{ item.label }}
</span>
@@ -95,12 +92,7 @@ const {
<div class="relative h-55 w-full overflow-hidden">
<BaseChart v-if="hasSamples" :option="chartOption" :height="220" />
<BaseEmpty
v-if="!hasSamples"
surface="none"
title="暂无趋势数据"
class="h-full place-content-center"
/>
<BaseEmpty v-if="!hasSamples" surface="none" title="暂无趋势数据" class="h-full place-content-center" />
</div>
</div>
</template>
@@ -46,8 +46,8 @@ const providerOptions = computed(() =>
})),
)
const profile = computed(() =>
props.profiles.find(item => item.provider === activeProvider.value) ?? props.profiles[0] ?? null,
const profile = computed(
() => props.profiles.find(item => item.provider === activeProvider.value) ?? props.profiles[0] ?? null,
)
const versionParts = computed(() => {
@@ -66,9 +66,7 @@ const releaseLabel = computed(() => {
const release = profile.value?.release
if (!release?.latestVersion)
return '尚未检查'
return release.latestBuild
? `${release.latestVersion} · Build ${release.latestBuild}`
: release.latestVersion
return release.latestBuild ? `${release.latestVersion} · Build ${release.latestBuild}` : release.latestVersion
})
const releaseStatus = computed(() => {
@@ -153,9 +151,7 @@ const runtimeEnvironment = computed(() => {
return { primary: '—', details: [] as string[], title: '—' }
const present = (value: string) => value !== '—' && value.toLowerCase() !== 'unknown'
const primary = [target.osType, target.osVersion]
.filter(present)
.join(' ')
const primary = [target.osType, target.osVersion].filter(present).join(' ')
const details = [target.arch, target.terminal].filter(present)
return {
primary: primary || '—',
@@ -179,11 +175,7 @@ function providerLabel(provider: string) {
</script>
<template>
<BaseCard
as="article"
title="上游请求身份"
class="flex min-h-95 w-full flex-col"
>
<BaseCard as="article" title="上游请求身份" class="flex min-h-95 w-full flex-col">
<template #actions>
<BaseSegmented
v-if="providerOptions.length > 1"
@@ -196,27 +188,22 @@ function providerLabel(provider: string) {
</template>
<template #body>
<BaseEmpty
v-if="!profile"
title="暂无请求身份"
surface="inset"
class="min-h-71.75 flex-1 place-content-center"
/>
<BaseEmpty v-if="!profile" title="暂无请求身份" surface="inset" class="min-h-71.75 flex-1 place-content-center" />
<div v-else class="flex flex-1">
<section
aria-label="请求身份组成"
class="grid min-w-0 flex-1 content-between gap-6 rounded-cp-lg bg-cp-fill-alter/70 px-5 py-5.5 sm:px-6 sm:py-5"
:class="verifiedLabel || checkedLabel
? 'sm:grid-rows-[auto_minmax(0,1fr)_auto_auto]'
: 'sm:grid-rows-[auto_minmax(0,1fr)_auto]'"
:class="
verifiedLabel || checkedLabel
? 'sm:grid-rows-[auto_minmax(0,1fr)_auto_auto]'
: 'sm:grid-rows-[auto_minmax(0,1fr)_auto]'
"
>
<div class="flex min-w-0 items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-2 text-cp-text">
<span
class="inline-flex size-7 shrink-0 items-center justify-center rounded-lg bg-cp-fill-tertiary"
>
<Box aria-hidden="true" class="size-3.75 text-cp-text-secondary" />
<span class="inline-flex size-7 shrink-0 items-center justify-center rounded-lg bg-cp-fill-tertiary">
<Box class="size-3.75 text-cp-text-secondary" />
</span>
<span class="truncate text-cp-xs leading-none font-heavy">{{ profile.product }}</span>
</div>
@@ -225,7 +212,7 @@ function providerLabel(provider: string) {
:class="releaseStatus.tone"
:title="releaseStatus.title"
>
<component :is="releaseStatus.icon" aria-hidden="true" class="size-3.5" />
<component :is="releaseStatus.icon" class="size-3.5" />
{{ releaseStatus.label }}
</span>
</div>
@@ -240,11 +227,7 @@ function providerLabel(provider: string) {
<span class="wrap-break-word text-[27px] leading-[1.05] font-heavy text-cp-text">
{{ versionParts.release }}
</span>
<span
v-if="versionParts.prerelease"
aria-hidden="true"
class="truncate text-cp-sm font-bold text-cp-text-secondary"
>
<span v-if="versionParts.prerelease" class="truncate text-cp-sm font-bold text-cp-text-secondary">
{{ versionParts.prerelease }}
</span>
</strong>
@@ -259,15 +242,11 @@ function providerLabel(provider: string) {
<dl
class="m-0 grid min-w-0 gap-5 sm:gap-7"
:class="profile.provider === 'xai'
? 'sm:grid-cols-[0.62fr_1.28fr_0.94fr]'
: 'sm:grid-cols-[0.72fr_1.28fr]'"
:class="profile.provider === 'xai' ? 'sm:grid-cols-[0.62fr_1.28fr_0.94fr]' : 'sm:grid-cols-[0.72fr_1.28fr]'"
>
<div v-if="profile.provider === 'xai'" class="min-w-0">
<dt
class="flex items-center gap-1.5 text-[10px] leading-none font-bold text-cp-text-quaternary"
>
<ShieldCheck aria-hidden="true" class="size-3.25 text-cp-text-tertiary" />
<dt class="flex items-center gap-1.5 text-[10px] leading-none font-bold text-cp-text-quaternary">
<ShieldCheck class="size-3.25 text-cp-text-tertiary" />
认证协议
</dt>
<dd
@@ -279,10 +258,8 @@ function providerLabel(provider: string) {
</div>
<div class="min-w-0">
<dt
class="flex items-center gap-1.5 text-[10px] leading-none font-bold text-cp-text-quaternary"
>
<Monitor aria-hidden="true" class="size-3.25 text-cp-text-tertiary" />
<dt class="flex items-center gap-1.5 text-[10px] leading-none font-bold text-cp-text-quaternary">
<Monitor class="size-3.25 text-cp-text-tertiary" />
{{ profile.provider === 'openai' ? '模拟运行环境' : '运行环境' }}
</dt>
<dd
@@ -290,22 +267,15 @@ function providerLabel(provider: string) {
:title="runtimeEnvironment.title"
>
{{ runtimeEnvironment.primary }}
<span
v-if="runtimeEnvironment.details.length"
class="text-cp-xs font-emphasis text-cp-text-secondary"
>
<template v-for="detail in runtimeEnvironment.details" :key="detail">
· {{ detail }}
</template>
<span v-if="runtimeEnvironment.details.length" class="text-cp-xs font-emphasis text-cp-text-secondary">
<template v-for="detail in runtimeEnvironment.details" :key="detail"> · {{ detail }} </template>
</span>
</dd>
</div>
<div class="min-w-0">
<dt
class="flex items-center gap-1.5 text-[10px] leading-none font-bold text-cp-text-quaternary"
>
<Terminal aria-hidden="true" class="size-3.25 text-cp-text-tertiary" />
<dt class="flex items-center gap-1.5 text-[10px] leading-none font-bold text-cp-text-quaternary">
<Terminal class="size-3.25 text-cp-text-tertiary" />
客户端标识
</dt>
<dd
@@ -543,9 +543,9 @@ function trendState(
function trendSummaryTone(label: string) {
if (label.includes('错误'))
return 'danger'
if (label.includes('最高'))
if (label.includes('总耗时'))
return 'warning'
if (label.includes('输出') || label.includes('最低') || label.includes('成功'))
if (label.includes('输出') || label.includes('成功'))
return 'success'
if (label.includes('缓存'))
return 'normal'
@@ -554,9 +554,9 @@ function trendSummaryTone(label: string) {
function trendSummaryColorVar(kind: DashboardTrendKind, label: string) {
if (kind === 'latency') {
if (label.includes('最高'))
if (label.includes('总耗时'))
return '--cp-color-orange-solid'
if (label.includes('最低'))
if (label.includes('输出'))
return '--cp-color-green-solid'
return '--cp-color-blue-solid'
}
@@ -61,6 +61,9 @@ export function useRequestTrendChart(options: {
point.requestsValue > 0
|| point.errorsValue > 0
|| (point.latencyValue ?? 0) > 0
|| (point.firstTokenP95Ms ?? 0) > 0
|| (point.latencyP95Ms ?? 0) > 0
|| (point.outputThroughputP50 ?? 0) > 0
|| point.tokensValue > 0
|| point.cachedTokensValue > 0,
),
@@ -105,7 +108,8 @@ export function useRequestTrendChart(options: {
right: activeKind.value === 'latency' ? 8 : 10,
top: 10,
bottom: 2,
containLabel: true,
outerBoundsMode: 'same' as const,
outerBoundsContain: 'axisLabel' as const,
},
xAxis: {
type: 'category' as const,
@@ -147,7 +151,7 @@ export function useRequestTrendChart(options: {
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
show: activeKind.value !== 'latency',
show: true,
color: activeKind.value === 'usage'
? trendColor('输出', '--cp-color-green-solid', '#12B981')
: trendColor('成功', '--cp-color-green-solid', '#12B981'),
@@ -171,6 +175,8 @@ export function useRequestTrendChart(options: {
function formatSecondaryAxisValue(value: number) {
if (activeKind.value === 'errors')
return `${Math.round(value)}%`
if (activeKind.value === 'latency')
return `${formatAxisCompact(value)}/s`
return formatAxisCompact(value)
}
@@ -219,12 +225,12 @@ export function useRequestTrendChart(options: {
return point?.cachedTokens ?? value
}
if (activeKind.value === 'latency') {
if (name === '平均')
return point?.latency ?? value
if (name === '最高')
return point?.maxLatency ?? value
if (name === '最低')
return point?.minLatency ?? value
if (name === '首字 P95')
return formatLatency(point?.firstTokenP95Ms, value)
if (name === '总耗时 P95')
return formatLatency(point?.latencyP95Ms, value)
if (name === '吞吐 P50')
return point?.outputThroughputP50 == null ? value : `${point.outputThroughputP50} tok/s`
}
if (name === '错误数')
return point?.errors ?? value
@@ -272,37 +278,22 @@ export function useRequestTrendChart(options: {
if (activeKind.value === 'latency') {
return [
lineSeries(
'最低',
activeSeriesValues('minLatencyValue'),
trendColor('最低', '--cp-color-green-solid', '#12B981'),
{
lineType: 'dotted',
smooth: 0.2,
stack: 'latency-range',
width: 1.2,
z: 1,
},
'总耗时 P95',
activeSeriesValues('latencyP95Ms'),
trendColor('总耗时', '--cp-color-orange-solid', '#F59E0B'),
{ lineType: 'dashed', smooth: 0.2, width: 1.8, z: 2 },
),
lineSeries(
'最高',
latencyRangeValues(),
trendColor('最高', '--cp-color-orange-solid', '#F59E0B'),
{
area: true,
areaStartAlpha: '26',
areaEndAlpha: '10',
lineType: 'dashed',
smooth: 0.2,
stack: 'latency-range',
width: 1.2,
z: 1,
},
'首字 P95',
activeSeriesValues('firstTokenP95Ms'),
trendColor('首字', '--cp-color-blue-solid', '#5983F4'),
{ area: true, smooth: 0.24, width: 2.5, z: 4 },
),
lineSeries(
'平均',
activeSeriesValues('latencyValue'),
trendColor('平均', '--cp-color-blue-solid', '#5983F4'),
{ smooth: 0.24, width: 2.5, z: 4 },
'吞吐 P50',
activeSeriesValues('outputThroughputP50'),
trendColor('吞吐', '--cp-color-green-solid', '#12B981'),
{ smooth: 0.2, width: 2, yAxisIndex: 1, z: 3 },
),
]
}
@@ -338,22 +329,6 @@ export function useRequestTrendChart(options: {
)
}
function latencyRangeValues() {
return points.value.map((point) => {
if (Number(point.requestsValue) <= 0)
return null
if (point.minLatencyValue === null || point.maxLatencyValue === null)
return null
if (point.minLatencyValue === undefined || point.maxLatencyValue === undefined)
return null
const minimum = Number(point.minLatencyValue)
const maximum = Number(point.maxLatencyValue)
return Number.isFinite(minimum) && Number.isFinite(maximum)
? Math.max(0, maximum - minimum)
: null
})
}
function lineSeries(
name: string,
data: (number | null)[],
@@ -483,6 +458,16 @@ function formatAxisNumber(value: number) {
return value >= 10 ? value.toFixed(0) : value.toFixed(1).replace(/\.0$/, '')
}
function formatLatency(value: number | null | undefined, fallback: string) {
if (value == null || !Number.isFinite(value))
return fallback
if (value < 1_000)
return `${Math.round(value)} ms`
if (value < 60_000)
return `${formatAxisNumber(value / 1_000)} s`
return `${formatAxisNumber(value / 60_000)} min`
}
function showTwoHourLabel(_index: number, value: string) {
const [hour, minute] = value.split(':').map(Number)
return minute === 0 && hour % 2 === 0
@@ -85,6 +85,13 @@ function previewTrendPoint(requestsValue: number, index: number): DashboardTrend
minLatencyValue: 180 + index * 3,
successRate: `${successRateValue.toFixed(1)}%`,
successRateValue,
firstTokenP50Ms: 210 + index * 3,
firstTokenP95Ms: 420 + index * 5,
latencyP95Ms: 760 + index * 12,
outputThroughputP50: 52 + (index % 5),
admissionDecisionP95Ms: 2 + (index % 3),
accountSelectionWaitP95Ms: 8 + (index % 4),
capacityUtilization: 0.42 + (index % 6) * 0.04,
}
}
@@ -17,11 +17,11 @@ import {
usageLegend,
usageLineSeries,
usageTooltip,
usageTooltipContent,
usageValueAxis,
} from '../utils/chart'
import {
decimalDisplayNumber,
escapeTooltip,
formatPercent,
formatUsd,
formatUsdAxis,
@@ -43,13 +43,18 @@ const props = withDefaults(
const activeView = shallowRef('cost')
const { palette } = useChartPalette()
const points = computed(() => props.cost.points)
const hasStandardCost = computed(() =>
points.value.some(point => point.standardCost != null),
const hasNoCacheCost = computed(() =>
points.value.some(point => point.noCacheCost != null),
)
const coverageRate = computed(() => {
const { known, partial, unknown } = props.cost.coverage
const total = known + partial + unknown
return total > 0 ? (known + partial) / total : null
})
const viewOptions = [
{ label: '费用', value: 'cost' },
{ label: 'Token', value: 'tokens' },
{ label: '节省', value: 'savings' },
{ label: '缓存', value: 'cache' },
]
@@ -57,11 +62,10 @@ const hasData = computed(() => {
if (props.loading || points.value.length === 0)
return false
if (activeView.value === 'cost') {
return points.value.some(point => point.estimatedCost != null || point.standardCost != null)
}
if (activeView.value === 'tokens') {
return points.value.some(point => point.totalTokens > 0 || point.cachedTokens > 0)
return points.value.some(point => point.estimatedCost != null || point.noCacheCost != null)
}
if (activeView.value === 'savings')
return points.value.some(point => point.cacheSavings != null)
return points.value.some(
point => point.inputTokens > 0 || point.cachedTokenRate > 0 || (point.cacheHitRequestRate ?? 0) > 0,
)
@@ -73,7 +77,14 @@ const chartOption = computed<EChartsOption>(() => {
return {
animationDuration: 240,
grid: { left: 0, right: 0, top: 40, bottom: 0, containLabel: true },
grid: {
left: 0,
right: 0,
top: 40,
bottom: 0,
outerBoundsMode: 'same',
outerBoundsContain: 'axisLabel',
},
legend: usageLegend(theme, legend),
tooltip: usageTooltip(theme, formatTooltip),
xAxis: usageCategoryAxis(
@@ -89,15 +100,15 @@ const chartOption = computed<EChartsOption>(() => {
})
function legendNames() {
if (activeView.value === 'tokens')
return ['未缓存输入', '缓存输入', '输出']
if (activeView.value === 'savings')
return ['缓存节省']
if (activeView.value === 'cache')
return ['缓存 Token 占比', '命中请求率']
return hasStandardCost.value ? ['预估费用', '标准费用'] : ['预估费用']
return hasNoCacheCost.value ? ['实际费用', '无缓存费用'] : ['实际费用']
}
function axisFormatter() {
if (activeView.value === 'cost')
if (activeView.value === 'cost' || activeView.value === 'savings')
return formatUsdAxis
if (activeView.value === 'cache')
return (value: number) => formatPercent(value)
@@ -106,25 +117,13 @@ function axisFormatter() {
function chartSeries(theme: UsageChartPalette): LineSeriesOption[] {
const chartPoints = points.value
if (activeView.value === 'tokens') {
if (activeView.value === 'savings') {
return [
usageLineSeries(
'未缓存输入',
chartPoints.map(point => Math.max(0, point.inputTokens - point.cachedTokens)),
theme.info,
{ stack: 'tokens' },
),
usageLineSeries(
'缓存输入',
chartPoints.map(point => point.cachedTokens),
theme.normal,
{ stack: 'tokens' },
),
usageLineSeries(
'输出',
chartPoints.map(point => point.outputTokens),
'缓存节省',
chartPoints.map(point => decimalDisplayNumber(point.cacheSavings)),
theme.success,
{ stack: 'tokens' },
{ area: 'strong' },
),
]
}
@@ -146,17 +145,17 @@ function chartSeries(theme: UsageChartPalette): LineSeriesOption[] {
const series = [
usageLineSeries(
'预估费用',
'实际费用',
chartPoints.map(point => decimalDisplayNumber(point.estimatedCost)),
theme.success,
{ area: 'strong' },
),
]
if (hasStandardCost.value) {
if (hasNoCacheCost.value) {
series.push(
usageLineSeries(
'标准费用',
chartPoints.map(point => decimalDisplayNumber(point.standardCost)),
'无缓存费用',
chartPoints.map(point => decimalDisplayNumber(point.noCacheCost)),
theme.textMuted,
),
)
@@ -170,32 +169,25 @@ function formatTooltip(params: unknown) {
if (!point)
return ''
const title = escapeTooltip(point.label)
if (activeView.value === 'tokens') {
return [
title,
`未缓存输入: ${formatCompactNumber(Math.max(0, point.inputTokens - point.cachedTokens))}`,
`缓存输入: ${formatCompactNumber(point.cachedTokens)}`,
`输出: ${formatCompactNumber(point.outputTokens)}`,
`总 Token: ${formatCompactNumber(point.totalTokens)}`,
].join('<br/>')
if (activeView.value === 'savings') {
return usageTooltipContent(palette.value, point.label, [
`缓存节省: ${formatUsd(point.cacheSavings)}`,
])
}
if (activeView.value === 'cache') {
return [
title,
return usageTooltipContent(palette.value, point.label, [
`缓存 Token 占比: ${formatPercent(point.cachedTokenRate)}`,
`命中请求率: ${formatPercent(point.cacheHitRequestRate)}`,
].join('<br/>')
])
}
const lines = [
title,
`预估费用: ${formatUsd(point.estimatedCost)}`,
`实际费用: ${formatUsd(point.estimatedCost)}`,
]
if (point.standardCost != null)
lines.push(`标准费用: ${formatUsd(point.standardCost)}`)
return lines.join('<br/>')
if (point.noCacheCost != null)
lines.push(`无缓存费用: ${formatUsd(point.noCacheCost)}`)
return usageTooltipContent(palette.value, point.label, lines)
}
</script>
@@ -203,7 +195,7 @@ function formatTooltip(params: unknown) {
<BaseCard
as="article"
title="成本效率"
description="预估费用、Token 与缓存收益"
description="实际费用、缓存节省、服务层溢价与单位成本"
class="h-full min-h-90"
>
<template #actions>
@@ -211,15 +203,47 @@ function formatTooltip(params: unknown) {
</template>
<template #body>
<div class="min-h-66">
<BaseChart v-if="hasData" :option="chartOption" :height="264" />
<div class="grid min-h-66 gap-3">
<div v-if="hasData" class="grid grid-cols-5 gap-1 rounded-xl bg-cp-fill-quaternary/45 p-2">
<div class="grid min-w-0 gap-1 px-1.5">
<span class="truncate text-[10px] font-bold text-cp-text-quaternary">实际费用</span>
<strong class="truncate font-mono text-cp-sm font-heavy tabular-nums text-cp-text" :title="formatUsd(cost.estimatedCost)">
{{ formatUsd(cost.estimatedCost) }}
</strong>
</div>
<div class="grid min-w-0 gap-1 px-1.5">
<span class="truncate text-[10px] font-bold text-cp-text-quaternary">缓存节省</span>
<strong class="truncate font-mono text-cp-sm font-heavy tabular-nums text-cp-green-text" :title="formatUsd(cost.cacheSavings)">
{{ formatUsd(cost.cacheSavings) }}
</strong>
</div>
<div class="grid min-w-0 gap-1 px-1.5">
<span class="truncate text-[10px] font-bold text-cp-text-quaternary">层级溢价</span>
<strong class="truncate font-mono text-cp-sm font-heavy tabular-nums text-cp-orange-text" :title="formatUsd(cost.tierPremium)">
{{ formatUsd(cost.tierPremium) }}
</strong>
</div>
<div class="grid min-w-0 gap-1 px-1.5">
<span class="truncate text-[10px] font-bold text-cp-text-quaternary">每成功请求</span>
<strong class="truncate font-mono text-cp-sm font-heavy tabular-nums text-cp-text" :title="formatUsd(cost.costPerSuccessfulRequest, true)">
{{ formatUsd(cost.costPerSuccessfulRequest, true) }}
</strong>
</div>
<div class="grid min-w-0 gap-1 px-1.5">
<span class="truncate text-[10px] font-bold text-cp-text-quaternary">费用覆盖</span>
<strong class="truncate font-mono text-cp-sm font-heavy tabular-nums text-cp-text">
{{ formatPercent(coverageRate) }}
</strong>
</div>
</div>
<BaseChart v-if="hasData" :option="chartOption" :height="210" />
<BaseEmpty
v-else
size="sm"
surface="none"
:title="loading ? '正在加载成本数据' : '暂无成本效率数据'"
description="当前范围没有可绘制的费用或 Token 数据"
class="h-66 place-content-center"
description="当前范围没有可绘制的费用或缓存收益数据"
class="h-52.5 place-content-center"
/>
</div>
</template>
@@ -44,19 +44,28 @@ const diagnosticColumns = defineTableColumns<DiagnosticDisplayItem>([
},
{
key: 'requestCount',
label: '请求 / 占比',
label: '请求',
kind: 'numeric',
size: 'sm',
},
{
key: 'impactScore',
label: '风险分',
kind: 'numeric',
size: 'sm',
},
{
key: 'errorCount',
label: '错误 / 错误率',
label: '错误 / 未完',
kind: 'numeric',
size: 'sm',
},
{ key: 'latencyP95Ms', label: 'P95', kind: 'numeric', size: 'sm' },
{ key: 'firstTokenP95Ms', label: '性能', kind: 'numeric', size: 'lg' },
{
key: 'estimatedCost',
label: '预估费用',
label: '费用',
kind: 'numeric',
size: 'sm',
},
])
@@ -71,7 +80,7 @@ const resultDimensionLabel = computed(
const sortedItems = computed(() =>
[...props.diagnostics.items].sort(
(left, right) => right.errorCount - left.errorCount || right.requestCount - left.requestCount,
(left, right) => right.impactScore - left.impactScore || right.requestCount - left.requestCount,
),
)
@@ -108,7 +117,7 @@ function diagnosticNameDisplay(name: string) {
<BaseCard
as="article"
title="热点诊断"
:description="`按${selectedDimensionLabel}定位错误、慢请求与费用热点`"
:description="`按${selectedDimensionLabel}定位高影响请求`"
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"
>
<template #actions>
@@ -143,7 +152,7 @@ function diagnosticNameDisplay(name: string) {
>
{{ row.nameDisplay.primary }}
</code>
<div
<span
v-if="row.nameDisplay.secondary"
class="flex min-w-0 items-center gap-1.25 text-cp-text-secondary"
>
@@ -152,10 +161,10 @@ function diagnosticNameDisplay(name: string) {
class="size-3.25 shrink-0 text-cp-blue-text"
stroke-width="2.4"
/>
<code class="block truncate font-mono text-cp-xs leading-none font-bold">
<code class="block truncate font-mono text-cp-xs font-bold">
{{ row.nameDisplay.secondary }}
</code>
</div>
</span>
</div>
</template>
@@ -173,27 +182,50 @@ function diagnosticNameDisplay(name: string) {
</span>
</template>
<template #errorCount="{ row }">
<span class="grid justify-items-end gap-1 font-mono leading-none tabular-nums">
<strong
class="font-bold"
:class="row.errorCount > 0 ? 'text-cp-error-text' : 'text-cp-text'"
>
{{ formatCompactNumber(row.errorCount) }}
</strong>
<small
class="text-[10px] font-emphasis"
:class="row.errorRate > 0 ? 'text-cp-error-text' : 'text-cp-text-quaternary'"
>
{{ formatPercent(row.errorRate) }}
</small>
</span>
<template #impactScore="{ row }">
<strong
class="font-mono font-bold tabular-nums"
:class="row.impactScore > 0.1 ? 'text-cp-error-text' : 'text-cp-text-secondary'"
title="综合错误、未完成、重试、请求占比与 TTFT 的风险分(0–100)"
>
{{ formatCompactNumber(row.impactScore * 100) }}
</strong>
</template>
<template #latencyP95Ms="{ row }">
<span class="font-mono font-bold tabular-nums text-cp-orange-text">
{{ formatDuration(row.latencyP95Ms) }}
</span>
<template #errorCount="{ row }">
<div
class="flex items-center justify-end gap-1.5 whitespace-nowrap text-right font-mono leading-none tabular-nums"
:aria-label="`错误 ${formatCompactNumber(row.errorCount)},未完成 ${formatCompactNumber(row.nonCompletionCount)}`"
:title="`错误率 ${formatPercent(row.errorRate)};未完成率 ${formatPercent(row.nonCompletionRate)}`"
>
<strong :class="row.errorCount > 0 ? 'text-cp-error-text' : 'text-cp-text-quaternary'">
{{ formatCompactNumber(row.errorCount) }}
</strong>
<span class="text-[9px] text-cp-text-quaternary">/</span>
<strong :class="row.nonCompletionCount > 0 ? 'text-cp-orange-text' : 'text-cp-text-quaternary'">
{{ formatCompactNumber(row.nonCompletionCount) }}
</strong>
</div>
</template>
<template #firstTokenP95Ms="{ row }">
<div
class="grid justify-items-end gap-1 font-mono leading-none tabular-nums"
:title="`重试率 ${formatPercent(row.retryRate)}`"
>
<span class="inline-flex items-baseline gap-1.5">
<small class="text-[10px] font-emphasis text-cp-text-quaternary">TTFT</small>
<strong class="font-bold text-cp-blue-text">
{{ formatDuration(row.firstTokenP95Ms) }}
</strong>
</span>
<small class="flex items-center gap-2 text-[10px] font-emphasis text-cp-text-quaternary">
<span>P95 {{ formatDuration(row.latencyP95Ms) }}</span>
<span :class="row.retryCount > 0 ? 'text-cp-orange-text' : undefined">
重试 {{ formatCompactNumber(row.retryCount) }}
</span>
</small>
</div>
</template>
<template #estimatedCost="{ row }">
@@ -17,9 +17,10 @@ import {
usageCategoryAxis,
usageLegend,
usageTooltip,
usageTooltipContent,
usageValueAxis,
} from '../utils/chart'
import { escapeTooltip, formatPercent } from '../utils/format'
import { formatPercent } from '../utils/format'
type Health = Awaited<ReturnType<typeof getUsageRecordInsightsOverview>>['health']
type HealthPoint = Health['points'][number]
@@ -62,8 +63,15 @@ const chartOption = computed<EChartsOption>(() => {
return {
animationDuration: 240,
grid: { left: 0, right: 0, top: 40, bottom: 0, containLabel: true },
legend: usageLegend(theme, ['请求量', '成功率', '失败']),
grid: {
left: 0,
right: 0,
top: 40,
bottom: 0,
outerBoundsMode: 'same',
outerBoundsContain: 'axisLabel',
},
legend: usageLegend(theme, ['成功', '服务失败', '取消', '未完成', '调用方错误', '服务成功率']),
tooltip: usageTooltip(theme, formatTooltip),
xAxis: usageCategoryAxis(
chartPoints.map(point => point.label),
@@ -78,18 +86,16 @@ const chartOption = computed<EChartsOption>(() => {
}),
],
series: [
outcomeSeries('成功', chartPoints.map(point => point.successRequests), theme.success),
outcomeSeries('服务失败', chartPoints.map(point => point.failedRequests), theme.danger),
outcomeSeries('取消', chartPoints.map(point => point.cancelledRequests), theme.info),
outcomeSeries('未完成', chartPoints.map(point => point.incompleteRequests), theme.warning),
outcomeSeries('调用方错误', chartPoints.map(point => point.callerErrorRequests), theme.normal),
{
name: '请求量',
type: 'bar',
data: chartPoints.map(requestCount),
barMaxWidth: 24,
itemStyle: { color: theme.info, opacity: 0.32, borderRadius: [3, 3, 0, 0] },
},
{
name: '成功率',
name: '服务成功率',
type: 'line',
yAxisIndex: 1,
data: chartPoints.map(requestSuccessRate),
data: chartPoints.map(serviceSuccessRate),
connectNulls: true,
smooth: 0.25,
symbol: activePointCount <= 16 ? 'circle' : 'none',
@@ -97,50 +103,47 @@ const chartOption = computed<EChartsOption>(() => {
lineStyle: { color: theme.success, width: 2.2 },
itemStyle: { color: theme.success },
},
{
name: '失败',
type: 'line',
data: chartPoints.map(point => (point.failedRequests > 0 ? requestCount(point) : null)),
connectNulls: false,
showSymbol: true,
symbol: 'circle',
symbolSize: 7,
lineStyle: { opacity: 0 },
itemStyle: {
color: theme.danger,
borderColor: theme.surface,
borderWidth: 2,
},
z: 4,
},
],
}
})
function requestCount(point: HealthPoint) {
return Math.max(0, point.successRequests ?? 0) + Math.max(0, point.failedRequests ?? 0)
return Math.max(0, point.totalRequests ?? 0)
}
function requestSuccessRate(point: HealthPoint) {
const total = requestCount(point)
function serviceSuccessRate(point: HealthPoint) {
const total = Math.max(0, point.successRequests) + Math.max(0, point.failedRequests)
return total > 0 ? Math.max(0, point.successRequests ?? 0) / total : null
}
function outcomeSeries(name: string, data: number[], color: string) {
return {
name,
type: 'bar' as const,
stack: 'outcome',
data,
barMaxWidth: 24,
itemStyle: { color, opacity: 0.72 },
}
}
function formatTooltip(params: unknown) {
const rows = tooltipRows(params)
const point = points.value[tooltipIndex(rows[0])]
if (!point)
return ''
const successRate = requestSuccessRate(point)
const successRate = serviceSuccessRate(point)
return [
escapeTooltip(point.label),
return usageTooltipContent(palette.value, point.label, [
`请求量: ${formatCompactNumber(requestCount(point))}`,
`成功: ${formatCompactNumber(point.successRequests)}`,
`失败: ${formatCompactNumber(point.failedRequests)}`,
`成功率: ${successRate == null ? '无请求' : formatPercent(successRate)}`,
].join('<br/>')
`服务失败: ${formatCompactNumber(point.failedRequests)}`,
`取消: ${formatCompactNumber(point.cancelledRequests)}`,
`未完成: ${formatCompactNumber(point.incompleteRequests)}`,
`调用方错误: ${formatCompactNumber(point.callerErrorRequests)}`,
`服务成功率: ${successRate == null ? '无服务结果' : formatPercent(successRate)}`,
])
}
</script>
@@ -148,19 +151,39 @@ function formatTooltip(params: unknown) {
<BaseCard
as="article"
title="请求健康"
:description="`按${granularityText}观察请求量、成功率与失败时段`"
:description="`按${granularityText}区分服务结果、取消、未完成与调用方错误`"
class="h-full min-h-90"
>
<template #body>
<div class="min-h-66">
<BaseChart v-if="hasData" :option="chartOption" :height="264" />
<div class="grid min-h-66" :class="hasData ? 'gap-3' : 'h-full'">
<div v-if="hasData" class="grid grid-cols-3 gap-2 rounded-xl bg-cp-fill-quaternary/45 p-2">
<div class="grid gap-1 px-2">
<span class="text-[10px] font-bold text-cp-text-quaternary">服务成功率</span>
<strong class="font-mono text-cp-base font-heavy tabular-nums text-cp-green-text">
{{ formatPercent(health.successRate) }}
</strong>
</div>
<div class="grid gap-1 px-2">
<span class="text-[10px] font-bold text-cp-text-quaternary">完成率</span>
<strong class="font-mono text-cp-base font-heavy tabular-nums text-cp-text">
{{ formatPercent(health.completionRate) }}
</strong>
</div>
<div class="grid gap-1 px-2">
<span class="text-[10px] font-bold text-cp-text-quaternary">非正常结束</span>
<strong class="font-mono text-cp-base font-heavy tabular-nums text-cp-orange-text">
{{ formatCompactNumber(health.cancelledRequests + health.incompleteRequests) }}
</strong>
</div>
</div>
<BaseChart v-if="hasData" :option="chartOption" :height="210" />
<BaseEmpty
v-else
size="sm"
surface="none"
:title="loading ? '正在加载请求健康数据' : '暂无请求健康数据'"
description="当前范围没有可绘制的请求记录"
class="h-66 place-content-center"
class="h-full place-content-center"
/>
</div>
</template>
@@ -27,7 +27,10 @@ const diagnosticDimension = defineModel('diagnosticDimension', {
</script>
<template>
<section class="mt-5 grid grid-cols-1 gap-3 xl:grid-cols-2" aria-label="使用统计观测">
<section
class="mt-5 grid grid-cols-1 gap-3 xl:auto-rows-[24.25rem] xl:grid-cols-2"
aria-label="使用统计观测"
>
<UsageHealthCard
:health="overview.health"
:granularity="overview.granularity"
@@ -8,6 +8,7 @@ import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import BaseChart from '@/components/charts/BaseChart.vue'
import { useChartPalette } from '@/composables/useChartPalette'
import { formatLocalizedCompactNumber as formatCompactNumber } from '@/utils/number'
import {
tooltipIndex,
@@ -16,9 +17,10 @@ import {
usageLegend,
usageLineSeries,
usageTooltip,
usageTooltipContent,
usageValueAxis,
} from '../utils/chart'
import { escapeTooltip, formatDuration, formatDurationAxis } from '../utils/format'
import { formatDuration, formatDurationAxis, formatPercent } from '../utils/format'
type Performance = Awaited<ReturnType<typeof getUsageRecordInsightsOverview>>['performance']
type PerformancePoint = Performance['points'][number]
@@ -40,28 +42,56 @@ const performancePoints = computed<PerformancePoint[]>(() => props.performance.p
const viewOptions = [
{ label: '总耗时', value: 'total' },
{ label: '首字', value: 'firstToken' },
{ label: '吞吐', value: 'throughput' },
{ label: '调度', value: 'scheduling' },
]
const percentileLabels = {
p50: '常规响应',
p95: '较慢响应',
p99: '极慢响应',
}
const seriesLabels = computed(() => {
if (activeView.value === 'throughput')
return ['P10', 'P50', 'P90'] as const
if (activeView.value === 'scheduling')
return ['准入 P95', '选号 P95', '容量 P95'] as const
return ['P50', 'P95', 'P99'] as const
})
const selectedPoints = computed(() =>
performancePoints.value.map(point => ({
point,
p50: percentileValue(point, 'p50'),
p95: percentileValue(point, 'p95'),
p99: percentileValue(point, 'p99'),
first: seriesValue(point, 'first'),
second: seriesValue(point, 'second'),
third: seriesValue(point, 'third'),
})),
)
const summaryMetrics = computed(() => {
const performance = props.performance
if (activeView.value === 'throughput') {
return [
{ label: 'P10', value: formatThroughput(performance.outputThroughputP10) },
{ label: 'P50', value: formatThroughput(performance.outputThroughputP50) },
{ label: 'P90', value: formatThroughput(performance.outputThroughputP90) },
]
}
if (activeView.value === 'scheduling') {
return [
{ label: '判定覆盖', value: formatPercent(performance.admissionDecisionCoverage) },
{ label: '选号覆盖', value: formatPercent(performance.accountSelectionWaitCoverage) },
{ label: '容量覆盖', value: formatPercent(performance.capacityCoverage) },
]
}
const prefix = activeView.value === 'firstToken' ? 'firstToken' : 'latency'
return [
{ label: 'P50', value: formatDuration(performance[`${prefix}P50Ms`]) },
{ label: 'P95', value: formatDuration(performance[`${prefix}P95Ms`]) },
{ label: 'P99', value: formatDuration(performance[`${prefix}P99Ms`]) },
]
})
const hasData = computed(
() =>
!props.loading
&& selectedPoints.value.some(
point => point.p50 != null || point.p95 != null || point.p99 != null,
point => point.first != null || point.second != null || point.third != null,
),
)
@@ -71,48 +101,77 @@ const chartOption = computed<EChartsOption>(() => {
return {
animationDuration: 240,
grid: { left: 0, right: 0, top: 40, bottom: 0, containLabel: true },
legend: usageLegend(theme, Object.values(percentileLabels)),
grid: {
left: 0,
right: 0,
top: 40,
bottom: 0,
outerBoundsMode: 'same',
outerBoundsContain: 'axisLabel',
},
legend: usageLegend(theme, [...seriesLabels.value]),
tooltip: usageTooltip(theme, formatTooltip),
xAxis: usageCategoryAxis(
points.map(({ point }) => point.label),
theme,
),
yAxis: usageValueAxis(theme, formatDurationAxis),
yAxis: activeView.value === 'scheduling'
? [
usageValueAxis(theme, formatDurationAxis),
usageValueAxis(theme, value => formatPercent(value), {
min: 0,
max: 1,
splitLine: false,
}),
]
: usageValueAxis(
theme,
activeView.value === 'throughput'
? value => `${formatCompactNumber(value)}/s`
: formatDurationAxis,
),
series: [
usageLineSeries(
percentileLabels.p50,
points.map(point => point.p50),
seriesLabels.value[0],
points.map(point => point.first),
theme.info,
{ area: 'subtle' },
),
usageLineSeries(
percentileLabels.p95,
points.map(point => point.p95),
seriesLabels.value[1],
points.map(point => point.second),
theme.warning,
),
usageLineSeries(
percentileLabels.p99,
points.map(point => point.p99),
theme.danger,
),
{
...usageLineSeries(
seriesLabels.value[2],
points.map(point => point.third),
activeView.value === 'scheduling' ? theme.success : theme.danger,
),
yAxisIndex: activeView.value === 'scheduling' ? 1 : 0,
},
],
}
})
function percentileValue(point: PerformancePoint, percentile: 'p50' | 'p95' | 'p99') {
if (activeView.value === 'firstToken') {
if (percentile === 'p50')
return point.firstTokenP50Ms
if (percentile === 'p95')
return point.firstTokenP95Ms
return point.firstTokenP99Ms
function seriesValue(point: PerformancePoint, position: 'first' | 'second' | 'third') {
if (activeView.value === 'throughput') {
if (position === 'first')
return point.outputThroughputP10
if (position === 'second')
return point.outputThroughputP50
return point.outputThroughputP90
}
if (percentile === 'p50')
return point.latencyP50Ms
if (percentile === 'p95')
return point.latencyP95Ms
return point.latencyP99Ms
if (activeView.value === 'scheduling') {
if (position === 'first')
return point.admissionDecisionP95Ms
if (position === 'second')
return point.accountSelectionWaitP95Ms
return point.capacityUtilizationP95
}
const prefix = activeView.value === 'firstToken' ? 'firstToken' : 'latency'
const suffix = position === 'first' ? 'P50Ms' : position === 'second' ? 'P95Ms' : 'P99Ms'
return point[`${prefix}${suffix}`]
}
function formatTooltip(params: unknown) {
@@ -121,12 +180,23 @@ function formatTooltip(params: unknown) {
if (!selected)
return ''
return [
escapeTooltip(selected.point.label),
`${percentileLabels.p50}: ${formatDuration(selected.p50)}`,
`${percentileLabels.p95}: ${formatDuration(selected.p95)}`,
`${percentileLabels.p99}: ${formatDuration(selected.p99)}`,
].join('<br/>')
return usageTooltipContent(palette.value, selected.point.label, [
`${seriesLabels.value[0]}: ${formatSeriesValue(selected.first, 'first')}`,
`${seriesLabels.value[1]}: ${formatSeriesValue(selected.second, 'second')}`,
`${seriesLabels.value[2]}: ${formatSeriesValue(selected.third, 'third')}`,
])
}
function formatSeriesValue(value: number | null, position: 'first' | 'second' | 'third') {
if (activeView.value === 'throughput')
return formatThroughput(value)
if (activeView.value === 'scheduling' && position === 'third')
return formatPercent(value)
return formatDuration(value)
}
function formatThroughput(value: number | null) {
return value == null ? '—' : `${formatCompactNumber(value)} tok/s`
}
</script>
@@ -134,25 +204,39 @@ function formatTooltip(params: unknown) {
<BaseCard
as="article"
title="响应速度"
description="总耗时或首字的分位耗时,越低越快"
description="延迟、吞吐与调度分位"
class="h-full min-h-90"
>
<template #actions>
<BaseSegmented v-model="activeView" label="性能指标" :options="viewOptions" :disabled="loading" class="w-43" />
<BaseSegmented v-model="activeView" label="性能指标" :options="viewOptions" :disabled="loading" class="w-68" />
</template>
<template #body>
<div class="min-h-66">
<BaseChart v-if="hasData" :option="chartOption" :height="264" />
<div class="grid min-h-66 gap-3">
<div v-if="hasData" class="grid grid-cols-3 gap-2 rounded-xl bg-cp-fill-quaternary/45 p-2">
<div v-for="metric in summaryMetrics" :key="metric.label" class="grid min-w-0 gap-1 px-2">
<span class="truncate text-[10px] font-bold text-cp-text-quaternary">{{ metric.label }}</span>
<strong class="truncate font-mono text-cp-base font-heavy tabular-nums text-cp-text" :title="metric.value">
{{ metric.value }}
</strong>
</div>
</div>
<BaseChart v-if="hasData" :option="chartOption" :height="210" />
<BaseEmpty
v-else
size="sm"
surface="none"
:title="loading ? '正在加载性能数据' : '暂无性能数据'"
:description="
activeView === 'firstToken' ? '当前范围没有首字耗时样本' : '当前范围没有总耗时样本'
activeView === 'firstToken'
? '当前范围没有首字耗时样本'
: activeView === 'throughput'
? '当前范围没有吞吐样本'
: activeView === 'scheduling'
? '部署迁移后才会开始积累调度与容量样本'
: '当前范围没有总耗时样本'
"
class="h-66 place-content-center"
class="h-52.5 place-content-center"
/>
</div>
</template>
@@ -51,10 +51,7 @@ const items = computed(() => [
</script>
<template>
<section
class="mt-5 grid shrink-0 grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4"
aria-label="使用概览"
>
<section class="mt-5 grid shrink-0 grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4" aria-label="使用概览">
<BaseCard
v-for="item in items"
:key="item.key"
@@ -62,20 +59,14 @@ const items = computed(() => [
padding="compact"
class="grid min-h-23 grid-cols-[36px_minmax(0,1fr)] items-stretch gap-3"
>
<BaseMotionIcon
aria-hidden="true"
class="inline-flex size-9 shrink-0 items-center justify-center rounded-cp"
:class="item.tone"
>
<BaseMotionIcon class="inline-flex size-9 shrink-0 items-center justify-center rounded-cp" :class="item.tone">
<component :is="item.icon" class="size-4.5" />
</BaseMotionIcon>
<div class="flex min-w-0 flex-col justify-between py-0.5">
<span class="block text-cp-sm leading-none font-bold text-cp-text-quaternary">
{{ item.label }}
</span>
<strong
class="block truncate text-[22px] leading-none font-extrabold text-cp-text"
>
<strong class="block truncate text-[22px] leading-none font-extrabold text-cp-text">
{{ item.value }}
</strong>
<span class="block truncate text-cp-sm leading-none font-emphasis text-cp-text-secondary">
@@ -271,6 +271,7 @@ function emptyOverview() {
incompleteRequests: 0,
callerErrorRequests: 0,
successRate: 0,
completionRate: 0,
requestChangeRate: null,
successRateChange: null,
points: [],
@@ -282,14 +283,30 @@ function emptyOverview() {
firstTokenP50Ms: null,
firstTokenP95Ms: null,
firstTokenP99Ms: null,
admissionDecisionP50Ms: null,
admissionDecisionP95Ms: null,
accountSelectionWaitP50Ms: null,
accountSelectionWaitP95Ms: null,
outputThroughputP10: null,
outputThroughputP50: null,
outputThroughputP90: null,
capacityUtilization: null,
capacityUtilizationP95: null,
latencyCoverage: 0,
firstTokenCoverage: 0,
admissionDecisionCoverage: 0,
accountSelectionWaitCoverage: 0,
capacityCoverage: 0,
points: [],
},
cost: {
estimatedCost: null,
standardCost: null,
noCacheCost: null,
cacheSavings: null,
tierPremium: null,
costPerRequest: null,
costPerSuccessfulRequest: null,
tokensPerRequest: 0,
cachedTokenRate: 0,
cacheHitRequestRate: 0,
@@ -298,6 +315,7 @@ function emptyOverview() {
cachedTokens: 0,
totalTokens: 0,
points: [],
coverage: { known: 0, partial: 0, unknown: 0, notBillable: 0 },
},
}
return overview
+10
View File
@@ -1,6 +1,7 @@
import type { LineSeriesOption } from 'echarts'
import type { useChartPalette } from '@/composables/useChartPalette'
import { chartTooltipStyle } from '@/components/charts/tooltip'
import { escapeTooltip } from './format'
type UsageChartPalette = ReturnType<typeof useChartPalette>['palette']['value']
type UsageAreaStrength = 'strong' | 'subtle'
@@ -65,6 +66,15 @@ export function usageTooltip(
}
}
export function usageTooltipContent(
theme: UsageChartPalette,
label: string,
lines: string[],
) {
const title = escapeTooltip(label)
return `<div style="margin:0 0 7px;padding:0 0 7px;border-bottom:1px solid ${theme.divider};color:${theme.textPrimary};font-family:'JetBrains Mono Variable','JetBrains Mono',monospace;font-size:11px;font-weight:750;line-height:1.2">${title}</div><div style="line-height:1.55">${lines.join('<br/>')}</div>`
}
export function usageCategoryAxis(labels: string[], theme: UsageChartPalette) {
return {
type: 'category' as const,
+12
View File
@@ -249,6 +249,8 @@ export function usageLatencyDetails(record: UsageDisplayRecord) {
}
const transportItems = [
{ label: '准入判定', value: durationValue(latencyDetails?.admissionDecisionMs) },
{ label: '账号选择等待', value: durationValue(latencyDetails?.accountSelectionWaitMs) },
{
label: '传输决策等待',
value: durationValue(latencyDetails?.transportDecisionWaitMs),
@@ -261,6 +263,16 @@ export function usageLatencyDetails(record: UsageDisplayRecord) {
.filter(item => item.value !== null)
.map(item => ({ ...item, value: formatDuration(item.value) }))
if (
latencyDetails?.capacityUsedSlots != null
&& latencyDetails.capacityTotalSlots != null
) {
transportItems.push({
label: '账号槽位快照',
value: `${latencyDetails.capacityUsedSlots} / ${latencyDetails.capacityTotalSlots}`,
})
}
return {
// SSE 可能先收到生命周期事件而没有文本或推理增量;这不是首字,须保留原始语义。
firstOutputLabel: firstTokenMs === null && firstEventMs !== null ? '首事件' : '首字',