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