mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(billing): add image output range pricing support
This commit is contained in:
@@ -30,13 +30,16 @@ impl DefaultBillingRuleGenerator {
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let image_output_price_entries = explicit_image_output_price_entries(pricing_config)
|
||||
.filter(|entries| !entries.is_empty())
|
||||
.unwrap_or_default();
|
||||
let explicit_image_output_price_default = explicit_image_output_price_default(pricing_config);
|
||||
let explicit_image_output_price_default =
|
||||
explicit_image_output_price_default(pricing_config);
|
||||
let image_output_price_default = explicit_image_output_price_default.unwrap_or(0.0);
|
||||
let has_image_output_pricing =
|
||||
!image_output_price_entries.is_empty() || explicit_image_output_price_default.is_some();
|
||||
let has_image_output_matrix = explicit_image_output_price_entries(pricing_config)
|
||||
.is_some_and(|entries| !entries.is_empty());
|
||||
let has_image_output_ranges = explicit_image_output_price_ranges(pricing_config)
|
||||
.is_some_and(|ranges| !ranges.is_empty());
|
||||
let has_image_output_pricing = has_image_output_matrix
|
||||
|| has_image_output_ranges
|
||||
|| explicit_image_output_price_default.is_some();
|
||||
|
||||
if tiers.is_empty()
|
||||
&& pricing.effective_price_per_request().is_none()
|
||||
@@ -104,6 +107,11 @@ impl DefaultBillingRuleGenerator {
|
||||
("image_count", "image_count", json!(0)),
|
||||
("image_count_unmetered", "image_count_unmetered", json!(0)),
|
||||
("image_price_key", "image_price_key", json!("default")),
|
||||
(
|
||||
"image_output_price_per_image",
|
||||
"image_output_price_per_image",
|
||||
json!(image_output_price_default),
|
||||
),
|
||||
] {
|
||||
dimension_mappings.insert(
|
||||
name.to_string(),
|
||||
@@ -156,18 +164,6 @@ impl DefaultBillingRuleGenerator {
|
||||
);
|
||||
}
|
||||
|
||||
if !image_output_price_entries.is_empty() {
|
||||
dimension_mappings.insert(
|
||||
"image_output_price_per_image".to_string(),
|
||||
json!({
|
||||
"source": "matrix",
|
||||
"key": "image_price_key",
|
||||
"entries": image_output_price_entries,
|
||||
"default": image_output_price_default,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if !tiers.is_empty() {
|
||||
dimension_mappings.insert(
|
||||
"input_price_per_1m".to_string(),
|
||||
@@ -302,7 +298,7 @@ fn build_tier_entries(
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn explicit_image_output_price_entries(
|
||||
pub(crate) fn explicit_image_output_price_entries(
|
||||
pricing_config: Option<&Value>,
|
||||
) -> Option<BTreeMap<String, Value>> {
|
||||
let pricing_config = pricing_config?;
|
||||
@@ -320,7 +316,93 @@ fn explicit_image_output_price_entries(
|
||||
Some(entries)
|
||||
}
|
||||
|
||||
fn explicit_image_output_price_default(pricing_config: Option<&Value>) -> Option<f64> {
|
||||
pub(crate) fn explicit_image_output_price_ranges(
|
||||
pricing_config: Option<&Value>,
|
||||
) -> Option<Vec<Value>> {
|
||||
let pricing_config = pricing_config?;
|
||||
let Some(value) = pricing_config.get("image_output_price_ranges") else {
|
||||
return Some(Vec::new());
|
||||
};
|
||||
|
||||
let mut ranges = Vec::new();
|
||||
match value {
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
let Some(object) = item.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let mut range = serde_json::Map::new();
|
||||
if let Some(up_to_pixels) = object
|
||||
.get("up_to_pixels")
|
||||
.or_else(|| object.get("up_to"))
|
||||
.or_else(|| object.get("max_pixels"))
|
||||
{
|
||||
range.insert("up_to_pixels".to_string(), up_to_pixels.clone());
|
||||
}
|
||||
if let Some(label) = object.get("label").cloned() {
|
||||
range.insert("label".to_string(), label);
|
||||
}
|
||||
if let Some(prices) = object.get("prices") {
|
||||
range.insert("prices".to_string(), prices.clone());
|
||||
} else {
|
||||
let mut prices = serde_json::Map::new();
|
||||
for quality in ["low", "medium", "high"] {
|
||||
if let Some(price) = object.get(quality).and_then(Value::as_f64) {
|
||||
prices.insert(quality.to_string(), json!(price));
|
||||
}
|
||||
}
|
||||
if prices.is_empty() {
|
||||
if let Some(price) = object
|
||||
.get("price_per_image")
|
||||
.or_else(|| object.get("price"))
|
||||
.or_else(|| object.get("value"))
|
||||
.and_then(Value::as_f64)
|
||||
{
|
||||
prices.insert("default".to_string(), json!(price));
|
||||
}
|
||||
}
|
||||
if !prices.is_empty() {
|
||||
range.insert("prices".to_string(), Value::Object(prices));
|
||||
}
|
||||
}
|
||||
if !range.is_empty() {
|
||||
ranges.push(Value::Object(range));
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Object(object) => {
|
||||
for (key, item) in object {
|
||||
let Some(entry) = item.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let mut range = serde_json::Map::new();
|
||||
if let Some(up_to_pixels) = entry
|
||||
.get("up_to_pixels")
|
||||
.or_else(|| entry.get("up_to"))
|
||||
.or_else(|| entry.get("max_pixels"))
|
||||
{
|
||||
range.insert("up_to_pixels".to_string(), up_to_pixels.clone());
|
||||
} else if let Ok(parsed) = key.parse::<u64>() {
|
||||
range.insert("up_to_pixels".to_string(), json!(parsed));
|
||||
}
|
||||
if let Some(label) = entry.get("label").cloned() {
|
||||
range.insert("label".to_string(), label);
|
||||
}
|
||||
if let Some(prices) = entry.get("prices") {
|
||||
range.insert("prices".to_string(), prices.clone());
|
||||
}
|
||||
if !range.is_empty() {
|
||||
ranges.push(Value::Object(range));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Some(ranges)
|
||||
}
|
||||
|
||||
pub(crate) fn explicit_image_output_price_default(pricing_config: Option<&Value>) -> Option<f64> {
|
||||
let pricing_config = pricing_config?;
|
||||
pricing_config
|
||||
.get("image_output_price_default")
|
||||
|
||||
@@ -86,6 +86,7 @@ fn has_pricing_data(value: &Value) -> bool {
|
||||
.is_some()
|
||||
|| [
|
||||
"image_output_prices",
|
||||
"image_output_price_ranges",
|
||||
"image_output_price_per_image",
|
||||
"image_output_price_matrix",
|
||||
"image_prices",
|
||||
|
||||
@@ -3,7 +3,10 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::default_rule::{normalize_task_type, DefaultBillingRuleGenerator};
|
||||
use crate::default_rule::{
|
||||
explicit_image_output_price_default, explicit_image_output_price_entries,
|
||||
explicit_image_output_price_ranges, normalize_task_type, DefaultBillingRuleGenerator,
|
||||
};
|
||||
use crate::precision::quantize_cost;
|
||||
use crate::pricing::{BillingComputation, BillingModelPricingSnapshot, BillingUsageInput};
|
||||
use crate::schema::{
|
||||
@@ -30,7 +33,6 @@ impl BillingService {
|
||||
pricing: &BillingModelPricingSnapshot,
|
||||
input: &BillingUsageInput,
|
||||
) -> Result<BillingComputation, ExpressionEvaluationError> {
|
||||
let image_output_pricing = image_output_pricing_state(pricing);
|
||||
let Some(rule) =
|
||||
DefaultBillingRuleGenerator::generate_for_pricing(pricing, &input.task_type)
|
||||
else {
|
||||
@@ -44,7 +46,7 @@ impl BillingService {
|
||||
rule_name: None,
|
||||
scope: None,
|
||||
expression: None,
|
||||
resolved_dimensions: build_dimensions(input, image_output_pricing),
|
||||
resolved_dimensions: build_dimensions(input, pricing),
|
||||
resolved_variables: BTreeMap::new(),
|
||||
cost_breakdown: BTreeMap::new(),
|
||||
total_cost: 0.0,
|
||||
@@ -63,7 +65,7 @@ impl BillingService {
|
||||
});
|
||||
};
|
||||
|
||||
let dims = build_dimensions(input, image_output_pricing);
|
||||
let dims = build_dimensions(input, pricing);
|
||||
let result = self.engine.evaluate(
|
||||
&rule.expression,
|
||||
Some(&rule.variables),
|
||||
@@ -126,7 +128,7 @@ impl Default for BillingService {
|
||||
|
||||
fn build_dimensions(
|
||||
input: &BillingUsageInput,
|
||||
image_output_pricing: ImageOutputPricingState,
|
||||
pricing: &BillingModelPricingSnapshot,
|
||||
) -> BTreeMap<String, Value> {
|
||||
let normalized_input_tokens = normalize_input_tokens_for_billing(
|
||||
input.api_format.as_deref(),
|
||||
@@ -146,6 +148,8 @@ fn build_dimensions(
|
||||
input.cache_creation_tokens,
|
||||
input.cache_read_tokens,
|
||||
);
|
||||
let image_output_pricing = image_output_pricing_state(pricing);
|
||||
let image_output_resolution = resolve_image_output_price_resolution(pricing, input);
|
||||
|
||||
let mut out = BTreeMap::from([
|
||||
("input_tokens".to_string(), json!(normalized_input_tokens)),
|
||||
@@ -191,15 +195,17 @@ fn build_dimensions(
|
||||
"image_output_matrix_enabled".to_string(),
|
||||
json!(image_output_pricing.matrix_enabled),
|
||||
),
|
||||
(
|
||||
"image_output_range_enabled".to_string(),
|
||||
json!(image_output_pricing.range_enabled),
|
||||
),
|
||||
(
|
||||
"image_output_pricing_mode".to_string(),
|
||||
json!(if image_output_pricing.matrix_enabled {
|
||||
"matrix"
|
||||
} else if image_output_pricing.enabled {
|
||||
"per_image"
|
||||
} else {
|
||||
"none"
|
||||
}),
|
||||
json!(image_output_resolution.pricing_mode),
|
||||
),
|
||||
(
|
||||
"image_output_price_per_image".to_string(),
|
||||
json!(image_output_resolution.price_per_image),
|
||||
),
|
||||
(
|
||||
"total_input_context".to_string(),
|
||||
@@ -226,6 +232,12 @@ fn build_dimensions(
|
||||
json!(cache_ttl_minutes.max(0)),
|
||||
);
|
||||
}
|
||||
if let Some(image_pixels) = image_output_resolution.image_pixels {
|
||||
out.insert("image_pixels".to_string(), json!(image_pixels));
|
||||
}
|
||||
if let Some(price_bucket) = image_output_resolution.price_bucket.as_ref() {
|
||||
out.insert("image_output_price_bucket".to_string(), json!(price_bucket));
|
||||
}
|
||||
if input.image_count > 0 {
|
||||
let image_size = input
|
||||
.image_size
|
||||
@@ -252,8 +264,8 @@ fn build_dimensions(
|
||||
"image_price_key".to_string(),
|
||||
json!(format!(
|
||||
"{}:{}",
|
||||
image_size.to_ascii_lowercase().replace(' ', ""),
|
||||
image_quality.to_ascii_lowercase()
|
||||
normalize_image_output_size(image_size),
|
||||
normalize_image_output_quality(image_quality)
|
||||
)),
|
||||
);
|
||||
}
|
||||
@@ -273,14 +285,99 @@ fn build_dimensions(
|
||||
struct ImageOutputPricingState {
|
||||
enabled: bool,
|
||||
matrix_enabled: bool,
|
||||
range_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ImageOutputPriceResolution {
|
||||
price_per_image: f64,
|
||||
pricing_mode: &'static str,
|
||||
price_bucket: Option<String>,
|
||||
image_pixels: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ParsedImageOutputPriceRange {
|
||||
up_to_pixels: Option<i64>,
|
||||
label: Option<String>,
|
||||
prices: BTreeMap<String, f64>,
|
||||
}
|
||||
|
||||
fn image_output_pricing_state(pricing: &BillingModelPricingSnapshot) -> ImageOutputPricingState {
|
||||
let matrix_enabled = pricing_has_image_output_matrix(pricing);
|
||||
let range_enabled = pricing_has_image_output_ranges(pricing);
|
||||
let default_enabled = pricing_has_image_output_default_price(pricing);
|
||||
ImageOutputPricingState {
|
||||
enabled: matrix_enabled || default_enabled,
|
||||
enabled: matrix_enabled || range_enabled || default_enabled,
|
||||
matrix_enabled,
|
||||
range_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_image_output_price_resolution(
|
||||
pricing: &BillingModelPricingSnapshot,
|
||||
input: &BillingUsageInput,
|
||||
) -> ImageOutputPriceResolution {
|
||||
let pricing_config = pricing.effective_tiered_pricing();
|
||||
let default_price = explicit_image_output_price_default(pricing_config);
|
||||
let image_size = input
|
||||
.image_size
|
||||
.as_deref()
|
||||
.map(normalize_image_output_size)
|
||||
.filter(|value| !value.is_empty());
|
||||
let image_quality = input
|
||||
.image_quality
|
||||
.as_deref()
|
||||
.map(normalize_image_output_quality)
|
||||
.filter(|value| !value.is_empty());
|
||||
let image_pixels = image_size.as_deref().and_then(parse_image_size_pixels);
|
||||
|
||||
if let (Some(size), Some(entries)) = (
|
||||
image_size.as_deref(),
|
||||
explicit_image_output_price_entries(pricing_config),
|
||||
) {
|
||||
for key in image_price_lookup_keys(size, image_quality.as_deref()) {
|
||||
if let Some(price) = entries.get(&key).and_then(Value::as_f64) {
|
||||
return ImageOutputPriceResolution {
|
||||
price_per_image: price,
|
||||
pricing_mode: "matrix",
|
||||
price_bucket: None,
|
||||
image_pixels,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pixels) = image_pixels {
|
||||
if let Some((price, bucket)) = resolve_image_output_range_price(
|
||||
explicit_image_output_price_ranges(pricing_config).unwrap_or_default(),
|
||||
pixels,
|
||||
image_quality.as_deref(),
|
||||
default_price,
|
||||
) {
|
||||
return ImageOutputPriceResolution {
|
||||
price_per_image: price,
|
||||
pricing_mode: "pixel_tiers",
|
||||
price_bucket: Some(bucket),
|
||||
image_pixels,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(price) = default_price {
|
||||
return ImageOutputPriceResolution {
|
||||
price_per_image: price,
|
||||
pricing_mode: "per_image",
|
||||
price_bucket: Some("default".to_string()),
|
||||
image_pixels,
|
||||
};
|
||||
}
|
||||
|
||||
ImageOutputPriceResolution {
|
||||
price_per_image: 0.0,
|
||||
pricing_mode: "none",
|
||||
price_bucket: None,
|
||||
image_pixels,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +399,11 @@ fn pricing_has_image_output_matrix(pricing: &BillingModelPricingSnapshot) -> boo
|
||||
})
|
||||
}
|
||||
|
||||
fn pricing_has_image_output_ranges(pricing: &BillingModelPricingSnapshot) -> bool {
|
||||
explicit_image_output_price_ranges(pricing.effective_tiered_pricing())
|
||||
.is_some_and(|ranges| !ranges.is_empty())
|
||||
}
|
||||
|
||||
fn pricing_has_image_output_default_price(pricing: &BillingModelPricingSnapshot) -> bool {
|
||||
let Some(config) = pricing.effective_tiered_pricing() else {
|
||||
return false;
|
||||
@@ -329,6 +431,147 @@ fn image_price_entries_have_matrix_values(value: &Value) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_image_output_range_price(
|
||||
ranges: Vec<Value>,
|
||||
image_pixels: i64,
|
||||
image_quality: Option<&str>,
|
||||
default_price: Option<f64>,
|
||||
) -> Option<(f64, String)> {
|
||||
let mut parsed_ranges = ranges
|
||||
.iter()
|
||||
.filter_map(parse_image_output_price_range)
|
||||
.collect::<Vec<_>>();
|
||||
parsed_ranges.sort_by(
|
||||
|left, right| match (left.up_to_pixels, right.up_to_pixels) {
|
||||
(Some(left), Some(right)) => left.cmp(&right),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => std::cmp::Ordering::Equal,
|
||||
},
|
||||
);
|
||||
|
||||
for range in parsed_ranges {
|
||||
if !range
|
||||
.up_to_pixels
|
||||
.map(|up_to| image_pixels <= up_to)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(price) =
|
||||
image_output_price_for_quality(&range.prices, image_quality).or(default_price)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
return Some((price, image_output_range_bucket(&range)));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_image_output_price_range(value: &Value) -> Option<ParsedImageOutputPriceRange> {
|
||||
let object = value.as_object()?;
|
||||
let prices = object
|
||||
.get("prices")
|
||||
.and_then(Value::as_object)?
|
||||
.iter()
|
||||
.filter_map(|(key, value)| {
|
||||
value
|
||||
.as_f64()
|
||||
.map(|price| (key.to_ascii_lowercase(), price))
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
if prices.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(ParsedImageOutputPriceRange {
|
||||
up_to_pixels: object.get("up_to_pixels").and_then(value_as_positive_i64),
|
||||
label: object
|
||||
.get("label")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
prices,
|
||||
})
|
||||
}
|
||||
|
||||
fn image_output_price_for_quality(
|
||||
prices: &BTreeMap<String, f64>,
|
||||
image_quality: Option<&str>,
|
||||
) -> Option<f64> {
|
||||
for key in image_quality_lookup_keys(image_quality) {
|
||||
if let Some(price) = prices.get(&key) {
|
||||
return Some(*price);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn image_price_lookup_keys(size: &str, image_quality: Option<&str>) -> Vec<String> {
|
||||
image_quality_lookup_keys(image_quality)
|
||||
.into_iter()
|
||||
.filter(|quality| quality != "default")
|
||||
.map(|quality| format!("{}:{}", size, quality))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn image_quality_lookup_keys(image_quality: Option<&str>) -> Vec<String> {
|
||||
let quality = image_quality
|
||||
.map(normalize_image_output_quality)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "medium".to_string());
|
||||
let mut keys = vec![quality.clone()];
|
||||
if quality == "auto" {
|
||||
keys.push("medium".to_string());
|
||||
}
|
||||
keys.push("default".to_string());
|
||||
keys
|
||||
}
|
||||
|
||||
fn image_output_range_bucket(range: &ParsedImageOutputPriceRange) -> String {
|
||||
range
|
||||
.label
|
||||
.clone()
|
||||
.unwrap_or_else(|| match range.up_to_pixels {
|
||||
Some(up_to_pixels) => format!("<={up_to_pixels}px"),
|
||||
None => "unbounded".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_image_output_size(value: &str) -> String {
|
||||
value
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.replace('×', "x")
|
||||
.chars()
|
||||
.filter(|ch| !ch.is_whitespace())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalize_image_output_quality(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn parse_image_size_pixels(size: &str) -> Option<i64> {
|
||||
let (width, height) = size.split_once('x')?;
|
||||
let width = width.parse::<i64>().ok()?;
|
||||
let height = height.parse::<i64>().ok()?;
|
||||
if width <= 0 || height <= 0 {
|
||||
return None;
|
||||
}
|
||||
width.checked_mul(height)
|
||||
}
|
||||
|
||||
fn value_as_positive_i64(value: &Value) -> Option<i64> {
|
||||
let parsed = value
|
||||
.as_i64()
|
||||
.or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok()))
|
||||
.or_else(|| value.as_f64().map(|value| value as i64))
|
||||
.or_else(|| value.as_str().and_then(|value| value.trim().parse().ok()))?;
|
||||
(parsed > 0).then_some(parsed)
|
||||
}
|
||||
|
||||
fn now_marker() -> String {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -607,6 +850,61 @@ mod tests {
|
||||
assert_eq!(result.cost_result.cost, 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_pixel_ranges_generate_rule_without_token_tiers() {
|
||||
let pricing = BillingModelPricingSnapshot {
|
||||
default_price_per_request: None,
|
||||
default_tiered_pricing: Some(json!({
|
||||
"image_output_price_ranges": [{
|
||||
"up_to_pixels": null,
|
||||
"prices": { "medium": 0.04 }
|
||||
}]
|
||||
})),
|
||||
..pricing()
|
||||
};
|
||||
|
||||
let result = BillingService::new()
|
||||
.calculate(
|
||||
&pricing,
|
||||
&BillingUsageInput {
|
||||
task_type: "image".to_string(),
|
||||
api_format: Some("openai:image".to_string()),
|
||||
request_count: 1,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
cache_creation_ephemeral_5m_tokens: 0,
|
||||
cache_creation_ephemeral_1h_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
image_count: 2,
|
||||
image_size: Some("1024x1024".to_string()),
|
||||
image_quality: Some("medium".to_string()),
|
||||
image_output_format: Some("png".to_string()),
|
||||
cache_ttl_minutes: None,
|
||||
},
|
||||
)
|
||||
.expect("billing should calculate");
|
||||
|
||||
assert_eq!(result.cost_result.status, BillingSnapshotStatus::Complete);
|
||||
assert_eq!(
|
||||
result
|
||||
.cost_result
|
||||
.snapshot
|
||||
.resolved_dimensions
|
||||
.get("image_output_pricing_mode"),
|
||||
Some(&json!("pixel_tiers"))
|
||||
);
|
||||
assert_eq!(
|
||||
result
|
||||
.cost_result
|
||||
.snapshot
|
||||
.cost_breakdown
|
||||
.get("image_output_cost"),
|
||||
Some(&0.08)
|
||||
);
|
||||
assert_eq!(result.cost_result.cost, 0.08);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_token_usage_with_matrix_adds_matrix_image_cost() {
|
||||
let pricing = BillingModelPricingSnapshot {
|
||||
@@ -665,6 +963,96 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_token_usage_with_pixel_ranges_adds_range_image_cost() {
|
||||
let pricing = BillingModelPricingSnapshot {
|
||||
default_price_per_request: None,
|
||||
default_tiered_pricing: Some(json!({
|
||||
"tiers": [{
|
||||
"up_to": null,
|
||||
"input_price_per_1m": 1.0,
|
||||
"output_price_per_1m": 2.0
|
||||
}],
|
||||
"image_output_price_default": 0.01,
|
||||
"image_output_price_ranges": [
|
||||
{
|
||||
"up_to_pixels": 1_048_576,
|
||||
"prices": { "medium": 0.04 }
|
||||
},
|
||||
{
|
||||
"up_to_pixels": 2_097_152,
|
||||
"prices": { "medium": 0.08 }
|
||||
}
|
||||
]
|
||||
})),
|
||||
..pricing()
|
||||
};
|
||||
|
||||
let result = BillingService::new()
|
||||
.calculate(
|
||||
&pricing,
|
||||
&BillingUsageInput {
|
||||
task_type: "image".to_string(),
|
||||
api_format: Some("openai:image".to_string()),
|
||||
request_count: 1,
|
||||
input_tokens: 1_000,
|
||||
output_tokens: 20_000,
|
||||
cache_creation_tokens: 0,
|
||||
cache_creation_ephemeral_5m_tokens: 0,
|
||||
cache_creation_ephemeral_1h_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
image_count: 1,
|
||||
image_size: Some("1536 x 1024".to_string()),
|
||||
image_quality: Some("medium".to_string()),
|
||||
image_output_format: Some("png".to_string()),
|
||||
cache_ttl_minutes: None,
|
||||
},
|
||||
)
|
||||
.expect("billing should calculate");
|
||||
|
||||
assert_eq!(
|
||||
result
|
||||
.cost_result
|
||||
.snapshot
|
||||
.resolved_dimensions
|
||||
.get("image_output_pricing_mode"),
|
||||
Some(&json!("pixel_tiers"))
|
||||
);
|
||||
assert_eq!(
|
||||
result
|
||||
.cost_result
|
||||
.snapshot
|
||||
.resolved_dimensions
|
||||
.get("image_pixels"),
|
||||
Some(&json!(1_572_864))
|
||||
);
|
||||
assert_eq!(
|
||||
result
|
||||
.cost_result
|
||||
.snapshot
|
||||
.resolved_dimensions
|
||||
.get("image_output_price_bucket"),
|
||||
Some(&json!("<=2097152px"))
|
||||
);
|
||||
assert_eq!(
|
||||
result
|
||||
.cost_result
|
||||
.snapshot
|
||||
.resolved_variables
|
||||
.get("image_output_price_per_image"),
|
||||
Some(&json!(0.08))
|
||||
);
|
||||
assert_eq!(
|
||||
result
|
||||
.cost_result
|
||||
.snapshot
|
||||
.cost_breakdown
|
||||
.get("image_output_cost"),
|
||||
Some(&0.08)
|
||||
);
|
||||
assert_eq!(result.cost_result.cost, 0.121);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_minute_cache_ttl_uses_base_cache_prices() {
|
||||
let pricing = BillingModelPricingSnapshot {
|
||||
|
||||
Reference in New Issue
Block a user