mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-09 02:47:45 +08:00
feat: add selectable routing groups and composite billing
Support per-model provider enablement and compact model editing. Capture request-time billing factors, charge customer costs separately, and preserve historical statistics without backfills.
This commit is contained in:
@@ -577,6 +577,45 @@ pub struct UpdateUserApiKeyBasicRecord {
|
||||
/// unchanged. Keeping this patch in the basic mutation record lets repositories apply the
|
||||
/// complete user-key update in one atomic write.
|
||||
pub feature_settings: Option<Option<serde_json::Value>>,
|
||||
/// Self-service updates merge routing selection separately against the
|
||||
/// current stored settings. `None` retains administrative replacement semantics.
|
||||
pub routing_group_selection: Option<UpdateApiKeyRoutingGroupSelection>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateApiKeyRoutingGroupSelection {
|
||||
/// `None` preserves the latest stored group; `Some(None)` follows the
|
||||
/// default; `Some(Some(id))` selects the validated public group.
|
||||
pub group_id: Option<Option<String>>,
|
||||
}
|
||||
|
||||
impl UpdateApiKeyRoutingGroupSelection {
|
||||
/// Repositories must call this while holding the same write lock as the
|
||||
/// surrounding API key mutation, so unrelated edits cannot restore a stale
|
||||
/// group choice or a stale feature-settings object.
|
||||
pub fn merge_feature_settings(
|
||||
&self,
|
||||
current: Option<&serde_json::Value>,
|
||||
replacement: Option<Option<serde_json::Value>>,
|
||||
) -> Option<serde_json::Value> {
|
||||
let group_id = match &self.group_id {
|
||||
None => current
|
||||
.and_then(|value| value.get("routing_group_id"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(|id| serde_json::Value::String(id.to_string())),
|
||||
Some(group_id) => group_id.clone().map(serde_json::Value::String),
|
||||
};
|
||||
let mut settings = replacement
|
||||
.unwrap_or_else(|| current.cloned())
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.unwrap_or_default();
|
||||
settings.remove("routing_group_id");
|
||||
settings.remove("routing_group_name");
|
||||
if let Some(group_id) = group_id {
|
||||
settings.insert("routing_group_id".to_string(), group_id);
|
||||
}
|
||||
(!settings.is_empty()).then_some(serde_json::Value::Object(settings))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for UpdateUserApiKeyBasicRecord {
|
||||
|
||||
@@ -346,6 +346,10 @@ pub struct UsageSettlementInput {
|
||||
pub billing_status: String,
|
||||
pub total_cost_usd: f64,
|
||||
pub actual_total_cost_usd: f64,
|
||||
/// Customer charge after all captured billing factors, independent of upstream cost.
|
||||
/// Missing values retain the legacy charge based on `actual_total_cost_usd`.
|
||||
#[serde(default)]
|
||||
pub billing_cost_usd: Option<f64>,
|
||||
pub finalized_at_unix_secs: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -366,6 +370,14 @@ impl UsageSettlementInput {
|
||||
"settlement cost must be finite".to_string(),
|
||||
));
|
||||
}
|
||||
if self
|
||||
.billing_cost_usd
|
||||
.is_some_and(|value| !value.is_finite() || value < 0.0)
|
||||
{
|
||||
return Err(crate::DataLayerError::InvalidInput(
|
||||
"settlement billing_cost_usd must be finite and non-negative".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -511,14 +523,17 @@ pub fn settlement_billing_status_for_usage_status(status: &str) -> &'static str
|
||||
}
|
||||
|
||||
pub fn settlement_billable_cost_usd(input: &UsageSettlementInput) -> f64 {
|
||||
input.actual_total_cost_usd.max(0.0)
|
||||
input
|
||||
.billing_cost_usd
|
||||
.unwrap_or(input.actual_total_cost_usd)
|
||||
.max(0.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
validate_wallet_settlement_values, ReconcileUsagePolicyCostInput,
|
||||
ReserveUsagePolicyCostInput, ReserveUsagePolicyRequestInput,
|
||||
settlement_billable_cost_usd, validate_wallet_settlement_values,
|
||||
ReconcileUsagePolicyCostInput, ReserveUsagePolicyCostInput, ReserveUsagePolicyRequestInput,
|
||||
UsagePolicyCostReservationState, UsagePolicyCostWindow, UsagePolicyRequestWindow,
|
||||
UsageSettlementInput,
|
||||
};
|
||||
@@ -535,11 +550,50 @@ mod tests {
|
||||
billing_status: "pending".to_string(),
|
||||
total_cost_usd: 0.1,
|
||||
actual_total_cost_usd: 0.1,
|
||||
billing_cost_usd: None,
|
||||
finalized_at_unix_secs: None,
|
||||
};
|
||||
assert!(input.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_customer_charge_is_validated_independently_of_upstream_cost() {
|
||||
let mut input: UsageSettlementInput = serde_json::from_value(serde_json::json!({
|
||||
"request_id": "billing-charge",
|
||||
"user_id": "user-1",
|
||||
"api_key_id": null,
|
||||
"provider_id": "provider-1",
|
||||
"status": "completed",
|
||||
"billing_status": "pending",
|
||||
"total_cost_usd": 2.0,
|
||||
"actual_total_cost_usd": 0.5,
|
||||
"finalized_at_unix_secs": null,
|
||||
}))
|
||||
.expect("legacy settlement input should deserialize");
|
||||
assert_eq!(input.billing_cost_usd, None);
|
||||
assert_eq!(settlement_billable_cost_usd(&input), 0.5);
|
||||
assert!(input.validate().is_ok());
|
||||
|
||||
for charge in [3.0, 0.0] {
|
||||
input.billing_cost_usd = Some(charge);
|
||||
assert!(input.validate().is_ok());
|
||||
assert_eq!(settlement_billable_cost_usd(&input), charge);
|
||||
assert_eq!(input.actual_total_cost_usd, 0.5);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<UsageSettlementInput>(
|
||||
serde_json::to_value(&input).unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
input
|
||||
);
|
||||
}
|
||||
|
||||
for charge in [-0.01, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
input.billing_cost_usd = Some(charge);
|
||||
assert!(input.validate().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wallet_settlement_values_reject_corruption_and_overflow() {
|
||||
assert!(validate_wallet_settlement_values(-3.0, 0.0, 12.0, 1.0).is_ok());
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::DataLayerError;
|
||||
|
||||
use super::ROUTING_GROUP_BILLING_MULTIPLIER_METADATA_KEY;
|
||||
|
||||
pub const BILLING_MULTIPLIER_SNAPSHOT_METADATA_KEY: &str = "billing_multiplier_snapshot";
|
||||
|
||||
/// Immutable customer pricing factors. Provider Key rates belong to upstream cost,
|
||||
/// not this snapshot. Add future factors (for example `user_group`) at admission.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BillingMultiplierSnapshot {
|
||||
version: u32,
|
||||
factors: BTreeMap<String, f64>,
|
||||
multiplier: f64,
|
||||
}
|
||||
|
||||
impl BillingMultiplierSnapshot {
|
||||
pub fn from_factors(factors: BTreeMap<String, f64>) -> Result<Self, DataLayerError> {
|
||||
if factors.len() > 16
|
||||
|| factors.iter().any(|(name, value)| {
|
||||
name.is_empty()
|
||||
|| name.len() > 64
|
||||
|| !name
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
||||
|| !value.is_finite()
|
||||
|| *value < 0.0
|
||||
})
|
||||
{
|
||||
return Err(invalid_snapshot());
|
||||
}
|
||||
let multiplier = if factors.values().any(|value| *value == 0.0) {
|
||||
0.0
|
||||
} else {
|
||||
factors.values().product::<f64>()
|
||||
};
|
||||
if !multiplier.is_finite() {
|
||||
return Err(invalid_snapshot());
|
||||
}
|
||||
Ok(Self {
|
||||
version: 1,
|
||||
factors,
|
||||
multiplier,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), DataLayerError> {
|
||||
let expected = Self::from_factors(self.factors.clone())?;
|
||||
if self.version != 1 || self.multiplier != expected.multiplier {
|
||||
return Err(invalid_snapshot());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn multiplier(&self) -> f64 {
|
||||
self.multiplier
|
||||
}
|
||||
|
||||
pub fn cost(&self, base_cost: f64) -> Result<f64, DataLayerError> {
|
||||
self.validate()?;
|
||||
let cost = base_cost * self.multiplier;
|
||||
if !base_cost.is_finite() || base_cost < 0.0 || !cost.is_finite() {
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
"customer billing cost must be finite and non-negative".to_string(),
|
||||
));
|
||||
}
|
||||
// Match wallet storage and usage-policy cost units (eight decimals).
|
||||
// Scaling a finite large amount must not introduce infinity by itself.
|
||||
let scaled = cost * 100_000_000.0;
|
||||
Ok(if scaled.is_finite() {
|
||||
scaled.round() / 100_000_000.0
|
||||
} else {
|
||||
cost
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_snapshot() -> DataLayerError {
|
||||
DataLayerError::InvalidInput("invalid billing multiplier snapshot".to_string())
|
||||
}
|
||||
|
||||
/// None preserves legacy charging. A malformed captured snapshot is an error,
|
||||
/// never an instruction to silently charge a different rate.
|
||||
pub fn billing_multiplier_snapshot(
|
||||
metadata: Option<&Value>,
|
||||
) -> Result<Option<BillingMultiplierSnapshot>, DataLayerError> {
|
||||
let Some(metadata) = metadata.and_then(Value::as_object) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let Some(value) = metadata.get(BILLING_MULTIPLIER_SNAPSHOT_METADATA_KEY) {
|
||||
let snapshot: BillingMultiplierSnapshot =
|
||||
serde_json::from_value(value.clone()).map_err(|_| invalid_snapshot())?;
|
||||
snapshot.validate()?;
|
||||
return Ok(Some(snapshot));
|
||||
}
|
||||
if let Some(value) = metadata.get(ROUTING_GROUP_BILLING_MULTIPLIER_METADATA_KEY) {
|
||||
let multiplier = value.as_f64().ok_or_else(invalid_snapshot)?;
|
||||
return BillingMultiplierSnapshot::from_factors(BTreeMap::from([(
|
||||
"routing_group".to_string(),
|
||||
multiplier,
|
||||
)]))
|
||||
.map(Some);
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn composes_customer_factors_without_provider_cost_and_freezes_them() {
|
||||
let snapshot = BillingMultiplierSnapshot::from_factors(BTreeMap::from([
|
||||
("routing_group".to_string(), 2.0),
|
||||
("user_group".to_string(), 0.75),
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(snapshot.multiplier(), 1.5);
|
||||
assert_eq!(snapshot.cost(10.0).unwrap(), 15.0);
|
||||
assert_eq!(snapshot.cost(0.123456789).unwrap(), 0.18518518);
|
||||
let metadata = json!({"billing_multiplier_snapshot": snapshot, "routing_group_billing_multiplier": 99, "rate_multiplier": 0.1});
|
||||
assert_eq!(
|
||||
billing_multiplier_snapshot(Some(&metadata)).unwrap(),
|
||||
Some(snapshot)
|
||||
);
|
||||
assert_eq!(billing_multiplier_snapshot(None).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_corrupt_overflowing_snapshots_and_accepts_zero_rates() {
|
||||
for factors in [
|
||||
BTreeMap::from([("routing_group".into(), -1.0)]),
|
||||
BTreeMap::from([("routing_group".into(), f64::INFINITY)]),
|
||||
BTreeMap::from([
|
||||
("routing_group".into(), f64::MAX),
|
||||
("user_group".into(), 2.0),
|
||||
]),
|
||||
] {
|
||||
assert!(BillingMultiplierSnapshot::from_factors(factors).is_err());
|
||||
}
|
||||
let zero = BillingMultiplierSnapshot::from_factors(BTreeMap::from([
|
||||
("routing_group".into(), 0.0),
|
||||
("user_group".into(), 2.0),
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(zero.cost(10.0).unwrap(), 0.0);
|
||||
for invalid in [
|
||||
Value::Null,
|
||||
json!({"version": 2, "factors": {}, "multiplier": 1}),
|
||||
json!({"version": 1, "factors": {"routing_group": 2}, "multiplier": 1}),
|
||||
] {
|
||||
assert!(billing_multiplier_snapshot(Some(
|
||||
&json!({"billing_multiplier_snapshot": invalid})
|
||||
))
|
||||
.is_err());
|
||||
}
|
||||
let doubled = BillingMultiplierSnapshot::from_factors(BTreeMap::from([(
|
||||
"routing_group".into(),
|
||||
2.0,
|
||||
)]))
|
||||
.unwrap();
|
||||
assert!(doubled.cost(f64::MAX).is_err());
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,17 @@ use serde_json::{Map, Value};
|
||||
use crate::repository::candidates::sanitize_request_candidate_skip_reason;
|
||||
|
||||
use super::{
|
||||
normalize_provider_response_model, LIVE_SESSION_METADATA_KEY,
|
||||
billing_multiplier_snapshot, normalize_provider_response_model,
|
||||
BILLING_MULTIPLIER_SNAPSHOT_METADATA_KEY, LIVE_SESSION_METADATA_KEY,
|
||||
PLAN_USAGE_RESERVATION_DEFERRED_METADATA_KEY, PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY,
|
||||
PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY, PROVIDER_REASONING_EFFORT_METADATA_KEY,
|
||||
PROVIDER_RESPONSE_MODEL_METADATA_KEY, PROVIDER_SERVICE_TIER_METADATA_KEY,
|
||||
REALTIME_SESSION_METADATA_KEY, REQUESTED_REASONING_EFFORT_METADATA_KEY,
|
||||
ROUTING_CANDIDATE_SKIP_REASON_METADATA_KEY, ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY,
|
||||
USAGE_AVAILABLE_METADATA_KEY, USAGE_PRICING_AVAILABLE_METADATA_KEY,
|
||||
WEBSOCKET_MODE_METADATA_KEY, WEBSOCKET_TRANSPORT_METADATA_KEY,
|
||||
ROUTING_GROUP_BILLING_MULTIPLIER_METADATA_KEY, ROUTING_GROUP_ID_METADATA_KEY,
|
||||
ROUTING_GROUP_NAME_METADATA_KEY, USAGE_AVAILABLE_METADATA_KEY,
|
||||
USAGE_PRICING_AVAILABLE_METADATA_KEY, WEBSOCKET_MODE_METADATA_KEY,
|
||||
WEBSOCKET_TRANSPORT_METADATA_KEY,
|
||||
};
|
||||
|
||||
const UPSTREAM_IS_STREAM_KEY: &str = "upstream_is_stream";
|
||||
@@ -43,8 +46,68 @@ pub fn sanitize_usage_request_metadata_ref(value: Option<&Value>) -> Option<Valu
|
||||
sanitize_usage_request_metadata_object(value?.as_object()?)
|
||||
}
|
||||
|
||||
/// Keep the request's first captured billing snapshot and reservation owner across retries.
|
||||
pub fn preserve_usage_routing_group_snapshot(
|
||||
incoming: Option<Value>,
|
||||
previous: Option<&Value>,
|
||||
) -> Option<Value> {
|
||||
let Some(previous) = previous.and_then(Value::as_object) else {
|
||||
return incoming;
|
||||
};
|
||||
let mut snapshot = Map::from_iter(
|
||||
[
|
||||
BILLING_MULTIPLIER_SNAPSHOT_METADATA_KEY,
|
||||
ROUTING_GROUP_BILLING_MULTIPLIER_METADATA_KEY,
|
||||
ROUTING_GROUP_ID_METADATA_KEY,
|
||||
ROUTING_GROUP_NAME_METADATA_KEY,
|
||||
PLAN_USAGE_RESERVATION_TOKEN_KEY,
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|key| {
|
||||
previous
|
||||
.get(key)
|
||||
.map(|value| (key.to_string(), value.clone()))
|
||||
}),
|
||||
);
|
||||
if !snapshot.contains_key(BILLING_MULTIPLIER_SNAPSHOT_METADATA_KEY)
|
||||
&& snapshot.contains_key(ROUTING_GROUP_BILLING_MULTIPLIER_METADATA_KEY)
|
||||
{
|
||||
let captured = billing_multiplier_snapshot(Some(&Value::Object(snapshot.clone())))
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|snapshot| serde_json::to_value(snapshot).ok())
|
||||
.unwrap_or(Value::Null);
|
||||
snapshot.insert(
|
||||
BILLING_MULTIPLIER_SNAPSHOT_METADATA_KEY.to_string(),
|
||||
captured,
|
||||
);
|
||||
}
|
||||
let Some(Value::Object(snapshot)) = sanitize_usage_request_metadata_object(&snapshot) else {
|
||||
return incoming;
|
||||
};
|
||||
let mut metadata = incoming
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.unwrap_or_default();
|
||||
metadata.extend(snapshot);
|
||||
Some(Value::Object(metadata))
|
||||
}
|
||||
|
||||
pub fn sanitize_usage_request_metadata_object(source: &Map<String, Value>) -> Option<Value> {
|
||||
let mut target = Map::new();
|
||||
if let Some(snapshot) = source.get(BILLING_MULTIPLIER_SNAPSHOT_METADATA_KEY) {
|
||||
let metadata = serde_json::json!({BILLING_MULTIPLIER_SNAPSHOT_METADATA_KEY: snapshot});
|
||||
let snapshot = match billing_multiplier_snapshot(Some(&metadata)) {
|
||||
Ok(Some(snapshot)) => serde_json::to_value(snapshot)
|
||||
.expect("validated billing multiplier snapshot must serialize"),
|
||||
// Preserve an invalid marker so malformed financial input cannot silently
|
||||
// fall back to legacy billing after metadata projection.
|
||||
_ => Value::Null,
|
||||
};
|
||||
target.insert(
|
||||
BILLING_MULTIPLIER_SNAPSHOT_METADATA_KEY.to_string(),
|
||||
snapshot,
|
||||
);
|
||||
}
|
||||
if let Some(source) = source
|
||||
.get("analytics_measurement")
|
||||
.and_then(|value| value.get("source"))
|
||||
@@ -81,6 +144,8 @@ pub fn sanitize_usage_request_metadata_object(source: &Map<String, Value>) -> Op
|
||||
}
|
||||
|
||||
insert_token(source, &mut target, "trace_id", 128);
|
||||
insert_token(source, &mut target, ROUTING_GROUP_ID_METADATA_KEY, 128);
|
||||
insert_bounded_text(source, &mut target, ROUTING_GROUP_NAME_METADATA_KEY, 256);
|
||||
insert_ip_address(source, &mut target, "client_ip");
|
||||
insert_client_family(source, &mut target);
|
||||
for key in [
|
||||
@@ -181,6 +246,7 @@ pub fn sanitize_usage_request_metadata_object(source: &Map<String, Value>) -> Op
|
||||
|
||||
for key in [
|
||||
"rate_multiplier",
|
||||
ROUTING_GROUP_BILLING_MULTIPLIER_METADATA_KEY,
|
||||
"input_price_per_1m",
|
||||
"output_price_per_1m",
|
||||
"cache_creation_price_per_1m",
|
||||
@@ -189,6 +255,20 @@ pub fn sanitize_usage_request_metadata_object(source: &Map<String, Value>) -> Op
|
||||
] {
|
||||
insert_nonnegative_number(source, &mut target, key);
|
||||
}
|
||||
// An invalid legacy routing factor must remain a financial tombstone. Dropping it
|
||||
// would make a subsequent reader silently fall back to the historical provider charge.
|
||||
if source
|
||||
.get(ROUTING_GROUP_BILLING_MULTIPLIER_METADATA_KEY)
|
||||
.is_some_and(|value| {
|
||||
!value
|
||||
.as_f64()
|
||||
.is_some_and(|value| value.is_finite() && value >= 0.0)
|
||||
})
|
||||
{
|
||||
target
|
||||
.entry(BILLING_MULTIPLIER_SNAPSHOT_METADATA_KEY.to_string())
|
||||
.or_insert(Value::Null);
|
||||
}
|
||||
|
||||
let billing_snapshot = source
|
||||
.get("billing_snapshot")
|
||||
@@ -1156,6 +1236,27 @@ fn insert_token(
|
||||
target.insert(key.to_string(), Value::String(value.to_string()));
|
||||
}
|
||||
|
||||
fn insert_bounded_text(
|
||||
source: &Map<String, Value>,
|
||||
target: &mut Map<String, Value>,
|
||||
key: &str,
|
||||
max_len: usize,
|
||||
) {
|
||||
let Some(value) = source
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| {
|
||||
!value.is_empty()
|
||||
&& value.chars().count() <= max_len
|
||||
&& !value.chars().any(char::is_control)
|
||||
})
|
||||
else {
|
||||
return;
|
||||
};
|
||||
target.insert(key.to_string(), Value::String(value.to_string()));
|
||||
}
|
||||
|
||||
fn insert_dimension_token(source: &Map<String, Value>, target: &mut Map<String, Value>, key: &str) {
|
||||
let Some(value) = source
|
||||
.get(key)
|
||||
@@ -1263,7 +1364,111 @@ fn safe_version_value(value: &Value) -> Option<String> {
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{sanitize_usage_request_metadata, sanitize_usage_request_metadata_ref};
|
||||
use super::{
|
||||
billing_multiplier_snapshot, preserve_usage_routing_group_snapshot,
|
||||
sanitize_usage_request_metadata, sanitize_usage_request_metadata_ref,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn billing_multiplier_snapshot_projection_preserves_invalid_marker_and_immutable_factors() {
|
||||
for snapshot in [
|
||||
serde_json::Value::Null,
|
||||
json!({"version": 1, "factors": {"routing_group": 2.0}, "multiplier": 1.0}),
|
||||
json!({"version": 99, "factors": {}, "multiplier": 1.0}),
|
||||
] {
|
||||
let projected = sanitize_usage_request_metadata(Some(json!({
|
||||
"billing_multiplier_snapshot": snapshot,
|
||||
"routing_group_billing_multiplier": 0.5,
|
||||
})))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
projected.get("billing_multiplier_snapshot"),
|
||||
Some(&serde_json::Value::Null)
|
||||
);
|
||||
assert!(billing_multiplier_snapshot(Some(&projected)).is_err());
|
||||
let preserved = preserve_usage_routing_group_snapshot(
|
||||
Some(json!({"billing_multiplier_snapshot": {"version": 1, "factors": {}, "multiplier": 1.0}})),
|
||||
Some(&projected),
|
||||
).unwrap();
|
||||
assert!(billing_multiplier_snapshot(Some(&preserved)).is_err());
|
||||
}
|
||||
let legacy =
|
||||
json!({"routing_group_billing_multiplier": 0.25, "routing_group_name": "历史分组"});
|
||||
let preserved = preserve_usage_routing_group_snapshot(
|
||||
Some(json!({"billing_multiplier_snapshot": {"version": 1, "factors": {"routing_group": 99.0}, "multiplier": 99.0}})),
|
||||
Some(&legacy),
|
||||
).unwrap();
|
||||
assert_eq!(
|
||||
billing_multiplier_snapshot(Some(&preserved))
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.multiplier(),
|
||||
0.25
|
||||
);
|
||||
assert_eq!(preserved["routing_group_name"], "历史分组");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_multiplier_snapshot_projection_rejects_malformed_legacy_factors() {
|
||||
for factor in [serde_json::Value::Null, json!(-1), json!("2"), json!({})] {
|
||||
let projected = sanitize_usage_request_metadata(Some(json!({
|
||||
"routing_group_billing_multiplier": factor,
|
||||
})))
|
||||
.expect("invalid financial input must retain a tombstone");
|
||||
assert_eq!(
|
||||
projected["billing_multiplier_snapshot"],
|
||||
serde_json::Value::Null
|
||||
);
|
||||
assert!(billing_multiplier_snapshot(Some(&projected)).is_err());
|
||||
assert_eq!(
|
||||
sanitize_usage_request_metadata(Some(projected.clone())),
|
||||
Some(projected)
|
||||
);
|
||||
}
|
||||
let generic = sanitize_usage_request_metadata(Some(json!({
|
||||
"routing_group_billing_multiplier": -1,
|
||||
"billing_multiplier_snapshot": {"version": 1, "factors": {"routing_group": 2}, "multiplier": 2},
|
||||
})))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
billing_multiplier_snapshot(Some(&generic))
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.multiplier(),
|
||||
2.0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_multiplier_snapshot_preserves_the_original_reservation_owner() {
|
||||
let token_a = "550e8400-e29b-41d4-a716-446655440001";
|
||||
let token_b = "550e8400-e29b-41d4-a716-446655440002";
|
||||
let incoming = json!({
|
||||
"plan_usage_reservation_token": token_b,
|
||||
"billing_multiplier_snapshot": {"version": 1, "factors": {"routing_group": 3}, "multiplier": 3},
|
||||
});
|
||||
let previous = json!({
|
||||
"plan_usage_reservation_token": token_a,
|
||||
"billing_multiplier_snapshot": {"version": 1, "factors": {"routing_group": 0.5}, "multiplier": 0.5},
|
||||
});
|
||||
let preserved =
|
||||
preserve_usage_routing_group_snapshot(Some(incoming.clone()), Some(&previous)).unwrap();
|
||||
assert_eq!(preserved["plan_usage_reservation_token"], token_a);
|
||||
assert_eq!(
|
||||
billing_multiplier_snapshot(Some(&preserved))
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.multiplier(),
|
||||
0.5
|
||||
);
|
||||
|
||||
for empty in [json!({}), json!({"plan_usage_reservation_token": " "})] {
|
||||
let preserved =
|
||||
preserve_usage_routing_group_snapshot(Some(incoming.clone()), Some(&empty))
|
||||
.unwrap();
|
||||
assert_eq!(preserved["plan_usage_reservation_token"], token_b);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_attribution_preserves_key_flag_without_custom_identity_or_purpose() {
|
||||
|
||||
@@ -2,6 +2,7 @@ mod analytics;
|
||||
#[cfg(test)]
|
||||
mod analytics_tests;
|
||||
mod attribution;
|
||||
mod billing_multiplier;
|
||||
mod capture_memory;
|
||||
mod compression;
|
||||
mod dashboard_summary;
|
||||
@@ -12,6 +13,7 @@ mod types;
|
||||
|
||||
pub use analytics::*;
|
||||
pub use attribution::*;
|
||||
pub use billing_multiplier::*;
|
||||
#[doc(hidden)]
|
||||
pub use capture_memory::{
|
||||
mark_usage_capture_memory_omitted, usage_json_heap_estimate, UsageCaptureMemoryBudget,
|
||||
@@ -60,6 +62,8 @@ pub use types::{
|
||||
PROVIDER_RESPONSE_MODEL_METADATA_KEY, PROVIDER_SERVICE_TIER_METADATA_KEY,
|
||||
REALTIME_SESSION_METADATA_KEY, REQUESTED_REASONING_EFFORT_METADATA_KEY,
|
||||
ROUTING_CANDIDATE_SKIP_REASON_METADATA_KEY, ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY,
|
||||
USAGE_AVAILABLE_METADATA_KEY, USAGE_PRICING_AVAILABLE_METADATA_KEY,
|
||||
WEBSOCKET_MODE_METADATA_KEY, WEBSOCKET_TRANSPORT_METADATA_KEY,
|
||||
ROUTING_GROUP_BILLING_MULTIPLIER_METADATA_KEY, ROUTING_GROUP_ID_METADATA_KEY,
|
||||
ROUTING_GROUP_NAME_METADATA_KEY, USAGE_AVAILABLE_METADATA_KEY,
|
||||
USAGE_PRICING_AVAILABLE_METADATA_KEY, WEBSOCKET_MODE_METADATA_KEY,
|
||||
WEBSOCKET_TRANSPORT_METADATA_KEY,
|
||||
};
|
||||
|
||||
@@ -11,6 +11,10 @@ pub const PROVIDER_RESPONSE_MODEL_METADATA_KEY: &str = "provider_response_model"
|
||||
pub const PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY: &str = "provider_cache_ttl_minutes";
|
||||
pub const ROUTING_CANDIDATE_SKIP_REASON_METADATA_KEY: &str = "routing_candidate_skip_reason";
|
||||
pub const ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY: &str = "routing_failure_diagnostic";
|
||||
/// Immutable routing-group multiplier captured when the request is planned.
|
||||
pub const ROUTING_GROUP_BILLING_MULTIPLIER_METADATA_KEY: &str = "routing_group_billing_multiplier";
|
||||
pub const ROUTING_GROUP_ID_METADATA_KEY: &str = "routing_group_id";
|
||||
pub const ROUTING_GROUP_NAME_METADATA_KEY: &str = "routing_group_name";
|
||||
pub const WEBSOCKET_MODE_METADATA_KEY: &str = "websocket_mode";
|
||||
pub const WEBSOCKET_TRANSPORT_METADATA_KEY: &str = "websocket_transport";
|
||||
pub const PLAN_USAGE_RESERVATION_DEFERRED_METADATA_KEY: &str = "plan_usage_reservation_deferred";
|
||||
@@ -828,6 +832,47 @@ impl StoredRequestUsageAudit {
|
||||
self.request_metadata_number("rate_multiplier")
|
||||
}
|
||||
|
||||
/// Historical requests without a captured multiplier retain the original 1x rate.
|
||||
pub fn routing_group_billing_multiplier(&self) -> f64 {
|
||||
self.request_metadata_number(ROUTING_GROUP_BILLING_MULTIPLIER_METADATA_KEY)
|
||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
||||
.unwrap_or(1.0)
|
||||
}
|
||||
|
||||
/// Routing factor projection retained for callers inspecting this individual factor.
|
||||
pub fn routing_group_billing_cost(&self) -> Option<f64> {
|
||||
let cost = self.total_cost_usd * self.routing_group_billing_multiplier();
|
||||
cost.is_finite().then_some(cost)
|
||||
}
|
||||
|
||||
pub fn billing_multiplier(&self) -> f64 {
|
||||
super::billing_multiplier_snapshot(self.request_metadata.as_ref())
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|snapshot| snapshot.multiplier())
|
||||
.unwrap_or(1.0)
|
||||
}
|
||||
|
||||
/// Customer charge is independent of upstream Key cost. Legacy rows keep their
|
||||
/// original charge; no current configuration is consulted for historical usage.
|
||||
pub fn billing_cost(&self) -> Option<f64> {
|
||||
match super::billing_multiplier_snapshot(self.request_metadata.as_ref()).ok()? {
|
||||
Some(snapshot) => snapshot.cost(self.total_cost_usd).ok(),
|
||||
None => self
|
||||
.actual_total_cost_usd
|
||||
.is_finite()
|
||||
.then_some(self.actual_total_cost_usd.max(0.0)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn routing_group_id(&self) -> Option<&str> {
|
||||
self.request_metadata_string(ROUTING_GROUP_ID_METADATA_KEY)
|
||||
}
|
||||
|
||||
pub fn routing_group_name(&self) -> Option<&str> {
|
||||
self.request_metadata_string(ROUTING_GROUP_NAME_METADATA_KEY)
|
||||
}
|
||||
|
||||
pub fn settlement_is_free_tier(&self) -> Option<bool> {
|
||||
self.request_metadata_bool("is_free_tier")
|
||||
}
|
||||
@@ -3407,6 +3452,40 @@ mod tests {
|
||||
assert!(record.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routing_group_snapshot_defaults_legacy_multiplier_without_inventing_a_group() {
|
||||
let mut usage = sample_usage();
|
||||
usage.total_cost_usd = 4.0;
|
||||
assert_eq!(usage.routing_group_billing_multiplier(), 1.0);
|
||||
assert_eq!(usage.routing_group_billing_cost(), Some(4.0));
|
||||
assert_eq!(usage.routing_group_id(), None);
|
||||
assert_eq!(usage.routing_group_name(), None);
|
||||
for (value, multiplier, cost) in [
|
||||
(json!(0), 0.0, 0.0),
|
||||
(json!(0.25), 0.25, 1.0),
|
||||
(json!(2.5), 2.5, 10.0),
|
||||
(json!(-2), 1.0, 4.0),
|
||||
(json!("Infinity"), 1.0, 4.0),
|
||||
(json!(f64::INFINITY), 1.0, 4.0),
|
||||
(json!(f64::NAN), 1.0, 4.0),
|
||||
] {
|
||||
usage.request_metadata = Some(json!({
|
||||
"routing_group_billing_multiplier": value,
|
||||
"routing_group_id": "group-recorded",
|
||||
"routing_group_name": "请求时的分组",
|
||||
"rate_multiplier": 0.75
|
||||
}));
|
||||
assert_eq!(usage.routing_group_billing_multiplier(), multiplier);
|
||||
assert_eq!(usage.routing_group_billing_cost(), Some(cost));
|
||||
assert_eq!(usage.routing_group_id(), Some("group-recorded"));
|
||||
assert_eq!(usage.routing_group_name(), Some("请求时的分组"));
|
||||
assert_eq!(usage.settlement_rate_multiplier(), Some(0.75));
|
||||
}
|
||||
usage.request_metadata = Some(json!({"routing_group_billing_multiplier": 2.0}));
|
||||
usage.total_cost_usd = f64::MAX;
|
||||
assert_eq!(usage.routing_group_billing_cost(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settlement_accessors_prefer_typed_metadata() {
|
||||
let mut usage = sample_usage();
|
||||
|
||||
@@ -3286,6 +3286,7 @@ mod tests {
|
||||
billing_status: "pending".to_string(),
|
||||
total_cost_usd: 0.1,
|
||||
actual_total_cost_usd: 0.1,
|
||||
billing_cost_usd: None,
|
||||
finalized_at_unix_secs: None,
|
||||
};
|
||||
assert!(input.validate().is_err());
|
||||
|
||||
Reference in New Issue
Block a user