mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-07 15:52:52 +03:00
feat: detailed token tracking in call logs + fix Anthropic input undercount (#1017)
Fixes critical Anthropic streaming input token undercount + adds detailed token tracking with DB migration 018. Includes 18 new unit tests. Integrated into release/v3.5.4.
This commit is contained in:
@@ -299,12 +299,18 @@ export function extractUsage(chunk) {
|
||||
// Claude/Antigravity streaming: message_start event carries INPUT tokens
|
||||
// FIX #74: This event was not handled — input_tokens were being dropped
|
||||
// Structure: { type: "message_start", message: { usage: { input_tokens: N, output_tokens: 0 } } }
|
||||
//
|
||||
// Note: Claude's input_tokens is only the non-cached portion.
|
||||
// Sum cache tokens into prompt_tokens for a correct total (consistent with
|
||||
// extractUsageFromResponse in usageExtractor.ts for non-streaming).
|
||||
if (chunk.type === "message_start" && chunk.message?.usage) {
|
||||
const u = chunk.message.usage;
|
||||
const inputTokens = u.input_tokens || u.prompt_tokens || 0;
|
||||
if (inputTokens > 0) {
|
||||
const cacheRead = u.cache_read_input_tokens || 0;
|
||||
const cacheCreation = u.cache_creation_input_tokens || 0;
|
||||
if (inputTokens > 0 || cacheRead > 0 || cacheCreation > 0) {
|
||||
return normalizeUsage({
|
||||
prompt_tokens: inputTokens,
|
||||
prompt_tokens: inputTokens + cacheRead + cacheCreation,
|
||||
completion_tokens: u.output_tokens || u.completion_tokens || 0,
|
||||
cache_read_input_tokens: u.cache_read_input_tokens,
|
||||
cache_creation_input_tokens: u.cache_creation_input_tokens,
|
||||
@@ -312,10 +318,13 @@ export function extractUsage(chunk) {
|
||||
}
|
||||
}
|
||||
|
||||
// Claude format (message_delta event) — carries OUTPUT tokens
|
||||
// Claude format (message_delta event) — typically carries OUTPUT tokens
|
||||
if (chunk.type === "message_delta" && chunk.usage && typeof chunk.usage === "object") {
|
||||
const deltaInput = chunk.usage.input_tokens || 0;
|
||||
const deltaCacheRead = chunk.usage.cache_read_input_tokens || 0;
|
||||
const deltaCacheCreation = chunk.usage.cache_creation_input_tokens || 0;
|
||||
return normalizeUsage({
|
||||
prompt_tokens: chunk.usage.input_tokens || 0,
|
||||
prompt_tokens: deltaInput + deltaCacheRead + deltaCacheCreation,
|
||||
completion_tokens: chunk.usage.output_tokens || 0,
|
||||
cache_read_input_tokens: chunk.usage.cache_read_input_tokens,
|
||||
cache_creation_input_tokens: chunk.usage.cache_creation_input_tokens,
|
||||
|
||||
6
src/lib/db/migrations/018_call_logs_detailed_tokens.sql
Normal file
6
src/lib/db/migrations/018_call_logs_detailed_tokens.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- Add detailed token breakdown columns to call_logs.
|
||||
-- These are NULL when the provider does not report the field,
|
||||
-- or an integer (including 0) when the provider explicitly returned a value.
|
||||
ALTER TABLE call_logs ADD COLUMN tokens_cache_read INTEGER DEFAULT NULL;
|
||||
ALTER TABLE call_logs ADD COLUMN tokens_cache_creation INTEGER DEFAULT NULL;
|
||||
ALTER TABLE call_logs ADD COLUMN tokens_reasoning INTEGER DEFAULT NULL;
|
||||
@@ -13,7 +13,13 @@ import type { RequestPipelinePayloads } from "@omniroute/open-sse/utils/requestL
|
||||
import { getDbInstance } from "../db/core";
|
||||
import { getRequestDetailLogByCallLogId } from "../db/detailedLogs";
|
||||
import { shouldPersistToDisk, CALL_LOGS_DIR } from "./migrations";
|
||||
import { getLoggedInputTokens, getLoggedOutputTokens } from "./tokenAccounting";
|
||||
import {
|
||||
getLoggedInputTokens,
|
||||
getLoggedOutputTokens,
|
||||
getPromptCacheReadTokensOrNull,
|
||||
getPromptCacheCreationTokensOrNull,
|
||||
getReasoningTokensOrNull,
|
||||
} from "./tokenAccounting";
|
||||
import { isNoLog } from "../compliance";
|
||||
import { sanitizePII } from "../piiSanitizer";
|
||||
import {
|
||||
@@ -26,7 +32,7 @@ import { getCallLogMaxEntries, getCallLogRetentionDays } from "../logEnv";
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
type CallLogArtifact = {
|
||||
schemaVersion: 2;
|
||||
schemaVersion: 3;
|
||||
summary: {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
@@ -39,7 +45,13 @@ type CallLogArtifact = {
|
||||
account: string;
|
||||
connectionId: string | null;
|
||||
duration: number;
|
||||
tokens: { in: number; out: number };
|
||||
tokens: {
|
||||
in: number;
|
||||
out: number;
|
||||
cacheRead: number | null;
|
||||
cacheWrite: number | null;
|
||||
reasoning: number | null;
|
||||
};
|
||||
requestType: string | null;
|
||||
sourceFormat: string | null;
|
||||
targetFormat: string | null;
|
||||
@@ -180,6 +192,9 @@ function buildArtifact(
|
||||
duration: number;
|
||||
tokensIn: number;
|
||||
tokensOut: number;
|
||||
tokensCacheRead: number | null;
|
||||
tokensCacheCreation: number | null;
|
||||
tokensReasoning: number | null;
|
||||
requestType: string | null;
|
||||
sourceFormat: string | null;
|
||||
targetFormat: string | null;
|
||||
@@ -193,7 +208,7 @@ function buildArtifact(
|
||||
pipelinePayloads: RequestPipelinePayloads | null
|
||||
): CallLogArtifact {
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
schemaVersion: 3,
|
||||
summary: {
|
||||
id: logEntry.id,
|
||||
timestamp: logEntry.timestamp,
|
||||
@@ -206,7 +221,13 @@ function buildArtifact(
|
||||
account: logEntry.account,
|
||||
connectionId: logEntry.connectionId,
|
||||
duration: logEntry.duration,
|
||||
tokens: { in: logEntry.tokensIn, out: logEntry.tokensOut },
|
||||
tokens: {
|
||||
in: logEntry.tokensIn,
|
||||
out: logEntry.tokensOut,
|
||||
cacheRead: logEntry.tokensCacheRead,
|
||||
cacheWrite: logEntry.tokensCacheCreation,
|
||||
reasoning: logEntry.tokensReasoning,
|
||||
},
|
||||
requestType: logEntry.requestType,
|
||||
sourceFormat: logEntry.sourceFormat,
|
||||
targetFormat: logEntry.targetFormat,
|
||||
@@ -387,6 +408,9 @@ export async function saveCallLog(entry: any) {
|
||||
duration: entry.duration || 0,
|
||||
tokensIn: toNumber(getLoggedInputTokens(entry.tokens)),
|
||||
tokensOut: toNumber(getLoggedOutputTokens(entry.tokens)),
|
||||
tokensCacheRead: getPromptCacheReadTokensOrNull(entry.tokens),
|
||||
tokensCacheCreation: getPromptCacheCreationTokensOrNull(entry.tokens),
|
||||
tokensReasoning: getReasoningTokensOrNull(entry.tokens),
|
||||
requestType: entry.requestType || null,
|
||||
sourceFormat: entry.sourceFormat || null,
|
||||
targetFormat: entry.targetFormat || null,
|
||||
@@ -403,13 +427,17 @@ export async function saveCallLog(entry: any) {
|
||||
`
|
||||
INSERT INTO call_logs (
|
||||
id, timestamp, method, path, status, model, requested_model, provider,
|
||||
account, connection_id, duration, tokens_in, tokens_out, request_type, source_format,
|
||||
account, connection_id, duration, tokens_in, tokens_out,
|
||||
tokens_cache_read, tokens_cache_creation, tokens_reasoning,
|
||||
request_type, source_format,
|
||||
target_format, api_key_id, api_key_name, combo_name, request_body, response_body, error,
|
||||
artifact_relpath, has_pipeline_details
|
||||
)
|
||||
VALUES (
|
||||
@id, @timestamp, @method, @path, @status, @model, @requestedModel, @provider,
|
||||
@account, @connectionId, @duration, @tokensIn, @tokensOut, @requestType, @sourceFormat,
|
||||
@account, @connectionId, @duration, @tokensIn, @tokensOut,
|
||||
@tokensCacheRead, @tokensCacheCreation, @tokensReasoning,
|
||||
@requestType, @sourceFormat,
|
||||
@targetFormat, @apiKeyId, @apiKeyName, @comboName, @requestBody, @responseBody, @error,
|
||||
NULL, 0
|
||||
)
|
||||
@@ -541,7 +569,13 @@ export async function getCallLogs(filter: any = {}) {
|
||||
provider: toStringOrNull(l.provider),
|
||||
account: toStringOrNull(l.account),
|
||||
duration: toNumber(l.duration),
|
||||
tokens: { in: toNumber(l.tokens_in), out: toNumber(l.tokens_out) },
|
||||
tokens: {
|
||||
in: toNumber(l.tokens_in),
|
||||
out: toNumber(l.tokens_out),
|
||||
cacheRead: l.tokens_cache_read != null ? toNumber(l.tokens_cache_read) : null,
|
||||
cacheWrite: l.tokens_cache_creation != null ? toNumber(l.tokens_cache_creation) : null,
|
||||
reasoning: l.tokens_reasoning != null ? toNumber(l.tokens_reasoning) : null,
|
||||
},
|
||||
sourceFormat: toStringOrNull(l.source_format),
|
||||
targetFormat: toStringOrNull(l.target_format),
|
||||
error: toStringOrNull(l.error),
|
||||
@@ -586,7 +620,14 @@ export async function getCallLogById(id: string) {
|
||||
account: toStringOrNull(entryRow.account),
|
||||
connectionId: toStringOrNull(entryRow.connection_id),
|
||||
duration: toNumber(entryRow.duration),
|
||||
tokens: { in: toNumber(entryRow.tokens_in), out: toNumber(entryRow.tokens_out) },
|
||||
tokens: {
|
||||
in: toNumber(entryRow.tokens_in),
|
||||
out: toNumber(entryRow.tokens_out),
|
||||
cacheRead: entryRow.tokens_cache_read != null ? toNumber(entryRow.tokens_cache_read) : null,
|
||||
cacheWrite:
|
||||
entryRow.tokens_cache_creation != null ? toNumber(entryRow.tokens_cache_creation) : null,
|
||||
reasoning: entryRow.tokens_reasoning != null ? toNumber(entryRow.tokens_reasoning) : null,
|
||||
},
|
||||
sourceFormat: toStringOrNull(entryRow.source_format),
|
||||
targetFormat: toStringOrNull(entryRow.target_format),
|
||||
apiKeyId: toStringOrNull(entryRow.api_key_id),
|
||||
|
||||
@@ -49,11 +49,22 @@ export function getLoggedInputTokens(tokens: unknown): number {
|
||||
}
|
||||
|
||||
if (tokenRecord.input_tokens !== undefined && tokenRecord.input_tokens !== null) {
|
||||
return toFiniteNumber(tokenRecord.input_tokens);
|
||||
// Anthropic / anthropic-compatible-cc streaming: input_tokens is only the
|
||||
// non-cached portion. The cache counters sit as separate top-level fields
|
||||
// (cache_read_input_tokens, cache_creation_input_tokens). We need to add
|
||||
// them to get the true total input.
|
||||
return (
|
||||
toFiniteNumber(tokenRecord.input_tokens) +
|
||||
toFiniteNumber(tokenRecord.cache_read_input_tokens) +
|
||||
toFiniteNumber(tokenRecord.cache_creation_input_tokens)
|
||||
);
|
||||
}
|
||||
|
||||
// prompt_tokens from translator already includes input + cache_read + cache_creation
|
||||
// Do NOT subtract cached tokens - we want the total billable prompt tokens
|
||||
// prompt_tokens from translator/extractor already includes cache tokens:
|
||||
// - OpenAI format: prompt_tokens inherently includes cached
|
||||
// - Claude non-streaming: extractUsageFromResponse sums input + cache_read + cache_creation
|
||||
// - Claude streaming: extractUsage (after fix) sums input + cache_read + cache_creation
|
||||
// Do NOT add cache fields here — would double-count.
|
||||
const promptTokens = toFiniteNumber(tokenRecord.prompt_tokens);
|
||||
return promptTokens;
|
||||
}
|
||||
@@ -66,14 +77,93 @@ export function getLoggedOutputTokens(tokens: unknown): number {
|
||||
return toFiniteNumber(tokenRecord.completion_tokens ?? tokenRecord.output_tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the reasoning/thinking output token count.
|
||||
* Checks multiple field locations used by different providers:
|
||||
* - completion_tokens_details.reasoning_tokens (OpenAI, OpenRouter)
|
||||
* - reasoning_tokens (GitHub — top-level)
|
||||
* - reasoning (usage_history DB format)
|
||||
*/
|
||||
export function getReasoningTokens(tokens: unknown): number {
|
||||
const tokenRecord = asRecord(tokens);
|
||||
const completionDetails = asRecord(tokenRecord.completion_tokens_details);
|
||||
return toFiniteNumber(
|
||||
tokenRecord.reasoning ?? tokenRecord.reasoning_tokens ?? completionDetails.reasoning_tokens
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Nullable variants ──────────────────────────────────────────────────
|
||||
// Return `null` when the provider simply doesn't report the field,
|
||||
// vs `0` when the provider explicitly reported zero.
|
||||
|
||||
function hasAnyKey(record: JsonRecord, keys: string[]): boolean {
|
||||
return keys.some((k) => record[k] !== undefined && record[k] !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return prompt cache-read tokens, or `null` if the provider didn't
|
||||
* report any cache-read field at all.
|
||||
*/
|
||||
export function getPromptCacheReadTokensOrNull(tokens: unknown): number | null {
|
||||
const tokenRecord = asRecord(tokens);
|
||||
const promptDetails = getPromptTokenDetails(tokenRecord);
|
||||
if (
|
||||
hasAnyKey(tokenRecord, ["cacheRead", "cache_read_input_tokens", "cached_tokens"]) ||
|
||||
hasAnyKey(promptDetails, ["cached_tokens"])
|
||||
) {
|
||||
return getPromptCacheReadTokens(tokens);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return prompt cache-creation (write) tokens, or `null` if the
|
||||
* provider didn't report any cache-creation field at all.
|
||||
*/
|
||||
export function getPromptCacheCreationTokensOrNull(tokens: unknown): number | null {
|
||||
const tokenRecord = asRecord(tokens);
|
||||
const promptDetails = getPromptTokenDetails(tokenRecord);
|
||||
if (
|
||||
hasAnyKey(tokenRecord, ["cacheCreation", "cache_creation_input_tokens"]) ||
|
||||
hasAnyKey(promptDetails, ["cache_creation_tokens"])
|
||||
) {
|
||||
return getPromptCacheCreationTokens(tokens);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return reasoning tokens, or `null` if the provider didn't report
|
||||
* any reasoning field at all.
|
||||
*/
|
||||
export function getReasoningTokensOrNull(tokens: unknown): number | null {
|
||||
const tokenRecord = asRecord(tokens);
|
||||
const completionDetails = asRecord(tokenRecord.completion_tokens_details);
|
||||
if (
|
||||
hasAnyKey(tokenRecord, ["reasoning", "reasoning_tokens"]) ||
|
||||
hasAnyKey(completionDetails, ["reasoning_tokens"])
|
||||
) {
|
||||
return getReasoningTokens(tokens);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatUsageLog(tokens: unknown): string {
|
||||
const input = getLoggedInputTokens(tokens);
|
||||
const output = getLoggedOutputTokens(tokens);
|
||||
const cacheRead = getPromptCacheReadTokens(tokens);
|
||||
const cacheWrite = getPromptCacheCreationTokens(tokens);
|
||||
const reasoning = getReasoningTokens(tokens);
|
||||
|
||||
let msg = `in=${input} | out=${output}`;
|
||||
if (cacheRead > 0) {
|
||||
msg += ` | CR=${cacheRead}`;
|
||||
}
|
||||
if (cacheWrite > 0) {
|
||||
msg += ` | CW=${cacheWrite}`;
|
||||
}
|
||||
if (reasoning > 0) {
|
||||
msg += ` | R=${reasoning}`;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getLoggedOutputTokens,
|
||||
getPromptCacheCreationTokens,
|
||||
getPromptCacheReadTokens,
|
||||
getReasoningTokens,
|
||||
} from "./tokenAccounting";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
@@ -167,7 +168,7 @@ export async function saveRequestUsage(entry: any) {
|
||||
getLoggedOutputTokens(entry.tokens),
|
||||
getPromptCacheReadTokens(entry.tokens),
|
||||
getPromptCacheCreationTokens(entry.tokens),
|
||||
entry.tokens?.reasoning ?? entry.tokens?.reasoning_tokens ?? 0,
|
||||
getReasoningTokens(entry.tokens),
|
||||
entry.status || null,
|
||||
entry.success === false ? 0 : 1,
|
||||
Number.isFinite(Number(entry.latencyMs)) ? Number(entry.latencyMs) : 0,
|
||||
|
||||
@@ -159,14 +159,32 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
Tokens (I/O)
|
||||
Tokens
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="px-2 py-0.5 rounded bg-primary/20 text-primary text-xs font-bold">
|
||||
In: {(detail?.tokens?.in || log.tokens?.in || 0).toLocaleString()}
|
||||
Total In: {(detail?.tokens?.in ?? log.tokens?.in ?? 0).toLocaleString()}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-700 dark:text-emerald-400 text-xs font-bold">
|
||||
Out: {(detail?.tokens?.out || log.tokens?.out || 0).toLocaleString()}
|
||||
Total Out: {(detail?.tokens?.out ?? log.tokens?.out ?? 0).toLocaleString()}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded bg-sky-500/20 text-sky-700 dark:text-sky-400 text-xs font-bold">
|
||||
Cache Read:{" "}
|
||||
{(detail?.tokens?.cacheRead ?? log.tokens?.cacheRead) != null
|
||||
? (detail?.tokens?.cacheRead ?? log.tokens?.cacheRead).toLocaleString()
|
||||
: "N/A"}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded bg-amber-500/20 text-amber-700 dark:text-amber-400 text-xs font-bold">
|
||||
Cache Write:{" "}
|
||||
{(detail?.tokens?.cacheWrite ?? log.tokens?.cacheWrite) != null
|
||||
? (detail?.tokens?.cacheWrite ?? log.tokens?.cacheWrite).toLocaleString()
|
||||
: "N/A"}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded bg-violet-500/20 text-violet-700 dark:text-violet-400 text-xs font-bold">
|
||||
Reasoning:{" "}
|
||||
{(detail?.tokens?.reasoning ?? log.tokens?.reasoning) != null
|
||||
? (detail?.tokens?.reasoning ?? log.tokens?.reasoning).toLocaleString()
|
||||
: "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,7 +220,7 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
Protocol
|
||||
Req Protocol
|
||||
</div>
|
||||
<span
|
||||
className="inline-block px-2.5 py-1 rounded text-[10px] font-bold uppercase"
|
||||
|
||||
@@ -31,7 +31,7 @@ const COLUMNS = [
|
||||
{ key: "model", label: "Model" },
|
||||
{ key: "requestedModel", label: "Requested" },
|
||||
{ key: "provider", label: "Provider" },
|
||||
{ key: "protocol", label: "Protocol" },
|
||||
{ key: "protocol", label: "Req Protocol" },
|
||||
{ key: "account", label: "Account" },
|
||||
{ key: "apiKey", label: "API Key" },
|
||||
{ key: "combo", label: "Combo" },
|
||||
@@ -582,7 +582,7 @@ export default function RequestLoggerV2() {
|
||||
)}
|
||||
{visibleColumns.protocol && (
|
||||
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
|
||||
Protocol
|
||||
Req Protocol
|
||||
</th>
|
||||
)}
|
||||
{visibleColumns.account && (
|
||||
@@ -728,12 +728,12 @@ export default function RequestLoggerV2() {
|
||||
)}
|
||||
{visibleColumns.tokens && (
|
||||
<td className="px-3 py-2 text-right whitespace-nowrap">
|
||||
<span className="text-text-muted">I:</span>{" "}
|
||||
<span className="text-text-muted">TI:</span>{" "}
|
||||
<span className="text-primary">
|
||||
{log.tokens?.in?.toLocaleString() || 0}
|
||||
</span>
|
||||
<span className="mx-1 text-border">|</span>
|
||||
<span className="text-text-muted">O:</span>{" "}
|
||||
<span className="text-text-muted">TO:</span>{" "}
|
||||
<span className="text-emerald-700 dark:text-emerald-400">
|
||||
{log.tokens?.out?.toLocaleString() || 0}
|
||||
</span>
|
||||
|
||||
@@ -30,7 +30,8 @@ export const PROVIDER_COLORS = {
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
export const PROTOCOL_COLORS = {
|
||||
openai: { bg: "#10A37F", text: "#fff", label: "OpenAI" },
|
||||
openai: { bg: "#1A1A2E", text: "#fff", label: "OpenAI-Chat" },
|
||||
"openai-responses": { bg: "#1A1A2E", text: "#fff", label: "OpenAI-Responses" },
|
||||
claude: { bg: "#D97757", text: "#fff", label: "Claude" },
|
||||
gemini: { bg: "#4285F4", text: "#fff", label: "Gemini" },
|
||||
warmup: { bg: "#F59E0B", text: "#000", label: "Warmup" },
|
||||
|
||||
247
tests/unit/call-log-detailed-tokens.test.mjs
Normal file
247
tests/unit/call-log-detailed-tokens.test.mjs
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Unit tests for detailed token tracking in call logs.
|
||||
*
|
||||
* Verifies that getPromptCacheReadTokensOrNull, getPromptCacheCreationTokensOrNull,
|
||||
* and getReasoningTokensOrNull correctly distinguish between:
|
||||
* - Provider didn't report the field → null
|
||||
* - Provider reported zero → 0
|
||||
*
|
||||
* Also tests getLoggedInputTokens for each provider format.
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// ── Inline the logic from tokenAccounting.ts ────────────────────────────
|
||||
|
||||
function asRecord(value) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||
}
|
||||
|
||||
function toFiniteNumber(value) {
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
function getPromptCacheReadTokens(tokens) {
|
||||
const tokenRecord = asRecord(tokens);
|
||||
const promptDetails = getPromptTokenDetails(tokenRecord);
|
||||
return toFiniteNumber(
|
||||
tokenRecord.cacheRead ??
|
||||
tokenRecord.cache_read_input_tokens ??
|
||||
tokenRecord.cached_tokens ??
|
||||
promptDetails.cached_tokens
|
||||
);
|
||||
}
|
||||
|
||||
function getPromptCacheCreationTokens(tokens) {
|
||||
const tokenRecord = asRecord(tokens);
|
||||
const promptDetails = getPromptTokenDetails(tokenRecord);
|
||||
return toFiniteNumber(
|
||||
tokenRecord.cacheCreation ??
|
||||
tokenRecord.cache_creation_input_tokens ??
|
||||
promptDetails.cache_creation_tokens
|
||||
);
|
||||
}
|
||||
|
||||
function getReasoningTokens(tokens) {
|
||||
const tokenRecord = asRecord(tokens);
|
||||
const completionDetails = asRecord(tokenRecord.completion_tokens_details);
|
||||
return toFiniteNumber(
|
||||
tokenRecord.reasoning ?? tokenRecord.reasoning_tokens ?? completionDetails.reasoning_tokens
|
||||
);
|
||||
}
|
||||
|
||||
function hasAnyKey(record, keys) {
|
||||
return keys.some((k) => record[k] !== undefined && record[k] !== null);
|
||||
}
|
||||
|
||||
function getPromptCacheReadTokensOrNull(tokens) {
|
||||
const tokenRecord = asRecord(tokens);
|
||||
const promptDetails = getPromptTokenDetails(tokenRecord);
|
||||
if (
|
||||
hasAnyKey(tokenRecord, ["cacheRead", "cache_read_input_tokens", "cached_tokens"]) ||
|
||||
hasAnyKey(promptDetails, ["cached_tokens"])
|
||||
) {
|
||||
return getPromptCacheReadTokens(tokens);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPromptCacheCreationTokensOrNull(tokens) {
|
||||
const tokenRecord = asRecord(tokens);
|
||||
const promptDetails = getPromptTokenDetails(tokenRecord);
|
||||
if (
|
||||
hasAnyKey(tokenRecord, ["cacheCreation", "cache_creation_input_tokens"]) ||
|
||||
hasAnyKey(promptDetails, ["cache_creation_tokens"])
|
||||
) {
|
||||
return getPromptCacheCreationTokens(tokens);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getReasoningTokensOrNull(tokens) {
|
||||
const tokenRecord = asRecord(tokens);
|
||||
const completionDetails = asRecord(tokenRecord.completion_tokens_details);
|
||||
if (
|
||||
hasAnyKey(tokenRecord, ["reasoning", "reasoning_tokens"]) ||
|
||||
hasAnyKey(completionDetails, ["reasoning_tokens"])
|
||||
) {
|
||||
return getReasoningTokens(tokens);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getLoggedInputTokens(tokens) {
|
||||
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) +
|
||||
toFiniteNumber(tokenRecord.cache_read_input_tokens) +
|
||||
toFiniteNumber(tokenRecord.cache_creation_input_tokens)
|
||||
);
|
||||
}
|
||||
const promptTokens = toFiniteNumber(tokenRecord.prompt_tokens);
|
||||
return promptTokens;
|
||||
}
|
||||
|
||||
// ── Provider format tests ───────────────────────────────────────────────
|
||||
|
||||
describe("detailed token extraction — per provider format", () => {
|
||||
it("Anthropic (streaming extracted): input_tokens=3, cache_creation=113613, cache_read=0", () => {
|
||||
// Raw Anthropic streaming usage (from message_start event)
|
||||
const tokens = {
|
||||
input_tokens: 3,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 113613,
|
||||
output_tokens: 6921,
|
||||
};
|
||||
assert.equal(getLoggedInputTokens(tokens), 113616, "Total input = 3 + 0 + 113613");
|
||||
assert.equal(getPromptCacheReadTokensOrNull(tokens), 0, "Cache read reported as 0");
|
||||
assert.equal(getPromptCacheCreationTokensOrNull(tokens), 113613, "Cache write = 113613");
|
||||
assert.equal(getReasoningTokensOrNull(tokens), null, "No reasoning field");
|
||||
});
|
||||
|
||||
it("anthropic-compatible-cc: same format as Anthropic", () => {
|
||||
const tokens = {
|
||||
input_tokens: 3,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 113613,
|
||||
output_tokens: 6921,
|
||||
};
|
||||
assert.equal(getLoggedInputTokens(tokens), 113616);
|
||||
assert.equal(getPromptCacheReadTokensOrNull(tokens), 0);
|
||||
assert.equal(getPromptCacheCreationTokensOrNull(tokens), 113613);
|
||||
assert.equal(getReasoningTokensOrNull(tokens), null);
|
||||
});
|
||||
|
||||
it("openai-compatible-aio: prompt_tokens=54042, cached=53221, reasoning=6433", () => {
|
||||
const tokens = {
|
||||
prompt_tokens: 54042,
|
||||
completion_tokens: 8000,
|
||||
prompt_tokens_details: { cached_tokens: 53221 },
|
||||
completion_tokens_details: { reasoning_tokens: 6433 },
|
||||
};
|
||||
assert.equal(getLoggedInputTokens(tokens), 54042, "prompt_tokens already includes cached");
|
||||
assert.equal(getPromptCacheReadTokensOrNull(tokens), 53221, "Cache read from details");
|
||||
assert.equal(getPromptCacheCreationTokensOrNull(tokens), null, "No cache creation field");
|
||||
assert.equal(getReasoningTokensOrNull(tokens), 6433, "Reasoning from completion details");
|
||||
});
|
||||
|
||||
it("OpenRouter: prompt_tokens=5, cached=0, cache_write=0, reasoning=60", () => {
|
||||
const tokens = {
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 100,
|
||||
prompt_tokens_details: { cached_tokens: 0, cache_write_tokens: 0 },
|
||||
completion_tokens_details: { reasoning_tokens: 60 },
|
||||
};
|
||||
assert.equal(getLoggedInputTokens(tokens), 5);
|
||||
assert.equal(getPromptCacheReadTokensOrNull(tokens), 0, "Cache read = 0 (reported)");
|
||||
// cache_write_tokens is in prompt_tokens_details but our function checks
|
||||
// cache_creation_input_tokens / cache_creation_tokens
|
||||
// OpenRouter uses cache_write_tokens which is NOT recognized → null
|
||||
assert.equal(
|
||||
getPromptCacheCreationTokensOrNull(tokens),
|
||||
null,
|
||||
"OpenRouter cache_write_tokens not mapped to creation"
|
||||
);
|
||||
assert.equal(getReasoningTokensOrNull(tokens), 60, "Reasoning = 60");
|
||||
});
|
||||
|
||||
it("GitHub: prompt_tokens=5, cached=0, reasoning_tokens=57", () => {
|
||||
const tokens = {
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 100,
|
||||
prompt_tokens_details: { cached_tokens: 0 },
|
||||
reasoning_tokens: 57,
|
||||
};
|
||||
assert.equal(getLoggedInputTokens(tokens), 5);
|
||||
assert.equal(getPromptCacheReadTokensOrNull(tokens), 0, "Cache read = 0 (reported)");
|
||||
assert.equal(getPromptCacheCreationTokensOrNull(tokens), null, "No cache creation");
|
||||
assert.equal(getReasoningTokensOrNull(tokens), 57, "Reasoning from top-level");
|
||||
});
|
||||
|
||||
it("Codex: only prompt_tokens/completion_tokens, no breakdowns", () => {
|
||||
const tokens = {
|
||||
prompt_tokens: 500,
|
||||
completion_tokens: 200,
|
||||
total_tokens: 700,
|
||||
};
|
||||
assert.equal(getLoggedInputTokens(tokens), 500);
|
||||
assert.equal(getPromptCacheReadTokensOrNull(tokens), null, "No cache read field");
|
||||
assert.equal(getPromptCacheCreationTokensOrNull(tokens), null, "No cache creation field");
|
||||
assert.equal(getReasoningTokensOrNull(tokens), null, "No reasoning field");
|
||||
});
|
||||
|
||||
it("Antigravity / openai-compatible-sp: same as Codex (no breakdowns)", () => {
|
||||
const tokens = {
|
||||
prompt_tokens: 300,
|
||||
completion_tokens: 150,
|
||||
};
|
||||
assert.equal(getPromptCacheReadTokensOrNull(tokens), null);
|
||||
assert.equal(getPromptCacheCreationTokensOrNull(tokens), null);
|
||||
assert.equal(getReasoningTokensOrNull(tokens), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("null vs 0 distinction", () => {
|
||||
it("explicit 0 is preserved (not collapsed to null)", () => {
|
||||
const tokens = {
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
};
|
||||
assert.equal(getPromptCacheReadTokensOrNull(tokens), 0);
|
||||
assert.equal(getPromptCacheCreationTokensOrNull(tokens), 0);
|
||||
assert.equal(getReasoningTokensOrNull(tokens), 0);
|
||||
});
|
||||
|
||||
it("missing fields return null", () => {
|
||||
const tokens = { prompt_tokens: 100, completion_tokens: 50 };
|
||||
assert.equal(getPromptCacheReadTokensOrNull(tokens), null);
|
||||
assert.equal(getPromptCacheCreationTokensOrNull(tokens), null);
|
||||
assert.equal(getReasoningTokensOrNull(tokens), null);
|
||||
});
|
||||
|
||||
it("undefined fields return null (not 0)", () => {
|
||||
const tokens = {
|
||||
prompt_tokens: 100,
|
||||
cache_read_input_tokens: undefined,
|
||||
};
|
||||
assert.equal(getPromptCacheReadTokensOrNull(tokens), null);
|
||||
});
|
||||
});
|
||||
130
tests/unit/token-accounting-input-fix.test.mjs
Normal file
130
tests/unit/token-accounting-input-fix.test.mjs
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Unit tests for getLoggedInputTokens fix — Anthropic / anthropic-compatible-cc
|
||||
*
|
||||
* The bug: Claude streaming sets prompt_tokens = input_tokens (non-cached only).
|
||||
* Fix: extractUsage in usageTracking.ts now sums input + cache_read + cache_creation
|
||||
* into prompt_tokens, consistent with the non-streaming extractor.
|
||||
*
|
||||
* getLoggedInputTokens itself also has a safety-net: when raw `input_tokens`
|
||||
* is present (e.g. from a raw API response), it adds cache tokens too.
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// ── Inline the fixed logic from tokenAccounting.ts ──────────────────────
|
||||
|
||||
function asRecord(value) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||
}
|
||||
|
||||
function toFiniteNumber(value) {
|
||||
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 getLoggedInputTokens(tokens) {
|
||||
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) +
|
||||
toFiniteNumber(tokenRecord.cache_read_input_tokens) +
|
||||
toFiniteNumber(tokenRecord.cache_creation_input_tokens)
|
||||
);
|
||||
}
|
||||
|
||||
// prompt_tokens from translator/extractor already includes cache tokens
|
||||
const promptTokens = toFiniteNumber(tokenRecord.prompt_tokens);
|
||||
return promptTokens;
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("getLoggedInputTokens — input fix for Anthropic streaming", () => {
|
||||
it("raw Anthropic usage with input_tokens: adds cache for correct total", () => {
|
||||
// Raw API response shape (before extractUsage processes it)
|
||||
const tokens = {
|
||||
input_tokens: 3,
|
||||
cache_read_input_tokens: 500,
|
||||
cache_creation_input_tokens: 100,
|
||||
output_tokens: 200,
|
||||
};
|
||||
assert.equal(getLoggedInputTokens(tokens), 603);
|
||||
});
|
||||
|
||||
it("raw Anthropic usage: input_tokens=3, cache_creation=113613 → 113616", () => {
|
||||
const tokens = {
|
||||
input_tokens: 3,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 113613,
|
||||
output_tokens: 6921,
|
||||
};
|
||||
assert.equal(getLoggedInputTokens(tokens), 113616);
|
||||
});
|
||||
|
||||
it("extracted streaming usage (after fix): prompt_tokens is total, no double-count", () => {
|
||||
// After the streaming extractor fix, message_start produces:
|
||||
// prompt_tokens = input_tokens + cache_read + cache_creation
|
||||
const tokens = {
|
||||
prompt_tokens: 113616, // already total (3 + 0 + 113613)
|
||||
completion_tokens: 6921,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 113613,
|
||||
};
|
||||
// No input_tokens field → falls to prompt_tokens → returns 113616 (no double-count)
|
||||
assert.equal(getLoggedInputTokens(tokens), 113616);
|
||||
});
|
||||
|
||||
it("extracted non-streaming usage: prompt_tokens is total, no double-count", () => {
|
||||
// extractUsageFromResponse sets prompt_tokens = input + cacheRead + cacheCreation
|
||||
const tokens = {
|
||||
prompt_tokens: 113616,
|
||||
completion_tokens: 6921,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 113613,
|
||||
};
|
||||
assert.equal(getLoggedInputTokens(tokens), 113616);
|
||||
});
|
||||
|
||||
it("OpenAI format: prompt_tokens=1000, no cache top-level fields → 1000", () => {
|
||||
const tokens = {
|
||||
prompt_tokens: 1000,
|
||||
completion_tokens: 500,
|
||||
total_tokens: 1500,
|
||||
};
|
||||
assert.equal(getLoggedInputTokens(tokens), 1000);
|
||||
});
|
||||
|
||||
it("OpenAI format with cached_tokens in details (no top-level cache fields) → prompt_tokens", () => {
|
||||
const tokens = {
|
||||
prompt_tokens: 54042,
|
||||
completion_tokens: 8000,
|
||||
prompt_tokens_details: { cached_tokens: 53221 },
|
||||
};
|
||||
assert.equal(getLoggedInputTokens(tokens), 54042);
|
||||
});
|
||||
|
||||
it("pre-computed 'input' field takes precedence over everything", () => {
|
||||
const tokens = {
|
||||
input: 999,
|
||||
prompt_tokens: 100,
|
||||
input_tokens: 50,
|
||||
};
|
||||
assert.equal(getLoggedInputTokens(tokens), 999);
|
||||
});
|
||||
|
||||
it("handles null/undefined gracefully", () => {
|
||||
assert.equal(getLoggedInputTokens(null), 0);
|
||||
assert.equal(getLoggedInputTokens(undefined), 0);
|
||||
assert.equal(getLoggedInputTokens({}), 0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user