mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
79
src/lib/usage/tokenAccounting.ts
Normal file
79
src/lib/usage/tokenAccounting.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user