mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 02:17:46 +08:00
1527 lines
56 KiB
Rust
1527 lines
56 KiB
Rust
use aether_data_contracts::repository::billing::StoredBillingModelContext;
|
|
use aether_data_contracts::repository::usage::{
|
|
extract_provider_cache_ttl_minutes_from_metadata, resolve_provider_cache_ttl_minutes,
|
|
resolve_provider_service_tier_from_request_capture, CANCELLED_REQUEST_FEE_METADATA_KEY,
|
|
USAGE_AVAILABLE_METADATA_KEY, USAGE_PRICING_AVAILABLE_METADATA_KEY,
|
|
};
|
|
use aether_data_contracts::DataLayerError;
|
|
use aether_usage_runtime::{UsageEvent, UsageEventType};
|
|
use async_trait::async_trait;
|
|
use serde_json::{json, Map, Value};
|
|
|
|
use crate::{
|
|
BillingComputation, BillingModelPricingSnapshot, BillingService, BillingSnapshotStatus,
|
|
BillingUsageInput,
|
|
};
|
|
|
|
const SETTLEMENT_SNAPSHOT_SCHEMA_VERSION: &str = "3.0";
|
|
|
|
#[async_trait]
|
|
pub trait BillingModelContextLookup: Send + Sync {
|
|
async fn find_billing_model_context_by_model_id(
|
|
&self,
|
|
provider_id: &str,
|
|
provider_api_key_id: Option<&str>,
|
|
model_id: &str,
|
|
) -> Result<Option<StoredBillingModelContext>, DataLayerError> {
|
|
let _ = (provider_id, provider_api_key_id, model_id);
|
|
Ok(None)
|
|
}
|
|
|
|
async fn find_billing_model_context(
|
|
&self,
|
|
provider_id: &str,
|
|
provider_api_key_id: Option<&str>,
|
|
global_model_name: &str,
|
|
) -> Result<Option<StoredBillingModelContext>, DataLayerError>;
|
|
}
|
|
|
|
pub async fn enrich_usage_event_with_billing(
|
|
data: &dyn BillingModelContextLookup,
|
|
event: &mut UsageEvent,
|
|
) -> Result<(), DataLayerError> {
|
|
if matches!(event.event_type, UsageEventType::Cancelled) {
|
|
event.data.total_cost_usd = Some(0.0);
|
|
event.data.actual_total_cost_usd = Some(0.0);
|
|
if let Some(metadata) = event
|
|
.data
|
|
.request_metadata
|
|
.as_mut()
|
|
.and_then(Value::as_object_mut)
|
|
{
|
|
metadata.remove(CANCELLED_REQUEST_FEE_METADATA_KEY);
|
|
}
|
|
}
|
|
// Session transports such as Codex Live expose lifecycle telemetry but no
|
|
// authoritative token/cost object. Do not run request-based pricing with
|
|
// zero default tokens: that would turn "unknown" into a fabricated charge.
|
|
if usage_is_explicitly_unavailable(event) {
|
|
event.data.input_tokens = None;
|
|
event.data.output_tokens = None;
|
|
event.data.total_tokens = None;
|
|
event.data.cache_creation_input_tokens = None;
|
|
event.data.cache_creation_ephemeral_5m_input_tokens = None;
|
|
event.data.cache_creation_ephemeral_1h_input_tokens = None;
|
|
event.data.cache_read_input_tokens = None;
|
|
event.data.cache_creation_cost_usd = None;
|
|
event.data.cache_read_cost_usd = None;
|
|
event.data.total_cost_usd = None;
|
|
event.data.actual_total_cost_usd = None;
|
|
return Ok(());
|
|
}
|
|
// Some protocols expose authoritative token totals with dimensions that
|
|
// Aether's pricing schema cannot safely express yet (Realtime audio is the
|
|
// first example). Preserve those tokens for observability, but never apply
|
|
// ordinary text token prices to them.
|
|
if usage_pricing_is_explicitly_unavailable(event) {
|
|
clear_usage_costs(event);
|
|
return Ok(());
|
|
}
|
|
if !matches!(
|
|
event.event_type,
|
|
UsageEventType::Completed | UsageEventType::Cancelled
|
|
) {
|
|
event.data.total_cost_usd = Some(0.0);
|
|
event.data.actual_total_cost_usd = Some(0.0);
|
|
return Ok(());
|
|
}
|
|
|
|
let Some(provider_id) = event
|
|
.data
|
|
.provider_id
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
else {
|
|
return Ok(());
|
|
};
|
|
if let Some(model_id) = event
|
|
.data
|
|
.model_id
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
{
|
|
if let Some(context) = data
|
|
.find_billing_model_context_by_model_id(
|
|
provider_id,
|
|
event.data.provider_api_key_id.as_deref(),
|
|
model_id,
|
|
)
|
|
.await?
|
|
{
|
|
let pricing = map_pricing_context(context);
|
|
let computation = calculate_billing_computation(&pricing, event)?;
|
|
apply_billing_computation(event, &pricing, computation)?;
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
let mut first_no_rule = None;
|
|
for lookup_name in billing_model_lookup_names(&event.data) {
|
|
let Some(context) = data
|
|
.find_billing_model_context(
|
|
provider_id,
|
|
event.data.provider_api_key_id.as_deref(),
|
|
lookup_name,
|
|
)
|
|
.await?
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
let pricing = map_pricing_context(context);
|
|
let computation = calculate_billing_computation(&pricing, event)?;
|
|
if matches!(
|
|
computation.cost_result.status,
|
|
BillingSnapshotStatus::NoRule
|
|
) {
|
|
first_no_rule.get_or_insert((pricing, computation));
|
|
continue;
|
|
}
|
|
apply_billing_computation(event, &pricing, computation)?;
|
|
return Ok(());
|
|
}
|
|
|
|
if let Some((pricing, computation)) = first_no_rule {
|
|
apply_billing_computation(event, &pricing, computation)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn usage_is_explicitly_unavailable(event: &UsageEvent) -> bool {
|
|
event
|
|
.data
|
|
.request_metadata
|
|
.as_ref()
|
|
.and_then(Value::as_object)
|
|
.and_then(|metadata| metadata.get(USAGE_AVAILABLE_METADATA_KEY))
|
|
.and_then(Value::as_bool)
|
|
== Some(false)
|
|
}
|
|
|
|
fn usage_pricing_is_explicitly_unavailable(event: &UsageEvent) -> bool {
|
|
event
|
|
.data
|
|
.request_metadata
|
|
.as_ref()
|
|
.and_then(Value::as_object)
|
|
.and_then(|metadata| metadata.get(USAGE_PRICING_AVAILABLE_METADATA_KEY))
|
|
.and_then(Value::as_bool)
|
|
== Some(false)
|
|
}
|
|
|
|
fn clear_usage_costs(event: &mut UsageEvent) {
|
|
event.data.cache_creation_cost_usd = None;
|
|
event.data.cache_read_cost_usd = None;
|
|
event.data.total_cost_usd = None;
|
|
event.data.actual_total_cost_usd = None;
|
|
}
|
|
|
|
fn billing_model_lookup_names(data: &aether_usage_runtime::UsageEventData) -> Vec<&str> {
|
|
let mut names = Vec::new();
|
|
for value in [data.target_model.as_deref(), Some(data.model.as_str())]
|
|
.into_iter()
|
|
.flatten()
|
|
{
|
|
let value = value.trim();
|
|
if !value.is_empty() && !names.contains(&value) {
|
|
names.push(value);
|
|
}
|
|
}
|
|
names
|
|
}
|
|
|
|
fn calculate_billing_computation(
|
|
pricing: &BillingModelPricingSnapshot,
|
|
event: &UsageEvent,
|
|
) -> Result<BillingComputation, DataLayerError> {
|
|
let failed =
|
|
event.data.status_code.unwrap_or_default() >= 400 || event.data.error_message.is_some();
|
|
let is_image_usage = usage_event_is_image_usage(&event.data);
|
|
let image_count = if failed {
|
|
0
|
|
} else {
|
|
usage_event_image_count(&event.data).unwrap_or(0)
|
|
};
|
|
let cancelled = matches!(event.event_type, UsageEventType::Cancelled);
|
|
let request_count = if cancelled {
|
|
1
|
|
} else if failed {
|
|
0
|
|
} else if is_image_usage && image_count > 0 {
|
|
image_count
|
|
} else {
|
|
1
|
|
};
|
|
let processing_tiers = usage_event_processing_tiers(&event.data);
|
|
let mut input = BillingUsageInput {
|
|
task_type: if is_image_usage {
|
|
"image".to_string()
|
|
} else {
|
|
event
|
|
.data
|
|
.request_type
|
|
.clone()
|
|
.unwrap_or_else(|| "chat".to_string())
|
|
},
|
|
api_format: event
|
|
.data
|
|
.endpoint_api_format
|
|
.clone()
|
|
.or_else(|| event.data.api_format.clone()),
|
|
requested_processing_tier: processing_tiers.requested,
|
|
// The response-reported tier remains usage audit data, but it is not authoritative for
|
|
// pricing. Settlement follows the final request that was sent upstream.
|
|
actual_processing_tier: None,
|
|
request_count,
|
|
input_tokens: event.data.input_tokens.unwrap_or_default() as i64,
|
|
output_tokens: event.data.output_tokens.unwrap_or_default() as i64,
|
|
cache_creation_tokens: event.data.cache_creation_input_tokens.unwrap_or_default() as i64,
|
|
cache_creation_ephemeral_5m_tokens: event
|
|
.data
|
|
.cache_creation_ephemeral_5m_input_tokens
|
|
.unwrap_or_default() as i64,
|
|
cache_creation_ephemeral_1h_tokens: event
|
|
.data
|
|
.cache_creation_ephemeral_1h_input_tokens
|
|
.unwrap_or_default() as i64,
|
|
cache_read_tokens: event.data.cache_read_input_tokens.unwrap_or_default() as i64,
|
|
image_count,
|
|
image_size: usage_event_dimension_string(&event.data, "image_size"),
|
|
image_quality: usage_event_dimension_string(&event.data, "image_quality"),
|
|
image_output_format: usage_event_dimension_string(&event.data, "image_output_format"),
|
|
cache_ttl_minutes: usage_event_provider_cache_ttl_minutes(&event.data)
|
|
.or(pricing.provider_api_key_cache_ttl_minutes),
|
|
};
|
|
|
|
if cancelled {
|
|
input.input_tokens = 0;
|
|
input.output_tokens = 0;
|
|
input.cache_creation_tokens = 0;
|
|
input.cache_creation_ephemeral_5m_tokens = 0;
|
|
input.cache_creation_ephemeral_1h_tokens = 0;
|
|
input.cache_read_tokens = 0;
|
|
input.image_count = 0;
|
|
}
|
|
|
|
BillingService::new()
|
|
.calculate(pricing, &input)
|
|
.map_err(|err| {
|
|
DataLayerError::UnexpectedValue(format!("billing calculation failed: {err}"))
|
|
})
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct UsageEventProcessingTiers {
|
|
requested: Option<String>,
|
|
}
|
|
|
|
fn usage_event_processing_tiers(
|
|
data: &aether_usage_runtime::UsageEventData,
|
|
) -> UsageEventProcessingTiers {
|
|
let requested = resolve_provider_service_tier_from_request_capture(
|
|
data.provider_request_body.as_ref(),
|
|
data.provider_request_body_state,
|
|
data.request_metadata.as_ref(),
|
|
);
|
|
|
|
UsageEventProcessingTiers { requested }
|
|
}
|
|
|
|
fn usage_event_provider_cache_ttl_minutes(
|
|
data: &aether_usage_runtime::UsageEventData,
|
|
) -> Option<i64> {
|
|
resolve_provider_cache_ttl_minutes(
|
|
data.endpoint_api_format
|
|
.as_deref()
|
|
.or(data.api_format.as_deref()),
|
|
data.target_model.as_deref().or(Some(data.model.as_str())),
|
|
Some(data.model.as_str()),
|
|
data.provider_request_body.as_ref(),
|
|
)
|
|
.or_else(|| extract_provider_cache_ttl_minutes_from_metadata(data.request_metadata.as_ref()))
|
|
}
|
|
|
|
fn usage_event_is_image_usage(data: &aether_usage_runtime::UsageEventData) -> bool {
|
|
data.request_type
|
|
.as_deref()
|
|
.is_some_and(|value| value.eq_ignore_ascii_case("image"))
|
|
|| api_format_endpoint_kind(data.endpoint_api_format.as_deref()) == Some("image")
|
|
|| api_format_endpoint_kind(data.api_format.as_deref()) == Some("image")
|
|
|| usage_event_image_count(data).is_some_and(|value| value > 0)
|
|
}
|
|
|
|
fn usage_event_image_count(data: &aether_usage_runtime::UsageEventData) -> Option<i64> {
|
|
metadata_dimension_i64(data.request_metadata.as_ref(), "dimensions", "image_count")
|
|
.or_else(|| {
|
|
metadata_dimension_i64(
|
|
data.request_metadata.as_ref(),
|
|
"billing_dimensions",
|
|
"image_count",
|
|
)
|
|
})
|
|
.filter(|value| *value > 0)
|
|
}
|
|
|
|
fn usage_event_dimension_string(
|
|
data: &aether_usage_runtime::UsageEventData,
|
|
dimension_key: &str,
|
|
) -> Option<String> {
|
|
metadata_dimension_string(data.request_metadata.as_ref(), "dimensions", dimension_key).or_else(
|
|
|| {
|
|
metadata_dimension_string(
|
|
data.request_metadata.as_ref(),
|
|
"billing_dimensions",
|
|
dimension_key,
|
|
)
|
|
},
|
|
)
|
|
}
|
|
|
|
fn metadata_dimension_string(
|
|
metadata: Option<&Value>,
|
|
bag_key: &str,
|
|
dimension_key: &str,
|
|
) -> Option<String> {
|
|
metadata
|
|
.and_then(Value::as_object)
|
|
.and_then(|object| object.get(bag_key))
|
|
.and_then(Value::as_object)
|
|
.and_then(|object| object.get(dimension_key))
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
}
|
|
|
|
fn metadata_dimension_i64(
|
|
metadata: Option<&Value>,
|
|
bag_key: &str,
|
|
dimension_key: &str,
|
|
) -> Option<i64> {
|
|
metadata
|
|
.and_then(Value::as_object)
|
|
.and_then(|object| object.get(bag_key))
|
|
.and_then(Value::as_object)
|
|
.and_then(|object| object.get(dimension_key))
|
|
.and_then(|value| {
|
|
value
|
|
.as_i64()
|
|
.or_else(|| value.as_u64().and_then(|number| i64::try_from(number).ok()))
|
|
})
|
|
}
|
|
|
|
fn api_format_endpoint_kind(api_format: Option<&str>) -> Option<&str> {
|
|
api_format
|
|
.and_then(|value| value.split_once(':').map(|(_, kind)| kind))
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
}
|
|
|
|
fn apply_billing_computation(
|
|
event: &mut UsageEvent,
|
|
pricing: &BillingModelPricingSnapshot,
|
|
computation: BillingComputation,
|
|
) -> Result<(), DataLayerError> {
|
|
let cancelled = matches!(event.event_type, UsageEventType::Cancelled);
|
|
if cancelled
|
|
&& !computation
|
|
.pricing_resolution
|
|
.price_per_request
|
|
.is_some_and(|price| price > 0.0)
|
|
{
|
|
return Ok(());
|
|
}
|
|
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)?;
|
|
if cancelled {
|
|
if let Some(metadata) = event
|
|
.data
|
|
.request_metadata
|
|
.as_mut()
|
|
.and_then(Value::as_object_mut)
|
|
{
|
|
metadata.insert(
|
|
CANCELLED_REQUEST_FEE_METADATA_KEY.to_string(),
|
|
Value::Bool(true),
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn map_pricing_context(context: StoredBillingModelContext) -> BillingModelPricingSnapshot {
|
|
context.into()
|
|
}
|
|
|
|
fn merge_billing_snapshot_metadata(
|
|
request_metadata: &mut Option<Value>,
|
|
pricing: &BillingModelPricingSnapshot,
|
|
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, computation);
|
|
|
|
let mut metadata = match request_metadata.take() {
|
|
Some(Value::Object(object)) => object,
|
|
_ => Map::new(),
|
|
};
|
|
metadata.insert("billing_snapshot".to_string(), billing_snapshot);
|
|
metadata.insert(
|
|
"settlement_snapshot_schema_version".to_string(),
|
|
Value::from(SETTLEMENT_SNAPSHOT_SCHEMA_VERSION),
|
|
);
|
|
metadata.insert("settlement_snapshot".to_string(), settlement_snapshot);
|
|
metadata.insert(
|
|
"billing_dimensions".to_string(),
|
|
Value::Object(snapshot.resolved_dimensions.clone().into_iter().collect()),
|
|
);
|
|
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,
|
|
computation: &BillingComputation,
|
|
) -> Value {
|
|
let snapshot = &computation.cost_result.snapshot;
|
|
let resolution = &computation.pricing_resolution;
|
|
json!({
|
|
"schema_version": SETTLEMENT_SNAPSHOT_SCHEMA_VERSION,
|
|
"pricing_snapshot": {
|
|
"provider_id": pricing.provider_id.clone(),
|
|
"provider_billing_type": pricing.provider_billing_type.clone(),
|
|
"provider_api_key_id": pricing.provider_api_key_id.clone(),
|
|
"global_model_id": pricing.global_model_id.clone(),
|
|
"global_model_name": pricing.global_model_name.clone(),
|
|
"model_id": pricing.model_id.clone(),
|
|
"provider_model_name": pricing.model_provider_model_name.clone(),
|
|
"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()),
|
|
"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,
|
|
"rate_multiplier": computation.rate_multiplier,
|
|
"is_free_tier": computation.is_free_tier,
|
|
},
|
|
"billing_plan_snapshot": {
|
|
"rule_id": snapshot.rule_id.clone(),
|
|
"rule_name": snapshot.rule_name.clone(),
|
|
"scope": snapshot.scope.clone(),
|
|
"expression": snapshot.expression.clone(),
|
|
"engine_version": snapshot.engine_version.clone(),
|
|
},
|
|
"resolved_dimensions": snapshot.resolved_dimensions.clone(),
|
|
"resolved_variables": snapshot.resolved_variables.clone(),
|
|
"cost_breakdown": snapshot.cost_breakdown.clone(),
|
|
"total_cost": snapshot.total_cost,
|
|
"actual_total_cost": computation.actual_total_cost,
|
|
"status": snapshot.status,
|
|
"calculated_at": snapshot.calculated_at.clone(),
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use aether_data_contracts::repository::billing::StoredBillingModelContext;
|
|
use aether_data_contracts::repository::usage::UsageBodyCaptureState;
|
|
use aether_usage_runtime::{UsageEvent, UsageEventData, UsageEventType};
|
|
use async_trait::async_trait;
|
|
use serde_json::json;
|
|
use serde_json::Value;
|
|
|
|
use super::{
|
|
enrich_usage_event_with_billing, usage_event_processing_tiers, BillingModelContextLookup,
|
|
};
|
|
|
|
struct TestLookup {
|
|
name_context: Option<StoredBillingModelContext>,
|
|
model_id_context: Option<StoredBillingModelContext>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl BillingModelContextLookup for TestLookup {
|
|
async fn find_billing_model_context_by_model_id(
|
|
&self,
|
|
_provider_id: &str,
|
|
_provider_api_key_id: Option<&str>,
|
|
_model_id: &str,
|
|
) -> Result<Option<StoredBillingModelContext>, aether_data_contracts::DataLayerError>
|
|
{
|
|
Ok(self.model_id_context.clone())
|
|
}
|
|
|
|
async fn find_billing_model_context(
|
|
&self,
|
|
_provider_id: &str,
|
|
_provider_api_key_id: Option<&str>,
|
|
_global_model_name: &str,
|
|
) -> Result<Option<StoredBillingModelContext>, aether_data_contracts::DataLayerError>
|
|
{
|
|
Ok(self.name_context.clone())
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unmetered_session_audit_does_not_fabricate_tokens_or_request_cost() {
|
|
let lookup = TestLookup {
|
|
name_context: None,
|
|
model_id_context: None,
|
|
};
|
|
let mut event = UsageEvent::new(
|
|
UsageEventType::Completed,
|
|
"req-live-unmetered",
|
|
UsageEventData {
|
|
provider_name: "OpenAI".to_string(),
|
|
provider_id: Some("provider-live".to_string()),
|
|
model: "gpt-live".to_string(),
|
|
input_tokens: Some(0),
|
|
output_tokens: Some(0),
|
|
total_tokens: Some(0),
|
|
total_cost_usd: Some(0.0),
|
|
actual_total_cost_usd: Some(0.0),
|
|
request_metadata: Some(json!({"usage_available": false})),
|
|
..UsageEventData::default()
|
|
},
|
|
);
|
|
|
|
enrich_usage_event_with_billing(&lookup, &mut event)
|
|
.await
|
|
.expect("unmetered session audit should be accepted");
|
|
|
|
assert_eq!(event.data.input_tokens, None);
|
|
assert_eq!(event.data.output_tokens, None);
|
|
assert_eq!(event.data.total_tokens, None);
|
|
assert_eq!(event.data.total_cost_usd, None);
|
|
assert_eq!(event.data.actual_total_cost_usd, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn authoritative_realtime_audio_tokens_are_visible_but_not_text_priced() {
|
|
let lookup = TestLookup {
|
|
name_context: None,
|
|
model_id_context: None,
|
|
};
|
|
let mut event = UsageEvent::new(
|
|
UsageEventType::Completed,
|
|
"req-realtime-audio",
|
|
UsageEventData {
|
|
provider_name: "OpenAI".to_string(),
|
|
provider_id: Some("provider-realtime".to_string()),
|
|
model: "gpt-realtime".to_string(),
|
|
input_tokens: Some(120),
|
|
output_tokens: Some(40),
|
|
total_tokens: Some(160),
|
|
request_metadata: Some(json!({
|
|
"usage_available": true,
|
|
"usage_pricing_available": false,
|
|
})),
|
|
..UsageEventData::default()
|
|
},
|
|
);
|
|
|
|
enrich_usage_event_with_billing(&lookup, &mut event)
|
|
.await
|
|
.expect("unsupported audio pricing should not fail enrichment");
|
|
|
|
assert_eq!(event.data.input_tokens, Some(120));
|
|
assert_eq!(event.data.output_tokens, Some(40));
|
|
assert_eq!(event.data.total_tokens, Some(160));
|
|
assert_eq!(event.data.total_cost_usd, None);
|
|
assert_eq!(event.data.actual_total_cost_usd, None);
|
|
}
|
|
|
|
#[test]
|
|
fn processing_tier_fact_uses_final_provider_request_body() {
|
|
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"));
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
|
|
#[test]
|
|
fn processing_tier_does_not_fall_back_to_stale_metadata_when_body_is_present() {
|
|
let data = UsageEventData {
|
|
provider_request_body: Some(json!({"model": "gpt-5"})),
|
|
response_body: Some(json!({"service_tier": "priority"})),
|
|
request_metadata: Some(json!({
|
|
"provider_service_tier": "priority",
|
|
"provider_actual_service_tier": "priority"
|
|
})),
|
|
..UsageEventData::default()
|
|
};
|
|
|
|
let tiers = usage_event_processing_tiers(&data);
|
|
|
|
assert_eq!(tiers.requested, None);
|
|
}
|
|
|
|
#[test]
|
|
fn processing_tier_uses_request_derived_metadata_when_body_capture_was_disabled() {
|
|
let data = UsageEventData {
|
|
provider_request_body: None,
|
|
provider_request_body_state: Some(UsageBodyCaptureState::Disabled),
|
|
response_body: Some(json!({"service_tier": "flex"})),
|
|
request_metadata: Some(json!({
|
|
"provider_service_tier": "priority",
|
|
"provider_actual_service_tier": "flex"
|
|
})),
|
|
..UsageEventData::default()
|
|
};
|
|
|
|
let tiers = usage_event_processing_tiers(&data);
|
|
|
|
assert_eq!(tiers.requested.as_deref(), Some("priority"));
|
|
}
|
|
|
|
#[test]
|
|
fn processing_tier_does_not_use_metadata_or_response_when_final_request_body_is_missing() {
|
|
let data = UsageEventData {
|
|
provider_request_body: None,
|
|
provider_request_body_state: Some(UsageBodyCaptureState::None),
|
|
response_body: Some(json!({"service_tier": "priority"})),
|
|
request_metadata: Some(json!({
|
|
"provider_service_tier": "priority",
|
|
"provider_actual_service_tier": "priority"
|
|
})),
|
|
..UsageEventData::default()
|
|
};
|
|
|
|
let tiers = usage_event_processing_tiers(&data);
|
|
|
|
assert_eq!(tiers.requested, None);
|
|
}
|
|
|
|
#[test]
|
|
fn processing_tier_uses_request_derived_metadata_when_body_capture_was_truncated() {
|
|
let data = UsageEventData {
|
|
provider_request_body: Some(json!({
|
|
"truncated": true,
|
|
"reason": "body_capture_limit_exceeded",
|
|
"max_bytes": 128,
|
|
"source_bytes": 4096,
|
|
"value_kind": "object"
|
|
})),
|
|
provider_request_body_state: Some(UsageBodyCaptureState::Truncated),
|
|
response_body: Some(json!({"service_tier": "default"})),
|
|
request_metadata: Some(json!({
|
|
"provider_service_tier": "priority",
|
|
"provider_actual_service_tier": "default"
|
|
})),
|
|
..UsageEventData::default()
|
|
};
|
|
|
|
let tiers = usage_event_processing_tiers(&data);
|
|
|
|
assert_eq!(tiers.requested.as_deref(), Some("priority"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn settlement_uses_effective_gpt_5_6_cache_ttl_after_body_capture() {
|
|
let lookup = TestLookup {
|
|
name_context: Some(
|
|
StoredBillingModelContext::new(
|
|
"provider-1".to_string(),
|
|
Some("pay_as_you_go".to_string()),
|
|
Some("key-1".to_string()),
|
|
None,
|
|
Some(60),
|
|
"global-model-1".to_string(),
|
|
"gpt-5.6-sol".to_string(),
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"tiers": [{
|
|
"up_to": null,
|
|
"input_price_per_1m": 5.0,
|
|
"output_price_per_1m": 30.0,
|
|
"cache_creation_price_per_1m": 6.25,
|
|
"cache_read_price_per_1m": 0.5,
|
|
"cache_ttl_pricing": [{
|
|
"ttl_minutes": 60,
|
|
"cache_creation_price_per_1m": 100.0,
|
|
"cache_read_price_per_1m": 100.0
|
|
}]
|
|
}]
|
|
})),
|
|
Some("model-1".to_string()),
|
|
Some("gpt-5.6-sol".to_string()),
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.expect("billing context should build"),
|
|
),
|
|
model_id_context: None,
|
|
};
|
|
|
|
for (request_id, provider_request_body, request_metadata) in [
|
|
(
|
|
"req-cache-body",
|
|
Some(json!({"model": "gpt-5.6-sol"})),
|
|
None,
|
|
),
|
|
(
|
|
"req-cache-metadata",
|
|
None,
|
|
Some(json!({"provider_cache_ttl_minutes": 30})),
|
|
),
|
|
] {
|
|
let mut event = UsageEvent::new(
|
|
UsageEventType::Completed,
|
|
request_id,
|
|
UsageEventData {
|
|
provider_name: "OpenAI".to_string(),
|
|
model: "gpt-5.6-sol".to_string(),
|
|
target_model: Some("gpt-5.6-sol".to_string()),
|
|
provider_id: Some("provider-1".to_string()),
|
|
provider_api_key_id: Some("key-1".to_string()),
|
|
request_type: Some("chat".to_string()),
|
|
api_format: Some("openai:responses".to_string()),
|
|
endpoint_api_format: Some("openai:responses".to_string()),
|
|
provider_request_body,
|
|
request_metadata,
|
|
input_tokens: Some(1_000_000),
|
|
cache_creation_input_tokens: Some(1_000_000),
|
|
status_code: Some(200),
|
|
..UsageEventData::default()
|
|
},
|
|
);
|
|
|
|
enrich_usage_event_with_billing(&lookup, &mut event)
|
|
.await
|
|
.expect("billing should succeed");
|
|
|
|
let snapshot = event
|
|
.data
|
|
.request_metadata
|
|
.as_ref()
|
|
.and_then(|value| value.get("billing_snapshot"))
|
|
.expect("billing snapshot should exist");
|
|
assert_eq!(
|
|
snapshot
|
|
.get("resolved_dimensions")
|
|
.and_then(|value| value.get("cache_ttl_minutes")),
|
|
Some(&json!(30))
|
|
);
|
|
assert_eq!(
|
|
snapshot
|
|
.get("resolved_variables")
|
|
.and_then(|value| value.get("cache_creation_price_per_1m")),
|
|
Some(&json!(6.25))
|
|
);
|
|
assert_eq!(event.data.total_cost_usd, Some(6.25));
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn enriches_completed_usage_event_with_billing_snapshot() {
|
|
let lookup = TestLookup {
|
|
name_context: Some(
|
|
StoredBillingModelContext::new(
|
|
"provider-1".to_string(),
|
|
Some("pay_as_you_go".to_string()),
|
|
Some("key-1".to_string()),
|
|
Some(json!({"openai:chat": 0.5})),
|
|
Some(60),
|
|
"global-model-1".to_string(),
|
|
"gpt-5".to_string(),
|
|
None,
|
|
Some(0.02),
|
|
Some(json!({"tiers":[{"up_to":null,"input_price_per_1m":3.0,"output_price_per_1m":15.0,"cache_creation_price_per_1m":3.75,"cache_read_price_per_1m":0.30}]})),
|
|
Some("model-1".to_string()),
|
|
Some("gpt-5-upstream".to_string()),
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.expect("billing context should build"),
|
|
),
|
|
model_id_context: None,
|
|
};
|
|
let mut event = UsageEvent::new(
|
|
UsageEventType::Completed,
|
|
"req-billing-1",
|
|
UsageEventData {
|
|
provider_name: "OpenAI".to_string(),
|
|
model: "gpt-5".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:chat".to_string()),
|
|
endpoint_api_format: Some("openai:chat".to_string()),
|
|
input_tokens: Some(1_000),
|
|
output_tokens: Some(500),
|
|
cache_read_input_tokens: Some(100),
|
|
status_code: Some(200),
|
|
..UsageEventData::default()
|
|
},
|
|
);
|
|
|
|
enrich_usage_event_with_billing(&lookup, &mut event)
|
|
.await
|
|
.expect("billing should succeed");
|
|
|
|
assert!(event.data.total_cost_usd.unwrap_or_default() > 0.0);
|
|
assert!(event.data.actual_total_cost_usd.unwrap_or_default() > 0.0);
|
|
assert_eq!(
|
|
event
|
|
.data
|
|
.request_metadata
|
|
.as_ref()
|
|
.and_then(|value| value.get("billing_snapshot"))
|
|
.and_then(|value| value.get("status"))
|
|
.and_then(Value::as_str),
|
|
Some("complete")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn openai_fast_usage_without_overlay_inherits_global_model_pricing() {
|
|
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-model-1".to_string(),
|
|
"gpt-5.6-sol".to_string(),
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"tiers": [{
|
|
"up_to": null,
|
|
"input_price_per_1m": 3.0,
|
|
"output_price_per_1m": 15.0
|
|
}]
|
|
})),
|
|
Some("model-1".to_string()),
|
|
Some("gpt-5.6-sol".to_string()),
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.expect("billing context should build"),
|
|
),
|
|
model_id_context: None,
|
|
};
|
|
let mut event = UsageEvent::new(
|
|
UsageEventType::Completed,
|
|
"req-fast-global-fallback",
|
|
UsageEventData {
|
|
provider_name: "OpenAI".to_string(),
|
|
model: "gpt-5.6-sol".to_string(),
|
|
target_model: Some("gpt-5.6-sol".to_string()),
|
|
provider_id: Some("provider-1".to_string()),
|
|
provider_api_key_id: Some("key-1".to_string()),
|
|
request_type: Some("chat".to_string()),
|
|
api_format: Some("openai:responses".to_string()),
|
|
endpoint_api_format: Some("openai:responses".to_string()),
|
|
// OpenAI calls the Fast request tier `priority` on the wire.
|
|
provider_request_body: Some(json!({
|
|
"model": "gpt-5.6-sol",
|
|
"service_tier": "priority"
|
|
})),
|
|
input_tokens: Some(1_000_000),
|
|
status_code: Some(200),
|
|
..UsageEventData::default()
|
|
},
|
|
);
|
|
|
|
enrich_usage_event_with_billing(&lookup, &mut event)
|
|
.await
|
|
.expect("billing should succeed");
|
|
|
|
assert_eq!(event.data.total_cost_usd, Some(3.0));
|
|
let metadata = event.data.request_metadata.as_ref().expect("metadata");
|
|
assert_eq!(
|
|
metadata.pointer("/billing_snapshot/status"),
|
|
Some(&json!("complete"))
|
|
);
|
|
let pricing = metadata
|
|
.pointer("/settlement_snapshot/pricing_snapshot")
|
|
.expect("settlement pricing snapshot");
|
|
assert_eq!(pricing["billing_processing_tier"], "priority");
|
|
assert_eq!(pricing["pricing_source"], "global_default");
|
|
assert_eq!(pricing["tiered_pricing_source"], "global_default");
|
|
assert_eq!(
|
|
pricing["tiered_pricing"]["tiers"][0]["input_price_per_1m"],
|
|
3.0
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn settlement_uses_requested_processing_tier_catalog_and_ignores_response_tier() {
|
|
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": {"price_multiplier": 0.5}
|
|
}
|
|
})),
|
|
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!(pricing_snapshot["actual_processing_tier"].is_null());
|
|
assert_eq!(pricing_snapshot["billing_processing_tier"], "priority");
|
|
assert_eq!(
|
|
pricing_snapshot["tiered_pricing_source"],
|
|
"provider_override"
|
|
);
|
|
assert_eq!(
|
|
pricing_snapshot["processing_tier_price_multiplier"],
|
|
Value::Null
|
|
);
|
|
assert_eq!(
|
|
pricing_snapshot["tiered_pricing"]["tiers"][0]["input_price_per_1m"],
|
|
10.0
|
|
);
|
|
// The response fact remains available for audit, but does not influence settlement.
|
|
assert_eq!(
|
|
event
|
|
.data
|
|
.request_metadata
|
|
.as_ref()
|
|
.and_then(|value| value.get("provider_actual_service_tier")),
|
|
Some(&json!("flex"))
|
|
);
|
|
assert_eq!(
|
|
event
|
|
.data
|
|
.request_metadata
|
|
.as_ref()
|
|
.and_then(|value| { value.pointer("/billing_dimensions/actual_processing_tier") }),
|
|
Some(&Value::Null)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn requested_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.84));
|
|
assert_eq!(event.data.actual_total_cost_usd, Some(0.84));
|
|
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"], "priority");
|
|
assert_eq!(pricing["tiered_pricing_source"], "provider_override");
|
|
assert_eq!(pricing["price_per_request_source"], "provider_override");
|
|
assert_eq!(pricing["pricing_source"], "provider_override");
|
|
assert_eq!(
|
|
metadata
|
|
.pointer("/billing_snapshot/resolved_variables/image_output_price_per_image")
|
|
.and_then(Value::as_f64),
|
|
Some(0.4)
|
|
);
|
|
assert_eq!(
|
|
metadata
|
|
.pointer("/billing_snapshot/cost_breakdown/image_output_cost")
|
|
.and_then(Value::as_f64),
|
|
Some(0.8)
|
|
);
|
|
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 {
|
|
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.02),
|
|
None,
|
|
Some("model-image-1".to_string()),
|
|
Some("gpt-image-2".to_string()),
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.expect("billing context should build"),
|
|
),
|
|
model_id_context: None,
|
|
};
|
|
let mut event = UsageEvent::new(
|
|
UsageEventType::Completed,
|
|
"req-image-billing-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("chat".to_string()),
|
|
api_format: Some("openai:chat".to_string()),
|
|
endpoint_api_format: Some("openai:image".to_string()),
|
|
request_metadata: Some(json!({
|
|
"dimensions": {
|
|
"image_count": 3
|
|
}
|
|
})),
|
|
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.06));
|
|
assert_eq!(event.data.actual_total_cost_usd, Some(0.06));
|
|
assert_eq!(
|
|
event
|
|
.data
|
|
.request_metadata
|
|
.as_ref()
|
|
.and_then(|value| value.get("billing_dimensions"))
|
|
.and_then(|value| value.get("request_count"))
|
|
.and_then(Value::as_i64),
|
|
Some(3)
|
|
);
|
|
assert_eq!(
|
|
event
|
|
.data
|
|
.request_metadata
|
|
.as_ref()
|
|
.and_then(|value| value.get("billing_dimensions"))
|
|
.and_then(|value| value.get("image_count"))
|
|
.and_then(Value::as_i64),
|
|
Some(3)
|
|
);
|
|
assert_eq!(
|
|
event
|
|
.data
|
|
.request_metadata
|
|
.as_ref()
|
|
.and_then(|value| value.get("billing_dimensions"))
|
|
.and_then(|value| value.get("effective_task_type"))
|
|
.and_then(Value::as_str),
|
|
Some("image")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn image_usage_uses_configured_output_price_matrix() {
|
|
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,
|
|
None,
|
|
Some(json!({
|
|
"tiers": [{
|
|
"up_to": null,
|
|
"input_price_per_1m": 5.0,
|
|
"output_price_per_1m": 30.0,
|
|
"cache_read_price_per_1m": 1.25
|
|
}],
|
|
"image_output_price_default": 0.01,
|
|
"image_output_prices": {
|
|
"1024x1024": {"low": 0.006, "medium": 0.053, "high": 0.211},
|
|
"1536x1024": {"low": 0.005, "medium": 0.041, "high": 0.165},
|
|
"1024x1536": {"low": 0.005, "medium": 0.041, "high": 0.165}
|
|
}
|
|
})),
|
|
Some("model-image-1".to_string()),
|
|
Some("gpt-image-2".to_string()),
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.expect("billing context should build"),
|
|
),
|
|
model_id_context: None,
|
|
};
|
|
let mut event = UsageEvent::new(
|
|
UsageEventType::Completed,
|
|
"req-image-billing-matrix-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("chat".to_string()),
|
|
api_format: Some("openai:chat".to_string()),
|
|
endpoint_api_format: Some("openai:image".to_string()),
|
|
request_metadata: Some(json!({
|
|
"dimensions": {
|
|
"image_count": 2,
|
|
"image_size": "1536x1024",
|
|
"image_quality": "medium",
|
|
"image_output_format": "png"
|
|
}
|
|
})),
|
|
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.082));
|
|
assert_eq!(event.data.actual_total_cost_usd, Some(0.082));
|
|
let metadata = event.data.request_metadata.as_ref().expect("metadata");
|
|
assert_eq!(
|
|
metadata
|
|
.get("billing_dimensions")
|
|
.and_then(|value| value.get("image_price_key"))
|
|
.and_then(Value::as_str),
|
|
Some("1536x1024:medium")
|
|
);
|
|
assert_eq!(
|
|
metadata
|
|
.get("billing_snapshot")
|
|
.and_then(|value| value.get("resolved_variables"))
|
|
.and_then(|value| value.get("image_output_price_per_image"))
|
|
.and_then(Value::as_f64),
|
|
Some(0.041)
|
|
);
|
|
assert_eq!(
|
|
metadata
|
|
.get("billing_snapshot")
|
|
.and_then(|value| value.get("cost_breakdown"))
|
|
.and_then(|value| value.get("image_output_cost"))
|
|
.and_then(Value::as_f64),
|
|
Some(0.082)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cancelled_usage_bills_only_configured_request_fee() {
|
|
for (request_type, request_price) in
|
|
[("chat", None), ("chat", Some(0.02)), ("image", Some(0.02))]
|
|
{
|
|
let lookup = TestLookup {
|
|
name_context: Some(
|
|
StoredBillingModelContext::new(
|
|
"provider-1".to_string(),
|
|
Some("pay_as_you_go".to_string()),
|
|
Some("key-1".to_string()),
|
|
Some(json!({"openai:responses": 0.5})),
|
|
Some(60),
|
|
"global-model-1".to_string(),
|
|
"gpt-5".to_string(),
|
|
None,
|
|
request_price,
|
|
Some(json!({"tiers":[{"up_to":null,"input_price_per_1m":3.0,"output_price_per_1m":15.0,"cache_creation_price_per_1m":3.75,"cache_read_price_per_1m":0.30}]})),
|
|
Some("model-1".to_string()),
|
|
Some("gpt-5-upstream".to_string()),
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.expect("billing context should build"),
|
|
),
|
|
model_id_context: None,
|
|
};
|
|
let mut event = UsageEvent::new(
|
|
UsageEventType::Cancelled,
|
|
"req-billing-cancelled-1",
|
|
UsageEventData {
|
|
provider_name: "OpenAI".to_string(),
|
|
model: "gpt-5".to_string(),
|
|
provider_id: Some("provider-1".to_string()),
|
|
provider_api_key_id: Some("key-1".to_string()),
|
|
request_type: Some(request_type.to_string()),
|
|
api_format: Some("openai:responses".to_string()),
|
|
endpoint_api_format: Some("openai:responses".to_string()),
|
|
input_tokens: Some(1_000),
|
|
output_tokens: Some(500),
|
|
cache_read_input_tokens: Some(100),
|
|
status_code: Some(499),
|
|
request_metadata: Some(
|
|
json!({"cancelled_request_fee": true, "image_count": 3}),
|
|
),
|
|
..UsageEventData::default()
|
|
},
|
|
);
|
|
|
|
enrich_usage_event_with_billing(&lookup, &mut event)
|
|
.await
|
|
.expect("billing should succeed");
|
|
|
|
let expected_cost = request_price.unwrap_or(0.0);
|
|
assert_eq!(event.data.total_cost_usd, Some(expected_cost));
|
|
assert_eq!(event.data.actual_total_cost_usd, Some(expected_cost * 0.5));
|
|
assert_eq!(event.data.input_tokens, Some(1_000));
|
|
assert_eq!(event.data.output_tokens, Some(500));
|
|
let metadata = event.data.request_metadata.as_ref().unwrap();
|
|
assert_eq!(
|
|
aether_data_contracts::repository::usage::cancelled_request_fee_is_billable(Some(
|
|
metadata
|
|
)),
|
|
request_price.is_some()
|
|
);
|
|
if request_price.is_some() {
|
|
assert_eq!(
|
|
metadata.pointer("/billing_snapshot/cost_breakdown/request_cost"),
|
|
Some(&json!(expected_cost))
|
|
);
|
|
assert_eq!(
|
|
metadata.pointer("/billing_dimensions/input_tokens"),
|
|
Some(&json!(0))
|
|
);
|
|
assert_eq!(
|
|
metadata.pointer("/billing_dimensions/output_tokens"),
|
|
Some(&json!(0))
|
|
);
|
|
assert_eq!(
|
|
metadata.pointer("/billing_dimensions/cache_read_tokens"),
|
|
Some(&json!(0))
|
|
);
|
|
assert_eq!(
|
|
metadata.pointer("/billing_dimensions/request_count"),
|
|
Some(&json!(1))
|
|
);
|
|
} else {
|
|
assert!(metadata.get("billing_snapshot").is_none());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn failed_usage_event_remains_unbilled() {
|
|
let lookup = TestLookup {
|
|
name_context: None,
|
|
model_id_context: None,
|
|
};
|
|
let mut event = UsageEvent::new(
|
|
UsageEventType::Failed,
|
|
"req-billing-failed-1",
|
|
UsageEventData {
|
|
provider_name: "OpenAI".to_string(),
|
|
model: "gpt-5".to_string(),
|
|
provider_id: Some("provider-1".to_string()),
|
|
provider_api_key_id: Some("key-1".to_string()),
|
|
request_type: Some("chat".to_string()),
|
|
input_tokens: Some(1_000),
|
|
output_tokens: Some(500),
|
|
status_code: Some(500),
|
|
..UsageEventData::default()
|
|
},
|
|
);
|
|
|
|
enrich_usage_event_with_billing(&lookup, &mut event)
|
|
.await
|
|
.expect("billing should succeed");
|
|
|
|
assert_eq!(event.data.total_cost_usd, Some(0.0));
|
|
assert_eq!(event.data.actual_total_cost_usd, Some(0.0));
|
|
assert!(event.data.request_metadata.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn enriches_by_provider_model_id_before_name_fallback() {
|
|
let blank_name_context = StoredBillingModelContext::new(
|
|
"provider-1".to_string(),
|
|
Some("pay_as_you_go".to_string()),
|
|
Some("key-1".to_string()),
|
|
None,
|
|
Some(60),
|
|
"global-model-blank".to_string(),
|
|
"claude-sonnet-4-6".to_string(),
|
|
None,
|
|
None,
|
|
None,
|
|
Some("model-blank".to_string()),
|
|
Some("claude-sonnet-4-6".to_string()),
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.expect("blank billing context should build");
|
|
let priced_model_context = StoredBillingModelContext::new(
|
|
"provider-1".to_string(),
|
|
Some("pay_as_you_go".to_string()),
|
|
Some("key-1".to_string()),
|
|
None,
|
|
Some(60),
|
|
"global-model-priced".to_string(),
|
|
"claude-sonnet-4-6".to_string(),
|
|
None,
|
|
None,
|
|
None,
|
|
Some("model-priced".to_string()),
|
|
Some("claude-sonnet-4-6".to_string()),
|
|
None,
|
|
None,
|
|
Some(
|
|
json!({"tiers":[{"up_to":null,"input_price_per_1m":3.0,"output_price_per_1m":15.0}]}),
|
|
),
|
|
)
|
|
.expect("priced billing context should build");
|
|
let lookup = TestLookup {
|
|
name_context: Some(blank_name_context),
|
|
model_id_context: Some(priced_model_context),
|
|
};
|
|
let mut event = UsageEvent::new(
|
|
UsageEventType::Completed,
|
|
"req-billing-model-id-1",
|
|
UsageEventData {
|
|
provider_name: "NekoCode".to_string(),
|
|
model: "claude-sonnet-4-6".to_string(),
|
|
model_id: Some("model-priced".to_string()),
|
|
provider_id: Some("provider-1".to_string()),
|
|
provider_api_key_id: Some("key-1".to_string()),
|
|
request_type: Some("chat".to_string()),
|
|
input_tokens: Some(1_000),
|
|
output_tokens: Some(500),
|
|
status_code: Some(200),
|
|
..UsageEventData::default()
|
|
},
|
|
);
|
|
|
|
enrich_usage_event_with_billing(&lookup, &mut event)
|
|
.await
|
|
.expect("billing should succeed");
|
|
|
|
assert!(event.data.total_cost_usd.unwrap_or_default() > 0.0);
|
|
assert_eq!(
|
|
event
|
|
.data
|
|
.request_metadata
|
|
.as_ref()
|
|
.and_then(|value| value.get("billing_snapshot"))
|
|
.and_then(|value| value.get("status"))
|
|
.and_then(Value::as_str),
|
|
Some("complete")
|
|
);
|
|
}
|
|
}
|