Files
Aether/crates/aether-billing/src/default_rule.rs
fawney19 010ab127e2 feat: 扩展 cache creation token 细分统计与 effective_input_tokens 计费逻辑
- 新增 cache_creation_ephemeral_5m/1h_input_tokens 字段,区分不同 TTL 的缓存写入 token
- 引入 effective_input_tokens(扣除 cache read 后的有效输入 token),暴露给 usage 接口
- billing 规则生成器支持 5m/1h ephemeral cache 独立定价与分级计费
- usage_mapper 增加 Claude/Anthropic 格式映射,修复 OpenAI responses 格式字段兼容性
- 迁移逻辑增强:支持 checksum 容错、applied/pending 数量日志、逐步执行信息输出
- executor 抽离 LocalExecutionRequestOutcome 类型,统一 sync/stream 路径返回语义
- provider-transport auth 层新增 complete passthrough headers 构建逻辑
- 前端 usage 类型全面补充 effective_input_tokens、cache_creation_tokens、total_input_context 字段
2026-04-10 17:44:55 +08:00

270 lines
10 KiB
Rust

use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::pricing::BillingModelPricingSnapshot;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct VirtualBillingRule {
pub id: String,
pub name: String,
pub task_type: String,
pub expression: String,
pub variables: BTreeMap<String, Value>,
pub dimension_mappings: BTreeMap<String, Value>,
pub scope: String,
}
pub struct DefaultBillingRuleGenerator;
impl DefaultBillingRuleGenerator {
pub fn generate_for_pricing(
pricing: &BillingModelPricingSnapshot,
task_type: &str,
) -> Option<VirtualBillingRule> {
let tiers = pricing
.effective_tiered_pricing()
.and_then(|value| value.get("tiers"))
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
if tiers.is_empty() && pricing.effective_price_per_request().is_none() {
return None;
}
let first_tier = tiers.first().cloned().unwrap_or_else(|| json!({}));
let base_input_price = tier_value(&first_tier, "input_price_per_1m", 0.0);
let base_output_price = tier_value(&first_tier, "output_price_per_1m", 0.0);
let base_cache_creation_price =
tier_value_with_fallback(&first_tier, "cache_creation_price_per_1m", 1.25);
let base_cache_read_price =
tier_value_with_fallback(&first_tier, "cache_read_price_per_1m", 0.1);
let base_request_price = pricing.effective_price_per_request().unwrap_or(0.0);
let mut variables = BTreeMap::new();
variables.insert("input_price_per_1m".to_string(), json!(base_input_price));
variables.insert("output_price_per_1m".to_string(), json!(base_output_price));
variables.insert(
"cache_creation_price_per_1m".to_string(),
json!(base_cache_creation_price),
);
variables.insert(
"cache_creation_ephemeral_5m_price_per_1m".to_string(),
json!(base_cache_creation_price),
);
variables.insert(
"cache_creation_ephemeral_1h_price_per_1m".to_string(),
json!(base_cache_creation_price),
);
variables.insert(
"cache_read_price_per_1m".to_string(),
json!(base_cache_read_price),
);
variables.insert("price_per_request".to_string(), json!(base_request_price));
let mut dimension_mappings = BTreeMap::new();
for (name, key, default) in [
("input_tokens", "input_tokens", json!(0)),
("output_tokens", "output_tokens", json!(0)),
("cache_creation_tokens", "cache_creation_tokens", json!(0)),
(
"cache_creation_ephemeral_5m_tokens",
"cache_creation_ephemeral_5m_tokens",
json!(0),
),
(
"cache_creation_ephemeral_1h_tokens",
"cache_creation_ephemeral_1h_tokens",
json!(0),
),
(
"cache_creation_uncategorized_tokens",
"cache_creation_uncategorized_tokens",
json!(0),
),
("cache_read_tokens", "cache_read_tokens", json!(0)),
("request_count", "request_count", json!(1)),
] {
dimension_mappings.insert(
name.to_string(),
json!({
"source": "dimension",
"key": key,
"required": false,
"allow_zero": true,
"default": default,
}),
);
}
for (name, expression) in [
("input_cost", "input_tokens * input_price_per_1m / 1000000"),
(
"output_cost",
"output_tokens * output_price_per_1m / 1000000",
),
(
"cache_creation_uncategorized_cost",
"cache_creation_uncategorized_tokens * cache_creation_price_per_1m / 1000000",
),
(
"cache_creation_ephemeral_5m_cost",
"cache_creation_ephemeral_5m_tokens * cache_creation_ephemeral_5m_price_per_1m / 1000000",
),
(
"cache_creation_ephemeral_1h_cost",
"cache_creation_ephemeral_1h_tokens * cache_creation_ephemeral_1h_price_per_1m / 1000000",
),
(
"cache_read_cost",
"cache_read_tokens * cache_read_price_per_1m / 1000000",
),
("request_cost", "request_count * price_per_request"),
] {
dimension_mappings.insert(
name.to_string(),
json!({
"source": "computed",
"expression": expression,
"required": false,
"default": 0,
}),
);
}
if !tiers.is_empty() {
dimension_mappings.insert(
"input_price_per_1m".to_string(),
json!({
"source": "tiered",
"tier_key": "total_input_context",
"allow_zero": true,
"tiers": build_tier_entries(&tiers, "input_price_per_1m", None, false),
"default": base_input_price,
}),
);
dimension_mappings.insert(
"output_price_per_1m".to_string(),
json!({
"source": "tiered",
"tier_key": "total_input_context",
"allow_zero": true,
"tiers": build_tier_entries(&tiers, "output_price_per_1m", None, false),
"default": base_output_price,
}),
);
dimension_mappings.insert(
"cache_creation_price_per_1m".to_string(),
json!({
"source": "tiered",
"tier_key": "total_input_context",
"allow_zero": true,
"ttl_key": "cache_ttl_minutes",
"ttl_value_key": "cache_creation_price_per_1m",
"tiers": build_tier_entries(&tiers, "cache_creation_price_per_1m", Some(1.25), true),
"default": base_cache_creation_price,
}),
);
dimension_mappings.insert(
"cache_creation_ephemeral_5m_price_per_1m".to_string(),
json!({
"source": "tiered",
"tier_key": "total_input_context",
"allow_zero": true,
"ttl_key": "cache_creation_ephemeral_5m_ttl_minutes",
"ttl_value_key": "cache_creation_price_per_1m",
"tiers": build_tier_entries(&tiers, "cache_creation_price_per_1m", Some(1.25), true),
"default": base_cache_creation_price,
}),
);
dimension_mappings.insert(
"cache_creation_ephemeral_1h_price_per_1m".to_string(),
json!({
"source": "tiered",
"tier_key": "total_input_context",
"allow_zero": true,
"ttl_key": "cache_creation_ephemeral_1h_ttl_minutes",
"ttl_value_key": "cache_creation_price_per_1m",
"tiers": build_tier_entries(&tiers, "cache_creation_price_per_1m", Some(1.25), true),
"default": base_cache_creation_price,
}),
);
dimension_mappings.insert(
"cache_read_price_per_1m".to_string(),
json!({
"source": "tiered",
"tier_key": "total_input_context",
"allow_zero": true,
"ttl_key": "cache_ttl_minutes",
"ttl_value_key": "cache_read_price_per_1m",
"tiers": build_tier_entries(&tiers, "cache_read_price_per_1m", Some(0.1), true),
"default": base_cache_read_price,
}),
);
}
Some(VirtualBillingRule {
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(),
variables,
dimension_mappings,
scope: "default".to_string(),
})
}
}
pub fn normalize_task_type(task_type: &str) -> &str {
if task_type.trim().eq_ignore_ascii_case("cli") {
"chat"
} else {
task_type.trim()
}
}
fn tier_value(tier: &Value, key: &str, default: f64) -> f64 {
tier.get(key).and_then(Value::as_f64).unwrap_or(default)
}
fn tier_value_with_fallback(tier: &Value, key: &str, default_multiplier: f64) -> f64 {
if let Some(value) = tier.get(key).and_then(Value::as_f64) {
return value;
}
tier.get("input_price_per_1m")
.and_then(Value::as_f64)
.map(|value| value * default_multiplier)
.unwrap_or(0.0)
}
fn build_tier_entries(
tiers: &[Value],
key: &str,
default_multiplier: Option<f64>,
include_cache_ttl_pricing: bool,
) -> Vec<Value> {
tiers
.iter()
.map(|tier| {
let mut value = serde_json::Map::new();
value.insert(
"up_to".to_string(),
tier.get("up_to").cloned().unwrap_or(Value::Null),
);
let resolved = match default_multiplier {
Some(multiplier) => Value::from(tier_value_with_fallback(tier, key, multiplier)),
None => Value::from(tier_value(tier, key, 0.0)),
};
value.insert("value".to_string(), resolved);
if include_cache_ttl_pricing {
if let Some(ttl_pricing) = tier.get("cache_ttl_pricing").cloned() {
value.insert("cache_ttl_pricing".to_string(), ttl_pricing);
}
}
Value::Object(value)
})
.collect()
}