From 35988d0eb7df680dabc1fe7a87aeb8f9ac2946ed Mon Sep 17 00:00:00 2001 From: Zyy Date: Wed, 9 Sep 2026 13:37:12 +0800 Subject: [PATCH] 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 --- .../gateway-protocol/src/openai/events.rs | 24 ++- .../gateway-protocol/tests/openai/events.rs | 51 +++++ .../openai/src/provider/observation.rs | 4 +- .../openai/src/transport/canonical.rs | 66 ++++--- .../providers/openai/src/transport/usage.rs | 182 ++++++++++++++---- .../openai/tests/provider/contract.rs | 29 ++- .../openai/tests/transport/canonical.rs | 101 +++++++++- .../providers/openai/tests/transport/usage.rs | 84 ++++++++ backend/crates/providers/xai/src/admin.rs | 5 +- backend/crates/providers/xai/src/lib.rs | 2 +- .../providers/xai/src/provider/stream.rs | 25 ++- .../providers/xai/src/transport/canonical.rs | 142 ++++++++++++-- .../crates/providers/xai/src/transport/mod.rs | 4 +- backend/crates/providers/xai/tests/admin.rs | 20 ++ .../providers/xai/tests/provider/contract.rs | 50 +++++ .../xai/tests/transport/canonical.rs | 135 ++++++++++++- 16 files changed, 825 insertions(+), 99 deletions(-) diff --git a/backend/crates/gateway-protocol/src/openai/events.rs b/backend/crates/gateway-protocol/src/openai/events.rs index 4a5a55009..1fd6dae46 100644 --- a/backend/crates/gateway-protocol/src/openai/events.rs +++ b/backend/crates/gateway-protocol/src/openai/events.rs @@ -65,7 +65,8 @@ pub fn extract_usage(body: &Value) -> Option { 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 { }) } -/// 校验计价所需的输入、输出和缓存读取字段,拒绝缺失、非法或不一致的数值。 +/// 校验计价所需的 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 文本中提取最终可见用量。 diff --git a/backend/crates/gateway-protocol/tests/openai/events.rs b/backend/crates/gateway-protocol/tests/openai/events.rs index e66ac3108..306cc9fbd 100644 --- a/backend/crates/gateway-protocol/tests/openai/events.rs +++ b/backend/crates/gateway-protocol/tests/openai/events.rs @@ -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 [ diff --git a/backend/crates/providers/openai/src/provider/observation.rs b/backend/crates/providers/openai/src/provider/observation.rs index 9a1a8e428..1eff055f7 100644 --- a/backend/crates/providers/openai/src/provider/observation.rs +++ b/backend/crates/providers/openai/src/provider/observation.rs @@ -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 { diff --git a/backend/crates/providers/openai/src/transport/canonical.rs b/backend/crates/providers/openai/src/transport/canonical.rs index 6242b3f4c..7e542305f 100644 --- a/backend/crates/providers/openai/src/transport/canonical.rs +++ b/backend/crates/providers/openai/src/transport/canonical.rs @@ -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 { - 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 { diff --git a/backend/crates/providers/openai/src/transport/usage.rs b/backend/crates/providers/openai/src/transport/usage.rs index 00baab361..f66f90631 100644 --- a/backend/crates/providers/openai/src/transport/usage.rs +++ b/backend/crates/providers/openai/src/transport/usage.rs @@ -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, } @@ -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 for OpenAiBillingUsage { @@ -72,6 +80,7 @@ impl From 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 { + 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 { - 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::(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 { 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 { 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 { + 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 } /// 规范化请求或响应携带的服务档位,供观测与计费共用。 diff --git a/backend/crates/providers/openai/tests/provider/contract.rs b/backend/crates/providers/openai/tests/provider/contract.rs index 92b3a48f9..783c72d8c 100644 --- a/backend/crates/providers/openai/tests/provider/contract.rs +++ b/backend/crates/providers/openai/tests/provider/contract.rs @@ -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::>(); - 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")); } diff --git a/backend/crates/providers/openai/tests/transport/canonical.rs b/backend/crates/providers/openai/tests/transport/canonical.rs index 6dc5dd82d..5e6775293 100644 --- a/backend/crates/providers/openai/tests/transport/canonical.rs +++ b/backend/crates/providers/openai/tests/transport/canonical.rs @@ -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 { + 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}" + ); + } +} diff --git a/backend/crates/providers/openai/tests/transport/usage.rs b/backend/crates/providers/openai/tests/transport/usage.rs index f304bf782..e5b6556fe 100644 --- a/backend/crates/providers/openai/tests/transport/usage.rs +++ b/backend/crates/providers/openai/tests/transport/usage.rs @@ -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; diff --git a/backend/crates/providers/xai/src/admin.rs b/backend/crates/providers/xai/src/admin.rs index f1f9f9e2d..b92426e61 100644 --- a/backend/crates/providers/xai/src/admin.rs +++ b/backend/crates/providers/xai/src/admin.rs @@ -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); }; diff --git a/backend/crates/providers/xai/src/lib.rs b/backend/crates/providers/xai/src/lib.rs index c48114e8c..539ddc114 100644 --- a/backend/crates/providers/xai/src/lib.rs +++ b/backend/crates/providers/xai/src/lib.rs @@ -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 初始化后交给组装根的最小能力集。 diff --git a/backend/crates/providers/xai/src/provider/stream.rs b/backend/crates/providers/xai/src/provider/stream.rs index f359ddd62..ba8d81b5a 100644 --- a/backend/crates/providers/xai/src/provider/stream.rs +++ b/backend/crates/providers/xai/src/provider/stream.rs @@ -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 = 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); diff --git a/backend/crates/providers/xai/src/transport/canonical.rs b/backend/crates/providers/xai/src/transport/canonical.rs index 18c3149ff..a0a77c868 100644 --- a/backend/crates/providers/xai/src/transport/canonical.rs +++ b/backend/crates/providers/xai/src/transport/canonical.rs @@ -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 { + 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 { + 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, usage_emitted: bool, output_start_seen: bool, + response_service_tier: Option, + 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, 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, 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 { - 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 { + 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 { 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) } diff --git a/backend/crates/providers/xai/src/transport/mod.rs b/backend/crates/providers/xai/src/transport/mod.rs index fcdc79307..abcd9365b 100644 --- a/backend/crates/providers/xai/src/transport/mod.rs +++ b/backend/crates/providers/xai/src/transport/mod.rs @@ -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, diff --git a/backend/crates/providers/xai/tests/admin.rs b/backend/crates/providers/xai/tests/admin.rs index 66b176d8c..d8a743683 100644 --- a/backend/crates/providers/xai/tests/admin.rs +++ b/backend/crates/providers/xai/tests/admin.rs @@ -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] diff --git a/backend/crates/providers/xai/tests/provider/contract.rs b/backend/crates/providers/xai/tests/provider/contract.rs index 236a28ab7..7d72677b7 100644 --- a/backend/crates/providers/xai/tests/provider/contract.rs +++ b/backend/crates/providers/xai/tests/provider/contract.rs @@ -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"); diff --git a/backend/crates/providers/xai/tests/transport/canonical.rs b/backend/crates/providers/xai/tests/transport/canonical.rs index a464fbeb5..22b837416 100644 --- a/backend/crates/providers/xai/tests/transport/canonical.rs +++ b/backend/crates/providers/xai/tests/transport/canonical.rs @@ -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 { + 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]