mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 16:37:46 +08:00
feat(openai): unify tier authorization and settlement
This commit is contained in:
@@ -3,7 +3,7 @@ use std::collections::BTreeMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::pricing::BillingModelPricingSnapshot;
|
||||
use crate::pricing::BillingPricingResolution;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct VirtualBillingRule {
|
||||
@@ -20,12 +20,12 @@ pub struct DefaultBillingRuleGenerator;
|
||||
|
||||
impl DefaultBillingRuleGenerator {
|
||||
pub fn generate_for_pricing(
|
||||
pricing: &BillingModelPricingSnapshot,
|
||||
global_model_name: &str,
|
||||
pricing: &BillingPricingResolution,
|
||||
task_type: &str,
|
||||
) -> Option<VirtualBillingRule> {
|
||||
let pricing_config = pricing.effective_tiered_pricing();
|
||||
let tiers = pricing
|
||||
.effective_tiered_pricing()
|
||||
let pricing_config = pricing.tiered_pricing.as_ref();
|
||||
let tiers = pricing_config
|
||||
.and_then(|value| value.get("tiers"))
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
@@ -41,10 +41,7 @@ impl DefaultBillingRuleGenerator {
|
||||
|| has_image_output_ranges
|
||||
|| explicit_image_output_price_default.is_some();
|
||||
|
||||
if tiers.is_empty()
|
||||
&& pricing.effective_price_per_request().is_none()
|
||||
&& !has_image_output_pricing
|
||||
{
|
||||
if tiers.is_empty() && pricing.price_per_request.is_none() && !has_image_output_pricing {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -55,7 +52,7 @@ impl DefaultBillingRuleGenerator {
|
||||
tier_value_with_fallback(&first_tier, "cache_creation_price_per_1m", 1.25);
|
||||
let base_cache_read_price =
|
||||
tier_value_with_fallback(&first_tier, "cache_read_price_per_1m", 0.1);
|
||||
let base_request_price = pricing.effective_price_per_request().unwrap_or(0.0);
|
||||
let base_request_price = pricing.price_per_request.unwrap_or(0.0);
|
||||
|
||||
let mut variables = BTreeMap::new();
|
||||
variables.insert("input_price_per_1m".to_string(), json!(base_input_price));
|
||||
@@ -237,7 +234,7 @@ impl DefaultBillingRuleGenerator {
|
||||
|
||||
Some(VirtualBillingRule {
|
||||
id: "__default__".to_string(),
|
||||
name: format!("Default rule for {}", pricing.global_model_name),
|
||||
name: format!("Default rule for {global_model_name}"),
|
||||
task_type: normalize_task_type(task_type).to_string(),
|
||||
expression: "input_cost + output_cost + cache_creation_uncategorized_cost + cache_creation_ephemeral_5m_cost + cache_creation_ephemeral_1h_cost + cache_read_cost + image_output_cost + request_cost".to_string(),
|
||||
variables,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
use aether_data_contracts::repository::billing::StoredBillingModelContext;
|
||||
use aether_data_contracts::repository::usage::{
|
||||
extract_provider_actual_service_tier_from_response, extract_provider_service_tier_from_body,
|
||||
normalize_provider_service_tier, PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY,
|
||||
PROVIDER_SERVICE_TIER_METADATA_KEY,
|
||||
};
|
||||
use aether_data_contracts::DataLayerError;
|
||||
use aether_usage_runtime::{UsageEvent, UsageEventType};
|
||||
use async_trait::async_trait;
|
||||
@@ -137,6 +142,7 @@ fn calculate_billing_computation(
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let processing_tiers = usage_event_processing_tiers(&event.data);
|
||||
let input = BillingUsageInput {
|
||||
task_type: if is_image_usage {
|
||||
"image".to_string()
|
||||
@@ -152,6 +158,8 @@ fn calculate_billing_computation(
|
||||
.endpoint_api_format
|
||||
.clone()
|
||||
.or_else(|| event.data.api_format.clone()),
|
||||
requested_processing_tier: processing_tiers.requested,
|
||||
actual_processing_tier: processing_tiers.actual,
|
||||
request_count,
|
||||
input_tokens: event.data.input_tokens.unwrap_or_default() as i64,
|
||||
output_tokens: event.data.output_tokens.unwrap_or_default() as i64,
|
||||
@@ -179,6 +187,34 @@ fn calculate_billing_computation(
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct UsageEventProcessingTiers {
|
||||
requested: Option<String>,
|
||||
actual: Option<String>,
|
||||
}
|
||||
|
||||
fn usage_event_processing_tiers(
|
||||
data: &aether_usage_runtime::UsageEventData,
|
||||
) -> UsageEventProcessingTiers {
|
||||
let metadata = data.request_metadata.as_ref().and_then(Value::as_object);
|
||||
let requested = extract_provider_service_tier_from_body(data.provider_request_body.as_ref())
|
||||
.or_else(|| {
|
||||
metadata
|
||||
.and_then(|metadata| metadata.get(PROVIDER_SERVICE_TIER_METADATA_KEY))
|
||||
.and_then(Value::as_str)
|
||||
.and_then(normalize_provider_service_tier)
|
||||
});
|
||||
let actual = metadata
|
||||
.and_then(|metadata| metadata.get(PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY))
|
||||
.and_then(Value::as_str)
|
||||
.and_then(normalize_provider_service_tier)
|
||||
.or_else(|| {
|
||||
extract_provider_actual_service_tier_from_response(data.response_body.as_ref())
|
||||
});
|
||||
|
||||
UsageEventProcessingTiers { requested, actual }
|
||||
}
|
||||
|
||||
fn usage_event_is_image_usage(data: &aether_usage_runtime::UsageEventData) -> bool {
|
||||
data.request_type
|
||||
.as_deref()
|
||||
@@ -262,54 +298,23 @@ fn apply_billing_computation(
|
||||
) -> Result<(), DataLayerError> {
|
||||
event.data.total_cost_usd = Some(computation.cost_result.cost);
|
||||
event.data.actual_total_cost_usd = Some(computation.actual_total_cost);
|
||||
merge_billing_snapshot_metadata(
|
||||
&mut event.data.request_metadata,
|
||||
pricing,
|
||||
&computation.cost_result.snapshot,
|
||||
computation.actual_total_cost,
|
||||
computation.rate_multiplier,
|
||||
computation.is_free_tier,
|
||||
)
|
||||
merge_billing_snapshot_metadata(&mut event.data.request_metadata, pricing, &computation)
|
||||
}
|
||||
|
||||
fn map_pricing_context(context: StoredBillingModelContext) -> BillingModelPricingSnapshot {
|
||||
BillingModelPricingSnapshot {
|
||||
provider_id: context.provider_id,
|
||||
provider_billing_type: context.provider_billing_type,
|
||||
provider_api_key_id: context.provider_api_key_id,
|
||||
provider_api_key_rate_multipliers: context.provider_api_key_rate_multipliers,
|
||||
provider_api_key_cache_ttl_minutes: context.provider_api_key_cache_ttl_minutes,
|
||||
global_model_id: context.global_model_id,
|
||||
global_model_name: context.global_model_name,
|
||||
global_model_config: context.global_model_config,
|
||||
default_price_per_request: context.default_price_per_request,
|
||||
default_tiered_pricing: context.default_tiered_pricing,
|
||||
model_id: context.model_id,
|
||||
model_provider_model_name: context.model_provider_model_name,
|
||||
model_config: context.model_config,
|
||||
model_price_per_request: context.model_price_per_request,
|
||||
model_tiered_pricing: context.model_tiered_pricing,
|
||||
}
|
||||
context.into()
|
||||
}
|
||||
|
||||
fn merge_billing_snapshot_metadata(
|
||||
request_metadata: &mut Option<Value>,
|
||||
pricing: &BillingModelPricingSnapshot,
|
||||
snapshot: &crate::BillingSnapshot,
|
||||
actual_total_cost: f64,
|
||||
rate_multiplier: f64,
|
||||
is_free_tier: bool,
|
||||
computation: &BillingComputation,
|
||||
) -> Result<(), DataLayerError> {
|
||||
let snapshot = &computation.cost_result.snapshot;
|
||||
let billing_snapshot = serde_json::to_value(snapshot).map_err(|err| {
|
||||
DataLayerError::UnexpectedValue(format!("failed to serialize billing snapshot: {err}"))
|
||||
})?;
|
||||
let settlement_snapshot = build_settlement_snapshot(
|
||||
pricing,
|
||||
snapshot,
|
||||
actual_total_cost,
|
||||
rate_multiplier,
|
||||
is_free_tier,
|
||||
);
|
||||
let settlement_snapshot = build_settlement_snapshot(pricing, computation);
|
||||
|
||||
let mut metadata = match request_metadata.take() {
|
||||
Some(Value::Object(object)) => object,
|
||||
@@ -325,19 +330,24 @@ fn merge_billing_snapshot_metadata(
|
||||
"billing_dimensions".to_string(),
|
||||
Value::Object(snapshot.resolved_dimensions.clone().into_iter().collect()),
|
||||
);
|
||||
metadata.insert("rate_multiplier".to_string(), Value::from(rate_multiplier));
|
||||
metadata.insert("is_free_tier".to_string(), Value::from(is_free_tier));
|
||||
metadata.insert(
|
||||
"rate_multiplier".to_string(),
|
||||
Value::from(computation.rate_multiplier),
|
||||
);
|
||||
metadata.insert(
|
||||
"is_free_tier".to_string(),
|
||||
Value::from(computation.is_free_tier),
|
||||
);
|
||||
*request_metadata = Some(Value::Object(metadata));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_settlement_snapshot(
|
||||
pricing: &BillingModelPricingSnapshot,
|
||||
snapshot: &crate::BillingSnapshot,
|
||||
actual_total_cost: f64,
|
||||
rate_multiplier: f64,
|
||||
is_free_tier: bool,
|
||||
computation: &BillingComputation,
|
||||
) -> Value {
|
||||
let snapshot = &computation.cost_result.snapshot;
|
||||
let resolution = &computation.pricing_resolution;
|
||||
json!({
|
||||
"schema_version": SETTLEMENT_SNAPSHOT_SCHEMA_VERSION,
|
||||
"pricing_snapshot": {
|
||||
@@ -348,11 +358,16 @@ fn build_settlement_snapshot(
|
||||
"global_model_name": pricing.global_model_name.clone(),
|
||||
"model_id": pricing.model_id.clone(),
|
||||
"provider_model_name": pricing.model_provider_model_name.clone(),
|
||||
"pricing_source": pricing.pricing_source(),
|
||||
"tiered_pricing": pricing.effective_tiered_pricing().cloned(),
|
||||
"price_per_request": pricing.effective_price_per_request(),
|
||||
"rate_multiplier": rate_multiplier,
|
||||
"is_free_tier": is_free_tier,
|
||||
"requested_processing_tier": resolution.requested_processing_tier,
|
||||
"actual_processing_tier": resolution.actual_processing_tier,
|
||||
"billing_processing_tier": resolution.billing_processing_tier,
|
||||
"pricing_source": resolution.pricing_source(),
|
||||
"tiered_pricing_source": resolution.tiered_pricing_source.map(|source| source.as_str()),
|
||||
"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,
|
||||
"rate_multiplier": computation.rate_multiplier,
|
||||
"is_free_tier": computation.is_free_tier,
|
||||
},
|
||||
"billing_plan_snapshot": {
|
||||
"rule_id": snapshot.rule_id.clone(),
|
||||
@@ -365,7 +380,7 @@ fn build_settlement_snapshot(
|
||||
"resolved_variables": snapshot.resolved_variables.clone(),
|
||||
"cost_breakdown": snapshot.cost_breakdown.clone(),
|
||||
"total_cost": snapshot.total_cost,
|
||||
"actual_total_cost": actual_total_cost,
|
||||
"actual_total_cost": computation.actual_total_cost,
|
||||
"status": snapshot.status,
|
||||
"calculated_at": snapshot.calculated_at.clone(),
|
||||
})
|
||||
@@ -379,7 +394,9 @@ mod tests {
|
||||
use serde_json::json;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{enrich_usage_event_with_billing, BillingModelContextLookup};
|
||||
use super::{
|
||||
enrich_usage_event_with_billing, usage_event_processing_tiers, BillingModelContextLookup,
|
||||
};
|
||||
|
||||
struct TestLookup {
|
||||
name_context: Option<StoredBillingModelContext>,
|
||||
@@ -409,6 +426,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processing_tier_facts_keep_request_and_terminal_response_independent() {
|
||||
let data = UsageEventData {
|
||||
provider_request_body: Some(json!({"service_tier": "Priority"})),
|
||||
response_body: Some(json!({"service_tier": "priority"})),
|
||||
request_metadata: Some(json!({
|
||||
"provider_service_tier": "batch",
|
||||
"provider_actual_service_tier": "Default"
|
||||
})),
|
||||
..UsageEventData::default()
|
||||
};
|
||||
|
||||
let tiers = usage_event_processing_tiers(&data);
|
||||
|
||||
assert_eq!(tiers.requested.as_deref(), Some("priority"));
|
||||
assert_eq!(tiers.actual.as_deref(), Some("default"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enriches_completed_usage_event_with_billing_snapshot() {
|
||||
let lookup = TestLookup {
|
||||
@@ -471,6 +506,181 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settlement_uses_actual_processing_tier_catalog_and_source() {
|
||||
let lookup = TestLookup {
|
||||
name_context: Some(
|
||||
StoredBillingModelContext::new(
|
||||
"provider-1".to_string(),
|
||||
Some("pay_as_you_go".to_string()),
|
||||
Some("key-1".to_string()),
|
||||
None,
|
||||
Some(60),
|
||||
"global-model-1".to_string(),
|
||||
"gpt-5.6".to_string(),
|
||||
None,
|
||||
None,
|
||||
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}]}
|
||||
}
|
||||
})),
|
||||
Some("model-1".to_string()),
|
||||
Some("gpt-5.6-upstream".to_string()),
|
||||
None,
|
||||
None,
|
||||
Some(json!({
|
||||
"processing_tiers": {
|
||||
"priority": {"tiers": [{"up_to": 272000, "input_price_per_1m": 10.0, "output_price_per_1m": 60.0}]}
|
||||
}
|
||||
})),
|
||||
)
|
||||
.expect("billing context should build"),
|
||||
),
|
||||
model_id_context: None,
|
||||
};
|
||||
let mut event = UsageEvent::new(
|
||||
UsageEventType::Completed,
|
||||
"req-billing-tier-1",
|
||||
UsageEventData {
|
||||
provider_name: "OpenAI".to_string(),
|
||||
model: "gpt-5.6".to_string(),
|
||||
provider_id: Some("provider-1".to_string()),
|
||||
provider_api_key_id: Some("key-1".to_string()),
|
||||
request_type: Some("chat".to_string()),
|
||||
api_format: Some("openai:responses".to_string()),
|
||||
endpoint_api_format: Some("openai:responses".to_string()),
|
||||
provider_request_body: Some(json!({"service_tier": "priority"})),
|
||||
response_body: Some(json!({"service_tier": "priority"})),
|
||||
request_metadata: Some(json!({
|
||||
"provider_service_tier": "priority",
|
||||
"provider_actual_service_tier": "flex"
|
||||
})),
|
||||
input_tokens: Some(1_000),
|
||||
output_tokens: Some(100),
|
||||
status_code: Some(200),
|
||||
..UsageEventData::default()
|
||||
},
|
||||
);
|
||||
|
||||
enrich_usage_event_with_billing(&lookup, &mut event)
|
||||
.await
|
||||
.expect("billing should succeed");
|
||||
|
||||
let pricing_snapshot = event
|
||||
.data
|
||||
.request_metadata
|
||||
.as_ref()
|
||||
.and_then(|value| value.pointer("/settlement_snapshot/pricing_snapshot"))
|
||||
.expect("settlement pricing snapshot should exist");
|
||||
assert_eq!(pricing_snapshot["requested_processing_tier"], "priority");
|
||||
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["tiered_pricing"]["tiers"][0]["input_price_per_1m"],
|
||||
2.5
|
||||
);
|
||||
assert_eq!(
|
||||
event
|
||||
.data
|
||||
.request_metadata
|
||||
.as_ref()
|
||||
.and_then(|value| { value.pointer("/billing_dimensions/actual_processing_tier") }),
|
||||
Some(&json!("flex"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_processing_catalog_controls_image_price_with_independent_fixed_price() {
|
||||
let lookup = TestLookup {
|
||||
name_context: Some(
|
||||
StoredBillingModelContext::new(
|
||||
"provider-1".to_string(),
|
||||
Some("pay_as_you_go".to_string()),
|
||||
Some("key-1".to_string()),
|
||||
None,
|
||||
None,
|
||||
"global-image-1".to_string(),
|
||||
"gpt-image-2".to_string(),
|
||||
None,
|
||||
Some(0.01),
|
||||
Some(json!({
|
||||
"image_output_price_default": 0.1,
|
||||
"processing_tiers": {
|
||||
"flex": {"image_output_price_default": 0.2}
|
||||
}
|
||||
})),
|
||||
Some("model-image-1".to_string()),
|
||||
Some("gpt-image-2".to_string()),
|
||||
None,
|
||||
Some(0.02),
|
||||
Some(json!({
|
||||
"processing_tiers": {
|
||||
"priority": {"image_output_price_default": 0.4}
|
||||
}
|
||||
})),
|
||||
)
|
||||
.expect("billing context should build"),
|
||||
),
|
||||
model_id_context: None,
|
||||
};
|
||||
let mut event = UsageEvent::new(
|
||||
UsageEventType::Completed,
|
||||
"req-image-processing-tier-1",
|
||||
UsageEventData {
|
||||
provider_name: "OpenAI Image".to_string(),
|
||||
model: "gpt-image-2".to_string(),
|
||||
provider_id: Some("provider-1".to_string()),
|
||||
provider_api_key_id: Some("key-1".to_string()),
|
||||
request_type: Some("image".to_string()),
|
||||
api_format: Some("openai:image".to_string()),
|
||||
endpoint_api_format: Some("openai:image".to_string()),
|
||||
provider_request_body: Some(json!({"service_tier": "priority"})),
|
||||
request_metadata: Some(json!({
|
||||
"provider_actual_service_tier": "flex",
|
||||
"dimensions": {"image_count": 2}
|
||||
})),
|
||||
status_code: Some(200),
|
||||
..UsageEventData::default()
|
||||
},
|
||||
);
|
||||
|
||||
enrich_usage_event_with_billing(&lookup, &mut event)
|
||||
.await
|
||||
.expect("billing should succeed");
|
||||
|
||||
assert_eq!(event.data.total_cost_usd, Some(0.44));
|
||||
assert_eq!(event.data.actual_total_cost_usd, Some(0.44));
|
||||
let metadata = event.data.request_metadata.as_ref().expect("metadata");
|
||||
let pricing = metadata
|
||||
.pointer("/settlement_snapshot/pricing_snapshot")
|
||||
.expect("pricing snapshot");
|
||||
assert_eq!(pricing["billing_processing_tier"], "flex");
|
||||
assert_eq!(pricing["tiered_pricing_source"], "global_default");
|
||||
assert_eq!(pricing["price_per_request_source"], "provider_override");
|
||||
assert_eq!(pricing["pricing_source"], "mixed");
|
||||
assert_eq!(
|
||||
metadata
|
||||
.pointer("/billing_snapshot/resolved_variables/image_output_price_per_image")
|
||||
.and_then(Value::as_f64),
|
||||
Some(0.2)
|
||||
);
|
||||
assert_eq!(
|
||||
metadata
|
||||
.pointer("/billing_snapshot/cost_breakdown/image_output_cost")
|
||||
.and_then(Value::as_f64),
|
||||
Some(0.4)
|
||||
);
|
||||
assert_eq!(
|
||||
metadata
|
||||
.pointer("/billing_snapshot/cost_breakdown/request_cost")
|
||||
.and_then(Value::as_f64),
|
||||
Some(0.04)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_usage_uses_image_count_for_request_cost() {
|
||||
let lookup = TestLookup {
|
||||
|
||||
@@ -22,7 +22,10 @@ pub use precision::{
|
||||
quantize_cost, quantize_display, quantize_value, BILLING_DISPLAY_PRECISION,
|
||||
BILLING_STORAGE_PRECISION,
|
||||
};
|
||||
pub use pricing::{BillingComputation, BillingModelPricingSnapshot, BillingUsageInput};
|
||||
pub use pricing::{
|
||||
BillingAuthorizationEstimateInput, BillingComputation, BillingModelPricingSnapshot,
|
||||
BillingPricingResolution, BillingPricingSource, BillingUsageInput,
|
||||
};
|
||||
pub use schema::{
|
||||
BillingSnapshot, BillingSnapshotStatus, CostResult, BILLING_SNAPSHOT_SCHEMA_VERSION,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,58 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use aether_data_contracts::repository::{
|
||||
billing::StoredBillingModelContext, usage::normalize_provider_service_tier,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BillingPricingSource {
|
||||
ProviderOverride,
|
||||
GlobalDefault,
|
||||
}
|
||||
|
||||
impl BillingPricingSource {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::ProviderOverride => "provider_override",
|
||||
Self::GlobalDefault => "global_default",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BillingPricingResolution {
|
||||
pub requested_processing_tier: Option<String>,
|
||||
pub actual_processing_tier: Option<String>,
|
||||
pub billing_processing_tier: Option<String>,
|
||||
pub tiered_pricing: Option<Value>,
|
||||
pub tiered_pricing_source: Option<BillingPricingSource>,
|
||||
pub price_per_request: Option<f64>,
|
||||
pub price_per_request_source: Option<BillingPricingSource>,
|
||||
}
|
||||
|
||||
impl BillingPricingResolution {
|
||||
pub fn requires_actual_processing_tier(&self) -> bool {
|
||||
self.billing_processing_tier.is_none()
|
||||
}
|
||||
|
||||
pub fn pricing_source(&self) -> &'static str {
|
||||
match (self.tiered_pricing_source, self.price_per_request_source) {
|
||||
(Some(tiered), Some(request)) if tiered != request => "mixed",
|
||||
(Some(source), _) | (_, Some(source)) => source.as_str(),
|
||||
(None, None) => "unpriced",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bills_standard_processing_tier(&self) -> bool {
|
||||
self.billing_processing_tier
|
||||
.as_deref()
|
||||
.is_some_and(processing_tier_is_standard)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BillingModelPricingSnapshot {
|
||||
pub provider_id: String,
|
||||
@@ -21,32 +73,159 @@ pub struct BillingModelPricingSnapshot {
|
||||
}
|
||||
|
||||
impl BillingModelPricingSnapshot {
|
||||
pub fn effective_tiered_pricing(&self) -> Option<&Value> {
|
||||
pub fn resolve_pricing(
|
||||
&self,
|
||||
requested_processing_tier: Option<&str>,
|
||||
actual_processing_tier: Option<&str>,
|
||||
) -> BillingPricingResolution {
|
||||
let requested_processing_tier = normalize_processing_tier(requested_processing_tier);
|
||||
let actual_processing_tier = normalize_processing_tier(actual_processing_tier);
|
||||
let billing_processing_tier = actual_processing_tier
|
||||
.as_deref()
|
||||
.map(canonical_processing_tier)
|
||||
.or_else(|| {
|
||||
requested_processing_tier.as_deref().map_or(
|
||||
Some("standard".to_string()),
|
||||
|requested| {
|
||||
processing_tier_is_standard(requested).then(|| "standard".to_string())
|
||||
},
|
||||
)
|
||||
});
|
||||
|
||||
let (tiered_pricing, tiered_pricing_source) = billing_processing_tier
|
||||
.as_deref()
|
||||
.and_then(|tier| self.resolve_tiered_pricing(tier))
|
||||
.map_or((None, None), |(pricing, source)| {
|
||||
(Some(pricing.clone()), Some(source))
|
||||
});
|
||||
let (price_per_request, price_per_request_source) = self
|
||||
.resolve_price_per_request()
|
||||
.map_or((None, None), |(price, source)| (Some(price), Some(source)));
|
||||
|
||||
BillingPricingResolution {
|
||||
requested_processing_tier,
|
||||
actual_processing_tier,
|
||||
billing_processing_tier,
|
||||
tiered_pricing,
|
||||
tiered_pricing_source,
|
||||
price_per_request,
|
||||
price_per_request_source,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_authorization_pricing_candidates(
|
||||
&self,
|
||||
requested_processing_tier: Option<&str>,
|
||||
) -> Option<Vec<BillingPricingResolution>> {
|
||||
let requested_processing_tier = normalize_processing_tier(requested_processing_tier);
|
||||
let requested_billing_tier = requested_processing_tier
|
||||
.as_deref()
|
||||
.map(canonical_processing_tier)
|
||||
.unwrap_or_else(|| "standard".to_string());
|
||||
let requested_resolution = self.authorization_pricing_for_tier(
|
||||
requested_processing_tier.clone(),
|
||||
Some(requested_billing_tier.clone()),
|
||||
);
|
||||
if !processing_tier_is_standard(&requested_billing_tier)
|
||||
&& requested_resolution.tiered_pricing.is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut billing_tiers = BTreeSet::from(["standard".to_string(), requested_billing_tier]);
|
||||
for pricing in [
|
||||
self.model_tiered_pricing.as_ref(),
|
||||
self.default_tiered_pricing.as_ref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let Some(processing_tiers) = pricing.get("processing_tiers").and_then(Value::as_object)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
billing_tiers.extend(processing_tiers.keys().filter_map(|tier| {
|
||||
normalize_processing_tier(Some(tier)).map(|tier| canonical_processing_tier(&tier))
|
||||
}));
|
||||
}
|
||||
|
||||
let candidates = billing_tiers
|
||||
.into_iter()
|
||||
.filter_map(|billing_tier| {
|
||||
let resolution = self.authorization_pricing_for_tier(
|
||||
requested_processing_tier.clone(),
|
||||
Some(billing_tier),
|
||||
);
|
||||
(resolution.bills_standard_processing_tier() || resolution.tiered_pricing.is_some())
|
||||
.then_some(resolution)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
(!candidates.is_empty()).then_some(candidates)
|
||||
}
|
||||
|
||||
fn authorization_pricing_for_tier(
|
||||
&self,
|
||||
requested_processing_tier: Option<String>,
|
||||
billing_processing_tier: Option<String>,
|
||||
) -> BillingPricingResolution {
|
||||
let (tiered_pricing, tiered_pricing_source) = billing_processing_tier
|
||||
.as_deref()
|
||||
.and_then(|tier| self.resolve_tiered_pricing(tier))
|
||||
.map_or((None, None), |(pricing, source)| {
|
||||
(Some(pricing.clone()), Some(source))
|
||||
});
|
||||
let (price_per_request, price_per_request_source) = self
|
||||
.resolve_price_per_request()
|
||||
.map_or((None, None), |(price, source)| (Some(price), Some(source)));
|
||||
|
||||
BillingPricingResolution {
|
||||
requested_processing_tier,
|
||||
actual_processing_tier: None,
|
||||
billing_processing_tier,
|
||||
tiered_pricing,
|
||||
tiered_pricing_source,
|
||||
price_per_request,
|
||||
price_per_request_source,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_tiered_pricing(
|
||||
&self,
|
||||
processing_tier: &str,
|
||||
) -> Option<(&Value, BillingPricingSource)> {
|
||||
if processing_tier_is_standard(processing_tier) {
|
||||
return self
|
||||
.model_tiered_pricing
|
||||
.as_ref()
|
||||
.filter(|value| has_pricing_data(value))
|
||||
.map(|value| (value, BillingPricingSource::ProviderOverride))
|
||||
.or_else(|| {
|
||||
self.default_tiered_pricing
|
||||
.as_ref()
|
||||
.filter(|value| has_pricing_data(value))
|
||||
.map(|value| (value, BillingPricingSource::GlobalDefault))
|
||||
});
|
||||
}
|
||||
|
||||
self.model_tiered_pricing
|
||||
.as_ref()
|
||||
.filter(|value| has_pricing_data(value))
|
||||
.or(self.default_tiered_pricing.as_ref())
|
||||
.and_then(|pricing| processing_tier_overlay(pricing, processing_tier))
|
||||
.map(|value| (value, BillingPricingSource::ProviderOverride))
|
||||
.or_else(|| {
|
||||
self.default_tiered_pricing
|
||||
.as_ref()
|
||||
.and_then(|pricing| processing_tier_overlay(pricing, processing_tier))
|
||||
.map(|value| (value, BillingPricingSource::GlobalDefault))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn effective_price_per_request(&self) -> Option<f64> {
|
||||
fn resolve_price_per_request(&self) -> Option<(f64, BillingPricingSource)> {
|
||||
self.model_price_per_request
|
||||
.or(self.default_price_per_request)
|
||||
}
|
||||
|
||||
pub fn pricing_source(&self) -> &'static str {
|
||||
if self
|
||||
.model_tiered_pricing
|
||||
.as_ref()
|
||||
.is_some_and(has_pricing_data)
|
||||
|| self.model_price_per_request.is_some()
|
||||
{
|
||||
"provider_override"
|
||||
} else if self.default_tiered_pricing.is_some() || self.default_price_per_request.is_some()
|
||||
{
|
||||
"global_default"
|
||||
} else {
|
||||
"unpriced"
|
||||
}
|
||||
.map(|price| (price, BillingPricingSource::ProviderOverride))
|
||||
.or_else(|| {
|
||||
self.default_price_per_request
|
||||
.map(|price| (price, BillingPricingSource::GlobalDefault))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_free_tier(&self) -> bool {
|
||||
@@ -75,6 +254,74 @@ impl BillingModelPricingSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&StoredBillingModelContext> for BillingModelPricingSnapshot {
|
||||
fn from(context: &StoredBillingModelContext) -> Self {
|
||||
Self {
|
||||
provider_id: context.provider_id.clone(),
|
||||
provider_billing_type: context.provider_billing_type.clone(),
|
||||
provider_api_key_id: context.provider_api_key_id.clone(),
|
||||
provider_api_key_rate_multipliers: context.provider_api_key_rate_multipliers.clone(),
|
||||
provider_api_key_cache_ttl_minutes: context.provider_api_key_cache_ttl_minutes,
|
||||
global_model_id: context.global_model_id.clone(),
|
||||
global_model_name: context.global_model_name.clone(),
|
||||
global_model_config: context.global_model_config.clone(),
|
||||
default_price_per_request: context.default_price_per_request,
|
||||
default_tiered_pricing: context.default_tiered_pricing.clone(),
|
||||
model_id: context.model_id.clone(),
|
||||
model_provider_model_name: context.model_provider_model_name.clone(),
|
||||
model_config: context.model_config.clone(),
|
||||
model_price_per_request: context.model_price_per_request,
|
||||
model_tiered_pricing: context.model_tiered_pricing.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StoredBillingModelContext> for BillingModelPricingSnapshot {
|
||||
fn from(context: StoredBillingModelContext) -> Self {
|
||||
Self {
|
||||
provider_id: context.provider_id,
|
||||
provider_billing_type: context.provider_billing_type,
|
||||
provider_api_key_id: context.provider_api_key_id,
|
||||
provider_api_key_rate_multipliers: context.provider_api_key_rate_multipliers,
|
||||
provider_api_key_cache_ttl_minutes: context.provider_api_key_cache_ttl_minutes,
|
||||
global_model_id: context.global_model_id,
|
||||
global_model_name: context.global_model_name,
|
||||
global_model_config: context.global_model_config,
|
||||
default_price_per_request: context.default_price_per_request,
|
||||
default_tiered_pricing: context.default_tiered_pricing,
|
||||
model_id: context.model_id,
|
||||
model_provider_model_name: context.model_provider_model_name,
|
||||
model_config: context.model_config,
|
||||
model_price_per_request: context.model_price_per_request,
|
||||
model_tiered_pricing: context.model_tiered_pricing,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_processing_tier(value: Option<&str>) -> Option<String> {
|
||||
value.and_then(normalize_provider_service_tier)
|
||||
}
|
||||
|
||||
fn canonical_processing_tier(value: &str) -> String {
|
||||
if processing_tier_is_standard(value) {
|
||||
"standard".to_string()
|
||||
} else {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn processing_tier_is_standard(value: &str) -> bool {
|
||||
matches!(value, "auto" | "default" | "standard")
|
||||
}
|
||||
|
||||
fn processing_tier_overlay<'a>(pricing: &'a Value, tier: &str) -> Option<&'a Value> {
|
||||
pricing
|
||||
.get("processing_tiers")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|tiers| tiers.get(tier))
|
||||
.filter(|value| has_pricing_data(value))
|
||||
}
|
||||
|
||||
fn has_pricing_data(value: &Value) -> bool {
|
||||
value
|
||||
.get("tiers")
|
||||
@@ -104,7 +351,7 @@ fn value_has_entries(value: &Value) -> bool {
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::BillingModelPricingSnapshot;
|
||||
use super::{BillingModelPricingSnapshot, BillingPricingSource};
|
||||
|
||||
fn snapshot(
|
||||
model_tiered_pricing: Option<serde_json::Value>,
|
||||
@@ -135,11 +382,21 @@ mod tests {
|
||||
json!({"tiers":[{"up_to":null,"input_price_per_1m":3.0,"output_price_per_1m":15.0}]});
|
||||
let pricing = snapshot(Some(json!({})), Some(default_pricing.clone()));
|
||||
|
||||
assert_eq!(pricing.effective_tiered_pricing(), Some(&default_pricing));
|
||||
let resolution = pricing.resolve_pricing(None, None);
|
||||
assert_eq!(resolution.tiered_pricing, Some(default_pricing.clone()));
|
||||
assert_eq!(
|
||||
resolution.tiered_pricing_source,
|
||||
Some(BillingPricingSource::GlobalDefault)
|
||||
);
|
||||
|
||||
let pricing = snapshot(Some(json!({"tiers": []})), Some(default_pricing.clone()));
|
||||
|
||||
assert_eq!(pricing.effective_tiered_pricing(), Some(&default_pricing));
|
||||
let resolution = pricing.resolve_pricing(None, None);
|
||||
assert_eq!(resolution.tiered_pricing, Some(default_pricing));
|
||||
assert_eq!(
|
||||
resolution.tiered_pricing_source,
|
||||
Some(BillingPricingSource::GlobalDefault)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -150,7 +407,140 @@ mod tests {
|
||||
json!({"tiers":[{"up_to":null,"input_price_per_1m":3.0,"output_price_per_1m":15.0}]});
|
||||
let pricing = snapshot(Some(provider_pricing.clone()), Some(default_pricing));
|
||||
|
||||
assert_eq!(pricing.effective_tiered_pricing(), Some(&provider_pricing));
|
||||
let resolution = pricing.resolve_pricing(None, None);
|
||||
assert_eq!(resolution.tiered_pricing, Some(provider_pricing));
|
||||
assert_eq!(
|
||||
resolution.tiered_pricing_source,
|
||||
Some(BillingPricingSource::ProviderOverride)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_nonstandard_request_requires_actual_tier() {
|
||||
let pricing = snapshot(
|
||||
None,
|
||||
Some(json!({
|
||||
"tiers": [{"up_to": null, "input_price_per_1m": 3.0}],
|
||||
"processing_tiers": {
|
||||
"priority": {"tiers": [{"up_to": 272000, "input_price_per_1m": 6.0}]}
|
||||
}
|
||||
})),
|
||||
);
|
||||
|
||||
let resolution = pricing.resolve_pricing(Some("Priority"), None);
|
||||
|
||||
assert!(resolution.requires_actual_processing_tier());
|
||||
assert_eq!(
|
||||
resolution.requested_processing_tier.as_deref(),
|
||||
Some("priority")
|
||||
);
|
||||
assert_eq!(resolution.billing_processing_tier, None);
|
||||
assert_eq!(resolution.tiered_pricing, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_tier_selects_exact_catalog_and_source() {
|
||||
let pricing = snapshot(
|
||||
Some(json!({
|
||||
"processing_tiers": {
|
||||
"priority": {"tiers": [{"up_to": 272000, "input_price_per_1m": 9.0}]}
|
||||
}
|
||||
})),
|
||||
Some(json!({
|
||||
"tiers": [{"up_to": null, "input_price_per_1m": 3.0}],
|
||||
"processing_tiers": {
|
||||
"flex": {"tiers": [{"up_to": null, "input_price_per_1m": 1.5}]}
|
||||
}
|
||||
})),
|
||||
);
|
||||
|
||||
let flex = pricing.resolve_pricing(Some("priority"), Some("flex"));
|
||||
assert_eq!(flex.billing_processing_tier.as_deref(), Some("flex"));
|
||||
assert_eq!(
|
||||
flex.tiered_pricing_source,
|
||||
Some(BillingPricingSource::GlobalDefault)
|
||||
);
|
||||
assert_eq!(
|
||||
flex.tiered_pricing
|
||||
.as_ref()
|
||||
.and_then(|value| value.pointer("/tiers/0/input_price_per_1m"))
|
||||
.and_then(serde_json::Value::as_f64),
|
||||
Some(1.5)
|
||||
);
|
||||
|
||||
let standard = pricing.resolve_pricing(Some("priority"), Some("Default"));
|
||||
assert_eq!(standard.actual_processing_tier.as_deref(), Some("default"));
|
||||
assert_eq!(
|
||||
standard.billing_processing_tier.as_deref(),
|
||||
Some("standard")
|
||||
);
|
||||
assert_eq!(
|
||||
standard.tiered_pricing_source,
|
||||
Some(BillingPricingSource::GlobalDefault)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiered_and_fixed_price_sources_are_recorded_independently() {
|
||||
let mut pricing = snapshot(
|
||||
None,
|
||||
Some(json!({"tiers": [{"up_to": null, "input_price_per_1m": 3.0}]})),
|
||||
);
|
||||
pricing.model_price_per_request = Some(0.02);
|
||||
|
||||
let resolution = pricing.resolve_pricing(None, None);
|
||||
|
||||
assert_eq!(
|
||||
resolution.tiered_pricing_source,
|
||||
Some(BillingPricingSource::GlobalDefault)
|
||||
);
|
||||
assert_eq!(
|
||||
resolution.price_per_request_source,
|
||||
Some(BillingPricingSource::ProviderOverride)
|
||||
);
|
||||
assert_eq!(resolution.pricing_source(), "mixed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_candidates_include_requested_catalog_without_inventing_actual_tier() {
|
||||
let pricing = snapshot(
|
||||
None,
|
||||
Some(json!({
|
||||
"tiers": [{"up_to": null, "input_price_per_1m": 3.0}],
|
||||
"processing_tiers": {
|
||||
"priority": {"tiers": [{"up_to": 272000, "input_price_per_1m": 6.0}]}
|
||||
}
|
||||
})),
|
||||
);
|
||||
|
||||
let candidates = pricing
|
||||
.resolve_authorization_pricing_candidates(Some("Priority"))
|
||||
.expect("authorization catalogs should resolve");
|
||||
let resolution = candidates
|
||||
.iter()
|
||||
.find(|resolution| resolution.billing_processing_tier.as_deref() == Some("priority"))
|
||||
.expect("priority catalog should be included");
|
||||
|
||||
assert_eq!(
|
||||
resolution.requested_processing_tier.as_deref(),
|
||||
Some("priority")
|
||||
);
|
||||
assert_eq!(resolution.actual_processing_tier, None);
|
||||
assert_eq!(
|
||||
resolution.billing_processing_tier.as_deref(),
|
||||
Some("priority")
|
||||
);
|
||||
assert_eq!(
|
||||
resolution
|
||||
.tiered_pricing
|
||||
.as_ref()
|
||||
.and_then(|value| value.pointer("/tiers/0/input_price_per_1m"))
|
||||
.and_then(serde_json::Value::as_f64),
|
||||
Some(6.0)
|
||||
);
|
||||
assert!(candidates.iter().any(|resolution| {
|
||||
resolution.billing_processing_tier.as_deref() == Some("standard")
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +548,10 @@ mod tests {
|
||||
pub struct BillingUsageInput {
|
||||
pub task_type: String,
|
||||
pub api_format: Option<String>,
|
||||
#[serde(default)]
|
||||
pub requested_processing_tier: Option<String>,
|
||||
#[serde(default)]
|
||||
pub actual_processing_tier: Option<String>,
|
||||
pub request_count: i64,
|
||||
pub input_tokens: i64,
|
||||
pub output_tokens: i64,
|
||||
@@ -172,11 +566,34 @@ pub struct BillingUsageInput {
|
||||
pub cache_ttl_minutes: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BillingAuthorizationEstimateInput {
|
||||
pub task_type: String,
|
||||
pub api_format: Option<String>,
|
||||
pub requested_processing_tier: Option<String>,
|
||||
pub input_tokens: i64,
|
||||
pub max_output_tokens: Option<i64>,
|
||||
}
|
||||
|
||||
impl BillingAuthorizationEstimateInput {
|
||||
pub fn new(task_type: impl Into<String>, input_tokens: i64) -> Self {
|
||||
Self {
|
||||
task_type: task_type.into(),
|
||||
api_format: None,
|
||||
requested_processing_tier: None,
|
||||
input_tokens: input_tokens.max(0),
|
||||
max_output_tokens: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BillingUsageInput {
|
||||
pub fn new(task_type: impl Into<String>) -> Self {
|
||||
Self {
|
||||
task_type: task_type.into(),
|
||||
api_format: None,
|
||||
requested_processing_tier: None,
|
||||
actual_processing_tier: None,
|
||||
request_count: 1,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
@@ -199,4 +616,5 @@ pub struct BillingComputation {
|
||||
pub actual_total_cost: f64,
|
||||
pub rate_multiplier: f64,
|
||||
pub is_free_tier: bool,
|
||||
pub pricing_resolution: BillingPricingResolution,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user