feat(billing): 引入 billing v3 settlement snapshot 及 usage_billing_facts 视图

- 新增迁移 20260424000000:为 usage_settlement_snapshots 添加 settlement_snapshot、
  billing_dimensions、billing_input/output/cache tokens、billing_total_cost_usd 等列,
  并创建 usage_billing_facts 视图(从 settlement snapshot 覆盖原始 usage token/cost 字段)
- event_enrichment:构建结构化 settlement_snapshot(含 pricing_snapshot、billing_plan_snapshot、
  resolved_dimensions、cost_breakdown 等),写入 request_metadata
- pricing:新增 pricing_source() 方法区分 provider_override / global_default / unpriced
- sql.rs:settlement snapshot 写入新列;用量汇总查询改用 usage_billing_facts 视图
- maintenance/runtime:所有统计查询的 FROM usage 替换为 FROM usage_billing_facts
- admin observability:在 settlement 响应中暴露 settlement_snapshot / billing_dimensions,
  并从 metadata 中剥离对应字段
- request_metadata:允许 settlement_snapshot / billing_dimensions 字段传播
- stream execution:在首个数据帧到达时记录 TTFB,补全流式请求的 streaming 事件
This commit is contained in:
fawney19
2026-04-24 17:52:33 +08:00
parent fb46fcd80f
commit f695238e8a
11 changed files with 1970 additions and 197 deletions

View File

@@ -2,13 +2,15 @@ use aether_data_contracts::repository::billing::StoredBillingModelContext;
use aether_data_contracts::DataLayerError;
use aether_usage_runtime::{UsageEvent, UsageEventType};
use async_trait::async_trait;
use serde_json::{Map, Value};
use serde_json::{json, Map, Value};
use crate::{
BillingComputation, BillingModelPricingSnapshot, BillingService, BillingSnapshotStatus,
BillingUsageInput,
};
const SETTLEMENT_SNAPSHOT_SCHEMA_VERSION: &str = "3.0";
#[async_trait]
pub trait BillingModelContextLookup: Send + Sync {
async fn find_billing_model_context_by_model_id(
@@ -65,7 +67,7 @@ pub async fn enrich_usage_event_with_billing(
{
let pricing = map_pricing_context(context);
let computation = calculate_billing_computation(&pricing, event)?;
apply_billing_computation(event, computation)?;
apply_billing_computation(event, &pricing, computation)?;
return Ok(());
}
}
@@ -89,15 +91,15 @@ pub async fn enrich_usage_event_with_billing(
computation.cost_result.status,
BillingSnapshotStatus::NoRule
) {
first_no_rule.get_or_insert(computation);
first_no_rule.get_or_insert((pricing, computation));
continue;
}
apply_billing_computation(event, computation)?;
apply_billing_computation(event, &pricing, computation)?;
return Ok(());
}
if let Some(computation) = first_no_rule {
apply_billing_computation(event, computation)?;
if let Some((pricing, computation)) = first_no_rule {
apply_billing_computation(event, &pricing, computation)?;
}
Ok(())
}
@@ -162,13 +164,16 @@ fn calculate_billing_computation(
fn apply_billing_computation(
event: &mut UsageEvent,
pricing: &BillingModelPricingSnapshot,
computation: BillingComputation,
) -> Result<(), DataLayerError> {
event.data.total_cost_usd = Some(computation.cost_result.cost);
event.data.actual_total_cost_usd = Some(computation.actual_total_cost);
merge_billing_snapshot_metadata(
&mut event.data.request_metadata,
pricing,
&computation.cost_result.snapshot,
computation.actual_total_cost,
computation.rate_multiplier,
computation.is_free_tier,
)
@@ -196,25 +201,83 @@ fn map_pricing_context(context: StoredBillingModelContext) -> BillingModelPricin
fn merge_billing_snapshot_metadata(
request_metadata: &mut Option<Value>,
pricing: &BillingModelPricingSnapshot,
snapshot: &crate::BillingSnapshot,
actual_total_cost: f64,
rate_multiplier: f64,
is_free_tier: bool,
) -> Result<(), DataLayerError> {
let snapshot = serde_json::to_value(snapshot).map_err(|err| {
let billing_snapshot = serde_json::to_value(snapshot).map_err(|err| {
DataLayerError::UnexpectedValue(format!("failed to serialize billing snapshot: {err}"))
})?;
let settlement_snapshot = build_settlement_snapshot(
pricing,
snapshot,
actual_total_cost,
rate_multiplier,
is_free_tier,
);
let mut metadata = match request_metadata.take() {
Some(Value::Object(object)) => object,
_ => Map::new(),
};
metadata.insert("billing_snapshot".to_string(), snapshot);
metadata.insert("billing_snapshot".to_string(), billing_snapshot);
metadata.insert(
"settlement_snapshot_schema_version".to_string(),
Value::from(SETTLEMENT_SNAPSHOT_SCHEMA_VERSION),
);
metadata.insert("settlement_snapshot".to_string(), settlement_snapshot);
metadata.insert(
"billing_dimensions".to_string(),
Value::Object(snapshot.resolved_dimensions.clone().into_iter().collect()),
);
metadata.insert("rate_multiplier".to_string(), Value::from(rate_multiplier));
metadata.insert("is_free_tier".to_string(), Value::from(is_free_tier));
*request_metadata = Some(Value::Object(metadata));
Ok(())
}
fn build_settlement_snapshot(
pricing: &BillingModelPricingSnapshot,
snapshot: &crate::BillingSnapshot,
actual_total_cost: f64,
rate_multiplier: f64,
is_free_tier: bool,
) -> Value {
json!({
"schema_version": SETTLEMENT_SNAPSHOT_SCHEMA_VERSION,
"pricing_snapshot": {
"provider_id": pricing.provider_id.clone(),
"provider_billing_type": pricing.provider_billing_type.clone(),
"provider_api_key_id": pricing.provider_api_key_id.clone(),
"global_model_id": pricing.global_model_id.clone(),
"global_model_name": pricing.global_model_name.clone(),
"model_id": pricing.model_id.clone(),
"provider_model_name": pricing.model_provider_model_name.clone(),
"pricing_source": pricing.pricing_source(),
"tiered_pricing": pricing.effective_tiered_pricing().cloned(),
"price_per_request": pricing.effective_price_per_request(),
"rate_multiplier": rate_multiplier,
"is_free_tier": is_free_tier,
},
"billing_plan_snapshot": {
"rule_id": snapshot.rule_id.clone(),
"rule_name": snapshot.rule_name.clone(),
"scope": snapshot.scope.clone(),
"expression": snapshot.expression.clone(),
"engine_version": snapshot.engine_version.clone(),
},
"resolved_dimensions": snapshot.resolved_dimensions.clone(),
"resolved_variables": snapshot.resolved_variables.clone(),
"cost_breakdown": snapshot.cost_breakdown.clone(),
"total_cost": snapshot.total_cost,
"actual_total_cost": actual_total_cost,
"status": snapshot.status,
"calculated_at": snapshot.calculated_at.clone(),
})
}
#[cfg(test)]
mod tests {
use aether_data_contracts::repository::billing::StoredBillingModelContext;

View File

@@ -33,6 +33,22 @@ impl BillingModelPricingSnapshot {
.or(self.default_price_per_request)
}
pub fn pricing_source(&self) -> &'static str {
if self
.model_tiered_pricing
.as_ref()
.is_some_and(has_tiered_pricing_tiers)
|| self.model_price_per_request.is_some()
{
"provider_override"
} else if self.default_tiered_pricing.is_some() || self.default_price_per_request.is_some()
{
"global_default"
} else {
"unpriced"
}
}
pub fn is_free_tier(&self) -> bool {
self.provider_billing_type
.as_deref()