feat(pricing): support processing tier multipliers

This commit is contained in:
elky
2026-07-16 23:30:42 +08:00
parent 312583f055
commit 0be380243b
40 changed files with 5012 additions and 565 deletions
+19 -1
View File
@@ -379,6 +379,7 @@ fn build_settlement_snapshot(
"billing_processing_tier": resolution.billing_processing_tier,
"pricing_source": resolution.pricing_source(),
"tiered_pricing_source": resolution.tiered_pricing_source.map(|source| source.as_str()),
"processing_tier_price_multiplier": resolution.processing_tier_price_multiplier,
"price_per_request_source": resolution.price_per_request_source.map(|source| source.as_str()),
"tiered_pricing": resolution.tiered_pricing,
"price_per_request": resolution.price_per_request,
@@ -460,6 +461,22 @@ mod tests {
assert_eq!(tiers.actual.as_deref(), Some("default"));
}
#[test]
fn processing_tier_facts_recognize_anthropic_fast_speed() {
let data = UsageEventData {
provider_request_body: Some(json!({"speed": "fast"})),
response_body: Some(json!({
"usage": {"speed": "fast", "service_tier": "standard"}
})),
..UsageEventData::default()
};
let tiers = usage_event_processing_tiers(&data);
assert_eq!(tiers.requested.as_deref(), Some("fast"));
assert_eq!(tiers.actual.as_deref(), Some("fast"));
}
#[tokio::test]
async fn settlement_uses_effective_gpt_5_6_cache_ttl_after_body_capture() {
let lookup = TestLookup {
@@ -637,7 +654,7 @@ mod tests {
Some(json!({
"tiers": [{"up_to": null, "input_price_per_1m": 5.0, "output_price_per_1m": 30.0}],
"processing_tiers": {
"flex": {"tiers": [{"up_to": null, "input_price_per_1m": 2.5, "output_price_per_1m": 15.0}]}
"flex": {"price_multiplier": 0.5}
}
})),
Some("model-1".to_string()),
@@ -692,6 +709,7 @@ mod tests {
assert_eq!(pricing_snapshot["actual_processing_tier"], "flex");
assert_eq!(pricing_snapshot["billing_processing_tier"], "flex");
assert_eq!(pricing_snapshot["tiered_pricing_source"], "global_default");
assert_eq!(pricing_snapshot["processing_tier_price_multiplier"], 0.5);
assert_eq!(
pricing_snapshot["tiered_pricing"]["tiers"][0]["input_price_per_1m"],
2.5
+2 -1
View File
@@ -24,7 +24,8 @@ pub use precision::{
};
pub use pricing::{
BillingAuthorizationEstimateInput, BillingComputation, BillingModelPricingSnapshot,
BillingPricingResolution, BillingPricingSource, BillingUsageInput,
BillingPricingConfigurationError, BillingPricingResolution, BillingPricingSource,
BillingUsageInput,
};
pub use schema::{
BillingSnapshot, BillingSnapshotStatus, CostResult, BILLING_SNAPSHOT_SCHEMA_VERSION,
File diff suppressed because it is too large Load Diff
+217 -9
View File
@@ -36,10 +36,12 @@ impl BillingService {
pricing: &BillingModelPricingSnapshot,
input: &BillingUsageInput,
) -> Result<BillingComputation, ExpressionEvaluationError> {
let pricing_resolution = pricing.resolve_pricing(
input.requested_processing_tier.as_deref(),
input.actual_processing_tier.as_deref(),
);
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)
}
@@ -48,14 +50,15 @@ impl BillingService {
pricing: &BillingModelPricingSnapshot,
estimate: &BillingAuthorizationEstimateInput,
) -> Result<Option<f64>, 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);
}
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));
}
@@ -1164,6 +1167,211 @@ mod tests {
}
}
#[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_and_unknown_actual_tier_fail_closed() {
let priority = BillingService::new()