Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions packages/__tests__/cost/modelCostFromRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 48 additions & 9 deletions packages/cost/models/calculate-cost.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
};
}
}

Expand All @@ -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);
Expand Down