diff --git a/open-sse/handlers/chatCore/attemptLogging.ts b/open-sse/handlers/chatCore/attemptLogging.ts index c8ac0303c8a..5ce51206b06 100644 --- a/open-sse/handlers/chatCore/attemptLogging.ts +++ b/open-sse/handlers/chatCore/attemptLogging.ts @@ -25,6 +25,7 @@ import { emit } from "@/lib/events/eventBus"; import { maybeLogToolCallSpecViolation } from "./toolCallSpecViolationAudit.ts"; import type { RequestCompletedPayload, RequestFailedPayload } from "@/lib/events/types"; import { saveCallLog, saveUpstreamHttpAttempt } from "@/lib/usageDb"; +import { captureReasoningSnapshotMeta } from "@/lib/usage/callLogs"; import type { VideoBridgeLogRedactionEntry } from "@/lib/guardrails/videoBridge"; import { redactVideoTranscriptFieldsForLog } from "@/lib/guardrails/videoBridgeSnapshotRedaction"; import { FORMATS } from "../../translator/formats.ts"; @@ -549,6 +550,9 @@ export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAtt ? { _omniroute_omitted: "video-transcript" } : (responseBody ?? (status >= 400 ? providerResponse : undefined)); const retainedError = videoContentRemoved && error ? "[omitted: video transcript]" : error; + // Keep reasoning observability even though no-log policy discards all + // request/response payloads. Only source and character-count scalars survive. + const noLogReasoningSnapshot = noLogEnabled ? captureReasoningSnapshotMeta(retainedResponse) : null; const providerWarnings = extractProviderWarnings(providerResponse, clientResponse, responseBody); if (providerWarnings.length > 0) { @@ -672,17 +676,19 @@ export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAtt // Encrypted-reasoning observation: stream-side flag plus duration, and // the two request bodies so the sink can read effort values // (requested from the client body, upstream from the post-strip body). - reasoningMeta: args.reasoningMeta ?? null, + reasoningMeta: noLogReasoningSnapshot + ? { ...(args.reasoningMeta ?? {}), ...noLogReasoningSnapshot } + : (args.reasoningMeta ?? null), clientRequestBody: body ?? null, upstreamRequestBody: providerRequest ?? null, - requestBody: cloneBoundedChatLogPayload( + requestBody: noLogEnabled ? null : cloneBoundedChatLogPayload( attachLogMeta(truncateForLog(retainedRequest as Record), { ...accountRotationMeta, claudePromptCache: claudeCacheMeta, geminiPromptCache, }) ), - responseBody: cloneBoundedChatLogPayload( + responseBody: noLogEnabled ? null : cloneBoundedChatLogPayload( attachLogMeta(truncateForLog(retainedResponse as Record), { ...accountRotationMeta, claudePromptCache: claudeCacheMeta diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index ceb8bc2aee6..51fd6bf4ec2 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -341,14 +341,30 @@ function snapshotHasOpaqueReasoningItem(body: unknown): boolean { function resolveReasoningObservation( usageReasoning: number | null, responseBody: unknown, - streamMeta?: { encryptedSeen?: boolean } | null + streamMeta?: { + encryptedSeen?: boolean; + snapshotSource?: string | null; + snapshotChars?: number | null; + } | null ): { source: string | null; chars: number | null } { - if (streamMeta?.encryptedSeen === true || snapshotHasOpaqueReasoningItem(responseBody)) { + if ( + streamMeta?.encryptedSeen === true || + streamMeta?.snapshotSource === "encrypted" || + snapshotHasOpaqueReasoningItem(responseBody) + ) { return { source: "encrypted", chars: null }; } if (usageReasoning != null && usageReasoning > 0) { return { source: "usage", chars: null }; } + // A no-log request keeps only scalars from its live response, not a cloned body. + if ( + (streamMeta?.snapshotSource === "content" || streamMeta?.snapshotSource === "think") && + typeof streamMeta.snapshotChars === "number" && + streamMeta.snapshotChars > 0 + ) { + return { source: streamMeta.snapshotSource, chars: streamMeta.snapshotChars }; + } const observed = getObservedReasoning(extractAssistantMessage(responseBody)); if (observed.chars > 0) { return { source: observed.source, chars: observed.chars }; @@ -356,6 +372,18 @@ function resolveReasoningObservation( return { source: null, chars: null }; } +/** + * Read numerical reasoning observations from the live response before a no-log + * attempt drops its payload. Never retain content or encrypted reasoning blobs. + */ +export function captureReasoningSnapshotMeta(responseBody: unknown): { + snapshotSource: string | null; + snapshotChars: number | null; +} { + const { source, chars } = resolveReasoningObservation(null, responseBody); + return { snapshotSource: source, snapshotChars: chars }; +} + function hasCallLogsColumn(columnName: string): boolean { // One PRAGMA per INSERT is wasteful; the cached answer is invalidated only // when a write fails with "no such column" (concurrent migration race). diff --git a/tests/unit/chatcore-attempt-logging.test.ts b/tests/unit/chatcore-attempt-logging.test.ts index 6cecbcc9ff9..9ba8755676d 100644 --- a/tests/unit/chatcore-attempt-logging.test.ts +++ b/tests/unit/chatcore-attempt-logging.test.ts @@ -15,7 +15,7 @@ const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-attempt-logging- process.env.DATA_DIR = testDataDir; const coreDb = await import("../../src/lib/db/core.ts"); -const { getCallLogById, getCallLogs } = await import("../../src/lib/usage/callLogs.ts"); +const { getCallLogById, getCallLogs, waitForCallLogSaves } = await import("../../src/lib/usage/callLogs.ts"); const { persistAttemptLogs } = await import("../../open-sse/handlers/chatCore/attemptLogging.ts"); const { getAuditLog } = await import("../../src/lib/compliance/index.ts"); @@ -315,3 +315,44 @@ test("combo attempts that share pendingRequestId both land, keyed independently assert.equal(row2.correlationId, traceId2); assert.equal(row2.comboStepId, "my-combo-model-3"); }); + +test("no-log attempt skips traversing request and response bodies while retaining usage counters", async () => { + const traceId = "no-log-heap-protection"; + const body: Record = {}; + Object.defineProperty(body, "expensive", { + enumerable: true, + get() { + throw new Error("no-log body must not be cloned"); + }, + }); + persistAttemptLogs( + { status: 200, tokens: { input: 125_000, output: 2 }, responseBody: { choices: [] } }, + baseCtx({ pendingRequestId: traceId, body, noLogEnabled: true }) + ); + assert.equal(await waitForCallLogSaves(CALL_LOG_SETTLE_TIMEOUT_MS), true); + const rows = await getCallLogs({ correlationId: traceId, limit: 5 }); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.status, 200); + assert.equal(rows[0]?.detailState, "none"); + assert.equal(rows[0]?.tokens.in, 125_000); +}); + +test("no-log responses preserve scalar reasoning observations without storing response bodies", async () => { + for (const [suffix, responseBody, expectedSource, expectedChars] of [ + ["content", { choices: [{ message: { reasoning_content: "private thoughts" } }] }, "content", 16], + ["encrypted", { output: [{ type: "reasoning", encrypted_content: "private opaque blob" }] }, "encrypted", null], + ] as const) { + const id = `no-log-reasoning-${suffix}`; + persistAttemptLogs( + { status: 200, tokens: { input: 4, output: 2 }, responseBody }, + baseCtx({ pendingRequestId: id, noLogEnabled: true }) + ); + assert.equal(await waitForCallLogSaves(CALL_LOG_SETTLE_TIMEOUT_MS), true); + const rows = await getCallLogs({ correlationId: id, limit: 5 }); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.detailState, "none"); + assert.equal(rows[0]?.hasResponseBody, false); + assert.equal(rows[0]?.reasoningSource, expectedSource); + assert.equal(rows[0]?.reasoningChars, expectedChars); + } +});