From b98c2f01a572da42531b34b4bc57299caabf702e Mon Sep 17 00:00:00 2001 From: Zyy Date: Sun, 13 Sep 2026 00:45:26 +0800 Subject: [PATCH] fix(accounts): fix quota forecasts and simplify capacity view Exclude confirmed OpenAI prewarm requests from completed usage while preserving audit records and unknown inference pricing. Keep only the quota capacity card, trim unused Admin/API diagnostics, and use the actual quota observation time without changing forecast gates. Reduce warning noise and align capacity headings with the cards. Co-authored-by: Codex --- .../gateway-admin/src/model/quota_forecast.rs | 44 +---- .../src/model/quota_forecast_sampling.rs | 10 -- .../tests/model/quota_forecast.rs | 8 +- .../gateway-admin/tests/use_case/accounts.rs | 6 +- .../src/admin/accounts/presenter.rs | 38 ----- .../gateway-api/src/admin/accounts/wire.rs | 28 --- .../tests/admin/accounts/presenter.rs | 45 ++--- .../gateway-store/src/postgres/usage_facts.rs | 4 + .../provider_accounts/quota_forecast.rs | 160 ++++++++++++++++++ .../src/transport/protocol/responses.rs | 12 +- .../openai/tests/provider/contract.rs | 50 ++++++ docs/api.md | 34 ++-- docs/architecture.md | 7 +- frontend/src/api/modules/accounts.ts | 28 --- .../ForecastCapacity.vue | 42 ++--- .../ForecastSample.vue | 97 ----------- .../AccountQuotaForecastModal/Skeleton.vue | 40 +---- .../AccountQuotaForecastModal/index.vue | 57 +++---- 18 files changed, 317 insertions(+), 393 deletions(-) delete mode 100644 frontend/src/views/accounts/components/AccountQuotaForecastModal/ForecastSample.vue diff --git a/backend/crates/gateway-admin/src/model/quota_forecast.rs b/backend/crates/gateway-admin/src/model/quota_forecast.rs index 35b6bf5c3..a7bbaa784 100644 --- a/backend/crates/gateway-admin/src/model/quota_forecast.rs +++ b/backend/crates/gateway-admin/src/model/quota_forecast.rs @@ -26,7 +26,6 @@ pub struct AccountQuotaForecast { pub low_sample: bool, pub incomplete_cost: bool, pub incomplete_tokens: bool, - pub method: QuotaForecastMethod, pub estimated_tokens: Option, pub estimated_usd: Option, /// 剩余估算始终属于源窗口,不随目标周期折算。 @@ -36,30 +35,12 @@ pub struct AccountQuotaForecast { #[derive(Debug, Clone, PartialEq)] pub struct QuotaForecastSource { - pub key: String, pub label: String, - pub window_seconds: u64, pub used_percent: Option, pub observed_at: Option>, - pub start_at: Option>, pub reset_at: DateTime, - pub request_count: u64, pub tokens: Option, - pub input_tokens: Option, - pub output_tokens: Option, - pub cached_tokens: Option, - pub known_cost_count: u64, - pub partial_cost_count: u64, - pub unavailable_cost_count: u64, pub usd: Option, - pub sample_start_at: Option>, - pub baseline_percent: f64, - pub sampled_percent: Option, - pub block_count: usize, - pub observation_count: usize, - pub missing_token_count: u64, - pub excluded_request_count: u64, - pub pending_request_count: u64, } /// 优先预测真实的对应窗口;缺少对应周期时只给出明确标识的 7/30 天容量折算。 @@ -88,7 +69,6 @@ pub fn account_quota_forecasts( low_sample: false, incomplete_cost: false, incomplete_tokens: false, - method: QuotaForecastMethod::Cumulative, estimated_tokens: None, estimated_usd: None, remaining_tokens: None, @@ -138,30 +118,12 @@ impl AccountQuotaForecast { .and_then(Duration::try_seconds) .and_then(|duration| reset_at.checked_sub_signed(duration)); self.source = Some(QuotaForecastSource { - key: window.key.clone(), label: window.label.clone(), - window_seconds: seconds, used_percent: percent, observed_at, - start_at: start, reset_at, - request_count: usage.map_or(0, |usage| usage.request_count), tokens: usage.map(|usage| usage.tokens), - input_tokens: usage.map(|usage| usage.input_tokens), - output_tokens: usage.map(|usage| usage.output_tokens), - cached_tokens: usage.map(|usage| usage.cached_tokens), - known_cost_count: usage.map_or(0, |usage| usage.known_cost_count), - partial_cost_count: 0, - unavailable_cost_count: usage.map_or(0, |usage| usage.unavailable_cost_count), usd, - sample_start_at: sample.map(|sample| sample.start_at), - baseline_percent: sample.map_or(0.0, |sample| sample.baseline_percent), - sampled_percent: sample.map(|sample| sample.sampled_percent), - block_count: sample.map_or(0, |sample| sample.block_count), - observation_count: sample.map_or(0, |sample| sample.observation_count), - missing_token_count: usage.map_or(0, |usage| usage.missing_token_count), - excluded_request_count: usage.map_or(0, |usage| usage.excluded_request_count), - pending_request_count: sample.map_or(0, |sample| sample.pending_request_count), }); self.incomplete_cost = usage.is_none_or(|usage| { usage.unavailable_cost_count > 0 @@ -169,7 +131,7 @@ impl AccountQuotaForecast { || usage.known_cost_count != usage.request_count }); self.incomplete_tokens = usage.is_some_and(|usage| usage.missing_token_count > 0); - self.method = sample.map_or(QuotaForecastMethod::Cumulative, |sample| sample.method); + let method = sample.map_or(QuotaForecastMethod::Cumulative, |sample| sample.method); let Some(start) = start.filter(|start| *start <= now && now < reset_at) else { self.unavailable_reason = Some("额度窗口已过期或边界无效,请刷新账号额度后重试。"); return; @@ -183,7 +145,7 @@ impl AccountQuotaForecast { Some("额度观测出现回落或累计记录不连续,正在重新积累配对样本。"); return; } - if account_added_at > start && self.method == QuotaForecastMethod::Cumulative { + if account_added_at > start && method == QuotaForecastMethod::Cumulative { self.unavailable_reason = Some( "本周期开始时的记录不完整,正在积累至少 5 个百分点的配对观测,无需等待下次重置。", ); @@ -210,7 +172,7 @@ impl AccountQuotaForecast { return; }; self.low_sample = sample.sampled_percent < LOW_SAMPLE_PERCENT - || (self.method == QuotaForecastMethod::Incremental && sample.block_count < 2); + || (method == QuotaForecastMethod::Incremental && sample.block_count < 2); // 预测是近似展示值;不复用为账单金额,也不把月折算当成自然月或额外余额。 let capacity_factor = 100.0 / sample.sampled_percent; let factor = capacity_factor * self.target_seconds as f64 / seconds as f64; diff --git a/backend/crates/gateway-admin/src/model/quota_forecast_sampling.rs b/backend/crates/gateway-admin/src/model/quota_forecast_sampling.rs index 1a72e438b..3ea574bde 100644 --- a/backend/crates/gateway-admin/src/model/quota_forecast_sampling.rs +++ b/backend/crates/gateway-admin/src/model/quota_forecast_sampling.rs @@ -89,16 +89,6 @@ pub enum QuotaForecastMethod { Incremental, } -impl QuotaForecastMethod { - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Cumulative => "cumulative", - Self::Incremental => "incremental", - } - } -} - #[derive(Debug, Clone)] pub struct QuotaForecastSample { pub key: String, diff --git a/backend/crates/gateway-admin/tests/model/quota_forecast.rs b/backend/crates/gateway-admin/tests/model/quota_forecast.rs index 13aacb8c1..f6f1f99f2 100644 --- a/backend/crates/gateway-admin/tests/model/quota_forecast.rs +++ b/backend/crates/gateway-admin/tests/model/quota_forecast.rs @@ -129,8 +129,8 @@ fn forecasts_use_actual_periods_and_do_not_extrapolate_remaining_capacity() { #[test] fn corresponding_actual_window_takes_priority_and_retains_its_duration() { let [week, month] = forecast("a(vec![window("month", 31), window("week", 7)])); - assert_eq!(week.source.unwrap().key, "week"); - assert_eq!(month.source.unwrap().key, "month"); + assert_eq!(week.source.unwrap().label, "week"); + assert_eq!(month.source.unwrap().label, "month"); assert!(!month.extrapolated); assert_eq!(month.target_seconds, 31 * 86_400); assert_eq!(month.estimated_tokens, Some(5_000)); @@ -241,7 +241,7 @@ fn incremental_sample_supports_mid_cycle_accounts_and_remaining_uses_current_per assert_eq!(week.estimated_tokens, Some(10_000)); assert_eq!(week.remaining_tokens, Some(8_000)); assert_eq!(month.remaining_tokens, Some(8_000)); - assert_eq!(week.method, QuotaForecastMethod::Incremental); + assert!(week.unavailable_reason.is_none()); assert!(!week.low_sample); } @@ -255,7 +255,7 @@ fn missing_tokens_suppress_token_estimates_without_discarding_complete_costs() { assert_eq!(week.estimated_tokens, None); assert_eq!(week.remaining_tokens, None); assert_eq!(week.estimated_usd, Some(10.0)); - assert_eq!(week.source.unwrap().missing_token_count, 1); + assert_eq!(week.source.unwrap().tokens, Some(1_000)); } #[test] diff --git a/backend/crates/gateway-admin/tests/use_case/accounts.rs b/backend/crates/gateway-admin/tests/use_case/accounts.rs index b0b9c0c6a..46b3f3b17 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts.rs @@ -1825,9 +1825,7 @@ async fn quota_forecast_does_not_query_usage_without_a_current_snapshot() { #[tokio::test] async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_changed_bucket() { - use gateway_admin::model::quota_forecast_sampling::{ - QuotaForecastHistoryPoint, QuotaForecastMethod, - }; + use gateway_admin::model::quota_forecast_sampling::QuotaForecastHistoryPoint; let now = Utc::now(); let observed = now - TimeDelta::minutes(1); let reset = now + TimeDelta::days(1); @@ -1884,7 +1882,7 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_ .quota_forecast(&ProviderAccountId::new("acct_test").unwrap()) .await .unwrap(); - assert_eq!(result.forecasts[0].method, QuotaForecastMethod::Incremental); + assert!(result.forecasts[0].unavailable_reason.is_none()); assert_eq!(result.forecasts[0].estimated_tokens, Some(5_000)); assert_eq!(result.forecasts[0].remaining_tokens, Some(3_000)); assert_eq!(store.quota_window_queries()[0].range.start, added); diff --git a/backend/crates/gateway-api/src/admin/accounts/presenter.rs b/backend/crates/gateway-api/src/admin/accounts/presenter.rs index 4768d3b11..ccd867481 100644 --- a/backend/crates/gateway-api/src/admin/accounts/presenter.rs +++ b/backend/crates/gateway-api/src/admin/accounts/presenter.rs @@ -2,7 +2,6 @@ use super::*; use gateway_admin::model::quota_forecast::{AccountQuotaForecast, AccountQuotaForecastReport}; -use gateway_admin::model::quota_forecast_sampling::QuotaForecastMethod; pub(super) fn account_page_data( result: AccountDirectoryPage, @@ -129,7 +128,6 @@ impl From for AccountQuotaForecastData { Self { account_id: report.account_id, generated_at: china_rfc3339(&report.generated_at), - generated_at_display: china_datetime(&report.generated_at), forecasts: report .forecasts .into_iter() @@ -148,9 +146,7 @@ fn quota_forecast_view(forecast: AccountQuotaForecast) -> AccountQuotaForecastVi target_days: forecast.target_seconds as f64 / 86_400.0, extrapolated: forecast.extrapolated, source: forecast.source.map(|source| QuotaForecastSourceView { - key: source.key, label: source.label, - window_days: source.window_seconds as f64 / 86_400.0, used_percent: source.used_percent, used_percent_display: source .used_percent @@ -159,48 +155,14 @@ fn quota_forecast_view(forecast: AccountQuotaForecast) -> AccountQuotaForecastVi observed_at_display: source .observed_at .map_or_else(|| "—".to_owned(), |value| china_datetime(&value)), - start_at: source.start_at.map(|value| china_rfc3339(&value)), - start_at_display: source - .start_at - .map_or_else(|| "—".to_owned(), |value| china_datetime(&value)), reset_at: china_rfc3339(&source.reset_at), - reset_at_display: china_datetime(&source.reset_at), - request_count_display: format_number(source.request_count), tokens_display: display_optional_tokens(source.tokens), - input_tokens_display: display_optional_tokens(source.input_tokens), - output_tokens_display: display_optional_tokens(source.output_tokens), - cached_tokens_display: display_optional_tokens(source.cached_tokens), - known_cost_count: source.known_cost_count, - known_cost_count_display: format_number(source.known_cost_count), - partial_cost_count: source.partial_cost_count, - partial_cost_count_display: format_number(source.partial_cost_count), - unavailable_cost_count: source.unavailable_cost_count, - unavailable_cost_count_display: format_number(source.unavailable_cost_count), usd_display: forecast_usd_display(source.usd), - sample_start_at: source.sample_start_at.map(|value| china_rfc3339(&value)), - sample_start_at_display: source - .sample_start_at - .map_or_else(|| "—".to_owned(), |value| china_datetime(&value)), - baseline_percent: source.baseline_percent, - sampled_percent: source.sampled_percent, - sampled_percent_display: source - .sampled_percent - .map_or_else(|| "—".to_owned(), |value| format!("{value:.1} 个百分点")), - block_count: source.block_count, - observation_count: source.observation_count, - missing_token_count: source.missing_token_count, - excluded_request_count: source.excluded_request_count, - pending_request_count: source.pending_request_count, }), unavailable_reason: forecast.unavailable_reason, low_sample: forecast.low_sample, incomplete_cost: forecast.incomplete_cost, incomplete_tokens: forecast.incomplete_tokens, - method: forecast.method.as_str(), - method_display: match forecast.method { - QuotaForecastMethod::Cumulative => "窗口累计估算", - QuotaForecastMethod::Incremental => "近期分段估算", - }, estimated_tokens: forecast.estimated_tokens, estimated_tokens_display: display_optional_tokens(forecast.estimated_tokens), estimated_usd: forecast.estimated_usd, diff --git a/backend/crates/gateway-api/src/admin/accounts/wire.rs b/backend/crates/gateway-api/src/admin/accounts/wire.rs index 52f42b95a..f9ad8171e 100644 --- a/backend/crates/gateway-api/src/admin/accounts/wire.rs +++ b/backend/crates/gateway-api/src/admin/accounts/wire.rs @@ -278,7 +278,6 @@ pub struct AccountView { pub struct AccountQuotaForecastData { pub account_id: String, pub generated_at: String, - pub generated_at_display: String, pub forecasts: Vec, } @@ -293,8 +292,6 @@ pub struct AccountQuotaForecastView { pub low_sample: bool, pub incomplete_cost: bool, pub incomplete_tokens: bool, - pub method: &'static str, - pub method_display: &'static str, pub estimated_tokens: Option, pub estimated_tokens_display: String, pub estimated_usd: Option, @@ -308,39 +305,14 @@ pub struct AccountQuotaForecastView { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct QuotaForecastSourceView { - pub key: String, pub label: String, - pub window_days: f64, pub used_percent: Option, pub used_percent_display: String, pub observed_at: Option, pub observed_at_display: String, - pub start_at: Option, - pub start_at_display: String, pub reset_at: String, - pub reset_at_display: String, - pub request_count_display: String, pub tokens_display: String, - pub input_tokens_display: String, - pub output_tokens_display: String, - pub cached_tokens_display: String, - pub known_cost_count: u64, - pub known_cost_count_display: String, - pub partial_cost_count: u64, - pub partial_cost_count_display: String, - pub unavailable_cost_count: u64, - pub unavailable_cost_count_display: String, pub usd_display: String, - pub sample_start_at: Option, - pub sample_start_at_display: String, - pub baseline_percent: f64, - pub sampled_percent: Option, - pub sampled_percent_display: String, - pub block_count: usize, - pub observation_count: usize, - pub missing_token_count: u64, - pub excluded_request_count: u64, - pub pending_request_count: u64, } #[derive(Debug, Clone, Serialize)] diff --git a/backend/crates/gateway-api/tests/admin/accounts/presenter.rs b/backend/crates/gateway-api/tests/admin/accounts/presenter.rs index ab94624a5..555495d64 100644 --- a/backend/crates/gateway-api/tests/admin/accounts/presenter.rs +++ b/backend/crates/gateway-api/tests/admin/accounts/presenter.rs @@ -1,48 +1,28 @@ use gateway_admin::model::{ provider_credentials::AccountUsagePeriod, quota_forecast::{AccountQuotaForecast, AccountQuotaForecastReport, QuotaForecastSource}, - quota_forecast_sampling::QuotaForecastMethod, }; use gateway_api::admin::accounts::AccountQuotaForecastData; #[test] -fn quota_forecast_projection_preserves_null_zero_and_sample_metadata() { +fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() { let now = "2026-09-12T00:00:00Z".parse().unwrap(); let forecast = AccountQuotaForecast { period: AccountUsagePeriod::Weekly, target_seconds: 7 * 86_400, extrapolated: false, source: Some(QuotaForecastSource { - key: "week".to_owned(), label: "周额度".to_owned(), - window_seconds: 7 * 86_400, used_percent: Some(100.0), observed_at: Some(now), - start_at: Some(now), reset_at: now, - request_count: 1_024, tokens: Some(1_000_000), - input_tokens: Some(900_000), - output_tokens: Some(100_000), - cached_tokens: Some(800_000), - known_cost_count: 1_000, - partial_cost_count: 24, - unavailable_cost_count: 0, usd: Some(0.1234), - sample_start_at: Some(now), - baseline_percent: 85.0, - sampled_percent: Some(15.0), - block_count: 3, - observation_count: 96, - missing_token_count: 0, - excluded_request_count: 4, - pending_request_count: 2, }), unavailable_reason: None, low_sample: false, incomplete_cost: true, incomplete_tokens: false, - method: QuotaForecastMethod::Incremental, estimated_tokens: Some(1_000_000), estimated_usd: None, remaining_tokens: Some(0), @@ -66,13 +46,22 @@ fn quota_forecast_projection_preserves_null_zero_and_sample_metadata() { assert!(week["estimatedUsd"].is_null()); assert_eq!(week["estimatedUsdDisplay"], "—"); assert_eq!(week["remainingTokensDisplay"], "0"); - assert_eq!(week["source"]["requestCountDisplay"], "1,024"); - assert_eq!(week["source"]["usdDisplay"], "$0.1234"); - assert_eq!(week["source"]["partialCostCount"], 24); - assert_eq!(week["method"], "incremental"); - assert_eq!(week["source"]["sampledPercentDisplay"], "15.0 个百分点"); - assert_eq!(week["source"]["pendingRequestCount"], 2); - assert!(week["source"].get("providerData").is_none()); + assert_eq!( + week["source"], + serde_json::json!({ + "label": "周额度", + "usedPercent": 100.0, + "usedPercentDisplay": "100.0%", + "observedAt": "2026-09-12T08:00:00+08:00", + "observedAtDisplay": "2026-09-12 08:00:00", + "resetAt": "2026-09-12T08:00:00+08:00", + "tokensDisplay": "1M", + "usdDisplay": "$0.1234" + }) + ); + assert!(week.get("method").is_none()); + assert!(week.get("methodDisplay").is_none()); + assert!(value.get("generatedAtDisplay").is_none()); assert_eq!(value["forecasts"][1]["period"], "monthly"); assert_eq!(value["forecasts"][1]["targetDays"], 30.0); assert_eq!(value["forecasts"][1]["extrapolated"], true); diff --git a/backend/crates/gateway-store/src/postgres/usage_facts.rs b/backend/crates/gateway-store/src/postgres/usage_facts.rs index bfffed633..3176493f4 100644 --- a/backend/crates/gateway-store/src/postgres/usage_facts.rs +++ b/backend/crates/gateway-store/src/postgres/usage_facts.rs @@ -11,11 +11,15 @@ use sqlx::{Postgres, QueryBuilder}; /// 因此不属于用量记录。图片端点由请求意图证明其用量语义,不依赖文本模型字段。 /// 客户端 WebSocket 的每个 `response.create` 没有独立 HTTP 状态码;成功终态与下游 /// 提交边界已足以证明交付,连接握手的 101 不能冒充单次请求状态。 +/// OpenAI Provider 将 `generate=false` 归类为 `prewarm`:它只准备连接与上下文, +/// 即使响应带有模型和输入 Token,也不是推理用量;原始审计和费用事实仍保留。 pub(crate) fn completed_usage_fact_predicate(alias: &str) -> String { let evidence = usage_evidence_predicate(alias); format!( "{alias}.outcome = 'succeeded' and {alias}.downstream_committed_at is not null + and ({alias}.provider_kind is distinct from 'openai' + or {alias}.request_kind is distinct from 'prewarm') and (({alias}.client_transport = 'websocket' and {alias}.client_status_code is null) or {alias}.client_status_code between 200 and 399) and ({evidence})" diff --git a/backend/crates/gateway-store/tests/postgres/provider_accounts/quota_forecast.rs b/backend/crates/gateway-store/tests/postgres/provider_accounts/quota_forecast.rs index 4d41c7ba8..33620582f 100644 --- a/backend/crates/gateway-store/tests/postgres/provider_accounts/quota_forecast.rs +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts/quota_forecast.rs @@ -1,5 +1,165 @@ use super::*; use gateway_admin::model::quota_forecast_sampling::MAX_FORECAST_HISTORY_POINTS; +use gateway_store::postgres::{ + ObservabilityPageSize, ObservabilityRange, ObservabilityRepository, ProviderAccountUsageQuery, + UsageRecordFilter, UsageRecordQuery, +}; + +#[tokio::test] +async fn quota_forecast_excludes_openai_prewarm_but_preserves_inference_and_audit() { + let Some(database) = TestDatabase::create("forecast_prewarm").await else { + return; + }; + PgProviderAccountRepository::new(database.pool.clone()) + .insert_provider_account(account("acct_prewarm", "user-prewarm")) + .await + .unwrap(); + let start = Utc::now() - TimeDelta::minutes(30); + let end = start + TimeDelta::minutes(20); + for (id, minute, tokens) in [("normal", 1, 100), ("review", 2, 200), ("prewarm", 3, 400)] { + // 普通成功推理也可以只有输入 Token,不能用 output_tokens = 0 判定预热。 + seed_model_request( + &database.pool, + ModelRequestSeed { + request_id: id, + account_id: "acct_prewarm", + provider_kind: "openai", + model: "gpt-test", + total_tokens: tokens, + cost_amount: "1", + started_at: start + TimeDelta::minutes(minute), + }, + ) + .await + .unwrap(); + } + sqlx::query("update model_requests set request_kind = 'review' where id = 'review'") + .execute(&database.pool) + .await + .unwrap(); + sqlx::query( + "update model_requests set request_kind = 'prewarm', client_transport = 'websocket', + client_status_code = null, upstream_transport = 'websocket', service_tier = 'auto', + cost_source = 'unavailable', cost_amount = null, cost_currency = null, + provider_observation_json = '{\"upstreamServiceTier\":\"auto\"}'::jsonb + where id = 'prewarm'", + ) + .execute(&database.pool) + .await + .unwrap(); + let store = admin_account_store(&database.pool); + let query = AccountUsageWindowQuery { + account_id: "acct_prewarm".to_owned(), + key: "week".to_owned(), + range: TimeRange { start, end }, + }; + let history = store.load_quota_forecast_history(&query).await.unwrap(); + assert_eq!(history.usage.request_count, 2); + assert_eq!(history.usage.tokens, 300); + assert_eq!(history.usage.missing_token_count, 0); + assert_eq!(history.usage.known_cost_count, 2); + assert_eq!(history.usage.unavailable_cost_count, 0); + assert_eq!(history.usage.usd, 2.0); + assert_eq!(history.usage.excluded_request_count, 1); + assert_eq!(history.pending_request_count, 0); + // 预热响应携带的额度观测仍可配对,但它不增加累计推理用量。 + assert_eq!(history.points.len(), 1); + assert_eq!(history.points[0].usage, history.usage); + + let repository = super::super::observability_repository(&database.pool); + let range = ObservabilityRange::new(start, end).unwrap(); + let account_usage = repository + .provider_account_usage( + ProviderAccountUsageQuery::for_accounts(range, vec!["acct_prewarm".to_owned()]) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(account_usage[0].request_count, 2); + assert_eq!(account_usage[0].cost_coverage.unavailable_count, 0); + let records = repository + .list_usage_records(UsageRecordQuery { + range, + filter: UsageRecordFilter::default(), + current_page: 1, + page_size: ObservabilityPageSize::new(10).unwrap(), + }) + .await + .unwrap(); + assert_eq!(records.total, 2); + assert!(records.items.iter().all(|record| record.id != "prewarm")); + let detail = repository.usage_record_detail("prewarm").await.unwrap(); + assert_eq!(detail.request.request_kind.as_deref(), Some("prewarm")); + assert_eq!(detail.request.service_tier.as_deref(), Some("auto")); + let dashboard = repository.dashboard_summary(range, end).await.unwrap(); + assert_eq!(dashboard.totals.request_count, 3); + assert_eq!(dashboard.totals.total_tokens, 300); + + // 真正推理的未知费用仍必须阻止费用外推,不能靠放宽覆盖门槛掩盖缺失。 + sqlx::query( + "update model_requests set cost_source = 'unavailable', cost_amount = null, + cost_currency = null, service_tier = 'auto' where id = 'review'", + ) + .execute(&database.pool) + .await + .unwrap(); + let history = store.load_quota_forecast_history(&query).await.unwrap(); + assert_eq!(history.usage.request_count, 2); + assert_eq!(history.usage.known_cost_count, 1); + assert_eq!(history.usage.unavailable_cost_count, 1); + assert_eq!(history.usage.usd, 1.0); + assert_eq!(history.usage.excluded_request_count, 1); + database.close().await; +} + +#[tokio::test] +async fn quota_forecast_does_not_apply_openai_prewarm_semantics_to_other_providers() { + let Some(database) = TestDatabase::create("forecast_xai_kind").await else { + return; + }; + let mut candidate = account("acct_xai_kind", "user-xai-kind"); + candidate.provider_kind = "xai".to_owned(); + PgProviderAccountRepository::new(database.pool.clone()) + .insert_provider_account(candidate) + .await + .unwrap(); + let start = Utc::now() - TimeDelta::minutes(30); + seed_model_request( + &database.pool, + ModelRequestSeed { + request_id: "xai_kind", + account_id: "acct_xai_kind", + provider_kind: "xai", + model: "grok-test", + total_tokens: 100, + cost_amount: "1", + started_at: start, + }, + ) + .await + .unwrap(); + sqlx::query("update model_requests set request_kind = 'prewarm' where id = 'xai_kind'") + .execute(&database.pool) + .await + .unwrap(); + let history = admin_account_store(&database.pool) + .load_quota_forecast_history(&AccountUsageWindowQuery { + account_id: "acct_xai_kind".to_owned(), + key: "week".to_owned(), + range: TimeRange { + start, + end: start + TimeDelta::minutes(10), + }, + }) + .await + .unwrap(); + assert_eq!(history.usage.request_count, 1); + assert_eq!(history.usage.tokens, 100); + assert_eq!(history.usage.known_cost_count, 1); + assert_eq!(history.usage.usd, 1.0); + assert_eq!(history.usage.excluded_request_count, 0); + database.close().await; +} #[tokio::test] async fn quota_forecast_pairs_completed_usage_and_preserves_missing_pending_and_excluded_counts() { diff --git a/backend/crates/providers/openai/src/transport/protocol/responses.rs b/backend/crates/providers/openai/src/transport/protocol/responses.rs index 5e65dfe04..f7107d0e8 100644 --- a/backend/crates/providers/openai/src/transport/protocol/responses.rs +++ b/backend/crates/providers/openai/src/transport/protocol/responses.rs @@ -644,10 +644,18 @@ impl CodexResponsesRequest { /// 提取 Codex 请求类型、子代理类型、推理预设与压缩语义。 pub fn semantics(&self) -> CodexRequestSemantics { - codex_responses_request_semantics_with_turn_metadata( + let mut semantics = codex_responses_request_semantics_with_turn_metadata( self.body(), self.turn_metadata.as_deref(), - ) + ); + // 官方 generate=false 只准备连接与上下文,不是模型推理。 + // 预热分类参与用量筛选,不能仅凭客户端的 request_kind 提示排除真实推理。 + if !self.generate() { + semantics.request_kind = Some("prewarm".to_owned()); + } else if semantics.request_kind.as_deref() == Some("prewarm") { + semantics.request_kind = None; + } + semantics } /// 返回请求语义与 child transport 隔离所用的子代理区分值。 diff --git a/backend/crates/providers/openai/tests/provider/contract.rs b/backend/crates/providers/openai/tests/provider/contract.rs index c7531a38f..117429193 100644 --- a/backend/crates/providers/openai/tests/provider/contract.rs +++ b/backend/crates/providers/openai/tests/provider/contract.rs @@ -6113,6 +6113,56 @@ fn request_observation_reads_openai_metadata_without_changing_the_operation() { assert!(matches!(operation, Operation::Generate(_))); } +#[test] +fn request_observation_classifies_prewarm_from_generate_without_rewriting_the_payload() { + let store = Arc::new(MemoryAccountStore::default()); + let provider = provider(&store); + let client_key_id = ClientApiKeyId::new("key_prewarm_observation").expect("client key"); + for (generate, request_kind, expected_kind) in [ + (Some(json!(false)), None, Some("prewarm")), + (Some(json!(false)), Some("prewarm"), Some("prewarm")), + (Some(json!(false)), Some("review"), Some("prewarm")), + (Some(json!(true)), Some("prewarm"), None), + (None, Some("prewarm"), None), + (Some(json!(null)), Some("prewarm"), None), + (Some(json!("false")), Some("prewarm"), None), + (None, Some("review"), Some("review")), + (None, None, None), + ] { + let mut body = Map::from_iter([ + ("model".to_owned(), json!("gpt-test")), + ("input".to_owned(), json!("hello")), + ("store".to_owned(), json!(false)), + ]); + if let Some(generate) = generate { + body.insert("generate".to_owned(), generate); + } + let payload = ProtocolPayload::json_object("openai", body.clone()) + .expect("OpenAI payload") + .with_context(Map::from_iter([( + "turn_metadata".to_owned(), + Value::String( + json!({"request_kind": request_kind, "subagent_kind": "worker"}).to_string(), + ), + )])); + let operation = Operation::Generate(GenerateRequest::from_protocol_payload(payload)); + + let observation = provider.request_observation(&operation, &client_key_id); + + assert_eq!( + observation.request_kind.as_deref(), + expected_kind, + "generate={:?}, request_kind={request_kind:?}", + body.get("generate"), + ); + assert_eq!(observation.subagent_kind.as_deref(), Some("worker")); + let Operation::Generate(generation) = operation else { + unreachable!(); + }; + assert_eq!(generation.protocol_payload().body(), &body); + } +} + #[test] fn endpoint_observation_should_read_models_without_rewriting_or_requiring_a_catalog() { let store = Arc::new(MemoryAccountStore::default()); diff --git a/docs/api.md b/docs/api.md index 1e99edec7..fce1ba5f6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -464,7 +464,7 @@ OAuth start 使用: 不轮询。关闭弹窗取消未完成的查询,重新打开重新采样。“刷新额度”仍调用现有 `POST /api/admin/accounts/quota/refresh`,成功后替换账号行并重新查询预测。 -响应 `data` 包含 `accountId`、`generatedAt`、`generatedAtDisplay` 和 `forecasts`(`weekly`、`monthly`): +响应 `data` 包含 `accountId`、`generatedAt` 和 `forecasts`(`weekly`、`monthly`): - `targetDays`、`extrapolated`:对应周期存在真实账号级窗口时采用实际时长;缺少对应窗口时,使用 可统计的周/月窗口按 7/30 天折算,明确标记 `extrapolated: true`。不把短期限流或模型专属桶当作账号容量。 @@ -472,22 +472,23 @@ OAuth start 使用: 公式为 `样本用量 × 100 / sampledPercent × 目标窗口秒数 / 源窗口秒数`。 - `remainingTokens` / `remainingUsd` 及对应 `*Display`:**额度快照时源窗口**的剩余估算, 公式为 `样本用量 × (100 - usedPercent) / sampledPercent`;不随目标周期折算,不代表当前可消费余额。 -- `method` / `methodDisplay`:`incremental` 为近期分段估算,`cumulative` 为窗口累计估算。 - 同一额度段内,以历史额度和截至相应完成时间的累计用量建立基线,每累计至少 5 个百分点形成一段, - 使用最近 3 个完整段及未满一段的尾部。只对合并区间计算比值,不平均逐请求小分母比值。 - 重复读数不增加段数;大于 1 个百分点的回落或累计计数倒退会中断采样,不能静默跨越。 -- `source`:源窗口标识、实际天数、原始已用比例、窗口开始 / 额度快照 / 重置时间, - **选中采样区间**的请求数、总 Token、输入 / 输出 / 缓存命中构成、USD 费用与费用记录数。 - `sampleStartAt` / `sampleStartAtDisplay` 为实际采样开始,`baselinePercent` 为基线已用比例, - `sampledPercent` / `sampledPercentDisplay` 为有效额度进度(百分点,而非时间进度)。 - `blockCount` 是采用的完整进度段数,`observationCount` 是可供配对的历史点数,二者均不是独立统计样本数。 - `missingTokenCount` 为不能由 total 或完整 input/output 确定 Token 的请求数; - `excludedRequestCount` 为选中区间内未进入成功用量口径的已结束请求数; - `pendingRequestCount` 为查询范围内在额度快照时尚未结束的请求数。 - 缓存命中属于输入,不重复相加。费用覆盖针对有效 USD 记录;其他币种或缺少有效金额均不能当成零 USD。 +- `source`:只返回容量卡所需的源窗口名称 `label`、已用比例 `usedPercent` / `usedPercentDisplay`、 + 额度观测时间 `observedAt` / `observedAtDisplay`、用于过期检查的 `resetAt`,以及选中采样区间 + 已记录的 `tokensDisplay` / `usdDisplay`。`source: null` 表示没有可选的源窗口。 + 不再返回请求数、Token 构成、费用覆盖计数、采样方法、基线与进度段等右侧明细, + Admin 结果模型也不再复制这些诊断字段;计算与完整性检查所需的内部采样事实不变。 - `unavailableReason`:不能估算时的说明,正常为 `null`。有效进度少于 5 个百分点不预测; `lowSample` 表示有效进度不足 10 个百分点,或增量采样少于 2 个完整段,仅是质量提示,不承诺精度。 `incompleteCost` 仅抑制费用预测;`incompleteTokens` 仅抑制 Token 预测。未知值不按确定的零消耗外推。 + 弹窗仅显示容量卡,将低样本展示为“初步估算”,以简短说明提示估算限制;底部的更新时间取 + `source.observedAtDisplay`,不能用查询生成时间冒充额度观测时间。只有实际阻止 Token 或费用 + 预测的数据缺失展示一条合并提示,不改变预测门槛。 + +内部采样优先采用近期分段,条件不足时才使用窗口累计估算。同一额度段内,以历史额度和截至相应 +完成时间的累计用量建立基线,每累计至少 5 个百分点形成一段,使用最近 3 个完整段及未满一段的尾部。 +上式中的 `sampledPercent` 是内部有效额度进度(百分点),不是时间进度或对外响应字段。 +只对合并区间计算比值,不平均逐请求小分母比值;重复读数不增加段数,大于 1 个百分点的回落或累计 +计数倒退会中断采样,不能静默跨越。缓存命中属于输入,不重复相加;其他币种或缺少有效金额不能当成零 USD。 采样查询 `[max(resetAt - windowSeconds, accountAddedAt), observedAt)` 内开始的请求, 仅把 `completedAt <= observedAt` 的完整交付用量计入当前分子;历史分子按各点的完成时间累计, @@ -789,7 +790,10 @@ request/response/upstream ID、outcome 与搜索文本。诊断 `dimension` 可 鉴权、解析、路由和准入等入口拒绝不属于该范围;请求观测仍是可能延迟或丢弃的异步投影。 汇总与洞察中的请求数与 outcome 分布覆盖筛选范围内全部请求;token、缓存、延迟与成本聚合仅统计 -已完整交付客户端的成功响应。 +已完整交付客户端的成功推理响应。OpenAI 的 `generate: false` 连接与上下文准备记录归类为 +`requestKind: "prewarm"`,不进入用量列表、账号用量或额度预测的 Token / 费用覆盖统计,但仍可按 +请求 ID 读取审计详情,响应中的额度观测仍可用于预测配对。此分类以实际 `generate` 字段为准, +不能仅凭客户端的同名 metadata 或输出 Token 为零排除普通推理;其他 Provider 不套用该规则。 详情接口按 `id` 可读取成功、失败或未完成请求。新增 `trace`(历史未采集记录为 `null`)和 `relatedRequests[]`(`requestId / relation / outcome / completedAt`);`relation` 为 `recovered_by` 或 diff --git a/docs/architecture.md b/docs/architecture.md index 7706555c8..3ab39f71b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -418,8 +418,11 @@ observation、调用 metadata;最终失败的关联头不与 opening 身份混 鉴权、解析、路由或准入等尚未进入执行会话的入口拒绝不纳入此请求记录边界;异步投影也仍可能延迟或 丢弃。排障需结合入口日志,不能将“无记录”解释成没有发生错误。 -只有完整交付客户端的成功响应进入 Token、延迟和成本聚合。实际 `service_tier` 只接受上游响应事件确认, -不能用请求期望值替代。 +只有完整交付客户端的成功推理响应进入 Token、延迟和成本聚合。OpenAI Provider 根据请求的 +`generate: false` 将连接与上下文准备归类为 `prewarm`,不信任客户端单独声明的同名 metadata。 +Store 的共享用量口径排除这些预热记录,账号用量与额度预测复用同一规则;原始请求审计、响应额度 +观测和费用事实仍保留,不将未知费用改写为零,也不影响 Client Key 结算账本。 +实际 `service_tier` 只接受上游响应事件确认,不能用请求期望值替代。 Worker 由各 Bundle 贡献、由 Host 统一监督: diff --git a/frontend/src/api/modules/accounts.ts b/frontend/src/api/modules/accounts.ts index d1a7c046b..32a819d28 100644 --- a/frontend/src/api/modules/accounts.ts +++ b/frontend/src/api/modules/accounts.ts @@ -149,46 +149,19 @@ export interface AccountQuotaForecast { targetDays: number extrapolated: boolean source: { - key: string label: string - windowDays: number usedPercent: number | null usedPercentDisplay: string observedAt: string | null observedAtDisplay: string - startAt: string | null - startAtDisplay: string resetAt: string - resetAtDisplay: string - requestCountDisplay: string tokensDisplay: string - inputTokensDisplay: string - outputTokensDisplay: string - cachedTokensDisplay: string - knownCostCount: number - knownCostCountDisplay: string - partialCostCount: number - partialCostCountDisplay: string - unavailableCostCount: number - unavailableCostCountDisplay: string usdDisplay: string - sampleStartAt: string | null - sampleStartAtDisplay: string - baselinePercent: number - sampledPercent: number | null - sampledPercentDisplay: string - blockCount: number - observationCount: number - missingTokenCount: number - excludedRequestCount: number - pendingRequestCount: number } | null unavailableReason: string | null lowSample: boolean incompleteCost: boolean incompleteTokens: boolean - method: 'cumulative' | 'incremental' - methodDisplay: string estimatedTokens: number | null estimatedTokensDisplay: string estimatedUsd: number | null @@ -202,7 +175,6 @@ export interface AccountQuotaForecast { export interface AccountQuotaForecastResponse { accountId: string generatedAt: string - generatedAtDisplay: string forecasts: AccountQuotaForecast[] } diff --git a/frontend/src/views/accounts/components/AccountQuotaForecastModal/ForecastCapacity.vue b/frontend/src/views/accounts/components/AccountQuotaForecastModal/ForecastCapacity.vue index b5e682438..a3476284c 100644 --- a/frontend/src/views/accounts/components/AccountQuotaForecastModal/ForecastCapacity.vue +++ b/frontend/src/views/accounts/components/AccountQuotaForecastModal/ForecastCapacity.vue @@ -5,6 +5,15 @@ import { computed } from 'vue' const props = defineProps<{ forecast: AccountQuotaForecast }>() const source = computed(() => props.forecast.source) +const incompleteNotice = computed(() => { + if (props.forecast.incompleteTokens && props.forecast.incompleteCost) + return '用量和费用数据不完整,暂时无法预测。' + if (props.forecast.incompleteTokens) + return '用量数据不完整,暂时无法预测 Token 容量。' + if (props.forecast.incompleteCost) + return '费用数据不完整,暂时无法估算金额。' + return null +}) const metrics = computed(() => [ { label: 'Token 容量', @@ -22,29 +31,25 @@ const metrics = computed(() => [ -