use std::collections::BTreeMap; use std::time::{SystemTime, UNIX_EPOCH}; use serde_json::{json, Value}; use crate::default_rule::{ explicit_image_output_price_default, explicit_image_output_price_entries, explicit_image_output_price_ranges, normalize_task_type, DefaultBillingRuleGenerator, }; use crate::precision::quantize_cost; use crate::pricing::{ BillingAuthorizationEstimateInput, BillingComputation, BillingModelPricingSnapshot, BillingPricingResolution, BillingUsageInput, }; use crate::schema::{ BillingSnapshot, BillingSnapshotStatus, CostResult, BILLING_SNAPSHOT_SCHEMA_VERSION, }; use crate::{ normalize_input_tokens_for_billing, normalize_total_input_context_for_cache_hit_rate, ExpressionEvaluationError, FormulaEngine, FormulaEvaluationStatus, }; pub struct BillingService { engine: FormulaEngine, } impl BillingService { pub fn new() -> Self { Self { engine: FormulaEngine::new(), } } pub fn calculate( &self, pricing: &BillingModelPricingSnapshot, input: &BillingUsageInput, ) -> Result { let pricing_resolution = pricing .resolve_pricing_checked( input.requested_processing_tier.as_deref(), input.actual_processing_tier.as_deref(), ) .map_err(|err| ExpressionEvaluationError::Failed(err.to_string()))?; self.calculate_with_resolution(pricing, input, pricing_resolution) } pub fn estimate_authorization_cost_upper_bound( &self, pricing: &BillingModelPricingSnapshot, estimate: &BillingAuthorizationEstimateInput, ) -> Result, ExpressionEvaluationError> { let Some(pricing_resolutions) = pricing .resolve_authorization_pricing_candidates(estimate.requested_processing_tier.as_deref()) .map_err(|err| ExpressionEvaluationError::Failed(err.to_string()))? else { return Ok(None); }; if normalize_task_type(&estimate.task_type) == "image" { return Ok(None); } if pricing.is_free_tier() { return Ok(Some(0.0)); } if estimate.max_output_tokens.is_none() && pricing_resolutions.iter().any(|resolution| { resolution .tiered_pricing .as_ref() .is_some_and(pricing_has_positive_output_rate) }) { return Ok(None); } let input_tokens = estimate.input_tokens.max(0); let output_tokens = estimate.max_output_tokens.unwrap_or(0).max(0); let base_input = BillingUsageInput { task_type: estimate.task_type.clone(), api_format: estimate.api_format.clone(), requested_processing_tier: estimate.requested_processing_tier.clone(), actual_processing_tier: None, input_tokens, output_tokens, 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()]; if input_tokens > 0 && pricing_resolutions .iter() .any(|resolution| resolution.tiered_pricing.is_some()) { let mut cache_creation = base_input.clone(); cache_creation.cache_creation_tokens = input_tokens; scenarios.push(cache_creation); 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_read = base_input; cache_read.cache_read_tokens = input_tokens; scenarios.push(cache_read); } let mut upper_bound = 0.0_f64; '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(), scenario.input_tokens, scenario.cache_creation_tokens, scenario.cache_read_tokens, ); let Some(pricing_candidates) = authorization_pricing_candidates(&pricing_resolution, total_input_context) else { return Ok(None); }; // Validate the selected catalog and its finite coverage using the same path as // settlement before evaluating every reachable tier as an upper-bound candidate. 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); } for candidate in pricing_candidates { let computation = self.calculate_with_resolution(pricing, scenario, candidate)?; if !billing_computation_is_bounded(&computation) { return Ok(None); } upper_bound = upper_bound.max(computation.actual_total_cost); } } } Ok(Some(upper_bound)) } fn calculate_with_resolution( &self, pricing: &BillingModelPricingSnapshot, input: &BillingUsageInput, pricing_resolution: BillingPricingResolution, ) -> Result { if pricing_resolution.requires_actual_processing_tier() { return Ok(no_rule_computation( pricing, input, pricing_resolution, "actual_processing_tier", )); } if !pricing_resolution.bills_standard_processing_tier() && pricing_resolution.tiered_pricing.is_none() && pricing_resolution.price_per_request.is_none() { return Ok(no_rule_computation( pricing, input, pricing_resolution, "processing_tier_catalog", )); } let total_input_context = normalize_total_input_context_for_cache_hit_rate( input.api_format.as_deref(), input.input_tokens, input.cache_creation_tokens, input.cache_read_tokens, ); let has_token_usage = input.input_tokens > 0 || input.output_tokens > 0 || input.cache_creation_tokens > 0 || input.cache_read_tokens > 0; if has_token_usage { if let Some(pricing_config) = pricing_resolution.tiered_pricing.as_ref() { let tiers = pricing_config .get("tiers") .and_then(Value::as_array) .map(Vec::as_slice) .unwrap_or_default(); if tiers.is_empty() && pricing_resolution.price_per_request.is_none() { return Ok(no_rule_computation( pricing, input, pricing_resolution, "token_pricing", )); } if !tiers.is_empty() && !pricing_covers_input_context(pricing_config, total_input_context) { return Ok(no_rule_computation( pricing, input, pricing_resolution, "input_context_tier", )); } } else if pricing_resolution.price_per_request.is_none() { return Ok(no_rule_computation( pricing, input, pricing_resolution, "token_pricing", )); } } let Some(rule) = DefaultBillingRuleGenerator::generate_for_pricing( &pricing.global_model_name, &pricing_resolution, &input.task_type, ) else { return Ok(no_rule_computation( pricing, input, pricing_resolution, "pricing_rule", )); }; let dims = build_dimensions(input, &pricing_resolution); let result = self.engine.evaluate( &rule.expression, Some(&rule.variables), Some(&dims), Some(&rule.dimension_mappings), false, )?; let status = match result.status { FormulaEvaluationStatus::Complete => BillingSnapshotStatus::Complete, FormulaEvaluationStatus::Incomplete => BillingSnapshotStatus::Incomplete, }; let total_cost = if matches!(status, BillingSnapshotStatus::Complete) { result.cost } else { 0.0 }; let rate_multiplier = pricing.rate_multiplier_for_api_format(input.api_format.as_deref()); let is_free_tier = pricing.is_free_tier(); let actual_total_cost = if is_free_tier { 0.0 } else { quantize_cost(total_cost * rate_multiplier) }; Ok(BillingComputation { cost_result: CostResult { cost: total_cost, status, snapshot: BillingSnapshot { schema_version: BILLING_SNAPSHOT_SCHEMA_VERSION.to_string(), rule_id: Some(rule.id), rule_name: Some(rule.name), scope: Some(rule.scope), expression: Some(rule.expression), resolved_dimensions: result.resolved_dimensions, resolved_variables: result.resolved_variables, cost_breakdown: result.cost_breakdown, total_cost, tier_index: result.tier_index, tier_info: result.tier_info, missing_required: result.missing_required, status, calculated_at: now_marker(), engine_version: "2.0".to_string(), }, }, actual_total_cost, rate_multiplier, is_free_tier, pricing_resolution, }) } } fn pricing_has_positive_output_rate(pricing: &Value) -> bool { pricing .get("tiers") .and_then(Value::as_array) .into_iter() .flatten() .filter_map(|tier| tier.get("output_price_per_1m").and_then(Value::as_f64)) .any(|price| price.is_finite() && price > 0.0) } fn billing_computation_is_bounded(computation: &BillingComputation) -> bool { computation.cost_result.status == BillingSnapshotStatus::Complete && computation.actual_total_cost.is_finite() && 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, ) -> Option> { let Some(config) = pricing.tiered_pricing.as_ref() else { return Some(vec![pricing.clone()]); }; let Some(tiers) = config.get("tiers").and_then(Value::as_array) else { return Some(vec![pricing.clone()]); }; if tiers.is_empty() { return Some(vec![pricing.clone()]); } let max_input_context = max_input_context.max(0); let mut previous_up_to: Option = None; let mut candidates = Vec::new(); for tier in tiers { let tier_object = tier.as_object()?; let up_to = match tier_object.get("up_to") { None | Some(Value::Null) => None, Some(value) => Some(nonnegative_i64(value)?), }; if let (Some(previous), Some(current)) = (previous_up_to, up_to) { if current < previous { return None; } } let lower_bound = previous_up_to.map_or(0, |value| value.saturating_add(1)); if lower_bound <= max_input_context { let mut candidate_config = config.clone(); let candidate_object = candidate_config.as_object_mut()?; let mut candidate_tier = tier.clone(); candidate_tier .as_object_mut()? .insert("up_to".to_string(), Value::Null); candidate_object.insert("tiers".to_string(), Value::Array(vec![candidate_tier])); let mut candidate = pricing.clone(); candidate.tiered_pricing = Some(candidate_config); candidates.push(candidate); } match up_to { Some(up_to) if max_input_context > up_to => previous_up_to = Some(up_to), _ => break, } } (!candidates.is_empty()).then_some(candidates) } fn nonnegative_i64(value: &Value) -> Option { value .as_i64() .or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok())) .filter(|value| *value >= 0) } fn no_rule_computation( pricing: &BillingModelPricingSnapshot, input: &BillingUsageInput, pricing_resolution: BillingPricingResolution, missing_required: &str, ) -> BillingComputation { let resolved_dimensions = build_dimensions(input, &pricing_resolution); BillingComputation { cost_result: CostResult { cost: 0.0, status: BillingSnapshotStatus::NoRule, snapshot: BillingSnapshot { schema_version: BILLING_SNAPSHOT_SCHEMA_VERSION.to_string(), rule_id: None, rule_name: None, scope: None, expression: None, resolved_dimensions, resolved_variables: BTreeMap::new(), cost_breakdown: BTreeMap::new(), total_cost: 0.0, tier_index: None, tier_info: None, missing_required: vec![missing_required.to_string()], status: BillingSnapshotStatus::NoRule, calculated_at: now_marker(), engine_version: "2.0".to_string(), }, }, actual_total_cost: 0.0, rate_multiplier: pricing.rate_multiplier_for_api_format(input.api_format.as_deref()), is_free_tier: pricing.is_free_tier(), pricing_resolution, } } fn pricing_covers_input_context(pricing: &Value, total_input_context: i64) -> bool { let Some(tiers) = pricing.get("tiers").and_then(Value::as_array) else { return true; }; let Some(last_tier) = tiers.last() else { return true; }; match last_tier.get("up_to") { None | Some(Value::Null) => true, Some(value) => value .as_i64() .or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok())) .is_some_and(|up_to| total_input_context <= up_to), } } impl Default for BillingService { fn default() -> Self { Self::new() } } fn build_dimensions( input: &BillingUsageInput, pricing: &BillingPricingResolution, ) -> BTreeMap { let normalized_input_tokens = normalize_input_tokens_for_billing( input.api_format.as_deref(), input.input_tokens, input.cache_creation_tokens, input.cache_read_tokens, ); let classified_cache_creation_tokens = input .cache_creation_ephemeral_5m_tokens .saturating_add(input.cache_creation_ephemeral_1h_tokens); let cache_creation_uncategorized_tokens = input .cache_creation_tokens .saturating_sub(classified_cache_creation_tokens) .max(0); let total_input_context = normalize_total_input_context_for_cache_hit_rate( input.api_format.as_deref(), input.input_tokens, input.cache_creation_tokens, input.cache_read_tokens, ); let pricing_config = pricing.tiered_pricing.as_ref(); let image_output_pricing = image_output_pricing_state(pricing_config); let image_output_resolution = resolve_image_output_price_resolution(pricing_config, input); let mut out = BTreeMap::from([ ("input_tokens".to_string(), json!(normalized_input_tokens)), ("output_tokens".to_string(), json!(input.output_tokens)), ( "cache_creation_tokens".to_string(), json!(input.cache_creation_tokens), ), ( "cache_creation_ephemeral_5m_tokens".to_string(), json!(input.cache_creation_ephemeral_5m_tokens), ), ( "cache_creation_ephemeral_1h_tokens".to_string(), json!(input.cache_creation_ephemeral_1h_tokens), ), ( "cache_creation_uncategorized_tokens".to_string(), json!(cache_creation_uncategorized_tokens), ), ( "cache_read_tokens".to_string(), json!(input.cache_read_tokens), ), ( "request_count".to_string(), json!(input.request_count.max(0)), ), ("image_count".to_string(), json!(input.image_count.max(0))), ( "image_count_unmetered".to_string(), json!(if image_output_pricing.enabled { input.image_count.max(0) } else { 0 }), ), ( "image_output_pricing_enabled".to_string(), json!(image_output_pricing.enabled), ), ( "image_output_matrix_enabled".to_string(), json!(image_output_pricing.matrix_enabled), ), ( "image_output_range_enabled".to_string(), json!(image_output_pricing.range_enabled), ), ( "image_output_pricing_mode".to_string(), json!(image_output_resolution.pricing_mode), ), ( "image_output_price_per_image".to_string(), json!(image_output_resolution.price_per_image), ), ( "total_input_context".to_string(), json!(total_input_context), ), ( "effective_task_type".to_string(), json!(normalize_task_type(&input.task_type)), ), ( "requested_processing_tier".to_string(), json!(pricing.requested_processing_tier), ), ( "actual_processing_tier".to_string(), json!(pricing.actual_processing_tier), ), ( "billing_processing_tier".to_string(), json!(pricing.billing_processing_tier), ), ]); out.insert( "cache_creation_ephemeral_5m_ttl_minutes".to_string(), json!(5), ); out.insert( "cache_creation_ephemeral_1h_ttl_minutes".to_string(), json!(60), ); if let Some(cache_ttl_minutes) = input.cache_ttl_minutes { out.insert( "cache_ttl_minutes".to_string(), json!(cache_ttl_minutes.max(0)), ); } if let Some(image_pixels) = image_output_resolution.image_pixels { out.insert("image_pixels".to_string(), json!(image_pixels)); } if let Some(price_bucket) = image_output_resolution.price_bucket.as_ref() { out.insert("image_output_price_bucket".to_string(), json!(price_bucket)); } if input.image_count > 0 { let image_size = input .image_size .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let image_quality = input .image_quality .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); if let Some(image_size) = image_size.as_ref() { out.insert("image_size".to_string(), json!(image_size)); } if let Some(image_quality) = image_quality.as_ref() { out.insert("image_quality".to_string(), json!(image_quality)); } if let (Some(image_size), Some(image_quality)) = (image_size.as_ref(), image_quality.as_ref()) { out.insert( "image_price_key".to_string(), json!(format!( "{}:{}", normalize_image_output_size(image_size), normalize_image_output_quality(image_quality) )), ); } } if let Some(output_format) = input .image_output_format .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { out.insert("image_output_format".to_string(), json!(output_format)); } out } #[derive(Debug, Clone, Copy)] struct ImageOutputPricingState { enabled: bool, matrix_enabled: bool, range_enabled: bool, } #[derive(Debug, Clone)] struct ImageOutputPriceResolution { price_per_image: f64, pricing_mode: &'static str, price_bucket: Option, image_pixels: Option, } #[derive(Debug, Clone)] struct ParsedImageOutputPriceRange { up_to_pixels: Option, label: Option, prices: BTreeMap, } fn image_output_pricing_state(pricing: Option<&Value>) -> ImageOutputPricingState { let matrix_enabled = pricing_has_image_output_matrix(pricing); let range_enabled = pricing_has_image_output_ranges(pricing); let default_enabled = pricing_has_image_output_default_price(pricing); ImageOutputPricingState { enabled: matrix_enabled || range_enabled || default_enabled, matrix_enabled, range_enabled, } } fn resolve_image_output_price_resolution( pricing: Option<&Value>, input: &BillingUsageInput, ) -> ImageOutputPriceResolution { let default_price = explicit_image_output_price_default(pricing); let image_size = input .image_size .as_deref() .map(normalize_image_output_size) .filter(|value| !value.is_empty()); let image_quality = input .image_quality .as_deref() .map(normalize_image_output_quality) .filter(|value| !value.is_empty()); let image_pixels = image_size.as_deref().and_then(parse_image_size_pixels); if let (Some(size), Some(entries)) = ( image_size.as_deref(), explicit_image_output_price_entries(pricing), ) { for key in image_price_lookup_keys(size, image_quality.as_deref()) { if let Some(price) = entries.get(&key).and_then(Value::as_f64) { return ImageOutputPriceResolution { price_per_image: price, pricing_mode: "matrix", price_bucket: None, image_pixels, }; } } } if let Some(pixels) = image_pixels { if let Some((price, bucket)) = resolve_image_output_range_price( explicit_image_output_price_ranges(pricing).unwrap_or_default(), pixels, image_quality.as_deref(), default_price, ) { return ImageOutputPriceResolution { price_per_image: price, pricing_mode: "pixel_tiers", price_bucket: Some(bucket), image_pixels, }; } } if let Some(price) = default_price { return ImageOutputPriceResolution { price_per_image: price, pricing_mode: "per_image", price_bucket: Some("default".to_string()), image_pixels, }; } ImageOutputPriceResolution { price_per_image: 0.0, pricing_mode: "none", price_bucket: None, image_pixels, } } fn pricing_has_image_output_matrix(pricing: Option<&Value>) -> bool { let Some(config) = pricing else { return false; }; [ "image_output_prices", "image_output_price_per_image", "image_output_price_matrix", "image_prices", ] .iter() .any(|key| { config .get(key) .is_some_and(image_price_entries_have_matrix_values) }) } fn pricing_has_image_output_ranges(pricing: Option<&Value>) -> bool { explicit_image_output_price_ranges(pricing).is_some_and(|ranges| !ranges.is_empty()) } fn pricing_has_image_output_default_price(pricing: Option<&Value>) -> bool { let Some(config) = pricing else { return false; }; config .get("image_output_price_default") .or_else(|| config.get("image_price_default")) .or_else(|| { config .get("image_output_prices") .and_then(|value| value.get("default")) }) .and_then(Value::as_f64) .is_some() } fn image_price_entries_have_matrix_values(value: &Value) -> bool { match value { Value::Object(object) => object.iter().any(|(key, value)| { !key.eq_ignore_ascii_case("default") && (value.as_f64().is_some() || image_price_entries_have_matrix_values(value)) }), Value::Array(items) => items.iter().any(image_price_entries_have_matrix_values), _ => false, } } fn resolve_image_output_range_price( ranges: Vec, image_pixels: i64, image_quality: Option<&str>, default_price: Option, ) -> Option<(f64, String)> { let mut parsed_ranges = ranges .iter() .filter_map(parse_image_output_price_range) .collect::>(); parsed_ranges.sort_by( |left, right| match (left.up_to_pixels, right.up_to_pixels) { (Some(left), Some(right)) => left.cmp(&right), (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater, (None, None) => std::cmp::Ordering::Equal, }, ); for range in parsed_ranges { if !range .up_to_pixels .map(|up_to| image_pixels <= up_to) .unwrap_or(true) { continue; } let Some(price) = image_output_price_for_quality(&range.prices, image_quality).or(default_price) else { continue; }; return Some((price, image_output_range_bucket(&range))); } None } fn parse_image_output_price_range(value: &Value) -> Option { let object = value.as_object()?; let prices = object .get("prices") .and_then(Value::as_object)? .iter() .filter_map(|(key, value)| { value .as_f64() .map(|price| (key.to_ascii_lowercase(), price)) }) .collect::>(); if prices.is_empty() { return None; } Some(ParsedImageOutputPriceRange { up_to_pixels: object.get("up_to_pixels").and_then(value_as_positive_i64), label: object .get("label") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), prices, }) } fn image_output_price_for_quality( prices: &BTreeMap, image_quality: Option<&str>, ) -> Option { for key in image_quality_lookup_keys(image_quality) { if let Some(price) = prices.get(&key) { return Some(*price); } } None } fn image_price_lookup_keys(size: &str, image_quality: Option<&str>) -> Vec { image_quality_lookup_keys(image_quality) .into_iter() .filter(|quality| quality != "default") .map(|quality| format!("{}:{}", size, quality)) .collect() } fn image_quality_lookup_keys(image_quality: Option<&str>) -> Vec { let quality = image_quality .map(normalize_image_output_quality) .filter(|value| !value.is_empty()) .unwrap_or_else(|| "medium".to_string()); let mut keys = vec![quality.clone()]; if quality == "auto" { keys.push("medium".to_string()); } keys.push("default".to_string()); keys } fn image_output_range_bucket(range: &ParsedImageOutputPriceRange) -> String { range .label .clone() .unwrap_or_else(|| match range.up_to_pixels { Some(up_to_pixels) => format!("<={up_to_pixels}px"), None => "unbounded".to_string(), }) } fn normalize_image_output_size(value: &str) -> String { value .trim() .to_ascii_lowercase() .replace('×', "x") .chars() .filter(|ch| !ch.is_whitespace()) .collect() } fn normalize_image_output_quality(value: &str) -> String { value.trim().to_ascii_lowercase() } fn parse_image_size_pixels(size: &str) -> Option { let (width, height) = size.split_once('x')?; let width = width.parse::().ok()?; let height = height.parse::().ok()?; if width <= 0 || height <= 0 { return None; } width.checked_mul(height) } fn value_as_positive_i64(value: &Value) -> Option { let parsed = value .as_i64() .or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok())) .or_else(|| value.as_f64().map(|value| value as i64)) .or_else(|| value.as_str().and_then(|value| value.trim().parse().ok()))?; (parsed > 0).then_some(parsed) } fn now_marker() -> String { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() .to_string() } #[cfg(test)] mod tests { use serde_json::json; use super::BillingService; use crate::{ BillingAuthorizationEstimateInput, BillingModelPricingSnapshot, BillingPricingSource, BillingSnapshotStatus, BillingUsageInput, }; fn pricing() -> BillingModelPricingSnapshot { BillingModelPricingSnapshot { provider_id: "provider-1".to_string(), provider_billing_type: Some("pay_as_you_go".to_string()), provider_api_key_id: Some("key-1".to_string()), provider_api_key_rate_multipliers: Some(json!({"openai:chat": 0.5})), provider_api_key_cache_ttl_minutes: Some(60), global_model_id: "global-model-1".to_string(), global_model_name: "gpt-5".to_string(), global_model_config: None, default_price_per_request: Some(0.02), default_tiered_pricing: Some(json!({ "tiers": [{ "up_to": null, "input_price_per_1m": 3.0, "output_price_per_1m": 15.0, "cache_creation_price_per_1m": 3.75, "cache_read_price_per_1m": 0.30 }] })), model_id: Some("model-1".to_string()), model_provider_model_name: Some("gpt-5-upstream".to_string()), model_config: None, model_price_per_request: None, model_tiered_pricing: None, } } fn processing_pricing() -> BillingModelPricingSnapshot { BillingModelPricingSnapshot { provider_api_key_rate_multipliers: None, default_price_per_request: None, default_tiered_pricing: Some(json!({ "tiers": [ {"up_to": 272000, "input_price_per_1m": 5.0, "cache_creation_price_per_1m": 6.25, "cache_read_price_per_1m": 0.5, "output_price_per_1m": 30.0}, {"up_to": null, "input_price_per_1m": 10.0, "cache_creation_price_per_1m": 12.5, "cache_read_price_per_1m": 1.0, "output_price_per_1m": 45.0} ], "processing_tiers": { "flex": {"tiers": [ {"up_to": 272000, "input_price_per_1m": 2.5, "cache_creation_price_per_1m": 3.125, "cache_read_price_per_1m": 0.25, "output_price_per_1m": 15.0}, {"up_to": null, "input_price_per_1m": 5.0, "cache_creation_price_per_1m": 6.25, "cache_read_price_per_1m": 0.5, "output_price_per_1m": 22.5} ]} } })), model_tiered_pricing: Some(json!({ "processing_tiers": { "priority": {"tiers": [ {"up_to": 272000, "input_price_per_1m": 10.0, "cache_creation_price_per_1m": 12.5, "cache_read_price_per_1m": 1.0, "output_price_per_1m": 60.0} ]} } })), ..pricing() } } fn processing_usage( requested: Option<&str>, actual: Option<&str>, input_tokens: i64, ) -> BillingUsageInput { BillingUsageInput { api_format: Some("openai:responses".to_string()), requested_processing_tier: requested.map(ToOwned::to_owned), actual_processing_tier: actual.map(ToOwned::to_owned), input_tokens, cache_creation_tokens: 10, cache_ttl_minutes: Some(30), ..BillingUsageInput::new("chat") } } #[test] fn calculates_complete_snapshot_for_usage() { let result = BillingService::new() .calculate( &pricing(), &BillingUsageInput { task_type: "chat".to_string(), api_format: Some("openai:chat".to_string()), requested_processing_tier: None, actual_processing_tier: None, request_count: 1, input_tokens: 1_000, output_tokens: 500, cache_creation_tokens: 0, cache_creation_ephemeral_5m_tokens: 0, cache_creation_ephemeral_1h_tokens: 0, cache_read_tokens: 100, image_count: 0, image_size: None, image_quality: None, image_output_format: None, cache_ttl_minutes: Some(60), }, ) .expect("billing should calculate"); assert_eq!(result.cost_result.status, BillingSnapshotStatus::Complete); assert!(result.cost_result.cost > 0.0); assert!(result.actual_total_cost > 0.0); assert_eq!(result.rate_multiplier, 0.5); } #[test] fn openai_cache_hit_context_does_not_double_count_cache_read() { let result = BillingService::new() .calculate( &pricing(), &BillingUsageInput { task_type: "chat".to_string(), api_format: Some("openai:responses".to_string()), requested_processing_tier: None, actual_processing_tier: None, request_count: 1, input_tokens: 1_000, output_tokens: 10, cache_creation_tokens: 0, cache_creation_ephemeral_5m_tokens: 0, cache_creation_ephemeral_1h_tokens: 0, cache_read_tokens: 800, image_count: 0, image_size: None, image_quality: None, image_output_format: None, cache_ttl_minutes: Some(60), }, ) .expect("billing should calculate"); assert_eq!( result .cost_result .snapshot .resolved_dimensions .get("input_tokens"), Some(&json!(200)) ); assert_eq!( result .cost_result .snapshot .resolved_dimensions .get("total_input_context"), Some(&json!(1_000)) ); } #[test] fn openai_cache_write_and_read_are_billed_separately() { let result = BillingService::new() .calculate( &pricing(), &BillingUsageInput { task_type: "chat".to_string(), api_format: Some("openai:responses".to_string()), requested_processing_tier: None, actual_processing_tier: None, request_count: 1, input_tokens: 1_000, output_tokens: 10, cache_creation_tokens: 100, cache_creation_ephemeral_5m_tokens: 0, cache_creation_ephemeral_1h_tokens: 0, cache_read_tokens: 800, image_count: 0, image_size: None, image_quality: None, image_output_format: None, cache_ttl_minutes: Some(60), }, ) .expect("billing should calculate"); let dimensions = &result.cost_result.snapshot.resolved_dimensions; assert_eq!(dimensions.get("input_tokens"), Some(&json!(100))); assert_eq!(dimensions.get("cache_creation_tokens"), Some(&json!(100))); assert_eq!(dimensions.get("cache_read_tokens"), Some(&json!(800))); assert_eq!(dimensions.get("total_input_context"), Some(&json!(1_000))); let costs = &result.cost_result.snapshot.cost_breakdown; assert!(costs.get("input_cost").copied().unwrap_or_default() > 0.0); assert!( costs .get("cache_creation_uncategorized_cost") .copied() .unwrap_or_default() > 0.0 ); assert!(costs.get("cache_read_cost").copied().unwrap_or_default() > 0.0); } #[test] fn nonstandard_request_without_actual_tier_uses_requested_catalog() { let result = BillingService::new() .calculate( &processing_pricing(), &processing_usage(Some("priority"), None, 100), ) .expect("billing should calculate"); assert_eq!(result.cost_result.status, BillingSnapshotStatus::Complete); assert_eq!( result.cost_result.snapshot.resolved_dimensions["billing_processing_tier"], json!("priority") ); assert_eq!( result.cost_result.snapshot.resolved_variables["input_price_per_1m"], json!(10.0) ); } #[test] fn openai_fast_request_without_overlay_uses_global_standard_catalog() { let pricing = BillingModelPricingSnapshot { default_price_per_request: None, default_tiered_pricing: Some(json!({ "tiers": [{ "up_to": null, "input_price_per_1m": 3.0, "output_price_per_1m": 15.0 }] })), model_tiered_pricing: None, ..pricing() }; let result = BillingService::new() .calculate( &pricing, &BillingUsageInput { api_format: Some("openai:responses".to_string()), requested_processing_tier: Some("priority".to_string()), input_tokens: 1_000_000, ..BillingUsageInput::new("chat") }, ) .expect("global Standard pricing should calculate OpenAI Fast usage"); assert_eq!(result.cost_result.status, BillingSnapshotStatus::Complete); assert_eq!(result.cost_result.cost, 3.0); assert_eq!( result.pricing_resolution.tiered_pricing_source, Some(crate::BillingPricingSource::GlobalDefault) ); assert_eq!( result.cost_result.snapshot.resolved_variables["input_price_per_1m"], json!(3.0) ); } #[test] fn nonstandard_request_with_only_fixed_price_is_still_billable() { let pricing = BillingModelPricingSnapshot { default_price_per_request: Some(0.02), default_tiered_pricing: None, model_tiered_pricing: None, ..pricing() }; let result = BillingService::new() .calculate(&pricing, &processing_usage(Some("fast"), None, 1_000)) .expect("fixed request pricing should calculate Fast usage"); assert_eq!(result.cost_result.status, BillingSnapshotStatus::Complete); assert_eq!(result.cost_result.cost, 0.02); assert_eq!(result.pricing_resolution.tiered_pricing, None); assert_eq!(result.pricing_resolution.price_per_request, Some(0.02)); } #[test] fn response_actual_tier_does_not_override_requested_catalog() { let cases = ["default", "flex", "priority"]; for actual in cases { let result = BillingService::new() .calculate( &processing_pricing(), &processing_usage(Some("priority"), Some(actual), 100), ) .expect("processing tier should resolve"); assert_eq!(result.cost_result.status, BillingSnapshotStatus::Complete); assert_eq!( result.pricing_resolution.actual_processing_tier.as_deref(), Some(actual) ); assert_eq!( result.pricing_resolution.billing_processing_tier.as_deref(), Some("priority") ); assert_eq!( result.cost_result.snapshot.resolved_variables["input_price_per_1m"], json!(10.0) ); assert_eq!( result.cost_result.snapshot.resolved_variables["cache_creation_price_per_1m"], json!(12.5) ); assert_eq!( result.pricing_resolution.tiered_pricing_source, Some(BillingPricingSource::ProviderOverride) ); } } #[test] fn processing_multiplier_is_settled_and_authorized_from_standard_prices() { let pricing = BillingModelPricingSnapshot { provider_api_key_rate_multipliers: None, default_price_per_request: Some(0.02), default_tiered_pricing: Some(json!({ "tiers": [{ "up_to": null, "input_price_per_1m": 2.0, "output_price_per_1m": 4.0, "cache_creation_price_per_1m": 0.0, "cache_read_price_per_1m": 0.0 }], "processing_tiers": { "priority": {"price_multiplier": 2.5} } })), model_price_per_request: None, model_tiered_pricing: None, ..pricing() }; let usage = BillingUsageInput { api_format: Some("openai:responses".to_string()), requested_processing_tier: Some("priority".to_string()), actual_processing_tier: Some("priority".to_string()), input_tokens: 1_000_000, ..BillingUsageInput::new("chat") }; let settled = BillingService::new() .calculate(&pricing, &usage) .expect("multiplier pricing should settle"); assert_eq!(settled.cost_result.status, BillingSnapshotStatus::Complete); assert_eq!(settled.cost_result.cost, 5.02); assert_eq!(settled.actual_total_cost, 5.02); assert_eq!( settled.cost_result.snapshot.resolved_variables["input_price_per_1m"], json!(5.0) ); assert_eq!( settled.cost_result.snapshot.resolved_variables["price_per_request"], json!(0.02), "processing multiplier must not affect price_per_request" ); let mut estimate = BillingAuthorizationEstimateInput::new("chat", 1_000_000); estimate.api_format = Some("openai:responses".to_string()); estimate.requested_processing_tier = Some("priority".to_string()); estimate.max_output_tokens = Some(0); assert_eq!( BillingService::new() .estimate_authorization_cost_upper_bound(&pricing, &estimate) .expect("multiplier pricing should authorize"), Some(5.02) ); } #[test] fn invalid_processing_multiplier_fails_closed_at_settlement_and_authorization() { 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": 2.0}], "processing_tiers": { "priority": {"price_multiplier": -1.0} } })), model_tiered_pricing: None, ..pricing() }; let usage = processing_usage(Some("priority"), Some("priority"), 1_000); let settlement_error = BillingService::new() .calculate(&pricing, &usage) .expect_err("invalid multiplier settlement must return a configuration error"); assert!(settlement_error .to_string() .contains("price_multiplier must be a non-negative finite number")); let mut estimate = BillingAuthorizationEstimateInput::new("chat", 1_000); estimate.api_format = Some("openai:responses".to_string()); estimate.requested_processing_tier = Some("priority".to_string()); estimate.max_output_tokens = Some(0); let authorization_error = BillingService::new() .estimate_authorization_cost_upper_bound(&pricing, &estimate) .expect_err("invalid multiplier authorization must return a configuration error"); assert!(authorization_error .to_string() .contains("price_multiplier must be a non-negative finite number")); } #[test] fn empty_processing_catalog_cannot_turn_an_invalid_multiplier_into_zero_cost() { let pricing = BillingModelPricingSnapshot { default_price_per_request: None, default_tiered_pricing: Some(json!({ "tiers": [{"up_to": null, "input_price_per_1m": 2.0}], "processing_tiers": { "priority": { "tiers": [{}], "price_multiplier": 2.0 } } })), model_tiered_pricing: None, ..pricing() }; let usage = processing_usage(Some("priority"), Some("priority"), 1_000); let settlement_error = BillingService::new() .calculate(&pricing, &usage) .expect_err("malformed processing pricing must not settle as zero"); assert!(settlement_error .to_string() .contains("explicit catalog contains malformed or unrecognized prices")); let mut estimate = BillingAuthorizationEstimateInput::new("chat", 1_000); estimate.requested_processing_tier = Some("priority".to_string()); estimate.max_output_tokens = Some(0); let authorization_error = BillingService::new() .estimate_authorization_cost_upper_bound(&pricing, &estimate) .expect_err("malformed processing pricing must stop authorization"); assert!(authorization_error .to_string() .contains("explicit catalog contains malformed or unrecognized prices")); } #[test] fn authorization_accepts_input_only_processing_catalog() { let pricing = BillingModelPricingSnapshot { default_price_per_request: None, default_tiered_pricing: Some(json!({ "tiers": [{"up_to": null, "input_price_per_1m": 0.1}], "processing_tiers": { "embedding": { "tiers": [{ "up_to": null, "input_price_per_1m": 0.2, "output_price_per_1m": null }] } } })), model_tiered_pricing: None, ..pricing() }; let mut estimate = BillingAuthorizationEstimateInput::new("embedding", 1_000_000); estimate.requested_processing_tier = Some("embedding".to_string()); assert_eq!( BillingService::new() .estimate_authorization_cost_upper_bound(&pricing, &estimate) .expect("input-only processing catalog should be valid"), Some(0.45), "the bound includes the runtime's conservative cache-write fallback" ); } #[test] fn invalid_image_catalog_is_reported_before_the_unavailable_image_estimate() { let pricing = BillingModelPricingSnapshot { default_price_per_request: None, default_tiered_pricing: Some(json!({ "image_output_price_ranges": { "1048576": {"low": 0.04} } })), model_tiered_pricing: None, ..pricing() }; let estimate = BillingAuthorizationEstimateInput::new("image", 0); let err = BillingService::new() .estimate_authorization_cost_upper_bound(&pricing, &estimate) .expect_err("an unparseable image range must be a configuration error"); assert!(err .to_string() .contains("Standard catalog contains malformed or unrecognized prices")); } #[test] fn historical_noncanonical_processing_tier_key_is_a_configuration_error() { let pricing = BillingModelPricingSnapshot { default_tiered_pricing: Some(json!({ "tiers": [{"up_to": null, "input_price_per_1m": 1.0}], "processing_tiers": { "Priority": {"price_multiplier": 2.0} } })), model_tiered_pricing: None, ..pricing() }; let mut estimate = BillingAuthorizationEstimateInput::new("chat", 1_000); estimate.requested_processing_tier = Some("priority".to_string()); estimate.max_output_tokens = Some(0); let err = BillingService::new() .estimate_authorization_cost_upper_bound(&pricing, &estimate) .expect_err("a noncanonical historical key must not disappear during lookup"); assert!(err .to_string() .contains("must be canonical lowercase without surrounding whitespace")); } #[test] fn finite_processing_catalog_fails_only_on_requested_catalog_bounds() { let priority = BillingService::new() .calculate( &processing_pricing(), &processing_usage(Some("priority"), Some("priority"), 300_000), ) .expect("billing should calculate"); assert_eq!(priority.cost_result.status, BillingSnapshotStatus::NoRule); assert_eq!( priority.cost_result.snapshot.missing_required, vec!["input_context_tier"] ); let conflicting_actual = BillingService::new() .calculate( &processing_pricing(), &processing_usage(Some("priority"), Some("expedited"), 100), ) .expect("billing should calculate"); assert_eq!( conflicting_actual.cost_result.status, BillingSnapshotStatus::Complete ); assert_eq!( conflicting_actual .pricing_resolution .billing_processing_tier .as_deref(), Some("priority") ); } #[test] fn fast_multiplier_uses_one_fixed_first_band_above_context_threshold() { let pricing = BillingModelPricingSnapshot { default_tiered_pricing: Some(json!({ "tiers": [ { "up_to": 271999, "input_price_per_1m": 5.0, "output_price_per_1m": 30.0 }, { "up_to": null, "input_price_per_1m": 10.0, "output_price_per_1m": 45.0 } ], "processing_tiers": { "priority": {"price_multiplier": 2.0} } })), model_tiered_pricing: None, ..pricing() }; for input_tokens in [271_999, 272_000, 300_000] { let result = BillingService::new() .calculate( &pricing, &processing_usage(Some("priority"), Some("priority"), input_tokens), ) .expect("Fast pricing should settle at every context size"); assert_eq!( result.cost_result.status, BillingSnapshotStatus::Complete, "context: {input_tokens}" ); assert_eq!( result.cost_result.snapshot.resolved_variables["input_price_per_1m"], json!(10.0), "Fast must use the first Standard band at context {input_tokens}" ); assert_eq!( result.cost_result.snapshot.resolved_variables["output_price_per_1m"], json!(60.0), "Fast output must remain fixed at context {input_tokens}" ); } } #[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, expected) in [ (None, 3.75), (Some("standard"), 3.75), (Some("flex"), 1.875), ] { 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(expected), "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_does_not_override_requested_tier_or_fixed_price() { let pricing = BillingModelPricingSnapshot { default_price_per_request: Some(0.02), ..processing_pricing() }; let result = BillingService::new() .calculate( &pricing, &processing_usage(Some("priority"), Some("expedited"), 100), ) .expect("billing should calculate"); assert_eq!(result.cost_result.status, BillingSnapshotStatus::Complete); assert_eq!( result.pricing_resolution.billing_processing_tier.as_deref(), Some("priority") ); assert_eq!( result.pricing_resolution.actual_processing_tier.as_deref(), Some("expedited") ); assert_eq!(result.pricing_resolution.price_per_request, Some(0.02)); } #[test] fn authorization_estimate_bounds_only_the_requested_catalog() { let service = BillingService::new(); let mut estimate = BillingAuthorizationEstimateInput::new("chat", 100_000); estimate.api_format = Some("openai:responses".to_string()); estimate.max_output_tokens = Some(1_000_000); estimate.requested_processing_tier = Some("priority".to_string()); let priority = service .estimate_authorization_cost_upper_bound(&processing_pricing(), &estimate) .expect("priority estimate should calculate") .expect("priority estimate should be bounded"); estimate.requested_processing_tier = Some("flex".to_string()); let flex = service .estimate_authorization_cost_upper_bound(&processing_pricing(), &estimate) .expect("flex estimate should calculate") .expect("flex estimate should be bounded"); assert_eq!(priority, 61.25); assert_eq!(flex, 15.3125); assert!(priority > flex); } #[test] fn authorization_estimate_returns_none_when_the_bound_cannot_be_proven() { let service = BillingService::new(); let mut estimate = BillingAuthorizationEstimateInput::new("chat", 100); estimate.api_format = Some("openai:responses".to_string()); estimate.requested_processing_tier = Some("priority".to_string()); assert_eq!( service .estimate_authorization_cost_upper_bound(&processing_pricing(), &estimate) .expect("unbounded output estimate should resolve"), None ); estimate.max_output_tokens = Some(10); estimate.requested_processing_tier = Some("expedited".to_string()); assert_eq!( service .estimate_authorization_cost_upper_bound(&processing_pricing(), &estimate) .expect("unknown tier should inherit the effective Standard catalog"), Some(0.000925) ); estimate.requested_processing_tier = Some("priority".to_string()); estimate.input_tokens = 300_000; assert_eq!( service .estimate_authorization_cost_upper_bound(&processing_pricing(), &estimate) .expect("finite catalog estimate should resolve"), None ); } #[test] fn authorization_estimate_supports_standard_fixed_price_and_free_tier() { let service = BillingService::new(); let estimate = BillingAuthorizationEstimateInput::new("chat", 1_000); let fixed_pricing = BillingModelPricingSnapshot { default_tiered_pricing: None, default_price_per_request: Some(0.02), provider_api_key_rate_multipliers: None, ..pricing() }; assert_eq!( service .estimate_authorization_cost_upper_bound(&fixed_pricing, &estimate) .expect("fixed estimate should calculate"), Some(0.02) ); let free_pricing = BillingModelPricingSnapshot { provider_billing_type: Some("free_tier".to_string()), ..processing_pricing() }; assert_eq!( service .estimate_authorization_cost_upper_bound(&free_pricing, &estimate) .expect("free estimate should calculate"), Some(0.0) ); } #[test] fn authorization_estimate_checks_every_reachable_non_monotonic_price_tier() { let pricing = BillingModelPricingSnapshot { default_price_per_request: None, default_tiered_pricing: Some(json!({ "tiers": [ { "up_to": 10_000, "input_price_per_1m": 100.0, "output_price_per_1m": 100.0, "cache_creation_price_per_1m": 100.0, "cache_read_price_per_1m": 100.0 }, { "up_to": null, "input_price_per_1m": 1.0, "output_price_per_1m": 1.0, "cache_creation_price_per_1m": 1.0, "cache_read_price_per_1m": 1.0 } ] })), model_tiered_pricing: None, ..pricing() }; let mut estimate = BillingAuthorizationEstimateInput::new("chat", 100_000); estimate.api_format = Some("openai:responses".to_string()); estimate.max_output_tokens = Some(0); assert_eq!( BillingService::new() .estimate_authorization_cost_upper_bound(&pricing, &estimate) .expect("non-monotonic catalog should calculate"), Some(10.0) ); } #[test] fn authorization_estimate_uses_api_key_cache_read_ttl_price() { let pricing = BillingModelPricingSnapshot { provider_api_key_cache_ttl_minutes: Some(60), default_price_per_request: None, default_tiered_pricing: Some(json!({ "tiers": [{ "up_to": null, "input_price_per_1m": 1.0, "output_price_per_1m": 1.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": 1.25, "cache_read_price_per_1m": 200.0 }] }] })), model_tiered_pricing: None, ..pricing() }; let mut estimate = BillingAuthorizationEstimateInput::new("chat", 100_000); estimate.api_format = Some("openai:responses".to_string()); estimate.max_output_tokens = Some(0); assert_eq!( BillingService::new() .estimate_authorization_cost_upper_bound(&pricing, &estimate) .expect("cache read TTL catalog should calculate"), Some(20.0) ); } #[test] fn fixed_request_pricing_remains_independent_for_standard_usage() { let pricing = BillingModelPricingSnapshot { default_tiered_pricing: None, default_price_per_request: Some(0.02), ..pricing() }; let result = BillingService::new() .calculate(&pricing, &processing_usage(None, None, 1_000)) .expect("fixed request pricing should calculate"); assert_eq!(result.cost_result.status, BillingSnapshotStatus::Complete); assert_eq!(result.cost_result.cost, 0.02); assert_eq!(result.pricing_resolution.tiered_pricing, None); assert_eq!(result.pricing_resolution.price_per_request, Some(0.02)); } #[test] fn image_token_usage_without_image_output_price_bills_tokens_only() { let pricing = BillingModelPricingSnapshot { default_price_per_request: None, default_tiered_pricing: Some(json!({ "tiers": [{ "up_to": null, "input_price_per_1m": 1.0, "output_price_per_1m": 2.0 }] })), ..pricing() }; let result = BillingService::new() .calculate( &pricing, &BillingUsageInput { task_type: "image".to_string(), api_format: Some("openai:image".to_string()), requested_processing_tier: None, actual_processing_tier: None, request_count: 1, input_tokens: 1_000, output_tokens: 20_000, cache_creation_tokens: 0, cache_creation_ephemeral_5m_tokens: 0, cache_creation_ephemeral_1h_tokens: 0, cache_read_tokens: 0, image_count: 1, image_size: Some("1024x1024".to_string()), image_quality: Some("medium".to_string()), image_output_format: Some("png".to_string()), cache_ttl_minutes: None, }, ) .expect("billing should calculate"); assert_eq!( result .cost_result .snapshot .resolved_dimensions .get("image_output_pricing_mode"), Some(&json!("none")) ); assert_eq!( result .cost_result .snapshot .resolved_dimensions .get("image_count_unmetered"), Some(&json!(0)) ); assert_eq!( result .cost_result .snapshot .cost_breakdown .get("image_output_cost"), Some(&0.0) ); assert_eq!(result.cost_result.cost, 0.041); } #[test] fn image_default_output_price_adds_image_cost_even_with_token_usage() { let pricing = BillingModelPricingSnapshot { default_price_per_request: None, default_tiered_pricing: Some(json!({ "tiers": [{ "up_to": null, "input_price_per_1m": 1.0, "output_price_per_1m": 2.0 }], "image_output_price_default": 0.05 })), ..pricing() }; let result = BillingService::new() .calculate( &pricing, &BillingUsageInput { task_type: "image".to_string(), api_format: Some("openai:image".to_string()), requested_processing_tier: None, actual_processing_tier: None, request_count: 1, input_tokens: 1_000, output_tokens: 20_000, cache_creation_tokens: 0, cache_creation_ephemeral_5m_tokens: 0, cache_creation_ephemeral_1h_tokens: 0, cache_read_tokens: 0, image_count: 1, image_size: Some("1024x1024".to_string()), image_quality: Some("medium".to_string()), image_output_format: Some("png".to_string()), cache_ttl_minutes: None, }, ) .expect("billing should calculate"); assert_eq!( result .cost_result .snapshot .resolved_dimensions .get("image_output_pricing_mode"), Some(&json!("per_image")) ); assert_eq!( result .cost_result .snapshot .cost_breakdown .get("image_output_cost"), Some(&0.05) ); assert_eq!(result.cost_result.cost, 0.091); } #[test] fn image_default_output_price_generates_rule_without_token_tiers() { let pricing = BillingModelPricingSnapshot { default_price_per_request: None, default_tiered_pricing: Some(json!({ "image_output_price_default": 0.05 })), ..pricing() }; let result = BillingService::new() .calculate( &pricing, &BillingUsageInput { task_type: "image".to_string(), api_format: Some("openai:image".to_string()), requested_processing_tier: None, actual_processing_tier: None, request_count: 1, input_tokens: 0, output_tokens: 0, cache_creation_tokens: 0, cache_creation_ephemeral_5m_tokens: 0, cache_creation_ephemeral_1h_tokens: 0, cache_read_tokens: 0, image_count: 2, image_size: Some("1024x1024".to_string()), image_quality: Some("medium".to_string()), image_output_format: Some("png".to_string()), cache_ttl_minutes: None, }, ) .expect("billing should calculate"); assert_eq!(result.cost_result.status, BillingSnapshotStatus::Complete); assert_eq!( result .cost_result .snapshot .cost_breakdown .get("image_output_cost"), Some(&0.1) ); assert_eq!(result.cost_result.cost, 0.1); } #[test] fn image_pixel_ranges_generate_rule_without_token_tiers() { let pricing = BillingModelPricingSnapshot { default_price_per_request: None, default_tiered_pricing: Some(json!({ "image_output_price_ranges": [{ "up_to_pixels": null, "prices": { "medium": 0.04 } }] })), ..pricing() }; let result = BillingService::new() .calculate( &pricing, &BillingUsageInput { task_type: "image".to_string(), api_format: Some("openai:image".to_string()), requested_processing_tier: None, actual_processing_tier: None, request_count: 1, input_tokens: 0, output_tokens: 0, cache_creation_tokens: 0, cache_creation_ephemeral_5m_tokens: 0, cache_creation_ephemeral_1h_tokens: 0, cache_read_tokens: 0, image_count: 2, image_size: Some("1024x1024".to_string()), image_quality: Some("medium".to_string()), image_output_format: Some("png".to_string()), cache_ttl_minutes: None, }, ) .expect("billing should calculate"); assert_eq!(result.cost_result.status, BillingSnapshotStatus::Complete); assert_eq!( result .cost_result .snapshot .resolved_dimensions .get("image_output_pricing_mode"), Some(&json!("pixel_tiers")) ); assert_eq!( result .cost_result .snapshot .cost_breakdown .get("image_output_cost"), Some(&0.08) ); assert_eq!(result.cost_result.cost, 0.08); } #[test] fn image_token_usage_with_matrix_adds_matrix_image_cost() { let pricing = BillingModelPricingSnapshot { default_price_per_request: None, default_tiered_pricing: Some(json!({ "tiers": [{ "up_to": null, "input_price_per_1m": 1.0, "output_price_per_1m": 2.0 }], "image_output_price_default": 0.01, "image_output_prices": { "1024x1024": { "medium": 0.05 } } })), ..pricing() }; let result = BillingService::new() .calculate( &pricing, &BillingUsageInput { task_type: "image".to_string(), api_format: Some("openai:image".to_string()), requested_processing_tier: None, actual_processing_tier: None, request_count: 1, input_tokens: 1_000, output_tokens: 20_000, cache_creation_tokens: 0, cache_creation_ephemeral_5m_tokens: 0, cache_creation_ephemeral_1h_tokens: 0, cache_read_tokens: 0, image_count: 1, image_size: Some("1024x1024".to_string()), image_quality: Some("medium".to_string()), image_output_format: Some("png".to_string()), cache_ttl_minutes: None, }, ) .expect("billing should calculate"); assert_eq!( result .cost_result .snapshot .resolved_dimensions .get("image_output_pricing_mode"), Some(&json!("matrix")) ); assert_eq!( result .cost_result .snapshot .cost_breakdown .get("image_output_cost"), Some(&0.05) ); } #[test] fn image_token_usage_with_pixel_ranges_adds_range_image_cost() { let pricing = BillingModelPricingSnapshot { default_price_per_request: None, default_tiered_pricing: Some(json!({ "tiers": [{ "up_to": null, "input_price_per_1m": 1.0, "output_price_per_1m": 2.0 }], "image_output_price_default": 0.01, "image_output_price_ranges": [ { "up_to_pixels": 1_048_576, "prices": { "medium": 0.04 } }, { "up_to_pixels": 2_097_152, "prices": { "medium": 0.08 } } ] })), ..pricing() }; let result = BillingService::new() .calculate( &pricing, &BillingUsageInput { task_type: "image".to_string(), api_format: Some("openai:image".to_string()), requested_processing_tier: None, actual_processing_tier: None, request_count: 1, input_tokens: 1_000, output_tokens: 20_000, cache_creation_tokens: 0, cache_creation_ephemeral_5m_tokens: 0, cache_creation_ephemeral_1h_tokens: 0, cache_read_tokens: 0, image_count: 1, image_size: Some("1536 x 1024".to_string()), image_quality: Some("medium".to_string()), image_output_format: Some("png".to_string()), cache_ttl_minutes: None, }, ) .expect("billing should calculate"); assert_eq!( result .cost_result .snapshot .resolved_dimensions .get("image_output_pricing_mode"), Some(&json!("pixel_tiers")) ); assert_eq!( result .cost_result .snapshot .resolved_dimensions .get("image_pixels"), Some(&json!(1_572_864)) ); assert_eq!( result .cost_result .snapshot .resolved_dimensions .get("image_output_price_bucket"), Some(&json!("<=2097152px")) ); assert_eq!( result .cost_result .snapshot .resolved_variables .get("image_output_price_per_image"), Some(&json!(0.08)) ); assert_eq!( result .cost_result .snapshot .cost_breakdown .get("image_output_cost"), Some(&0.08) ); assert_eq!(result.cost_result.cost, 0.121); } #[test] fn five_minute_cache_ttl_uses_base_cache_prices() { let pricing = BillingModelPricingSnapshot { provider_id: "provider-1".to_string(), provider_billing_type: Some("pay_as_you_go".to_string()), provider_api_key_id: Some("key-1".to_string()), provider_api_key_rate_multipliers: None, provider_api_key_cache_ttl_minutes: Some(5), global_model_id: "global-model-1".to_string(), global_model_name: "gpt-5.4".to_string(), global_model_config: None, default_price_per_request: None, default_tiered_pricing: Some(json!({ "tiers": [{ "up_to": null, "input_price_per_1m": 2.5, "output_price_per_1m": 15.0, "cache_creation_price_per_1m": 3.125, "cache_read_price_per_1m": 0.25, "cache_ttl_pricing": [{ "ttl_minutes": 60, "cache_creation_price_per_1m": 5.0, "cache_read_price_per_1m": null }] }] })), model_id: None, model_provider_model_name: None, model_config: None, model_price_per_request: None, model_tiered_pricing: None, }; let result = BillingService::new() .calculate( &pricing, &BillingUsageInput { task_type: "chat".to_string(), api_format: None, requested_processing_tier: None, actual_processing_tier: None, request_count: 1, input_tokens: 1_000, output_tokens: 10, cache_creation_tokens: 0, cache_creation_ephemeral_5m_tokens: 0, cache_creation_ephemeral_1h_tokens: 0, cache_read_tokens: 100, image_count: 0, image_size: None, image_quality: None, image_output_format: None, cache_ttl_minutes: Some(5), }, ) .expect("billing should calculate"); assert_eq!( result .cost_result .snapshot .resolved_variables .get("cache_creation_price_per_1m"), Some(&json!(3.125)) ); assert_eq!( result .cost_result .snapshot .resolved_variables .get("cache_read_price_per_1m"), Some(&json!(0.25)) ); } #[test] fn one_hour_cache_ttl_keeps_base_cache_read_when_ttl_entry_omits_it() { let pricing = BillingModelPricingSnapshot { provider_id: "provider-1".to_string(), provider_billing_type: Some("pay_as_you_go".to_string()), provider_api_key_id: Some("key-1".to_string()), provider_api_key_rate_multipliers: None, provider_api_key_cache_ttl_minutes: Some(60), global_model_id: "global-model-1".to_string(), global_model_name: "gpt-5.4".to_string(), global_model_config: None, default_price_per_request: None, default_tiered_pricing: Some(json!({ "tiers": [{ "up_to": null, "input_price_per_1m": 2.5, "output_price_per_1m": 15.0, "cache_creation_price_per_1m": 3.125, "cache_read_price_per_1m": 0.25, "cache_ttl_pricing": [{ "ttl_minutes": 60, "cache_creation_price_per_1m": 5.0, "cache_read_price_per_1m": null }] }] })), model_id: None, model_provider_model_name: None, model_config: None, model_price_per_request: None, model_tiered_pricing: None, }; let result = BillingService::new() .calculate( &pricing, &BillingUsageInput { task_type: "chat".to_string(), api_format: None, requested_processing_tier: None, actual_processing_tier: None, request_count: 1, input_tokens: 1_000, output_tokens: 10, cache_creation_tokens: 0, cache_creation_ephemeral_5m_tokens: 0, cache_creation_ephemeral_1h_tokens: 0, cache_read_tokens: 100, image_count: 0, image_size: None, image_quality: None, image_output_format: None, cache_ttl_minutes: Some(60), }, ) .expect("billing should calculate"); assert_eq!( result .cost_result .snapshot .resolved_variables .get("cache_creation_price_per_1m"), Some(&json!(5.0)) ); assert_eq!( result .cost_result .snapshot .resolved_variables .get("cache_read_price_per_1m"), Some(&json!(0.25)) ); } }