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
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ jest.mock('./providers', () => {
},
},
},
'anthropic/claude-sonnet-5': {
model: 'anthropic/claude-sonnet-5',
cost: {
'amazon-bedrock-claude-on-aws': { prompt_token: 0.000002, completion_token: 0.00001 },
'amazon-bedrock-global': { prompt_token: 0.000002, completion_token: 0.00001 },
'amazon-bedrock-us-east-1': { prompt_token: 0.0000022, completion_token: 0.000011 },
anthropic: { prompt_token: 0.000002, completion_token: 0.00001 },
},
},
'google/gemini-2.5-pro-preview': {
model: 'google/gemini-2.5-pro-preview',
cost: {
Expand Down Expand Up @@ -288,6 +297,23 @@ describe('findCostFromModel()', () => {
})
})

describe('bedrock inference profiles', () => {
it('bills a us. profile at the us regional rate', () => {
const result = findCostFromModel('bedrock/us.anthropic.claude-sonnet-5', { $ai_provider: 'bedrock' })

expect(result).toBeDefined()
expect(result!.cost.provider).toBe('amazon-bedrock-us-east-1')
expect(result!.cost.cost.prompt_token).toBe(0.0000022)
})

it('bills an unprefixed bedrock model at the same rate as before', () => {
const result = findCostFromModel('claude-sonnet-5', { $ai_provider: 'bedrock' })

expect(result).toBeDefined()
expect(result!.cost.provider).toBe('amazon-bedrock-claude-on-aws')
})
})

describe('edge cases', () => {
it('returns undefined for completely unknown model', () => {
const result = findCostFromModel('completely-unknown-model-xyz-123', { $ai_provider: 'unknown' })
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { logger } from '~/common/utils/logger'
import { Properties } from '~/plugin-scaffold'

import { resolveModelCostForProvider } from './provider-matching'
import { extractInferenceProfileRegion, resolveModelCostForProvider } from './provider-matching'
import { manualCostsByModel, openRouterCostsByModel } from './providers'
import type { ModelCostRow, ResolvedModelCost } from './providers/types'

Expand Down Expand Up @@ -43,10 +43,13 @@ export const findCostFromModel = (model: string, properties: Properties): CostMo

const provider: string | undefined = providerProperty ? String(providerProperty).toLowerCase() : undefined

// Read the region before matching, since catalog lookups drop the inference-profile prefix
const region: string | undefined = extractInferenceProfileRegion(model)

const manualMatch: ModelCostRow | undefined = findManualCost(model)

const resolvedManualMatch: ResolvedModelCost | undefined = manualMatch
? resolveModelCostForProvider(manualMatch.cost, provider, manualMatch.model)
? resolveModelCostForProvider(manualMatch.cost, provider, manualMatch.model, region)
: undefined

if (resolvedManualMatch) {
Expand All @@ -56,7 +59,7 @@ export const findCostFromModel = (model: string, properties: Properties): CostMo
const openRouterMatch: ModelCostRow | undefined = searchModelInCosts(model, openRouterCostsByModel)

const resolvedOpenRouterMatch: ResolvedModelCost | undefined = openRouterMatch
? resolveModelCostForProvider(openRouterMatch.cost, provider, openRouterMatch.model)
? resolveModelCostForProvider(openRouterMatch.cost, provider, openRouterMatch.model, region)
: undefined

if (resolvedOpenRouterMatch) {
Expand Down
85 changes: 85 additions & 0 deletions nodejs/src/ingestion/pipelines/ai/costs/provider-matching.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,33 @@
import {
PROVIDER_ALIASES,
extractInferenceProfileRegion,
normalizeProviderKey,
resolveModelCostForProvider,
resolveProviderAliases,
} from './provider-matching'
import type { ModelCostByProvider } from './providers/types'

describe('extractInferenceProfileRegion()', () => {
it.each([
// The prefix sits after the `provider/` segment, so a naive startsWith('us.') misses it
['bedrock/us.anthropic.claude-sonnet-5', 'us'],
['bedrock/eu.anthropic.claude-sonnet-4-6', 'eu'],
['bedrock/global.anthropic.claude-fable-5', 'global'],
['bedrock/apac.anthropic.claude-sonnet-5', 'apac'],
['us.anthropic.claude-sonnet-5', 'us'],
['BEDROCK/US.anthropic.claude-sonnet-5', 'us'],
['arn:aws:bedrock:us-east-1:123:inference-profile/us.anthropic.claude-sonnet-5', 'us'],
// Versioned and dotted model names must not read as region tokens
['claude-opus-4.5', undefined],
['anthropic/claude-sonnet-4.6', undefined],
['openai/gpt-4.1-mini', undefined],
['constructor.anthropic.claude-sonnet-5', undefined],
['', undefined],
])('extracts %s as region %s', (model, expected) => {
expect(extractInferenceProfileRegion(model)).toBe(expected)
})
})

describe('normalizeProviderKey()', () => {
it('lowercases provider names', () => {
expect(normalizeProviderKey('OpenAI')).toBe('openai')
Expand Down Expand Up @@ -481,6 +503,69 @@ describe('resolveModelCostForProvider()', () => {
})
})

describe('inference-profile region matching', () => {
it('prefers the request region over another provider-specific key', () => {
const costs = createMockCosts({
'amazon-bedrock-claude-on-aws': { prompt_token: 0.000002, completion_token: 0.00001 },
'amazon-bedrock-global': { prompt_token: 0.000002, completion_token: 0.00001 },
'amazon-bedrock-us-east-1': { prompt_token: 0.0000022, completion_token: 0.000011 },
})

const result = resolveModelCostForProvider(costs, 'bedrock', 'anthropic/claude-sonnet-5', 'us')

expect(result).toBeDefined()
expect(result!.provider).toBe('amazon-bedrock-us-east-1')
})

it('prefers the request region over the un-regioned provider key', () => {
const costs = createMockCosts({
'amazon-bedrock': { prompt_token: 0.000003, completion_token: 0.000015 },
'amazon-bedrock-eu-west-1': { prompt_token: 0.0000033, completion_token: 0.0000165 },
})

const result = resolveModelCostForProvider(costs, 'bedrock', 'amazon/nova-lite-v1', 'eu')

expect(result).toBeDefined()
expect(result!.provider).toBe('amazon-bedrock-eu-west-1')
})

it('falls back to the global key rather than a different region', () => {
const costs = createMockCosts({
'amazon-bedrock-eu-west-1': { prompt_token: 0.0000033, completion_token: 0.0000165 },
'amazon-bedrock-global': { prompt_token: 0.000003, completion_token: 0.000015 },
})

const result = resolveModelCostForProvider(costs, 'bedrock', 'anthropic/claude-sonnet-4.6', 'us')

expect(result).toBeDefined()
expect(result!.provider).toBe('amazon-bedrock-global')
})

it('ignores keys whose region only shares a prefix with the request region', () => {
const costs = createMockCosts({
'amazon-bedrock-eu-west-1': { prompt_token: 0.0000033, completion_token: 0.0000165 },
'amazon-bedrock-usw': { prompt_token: 0.000009, completion_token: 0.000009 },
})

const result = resolveModelCostForProvider(costs, 'bedrock', 'anthropic/claude-sonnet-5', 'us')

expect(result).toBeDefined()
expect(result!.provider).toBe('amazon-bedrock-eu-west-1')
})

it('keeps unprefixed models on the existing exact match', () => {
const costs = createMockCosts({
'amazon-bedrock': { prompt_token: 0.000003, completion_token: 0.000015 },
'amazon-bedrock-eu-west-1': { prompt_token: 0.0000033, completion_token: 0.0000165 },
})

const result = resolveModelCostForProvider(costs, 'bedrock', 'amazon/nova-lite-v1')

expect(result).toBeDefined()
expect(result!.provider).toBe('amazon-bedrock')
})
})

describe('provider normalization edge cases', () => {
it('normalizes provider with underscores and dots', () => {
const costs = createMockCosts({
Expand Down
110 changes: 100 additions & 10 deletions nodejs/src/ingestion/pipelines/ai/costs/provider-matching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,39 @@ export const PROVIDER_ALIASES: Record<string, CanonicalProvider> = {
or: 'default',
}

/**
* Cross-region inference profiles prefix the model ID with the region the request ran in
* (`us.anthropic.claude-sonnet-5`). That token is the only signal telling us which regional
* pricing row applies, so we keep it and use it to pick between provider keys.
*
* Each token maps to the region key prefixes to try, in order. `global` is the last resort
* everywhere because it is the un-regioned list price — a closer stand-in for a missing region
* than another region's premium would be.
*/
const INFERENCE_PROFILE_REGIONS = new Map<string, string[]>([
['us', ['us', 'global']],
['eu', ['eu', 'global']],
['apac', ['apac', 'ap', 'global']],
['global', ['global']],
])

/**
* Extracts the cross-region inference-profile token from a model string, if present.
*
* The token sits after any `provider/` segment (`bedrock/us.anthropic.claude-sonnet-5`) or
* inference-profile ARN path, so we only look at the part after the last slash.
*
* @param model - The raw model name from the event
* @returns The region token (for example `us`), or undefined for unprefixed models
*/
export const extractInferenceProfileRegion = (model: string): string | undefined => {
const modelId: string = model.toLowerCase().split('/').pop() ?? ''

const token: string = modelId.split('.')[0]

return INFERENCE_PROFILE_REGIONS.has(token) ? token : undefined
}

/**
* Normalizes a provider key by lowercasing and replacing non-alphanumeric characters
* with hyphens.
Expand All @@ -100,6 +133,49 @@ export const resolveProviderAliases = (provider: string): string => {
return PROVIDER_ALIASES[normalizedProvider] ?? normalizedProvider
}

/**
* Finds the provider key whose region matches the request's inference profile, for example
* `amazon-bedrock-us-east-1` for a `us.` profile served by `amazon-bedrock`.
*
* Candidates are sorted so the pick stays stable — key order in `llm-costs.json` is regenerated
* by a scheduled job and must not decide which region we bill at.
*/
const findRegionalProviderMatch = (
providerCosts: ModelCostByProvider,
providerSearches: string[],
region: string,
findProviderMatch: (providerKey: string) => ResolvedModelCost | undefined
): ResolvedModelCost | undefined => {
const regionPrefixes: string[] | undefined = INFERENCE_PROFILE_REGIONS.get(region)

if (!regionPrefixes) {
return undefined
}

const providerKeys: string[] = Object.keys(providerCosts).sort()

for (const search of providerSearches) {
for (const regionPrefix of regionPrefixes) {
const regionKey = `${search}-${regionPrefix}`

for (const providerKey of providerKeys) {
// Only match on a key boundary, so `us` never matches `amazon-bedrock-usw`.
if (providerKey !== regionKey && !providerKey.startsWith(`${regionKey}-`)) {
continue
}

const match: ResolvedModelCost | undefined = findProviderMatch(providerKey)

if (match) {
return match
}
}
}
}

return undefined
}

/**
* Attempts to find a matching provider in the cost model.
*
Expand All @@ -109,12 +185,15 @@ export const resolveProviderAliases = (provider: string): string => {
* @param providerCosts - The cost model with provider-specific pricing
* @param provider - The provider name from the event (optional)
* @param model - The model name for the resolved cost
* @param region - Inference-profile region token from the model ID (optional), preferred over
* both the un-regioned provider key and the partial-match fallback
* @returns The resolved model cost, or undefined if no valid cost is found
*/
export const resolveModelCostForProvider = (
providerCosts: ModelCostByProvider,
provider: string | undefined,
model: string
model: string,
region?: string
): ResolvedModelCost | undefined => {
if (!providerCosts || Object.keys(providerCosts).length === 0) {
return undefined
Expand All @@ -135,17 +214,33 @@ export const resolveModelCostForProvider = (
}

if (provider) {
// Try alias resolution first
const canonicalKey: string = resolveProviderAliases(provider)
const normalizedProvider: string = normalizeProviderKey(provider)

// Search against the canonical key too so regional-only cost records
// (e.g. `google-ai-studio-global`) still match when the event uses an alias like `gemini`.
const providerSearches: string[] =
canonicalKey === normalizedProvider ? [normalizedProvider] : [canonicalKey, normalizedProvider]

// A region from the model's inference profile is stronger evidence than any of the
// matches below: without it we would fall through to the partial match and pick whichever
// regional key happens to come first in the cost model.
const regionalMatch: ResolvedModelCost | undefined = region
? findRegionalProviderMatch(providerCosts, providerSearches, region, findProviderMatch)
: undefined

if (regionalMatch) {
return regionalMatch
}

// Try alias resolution first
const match: ResolvedModelCost | undefined = findProviderMatch(canonicalKey)

if (match) {
return match
}

// Try provider variations
const normalizedProvider: string = normalizeProviderKey(provider)

const providerCandidates: string[] = [normalizedProvider, provider.toLowerCase(), provider]

for (const candidate of providerCandidates) {
Expand All @@ -156,12 +251,7 @@ export const resolveModelCostForProvider = (
}
}

// Search against the canonical key too so regional-only cost records
// (e.g. `google-ai-studio-global`) still match when the event uses an alias like `gemini`.
const partialMatchSearches: string[] =
canonicalKey === normalizedProvider ? [normalizedProvider] : [canonicalKey, normalizedProvider]

for (const search of partialMatchSearches) {
for (const search of providerSearches) {
const partialMatchKey: string | undefined = Object.keys(providerCosts).find((key: string) =>
key.includes(search)
)
Expand Down
Loading