fix(openai): align billing with official pricing

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-08-14 21:14:36 +08:00
co-authored by Codex
parent 482e192e2d
commit f642764f34
9 changed files with 667 additions and 148 deletions
+7 -5
View File
@@ -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);
+2 -2
View File
@@ -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 初始化后交给组装根的最小能力集。
@@ -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 {
@@ -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<String>,
response_service_tier: Option<String>,
web_search_pricing: Option<WebSearchPricing>,
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<u64> {
let mut calls = 0_u64;
for item in response
.get("output")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter(|item| item.get("type").and_then(Value::as_str) == Some("web_search_call"))
{
match item
.get("action")
.and_then(|action| action.get("type"))
.and_then(Value::as_str)
{
Some("search") => calls = calls.checked_add(1)?,
Some("open_page" | "find_in_page") => {}
Some(_) | None => return None,
}
}
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;
@@ -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,
},
@@ -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<WebSearchPricing>,
}
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<WebSearchPricing>,
) -> Self {
self.web_search_calls = calls;
self.web_search_pricing = pricing;
self
}
}
impl From<TokenUsage> 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<TokenRates> {
// 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<CalculatedCostBreakdown> {
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<ModelPricing> {
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<ModelPricing> {
.map(|rule| rule.pricing)
}
pub(crate) fn web_search_pricing(model: &str, tools: Option<&[Value]>) -> Option<WebSearchPricing> {
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<PricingTier> {
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<u128> {
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<TokenAmounts> {
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<u128> {
if percent == 0 {
return Some(0);
}
apply_percent(rates.input_ticks, percent)
}
fn effective_multiplier_percent(total: u128, standard: u128) -> Option<u32> {
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()
@@ -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]
@@ -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!(
@@ -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]