feat(billing): add image quality pricing and usage tracking

This commit is contained in:
ZheFox
2026-05-18 00:11:30 +08:00
parent 680b617b00
commit c024c782e4
13 changed files with 559 additions and 12 deletions

View File

@@ -816,6 +816,11 @@ fn openai_image_stream_standardized_usage(
.dimensions
.insert("image_size".to_string(), serde_json::json!(size));
}
if let Some(quality) = image_request_quality(report_context) {
standardized_usage
.dimensions
.insert("image_quality".to_string(), serde_json::json!(quality));
}
(standardized_usage.signal_score() > 0).then_some(standardized_usage)
}
@@ -1049,6 +1054,16 @@ fn image_request_size(report_context: Option<&Value>) -> Option<String> {
.map(ToOwned::to_owned)
}
fn image_request_quality(report_context: Option<&Value>) -> Option<String> {
report_context
.and_then(|value| value.get("image_request"))
.and_then(|value| value.get("quality"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn image_bridge_model(report_context: Option<&Value>) -> Option<String> {
report_context.and_then(|context| {
context

View File

@@ -978,6 +978,7 @@ mod tests {
let mut report_context = report_context("openai:image", "openai:chat");
report_context["image_request"] = json!({
"size": "1024x1024",
"quality": "medium",
"output_format": "png",
});
let mut observer = StreamingStandardTerminalObserver::default();
@@ -1048,5 +1049,9 @@ mod tests {
usage.dimensions.get("image_output_format"),
Some(&json!("png"))
);
assert_eq!(
usage.dimensions.get("image_quality"),
Some(&json!("medium"))
);
}
}

View File

@@ -471,6 +471,11 @@ fn openai_image_standardized_usage(
.dimensions
.insert("image_size".to_string(), json!(size));
}
if let Some(quality) = image_request_quality(report_context) {
standardized_usage
.dimensions
.insert("image_quality".to_string(), json!(quality));
}
(standardized_usage.signal_score() > 0).then_some(standardized_usage)
}
@@ -546,6 +551,16 @@ fn image_request_size(report_context: Option<&Value>) -> Option<String> {
.map(ToOwned::to_owned)
}
fn image_request_quality(report_context: Option<&Value>) -> Option<String> {
report_context
.and_then(|value| value.get("image_request"))
.and_then(|value| value.get("quality"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn mime_type_from_image_output_format(output_format: &str) -> String {
match output_format.trim().to_ascii_lowercase().as_str() {
"jpg" | "jpeg" => "image/jpeg".to_string(),
@@ -999,7 +1014,8 @@ mod tests {
"image_request": {
"operation": "generate",
"output_format": "png",
"size": "1024x1024"
"size": "1024x1024",
"quality": "medium"
}
});
let outcome = maybe_bridge_standard_sync_json_to_stream(
@@ -1045,6 +1061,10 @@ mod tests {
usage.dimensions.get("image_size"),
Some(&json!("1024x1024"))
);
assert_eq!(
usage.dimensions.get("image_quality"),
Some(&json!("medium"))
);
}
#[test]

View File

@@ -23,14 +23,23 @@ impl DefaultBillingRuleGenerator {
pricing: &BillingModelPricingSnapshot,
task_type: &str,
) -> Option<VirtualBillingRule> {
let pricing_config = pricing.effective_tiered_pricing();
let tiers = pricing
.effective_tiered_pricing()
.and_then(|value| value.get("tiers"))
.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 image_output_price_default =
explicit_image_output_price_default(pricing_config).unwrap_or(0.0);
if tiers.is_empty() && pricing.effective_price_per_request().is_none() {
if tiers.is_empty()
&& pricing.effective_price_per_request().is_none()
&& image_output_price_entries.is_empty()
{
return None;
}
@@ -63,6 +72,10 @@ impl DefaultBillingRuleGenerator {
json!(base_cache_read_price),
);
variables.insert("price_per_request".to_string(), json!(base_request_price));
variables.insert(
"image_output_price_per_image".to_string(),
json!(image_output_price_default),
);
let mut dimension_mappings = BTreeMap::new();
for (name, key, default) in [
@@ -87,6 +100,8 @@ impl DefaultBillingRuleGenerator {
("cache_read_tokens", "cache_read_tokens", json!(0)),
("request_count", "request_count", json!(1)),
("image_count", "image_count", json!(0)),
("image_count_unmetered", "image_count_unmetered", json!(0)),
("image_price_key", "image_price_key", json!("default")),
] {
dimension_mappings.insert(
name.to_string(),
@@ -122,6 +137,10 @@ impl DefaultBillingRuleGenerator {
"cache_read_cost",
"cache_read_tokens * cache_read_price_per_1m / 1000000",
),
(
"image_output_cost",
"image_count_unmetered * image_output_price_per_image",
),
("request_cost", "request_count * price_per_request"),
] {
dimension_mappings.insert(
@@ -135,6 +154,18 @@ 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(),
@@ -210,7 +241,7 @@ impl DefaultBillingRuleGenerator {
id: "__default__".to_string(),
name: format!("Default rule for {}", pricing.global_model_name),
task_type: normalize_task_type(task_type).to_string(),
expression: "input_cost + output_cost + cache_creation_uncategorized_cost + cache_creation_ephemeral_5m_cost + cache_creation_ephemeral_1h_cost + cache_read_cost + request_cost".to_string(),
expression: "input_cost + output_cost + cache_creation_uncategorized_cost + cache_creation_ephemeral_5m_cost + cache_creation_ephemeral_1h_cost + cache_read_cost + image_output_cost + request_cost".to_string(),
variables,
dimension_mappings,
scope: "default".to_string(),
@@ -268,3 +299,115 @@ fn build_tier_entries(
})
.collect()
}
fn explicit_image_output_price_entries(
pricing_config: Option<&Value>,
) -> Option<BTreeMap<String, Value>> {
let pricing_config = pricing_config?;
let mut entries = BTreeMap::new();
for key in [
"image_output_prices",
"image_output_price_per_image",
"image_output_price_matrix",
"image_prices",
] {
if let Some(value) = pricing_config.get(key) {
collect_image_output_price_entries(value, &mut entries);
}
}
Some(entries)
}
fn explicit_image_output_price_default(pricing_config: Option<&Value>) -> Option<f64> {
let pricing_config = pricing_config?;
pricing_config
.get("image_output_price_default")
.or_else(|| pricing_config.get("image_price_default"))
.or_else(|| {
pricing_config
.get("image_output_prices")
.and_then(|value| value.get("default"))
})
.and_then(Value::as_f64)
}
fn collect_image_output_price_entries(value: &Value, entries: &mut BTreeMap<String, Value>) {
if let Some(object) = value.as_object() {
for (key, value) in object {
if key.eq_ignore_ascii_case("default") {
continue;
}
if let Some(price) = value.as_f64() {
entries.insert(normalize_image_price_key(key), json!(price));
continue;
}
let Some(nested) = value.as_object() else {
continue;
};
let key_is_quality = matches_quality_key(key);
for (nested_key, nested_value) in nested {
let Some(price) = nested_value.as_f64() else {
continue;
};
let (size, quality) = if key_is_quality {
(nested_key.as_str(), key.as_str())
} else {
(key.as_str(), nested_key.as_str())
};
entries.insert(image_price_key(size, quality), json!(price));
}
}
return;
}
if let Some(items) = value.as_array() {
for item in items.iter().filter_map(Value::as_object) {
let Some(size) = item.get("size").and_then(Value::as_str) else {
continue;
};
let quality = item
.get("quality")
.and_then(Value::as_str)
.unwrap_or("medium");
let Some(price) = item
.get("price_per_image")
.or_else(|| item.get("price"))
.or_else(|| item.get("cost"))
.and_then(Value::as_f64)
else {
continue;
};
entries.insert(image_price_key(size, quality), json!(price));
}
}
}
fn normalize_image_price_key(value: &str) -> String {
if let Some((size, quality)) = value.split_once(':').or_else(|| value.split_once('|')) {
return image_price_key(size, quality);
}
value.trim().to_ascii_lowercase().replace(' ', "")
}
fn image_price_key(size: &str, quality: &str) -> String {
format!(
"{}:{}",
normalize_image_size(size),
normalize_image_quality(quality)
)
}
fn normalize_image_size(value: &str) -> String {
value.trim().to_ascii_lowercase().replace(' ', "")
}
fn normalize_image_quality(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn matches_quality_key(value: &str) -> bool {
matches!(
normalize_image_quality(value).as_str(),
"low" | "medium" | "high"
)
}

View File

@@ -169,6 +169,9 @@ fn calculate_billing_computation(
.unwrap_or_default() as i64,
cache_read_tokens: event.data.cache_read_input_tokens.unwrap_or_default() as i64,
image_count,
image_size: usage_event_dimension_string(&event.data, "image_size"),
image_quality: usage_event_dimension_string(&event.data, "image_quality"),
image_output_format: usage_event_dimension_string(&event.data, "image_output_format"),
cache_ttl_minutes: pricing.provider_api_key_cache_ttl_minutes,
};
@@ -200,6 +203,37 @@ fn usage_event_image_count(data: &aether_usage_runtime::UsageEventData) -> Optio
.filter(|value| *value > 0)
}
fn usage_event_dimension_string(
data: &aether_usage_runtime::UsageEventData,
dimension_key: &str,
) -> Option<String> {
metadata_dimension_string(data.request_metadata.as_ref(), "dimensions", dimension_key).or_else(
|| {
metadata_dimension_string(
data.request_metadata.as_ref(),
"billing_dimensions",
dimension_key,
)
},
)
}
fn metadata_dimension_string(
metadata: Option<&Value>,
bag_key: &str,
dimension_key: &str,
) -> Option<String> {
metadata
.and_then(Value::as_object)
.and_then(|object| object.get(bag_key))
.and_then(Value::as_object)
.and_then(|object| object.get(dimension_key))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn metadata_dimension_i64(
metadata: Option<&Value>,
bag_key: &str,
@@ -524,6 +558,100 @@ mod tests {
);
}
#[tokio::test]
async fn image_usage_uses_configured_output_price_matrix() {
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,
None,
Some(json!({
"tiers": [{
"up_to": null,
"input_price_per_1m": 5.0,
"output_price_per_1m": 30.0,
"cache_read_price_per_1m": 1.25
}],
"image_output_price_default": 0.01,
"image_output_prices": {
"1024x1024": {"low": 0.006, "medium": 0.053, "high": 0.211},
"1536x1024": {"low": 0.005, "medium": 0.041, "high": 0.165},
"1024x1536": {"low": 0.005, "medium": 0.041, "high": 0.165}
}
})),
Some("model-image-1".to_string()),
Some("gpt-image-2".to_string()),
None,
None,
None,
)
.expect("billing context should build"),
),
model_id_context: None,
};
let mut event = UsageEvent::new(
UsageEventType::Completed,
"req-image-billing-matrix-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("chat".to_string()),
api_format: Some("openai:chat".to_string()),
endpoint_api_format: Some("openai:image".to_string()),
request_metadata: Some(json!({
"dimensions": {
"image_count": 2,
"image_size": "1536x1024",
"image_quality": "medium",
"image_output_format": "png"
}
})),
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.082));
assert_eq!(event.data.actual_total_cost_usd, Some(0.082));
let metadata = event.data.request_metadata.as_ref().expect("metadata");
assert_eq!(
metadata
.get("billing_dimensions")
.and_then(|value| value.get("image_price_key"))
.and_then(Value::as_str),
Some("1536x1024:medium")
);
assert_eq!(
metadata
.get("billing_snapshot")
.and_then(|value| value.get("resolved_variables"))
.and_then(|value| value.get("image_output_price_per_image"))
.and_then(Value::as_f64),
Some(0.041)
);
assert_eq!(
metadata
.get("billing_snapshot")
.and_then(|value| value.get("cost_breakdown"))
.and_then(|value| value.get("image_output_cost"))
.and_then(Value::as_f64),
Some(0.082)
);
}
#[tokio::test]
async fn enriches_cancelled_usage_event_with_billing_snapshot() {
let lookup = TestLookup {

View File

@@ -24,7 +24,7 @@ impl BillingModelPricingSnapshot {
pub fn effective_tiered_pricing(&self) -> Option<&Value> {
self.model_tiered_pricing
.as_ref()
.filter(|value| has_tiered_pricing_tiers(value))
.filter(|value| has_pricing_data(value))
.or(self.default_tiered_pricing.as_ref())
}
@@ -37,7 +37,7 @@ impl BillingModelPricingSnapshot {
if self
.model_tiered_pricing
.as_ref()
.is_some_and(has_tiered_pricing_tiers)
.is_some_and(has_pricing_data)
|| self.model_price_per_request.is_some()
{
"provider_override"
@@ -75,11 +75,28 @@ impl BillingModelPricingSnapshot {
}
}
fn has_tiered_pricing_tiers(value: &Value) -> bool {
fn has_pricing_data(value: &Value) -> bool {
value
.get("tiers")
.and_then(Value::as_array)
.is_some_and(|tiers| !tiers.is_empty())
|| value
.get("image_output_price_default")
.and_then(Value::as_f64)
.is_some()
|| [
"image_output_prices",
"image_output_price_per_image",
"image_output_price_matrix",
"image_prices",
]
.iter()
.any(|key| value.get(key).is_some_and(value_has_entries))
}
fn value_has_entries(value: &Value) -> bool {
value.as_object().is_some_and(|object| !object.is_empty())
|| value.as_array().is_some_and(|items| !items.is_empty())
}
#[cfg(test)]
@@ -148,6 +165,9 @@ pub struct BillingUsageInput {
pub cache_creation_ephemeral_1h_tokens: i64,
pub cache_read_tokens: i64,
pub image_count: i64,
pub image_size: Option<String>,
pub image_quality: Option<String>,
pub image_output_format: Option<String>,
pub cache_ttl_minutes: Option<i64>,
}
@@ -164,6 +184,9 @@ impl BillingUsageInput {
cache_creation_ephemeral_1h_tokens: 0,
cache_read_tokens: 0,
image_count: 0,
image_size: None,
image_quality: None,
image_output_format: None,
cache_ttl_minutes: None,
}
}

View File

@@ -171,6 +171,14 @@ fn build_dimensions(input: &BillingUsageInput) -> BTreeMap<String, Value> {
json!(input.request_count.max(0)),
),
("image_count".to_string(), json!(input.image_count.max(0))),
(
"image_count_unmetered".to_string(),
json!(if input.output_tokens > 0 {
0
} else {
input.image_count.max(0)
}),
),
(
"total_input_context".to_string(),
json!(total_input_context),
@@ -196,6 +204,46 @@ fn build_dimensions(input: &BillingUsageInput) -> BTreeMap<String, Value> {
json!(cache_ttl_minutes.max(0)),
);
}
if input.image_count > 0 {
let image_size = input
.image_size
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let image_quality = input
.image_quality
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
if let Some(image_size) = image_size.as_ref() {
out.insert("image_size".to_string(), json!(image_size));
}
if let Some(image_quality) = image_quality.as_ref() {
out.insert("image_quality".to_string(), json!(image_quality));
}
if let (Some(image_size), Some(image_quality)) =
(image_size.as_ref(), image_quality.as_ref())
{
out.insert(
"image_price_key".to_string(),
json!(format!(
"{}:{}",
image_size.to_ascii_lowercase().replace(' ', ""),
image_quality.to_ascii_lowercase()
)),
);
}
}
if let Some(output_format) = input
.image_output_format
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
out.insert("image_output_format".to_string(), json!(output_format));
}
out
}
@@ -258,6 +306,9 @@ mod tests {
cache_creation_ephemeral_1h_tokens: 0,
cache_read_tokens: 100,
image_count: 0,
image_size: None,
image_quality: None,
image_output_format: None,
cache_ttl_minutes: Some(60),
},
)
@@ -285,6 +336,9 @@ mod tests {
cache_creation_ephemeral_1h_tokens: 0,
cache_read_tokens: 800,
image_count: 0,
image_size: None,
image_quality: None,
image_output_format: None,
cache_ttl_minutes: Some(60),
},
)
@@ -355,6 +409,9 @@ mod tests {
cache_creation_ephemeral_1h_tokens: 0,
cache_read_tokens: 100,
image_count: 0,
image_size: None,
image_quality: None,
image_output_format: None,
cache_ttl_minutes: Some(5),
},
)
@@ -425,6 +482,9 @@ mod tests {
cache_creation_ephemeral_1h_tokens: 0,
cache_read_tokens: 100,
image_count: 0,
image_size: None,
image_quality: None,
image_output_format: None,
cache_ttl_minutes: Some(60),
},
)