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

@@ -299,6 +299,9 @@ fn admin_usage_strip_settlement_metadata(metadata: &mut serde_json::Map<String,
metadata.remove("billing_snapshot");
metadata.remove("billing_snapshot_schema_version");
metadata.remove("billing_snapshot_status");
metadata.remove("settlement_snapshot");
metadata.remove("settlement_snapshot_schema_version");
metadata.remove("billing_dimensions");
metadata.remove("rate_multiplier");
metadata.remove("is_free_tier");
metadata.remove("input_price_per_1m");
@@ -353,6 +356,36 @@ fn admin_usage_body_capture_json(item: &StoredRequestUsageAudit) -> Value {
fn admin_usage_settlement_json(item: &StoredRequestUsageAudit) -> Value {
let mut settlement = serde_json::Map::new();
if let Some(snapshot) = item
.request_metadata
.as_ref()
.and_then(Value::as_object)
.and_then(|metadata| metadata.get("settlement_snapshot"))
.cloned()
{
settlement.insert("settlement_snapshot".to_string(), snapshot);
}
if let Some(schema_version) = item
.request_metadata
.as_ref()
.and_then(Value::as_object)
.and_then(|metadata| metadata.get("settlement_snapshot_schema_version"))
.and_then(Value::as_str)
{
settlement.insert(
"settlement_snapshot_schema_version".to_string(),
json!(schema_version),
);
}
if let Some(dimensions) = item
.request_metadata
.as_ref()
.and_then(Value::as_object)
.and_then(|metadata| metadata.get("billing_dimensions"))
.cloned()
{
settlement.insert("billing_dimensions".to_string(), dimensions);
}
if let Some(snapshot) = item
.request_metadata
.as_ref()
@@ -2339,6 +2372,17 @@ mod tests {
"cache_creation_price_per_1m": 3.75,
"cache_read_price_per_1m": 0.30,
"price_per_request": 0.02,
"settlement_snapshot_schema_version": "3.0",
"billing_dimensions": {
"input_tokens": 35,
"total_input_context": 42
},
"settlement_snapshot": {
"schema_version": "3.0",
"pricing_snapshot": {
"pricing_source": "provider_override"
}
},
"billing_snapshot": {
"resolved_variables": {
"output_price_per_1m": 11.0
@@ -2369,6 +2413,18 @@ mod tests {
payload["settlement"]["billing_snapshot"]["resolved_variables"]["output_price_per_1m"],
11.0
);
assert_eq!(
payload["settlement"]["settlement_snapshot_schema_version"],
"3.0"
);
assert_eq!(
payload["settlement"]["settlement_snapshot"]["pricing_snapshot"]["pricing_source"],
"provider_override"
);
assert_eq!(
payload["settlement"]["billing_dimensions"]["input_tokens"],
35
);
assert_eq!(payload["settlement"]["billing_snapshot_status"], "resolved");
assert_eq!(payload["settlement"]["rate_multiplier"], 0.5);
assert_eq!(payload["settlement"]["is_free_tier"], false);
@@ -2381,6 +2437,9 @@ mod tests {
assert!(payload["metadata"]["billing_snapshot"].is_null());
assert!(payload["metadata"]["billing_snapshot_schema_version"].is_null());
assert!(payload["metadata"]["billing_snapshot_status"].is_null());
assert!(payload["metadata"]["settlement_snapshot"].is_null());
assert!(payload["metadata"]["settlement_snapshot_schema_version"].is_null());
assert!(payload["metadata"]["billing_dimensions"].is_null());
assert!(payload["metadata"]["rate_multiplier"].is_null());
assert!(payload["metadata"]["is_free_tier"].is_null());
assert!(payload["metadata"]["input_price_per_1m"].is_null());

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

View File

@@ -4711,7 +4711,268 @@ ALTER TABLE IF EXISTS public.usage_settlement_snapshots
ADD COLUMN IF NOT EXISTS output_price_per_1m numeric(20,8),
ADD COLUMN IF NOT EXISTS cache_creation_price_per_1m numeric(20,8),
ADD COLUMN IF NOT EXISTS cache_read_price_per_1m numeric(20,8),
ADD COLUMN IF NOT EXISTS price_per_request numeric(20,8);
ADD COLUMN IF NOT EXISTS price_per_request numeric(20,8),
ADD COLUMN IF NOT EXISTS settlement_snapshot_schema_version character varying(20),
ADD COLUMN IF NOT EXISTS settlement_snapshot jsonb,
ADD COLUMN IF NOT EXISTS billing_dimensions jsonb,
ADD COLUMN IF NOT EXISTS billing_input_tokens bigint,
ADD COLUMN IF NOT EXISTS billing_effective_input_tokens bigint,
ADD COLUMN IF NOT EXISTS billing_output_tokens bigint,
ADD COLUMN IF NOT EXISTS billing_cache_creation_tokens bigint,
ADD COLUMN IF NOT EXISTS billing_cache_creation_5m_tokens bigint,
ADD COLUMN IF NOT EXISTS billing_cache_creation_1h_tokens bigint,
ADD COLUMN IF NOT EXISTS billing_cache_read_tokens bigint,
ADD COLUMN IF NOT EXISTS billing_total_input_context bigint,
ADD COLUMN IF NOT EXISTS billing_cache_creation_cost_usd numeric(20,8),
ADD COLUMN IF NOT EXISTS billing_cache_read_cost_usd numeric(20,8),
ADD COLUMN IF NOT EXISTS billing_total_cost_usd numeric(20,8),
ADD COLUMN IF NOT EXISTS billing_actual_total_cost_usd numeric(20,8),
ADD COLUMN IF NOT EXISTS billing_pricing_source character varying(50),
ADD COLUMN IF NOT EXISTS billing_rule_id character varying(100),
ADD COLUMN IF NOT EXISTS billing_rule_version character varying(50);
CREATE INDEX IF NOT EXISTS ix_usage_settlement_snapshots_schema_version
ON public.usage_settlement_snapshots USING btree (settlement_snapshot_schema_version);
CREATE INDEX IF NOT EXISTS ix_usage_settlement_snapshots_pricing_source
ON public.usage_settlement_snapshots USING btree (billing_pricing_source);
CREATE OR REPLACE VIEW public.usage_billing_facts AS
SELECT
usage_rows.id,
usage_rows.request_id,
usage_rows.user_id,
usage_rows.api_key_id,
usage_rows.username,
usage_rows.api_key_name,
usage_rows.provider_name,
usage_rows.model,
usage_rows.target_model,
usage_rows.provider_id,
usage_rows.provider_endpoint_id,
usage_rows.provider_api_key_id,
usage_rows.request_type,
usage_rows.api_format,
usage_rows.api_family,
usage_rows.endpoint_kind,
usage_rows.endpoint_api_format,
usage_rows.provider_api_family,
usage_rows.provider_endpoint_kind,
COALESCE(usage_rows.has_format_conversion, FALSE) AS has_format_conversion,
COALESCE(usage_rows.is_stream, FALSE) AS is_stream,
usage_rows.status_code,
usage_rows.error_message,
usage_rows.error_category,
usage_rows.response_time_ms,
usage_rows.first_byte_time_ms,
usage_rows.status,
COALESCE(settlement.billing_status, usage_rows.billing_status) AS billing_status,
usage_rows.created_at,
COALESCE(settlement.finalized_at, usage_rows.finalized_at) AS finalized_at,
GREATEST(COALESCE(settlement.billing_input_tokens, usage_rows.input_tokens, 0), 0)::bigint
AS input_tokens,
GREATEST(
COALESCE(
settlement.billing_effective_input_tokens,
CASE
WHEN GREATEST(COALESCE(usage_rows.input_tokens, 0), 0) <= 0 THEN 0
WHEN GREATEST(COALESCE(usage_rows.cache_read_input_tokens, 0), 0) <= 0
THEN GREATEST(COALESCE(usage_rows.input_tokens, 0), 0)
WHEN split_part(lower(COALESCE(COALESCE(usage_rows.endpoint_api_format, usage_rows.api_format), '')), ':', 1)
IN ('openai', 'gemini', 'google')
THEN GREATEST(
GREATEST(COALESCE(usage_rows.input_tokens, 0), 0)
- GREATEST(COALESCE(usage_rows.cache_read_input_tokens, 0), 0),
0
)
ELSE GREATEST(COALESCE(usage_rows.input_tokens, 0), 0)
END
),
0
)::bigint AS effective_input_tokens,
GREATEST(COALESCE(settlement.billing_output_tokens, usage_rows.output_tokens, 0), 0)::bigint
AS output_tokens,
GREATEST(
COALESCE(
settlement.billing_cache_creation_tokens,
CASE
WHEN COALESCE(usage_rows.cache_creation_input_tokens, 0) = 0
AND (
COALESCE(usage_rows.cache_creation_input_tokens_5m, 0)
+ COALESCE(usage_rows.cache_creation_input_tokens_1h, 0)
) > 0
THEN COALESCE(usage_rows.cache_creation_input_tokens_5m, 0)
+ COALESCE(usage_rows.cache_creation_input_tokens_1h, 0)
ELSE COALESCE(usage_rows.cache_creation_input_tokens, 0)
END,
0
),
0
)::bigint AS cache_creation_input_tokens,
GREATEST(
COALESCE(
settlement.billing_cache_creation_5m_tokens,
usage_rows.cache_creation_input_tokens_5m,
0
),
0
)::bigint AS cache_creation_input_tokens_5m,
GREATEST(
COALESCE(
settlement.billing_cache_creation_1h_tokens,
usage_rows.cache_creation_input_tokens_1h,
0
),
0
)::bigint AS cache_creation_input_tokens_1h,
GREATEST(COALESCE(settlement.billing_cache_read_tokens, usage_rows.cache_read_input_tokens, 0), 0)::bigint
AS cache_read_input_tokens,
GREATEST(
COALESCE(
CASE
WHEN settlement.billing_input_tokens IS NOT NULL
OR settlement.billing_output_tokens IS NOT NULL
OR settlement.billing_cache_creation_tokens IS NOT NULL
OR settlement.billing_cache_creation_5m_tokens IS NOT NULL
OR settlement.billing_cache_creation_1h_tokens IS NOT NULL
OR settlement.billing_cache_read_tokens IS NOT NULL
THEN COALESCE(settlement.billing_input_tokens, 0)
+ COALESCE(settlement.billing_output_tokens, 0)
+ COALESCE(
settlement.billing_cache_creation_tokens,
COALESCE(settlement.billing_cache_creation_5m_tokens, 0)
+ COALESCE(settlement.billing_cache_creation_1h_tokens, 0),
0
)
+ COALESCE(settlement.billing_cache_read_tokens, 0)
END,
usage_rows.total_tokens,
0
),
0
)::bigint AS total_tokens,
GREATEST(
COALESCE(
settlement.billing_total_input_context,
CASE
WHEN split_part(lower(COALESCE(COALESCE(usage_rows.endpoint_api_format, usage_rows.api_format), '')), ':', 1)
IN ('claude', 'anthropic')
THEN GREATEST(COALESCE(usage_rows.input_tokens, 0), 0)
+ CASE
WHEN COALESCE(usage_rows.cache_creation_input_tokens, 0) = 0
AND (
COALESCE(usage_rows.cache_creation_input_tokens_5m, 0)
+ COALESCE(usage_rows.cache_creation_input_tokens_1h, 0)
) > 0
THEN COALESCE(usage_rows.cache_creation_input_tokens_5m, 0)
+ COALESCE(usage_rows.cache_creation_input_tokens_1h, 0)
ELSE COALESCE(usage_rows.cache_creation_input_tokens, 0)
END
+ GREATEST(COALESCE(usage_rows.cache_read_input_tokens, 0), 0)
WHEN split_part(lower(COALESCE(COALESCE(usage_rows.endpoint_api_format, usage_rows.api_format), '')), ':', 1)
IN ('openai', 'gemini', 'google')
THEN CASE
WHEN GREATEST(COALESCE(usage_rows.input_tokens, 0), 0) <= 0 THEN 0
WHEN GREATEST(COALESCE(usage_rows.cache_read_input_tokens, 0), 0) <= 0
THEN GREATEST(COALESCE(usage_rows.input_tokens, 0), 0)
ELSE GREATEST(
GREATEST(COALESCE(usage_rows.input_tokens, 0), 0)
- GREATEST(COALESCE(usage_rows.cache_read_input_tokens, 0), 0),
0
)
END
+ GREATEST(COALESCE(usage_rows.cache_read_input_tokens, 0), 0)
ELSE GREATEST(COALESCE(usage_rows.input_tokens, 0), 0)
+ CASE
WHEN COALESCE(usage_rows.cache_creation_input_tokens, 0) = 0
AND (
COALESCE(usage_rows.cache_creation_input_tokens_5m, 0)
+ COALESCE(usage_rows.cache_creation_input_tokens_1h, 0)
) > 0
THEN COALESCE(usage_rows.cache_creation_input_tokens_5m, 0)
+ COALESCE(usage_rows.cache_creation_input_tokens_1h, 0)
ELSE COALESCE(usage_rows.cache_creation_input_tokens, 0)
END
+ GREATEST(COALESCE(usage_rows.cache_read_input_tokens, 0), 0)
END,
0
),
0
)::bigint AS total_input_context,
COALESCE(CAST(usage_rows.input_cost_usd AS DOUBLE PRECISION), 0) AS input_cost_usd,
COALESCE(CAST(usage_rows.output_cost_usd AS DOUBLE PRECISION), 0) AS output_cost_usd,
COALESCE(
CAST(settlement.billing_cache_creation_cost_usd AS DOUBLE PRECISION),
CAST(usage_rows.cache_creation_cost_usd AS DOUBLE PRECISION),
0
) AS cache_creation_cost_usd,
COALESCE(
CAST(settlement.billing_cache_read_cost_usd AS DOUBLE PRECISION),
CAST(usage_rows.cache_read_cost_usd AS DOUBLE PRECISION),
0
) AS cache_read_cost_usd,
COALESCE(
CAST(settlement.billing_total_cost_usd AS DOUBLE PRECISION),
CAST(usage_rows.total_cost_usd AS DOUBLE PRECISION),
0
) AS total_cost_usd,
COALESCE(
CAST(settlement.billing_actual_total_cost_usd AS DOUBLE PRECISION),
CAST(usage_rows.actual_total_cost_usd AS DOUBLE PRECISION),
0
) AS actual_total_cost_usd,
COALESCE(
CAST(settlement.output_price_per_1m AS DOUBLE PRECISION),
CAST(usage_rows.output_price_per_1m AS DOUBLE PRECISION)
) AS output_price_per_1m,
COALESCE(
CAST(settlement.input_price_per_1m AS DOUBLE PRECISION),
CAST(usage_rows.input_price_per_1m AS DOUBLE PRECISION)
) AS input_price_per_1m,
COALESCE(
CAST(settlement.cache_creation_price_per_1m AS DOUBLE PRECISION),
CAST(usage_rows.cache_creation_price_per_1m AS DOUBLE PRECISION)
) AS cache_creation_price_per_1m,
COALESCE(
CAST(settlement.cache_read_price_per_1m AS DOUBLE PRECISION),
CAST(usage_rows.cache_read_price_per_1m AS DOUBLE PRECISION)
) AS cache_read_price_per_1m,
COALESCE(
CAST(settlement.price_per_request AS DOUBLE PRECISION),
CAST(usage_rows.price_per_request AS DOUBLE PRECISION)
) AS price_per_request,
settlement.billing_pricing_source,
settlement.billing_rule_id,
settlement.billing_rule_version
FROM public."usage" AS usage_rows
LEFT JOIN public.usage_settlement_snapshots AS settlement
ON settlement.request_id = usage_rows.request_id;
COMMENT ON VIEW public.usage_billing_facts IS
'Canonical billing read model. Token/cost fields prefer usage_settlement_snapshots.billing_* and fall back to deprecated usage mirrors for legacy rows.';
COMMENT ON COLUMN public.usage.input_tokens IS
'DEPRECATED: billing dimension mirror. Use public.usage_settlement_snapshots.billing_input_tokens or public.usage_billing_facts.input_tokens.';
COMMENT ON COLUMN public.usage.output_tokens IS
'DEPRECATED: billing dimension mirror. Use public.usage_settlement_snapshots.billing_output_tokens or public.usage_billing_facts.output_tokens.';
COMMENT ON COLUMN public.usage.total_tokens IS
'DEPRECATED: billing dimension mirror. Use public.usage_billing_facts.total_tokens.';
COMMENT ON COLUMN public.usage.cache_creation_input_tokens IS
'DEPRECATED: billing dimension mirror. Use public.usage_settlement_snapshots.billing_cache_creation_tokens or public.usage_billing_facts.cache_creation_input_tokens.';
COMMENT ON COLUMN public.usage.cache_creation_input_tokens_5m IS
'DEPRECATED: billing dimension mirror. Use public.usage_settlement_snapshots.billing_cache_creation_5m_tokens or public.usage_billing_facts.cache_creation_input_tokens_5m.';
COMMENT ON COLUMN public.usage.cache_creation_input_tokens_1h IS
'DEPRECATED: billing dimension mirror. Use public.usage_settlement_snapshots.billing_cache_creation_1h_tokens or public.usage_billing_facts.cache_creation_input_tokens_1h.';
COMMENT ON COLUMN public.usage.cache_read_input_tokens IS
'DEPRECATED: billing dimension mirror. Use public.usage_settlement_snapshots.billing_cache_read_tokens or public.usage_billing_facts.cache_read_input_tokens.';
COMMENT ON COLUMN public.usage.cache_creation_cost_usd IS
'DEPRECATED: billing cost mirror. Use public.usage_settlement_snapshots.billing_cache_creation_cost_usd or public.usage_billing_facts.cache_creation_cost_usd.';
COMMENT ON COLUMN public.usage.cache_read_cost_usd IS
'DEPRECATED: billing cost mirror. Use public.usage_settlement_snapshots.billing_cache_read_cost_usd or public.usage_billing_facts.cache_read_cost_usd.';
COMMENT ON COLUMN public.usage.total_cost_usd IS
'DEPRECATED: billing cost mirror. Use public.usage_settlement_snapshots.billing_total_cost_usd or public.usage_billing_facts.total_cost_usd.';
COMMENT ON COLUMN public.usage.actual_total_cost_usd IS
'DEPRECATED: billing cost mirror. Use public.usage_settlement_snapshots.billing_actual_total_cost_usd or public.usage_billing_facts.actual_total_cost_usd.';
COMMENT ON COLUMN public.usage.wallet_id IS
'DEPRECATED: settlement owner moved to public.usage_settlement_snapshots.wallet_id. Legacy compatibility only; do not write new values.';

View File

@@ -0,0 +1,262 @@
ALTER TABLE IF EXISTS public.usage_settlement_snapshots
ADD COLUMN IF NOT EXISTS settlement_snapshot_schema_version character varying(20),
ADD COLUMN IF NOT EXISTS settlement_snapshot jsonb,
ADD COLUMN IF NOT EXISTS billing_dimensions jsonb,
ADD COLUMN IF NOT EXISTS billing_input_tokens bigint,
ADD COLUMN IF NOT EXISTS billing_effective_input_tokens bigint,
ADD COLUMN IF NOT EXISTS billing_output_tokens bigint,
ADD COLUMN IF NOT EXISTS billing_cache_creation_tokens bigint,
ADD COLUMN IF NOT EXISTS billing_cache_creation_5m_tokens bigint,
ADD COLUMN IF NOT EXISTS billing_cache_creation_1h_tokens bigint,
ADD COLUMN IF NOT EXISTS billing_cache_read_tokens bigint,
ADD COLUMN IF NOT EXISTS billing_total_input_context bigint,
ADD COLUMN IF NOT EXISTS billing_cache_creation_cost_usd numeric(20,8),
ADD COLUMN IF NOT EXISTS billing_cache_read_cost_usd numeric(20,8),
ADD COLUMN IF NOT EXISTS billing_total_cost_usd numeric(20,8),
ADD COLUMN IF NOT EXISTS billing_actual_total_cost_usd numeric(20,8),
ADD COLUMN IF NOT EXISTS billing_pricing_source character varying(50),
ADD COLUMN IF NOT EXISTS billing_rule_id character varying(100),
ADD COLUMN IF NOT EXISTS billing_rule_version character varying(50);
CREATE INDEX IF NOT EXISTS ix_usage_settlement_snapshots_schema_version
ON public.usage_settlement_snapshots USING btree (settlement_snapshot_schema_version);
CREATE INDEX IF NOT EXISTS ix_usage_settlement_snapshots_pricing_source
ON public.usage_settlement_snapshots USING btree (billing_pricing_source);
CREATE OR REPLACE VIEW public.usage_billing_facts AS
SELECT
usage_rows.id,
usage_rows.request_id,
usage_rows.user_id,
usage_rows.api_key_id,
usage_rows.username,
usage_rows.api_key_name,
usage_rows.provider_name,
usage_rows.model,
usage_rows.target_model,
usage_rows.provider_id,
usage_rows.provider_endpoint_id,
usage_rows.provider_api_key_id,
usage_rows.request_type,
usage_rows.api_format,
usage_rows.api_family,
usage_rows.endpoint_kind,
usage_rows.endpoint_api_format,
usage_rows.provider_api_family,
usage_rows.provider_endpoint_kind,
COALESCE(usage_rows.has_format_conversion, FALSE) AS has_format_conversion,
COALESCE(usage_rows.is_stream, FALSE) AS is_stream,
usage_rows.status_code,
usage_rows.error_message,
usage_rows.error_category,
usage_rows.response_time_ms,
usage_rows.first_byte_time_ms,
usage_rows.status,
COALESCE(settlement.billing_status, usage_rows.billing_status) AS billing_status,
usage_rows.created_at,
COALESCE(settlement.finalized_at, usage_rows.finalized_at) AS finalized_at,
GREATEST(COALESCE(settlement.billing_input_tokens, usage_rows.input_tokens, 0), 0)::bigint
AS input_tokens,
GREATEST(
COALESCE(
settlement.billing_effective_input_tokens,
CASE
WHEN GREATEST(COALESCE(usage_rows.input_tokens, 0), 0) <= 0 THEN 0
WHEN GREATEST(COALESCE(usage_rows.cache_read_input_tokens, 0), 0) <= 0
THEN GREATEST(COALESCE(usage_rows.input_tokens, 0), 0)
WHEN split_part(lower(COALESCE(COALESCE(usage_rows.endpoint_api_format, usage_rows.api_format), '')), ':', 1)
IN ('openai', 'gemini', 'google')
THEN GREATEST(
GREATEST(COALESCE(usage_rows.input_tokens, 0), 0)
- GREATEST(COALESCE(usage_rows.cache_read_input_tokens, 0), 0),
0
)
ELSE GREATEST(COALESCE(usage_rows.input_tokens, 0), 0)
END
),
0
)::bigint AS effective_input_tokens,
GREATEST(COALESCE(settlement.billing_output_tokens, usage_rows.output_tokens, 0), 0)::bigint
AS output_tokens,
GREATEST(
COALESCE(
settlement.billing_cache_creation_tokens,
CASE
WHEN COALESCE(usage_rows.cache_creation_input_tokens, 0) = 0
AND (
COALESCE(usage_rows.cache_creation_input_tokens_5m, 0)
+ COALESCE(usage_rows.cache_creation_input_tokens_1h, 0)
) > 0
THEN COALESCE(usage_rows.cache_creation_input_tokens_5m, 0)
+ COALESCE(usage_rows.cache_creation_input_tokens_1h, 0)
ELSE COALESCE(usage_rows.cache_creation_input_tokens, 0)
END,
0
),
0
)::bigint AS cache_creation_input_tokens,
GREATEST(
COALESCE(
settlement.billing_cache_creation_5m_tokens,
usage_rows.cache_creation_input_tokens_5m,
0
),
0
)::bigint AS cache_creation_input_tokens_5m,
GREATEST(
COALESCE(
settlement.billing_cache_creation_1h_tokens,
usage_rows.cache_creation_input_tokens_1h,
0
),
0
)::bigint AS cache_creation_input_tokens_1h,
GREATEST(COALESCE(settlement.billing_cache_read_tokens, usage_rows.cache_read_input_tokens, 0), 0)::bigint
AS cache_read_input_tokens,
GREATEST(
COALESCE(
CASE
WHEN settlement.billing_input_tokens IS NOT NULL
OR settlement.billing_output_tokens IS NOT NULL
OR settlement.billing_cache_creation_tokens IS NOT NULL
OR settlement.billing_cache_creation_5m_tokens IS NOT NULL
OR settlement.billing_cache_creation_1h_tokens IS NOT NULL
OR settlement.billing_cache_read_tokens IS NOT NULL
THEN COALESCE(settlement.billing_input_tokens, 0)
+ COALESCE(settlement.billing_output_tokens, 0)
+ COALESCE(
settlement.billing_cache_creation_tokens,
COALESCE(settlement.billing_cache_creation_5m_tokens, 0)
+ COALESCE(settlement.billing_cache_creation_1h_tokens, 0),
0
)
+ COALESCE(settlement.billing_cache_read_tokens, 0)
END,
usage_rows.total_tokens,
0
),
0
)::bigint AS total_tokens,
GREATEST(
COALESCE(
settlement.billing_total_input_context,
CASE
WHEN split_part(lower(COALESCE(COALESCE(usage_rows.endpoint_api_format, usage_rows.api_format), '')), ':', 1)
IN ('claude', 'anthropic')
THEN GREATEST(COALESCE(usage_rows.input_tokens, 0), 0)
+ CASE
WHEN COALESCE(usage_rows.cache_creation_input_tokens, 0) = 0
AND (
COALESCE(usage_rows.cache_creation_input_tokens_5m, 0)
+ COALESCE(usage_rows.cache_creation_input_tokens_1h, 0)
) > 0
THEN COALESCE(usage_rows.cache_creation_input_tokens_5m, 0)
+ COALESCE(usage_rows.cache_creation_input_tokens_1h, 0)
ELSE COALESCE(usage_rows.cache_creation_input_tokens, 0)
END
+ GREATEST(COALESCE(usage_rows.cache_read_input_tokens, 0), 0)
WHEN split_part(lower(COALESCE(COALESCE(usage_rows.endpoint_api_format, usage_rows.api_format), '')), ':', 1)
IN ('openai', 'gemini', 'google')
THEN CASE
WHEN GREATEST(COALESCE(usage_rows.input_tokens, 0), 0) <= 0 THEN 0
WHEN GREATEST(COALESCE(usage_rows.cache_read_input_tokens, 0), 0) <= 0
THEN GREATEST(COALESCE(usage_rows.input_tokens, 0), 0)
ELSE GREATEST(
GREATEST(COALESCE(usage_rows.input_tokens, 0), 0)
- GREATEST(COALESCE(usage_rows.cache_read_input_tokens, 0), 0),
0
)
END
+ GREATEST(COALESCE(usage_rows.cache_read_input_tokens, 0), 0)
ELSE GREATEST(COALESCE(usage_rows.input_tokens, 0), 0)
+ CASE
WHEN COALESCE(usage_rows.cache_creation_input_tokens, 0) = 0
AND (
COALESCE(usage_rows.cache_creation_input_tokens_5m, 0)
+ COALESCE(usage_rows.cache_creation_input_tokens_1h, 0)
) > 0
THEN COALESCE(usage_rows.cache_creation_input_tokens_5m, 0)
+ COALESCE(usage_rows.cache_creation_input_tokens_1h, 0)
ELSE COALESCE(usage_rows.cache_creation_input_tokens, 0)
END
+ GREATEST(COALESCE(usage_rows.cache_read_input_tokens, 0), 0)
END,
0
),
0
)::bigint AS total_input_context,
COALESCE(CAST(usage_rows.input_cost_usd AS DOUBLE PRECISION), 0) AS input_cost_usd,
COALESCE(CAST(usage_rows.output_cost_usd AS DOUBLE PRECISION), 0) AS output_cost_usd,
COALESCE(
CAST(settlement.billing_cache_creation_cost_usd AS DOUBLE PRECISION),
CAST(usage_rows.cache_creation_cost_usd AS DOUBLE PRECISION),
0
) AS cache_creation_cost_usd,
COALESCE(
CAST(settlement.billing_cache_read_cost_usd AS DOUBLE PRECISION),
CAST(usage_rows.cache_read_cost_usd AS DOUBLE PRECISION),
0
) AS cache_read_cost_usd,
COALESCE(
CAST(settlement.billing_total_cost_usd AS DOUBLE PRECISION),
CAST(usage_rows.total_cost_usd AS DOUBLE PRECISION),
0
) AS total_cost_usd,
COALESCE(
CAST(settlement.billing_actual_total_cost_usd AS DOUBLE PRECISION),
CAST(usage_rows.actual_total_cost_usd AS DOUBLE PRECISION),
0
) AS actual_total_cost_usd,
COALESCE(
CAST(settlement.output_price_per_1m AS DOUBLE PRECISION),
CAST(usage_rows.output_price_per_1m AS DOUBLE PRECISION)
) AS output_price_per_1m,
COALESCE(
CAST(settlement.input_price_per_1m AS DOUBLE PRECISION),
CAST(usage_rows.input_price_per_1m AS DOUBLE PRECISION)
) AS input_price_per_1m,
COALESCE(
CAST(settlement.cache_creation_price_per_1m AS DOUBLE PRECISION),
CAST(usage_rows.cache_creation_price_per_1m AS DOUBLE PRECISION)
) AS cache_creation_price_per_1m,
COALESCE(
CAST(settlement.cache_read_price_per_1m AS DOUBLE PRECISION),
CAST(usage_rows.cache_read_price_per_1m AS DOUBLE PRECISION)
) AS cache_read_price_per_1m,
COALESCE(
CAST(settlement.price_per_request AS DOUBLE PRECISION),
CAST(usage_rows.price_per_request AS DOUBLE PRECISION)
) AS price_per_request,
settlement.billing_pricing_source,
settlement.billing_rule_id,
settlement.billing_rule_version
FROM public."usage" AS usage_rows
LEFT JOIN public.usage_settlement_snapshots AS settlement
ON settlement.request_id = usage_rows.request_id;
COMMENT ON VIEW public.usage_billing_facts IS
'Canonical billing read model. Token/cost fields prefer usage_settlement_snapshots.billing_* and fall back to deprecated usage mirrors for legacy rows.';
COMMENT ON COLUMN public.usage.input_tokens IS
'DEPRECATED: billing dimension mirror. Use public.usage_settlement_snapshots.billing_input_tokens or public.usage_billing_facts.input_tokens.';
COMMENT ON COLUMN public.usage.output_tokens IS
'DEPRECATED: billing dimension mirror. Use public.usage_settlement_snapshots.billing_output_tokens or public.usage_billing_facts.output_tokens.';
COMMENT ON COLUMN public.usage.total_tokens IS
'DEPRECATED: billing dimension mirror. Use public.usage_billing_facts.total_tokens.';
COMMENT ON COLUMN public.usage.cache_creation_input_tokens IS
'DEPRECATED: billing dimension mirror. Use public.usage_settlement_snapshots.billing_cache_creation_tokens or public.usage_billing_facts.cache_creation_input_tokens.';
COMMENT ON COLUMN public.usage.cache_creation_input_tokens_5m IS
'DEPRECATED: billing dimension mirror. Use public.usage_settlement_snapshots.billing_cache_creation_5m_tokens or public.usage_billing_facts.cache_creation_input_tokens_5m.';
COMMENT ON COLUMN public.usage.cache_creation_input_tokens_1h IS
'DEPRECATED: billing dimension mirror. Use public.usage_settlement_snapshots.billing_cache_creation_1h_tokens or public.usage_billing_facts.cache_creation_input_tokens_1h.';
COMMENT ON COLUMN public.usage.cache_read_input_tokens IS
'DEPRECATED: billing dimension mirror. Use public.usage_settlement_snapshots.billing_cache_read_tokens or public.usage_billing_facts.cache_read_input_tokens.';
COMMENT ON COLUMN public.usage.cache_creation_cost_usd IS
'DEPRECATED: billing cost mirror. Use public.usage_settlement_snapshots.billing_cache_creation_cost_usd or public.usage_billing_facts.cache_creation_cost_usd.';
COMMENT ON COLUMN public.usage.cache_read_cost_usd IS
'DEPRECATED: billing cost mirror. Use public.usage_settlement_snapshots.billing_cache_read_cost_usd or public.usage_billing_facts.cache_read_cost_usd.';
COMMENT ON COLUMN public.usage.total_cost_usd IS
'DEPRECATED: billing cost mirror. Use public.usage_settlement_snapshots.billing_total_cost_usd or public.usage_billing_facts.total_cost_usd.';
COMMENT ON COLUMN public.usage.actual_total_cost_usd IS
'DEPRECATED: billing cost mirror. Use public.usage_settlement_snapshots.billing_actual_total_cost_usd or public.usage_billing_facts.actual_total_cost_usd.';

View File

@@ -8,7 +8,7 @@ use tracing::{error, info, warn};
static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
static BASELINE_V2_SQL: &str = include_str!("../bootstrap/20260413020000_baseline_v2.sql");
const BASELINE_V2_CUTOFF_VERSION: i64 = 20260423000000;
const BASELINE_V2_CUTOFF_VERSION: i64 = 20260424000000;
const MIGRATIONS_TABLE_EXISTS_SQL: &str =
"SELECT to_regclass('public._sqlx_migrations') IS NOT NULL";
const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#"
@@ -663,6 +663,7 @@ SELECT EXISTS (
20260422110000,
20260422120000,
20260423000000,
20260424000000,
]
);
}
@@ -683,6 +684,10 @@ SELECT EXISTS (
.contains("CREATE TABLE IF NOT EXISTS public.usage_settlement_snapshots"));
assert!(BASELINE_V2_SQL.contains("billing_snapshot_schema_version"));
assert!(BASELINE_V2_SQL.contains("price_per_request"));
assert!(BASELINE_V2_SQL.contains("settlement_snapshot_schema_version"));
assert!(BASELINE_V2_SQL.contains("billing_effective_input_tokens"));
assert!(BASELINE_V2_SQL.contains("CREATE OR REPLACE VIEW public.usage_billing_facts"));
assert!(BASELINE_V2_SQL.contains("usage_settlement_snapshots.billing_total_cost_usd"));
assert!(BASELINE_V2_SQL.contains("candidate_index integer"));
assert!(BASELINE_V2_SQL.contains("CREATE TABLE IF NOT EXISTS public.stats_user_summary"));
assert!(
@@ -810,6 +815,7 @@ SELECT EXISTS (
20260422110000,
20260422120000,
20260423000000,
20260424000000,
]
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -75,6 +75,9 @@ fn copy_allowed_metadata_fields(source: &Map<String, Value>, target: &mut Map<St
copy_non_null_value(source, target, "billing_snapshot");
copy_non_empty_string(source, target, "billing_snapshot_schema_version");
copy_non_empty_string(source, target, "billing_snapshot_status");
copy_non_null_value(source, target, "settlement_snapshot");
copy_non_empty_string(source, target, "settlement_snapshot_schema_version");
copy_non_null_value(source, target, "billing_dimensions");
copy_non_empty_string(source, target, "model_id");
copy_non_empty_string(source, target, "global_model_id");
copy_non_empty_string(source, target, "global_model_name");
@@ -100,6 +103,9 @@ fn move_allowed_metadata_fields(mut source: Map<String, Value>, target: &mut Map
remove_non_null_value(&mut source, target, "billing_snapshot");
remove_non_empty_string(&mut source, target, "billing_snapshot_schema_version");
remove_non_empty_string(&mut source, target, "billing_snapshot_status");
remove_non_null_value(&mut source, target, "settlement_snapshot");
remove_non_empty_string(&mut source, target, "settlement_snapshot_schema_version");
remove_non_null_value(&mut source, target, "billing_dimensions");
remove_non_empty_string(&mut source, target, "model_id");
remove_non_empty_string(&mut source, target, "global_model_id");
remove_non_empty_string(&mut source, target, "global_model_name");