diff --git a/backend/crates/gateway-admin/src/model/observability.rs b/backend/crates/gateway-admin/src/model/observability.rs index 7bfc12983..bde9385e2 100644 --- a/backend/crates/gateway-admin/src/model/observability.rs +++ b/backend/crates/gateway-admin/src/model/observability.rs @@ -809,6 +809,8 @@ pub struct OpsError { pub provider_account_ref: Option, pub provider_account_name: Option, pub provider_account_email: Option, + pub provider_account_plan_type: Option, + pub provider_account_plan_type_display: Option, pub provider_account_authentication_kind: Option, pub upstream_model_id: Option, pub upstream_transport: Option, diff --git a/backend/crates/gateway-admin/src/use_case/observability.rs b/backend/crates/gateway-admin/src/use_case/observability.rs index 38047327a..30a33eb65 100644 --- a/backend/crates/gateway-admin/src/use_case/observability.rs +++ b/backend/crates/gateway-admin/src/use_case/observability.rs @@ -382,10 +382,19 @@ impl ObservabilityService for DefaultObservabilityService { } async fn ops_errors(&self, query: OpsErrorQuery) -> Result { - self.store + let mut page = self + .store .list_ops_errors(query) .await - .map_err(|error| map_store_error(error, "operations errors")) + .map_err(|error| map_store_error(error, "operations errors"))?; + for error in &mut page.items { + error.provider_account_plan_type_display = + error.provider_kind.as_deref().and_then(|provider| { + self.providers + .plan_type_display(provider, error.provider_account_plan_type.as_deref()) + }); + } + Ok(page) } } diff --git a/backend/crates/gateway-api/src/admin/observability/presenter.rs b/backend/crates/gateway-api/src/admin/observability/presenter.rs index afa9ab724..965def980 100644 --- a/backend/crates/gateway-api/src/admin/observability/presenter.rs +++ b/backend/crates/gateway-api/src/admin/observability/presenter.rs @@ -583,6 +583,8 @@ pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView { account_id: error.provider_account_ref, account_name: error.provider_account_name, account_email: error.provider_account_email, + account_plan_type: error.provider_account_plan_type, + account_plan_type_display: error.provider_account_plan_type_display, route: error.endpoint.unwrap_or_else(|| "—".to_owned()), model, requested_model: error.requested_model_id, diff --git a/backend/crates/gateway-api/src/admin/observability/wire.rs b/backend/crates/gateway-api/src/admin/observability/wire.rs index 05542ce5a..be27bec34 100644 --- a/backend/crates/gateway-api/src/admin/observability/wire.rs +++ b/backend/crates/gateway-api/src/admin/observability/wire.rs @@ -792,6 +792,8 @@ pub struct OpsErrorView { pub account_id: Option, pub account_name: Option, pub account_email: Option, + pub account_plan_type: Option, + pub account_plan_type_display: Option, pub route: String, pub model: Option, pub requested_model: Option, diff --git a/backend/crates/gateway-api/tests/admin/observability/response.rs b/backend/crates/gateway-api/tests/admin/observability/response.rs index e2a9c16e1..89e1aa35b 100644 --- a/backend/crates/gateway-api/tests/admin/observability/response.rs +++ b/backend/crates/gateway-api/tests/admin/observability/response.rs @@ -447,6 +447,8 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() { provider_account_ref: Some("acct_err".to_owned()), provider_account_name: None, provider_account_email: Some("err@example.invalid".to_owned()), + provider_account_plan_type: Some("pro".to_owned()), + provider_account_plan_type_display: Some("Pro".to_owned()), provider_account_authentication_kind: Some("api_key".to_owned()), upstream_model_id: Some("upstream-err".to_owned()), upstream_transport: Some("http_sse".to_owned()), @@ -509,6 +511,8 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() { "kind": value["data"]["items"][0]["kind"], "accountId": value["data"]["items"][0]["accountId"], "accountLabel": value["data"]["items"][0]["metadata"]["accountLabel"], + "accountPlanType": value["data"]["items"][0]["accountPlanType"], + "accountPlanTypeDisplay": value["data"]["items"][0]["accountPlanTypeDisplay"], "clientStatusCode": value["data"]["items"][0]["clientStatusCode"], "route": value["data"]["items"][0]["route"], "requestedModel": value["data"]["items"][0]["requestedModel"], @@ -528,6 +532,8 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() { "kind": "model_request", "accountId": "acct_err", "accountLabel": "err@example.invalid", + "accountPlanType": "pro", + "accountPlanTypeDisplay": "Pro", "clientStatusCode": 502, "route": "/v1/responses", "requestedModel": "gpt-5.4", diff --git a/backend/crates/gateway-api/tests/key_usage/fixtures.rs b/backend/crates/gateway-api/tests/key_usage/fixtures.rs index dd937c13e..bb3360e3f 100644 --- a/backend/crates/gateway-api/tests/key_usage/fixtures.rs +++ b/backend/crates/gateway-api/tests/key_usage/fixtures.rs @@ -168,6 +168,8 @@ fn error_record() -> OpsError { provider_account_name: Some("private-sentinel".to_owned()), provider_account_email: Some("private-sentinel".to_owned()), provider_account_authentication_kind: Some("private-sentinel".to_owned()), + provider_account_plan_type: Some("private-sentinel".to_owned()), + provider_account_plan_type_display: Some("private-sentinel".to_owned()), upstream_model_id: Some("private-sentinel".to_owned()), upstream_transport: Some("websocket".to_owned()), failure_kind: "private-sentinel".to_owned(), diff --git a/backend/crates/gateway-store/src/postgres/observability/mapping.rs b/backend/crates/gateway-store/src/postgres/observability/mapping.rs index 1a8352b26..bf41391f4 100644 --- a/backend/crates/gateway-store/src/postgres/observability/mapping.rs +++ b/backend/crates/gateway-store/src/postgres/observability/mapping.rs @@ -757,6 +757,8 @@ pub(crate) fn admin_ops_error(error: OpsErrorRecord) -> admin_observability::Ops provider_account_ref: error.provider_account_ref, provider_account_name: error.provider_account_name, provider_account_email: error.provider_account_email, + provider_account_plan_type: error.provider_account_plan_type, + provider_account_plan_type_display: None, provider_account_authentication_kind: error.provider_account_authentication_kind, upstream_model_id: error.upstream_model_id, upstream_transport: error.upstream_transport, @@ -955,6 +957,7 @@ pub(crate) fn ops_error_from_row(row: &sqlx::postgres::PgRow) -> StoreResult, pub provider_account_name: Option, pub provider_account_email: Option, + pub provider_account_plan_type: Option, pub provider_account_authentication_kind: Option, pub upstream_model_id: Option, pub upstream_transport: Option, diff --git a/backend/crates/gateway-store/src/postgres/observability/queries/ops.rs b/backend/crates/gateway-store/src/postgres/observability/queries/ops.rs index 4f96344b1..75cb55c6e 100644 --- a/backend/crates/gateway-store/src/postgres/observability/queries/ops.rs +++ b/backend/crates/gateway-store/src/postgres/observability/queries/ops.rs @@ -10,6 +10,7 @@ const REQUEST_ERROR_SELECT: &str = "select 'model_request'::text as source, mr.endpoint, mr.provider_kind, mr.provider_account_ref, mr.provider_account_name_snapshot as provider_account_name, mr.provider_account_email_snapshot as provider_account_email, + account.plan_type as provider_account_plan_type, mr.provider_account_authentication_kind_snapshot as provider_account_authentication_kind, mr.upstream_model_id, mr.upstream_transport, @@ -33,6 +34,7 @@ const REQUEST_ERROR_SELECT: &str = "select 'model_request'::text as source, 'model_request:' || mr.id as stable_sort_id from model_requests mr left join client_api_keys client_key on client_key.id = mr.client_api_key_ref +left join provider_accounts account on account.id = mr.provider_account_ref where true"; const OPS_EVENT_SELECT: &str = "select 'ops_event'::text as source, @@ -42,6 +44,7 @@ const OPS_EVENT_SELECT: &str = "select 'ops_event'::text as source, mr.endpoint, oe.provider_kind, oe.provider_account_ref, oe.provider_account_name_snapshot as provider_account_name, oe.provider_account_email_snapshot as provider_account_email, + account.plan_type as provider_account_plan_type, oe.provider_account_authentication_kind_snapshot as provider_account_authentication_kind, oe.upstream_model_id, null::text as upstream_transport, oe.failure_kind, @@ -66,6 +69,7 @@ const OPS_EVENT_SELECT: &str = "select 'ops_event'::text as source, from ops_events oe left join model_requests mr on mr.id = oe.model_request_id left join client_api_keys client_key on client_key.id = mr.client_api_key_ref +left join provider_accounts account on account.id = oe.provider_account_ref where true"; pub(crate) async fn list_ops_errors( diff --git a/backend/crates/gateway-store/tests/postgres/observability/mod.rs b/backend/crates/gateway-store/tests/postgres/observability/mod.rs index b79f24d8a..f02233637 100644 --- a/backend/crates/gateway-store/tests/postgres/observability/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/observability/mod.rs @@ -653,6 +653,114 @@ async fn ops_search_should_treat_sql_wildcards_as_literals() { database.close().await; } +#[tokio::test] +async fn ops_errors_should_use_each_event_accounts_current_subscription() { + let Some(database) = TestDatabase::create("ops_account_subscription").await else { + return; + }; + let now = Utc::now(); + seed_observability_facts(&database.pool, now) + .await + .expect("seed observability facts"); + sqlx::query( + "insert into provider_accounts ( + id, provider_kind, name, email, upstream_user_id, upstream_account_id, + plan_type, authentication_kind, provider_credentials_json, + credential_revision, credential_observed_at, has_refresh_token, + created_at, updated_at + ) select 'acct_retry', provider_kind, name, email, upstream_user_id, + 'retry-workspace', 'team', authentication_kind, + provider_credentials_json, credential_revision, credential_observed_at, + has_refresh_token, created_at, updated_at + from provider_accounts where id = 'acct_observe'", + ) + .execute(&database.pool) + .await + .expect("seed same-email retry account"); + sqlx::query( + "update ops_events set provider_account_id = 'acct_retry', + provider_account_ref = 'acct_retry' + where id = 'ops_observe_retry'", + ) + .execute(&database.pool) + .await + .expect("assign retry event to its own account"); + let query = admin_observability::OpsErrorQuery { + range: admin_observability::TimeRange::new( + now - TimeDelta::hours(1), + now + TimeDelta::hours(1), + ) + .expect("ops range"), + filter: admin_observability::OpsErrorFilter::default(), + current_page: 1, + page_size: PageSize::new(10).expect("page size"), + }; + let store = admin_observability_store(&database.pool); + for plan in [Some("pro"), Some("plus"), None] { + sqlx::query("update provider_accounts set plan_type = $1 where id = 'acct_observe'") + .bind(plan) + .execute(&database.pool) + .await + .expect("update current subscription"); + let page = store + .list_ops_errors(query.clone()) + .await + .expect("ops page"); + assert_eq!(page.total, 2); + assert_eq!(page.items.len(), 2); + let request = page + .items + .iter() + .find(|error| error.source == "model_request") + .expect("request error"); + let event = page + .items + .iter() + .find(|error| error.source == "ops_event") + .expect("retry event"); + assert_eq!(request.provider_account_email, event.provider_account_email); + assert_eq!(request.provider_account_plan_type.as_deref(), plan); + assert_eq!(event.provider_account_plan_type.as_deref(), Some("team")); + } + sqlx::query( + "update ops_events set model_request_id = null, attempt_index = null + where id = 'ops_observe_retry'", + ) + .execute(&database.pool) + .await + .expect("detach event from request"); + let page = store + .list_ops_errors(query.clone()) + .await + .expect("ops page"); + assert_eq!( + page.items + .iter() + .find(|error| error.source == "ops_event") + .expect("standalone event") + .provider_account_plan_type + .as_deref(), + Some("team") + ); + sqlx::query("delete from provider_accounts where id = 'acct_retry'") + .execute(&database.pool) + .await + .expect("delete retry account"); + let page = store + .list_ops_errors(query) + .await + .expect("deleted account history"); + assert_eq!(page.total, 2); + let event = page + .items + .iter() + .find(|error| error.source == "ops_event") + .expect("deleted account event"); + assert_eq!(event.provider_account_ref.as_deref(), Some("acct_retry")); + assert_eq!(event.provider_account_plan_type, None); + database.close().await; +} + #[tokio::test] async fn ops_should_include_incomplete_upstream_errors() { let Some(database) = TestDatabase::create("ops_incomplete_error").await else { diff --git a/docs/api.md b/docs/api.md index b86fd2bd0..702fdd72a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1562,8 +1562,8 @@ request/response/upstream ID、outcome 与搜索文本。诊断 `dimension` 可 `requestShare` 的分母为该维度筛选后、截取前的全部请求数;`failureClass` 只在带错误类型的请求内计算占比 `retryCount` 为额外执行尝试次数之和,`retryRate` 为发生过重试的请求数占该组请求数的比例,同一请求多次重试只计一次 -账号维度诊断项和使用记录列表的 `accountPlanType` / `accountPlanTypeDisplay` 返回账号当前套餐及展示名称, -按内部账号 ID 关联;套餐未知或账号已删除时为 `null`,不作为请求发生时的套餐快照 +账号维度诊断项、使用记录列表和错误排查列表的 `accountPlanType` / `accountPlanTypeDisplay` 返回账号当前订阅及展示名称, +按各记录的内部账号 ID 关联;订阅未知或账号已删除时为 `null`,不作为请求发生时的订阅快照 管理端请求列表及 Dashboard 最近请求中的 `accountNotes` 为账号当前备注,按内部账号 ID 关联。 备注不写入请求历史快照;无备注或账号已删除时返回 `null`,修改备注不改变历史请求的账号归属 diff --git a/frontend/src/api/modules/usage.ts b/frontend/src/api/modules/usage.ts index 13cbe5a5c..97250a1eb 100644 --- a/frontend/src/api/modules/usage.ts +++ b/frontend/src/api/modules/usage.ts @@ -278,6 +278,8 @@ export interface OpsError { accountId: string | null accountName: string | null accountEmail: string | null + accountPlanType: string | null + accountPlanTypeDisplay: string | null route: string model: string | null requestedModel: string | null diff --git a/frontend/src/views/accounts/components/AccountQuotaPanel/index.vue b/frontend/src/views/accounts/components/AccountQuotaPanel/index.vue index 5af5835ae..d41089e56 100644 --- a/frontend/src/views/accounts/components/AccountQuotaPanel/index.vue +++ b/frontend/src/views/accounts/components/AccountQuotaPanel/index.vue @@ -2,12 +2,13 @@ import type { AccountRow } from '../../constants' import { BaseEmpty, BaseIconButton } from '@codex-proxy/ui' -import { RefreshCw, UserRound } from '@lucide/vue' +import { ChartNoAxesCombined, RefreshCw, UserRound } from '@lucide/vue' import { computed, shallowRef, watch } from 'vue' import { formatProviderLabel } from '@/utils/providers' import { groupedAccountQuotaWindows, orderedPanelQuotaWindows } from '../../constants' import AccountPlanBadge from '../AccountPlanBadge.vue' import AccountProfileModal from '../AccountProfileModal/index.vue' +import AccountQuotaForecastModal from '../AccountQuotaForecastModal/index.vue' import AccountQuotaPanelEntry from './Entry.vue' import AccountResetCredits from './ResetCredits.vue' @@ -17,6 +18,7 @@ const props = defineProps<{ }>() const emit = defineEmits<{ + accountUpdated: [account: AccountRow] refreshQuota: [accountId: string] quotaReset: [accountId: string] }>() @@ -25,8 +27,10 @@ const quotaEntries = computed(() => groupedAccountQuotaWindows( orderedPanelQuotaWindows(props.account.quota.windows), )) const profileOpen = shallowRef(false) +const forecastOpen = shallowRef(false) +const hasForecast = computed(() => props.account.authenticationKind !== 'api_key') const hasPersonalInfo = computed(() => props.account.capabilities.profile || props.account.capabilities.subscription) -const hasActions = computed(() => hasPersonalInfo.value || props.account.capabilities.quotaRefresh || props.account.capabilities.resetCredits) +const hasActions = computed(() => hasPersonalInfo.value || hasForecast.value || props.account.capabilities.quotaRefresh || props.account.capabilities.resetCredits) watch(hasPersonalInfo, (available) => { if (!available) profileOpen.value = false @@ -69,6 +73,17 @@ watch(hasPersonalInfo, (available) => { :account="account" @consumed="emit('quotaReset', $event)" /> + + + { v-model="profileOpen" :account="account" /> + diff --git a/frontend/src/views/accounts/components/AccountUsagePanel.vue b/frontend/src/views/accounts/components/AccountUsagePanel.vue index 7523a8cd2..25659951a 100644 --- a/frontend/src/views/accounts/components/AccountUsagePanel.vue +++ b/frontend/src/views/accounts/components/AccountUsagePanel.vue @@ -1,21 +1,15 @@