feat(usage): merge key-model usage without PR pricing

Retain the GPT-6 Sol and Luna pricing already present on main.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-24 14:48:53 +08:00
co-authored by Codex
23 changed files with 307 additions and 23 deletions
+1 -1
View File
@@ -85,7 +85,7 @@ INSTALL_DIR="$HOME/services/codex-proxy-rs" bash install.sh
### 登录管理端
部署完成后,打开 `http://127.0.0.1:8080`,使用 `admin@cpr.local` 和管理员密码登录。
API Key 持有者可在同一登录页切换登录身份,进入 `/key-usage` 查看自己的用量、趋势、请求日志、额度与健康时间线;不能访问管理员页面。
API Key 持有者可在同一登录页切换登录身份,进入 `/key-usage` 查看自己的用量、按模型统计的 Token 与估算费用、趋势、请求日志、额度与健康时间线;不能访问管理员页面。
页面右上角的「密钥配置」支持复制 Codex 配置文件和导入 CCSwitch,导入时同时启用当前 Key 的日/周额度查询,默认刷新间隔为 30 分钟
默认地址只能在服务器本机访问,从其他设备使用时,需要配置
@@ -4,7 +4,8 @@ use super::{
PageSize,
client_keys::ClientKeyRecord,
observability::{
HealthTimeline, OpsErrorPage, RequestMetricPoint, TimeRange, UsageOverview, UsagePage,
DiagnosticObservation, HealthTimeline, OpsErrorPage, RequestMetricPoint, TimeRange,
UsageOverview, UsagePage,
},
};
@@ -32,6 +33,7 @@ pub struct KeyUsageOverview {
pub key: ClientKeyRecord,
pub overview: UsageOverview,
pub trend: Vec<RequestMetricPoint>,
pub models: Vec<DiagnosticObservation>,
pub health_timeline: HealthTimeline,
}
@@ -158,6 +158,7 @@ pub struct OpsErrorQuery {
pub enum DiagnosticDimension {
Provider,
Model,
KeyModel,
Account,
ApiKey,
Transport,
@@ -1156,6 +1157,7 @@ pub struct DiagnosticsItem {
pub retry_rate: f64,
pub impact_score: f64,
pub estimated_cost: Option<DecimalAmount>,
pub cost_incomplete: bool,
pub attempt_count: u64,
pub total_tokens: u64,
}
@@ -16,7 +16,8 @@ use crate::{
KeyUsageRecordsQuery,
},
observability::{
OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery, china_day_start,
DiagnosticDimension, OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery,
china_day_start,
},
system::SystemVersion,
},
@@ -187,9 +188,11 @@ impl KeyUsageService for DefaultKeyUsageService {
start: china_day_start(now),
end: now,
};
let (overview, trend, health_points) = futures::try_join!(
let (overview, trend, models, health_points) = futures::try_join!(
self.observations.usage_summary(query.range, filter.clone()),
self.observations.usage_trend(query.range, filter),
self.observations.usage_trend(query.range, filter.clone()),
self.observations
.usage_diagnostics(query.range, filter, DiagnosticDimension::Model),
self.observations
.usage_trend(today, usage_filter(&id, None)),
)
@@ -198,6 +201,7 @@ impl KeyUsageService for DefaultKeyUsageService {
key,
overview,
trend,
models,
health_timeline: health_timeline_at(&health_points, now),
}))
}
@@ -383,6 +383,8 @@ impl ObservabilityService for DefaultObservabilityService {
retry_rate,
impact_score,
estimated_cost: usd_cost(&item.costs),
cost_incomplete: item.cost_coverage.partial_count > 0
|| item.cost_coverage.unavailable_count > 0,
attempt_count: item.attempt_count,
total_tokens: item.total_tokens,
}
@@ -1296,6 +1296,7 @@ pub(crate) fn diagnostics_view(
retry_rate: item.retry_rate,
impact_score: item.impact_score,
estimated_cost: item.estimated_cost.as_ref().map(ToString::to_string),
cost_incomplete: item.cost_incomplete,
attempt_count: item.attempt_count,
total_tokens: item.total_tokens,
})
@@ -168,6 +168,7 @@ impl TrendKind {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnosticDimension {
Model,
KeyModel,
Account,
ApiKey,
Provider,
@@ -181,6 +182,7 @@ impl DiagnosticDimension {
pub fn parse(value: Option<&str>) -> Result<Self, WireValidationError> {
match trimmed(value) {
None | Some("model") => Ok(Self::Model),
Some("keyModel") => Ok(Self::KeyModel),
Some("account") => Ok(Self::Account),
Some("apiKey" | "api_key") => Ok(Self::ApiKey),
Some("provider") => Ok(Self::Provider),
@@ -196,6 +198,7 @@ impl DiagnosticDimension {
pub const fn display_name(self) -> &'static str {
match self {
Self::Model => "model",
Self::KeyModel => "keyModel",
Self::Account => "account",
Self::ApiKey => "apiKey",
Self::Provider => "provider",
@@ -265,6 +268,7 @@ pub(crate) fn domain_diagnostic_dimension(
) -> domain::DiagnosticDimension {
match dimension {
DiagnosticDimension::Model => domain::DiagnosticDimension::Model,
DiagnosticDimension::KeyModel => domain::DiagnosticDimension::KeyModel,
DiagnosticDimension::Account => domain::DiagnosticDimension::Account,
DiagnosticDimension::ApiKey => domain::DiagnosticDimension::ApiKey,
DiagnosticDimension::Provider => domain::DiagnosticDimension::Provider,
@@ -760,6 +760,7 @@ pub struct DiagnosticItemView {
pub retry_rate: f64,
pub impact_score: f64,
pub estimated_cost: Option<String>,
pub cost_incomplete: bool,
pub attempt_count: u64,
pub total_tokens: u64,
}
@@ -54,6 +54,7 @@ pub(super) struct OverviewView {
end_time: DateTime<Utc>,
key: KeyView,
summary: MetricsView,
models: Vec<ModelUsageView>,
trend: Vec<TrendPointView>,
health_timeline: HealthTimelineView,
}
@@ -87,6 +88,16 @@ struct MetricsView {
cost_incomplete: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ModelUsageView {
model: String,
requests: u64,
total_tokens: u64,
cost_usd: Option<String>,
cost_incomplete: bool,
}
fn metrics(value: &RequestMetrics, costs: &[CurrencyCost], coverage: &CostCoverage) -> MetricsView {
MetricsView {
requests: value.request_count,
@@ -137,6 +148,22 @@ pub(super) fn overview(value: KeyUsageOverview) -> OverviewView {
&value.overview.attempts.costs,
&value.overview.attempts.cost_coverage,
),
models: value
.models
.into_iter()
.map(|model| ModelUsageView {
model: model.name,
requests: model.request_count,
total_tokens: model.total_tokens,
cost_usd: model
.costs
.iter()
.find(|cost| cost.currency.eq_ignore_ascii_case("USD"))
.map(|cost| cost.amount.to_string()),
cost_incomplete: model.cost_coverage.partial_count > 0
|| model.cost_coverage.unavailable_count > 0,
})
.collect(),
trend: value
.trend
.into_iter()
@@ -609,6 +609,7 @@ pub(super) struct MemoryObservations {
pub trend: Option<Vec<RequestMetricPoint>>,
pub summaries: Vec<(TimeRange, UsageFilter)>,
pub trends: Vec<(TimeRange, UsageFilter)>,
pub diagnostic_queries: Vec<(TimeRange, UsageFilter, DiagnosticDimension)>,
pub records: Vec<UsageQuery>,
pub errors: Vec<OpsErrorQuery>,
}
@@ -1283,10 +1284,15 @@ impl ObservabilityStore for UnusedStore {
async fn usage_diagnostics(
&self,
_: TimeRange,
_: UsageFilter,
_: DiagnosticDimension,
range: TimeRange,
filter: UsageFilter,
dimension: DiagnosticDimension,
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
self.observations
.lock()
.expect("observations")
.diagnostic_queries
.push((range, filter, dimension));
Ok(self.diagnostics.lock().expect("diagnostics").clone())
}
@@ -61,6 +61,13 @@ fn diagnostics_query_should_keep_wire_dimension_name() {
serde_json::from_value(json!({"dimension": "failure_class"})).unwrap();
assert_eq!(query.dimension().unwrap(), DiagnosticDimension::Failure);
assert_eq!(DiagnosticDimension::Failure.display_name(), "failureClass");
let key_model: DiagnosticsQuery =
serde_json::from_value(json!({"dimension": "keyModel"})).unwrap();
assert_eq!(
key_model.dimension().unwrap(),
DiagnosticDimension::KeyModel
);
assert_eq!(DiagnosticDimension::KeyModel.display_name(), "keyModel");
}
#[test]
@@ -3,9 +3,9 @@ use chrono::{Duration, Utc};
use gateway_admin::model::{
client_keys::ClientKeyRecord,
observability::{
AttemptMetrics, CostCoverage, CurrencyCost, DecimalAmount, Granularity, OpsError,
RequestMetricPoint, RequestMetrics, TimeRange, UsageBilling, UsageListRecord,
UsageOverview,
AttemptMetrics, CostCoverage, CurrencyCost, DecimalAmount, DiagnosticObservation,
Granularity, OpsError, RequestMetricPoint, RequestMetrics, TimeRange, UsageBilling,
UsageListRecord, UsageOverview,
},
};
use gateway_core::{
@@ -84,6 +84,33 @@ pub(super) async fn fixture() -> AdminTestFixture {
}]);
}
fixture.usage_records.lock().unwrap().push(usage_record());
fixture
.diagnostics
.lock()
.unwrap()
.push(DiagnosticObservation {
key: "coding".to_owned(),
name: "coding".to_owned(),
request_count: 2,
success_count: 1,
failure_count: 1,
attempt_count: 2,
total_tokens: 1100,
average_latency_ms: None,
latency_p95_ms: None,
first_token_p95_ms: None,
non_completion_count: 0,
retry_count: 0,
cost_coverage: CostCoverage {
calculated_count: 1,
unavailable_count: 1,
..Default::default()
},
costs: vec![CurrencyCost {
currency: "USD".to_owned(),
amount: "0.123456".parse().unwrap(),
}],
});
fixture.ops_errors.lock().unwrap().push(error_record());
fixture
}
@@ -176,6 +176,7 @@ async fn overview_scopes_every_query_and_projects_only_key_visible_fields() {
"endTime",
"key",
"summary",
"models",
"trend",
"healthTimeline",
],
@@ -202,6 +203,16 @@ async fn overview_scopes_every_query_and_projects_only_key_visible_fields() {
assert_eq!(data["trend"][0]["reasoningTokens"], 40);
assert_eq!(data["summary"]["costUsd"], "0.123456");
assert_eq!(data["summary"]["costIncomplete"], true);
assert_eq!(
data["models"],
json!([{
"model": "coding",
"requests": 2,
"totalTokens": 1100,
"costUsd": "0.123456",
"costIncomplete": true,
}])
);
assert_eq!(data["trend"][0]["bucketSeconds"], 900);
assert_eq!(
data["healthTimeline"]["points"].as_array().unwrap().len(),
@@ -221,6 +232,18 @@ async fn overview_scopes_every_query_and_projects_only_key_visible_fields() {
assert!(filter.provider_kind.is_none());
}
assert_eq!(observations.summaries[0].1.model.as_deref(), Some("coding"));
assert_eq!(observations.diagnostic_queries.len(), 1);
assert_eq!(
observations.diagnostic_queries[0]
.1
.client_api_key_ref
.as_deref(),
Some("key-42")
);
assert_eq!(
observations.diagnostic_queries[0].1.model.as_deref(),
Some("coding")
);
let health = observations
.trends
.iter()
@@ -79,6 +79,7 @@ pub(crate) const fn store_diagnostic_dimension(
match dimension {
admin_observability::DiagnosticDimension::Provider => DiagnosticDimension::Provider,
admin_observability::DiagnosticDimension::Model => DiagnosticDimension::Model,
admin_observability::DiagnosticDimension::KeyModel => DiagnosticDimension::KeyModel,
admin_observability::DiagnosticDimension::Account => DiagnosticDimension::Account,
admin_observability::DiagnosticDimension::ApiKey => DiagnosticDimension::ApiKey,
admin_observability::DiagnosticDimension::Transport => DiagnosticDimension::Transport,
@@ -185,6 +185,7 @@ pub struct OpsErrorQuery {
pub enum DiagnosticDimension {
Provider,
Model,
KeyModel,
Account,
ApiKey,
Transport,
@@ -5,6 +5,7 @@ use serde_json::Value;
const MODEL_DIAGNOSTIC_DIMENSION_SQL: &str =
"coalesce(mr.upstream_model_id, mr.requested_model_id)";
const KEY_MODEL_DIAGNOSTIC_DIMENSION_SQL: &str = "json_build_array(mr.client_api_key_ref, coalesce(mr.upstream_model_id, mr.requested_model_id))::text";
pub(crate) fn push_usage_filter(
query: &mut QueryBuilder<Postgres>,
@@ -449,7 +450,16 @@ pub(crate) async fn usage_diagnostics(
where currency_grouping = 1
order by request_count desc, dimension_name limit ",
);
statement.push_bind(DIAGNOSTIC_LIMIT);
// Key 用量页及管理端 Key×模型视图都需要保留区间内的全部组合。
statement.push_bind(
if dimension == DiagnosticDimension::KeyModel
|| (dimension == DiagnosticDimension::Model && filter.client_api_key_ref.is_some())
{
i64::MAX
} else {
DIAGNOSTIC_LIMIT
},
);
statement.push(
")
select aggregated.*
@@ -515,6 +525,28 @@ pub(crate) async fn usage_diagnostics(
)
.await?
}
DiagnosticDimension::KeyModel => {
let pairs = observations
.iter()
.map(|item| {
serde_json::from_str::<(String, String)>(&item.key)
.map_err(|_| postgres_unavailable("decode key/model diagnostic"))
})
.collect::<StoreResult<Vec<_>>>()?;
let names = diagnostic_api_key_display_names(
pool,
&pairs.iter().map(|(key, _)| key.clone()).collect::<Vec<_>>(),
)
.await?;
observations
.iter()
.zip(pairs)
.map(|(item, (key, model))| {
let name = names.get(&key).map_or(key.as_str(), String::as_str);
(item.key.clone(), format!("{name} → {model}"))
})
.collect()
}
_ => HashMap::new(),
};
for observation in &mut observations {
@@ -593,6 +625,7 @@ pub(crate) fn diagnostic_dimension_sql(dimension: DiagnosticDimension) -> &'stat
match dimension {
DiagnosticDimension::Provider => "coalesce(mr.provider_kind, 'unrouted')",
DiagnosticDimension::Model => MODEL_DIAGNOSTIC_DIMENSION_SQL,
DiagnosticDimension::KeyModel => KEY_MODEL_DIAGNOSTIC_DIMENSION_SQL,
DiagnosticDimension::Account => "coalesce(mr.provider_account_ref, 'unrouted')",
DiagnosticDimension::ApiKey => "mr.client_api_key_ref",
DiagnosticDimension::Transport => {
@@ -618,6 +651,11 @@ pub(crate) fn push_diagnostic_dimension_filter(
statement.push(MODEL_DIAGNOSTIC_DIMENSION_SQL);
statement.push(" is not null");
}
DiagnosticDimension::KeyModel => {
statement.push(" and mr.client_api_key_ref is not null and ");
statement.push(MODEL_DIAGNOSTIC_DIMENSION_SQL);
statement.push(" is not null");
}
DiagnosticDimension::Status => {
statement
.push(" and coalesce(mr.upstream_status_code, mr.client_status_code) is not null");
@@ -847,6 +847,11 @@ async fn api_key_diagnostics_should_display_key_name_and_fallback_to_ref() {
seed_api_key(&database.pool, "key_diag", "My Key", started_at).await;
let store = PgExecutionStore::new(database.pool.clone());
for id in ["req_key_a", "req_key_b"] {
let upstream_model = if id == "req_key_a" {
"upstream-model"
} else {
"other-model"
};
let mut request = new_request(id, started_at);
request.client_api_key_id = Some("key_diag".to_owned());
request.client_api_key_ref = "key_diag".to_owned();
@@ -859,7 +864,7 @@ async fn api_key_diagnostics_should_display_key_name_and_fallback_to_ref() {
provider_kind: "openai".to_owned(),
provider_account_id: None,
provider_account_ref: Some("acct_key".to_owned()),
upstream_model_id: Some("upstream-model".to_owned()),
upstream_model_id: Some(upstream_model.to_owned()),
upstream_transport: "http_sse".to_owned(),
http_version: None,
};
@@ -893,6 +898,27 @@ async fn api_key_diagnostics_should_display_key_name_and_fallback_to_ref() {
assert_eq!(diagnostics[0].request_count, 2);
assert_eq!(diagnostics[0].latency_p95_ms, Some(1950));
let key_models = repository
.usage_diagnostics(
range_around(started_at),
UsageRecordFilter::default(),
DiagnosticDimension::KeyModel,
)
.await
.expect("key/model diagnostics");
assert_eq!(key_models.len(), 2);
let names = key_models
.iter()
.map(|item| {
let (key, model): (String, String) = serde_json::from_str(&item.key).unwrap();
assert_eq!(key, "key_diag");
assert_eq!(item.request_count, 1);
(model, item.name.clone())
})
.collect::<std::collections::BTreeMap<_, _>>();
assert_eq!(names["upstream-model"], "My Key → upstream-model");
assert_eq!(names["other-model"], "My Key → other-model");
sqlx::query("delete from client_api_keys where id = 'key_diag'")
.execute(&database.pool)
.await
@@ -909,6 +935,45 @@ async fn api_key_diagnostics_should_display_key_name_and_fallback_to_ref() {
assert_eq!(diagnostics[0].key, "key_diag");
assert_eq!(diagnostics[0].name, "key_diag");
seed_api_key(&database.pool, "key_other", "Other Key", started_at).await;
let mut request = new_request("req_key_other", started_at);
request.client_api_key_id = Some("key_other".to_owned());
request.client_api_key_ref = "key_other".to_owned();
store
.insert_model_request_with_first_attempt(
request,
ModelRequestAttemptStart {
account_selection_wait_ms: None,
capacity_used_slots: None,
capacity_total_slots: None,
model_request_id: "req_key_other".to_owned(),
attempt_count: 1,
provider_kind: "openai".to_owned(),
provider_account_id: None,
provider_account_ref: Some("acct_key".to_owned()),
upstream_model_id: Some("upstream-model".to_owned()),
upstream_transport: "http_sse".to_owned(),
http_version: None,
},
)
.await
.expect("insert other key request");
finalize_request(&database.pool, "req_key_other", started_at).await;
let key_models = repository
.usage_diagnostics(
range_around(started_at),
UsageRecordFilter::default(),
DiagnosticDimension::KeyModel,
)
.await
.expect("key/model diagnostics for both keys");
assert_eq!(key_models.len(), 3);
assert!(
key_models
.iter()
.any(|item| { item.name == "Other Key → upstream-model" && item.request_count == 1 })
);
database.close().await;
}
+4 -2
View File
@@ -422,7 +422,7 @@ OpenAI 选号阶段确认本次可选账号全部额度耗尽时,HTTP 返回 `
| 方法 | 路由 | 查询 | 说明 |
| --- | --- | --- | --- |
| `GET` | `/api/key-usage/overview` | `startTime`、`endTime`、`model?` | 用量汇总、趋势、当前额度和北京时间今日健康时间线 |
| `GET` | `/api/key-usage/overview` | `startTime`、`endTime`、`model?` | 用量汇总、逐模型 Token 与估算费用、趋势、当前额度和北京时间今日健康时间线 |
| `GET` | `/api/key-usage/records` | 同上,另含 `kind?`、`currentPage?`、`pageSize?` | 当前 Key 的成功请求或错误记录 |
| `GET` | `/api/key-usage/config` | 无 | 当前 Key 的客户端配置凭据 |
| `GET` | `/api/key-usage/version` | 无 | “关于”弹窗使用的当前版本号和提交号 |
@@ -1487,7 +1487,9 @@ Dashboard 的 `capacityInfo.maxConcurrentPerAccount` 为默认账号并发上限
用量查询可组合页码/游标、时间范围、Provider、Client Key、账号、模型、route、transport、状态码、
request/response/upstream ID、outcome 与搜索文本。诊断 `dimension` 可取 `model`、`account`、
`apiKey`、`provider`、`transport`、`failureClass`、`status`。
`apiKey`、`keyModel`、`provider`、`transport`、`failureClass`、`status`。`keyModel` 按 Client Key 与模型交叉汇总,
模型优先采用上游发送值,缺失时采用客户端请求值;
返回请求数、Token 和估算费用;费用不完整时 `costIncomplete` 为 `true`。
`/api/admin/usage/providers` 仅接受可选的 `startTime`、`endTime`,返回 `data: string[]`,没有记录时为空数组。
时间使用 RFC 3339 格式,包含起点、不包含终点;默认终点为当前时间、起点为终点前 7 天,范围须为正且不超过 366 天。
+9
View File
@@ -49,10 +49,19 @@ export interface KeyUsageOverview {
endTime: string
key: KeyUsageBudget
summary: KeyUsageMetrics
models: KeyUsageModel[]
trend: KeyUsageTrendPoint[]
healthTimeline: DashboardHealthTimeline
}
export interface KeyUsageModel {
model: string
requests: number
totalTokens: number
costUsd: string | null
costIncomplete: boolean
}
export type KeyUsageRecordKind = 'success' | 'error'
export interface KeyUsageRecord {
+1
View File
@@ -450,6 +450,7 @@ export interface UsageDiagnosticItem {
retryRate: number
impactScore: number
estimatedCost: string | null
costIncomplete: boolean
attemptCount: number
totalTokens: number
}
@@ -0,0 +1,41 @@
<script setup lang="ts">
import type { KeyUsageModel } from '@/api/modules/key-usage'
import BaseCard from '@/components/base/BaseCard.vue'
import { defineTableColumns } from '@/components/base/BaseTable/columns'
import BaseTable from '@/components/base/BaseTable/index.vue'
import { formatInteger } from '@/utils/number'
import { money } from '../utils/format'
defineProps<{ models: KeyUsageModel[] }>()
const columns = defineTableColumns<KeyUsageModel>([
{ key: 'model', label: '模型', kind: 'custom', size: 'xl' },
{ key: 'requests', label: '请求', kind: 'numeric', size: 'sm' },
{ key: 'totalTokens', label: 'TOKEN', kind: 'numeric', size: 'lg' },
{ key: 'costUsd', label: '估算费用 USD', kind: 'numeric', size: 'lg' },
])
</script>
<template>
<BaseCard title="按模型用量" description="当前密钥在所选范围内的请求、Token 与估算费用">
<div class="h-70 min-h-0 overflow-hidden">
<BaseTable :columns="columns" :rows="models" row-key="model" class="min-w-0" empty-text="所选条件下暂无模型用量">
<template #model="{ row }">
<code class="block max-w-full truncate font-mono text-cp-sm font-heavy text-cp-text">{{ row.model }}</code>
</template>
<template #requests="{ row }">
<span class="font-mono tabular-nums">{{ formatInteger(row.requests) }}</span>
</template>
<template #totalTokens="{ row }">
<span class="font-mono tabular-nums">{{ formatInteger(row.totalTokens) }}</span>
</template>
<template #costUsd="{ row }">
<span class="grid justify-items-end gap-1 font-mono tabular-nums">
<strong>{{ money(row.costUsd) }}</strong>
<small v-if="row.costIncomplete" class="text-cp-xs text-cp-text-tertiary">部分费用缺失</small>
</span>
</template>
</BaseTable>
</div>
</BaseCard>
</template>
+2
View File
@@ -9,6 +9,7 @@ import AppAboutModal from '@/components/AppAboutModal.vue'
import RequestHealthTimelineCard from '@/views/dashboard/components/RequestHealthTimelineCard.vue'
import KeyUsageBudget from './components/KeyUsageBudget.vue'
import KeyUsageHeader from './components/KeyUsageHeader.vue'
import KeyUsageModels from './components/KeyUsageModels.vue'
import KeyUsageRecords from './components/KeyUsageRecords.vue'
import KeyUsageSkeleton from './components/KeyUsageSkeleton.vue'
import KeyUsageSummary from './components/KeyUsageSummary.vue'
@@ -62,6 +63,7 @@ async function openAbout() {
</p>
<template v-if="overview">
<KeyUsageSummary :summary="overview.summary" />
<KeyUsageModels :models="overview.models" />
<div class="grid min-w-0 gap-5 xl:grid-cols-[minmax(0,1.4fr)_minmax(400px,1fr)]">
<KeyUsageTrend class="min-w-0" :points="overview.trend" />
<KeyUsageBudget :budget="overview.key" />
@@ -24,6 +24,7 @@ const dimension = defineModel('dimension', { type: String, required: true })
const dimensionOptions = [
{ label: '模型', value: 'model' },
{ label: '密钥×模型', value: 'keyModel' },
{ label: '账号', value: 'account' },
{ label: '密钥', value: 'apiKey' },
{ label: '上游', value: 'provider' },
@@ -65,18 +66,28 @@ const diagnosticColumns = defineTableColumns<DiagnosticDisplayItem>([
},
])
const keyModelColumns = defineTableColumns<DiagnosticDisplayItem>([
{ key: 'nameDisplay', label: '密钥 / 模型', kind: 'custom', size: 'xl' },
{ key: 'requestCount', label: '请求', kind: 'numeric', size: 'sm' },
{ key: 'totalTokens', label: 'TOKEN', kind: 'numeric', size: 'lg' },
{ key: 'estimatedCost', label: '估算费用 USD', kind: 'numeric', size: 'lg' },
])
const selectedDimensionLabel = computed(
() => dimensionOptions.find(option => option.value === dimension.value)?.label ?? '维度',
)
const resultDimension = computed(() => props.diagnostics.dimension || dimension.value)
const visibleColumns = computed(() => resultDimension.value === 'keyModel' ? keyModelColumns : diagnosticColumns)
const resultDimensionLabel = computed(
() => dimensionOptions.find(option => option.value === resultDimension.value)?.label ?? '维度',
)
const sortedItems = computed(() =>
[...props.diagnostics.items].sort(
(left, right) => right.impactScore - left.impactScore || right.requestCount - left.requestCount,
(left, right) => resultDimension.value === 'keyModel'
? right.requestCount - left.requestCount
: right.impactScore - left.impactScore || right.requestCount - left.requestCount,
),
)
@@ -96,7 +107,7 @@ function diagnosticNameDisplay(name: string) {
const raw = name.trim() || '未知'
const full
= resultDimension.value === 'transport' ? ({ websocket: 'WS', http_sse: 'SSE' }[raw] ?? raw) : raw
if (resultDimension.value !== 'model' && resultDimension.value !== 'account') {
if (resultDimension.value !== 'model' && resultDimension.value !== 'account' && resultDimension.value !== 'keyModel') {
return { primary: full, secondary: '', full }
}
@@ -112,8 +123,8 @@ function diagnosticNameDisplay(name: string) {
<template>
<BaseCard
as="article"
title="热点诊断"
:description="`按${selectedDimensionLabel}定位高影响请求`"
:title="resultDimension === 'keyModel' ? '密钥模型用量' : '热点诊断'"
:description="resultDimension === 'keyModel' ? '各密钥下每个模型的 Token 与估算费用' : `按${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>
@@ -122,7 +133,7 @@ function diagnosticNameDisplay(name: string) {
label="诊断维度"
:options="dimensionOptions"
:disabled="loading"
class="w-full min-w-0 lg:w-80"
class="w-full min-w-0 lg:w-96"
/>
</template>
@@ -131,7 +142,7 @@ function diagnosticNameDisplay(name: string) {
v-if="hasData"
:key="resultDimension"
class="min-h-0 w-full xl:contain-[size]"
:columns="diagnosticColumns"
:columns="visibleColumns"
:rows="displayItems"
density="compact"
row-key="key"
@@ -225,8 +236,15 @@ function diagnosticNameDisplay(name: string) {
</template>
<template #estimatedCost="{ row }">
<span class="font-mono font-bold tabular-nums text-cp-green-text">
{{ formatUsd(row.estimatedCost) }}
<span class="grid justify-items-end gap-1 font-mono tabular-nums text-cp-green-text">
<strong>{{ formatUsd(row.estimatedCost) }}</strong>
<small v-if="row.costIncomplete" class="text-[10px] text-cp-text-tertiary">部分费用缺失</small>
</span>
</template>
<template #totalTokens="{ row }">
<span class="font-mono font-bold tabular-nums" :title="String(row.totalTokens)">
{{ formatCompactNumber(row.totalTokens) }}
</span>
</template>
</BaseTable>