mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 16:37:46 +08:00
fix(billing): preserve effective cache and tier facts
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user