diff --git a/docs/onboarding/ai-observability/_snippets/generation-event.tsx b/docs/onboarding/ai-observability/_snippets/generation-event.tsx index 5ff96d23d936..c7717b69c031 100644 --- a/docs/onboarding/ai-observability/_snippets/generation-event.tsx +++ b/docs/onboarding/ai-observability/_snippets/generation-event.tsx @@ -424,7 +424,20 @@ export const GenerationEvent = (): JSX.Element => {

- (Optional) Price per cached token write + (Optional) Price per cached token write. For custom Anthropic pricing, this + applies to both cache TTLs unless $ai_cache_write_1h_token_price is + set. +

+ + + + + $ai_cache_write_1h_token_price + + +

+ (Optional) Price per token written to Anthropic's 1-hour cache. Takes + precedence over $ai_cache_write_token_price for 1-hour writes.

@@ -501,6 +514,30 @@ export const GenerationEvent = (): JSX.Element => {

(Optional) Number of tokens written to cache (Anthropic-specific) +
+ When both TTL-specific counts are present, PostHog uses them instead of this + aggregate. The aggregate should equal their sum; if either count is missing, PostHog + uses the aggregate. +

+ + + + + $ai_cache_creation_5m_input_tokens + + +

+ (Optional) Number of tokens written to Anthropic's 5-minute cache +

+ + + + + $ai_cache_creation_1h_input_tokens + + +

+ (Optional) Number of tokens written to Anthropic's 1-hour cache

diff --git a/frontend/src/taxonomy/core-filter-definitions-by-group.json b/frontend/src/taxonomy/core-filter-definitions-by-group.json index fa937da48b4b..26bff736d078 100644 --- a/frontend/src/taxonomy/core-filter-definitions-by-group.json +++ b/frontend/src/taxonomy/core-filter-definitions-by-group.json @@ -151,6 +151,22 @@ "label": "AI billable (LLM)", "system": true }, + "$ai_cache_creation_1h_input_tokens": { + "description": "The number of tokens created in the 1-hour prompt cache (Anthropic only).", + "examples": [ + 23 + ], + "label": "AI 1-hour cache creation input tokens (LLM)", + "type": "Numeric" + }, + "$ai_cache_creation_5m_input_tokens": { + "description": "The number of tokens created in the 5-minute prompt cache (Anthropic only).", + "examples": [ + 23 + ], + "label": "AI 5-minute cache creation input tokens (LLM)", + "type": "Numeric" + }, "$ai_cache_creation_input_tokens": { "description": "The number of tokens created in the cache for the input prompt (anthropic only).", "examples": [ @@ -188,6 +204,14 @@ ], "label": "AI cache reporting exclusive (LLM)" }, + "$ai_cache_write_1h_token_price": { + "description": "The price per token written to the 1-hour prompt cache. Set this to override PostHog's cost calculation for 1-hour cache writes.", + "examples": [ + 6e-06 + ], + "label": "AI 1-hour cache write token price (LLM)", + "type": "Numeric" + }, "$ai_cache_write_token_price": { "description": "The price per token written to the prompt cache.", "examples": [ @@ -4509,6 +4533,22 @@ "label": "AI billable (LLM)", "system": true }, + "$ai_cache_creation_1h_input_tokens": { + "description": "The number of tokens created in the 1-hour prompt cache (Anthropic only).", + "examples": [ + 23 + ], + "label": "AI 1-hour cache creation input tokens (LLM)", + "type": "Numeric" + }, + "$ai_cache_creation_5m_input_tokens": { + "description": "The number of tokens created in the 5-minute prompt cache (Anthropic only).", + "examples": [ + 23 + ], + "label": "AI 5-minute cache creation input tokens (LLM)", + "type": "Numeric" + }, "$ai_cache_creation_input_tokens": { "description": "The number of tokens created in the cache for the input prompt (anthropic only).", "examples": [ @@ -4546,6 +4586,14 @@ ], "label": "AI cache reporting exclusive (LLM)" }, + "$ai_cache_write_1h_token_price": { + "description": "The price per token written to the 1-hour prompt cache. Set this to override PostHog's cost calculation for 1-hour cache writes.", + "examples": [ + 6e-06 + ], + "label": "AI 1-hour cache write token price (LLM)", + "type": "Numeric" + }, "$ai_cache_write_token_price": { "description": "The price per token written to the prompt cache.", "examples": [ diff --git a/nodejs/src/ingestion/common/steps/event-preprocessing/validate-ai-event-tokens.test.ts b/nodejs/src/ingestion/common/steps/event-preprocessing/validate-ai-event-tokens.test.ts index 8fe89f6191ab..f52db61d3a7a 100644 --- a/nodejs/src/ingestion/common/steps/event-preprocessing/validate-ai-event-tokens.test.ts +++ b/nodejs/src/ingestion/common/steps/event-preprocessing/validate-ai-event-tokens.test.ts @@ -49,6 +49,8 @@ const TOKEN_PROPERTIES = [ '$ai_reasoning_tokens', '$ai_cache_read_input_tokens', '$ai_cache_creation_input_tokens', + '$ai_cache_creation_5m_input_tokens', + '$ai_cache_creation_1h_input_tokens', ] as const const AI_EVENT_TYPES = ['$ai_generation', '$ai_embedding', '$ai_span', '$ai_trace', '$ai_metric', '$ai_feedback'] diff --git a/nodejs/src/ingestion/common/steps/event-preprocessing/validate-ai-event-tokens.ts b/nodejs/src/ingestion/common/steps/event-preprocessing/validate-ai-event-tokens.ts index c7f72aa925a3..6a846ca5e816 100644 --- a/nodejs/src/ingestion/common/steps/event-preprocessing/validate-ai-event-tokens.ts +++ b/nodejs/src/ingestion/common/steps/event-preprocessing/validate-ai-event-tokens.ts @@ -10,6 +10,8 @@ const TOKEN_PROPERTIES = [ '$ai_reasoning_tokens', '$ai_cache_read_input_tokens', '$ai_cache_creation_input_tokens', + '$ai_cache_creation_5m_input_tokens', + '$ai_cache_creation_1h_input_tokens', ] as const /** diff --git a/nodejs/src/ingestion/pipelines/ai/costs/index.ts b/nodejs/src/ingestion/pipelines/ai/costs/index.ts index b4dd2c105492..4641f64db11e 100644 --- a/nodejs/src/ingestion/pipelines/ai/costs/index.ts +++ b/nodejs/src/ingestion/pipelines/ai/costs/index.ts @@ -134,6 +134,7 @@ export const processCost = (event: EventWithProperties): EventWithProperties => completion_token: event.properties['$ai_output_token_price'], cache_read_token: event.properties['$ai_cache_read_token_price'], cache_write_token: event.properties['$ai_cache_write_token_price'], + cache_write_1h_token: event.properties['$ai_cache_write_1h_token_price'], request: event.properties['$ai_request_price'], web_search: event.properties['$ai_web_search_price'], }, diff --git a/nodejs/src/ingestion/pipelines/ai/costs/input-costs.test.ts b/nodejs/src/ingestion/pipelines/ai/costs/input-costs.test.ts index daf8e3fa8e09..6d9709fe793e 100644 --- a/nodejs/src/ingestion/pipelines/ai/costs/input-costs.test.ts +++ b/nodejs/src/ingestion/pipelines/ai/costs/input-costs.test.ts @@ -213,6 +213,93 @@ describe('calculateInputCost()', () => { expectCostToBeCloseTo(result, 0.004275) }) + it.each([ + { + name: '5-minute', + cacheCreationTokens: 100, + ttlProperties: { + $ai_cache_creation_5m_input_tokens: 100, + $ai_cache_creation_1h_input_tokens: 0, + }, + expectedCost: '0.003375', + }, + { + name: '1-hour', + cacheCreationTokens: 100, + ttlProperties: { + $ai_cache_creation_5m_input_tokens: 0, + $ai_cache_creation_1h_input_tokens: 100, + }, + expectedCost: '0.0036', + }, + { + name: 'mixed-TTL', + cacheCreationTokens: 300, + ttlProperties: { + $ai_cache_creation_5m_input_tokens: 100, + $ai_cache_creation_1h_input_tokens: 200, + }, + expectedCost: '0.004575', + }, + { + name: 'mixed-TTL numeric strings', + cacheCreationTokens: 300, + ttlProperties: { + $ai_cache_creation_5m_input_tokens: '100', + $ai_cache_creation_1h_input_tokens: '200', + }, + expectedCost: '0.004575', + }, + { + name: 'legacy aggregate-only', + cacheCreationTokens: 300, + ttlProperties: {}, + expectedCost: '0.004125', + }, + ])('prices $name cache creation tokens', ({ cacheCreationTokens, ttlProperties, expectedCost }) => { + const event = createAnthropicTestEvent(1000, undefined, cacheCreationTokens, ttlProperties) + + const result = calculateInputCost(event, ANTHROPIC_MODEL) + + expect(result).toBe(expectedCost) + }) + + it.each([ + { + name: '5-minute count only', + ttlProperties: { $ai_cache_creation_5m_input_tokens: 100 }, + }, + { + name: '1-hour count only', + ttlProperties: { $ai_cache_creation_1h_input_tokens: 200 }, + }, + ])('uses the aggregate when the TTL breakdown has $name', ({ ttlProperties }) => { + const event = createAnthropicTestEvent(1000, undefined, 300, ttlProperties) + + const result = calculateInputCost(event, ANTHROPIC_MODEL) + + expect(result).toBe('0.004125') + }) + + it('uses the generic custom cache-write rate for both TTLs when no 1-hour rate is set', () => { + const customModel = createTestModel({ + provider: 'custom', + cost: { + prompt_token: 0.000003, + completion_token: 0.000015, + cache_write_token: 0.000004, + }, + }) + const event = createAnthropicTestEvent(1000, undefined, 300, { + $ai_cache_creation_5m_input_tokens: 100, + $ai_cache_creation_1h_input_tokens: 200, + }) + + const result = calculateInputCost(event, customModel) + + expect(result).toBe('0.0042') + }) + it('uses 1.25x multiplier fallback for cache write when not defined', () => { const modelWithoutCacheWrite = createTestModel({ model: 'claude-2', diff --git a/nodejs/src/ingestion/pipelines/ai/costs/input-costs.ts b/nodejs/src/ingestion/pipelines/ai/costs/input-costs.ts index 8a017377c16c..6863d9648a88 100644 --- a/nodejs/src/ingestion/pipelines/ai/costs/input-costs.ts +++ b/nodejs/src/ingestion/pipelines/ai/costs/input-costs.ts @@ -39,6 +39,14 @@ const usesInclusiveAnthropicInputTokens = (event: PluginEvent): boolean => { return provider === 'gateway' && framework === 'vercel' } +const hasNumericProperty = (event: PluginEvent, key: string): boolean => { + const value = event.properties?.[key] + return ( + (typeof value === 'number' && Number.isFinite(value)) || + (typeof value === 'string' && value.length > 0 && Number.isFinite(Number(value))) + ) +} + export const resolveCacheReportingExclusive = (event: PluginEvent): boolean => { if (!event.properties) { return false @@ -177,12 +185,28 @@ export const calculateInputCost = (event: PluginEvent, cost: ResolvedModelCost): const cachedTextTokens = cacheReadTokens - cachedAudioInputTokens if (matchProvider(event, 'anthropic')) { - const cacheWriteTokens = numericProperty(event, '$ai_cache_creation_input_tokens') - - const writeCost = - cost.cost.cache_write_token !== undefined - ? bigDecimal.multiply(cost.cost.cache_write_token, cacheWriteTokens) - : bigDecimal.multiply(bigDecimal.multiply(cost.cost.prompt_token, 1.25), cacheWriteTokens) + const aggregateCacheWriteTokens = numericProperty(event, '$ai_cache_creation_input_tokens') + const cacheWrite5mTokens = numericProperty(event, '$ai_cache_creation_5m_input_tokens') + const cacheWrite1hTokens = numericProperty(event, '$ai_cache_creation_1h_input_tokens') + const hasCacheWriteBreakdown = + hasNumericProperty(event, '$ai_cache_creation_5m_input_tokens') && + hasNumericProperty(event, '$ai_cache_creation_1h_input_tokens') + const cacheWriteTokens = hasCacheWriteBreakdown + ? cacheWrite5mTokens + cacheWrite1hTokens + : aggregateCacheWriteTokens + + const cacheWrite5mRate = cost.cost.cache_write_token ?? bigDecimal.multiply(cost.cost.prompt_token, 1.25) + const cacheWrite1hRate = + cost.cost.cache_write_1h_token ?? + (cost.provider === 'custom' && cost.cost.cache_write_token !== undefined + ? cost.cost.cache_write_token + : bigDecimal.multiply(cost.cost.prompt_token, 2)) + const writeCost = hasCacheWriteBreakdown + ? bigDecimal.add( + bigDecimal.multiply(cacheWrite5mRate, cacheWrite5mTokens), + bigDecimal.multiply(cacheWrite1hRate, cacheWrite1hTokens) + ) + : bigDecimal.multiply(cacheWrite5mRate, cacheWriteTokens) const cacheReadCost = cost.cost.cache_read_token !== undefined diff --git a/nodejs/src/ingestion/pipelines/ai/costs/modality-tokens.test.ts b/nodejs/src/ingestion/pipelines/ai/costs/modality-tokens.test.ts index 869b1482c34c..3d6c627024f6 100644 --- a/nodejs/src/ingestion/pipelines/ai/costs/modality-tokens.test.ts +++ b/nodejs/src/ingestion/pipelines/ai/costs/modality-tokens.test.ts @@ -432,6 +432,116 @@ describe('extractModalityTokens()', () => { }) describe('cache modality extraction', () => { + const anthropicUsage = { + input_tokens: 2048, + cache_creation_input_tokens: 248, + cache_creation: { + ephemeral_5m_input_tokens: 148, + ephemeral_1h_input_tokens: 100, + }, + } + const vercelBedrockUsage = { + cacheDetails: [ + { ttl: 'T5M', inputTokens: 148 }, + { ttl: 'T1H', inputTokens: 100 }, + ], + } + + it.each([ + { + name: 'direct Anthropic usage', + usage: anthropicUsage, + expected5mTokens: 148, + expected1hTokens: 100, + }, + { + name: 'Vercel usage.raw', + usage: { + usage: { + raw: anthropicUsage, + }, + }, + expected5mTokens: 148, + expected1hTokens: 100, + }, + { + name: 'Vercel providerMetadata.anthropic.usage', + usage: { + providerMetadata: { + anthropic: { + usage: anthropicUsage, + }, + }, + }, + expected5mTokens: 148, + expected1hTokens: 100, + }, + { + name: 'Vercel providerMetadata.amazonBedrock.usage with only one-hour writes', + usage: { + providerMetadata: { + amazonBedrock: { + usage: { cacheDetails: [{ ttl: 'T1H', inputTokens: 100 }] }, + }, + }, + }, + expected5mTokens: 0, + expected1hTokens: 100, + }, + { + name: 'Vercel rawUsage.providerMetadata.amazonBedrock.usage', + usage: { + rawUsage: { + providerMetadata: { + amazonBedrock: { usage: vercelBedrockUsage }, + }, + }, + }, + expected5mTokens: 148, + expected1hTokens: 100, + }, + { + name: 'legacy providerMetadata.bedrock.usage with only five-minute writes', + usage: { + providerMetadata: { + bedrock: { + usage: { cacheDetails: [{ ttl: '5m', inputTokens: 148 }] }, + }, + }, + }, + expected5mTokens: 148, + expected1hTokens: 0, + }, + { + name: 'Vercel providerMetadata with both Bedrock aliases', + usage: { + providerMetadata: { + amazonBedrock: { usage: vercelBedrockUsage }, + bedrock: { + usage: { + cacheDetails: [ + { ttl: '5m', inputTokens: 999 }, + { ttl: '1h', inputTokens: 999 }, + ], + }, + }, + }, + }, + expected5mTokens: 148, + expected1hTokens: 100, + }, + ])('extracts cache creation tokens by TTL from $name', ({ usage, expected5mTokens, expected1hTokens }) => { + const event = createAIEvent({ + $ai_usage: usage, + }) + + const result = extractModalityTokens(event) + + expect(result.properties['$ai_cache_creation_5m_input_tokens']).toBe(expected5mTokens) + expect(result.properties['$ai_cache_creation_1h_input_tokens']).toBe(expected1hTokens) + expect(result.properties['$ai_usage']).toBeUndefined() + }) + it('extracts cached audio tokens from Gemini cacheTokensDetails array format', () => { const event = createAIEvent({ $ai_usage: { diff --git a/nodejs/src/ingestion/pipelines/ai/costs/modality-tokens.ts b/nodejs/src/ingestion/pipelines/ai/costs/modality-tokens.ts index 060eeb7426a6..d78a023d1754 100644 --- a/nodejs/src/ingestion/pipelines/ai/costs/modality-tokens.ts +++ b/nodejs/src/ingestion/pipelines/ai/costs/modality-tokens.ts @@ -35,14 +35,20 @@ const modalityOf = (detail: Record): string | null => { return typeof modality === 'string' ? modality.toLowerCase() : null } -type ExtractionSource = 'gemini_input' | 'gemini_output' | 'gemini_cache' | 'openai_input' | 'openai_cache' +type ExtractionSource = + | 'anthropic_cache' + | 'bedrock_cache' + | 'gemini_input' + | 'gemini_output' + | 'gemini_cache' + | 'openai_input' + | 'openai_cache' /** - * Extract modality-specific token counts from raw provider usage metadata. - * Supports Gemini's promptTokensDetails (input modality), candidatesTokensDetails - * (output modality), and cacheTokensDetails (cache modality), plus the OpenAI - * equivalents under prompt_tokens_details. Removes $ai_usage from properties - * after extraction so it does not get persisted to ClickHouse. + * Extract token details used by cost calculation from raw provider usage metadata. + * Supports Anthropic's cache-creation TTL breakdown, Gemini's input/output/cache + * modality details, and OpenAI's prompt_tokens_details. Removes $ai_usage from + * properties after extraction so it does not get persisted to ClickHouse. */ export const extractModalityTokens = (event: EventWithProperties): EventWithProperties => { const usage = event.properties['$ai_usage'] @@ -202,6 +208,67 @@ export const extractModalityTokens = (event: EventWithProperties): EventWithProp } } + const extractAnthropicCacheCreation = (metadata: Record): void => { + const cacheCreation = metadata['cache_creation'] + if (!isObject(cacheCreation)) { + return + } + + const fiveMinuteTokens = cacheCreation['ephemeral_5m_input_tokens'] + const oneHourTokens = cacheCreation['ephemeral_1h_input_tokens'] + if (typeof fiveMinuteTokens === 'number' && fiveMinuteTokens >= 0) { + event.properties['$ai_cache_creation_5m_input_tokens'] = fiveMinuteTokens + extractedSources.add('anthropic_cache') + } + if (typeof oneHourTokens === 'number' && oneHourTokens >= 0) { + event.properties['$ai_cache_creation_1h_input_tokens'] = oneHourTokens + extractedSources.add('anthropic_cache') + } + } + + const extractBedrockCacheCreation = (metadata: Record): void => { + const amazonBedrockMetadata = metadata['amazonBedrock'] + const bedrockMetadata = isObject(amazonBedrockMetadata) ? amazonBedrockMetadata : metadata['bedrock'] + if (!isObject(bedrockMetadata) || !isObject(bedrockMetadata['usage'])) { + return + } + + const cacheDetails = bedrockMetadata['usage']['cacheDetails'] + if (!Array.isArray(cacheDetails)) { + return + } + + let fiveMinuteTokens = 0 + let oneHourTokens = 0 + let foundCacheDetails = false + + for (const detail of cacheDetails) { + if (!isObject(detail)) { + continue + } + + const inputTokens = detail['inputTokens'] + const ttl = typeof detail['ttl'] === 'string' ? detail['ttl'].toLowerCase() : null + if (typeof inputTokens !== 'number' || !Number.isFinite(inputTokens) || inputTokens < 0) { + continue + } + + if (ttl === 't5m' || ttl === '5m') { + fiveMinuteTokens += inputTokens + foundCacheDetails = true + } else if (ttl === 't1h' || ttl === '1h') { + oneHourTokens += inputTokens + foundCacheDetails = true + } + } + + if (foundCacheDetails) { + event.properties['$ai_cache_creation_5m_input_tokens'] = fiveMinuteTokens + event.properties['$ai_cache_creation_1h_input_tokens'] = oneHourTokens + extractedSources.add('bedrock_cache') + } + } + // Walk each `usage`-shaped metadata object encountered across the SDK // wrapper variants and pull every modality breakdown it exposes. // Gemini metadata arrives camelCased through the Vercel AI SDK and @@ -210,6 +277,7 @@ export const extractModalityTokens = (event: EventWithProperties): EventWithProp if (!isObject(metadata)) { return } + extractAnthropicCacheCreation(metadata) extractInputModality(pick(metadata, 'promptTokensDetails', 'prompt_tokens_details')) extractOutputModality( pick(metadata, 'candidatesTokensDetails', 'candidates_tokens_details', 'outputTokenDetails') @@ -221,6 +289,12 @@ export const extractModalityTokens = (event: EventWithProperties): EventWithProp extractFromMetadata(usage) + // @posthog/ai preserves raw Vercel usage under usage.raw. + const usageDetails = (usage as Record)['usage'] + if (isObject(usageDetails)) { + extractFromMetadata(usageDetails['raw']) + } + // Vercel AI SDK with rawResponse at top level: { rawResponse: { usageMetadata: {...} } } const topLevelRawResponse = (usage as Record)['rawResponse'] if (isObject(topLevelRawResponse)) { @@ -231,6 +305,13 @@ export const extractModalityTokens = (event: EventWithProperties): EventWithProp const providerMetadata = (usage as Record)['providerMetadata'] if (isObject(providerMetadata)) { extractFromMetadata(providerMetadata['google']) + extractBedrockCacheCreation(providerMetadata) + + // Anthropic's original usage is also available in provider metadata. + const anthropicProviderMetadata = providerMetadata['anthropic'] + if (isObject(anthropicProviderMetadata)) { + extractFromMetadata(anthropicProviderMetadata['usage']) + } } // Vercel AI SDK V3 / nested rawUsage variants: @@ -242,6 +323,7 @@ export const extractModalityTokens = (event: EventWithProperties): EventWithProp const rawProviderMetadata = rawUsage['providerMetadata'] if (isObject(rawProviderMetadata)) { extractFromMetadata(rawProviderMetadata['google']) + extractBedrockCacheCreation(rawProviderMetadata) } const rawUsageUsage = rawUsage['usage'] diff --git a/nodejs/src/ingestion/pipelines/ai/costs/providers/types.ts b/nodejs/src/ingestion/pipelines/ai/costs/providers/types.ts index 36eac4abb26f..f27095973148 100644 --- a/nodejs/src/ingestion/pipelines/ai/costs/providers/types.ts +++ b/nodejs/src/ingestion/pipelines/ai/costs/providers/types.ts @@ -3,6 +3,7 @@ export interface ModelCost { completion_token: number cache_read_token?: number cache_write_token?: number + cache_write_1h_token?: number request?: number web_search?: number image?: number diff --git a/nodejs/src/ingestion/pipelines/ai/process-ai-event.test.ts b/nodejs/src/ingestion/pipelines/ai/process-ai-event.test.ts index b5e885776d06..7bd33a5c6575 100644 --- a/nodejs/src/ingestion/pipelines/ai/process-ai-event.test.ts +++ b/nodejs/src/ingestion/pipelines/ai/process-ai-event.test.ts @@ -885,6 +885,81 @@ describe('processAiEvent()', () => { expect(result.properties!.$ai_total_cost_usd).toBeCloseTo(0.0054, 6) }) + it('extracts and prices Anthropic mixed-TTL cache writes with custom rates', () => { + event.properties!.$ai_provider = 'anthropic' + event.properties!.$ai_input_token_price = 0.000003 + event.properties!.$ai_output_token_price = 0.000015 + event.properties!.$ai_cache_write_token_price = 0.000004 + event.properties!.$ai_cache_write_1h_token_price = 0.000007 + event.properties!.$ai_input_tokens = 1000 + event.properties!.$ai_output_tokens = 100 + event.properties!.$ai_cache_creation_input_tokens = 300 + event.properties!.$ai_usage = { + usage: { + inputTokens: { + total: 1300, + noCache: 1000, + cacheRead: 0, + cacheWrite: 300, + }, + outputTokens: { total: 100, text: 100, reasoning: 0 }, + raw: { + cache_creation_input_tokens: 300, + cache_creation: { + ephemeral_5m_input_tokens: 100, + ephemeral_1h_input_tokens: 200, + }, + }, + }, + providerMetadata: { + anthropic: { + usage: { + cache_creation_input_tokens: 300, + cache_creation: { + ephemeral_5m_input_tokens: 100, + ephemeral_1h_input_tokens: 200, + }, + }, + }, + }, + } + + const result = processAiEvent(event) + + expect(result.properties!.$ai_cache_creation_5m_input_tokens).toBe(100) + expect(result.properties!.$ai_cache_creation_1h_input_tokens).toBe(200) + expect(result.properties!.$ai_usage).toBeUndefined() + expect(result.properties!.$ai_input_cost_usd).toBeCloseTo(0.0048, 8) + expect(result.properties!.$ai_total_cost_usd).toBeCloseTo(0.0063, 8) + }) + + it('extracts and prices Vercel Bedrock mixed-TTL cache writes', () => { + event.properties!.$ai_model = 'anthropic/claude-sonnet-4' + event.properties!.$ai_provider = 'bedrock' + event.properties!.$ai_input_tokens = 1000 + event.properties!.$ai_output_tokens = 100 + event.properties!.$ai_cache_creation_input_tokens = 300 + const bedrockUsage = { + cacheDetails: [ + { ttl: 'T5M', inputTokens: 100 }, + { ttl: 'T1H', inputTokens: 200 }, + ], + } + event.properties!.$ai_usage = { + providerMetadata: { + amazonBedrock: { usage: bedrockUsage }, + }, + } + + const result = processAiEvent(event) + + expect(result.properties!.$ai_input_cost_usd).toBeCloseTo(0.004575, 8) + expect(result.properties!.$ai_total_cost_usd).toBeCloseTo(0.006075, 8) + expect(result.properties!.$ai_cache_creation_5m_input_tokens).toBe(100) + expect(result.properties!.$ai_cache_creation_1h_input_tokens).toBe(200) + expect(result.properties!.$ai_usage).toBeUndefined() + }) + it('falls back to multipliers when cache prices not provided', () => { event.properties!.$ai_provider = 'openai' event.properties!.$ai_input_token_price = 0.001 diff --git a/posthog/taxonomy/taxonomy.py b/posthog/taxonomy/taxonomy.py index 340e9c273b8c..b0d2ab9cdd45 100644 --- a/posthog/taxonomy/taxonomy.py +++ b/posthog/taxonomy/taxonomy.py @@ -2218,6 +2218,18 @@ class CoreFilterDefinition(TypedDict): "description": "The number of tokens created in the cache for the input prompt (anthropic only).", "examples": [23], }, + "$ai_cache_creation_5m_input_tokens": { + "label": "AI 5-minute cache creation input tokens (LLM)", + "description": "The number of tokens created in the 5-minute prompt cache (Anthropic only).", + "examples": [23], + "type": "Numeric", + }, + "$ai_cache_creation_1h_input_tokens": { + "label": "AI 1-hour cache creation input tokens (LLM)", + "description": "The number of tokens created in the 1-hour prompt cache (Anthropic only).", + "examples": [23], + "type": "Numeric", + }, "$ai_cache_reporting_exclusive": { "label": "AI cache reporting exclusive (LLM)", "description": "Whether cache tokens are excluded from the input token count. When true, cache tokens are separate from input tokens (Anthropic-style). When false, input tokens already include cache tokens. Auto-detected from provider when not set explicitly.", @@ -3151,6 +3163,12 @@ class CoreFilterDefinition(TypedDict): "examples": [0.00000375], "type": "Numeric", }, + "$ai_cache_write_1h_token_price": { + "label": "AI 1-hour cache write token price (LLM)", + "description": "The price per token written to the 1-hour prompt cache. Set this to override PostHog's cost calculation for 1-hour cache writes.", + "examples": [0.000006], + "type": "Numeric", + }, "$ai_request_price": { "label": "AI request price (LLM)", "description": "The flat per-request price charged by the LLM provider, independent of token usage.",