diff --git a/packages/__tests__/cost/modelCostFromRegistry.test.ts b/packages/__tests__/cost/modelCostFromRegistry.test.ts index 73aa4f6602..431988acfd 100644 --- a/packages/__tests__/cost/modelCostFromRegistry.test.ts +++ b/packages/__tests__/cost/modelCostFromRegistry.test.ts @@ -411,6 +411,169 @@ describe("modelCostBreakdownFromRegistry", () => { } }); + it("should use higher tier pricing for OpenAI GPT-5.4 over 272K tokens", () => { + const modelUsage: ModelUsage = { + input: 300000, // over the 272K threshold + output: 50000, + }; + + const breakdown = modelCostBreakdownFromRegistry({ + modelUsage, + providerModelId: "gpt-5.4", + provider: "openai" as ModelProviderName, + }); + + expect(breakdown).not.toBeNull(); + if (breakdown) { + // Higher tier: $5/M input, $22.50/M output + expect(breakdown.inputCost).toBe(300000 * 0.000005); + expect(breakdown.outputCost).toBe(50000 * 0.0000225); + expect(breakdown.totalCost).toBeCloseTo(2.625, 10); + } + }); + + it("should keep base tier pricing for OpenAI GPT-5.4 under 272K tokens", () => { + const modelUsage: ModelUsage = { + input: 200000, + output: 50000, + cacheDetails: { + cachedInput: 10000, + }, + }; + + const breakdown = modelCostBreakdownFromRegistry({ + modelUsage, + providerModelId: "gpt-5.4", + provider: "openai" as ModelProviderName, + }); + + expect(breakdown).not.toBeNull(); + if (breakdown) { + // Base tier: $2.50/M input, $15/M output, cachedInput multiplier 0.1 + expect(breakdown.inputCost).toBe(200000 * 0.0000025); + expect(breakdown.outputCost).toBe(50000 * 0.000015); + expect(breakdown.cachedInputCost).toBe(10000 * 0.0000025 * 0.1); + } + }); + + it("should count cached input toward the GPT-5.4 threshold", () => { + const modelUsage: ModelUsage = { + input: 200000, + output: 1000, + cacheDetails: { + // input alone is under 272K; input + cachedInput is over it + cachedInput: 100000, + }, + }; + + const breakdown = modelCostBreakdownFromRegistry({ + modelUsage, + providerModelId: "gpt-5.4", + provider: "openai" as ModelProviderName, + }); + + expect(breakdown).not.toBeNull(); + if (breakdown) { + expect(breakdown.inputCost).toBe(200000 * 0.000005); + expect(breakdown.outputCost).toBe(1000 * 0.0000225); + expect(breakdown.cachedInputCost).toBe(100000 * 0.000005 * 0.1); + } + }); + + it.each([ + ["azure", 0.000005, 0.0000225], + ["helicone", 0.000005, 0.0000225], + ["openrouter", 0.000005_275, 0.000023_7375], + ])( + "should use higher tier pricing for GPT-5.4 on %s over 272K tokens", + (provider, inputRate, outputRate) => { + const modelUsage: ModelUsage = { + input: 300000, + output: 50000, + }; + + const breakdown = modelCostBreakdownFromRegistry({ + modelUsage, + providerModelId: + provider === "openrouter" ? "openai/gpt-5.4" : provider === "helicone" ? "pa/gpt-5.4" : "gpt-5.4", + provider: provider as ModelProviderName, + }); + + expect(breakdown).not.toBeNull(); + if (breakdown) { + expect(breakdown.inputCost).toBeCloseTo(300000 * inputRate, 10); + expect(breakdown.outputCost).toBeCloseTo(50000 * outputRate, 10); + } + } + ); + + it("should apply the Anthropic threshold rule to Claude Sonnet 4 on Bedrock", () => { + const modelUsage: ModelUsage = { + input: 150000, + output: 10000, + cacheDetails: { + // Anthropic counts cache writes as prompt, so 150K + 30K + 40K is over 200K + cachedInput: 30000, + write5m: 40000, + }, + }; + + const breakdown = modelCostBreakdownFromRegistry({ + modelUsage, + providerModelId: "anthropic.claude-sonnet-4-20250514-v1:0", + provider: "bedrock" as ModelProviderName, + }); + + expect(breakdown).not.toBeNull(); + if (breakdown) { + // Higher tier: $6/M input, $22.50/M output + expect(breakdown.inputCost).toBe(150000 * 0.000006); + expect(breakdown.outputCost).toBe(10000 * 0.0000225); + } + }); + + it("should keep base tier pricing for a Bedrock request under the threshold", () => { + const modelUsage: ModelUsage = { + input: 100000, + output: 10000, + cacheDetails: { + cachedInput: 10000, + }, + }; + + const breakdown = modelCostBreakdownFromRegistry({ + modelUsage, + providerModelId: "anthropic.claude-sonnet-4-20250514-v1:0", + provider: "bedrock" as ModelProviderName, + }); + + expect(breakdown).not.toBeNull(); + if (breakdown) { + expect(breakdown.inputCost).toBe(100000 * 0.000003); + expect(breakdown.outputCost).toBe(10000 * 0.000015); + expect(breakdown.cachedInputCost).toBe(10000 * 0.000003 * 0.1); + } + }); + + it("should leave single-tier model pricing unchanged", () => { + const modelUsage: ModelUsage = { + input: 500000, + output: 1000, + }; + + const breakdown = modelCostBreakdownFromRegistry({ + modelUsage, + providerModelId: "gpt-4o", + provider: "openai" as ModelProviderName, + }); + + expect(breakdown).not.toBeNull(); + if (breakdown) { + expect(breakdown.inputCost).toBe(500000 * 0.0000025); + expect(breakdown.outputCost).toBe(1000 * 0.00001); + } + }); + it("should handle Vertex Gemini 3 Pro with threshold pricing", () => { const modelUsage: ModelUsage = { input: 250000, // Over 200K threshold diff --git a/packages/cost/models/calculate-cost.ts b/packages/cost/models/calculate-cost.ts index 860396a924..dc95436d1f 100644 --- a/packages/cost/models/calculate-cost.ts +++ b/packages/cost/models/calculate-cost.ts @@ -1,5 +1,5 @@ import type { ModelUsage, ModalityUsage } from "../usage/types"; -import type { ModelProviderConfig, ModelPricing, ModalityPricing } from "./types"; +import type { AuthorName, ModelProviderConfig, ModelPricing, ModalityPricing } from "./types"; import type { ModelProviderName } from "./providers"; import { registry } from "./registry"; @@ -111,7 +111,24 @@ function getPricingTier( return preprocessedPricing[matchedTierIndex]; } -function getThresholdValueFunction(provider: ModelProviderName): (usage: ModelUsage, field: CostBreakdownField) => number { +// total prompt length: fresh input plus anything read from cache +function promptLength(usage: ModelUsage): number { + return usage.input + (usage.cacheDetails?.cachedInput ?? 0); +} + +// Anthropic bills cache writes as part of the prompt, so they count toward the threshold +function anthropicPromptLength(usage: ModelUsage): number { + return ( + promptLength(usage) + + (usage.cacheDetails?.write5m ?? 0) + + (usage.cacheDetails?.write1h ?? 0) + ); +} + +function getThresholdValueFunction( + provider: ModelProviderName, + author: AuthorName, +): (usage: ModelUsage, field: CostBreakdownField) => number { switch (provider) { case "vertex": return (usage: ModelUsage, field: CostBreakdownField) => { @@ -142,10 +159,7 @@ function getThresholdValueFunction(provider: ModelProviderName): (usage: ModelUs switch (field) { case "inputCost": case "outputCost": - return usage.input + - (usage.cacheDetails?.cachedInput ?? 0) + - (usage.cacheDetails?.write5m ?? 0) + - (usage.cacheDetails?.write1h ?? 0); + return anthropicPromptLength(usage); default: return 0; } @@ -156,13 +170,38 @@ function getThresholdValueFunction(provider: ModelProviderName): (usage: ModelUs case "inputCost": case "outputCost": case "cachedInputCost": - return usage.input + (usage.cacheDetails?.cachedInput ?? 0); + return promptLength(usage); default: return 0; } } default: - return () => 0; + // Everything else (openai, azure, openrouter, helicone, bedrock, ...) tiers on + // the request's prompt length. These providers resell models from several + // authors, so an Anthropic-authored model keeps the Anthropic rule wherever it + // is served from. Providers whose models declare a single tier are unaffected: + // the only tier has threshold 0, so any value selects it. + if (author === "anthropic") { + return (usage: ModelUsage, field: CostBreakdownField) => { + switch (field) { + case "inputCost": + case "outputCost": + return anthropicPromptLength(usage); + default: + return 0; + } + }; + } + return (usage: ModelUsage, field: CostBreakdownField) => { + switch (field) { + case "inputCost": + case "outputCost": + case "cachedInputCost": + return promptLength(usage); + default: + return 0; + } + }; } } @@ -186,7 +225,7 @@ export function calculateModelCostBreakdown(params: { // e.g Anthropic's inputCost and output cost is higher if PROMPT >= X tokens // e.g Vertex's inputCost is higher if INPUT >= X tokens, but cachedInputCost is higher if CACHED_INPUT >= X tokens // getThresholdValue is a function that will return the value to compare to X - const getThresholdValue = getThresholdValueFunction(provider); + const getThresholdValue = getThresholdValueFunction(provider, config.author); const sortedPricing = [...config.pricing].sort((a, b) => a.threshold - b.threshold); // Preprocess pricing tiers once upfront to fill missing fields from previous tiers const preprocessedPricing = preprocessPricingTiers(sortedPricing);