From 85b1c13c06c3c1763907b0f835e2b55dc10cfe2b Mon Sep 17 00:00:00 2001 From: Yongzhao Chen Date: Tue, 22 Sep 2026 21:27:47 +0200 Subject: [PATCH 1/5] feat(usage): show per-model usage for client keys Add published GPT-6 Sol and Luna pricing for estimated costs. Co-authored-by: Codex --- README.md | 2 +- .../gateway-admin/src/model/key_usage.rs | 4 +- .../gateway-admin/src/use_case/key_usage.rs | 10 +++-- .../gateway-api/src/key_usage/presenter.rs | 27 ++++++++++++ backend/crates/gateway-api/tests/admin/mod.rs | 12 ++++-- .../gateway-api/tests/key_usage/fixtures.rs | 29 +++++++++++-- .../crates/gateway-api/tests/key_usage/mod.rs | 23 +++++++++++ .../postgres/observability/queries/usage.rs | 9 +++- .../providers/openai/src/transport/usage.rs | 26 +++++++++++- .../providers/openai/tests/transport/usage.rs | 33 +++++++++++++++ docs/api.md | 2 +- frontend/src/api/modules/key-usage.ts | 9 ++++ .../key-usage/components/KeyUsageModels.vue | 41 +++++++++++++++++++ frontend/src/views/key-usage/index.vue | 2 + 14 files changed, 214 insertions(+), 15 deletions(-) create mode 100644 frontend/src/views/key-usage/components/KeyUsageModels.vue diff --git a/README.md b/README.md index 4d8dffeab..3078bd401 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,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/use_case/key_usage.rs b/backend/crates/gateway-admin/src/use_case/key_usage.rs index 0aa53668d..26eacd011 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, }, @@ -171,9 +172,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)), ) @@ -182,6 +185,7 @@ impl KeyUsageService for DefaultKeyUsageService { key, overview, trend, + models, health_timeline: health_timeline_at(&health_points, now), })) } 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 e7475c8cf..1b762c3d9 100644 --- a/backend/crates/gateway-api/tests/admin/mod.rs +++ b/backend/crates/gateway-api/tests/admin/mod.rs @@ -587,6 +587,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, } @@ -1227,10 +1228,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/key_usage/fixtures.rs b/backend/crates/gateway-api/tests/key_usage/fixtures.rs index 56200d723..6d7a89f52 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::{ @@ -85,6 +85,29 @@ 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/queries/usage.rs b/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs index d231a83bd..6fe66b43a 100644 --- a/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs +++ b/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs @@ -449,7 +449,14 @@ 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::Model + && filter.client_api_key_ref.is_some() + { + i64::MAX + } else { + DIAGNOSTIC_LIMIT + }); statement.push( ") select aggregated.* diff --git a/backend/crates/providers/openai/src/transport/usage.rs b/backend/crates/providers/openai/src/transport/usage.rs index 3dbaeac69..9dc0253e6 100644 --- a/backend/crates/providers/openai/src/transport/usage.rs +++ b/backend/crates/providers/openai/src/transport/usage.rs @@ -226,12 +226,34 @@ struct PricingRule { pricing: ModelPricing, } -// 价格来源:https://developers.openai.com/api/docs/pricing,核验日期 2026-09-09。 -// 使用常规价,不采用 Sol 的临时 $4/$20 优惠;缓存、Flex、Fast 和长上下文 +// 价格来源:https://developers.openai.com/api/docs/pricing;原有条目核验于 2026-09-09, +// GPT-6 Sol/Luna 核验于 2026-09-22。 +// 使用常规价,不采用 GPT-5.6 Sol 的临时 $4/$20 优惠;缓存、Flex、Fast 和长上下文 // 档位也统一按常规价计算。 // 已按 https://developers.openai.com/api/docs/deprecations 核验至 2026-09-13, // 移除已关闭的型号;仅宣布弃用但尚未到关闭日期的型号继续保留。 const PRICING_RULES: &[PricingRule] = &[ + // Sol / Luna:https://developers.openai.com/api/docs/pricing + PricingRule { + model: "gpt-6-sol", + pricing: ModelPricing::new(20_000, 100_000, 2_000) + .with_cache_write(125) + .with_flex(10_000, 50_000, 1_000) + .with_fast(40_000, 200_000, 4_000) + .with_long(40_000, 150_000, 4_000) + .with_long_flex(20_000, 75_000, 2_000) + .with_long_fast(80_000, 300_000, 8_000), + }, + PricingRule { + model: "gpt-6-luna", + pricing: ModelPricing::new(1_000, 5_000, 100) + .with_cache_write(125) + .with_flex(500, 2_500, 50) + .with_fast(2_000, 10_000, 200) + .with_long(2_000, 7_500, 200) + .with_long_flex(1_000, 3_750, 100) + .with_long_fast(4_000, 15_000, 400), + }, // Astra:https://developers.openai.com/api/docs/models/gpt-6-astra // 已于 2026-09-09 对照官方价目表核验。 PricingRule { diff --git a/backend/crates/providers/openai/tests/transport/usage.rs b/backend/crates/providers/openai/tests/transport/usage.rs index f19d597f4..69ceeab3b 100644 --- a/backend/crates/providers/openai/tests/transport/usage.rs +++ b/backend/crates/providers/openai/tests/transport/usage.rs @@ -76,6 +76,39 @@ fn astra_billing_should_preserve_components_across_tiers_and_context_boundary() ); } +#[test] +fn gpt_6_sol_and_luna_should_use_published_tier_and_context_prices() { + // 每百万 token 价格依次为输入、缓存读、缓存写、输出。 + for (model, tier, input, expected) in [ + ("gpt-6-sol", None, 272_000, ["2", "0.2", "2.5", "10"]), + ("gpt-6-sol", None, 272_001, ["4", "0.4", "5", "15"]), + ("gpt-6-sol", Some("flex"), 272_000, ["1", "0.1", "1.25", "5"]), + ("gpt-6-sol", Some("fast"), 272_001, ["8", "0.8", "10", "30"]), + ("gpt-6-luna", None, 272_000, ["0.1", "0.01", "0.125", "0.5"]), + ("gpt-6-luna", None, 272_001, ["0.2", "0.02", "0.25", "0.75"]), + ("gpt-6-luna", Some("flex"), 272_001, ["0.1", "0.01", "0.125", "0.375"]), + ("gpt-6-luna", Some("fast"), 272_000, ["0.2", "0.02", "0.25", "1"]), + ] { + let breakdown = openai_billing_breakdown(model, billing_usage(input, 1, 1, 1), tier) + .expect("published GPT-6 pricing"); + let actual = [ + breakdown.input_price_per_million(), + breakdown.cache_read_price_per_million(), + breakdown.cache_write_price_per_million(), + breakdown.output_price_per_million(), + ] + .map(|price| { + price + .amount() + .to_string() + .trim_end_matches('0') + .trim_end_matches('.') + .to_owned() + }); + assert_eq!(actual, expected, "{model} {tier:?} {input}"); + } +} + #[test] fn billing_breakdown_should_preserve_input_output_and_cache_components() { let breakdown = openai_billing_breakdown("gpt-5.6-sol", billing_usage(100, 5, 20, 10), None) diff --git a/docs/api.md b/docs/api.md index 50293a7c0..20ac12740 100644 --- a/docs/api.md +++ b/docs/api.md @@ -379,7 +379,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` | 无 | “关于”弹窗使用的当前版本号和提交号 | 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/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 c2e08eac4..57eefd72c 100644 --- a/frontend/src/views/key-usage/index.vue +++ b/frontend/src/views/key-usage/index.vue @@ -10,6 +10,7 @@ import BaseScrollbar from '@/components/base/BaseScrollbar.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' @@ -63,6 +64,7 @@ async function openAbout() {