Skip to content

feat: detailed token tracking + fix Anthropic input undercount - #1017

Merged
diegosouzapw merged 1 commit into
cheaper-inference:mainfrom
rdself:feat/detailed-token-tracking
Apr 7, 2026
Merged

diegosouzapw merged 1 commit into
cheaper-inference:mainfrom
rdself:feat/detailed-token-tracking

Conversation

@rdself

@rdself rdself commented Apr 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Fix Anthropic/anthropic-compatible-cc streaming input token bug: extractUsage() in usageTracking.ts was setting prompt_tokens to only input_tokens (non-cached portion), causing call logs to record drastically undercounted input (e.g. in=3 when real total is 113,616 with cache creation). Now sums input_tokens + cache_read + cache_creation consistently with the non-streaming extractor.
  • Add detailed token breakdown to call logs: New DB columns (tokens_cache_read, tokens_cache_creation, tokens_reasoning) via migration 018. Schema version 2→3. Nullable fields distinguish "provider didn't report" (null → N/A) from "provider reported zero" (0).
  • UI enhancements:
    • List page: I:/O: → TI:/TO: (Total Input/Output)
    • List page + Detail page: Protocol → Req Protocol (clarifies it's the client request protocol)
    • Detail page: always shows 5 token labels (Total In, Total Out, Cache Read, Cache Write, Reasoning), with N/A for null values
  • Protocol label clarity: Renamed "OpenAI" protocol label to "OpenAI-Chat", added new "OpenAI-Responses" entry. Both share the same dark background (#1A1A2E) instead of the old green, to visually distinguish request protocol from provider.

Provider token field coverage

Provider Cache Read Cache Write Reasoning Input fix needed?
anthropic ✅ ✅ — ✅ (streaming)
anthropic-compatible-cc ✅ ✅ — ✅ (streaming)
openai-compatible-aio ✅ — ✅ —
openrouter ✅ ⚠️ (cache_write_tokens not mapped) ✅ —
github ✅ — ✅ —
codex / antigravity / openai-compatible-sp — — — —

Files changed

File Change
open-sse/utils/usageTracking.ts Root cause fix: sum cache into prompt_tokens for Claude streaming
src/lib/usage/tokenAccounting.ts Fix getLoggedInputTokens input_tokens branch; add getReasoningTokens, nullable variants; enhance formatUsageLog
src/lib/db/migrations/018_call_logs_detailed_tokens.sql New: 3 nullable INTEGER columns
src/lib/usage/callLogs.ts Schema v3, extract + store + return new token fields
src/lib/usage/usageHistory.ts Use centralized getReasoningTokens()
src/shared/constants/colors.ts openai → "OpenAI-Chat" + new openai-responses entry, unified dark bg
src/shared/components/RequestLoggerV2.tsx TI/TO labels + Req Protocol header
src/shared/components/RequestLoggerDetail.tsx 5 always-visible token labels + Req Protocol
tests/unit/token-accounting-input-fix.test.mjs New: 8 test cases
tests/unit/call-log-detailed-tokens.test.mjs New: 10 test cases

Test plan

  • 18 new unit tests pass (token accounting fix + detailed extraction per provider)
  • All existing unit tests pass (7 pre-existing Qoder failures unrelated)
  • Migration 018 applies cleanly
  • Docker rebuild → send Anthropic request → verify in is total input, cacheWrite has value
  • Send Codex request → cache/reasoning fields are null → detail page shows N/A
  • Verify list page shows TI/TO + Req Protocol
  • Verify detail page shows all 5 token labels
  • Verify OpenAI-Chat and OpenAI-Responses protocol badges show correct labels and dark background

Copilot AI review requested due to automatic review settings April 5, 2026 23:22

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request enhances token tracking by adding support for cache read, cache creation, and reasoning tokens across the database, logging, and UI components. It also fixes a bug in streaming usage extraction where cached input tokens were being dropped. I have provided a suggestion to include 'cache_write_tokens' in the 'getPromptCacheCreationTokensOrNull' function to improve compatibility with providers like OpenRouter.

Comment on lines +123 to +133
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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation misses support for cache_write_tokens, which is used by providers like OpenRouter (as noted in the PR description and unit tests). Since getPromptCacheCreationTokensOrNull is intended to provide a detailed breakdown, it should ideally include this field to ensure accurate tracking for those providers.

Note: The underlying helper getPromptCacheCreationTokens (not in this diff) should also be updated to include cache_write_tokens for consistency.

Suggested change
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;
}
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", "cache_write_tokens"])
) {
return toFiniteNumber(
tokenRecord.cacheCreation ??
tokenRecord.cache_creation_input_tokens ??
promptDetails.cache_creation_tokens ??
promptDetails.cache_write_tokens
);
}
return null;
}

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes Anthropic (and Anthropic-compatible) streaming input token undercounting by ensuring cached prompt tokens are included in logged “input” totals, and extends call logs to persist and display a more detailed token breakdown (cache read/write + reasoning) across DB, API, and UI.

Changes:

  • Fix Claude streaming usage extraction to set prompt_tokens = input_tokens + cache_read_input_tokens + cache_creation_input_tokens.
  • Add nullable call log columns for cache read/write and reasoning tokens; plumb through persistence + retrieval.
  • Update logger UI labels and display a consistent 5-field token breakdown (with N/A when provider doesn’t report).

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
open-sse/utils/usageTracking.ts Fix Claude streaming extraction to include cache tokens in prompt_tokens (and message_delta total)
src/lib/usage/tokenAccounting.ts Fix getLoggedInputTokens for Anthropic raw usage; add reasoning + nullable helper variants; enrich formatUsageLog
src/lib/db/migrations/018_call_logs_detailed_tokens.sql Add nullable token breakdown columns to call_logs
src/lib/usage/callLogs.ts Bump artifact schema to v3; persist/read new nullable token fields in call logs
src/lib/usage/usageHistory.ts Centralize reasoning extraction via getReasoningTokens()
src/shared/components/RequestLoggerV2.tsx Update list-page labels (TI/TO) and “Req Protocol” header
src/shared/components/RequestLoggerDetail.tsx Always show Total In/Out + Cache Read/Write + Reasoning (null → N/A)
tests/unit/token-accounting-input-fix.test.mjs New tests covering the Anthropic streaming input undercount fix logic
tests/unit/call-log-detailed-tokens.test.mjs New tests covering provider formats and null-vs-0 behavior for breakdown tokens

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/lib/usage/callLogs.ts
Comment on lines 409 to +413
tokensIn: toNumber(getLoggedInputTokens(entry.tokens)),
tokensOut: toNumber(getLoggedOutputTokens(entry.tokens)),
tokensCacheRead: getPromptCacheReadTokensOrNull(entry.tokens),
tokensCacheCreation: getPromptCacheCreationTokensOrNull(entry.tokens),
tokensReasoning: getReasoningTokensOrNull(entry.tokens),

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new *OrNull() extraction relies on the raw entry.tokens retaining explicit 0 values to distinguish “reported 0” from “not reported”. In the streaming pipeline, some usage merges currently drop falsy values (e.g. cache_read_input_tokens: 0), which would cause these columns to be stored as null instead of 0. Update the upstream usage aggregation to preserve numeric zeros (check != null / !== undefined rather than truthiness) so call logs reflect the intended null-vs-0 semantics.

Copilot uses AI. Check for mistakes.
Comment on lines +14 to +49

// ── 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;
}

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests inline copies of the production token-accounting helpers (asRecord, toFiniteNumber, getLoggedInputTokens). That makes the tests validate the duplicated implementation rather than the actual exported code, and they can drift if src/lib/usage/tokenAccounting.ts changes. Prefer importing getLoggedInputTokens from src/lib/usage/tokenAccounting.ts and testing it directly with the same fixtures.

Suggested change
// ── 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;
}
import { getLoggedInputTokens } from "../../src/lib/usage/tokenAccounting.ts";

Copilot uses AI. Check for mistakes.
Comment on lines +14 to +65

// ── 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
);
}

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test file inlines substantial logic from src/lib/usage/tokenAccounting.ts (including the new nullable helpers). Because the assertions run against the duplicated implementation, the test suite may still pass even if the real exported functions regress. Import the actual functions (getPromptCacheReadTokensOrNull, getPromptCacheCreationTokensOrNull, getReasoningTokensOrNull, getLoggedInputTokens) from src/lib/usage/tokenAccounting.ts and test them directly to ensure coverage matches production behavior.

Suggested change
// ── 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
);
}
import {
getPromptCacheReadTokensOrNull as getPromptCacheReadTokens,
getPromptCacheCreationTokensOrNull as getPromptCacheCreationTokens,
getReasoningTokensOrNull as getReasoningTokens,
getLoggedInputTokens,
} from "../../src/lib/usage/tokenAccounting.ts";
// Use the production token-accounting implementation directly so these
// tests fail if the exported behavior regresses.

Copilot uses AI. Check for mistakes.
…rcount

Fix Anthropic/anthropic-compatible-cc streaming input token bug where
only non-cached input_tokens was recorded (e.g. in=3 instead of 113616).
Root cause: extractUsage() in usageTracking.ts set prompt_tokens to just
input_tokens without adding cache_read + cache_creation. Now consistent
with the non-streaming extractor.

Add cache read, cache write, and reasoning token columns to call_logs
(migration 018). Schema version 2→3. Nullable fields distinguish
"provider didn't report" (null/N/A) from "provider reported zero" (0).

UI changes:
- List page: I:/O: → TI:/TO:, Protocol → Req Protocol
- Detail page: always show 5 token labels (Total In, Total Out,
  Cache Read, Cache Write, Reasoning), null displays as N/A
- Detail page: Protocol → Req Protocol
- Protocol label "OpenAI" → "OpenAI-Chat", new "OpenAI-Responses" entry,
  both share dark background (#1A1A2E) instead of the old green
@sonarqubecloud

sonarqubecloud Bot commented Apr 6, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
9.6% Duplication on New Code (required ≤ 3%)
C Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@diegosouzapw
diegosouzapw merged commit eb704d6 into cheaper-inference:main Apr 7, 2026
2 of 3 checks passed
@diegosouzapw

Copy link
Copy Markdown
Collaborator

Thanks @rdself for this incredible contribution! The Anthropic input token fix and detailed token tracking are exactly what we needed. The null vs 0 distinction is very well thought out. 18 new tests -- outstanding quality. Merged into v3.5.4.

@rdself
rdself deleted the feat/detailed-token-tracking branch April 16, 2026 01:14
@diegosouzapw diegosouzapw mentioned this pull request Apr 22, 2026
Poid-ZA pushed a commit to Poid-ZA/OmniRoute that referenced this pull request Aug 5, 2026
…rcount (cheaper-inference#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.
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…rcount (cheaper-inference#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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants