feat(openai): unify tier authorization and settlement

This commit is contained in:
MMEXA
2026-07-11 12:27:05 +08:00
parent b2f596b8f0
commit 0b30cc6b0f
32 changed files with 3170 additions and 670 deletions
+259 -49
View File
@@ -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 {