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]