diff --git a/backend/crates/providers/openai/src/admin.rs b/backend/crates/providers/openai/src/admin.rs index 15267f53a..ae44cb262 100644 --- a/backend/crates/providers/openai/src/admin.rs +++ b/backend/crates/providers/openai/src/admin.rs @@ -51,10 +51,10 @@ use crate::credential::{ }; use crate::credential::{CodexCredentialCodec, CodexOAuthSecret, oauth_owner_ref}; use crate::transport::CodexWebSocketPool; -use crate::transport::openai_billing_breakdown; use crate::transport::profile::{ CodexDesktopReleaseSnapshot, CodexDesktopReleaseStatus, CodexWireProfile, CodexWireProfileState, }; +use crate::transport::{OpenAiBillingUsage, openai_billing_breakdown}; const PROVIDER_NAME: &str = "openai"; const PENDING_DOCUMENT_SCHEMA_VERSION: u64 = 3; @@ -215,10 +215,12 @@ impl ProviderAdmin for OpenAiAdminProvider { }; let Some(breakdown) = openai_billing_breakdown( &input.upstream_model_id, - input_tokens, - output_tokens, - input.cached_tokens.unwrap_or_default(), - input.cache_write_tokens.unwrap_or_default(), + OpenAiBillingUsage::new( + input_tokens, + output_tokens, + input.cached_tokens.unwrap_or_default(), + input.cache_write_tokens.unwrap_or_default(), + ), input.service_tier.as_deref(), ) else { return Ok(None); diff --git a/backend/crates/providers/openai/src/lib.rs b/backend/crates/providers/openai/src/lib.rs index ce53c64e6..fdd7952a2 100644 --- a/backend/crates/providers/openai/src/lib.rs +++ b/backend/crates/providers/openai/src/lib.rs @@ -36,8 +36,8 @@ pub mod credential; pub mod transport; pub use transport::{ - CodexCanonicalDecoder, CodexCanonicalError, CodexRequestEncodeError, encode_generate_request, - openai_billing_breakdown, + CodexCanonicalDecoder, CodexCanonicalError, CodexRequestEncodeError, OpenAiBillingUsage, + encode_generate_request, openai_billing_breakdown, }; /// OpenAI 初始化后交给组装根的最小能力集。 diff --git a/backend/crates/providers/openai/src/provider.rs b/backend/crates/providers/openai/src/provider.rs index 203aa5232..fff795f04 100644 --- a/backend/crates/providers/openai/src/provider.rs +++ b/backend/crates/providers/openai/src/provider.rs @@ -736,6 +736,7 @@ fn cold_response_stream(response: ColdResponse) -> EventStream { // (transport/protocol/websocket.rs),push_frames 抽出的 data 即上游原文。 let mut decoder = CodexCanonicalDecoder::new(upstream_model.as_str()) .with_requested_service_tier(request.service_tier()) + .with_request_tool_pricing(upstream_model.as_str(), request.tools()) .with_raw_sse_passthrough(); let mut pre_commit_events = PreCommitClientEvents::new(); loop { diff --git a/backend/crates/providers/openai/src/transport/canonical.rs b/backend/crates/providers/openai/src/transport/canonical.rs index 1010d19b3..331ce542d 100644 --- a/backend/crates/providers/openai/src/transport/canonical.rs +++ b/backend/crates/providers/openai/src/transport/canonical.rs @@ -21,7 +21,10 @@ use thiserror::Error; use super::protocol::responses::{ ResponseEventSignals, ResponsesSseFailure, response_event_signals, }; -use super::usage::{normalize_service_tier, openai_billing_breakdown}; +use super::usage::{ + OpenAiBillingUsage, WebSearchPricing, normalize_service_tier, openai_billing_breakdown, + web_search_pricing, +}; const CONTENTS_PER_OUTPUT: u32 = 1_024; @@ -43,6 +46,7 @@ pub struct CodexCanonicalDecoder { semantic_output_seen: bool, requested_service_tier: Option, response_service_tier: Option, + web_search_pricing: Option, timing_signals: ResponseEventSignals, raw_sse_passthrough: bool, } @@ -139,6 +143,7 @@ impl CodexCanonicalDecoder { semantic_output_seen: false, requested_service_tier: None, response_service_tier: None, + web_search_pricing: None, timing_signals: ResponseEventSignals::default(), raw_sse_passthrough: false, } @@ -160,6 +165,13 @@ impl CodexCanonicalDecoder { self } + /// 记录请求工具类型,用于区分标准与预览 Web Search 的按次价格。 + #[must_use] + pub fn with_request_tool_pricing(mut self, model: &str, tools: Option<&[Value]>) -> Self { + self.web_search_pricing = web_search_pricing(model, tools); + self + } + pub fn push(&mut self, chunk: &[u8]) -> CodexCanonicalOutcome { self.timing_signals = ResponseEventSignals::default(); if self.raw_sse_passthrough { @@ -849,15 +861,15 @@ impl CodexCanonicalDecoder { .requested_service_tier .as_deref() .or(self.response_service_tier.as_deref()); + let web_search_calls = web_search_call_count(response); if let Some(breakdown) = usage .filter(|usage| billable_usage_is_complete(response, *usage)) .and_then(|usage| { + let web_search_calls = web_search_calls?; openai_billing_breakdown( &model, - usage.input_tokens, - usage.output_tokens, - usage.cached_tokens, - usage.cache_write_tokens, + OpenAiBillingUsage::from(usage) + .with_web_search_calls(web_search_calls, self.web_search_pricing), service_tier, ) }) @@ -984,6 +996,28 @@ 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, + } + } + Some(calls) +} + fn billable_usage_is_complete(response: &Value, usage: TokenUsage) -> bool { let Some(raw) = response.get("usage").filter(|value| value.is_object()) else { return false; diff --git a/backend/crates/providers/openai/src/transport/mod.rs b/backend/crates/providers/openai/src/transport/mod.rs index 43ca39e74..868964029 100644 --- a/backend/crates/providers/openai/src/transport/mod.rs +++ b/backend/crates/providers/openai/src/transport/mod.rs @@ -38,7 +38,7 @@ pub use self::{ headers::build_codex_base_headers, request::{CodexRequestEncodeError, encode_generate_request}, response_meta::CodexResponseMetadata, - usage::{MAX_CODEX_USAGE_BODY_BYTES, openai_billing_breakdown}, + usage::{MAX_CODEX_USAGE_BODY_BYTES, OpenAiBillingUsage, openai_billing_breakdown}, websocket::{ CodexWebSocketPool, CodexWebSocketPoolConfig, CodexWebSocketPoolKey, WebSocketPoolDecision, }, diff --git a/backend/crates/providers/openai/src/transport/usage.rs b/backend/crates/providers/openai/src/transport/usage.rs index 17cb8decc..5e78c042d 100644 --- a/backend/crates/providers/openai/src/transport/usage.rs +++ b/backend/crates/providers/openai/src/transport/usage.rs @@ -2,7 +2,7 @@ use gateway_core::accounting::{ CalculatedCostAmounts, CalculatedCostBreakdown, CalculatedCostRates, CurrencyCode, Decimal, Money, }; -use gateway_protocol::openai::events::retry_after_seconds_from_body; +use gateway_protocol::openai::events::{TokenUsage, retry_after_seconds_from_body}; use reqwest::StatusCode; use serde_json::Value; @@ -14,6 +14,83 @@ 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; + +/// OpenAI 公开 Token 价格计算所需的单次用量事实。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct OpenAiBillingUsage { + input_tokens: u64, + output_tokens: u64, + cached_tokens: u64, + cache_write_tokens: u64, + image_input_tokens: u64, + image_output_tokens: u64, + web_search_calls: u64, + web_search_pricing: Option, +} + +impl OpenAiBillingUsage { + /// 构造不含托管工具费用的 Token 用量。 + #[must_use] + pub const fn new( + input_tokens: u64, + output_tokens: u64, + cached_tokens: u64, + cache_write_tokens: u64, + ) -> Self { + Self { + input_tokens, + output_tokens, + cached_tokens, + cache_write_tokens, + image_input_tokens: 0, + image_output_tokens: 0, + web_search_calls: 0, + web_search_pricing: None, + } + } + + pub(crate) const fn with_web_search_calls( + mut self, + calls: u64, + pricing: Option, + ) -> Self { + self.web_search_calls = calls; + self.web_search_pricing = pricing; + self + } +} + +impl From for OpenAiBillingUsage { + fn from(usage: TokenUsage) -> Self { + Self { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + cached_tokens: usage.cached_tokens, + cache_write_tokens: usage.cache_write_tokens, + image_input_tokens: usage.image_input_tokens, + image_output_tokens: usage.image_output_tokens, + web_search_calls: 0, + web_search_pricing: None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WebSearchPricing { + Standard, + PreviewNonReasoning, +} + +impl WebSearchPricing { + const fn price_per_call_ticks(self) -> u128 { + match self { + Self::Standard => WEB_SEARCH_CALL_TICKS, + Self::PreviewNonReasoning => WEB_SEARCH_PREVIEW_NON_REASONING_CALL_TICKS, + } + } +} #[derive(Clone, Copy)] struct TokenRates { @@ -42,9 +119,11 @@ impl TokenRates { #[derive(Clone, Copy)] struct ModelPricing { standard: TokenRates, - priority: TokenRates, - long: TokenRates, - long_priority: TokenRates, + flex: TokenRates, + fast: TokenRates, + long_standard: TokenRates, + long_flex: TokenRates, + long_fast: TokenRates, cache_write_percent: u32, } @@ -52,29 +131,37 @@ impl ModelPricing { const fn new(input: u128, output: u128, cache_read: u128) -> Self { Self { standard: TokenRates::new(input, output, cache_read), - priority: TokenRates::ZERO, - long: TokenRates::ZERO, - long_priority: TokenRates::ZERO, + flex: TokenRates::ZERO, + fast: TokenRates::ZERO, + long_standard: TokenRates::ZERO, + long_flex: TokenRates::ZERO, + long_fast: TokenRates::ZERO, cache_write_percent: 0, } } - const fn with_priority(mut self, input: u128, output: u128, cache_read: u128) -> Self { - self.priority = TokenRates::new(input, output, cache_read); + const fn with_flex(mut self, input: u128, output: u128, cache_read: u128) -> Self { + self.flex = TokenRates::new(input, output, cache_read); self } - const fn with_long_context( - mut self, - input: u128, - output: u128, - cache_read: u128, - priority_input: u128, - priority_output: u128, - priority_cache_read: u128, - ) -> Self { - self.long = TokenRates::new(input, output, cache_read); - self.long_priority = TokenRates::new(priority_input, priority_output, priority_cache_read); + const fn with_fast(mut self, input: u128, output: u128, cache_read: u128) -> Self { + self.fast = TokenRates::new(input, output, cache_read); + self + } + + const fn with_long(mut self, input: u128, output: u128, cache_read: u128) -> Self { + self.long_standard = TokenRates::new(input, output, cache_read); + self + } + + const fn with_long_flex(mut self, input: u128, output: u128, cache_read: u128) -> Self { + self.long_flex = TokenRates::new(input, output, cache_read); + self + } + + const fn with_long_fast(mut self, input: u128, output: u128, cache_read: u128) -> Self { + self.long_fast = TokenRates::new(input, output, cache_read); self } @@ -82,6 +169,29 @@ impl ModelPricing { self.cache_write_percent = percent; self } + + fn rates(self, tier: PricingTier, long_context: bool) -> Option { + // 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. + let uses_long_rates = long_context && self.long_standard.is_configured(); + let rates = match (tier, uses_long_rates) { + (PricingTier::Standard, false) => self.standard, + (PricingTier::Flex, false) => self.flex, + (PricingTier::Fast, false) => self.fast, + (PricingTier::Standard, true) => self.long_standard, + (PricingTier::Flex, true) => self.long_flex, + (PricingTier::Fast, true) => self.long_fast, + }; + rates.is_configured().then_some(rates) + } +} + +#[derive(Clone, Copy)] +enum PricingTier { + Standard, + Flex, + Fast, } #[derive(Clone, Copy)] @@ -90,86 +200,178 @@ struct PricingRule { pricing: ModelPricing, } +// Source: https://developers.openai.com/api/docs/pricing (verified 2026-08-14). const PRICING_RULES: &[PricingRule] = &[ PricingRule { model: "gpt-5.6-sol", pricing: ModelPricing::new(50_000, 300_000, 5_000) .with_cache_write(125) - .with_priority(100_000, 600_000, 10_000) - .with_long_context(100_000, 450_000, 10_000, 0, 0, 0), + .with_flex(25_000, 150_000, 2_500) + .with_fast(100_000, 600_000, 10_000) + .with_long(100_000, 450_000, 10_000) + .with_long_flex(50_000, 225_000, 5_000) + .with_long_fast(200_000, 900_000, 20_000), }, PricingRule { model: "gpt-5.6-terra", pricing: ModelPricing::new(20_000, 120_000, 2_000) .with_cache_write(125) - .with_priority(40_000, 240_000, 4_000) - .with_long_context(40_000, 180_000, 4_000, 0, 0, 0), + .with_flex(10_000, 60_000, 1_000) + .with_fast(40_000, 240_000, 4_000) + .with_long(40_000, 180_000, 4_000) + .with_long_flex(20_000, 90_000, 2_000) + .with_long_fast(80_000, 360_000, 8_000), }, PricingRule { model: "gpt-5.6-luna", pricing: ModelPricing::new(2_000, 12_000, 200) .with_cache_write(125) - .with_priority(4_000, 24_000, 400) - .with_long_context(4_000, 18_000, 400, 0, 0, 0), + .with_flex(1_000, 6_000, 100) + .with_fast(4_000, 24_000, 400) + .with_long(4_000, 18_000, 400) + .with_long_flex(2_000, 9_000, 200) + .with_long_fast(8_000, 36_000, 800), }, PricingRule { model: "gpt-5.6", pricing: ModelPricing::new(50_000, 300_000, 5_000) .with_cache_write(125) - .with_priority(100_000, 600_000, 10_000) - .with_long_context(100_000, 450_000, 10_000, 0, 0, 0), + .with_flex(25_000, 150_000, 2_500) + .with_fast(100_000, 600_000, 10_000) + .with_long(100_000, 450_000, 10_000) + .with_long_flex(50_000, 225_000, 5_000) + .with_long_fast(200_000, 900_000, 20_000), }, PricingRule { model: "gpt-5.5-pro", pricing: ModelPricing::new(300_000, 1_800_000, 0) - .with_priority(750_000, 4_500_000, 0) - .with_long_context(600_000, 2_700_000, 0, 1_500_000, 6_750_000, 0), + .with_flex(150_000, 900_000, 0) + .with_long(600_000, 2_700_000, 0), }, PricingRule { model: "gpt-5.5", pricing: ModelPricing::new(50_000, 300_000, 5_000) - .with_priority(125_000, 750_000, 12_500) - .with_long_context(100_000, 450_000, 10_000, 250_000, 1_125_000, 25_000), + .with_flex(25_000, 150_000, 2_500) + .with_fast(125_000, 750_000, 12_500) + .with_long(100_000, 450_000, 10_000) + .with_long_flex(50_000, 225_000, 5_000), }, PricingRule { model: "gpt-5.4-mini", - pricing: ModelPricing::new(7_500, 45_000, 750), + pricing: ModelPricing::new(7_500, 45_000, 750) + .with_flex(3_750, 22_500, 375) + .with_fast(15_000, 90_000, 1_500), }, PricingRule { model: "gpt-5.4-nano", - pricing: ModelPricing::new(2_000, 12_500, 200), + pricing: ModelPricing::new(2_000, 12_500, 200).with_flex(1_000, 6_250, 100), }, PricingRule { model: "gpt-5.4-pro", pricing: ModelPricing::new(300_000, 1_800_000, 0) - .with_priority(750_000, 4_500_000, 0) - .with_long_context(600_000, 2_700_000, 0, 1_500_000, 6_750_000, 0), + .with_flex(150_000, 900_000, 0) + .with_long(600_000, 2_700_000, 0) + .with_long_flex(300_000, 1_350_000, 0), }, PricingRule { model: "gpt-5.4", pricing: ModelPricing::new(25_000, 150_000, 2_500) - .with_priority(50_000, 300_000, 5_000) - .with_long_context(50_000, 225_000, 5_000, 100_000, 450_000, 10_000), - }, - PricingRule { - model: "gpt-5.3-codex-spark", - pricing: ModelPricing::new(12_500, 100_000, 1_250).with_priority(25_000, 200_000, 2_500), + .with_flex(12_500, 75_000, 1_300) + .with_fast(50_000, 300_000, 5_000) + .with_long(50_000, 225_000, 5_000) + .with_long_flex(25_000, 112_500, 2_500), }, PricingRule { model: "gpt-5.3-codex", - pricing: ModelPricing::new(17_500, 140_000, 1_750).with_priority(35_000, 280_000, 3_500), + pricing: ModelPricing::new(17_500, 140_000, 1_750).with_fast(35_000, 280_000, 3_500), + }, + PricingRule { + model: "gpt-5.2-pro", + pricing: ModelPricing::new(210_000, 1_680_000, 0), }, PricingRule { model: "gpt-5.2", - pricing: ModelPricing::new(17_500, 140_000, 1_750).with_priority(35_000, 280_000, 3_500), + pricing: ModelPricing::new(17_500, 140_000, 1_750) + .with_flex(8_750, 70_000, 875) + .with_fast(35_000, 280_000, 3_500), + }, + PricingRule { + model: "gpt-5.1", + pricing: ModelPricing::new(12_500, 100_000, 1_250) + .with_flex(6_250, 50_000, 625) + .with_fast(25_000, 200_000, 2_500), + }, + PricingRule { + model: "gpt-5-mini", + pricing: ModelPricing::new(2_500, 20_000, 250) + .with_flex(1_250, 10_000, 125) + .with_fast(4_500, 36_000, 450), + }, + PricingRule { + model: "gpt-5-nano", + pricing: ModelPricing::new(500, 4_000, 50).with_flex(250, 2_000, 25), + }, + PricingRule { + model: "gpt-5-pro", + pricing: ModelPricing::new(150_000, 1_200_000, 0), + }, + PricingRule { + model: "gpt-5", + pricing: ModelPricing::new(12_500, 100_000, 1_250) + .with_flex(6_250, 50_000, 625) + .with_fast(25_000, 200_000, 2_500), + }, + PricingRule { + model: "gpt-4.1-mini", + pricing: ModelPricing::new(4_000, 16_000, 1_000).with_fast(7_000, 28_000, 1_750), + }, + PricingRule { + model: "gpt-4.1-nano", + pricing: ModelPricing::new(1_000, 4_000, 250).with_fast(2_000, 8_000, 500), + }, + PricingRule { + model: "gpt-4.1", + pricing: ModelPricing::new(20_000, 80_000, 5_000).with_fast(35_000, 140_000, 8_750), + }, + PricingRule { + model: "gpt-4o-2024-05-13", + pricing: ModelPricing::new(50_000, 150_000, 0).with_fast(87_500, 262_500, 0), }, PricingRule { model: "gpt-4o-mini", - pricing: ModelPricing::new(1_500, 6_000, 750), + pricing: ModelPricing::new(1_500, 6_000, 750).with_fast(2_500, 10_000, 1_250), }, PricingRule { model: "gpt-4o", - pricing: ModelPricing::new(25_000, 100_000, 12_500), + pricing: ModelPricing::new(25_000, 100_000, 12_500).with_fast(42_500, 170_000, 21_250), + }, + PricingRule { + model: "o1-pro", + pricing: ModelPricing::new(1_500_000, 6_000_000, 0), + }, + PricingRule { + model: "o1", + pricing: ModelPricing::new(150_000, 600_000, 75_000), + }, + PricingRule { + model: "o3-pro", + pricing: ModelPricing::new(200_000, 800_000, 0), + }, + PricingRule { + model: "o3-mini", + pricing: ModelPricing::new(11_000, 44_000, 5_500), + }, + PricingRule { + model: "o3", + pricing: ModelPricing::new(20_000, 80_000, 5_000) + .with_flex(10_000, 40_000, 2_500) + .with_fast(35_000, 140_000, 8_750), + }, + PricingRule { + model: "o4-mini", + pricing: ModelPricing::new(11_000, 44_000, 2_750) + .with_flex(5_500, 22_000, 1_380) + .with_fast(20_000, 80_000, 5_000), }, PricingRule { model: "gpt-4-turbo", @@ -179,93 +381,86 @@ const PRICING_RULES: &[PricingRule] = &[ model: "gpt-4", pricing: ModelPricing::new(300_000, 600_000, 0), }, + PricingRule { + model: "gpt-3.5-turbo-instruct", + pricing: ModelPricing::new(15_000, 20_000, 0), + }, + PricingRule { + model: "gpt-3.5-turbo-1106", + pricing: ModelPricing::new(10_000, 20_000, 0), + }, PricingRule { model: "gpt-3.5-turbo", pricing: ModelPricing::new(5_000, 15_000, 0), }, ]; +const UNPRICED_MODELS: &[&str] = &["gpt-5.3-codex-spark"]; + +#[derive(Clone, Copy)] +struct TokenAmounts { + input_ticks: u128, + output_ticks: u128, + cache_read_ticks: u128, + cache_write_ticks: u128, + total_ticks: u128, +} + /// 按 OpenAI Provider 当前受控价格规则计算费用明细。 #[must_use] pub fn openai_billing_breakdown( model: &str, - input_tokens: u64, - output_tokens: u64, - cached_tokens: u64, - cache_write_tokens: u64, + usage: OpenAiBillingUsage, service_tier: Option<&str>, ) -> Option { - if cached_tokens > input_tokens { + if usage.cached_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 pricing = model_pricing(model)?; - let long_context = input_tokens > LONG_CONTEXT_THRESHOLD; + let long_context = usage.input_tokens > LONG_CONTEXT_THRESHOLD; let normalized_tier = normalize_service_tier(service_tier); - let priority = matches!(normalized_tier.as_deref(), Some("priority" | "fast")); - let mut rates = if long_context && pricing.long.is_configured() { - pricing.long - } else { - pricing.standard - }; - let multiplier_percent = if priority { - let priority_rates = if long_context && pricing.long_priority.is_configured() { - pricing.long_priority - } else { - pricing.priority - }; - if priority_rates.is_configured() { - rates = priority_rates; - 100 - } else { - 200 - } - } else if normalized_tier.as_deref() == Some("flex") { - 50 - } else { - 100 - }; - - let billed_cache_read = if rates.cache_read_ticks > 0 { - cached_tokens.min(input_tokens) - } else { - 0 - }; - let cache_write_rate = if pricing.cache_write_percent > 0 { - apply_percent(rates.input_ticks, pricing.cache_write_percent)? - } else { - 0 - }; - let billed_cache_write = if cache_write_rate > 0 { - cache_write_tokens.min(input_tokens.saturating_sub(billed_cache_read)) - } else { - 0 - }; - let uncached_input = input_tokens - .saturating_sub(billed_cache_read) - .saturating_sub(billed_cache_write); - let input_amount = u128::from(uncached_input).checked_mul(rates.input_ticks)?; - let output_amount = u128::from(output_tokens).checked_mul(rates.output_ticks)?; - let cache_read_amount = u128::from(billed_cache_read).checked_mul(rates.cache_read_ticks)?; - let cache_write_amount = u128::from(billed_cache_write).checked_mul(cache_write_rate)?; - let standard_amount = input_amount - .checked_add(output_amount)? - .checked_add(cache_read_amount)? - .checked_add(cache_write_amount)?; - let total_amount = apply_percent(standard_amount, multiplier_percent)?; + let tier = pricing_tier(normalized_tier.as_deref())?; + let standard_rates = pricing.rates(PricingTier::Standard, long_context)?; + let selected_rates = pricing.rates(tier, long_context)?; + let mut standard = token_amounts( + standard_rates, + pricing.cache_write_percent, + usage.input_tokens, + usage.output_tokens, + usage.cached_tokens, + usage.cache_write_tokens, + )?; + let mut selected = token_amounts( + selected_rates, + pricing.cache_write_percent, + usage.input_tokens, + usage.output_tokens, + 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)?; + 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)?; Some(CalculatedCostBreakdown::new( CalculatedCostAmounts::new( - usd_money(input_amount)?, - usd_money(output_amount)?, - usd_money(cache_read_amount)?, - usd_money(cache_write_amount)?, - usd_money(standard_amount)?, - usd_money(total_amount)?, + usd_money(selected.input_ticks)?, + usd_money(selected.output_ticks)?, + usd_money(selected.cache_read_ticks)?, + usd_money(selected.cache_write_ticks)?, + usd_money(standard.total_ticks)?, + usd_money(selected.total_ticks)?, ), CalculatedCostRates::new( - usd_price_per_million(rates.input_ticks)?, - usd_price_per_million(rates.output_ticks)?, - usd_price_per_million(rates.cache_read_ticks)?, + usd_price_per_million(selected_rates.input_ticks)?, + usd_price_per_million(selected_rates.output_ticks)?, + usd_price_per_million(selected_rates.cache_read_ticks)?, usd_price_per_million(cache_write_rate)?, ), Some(normalized_tier.unwrap_or_else(|| "default".to_owned())), @@ -275,6 +470,12 @@ pub fn openai_billing_breakdown( 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; + } PRICING_RULES .iter() .filter(|rule| model_matches_rule(&normalized, rule.model)) @@ -282,6 +483,120 @@ fn model_pricing(model: &str) -> Option { .map(|rule| rule.pricing) } +pub(crate) fn web_search_pricing(model: &str, tools: Option<&[Value]>) -> Option { + let mut standard = false; + let mut preview = false; + for tool_type in tools + .into_iter() + .flatten() + .filter_map(|tool| tool.get("type").and_then(Value::as_str)) + { + if tool_type == "web_search_preview" || tool_type.starts_with("web_search_preview_") { + preview = true; + } else if tool_type == "web_search" || tool_type.starts_with("web_search_") { + standard = true; + } + } + match (standard, preview) { + // These two models bill non-preview search content as a fixed 8K input block. + // The response does not expose enough detail to replace that block without + // double-counting ordinary input, so leave the request unpriced. + (true, false) if fixed_block_web_search_model(model) => None, + (true, false) => Some(WebSearchPricing::Standard), + (false, true) if reasoning_model(model) => Some(WebSearchPricing::Standard), + (false, true) => Some(WebSearchPricing::PreviewNonReasoning), + (false, false) | (true, true) => None, + } +} + +fn fixed_block_web_search_model(model: &str) -> bool { + let normalized = normalize_model_name(model); + ["gpt-4o-mini", "gpt-4.1-mini"] + .iter() + .any(|rule| model_matches_rule(&normalized, rule)) +} + +fn reasoning_model(model: &str) -> bool { + let normalized = normalize_model_name(model); + normalized.starts_with("gpt-5") + || normalized.starts_with("o1") + || normalized.starts_with("o3") + || normalized.starts_with("o4") +} + +fn pricing_tier(service_tier: Option<&str>) -> Option { + match service_tier { + None | Some("auto" | "default" | "standard") => Some(PricingTier::Standard), + Some("flex") => Some(PricingTier::Flex), + Some("fast" | "priority") => Some(PricingTier::Fast), + Some(_) => None, + } +} + +fn web_search_amount_ticks(usage: OpenAiBillingUsage) -> Option { + if usage.web_search_calls == 0 { + return Some(0); + } + u128::from(usage.web_search_calls).checked_mul(usage.web_search_pricing?.price_per_call_ticks()) +} + +fn token_amounts( + rates: TokenRates, + cache_write_percent: u32, + input_tokens: u64, + output_tokens: u64, + cached_tokens: u64, + cache_write_tokens: u64, +) -> Option { + let billed_cache_read = if rates.cache_read_ticks > 0 { + cached_tokens.min(input_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)) + } else { + 0 + }; + let uncached_input = input_tokens + .saturating_sub(billed_cache_read) + .saturating_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)?; + let cache_write_ticks = u128::from(billed_cache_write).checked_mul(cache_write_rate)?; + let total_ticks = input_ticks + .checked_add(output_ticks)? + .checked_add(cache_read_ticks)? + .checked_add(cache_write_ticks)?; + Some(TokenAmounts { + input_ticks, + output_ticks, + cache_read_ticks, + cache_write_ticks, + total_ticks, + }) +} + +fn cache_write_rate(rates: TokenRates, percent: u32) -> Option { + if percent == 0 { + return Some(0); + } + apply_percent(rates.input_ticks, percent) +} + +fn effective_multiplier_percent(total: u128, standard: u128) -> Option { + if standard == 0 { + return Some(100); + } + let rounded = total + .checked_mul(100)? + .checked_add(standard / 2)? + .checked_div(standard)?; + u32::try_from(rounded).ok() +} + fn normalize_model_name(model: &str) -> String { model .trim() diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index c7977cbf9..ec28e39ba 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -148,15 +148,15 @@ async fn openai_admin_provider_exposes_live_wire_profile_and_validated_billing() cache_write_tokens: Some(0), total: CurrencyCost { currency: "USD".to_owned(), - amount: "5".parse().expect("fast amount"), + amount: "4.25".parse().expect("fast amount"), }, }) .expect("fast billing") .expect("known fast pricing"); assert_eq!(fast_billing.service_tier.as_deref(), Some("priority")); - assert_eq!(fast_billing.multiplier_percent, 200); + assert_eq!(fast_billing.multiplier_percent, 170); assert_eq!(fast_billing.standard_amount.amount.as_str(), "2.5"); - assert_eq!(fast_billing.total_amount.amount.as_str(), "5"); + assert_eq!(fast_billing.total_amount.amount.as_str(), "4.25"); } #[tokio::test] diff --git a/backend/crates/providers/openai/tests/transport/canonical.rs b/backend/crates/providers/openai/tests/transport/canonical.rs index b36ed3590..c88f5d8d3 100644 --- a/backend/crates/providers/openai/tests/transport/canonical.rs +++ b/backend/crates/providers/openai/tests/transport/canonical.rs @@ -4,6 +4,7 @@ use provider_openai::transport::canonical::{ CodexCanonicalDecoder, CodexCanonicalError, CodexCanonicalFailure, CodexCanonicalOutcome, }; use provider_openai::transport::protocol::websocket::websocket_event_to_sse_frame; +use serde_json::json; const METADATA_PREFIX_FIXTURE: &str = include_str!( "../../../../gateway-api/tests/openai/responses/fixtures/http_sse/metadata_only_prefix.sse" @@ -262,6 +263,69 @@ fn decoder_should_use_response_service_tier_when_request_omits_it() { ))); } +#[test] +fn decoder_should_add_standard_web_search_call_cost() { + let body = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_search_cost\",\"model\":\"gpt-5.4\"}}\n\n", + "event: response.completed\n", + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_search_cost\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[{\"type\":\"web_search_call\",\"id\":\"ws_1\",\"status\":\"completed\",\"action\":{\"type\":\"search\"}}],\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"total_tokens\":0}}}\n\n", + ); + let tools = vec![json!({ "type": "web_search" })]; + let events = CodexCanonicalDecoder::new("fallback") + .with_request_tool_pricing("gpt-5.4", Some(&tools)) + .push(body.as_bytes()) + .expect("canonical web search response"); + + assert!(canonical_facts(&events).into_iter().any(|event| matches!( + event, + GatewayEvent::CalculatedCost(cost) + if cost.total().amount().scaled() == 100_000_000 + ))); +} + +#[test] +fn decoder_should_use_non_reasoning_preview_web_search_price() { + let body = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_preview_search_cost\",\"model\":\"gpt-4o\"}}\n\n", + "event: response.completed\n", + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_preview_search_cost\",\"model\":\"gpt-4o\",\"status\":\"completed\",\"output\":[{\"type\":\"web_search_call\",\"id\":\"ws_1\",\"status\":\"completed\",\"action\":{\"type\":\"search\"}}],\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"total_tokens\":0}}}\n\n", + ); + let tools = vec![json!({ "type": "web_search_preview" })]; + let events = CodexCanonicalDecoder::new("fallback") + .with_request_tool_pricing("gpt-4o", Some(&tools)) + .push(body.as_bytes()) + .expect("canonical preview web search response"); + + assert!(canonical_facts(&events).into_iter().any(|event| matches!( + event, + GatewayEvent::CalculatedCost(cost) + if cost.total().amount().scaled() == 250_000_000 + ))); +} + +#[test] +fn decoder_should_fail_closed_for_fixed_block_web_search_content() { + let body = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_fixed_search_cost\",\"model\":\"gpt-4o-mini\"}}\n\n", + "event: response.completed\n", + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_fixed_search_cost\",\"model\":\"gpt-4o-mini\",\"status\":\"completed\",\"output\":[{\"type\":\"web_search_call\",\"id\":\"ws_1\",\"status\":\"completed\",\"action\":{\"type\":\"search\"}}],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n", + ); + let tools = vec![json!({ "type": "web_search" })]; + let events = CodexCanonicalDecoder::new("fallback") + .with_request_tool_pricing("gpt-4o-mini", Some(&tools)) + .push(body.as_bytes()) + .expect("canonical fixed-block search response"); + + assert!( + canonical_facts(&events) + .into_iter() + .all(|event| !matches!(event, GatewayEvent::CalculatedCost(_))) + ); +} + #[test] fn websocket_decoder_should_prefer_requested_service_tier_over_response_tier_for_billing() { let created = websocket_event_to_sse_frame( @@ -423,6 +487,25 @@ fn decoder_should_preserve_image_tool_tokens_in_canonical_usage() { assert_eq!(image_usage, Some((Some(31), Some(9)))); } +#[test] +fn decoder_should_not_understate_cost_when_image_tool_price_is_ambiguous() { + let body = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_image_cost\",\"model\":\"gpt-5.4\"}}\n\n", + "event: response.completed\n", + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_image_cost\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":5,\"total_tokens\":17},\"tool_usage\":{\"image_gen\":{\"input_tokens\":31,\"output_tokens\":9}}}}\n\n", + ); + let events = CodexCanonicalDecoder::new("fallback") + .push(body.as_bytes()) + .expect("canonical image cost response"); + + assert!( + canonical_facts(&events) + .into_iter() + .all(|event| !matches!(event, GatewayEvent::CalculatedCost(_))) + ); +} + #[test] fn decoder_should_drop_rate_limit_control_and_preserve_metadata_lifecycle_events() { let body = concat!( diff --git a/backend/crates/providers/openai/tests/transport/usage.rs b/backend/crates/providers/openai/tests/transport/usage.rs index cd560de63..c6883ad40 100644 --- a/backend/crates/providers/openai/tests/transport/usage.rs +++ b/backend/crates/providers/openai/tests/transport/usage.rs @@ -8,7 +8,7 @@ use chrono::{TimeZone, Utc}; use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState}; use provider_openai::transport::{ CodexBackendClient, CodexClientError, CodexRequestContext, MAX_CODEX_USAGE_BODY_BYTES, - build_reqwest_client, openai_billing_breakdown, + OpenAiBillingUsage, build_reqwest_client, openai_billing_breakdown, }; use reqwest::StatusCode; use wiremock::matchers::{method, path}; @@ -16,10 +16,24 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; const OVERSIZED_BODY_ERROR: &str = "upstream usage response exceeded the body limit"; +fn billing_usage( + input_tokens: u64, + output_tokens: u64, + cached_tokens: u64, + cache_write_tokens: u64, +) -> OpenAiBillingUsage { + OpenAiBillingUsage::new( + input_tokens, + output_tokens, + cached_tokens, + cache_write_tokens, + ) +} + #[test] fn billing_breakdown_should_preserve_input_output_and_cache_components() { - let breakdown = - openai_billing_breakdown("gpt-5.6-sol", 100, 5, 20, 10, None).expect("known model pricing"); + let breakdown = openai_billing_breakdown("gpt-5.6-sol", billing_usage(100, 5, 20, 10), None) + .expect("known model pricing"); assert_eq!(breakdown.input_amount().amount().scaled(), 3_500_000); assert_eq!(breakdown.output_amount().amount().scaled(), 1_500_000); @@ -32,50 +46,120 @@ fn billing_breakdown_should_preserve_input_output_and_cache_components() { #[test] fn billing_breakdown_should_use_latest_gpt_5_6_and_cached_input_prices() { - let terra = openai_billing_breakdown("gpt-5.6-terra", 1, 1, 0, 0, None) + let terra = openai_billing_breakdown("gpt-5.6-terra", billing_usage(1, 1, 0, 0), None) .expect("gpt-5.6-terra standard pricing"); assert_eq!(terra.total_amount().amount().scaled(), 140_000); - let terra_fast = openai_billing_breakdown("gpt-5.6-terra", 1, 1, 0, 0, Some("fast")) - .expect("gpt-5.6-terra fast pricing"); + let terra_fast = + openai_billing_breakdown("gpt-5.6-terra", billing_usage(1, 1, 0, 0), Some("fast")) + .expect("gpt-5.6-terra fast pricing"); assert_eq!(terra_fast.total_amount().amount().scaled(), 280_000); - let terra_long = openai_billing_breakdown("gpt-5.6-terra", 272_001, 0, 0, 0, None) - .expect("gpt-5.6-terra long-context pricing"); + let terra_long = + openai_billing_breakdown("gpt-5.6-terra", billing_usage(272_001, 0, 0, 0), None) + .expect("gpt-5.6-terra long-context pricing"); assert_eq!(terra_long.total_amount().amount().scaled(), 10_880_040_000); - let luna = openai_billing_breakdown("gpt-5.6-luna", 1, 1, 0, 0, None) + let luna = openai_billing_breakdown("gpt-5.6-luna", billing_usage(1, 1, 0, 0), None) .expect("gpt-5.6-luna standard pricing"); assert_eq!(luna.total_amount().amount().scaled(), 14_000); - let gpt_4o = - openai_billing_breakdown("gpt-4o", 1, 1, 1, 0, None).expect("gpt-4o cached-input pricing"); + let gpt_4o = openai_billing_breakdown("gpt-4o", billing_usage(1, 1, 1, 0), None) + .expect("gpt-4o cached-input pricing"); assert_eq!(gpt_4o.total_amount().amount().scaled(), 112_500); - let gpt_4o_mini = openai_billing_breakdown("gpt-4o-mini", 1, 1, 1, 0, None) + let gpt_4o_mini = openai_billing_breakdown("gpt-4o-mini", billing_usage(1, 1, 1, 0), None) .expect("gpt-4o-mini cached-input pricing"); assert_eq!(gpt_4o_mini.total_amount().amount().scaled(), 6_750); } #[test] fn billing_breakdown_should_apply_fast_and_flex_tiers_without_guessing_unknown_models() { - let fast = openai_billing_breakdown("gpt-5.4", 1, 1, 0, 0, Some("fast")).expect("fast pricing"); - let flex = openai_billing_breakdown("gpt-5.4", 1, 1, 0, 0, Some("flex")).expect("flex pricing"); + let fast = openai_billing_breakdown("gpt-5.4", billing_usage(1, 1, 0, 0), Some("fast")) + .expect("fast pricing"); + let flex = openai_billing_breakdown("gpt-5.4", billing_usage(1, 1, 0, 0), Some("flex")) + .expect("flex pricing"); assert_eq!(fast.total_amount().amount().scaled(), 350_000); assert_eq!(fast.service_tier(), Some("fast")); - assert_eq!(fast.multiplier_percent(), 100); + assert_eq!(fast.multiplier_percent(), 200); assert_eq!(flex.total_amount().amount().scaled(), 87_500); assert_eq!(flex.multiplier_percent(), 50); - assert!(openai_billing_breakdown("unknown-model", 1, 1, 0, 0, None).is_none()); + assert!(openai_billing_breakdown("unknown-model", billing_usage(1, 1, 0, 0), None).is_none()); +} + +#[test] +fn billing_breakdown_should_use_official_fast_long_context_prices() { + let breakdown = + openai_billing_breakdown("gpt-5.6-sol", billing_usage(272_001, 1, 1, 1), Some("fast")) + .expect("gpt-5.6-sol fast long-context pricing"); + + assert_eq!( + ( + breakdown.input_price_per_million().amount().scaled(), + breakdown.cache_read_price_per_million().amount().scaled(), + breakdown.cache_write_price_per_million().amount().scaled(), + breakdown.output_price_per_million().amount().scaled(), + ), + ( + 200_000_000_000, + 20_000_000_000, + 250_000_000_000, + 900_000_000_000, + ) + ); +} + +#[test] +fn billing_breakdown_should_fail_closed_for_unpublished_prices() { + assert_eq!( + ( + openai_billing_breakdown("gpt-5.5", billing_usage(272_001, 1, 0, 0), Some("fast")) + .is_none(), + openai_billing_breakdown("gpt-5.5-pro", billing_usage(1, 1, 0, 0), Some("fast")) + .is_none(), + openai_billing_breakdown("gpt-5.3-codex-spark", billing_usage(1, 1, 0, 0), None) + .is_none(), + openai_billing_breakdown("gpt-4o", billing_usage(1, 1, 0, 0), Some("flex")).is_none(), + ), + (true, true, true, true) + ); +} + +#[test] +fn billing_breakdown_should_use_official_gpt_4o_fast_prices() { + let breakdown = openai_billing_breakdown("gpt-4o", billing_usage(1, 1, 1, 0), Some("priority")) + .expect("gpt-4o fast pricing"); + + assert_eq!( + ( + breakdown.cache_read_price_per_million().amount().scaled(), + breakdown.output_price_per_million().amount().scaled(), + breakdown.standard_amount().amount().scaled(), + breakdown.total_amount().amount().scaled(), + breakdown.multiplier_percent(), + ), + (21_250_000_000, 170_000_000_000, 112_500, 191_250, 170,) + ); +} + +#[test] +fn billing_breakdown_should_use_published_flex_cache_price() { + let breakdown = openai_billing_breakdown("gpt-5.4", billing_usage(1, 0, 1, 0), Some("flex")) + .expect("gpt-5.4 flex pricing"); + + assert_eq!( + breakdown.cache_read_price_per_million().amount().scaled(), + 1_300_000_000 + ); } #[test] fn billing_breakdown_should_switch_only_after_the_long_context_threshold() { - let boundary = openai_billing_breakdown("gpt-5.4", 272_000, 0, 0, 0, None) + let boundary = openai_billing_breakdown("gpt-5.4", billing_usage(272_000, 0, 0, 0), None) .expect("short-context boundary"); - let long = - openai_billing_breakdown("gpt-5.4", 272_001, 0, 0, 0, None).expect("long-context pricing"); + let long = openai_billing_breakdown("gpt-5.4", billing_usage(272_001, 0, 0, 0), None) + .expect("long-context pricing"); assert_eq!( boundary.input_price_per_million().amount().scaled(), @@ -85,7 +169,7 @@ fn billing_breakdown_should_switch_only_after_the_long_context_threshold() { long.input_price_per_million().amount().scaled(), 50_000_000_000 ); - assert!(openai_billing_breakdown("gpt-5.4", 1, 0, 2, 0, None).is_none()); + assert!(openai_billing_breakdown("gpt-5.4", billing_usage(1, 0, 2, 0), None).is_none()); } #[tokio::test]