From 6275c25e67dcd611a2be41c9d33d9706c1c3d0ff Mon Sep 17 00:00:00 2001 From: Rene Fichtmueller Date: Sat, 18 Jul 2026 01:04:54 +0200 Subject: [PATCH] fix(gateway): redact outbound bridge prompts --- .../__tests__/context-compressor.test.ts | 21 ++ .../modules/__tests__/pii-redaction.test.ts | 69 +++++ packages/gateway/src/modules/pii-redaction.ts | 13 +- packages/gateway/src/routes/completion.ts | 243 ++++++++++++++---- packages/gateway/src/routes/dashboard.ts | 9 + 5 files changed, 306 insertions(+), 49 deletions(-) create mode 100644 packages/gateway/src/modules/__tests__/context-compressor.test.ts create mode 100644 packages/gateway/src/modules/__tests__/pii-redaction.test.ts diff --git a/packages/gateway/src/modules/__tests__/context-compressor.test.ts b/packages/gateway/src/modules/__tests__/context-compressor.test.ts new file mode 100644 index 0000000..be9baa3 --- /dev/null +++ b/packages/gateway/src/modules/__tests__/context-compressor.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { compressContext } from '../context-compressor.js'; + +describe('context compressor', () => { + it('checks short inputs without lossy compression', () => { + const result = compressContext('short operational prompt', { enabled: true, mode: 'auto' }); + + expect(result.applied).toBe(false); + expect(result.tokensSaved).toBe(0); + expect(result.tokensBefore).toBe(result.tokensAfter); + }); + + it('compresses long contexts before routing', () => { + const result = compressContext('x'.repeat(80_000), { enabled: true, mode: 'auto' }); + + expect(result.applied).toBe(true); + expect(result.tokensSaved).toBeGreaterThan(0); + expect(result.tokensAfter).toBeLessThan(result.tokensBefore); + expect(result.input).toContain('[ctxlean omitted'); + }); +}); diff --git a/packages/gateway/src/modules/__tests__/pii-redaction.test.ts b/packages/gateway/src/modules/__tests__/pii-redaction.test.ts new file mode 100644 index 0000000..2318cad --- /dev/null +++ b/packages/gateway/src/modules/__tests__/pii-redaction.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + getRedactMode, + redactPii, + restorePii, + shouldRedactFor, +} from '../pii-redaction.js'; + +const ORIGINAL_REDACT_PII_MODE = process.env['REDACT_PII_MODE']; +const ORIGINAL_PII_REDACTION_MODE = process.env['PII_REDACTION_MODE']; + +afterEach(() => { + if (ORIGINAL_REDACT_PII_MODE === undefined) { + delete process.env['REDACT_PII_MODE']; + } else { + process.env['REDACT_PII_MODE'] = ORIGINAL_REDACT_PII_MODE; + } + + if (ORIGINAL_PII_REDACTION_MODE === undefined) { + delete process.env['PII_REDACTION_MODE']; + } else { + process.env['PII_REDACTION_MODE'] = ORIGINAL_PII_REDACTION_MODE; + } +}); + +describe('PII redaction', () => { + it('defaults to cloud_only redaction', () => { + delete process.env['REDACT_PII_MODE']; + delete process.env['PII_REDACTION_MODE']; + + expect(getRedactMode()).toBe('cloud_only'); + }); + + it('supports explicit opt-out and the legacy alias', () => { + process.env['REDACT_PII_MODE'] = 'off'; + process.env['PII_REDACTION_MODE'] = 'always'; + expect(getRedactMode()).toBe('off'); + + delete process.env['REDACT_PII_MODE']; + process.env['PII_REDACTION_MODE'] = 'always'; + expect(getRedactMode()).toBe('always'); + }); + + it('redacts and restores outward identifiers and API keys', () => { + const email = ['person', 'example.org'].join('@'); + const ipAddress = [203, 0, 113, 42].join('.'); + const apiKey = `sk-${'a'.repeat(24)}`; + const original = [ + `Contact ${email} from ${ipAddress}.`, + `Use token ${apiKey}.`, + ].join(' '); + + const result = redactPii(original); + + expect(result.redacted).toContain(''); + expect(result.redacted).toContain(''); + expect(result.redacted).toContain(''); + expect(result.redacted).not.toContain(email); + expect(result.redacted).not.toContain(ipAddress); + expect(result.redacted).not.toContain(apiKey); + expect(restorePii(result.redacted, result.restoreMap)).toBe(original); + }); + + it('redacts cloud and bridge providers while leaving local providers raw in cloud_only mode', () => { + expect(shouldRedactFor('cloud_only', 'claude-bridge', 'gateway-test')).toBe(true); + expect(shouldRedactFor('cloud_only', 'api.openai.com', 'gateway-test')).toBe(true); + expect(shouldRedactFor('cloud_only', 'ollama', 'gateway-test')).toBe(false); + }); +}); diff --git a/packages/gateway/src/modules/pii-redaction.ts b/packages/gateway/src/modules/pii-redaction.ts index 288a103..d53f347 100644 --- a/packages/gateway/src/modules/pii-redaction.ts +++ b/packages/gateway/src/modules/pii-redaction.ts @@ -10,9 +10,9 @@ * trust boundary without redaction. The mapping lives only in process * memory for the duration of a single call — never persisted. * - * Modes (env REDACT_PII_MODE): + * Modes (env REDACT_PII_MODE or PII_REDACTION_MODE): * - off → no redaction - * - cloud_only → redact when target provider is non-local + * - cloud_only → redact when target provider is non-local (default) * (i.e. any *-bridge or hosted API; Ollama passes raw) * - always → redact every call regardless of provider * @@ -23,7 +23,7 @@ import { logger } from '../observability/logger.js'; export type RedactMode = 'off' | 'cloud_only' | 'always'; export type PiiCategory = | 'email' | 'phone' | 'credit_card' | 'iban' | 'ssn' | 'ip_address' - | 'aws_key' | 'private_key' | 'jwt' | 'person_name'; + | 'aws_key' | 'api_key' | 'private_key' | 'jwt' | 'person_name'; export interface PiiMatch { category: PiiCategory; @@ -113,6 +113,9 @@ const RULES: readonly DetectionRule[] = [ // AWS access key IDs { category: 'aws_key', pattern: /\bAKIA[0-9A-Z]{16}\b/g }, + // Common API access tokens (OpenAI-style, GitHub, GitLab, Slack) + { category: 'api_key', pattern: /\b(?:sk-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{20,})\b/g }, + // PEM-style private keys { category: 'private_key', pattern: /-----BEGIN (?:RSA |EC |OPENSSH |PGP |DSA )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |PGP |DSA )?PRIVATE KEY-----/g }, @@ -127,7 +130,7 @@ const RULES: readonly DetectionRule[] = [ const counters: Record = { email: 0, phone: 0, credit_card: 0, iban: 0, ssn: 0, ip_address: 0, - aws_key: 0, private_key: 0, jwt: 0, person_name: 0, + aws_key: 0, api_key: 0, private_key: 0, jwt: 0, person_name: 0, }; function tokenFor(category: PiiCategory, restoreMap: Map): string { @@ -190,7 +193,7 @@ export function restorePii(text: string, restoreMap: Map): strin // ─── Mode helpers ──────────────────────────────────────────────────────────── export function getRedactMode(): RedactMode { - const v = (process.env['REDACT_PII_MODE'] ?? 'off').toLowerCase(); + const v = (process.env['REDACT_PII_MODE'] ?? process.env['PII_REDACTION_MODE'] ?? 'cloud_only').toLowerCase(); if (v === 'cloud_only' || v === 'always') return v; return 'off'; } diff --git a/packages/gateway/src/routes/completion.ts b/packages/gateway/src/routes/completion.ts index a48ee35..9f202a7 100644 --- a/packages/gateway/src/routes/completion.ts +++ b/packages/gateway/src/routes/completion.ts @@ -231,6 +231,111 @@ interface GatewayCompletionResult { body: Record; } +function mergeRestoreMaps(target: Map, source: Map): void { + for (const [sourceToken, value] of source.entries()) { + const existing = Array.from(target.entries()).find(([, current]) => current === value); + if (existing) continue; + + if (!target.has(sourceToken)) { + target.set(sourceToken, value); + continue; + } + + const match = sourceToken.match(/^<(.+)_(\d+)>$/); + const prefix = match?.[1] ?? 'PII'; + let next = 1; + let token = `<${prefix}_${String(next).padStart(3, '0')}>`; + while (target.has(token)) { + next += 1; + token = `<${prefix}_${String(next).padStart(3, '0')}>`; + } + target.set(token, value); + } +} + +function applyPiiTokens(text: string, restoreMap: Map): string { + let out = text; + const entries = Array.from(restoreMap.entries()) + .filter(([, value]) => value.length > 0) + .sort((a, b) => b[1].length - a[1].length); + + for (const [token, value] of entries) { + out = out.split(value).join(token); + } + return out; +} + +async function buildOutboundPiiRedactionMap( + text: string, + options: { providerName: string; caller: string; callId: string; language?: string } +): Promise | null> { + const redactMode = getRedactMode(); + if (!text.trim() || !shouldRedactFor(redactMode, options.providerName, options.caller)) return null; + + const restoreMap = new Map(); + const pii = redactPii(text); + mergeRestoreMaps(restoreMap, pii.restoreMap); + + let nameCount = 0; + const presidioUrl = process.env['PRESIDIO_URL']; + if (presidioUrl) { + try { + const nameResult = await redactPersonNamesAsync(applyPiiTokens(text, restoreMap), presidioUrl, options.language ?? 'de'); + nameCount = nameResult.count; + mergeRestoreMaps(restoreMap, nameResult.restoreMap); + } catch (err) { + logger.warn({ err, callId: options.callId }, 'Presidio name redaction failed — continuing'); + } + } + + if (restoreMap.size === 0) return null; + + logger.info( + { + callId: options.callId, + caller: options.caller, + provider: options.providerName, + redactedCounts: pii.counts, + redactedNames: nameCount, + redactedTokens: restoreMap.size, + }, + 'PII redaction applied before outbound LLM call', + ); + return restoreMap; +} + +function redactChatContent( + content: OpenAIChatCompletionRequest['messages'][number]['content'], + restoreMap: Map +): OpenAIChatCompletionRequest['messages'][number]['content'] { + if (typeof content === 'string') return applyPiiTokens(content, restoreMap); + if (!Array.isArray(content)) return content; + + return content.map((part) => { + if (typeof part === 'string') return applyPiiTokens(part, restoreMap); + if (!part || typeof part !== 'object') return part; + + const copy: Record = { ...(part as Record) }; + for (const key of ['text', 'input_text', 'output_text']) { + if (typeof copy[key] === 'string') copy[key] = applyPiiTokens(copy[key] as string, restoreMap); + } + return copy; + }); +} + +function redactChatCompletionBody( + body: OpenAIChatCompletionRequest, + restoreMap: Map +): OpenAIChatCompletionRequest { + return { + ...body, + messages: body.messages.map((message) => ({ + ...message, + content: redactChatContent(message.content, restoreMap), + })), + }; +} + // input: string, // caller: string, @@ -397,7 +502,8 @@ function buildResponseBody(callId: string, decision: ReturnType, t } async function executeCompletion(body: CompletionRequest, startMs: number, callId: string): Promise { - const { caller, language, context, options } = body; + const { caller, language, options } = body; + let context = body.context; // ─── Plugin pre-hooks (PLUGINS_DIR) ──────────────────────────────────── try { @@ -413,33 +519,18 @@ async function executeCompletion(body: CompletionRequest, startMs: number, callI } // ─── PII Redaction (REDACT_PII_MODE: off|cloud_only|always) ───────────── - const redactMode = getRedactMode(); - let piiRestoreMap: Map | null = null; - if (redactMode !== 'off' && shouldRedactFor(redactMode, 'unknown', caller)) { - const r = redactPii(body.input); - if (r.restoreMap.size > 0) { - body = { ...body, input: r.redacted }; - piiRestoreMap = r.restoreMap; - logger.info( - { callId, caller, redactedCounts: r.counts, redactedTokens: r.restoreMap.size }, - 'PII redaction applied', - ); - } - } - - // ─── PII: person names via Presidio+GLiNER sidecar (async, non-blocking) ── - const presidioUrl = process.env['PRESIDIO_URL']; - if (presidioUrl && redactMode !== 'off' && shouldRedactFor(redactMode, 'unknown', caller)) { - try { - const nameResult = await redactPersonNamesAsync(body.input, presidioUrl); - if (nameResult.count > 0) { - body = { ...body, input: nameResult.redacted }; - if (!piiRestoreMap) piiRestoreMap = new Map(); - for (const [k, v] of nameResult.restoreMap) piiRestoreMap.set(k, v); - logger.info({ callId, caller, names: nameResult.count }, 'Person names redacted via Presidio'); - } - } catch (err) { - logger.warn({ err, callId }, 'Presidio name redaction failed — continuing'); + const systemContext = typeof context === 'object' && context && 'system' in context + ? String((context as any).system ?? '') + : ''; + const piiRestoreMap = await buildOutboundPiiRedactionMap( + [body.input, systemContext].filter(Boolean).join('\n\n'), + { providerName: 'unknown', caller, callId, language } + ); + if (piiRestoreMap) { + body = { ...body, input: applyPiiTokens(body.input, piiRestoreMap) }; + if (systemContext && context) { + context = { ...context, system: applyPiiTokens(systemContext, piiRestoreMap) }; + body = { ...body, context }; } } @@ -517,7 +608,7 @@ async function executeCompletion(body: CompletionRequest, startMs: number, callI // ─── Cache check (Tier 1: exact-match hash lookup) ───────────────────── const agenticNoCache = shouldBypassResponseCache(caller); - const skipCache = agenticNoCache || (options as any)?.skip_cache === true; + const skipCache = agenticNoCache || (options as any)?.skip_cache === true || Boolean(piiRestoreMap); const cacheableReq = { caller, task_type: body.task_type, @@ -554,6 +645,14 @@ async function executeCompletion(body: CompletionRequest, startMs: number, callI if (hit) { const latencyMs = Date.now() - startMs; void recordCacheHit(dbForCache, hit.id); + void logCompressionMetric(dbForCache, { + filePath: callId, + mode: 'cache:none', + tokensBefore: hit.tokensIn, + tokensAfter: hit.tokensIn, + savingsPct: 0, + toolUsed: 'gateway', + }); // Log cache hit as a successful request (status=approved, fallback=false) const requestLogger = createRequestLogger(dbForCache); void requestLogger.logRequest( @@ -655,12 +754,14 @@ async function executeCompletion(body: CompletionRequest, startMs: number, callI } const latencyMs = Date.now() - startMs; - const outputText = ollamaResponse.response; + const modelOutputText = ollamaResponse.response; + const outputText = piiRestoreMap ? restorePii(modelOutputText, piiRestoreMap) : modelOutputText; const validationOutput = await runPostValidation(outputText, { validators: decision.validators, language, output_format: decision.output_format, requires_fact_check: decision.requires_fact_check, schema: resolved.schema }); const confidenceResult = evaluateConfidence(validationOutput); recordAllMetrics(caller, taskType, confidenceResult, ollamaResponse, decision, validationOutput); - const { costUsd, costSavedUsd } = await auditAndTrackCosts(caller, taskType, compression.originalInput, outputText, latencyMs, ollamaResponse, resolved, decision, confidenceResult, validationOutput, classificationResult, callId, compression); + const auditOutputText = piiRestoreMap ? modelOutputText : outputText; + const { costUsd, costSavedUsd } = await auditAndTrackCosts(caller, taskType, compression.originalInput, auditOutputText, latencyMs, ollamaResponse, resolved, decision, confidenceResult, validationOutput, classificationResult, callId, compression); latencySeconds.labels({ caller, task_type: taskType, model: ollamaResponse.model ?? decision.model }).observe(latencyMs / 1000); @@ -966,16 +1067,24 @@ async function callSubscriptionBridgeChatCompletion( body: OpenAIChatCompletionRequest, startMs: number, callId: string, - requestedSubscriptionId?: string + requestedSubscriptionId: string | undefined, + caller: string ): Promise<{ ok: true; body: Record } | { ok: false; statusCode: number; error: string } | null> { const target = subscriptionBridgeTargetForSubscriptionId(requestedSubscriptionId) ?? subscriptionBridgeTargetForModel(body.model); if (!target) return null; const compressed = compressChatMessagesForBridge(body); - const inputText = compressed.inputText; + const outboundRedactionMap = await buildOutboundPiiRedactionMap( + chatMessagesToPrompt(compressed.body.messages, { includeSystem: true }), + { providerName: target.provider, caller, callId, language: 'en' } + ); + const outboundBody = outboundRedactionMap + ? redactChatCompletionBody(compressed.body, outboundRedactionMap) + : compressed.body; + const inputText = chatMessagesToPrompt(outboundBody.messages, { includeSystem: false }); try { - const upstream = await postSubscriptionBridgeCompletion(target, compressed.body, inputText); + const upstream = await postSubscriptionBridgeCompletion(target, outboundBody, inputText); if (!upstream.ok || upstream.data?.success === false) { const error = bridgeErrorMessage(upstream.data, upstream.statusCode); logger.warn( @@ -985,9 +1094,39 @@ async function callSubscriptionBridgeChatCompletion( return { ok: false, statusCode: 424, error }; } - const text = contentFromSubscriptionBridge(upstream.data); + const modelText = contentFromSubscriptionBridge(upstream.data); + const text = outboundRedactionMap ? restorePii(String(modelText), outboundRedactionMap) : String(modelText); const promptTokens = upstream.data?.usage?.prompt_tokens ?? Math.ceil(inputText.length / 4); - const completionTokens = upstream.data?.usage?.completion_tokens ?? Math.ceil(String(text).length / 4); + const completionTokens = upstream.data?.usage?.completion_tokens ?? Math.ceil(String(modelText).length / 4); + const latencyMs = Date.now() - startMs; + try { + const db = getPool(); + void logCompressionMetric(db, { + filePath: callId, + mode: `${compressed.compression.method}:${compressed.compression.strategy}`, + tokensBefore: compressed.compression.tokensBefore, + tokensAfter: compressed.compression.tokensAfter, + savingsPct: Math.round(compressed.compression.ratio * 10000) / 100, + toolUsed: 'gateway', + }); + void createRequestLogger(db).logRequest( + callId, + caller, + 'subscription_bridge', + body.model, + 'approved', + promptTokens, + completionTokens, + 0, + latencyMs, + 0, + false, + undefined, + ); + void recordSubscriptionUsage(db, target.subscriptionId, promptTokens + completionTokens); + } catch (e) { + logger.warn({ e, callId }, 'failed to log subscription bridge request'); + } return { ok: true, body: { @@ -1012,7 +1151,7 @@ async function callSubscriptionBridgeChatCompletion( provider: target.provider, subscription_id: target.subscriptionId, subscription_forced: Boolean(requestedSubscriptionId), - latency_ms: Date.now() - startMs, + latency_ms: latencyMs, passthrough: true, compression: buildCompressionResponse(compressed.compression), }, @@ -1399,7 +1538,8 @@ export async function completionRoute(fastify: FastifyInstance): Promise { const callId = `chatcmpl-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; const requestedSubscriptionId = requestedSubscriptionFromHeaders(request); - const subscriptionBridgeResult = await callSubscriptionBridgeChatCompletion(parsed.data, startMs, callId, requestedSubscriptionId); + const { caller: bridgeCaller } = detectCaller(request, 'openai-compatible', parsed.data.user); + const subscriptionBridgeResult = await callSubscriptionBridgeChatCompletion(parsed.data, startMs, callId, requestedSubscriptionId, bridgeCaller); if (requestedSubscriptionId && !subscriptionBridgeResult) { return reply.status(404).send({ error: { @@ -1505,18 +1645,24 @@ export async function completionRoute(fastify: FastifyInstance): Promise { if (isCodexBridgeModel(parsed.data.model)) { try { const bridgeUrl = process.env['CODEX_BRIDGE_URL'] ?? 'http://localhost:3253'; + const bridgeCaller = (request.headers['x-llm-interceptor-caller'] as string) || 'codex-app'; const originalInputText = typeof parsed.data.input === 'string' ? parsed.data.input : (Array.isArray(parsed.data.input) ? parsed.data.input.map((p: any) => typeof p?.content === 'string' ? p.content : (Array.isArray(p?.content) ? p.content.map((c: any) => c?.text ?? '').join(' ') : '')).join(' ') : ''); const compression = compressContext(originalInputText, { enabled: true, mode: 'auto' }); const inputText = compression.input; + const outboundRedactionMap = await buildOutboundPiiRedactionMap( + inputText, + { providerName: 'codex-bridge', caller: bridgeCaller, callId, language: 'en' } + ); + const outboundInputText = outboundRedactionMap ? applyPiiTokens(inputText, outboundRedactionMap) : inputText; const upstream = await fetch(`${bridgeUrl}/v1/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: parsed.data.model, - messages: [{ role: 'user', content: inputText }], + messages: [{ role: 'user', content: outboundInputText }], }), }); const upstreamText = await upstream.text(); @@ -1527,11 +1673,11 @@ export async function completionRoute(fastify: FastifyInstance): Promise { upstreamJson = { raw: upstreamText }; } if (upstream.ok && upstreamJson?.success !== false) { - const text = upstreamJson?.content ?? upstreamJson?.response ?? upstreamJson?.choices?.[0]?.message?.content ?? ''; + const modelText = upstreamJson?.content ?? upstreamJson?.response ?? upstreamJson?.choices?.[0]?.message?.content ?? ''; + const text = outboundRedactionMap ? restorePii(String(modelText), outboundRedactionMap) : String(modelText); logger.info({ callId, model: parsed.data.model, len: text.length }, 'codex-bridge passthrough OK'); - const bridgeCaller = (request.headers['x-llm-interceptor-caller'] as string) || 'codex-app'; - const bridgeTokensIn = (upstreamJson?.usage?.prompt_tokens as number | undefined) ?? Math.ceil(inputText.length / 4); - const bridgeTokensOut = (upstreamJson?.usage?.completion_tokens as number | undefined) ?? Math.ceil(text.length / 4); + const bridgeTokensIn = (upstreamJson?.usage?.prompt_tokens as number | undefined) ?? Math.ceil(outboundInputText.length / 4); + const bridgeTokensOut = (upstreamJson?.usage?.completion_tokens as number | undefined) ?? Math.ceil(String(modelText).length / 4); const respBody = toOpenAIResponsesResponse({ output: text, model: parsed.data.model, @@ -1548,7 +1694,16 @@ export async function completionRoute(fastify: FastifyInstance): Promise { } // Write to request_tracking so dashboard token totals include bridge calls. try { - const bridgeLogger = createRequestLogger(getPool()); + const db = getPool(); + void logCompressionMetric(db, { + filePath: callId, + mode: `${compression.method}:${compression.strategy}`, + tokensBefore: compression.tokensBefore, + tokensAfter: compression.tokensAfter, + savingsPct: Math.round(compression.ratio * 10000) / 100, + toolUsed: 'gateway', + }); + const bridgeLogger = createRequestLogger(db); void bridgeLogger.logRequest( callId, bridgeCaller, diff --git a/packages/gateway/src/routes/dashboard.ts b/packages/gateway/src/routes/dashboard.ts index 3f5c6d6..901fe2d 100644 --- a/packages/gateway/src/routes/dashboard.ts +++ b/packages/gateway/src/routes/dashboard.ts @@ -40,6 +40,7 @@ import { getSubscriptionWallet, recordSubscriptionUsage } from '../modules/subsc import { rememberFact, recallFacts, forgetCaller } from '../modules/knowledge-memory.js'; import { getRaceStats } from '../modules/race-mode.js'; import { dashboardAuthStatus, requireDashboardAuth } from '../modules/admin-auth.js'; +import { logCompressionMetric } from '../utils/tokenvault-hooks.js'; const execFileAsync = promisify(execFile); @@ -1760,6 +1761,14 @@ export async function dashboardRoute(fastify: FastifyInstance): Promise { await requestLogger.logRequest(requestId, caller, 'usage_import', model, 'approved', tokensIn, tokensOut, costUsd, 0); await db.query(`UPDATE request_tracking SET created_at=$1 WHERE request_id=$2`, [usedAt, requestId]); } + await logCompressionMetric(db, { + filePath: requestId, + mode: 'usage-import:none', + tokensBefore: tokensIn, + tokensAfter: tokensIn, + savingsPct: 0, + toolUsed: 'gateway', + }); return reply.status(200).send({ success: true, imported: { caller, model, day, tokensIn, tokensOut, costUsd, usedAt } }); } catch (error) { logger.error({ error }, 'Failed to import usage report');