Skip to content
Merged
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
448 changes: 208 additions & 240 deletions cloud/api/router/cost-calculator.test.ts

Large diffs are not rendered by default.

64 changes: 22 additions & 42 deletions cloud/api/router/cost-calculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,11 @@ export abstract class BaseCostCalculator {
* Extracts token usage from a provider response.
*
* Must be implemented by each provider-specific calculator.
*
* @param body - The parsed provider response body
* @returns Validated TokenUsage with non-negative numbers, or null if extraction fails
*/
protected abstract extractUsage(body: unknown): TokenUsage | null;
public abstract extractUsage(body: unknown): TokenUsage | null;

/**
* Calculates cache write cost from provider-specific breakdown.
Expand Down Expand Up @@ -73,47 +76,27 @@ export abstract class BaseCostCalculator {
): TokenUsage | null;

/**
* Main entry point: calculates usage and cost for a request.
* Calculates cost from TokenUsage using models.dev pricing data.
*
* @param modelId - The model ID from the request
* @param responseBody - The parsed provider response body
* @returns Effect with usage and cost data (in centi-cents), or null if usage unavailable
* @param modelId - The model ID
* @param usage - Token usage data from the provider response
* @returns Effect with cost breakdown (in centi-cents), or null if pricing unavailable
*/
public calculate(
modelId: string,
responseBody: unknown,
): Effect.Effect<
{
usage: TokenUsage;
cost: CostBreakdown;
} | null,
Error
> {
usage: TokenUsage,
): Effect.Effect<CostBreakdown | null, Error> {
return Effect.gen(this, function* () {
// Extract usage from response
const usage = this.extractUsage(responseBody);
if (!usage) {
return null;
}

// Get pricing data (null if unavailable)
const pricing = yield* getModelPricing(this.provider, modelId).pipe(
Effect.catchAll(() => Effect.succeed(null)),
);

if (!pricing) {
// Return usage without cost data
return {
usage,
cost: {
inputCost: 0n,
outputCost: 0n,
totalCost: 0n,
},
};
return null;
}

// Calculate cost in centi-cents
// Calculate costs in centi-cents using BIGINT (pricing is centi-cents per million tokens)
const inputCost =
(BigInt(usage.inputTokens) * pricing.input) / 1_000_000n;
const outputCost =
Expand All @@ -135,14 +118,11 @@ export abstract class BaseCostCalculator {
inputCost + outputCost + (cacheReadCost || 0n) + (cacheWriteCost || 0n);

return {
usage,
cost: {
inputCost,
outputCost,
cacheReadCost,
cacheWriteCost,
totalCost,
},
inputCost,
outputCost,
cacheReadCost,
cacheWriteCost,
totalCost,
};
});
}
Expand All @@ -160,7 +140,7 @@ export class OpenAICostCalculator extends BaseCostCalculator {
super("openai");
}

protected extractUsage(body: unknown): TokenUsage | null {
public extractUsage(body: unknown): TokenUsage | null {
if (typeof body !== "object" || body === null) return null;

const bodyObj = body as Record<string, unknown>;
Expand Down Expand Up @@ -245,7 +225,7 @@ export class AnthropicCostCalculator extends BaseCostCalculator {
// Pricing multiplier: 1h / 5m = 2.0 / 1.25 = 1.6
static readonly EPHEMERAL_1H_MULTIPLIER = 1.6;

protected extractUsage(body: unknown): TokenUsage | null {
public extractUsage(body: unknown): TokenUsage | null {
if (typeof body !== "object" || body === null) return null;

const usage = (
Expand Down Expand Up @@ -285,8 +265,8 @@ export class AnthropicCostCalculator extends BaseCostCalculator {
return {
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
cacheReadTokens,
cacheWriteTokens,
cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined,
cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined,
cacheWriteBreakdown,
};
}
Expand Down Expand Up @@ -346,7 +326,7 @@ export class GoogleCostCalculator extends BaseCostCalculator {
super("google");
}

protected extractUsage(body: unknown): TokenUsage | null {
public extractUsage(body: unknown): TokenUsage | null {
if (typeof body !== "object" || body === null) return null;

const bodyObj = body as Record<string, unknown>;
Expand Down
127 changes: 50 additions & 77 deletions cloud/api/router/pricing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
fetchModelsDotDevPricingData,
getModelsDotDevPricingData,
getModelPricing,
calculateCost,
clearPricingCache,
} from "@/api/router/pricing";

Expand Down Expand Up @@ -224,93 +223,67 @@ describe("Pricing", () => {

expect(result).toBeNull();
});
});

describe("calculateCost", () => {
it("should calculate cost correctly for basic usage in centi-cents", () => {
// Pricing in centi-cents per million tokens
// $0.15 per million = 1500cc, $0.60 per million = 6000cc
const pricing = {
input: 1500n,
output: 6000n,
};

const usage = {
inputTokens: 1000,
outputTokens: 500,
};

const result = calculateCost(pricing, usage);

// 1000 tokens / 1M * 1500cc = 1.5cc (rounds to 1)
expect(result.inputCost).toBe(1n);
// 500 tokens / 1M * 6000cc = 3cc
expect(result.outputCost).toBe(3n);
expect(result.totalCost).toBe(4n);
expect(result.cacheReadCost).toBeUndefined();
expect(result.cacheWriteCost).toBeUndefined();
});

it("should calculate cost with cache tokens in centi-cents", () => {
const pricing = {
input: 1500n,
output: 6000n,
cache_read: 750n,
cache_write: 1875n,
};

const usage = {
inputTokens: 1000,
outputTokens: 500,
cacheReadTokens: 200,
cacheWriteTokens: 100,
};

const result = calculateCost(pricing, usage);

expect(result.inputCost).toBe(1n); // 1000 / 1M * 1500cc = 1.5cc -> 1cc
expect(result.outputCost).toBe(3n); // 500 / 1M * 6000cc = 3cc
expect(result.cacheReadCost).toBe(0n); // 200 / 1M * 750cc = 0.15cc -> 0cc
expect(result.cacheWriteCost).toBe(0n); // 100 / 1M * 1875cc = 0.1875cc -> 0cc
expect(result.totalCost).toBe(4n); // 1 + 3 + 0 + 0
});

it("should handle missing cache pricing", () => {
const pricing = {
input: 1500n,
output: 6000n,
it("should return null for model with invalid cache pricing (NaN)", async () => {
const mockData = {
openai: {
id: "openai",
name: "OpenAI",
models: {
"invalid-model": {
id: "invalid-model",
name: "Invalid Model",
cost: {
input: 0.15,
output: 0.6,
cache_read: NaN, // Invalid cache pricing
},
},
},
},
};

const usage = {
inputTokens: 1000,
outputTokens: 500,
cacheReadTokens: 200,
cacheWriteTokens: 100,
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockData),
}) as unknown as typeof fetch;

const result = calculateCost(pricing, usage);
const result = await Effect.runPromise(
getModelPricing("openai", "invalid-model"),
);

expect(result.cacheReadCost).toBeUndefined();
expect(result.cacheWriteCost).toBeUndefined();
expect(result.totalCost).toBe(4n);
expect(result).toBeNull();
});

it("should handle zero tokens", () => {
const pricing = {
input: 1500n,
output: 6000n,
it("should return null for model with negative cache pricing", async () => {
const mockData = {
openai: {
id: "openai",
name: "OpenAI",
models: {
"invalid-model": {
id: "invalid-model",
name: "Invalid Model",
cost: {
input: 0.15,
output: 0.6,
cache_write: -0.5, // Negative cache pricing
},
},
},
},
};

const usage = {
inputTokens: 0,
outputTokens: 0,
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockData),
}) as unknown as typeof fetch;

const result = calculateCost(pricing, usage);
const result = await Effect.runPromise(
getModelPricing("openai", "invalid-model"),
);

expect(result.inputCost).toBe(0n);
expect(result.outputCost).toBe(0n);
expect(result.totalCost).toBe(0n);
expect(result).toBeNull();
});
});

Expand Down
68 changes: 24 additions & 44 deletions cloud/api/router/pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,31 @@ export function getModelsDotDevPricingData() {

/**
* Converts dollar-based pricing to centi-cent pricing.
* Returns null if pricing contains invalid values (NaN, negative, etc.).
*/
function convertPricingToCenticents(
pricing: ModelPricingDollars,
): ModelPricing {
): ModelPricing | null {
// Validate input and output (required fields)
if (
!Number.isFinite(pricing.input) ||
!Number.isFinite(pricing.output) ||
pricing.input < 0 ||
pricing.output < 0
) {
return null;
}

// Validate optional cache fields if present
if (
(pricing.cache_read !== undefined &&
(!Number.isFinite(pricing.cache_read) || pricing.cache_read < 0)) ||
(pricing.cache_write !== undefined &&
(!Number.isFinite(pricing.cache_write) || pricing.cache_write < 0))
) {
return null;
}

return {
input: dollarsToCenticents(pricing.input),
output: dollarsToCenticents(pricing.output),
Expand Down Expand Up @@ -182,7 +203,7 @@ export function getModelPricing(
}

/**
* Usage data for cost calculation
* Usage data for cost calculation.
*/
export interface TokenUsage {
inputTokens: number;
Expand All @@ -201,7 +222,7 @@ export interface TokenUsage {
}

/**
* Calculated cost breakdown in centi-cents
* Calculated cost breakdown in centi-cents.
*/
export interface CostBreakdown {
inputCost: CostInCenticents;
Expand All @@ -210,44 +231,3 @@ export interface CostBreakdown {
cacheWriteCost?: CostInCenticents;
totalCost: CostInCenticents;
}

/**
* Calculates the cost for a request based on usage and pricing.
*
* All calculations are performed in centi-cents using BIGINT arithmetic.
* Pricing is in centi-cents per million tokens.
*
* @param pricing - The model's pricing information in centi-cents
* @param usage - Token usage from the response
* @returns Cost breakdown in centi-cents
*/
export function calculateCost(
pricing: ModelPricing,
usage: TokenUsage,
): CostBreakdown {
// Pricing is in centi-cents per million tokens
// Calculate: (tokens * price_per_million) / 1_000_000
const inputCost = (BigInt(usage.inputTokens) * pricing.input) / 1_000_000n;
const outputCost = (BigInt(usage.outputTokens) * pricing.output) / 1_000_000n;

const cacheReadCost =
usage.cacheReadTokens && pricing.cache_read
? (BigInt(usage.cacheReadTokens) * pricing.cache_read) / 1_000_000n
: undefined;

const cacheWriteCost =
usage.cacheWriteTokens && pricing.cache_write
? (BigInt(usage.cacheWriteTokens) * pricing.cache_write) / 1_000_000n
: undefined;

const totalCost =
inputCost + outputCost + (cacheReadCost || 0n) + (cacheWriteCost || 0n);

return {
inputCost,
outputCost,
cacheReadCost,
cacheWriteCost,
totalCost,
};
}
Loading