Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions open-sse/handlers/chatCore/attemptLogging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<string, unknown>), {
...accountRotationMeta,
claudePromptCache: claudeCacheMeta,
geminiPromptCache,
})
),
responseBody: cloneBoundedChatLogPayload(
responseBody: noLogEnabled ? null : cloneBoundedChatLogPayload(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
attachLogMeta(truncateForLog(retainedResponse as Record<string, unknown>), {
...accountRotationMeta,
claudePromptCache: claudeCacheMeta
Expand Down
32 changes: 30 additions & 2 deletions src/lib/usage/callLogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,21 +341,49 @@ 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 };
}
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).
Expand Down
43 changes: 42 additions & 1 deletion tests/unit/chatcore-attempt-logging.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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<string, unknown> = {};
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);
}
});
Loading