import { modelCatalog } from "@internal/llm-model-catalog"; import type { CreateEventInput, LlmMetricsData } from "../eventRepository/eventRepository.types"; // Registry interface — matches ModelPricingRegistry from @internal/llm-model-catalog type CostRegistry = { isLoaded: boolean; calculateCost( responseModel: string, usageDetails: Record ): { matchedModelId: string; matchedModelName: string; pricingTierId: string; pricingTierName: string; inputCost: number; outputCost: number; totalCost: number; costDetails: Record; } | null; }; let _registry: CostRegistry | undefined; const ENRICHABLE_KINDS = new Set(["INTERNAL", "SERVER", "CLIENT", "CONSUMER", "PRODUCER"]); export function setLlmPricingRegistry(registry: CostRegistry): void { _registry = registry; } export function enrichCreatableEvents(events: CreateEventInput[]) { return events.map((event) => { return enrichCreatableEvent(event); }); } function enrichCreatableEvent(event: CreateEventInput): CreateEventInput { const message = formatPythonStyle(event.message, event.properties); event.message = message; event.style = enrichStyle(event); enrichLlmMetrics(event); enrichPromptResolve(event); return event; } function enrichLlmMetrics(event: CreateEventInput): void { const props = event.properties; if (!props) return; // Only enrich span-like events (INTERNAL, SERVER, CLIENT, CONSUMER, PRODUCER — not LOG, UNSPECIFIED) if (!ENRICHABLE_KINDS.has(event.kind as string)) return; // Skip partial spans (they don't have final token counts) if (event.isPartial) return; // Only use gen_ai.* attributes for model resolution to avoid double-counting. // The Vercel AI SDK emits both a parent span (ai.streamText with ai.usage.*) // and a child span (ai.streamText.doStream with gen_ai.*). We only enrich the // child span that has the canonical gen_ai.response.model attribute. const responseModel = typeof props["gen_ai.response.model"] === "string" ? props["gen_ai.response.model"] : typeof props["gen_ai.request.model"] === "string" ? props["gen_ai.request.model"] : null; if (!responseModel) { return; } // Extract usage details, normalizing attribute names const usageDetails = extractUsageDetails(props); // Need at least some token usage const hasTokens = Object.values(usageDetails).some((v) => v > 0); if (!hasTokens) { return; } // Add style accessories for model and tokens (even without cost data) const inputTokens = usageDetails["input"] ?? 0; const outputTokens = usageDetails["output"] ?? 0; const totalTokens = usageDetails["total"] ?? inputTokens + outputTokens; const pillItems: Array<{ text: string; icon: string }> = [ { text: responseModel, icon: "tabler-cube" }, { text: formatTokenCount(totalTokens), icon: "tabler-hash" }, ]; // Provider-reported cost (gateway/openrouter) is the exact per-request bill and already // reflects cache-read discounts and the real per-provider rate, so prefer it and only fall // back to catalog pricing when it is absent. The registry handles prefix stripping (e.g. // "mistral/mistral-large-3" → "mistral-large-3") for gateway/openrouter models in match(). const providerCost = extractProviderCost(props); let cost: ReturnType["calculateCost"]> | null = null; if (!providerCost && _registry?.isLoaded) { cost = _registry.calculateCost(responseModel, usageDetails); } if (cost) { // Add trigger.llm.* attributes to the span from our pricing registry event.properties = { ...props, "trigger.llm.input_cost": cost.inputCost, "trigger.llm.output_cost": cost.outputCost, "trigger.llm.total_cost": cost.totalCost, "trigger.llm.cached_cost": cost.costDetails["input_cached_tokens"] ?? 0, "trigger.llm.cache_creation_cost": cost.costDetails["cache_creation_input_tokens"] ?? 0, "trigger.llm.matched_model": cost.matchedModelName, "trigger.llm.matched_model_id": cost.matchedModelId, "trigger.llm.pricing_tier": cost.pricingTierName, "trigger.llm.pricing_tier_id": cost.pricingTierId, }; pillItems.push({ text: formatCost(cost.totalCost), icon: "tabler-currency-dollar" }); } else if (providerCost) { // Use provider-reported cost as fallback (no input/output breakdown available) event.properties = { ...props, "trigger.llm.total_cost": providerCost.totalCost, "trigger.llm.cost_source": providerCost.source, }; pillItems.push({ text: formatCost(providerCost.totalCost), icon: "tabler-currency-dollar" }); } event.style = { ...(event.style as Record | undefined), accessory: { style: "pills", items: pillItems, }, } as unknown as typeof event.style; // Only write llm_metrics when cost data is available if (!cost && !providerCost) return; // Build metadata map from run tags and ai.telemetry.metadata.* const metadata: Record = {}; if (event.runTags) { for (const tag of event.runTags) { const colonIdx = tag.indexOf(":"); if (colonIdx > 0) { metadata[tag.substring(0, colonIdx)] = tag.substring(colonIdx + 1); } } } for (const [key, value] of Object.entries(props)) { if (key.startsWith("ai.telemetry.metadata.") && typeof value === "string") { metadata[key.slice("ai.telemetry.metadata.".length)] = value; } } // Extract new performance/behavioral fields. // v6 emits ai.response.finishReason (plain string); v7 (@ai-sdk/otel) emits // gen_ai.response.finish_reasons as a JSON array string (e.g. `["stop"]`). const finishReason = readFinishReason(props); const operationId = typeof props["ai.operationId"] === "string" ? props["ai.operationId"] : typeof props["gen_ai.operation.name"] === "string" ? props["gen_ai.operation.name"] : typeof props["operation.name"] === "string" ? props["operation.name"] : ""; const msToFirstChunk = typeof props["ai.response.msToFirstChunk"] === "number" ? props["ai.response.msToFirstChunk"] : 0; const avgTokensPerSec = typeof props["ai.response.avgOutputTokensPerSecond"] === "number" ? props["ai.response.avgOutputTokensPerSecond"] : 0; const costSource = cost ? "registry" : providerCost ? providerCost.source : ""; const providerCostValue = providerCost?.totalCost ?? 0; // Set _llmMetrics side-channel for dual-write to llm_metrics_v1 const llmMetrics: LlmMetricsData = { genAiSystem: typeof props["gen_ai.system"] === "string" ? props["gen_ai.system"] : typeof props["gen_ai.provider.name"] === "string" ? props["gen_ai.provider.name"] : "unknown", requestModel: typeof props["gen_ai.request.model"] === "string" ? props["gen_ai.request.model"] : responseModel, responseModel, baseResponseModel: modelCatalog[responseModel]?.baseModelName ?? responseModel, matchedModelId: cost?.matchedModelId ?? "", operationId, finishReason, costSource, pricingTierId: cost?.pricingTierId ?? (providerCost ? `provider:${providerCost.source}` : ""), pricingTierName: cost?.pricingTierName ?? (providerCost ? `${providerCost.source} reported` : ""), inputTokens: usageDetails["input"] ?? 0, outputTokens: usageDetails["output"] ?? 0, totalTokens: usageDetails["total"] ?? (usageDetails["input"] ?? 0) + (usageDetails["output"] ?? 0), usageDetails, inputCost: cost?.inputCost ?? 0, outputCost: cost?.outputCost ?? 0, totalCost: cost?.totalCost ?? providerCost?.totalCost ?? 0, costDetails: cost?.costDetails ?? {}, providerCost: providerCostValue, msToFirstChunk, tokensPerSecond: avgTokensPerSec, metadata, promptSlug: metadata["prompt.slug"] ?? "", promptVersion: parseInt(metadata["prompt.version"] ?? "0", 10) || 0, }; event._llmMetrics = llmMetrics; } function extractUsageDetails(props: Record): Record { const details: Record = {}; // Only map gen_ai.usage.* attributes — NOT ai.usage.* from parent spans. // This prevents double-counting when both parent (ai.streamText) and child // (ai.streamText.doStream) spans carry token counts. const mappings: Record = { "gen_ai.usage.input_tokens": "input", "gen_ai.usage.output_tokens": "output", "gen_ai.usage.prompt_tokens": "input", "gen_ai.usage.completion_tokens": "output", "gen_ai.usage.total_tokens": "total", "gen_ai.usage.cache_read_input_tokens": "input_cached_tokens", "gen_ai.usage.input_tokens_cache_read": "input_cached_tokens", // AI SDK 7 (@ai-sdk/otel) nests cache token counts: gen_ai.usage.cache_read.input_tokens "gen_ai.usage.cache_read.input_tokens": "input_cached_tokens", "gen_ai.usage.cache_creation_input_tokens": "cache_creation_input_tokens", "gen_ai.usage.input_tokens_cache_write": "cache_creation_input_tokens", "gen_ai.usage.cache_creation.input_tokens": "cache_creation_input_tokens", "gen_ai.usage.reasoning_tokens": "reasoning_tokens", }; for (const [attrKey, usageKey] of Object.entries(mappings)) { const value = props[attrKey]; if (typeof value === "number" && value > 0) { // Don't overwrite if already set (first mapping wins) if (details[usageKey] === undefined) { details[usageKey] = value; } } } return details; } /** * Resolve the finish reason across AI SDK majors. v6 emits * `ai.response.finishReason` as a plain string; v7 (@ai-sdk/otel) emits * `gen_ai.response.finish_reasons` as a JSON array string (e.g. `["stop"]`). */ function readFinishReason(props: Record): string { const v6 = props["ai.response.finishReason"]; if (typeof v6 === "string" && v6) return v6; const v7 = props["gen_ai.response.finish_reasons"]; if (typeof v7 === "string" && v7) { const trimmed = v7.trim(); if (trimmed.startsWith("[")) { try { const parsed = JSON.parse(trimmed); if (Array.isArray(parsed)) { const first = parsed.find((r) => typeof r === "string"); if (typeof first === "string") return first; } } catch { // fall through to the raw value } } return v7; } return ""; } function enrichStyle(event: CreateEventInput) { const baseStyle = event.style ?? {}; const props = event.properties; if (!props) { return baseStyle; } const system = props["gen_ai.system"] ?? props["gen_ai.provider.name"]; const modelId = props["gen_ai.request.model"] ?? props["ai.model.id"]; const provider = resolveAiProvider( typeof system === "string" ? system : undefined, typeof modelId === "string" ? modelId : undefined ); if (provider) { return { ...baseStyle, icon: `ai-provider-${provider}` }; } // Agent workflow check const name = props["name"]; if (typeof name === "string" && name.includes("Agent workflow")) { return { ...baseStyle, icon: "tabler-brain" }; } const message = event.message; if (typeof message === "string" && message === "ai.toolCall") { return { ...baseStyle, icon: "hero-wrench" }; } if (typeof message === "string" && message.startsWith("ai.")) { return { ...baseStyle, icon: "hero-sparkles" }; } return baseStyle; } function formatTokenCount(tokens: number): string { if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`; return tokens.toString(); } /** * Extract provider-reported cost from ai.response.providerMetadata. * Gateway and OpenRouter include per-request cost in their metadata. */ function extractProviderCost( props: Record ): { totalCost: number; source: string } | null { const rawMeta = props["ai.response.providerMetadata"]; if (typeof rawMeta !== "string") return null; // Cheap guard: providerMetadata can be large for reasoning models (it carries the full // reasoning_details text), and this now runs on every AI span. Skip the JSON parse when // there is no cost field to find. if (!rawMeta.includes('"cost"')) return null; let meta: Record; try { meta = JSON.parse(rawMeta) as Record; } catch { return null; } if (!meta || typeof meta !== "object") return null; // Gateway: { gateway: { cost: "0.0006615" } } const gateway = meta.gateway; if (gateway && typeof gateway === "object") { const gw = gateway as Record; const cost = parseFloat(String(gw.cost ?? "0")); if (cost > 0) return { totalCost: cost, source: "gateway" }; } // OpenRouter: { openrouter: { usage: { cost: 0.000135 } } } const openrouter = meta.openrouter; if (openrouter && typeof openrouter === "object") { const or = openrouter as Record; const usage = or.usage; if (usage && typeof usage === "object") { const cost = Number((usage as Record).cost ?? 0); if (cost > 0) return { totalCost: cost, source: "openrouter" }; } } return null; } function formatCost(cost: number): string { if (cost >= 1) return `$${cost.toFixed(2)}`; if (cost >= 0.01) return `$${cost.toFixed(4)}`; return `$${cost.toFixed(6)}`; } function repr(value: any): string { if (typeof value === "string") { return `'${value}'`; } return String(value); } function formatPythonStyle(template: string, values: Record): string { // Early return if template is too long if (template.length >= 256) { return template; } // Early return if no template variables present if (!template.includes("{")) { return template; } return template.replace(/\{([^}]+?)(?:!r)?\}/g, (match, key) => { const hasRepr = match.endsWith("!r}"); const actualKey = hasRepr ? key : key; const value = values?.[actualKey]; if (value === undefined) { return match; } return hasRepr ? repr(value) : String(value); }); } type AiProvider = | "anthropic" | "openai" | "gemini" | "llama" | "deepseek" | "xai" | "perplexity" | "cerebras" | "azure" | "mistral"; const systemToProvider: Record = { anthropic: "anthropic", openai: "openai", azure: "azure", "google.generative-ai": "gemini", google: "gemini", xai: "xai", deepseek: "deepseek", cerebras: "cerebras", perplexity: "perplexity", "meta-llama": "llama", mistral: "mistral", }; const modelPatterns: [RegExp, AiProvider][] = [ [/\banthropic\b|claude/i, "anthropic"], [/\bopenai\b|gpt-|o[134]-|chatgpt/i, "openai"], [/gemini/i, "gemini"], [/llama/i, "llama"], [/deepseek/i, "deepseek"], [/grok/i, "xai"], [/sonar/i, "perplexity"], [/cerebras/i, "cerebras"], [/mistral|mixtral|codestral|pixtral/i, "mistral"], ]; function resolveAiProvider( system: string | undefined, modelId: string | undefined ): AiProvider | undefined { if (modelId) { if (modelId.includes("/")) { const prefix = modelId.split("/")[0].toLowerCase(); const fromPrefix = systemToProvider[prefix]; if (fromPrefix) return fromPrefix; } for (const [pattern, provider] of modelPatterns) { if (pattern.test(modelId)) return provider; } } if (system) { const normalized = system.toLowerCase().split(".")[0]; return systemToProvider[system] ?? systemToProvider[normalized]; } return undefined; } function enrichPromptResolve(event: CreateEventInput): void { const props = event.properties; if (!props) return; const slug = props["prompt.slug"]; const version = props["prompt.version"]; if (typeof slug !== "string") return; const style = (event.style ?? {}) as Record; const accessory = style.accessory as Record | undefined; const existingItems = accessory && "items" in accessory ? (accessory.items as Array<{ text: string; icon?: string; variant?: string }>) : []; const items = [ ...existingItems, { text: `${slug}${typeof version === "number" ? ` v${version}` : ""}`, icon: "tabler-file-text-ai", }, ]; event.style = { ...style, icon: style.icon ?? "tabler-file-text-ai", accessory: { style: "pills" as const, items }, } as unknown as typeof event.style; }