UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

322 lines (321 loc) • 12.8 kB
/** * Centralized token usage extraction utilities * Handles multiple provider formats and optional fields * * Consolidates token extraction logic from: * - analytics.ts * - streamAnalytics.ts */ /** * Extract input token count from various provider formats * Priority: input > inputTokens > promptTokens */ export function extractInputTokens(usage) { if (typeof usage.input === "number") { return usage.input; } if (typeof usage.inputTokens === "number") { return usage.inputTokens; } if (typeof usage.promptTokens === "number") { return usage.promptTokens; } return 0; } /** * Extract output token count from various provider formats * Priority: output > outputTokens > completionTokens */ export function extractOutputTokens(usage) { if (typeof usage.output === "number") { return usage.output; } if (typeof usage.outputTokens === "number") { return usage.outputTokens; } if (typeof usage.completionTokens === "number") { return usage.completionTokens; } return 0; } /** * Extract total token count from various provider formats * Falls back to input + output if total is not provided */ export function extractTotalTokens(usage, input, output) { // A literal 0 alongside non-zero components means the provider omitted the // figure (several gateways/parsers emit total: 0) — fall through to the // computed sum instead of letting the zero win. if (typeof usage.total === "number" && usage.total > 0) { return usage.total; } if (typeof usage.totalTokens === "number" && usage.totalTokens > 0) { return usage.totalTokens; } return input + output; } /** * Extract reasoning/thinking token count from various provider formats * Supports: reasoningTokens, thinkingTokens, reasoning_tokens, reasoning */ export function extractReasoningTokens(usage) { if (typeof usage.reasoningTokens === "number" && usage.reasoningTokens > 0) { return usage.reasoningTokens; } if (typeof usage.thinkingTokens === "number" && usage.thinkingTokens > 0) { return usage.thinkingTokens; } if (typeof usage.reasoning_tokens === "number" && usage.reasoning_tokens > 0) { return usage.reasoning_tokens; } if (typeof usage.reasoning === "number" && usage.reasoning > 0) { return usage.reasoning; } return undefined; } /** * Extract cache creation token count from various provider formats * Supports: cacheCreationInputTokens, cacheCreationTokens, and the ai@6 * normalized `inputTokenDetails.cacheWriteTokens` (the only shape the native * direct-Anthropic path reports through generateText). */ export function extractCacheCreationTokens(usage) { if (typeof usage.cacheCreationInputTokens === "number" && usage.cacheCreationInputTokens > 0) { return usage.cacheCreationInputTokens; } if (typeof usage.cacheCreationTokens === "number" && usage.cacheCreationTokens > 0) { return usage.cacheCreationTokens; } if (typeof usage.inputTokenDetails?.cacheWriteTokens === "number" && usage.inputTokenDetails.cacheWriteTokens > 0) { return usage.inputTokenDetails.cacheWriteTokens; } return undefined; } /** * Extract cache read token count from various provider formats * Supports: cacheReadInputTokens, cacheReadTokens, and the ai@6 normalized * shapes — flat `cachedInputTokens` and nested * `inputTokenDetails.cacheReadTokens` (the only shapes the native * direct-Anthropic path reports through generateText). */ export function extractCacheReadTokens(usage) { if (typeof usage.cacheReadInputTokens === "number" && usage.cacheReadInputTokens > 0) { return usage.cacheReadInputTokens; } if (typeof usage.cacheReadTokens === "number" && usage.cacheReadTokens > 0) { return usage.cacheReadTokens; } if (typeof usage.cachedInputTokens === "number" && usage.cachedInputTokens > 0) { return usage.cachedInputTokens; } if (typeof usage.inputTokenDetails?.cacheReadTokens === "number" && usage.inputTokenDetails.cacheReadTokens > 0) { return usage.inputTokenDetails.cacheReadTokens; } return undefined; } /** * Extract cache read token count from the OVERLAPPING-convention nested path * used by OpenAI / DeepSeek / NIM / OpenAI-compatible providers: * `usage.prompt_tokens_details.cached_tokens`. * * Unlike {@link extractCacheReadTokens} (non-overlapping Anthropic/Vertex * convention where cache tokens are reported SEPARATELY from input), the value * returned here is a SUBSET already included in `prompt_tokens`. Callers that * use this value MUST subtract it from `input` to avoid double-counting. */ export function extractCachedInputTokensOverlapping(usage) { const cached = usage.prompt_tokens_details?.cached_tokens; if (typeof cached === "number" && cached > 0) { return cached; } return undefined; } /** * Calculate cache savings percentage * * This represents the percentage of input tokens served from cache. * For Anthropic, cache read tokens cost 0.1x, so actual cost savings = cacheSavingsPercent * 0.9 * For other providers, cost savings may vary based on their cache pricing. * * @param cacheReadTokens Number of tokens read from cache * @param inputTokens Number of non-cached input tokens * @returns Percentage of tokens served from cache (0-100), or undefined if no cache usage */ export function calculateCacheSavingsPercent(cacheReadTokens, inputTokens) { if (cacheReadTokens === undefined || cacheReadTokens <= 0) { return undefined; } const totalInputWithCache = inputTokens + cacheReadTokens; if (totalInputWithCache <= 0) { return undefined; } return Math.round((cacheReadTokens / totalInputWithCache) * 100); } /** * Extract token usage from various provider response formats * * Handles multiple input formats: * - BaseProvider normalized format (input/output/total) * - AI SDK format (inputTokens/outputTokens/totalTokens) * - OpenAI/Mistral format (promptTokens/completionTokens) * - Nested usage objects * * Also extracts optional fields: * - Cache creation and read tokens (Anthropic-style) * - Reasoning/thinking tokens (OpenAI o1, Anthropic, Google) * - Cache savings percentage * * @param result Raw usage object from provider response * @param options Extraction options * @returns Normalized TokenUsage object */ export function extractTokenUsage(result, options = {}) { const { calculateCacheSavings = true, missingOptionalBehavior = "undefined", } = options; // Handle null/undefined input if (!result) { return { input: 0, output: 0, total: 0 }; } // Handle nested usage object (some providers wrap usage in a usage property) const usage = result.usage && typeof result.usage === "object" ? result.usage : result; // Extract base token counts let input = extractInputTokens(usage); const output = extractOutputTokens(usage); // Extract optional token fields const reasoning = extractReasoningTokens(usage); const cacheCreationTokens = extractCacheCreationTokens(usage); let cacheReadTokens = extractCacheReadTokens(usage); // Overlapping-convention fallback (OpenAI/DeepSeek/NIM/OpenAI-compatible): // when no non-overlapping cache-read field was present, look for the nested // `prompt_tokens_details.cached_tokens`. That value is a SUBSET already // included in `input`, so we must subtract it from `input` to avoid // double-counting (and to let calculateCost apply the cheaper cacheRead rate // to the cached portion). Only do this when cached <= input so a malformed // response can never produce negative input or inflate the total. if (cacheReadTokens === undefined) { const overlappingCached = extractCachedInputTokensOverlapping(usage); if (overlappingCached !== undefined && overlappingCached <= input) { cacheReadTokens = overlappingCached; input = Math.max(0, input - overlappingCached); } } // ai@6-shape rebase: when the cache values came ONLY from the ai@6 // normalized shape (cachedInputTokens / inputTokenDetails.*), the flat // input is cache-INCLUSIVE — asLanguageModelUsage reports // inputTokens.total = noCache + cacheRead + cacheWrite — so `input` must // be rebased onto the uncached portion or calculateCost bills the cached // tokens twice (full input rate + cacheRead/cacheCreation rate). The // native fields excluded below (cacheReadInputTokens / cacheReadTokens / // cacheCreationInputTokens / cacheCreationTokens) follow the // non-overlapping convention where input is already the uncached // remainder, and the overlapping snake_case shape was already rebased // above — neither must be rebased again. if ((cacheReadTokens !== undefined || cacheCreationTokens !== undefined) && usage.cacheReadInputTokens === undefined && usage.cacheReadTokens === undefined && usage.cacheCreationInputTokens === undefined && usage.cacheCreationTokens === undefined && usage.prompt_tokens_details?.cached_tokens === undefined && (usage.cachedInputTokens !== undefined || usage.inputTokenDetails !== undefined)) { const noCacheTokens = usage.inputTokenDetails?.noCacheTokens; if (typeof noCacheTokens === "number") { input = noCacheTokens; } else { input = Math.max(0, input - (cacheReadTokens ?? 0) - (usage.inputTokenDetails?.cacheWriteTokens ?? 0)); } } // Total: prefer the provider-reported figure; the fallback conserves every // billed component — cache tokens are additive on top of the (post-rebase) // uncached input. Reasoning is already inside `output`, so it is NOT added. const total = extractTotalTokens(usage, input + (cacheReadTokens ?? 0) + (cacheCreationTokens ?? 0), output); // Calculate cache savings if enabled const cacheSavingsPercent = calculateCacheSavings ? calculateCacheSavingsPercent(cacheReadTokens, input) : undefined; // Build result object const tokenUsage = { input, output, total, }; // Add optional fields based on behavior setting if (missingOptionalBehavior === "zero") { tokenUsage.cacheCreationTokens = cacheCreationTokens ?? 0; tokenUsage.cacheReadTokens = cacheReadTokens ?? 0; tokenUsage.reasoning = reasoning ?? 0; tokenUsage.cacheSavingsPercent = cacheSavingsPercent ?? 0; } else { // Only include optional fields if they have values if (cacheCreationTokens !== undefined) { tokenUsage.cacheCreationTokens = cacheCreationTokens; } if (cacheReadTokens !== undefined) { tokenUsage.cacheReadTokens = cacheReadTokens; } if (reasoning !== undefined) { tokenUsage.reasoning = reasoning; } if (cacheSavingsPercent !== undefined) { tokenUsage.cacheSavingsPercent = cacheSavingsPercent; } } return tokenUsage; } /** * Create a default/empty TokenUsage object * Useful for error handling and fallback scenarios */ export function createEmptyTokenUsage() { return { input: 0, output: 0, total: 0, }; } /** * Check if a TokenUsage object has any non-zero values */ export function hasTokenUsage(usage) { return usage.input > 0 || usage.output > 0 || usage.total > 0; } /** * Merge two TokenUsage objects by summing their values * Useful for aggregating usage across multiple calls */ export function mergeTokenUsage(a, b) { const merged = { input: a.input + b.input, output: a.output + b.output, total: a.total + b.total, }; // Merge optional fields if present in either const cacheCreationTokens = (a.cacheCreationTokens ?? 0) + (b.cacheCreationTokens ?? 0); const cacheReadTokens = (a.cacheReadTokens ?? 0) + (b.cacheReadTokens ?? 0); const reasoning = (a.reasoning ?? 0) + (b.reasoning ?? 0); if (cacheCreationTokens > 0) { merged.cacheCreationTokens = cacheCreationTokens; } if (cacheReadTokens > 0) { merged.cacheReadTokens = cacheReadTokens; } if (reasoning > 0) { merged.reasoning = reasoning; } // Recalculate cache savings for merged usage const cacheSavingsPercent = calculateCacheSavingsPercent(merged.cacheReadTokens, merged.input); if (cacheSavingsPercent !== undefined) { merged.cacheSavingsPercent = cacheSavingsPercent; } return merged; }