Merge PR #666: Add Claude prompt cache logging and exclude cache reads

Includes fixes applied during review:
- Removed duplicate imports in chatCore.ts
- Fixed stray translatedBody argument (stream boolean bug)
- Fixed truncated test file
- Fixed usageExtractor cached_tokens fallback

Closes #688, Closes #640
This commit is contained in:
diegosouzapw
2026-03-28 01:53:25 -03:00
9 changed files with 348 additions and 43 deletions

View File

@@ -1,13 +1,17 @@
FROM node:22-bookworm-slim AS builder
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends libsecret-1-0 \
&& rm -rf /var/lib/apt/lists/*
COPY package*.json ./
COPY scripts/postinstall.mjs ./scripts/postinstall.mjs
COPY scripts/native-binary-compat.mjs ./scripts/native-binary-compat.mjs
RUN if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; else npm install --no-audit --no-fund; fi
COPY . ./
RUN mkdir -p /app/data && npm run build
RUN mkdir -p /app/data && npm run build -- --webpack
FROM node:22-bookworm-slim AS runner-base
WORKDIR /app
@@ -25,6 +29,9 @@ ENV NODE_OPTIONS="--max-old-space-size=256"
# Data directory inside Docker — must match the volume mount in docker-compose.yml
ENV DATA_DIR=/app/data
RUN apt-get update \
&& apt-get install -y --no-install-recommends libsecret-1-0 \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /app/data
COPY --from=builder /app/public ./public

View File

@@ -18,6 +18,7 @@ const nextConfig = {
"thread-stream",
"better-sqlite3",
"keytar",
"wreq-js",
"zod",
"child_process",
"fs",
@@ -72,6 +73,7 @@ const nextConfig = {
const KNOWN_EXTERNALS = new Set([
"better-sqlite3",
"keytar",
"wreq-js",
"zod",
"pino",
"pino-pretty",

View File

@@ -26,13 +26,15 @@ import {
appendRequestLog,
saveCallLog,
} from "@/lib/usageDb";
import { getLoggedInputTokens, getLoggedOutputTokens } from "@/lib/usage/tokenAccounting";
import { CLAUDE_OAUTH_TOOL_PREFIX } from "../translator/request/openai-to-claude.ts";
import {
getModelNormalizeToolCallId,
getModelPreserveOpenAIDeveloperRole,
getModelUpstreamExtraHeaders,
} from "@/lib/localDb";
import { getExecutor } from "../executors/index.ts";
import { CLAUDE_OAUTH_TOOL_PREFIX } from "../translator/request/openai-to-claude.ts";
import {
parseCodexQuotaHeaders,
getCodexResetTime,
@@ -130,6 +132,157 @@ function restoreClaudePassthroughToolNames(
};
}
function getHeaderValueCaseInsensitive(
headers: Record<string, unknown> | null | undefined,
targetName: string
) {
if (!headers || typeof headers !== "object") return null;
const lowered = targetName.toLowerCase();
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === lowered && typeof value === "string" && value.trim()) {
return value.trim();
}
}
return null;
}
function buildClaudePromptCacheLogMeta(
targetFormat: string,
finalBody: Record<string, unknown> | null | undefined,
providerHeaders: Record<string, unknown> | null | undefined
) {
if (targetFormat !== FORMATS.CLAUDE || !finalBody || typeof finalBody !== "object") return null;
const describeCacheControl = (cacheControl: Record<string, unknown> | undefined, extra = {}) => ({
type:
cacheControl && typeof cacheControl.type === "string" && cacheControl.type.trim()
? cacheControl.type.trim()
: "ephemeral",
ttl:
cacheControl && typeof cacheControl.ttl === "string" && cacheControl.ttl.trim()
? cacheControl.ttl.trim()
: null,
...extra,
});
const systemBreakpoints = Array.isArray(finalBody.system)
? finalBody.system.flatMap((block, index) => {
if (!block || typeof block !== "object") return [];
const cacheControl =
block.cache_control && typeof block.cache_control === "object"
? block.cache_control
: null;
return cacheControl ? [describeCacheControl(cacheControl, { index })] : [];
})
: [];
const toolBreakpoints = Array.isArray(finalBody.tools)
? finalBody.tools.flatMap((tool, index) => {
if (!tool || typeof tool !== "object") return [];
const cacheControl =
tool.cache_control && typeof tool.cache_control === "object" ? tool.cache_control : null;
const name = typeof tool.name === "string" && tool.name.trim() ? tool.name.trim() : null;
return cacheControl ? [describeCacheControl(cacheControl, { index, name })] : [];
})
: [];
const messageBreakpoints = Array.isArray(finalBody.messages)
? finalBody.messages.flatMap((message, messageIndex) => {
if (!message || typeof message !== "object" || !Array.isArray(message.content)) return [];
const role =
typeof message.role === "string" && message.role.trim() ? message.role.trim() : "unknown";
return message.content.flatMap((block, contentIndex) => {
if (!block || typeof block !== "object") return [];
const cacheControl =
block.cache_control && typeof block.cache_control === "object"
? block.cache_control
: null;
if (!cacheControl) return [];
return [
describeCacheControl(cacheControl, {
messageIndex,
contentIndex,
role,
blockType:
typeof block.type === "string" && block.type.trim() ? block.type.trim() : "unknown",
}),
];
});
})
: [];
const totalBreakpoints =
systemBreakpoints.length + toolBreakpoints.length + messageBreakpoints.length;
const anthropicBeta = getHeaderValueCaseInsensitive(providerHeaders, "Anthropic-Beta");
if (totalBreakpoints === 0 && !anthropicBeta) return null;
return {
applied: totalBreakpoints > 0,
totalBreakpoints,
anthropicBeta,
systemBreakpoints,
toolBreakpoints,
messageBreakpoints,
};
}
function toPositiveNumber(value: unknown) {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
}
function buildCacheUsageLogMeta(usage: Record<string, unknown> | null | undefined) {
if (!usage || typeof usage !== "object") return null;
const promptTokenDetails =
usage.prompt_tokens_details && typeof usage.prompt_tokens_details === "object"
? (usage.prompt_tokens_details as Record<string, unknown>)
: undefined;
const hasCacheFields =
"cache_read_input_tokens" in usage ||
"cached_tokens" in usage ||
"cache_creation_input_tokens" in usage ||
(!!promptTokenDetails &&
("cached_tokens" in promptTokenDetails || "cache_creation_tokens" in promptTokenDetails));
const cacheReadTokens = toPositiveNumber(
usage.cache_read_input_tokens ?? usage.cached_tokens ?? promptTokenDetails?.cached_tokens
);
const cacheCreationTokens = toPositiveNumber(
usage.cache_creation_input_tokens ?? promptTokenDetails?.cache_creation_tokens
);
if (!hasCacheFields) return null;
return {
cacheReadTokens,
cacheCreationTokens,
};
}
function attachLogMeta(
payload: Record<string, unknown> | null | undefined,
meta: Record<string, unknown> | null | undefined
) {
if (!meta || typeof meta !== "object") return payload;
const compactMeta = Object.fromEntries(
Object.entries(meta).filter(([, value]) => value !== null && value !== undefined)
);
if (Object.keys(compactMeta).length === 0) return payload;
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return { _omniroute: compactMeta, _payload: payload ?? null };
}
const existing =
payload._omniroute &&
typeof payload._omniroute === "object" &&
!Array.isArray(payload._omniroute)
? payload._omniroute
: {};
return {
...payload,
_omniroute: {
...existing,
...compactMeta,
},
};
}
/**
* Core chat handler - shared between SSE and Worker
* Returns { success, response, status, error } for caller to handle fallback
@@ -720,6 +873,7 @@ export async function handleChatCore({
let providerUrl;
let providerHeaders;
let finalBody;
let claudePromptCacheLogMeta = null;
try {
const result = await executeProviderRequest(effectiveModel, true);
@@ -728,6 +882,11 @@ export async function handleChatCore({
providerUrl = result.url;
providerHeaders = result.headers;
finalBody = result.transformedBody;
claudePromptCacheLogMeta = buildClaudePromptCacheLogMeta(
targetFormat,
finalBody,
providerHeaders
);
// Log target request (final request to provider)
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
@@ -757,7 +916,9 @@ export async function handleChatCore({
provider,
connectionId,
duration: Date.now() - startTime,
requestBody: body,
requestBody: attachLogMeta(body, {
claudePromptCache: claudePromptCacheLogMeta,
}),
error: error.message,
sourceFormat,
targetFormat,
@@ -897,7 +1058,9 @@ export async function handleChatCore({
provider,
connectionId,
duration: Date.now() - startTime,
requestBody: body,
requestBody: attachLogMeta(body, {
claudePromptCache: claudePromptCacheLogMeta,
}),
error: message,
sourceFormat,
targetFormat,
@@ -1083,6 +1246,7 @@ export async function handleChatCore({
);
// Save structured call log with full payloads
const cacheUsageLogMeta = buildCacheUsageLogMeta(usage);
saveCallLog({
method: "POST",
path: clientRawRequest?.endpoint || "/v1/chat/completions",
@@ -1093,8 +1257,19 @@ export async function handleChatCore({
connectionId,
duration: Date.now() - startTime,
tokens: usage,
requestBody: body,
responseBody,
requestBody: attachLogMeta(body, {
claudePromptCache: claudePromptCacheLogMeta,
}),
responseBody: attachLogMeta(responseBody, {
claudePromptCache: claudePromptCacheLogMeta
? {
applied: claudePromptCacheLogMeta.applied,
totalBreakpoints: claudePromptCacheLogMeta.totalBreakpoints,
anthropicBeta: claudePromptCacheLogMeta.anthropicBeta,
}
: null,
claudePromptCacheUsage: cacheUsageLogMeta,
}),
sourceFormat,
targetFormat,
comboName,
@@ -1103,7 +1278,7 @@ export async function handleChatCore({
noLog: apiKeyInfo?.noLog === true,
}).catch(() => {});
if (usage && typeof usage === "object") {
const msg = `[${new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" })}] 📊 [USAGE] ${provider.toUpperCase()} | in=${usage?.prompt_tokens || 0} | out=${usage?.completion_tokens || 0}${connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""}`;
const msg = `[${new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" })}] 📊 [USAGE] ${provider.toUpperCase()} | in=${getLoggedInputTokens(usage)} | out=${getLoggedOutputTokens(usage)}${connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""}`;
console.log(`${COLORS.green}${msg}${COLORS.reset}`);
saveRequestUsage({
@@ -1225,6 +1400,7 @@ export async function handleChatCore({
usage: streamUsage,
responseBody: streamResponseBody,
}) => {
const cacheUsageLogMeta = buildCacheUsageLogMeta(streamUsage);
saveCallLog({
method: "POST",
path: clientRawRequest?.endpoint || "/v1/chat/completions",
@@ -1235,8 +1411,19 @@ export async function handleChatCore({
connectionId,
duration: Date.now() - startTime,
tokens: streamUsage || {},
requestBody: body,
responseBody: streamResponseBody ?? undefined,
requestBody: attachLogMeta(body, {
claudePromptCache: claudePromptCacheLogMeta,
}),
responseBody: attachLogMeta(streamResponseBody ?? undefined, {
claudePromptCache: claudePromptCacheLogMeta
? {
applied: claudePromptCacheLogMeta.applied,
totalBreakpoints: claudePromptCacheLogMeta.totalBreakpoints,
anthropicBeta: claudePromptCacheLogMeta.anthropicBeta,
}
: null,
claudePromptCacheUsage: cacheUsageLogMeta,
}),
sourceFormat,
targetFormat,
comboName,

View File

@@ -29,7 +29,10 @@ export function extractUsageFromResponse(responseBody, provider) {
return {
prompt_tokens: responsesUsage.input_tokens || 0,
completion_tokens: responsesUsage.output_tokens || 0,
cached_tokens: responsesUsage.cache_read_input_tokens,
cache_read_input_tokens: responsesUsage.cache_read_input_tokens,
cached_tokens:
responsesUsage.input_tokens_details?.cached_tokens ??
responsesUsage.cache_read_input_tokens,
cache_creation_input_tokens: responsesUsage.cache_creation_input_tokens,
reasoning_tokens:
responsesUsage.reasoning_tokens || responsesUsage.output_tokens_details?.reasoning_tokens,

View File

@@ -86,6 +86,24 @@ export function fixToolUseOrdering(messages) {
return merged;
}
function ensureMessageContentArray(msg) {
if (Array.isArray(msg?.content)) return msg.content;
if (typeof msg?.content === "string" && msg.content.trim()) {
msg.content = [{ type: "text", text: msg.content }];
return msg.content;
}
return [];
}
function markMessageCacheControl(msg, ttl) {
const content = ensureMessageContentArray(msg);
if (content.length === 0) return false;
const lastIndex = content.length - 1;
content[lastIndex].cache_control =
ttl !== undefined ? { type: "ephemeral", ttl } : { type: "ephemeral" };
return true;
}
// Prepare request for Claude format endpoints
// - Cleanup cache_control
// - Filter empty messages
@@ -156,15 +174,27 @@ export function prepareClaudeRequest(body, provider = null) {
const lastMessageIsUser = lastMessage?.role === "user";
const thinkingEnabled = body.thinking?.type === "enabled" && lastMessageIsUser;
// Claude Code-style prompt caching:
// - cache the second-to-last user turn for conversation reuse
// - cache the last assistant turn so the next user turn can reuse it
const userMessageIndexes = filtered.reduce((indexes, msg, index) => {
if (msg?.role === "user") indexes.push(index);
return indexes;
}, []);
const secondToLastUserIndex =
userMessageIndexes.length >= 2 ? userMessageIndexes[userMessageIndexes.length - 2] : -1;
if (secondToLastUserIndex >= 0) {
markMessageCacheControl(filtered[secondToLastUserIndex]);
}
// Pass 2 (reverse): add cache_control to last assistant + handle thinking for Anthropic
let lastAssistantProcessed = false;
for (let i = filtered.length - 1; i >= 0; i--) {
const msg = filtered[i];
if (msg.role === "assistant" && Array.isArray(msg.content)) {
if (msg.role === "assistant" && Array.isArray(ensureMessageContentArray(msg))) {
// Add cache_control to last block of first (from end) assistant with content
if (!lastAssistantProcessed && msg.content.length > 0) {
msg.content[msg.content.length - 1].cache_control = { type: "ephemeral" };
if (!lastAssistantProcessed && markMessageCacheControl(msg)) {
lastAssistantProcessed = true;
}

View File

@@ -3,6 +3,12 @@
*/
import { saveRequestUsage, appendRequestLog } from "@/lib/usageDb";
import {
getLoggedInputTokens,
getLoggedOutputTokens,
getPromptCacheCreationTokens,
getPromptCacheReadTokens,
} from "@/lib/usage/tokenAccounting";
import { FORMATS } from "../translator/formats.ts";
// ANSI color codes
@@ -415,8 +421,8 @@ export function logUsage(provider, usage, model = null, connectionId = null, api
// Support both formats:
// - OpenAI: prompt_tokens, completion_tokens
// - Claude: input_tokens, output_tokens
const inTokens = usage?.prompt_tokens || usage?.input_tokens || 0;
const outTokens = usage?.completion_tokens || usage?.output_tokens || 0;
const inTokens = getLoggedInputTokens(usage);
const outTokens = getLoggedOutputTokens(usage);
const accountPrefix = connectionId ? connectionId.slice(0, 8) + "..." : "unknown";
let msg = `[${getTimeString()}] 📊 ${COLORS.green}[USAGE] ${p} | in=${inTokens} | out=${outTokens} | account=${accountPrefix}${COLORS.reset}`;
@@ -427,10 +433,10 @@ export function logUsage(provider, usage, model = null, connectionId = null, api
}
// Add cache info if present (unified from different formats)
const cacheRead = usage.cache_read_input_tokens || usage.cached_tokens;
const cacheRead = getPromptCacheReadTokens(usage);
if (cacheRead) msg += ` | cache_read=${cacheRead}`;
const cacheCreation = usage.cache_creation_input_tokens;
const cacheCreation = getPromptCacheCreationTokens(usage);
if (cacheCreation) msg += ` | cache_create=${cacheCreation}`;
const reasoning = usage.reasoning_tokens;
@@ -438,11 +444,9 @@ export function logUsage(provider, usage, model = null, connectionId = null, api
console.log(msg);
// Save to usage DB
// input = total input tokens (non-cached + cache_read + cache_creation)
// This ensures analytics show correct totals for heavily-cached requests
// Save to usage DB with cache-read tracked separately from the main input counter.
const tokens = {
input: inTokens + (cacheRead || 0) + (cacheCreation || 0),
input: inTokens,
output: outTokens,
cacheRead: cacheRead || 0,
cacheCreation: cacheCreation || 0,

View File

@@ -11,6 +11,7 @@ import path from "path";
import fs from "fs";
import { getDbInstance } from "../db/core";
import { shouldPersistToDisk, CALL_LOGS_DIR } from "./migrations";
import { getLoggedInputTokens, getLoggedOutputTokens } from "./tokenAccounting";
import { isNoLog } from "../compliance";
import { sanitizePII } from "../piiSanitizer";
@@ -185,12 +186,8 @@ export async function saveCallLog(entry: any) {
account,
connectionId: entry.connectionId || null,
duration: entry.duration || 0,
tokensIn: toNumber(
(entry.tokens?.prompt_tokens ?? entry.tokens?.input_tokens ?? 0) +
(entry.tokens?.cache_read_input_tokens ?? entry.tokens?.cached_tokens ?? 0) +
(entry.tokens?.cache_creation_input_tokens ?? 0)
),
tokensOut: toNumber(entry.tokens?.completion_tokens ?? entry.tokens?.output_tokens ?? 0),
tokensIn: toNumber(getLoggedInputTokens(entry.tokens)),
tokensOut: toNumber(getLoggedOutputTokens(entry.tokens)),
requestType: entry.requestType || null,
sourceFormat: entry.sourceFormat || null,
targetFormat: entry.targetFormat || null,

View File

@@ -0,0 +1,79 @@
type JsonRecord = Record<string, unknown>;
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function toFiniteNumber(value: unknown): number {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
}
function getPromptTokenDetails(tokens: unknown): JsonRecord {
const tokenRecord = asRecord(tokens);
const promptDetails = asRecord(tokenRecord.prompt_tokens_details);
if (Object.keys(promptDetails).length > 0) return promptDetails;
return asRecord(tokenRecord.input_tokens_details);
}
export function getPromptCacheReadTokens(tokens: unknown): number {
const tokenRecord = asRecord(tokens);
const promptDetails = getPromptTokenDetails(tokenRecord);
return toFiniteNumber(
tokenRecord.cacheRead ??
tokenRecord.cache_read_input_tokens ??
tokenRecord.cached_tokens ??
promptDetails.cached_tokens
);
}
export function getPromptCacheCreationTokens(tokens: unknown): number {
const tokenRecord = asRecord(tokens);
const promptDetails = getPromptTokenDetails(tokenRecord);
return toFiniteNumber(
tokenRecord.cacheCreation ??
tokenRecord.cache_creation_input_tokens ??
promptDetails.cache_creation_tokens
);
}
export function getLoggedInputTokens(tokens: unknown): number {
const tokenRecord = asRecord(tokens);
if (tokenRecord.input !== undefined && tokenRecord.input !== null) {
return toFiniteNumber(tokenRecord.input);
}
if (tokenRecord.input_tokens !== undefined && tokenRecord.input_tokens !== null) {
return toFiniteNumber(tokenRecord.input_tokens);
}
const promptTokens = toFiniteNumber(tokenRecord.prompt_tokens);
if (promptTokens <= 0) return 0;
const promptDetails = getPromptTokenDetails(tokenRecord);
const cachedFromDetails = toFiniteNumber(promptDetails.cached_tokens);
if (cachedFromDetails > 0) {
return Math.max(promptTokens - cachedFromDetails, 0);
}
if ("cached_tokens" in tokenRecord && !("cache_read_input_tokens" in tokenRecord)) {
return Math.max(promptTokens - toFiniteNumber(tokenRecord.cached_tokens), 0);
}
return promptTokens;
}
export function getLoggedOutputTokens(tokens: unknown): number {
const tokenRecord = asRecord(tokens);
if (tokenRecord.output !== undefined && tokenRecord.output !== null) {
return toFiniteNumber(tokenRecord.output);
}
return toFiniteNumber(
tokenRecord.completion_tokens ?? tokenRecord.output_tokens
);
}

View File

@@ -9,6 +9,12 @@
import { getDbInstance } from "../db/core";
import { shouldPersistToDisk } from "./migrations";
import {
getLoggedInputTokens,
getLoggedOutputTokens,
getPromptCacheCreationTokens,
getPromptCacheReadTokens,
} from "./tokenAccounting";
type JsonRecord = Record<string, unknown>;
@@ -157,10 +163,10 @@ export async function saveRequestUsage(entry: any) {
entry.connectionId || null,
entry.apiKeyId || null,
entry.apiKeyName || null,
entry.tokens?.input ?? entry.tokens?.prompt_tokens ?? 0,
entry.tokens?.output ?? entry.tokens?.completion_tokens ?? 0,
entry.tokens?.cacheRead ?? entry.tokens?.cached_tokens ?? 0,
entry.tokens?.cacheCreation ?? entry.tokens?.cache_creation_input_tokens ?? 0,
getLoggedInputTokens(entry.tokens),
getLoggedOutputTokens(entry.tokens),
getPromptCacheReadTokens(entry.tokens),
getPromptCacheCreationTokens(entry.tokens),
entry.tokens?.reasoning ?? entry.tokens?.reasoning_tokens ?? 0,
entry.status || null,
entry.success === false ? 0 : 1,
@@ -422,18 +428,8 @@ export async function appendRequestLog({
}
} catch {}
const sent =
tokens?.input !== undefined
? tokens.input
: tokens?.prompt_tokens !== undefined
? tokens.prompt_tokens
: "-";
const received =
tokens?.output !== undefined
? tokens.output
: tokens?.completion_tokens !== undefined
? tokens.completion_tokens
: "-";
const sent = tokens ? getLoggedInputTokens(tokens) : "-";
const received = tokens ? getLoggedOutputTokens(tokens) : "-";
const line = `${timestamp} | ${m} | ${p} | ${account} | ${sent} | ${received} | ${status}\n`;
fs.appendFileSync(LOG_FILE, line);