Skip to content
Closed
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
92 changes: 91 additions & 1 deletion packages/js/src/__tests__/calcPrice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'

import type { ModelPrice, Usage } from '../types'

import { calcPrice } from '../engine'
import { calcPrice, getActiveModelPrice } from '../engine'

const MILLION = 1_000_000

Expand Down Expand Up @@ -227,5 +227,95 @@ describe('Core Price Calculation Function', () => {
const result = calcPrice(usage, modelPrice)
expect(result).toMatchObject(expected)
})

it('should apply the OpenAI Batch API conditional price', () => {
const modelPrice = getActiveModelPrice(
{
id: 'gpt-4.1',
match: { equals: 'gpt-4.1' },
prices: [
{ prices: { cache_read_mtok: 0.5, input_mtok: 2, output_mtok: 8 } },
{
constraint: { price_context: { service_tier: 'batch' }, type: 'price_context' },
prices: { cache_read_mtok: 0.25, input_mtok: 1, output_mtok: 4 },
},
],
},
new Date(),
{ service_tier: 'batch' }
)

const result = calcPrice({ cache_read_tokens: 100, input_tokens: 1000, output_tokens: 100 }, modelPrice)

expect(modelPrice).toEqual({ cache_read_mtok: 0.25, input_mtok: 1, output_mtok: 4 })
expect(result.input_price).toBeCloseTo(0.000925)
expect(result.output_price).toBeCloseTo(0.0004)
expect(result.total_price).toBeCloseTo(0.001325)
})

it('should apply Anthropic Message Batches conditional prices', () => {
const modelPrice = getActiveModelPrice(
{
id: 'claude-3-5-haiku-latest',
match: { equals: 'claude-3-5-haiku-20241022' },
prices: [
{ prices: { cache_read_mtok: 0.08, cache_write_mtok: 1, input_mtok: 0.8, output_mtok: 4 } },
{
constraint: { price_context: { service_tier: ['batch', 'message_batch'] }, type: 'price_context' },
prices: { cache_read_mtok: 0.04, cache_write_mtok: 0.5, input_mtok: 0.4, output_mtok: 2 },
},
],
},
new Date(),
{ service_tier: 'batch' }
)

const result = calcPrice({ cache_read_tokens: 200, cache_write_tokens: 100, input_tokens: 1000, output_tokens: 100 }, modelPrice)

expect(modelPrice).toEqual({
cache_read_mtok: 0.04,
cache_write_mtok: 0.5,
input_mtok: 0.4,
output_mtok: 2,
})
expect(result.input_price).toBeCloseTo(0.000338)
expect(result.output_price).toBeCloseTo(0.0002)
expect(result.total_price).toBeCloseTo(0.000538)
})

it('should use the last matching request-context conditional price', () => {
const modelPrice = getActiveModelPrice(
{
id: 'test-model',
match: { equals: 'test-model' },
prices: [
{ prices: { input_mtok: 10, output_mtok: 20 } },
{
constraint: {
not_price_context: { service_tier: 'batch' },
price_context: { speed: 'fast' },
type: 'price_context',
},
prices: { input_mtok: 30, output_mtok: 150 },
},
{
constraint: { price_context: { inference_geo: 'us' }, type: 'price_context' },
prices: { input_mtok: 40, output_mtok: 160 },
},
],
},
new Date(),
{ inference_geo: 'us', speed: 'fast' }
)

const result = calcPrice({ input_tokens: 1000, output_tokens: 100 }, modelPrice)

expect(modelPrice).toEqual({ input_mtok: 40, output_mtok: 160 })
expect(result).toMatchObject({
input_price: 0.04,
output_price: 0.016,
total_price: 0.056,
})
})
})
})
36 changes: 36 additions & 0 deletions packages/js/src/__tests__/extractUsage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,45 @@ describe('extractUsage', () => {

expect(extractUsage(provider, { model: 'test-model', usage: { output_tokens: 2, totals: 1 } })).toEqual({
model: 'test-model',
pricing_context: {},
usage: { output_tokens: 2 },
})
})

it('should extract pricing context while preserving usage fields', () => {
const provider: Provider = {
api_pattern: 'test',
extractors: [
{
api_flavor: 'default',
mappings: [
{ dest: 'input_tokens', path: 'input_tokens', required: true },
{ dest: 'output_tokens', path: 'output_tokens', required: true },
],
model_path: 'model',
pricing_context_mappings: [
{ dest: 'service_tier', path: 'service_tier', required: false },
{ dest: 'inference_geo', path: 'inference_geo', required: false },
],
root: 'usage',
},
],
id: 'test',
models: [],
name: 'Test',
}

expect(
extractUsage(provider, {
model: 'test-model',
usage: { input_tokens: 100, output_tokens: 20, service_tier: 'batch' },
})
).toEqual({
model: 'test-model',
pricing_context: { service_tier: 'batch' },
usage: { input_tokens: 100, output_tokens: 20 },
})
})
})

describe('apiFlavor handling', () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/js/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,14 @@ export function calcPrice(usage: Usage, modelId: string, options?: PriceOptions)
const model = matchModelWithFallback(provider, lowerModelId, providerData)
if (!model) return null
const timestamp = options?.timestamp ?? new Date()
const modelPrice = getActiveModelPrice(model, timestamp)
const priceContext = options?.priceContext ?? {}
const modelPrice = getActiveModelPrice(model, timestamp, priceContext)
const priceResult = calcPriceInternal(usage, modelPrice)
return {
auto_update_timestamp: undefined,
model,
model_price: modelPrice,
price_context: priceContext,
provider,
...priceResult,
}
Expand Down
57 changes: 46 additions & 11 deletions packages/js/src/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,30 @@ export const data: Provider[] = [
],
},
context_window: 200000,
prices: {
input_mtok: 0.8,
cache_write_mtok: 1,
cache_read_mtok: 0.08,
output_mtok: 4,
},
prices: [
{
prices: {
input_mtok: 0.8,
cache_write_mtok: 1,
cache_read_mtok: 0.08,
output_mtok: 4,
},
},
{
constraint: {
price_context: {
service_tier: 'batch',
},
type: 'price_context',
},
prices: {
input_mtok: 0.4,
cache_write_mtok: 0.5,
cache_read_mtok: 0.04,
output_mtok: 2,
},
},
],
},
{
id: 'claude-3-5-sonnet',
Expand Down Expand Up @@ -10994,11 +11012,28 @@ export const data: Provider[] = [
],
},
context_window: 1000000,
prices: {
input_mtok: 2,
cache_read_mtok: 0.5,
output_mtok: 8,
},
prices: [
{
prices: {
input_mtok: 2,
cache_read_mtok: 0.5,
output_mtok: 8,
},
},
{
constraint: {
price_context: {
service_tier: 'batch',
},
type: 'price_context',
},
prices: {
input_mtok: 1,
cache_read_mtok: 0.25,
output_mtok: 4,
},
},
],
},
{
id: 'gpt-4.1-mini',
Expand Down
99 changes: 66 additions & 33 deletions packages/js/src/engine.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
import { MatchLogic, ModelInfo, ModelPrice, ModelPriceCalculationResult, Provider, ProviderFindOptions, TieredPrices, Usage } from './types'
import {
MatchLogic,
ModelInfo,
ModelPrice,
ModelPriceCalculationResult,
PriceContext,
PriceContextValue,
Provider,
ProviderFindOptions,
TieredPrices,
Usage,
} from './types'

/**
* Calculate price using threshold-based (cliff) pricing model.
Expand Down Expand Up @@ -116,47 +127,69 @@ export function calcPrice(usage: Usage, modelPrice: ModelPrice): ModelPriceCalcu
}
}

export function getActiveModelPrice(model: ModelInfo, timestamp: Date): ModelPrice {
export function getActiveModelPrice(model: ModelInfo, timestamp: Date, priceContext: PriceContext = {}): ModelPrice {
let modelPrice: ModelPrice
if (!Array.isArray(model.prices)) {
return model.prices
}
// Conditional prices: last active wins
for (let i = model.prices.length - 1; i >= 0; i--) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const cond = model.prices[i]!
const constraint = cond.constraint

if (constraint === undefined) {
return cond.prices
modelPrice = model.prices
} else {
const firstPrice = model.prices[0]
if (firstPrice === undefined) {
throw new Error(`Model ${model.id} has no prices`)
}
// Conditional prices: last active wins
modelPrice = firstPrice.prices
for (let i = model.prices.length - 1; i >= 0; i--) {
const cond = model.prices[i]
if (cond === undefined) continue
const constraint = cond.constraint

if (constraint.type === 'start_date') {
if (timestamp >= new Date(constraint.start_date)) {
return cond.prices
if (constraint === undefined) {
modelPrice = cond.prices
break
}
} else {
// Extract UTC time to match constraint times which are in UTC (with 'Z' suffix)
const t = timestamp.toISOString().slice(11, 19) // Get "HH:MM:SS" from ISO string
const startTime = constraint.start_time
const endTime = constraint.end_time

// Handle time ranges that span midnight (end time < start time)
if (endTime < startTime) {
// Time is in range if it's >= start OR < end
if (t >= startTime || t < endTime) {
return cond.prices

if (constraint.type === 'start_date') {
if (timestamp >= new Date(constraint.start_date)) {
modelPrice = cond.prices
break
}
} else {
// Normal time range (start <= time < end)
if (t >= startTime && t < endTime) {
return cond.prices
} else if (constraint.type === 'time_of_date') {
// Extract UTC time to match constraint times which are in UTC (with 'Z' suffix)
const t = timestamp.toISOString().slice(11, 19) // Get "HH:MM:SS" from ISO string
const startTime = constraint.start_time
const endTime = constraint.end_time

// Handle time ranges that span midnight (end time < start time)
if (endTime < startTime) {
// Time is in range if it's >= start OR < end
if (t >= startTime || t < endTime) {
modelPrice = cond.prices
break
}
} else {
// Normal time range (start <= time < end)
if (t >= startTime && t < endTime) {
modelPrice = cond.prices
break
}
}
} else if (
matchesContext(constraint.price_context, priceContext) &&
!(constraint.not_price_context && matchesContext(constraint.not_price_context, priceContext))
) {
Comment on lines +176 to +179

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A malformed or future constraint type can now crash price resolution instead of being skipped, because the new branch dereferences constraint.price_context without a type guard. Consider gating this branch on constraint.type === 'price_context' before calling matchesContext.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/js/src/engine.ts, line 176:

<comment>A malformed or future constraint type can now crash price resolution instead of being skipped, because the new branch dereferences `constraint.price_context` without a type guard. Consider gating this branch on `constraint.type === 'price_context'` before calling `matchesContext`.</comment>

<file context>
@@ -174,80 +173,16 @@ export function getActiveModelPrice(model: ModelInfo, timestamp: Date, priceCont
             break
           }
         }
+      } else if (
+        matchesContext(constraint.price_context, priceContext) &&
+        !(constraint.not_price_context && matchesContext(constraint.not_price_context, priceContext))
</file context>
Suggested change
} else if (
matchesContext(constraint.price_context, priceContext) &&
!(constraint.not_price_context && matchesContext(constraint.not_price_context, priceContext))
) {
} else if (
constraint.type === 'price_context' &&
matchesContext(constraint.price_context, priceContext) &&
!(constraint.not_price_context && matchesContext(constraint.not_price_context, priceContext))
) {

modelPrice = cond.prices
break
}
}
}
// Fallback to first
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return model.prices[0]!.prices
return modelPrice
}

function matchesContext(expectedContext: Record<string, PriceContextValue | PriceContextValue[]>, priceContext: PriceContext): boolean {
return Object.entries(expectedContext).every(([key, expected]) => {
const actual = priceContext[key]
return Array.isArray(expected) ? actual !== undefined && expected.includes(actual) : actual === expected
})
}

export function matchLogic(logic: MatchLogic, text: string): boolean {
Expand Down
18 changes: 16 additions & 2 deletions packages/js/src/extractUsage.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { matchLogic } from './engine'
import { ArrayMatch, ExtractPath, Provider, Usage } from './types'
import { ArrayMatch, ExtractPath, PriceContext, PriceContextValue, Provider, Usage } from './types'

interface ExtractedUsage {
model: null | string
pricing_context: PriceContext
usage: Usage
}

Expand Down Expand Up @@ -42,7 +43,15 @@ export function extractUsage(provider: Provider, responseData: unknown, apiFlavo
throw new Error(`No usage information found at ${JSON.stringify(extractor.root)}`)
}

return { model, usage }
const pricingContext: PriceContext = {}
for (const mapping of extractor.pricing_context_mappings ?? []) {
const value = extractPath(mapping.path, usageObj, priceContextValueCheck, mapping.required, root)
if (value !== null) {
pricingContext[mapping.dest] = value
}
}

return { model, pricing_context: pricingContext, usage }
}

function extractPath<T>(path: ExtractPath, data: unknown, typeCheck: TypeCheck<T>, required: true, dataPath: (ArrayMatch | string)[]): T
Expand Down Expand Up @@ -177,6 +186,11 @@ const numberCheck: TypeCheck<number> = {
name: 'number',
}

const priceContextValueCheck: TypeCheck<PriceContextValue> = {
guard: (value: unknown): value is PriceContextValue => ['boolean', 'number', 'string'].includes(typeof value),
name: 'string, number, or boolean',
}

const dottedPath = (dataPath: (ArrayMatch | string)[], errorPath: (ArrayMatch | string)[]): string =>
[...dataPath.map(asString), ...errorPath.map(asString)].join('.')

Expand Down
Loading