1116 lines
38 KiB
Swift
1116 lines
38 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import CodexBarCore
|
|
|
|
struct CostUsagePricingTests {
|
|
@Test
|
|
func `normalizes codex model variants exactly`() {
|
|
#expect(CostUsagePricing.normalizeCodexModel("openai/gpt-5-codex") == "gpt-5-codex")
|
|
#expect(CostUsagePricing.normalizeCodexModel("gpt-5.2-codex") == "gpt-5.2-codex")
|
|
#expect(CostUsagePricing.normalizeCodexModel("gpt-5.1-codex-max") == "gpt-5.1-codex-max")
|
|
#expect(CostUsagePricing.normalizeCodexModel("gpt-5.4-pro-2026-03-05") == "gpt-5.4-pro")
|
|
#expect(CostUsagePricing.normalizeCodexModel("gpt-5.4-mini-2026-03-17") == "gpt-5.4-mini")
|
|
#expect(CostUsagePricing.normalizeCodexModel("gpt-5.4-nano-2026-03-17") == "gpt-5.4-nano")
|
|
#expect(CostUsagePricing.normalizeCodexModel("gpt-5.5-2026-04-23") == "gpt-5.5")
|
|
#expect(CostUsagePricing.normalizeCodexModel("gpt-5.5-pro-2026-04-23") == "gpt-5.5-pro")
|
|
#expect(CostUsagePricing.normalizeCodexModel("gpt-5.3-codex-2026-03-05") == "gpt-5.3-codex")
|
|
#expect(CostUsagePricing.normalizeCodexModel("gpt-5.3-codex-spark") == "gpt-5.3-codex-spark")
|
|
#expect(CostUsagePricing.normalizeCodexModel("openai/gpt-5.6-sol") == "gpt-5.6-sol")
|
|
#expect(CostUsagePricing.normalizeCodexModel("openai/gpt-5.6-terra") == "gpt-5.6-terra")
|
|
#expect(CostUsagePricing.normalizeCodexModel("gpt-5.6-luna") == "gpt-5.6-luna")
|
|
#expect(CostUsagePricing.normalizeCodexModel("gpt-5.6") == "gpt-5.6-sol")
|
|
// Fictitious dated suffixes only exercise normalize stripping (not released snapshot IDs).
|
|
#expect(CostUsagePricing.normalizeCodexModel("gpt-5.6-sol-2099-01-01") == "gpt-5.6-sol")
|
|
#expect(CostUsagePricing.normalizeCodexModel("openai/gpt-5.6-terra-2099-01-01") == "gpt-5.6-terra")
|
|
}
|
|
|
|
@Test
|
|
func `unattributed codex usage stays unpriced despite a catalog collision`() throws {
|
|
let root = try Self.seedModelsDevCache("""
|
|
{
|
|
"openai": {
|
|
"id": "openai",
|
|
"models": {
|
|
"unknown": {
|
|
"id": "unknown",
|
|
"cost": { "input": 99, "output": 199 }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""")
|
|
|
|
let cost = CostUsagePricing.codexCostUSD(
|
|
model: CostUsagePricing.codexUnattributedModel,
|
|
inputTokens: 100,
|
|
cachedInputTokens: 10,
|
|
outputTokens: 5,
|
|
modelsDevCacheRoot: root)
|
|
|
|
#expect(cost == nil)
|
|
}
|
|
|
|
@Test
|
|
func `codex cost supports gpt51 codex max`() {
|
|
let cost = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.1-codex-max",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 10,
|
|
outputTokens: 5)
|
|
#expect(cost != nil)
|
|
}
|
|
|
|
@Test
|
|
func `codex cost supports gpt53 codex`() {
|
|
let cost = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.3-codex",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 10,
|
|
outputTokens: 5)
|
|
#expect(cost != nil)
|
|
}
|
|
|
|
@Test
|
|
func `codex cost supports gpt54 mini and nano`() {
|
|
let mini = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.4-mini-2026-03-17",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 10,
|
|
outputTokens: 5)
|
|
let nano = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.4-nano",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 10,
|
|
outputTokens: 5)
|
|
|
|
#expect(mini != nil)
|
|
#expect(nano != nil)
|
|
}
|
|
|
|
@Test
|
|
func `codex cost supports gpt55 bundled fallback`() throws {
|
|
let root = try Self.cacheRoot()
|
|
let cost = CostUsagePricing.codexCostUSD(
|
|
model: "openai/gpt-5.5-2026-04-23",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 10,
|
|
outputTokens: 5,
|
|
modelsDevCacheRoot: root)
|
|
|
|
// Codex `input_tokens` includes cached reads, so only the 90 non-cached tokens are
|
|
// billed at the input rate; the 10 cached tokens are billed at the cache rate.
|
|
let expected = (90.0 * 5e-6) + (10.0 * 5e-7) + (5.0 * 3e-5)
|
|
#expect(cost == expected)
|
|
}
|
|
|
|
@Test
|
|
func `codex cost supports gpt56 sol terra luna bundled fallback`() throws {
|
|
// Empty models.dev cache root forces the built-in table for GPT-5.6 tiers.
|
|
let root = try Self.cacheRoot()
|
|
|
|
let sol = CostUsagePricing.codexCostUSD(
|
|
model: "openai/gpt-5.6-sol",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 10,
|
|
outputTokens: 5,
|
|
modelsDevCacheRoot: root)
|
|
let terra = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.6-terra",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 10,
|
|
outputTokens: 5,
|
|
modelsDevCacheRoot: root)
|
|
let luna = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.6-luna",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 10,
|
|
outputTokens: 5,
|
|
modelsDevCacheRoot: root)
|
|
let alias = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.6",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 10,
|
|
outputTokens: 5,
|
|
modelsDevCacheRoot: root)
|
|
|
|
// Rates per token: Sol $5/$30 per 1M, Terra $2.50/$15, Luna $1/$6;
|
|
// cache read is 10% of input. Non-cached input is 90 tokens.
|
|
#expect(sol == (90.0 * 5e-6) + (10.0 * 5e-7) + (5.0 * 3e-5))
|
|
#expect(terra == (90.0 * 2.5e-6) + (10.0 * 2.5e-7) + (5.0 * 1.5e-5))
|
|
#expect(luna == (90.0 * 1e-6) + (10.0 * 1e-7) + (5.0 * 6e-6))
|
|
// Unsuffixed gpt-5.6 alias routes to Sol.
|
|
#expect(alias == sol)
|
|
}
|
|
|
|
@Test
|
|
func `codex models dev falls back from gpt56 alias to canonical sol pricing`() throws {
|
|
let canonicalOnlyRoot = try Self.seedModelsDevCache("""
|
|
{
|
|
"openai": {
|
|
"id": "openai",
|
|
"models": {
|
|
"gpt-5.6-sol": {
|
|
"id": "gpt-5.6-sol",
|
|
"cost": { "input": 7, "output": 31 }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""")
|
|
let aliasAndCanonicalRoot = try Self.seedModelsDevCache("""
|
|
{
|
|
"openai": {
|
|
"id": "openai",
|
|
"models": {
|
|
"gpt-5.6": {
|
|
"id": "gpt-5.6",
|
|
"cost": { "input": 3, "output": 13 }
|
|
},
|
|
"gpt-5.6-sol": {
|
|
"id": "gpt-5.6-sol",
|
|
"cost": { "input": 7, "output": 31 }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""")
|
|
|
|
let canonicalFallback = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.6",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 0,
|
|
outputTokens: 0,
|
|
modelsDevCacheRoot: canonicalOnlyRoot)
|
|
let exactAlias = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.6",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 0,
|
|
outputTokens: 0,
|
|
modelsDevCacheRoot: aliasAndCanonicalRoot)
|
|
|
|
#expect(canonicalFallback == 100.0 * 7e-6)
|
|
#expect(exactAlias == 100.0 * 3e-6)
|
|
}
|
|
|
|
@Test
|
|
func `codex pricing key distinguishes an empty long context block from no block`() throws {
|
|
let withoutLongContext = try Self.modelsDevArtifact("""
|
|
{
|
|
"openai": {
|
|
"id": "openai",
|
|
"models": {
|
|
"gpt-5.6-sol": {
|
|
"id": "gpt-5.6-sol",
|
|
"cost": { "input": 5, "output": 30 }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""")
|
|
let withEmptyLongContext = try Self.modelsDevArtifact("""
|
|
{
|
|
"openai": {
|
|
"id": "openai",
|
|
"models": {
|
|
"gpt-5.6-sol": {
|
|
"id": "gpt-5.6-sol",
|
|
"cost": {
|
|
"input": 5,
|
|
"output": 30,
|
|
"context_over_200k": {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""")
|
|
|
|
let withoutKey = CostUsagePricingKey.codex(
|
|
modelsDevArtifact: withoutLongContext,
|
|
formulaVersion: 1)
|
|
let withEmptyKey = CostUsagePricingKey.codex(
|
|
modelsDevArtifact: withEmptyLongContext,
|
|
formulaVersion: 1)
|
|
|
|
#expect(withoutKey != withEmptyKey)
|
|
}
|
|
|
|
@Test
|
|
func `codex cost applies gpt56 long context rates`() throws {
|
|
let root = try Self.cacheRoot()
|
|
let sol = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.6-sol",
|
|
inputTokens: 272_001,
|
|
cachedInputTokens: 10,
|
|
outputTokens: 10,
|
|
cacheWriteInputTokens: 20,
|
|
modelsDevCacheRoot: root)
|
|
let terra = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.6-terra",
|
|
inputTokens: 272_001,
|
|
cachedInputTokens: 10,
|
|
outputTokens: 10,
|
|
cacheWriteInputTokens: 20,
|
|
modelsDevCacheRoot: root)
|
|
let luna = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.6-luna",
|
|
inputTokens: 272_001,
|
|
cachedInputTokens: 10,
|
|
outputTokens: 10,
|
|
cacheWriteInputTokens: 20,
|
|
modelsDevCacheRoot: root)
|
|
|
|
// Long-context (>272K) rates apply to the entire request. Total input contains 10 cached,
|
|
// 20 cache-write, and 271,971 ordinary input tokens.
|
|
#expect(sol == (271_971.0 * 1e-5) + (10.0 * 1e-6) + (20.0 * 1.25e-5) + (10.0 * 4.5e-5))
|
|
#expect(terra == (271_971.0 * 5e-6) + (10.0 * 5e-7) + (20.0 * 6.25e-6) + (10.0 * 2.25e-5))
|
|
#expect(luna == (271_971.0 * 2e-6) + (10.0 * 2e-7) + (20.0 * 2.5e-6) + (10.0 * 9e-6))
|
|
}
|
|
|
|
@Test
|
|
func `codex cost bills gpt56 cache writes at one point two five x input`() throws {
|
|
let root = try Self.cacheRoot()
|
|
// Total prompt 100: 70 uncached + 20 cache-write + 10 cache-read.
|
|
let sol = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.6-sol",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 10,
|
|
outputTokens: 5,
|
|
cacheWriteInputTokens: 20,
|
|
modelsDevCacheRoot: root)
|
|
|
|
let expected = (70.0 * 5e-6) + (10.0 * 5e-7) + (20.0 * 6.25e-6) + (5.0 * 3e-5)
|
|
#expect(sol == expected)
|
|
}
|
|
|
|
@Test
|
|
func `codex priority cost supports gpt56 tiers`() {
|
|
let sol = CostUsagePricing.codexPriorityCostUSD(
|
|
model: "gpt-5.6-sol",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 20,
|
|
outputTokens: 10)
|
|
let terra = CostUsagePricing.codexPriorityCostUSD(
|
|
model: "gpt-5.6-terra",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 20,
|
|
outputTokens: 10)
|
|
let luna = CostUsagePricing.codexPriorityCostUSD(
|
|
model: "gpt-5.6-luna",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 20,
|
|
outputTokens: 10)
|
|
|
|
// Priority is 2x short-context rates (Sol input $10/1M, etc.).
|
|
#expect(sol == (80.0 * 1e-5) + (20.0 * 1e-6) + (10.0 * 6e-5))
|
|
#expect(terra == (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5))
|
|
#expect(luna == (80.0 * 2e-6) + (20.0 * 2e-7) + (10.0 * 1.2e-5))
|
|
}
|
|
|
|
@Test
|
|
func `codex priority cost uses explicit cache write rates`() {
|
|
let sol = CostUsagePricing.codexPriorityCostUSD(
|
|
model: "gpt-5.6-sol",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 10,
|
|
cacheWriteInputTokens: 20,
|
|
outputTokens: 5)
|
|
let terra = CostUsagePricing.codexPriorityCostUSD(
|
|
model: "gpt-5.6-terra",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 10,
|
|
cacheWriteInputTokens: 20,
|
|
outputTokens: 5)
|
|
let luna = CostUsagePricing.codexPriorityCostUSD(
|
|
model: "gpt-5.6-luna",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 10,
|
|
cacheWriteInputTokens: 20,
|
|
outputTokens: 5)
|
|
let modelWithoutCacheWriteSupport = CostUsagePricing.codexPriorityCostUSD(
|
|
model: "gpt-5.5",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 10,
|
|
cacheWriteInputTokens: 20,
|
|
outputTokens: 5)
|
|
|
|
#expect(sol == (70.0 * 1e-5) + (10.0 * 1e-6) + (20.0 * 1.25e-5) + (5.0 * 6e-5))
|
|
#expect(terra == (70.0 * 5e-6) + (10.0 * 5e-7) + (20.0 * 6.25e-6) + (5.0 * 3e-5))
|
|
#expect(luna == (70.0 * 2e-6) + (10.0 * 2e-7) + (20.0 * 2.5e-6) + (5.0 * 1.2e-5))
|
|
// A model without an explicit Priority cache-write price keeps the legacy input-rate fold.
|
|
#expect(
|
|
modelWithoutCacheWriteSupport ==
|
|
(90.0 * 1.25e-5) + (10.0 * 1.25e-6) + (5.0 * 7.5e-5))
|
|
}
|
|
|
|
@Test
|
|
func `codex cost applies gpt54 and gpt55 long context rates to full session`() throws {
|
|
let root = try Self.cacheRoot()
|
|
let gpt54 = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.4",
|
|
inputTokens: 272_001,
|
|
cachedInputTokens: 0,
|
|
outputTokens: 10,
|
|
modelsDevCacheRoot: root)
|
|
let gpt55 = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.5",
|
|
inputTokens: 272_001,
|
|
cachedInputTokens: 0,
|
|
outputTokens: 10,
|
|
modelsDevCacheRoot: root)
|
|
|
|
#expect(gpt54 == (272_001.0 * 5e-6) + (10.0 * 2.25e-5))
|
|
#expect(gpt55 == (272_001.0 * 1e-5) + (10.0 * 4.5e-5))
|
|
}
|
|
|
|
@Test
|
|
func `codex cost keeps normal rates at long context input boundary`() throws {
|
|
let root = try Self.cacheRoot()
|
|
let gpt55 = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.5",
|
|
inputTokens: 272_000,
|
|
cachedInputTokens: 0,
|
|
outputTokens: 128_000,
|
|
modelsDevCacheRoot: root)
|
|
|
|
#expect(gpt55 == (272_000.0 * 5e-6) + (128_000.0 * 3e-5))
|
|
}
|
|
|
|
@Test
|
|
func `codex cost applies long context rates to all cached and non cached input`() throws {
|
|
let root = try Self.cacheRoot()
|
|
let gpt55 = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.5",
|
|
inputTokens: 300_000,
|
|
cachedInputTokens: 200_000,
|
|
outputTokens: 10,
|
|
modelsDevCacheRoot: root)
|
|
|
|
// 200K cached reads are a subset of the 300K input, leaving 100K non-cached input.
|
|
let cached = 200_000.0 * 1e-6
|
|
let nonCached = 100_000.0 * 1e-5
|
|
let output = 10.0 * 4.5e-5
|
|
|
|
#expect(gpt55 == cached + nonCached + output)
|
|
}
|
|
|
|
@Test
|
|
func `codex cost clamps cache reads to input tokens`() throws {
|
|
// `cached_input_tokens` can never exceed `input_tokens` in real Codex data; if it does,
|
|
// clamp cached to input so the surplus is not invented and input is never double-billed.
|
|
let root = try Self.cacheRoot()
|
|
let gpt55 = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.5",
|
|
inputTokens: 20,
|
|
cachedInputTokens: 500,
|
|
outputTokens: 5,
|
|
modelsDevCacheRoot: root)
|
|
|
|
let expected = (20.0 * 5e-7) + (5.0 * 3e-5)
|
|
|
|
#expect(gpt55 == expected)
|
|
}
|
|
|
|
@Test
|
|
func `codex cost does not double bill cached input tokens`() throws {
|
|
// Regression for the cached double-count: input_tokens includes cached reads, so a turn
|
|
// with 1000 input / 900 cached must bill 100 tokens at the input rate and 900 at the
|
|
// cache rate — not the full 1000 at the input rate plus 900 again at the cache rate.
|
|
let root = try Self.cacheRoot()
|
|
let cost = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5-codex",
|
|
inputTokens: 1000,
|
|
cachedInputTokens: 900,
|
|
outputTokens: 10,
|
|
modelsDevCacheRoot: root)
|
|
|
|
let expected = (100.0 * 1.25e-6) + (900.0 * 1.25e-7) + (10.0 * 1e-5)
|
|
#expect(cost == expected)
|
|
}
|
|
|
|
@Test
|
|
func `codex priority cost applies model specific fast rates`() {
|
|
let gpt54 = CostUsagePricing.codexPriorityCostUSD(
|
|
model: "gpt-5.4",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 20,
|
|
outputTokens: 10)
|
|
let gpt55 = CostUsagePricing.codexPriorityCostUSD(
|
|
model: "gpt-5.5",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 20,
|
|
outputTokens: 10)
|
|
let gpt54Mini = CostUsagePricing.codexPriorityCostUSD(
|
|
model: "gpt-5.4-mini",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 20,
|
|
outputTokens: 10)
|
|
|
|
#expect(gpt54 == (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5))
|
|
#expect(gpt55 == (80.0 * 1.25e-5) + (20.0 * 1.25e-6) + (10.0 * 7.5e-5))
|
|
#expect(gpt54Mini == (80.0 * 1.5e-6) + (20.0 * 1.5e-7) + (10.0 * 9e-6))
|
|
}
|
|
|
|
@Test
|
|
func `codex priority cost is unavailable for long context requests`() {
|
|
let gpt55 = CostUsagePricing.codexPriorityCostUSD(
|
|
model: "gpt-5.5",
|
|
inputTokens: 272_001,
|
|
cachedInputTokens: 0,
|
|
outputTokens: 10)
|
|
let gpt56Sol = CostUsagePricing.codexPriorityCostUSD(
|
|
model: "gpt-5.6-sol",
|
|
inputTokens: 272_001,
|
|
cachedInputTokens: 0,
|
|
outputTokens: 10)
|
|
let gpt56Terra = CostUsagePricing.codexPriorityCostUSD(
|
|
model: "gpt-5.6-terra",
|
|
inputTokens: 272_001,
|
|
cachedInputTokens: 0,
|
|
outputTokens: 10)
|
|
let gpt56Luna = CostUsagePricing.codexPriorityCostUSD(
|
|
model: "gpt-5.6-luna",
|
|
inputTokens: 272_001,
|
|
cachedInputTokens: 0,
|
|
outputTokens: 10)
|
|
let gpt54Mini = CostUsagePricing.codexPriorityCostUSD(
|
|
model: "gpt-5.4-mini",
|
|
inputTokens: 272_001,
|
|
cachedInputTokens: 0,
|
|
outputTokens: 10)
|
|
|
|
#expect(gpt55 == nil)
|
|
#expect(gpt56Sol == nil)
|
|
#expect(gpt56Terra == nil)
|
|
#expect(gpt56Luna == nil)
|
|
#expect(gpt54Mini == nil)
|
|
}
|
|
|
|
@Test
|
|
func `codex priority cost counts only input tokens toward the limit`() {
|
|
let eligible = CostUsagePricing.codexPriorityCostUSD(
|
|
model: "gpt-5.5",
|
|
inputTokens: 200_000,
|
|
cachedInputTokens: 100_000,
|
|
outputTokens: 10)
|
|
let boundary = CostUsagePricing.codexPriorityCostUSD(
|
|
model: "gpt-5.5",
|
|
inputTokens: 272_000,
|
|
cachedInputTokens: 0,
|
|
outputTokens: 10)
|
|
let overLimit = CostUsagePricing.codexPriorityCostUSD(
|
|
model: "gpt-5.5",
|
|
inputTokens: 272_001,
|
|
cachedInputTokens: 0,
|
|
outputTokens: 10)
|
|
|
|
#expect(eligible == (100_000.0 * 1.25e-5) + (100_000.0 * 1.25e-6) + (10.0 * 7.5e-5))
|
|
#expect(boundary != nil)
|
|
#expect(overLimit == nil)
|
|
}
|
|
|
|
@Test
|
|
func `codex priority cost remains available at priority input boundary`() {
|
|
let gpt55 = CostUsagePricing.codexPriorityCostUSD(
|
|
model: "gpt-5.5",
|
|
inputTokens: 272_000,
|
|
cachedInputTokens: 0,
|
|
outputTokens: 10)
|
|
|
|
#expect(gpt55 == (272_000.0 * 1.25e-5) + (10.0 * 7.5e-5))
|
|
}
|
|
|
|
@Test
|
|
func `codex models dev pricing uses codex long context threshold`() throws {
|
|
let root = try Self.seedModelsDevCache("""
|
|
{
|
|
"openai": {
|
|
"id": "openai",
|
|
"models": {
|
|
"gpt-5.5": {
|
|
"id": "gpt-5.5",
|
|
"cost": {
|
|
"input": 5,
|
|
"output": 30,
|
|
"cache_read": 0.5,
|
|
"context_over_200k": {
|
|
"input": 10,
|
|
"output": 45,
|
|
"cache_read": 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""")
|
|
|
|
let atBoundary = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.5",
|
|
inputTokens: 272_000,
|
|
cachedInputTokens: 0,
|
|
outputTokens: 10,
|
|
modelsDevCacheRoot: root)
|
|
let aboveBoundary = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.5",
|
|
inputTokens: 272_001,
|
|
cachedInputTokens: 0,
|
|
outputTokens: 10,
|
|
modelsDevCacheRoot: root)
|
|
|
|
#expect(atBoundary == (272_000.0 * 5e-6) + (10.0 * 3e-5))
|
|
#expect(aboveBoundary == (272_001.0 * 1e-5) + (10.0 * 4.5e-5))
|
|
}
|
|
|
|
@Test
|
|
func `codex models dev cached fallback uses long context input rate when cache read is absent`() throws {
|
|
let root = try Self.seedModelsDevCache("""
|
|
{
|
|
"openai": {
|
|
"id": "openai",
|
|
"models": {
|
|
"gpt-5.5": {
|
|
"id": "gpt-5.5",
|
|
"cost": {
|
|
"input": 5,
|
|
"output": 30,
|
|
"context_over_200k": {
|
|
"input": 10,
|
|
"output": 45
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""")
|
|
|
|
let cost = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.5",
|
|
inputTokens: 300_000,
|
|
cachedInputTokens: 200_000,
|
|
outputTokens: 10,
|
|
modelsDevCacheRoot: root)
|
|
|
|
// The catalog has a long-context block but omits cache_read, so preserve its omission
|
|
// semantics: cached tokens fall back to the long-context input rate rather than mixing in
|
|
// one field from the bundled table.
|
|
let expected = (100_000.0 * 10e-6) + (200_000.0 * 10e-6) + (10.0 * 45e-6)
|
|
#expect(cost == expected)
|
|
}
|
|
}
|
|
|
|
extension CostUsagePricingTests {
|
|
@Test
|
|
func `codex models dev uses bundled short cache rates only when catalog omits them`() throws {
|
|
let missingRoot = try Self.seedModelsDevCache("""
|
|
{
|
|
"openai": {
|
|
"id": "openai",
|
|
"models": {
|
|
"gpt-5.6-sol": {
|
|
"id": "gpt-5.6-sol",
|
|
"cost": { "input": 5, "output": 30 }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""")
|
|
let explicitZeroRoot = try Self.seedModelsDevCache("""
|
|
{
|
|
"openai": {
|
|
"id": "openai",
|
|
"models": {
|
|
"gpt-5.6-sol": {
|
|
"id": "gpt-5.6-sol",
|
|
"cost": { "input": 5, "output": 30, "cache_read": 0, "cache_write": 0 }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""")
|
|
|
|
let missing = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.6-sol",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 10,
|
|
outputTokens: 0,
|
|
cacheWriteInputTokens: 20,
|
|
modelsDevCacheRoot: missingRoot)
|
|
let explicitZero = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.6-sol",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 10,
|
|
outputTokens: 0,
|
|
cacheWriteInputTokens: 20,
|
|
modelsDevCacheRoot: explicitZeroRoot)
|
|
|
|
#expect(missing == (70.0 * 5e-6) + (10.0 * 5e-7) + (20.0 * 6.25e-6))
|
|
#expect(explicitZero == 70.0 * 5e-6)
|
|
}
|
|
|
|
@Test
|
|
func `codex models dev falls back bundled long context rates when catalog omits them`() throws {
|
|
// Catalog has short-context rates only; bundled table supplies the 272K threshold + rates.
|
|
let root = try Self.seedModelsDevCache("""
|
|
{
|
|
"openai": {
|
|
"id": "openai",
|
|
"models": {
|
|
"gpt-5.6-sol": {
|
|
"id": "gpt-5.6-sol",
|
|
"cost": {
|
|
"input": 5,
|
|
"output": 30,
|
|
"cache_read": 0.5
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""")
|
|
|
|
let cost = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.6-sol",
|
|
inputTokens: 272_001,
|
|
cachedInputTokens: 0,
|
|
outputTokens: 10,
|
|
modelsDevCacheRoot: root)
|
|
|
|
// Without bundled above-threshold fallback this would bill short rates ($5/$30) despite
|
|
// entering long-context mode via the bundled threshold.
|
|
#expect(cost == (272_001.0 * 1e-5) + (10.0 * 4.5e-5))
|
|
}
|
|
|
|
@Test
|
|
func `codex models dev overrides every gpt56 long context token bucket`() throws {
|
|
let root = try Self.seedModelsDevCache("""
|
|
{
|
|
"openai": {
|
|
"id": "openai",
|
|
"models": {
|
|
"gpt-5.6-sol": {
|
|
"id": "gpt-5.6-sol",
|
|
"cost": {
|
|
"input": 1,
|
|
"output": 2,
|
|
"cache_read": 0.1,
|
|
"cache_write": 1.25,
|
|
"context_over_200k": {
|
|
"input": 11,
|
|
"output": 22,
|
|
"cache_read": 1.1,
|
|
"cache_write": 13.75
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""")
|
|
|
|
let cost = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.6-sol",
|
|
inputTokens: 272_001,
|
|
cachedInputTokens: 100_000,
|
|
outputTokens: 10,
|
|
cacheWriteInputTokens: 50000,
|
|
modelsDevCacheRoot: root)
|
|
|
|
let expected = (122_001.0 * 11e-6)
|
|
+ (100_000.0 * 1.1e-6)
|
|
+ (50000.0 * 13.75e-6)
|
|
+ (10.0 * 22e-6)
|
|
#expect(cost == expected)
|
|
}
|
|
|
|
@Test
|
|
func `codex cost supports gpt55 pro bundled fallback`() throws {
|
|
let root = try Self.cacheRoot()
|
|
let cost = CostUsagePricing.codexCostUSD(
|
|
model: "openai/gpt-5.5-pro-2026-04-23",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 10,
|
|
outputTokens: 5,
|
|
modelsDevCacheRoot: root)
|
|
|
|
// gpt-5.5-pro has no cache-read rate, so cached falls back to the input rate; with 90
|
|
// non-cached + 10 cached priced at the same rate this is 100 tokens at 3e-5.
|
|
let expected = (100.0 * 3e-5) + (5.0 * 1.8e-4)
|
|
#expect(cost == expected)
|
|
}
|
|
|
|
@Test
|
|
func `codex cost returns zero for research preview fallback model`() throws {
|
|
let root = try Self.cacheRoot()
|
|
let cost = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.3-codex-spark",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 10,
|
|
outputTokens: 5,
|
|
modelsDevCacheRoot: root)
|
|
#expect(cost == 0)
|
|
#expect(CostUsagePricing.codexDisplayLabel(model: "gpt-5.3-codex-spark") == "Research Preview")
|
|
#expect(CostUsagePricing.codexDisplayLabel(model: "gpt-5.2-codex") == nil)
|
|
}
|
|
|
|
@Test
|
|
func `codex cost prefers models dev cache over bundled fallback`() throws {
|
|
let root = try Self.seedModelsDevCache("""
|
|
{
|
|
"openai": {
|
|
"id": "openai",
|
|
"models": {
|
|
"gpt-5.5": {
|
|
"id": "gpt-5.5",
|
|
"cost": { "input": 10, "output": 20, "cache_read": 1 }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""")
|
|
|
|
let cost = CostUsagePricing.codexCostUSD(
|
|
model: "openai/gpt-5.5",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 10,
|
|
outputTokens: 5,
|
|
modelsDevCacheRoot: root)
|
|
|
|
let expected = (90.0 * 10e-6) + (10.0 * 1e-6) + (5.0 * 20e-6)
|
|
#expect(cost == expected)
|
|
}
|
|
|
|
@Test
|
|
func `codex cost lets models dev override research preview fallback`() throws {
|
|
let root = try Self.seedModelsDevCache("""
|
|
{
|
|
"openai": {
|
|
"id": "openai",
|
|
"models": {
|
|
"gpt-5.3-codex-spark": {
|
|
"id": "gpt-5.3-codex-spark",
|
|
"cost": { "input": 2, "output": 8, "cache_read": 0.2 }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""")
|
|
|
|
let cost = CostUsagePricing.codexCostUSD(
|
|
model: "gpt-5.3-codex-spark",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 10,
|
|
outputTokens: 5,
|
|
modelsDevCacheRoot: root)
|
|
|
|
let expected = (90.0 * 2e-6) + (10.0 * 0.2e-6) + (5.0 * 8e-6)
|
|
#expect(cost == expected)
|
|
#expect(CostUsagePricing.codexDisplayLabel(model: "gpt-5.3-codex-spark") == "Research Preview")
|
|
}
|
|
|
|
@Test
|
|
func `codex cost falls back to bundled pricing when models dev misses provider model`() throws {
|
|
let root = try Self.seedModelsDevCache("""
|
|
{
|
|
"anthropic": {
|
|
"id": "anthropic",
|
|
"models": {
|
|
"gpt-5.5": {
|
|
"id": "gpt-5.5",
|
|
"cost": { "input": 10, "output": 20, "cache_read": 1 }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""")
|
|
|
|
let cost = CostUsagePricing.codexCostUSD(
|
|
model: "openai/gpt-5.5",
|
|
inputTokens: 100,
|
|
cachedInputTokens: 10,
|
|
outputTokens: 5,
|
|
modelsDevCacheRoot: root)
|
|
|
|
let expected = (90.0 * 5e-6) + (10.0 * 5e-7) + (5.0 * 3e-5)
|
|
#expect(cost == expected)
|
|
}
|
|
|
|
@Test
|
|
func `normalizes claude opus41 dated variants`() {
|
|
#expect(CostUsagePricing.normalizeClaudeModel("claude-opus-4-1-20250805") == "claude-opus-4-1")
|
|
}
|
|
|
|
@Test
|
|
func `claude cost supports opus41 dated variant`() {
|
|
let cost = CostUsagePricing.claudeCostUSD(
|
|
model: "claude-opus-4-1-20250805",
|
|
inputTokens: 10,
|
|
cacheReadInputTokens: 0,
|
|
cacheCreationInputTokens: 0,
|
|
outputTokens: 5)
|
|
#expect(cost != nil)
|
|
}
|
|
|
|
@Test
|
|
func `claude cost supports opus46 dated variant`() {
|
|
let cost = CostUsagePricing.claudeCostUSD(
|
|
model: "claude-opus-4-6-20260205",
|
|
inputTokens: 10,
|
|
cacheReadInputTokens: 0,
|
|
cacheCreationInputTokens: 0,
|
|
outputTokens: 5)
|
|
#expect(cost != nil)
|
|
}
|
|
|
|
@Test
|
|
func `claude cost supports opus47`() {
|
|
let cost = CostUsagePricing.claudeCostUSD(
|
|
model: "claude-opus-4-7",
|
|
inputTokens: 10,
|
|
cacheReadInputTokens: 0,
|
|
cacheCreationInputTokens: 0,
|
|
outputTokens: 5)
|
|
let expected = (10.0 * 5e-6) + (5.0 * 2.5e-5)
|
|
#expect(cost == expected)
|
|
}
|
|
|
|
@Test
|
|
func `claude cost supports opus48`() throws {
|
|
// Point at a fresh, empty cache root so the models.dev lookup misses and this
|
|
// exercises the built-in fallback table specifically — not a local cache hit.
|
|
let emptyCacheRoot = try Self.cacheRoot()
|
|
let cost = CostUsagePricing.claudeCostUSD(
|
|
model: "claude-opus-4-8",
|
|
inputTokens: 10,
|
|
cacheReadInputTokens: 0,
|
|
cacheCreationInputTokens: 0,
|
|
outputTokens: 5,
|
|
modelsDevCacheRoot: emptyCacheRoot)
|
|
let expected = (10.0 * 5e-6) + (5.0 * 2.5e-5)
|
|
#expect(cost == expected)
|
|
}
|
|
|
|
@Test
|
|
func `claude cost supports fable5 bundled fallback`() throws {
|
|
let emptyCacheRoot = try Self.cacheRoot()
|
|
let cost = CostUsagePricing.claudeCostUSD(
|
|
model: "claude-fable-5",
|
|
inputTokens: 100,
|
|
cacheReadInputTokens: 20,
|
|
cacheCreationInputTokens: 10,
|
|
outputTokens: 5,
|
|
modelsDevCacheRoot: emptyCacheRoot)
|
|
let expected = (100.0 * 1e-5) + (20.0 * 1e-6) + (10.0 * 1.25e-5) + (5.0 * 5e-5)
|
|
#expect(cost == expected)
|
|
}
|
|
|
|
@Test
|
|
func `claude cost preserves historical sonnet46 long context pricing`() throws {
|
|
let emptyCacheRoot = try Self.cacheRoot()
|
|
let historical = CostUsagePricing.claudeCostUSD(
|
|
model: "claude-sonnet-4-6",
|
|
inputTokens: 240_000,
|
|
cacheReadInputTokens: 0,
|
|
cacheCreationInputTokens: 0,
|
|
outputTokens: 0,
|
|
pricingDate: Date(timeIntervalSince1970: 1_773_359_999),
|
|
modelsDevCacheRoot: emptyCacheRoot)
|
|
let current = CostUsagePricing.claudeCostUSD(
|
|
model: "claude-sonnet-4-6",
|
|
inputTokens: 240_000,
|
|
cacheReadInputTokens: 0,
|
|
cacheCreationInputTokens: 0,
|
|
outputTokens: 0,
|
|
pricingDate: Date(timeIntervalSince1970: 1_773_360_000),
|
|
modelsDevCacheRoot: emptyCacheRoot)
|
|
|
|
#expect(historical == 1.44)
|
|
#expect(current == 0.72)
|
|
}
|
|
|
|
@Test
|
|
func `claude cost ignores stale sonnet46 threshold catalog after cutover`() throws {
|
|
let cacheRoot = try Self.seedModelsDevCache("""
|
|
{
|
|
"anthropic": {
|
|
"id": "anthropic",
|
|
"models": {
|
|
"claude-sonnet-4-6": {
|
|
"id": "claude-sonnet-4-6",
|
|
"cost": {
|
|
"input": 3,
|
|
"output": 15,
|
|
"cache_read": 0.3,
|
|
"cache_write": 3.75,
|
|
"context_over_200k": {
|
|
"input": 6,
|
|
"output": 22.5,
|
|
"cache_read": 0.6,
|
|
"cache_write": 7.5
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""")
|
|
let cost = CostUsagePricing.claudeCostUSD(
|
|
model: "claude-sonnet-4-6",
|
|
inputTokens: 240_000,
|
|
cacheReadInputTokens: 0,
|
|
cacheCreationInputTokens: 0,
|
|
outputTokens: 0,
|
|
pricingDate: Date(timeIntervalSince1970: 1_773_360_000),
|
|
modelsDevCacheRoot: cacheRoot)
|
|
|
|
#expect(cost == 0.72)
|
|
}
|
|
|
|
@Test
|
|
func `claude cost prices one hour cache writes separately`() throws {
|
|
let emptyCacheRoot = try Self.cacheRoot()
|
|
let cost = CostUsagePricing.claudeCostUSD(
|
|
model: "claude-fable-5",
|
|
inputTokens: 100,
|
|
cacheReadInputTokens: 20,
|
|
cacheCreationInputTokens: 30,
|
|
cacheCreationInputTokens1h: 20,
|
|
outputTokens: 5,
|
|
modelsDevCacheRoot: emptyCacheRoot)
|
|
let expected = (100.0 * 1e-5)
|
|
+ (20.0 * 1e-6)
|
|
+ (10.0 * 1.25e-5)
|
|
+ (20.0 * 2e-5)
|
|
+ (5.0 * 5e-5)
|
|
#expect(cost == expected)
|
|
}
|
|
|
|
@Test
|
|
func `claude cost applies long context rates across cache write durations`() throws {
|
|
let cacheRoot = try Self.seedModelsDevCache("""
|
|
{
|
|
"anthropic": {
|
|
"id": "anthropic",
|
|
"models": {
|
|
"claude-threshold-model": {
|
|
"id": "claude-threshold-model",
|
|
"cost": {
|
|
"input": 3,
|
|
"output": 15,
|
|
"cache_read": 0.3,
|
|
"cache_write": 3.75,
|
|
"context_over_200k": {
|
|
"input": 6,
|
|
"output": 22.5,
|
|
"cache_read": 0.6,
|
|
"cache_write": 7.5
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""")
|
|
let cost = CostUsagePricing.claudeCostUSD(
|
|
model: "claude-threshold-model",
|
|
inputTokens: 0,
|
|
cacheReadInputTokens: 0,
|
|
cacheCreationInputTokens: 240_000,
|
|
cacheCreationInputTokens1h: 120_000,
|
|
outputTokens: 0,
|
|
modelsDevCacheRoot: cacheRoot)
|
|
let expected = (120_000.0 * 12e-6)
|
|
+ (120_000.0 * 7.5e-6)
|
|
#expect(cost == expected)
|
|
}
|
|
|
|
@Test
|
|
func `claude sonnet46 uses standard pricing across full context`() throws {
|
|
let emptyCacheRoot = try Self.cacheRoot()
|
|
let cost = CostUsagePricing.claudeCostUSD(
|
|
model: "claude-sonnet-4-6",
|
|
inputTokens: 0,
|
|
cacheReadInputTokens: 0,
|
|
cacheCreationInputTokens: 240_000,
|
|
outputTokens: 0,
|
|
modelsDevCacheRoot: emptyCacheRoot)
|
|
#expect(cost == 240_000.0 * 3.75e-6)
|
|
}
|
|
|
|
@Test
|
|
func `claude cost returns nil for unknown models`() {
|
|
let cost = CostUsagePricing.claudeCostUSD(
|
|
model: "glm-4.6",
|
|
inputTokens: 100,
|
|
cacheReadInputTokens: 500,
|
|
cacheCreationInputTokens: 0,
|
|
outputTokens: 40)
|
|
#expect(cost == nil)
|
|
}
|
|
|
|
@Test
|
|
func `claude cost prefers models dev cache with threshold pricing`() throws {
|
|
let root = try Self.seedModelsDevCache("""
|
|
{
|
|
"anthropic": {
|
|
"id": "anthropic",
|
|
"models": {
|
|
"claude-sonnet-4-6": {
|
|
"id": "claude-sonnet-4-6",
|
|
"cost": {
|
|
"input": 3,
|
|
"output": 15,
|
|
"cache_read": 0.3,
|
|
"cache_write": 3.75,
|
|
"context_over_200k": {
|
|
"input": 6,
|
|
"output": 22.5,
|
|
"cache_read": 0.6,
|
|
"cache_write": 7.5
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""")
|
|
|
|
let cost = CostUsagePricing.claudeCostUSD(
|
|
model: "claude-sonnet-4-6",
|
|
inputTokens: 200_010,
|
|
cacheReadInputTokens: 5,
|
|
cacheCreationInputTokens: 5,
|
|
outputTokens: 5,
|
|
modelsDevCacheRoot: root)
|
|
|
|
let expected = (200_010.0 * 6e-6)
|
|
+ (5.0 * 0.6e-6)
|
|
+ (5.0 * 7.5e-6)
|
|
+ (5.0 * 22.5e-6)
|
|
#expect(cost == expected)
|
|
}
|
|
|
|
private static func seedModelsDevCache(_ json: String) throws -> URL {
|
|
let root = try Self.cacheRoot()
|
|
let catalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8))
|
|
ModelsDevCache.save(catalog: catalog, fetchedAt: Date(), cacheRoot: root)
|
|
return root
|
|
}
|
|
|
|
private static func modelsDevArtifact(_ json: String) throws -> ModelsDevCacheArtifact {
|
|
let catalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8))
|
|
return ModelsDevCacheArtifact(
|
|
version: ModelsDevCache.artifactVersion,
|
|
fetchedAt: Date(timeIntervalSince1970: 0),
|
|
catalog: catalog)
|
|
}
|
|
|
|
private static func cacheRoot() throws -> URL {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-pricing-tests-\(UUID().uuidString)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
return root
|
|
}
|
|
}
|