mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix(openai): align billing with official pricing
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user