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(() => [ -