From dadff67fbfa587a2645892528f724ccfbf953a95 Mon Sep 17 00:00:00 2001 From: Andrew Munsell Date: Sun, 3 May 2026 21:02:45 -0700 Subject: [PATCH] feat(logs): show compression tokens in request log UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add tokens_compressed INTEGER column to call_logs (migration 041) - Capture compression savings from both legacy compressContext() and modular applyCompression() pipelines in chatCore.ts - Clamp token delta with Math.max(0, ...) in both compression paths - Display inline ↓N purple badge in log list view tokens cell - Show "Compressed: FROM → TO (-X%)" badge in detail modal Input section - Bump CallLogArtifact schemaVersion from 4 to 5 - Add functional round-trip test for tokensCompressed persistence - Fix call-log-cap test to match bumped schemaVersion --- open-sse/handlers/chatCore.ts | 7 ++ src/i18n/messages/en.json | 1 + src/lib/db/core.ts | 5 +- src/lib/db/migrationRunner.ts | 2 + .../db/migrations/041_compression_tokens.sql | 4 + src/lib/usage/callLogArtifacts.ts | 3 +- src/lib/usage/callLogs.ts | 11 ++- src/lib/usage/migrations.ts | 2 +- src/shared/components/RequestLoggerDetail.tsx | 10 ++ src/shared/components/RequestLoggerV2.tsx | 11 +++ tests/unit/call-log-cap.test.ts | 2 +- tests/unit/compression-tokens.test.ts | 98 +++++++++++++++++++ 12 files changed, 149 insertions(+), 7 deletions(-) create mode 100644 src/lib/db/migrations/041_compression_tokens.sql create mode 100644 tests/unit/compression-tokens.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index dbf58ed0cf15..e271e32ba394 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -954,6 +954,7 @@ export async function handleChatCore({ const suffix = extra ? ` ${JSON.stringify(extra)}` : ""; log?.info?.("STAGE_TRACE", `${traceId} ${label} t=${elapsed}ms${suffix}`); }; + let tokensCompressed: number | null = null; const persistFailureUsage = (statusCode: number, errorCode?: string | null) => { saveRequestUsage({ provider: provider || "unknown", @@ -1285,6 +1286,7 @@ export async function handleChatCore({ comboName, comboStepId, comboExecutionKey, + tokensCompressed, cacheSource: cacheSource === "semantic" ? "semantic" : "upstream", apiKeyId: apiKeyInfo?.id || null, apiKeyName: apiKeyInfo?.name || null, @@ -1652,6 +1654,10 @@ export async function handleChatCore({ if (result.compressed && result.stats) { body = result.body as typeof body; estimatedTokens = result.stats.compressedTokens; + tokensCompressed = Math.max( + 0, + result.stats.originalTokens - result.stats.compressedTokens + ); trackCompressionStats(result.stats); void (async () => { try { @@ -1793,6 +1799,7 @@ export async function handleChatCore({ if (compressionResult.compressed) { body = compressionResult.body; const stats = compressionResult.stats; + tokensCompressed = Math.max(0, (stats?.original ?? 0) - (stats?.final ?? 0)); const layersInfo = stats && "layers" in stats && Array.isArray(stats.layers) ? ` (layers: ${stats.layers.map((l: { name: string }) => l.name).join(", ")})` diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index d61db2dfc3c1..07d72224224c 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -5112,6 +5112,7 @@ "apiKey": "API Key", "combo": "Combo", "tokens": "Tokens", + "compressed": "Compression", "tps": "TPS", "duration": "Duration", "time": "Time" diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index 80d5a030b5a0..6d7ae9553edf 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -232,6 +232,7 @@ const SCHEMA_SQL = ` tokens_cache_read INTEGER DEFAULT NULL, tokens_cache_creation INTEGER DEFAULT NULL, tokens_reasoning INTEGER DEFAULT NULL, + tokens_compressed INTEGER DEFAULT NULL, cache_source TEXT DEFAULT "upstream", request_type TEXT, source_format TEXT, @@ -787,6 +788,7 @@ function offloadLegacyCallLogDetails(db: SqliteDatabase) { tokens_cache_read: number | null; tokens_cache_creation: number | null; tokens_reasoning: number | null; + tokens_compressed: number | null; request_type: string | null; source_format: string | null; target_format: string | null; @@ -838,7 +840,7 @@ function offloadLegacyCallLogDetails(db: SqliteDatabase) { const tx = db.transaction(() => { for (const row of pendingRows) { const artifact: CallLogArtifact = { - schemaVersion: 4, + schemaVersion: 5, summary: { id: row.id, timestamp: row.timestamp || new Date().toISOString(), @@ -857,6 +859,7 @@ function offloadLegacyCallLogDetails(db: SqliteDatabase) { cacheRead: row.tokens_cache_read ?? null, cacheWrite: row.tokens_cache_creation ?? null, reasoning: row.tokens_reasoning ?? null, + compressed: row.tokens_compressed ?? null, }, requestType: row.request_type || null, sourceFormat: row.source_format || null, diff --git a/src/lib/db/migrationRunner.ts b/src/lib/db/migrationRunner.ts index 338d7aa9ecce..738531f84c48 100644 --- a/src/lib/db/migrationRunner.ts +++ b/src/lib/db/migrationRunner.ts @@ -266,6 +266,8 @@ function isSchemaAlreadyApplied( return hasColumn(db, "provider_connections", "max_concurrent"); case "040": return hasColumn(db, "proxy_registry", "source"); + case "041": + return hasColumn(db, "call_logs", "tokens_compressed"); default: return false; } diff --git a/src/lib/db/migrations/041_compression_tokens.sql b/src/lib/db/migrations/041_compression_tokens.sql new file mode 100644 index 000000000000..f2ccc3251eb2 --- /dev/null +++ b/src/lib/db/migrations/041_compression_tokens.sql @@ -0,0 +1,4 @@ +-- Migration 041: Add tokens_compressed to call_logs +-- Tracks how many tokens were removed by proactive context compression. +-- NULL = compression not applied. N > 0 = tokens eliminated. +ALTER TABLE call_logs ADD COLUMN tokens_compressed INTEGER DEFAULT NULL; diff --git a/src/lib/usage/callLogArtifacts.ts b/src/lib/usage/callLogArtifacts.ts index 6ad1f26dcbe0..41d03e1648cc 100644 --- a/src/lib/usage/callLogArtifacts.ts +++ b/src/lib/usage/callLogArtifacts.ts @@ -19,7 +19,7 @@ const STREAM_CHUNKS_OMITTED_FOR_SIZE_LIMIT = export type CallLogDetailState = "none" | "ready" | "missing" | "corrupt" | "legacy-inline"; export type CallLogArtifact = { - schemaVersion: 4; + schemaVersion: 5; summary: { id: string; timestamp: string; @@ -38,6 +38,7 @@ export type CallLogArtifact = { cacheRead: number | null; cacheWrite: number | null; reasoning: number | null; + compressed: number | null; }; requestType: string | null; sourceFormat: string | null; diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index 30977f21fa16..ea231dcaf399 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -53,6 +53,7 @@ type CallLogSummaryRow = { tokens_cache_read: number | null; tokens_cache_creation: number | null; tokens_reasoning: number | null; + tokens_compressed: number | null; cache_source: string | null; request_type: string | null; source_format: string | null; @@ -286,6 +287,7 @@ function buildArtifact( tokensCacheRead: number | null; tokensCacheCreation: number | null; tokensReasoning: number | null; + tokensCompressed: number | null; requestType: string | null; sourceFormat: string | null; targetFormat: string | null; @@ -301,7 +303,7 @@ function buildArtifact( pipelinePayloads: RequestPipelinePayloads | null ): CallLogArtifact { return { - schemaVersion: 4, + schemaVersion: 5, summary: { id: logEntry.id, timestamp: logEntry.timestamp, @@ -320,6 +322,7 @@ function buildArtifact( cacheRead: logEntry.tokensCacheRead, cacheWrite: logEntry.tokensCacheCreation, reasoning: logEntry.tokensReasoning, + compressed: logEntry.tokensCompressed, }, requestType: logEntry.requestType, sourceFormat: logEntry.sourceFormat, @@ -546,6 +549,7 @@ function mapSummaryRow(row: CallLogSummaryRow) { cacheRead: row.tokens_cache_read != null ? toNumber(row.tokens_cache_read) : null, cacheWrite: row.tokens_cache_creation != null ? toNumber(row.tokens_cache_creation) : null, reasoning: row.tokens_reasoning != null ? toNumber(row.tokens_reasoning) : null, + compressed: row.tokens_compressed != null ? toNumber(row.tokens_compressed) : null, }, cacheSource: row.cache_source || "upstream", requestType: row.request_type, @@ -635,6 +639,7 @@ export async function saveCallLog(entry: any) { tokensCacheRead: getPromptCacheReadTokensOrNull(entry.tokens), tokensCacheCreation: getPromptCacheCreationTokensOrNull(entry.tokens), tokensReasoning: getReasoningTokensOrNull(entry.tokens), + tokensCompressed: entry.tokensCompressed != null ? toNumber(entry.tokensCompressed) : null, cacheSource: entry.cacheSource === "semantic" ? "semantic" : "upstream", requestType: entry.requestType || null, sourceFormat: entry.sourceFormat || null, @@ -687,7 +692,7 @@ export async function saveCallLog(entry: any) { INSERT INTO call_logs ( id, timestamp, method, path, status, model, requested_model, provider, account, connection_id, duration, tokens_in, tokens_out, - tokens_cache_read, tokens_cache_creation, tokens_reasoning, + tokens_cache_read, tokens_cache_creation, tokens_reasoning, tokens_compressed, cache_source, request_type, source_format, target_format, api_key_id, api_key_name, combo_name, combo_step_id, combo_execution_key, error_summary, detail_state, artifact_relpath, artifact_size_bytes, artifact_sha256, @@ -696,7 +701,7 @@ export async function saveCallLog(entry: any) { VALUES ( @id, @timestamp, @method, @path, @status, @model, @requestedModel, @provider, @account, @connectionId, @duration, @tokensIn, @tokensOut, - @tokensCacheRead, @tokensCacheCreation, @tokensReasoning, + @tokensCacheRead, @tokensCacheCreation, @tokensReasoning, @tokensCompressed, @cacheSource, @requestType, @sourceFormat, @targetFormat, @apiKeyId, @apiKeyName, @comboName, @comboStepId, @comboExecutionKey, @errorSummary, @detailState, @artifactRelPath, @artifactSizeBytes, @artifactSha256, diff --git a/src/lib/usage/migrations.ts b/src/lib/usage/migrations.ts index 38848e46d5b1..d44febaf9585 100644 --- a/src/lib/usage/migrations.ts +++ b/src/lib/usage/migrations.ts @@ -358,7 +358,7 @@ export function migrateUsageJsonToSqlite() { if (detailExpected) { const artifact: CallLogArtifact = { - schemaVersion: 4, + schemaVersion: 5, summary: { id, timestamp, diff --git a/src/shared/components/RequestLoggerDetail.tsx b/src/shared/components/RequestLoggerDetail.tsx index 27da5d8b1beb..d268a3ec1e43 100644 --- a/src/shared/components/RequestLoggerDetail.tsx +++ b/src/shared/components/RequestLoggerDetail.tsx @@ -158,6 +158,7 @@ export default function RequestLoggerDetail({ cacheRead: detail?.tokens?.cacheRead ?? log.tokens?.cacheRead, cacheWrite: detail?.tokens?.cacheWrite ?? log.tokens?.cacheWrite, reasoning: detail?.tokens?.reasoning ?? log.tokens?.reasoning, + compressed: detail?.tokens?.compressed ?? log.tokens?.compressed, }; const formatTokenValue = (value) => (value != null ? value.toLocaleString() : "N/A"); @@ -248,6 +249,15 @@ export default function RequestLoggerDetail({ Cache Write: {formatTokenValue(tokenStats.cacheWrite)} + {tokenStats.compressed != null && tokenStats.compressed > 0 && (() => { + const fromTokens = tokenStats.totalIn + tokenStats.compressed; + const pct = Math.round((tokenStats.compressed / fromTokens) * 100); + return ( + + Compressed: {fromTokens.toLocaleString()} → {tokenStats.totalIn.toLocaleString()} (-{pct}%) + + ); + })()}
diff --git a/src/shared/components/RequestLoggerV2.tsx b/src/shared/components/RequestLoggerV2.tsx index d7032bf15ece..4631760a3821 100644 --- a/src/shared/components/RequestLoggerV2.tsx +++ b/src/shared/components/RequestLoggerV2.tsx @@ -807,6 +807,17 @@ export default function RequestLoggerV2() { {log.tokens?.out?.toLocaleString() || 0} + {log.tokens?.compressed != null && log.tokens.compressed > 0 && ( + <> + | + + ↓{log.tokens.compressed.toLocaleString()} + + + )} )} {visibleColumns.tps && ( diff --git a/tests/unit/call-log-cap.test.ts b/tests/unit/call-log-cap.test.ts index ed8d108238a5..1bb8434d11ae 100644 --- a/tests/unit/call-log-cap.test.ts +++ b/tests/unit/call-log-cap.test.ts @@ -630,7 +630,7 @@ test("saveCallLog falls back to a compact sentinel when the configured cap is ve const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as any).artifact_relpath); const artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8")); assert.deepEqual(artifact, { - schemaVersion: 4, + schemaVersion: 5, _omniroute_truncated: true, reason: "call_log_artifact_size_limit_exceeded", }); diff --git a/tests/unit/compression-tokens.test.ts b/tests/unit/compression-tokens.test.ts new file mode 100644 index 000000000000..a7a5a422b87e --- /dev/null +++ b/tests/unit/compression-tokens.test.ts @@ -0,0 +1,98 @@ +/** + * @file Unit tests for tokens_compressed tracking in call logs. + * + * Assumes migrations work. Tests functional behavior only. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, "../.."); + +async function freshImport(modulePath: string) { + return import(`${modulePath}?q=${Date.now()}-${Math.random().toString(16).slice(2)}`); +} + +function setupTempDataDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-comp")); + process.env.DATA_DIR = dir; + process.env.OMNIROUTE_MIGRATIONS_DIR = path.join(root, "src/lib/db/migrations"); + process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; + return dir; +} + +test("tokensCompressed round-trips through saveCallLog → getCallLogs", async () => { + const dir = setupTempDataDir(); + try { + const core = await freshImport("../../src/lib/db/core.ts"); + core.resetDbInstance(); + + const db = core.getDbInstance(); + const runner = await freshImport("../../src/lib/db/migrationRunner.ts"); + runner.runMigrations(db); + + const callLogs = await freshImport("../../src/lib/usage/callLogs.ts"); + + // Log without compression + await callLogs.saveCallLog({ + id: "log-null", + method: "POST", + path: "/v1/chat/completions", + status: 200, + model: "gpt-4", + provider: "openai", + duration: 1000, + tokens: { input: 120, output: 45 }, + requestBody: null, + responseBody: null, + }); + + // Log with compression + await callLogs.saveCallLog({ + id: "log-350", + method: "POST", + path: "/v1/chat/completions", + status: 200, + model: "gpt-4", + provider: "openai", + duration: 2000, + tokens: { input: 1000, output: 500 }, + tokensCompressed: 350, + requestBody: null, + responseBody: null, + }); + + const logs = await callLogs.getCallLogs({ + limit: 10, + }); + + const logNull = logs.find( + (l: { id: string }) => l.id === "log-null" + ); + const logComp = logs.find( + (l: { id: string }) => l.id === "log-350" + ); + + // null when no compression + assert.equal( + logNull.tokens?.compressed, + null, + "uncompressed log should have null compressed" + ); + + // Positive value when compressed + assert.equal( + logComp.tokens?.compressed, + 350, + "compressed log should store exact token delta" + ); + + // Input tokens unaffected + assert.equal(logComp.tokens?.in, 1000); + assert.equal(logComp.tokens?.out, 500); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +});