mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 03:09:50 +08:00
feat(openai): unify tier authorization and settlement
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user