mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
feat(observability): expose runtime pressure insights
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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,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 {
|
||||
|
||||
@@ -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 +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
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -4,14 +4,12 @@ sqlx 在服务启动时(`serve` 监听之前)按编号顺序执行本目录
|
||||
文件的 checksum 记入 `_sqlx_migrations`。此后每次启动都会重新校验:**已应用
|
||||
迁移的文件字节与记录不一致时,服务直接拒绝启动**。
|
||||
|
||||
当前大版本只有一份冻结迁移:`0001_initial.sql` 作为完整的新库基线。
|
||||
|
||||
## 冻结规则
|
||||
|
||||
- 同一大版本内,已合入 main 的迁移文件**永久冻结**,一个字节都不能改——包括注释和
|
||||
空白。任何改动都会让该大版本的已部署实例在下次重启时因 checksum 失配而无法启动,
|
||||
且没有自动恢复路径。
|
||||
- 同一大版本内的 schema 变更一律新增编号迁移(下一份从 `0003_...` 开始),永远不回改
|
||||
- 同一大版本内的 schema 变更一律新增编号迁移,永远不回改
|
||||
旧文件。切换大版本时允许把已完成升级的 schema 折叠成新的 `0001` 基线,因为跨大版本
|
||||
不支持在线升级,目标版本必须使用全新数据目录部署。
|
||||
- `.frozen-sha256` 是冻结清单(`sha256sum` 格式)。CI 会校验:清单内文件
|
||||
|
||||
@@ -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、额度与主动重置
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ? '首事件' : '首字',
|
||||
|
||||
Reference in New Issue
Block a user