mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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:
@@ -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
@@ -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 天。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user