diff --git a/backend/crates/gateway-admin/src/model/observability.rs b/backend/crates/gateway-admin/src/model/observability.rs index 2d4540a48..177f5b0c9 100644 --- a/backend/crates/gateway-admin/src/model/observability.rs +++ b/backend/crates/gateway-admin/src/model/observability.rs @@ -965,6 +965,7 @@ pub struct DiagnosticObservation { pub key: String, pub name: String, pub account_provider_kind: Option, + pub account_authentication_kind: Option, pub account_plan_type: Option, pub request_count: u64, pub success_count: u64, @@ -1435,6 +1436,7 @@ pub struct UsageInsight { pub struct DiagnosticsItem { pub key: String, pub name: String, + pub account_authentication_kind: Option, pub account_plan_type: Option, pub account_plan_type_display: Option, pub request_count: u64, diff --git a/backend/crates/gateway-admin/src/use_case/observability/mod.rs b/backend/crates/gateway-admin/src/use_case/observability/mod.rs index 9f9fe81b8..6d4d38fa8 100644 --- a/backend/crates/gateway-admin/src/use_case/observability/mod.rs +++ b/backend/crates/gateway-admin/src/use_case/observability/mod.rs @@ -344,6 +344,7 @@ impl ObservabilityService for DefaultObservabilityService { DiagnosticsItem { key: item.key, name: item.name, + account_authentication_kind: item.account_authentication_kind, account_plan_type_display: item.account_provider_kind.as_deref().and_then( |provider| { self.providers diff --git a/backend/crates/gateway-admin/tests/use_case/observability/mod.rs b/backend/crates/gateway-admin/tests/use_case/observability/mod.rs index 831b6bbef..02825a9ac 100644 --- a/backend/crates/gateway-admin/tests/use_case/observability/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/observability/mod.rs @@ -1559,6 +1559,7 @@ fn diagnostic(name: &str, request_count: u64) -> DiagnosticObservation { retry_count: 0, retried_request_count: 0, account_provider_kind: None, + account_authentication_kind: None, account_plan_type: None, key: name.to_owned(), name: name.to_owned(), diff --git a/backend/crates/gateway-api/src/admin/observability/presenter.rs b/backend/crates/gateway-api/src/admin/observability/presenter.rs index 8783554fa..227a9df80 100644 --- a/backend/crates/gateway-api/src/admin/observability/presenter.rs +++ b/backend/crates/gateway-api/src/admin/observability/presenter.rs @@ -1339,6 +1339,7 @@ pub(crate) fn diagnostics_view( .into_iter() .map(|item| DiagnosticItemView { key: item.key, + authentication_kind: item.account_authentication_kind, name: if item.name == "__none__" { "未知".to_owned() } else { diff --git a/backend/crates/gateway-api/src/admin/observability/wire.rs b/backend/crates/gateway-api/src/admin/observability/wire.rs index ee72034db..47215d31c 100644 --- a/backend/crates/gateway-api/src/admin/observability/wire.rs +++ b/backend/crates/gateway-api/src/admin/observability/wire.rs @@ -845,6 +845,7 @@ pub struct UsageCacheView { pub struct DiagnosticItemView { pub key: String, pub name: String, + pub authentication_kind: Option, pub account_plan_type: Option, pub account_plan_type_display: Option, pub request_count: u64, diff --git a/backend/crates/gateway-api/tests/admin/observability/response.rs b/backend/crates/gateway-api/tests/admin/observability/response.rs index 200e82044..07dc65272 100644 --- a/backend/crates/gateway-api/tests/admin/observability/response.rs +++ b/backend/crates/gateway-api/tests/admin/observability/response.rs @@ -670,67 +670,84 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() { use crate::admin::{AdminTestFixture, AdminTestState}; - let fixture = AdminTestFixture::new().await; - fixture.auth.insert_session("valid-session"); - *fixture.diagnostics.lock().expect("diagnostics") = DiagnosticsObservation { - analytics: Default::default(), - total_request_count: 4, - items: vec![DiagnosticObservation { - key: "acct_diag".to_owned(), - account_provider_kind: Some("openai".to_owned()), - account_plan_type: Some("pro".to_owned()), - name: "diag@example.invalid".to_owned(), - request_count: 2, - success_count: 2, - failure_count: 0, - attempt_count: 5, - total_tokens: 200, - average_latency_ms: Some(100), - latency_p95_ms: Some(3800), - first_token_p95_ms: Some(1200), - non_completion_count: 0, - retry_count: 3, - retried_request_count: 1, - cost_coverage: CostCoverage::default(), - costs: Vec::new(), - }], - }; - let response = observability::router::() - .with_state(fixture.state()) - .oneshot( - Request::builder() - .uri("/api/admin/usage/insights/diagnostics?dimension=account") - .header(header::COOKIE, "cpr_session=valid-session") - .header("x-request-id", "req_diagnostics_snapshot") - .body(Body::empty()) - .expect("diagnostics request"), - ) - .await - .expect("diagnostics response"); - assert_eq!(response.status(), StatusCode::OK); - let body = to_bytes(response.into_body(), 1024 * 1024) - .await - .expect("diagnostics body"); - let value: serde_json::Value = serde_json::from_slice(&body).expect("diagnostics JSON"); - assert_eq!(value["data"]["items"][0]["requestShare"], 0.5); - assert_eq!(value["data"]["items"][0]["retryCount"], 3); - assert_eq!(value["data"]["items"][0]["retryRate"], 0.5); - assert_eq!(value["data"]["items"][0]["accountPlanType"], "pro"); - assert_eq!(value["data"]["items"][0]["accountPlanTypeDisplay"], "Pro"); - assert_eq!( - ( - &value["data"]["items"][0]["key"], - &value["data"]["items"][0]["name"], - &value["data"]["items"][0]["latencyP95Ms"], - &value["data"]["dimension"], - ), - ( - &serde_json::json!("acct_diag"), - &serde_json::json!("diag@example.invalid"), - &serde_json::json!(3800), - &serde_json::json!("account"), - ) - ); + for (authentication_kind, plan_type, plan_display) in [ + (Some("oauth"), Some("pro"), Some("Pro")), + (Some("api_key"), None, None), + (None, None, None), + ] { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + *fixture.diagnostics.lock().expect("diagnostics") = DiagnosticsObservation { + analytics: Default::default(), + total_request_count: 4, + items: vec![DiagnosticObservation { + key: "acct_diag".to_owned(), + account_provider_kind: Some("openai".to_owned()), + account_authentication_kind: authentication_kind.map(str::to_owned), + account_plan_type: plan_type.map(str::to_owned), + name: "diag@example.invalid".to_owned(), + request_count: 2, + success_count: 2, + failure_count: 0, + attempt_count: 5, + total_tokens: 200, + average_latency_ms: Some(100), + latency_p95_ms: Some(3800), + first_token_p95_ms: Some(1200), + non_completion_count: 0, + retry_count: 3, + retried_request_count: 1, + cost_coverage: CostCoverage::default(), + costs: Vec::new(), + }], + }; + let response = observability::router::() + .with_state(fixture.state()) + .oneshot( + Request::builder() + .uri("/api/admin/usage/insights/diagnostics?dimension=account") + .header(header::COOKIE, "cpr_session=valid-session") + .header("x-request-id", "req_diagnostics_snapshot") + .body(Body::empty()) + .expect("diagnostics request"), + ) + .await + .expect("diagnostics response"); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("diagnostics body"); + let value: serde_json::Value = serde_json::from_slice(&body).expect("diagnostics JSON"); + assert_eq!(value["data"]["items"][0]["requestShare"], 0.5); + assert_eq!(value["data"]["items"][0]["retryCount"], 3); + assert_eq!(value["data"]["items"][0]["retryRate"], 0.5); + assert_eq!( + value["data"]["items"][0]["authenticationKind"], + serde_json::json!(authentication_kind) + ); + assert_eq!( + value["data"]["items"][0]["accountPlanType"], + serde_json::json!(plan_type) + ); + assert_eq!( + value["data"]["items"][0]["accountPlanTypeDisplay"], + serde_json::json!(plan_display) + ); + assert_eq!( + ( + &value["data"]["items"][0]["key"], + &value["data"]["items"][0]["name"], + &value["data"]["items"][0]["latencyP95Ms"], + &value["data"]["dimension"], + ), + ( + &serde_json::json!("acct_diag"), + &serde_json::json!("diag@example.invalid"), + &serde_json::json!(3800), + &serde_json::json!("account"), + ) + ); + } } pub(super) fn usage_record_with_account( diff --git a/backend/crates/gateway-store/src/postgres/observability/analytics/query.rs b/backend/crates/gateway-store/src/postgres/observability/analytics/query.rs index de8f20820..de54dd4a7 100644 --- a/backend/crates/gateway-store/src/postgres/observability/analytics/query.rs +++ b/backend/crates/gateway-store/src/postgres/observability/analytics/query.rs @@ -634,6 +634,7 @@ impl<'a> AnalyticsSnapshot<'a> { key: key.clone(), name: key, account_provider_kind: None, + account_authentication_kind: None, account_plan_type: None, request_count: metrics.request_count, success_count: metrics.success_count, @@ -669,9 +670,9 @@ impl<'a> AnalyticsSnapshot<'a> { .map(|item| item.key.clone()) .collect::>(); let statement = if dimension == DiagnosticDimension::Account { - "select requested.id, historical.provider_kind, historical.provider_account_plan_type_snapshot as plan_type, coalesce(historical.provider_account_email_snapshot,historical.provider_account_name_snapshot,requested.id) as name from unnest($1::text[]) requested(id) left join lateral (select provider_kind,provider_account_plan_type_snapshot,provider_account_email_snapshot,provider_account_name_snapshot from model_request_observations mr where mr.provider_account_ref=requested.id order by mr.started_at desc,mr.id desc limit 1) historical on true" + "select requested.id, historical.provider_kind, historical.provider_account_authentication_kind_snapshot as authentication_kind, historical.provider_account_plan_type_snapshot as plan_type, coalesce(historical.provider_account_email_snapshot,historical.provider_account_name_snapshot,requested.id) as name from unnest($1::text[]) requested(id) left join lateral (select provider_kind,provider_account_authentication_kind_snapshot,provider_account_plan_type_snapshot,provider_account_email_snapshot,provider_account_name_snapshot from model_request_observations mr where mr.provider_account_ref=requested.id order by mr.started_at desc,mr.id desc limit 1) historical on true" } else { - "select requested.id, null::text as provider_kind, null::text as plan_type,coalesce(historical.client_api_key_name_snapshot,requested.id) as name from unnest($1::text[]) requested(id) left join lateral (select client_api_key_name_snapshot from model_request_observations mr where mr.client_api_key_ref=requested.id order by mr.started_at desc,mr.id desc limit 1) historical on true" + "select requested.id, null::text as provider_kind, null::text as authentication_kind, null::text as plan_type,coalesce(historical.client_api_key_name_snapshot,requested.id) as name from unnest($1::text[]) requested(id) left join lateral (select client_api_key_name_snapshot from model_request_observations mr where mr.client_api_key_ref=requested.id order by mr.started_at desc,mr.id desc limit 1) historical on true" }; let labels = sqlx::query(statement) .bind(ids) @@ -683,6 +684,7 @@ impl<'a> AnalyticsSnapshot<'a> { if let Some(item) = observations.iter_mut().find(|item| item.key == id) { item.name = get(&row, "name")?; item.account_provider_kind = get(&row, "provider_kind")?; + item.account_authentication_kind = get(&row, "authentication_kind")?; item.account_plan_type = get(&row, "plan_type")?; } } diff --git a/backend/crates/gateway-store/src/postgres/observability/mapping.rs b/backend/crates/gateway-store/src/postgres/observability/mapping.rs index 9b4b0bee3..053ade186 100644 --- a/backend/crates/gateway-store/src/postgres/observability/mapping.rs +++ b/backend/crates/gateway-store/src/postgres/observability/mapping.rs @@ -279,6 +279,7 @@ fn admin_diagnostic_observation( key: observation.key, name: observation.name, account_provider_kind: observation.account_provider_kind, + account_authentication_kind: observation.account_authentication_kind, account_plan_type: observation.account_plan_type, request_count: observation.request_count, success_count: observation.success_count, diff --git a/backend/crates/gateway-store/src/postgres/observability/model.rs b/backend/crates/gateway-store/src/postgres/observability/model.rs index a4ce35e3a..9549b5265 100644 --- a/backend/crates/gateway-store/src/postgres/observability/model.rs +++ b/backend/crates/gateway-store/src/postgres/observability/model.rs @@ -378,6 +378,7 @@ pub struct DiagnosticObservation { pub key: String, pub name: String, pub account_provider_kind: Option, + pub account_authentication_kind: Option, pub account_plan_type: Option, pub request_count: u64, pub success_count: u64, diff --git a/backend/crates/gateway-store/tests/postgres/observability/mod.rs b/backend/crates/gateway-store/tests/postgres/observability/mod.rs index ce99b3b75..e3f6fd684 100644 --- a/backend/crates/gateway-store/tests/postgres/observability/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/observability/mod.rs @@ -497,6 +497,44 @@ async fn usage_list_and_diagnostics_preserve_account_snapshots_by_id() { diagnostics.items[0].account_plan_type.as_deref(), Some("team") ); + for (authentication_kind, plan_type) in [ + (Some("api_key"), None), + (Some("oauth"), Some("team")), + (None, None), + ] { + sqlx::query( + "update model_requests set request_observation_json = jsonb_set( + request_observation_json, '{account}', + (request_observation_json->'account') || jsonb_build_object( + 'authenticationKind', $1::text, 'planTypeSnapshot', $2::text + ) + ) where id = 'req_observe_uncommitted'", + ) + .bind(authentication_kind) + .bind(plan_type) + .execute(&database.pool) + .await + .expect("update request authentication snapshot"); + super::drain_analytics(&database.pool).await; + let diagnostics = store + .usage_diagnostics( + range, + admin_observability::UsageFilter { + provider_account_ref: Some("acct_team".to_owned()), + ..Default::default() + }, + admin_observability::DiagnosticDimension::Account, + 100, + ) + .await + .expect("deleted account authentication snapshot"); + assert_eq!(diagnostics.items.len(), 1); + assert_eq!( + diagnostics.items[0].account_authentication_kind.as_deref(), + authentication_kind + ); + assert_eq!(diagnostics.items[0].account_plan_type.as_deref(), plan_type); + } database.close().await; } diff --git a/docs/api.md b/docs/api.md index 911816965..e49db8041 100644 --- a/docs/api.md +++ b/docs/api.md @@ -2250,7 +2250,7 @@ Dashboard 返回原始 `asOf` 及 `asOfDisplay`,作为当前数据的查询锚 `retryCount` 为额外执行尝试次数之和,`retryRate` 为发生过重试的请求数占该组请求数的比例,同一请求多次重试只计一次 使用记录列表和错误排查列表的 `accountPlanType` / `accountPlanTypeDisplay` 来自记录保存的账号订阅快照。 -账号维度诊断使用该账号最近请求的订阅快照;未保存订阅时为 `null` +账号维度诊断使用该账号最近请求的订阅快照,并通过 `authenticationKind` 返回同一请求的认证类型快照;未保存的字段为 `null`,非账号维度的认证类型为 `null` 管理端请求列表及 Dashboard 最近请求中的 `accountNotes` 来自请求保存的账号备注快照,未保存时为 `null` diff --git a/frontend/src/api/modules/usage.ts b/frontend/src/api/modules/usage.ts index 589cf4e5e..97edde031 100644 --- a/frontend/src/api/modules/usage.ts +++ b/frontend/src/api/modules/usage.ts @@ -361,6 +361,7 @@ export type UsageMetricResponse = { export interface UsageDiagnosticItem { key: string name: string + authenticationKind: string | null accountPlanType: string | null accountPlanTypeDisplay: string | null requestCount: number diff --git a/frontend/src/components/usage/UsageRecordsTable.vue b/frontend/src/components/usage/UsageRecordsTable.vue index 06734de55..715e2c6ab 100644 --- a/frontend/src/components/usage/UsageRecordsTable.vue +++ b/frontend/src/components/usage/UsageRecordsTable.vue @@ -79,9 +79,10 @@ const { t } = useI18n({ useScope: 'global' })