feat(openai): unify tier authorization and settlement

This commit is contained in:
MMEXA
2026-07-11 12:27:05 +08:00
parent b2f596b8f0
commit 0b30cc6b0f
32 changed files with 3170 additions and 670 deletions
+706 -49
View File
@@ -8,7 +8,10 @@ use crate::default_rule::{
explicit_image_output_price_ranges, normalize_task_type, DefaultBillingRuleGenerator,
};
use crate::precision::quantize_cost;
use crate::pricing::{BillingComputation, BillingModelPricingSnapshot, BillingUsageInput};
use crate::pricing::{
BillingAuthorizationEstimateInput, BillingComputation, BillingModelPricingSnapshot,
BillingPricingResolution, BillingUsageInput,
};
use crate::schema::{
BillingSnapshot, BillingSnapshotStatus, CostResult, BILLING_SNAPSHOT_SCHEMA_VERSION,
};
@@ -33,39 +36,199 @@ impl BillingService {
pricing: &BillingModelPricingSnapshot,
input: &BillingUsageInput,
) -> Result<BillingComputation, ExpressionEvaluationError> {
let Some(rule) =
DefaultBillingRuleGenerator::generate_for_pricing(pricing, &input.task_type)
else {
return Ok(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: build_dimensions(input, pricing),
resolved_variables: BTreeMap::new(),
cost_breakdown: BTreeMap::new(),
total_cost: 0.0,
tier_index: None,
tier_info: None,
missing_required: Vec::new(),
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(),
});
let pricing_resolution = pricing.resolve_pricing(
input.requested_processing_tier.as_deref(),
input.actual_processing_tier.as_deref(),
);
self.calculate_with_resolution(pricing, input, pricing_resolution)
}
pub fn estimate_authorization_cost_upper_bound(
&self,
pricing: &BillingModelPricingSnapshot,
estimate: &BillingAuthorizationEstimateInput,
) -> Result<Option<f64>, ExpressionEvaluationError> {
if normalize_task_type(&estimate.task_type) == "image" {
return Ok(None);
}
let Some(pricing_resolutions) = pricing.resolve_authorization_pricing_candidates(
estimate.requested_processing_tier.as_deref(),
) else {
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: 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);
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;
for pricing_resolution in pricing_resolutions {
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) {
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<BillingComputation, ExpressionEvaluationError> {
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()
{
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);
let dims = build_dimensions(input, &pricing_resolution);
let result = self.engine.evaluate(
&rule.expression,
Some(&rule.variables),
@@ -116,10 +279,139 @@ impl BillingService {
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 authorization_pricing_candidates(
pricing: &BillingPricingResolution,
max_input_context: i64,
) -> Option<Vec<BillingPricingResolution>> {
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<i64> = 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<i64> {
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()
@@ -128,7 +420,7 @@ impl Default for BillingService {
fn build_dimensions(
input: &BillingUsageInput,
pricing: &BillingModelPricingSnapshot,
pricing: &BillingPricingResolution,
) -> BTreeMap<String, Value> {
let normalized_input_tokens = normalize_input_tokens_for_billing(
input.api_format.as_deref(),
@@ -149,8 +441,9 @@ fn build_dimensions(
input.cache_creation_tokens,
input.cache_read_tokens,
);
let image_output_pricing = image_output_pricing_state(pricing);
let image_output_resolution = resolve_image_output_price_resolution(pricing, input);
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)),
@@ -216,6 +509,18 @@ fn build_dimensions(
"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(
@@ -304,7 +609,7 @@ struct ParsedImageOutputPriceRange {
prices: BTreeMap<String, f64>,
}
fn image_output_pricing_state(pricing: &BillingModelPricingSnapshot) -> ImageOutputPricingState {
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);
@@ -316,11 +621,10 @@ fn image_output_pricing_state(pricing: &BillingModelPricingSnapshot) -> ImageOut
}
fn resolve_image_output_price_resolution(
pricing: &BillingModelPricingSnapshot,
pricing: Option<&Value>,
input: &BillingUsageInput,
) -> ImageOutputPriceResolution {
let pricing_config = pricing.effective_tiered_pricing();
let default_price = explicit_image_output_price_default(pricing_config);
let default_price = explicit_image_output_price_default(pricing);
let image_size = input
.image_size
.as_deref()
@@ -335,7 +639,7 @@ fn resolve_image_output_price_resolution(
if let (Some(size), Some(entries)) = (
image_size.as_deref(),
explicit_image_output_price_entries(pricing_config),
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) {
@@ -351,7 +655,7 @@ fn resolve_image_output_price_resolution(
if let Some(pixels) = image_pixels {
if let Some((price, bucket)) = resolve_image_output_range_price(
explicit_image_output_price_ranges(pricing_config).unwrap_or_default(),
explicit_image_output_price_ranges(pricing).unwrap_or_default(),
pixels,
image_quality.as_deref(),
default_price,
@@ -382,8 +686,8 @@ fn resolve_image_output_price_resolution(
}
}
fn pricing_has_image_output_matrix(pricing: &BillingModelPricingSnapshot) -> bool {
let Some(config) = pricing.effective_tiered_pricing() else {
fn pricing_has_image_output_matrix(pricing: Option<&Value>) -> bool {
let Some(config) = pricing else {
return false;
};
[
@@ -400,13 +704,12 @@ fn pricing_has_image_output_matrix(pricing: &BillingModelPricingSnapshot) -> boo
})
}
fn pricing_has_image_output_ranges(pricing: &BillingModelPricingSnapshot) -> bool {
explicit_image_output_price_ranges(pricing.effective_tiered_pricing())
.is_some_and(|ranges| !ranges.is_empty())
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: &BillingModelPricingSnapshot) -> bool {
let Some(config) = pricing.effective_tiered_pricing() else {
fn pricing_has_image_output_default_price(pricing: Option<&Value>) -> bool {
let Some(config) = pricing else {
return false;
};
config
@@ -586,7 +889,10 @@ mod tests {
use serde_json::json;
use super::BillingService;
use crate::{BillingModelPricingSnapshot, BillingSnapshotStatus, BillingUsageInput};
use crate::{
BillingAuthorizationEstimateInput, BillingModelPricingSnapshot, BillingPricingSource,
BillingSnapshotStatus, BillingUsageInput,
};
fn pricing() -> BillingModelPricingSnapshot {
BillingModelPricingSnapshot {
@@ -616,6 +922,49 @@ mod tests {
}
}
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()
@@ -624,6 +973,8 @@ mod tests {
&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,
@@ -654,6 +1005,8 @@ mod tests {
&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,
@@ -696,6 +1049,8 @@ mod tests {
&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,
@@ -730,6 +1085,292 @@ mod tests {
assert!(costs.get("cache_read_cost").copied().unwrap_or_default() > 0.0);
}
#[test]
fn nonstandard_request_without_actual_tier_fails_closed() {
let result = BillingService::new()
.calculate(
&processing_pricing(),
&processing_usage(Some("priority"), None, 100),
)
.expect("billing should calculate");
assert_eq!(result.cost_result.status, BillingSnapshotStatus::NoRule);
assert_eq!(
result.cost_result.snapshot.missing_required,
vec!["actual_processing_tier"]
);
assert_eq!(
result.cost_result.snapshot.resolved_dimensions["billing_processing_tier"],
json!(null)
);
}
#[test]
fn actual_tier_controls_standard_flex_and_priority_catalogs() {
let cases = [
(
"default",
100,
5.0,
6.25,
BillingPricingSource::GlobalDefault,
),
("flex", 100, 2.5, 3.125, BillingPricingSource::GlobalDefault),
(
"priority",
100,
10.0,
12.5,
BillingPricingSource::ProviderOverride,
),
];
for (actual, input_tokens, input_price, cache_write_price, source) in cases {
let result = BillingService::new()
.calculate(
&processing_pricing(),
&processing_usage(Some("priority"), Some(actual), input_tokens),
)
.expect("processing tier should resolve");
assert_eq!(result.cost_result.status, BillingSnapshotStatus::Complete);
assert_eq!(
result.cost_result.snapshot.resolved_variables["input_price_per_1m"],
json!(input_price)
);
assert_eq!(
result.cost_result.snapshot.resolved_variables["cache_creation_price_per_1m"],
json!(cache_write_price)
);
assert_eq!(
result.pricing_resolution.tiered_pricing_source,
Some(source)
);
}
}
#[test]
fn finite_processing_catalog_and_unknown_actual_tier_fail_closed() {
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 unknown = BillingService::new()
.calculate(
&processing_pricing(),
&processing_usage(Some("priority"), Some("expedited"), 100),
)
.expect("billing should calculate");
assert_eq!(unknown.cost_result.status, BillingSnapshotStatus::NoRule);
assert_eq!(
unknown.cost_result.snapshot.missing_required,
vec!["processing_tier_catalog"]
);
}
#[test]
fn unknown_actual_tier_cannot_fall_back_to_fixed_request_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::NoRule);
assert_eq!(
result.cost_result.snapshot.missing_required,
vec!["processing_tier_catalog"]
);
assert_eq!(result.pricing_resolution.price_per_request, Some(0.02));
}
#[test]
fn authorization_estimate_bounds_requested_and_provider_actual_catalogs() {
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, 61.25);
assert_eq!(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 estimate should resolve"),
None
);
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 {
@@ -750,6 +1391,8 @@ mod tests {
&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,
@@ -814,6 +1457,8 @@ mod tests {
&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,
@@ -865,6 +1510,8 @@ mod tests {
&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,
@@ -912,6 +1559,8 @@ mod tests {
&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,
@@ -972,6 +1621,8 @@ mod tests {
&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,
@@ -1037,6 +1688,8 @@ mod tests {
&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,
@@ -1135,6 +1788,8 @@ mod tests {
&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,
@@ -1208,6 +1863,8 @@ mod tests {
&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,