Spaces:
Paused
Paused
| // @ts-nocheck | |
| /** | |
| * Token Usage Tracking - Extract, normalize, estimate and log token usage | |
| */ | |
| import { appendRequestLog } from "@/lib/usageDb"; | |
| import { | |
| getLoggedInputTokens, | |
| getLoggedOutputTokens, | |
| getPromptCacheCreationTokens, | |
| getPromptCacheReadTokens, | |
| } from "@/lib/usage/tokenAccounting"; | |
| import { FORMATS } from "../translator/formats.ts"; | |
| // ANSI color codes | |
| export const COLORS = { | |
| reset: "\x1b[0m", | |
| red: "\x1b[31m", | |
| green: "\x1b[32m", | |
| yellow: "\x1b[33m", | |
| blue: "\x1b[34m", | |
| cyan: "\x1b[36m", | |
| }; | |
| /** | |
| * Safety buffer added to reported token usage to prevent clients from hitting | |
| * context window limits. Accounts for overhead from system prompts, | |
| * tool definitions, and format translation that may not be reflected in raw usage. | |
| * | |
| * Configurable via: | |
| * - Settings API / Dashboard: `usageTokenBuffer` (persisted in DB) | |
| * - Environment variable: `USAGE_TOKEN_BUFFER` | |
| * - Defaults to 2000 if neither is set | |
| * | |
| * Set to 0 to disable the buffer entirely (raw provider token counts). | |
| */ | |
| const DEFAULT_BUFFER_TOKENS = 2000; | |
| let _cachedBuffer: number | null = null; | |
| let _cacheTimestamp = 0; | |
| const CACHE_TTL_MS = 30_000; // Re-read from DB/env every 30s | |
| function getBufferTokens(): number { | |
| const now = Date.now(); | |
| const isExpired = _cachedBuffer !== null && now - _cacheTimestamp >= CACHE_TTL_MS; | |
| if (_cachedBuffer !== null && !isExpired) { | |
| return _cachedBuffer; | |
| } | |
| // Priority: env var > cached DB value > default | |
| const envVal = process.env.USAGE_TOKEN_BUFFER; | |
| if (envVal !== undefined) { | |
| const parsed = parseInt(envVal, 10); | |
| if (!isNaN(parsed) && parsed >= 0) { | |
| _cachedBuffer = parsed; | |
| _cacheTimestamp = now; | |
| return parsed; | |
| } | |
| } | |
| // Return cached value or default; kick off async DB read to update cache. | |
| // On first call (_cachedBuffer is null), use the default. | |
| // On TTL expiry (_cachedBuffer is stale), continue returning the stale value | |
| // while refreshing asynchronously — prevents blocking the hot path. | |
| if (_cachedBuffer === null || isExpired) { | |
| if (_cachedBuffer === null) { | |
| _cachedBuffer = DEFAULT_BUFFER_TOKENS; | |
| } | |
| _cacheTimestamp = now; | |
| _loadBufferFromDb(); | |
| } | |
| return _cachedBuffer; | |
| } | |
| async function _loadBufferFromDb(): Promise<void> { | |
| try { | |
| const { getSettings } = await import("@/lib/db/settings"); | |
| const settings = await getSettings(); | |
| const val = settings.usageTokenBuffer; | |
| if (typeof val === "number" && val >= 0) { | |
| _cachedBuffer = val; | |
| _cacheTimestamp = Date.now(); | |
| } | |
| } catch { | |
| // DB not ready yet or settings unavailable — keep current value | |
| } | |
| } | |
| /** Force-refresh the buffer from settings (e.g. after a settings update). */ | |
| export function invalidateBufferTokensCache(): void { | |
| _cachedBuffer = null; | |
| _cacheTimestamp = 0; | |
| } | |
| // Get HH:MM:SS timestamp | |
| function getTimeString() { | |
| return new Date().toLocaleTimeString("en-US", { | |
| hour12: false, | |
| hour: "2-digit", | |
| minute: "2-digit", | |
| second: "2-digit", | |
| }); | |
| } | |
| /** | |
| * Add buffer tokens to usage to prevent context errors | |
| * @param {object} usage - Usage object (supported format) | |
| * @returns {object} Usage with buffer added | |
| */ | |
| export function addBufferToUsage(usage) { | |
| if (!usage || typeof usage !== "object") return usage; | |
| const buffer = getBufferTokens(); | |
| if (buffer === 0) return usage; | |
| const result = { ...usage }; | |
| // Claude format | |
| if (result.input_tokens !== undefined) { | |
| result.input_tokens += buffer; | |
| } | |
| // OpenAI format | |
| if (result.prompt_tokens !== undefined) { | |
| result.prompt_tokens += buffer; | |
| } | |
| // Calculate or update total_tokens | |
| if (result.total_tokens !== undefined) { | |
| result.total_tokens += buffer; | |
| } else if (result.prompt_tokens !== undefined && result.completion_tokens !== undefined) { | |
| // Calculate total_tokens if not exists | |
| result.total_tokens = result.prompt_tokens + result.completion_tokens; | |
| } | |
| return result; | |
| } | |
| export function filterUsageForFormat(usage, targetFormat) { | |
| if (!usage || typeof usage !== "object") return usage; | |
| // Cross-map between Claude-style and OpenAI-style field names before filtering. | |
| // Some providers return input_tokens/output_tokens even when using OpenAI format. | |
| const convertedUsage = { ...usage }; | |
| if (targetFormat === FORMATS.CLAUDE || targetFormat === FORMATS.OPENAI_RESPONSES) { | |
| // OpenAI → Claude: prompt_tokens → input_tokens | |
| if (convertedUsage.prompt_tokens !== undefined && convertedUsage.input_tokens === undefined) { | |
| convertedUsage.input_tokens = convertedUsage.prompt_tokens; | |
| } | |
| if ( | |
| convertedUsage.completion_tokens !== undefined && | |
| convertedUsage.output_tokens === undefined | |
| ) { | |
| convertedUsage.output_tokens = convertedUsage.completion_tokens; | |
| } | |
| } else { | |
| // Claude → OpenAI: input_tokens → prompt_tokens | |
| if (convertedUsage.input_tokens !== undefined && convertedUsage.prompt_tokens === undefined) { | |
| convertedUsage.prompt_tokens = convertedUsage.input_tokens; | |
| } | |
| if ( | |
| convertedUsage.output_tokens !== undefined && | |
| convertedUsage.completion_tokens === undefined | |
| ) { | |
| convertedUsage.completion_tokens = convertedUsage.output_tokens; | |
| } | |
| // Ensure total_tokens is set | |
| if ( | |
| convertedUsage.total_tokens === undefined && | |
| convertedUsage.prompt_tokens !== undefined && | |
| convertedUsage.completion_tokens !== undefined | |
| ) { | |
| convertedUsage.total_tokens = convertedUsage.prompt_tokens + convertedUsage.completion_tokens; | |
| } | |
| } | |
| // Helper to pick only defined fields from usage | |
| const pickFields = (fields) => { | |
| const filtered = {}; | |
| for (const field of fields) { | |
| if (convertedUsage[field] !== undefined) { | |
| filtered[field] = convertedUsage[field]; | |
| } | |
| } | |
| return filtered; | |
| }; | |
| // Define allowed fields for each format | |
| const formatFields = { | |
| [FORMATS.CLAUDE]: [ | |
| "input_tokens", | |
| "output_tokens", | |
| "cache_read_input_tokens", | |
| "cache_creation_input_tokens", | |
| "estimated", | |
| ], | |
| [FORMATS.GEMINI]: [ | |
| "promptTokenCount", | |
| "candidatesTokenCount", | |
| "totalTokenCount", | |
| "cachedContentTokenCount", | |
| "thoughtsTokenCount", | |
| "estimated", | |
| ], | |
| [FORMATS.OPENAI_RESPONSES]: [ | |
| "input_tokens", | |
| "output_tokens", | |
| "input_tokens_details", | |
| "output_tokens_details", | |
| "estimated", | |
| ], | |
| // OpenAI format (default for OPENAI, CODEX, KIRO, etc.) | |
| default: [ | |
| "prompt_tokens", | |
| "completion_tokens", | |
| "total_tokens", | |
| "cached_tokens", | |
| "reasoning_tokens", | |
| "prompt_tokens_details", | |
| "completion_tokens_details", | |
| "estimated", | |
| ], | |
| }; | |
| // Get fields for target format | |
| let fields = formatFields[targetFormat]; | |
| // Use same fields for similar formats | |
| if (targetFormat === FORMATS.GEMINI_CLI || targetFormat === FORMATS.ANTIGRAVITY) { | |
| fields = formatFields[FORMATS.GEMINI]; | |
| } else if (targetFormat === FORMATS.OPENAI_RESPONSE) { | |
| fields = formatFields[FORMATS.OPENAI_RESPONSES]; | |
| } else if (!fields) { | |
| fields = formatFields.default; | |
| } | |
| return pickFields(fields); | |
| } | |
| /** | |
| * Normalize usage object - ensure all values are valid numbers | |
| */ | |
| export function normalizeUsage(usage) { | |
| if (!usage || typeof usage !== "object" || Array.isArray(usage)) return null; | |
| const normalized = {}; | |
| const assignNumber = (key, value) => { | |
| if (value === undefined || value === null) return; | |
| const numeric = Number(value); | |
| if (Number.isFinite(numeric)) normalized[key] = numeric; | |
| }; | |
| assignNumber("prompt_tokens", usage?.prompt_tokens); | |
| assignNumber("completion_tokens", usage?.completion_tokens); | |
| assignNumber("total_tokens", usage?.total_tokens); | |
| assignNumber("cache_read_input_tokens", usage?.cache_read_input_tokens); | |
| assignNumber("cache_creation_input_tokens", usage?.cache_creation_input_tokens); | |
| assignNumber("cached_tokens", usage?.cached_tokens); | |
| assignNumber("reasoning_tokens", usage?.reasoning_tokens); | |
| if (Object.keys(normalized).length === 0) return null; | |
| return normalized; | |
| } | |
| /** | |
| * Check if usage has valid token data | |
| * Valid = has at least one token field with value > 0 | |
| * Invalid = empty object {}, null, undefined, no token fields, or all zeros | |
| */ | |
| export function hasValidUsage(usage) { | |
| if (!usage || typeof usage !== "object") return false; | |
| // Check for known token fields with value > 0 | |
| const tokenFields = [ | |
| "prompt_tokens", | |
| "completion_tokens", | |
| "total_tokens", // OpenAI | |
| "input_tokens", | |
| "output_tokens", // Claude | |
| "promptTokenCount", | |
| "candidatesTokenCount", // Gemini | |
| ]; | |
| for (const field of tokenFields) { | |
| if (typeof usage[field] === "number" && usage[field] > 0) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| /** | |
| * Extract usage from supported formats (Claude, OpenAI, Gemini, Responses API) | |
| */ | |
| export function extractUsage(chunk) { | |
| if (!chunk || typeof chunk !== "object") return null; | |
| // Claude/Antigravity streaming: message_start event carries INPUT tokens | |
| // FIX #74: This event was not handled — input_tokens were being dropped | |
| // Structure: { type: "message_start", message: { usage: { input_tokens: N, output_tokens: 0 } } } | |
| // | |
| // Note: Claude's input_tokens is only the non-cached portion. | |
| // Sum cache tokens into prompt_tokens for a correct total (consistent with | |
| // extractUsageFromResponse in usageExtractor.ts for non-streaming). | |
| if (chunk.type === "message_start" && chunk.message?.usage) { | |
| const u = chunk.message.usage; | |
| const inputTokens = u.input_tokens || u.prompt_tokens || 0; | |
| const cacheRead = u.cache_read_input_tokens || 0; | |
| const cacheCreation = u.cache_creation_input_tokens || 0; | |
| if (inputTokens > 0 || cacheRead > 0 || cacheCreation > 0) { | |
| return normalizeUsage({ | |
| prompt_tokens: inputTokens + cacheRead + cacheCreation, | |
| completion_tokens: u.output_tokens || u.completion_tokens || 0, | |
| cache_read_input_tokens: u.cache_read_input_tokens, | |
| cache_creation_input_tokens: u.cache_creation_input_tokens, | |
| }); | |
| } | |
| } | |
| // Claude format (message_delta event) — typically carries OUTPUT tokens | |
| if (chunk.type === "message_delta" && chunk.usage && typeof chunk.usage === "object") { | |
| const deltaInput = chunk.usage.input_tokens || 0; | |
| const deltaCacheRead = chunk.usage.cache_read_input_tokens || 0; | |
| const deltaCacheCreation = chunk.usage.cache_creation_input_tokens || 0; | |
| return normalizeUsage({ | |
| prompt_tokens: deltaInput + deltaCacheRead + deltaCacheCreation, | |
| completion_tokens: chunk.usage.output_tokens || 0, | |
| cache_read_input_tokens: chunk.usage.cache_read_input_tokens, | |
| cache_creation_input_tokens: chunk.usage.cache_creation_input_tokens, | |
| }); | |
| } | |
| // OpenAI Responses API format (response.completed or response.done) | |
| if ( | |
| (chunk.type === "response.completed" || chunk.type === "response.done") && | |
| chunk.response?.usage && | |
| typeof chunk.response.usage === "object" | |
| ) { | |
| const usage = chunk.response.usage; | |
| return normalizeUsage({ | |
| prompt_tokens: usage.input_tokens || usage.prompt_tokens || 0, | |
| completion_tokens: usage.output_tokens || usage.completion_tokens || 0, | |
| cached_tokens: | |
| usage.input_tokens_details?.cached_tokens ?? | |
| usage.prompt_tokens_details?.cached_tokens ?? | |
| usage.cache_read_input_tokens, | |
| cache_creation_input_tokens: usage.cache_creation_input_tokens, | |
| reasoning_tokens: | |
| usage.output_tokens_details?.reasoning_tokens ?? | |
| usage.completion_tokens_details?.reasoning_tokens ?? | |
| usage.reasoning_tokens, | |
| }); | |
| } | |
| // OpenAI format | |
| if ( | |
| chunk.usage && | |
| typeof chunk.usage === "object" && | |
| (chunk.usage.prompt_tokens !== undefined || chunk.usage.input_tokens !== undefined) | |
| ) { | |
| return normalizeUsage({ | |
| prompt_tokens: chunk.usage.prompt_tokens ?? chunk.usage.input_tokens ?? 0, | |
| completion_tokens: chunk.usage.completion_tokens ?? chunk.usage.output_tokens ?? 0, | |
| cached_tokens: | |
| chunk.usage.prompt_tokens_details?.cached_tokens ?? | |
| chunk.usage.input_tokens_details?.cached_tokens, | |
| reasoning_tokens: | |
| chunk.usage.completion_tokens_details?.reasoning_tokens ?? | |
| chunk.usage.output_tokens_details?.reasoning_tokens, | |
| }); | |
| } | |
| // Gemini format (Antigravity) | |
| if (chunk.usageMetadata && typeof chunk.usageMetadata === "object") { | |
| return normalizeUsage({ | |
| prompt_tokens: chunk.usageMetadata?.promptTokenCount || 0, | |
| completion_tokens: chunk.usageMetadata?.candidatesTokenCount || 0, | |
| total_tokens: chunk.usageMetadata?.totalTokenCount, | |
| cached_tokens: chunk.usageMetadata?.cachedContentTokenCount, | |
| reasoning_tokens: chunk.usageMetadata?.thoughtsTokenCount, | |
| }); | |
| } | |
| return null; | |
| } | |
| // Heuristic token estimation constants | |
| const CHARS_PER_TOKEN_SCHEMA = 6; // ~6 chars/token for JSON schemas (more verbose per token) | |
| /** | |
| * Improved token estimation heuristic (no dependency). | |
| * Splits text on common token boundaries (whitespace, punctuation, camelCase) | |
| * and applies a sub-word correction factor. Better accuracy for: | |
| * - English text (~4 chars/token) | |
| * - CJK text (~1 char/token for ideographs) | |
| * - Code (~3.5 chars/token, more punctuation-heavy) | |
| * | |
| * @param {string} text - Text to estimate tokens for | |
| * @returns {number} Estimated token count | |
| */ | |
| function estimateTokenCount(text) { | |
| if (!text || typeof text !== "string") return 0; | |
| // Count CJK ideographs separately — each is roughly 1 token | |
| const cjkMatches = text.match(/[\u3000-\u9fff\uf900-\ufaff\u{20000}-\u{2fa1f}]/gu); | |
| const cjkCount = cjkMatches ? cjkMatches.length : 0; | |
| // Remove CJK chars for the remaining estimation | |
| const nonCJK = text.replace(/[\u3000-\u9fff\uf900-\ufaff]/g, " "); | |
| // Split on token boundaries: whitespace, punctuation, camelCase transitions | |
| const tokens = nonCJK | |
| .split(/(\s+|[^\w\s]|(?<=[a-z])(?=[A-Z]))/) | |
| .filter((t) => t && t.trim().length > 0); | |
| // Apply sub-word correction: BPE tokenizers often split long words | |
| // into sub-word pieces, so raw token count underestimates slightly | |
| const estimatedNonCJK = Math.ceil(tokens.length * 1.3); | |
| return cjkCount + estimatedNonCJK; | |
| } | |
| /** | |
| * Estimate input tokens from request body. | |
| * Separates tool definitions (JSON schemas) from message content | |
| * for more accurate estimation since JSON schemas are more verbose but | |
| * compress into fewer tokens than plain text. | |
| */ | |
| export function estimateInputTokens(body) { | |
| if (!body || typeof body !== "object") return 0; | |
| try { | |
| let toolTokens = 0; | |
| let messageTokens = 0; | |
| // Separate tool definitions from the rest of the body | |
| if (body.tools && Array.isArray(body.tools)) { | |
| const toolStr = JSON.stringify(body.tools); | |
| toolTokens = Math.ceil(toolStr.length / CHARS_PER_TOKEN_SCHEMA); | |
| // Estimate messages without tools | |
| const { tools, ...bodyWithoutTools } = body; | |
| messageTokens = estimateTokenCount(JSON.stringify(bodyWithoutTools)); | |
| } else { | |
| messageTokens = estimateTokenCount(JSON.stringify(body)); | |
| } | |
| return messageTokens + toolTokens; | |
| } catch (err) { | |
| // Fallback if stringify fails | |
| return 0; | |
| } | |
| } | |
| /** | |
| * Estimate output tokens from content length. | |
| * Uses improved heuristic when possible, falls back to length-based estimation. | |
| */ | |
| export function estimateOutputTokens(contentLength) { | |
| if (!contentLength || contentLength <= 0) return 0; | |
| // When we only have a character count, use 4 chars/token with sub-word correction | |
| return Math.max(1, Math.ceil(contentLength / 3.5)); | |
| } | |
| /** | |
| * Format usage object based on target format | |
| * @param {number} inputTokens - Input/prompt tokens | |
| * @param {number} outputTokens - Output/completion tokens | |
| * @param {string} targetFormat - Target format from FORMATS | |
| */ | |
| export function formatUsage(inputTokens, outputTokens, targetFormat) { | |
| // Claude format uses input_tokens/output_tokens | |
| if (targetFormat === FORMATS.CLAUDE) { | |
| return addBufferToUsage({ | |
| input_tokens: inputTokens, | |
| output_tokens: outputTokens, | |
| estimated: true, | |
| }); | |
| } | |
| // Default: OpenAI format (works for openai, gemini, responses, etc.) | |
| return addBufferToUsage({ | |
| prompt_tokens: inputTokens, | |
| completion_tokens: outputTokens, | |
| total_tokens: inputTokens + outputTokens, | |
| estimated: true, | |
| }); | |
| } | |
| /** | |
| * Estimate full usage when provider doesn't return it | |
| * @param {object} body - Request body for input token estimation | |
| * @param {number} contentLength - Content length for output token estimation | |
| * @param {string} targetFormat - Target format from FORMATS constant | |
| */ | |
| export function estimateUsage(body, contentLength, targetFormat = FORMATS.OPENAI) { | |
| return formatUsage(estimateInputTokens(body), estimateOutputTokens(contentLength), targetFormat); | |
| } | |
| /** | |
| * Log usage with cache info (green color) | |
| */ | |
| export function logUsage( | |
| provider, | |
| usage, | |
| model: string | null = null, | |
| connectionId: string | null = null, | |
| apiKeyInfo = null | |
| ) { | |
| if (!usage || typeof usage !== "object") return; | |
| const p = provider?.toUpperCase() || "UNKNOWN"; | |
| // Support both formats: | |
| // - OpenAI: prompt_tokens, completion_tokens | |
| // - Claude: input_tokens, output_tokens | |
| const inTokens = getLoggedInputTokens(usage); | |
| const outTokens = getLoggedOutputTokens(usage); | |
| void apiKeyInfo; | |
| const normalizedConnectionId = typeof connectionId === "string" ? connectionId : undefined; | |
| const accountPrefix = normalizedConnectionId | |
| ? normalizedConnectionId.slice(0, 8) + "..." | |
| : "unknown"; | |
| let msg = `[${getTimeString()}] 📊 ${COLORS.green}[USAGE] ${p} | in=${inTokens} | out=${outTokens} | account=${accountPrefix}${COLORS.reset}`; | |
| // Add estimated flag if present | |
| if (usage.estimated) { | |
| msg += ` ${COLORS.yellow}(estimated)${COLORS.reset}`; | |
| } | |
| // Add cache info if present (unified from different formats) | |
| const cacheRead = getPromptCacheReadTokens(usage); | |
| if (cacheRead) msg += ` | cache_read=${cacheRead}`; | |
| const cacheCreation = getPromptCacheCreationTokens(usage); | |
| if (cacheCreation) msg += ` | cache_create=${cacheCreation}`; | |
| const reasoning = usage.reasoning_tokens; | |
| if (reasoning) msg += ` | reasoning=${reasoning}`; | |
| console.log(msg); | |
| // Streaming requests persist usage once in chatCore's completion callback. | |
| // Keep this helper side-effect free apart from console visibility. | |
| const tokens = { | |
| input: inTokens, | |
| output: outTokens, | |
| cacheRead: cacheRead || 0, | |
| cacheCreation: cacheCreation || 0, | |
| reasoning: reasoning || 0, | |
| }; | |
| appendRequestLog({ | |
| model: typeof model === "string" ? model : undefined, | |
| provider: typeof provider === "string" ? provider : undefined, | |
| connectionId: normalizedConnectionId, | |
| tokens, | |
| status: "200 OK", | |
| }).catch(() => {}); | |
| } | |