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 <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-13 00:45:26 +08:00
co-authored by Codex
parent 14e7967e1c
commit b98c2f01a5
18 changed files with 317 additions and 393 deletions
@@ -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<u64>,
pub estimated_usd: Option<f64>,
/// 剩余估算始终属于源窗口,不随目标周期折算。
@@ -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<f64>,
pub observed_at: Option<DateTime<Utc>>,
pub start_at: Option<DateTime<Utc>>,
pub reset_at: DateTime<Utc>,
pub request_count: u64,
pub tokens: Option<u64>,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub cached_tokens: Option<u64>,
pub known_cost_count: u64,
pub partial_cost_count: u64,
pub unavailable_cost_count: u64,
pub usd: Option<f64>,
pub sample_start_at: Option<DateTime<Utc>>,
pub baseline_percent: f64,
pub sampled_percent: Option<f64>,
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;
@@ -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,
@@ -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(&quota(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]
@@ -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);
@@ -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<AccountQuotaForecastReport> 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,
@@ -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<AccountQuotaForecastView>,
}
@@ -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<u64>,
pub estimated_tokens_display: String,
pub estimated_usd: Option<f64>,
@@ -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<f64>,
pub used_percent_display: String,
pub observed_at: Option<String>,
pub observed_at_display: String,
pub start_at: Option<String>,
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<String>,
pub sample_start_at_display: String,
pub baseline_percent: f64,
pub sampled_percent: Option<f64>,
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)]
@@ -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);
@@ -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})"
@@ -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() {
@@ -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 隔离所用的子代理区分值。
@@ -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());
+19 -15
View File
@@ -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` 或
+5 -2
View File
@@ -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 统一监督:
-28
View File
@@ -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[]
}
@@ -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(() => [
</script>
<template>
<section v-if="source" aria-label="容量预测" class="flex flex-col gap-5 rounded-cp-card bg-cp-fill-tertiary/70 p-4 md:justify-between [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<section v-if="source" aria-label="容量预测" class="flex flex-col gap-5 rounded-cp-card bg-cp-fill-tertiary/70 p-4 [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<div>
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-cp-xs text-cp-text-secondary">{{ source.label }}已用</span>
<span
class="inline-flex items-center gap-1 rounded-cp-sm px-2 py-1 text-cp-xs font-emphasis"
:class="forecast.lowSample ? 'bg-cp-warning-container text-cp-warning-on-container' : 'bg-cp-info-container text-cp-info-on-container'"
>
<TriangleAlert v-if="forecast.lowSample" class="size-3" />
<Info v-else class="size-3" />
{{ forecast.lowSample ? '低样本 · 误差较大' : '估算值 · 仅供参考' }}
<span class="inline-flex items-center gap-1 rounded-cp-sm bg-cp-info-container px-2 py-1 text-cp-xs font-emphasis text-cp-info-on-container">
<Info class="size-3" aria-hidden="true" />
{{ forecast.lowSample ? '初步估算 · 仅供参考' : '估算值 · 仅供参考' }}
</span>
</div>
<div class="mt-2 flex items-end justify-between gap-4">
<strong class="font-mono text-3xl font-heavy tabular-nums text-cp-text [html[data-theme=light]_&]:font-emphasis">{{ source.usedPercentDisplay }}</strong>
<span class="pb-1 text-cp-xs text-cp-text-secondary">完整源窗口 <span class="font-mono">100%</span></span>
<span class="pb-1 text-cp-xs text-cp-text-secondary">本周期 <span class="font-mono">100%</span></span>
</div>
<!-- 实心为已观测比例,虚线为待推算容量,不伪造历史趋势或置信区间。 -->
<div
class="relative mt-3 h-3 overflow-hidden rounded-full bg-cp-bg-container"
role="img"
:aria-label="`源窗口已用 ${source.usedPercentDisplay},其余为推算区域`"
:aria-label="`本周期已用 ${source.usedPercentDisplay},其余为推算区域`"
>
<span class="absolute inset-x-0 top-1/2 border-t-2 border-dashed border-cp-primary/50" />
<span class="absolute inset-y-0 left-0 rounded-full bg-cp-primary" :style="{ width: `${source.usedPercent ?? 0}%` }" />
@@ -55,8 +60,8 @@ const metrics = computed(() => [
</div>
<div class="grid gap-2">
<div class="flex justify-between px-3 text-cp-xs text-cp-text-secondary">
<span>采样区间已记录</span>
<div class="flex justify-between text-cp-xs text-cp-text-secondary">
<span>已记录用量</span>
<span>完整{{ forecast.period === 'weekly' ? '周' : '月' }}预测</span>
</div>
<div class="grid gap-2">
@@ -77,21 +82,16 @@ const metrics = computed(() => [
<div class="grid gap-3">
<div class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-2 text-cp-xs">
<span class="text-cp-text-secondary">快照时源窗口剩余</span>
<span class="text-cp-text-secondary">更新时的剩余额度</span>
<span class="font-mono font-emphasis text-cp-text [html[data-theme=light]_&]:font-medium">
{{ forecast.remainingTokensDisplay }} Tokens
<span class="mx-1 text-cp-text-tertiary">/</span>
{{ forecast.remainingUsdDisplay }}
</span>
</div>
<p v-if="forecast.lowSample" class="m-0 text-cp-xs leading-relaxed text-cp-warning-text">
有效额度进度或分段样本较少,估算容易波动。
</p>
<p v-if="forecast.incompleteCost" class="m-0 text-cp-xs leading-relaxed text-cp-warning-text">
美元费用记录不完整,暂不预测等价费用。
</p>
<p v-if="forecast.incompleteTokens" class="m-0 text-cp-xs leading-relaxed text-cp-warning-text">
Token 记录不完整,暂不预测 Token 容量;未知用量不按零消耗估算。
<p v-if="incompleteNotice" class="m-0 flex items-start gap-1.5 text-cp-xs leading-relaxed text-cp-warning-text">
<TriangleAlert class="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
{{ incompleteNotice }}
</p>
</div>
</section>
@@ -1,97 +0,0 @@
<script setup lang="ts">
import type { AccountQuotaForecast } from '@/api'
import { computed } from 'vue'
const props = defineProps<{ source: NonNullable<AccountQuotaForecast['source']>, methodDisplay: string }>()
const costCoverage = computed(() => {
const total = props.source.knownCostCount + props.source.partialCostCount + props.source.unavailableCostCount
return total > 0 ? `${(props.source.knownCostCount / total * 100).toFixed(1)}%` : '—'
})
const tokenRows = computed(() => [
{
label: '输入 Token',
value: props.source.inputTokensDisplay,
tone: 'bg-cp-green-container text-cp-green-on-container [html[data-theme=light]_&]:text-cp-green-text',
markerTone: 'bg-cp-green-solid',
},
{
label: '输出 Token',
value: props.source.outputTokensDisplay,
tone: 'bg-cp-orange-container text-cp-orange-on-container [html[data-theme=light]_&]:text-cp-orange-text',
markerTone: 'bg-cp-orange-solid',
},
{
label: '缓存命中',
value: props.source.cachedTokensDisplay,
tone: 'bg-cp-cyan-container text-cp-cyan-on-container [html[data-theme=light]_&]:text-cp-cyan-text',
markerTone: 'bg-cp-cyan-solid',
},
])
const timeRows = computed(() => [
{ label: '窗口开始', value: props.source.startAtDisplay },
{ label: '采样开始', value: props.source.sampleStartAtDisplay },
{ label: '采样截止 / 快照', value: props.source.observedAtDisplay },
{ label: '额度重置', value: props.source.resetAtDisplay },
])
</script>
<template>
<section aria-label="采样依据" class="flex flex-col gap-5 rounded-cp-card bg-cp-fill-tertiary/70 p-4 md:justify-between [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<div class="grid gap-3">
<div class="grid gap-1.5">
<div class="flex items-baseline justify-between gap-3 text-cp-xs">
<span class="text-cp-text-secondary">采样请求</span>
<strong class="font-mono text-cp-sm font-heavy text-cp-text [html[data-theme=light]_&]:font-emphasis">{{ source.requestCountDisplay }}</strong>
</div>
<div class="flex flex-wrap items-baseline justify-between gap-2 text-cp-xs">
<span class="text-cp-text-secondary">{{ methodDisplay }}</span>
<span class="font-mono text-cp-text">{{ source.sampledPercentDisplay }}</span>
</div>
</div>
<dl class="m-0 grid gap-2">
<div
v-for="row in tokenRows"
:key="row.label"
class="flex items-center justify-between gap-3 rounded-cp px-3 py-2.5 text-cp-xs [html[data-theme=light]_&]:bg-cp-bg-container"
:class="row.tone"
>
<dt class="flex items-center gap-2 [html[data-theme=light]_&]:text-cp-text-secondary">
<span class="hidden size-1.5 shrink-0 rounded-full [html[data-theme=light]_&]:block" :class="row.markerTone" aria-hidden="true" />
{{ row.label }}
</dt>
<dd class="m-0 font-mono font-emphasis tabular-nums">
{{ row.value }}
</dd>
</div>
</dl>
</div>
<div class="grid gap-2">
<div class="flex justify-between gap-3 text-cp-xs">
<span class="text-cp-text-secondary">美元费用覆盖</span>
<strong class="font-mono font-emphasis text-cp-text">{{ costCoverage }}</strong>
</div>
<div class="h-1 overflow-hidden rounded-full bg-cp-fill-secondary" role="img" :aria-label="`费用记录覆盖 ${costCoverage}`">
<div class="h-full rounded-full bg-cp-success" :style="{ width: costCoverage === '—' ? '0%' : costCoverage }" />
</div>
<p class="m-0 text-cp-xs text-cp-text-tertiary">
完整 {{ source.knownCostCountDisplay }} 次 · 部分 {{ source.partialCostCountDisplay }} 次 · 缺失 {{ source.unavailableCostCountDisplay }} 次
</p>
<p v-if="source.blockCount > 0" class="m-0 text-cp-xs text-cp-text-tertiary">
{{ source.blockCount }} 个有效进度段 · 历史读数不等于独立样本
</p>
<p v-if="source.missingTokenCount > 0 || source.excludedRequestCount > 0 || source.pendingRequestCount > 0" class="m-0 text-cp-xs leading-relaxed text-cp-warning-text">
Token 缺失 {{ source.missingTokenCount }} 次 · 未纳入 {{ source.excludedRequestCount }} 次 · 快照时未完成 {{ source.pendingRequestCount }} 次
</p>
</div>
<dl class="m-0 grid gap-3 text-cp-xs">
<div v-for="row in timeRows" :key="row.label" class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<dt class="text-cp-text-secondary">
{{ row.label }}
</dt>
<dd class="m-0 font-mono text-cp-text">
{{ row.value }}
</dd>
</div>
</dl>
</section>
</template>
@@ -5,7 +5,7 @@ import BaseSkeleton from '@/components/base/BaseSkeleton.vue'
<template>
<div role="status" aria-busy="true">
<span class="sr-only">正在加载额度预测</span>
<div class="grid items-start gap-4 md:grid-cols-[1.15fr_1fr] md:items-stretch" aria-hidden="true">
<div aria-hidden="true">
<div class="flex min-h-96 flex-col justify-between gap-5 rounded-cp-card bg-cp-fill-tertiary/70 p-4 [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<div>
<div class="flex items-center justify-between gap-3">
@@ -20,7 +20,7 @@ import BaseSkeleton from '@/components/base/BaseSkeleton.vue'
</div>
<div class="grid gap-2">
<div class="mb-1 flex justify-between gap-3 px-3">
<div class="mb-1 flex justify-between gap-3">
<BaseSkeleton shape="text" class="w-20" />
<BaseSkeleton shape="text" class="w-20" />
</div>
@@ -39,42 +39,6 @@ import BaseSkeleton from '@/components/base/BaseSkeleton.vue'
<BaseSkeleton shape="text" class="w-32" />
</div>
</div>
<div class="flex min-h-96 flex-col justify-between gap-5 rounded-cp-card bg-cp-fill-tertiary/70 p-4 [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<div class="grid gap-3">
<div class="grid gap-1.5">
<div v-for="row in 2" :key="row" class="flex justify-between gap-3">
<BaseSkeleton shape="text" class="w-20" />
<BaseSkeleton shape="text" :class="row === 1 ? 'w-8' : 'w-24'" />
</div>
</div>
<div class="grid gap-2">
<div v-for="row in 3" :key="row" class="flex h-9.5 items-center justify-between gap-3 rounded-cp bg-cp-bg-container px-3">
<div class="flex items-center gap-2">
<BaseSkeleton shape="circle" class="size-1.5" />
<BaseSkeleton shape="text" class="w-16" />
</div>
<BaseSkeleton shape="text" class="w-12" />
</div>
</div>
</div>
<div class="grid gap-2">
<div class="flex justify-between gap-3">
<BaseSkeleton shape="text" class="w-24" />
<BaseSkeleton shape="text" class="w-12" />
</div>
<BaseSkeleton class="h-1 w-full rounded-full" />
<BaseSkeleton shape="text" class="w-3/4" />
</div>
<div class="grid gap-3">
<div v-for="row in 4" :key="row" class="flex justify-between gap-3">
<BaseSkeleton shape="text" class="w-20" />
<BaseSkeleton shape="text" class="w-40 max-w-3/5" />
</div>
</div>
</div>
</div>
</div>
</template>
@@ -13,7 +13,6 @@ import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import { useAccountQuotaForecast } from '../../composables/useAccountQuotaForecast'
import AccountIdentityCell from '../AccountIdentityCell.vue'
import ForecastCapacity from './ForecastCapacity.vue'
import ForecastSample from './ForecastSample.vue'
import ForecastSkeleton from './Skeleton.vue'
const props = defineProps<{ account: AccountRow }>()
@@ -65,7 +64,7 @@ function handleExplanationKeydown(event: KeyboardEvent) {
v-model="open"
title="额度预测"
description="按当前用量结构,估算完整周期的容量"
size="xl"
size="md"
tone="info"
>
<template #icon>
@@ -93,13 +92,12 @@ function handleExplanationKeydown(event: KeyboardEvent) {
</template>
</BaseEmpty>
</div>
<div v-else-if="forecast" class="grid items-start gap-4 md:items-stretch" :class="forecast.source ? 'md:grid-cols-[1.15fr_1fr]' : ''">
<template v-else-if="forecast">
<div v-if="unavailableReason" class="grid rounded-cp-card bg-cp-fill-tertiary/70 [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<BaseEmpty title="暂时无法预测" :description="unavailableReason" :icon="ChartNoAxesCombined" surface="none" class="min-h-72 content-center" />
</div>
<ForecastCapacity v-else :forecast="forecast" />
<ForecastSample v-if="forecast.source" :source="forecast.source" :method-display="forecast.methodDisplay" />
</div>
</template>
</div>
</div>
@@ -121,40 +119,25 @@ function handleExplanationKeydown(event: KeyboardEvent) {
</BaseButton>
</template>
<section :id="explanationId" role="tooltip" class="grid w-96 max-w-[calc(100vw-2rem)] gap-3 p-4 text-cp-xs leading-relaxed text-cp-text-secondary">
<div class="grid gap-1">
<h4 class="m-0 font-heavy text-cp-text">
计算方式
</h4>
<p class="m-0">
样本用量 ÷(有效额度进度 / 100)<span v-if="forecast?.extrapolated"> × {{ forecast.targetDays }}/{{ forecast.source?.windowDays }}</span>。
</p>
<p class="m-0">
优先使用最近 3 个完整进度段及尾部,每段至少 5 个百分点;不足时仅在本周期记录可用的情况下使用累计估算。
</p>
<p class="m-0">
历史额度以请求完成时间近似对齐,不能证明扣额同步。覆盖率表示记录完整度,不是预测准确率。
</p>
</div>
<div v-if="forecast?.extrapolated" class="grid gap-1">
<h4 class="m-0 font-heavy text-cp-text">
跨周期折算
</h4>
<p class="m-0">
不代表自然月额度或可一次用完的余额;剩余估算仍属于源窗口。
</p>
</div>
<div class="grid gap-1">
<h4 class="m-0 font-heavy text-cp-text">
仅供参考
</h4>
<p class="m-0">
非官方承诺额度。站外使用、日志清理、观测延迟及模型组合变化均会影响结果;等价费用不是订阅价格或账户余额。
</p>
</div>
<h4 class="m-0 font-heavy text-cp-text">
仅供参考
</h4>
<p class="m-0">
根据已记录用量估算,结果会随使用的模型和方式变化,并非官方承诺额度。
</p>
<p v-if="forecast?.lowSample" class="m-0">
目前数据还较少,结果可能有较大波动。
</p>
<p v-if="forecast?.extrapolated" class="m-0">
本页预测为折算值,剩余量仍按{{ forecast.source?.label ?? '当前周期' }}计算。
</p>
<p class="m-0">
剩余量以数据更新时间为准;等价费用不是实际账单或账户余额。
</p>
</section>
</BasePopover>
<span v-if="report" class="hidden text-cp-xs text-cp-text-tertiary sm:block">
查询于 {{ report.generatedAtDisplay }}
<span v-if="forecast?.source?.observedAt" class="hidden text-cp-xs text-cp-text-tertiary sm:block">
更新于 {{ forecast.source.observedAtDisplay }}
</span>
</div>
<BaseButton variant="secondary" @click="open = false">