fix(billing): correct provider pricing and service tier accounting

Use the actual response tier for OpenAI costs and persisted observations.
Replace broad model-prefix pricing with explicit variants and documented
aliases and snapshots. Retain regular Sol prices, excluding temporary
promotions, and add GPT Image 2.5 Sunburst and Flare token pricing.

Include File Search call fees and leave incomplete tool totals unpriced.
Add Grok 4.6 and confirmed Priority rates, persist the served xAI tier,
and preserve authoritative zero-cost responses. Reject inconsistent cache
counts, invalid totals and token-count overflow during local estimation.

Validation: OpenAI, xAI and gateway-protocol tests, additional billing and
compaction regressions, workspace rustfmt, strict workspace Clippy with
all targets and features, and diff whitespace checks passed.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-09 13:37:12 +08:00
co-authored by Codex
parent 6673b6bd1e
commit 35988d0eb7
16 changed files with 825 additions and 99 deletions
@@ -65,7 +65,8 @@ pub fn extract_usage(body: &Value) -> Option<TokenUsage> {
let image_output_tokens =
nested_number_field(body, &["tool_usage", "image_gen", "output_tokens"])
.unwrap_or_default();
let total_tokens = number_field(usage, "total_tokens").unwrap_or(input_tokens + output_tokens);
let total_tokens = number_field(usage, "total_tokens")
.unwrap_or_else(|| input_tokens.saturating_add(output_tokens));
let has_usage = [
"input_tokens",
@@ -98,7 +99,7 @@ pub fn extract_usage(body: &Value) -> Option<TokenUsage> {
})
}
/// 校验计价所需的输入、输出和缓存读取字段,拒绝缺失、非法或不一致的数值。
/// 校验计价所需的 token 字段,拒绝缺失、非法、溢出或不一致的数值。
///
/// 用量提取允许缺失字段补零;计价必须额外确认这些零值有明确的上游事实。
#[must_use]
@@ -122,11 +123,28 @@ pub fn billable_usage_is_complete(response: &Value, usage: TokenUsage) -> bool {
Some(value) => value.as_u64(),
None => Some(0),
};
let written = raw
.pointer("/input_tokens_details/cache_write_tokens")
.or_else(|| raw.pointer("/prompt_tokens_details/cache_write_tokens"))
.or_else(|| raw.get("cache_write_tokens"));
let written = match written {
Some(value) => value.as_u64(),
None => Some(0),
};
let total = usage.input_tokens.checked_add(usage.output_tokens);
input == Some(usage.input_tokens)
&& output == Some(usage.output_tokens)
&& cached == Some(usage.cached_tokens)
&& usage.cached_tokens <= usage.input_tokens
&& written == Some(usage.cache_write_tokens)
&& usage
.cached_tokens
.checked_add(usage.cache_write_tokens)
.is_some_and(|cached| cached <= usage.input_tokens)
&& total == Some(usage.total_tokens)
&& raw
.get("total_tokens")
.is_none_or(|value| value.as_u64() == total)
}
/// 从完整 SSE 文本中提取最终可见用量。
@@ -5,6 +5,57 @@ use gateway_protocol::openai::events::{
};
use serde_json::json;
#[test]
fn billable_usage_should_validate_cache_writes_totals_and_overflow() {
for (raw, expected) in [
(
json!({"input_tokens": 100, "output_tokens": 10, "input_tokens_details": {"cached_tokens": 20, "cache_write_tokens": 80}, "total_tokens": 110}),
true,
),
(
json!({"prompt_tokens": 100, "completion_tokens": 10, "prompt_tokens_details": {"cached_tokens": 20, "cache_write_tokens": 80}}),
true,
),
(
json!({"input_tokens": 100, "output_tokens": 10, "cached_tokens": 20, "cache_write_tokens": 90}),
false,
),
(
json!({"input_tokens": 100, "output_tokens": 10, "cache_write_tokens": -1}),
false,
),
(
json!({"input_tokens": 100, "output_tokens": 10, "input_tokens_details": {"cache_write_tokens": "1"}, "cache_write_tokens": 1}),
false,
),
(
json!({"input_tokens": 100, "output_tokens": 10, "cache_write_tokens": 1.5}),
false,
),
(
json!({"input_tokens": 100, "output_tokens": 10, "total_tokens": 111}),
false,
),
(
json!({"input_tokens": 100, "output_tokens": 10, "total_tokens": "110"}),
false,
),
(json!({"input_tokens": u64::MAX, "output_tokens": 1}), false),
(
json!({"input_tokens": u64::MAX, "output_tokens": 0, "cached_tokens": u64::MAX, "cache_write_tokens": 1}),
false,
),
] {
let response = json!({"usage": raw});
let usage = extract_usage(&response).expect("提取用量不得因溢出而崩溃");
assert_eq!(
billable_usage_is_complete(&response, usage),
expected,
"{response}"
);
}
}
#[test]
fn billable_usage_should_require_valid_input_output_and_cache_facts() {
for (raw, expected) in [
@@ -145,9 +145,9 @@ impl OpenAiResponseObservationState {
}
pub(super) fn effective_service_tier(&self) -> Option<&str> {
self.requested_service_tier
self.upstream_service_tier
.as_deref()
.or(self.upstream_service_tier.as_deref())
.or(self.requested_service_tier.as_deref())
}
pub(super) fn merge_rate_limit_headers(&mut self, updates: &[(String, String)]) -> bool {
@@ -158,7 +158,7 @@ impl CodexCanonicalDecoder {
self
}
/// 请求显式指定的档位优先用于计费;响应档位只在请求未指定时兜底。
/// 响应未报告实际档位时,才使用请求档位估算费用。
#[must_use]
pub fn with_requested_service_tier(mut self, service_tier: Option<&str>) -> Self {
self.requested_service_tier = normalize_service_tier(service_tier);
@@ -862,18 +862,19 @@ impl CodexCanonicalDecoder {
.unwrap_or(&self.fallback_model)
.to_owned();
let service_tier = self
.requested_service_tier
.response_service_tier
.as_deref()
.or(self.response_service_tier.as_deref());
let web_search_calls = web_search_call_count(response);
.or(self.requested_service_tier.as_deref());
let tool_calls = billable_tool_calls(response);
if let Some(breakdown) = usage
.filter(|usage| billable_usage_is_complete(response, *usage))
.and_then(|usage| {
let web_search_calls = web_search_calls?;
let (web_search_calls, file_search_calls) = tool_calls?;
openai_billing_breakdown(
&model,
OpenAiBillingUsage::from(usage)
.with_web_search_calls(web_search_calls, self.web_search_pricing),
.with_web_search_calls(web_search_calls, self.web_search_pricing)
.with_file_search_calls(file_search_calls),
service_tier,
)
})
@@ -1000,26 +1001,43 @@ fn core_usage(usage: TokenUsage) -> Usage {
normalized
}
fn web_search_call_count(response: &Value) -> Option<u64> {
let mut calls = 0_u64;
for item in response
.get("output")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter(|item| item.get("type").and_then(Value::as_str) == Some("web_search_call"))
{
match item
.get("action")
.and_then(|action| action.get("type"))
.and_then(Value::as_str)
{
Some("search") => calls = calls.checked_add(1)?,
Some("open_page" | "find_in_page") => {}
Some(_) | None => return None,
fn billable_tool_calls(response: &Value) -> Option<(u64, u64)> {
let mut web = 0_u64;
let mut file = 0_u64;
let items = match response.get("output") {
Some(value) => value.as_array()?.as_slice(),
None => &[],
};
for item in items {
match item.get("type").and_then(Value::as_str)? {
"web_search_call" => match item.pointer("/action/type").and_then(Value::as_str) {
Some("search") => web = web.checked_add(1)?,
Some("open_page" | "find_in_page") => {}
_ => return None,
},
"file_search_call"
if item.get("status").and_then(Value::as_str) == Some("completed") =>
{
file = file.checked_add(1)?;
}
// 这些输出没有独立的 OpenAI 工具调用费。
"message"
| "reasoning"
| "function_call"
| "custom_tool_call"
| "computer_call"
| "local_shell_call"
| "apply_patch_call"
| "mcp_call"
| "mcp_list_tools"
| "mcp_approval_request"
| "compaction" => {}
// 容器会话、生图及未知工具需要额外计费事实,不能把 token 小计
// 当作完整费用。
_ => return None,
}
}
Some(calls)
Some((web, file))
}
fn incomplete_finish_reason(response: &Value) -> FinishReason {
@@ -16,6 +16,7 @@ use super::{
const LONG_CONTEXT_THRESHOLD: u64 = 272_000;
const WEB_SEARCH_CALL_TICKS: u128 = 100_000_000;
const WEB_SEARCH_PREVIEW_NON_REASONING_CALL_TICKS: u128 = 250_000_000;
const FILE_SEARCH_CALL_TICKS: u128 = 25_000_000;
/// OpenAI 公开 Token 价格计算所需的单次用量事实。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
@@ -27,6 +28,7 @@ pub struct OpenAiBillingUsage {
image_input_tokens: u64,
image_output_tokens: u64,
web_search_calls: u64,
file_search_calls: u64,
web_search_pricing: Option<WebSearchPricing>,
}
@@ -47,6 +49,7 @@ impl OpenAiBillingUsage {
image_input_tokens: 0,
image_output_tokens: 0,
web_search_calls: 0,
file_search_calls: 0,
web_search_pricing: None,
}
}
@@ -60,6 +63,11 @@ impl OpenAiBillingUsage {
self.web_search_pricing = pricing;
self
}
pub(crate) const fn with_file_search_calls(mut self, calls: u64) -> Self {
self.file_search_calls = calls;
self
}
}
impl From<TokenUsage> for OpenAiBillingUsage {
@@ -72,6 +80,7 @@ impl From<TokenUsage> for OpenAiBillingUsage {
image_input_tokens: usage.image_input_tokens,
image_output_tokens: usage.image_output_tokens,
web_search_calls: 0,
file_search_calls: 0,
web_search_pricing: None,
}
}
@@ -125,6 +134,7 @@ struct ModelPricing {
long_flex: TokenRates,
long_fast: TokenRates,
cache_write_percent: u32,
unpriced_long_context: bool,
}
impl ModelPricing {
@@ -137,6 +147,7 @@ impl ModelPricing {
long_flex: TokenRates::ZERO,
long_fast: TokenRates::ZERO,
cache_write_percent: 0,
unpriced_long_context: false,
}
}
@@ -170,7 +181,15 @@ impl ModelPricing {
self
}
const fn with_unpriced_long_context(mut self) -> Self {
self.unpriced_long_context = true;
self
}
fn rates(self, tier: PricingTier, long_context: bool) -> Option<TokenRates> {
if long_context && self.unpriced_long_context {
return None;
}
// Only models with a published long-context column switch at the threshold.
// A dash in that column is not a second, unavailable price tier for models
// whose entire supported context is covered by the short-context price.
@@ -200,10 +219,12 @@ struct PricingRule {
pricing: ModelPricing,
}
// Source: https://developers.openai.com/api/docs/pricing (verified 2026-08-14).
// 价格来源:https://developers.openai.com/api/docs/pricing,核验日期 2026-09-09。
// 使用常规价,不采用 Sol 的临时 $4/$20 优惠;缓存、Flex、Fast 和长上下文
// 档位也统一按常规价计算。
const PRICING_RULES: &[PricingRule] = &[
// Astra: https://developers.openai.com/api/docs/models/gpt-6-astra
// Rates verified against the pricing table on 2026-09-05.
// Astra:https://developers.openai.com/api/docs/models/gpt-6-astra
// 已于 2026-09-09 对照官方价目表核验。
PricingRule {
model: "gpt-6-astra",
pricing: ModelPricing::new(100_000, 500_000, 10_000)
@@ -405,10 +426,60 @@ const PRICING_RULES: &[PricingRule] = &[
model: "gpt-3.5-turbo",
pricing: ModelPricing::new(5_000, 15_000, 0),
},
// 各变体独立采用已公开的价格与档位,不能继承父型号的所有档位。
// Cyber 长上下文的官方来源存在差异,该区间暂不估价。
PricingRule {
model: "gpt-5.1-codex-mini",
pricing: ModelPricing::new(2_500, 20_000, 250),
},
PricingRule {
model: "gpt-5.3-chat-latest",
pricing: ModelPricing::new(17_500, 140_000, 1_750),
},
PricingRule {
model: "gpt-5.6-cyber",
pricing: ModelPricing::new(125_000, 750_000, 12_500)
.with_cache_write(125)
.with_unpriced_long_context(),
},
PricingRule {
model: "gpt-5.5-cyber",
pricing: ModelPricing::new(125_000, 750_000, 12_500).with_unpriced_long_context(),
},
PricingRule {
model: "chat-latest",
pricing: ModelPricing::new(50_000, 300_000, 5_000),
},
PricingRule {
model: "gpt-5-codex",
pricing: ModelPricing::new(12_500, 100_000, 1_250),
},
PricingRule {
model: "gpt-5.1-codex",
pricing: ModelPricing::new(12_500, 100_000, 1_250),
},
PricingRule {
model: "gpt-5.1-codex-max",
pricing: ModelPricing::new(12_500, 100_000, 1_250),
},
PricingRule {
model: "gpt-5-chat-latest",
pricing: ModelPricing::new(12_500, 100_000, 1_250),
},
PricingRule {
model: "gpt-5.1-chat-latest",
pricing: ModelPricing::new(12_500, 100_000, 1_250),
},
PricingRule {
model: "gpt-5.2-codex",
pricing: ModelPricing::new(17_500, 140_000, 1_750),
},
PricingRule {
model: "gpt-5.2-chat-latest",
pricing: ModelPricing::new(17_500, 140_000, 1_750),
},
];
const UNPRICED_MODELS: &[&str] = &["gpt-5.3-codex-spark"];
#[derive(Clone, Copy)]
struct TokenAmounts {
input_ticks: u128,
@@ -439,13 +510,14 @@ fn openai_billing_breakdown_with_context(
service_tier: Option<&str>,
long_context: bool,
) -> Option<CalculatedCostBreakdown> {
if usage.cached_tokens > usage.input_tokens
if usage.cached_tokens.checked_add(usage.cache_write_tokens)? > usage.input_tokens
|| usage.image_input_tokens > 0
|| usage.image_output_tokens > 0
{
return None;
}
let web_search_ticks = web_search_amount_ticks(usage)?;
let tool_ticks = web_search_amount_ticks(usage)?
.checked_add(u128::from(usage.file_search_calls).checked_mul(FILE_SEARCH_CALL_TICKS)?)?;
let pricing = model_pricing(model)?;
let normalized_tier = normalize_service_tier(service_tier);
let tier = pricing_tier(normalized_tier.as_deref())?;
@@ -467,8 +539,8 @@ fn openai_billing_breakdown_with_context(
usage.cached_tokens,
usage.cache_write_tokens,
)?;
standard.total_ticks = standard.total_ticks.checked_add(web_search_ticks)?;
selected.total_ticks = selected.total_ticks.checked_add(web_search_ticks)?;
standard.total_ticks = standard.total_ticks.checked_add(tool_ticks)?;
selected.total_ticks = selected.total_ticks.checked_add(tool_ticks)?;
let multiplier_percent =
effective_multiplier_percent(selected.total_ticks, standard.total_ticks)?;
let cache_write_rate = cache_write_rate(selected_rates, pricing.cache_write_percent)?;
@@ -503,7 +575,12 @@ pub(crate) fn image_calculated_cost(request_body: &[u8], usage: &Value) -> Optio
let request = serde_json::from_slice::<ImageModel>(request_body).ok()?;
if !matches!(
request.model.as_str(),
"gpt-image-2" | "gpt-image-2-2026-04-21"
"gpt-image-2"
| "gpt-image-2-2026-04-21"
| "gpt-image-2.5-sunburst"
| "gpt-image-2.5-sunburst-2026-09-08"
| "gpt-image-2.5-flare"
| "gpt-image-2.5-flare-2026-09-08"
) {
return None;
}
@@ -542,8 +619,9 @@ pub(crate) fn image_calculated_cost(request_body: &[u8], usage: &Value) -> Optio
(_, _, cached) if cached == input => (text_input, image_input),
_ => return None,
};
// https://developers.openai.com/api/docs/pricing (2026-09-08)
// USD / 1M tokens: text 5 / cached 1.25; image 8 / cached 2 / output 30.
// 价格来源:https://developers.openai.com/api/docs/pricing,核验日期 2026-09-09。
// GPT Image 2 和两个 2.5 型号按相同的 token 单价计费,不使用按张估价。
// 每百万 token 的美元单价:文本 5 / 缓存 1.25;图片 8 / 缓存 2 / 输出 30。
let text = token_amounts(
TokenRates::new(50_000, 0, 12_500),
0,
@@ -565,16 +643,10 @@ pub(crate) fn image_calculated_cost(request_body: &[u8], usage: &Value) -> Optio
fn model_pricing(model: &str) -> Option<ModelPricing> {
let normalized = normalize_model_name(model);
if UNPRICED_MODELS
.iter()
.any(|rule| model_matches_rule(&normalized, rule))
{
return None;
}
let model = pricing_model_name(&normalized);
PRICING_RULES
.iter()
.filter(|rule| model_matches_rule(&normalized, rule.model))
.max_by_key(|rule| rule.model.len())
.find(|rule| model == rule.model)
.map(|rule| rule.pricing)
}
@@ -613,16 +685,17 @@ fn fixed_block_web_search_model(model: &str) -> bool {
fn reasoning_model(model: &str) -> bool {
let normalized = normalize_model_name(model);
normalized.starts_with("gpt-5")
|| model_matches_rule(&normalized, "gpt-6-astra")
|| normalized.starts_with("o1")
|| normalized.starts_with("o3")
|| normalized.starts_with("o4")
let model = pricing_model_name(&normalized);
model.starts_with("gpt-5")
|| model == "gpt-6-astra"
|| model.starts_with("o1")
|| model.starts_with("o3")
|| model.starts_with("o4")
}
fn pricing_tier(service_tier: Option<&str>) -> Option<PricingTier> {
match service_tier {
None | Some("auto" | "default" | "standard") => Some(PricingTier::Standard),
None | Some("default" | "standard") => Some(PricingTier::Standard),
Some("flex") => Some(PricingTier::Flex),
Some("fast" | "priority") => Some(PricingTier::Fast),
Some(_) => None,
@@ -644,20 +717,23 @@ fn token_amounts(
cached_tokens: u64,
cache_write_tokens: u64,
) -> Option<TokenAmounts> {
if cached_tokens.checked_add(cache_write_tokens)? > input_tokens {
return None;
}
let billed_cache_read = if rates.cache_read_ticks > 0 {
cached_tokens.min(input_tokens)
cached_tokens
} else {
0
};
let cache_write_rate = cache_write_rate(rates, cache_write_percent)?;
let billed_cache_write = if cache_write_rate > 0 {
cache_write_tokens.min(input_tokens.saturating_sub(billed_cache_read))
cache_write_tokens
} else {
0
};
let uncached_input = input_tokens
.saturating_sub(billed_cache_read)
.saturating_sub(billed_cache_write);
.checked_sub(billed_cache_read)?
.checked_sub(billed_cache_write)?;
let input_ticks = u128::from(uncached_input).checked_mul(rates.input_ticks)?;
let output_ticks = u128::from(output_tokens).checked_mul(rates.output_ticks)?;
let cache_read_ticks = u128::from(billed_cache_read).checked_mul(rates.cache_read_ticks)?;
@@ -703,13 +779,47 @@ fn normalize_model_name(model: &str) -> String {
.to_ascii_lowercase()
}
fn model_matches_rule(model: &str, rule: &str) -> bool {
if model == rule {
return true;
// 仅已核验的别名和快照可以共用价格;未知后缀、未来日期和微调模型
// 不得直接继承父型号的价格。
fn pricing_model_name(model: &str) -> &str {
match model {
"gpt-3.5-turbo-0125" => "gpt-3.5-turbo",
"gpt-4-0314" => "gpt-4",
"gpt-4-0613" => "gpt-4",
"gpt-4-turbo-2024-04-09" => "gpt-4-turbo",
"gpt-4.1-2025-04-14" => "gpt-4.1",
"gpt-4.1-mini-2025-04-14" => "gpt-4.1-mini",
"gpt-4.1-nano-2025-04-14" => "gpt-4.1-nano",
"gpt-4o-2024-08-06" => "gpt-4o",
"gpt-4o-2024-11-20" => "gpt-4o",
"gpt-4o-mini-2024-07-18" => "gpt-4o-mini",
"gpt-5-2025-08-07" => "gpt-5",
"gpt-5-mini-2025-08-07" => "gpt-5-mini",
"gpt-5-nano-2025-08-07" => "gpt-5-nano",
"gpt-5-pro-2025-10-06" => "gpt-5-pro",
"gpt-5.1-2025-11-13" => "gpt-5.1",
"gpt-5.2-2025-12-11" => "gpt-5.2",
"gpt-5.2-pro-2025-12-11" => "gpt-5.2-pro",
"gpt-5.4-2026-03-05" => "gpt-5.4",
"gpt-5.4-mini-2026-03-17" => "gpt-5.4-mini",
"gpt-5.4-nano-2026-03-17" => "gpt-5.4-nano",
"gpt-5.4-pro-2026-03-05" => "gpt-5.4-pro",
"gpt-5.5-2026-04-23" => "gpt-5.5",
"gpt-5.5-pro-2026-04-23" => "gpt-5.5-pro",
"gpt-daybreak-blue-latest" => "gpt-5.6-sol",
"gpt-daybreak-red-latest" => "gpt-5.6-cyber",
"o1-2024-12-17" => "o1",
"o1-pro-2025-03-19" => "o1-pro",
"o3-2025-04-16" => "o3",
"o3-mini-2025-01-31" => "o3-mini",
"o3-pro-2025-06-10" => "o3-pro",
"o4-mini-2025-04-16" => "o4-mini",
_ => model,
}
model
.strip_prefix(rule)
.is_some_and(|suffix| matches!(suffix.as_bytes().first(), Some(b'-' | b'.' | b':')))
}
fn model_matches_rule(model: &str, rule: &str) -> bool {
pricing_model_name(model) == rule
}
/// 规范化请求或响应携带的服务档位,供观测与计费共用。
@@ -1001,6 +1001,18 @@ async fn image_endpoints_bypass_only_the_text_catalog_and_preserve_the_current_c
br#"{"model":"gpt-image-2","images":[{"image_url":"data:image/png;base64,AAEC"}],"prompt":"add fog","prompt":"duplicate remains opaque"}"#.as_slice(),
br#"{"created":1787212801,"data":[{"b64_json":"AwQF"}],"quality":"high"}"#.as_slice(),
),
(
ImageRequestKind::Generation,
"/codex/images/generations",
br#"{"model":"gpt-image-2.5-flare","prompt":"a lighthouse","quality":"xhigh"}"#.as_slice(),
br#"{"created":1788900000,"data":[{"b64_json":"AAEC"}],"quality":"xhigh"}"#.as_slice(),
),
(
ImageRequestKind::Edit,
"/codex/images/edits",
br#"{"model":"gpt-image-2.5-sunburst","images":[{"image_url":"data:image/png;base64,AAEC"}],"prompt":"add fog","quality":"max"}"#.as_slice(),
br#"{"created":1788900001,"data":[{"b64_json":"AwQF"}],"quality":"max"}"#.as_slice(),
),
];
for (_, endpoint, body, response_body) in &cases {
Mock::given(method("POST"))
@@ -1225,6 +1237,10 @@ async fn image_prices_should_use_modality_rates_and_precede_delivery() {
for (kind, model) in [
(ImageRequestKind::Generation, "gpt-image-2"),
(ImageRequestKind::Edit, "gpt-image-2-2026-04-21"),
(ImageRequestKind::Generation, "gpt-image-2.5-sunburst"),
(ImageRequestKind::Edit, "gpt-image-2.5-sunburst-2026-09-08"),
(ImageRequestKind::Generation, "gpt-image-2.5-flare"),
(ImageRequestKind::Edit, "gpt-image-2.5-flare-2026-09-08"),
] {
let events = image_metering_events(kind, &response, model).await;
let costs = events
@@ -1240,9 +1256,13 @@ async fn image_prices_should_use_modality_rates_and_precede_delivery() {
})
})
.collect::<Vec<_>>();
assert_eq!(costs.len(), 1);
assert_eq!(costs.len(), 1, "model={model}");
let (cost_index, cost) = costs[0];
assert_eq!(cost.total().amount().scaled(), expected_ticks);
assert_eq!(
cost.total().amount().scaled(),
expected_ticks,
"model={model}"
);
assert_eq!(cost.into_estimate().source().as_str(), "calculated");
let wire_index = events
.iter()
@@ -1272,6 +1292,9 @@ async fn image_prices_should_remain_unknown_when_modality_or_model_is_uncertain(
let mut cases = vec![
("gpt-image-future", valid.clone()),
("gpt-image-2-future", valid.clone()),
("gpt-image-2.5", valid.clone()),
("gpt-image-2.5-sunburst-future", valid.clone()),
("gpt-image-2.5-flare-future", valid.clone()),
("gpt-image-2", json!(null)),
(
"gpt-image-2",
@@ -4820,7 +4843,7 @@ async fn completed_response_persists_session_affinity_before_stream_consumer_sto
drop(stream);
assert_eq!(affinity.binding_count(), 1);
assert_eq!(observed_service_tier.as_deref(), Some("priority"));
assert_eq!(observed_service_tier.as_deref(), Some("default"));
assert_eq!(upstream_service_tier.as_deref(), Some("default"));
}
@@ -219,7 +219,7 @@ fn decoder_should_emit_calculated_cost_for_complete_known_model_usage() {
}
#[test]
fn decoder_should_prefer_requested_service_tier_over_response_tier_for_billing() {
fn decoder_should_prefer_response_service_tier_over_requested_tier_for_billing() {
let body = concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_fast_cost\",\"model\":\"gpt-5.4\",\"service_tier\":\"default\"}}\n\n",
@@ -236,7 +236,7 @@ fn decoder_should_prefer_requested_service_tier_over_response_tier_for_billing()
assert!(canonical_facts(&events).into_iter().any(|event| matches!(
event,
GatewayEvent::CalculatedCost(cost)
if cost.total().amount().scaled() == 6_875_000
if cost.total().amount().scaled() == 3_437_500
)));
}
@@ -346,7 +346,7 @@ fn decoder_should_fail_closed_for_fixed_block_web_search_content() {
}
#[test]
fn websocket_decoder_should_prefer_requested_service_tier_over_response_tier_for_billing() {
fn websocket_decoder_should_prefer_response_service_tier_over_requested_tier_for_billing() {
let created = websocket_event_to_sse_frame(
r#"{"type":"response.created","response":{"id":"resp_ws_fast_cost","model":"gpt-5.4","service_tier":"default"}}"#,
)
@@ -366,7 +366,7 @@ fn websocket_decoder_should_prefer_requested_service_tier_over_response_tier_for
assert!(canonical_facts(&events).into_iter().any(|event| matches!(
event,
GatewayEvent::CalculatedCost(cost)
if cost.total().amount().scaled() == 6_875_000
if cost.total().amount().scaled() == 3_437_500
)));
}
@@ -936,3 +936,96 @@ fn canonical_facts(events: &[ProviderEvent]) -> Vec<&GatewayEvent> {
.flat_map(ProviderEvent::canonical_facts)
.collect()
}
fn pricing_response_cost(
requested: Option<&str>,
actual: Option<&str>,
output: serde_json::Value,
tools: &[serde_json::Value],
) -> Option<u128> {
let mut response = json!({
"id": "resp_pricing", "model": "gpt-6-astra", "status": "completed",
"output": output, "usage": {"input_tokens": 10_000, "output_tokens": 0, "total_tokens": 10_000}
});
if let Some(tier) = actual {
response["service_tier"] = json!(tier);
}
let created = json!({"type": "response.created", "response": {"id": "resp_pricing", "model": "gpt-6-astra"}});
let completed = json!({"type": "response.completed", "response": response});
let body = format!(
"event: response.created\ndata: {created}\n\nevent: response.completed\ndata: {completed}\n\n"
);
let events = CodexCanonicalDecoder::new("gpt-6-astra")
.with_requested_service_tier(requested)
.with_request_tool_pricing("gpt-6-astra", Some(tools))
.with_raw_sse_passthrough()
.push(body.as_bytes())
.expect("计费不确定时仍须正常透传响应");
assert!(
canonical_facts(&events)
.iter()
.any(|event| matches!(event, GatewayEvent::Completed(_)))
);
canonical_facts(&events)
.into_iter()
.find_map(|event| match event {
GatewayEvent::CalculatedCost(cost) => Some(cost.total().amount().scaled()),
_ => None,
})
}
#[test]
fn billing_should_follow_actual_tier_and_only_fall_back_when_it_is_absent() {
for (requested, actual, expected) in [
(Some("fast"), Some("default"), Some(1_000_000_000)),
(Some("auto"), Some("fast"), Some(2_000_000_000)),
(Some("default"), Some("flex"), Some(500_000_000)),
(Some("fast"), None, Some(2_000_000_000)),
(Some("auto"), None, None),
(Some("default"), Some("future"), None),
] {
assert_eq!(
pricing_response_cost(requested, actual, json!([]), &[]),
expected,
"{requested:?} -> {actual:?}"
);
}
}
#[test]
fn billing_should_add_file_and_web_search_fees_without_tier_markup() {
let output = json!([
{"type": "file_search_call", "id": "fs_1", "status": "completed"},
{"type": "file_search_call", "id": "fs_2", "status": "completed"},
{"type": "web_search_call", "id": "ws_1", "status": "completed", "action": {"type": "search"}}
]);
for (tier, expected) in [("default", 1_150_000_000), ("fast", 2_150_000_000)] {
assert_eq!(
pricing_response_cost(
None,
Some(tier),
output.clone(),
&[json!({"type": "web_search"})]
),
Some(expected)
);
}
}
#[test]
fn billing_should_not_emit_partial_totals_for_unpriced_tool_outputs() {
for output in [
json!([{"type": "code_interpreter_call", "id": "ci_1", "status": "completed"}]),
json!([{"type": "shell_call", "id": "sh_1", "status": "completed"}]),
json!([{"type": "image_generation_call", "id": "ig_1", "status": "completed"}]),
json!([{"type": "future_paid_tool_call", "id": "tool_1"}]),
json!([{"type": "file_search_call", "id": "fs_1", "status": "failed"}]),
json!({"malformed": true}),
] {
assert_eq!(
pricing_response_cost(None, None, output.clone(), &[]),
None,
"{output}"
);
}
}
@@ -239,6 +239,90 @@ async fn exact_limit_success_body_should_be_accepted() {
assert!(usage["rate_limit"].is_object());
}
#[test]
fn billing_should_use_explicit_variant_prices_and_verified_snapshots() {
// 每个用例包含一个普通输入 token 和一个输出 token,金额单位为 USD ticks。
for (model, expected) in [
("gpt-5.1-codex-mini", 22_500),
("gpt-5.3-chat-latest", 157_500),
("gpt-5.6-cyber", 875_000),
("gpt-5.5-cyber", 875_000),
("gpt-daybreak-red-latest", 875_000),
("gpt-daybreak-blue-latest", 350_000),
("chat-latest", 350_000),
("gpt-5.1-codex-max", 112_500),
("gpt-5.2-codex", 157_500),
("gpt-5.4-2026-03-05", 175_000),
(" OpenAI/GPT-4o-2024-08-06 ", 125_000),
("gpt-4o-2024-05-13", 200_000),
("gpt-3.5-turbo-0125", 20_000),
("gpt-3.5-turbo-1106", 30_000),
] {
let billing =
openai_billing_breakdown(model, billing_usage(1, 1, 0, 0), None).expect(model);
assert_eq!(
billing.total_amount().amount().scaled(),
expected,
"{model}"
);
}
}
#[test]
fn billing_should_not_inherit_prices_for_unknown_models_or_tiers() {
for model in [
"gpt-6-astra-future",
"gpt-6-astra-2099-01-01",
"gpt-5.6-sol-wm",
"gpt-5.6-cyber-future",
"gpt-5.4-cyber",
"gpt-5.3-codex-spark",
"gpt-4.5-preview",
"gpt-4-32k",
"gpt-5.4:custom",
"gpt-5.4.1",
] {
assert!(
openai_billing_breakdown(model, billing_usage(1, 1, 0, 0), None).is_none(),
"{model}"
);
}
for (model, input, tier) in [
("gpt-5.6-cyber", 272_001, None),
("gpt-5.5-cyber", 272_001, None),
("gpt-5.6-cyber", 1, Some("fast")),
("gpt-5.1-codex-mini", 1, Some("flex")),
("gpt-6-astra", 1, Some("auto")),
("gpt-6-astra", 1, Some("ultrafast")),
] {
assert!(
openai_billing_breakdown(model, billing_usage(input, 1, 0, 0), tier).is_none(),
"{model} {tier:?}"
);
}
}
#[test]
fn billing_should_reject_overlapping_or_overflowing_cache_counts() {
for (input, cached, written) in [(100, 20, 90), (100, 0, 101), (u64::MAX, u64::MAX, 1)] {
assert!(
openai_billing_breakdown(
"gpt-6-astra",
billing_usage(input, 0, cached, written),
None
)
.is_none()
);
}
let cyber = openai_billing_breakdown("gpt-5.6-cyber", billing_usage(100, 10, 20, 10), None)
.expect("已公开的 Cyber 缓存写价格");
assert_eq!(
cyber.cache_write_price_per_million().amount().to_string(),
"15.6250000000"
);
assert_eq!(cyber.total_amount().amount().scaled(), 18_062_500);
}
#[tokio::test]
async fn fetch_should_use_wham_usage_headers_only() {
let server = MockServer::start().await;
+3 -2
View File
@@ -56,7 +56,7 @@ use crate::credential::{
RotateManagedGrokCredential, SecretValue, VerifiedGrokAccount, VerifiedTokenSet,
};
use crate::transport::profile::{GrokCliReleaseSnapshot, GrokCliReleaseStatus};
use crate::transport::{GROK_CLI_BASE_URL, XAI_PROVIDER_NAME, grok_billing_breakdown};
use crate::transport::{GROK_CLI_BASE_URL, XAI_PROVIDER_NAME, grok_billing_breakdown_with_tier};
const PENDING_SCHEMA_VERSION: u64 = 3;
const PENDING_TTL: TimeDelta = TimeDelta::minutes(30);
@@ -390,11 +390,12 @@ impl ProviderAdmin for XaiAdminProvider {
else {
return Ok(None);
};
let Some(breakdown) = grok_billing_breakdown(
let Some(breakdown) = grok_billing_breakdown_with_tier(
&input.upstream_model_id,
input_tokens,
output_tokens,
input.cached_tokens.unwrap_or_default(),
input.service_tier.as_deref(),
) else {
return Ok(None);
};
+1 -1
View File
@@ -79,7 +79,7 @@ pub use transport::{
MAX_GROK_BILLING_BYTES, MAX_GROK_MODEL_CATALOG_BYTES, OfficialGrokEndpointPolicy,
ReqwestGrokInferenceTransport, ReqwestGrokModelCatalogTransport, ReqwestOAuthTransport,
SelectedGrokSession, XAI_PROVIDER_NAME, build_grok_headers, grok_billing_breakdown,
parse_grok_billing, parse_grok_model_catalog,
grok_billing_breakdown_with_tier, parse_grok_billing, parse_grok_model_catalog,
};
/// xAI 初始化后交给组装根的最小能力集。
@@ -185,7 +185,8 @@ pub(super) fn cold_compaction_http_sse_stream(
}
}
};
yield ProviderEvent::observation(accepted.observation);
let mut observation = accepted.observation;
yield ProviderEvent::observation(observation.clone());
let mut body = accepted.response.into_body();
let mut canonical = GrokCanonicalDecoder::new(upstream_model.as_str());
@@ -251,6 +252,10 @@ pub(super) fn cold_compaction_http_sse_stream(
}
}
if let Some(tier) = canonical.response_service_tier() {
observation = observation.with_service_tier_if_valid(tier.to_owned());
yield ProviderEvent::observation(observation);
}
let started = facts
.started
.ok_or_else(|| mark_transient_compaction_failure(protocol_sent()))?;
@@ -460,7 +465,7 @@ pub(super) fn cold_http_sse_stream(
}
};
let observation = xai_response_observation(&response)?;
let mut observation = xai_response_observation(&response)?;
let base_timings = observation.timings();
let mut first_token_ms: Option<u64> = None;
yield ProviderEvent::observation(observation.clone());
@@ -529,6 +534,14 @@ pub(super) fn cold_http_sse_stream(
}),
);
}
if let Some(tier) = decoder.response_service_tier()
&& observation.service_tier() != Some(tier)
{
observation = observation.with_service_tier_if_valid(tier.to_owned());
yield ProviderEvent::observation(observation.clone().with_timings(
ProviderResponseTimings { first_token_ms, ..base_timings },
));
}
let completed = events
.iter()
.flat_map(ProviderEvent::canonical_facts)
@@ -581,6 +594,14 @@ pub(super) fn cold_http_sse_stream(
}),
);
}
if let Some(tier) = decoder.response_service_tier()
&& observation.service_tier() != Some(tier)
{
observation = observation.with_service_tier_if_valid(tier.to_owned());
yield ProviderEvent::observation(observation.clone().with_timings(
ProviderResponseTimings { first_token_ms, ..base_timings },
));
}
attach_xai_session_update(&mut final_events, &mut session_capture)?;
if let Some(capture) = reasoning_replay_capture.as_mut() {
capture.observe(&final_events);
@@ -38,6 +38,20 @@ struct ModelPricing {
long: TokenRates,
}
// 价格来源:https://docs.x.ai/developers/pricing,核验日期 2026-09-09。
const GROK_46_PRICING: ModelPricing = ModelPricing {
short: TokenRates {
input_ticks: 20_000,
cached_input_ticks: 5_000,
output_ticks: 60_000,
},
long: TokenRates {
input_ticks: 40_000,
cached_input_ticks: 10_000,
output_ticks: 120_000,
},
};
const GROK_45_PRICING: ModelPricing = ModelPricing {
short: TokenRates {
input_ticks: 20_000,
@@ -85,6 +99,23 @@ pub fn grok_billing_breakdown(
output_tokens: u64,
cached_tokens: u64,
) -> Option<CalculatedCostBreakdown> {
grok_billing_breakdown_with_tier(model, input_tokens, output_tokens, cached_tokens, None)
}
/// 按响应确认的实际档位计算 xAI Token 费用;未知档位不估算。
#[must_use]
pub fn grok_billing_breakdown_with_tier(
model: &str,
input_tokens: u64,
output_tokens: u64,
cached_tokens: u64,
service_tier: Option<&str>,
) -> Option<CalculatedCostBreakdown> {
let (tier, multiplier) = match service_tier.map(str::trim) {
None | Some("default" | "standard") => ("default", 1_u128),
Some("priority") => ("priority", 2),
Some(_) => return None,
};
let pricing = model_pricing(model)?;
let rates = if input_tokens >= LONG_CONTEXT_THRESHOLD {
pricing.long
@@ -101,21 +132,21 @@ pub fn grok_billing_breakdown(
.checked_add(output_amount_ticks)?;
Some(CalculatedCostBreakdown::new(
CalculatedCostAmounts::new(
usd_money(input_amount_ticks)?,
usd_money(output_amount_ticks)?,
usd_money(cache_read_amount_ticks)?,
usd_money(input_amount_ticks.checked_mul(multiplier)?)?,
usd_money(output_amount_ticks.checked_mul(multiplier)?)?,
usd_money(cache_read_amount_ticks.checked_mul(multiplier)?)?,
usd_money(0)?,
usd_money(standard_amount_ticks)?,
usd_money(standard_amount_ticks)?,
usd_money(standard_amount_ticks.checked_mul(multiplier)?)?,
),
CalculatedCostRates::new(
usd_price_per_million(rates.input_ticks)?,
usd_price_per_million(rates.output_ticks)?,
usd_price_per_million(rates.cached_input_ticks)?,
usd_price_per_million(rates.input_ticks.checked_mul(multiplier)?)?,
usd_price_per_million(rates.output_ticks.checked_mul(multiplier)?)?,
usd_price_per_million(rates.cached_input_ticks.checked_mul(multiplier)?)?,
usd_money(0)?,
),
Some("default".to_owned()),
100,
Some(tier.to_owned()),
if multiplier == 2 { 200 } else { 100 },
))
}
@@ -144,6 +175,8 @@ pub struct GrokCanonicalDecoder {
tool_arguments_seen: BTreeSet<u32>,
usage_emitted: bool,
output_start_seen: bool,
response_service_tier: Option<String>,
requires_provider_cost: bool,
}
impl GrokCanonicalDecoder {
@@ -159,6 +192,8 @@ impl GrokCanonicalDecoder {
tool_arguments_seen: BTreeSet::new(),
usage_emitted: false,
output_start_seen: false,
response_service_tier: None,
requires_provider_cost: false,
}
}
@@ -168,10 +203,30 @@ impl GrokCanonicalDecoder {
pub fn for_request(fallback_model: impl Into<String>, request: &GrokResponsesRequest) -> Self {
Self {
response_transform: request.response_transform(),
requires_provider_cost: request.body().get("tool_choice").and_then(Value::as_str)
!= Some("none")
&& request
.body()
.get("tools")
.and_then(Value::as_array)
.is_some_and(|tools| {
tools.iter().any(|tool| {
!matches!(
tool.get("type").and_then(Value::as_str),
Some("function" | "custom" | "mcp")
)
})
}),
..Self::new(fallback_model)
}
}
/// 上游报告的实际服务档位,供费用与持久观测共用。
#[must_use]
pub fn response_service_tier(&self) -> Option<&str> {
self.response_service_tier.as_deref()
}
pub fn push(&mut self, chunk: &[u8]) -> Result<Vec<ProviderEvent>, ProviderError> {
let events = self.decoder.push(chunk).map_err(protocol_error)?;
self.decode(events)
@@ -259,6 +314,16 @@ impl GrokCanonicalDecoder {
continue;
};
let event_type = event_type.to_owned();
// 工具转换可能隐藏注入的调用,计费事实必须从转换前的上游事件读取。
if let Some(response) = value.get("response") {
if let Some(tier) = response.get("service_tier").and_then(Value::as_str) {
self.response_service_tier = Some(tier.trim().to_owned());
}
self.requires_provider_cost |= !token_only_response(response);
}
if let Some(item) = value.get("item") {
self.requires_provider_cost |= !token_only_output(item);
}
// 无法转换的上游帧(多为畸形帧)直接丢弃,保持已开始的客户端流不中断。
let Ok(transformed) = self
.response_transform
@@ -590,7 +655,10 @@ impl GrokCanonicalDecoder {
.to_owned();
if let Some(cost) = provider_reported_cost(response)? {
output.push(GatewayEvent::ProviderCost(cost));
} else if let Some(cost) = usage.and_then(|usage| calculated_cost(response, &model, usage))
} else if !self.requires_provider_cost
&& let Some(cost) = usage.and_then(|usage| {
calculated_cost(response, &model, usage, self.response_service_tier())
})
{
output.push(GatewayEvent::CalculatedCost(cost));
}
@@ -729,29 +797,71 @@ fn provider_reported_cost(response: &Value) -> Result<Option<ProviderReportedCos
return Ok(None);
};
let ticks = value.as_u64().ok_or_else(protocol_error_marker)?;
if ticks == 0 {
return Ok(None);
}
// 明确返回的零费用也是上游账单事实,不能当作缺失而改用本地估算。
ProviderReportedCost::from_usd_ticks(u128::from(ticks))
.map(Some)
.map_err(protocol_error)
}
fn calculated_cost(response: &Value, model: &str, usage: TokenUsage) -> Option<CalculatedCost> {
if !billable_usage_is_complete(response, usage) {
// cost_in_usd_ticks 已包含服务端工具费;缺少该字段时,只有全部按 token
// 计费的响应才能确定总价。
fn token_only_output(item: &Value) -> bool {
matches!(
item.get("type").and_then(Value::as_str),
Some(
"message"
| "reasoning"
| "function_call"
| "custom_tool_call"
| "mcp_call"
| "mcp_list_tools"
| "mcp_approval_request"
| "compaction"
)
)
}
fn token_only_response(response: &Value) -> bool {
let empty = |value: &Value| value.as_object().is_some_and(serde_json::Map::is_empty);
response.get("tool_usage").is_none_or(empty)
&& response
.pointer("/usage/server_side_tool_usage")
.is_none_or(empty)
&& response
.pointer("/usage/num_server_side_tool_calls")
.is_none_or(|value| value.as_u64() == Some(0))
&& response.get("output").is_none_or(|value| {
value
.as_array()
.is_some_and(|items| items.iter().all(token_only_output))
})
}
fn calculated_cost(
response: &Value,
model: &str,
usage: TokenUsage,
service_tier: Option<&str>,
) -> Option<CalculatedCost> {
if !billable_usage_is_complete(response, usage)
|| usage.image_input_tokens > 0
|| usage.image_output_tokens > 0
{
return None;
}
let breakdown = grok_billing_breakdown(
let breakdown = grok_billing_breakdown_with_tier(
model,
usage.input_tokens,
usage.output_tokens,
usage.cached_tokens,
service_tier,
)?;
Some(breakdown.calculated_cost())
}
fn model_pricing(model: &str) -> Option<ModelPricing> {
match model {
"grok-4.6" | "grok-4.6-latest" => Some(GROK_46_PRICING),
"grok-4.5" | "grok-4.5-latest" | "grok-4.5-build-free" | "grok-build-latest" => {
Some(GROK_45_PRICING)
}
@@ -11,7 +11,9 @@ pub(crate) mod profile;
mod request;
mod session;
pub use canonical::{GrokCanonicalDecoder, grok_billing_breakdown};
pub use canonical::{
GrokCanonicalDecoder, grok_billing_breakdown, grok_billing_breakdown_with_tier,
};
pub use catalog::{
GROK_BILLING_URL, GROK_MODEL_CATALOG_URL, GrokBillingClient, GrokBillingError,
GrokBillingRequest, GrokBillingSnapshot, GrokBillingTransport, GrokBillingTransportError,
@@ -158,6 +158,26 @@ async fn xai_admin_provider_validates_known_billing_breakdown() {
assert_eq!(billing.total_amount.amount.as_str(), "0.0002175");
assert_eq!(billing.input_price_per_million.amount.as_str(), "2");
assert_eq!(billing.output_price_per_million.amount.as_str(), "6");
let priority = admin
.calculated_billing(&ProviderBillingInput {
upstream_model_id: "grok-4.6".to_owned(),
service_tier: Some("priority".to_owned()),
input_tokens: Some(100),
output_tokens: Some(10),
cached_tokens: Some(20),
cache_write_tokens: Some(0),
total: CurrencyCost {
currency: "USD".to_owned(),
amount: "0.00046".parse().expect("amount"),
},
})
.expect("计费明细")
.expect("已知的 Priority 价格");
assert_eq!(priority.service_tier.as_deref(), Some("priority"));
assert_eq!(priority.standard_amount.amount.as_str(), "0.00023");
assert_eq!(priority.input_price_per_million.amount.as_str(), "4");
assert_eq!(priority.multiplier_percent, 200);
}
#[tokio::test]
@@ -1239,6 +1239,56 @@ async fn transport_error_observation_should_retain_available_metrics() {
assert_eq!(error.kind(), ProviderErrorKind::Unavailable);
}
#[tokio::test]
async fn billing_observation_should_persist_the_final_response_tier_before_completion() {
for (actual, expected_cost) in [("priority", 400_000_000), ("default", 200_000_000)] {
let body = format!(
concat!(
"event: response.created\ndata: {{\"type\":\"response.created\",\"response\":{{\"id\":\"resp_tier\",\"model\":\"grok-4.6\",\"service_tier\":\"priority\"}}}}\n\n",
"event: response.completed\ndata: {{\"type\":\"response.completed\",\"response\":{{\"id\":\"resp_tier\",\"model\":\"grok-4.6\",\"service_tier\":\"{}\",\"status\":\"completed\",\"output\":[],\"usage\":{{\"input_tokens\":10000,\"output_tokens\":0}}}}}}\n\n"
),
actual
);
let transport =
StubInferenceTransport::sequence([InferenceMode::SuccessBody(body.into_bytes())]);
let provider = provider(StubSelector::success(), transport).await;
let mut stream = provider
.execute(
provider_request("xai"),
context(CancellationToken::new(), None),
)
.await
.expect("provider stream");
let mut tier = None;
let mut cost = None;
let mut completed = false;
while let Some(event) = stream.next().await {
let event = event.expect("provider event");
if let Some(value) = event
.response_observation()
.and_then(|value| value.service_tier())
{
tier = Some(value.to_owned());
}
for fact in event.canonical_facts() {
match fact {
GatewayEvent::CalculatedCost(value) => {
cost = Some(value.total().amount().scaled())
}
GatewayEvent::Completed(_) => completed = true,
_ => {}
}
}
if completed {
break;
}
}
assert!(completed);
assert_eq!(tier.as_deref(), Some(actual));
assert_eq!(cost, Some(expected_cost));
}
}
#[tokio::test]
async fn compaction_stream_should_emit_openai_wire_and_keep_only_metering_canonical() {
let summary = valid_compaction_summary("validated summary");
@@ -5,7 +5,10 @@ use gateway_core::policy::ClientApiKeyId;
use gateway_protocol::openai::sse::{encode_sse_event, encode_sse_event_with_metadata};
use serde_json::Value;
use provider_xai::{GrokCanonicalDecoder, GrokResponsesRequest, grok_billing_breakdown};
use provider_xai::{
GrokCanonicalDecoder, GrokResponsesRequest, grok_billing_breakdown,
grok_billing_breakdown_with_tier,
};
fn terminal_cost_events(
model: &str,
@@ -231,17 +234,139 @@ fn billing_breakdown_should_match_the_calculated_terminal_cost() {
#[test]
fn decoder_should_price_official_grok_45_build_free_variant() {
let events = terminal_cost_events("grok-4.5-build-free", 201, 0, 76, Some(0));
let events = terminal_cost_events("grok-4.5-build-free", 201, 0, 76, None);
assert_eq!(calculated_cost_ticks(&events), Some(8_580_000));
}
#[test]
fn zero_provider_cost_should_fall_back_to_calculated_cost() {
fn zero_provider_cost_should_remain_an_authoritative_zero() {
let events = terminal_cost_events("grok-4.5", 1, 0, 1, Some(0));
assert_eq!(calculated_cost_ticks(&events), Some(80_000));
assert_eq!(provider_cost_ticks(&events), None);
assert_eq!(calculated_cost_ticks(&events), None);
assert_eq!(provider_cost_ticks(&events), Some(0));
}
#[test]
fn billing_should_price_grok_46_cache_and_priority_at_the_context_boundary() {
// 短、长上下文分别检查普通输入、缓存、输出的单价,Priority 只采用已确认档位。
for (input, tier, expected) in [
(199_999, None, ["2", "0.5", "6"]),
(200_000, None, ["4", "1", "12"]),
(199_999, Some("priority"), ["4", "1", "12"]),
(200_000, Some("priority"), ["8", "2", "24"]),
] {
let billing = grok_billing_breakdown_with_tier("grok-4.6", input, 10, 20, tier)
.expect("Grok 4.6 价格");
let rates = [
billing.input_price_per_million(),
billing.cache_read_price_per_million(),
billing.output_price_per_million(),
]
.map(|money| {
money
.amount()
.to_string()
.trim_end_matches('0')
.trim_end_matches('.')
.to_owned()
});
assert_eq!(rates, expected);
}
assert!(grok_billing_breakdown_with_tier("grok-4.6", 10, 1, 0, Some("future")).is_none());
let priority = grok_billing_breakdown_with_tier("grok-4.6", 100, 10, 20, Some("priority"))
.expect("Priority 价格");
assert_eq!(priority.standard_amount().amount().scaled(), 2_300_000);
assert_eq!(priority.total_amount().amount().scaled(), 4_600_000);
assert_eq!(priority.multiplier_percent(), 200);
}
fn pricing_events(
mut response: Value,
request: Option<&GrokResponsesRequest>,
) -> Vec<GatewayEvent> {
response["id"] = serde_json::json!("resp_pricing");
response["model"] = serde_json::json!("grok-4.6");
response["status"] = serde_json::json!("completed");
let created = serde_json::json!({"type": "response.created", "response": {"id": "resp_pricing", "model": "grok-4.6", "service_tier": "priority"}});
let completed = serde_json::json!({"type": "response.completed", "response": response});
let body = format!(
"event: response.created\ndata: {created}\n\nevent: response.completed\ndata: {completed}\n\n"
);
let mut decoder = request.map_or_else(
|| GrokCanonicalDecoder::new("grok-4.6"),
|request| GrokCanonicalDecoder::for_request("grok-4.6", request),
);
let events = decoder
.push(body.as_bytes())
.expect("计费未知时仍应交付响应");
let facts: Vec<_> = events
.into_iter()
.flat_map(|event| event.into_parts().0)
.collect();
assert!(
facts
.iter()
.any(|event| matches!(event, GatewayEvent::Completed(_)))
);
facts
}
#[test]
fn billing_should_use_the_final_response_tier_and_preserve_provider_cost() {
for (tier, ticks, calculated, provider) in [
("priority", None, Some(400_000_000), None),
("default", None, Some(200_000_000), None),
("future", None, None, None),
("priority", Some(0), None, Some(0)),
("priority", Some(123), None, Some(123)),
] {
let mut response = serde_json::json!({"service_tier": tier, "output": [], "usage": {"input_tokens": 10_000, "output_tokens": 0}});
if let Some(ticks) = ticks {
response["usage"]["cost_in_usd_ticks"] = serde_json::json!(ticks);
}
let events = pricing_events(response, None);
assert_eq!(
(calculated_cost_ticks(&events), provider_cost_ticks(&events)),
(calculated, provider),
"{tier}"
);
}
}
#[test]
fn billing_should_require_provider_totals_for_server_tools() {
for kind in [
"web_search_call",
"x_search_call",
"code_interpreter_call",
"file_search_call",
"image_generation_call",
"future_tool_call",
] {
let response = serde_json::json!({"output": [{"type": kind, "id": "tool_1", "status": "completed"}], "usage": {"input_tokens": 100, "output_tokens": 1}});
assert_eq!(
calculated_cost_ticks(&pricing_events(response, None)),
None,
"{kind}"
);
}
let request =
tool_request(serde_json::json!({"input": "查资料", "tools": [{"type": "web_search"}]}));
// 工具结果可能被上游省略,允许调用收费工具时不能仅靠空 output 认定没有附加费。
let mut response =
serde_json::json!({"output": [], "usage": {"input_tokens": 100, "output_tokens": 1}});
assert_eq!(
calculated_cost_ticks(&pricing_events(response.clone(), Some(&request))),
None
);
response["usage"]["cost_in_usd_ticks"] = serde_json::json!(0);
assert_eq!(
provider_cost_ticks(&pricing_events(response, Some(&request))),
Some(0)
);
let response = serde_json::json!({"output": [], "usage": {"input_tokens": 100, "output_tokens": 1, "num_server_side_tool_calls": 1}});
assert_eq!(calculated_cost_ticks(&pricing_events(response, None)), None);
}
#[test]