fix(usage): include cache tokens in input token counts

- Fix getLoggedInputTokens to return full prompt_tokens (input + cache_read + cache_creation)
- Fix usageExtractor for non-streaming Claude responses to calculate total correctly
- Add formatUsageLog helper to show CR=<cache_read> in logs
- Add migration 012 to fix historical token counts in usage_history
- Move prompt cache metrics from Settings to /dashboard/cache page

Per Claude API docs:
Total input tokens = input_tokens + cache_creation_input_tokens + cache_read_input_tokens

Fixes issue where totalInputTokens (71k) was less than totalCacheCreationTokens (1.35M).

Tested:
- All 1134 unit tests pass
- Cache metrics API returns correct totals
- Migration is idempotent and tracked in _omniroute_migrations
- Logs show cache read tokens: 'in=6055 | out=211 | CR=22399'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
tombii
2026-03-30 17:53:06 +02:00
parent 8a9c15c874
commit c6eadc504b
6 changed files with 54 additions and 27 deletions

View File

@@ -32,7 +32,11 @@ import {
appendRequestLog,
saveCallLog,
} from "@/lib/usageDb";
import { getLoggedInputTokens, getLoggedOutputTokens } from "@/lib/usage/tokenAccounting";
import {
getLoggedInputTokens,
getLoggedOutputTokens,
formatUsageLog,
} from "@/lib/usage/tokenAccounting";
import { recordCost } from "@/domain/costRules";
import { calculateCost } from "@/lib/usage/costCalculator";
import { CLAUDE_OAUTH_TOOL_PREFIX } from "../translator/request/openai-to-claude.ts";
@@ -1432,7 +1436,7 @@ export async function handleChatCore({
// Save structured call log with full payloads
const cacheUsageLogMeta = buildCacheUsageLogMeta(usage);
if (usage && typeof usage === "object") {
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)}...` : ""}`;
const msg = `[${new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" })}] 📊 [USAGE] ${provider.toUpperCase()} | ${formatUsageLog(usage)}${connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""}`;
console.log(`${COLORS.green}${msg}${COLORS.reset}`);
// Track cache token metrics

View File

@@ -46,11 +46,18 @@ export function extractUsageFromResponse(responseBody, provider) {
(responseBody.usage.input_tokens !== undefined ||
responseBody.usage.output_tokens !== undefined)
) {
const inputTokens = responseBody.usage.input_tokens || 0;
const cacheRead = responseBody.usage.cache_read_input_tokens || 0;
const cacheCreation = responseBody.usage.cache_creation_input_tokens || 0;
// Total prompt tokens = input + cache_read + cache_creation (per Claude API docs)
const promptTokens = inputTokens + cacheRead + cacheCreation;
return {
prompt_tokens: responseBody.usage.input_tokens || 0,
prompt_tokens: promptTokens,
completion_tokens: responseBody.usage.output_tokens || 0,
cache_read_input_tokens: responseBody.usage.cache_read_input_tokens,
cache_creation_input_tokens: responseBody.usage.cache_creation_input_tokens,
cache_read_input_tokens: cacheRead,
cache_creation_input_tokens: cacheCreation,
};
}

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react";
import { Card, Button, EmptyState } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
import CacheStatsCard from "../settings/components/CacheStatsCard";
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -286,9 +287,9 @@ export default function CachePage() {
<InfoRow icon="info">{t("behaviorDeterministic")}</InfoRow>
<InfoRow icon="info">
{t.rich("behaviorBypass", {
header: () => (
header: (chunks) => (
<code className="bg-surface px-1 py-0.5 rounded text-xs font-mono">
X-OmniRoute-No-Cache: true
{chunks}
</code>
),
})}
@@ -296,9 +297,9 @@ export default function CachePage() {
<InfoRow icon="info">{t("behaviorTwoTier")}</InfoRow>
<InfoRow icon="info">
{t.rich("behaviorTtl", {
envVar: () => (
envVar: (chunks) => (
<code className="bg-surface px-1 py-0.5 rounded text-xs font-mono">
SEMANTIC_CACHE_TTL_MS
{chunks}
</code>
),
})}
@@ -333,6 +334,9 @@ export default function CachePage() {
</div>
</div>
</Card>
{/* Claude Prompt Cache Metrics */}
<CacheStatsCard />
</>
)}
</div>

View File

@@ -16,8 +16,6 @@ import CodexServiceTierTab from "./components/CodexServiceTierTab";
import SystemPromptTab from "./components/SystemPromptTab";
import ModelAliasesTab from "./components/ModelAliasesTab";
import BackgroundDegradationTab from "./components/BackgroundDegradationTab";
import CacheStatsCard from "./components/CacheStatsCard";
import ResilienceTab from "./components/ResilienceTab";
const tabs = [
@@ -88,7 +86,6 @@ export default function SettingsPage() {
<ThinkingBudgetTab />
<CodexServiceTierTab />
<SystemPromptTab />
<CacheStatsCard />
</div>
)}

View File

@@ -0,0 +1,15 @@
-- Migration 012: Fix tokens_input to include cache tokens
--
-- Problem: Historical data stored tokens_input as just the base input_tokens
-- from the API, not including cache_read and cache_creation tokens.
--
-- Per Claude API docs:
-- Total input tokens = input_tokens + cache_creation_input_tokens + cache_read_input_tokens
--
-- This migration corrects historical records by adding cache tokens to tokens_input.
-- Only affects records where cache tokens exist.
-- Update tokens_input to include cache tokens
UPDATE usage_history
SET tokens_input = tokens_input + tokens_cache_read + tokens_cache_creation
WHERE tokens_cache_read > 0 OR tokens_cache_creation > 0;

View File

@@ -52,19 +52,9 @@ export function getLoggedInputTokens(tokens: unknown): number {
return toFiniteNumber(tokenRecord.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
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;
}
@@ -73,7 +63,17 @@ export function getLoggedOutputTokens(tokens: unknown): number {
if (tokenRecord.output !== undefined && tokenRecord.output !== null) {
return toFiniteNumber(tokenRecord.output);
}
return toFiniteNumber(
tokenRecord.completion_tokens ?? tokenRecord.output_tokens
);
return toFiniteNumber(tokenRecord.completion_tokens ?? tokenRecord.output_tokens);
}
export function formatUsageLog(tokens: unknown): string {
const input = getLoggedInputTokens(tokens);
const output = getLoggedOutputTokens(tokens);
const cacheRead = getPromptCacheReadTokens(tokens);
let msg = `in=${input} | out=${output}`;
if (cacheRead > 0) {
msg += ` | CR=${cacheRead}`;
}
return msg;
}