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 @@ + + + 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() {