fix(billing): preserve effective cache and tier facts

This commit is contained in:
MMEXA
2026-07-11 18:12:55 +08:00
parent 14c4e3a04e
commit 3014fd50c6
12 changed files with 568 additions and 41 deletions
+118 -4
View File
@@ -1,8 +1,9 @@
use aether_data_contracts::repository::billing::StoredBillingModelContext;
use aether_data_contracts::repository::usage::{
extract_provider_actual_service_tier_from_response, extract_provider_service_tier_from_body,
normalize_provider_service_tier, PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY,
PROVIDER_SERVICE_TIER_METADATA_KEY,
extract_provider_actual_service_tier_from_response,
extract_provider_cache_ttl_minutes_from_metadata, extract_provider_service_tier_from_body,
normalize_provider_service_tier, resolve_provider_cache_ttl_minutes,
PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY, PROVIDER_SERVICE_TIER_METADATA_KEY,
};
use aether_data_contracts::DataLayerError;
use aether_usage_runtime::{UsageEvent, UsageEventType};
@@ -177,7 +178,8 @@ fn calculate_billing_computation(
image_size: usage_event_dimension_string(&event.data, "image_size"),
image_quality: usage_event_dimension_string(&event.data, "image_quality"),
image_output_format: usage_event_dimension_string(&event.data, "image_output_format"),
cache_ttl_minutes: pricing.provider_api_key_cache_ttl_minutes,
cache_ttl_minutes: usage_event_provider_cache_ttl_minutes(&event.data)
.or(pricing.provider_api_key_cache_ttl_minutes),
};
BillingService::new()
@@ -215,6 +217,20 @@ fn usage_event_processing_tiers(
UsageEventProcessingTiers { requested, actual }
}
fn usage_event_provider_cache_ttl_minutes(
data: &aether_usage_runtime::UsageEventData,
) -> Option<i64> {
resolve_provider_cache_ttl_minutes(
data.endpoint_api_format
.as_deref()
.or(data.api_format.as_deref()),
data.target_model.as_deref().or(Some(data.model.as_str())),
Some(data.model.as_str()),
data.provider_request_body.as_ref(),
)
.or_else(|| extract_provider_cache_ttl_minutes_from_metadata(data.request_metadata.as_ref()))
}
fn usage_event_is_image_usage(data: &aether_usage_runtime::UsageEventData) -> bool {
data.request_type
.as_deref()
@@ -444,6 +460,104 @@ mod tests {
assert_eq!(tiers.actual.as_deref(), Some("default"));
}
#[tokio::test]
async fn settlement_uses_effective_gpt_5_6_cache_ttl_after_body_capture() {
let lookup = TestLookup {
name_context: Some(
StoredBillingModelContext::new(
"provider-1".to_string(),
Some("pay_as_you_go".to_string()),
Some("key-1".to_string()),
None,
Some(60),
"global-model-1".to_string(),
"gpt-5.6-sol".to_string(),
None,
None,
Some(json!({
"tiers": [{
"up_to": null,
"input_price_per_1m": 5.0,
"output_price_per_1m": 30.0,
"cache_creation_price_per_1m": 6.25,
"cache_read_price_per_1m": 0.5,
"cache_ttl_pricing": [{
"ttl_minutes": 60,
"cache_creation_price_per_1m": 100.0,
"cache_read_price_per_1m": 100.0
}]
}]
})),
Some("model-1".to_string()),
Some("gpt-5.6-sol".to_string()),
None,
None,
None,
)
.expect("billing context should build"),
),
model_id_context: None,
};
for (request_id, provider_request_body, request_metadata) in [
(
"req-cache-body",
Some(json!({"model": "gpt-5.6-sol"})),
None,
),
(
"req-cache-metadata",
None,
Some(json!({"provider_cache_ttl_minutes": 30})),
),
] {
let mut event = UsageEvent::new(
UsageEventType::Completed,
request_id,
UsageEventData {
provider_name: "OpenAI".to_string(),
model: "gpt-5.6-sol".to_string(),
target_model: Some("gpt-5.6-sol".to_string()),
provider_id: Some("provider-1".to_string()),
provider_api_key_id: Some("key-1".to_string()),
request_type: Some("chat".to_string()),
api_format: Some("openai:responses".to_string()),
endpoint_api_format: Some("openai:responses".to_string()),
provider_request_body,
request_metadata,
input_tokens: Some(1_000_000),
cache_creation_input_tokens: Some(1_000_000),
status_code: Some(200),
..UsageEventData::default()
},
);
enrich_usage_event_with_billing(&lookup, &mut event)
.await
.expect("billing should succeed");
let snapshot = event
.data
.request_metadata
.as_ref()
.and_then(|value| value.get("billing_snapshot"))
.expect("billing snapshot should exist");
assert_eq!(
snapshot
.get("resolved_dimensions")
.and_then(|value| value.get("cache_ttl_minutes")),
Some(&json!(30))
);
assert_eq!(
snapshot
.get("resolved_variables")
.and_then(|value| value.get("cache_creation_price_per_1m")),
Some(&json!(6.25))
);
assert_eq!(event.data.total_cost_usd, Some(6.25));
}
}
#[tokio::test]
async fn enriches_completed_usage_event_with_billing_snapshot() {
let lookup = TestLookup {
+12
View File
@@ -51,6 +51,15 @@ impl BillingPricingResolution {
.as_deref()
.is_some_and(processing_tier_is_standard)
}
pub fn bills_requested_processing_tier(&self) -> bool {
let requested = self
.requested_processing_tier
.as_deref()
.map(canonical_processing_tier)
.unwrap_or_else(|| "standard".to_string());
self.billing_processing_tier.as_deref() == Some(requested.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -571,6 +580,8 @@ pub struct BillingAuthorizationEstimateInput {
pub task_type: String,
pub api_format: Option<String>,
pub requested_processing_tier: Option<String>,
#[serde(default)]
pub cache_ttl_minutes: Option<i64>,
pub input_tokens: i64,
pub max_output_tokens: Option<i64>,
}
@@ -581,6 +592,7 @@ impl BillingAuthorizationEstimateInput {
task_type: task_type.into(),
api_format: None,
requested_processing_tier: None,
cache_ttl_minutes: None,
input_tokens: input_tokens.max(0),
max_output_tokens: None,
}
+150 -12
View File
@@ -79,7 +79,9 @@ impl BillingService {
actual_processing_tier: None,
input_tokens,
output_tokens,
cache_ttl_minutes: pricing.provider_api_key_cache_ttl_minutes,
cache_ttl_minutes: estimate
.cache_ttl_minutes
.or(pricing.provider_api_key_cache_ttl_minutes),
..BillingUsageInput::new(estimate.task_type.clone())
};
let mut scenarios = vec![base_input.clone()];
@@ -92,17 +94,19 @@ impl BillingService {
cache_creation.cache_creation_tokens = input_tokens;
scenarios.push(cache_creation);
let mut cache_creation_5m = base_input.clone();
cache_creation_5m.cache_creation_tokens = input_tokens;
cache_creation_5m.cache_creation_ephemeral_5m_tokens = input_tokens;
cache_creation_5m.cache_ttl_minutes = Some(5);
scenarios.push(cache_creation_5m);
if estimate.cache_ttl_minutes.is_none() {
let mut cache_creation_5m = base_input.clone();
cache_creation_5m.cache_creation_tokens = input_tokens;
cache_creation_5m.cache_creation_ephemeral_5m_tokens = input_tokens;
cache_creation_5m.cache_ttl_minutes = Some(5);
scenarios.push(cache_creation_5m);
let mut cache_creation_1h = base_input.clone();
cache_creation_1h.cache_creation_tokens = input_tokens;
cache_creation_1h.cache_creation_ephemeral_1h_tokens = input_tokens;
cache_creation_1h.cache_ttl_minutes = Some(60);
scenarios.push(cache_creation_1h);
let mut cache_creation_1h = base_input.clone();
cache_creation_1h.cache_creation_tokens = input_tokens;
cache_creation_1h.cache_creation_ephemeral_1h_tokens = input_tokens;
cache_creation_1h.cache_ttl_minutes = Some(60);
scenarios.push(cache_creation_1h);
}
let mut cache_read = base_input;
cache_read.cache_read_tokens = input_tokens;
@@ -110,7 +114,8 @@ impl BillingService {
}
let mut upper_bound = 0.0_f64;
for pricing_resolution in pricing_resolutions {
'pricing_catalogs: for pricing_resolution in pricing_resolutions {
let is_requested_catalog = pricing_resolution.bills_requested_processing_tier();
for scenario in &scenarios {
let total_input_context = normalize_total_input_context_for_cache_hit_rate(
scenario.api_format.as_deref(),
@@ -129,6 +134,11 @@ impl BillingService {
let selected =
self.calculate_with_resolution(pricing, scenario, pricing_resolution.clone())?;
if !billing_computation_is_bounded(&selected) {
if !is_requested_catalog
&& billing_computation_is_outside_catalog_context(&selected)
{
continue 'pricing_catalogs;
}
return Ok(None);
}
@@ -300,6 +310,11 @@ fn billing_computation_is_bounded(computation: &BillingComputation) -> bool {
&& computation.actual_total_cost >= 0.0
}
fn billing_computation_is_outside_catalog_context(computation: &BillingComputation) -> bool {
computation.cost_result.status == BillingSnapshotStatus::NoRule
&& computation.cost_result.snapshot.missing_required == ["input_context_tier"]
}
fn authorization_pricing_candidates(
pricing: &BillingPricingResolution,
max_input_context: i64,
@@ -1176,6 +1191,129 @@ mod tests {
);
}
#[test]
fn processing_catalog_boundaries_match_context_and_priority_contracts() {
let cases = [
(
"default",
272_000,
BillingSnapshotStatus::Complete,
Some(5.0),
),
(
"default",
272_001,
BillingSnapshotStatus::Complete,
Some(10.0),
),
("flex", 272_000, BillingSnapshotStatus::Complete, Some(2.5)),
("flex", 272_001, BillingSnapshotStatus::Complete, Some(5.0)),
(
"priority",
272_000,
BillingSnapshotStatus::Complete,
Some(10.0),
),
("priority", 272_001, BillingSnapshotStatus::NoRule, None),
];
for (actual, input_tokens, status, input_price) in cases {
let result = BillingService::new()
.calculate(
&processing_pricing(),
&processing_usage(Some(actual), Some(actual), input_tokens),
)
.expect("processing boundary should resolve");
assert_eq!(
result.cost_result.status, status,
"{actual} at {input_tokens}"
);
if let Some(input_price) = input_price {
assert_eq!(
result.cost_result.snapshot.resolved_variables["input_price_per_1m"],
json!(input_price),
"{actual} at {input_tokens}"
);
} else {
assert_eq!(
result.cost_result.snapshot.missing_required,
vec!["input_context_tier"]
);
}
}
}
#[test]
fn authorization_estimate_uses_known_request_cache_ttl() {
let pricing = BillingModelPricingSnapshot {
provider_api_key_rate_multipliers: None,
default_price_per_request: None,
default_tiered_pricing: Some(json!({
"tiers": [{
"up_to": null,
"input_price_per_1m": 1.0,
"output_price_per_1m": 0.0,
"cache_creation_price_per_1m": 1.25,
"cache_read_price_per_1m": 0.1,
"cache_ttl_pricing": [{
"ttl_minutes": 60,
"cache_creation_price_per_1m": 100.0,
"cache_read_price_per_1m": 100.0
}]
}]
})),
..pricing()
};
let service = BillingService::new();
let mut estimate = BillingAuthorizationEstimateInput::new("chat", 1_000_000);
estimate.api_format = Some("openai:responses".to_string());
estimate.max_output_tokens = Some(0);
estimate.cache_ttl_minutes = Some(30);
assert_eq!(
service
.estimate_authorization_cost_upper_bound(&pricing, &estimate)
.expect("known TTL estimate should calculate"),
Some(1.25)
);
estimate.cache_ttl_minutes = None;
assert_eq!(
service
.estimate_authorization_cost_upper_bound(&pricing, &estimate)
.expect("unknown TTL estimate should calculate"),
Some(100.0)
);
}
#[test]
fn authorization_estimate_uses_only_processing_catalogs_eligible_for_context() {
let service = BillingService::new();
let mut estimate = BillingAuthorizationEstimateInput::new("chat", 300_000);
estimate.api_format = Some("openai:responses".to_string());
estimate.max_output_tokens = Some(0);
estimate.cache_ttl_minutes = Some(30);
for requested_processing_tier in [None, Some("standard"), Some("flex")] {
estimate.requested_processing_tier = requested_processing_tier.map(ToOwned::to_owned);
assert_eq!(
service
.estimate_authorization_cost_upper_bound(&processing_pricing(), &estimate)
.expect("eligible processing catalogs should calculate"),
Some(3.75),
"requested tier: {requested_processing_tier:?}"
);
}
estimate.requested_processing_tier = Some("priority".to_string());
assert_eq!(
service
.estimate_authorization_cost_upper_bound(&processing_pricing(), &estimate)
.expect("ineligible requested catalog should resolve"),
None
);
}
#[test]
fn unknown_actual_tier_cannot_fall_back_to_fixed_request_price() {
let pricing = BillingModelPricingSnapshot {