diff --git a/README.md b/README.md
index 6ca37288a..02062b2f4 100644
--- a/README.md
+++ b/README.md
@@ -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 分钟
默认地址只能在服务器本机访问,从其他设备使用时,需要配置
diff --git a/backend/crates/gateway-admin/src/model/key_usage.rs b/backend/crates/gateway-admin/src/model/key_usage.rs
index 8c37735af..29a6780ce 100644
--- a/backend/crates/gateway-admin/src/model/key_usage.rs
+++ b/backend/crates/gateway-admin/src/model/key_usage.rs
@@ -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,
+ pub models: Vec,
pub health_timeline: HealthTimeline,
}
diff --git a/backend/crates/gateway-admin/src/model/observability.rs b/backend/crates/gateway-admin/src/model/observability.rs
index 717341444..556c22e92 100644
--- a/backend/crates/gateway-admin/src/model/observability.rs
+++ b/backend/crates/gateway-admin/src/model/observability.rs
@@ -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,
+ pub cost_incomplete: bool,
pub attempt_count: u64,
pub total_tokens: u64,
}
diff --git a/backend/crates/gateway-admin/src/use_case/key_usage.rs b/backend/crates/gateway-admin/src/use_case/key_usage.rs
index c7253683b..adeeb36bc 100644
--- a/backend/crates/gateway-admin/src/use_case/key_usage.rs
+++ b/backend/crates/gateway-admin/src/use_case/key_usage.rs
@@ -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),
}))
}
diff --git a/backend/crates/gateway-admin/src/use_case/observability.rs b/backend/crates/gateway-admin/src/use_case/observability.rs
index 4a98ea833..e70c5b27e 100644
--- a/backend/crates/gateway-admin/src/use_case/observability.rs
+++ b/backend/crates/gateway-admin/src/use_case/observability.rs
@@ -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,
}
diff --git a/backend/crates/gateway-api/src/admin/observability/presenter.rs b/backend/crates/gateway-api/src/admin/observability/presenter.rs
index d88819220..8351398be 100644
--- a/backend/crates/gateway-api/src/admin/observability/presenter.rs
+++ b/backend/crates/gateway-api/src/admin/observability/presenter.rs
@@ -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,
})
diff --git a/backend/crates/gateway-api/src/admin/observability/query.rs b/backend/crates/gateway-api/src/admin/observability/query.rs
index 6171e81ec..afd18986a 100644
--- a/backend/crates/gateway-api/src/admin/observability/query.rs
+++ b/backend/crates/gateway-api/src/admin/observability/query.rs
@@ -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 {
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,
diff --git a/backend/crates/gateway-api/src/admin/observability/wire.rs b/backend/crates/gateway-api/src/admin/observability/wire.rs
index 26e5a0f13..7285c1567 100644
--- a/backend/crates/gateway-api/src/admin/observability/wire.rs
+++ b/backend/crates/gateway-api/src/admin/observability/wire.rs
@@ -760,6 +760,7 @@ pub struct DiagnosticItemView {
pub retry_rate: f64,
pub impact_score: f64,
pub estimated_cost: Option,
+ pub cost_incomplete: bool,
pub attempt_count: u64,
pub total_tokens: u64,
}
diff --git a/backend/crates/gateway-api/src/key_usage/presenter.rs b/backend/crates/gateway-api/src/key_usage/presenter.rs
index 11d118080..fceabf6b3 100644
--- a/backend/crates/gateway-api/src/key_usage/presenter.rs
+++ b/backend/crates/gateway-api/src/key_usage/presenter.rs
@@ -54,6 +54,7 @@ pub(super) struct OverviewView {
end_time: DateTime,
key: KeyView,
summary: MetricsView,
+ models: Vec,
trend: Vec,
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,
+ 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()
diff --git a/backend/crates/gateway-api/tests/admin/mod.rs b/backend/crates/gateway-api/tests/admin/mod.rs
index 0989b4e99..8995bb3fb 100644
--- a/backend/crates/gateway-api/tests/admin/mod.rs
+++ b/backend/crates/gateway-api/tests/admin/mod.rs
@@ -609,6 +609,7 @@ pub(super) struct MemoryObservations {
pub trend: Option>,
pub summaries: Vec<(TimeRange, UsageFilter)>,
pub trends: Vec<(TimeRange, UsageFilter)>,
+ pub diagnostic_queries: Vec<(TimeRange, UsageFilter, DiagnosticDimension)>,
pub records: Vec,
pub errors: Vec,
}
@@ -1283,10 +1284,15 @@ impl ObservabilityStore for UnusedStore {
async fn usage_diagnostics(
&self,
- _: TimeRange,
- _: UsageFilter,
- _: DiagnosticDimension,
+ range: TimeRange,
+ filter: UsageFilter,
+ dimension: DiagnosticDimension,
) -> AdminStoreResult> {
+ self.observations
+ .lock()
+ .expect("observations")
+ .diagnostic_queries
+ .push((range, filter, dimension));
Ok(self.diagnostics.lock().expect("diagnostics").clone())
}
diff --git a/backend/crates/gateway-api/tests/admin/observability/query.rs b/backend/crates/gateway-api/tests/admin/observability/query.rs
index a832080af..fc77fbe97 100644
--- a/backend/crates/gateway-api/tests/admin/observability/query.rs
+++ b/backend/crates/gateway-api/tests/admin/observability/query.rs
@@ -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]
diff --git a/backend/crates/gateway-api/tests/key_usage/fixtures.rs b/backend/crates/gateway-api/tests/key_usage/fixtures.rs
index 3fe5e372a..08b216173 100644
--- a/backend/crates/gateway-api/tests/key_usage/fixtures.rs
+++ b/backend/crates/gateway-api/tests/key_usage/fixtures.rs
@@ -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
}
diff --git a/backend/crates/gateway-api/tests/key_usage/mod.rs b/backend/crates/gateway-api/tests/key_usage/mod.rs
index 43a6051e9..249bc2d7c 100644
--- a/backend/crates/gateway-api/tests/key_usage/mod.rs
+++ b/backend/crates/gateway-api/tests/key_usage/mod.rs
@@ -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()
diff --git a/backend/crates/gateway-store/src/postgres/observability/mapping.rs b/backend/crates/gateway-store/src/postgres/observability/mapping.rs
index 07f4e019a..af5818e26 100644
--- a/backend/crates/gateway-store/src/postgres/observability/mapping.rs
+++ b/backend/crates/gateway-store/src/postgres/observability/mapping.rs
@@ -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,
diff --git a/backend/crates/gateway-store/src/postgres/observability/model.rs b/backend/crates/gateway-store/src/postgres/observability/model.rs
index e3cdc6a7f..3d934d26b 100644
--- a/backend/crates/gateway-store/src/postgres/observability/model.rs
+++ b/backend/crates/gateway-store/src/postgres/observability/model.rs
@@ -185,6 +185,7 @@ pub struct OpsErrorQuery {
pub enum DiagnosticDimension {
Provider,
Model,
+ KeyModel,
Account,
ApiKey,
Transport,
diff --git a/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs b/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs
index d231a83bd..ba4103796 100644
--- a/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs
+++ b/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs
@@ -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,
@@ -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::>>()?;
+ let names = diagnostic_api_key_display_names(
+ pool,
+ &pairs.iter().map(|(key, _)| key.clone()).collect::>(),
+ )
+ .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");
diff --git a/backend/crates/gateway-store/tests/postgres/snapshots.rs b/backend/crates/gateway-store/tests/postgres/snapshots.rs
index c7cba7789..264a5c32c 100644
--- a/backend/crates/gateway-store/tests/postgres/snapshots.rs
+++ b/backend/crates/gateway-store/tests/postgres/snapshots.rs
@@ -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::>();
+ 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;
}
diff --git a/docs/api.md b/docs/api.md
index dbe0a0c57..0f025901c 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -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 天。
diff --git a/frontend/src/api/modules/key-usage.ts b/frontend/src/api/modules/key-usage.ts
index 7e742f4d1..89243ef02 100644
--- a/frontend/src/api/modules/key-usage.ts
+++ b/frontend/src/api/modules/key-usage.ts
@@ -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 {
diff --git a/frontend/src/api/modules/usage.ts b/frontend/src/api/modules/usage.ts
index 214a66dde..9981aad5f 100644
--- a/frontend/src/api/modules/usage.ts
+++ b/frontend/src/api/modules/usage.ts
@@ -450,6 +450,7 @@ export interface UsageDiagnosticItem {
retryRate: number
impactScore: number
estimatedCost: string | null
+ costIncomplete: boolean
attemptCount: number
totalTokens: number
}
diff --git a/frontend/src/views/key-usage/components/KeyUsageModels.vue b/frontend/src/views/key-usage/components/KeyUsageModels.vue
new file mode 100644
index 000000000..8e33498e7
--- /dev/null
+++ b/frontend/src/views/key-usage/components/KeyUsageModels.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+ {{ row.model }}
+
+
+ {{ formatInteger(row.requests) }}
+
+
+ {{ formatInteger(row.totalTokens) }}
+
+
+
+ {{ money(row.costUsd) }}
+ 部分费用缺失
+
+
+
+
+
+
diff --git a/frontend/src/views/key-usage/index.vue b/frontend/src/views/key-usage/index.vue
index 3a259f6a2..27e68c3fc 100644
--- a/frontend/src/views/key-usage/index.vue
+++ b/frontend/src/views/key-usage/index.vue
@@ -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() {
+
diff --git a/frontend/src/views/usage/components/UsageDiagnosticCard.vue b/frontend/src/views/usage/components/UsageDiagnosticCard.vue
index 6b531aed3..5ef131b27 100644
--- a/frontend/src/views/usage/components/UsageDiagnosticCard.vue
+++ b/frontend/src/views/usage/components/UsageDiagnosticCard.vue
@@ -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([
},
])
+const keyModelColumns = defineTableColumns([
+ { 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) {
@@ -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"
/>
@@ -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) {
-
- {{ formatUsd(row.estimatedCost) }}
+
+ {{ formatUsd(row.estimatedCost) }}
+ 部分费用缺失
+
+
+
+
+
+ {{ formatCompactNumber(row.totalTokens) }}