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

This commit is contained in:
ZheFox
2026-05-18 02:49:56 +08:00
parent 691ccaaa04
commit 0b2a8fafce
9 changed files with 884 additions and 15 deletions

View File

@@ -33,12 +33,14 @@ impl DefaultBillingRuleGenerator {
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);
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();
if tiers.is_empty()
&& pricing.effective_price_per_request().is_none()
&& image_output_price_entries.is_empty()
&& !has_image_output_pricing
{
return None;
}

View File

@@ -30,6 +30,7 @@ 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 {
@@ -43,7 +44,7 @@ impl BillingService {
rule_name: None,
scope: None,
expression: None,
resolved_dimensions: build_dimensions(input),
resolved_dimensions: build_dimensions(input, image_output_pricing),
resolved_variables: BTreeMap::new(),
cost_breakdown: BTreeMap::new(),
total_cost: 0.0,
@@ -62,7 +63,7 @@ impl BillingService {
});
};
let dims = build_dimensions(input);
let dims = build_dimensions(input, image_output_pricing);
let result = self.engine.evaluate(
&rule.expression,
Some(&rule.variables),
@@ -123,7 +124,10 @@ impl Default for BillingService {
}
}
fn build_dimensions(input: &BillingUsageInput) -> BTreeMap<String, Value> {
fn build_dimensions(
input: &BillingUsageInput,
image_output_pricing: ImageOutputPricingState,
) -> BTreeMap<String, Value> {
let normalized_input_tokens = normalize_input_tokens_for_billing(
input.api_format.as_deref(),
input.input_tokens,
@@ -173,10 +177,28 @@ fn build_dimensions(input: &BillingUsageInput) -> BTreeMap<String, Value> {
("image_count".to_string(), json!(input.image_count.max(0))),
(
"image_count_unmetered".to_string(),
json!(if input.output_tokens > 0 {
0
} else {
json!(if image_output_pricing.enabled {
input.image_count.max(0)
} else {
0
}),
),
(
"image_output_pricing_enabled".to_string(),
json!(image_output_pricing.enabled),
),
(
"image_output_matrix_enabled".to_string(),
json!(image_output_pricing.matrix_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"
}),
),
(
@@ -247,6 +269,66 @@ fn build_dimensions(input: &BillingUsageInput) -> BTreeMap<String, Value> {
out
}
#[derive(Debug, Clone, Copy)]
struct ImageOutputPricingState {
enabled: bool,
matrix_enabled: bool,
}
fn image_output_pricing_state(pricing: &BillingModelPricingSnapshot) -> ImageOutputPricingState {
let matrix_enabled = pricing_has_image_output_matrix(pricing);
let default_enabled = pricing_has_image_output_default_price(pricing);
ImageOutputPricingState {
enabled: matrix_enabled || default_enabled,
matrix_enabled,
}
}
fn pricing_has_image_output_matrix(pricing: &BillingModelPricingSnapshot) -> bool {
let Some(config) = pricing.effective_tiered_pricing() else {
return false;
};
[
"image_output_prices",
"image_output_price_per_image",
"image_output_price_matrix",
"image_prices",
]
.iter()
.any(|key| {
config
.get(key)
.is_some_and(image_price_entries_have_matrix_values)
})
}
fn pricing_has_image_output_default_price(pricing: &BillingModelPricingSnapshot) -> bool {
let Some(config) = pricing.effective_tiered_pricing() else {
return false;
};
config
.get("image_output_price_default")
.or_else(|| config.get("image_price_default"))
.or_else(|| {
config
.get("image_output_prices")
.and_then(|value| value.get("default"))
})
.and_then(Value::as_f64)
.is_some()
}
fn image_price_entries_have_matrix_values(value: &Value) -> bool {
match value {
Value::Object(object) => object.iter().any(|(key, value)| {
!key.eq_ignore_ascii_case("default")
&& (value.as_f64().is_some() || image_price_entries_have_matrix_values(value))
}),
Value::Array(items) => items.iter().any(image_price_entries_have_matrix_values),
_ => false,
}
}
fn now_marker() -> String {
SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -362,6 +444,227 @@ mod tests {
);
}
#[test]
fn image_token_usage_without_image_output_price_bills_tokens_only() {
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
}]
})),
..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("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
.snapshot
.resolved_dimensions
.get("image_output_pricing_mode"),
Some(&json!("none"))
);
assert_eq!(
result
.cost_result
.snapshot
.resolved_dimensions
.get("image_count_unmetered"),
Some(&json!(0))
);
assert_eq!(
result
.cost_result
.snapshot
.cost_breakdown
.get("image_output_cost"),
Some(&0.0)
);
assert_eq!(result.cost_result.cost, 0.041);
}
#[test]
fn image_default_output_price_adds_image_cost_even_with_token_usage() {
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.05
})),
..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("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
.snapshot
.resolved_dimensions
.get("image_output_pricing_mode"),
Some(&json!("per_image"))
);
assert_eq!(
result
.cost_result
.snapshot
.cost_breakdown
.get("image_output_cost"),
Some(&0.05)
);
assert_eq!(result.cost_result.cost, 0.091);
}
#[test]
fn image_default_output_price_generates_rule_without_token_tiers() {
let pricing = BillingModelPricingSnapshot {
default_price_per_request: None,
default_tiered_pricing: Some(json!({
"image_output_price_default": 0.05
})),
..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
.cost_breakdown
.get("image_output_cost"),
Some(&0.1)
);
assert_eq!(result.cost_result.cost, 0.1);
}
#[test]
fn image_token_usage_with_matrix_adds_matrix_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_prices": {
"1024x1024": { "medium": 0.05 }
}
})),
..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("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
.snapshot
.resolved_dimensions
.get("image_output_pricing_mode"),
Some(&json!("matrix"))
);
assert_eq!(
result
.cost_result
.snapshot
.cost_breakdown
.get("image_output_cost"),
Some(&0.05)
);
}
#[test]
fn five_minute_cache_ttl_uses_base_cache_prices() {
let pricing = BillingModelPricingSnapshot {