diff --git a/nodejs/src/ingestion/pipelines/ai/costs/cost-model-matching.test.ts b/nodejs/src/ingestion/pipelines/ai/costs/cost-model-matching.test.ts index 527fe146da3a..8c9a67c4f203 100644 --- a/nodejs/src/ingestion/pipelines/ai/costs/cost-model-matching.test.ts +++ b/nodejs/src/ingestion/pipelines/ai/costs/cost-model-matching.test.ts @@ -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: { @@ -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' }) diff --git a/nodejs/src/ingestion/pipelines/ai/costs/cost-model-matching.ts b/nodejs/src/ingestion/pipelines/ai/costs/cost-model-matching.ts index 47ecbb26211e..aa10d1d97d87 100644 --- a/nodejs/src/ingestion/pipelines/ai/costs/cost-model-matching.ts +++ b/nodejs/src/ingestion/pipelines/ai/costs/cost-model-matching.ts @@ -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' @@ -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) { @@ -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) { diff --git a/nodejs/src/ingestion/pipelines/ai/costs/provider-matching.test.ts b/nodejs/src/ingestion/pipelines/ai/costs/provider-matching.test.ts index 35105d78f266..676899b21bf4 100644 --- a/nodejs/src/ingestion/pipelines/ai/costs/provider-matching.test.ts +++ b/nodejs/src/ingestion/pipelines/ai/costs/provider-matching.test.ts @@ -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') @@ -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({ diff --git a/nodejs/src/ingestion/pipelines/ai/costs/provider-matching.ts b/nodejs/src/ingestion/pipelines/ai/costs/provider-matching.ts index 7f013ad19405..f66b51eda397 100644 --- a/nodejs/src/ingestion/pipelines/ai/costs/provider-matching.ts +++ b/nodejs/src/ingestion/pipelines/ai/costs/provider-matching.ts @@ -78,6 +78,39 @@ export const PROVIDER_ALIASES: Record = { 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([ + ['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. @@ -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. * @@ -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 @@ -135,8 +214,26 @@ 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) { @@ -144,8 +241,6 @@ export const resolveModelCostForProvider = ( } // Try provider variations - const normalizedProvider: string = normalizeProviderKey(provider) - const providerCandidates: string[] = [normalizedProvider, provider.toLowerCase(), provider] for (const candidate of providerCandidates) { @@ -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) )