diff --git a/apps/desktop/src/main/__tests__/materialize-turns.test.ts b/apps/desktop/src/main/__tests__/materialize-turns.test.ts
index 7feed7eb3d..53f56eaf90 100644
--- a/apps/desktop/src/main/__tests__/materialize-turns.test.ts
+++ b/apps/desktop/src/main/__tests__/materialize-turns.test.ts
@@ -221,6 +221,9 @@ describe('materializeTurns', () => {
ts: 110,
input: 1000,
output: 200,
+ cacheMissInput: 800,
+ cacheRead: 150,
+ reasoning: 20,
costUsd: 0.01,
} as StoredMessage,
{
@@ -230,12 +233,18 @@ describe('materializeTurns', () => {
ts: 120,
input: 500,
output: 50,
+ cacheMissInput: 300,
+ cacheRead: 100,
+ reasoning: 5,
costUsd: 0.005,
} as StoredMessage,
assistantMsg('t1', 200, 'a'),
]);
assert.equal(turns[0]?.tokens?.input, 1500);
assert.equal(turns[0]?.tokens?.output, 250);
+ assert.equal(turns[0]?.tokens?.cacheMiss, 1100);
+ assert.equal(turns[0]?.tokens?.cacheRead, 250);
+ assert.equal(turns[0]?.tokens?.reasoning, 25);
// Use a tolerance since FP add may produce 0.015000000000000001 etc.
assert.ok(
turns[0]?.tokens?.costUsd !== undefined &&
diff --git a/apps/desktop/src/renderer/settings/SettingsModal.tsx b/apps/desktop/src/renderer/settings/SettingsModal.tsx
index ee41984d4a..7149932b46 100644
--- a/apps/desktop/src/renderer/settings/SettingsModal.tsx
+++ b/apps/desktop/src/renderer/settings/SettingsModal.tsx
@@ -5475,7 +5475,7 @@ function UsageSettingsPage(props: {
-
+
{
requests,
inputTokens: 0,
outputTokens: 0,
+ cacheMissTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
@@ -134,6 +135,7 @@ describe('buildDailyReviewSummary', () => {
totalTokens: {
input: 100,
output: 200,
+ cacheMiss: 100,
cacheRead: 0,
cacheWrite: 0,
reasoning: 0,
diff --git a/packages/core/src/__tests__/health.test.ts b/packages/core/src/__tests__/health.test.ts
index 7d9bdcd36b..0617704f2a 100644
--- a/packages/core/src/__tests__/health.test.ts
+++ b/packages/core/src/__tests__/health.test.ts
@@ -61,6 +61,7 @@ describe('HealthSignal contract', () => {
modelId: 'glm-4.7',
inputTokens: 1,
outputTokens: 2,
+ cacheMissTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
@@ -81,6 +82,7 @@ describe('HealthSignal contract', () => {
modelId: 'glm-4.7',
inputTokens: 1,
outputTokens: 0,
+ cacheMissTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
@@ -130,6 +132,7 @@ describe('HealthSignal contract', () => {
modelId: 'glm-4.7',
inputTokens: 1,
outputTokens: 1,
+ cacheMissTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
@@ -311,6 +314,7 @@ describe('HealthSignal contract', () => {
modelId: 'glm-4.7',
inputTokens: 1,
outputTokens: 0,
+ cacheMissTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts
index 3ca426bb76..7b050c7748 100644
--- a/packages/core/src/events.ts
+++ b/packages/core/src/events.ts
@@ -320,7 +320,15 @@ export interface TokenUsageEvent extends BaseEvent {
type: 'token_usage';
input: number;
output: number;
+ cacheHitInput?: number;
+ cacheMissInput?: number;
+ cacheWriteInput?: number;
+ reasoning?: number;
+ total?: number;
+ rawFinishReason?: string;
+ /** Backward-compatible alias for cacheHitInput. */
cacheRead?: number;
+ /** Backward-compatible alias for cacheWriteInput. */
cacheCreation?: number;
costUsd?: number;
contextRemaining?: number;
diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts
index af5cc1b538..0eedcc57ec 100644
--- a/packages/core/src/runtime-event.ts
+++ b/packages/core/src/runtime-event.ts
@@ -165,7 +165,15 @@ export type RuntimeEventContentKind = typeof RUNTIME_EVENT_CONTENT_KINDS[number]
export interface RuntimeEventTokenUsage {
input: number;
output: number;
+ cacheHitInput?: number;
+ cacheMissInput?: number;
+ cacheWriteInput?: number;
+ reasoning?: number;
+ total?: number;
+ rawFinishReason?: string;
+ /** Backward-compatible alias for cacheHitInput. */
cacheRead?: number;
+ /** Backward-compatible alias for cacheWriteInput. */
cacheCreation?: number;
costUsd?: number;
contextRemaining?: number;
diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts
index 759b1e272c..0ae7a2878d 100644
--- a/packages/core/src/session.ts
+++ b/packages/core/src/session.ts
@@ -229,7 +229,15 @@ export interface TokenUsageMessage {
ts: number;
input: number;
output: number;
+ cacheHitInput?: number;
+ cacheMissInput?: number;
+ cacheWriteInput?: number;
+ reasoning?: number;
+ total?: number;
+ rawFinishReason?: string;
+ /** Backward-compatible alias for cacheHitInput. */
cacheRead?: number;
+ /** Backward-compatible alias for cacheWriteInput. */
cacheCreation?: number;
costUsd?: number;
}
diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts
index f471a64f95..806d1f8105 100644
--- a/packages/core/src/settings.ts
+++ b/packages/core/src/settings.ts
@@ -268,8 +268,10 @@ export interface UsageRequestLog {
toolName?: string;
inputTokens: number;
outputTokens: number;
+ cacheMiss?: number;
cacheRead?: number;
cacheCreation?: number;
+ reasoning?: number;
costUsd?: number;
latencyMs?: number;
status: 'success' | 'error';
@@ -282,8 +284,10 @@ export interface UsageSummary {
inputTokens: number;
outputTokens: number;
cacheTokens: number;
+ cacheMiss: number;
cacheRead: number;
cacheCreation: number;
+ reasoning: number;
}
export interface UsageStats {
diff --git a/packages/core/src/usage-stats/types.ts b/packages/core/src/usage-stats/types.ts
index 28e866b834..c9df19c4b2 100644
--- a/packages/core/src/usage-stats/types.ts
+++ b/packages/core/src/usage-stats/types.ts
@@ -23,6 +23,7 @@ export interface UsageSummaryV2 {
totalTokens: {
input: number;
output: number;
+ cacheMiss: number;
cacheRead: number;
cacheWrite: number;
reasoning: number;
@@ -39,6 +40,7 @@ export interface UsageBucket {
requests: number;
inputTokens: number;
outputTokens: number;
+ cacheMissTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
reasoningTokens: number;
@@ -57,6 +59,7 @@ export interface UsageLogRow {
toolName?: string;
inputTokens: number;
outputTokens: number;
+ cacheMissTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
reasoningTokens: number;
@@ -85,10 +88,27 @@ export interface LlmCallRecord {
modelId: string;
inputTokens: number;
outputTokens: number;
+ cacheHitInputTokens?: number;
+ cacheMissInputTokens?: number;
+ /** Backward-compatible alias for cacheHitInputTokens. */
cachedInputTokens?: number;
cacheWriteInputTokens?: number;
reasoningTokens?: number;
totalTokens?: number;
+ rawFinishReason?: string;
+ rawUsage?: {
+ prompt_tokens?: number;
+ completion_tokens?: number;
+ total_tokens?: number;
+ prompt_cache_hit_tokens?: number;
+ prompt_cache_miss_tokens?: number;
+ prompt_tokens_details?: {
+ cached_tokens?: number;
+ };
+ completion_tokens_details?: {
+ reasoning_tokens?: number;
+ };
+ };
latencyMs: number;
status: 'success' | 'error' | 'aborted';
errorClass?: string;
diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts
index 7bd0739edf..a5267a9950 100644
--- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts
+++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts
@@ -524,6 +524,8 @@ describe('AiSdkBackend usage telemetry', () => {
assert.deepEqual(usage, {
inputTokens: 100,
outputTokens: 20,
+ cacheHitInputTokens: 30,
+ cacheMissInputTokens: 60,
cachedInputTokens: 30,
cacheWriteInputTokens: 10,
reasoningTokens: 5,
@@ -592,7 +594,18 @@ describe('AiSdkBackend usage telemetry', () => {
const usageMessage = messages.find((message) =>
(message as { type?: string }).type === 'token_usage'
- ) as { input?: number; output?: number; cacheRead?: number; cacheCreation?: number } | undefined;
+ ) as {
+ input?: number;
+ output?: number;
+ cacheHitInput?: number;
+ cacheMissInput?: number;
+ cacheWriteInput?: number;
+ cacheRead?: number;
+ cacheCreation?: number;
+ reasoning?: number;
+ total?: number;
+ rawFinishReason?: string;
+ } | undefined;
const usageEvent = events.find((event) => event.type === 'token_usage') as
| Extract
| undefined;
@@ -601,18 +614,33 @@ describe('AiSdkBackend usage telemetry', () => {
assert.equal((usageMessage as { turnId?: string } | undefined)?.turnId, 'turn-1');
assert.equal(usageMessage?.input, 10);
assert.equal(usageMessage?.output, 7);
+ assert.equal(usageMessage?.cacheHitInput, 3);
+ assert.equal(usageMessage?.cacheMissInput, 5);
+ assert.equal(usageMessage?.cacheWriteInput, 2);
assert.equal(usageMessage?.cacheRead, 3);
assert.equal(usageMessage?.cacheCreation, 2);
+ assert.equal(usageMessage?.reasoning, 2);
+ assert.equal(usageMessage?.total, 17);
+ assert.equal(usageMessage?.rawFinishReason, 'stop');
assert.equal(usageEvent?.input, 10);
assert.equal(usageEvent?.output, 7);
+ assert.equal(usageEvent?.cacheHitInput, 3);
+ assert.equal(usageEvent?.cacheMissInput, 5);
+ assert.equal(usageEvent?.cacheWriteInput, 2);
assert.equal(usageEvent?.cacheRead, 3);
assert.equal(usageEvent?.cacheCreation, 2);
+ assert.equal(usageEvent?.reasoning, 2);
+ assert.equal(usageEvent?.total, 17);
+ assert.equal(usageEvent?.rawFinishReason, 'stop');
assert.equal(llmRecords[0]?.inputTokens, 10);
assert.equal(llmRecords[0]?.outputTokens, 7);
+ assert.equal(llmRecords[0]?.cacheHitInputTokens, 3);
+ assert.equal(llmRecords[0]?.cacheMissInputTokens, 5);
assert.equal(llmRecords[0]?.cachedInputTokens, 3);
assert.equal(llmRecords[0]?.cacheWriteInputTokens, 2);
assert.equal(llmRecords[0]?.reasoningTokens, 2);
assert.equal(llmRecords[0]?.totalTokens, 17);
+ assert.equal(llmRecords[0]?.rawFinishReason, 'stop');
});
});
diff --git a/packages/runtime/src/__tests__/cost.test.ts b/packages/runtime/src/__tests__/cost.test.ts
new file mode 100644
index 0000000000..e27a5fc30c
--- /dev/null
+++ b/packages/runtime/src/__tests__/cost.test.ts
@@ -0,0 +1,73 @@
+import assert from 'node:assert/strict';
+import { describe, test } from 'node:test';
+
+import { computeCost } from '../telemetry/cost.js';
+
+describe('computeCost', () => {
+ test('charges full input price only for cache-miss input', () => {
+ const cost = computeCost(
+ {
+ inputTokens: 100,
+ outputTokens: 50,
+ cacheHitInputTokens: 30,
+ cacheMissInputTokens: 60,
+ cacheWriteInputTokens: 10,
+ },
+ {
+ modelKey: 'deepseek:deepseek-chat',
+ inputUsdPer1M: 1,
+ outputUsdPer1M: 2,
+ cacheReadUsdPer1M: 0.25,
+ cacheWriteUsdPer1M: 1.5,
+ },
+ );
+
+ assert.equal(cost.inputCost, 0.00006);
+ assert.equal(cost.cacheReadCost, 0.0000075);
+ assert.ok(Math.abs(cost.cacheWriteCost - 0.000015) < 1e-12);
+ assert.equal(cost.outputCost, 0.0001);
+ assert.ok(Math.abs(cost.totalCost - 0.0001825) < 1e-12);
+ });
+
+ test('derives cache miss from total input when explicit miss is absent', () => {
+ const cost = computeCost(
+ {
+ inputTokens: 100,
+ outputTokens: 0,
+ cachedInputTokens: 40,
+ cacheWriteInputTokens: 10,
+ },
+ {
+ modelKey: 'deepseek:deepseek-chat',
+ inputUsdPer1M: 1,
+ outputUsdPer1M: 2,
+ cacheReadUsdPer1M: 0.25,
+ cacheWriteUsdPer1M: 1.5,
+ },
+ );
+
+ assert.equal(cost.inputCost, 0.00005);
+ assert.equal(cost.cacheReadCost, 0.00001);
+ assert.ok(Math.abs(cost.cacheWriteCost - 0.000015) < 1e-12);
+ assert.ok(Math.abs(cost.totalCost - 0.000075) < 1e-12);
+ });
+
+ test('treats all input as fresh when no cache data exists', () => {
+ const cost = computeCost(
+ {
+ inputTokens: 100,
+ outputTokens: 0,
+ },
+ {
+ modelKey: 'deepseek:deepseek-chat',
+ inputUsdPer1M: 1,
+ outputUsdPer1M: 2,
+ cacheReadUsdPer1M: 0.25,
+ },
+ );
+
+ assert.equal(cost.inputCost, 0.0001);
+ assert.equal(cost.cacheReadCost, 0);
+ assert.equal(cost.totalCost, 0.0001);
+ });
+});
diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts
index b4e6caf2ec..00953d526a 100644
--- a/packages/runtime/src/__tests__/model-adapter.test.ts
+++ b/packages/runtime/src/__tests__/model-adapter.test.ts
@@ -103,6 +103,8 @@ describe('ModelAdapter stream and error normalization', () => {
{
inputTokens: 20,
outputTokens: 5,
+ cacheHitInputTokens: 7,
+ cacheMissInputTokens: 10,
cachedInputTokens: 7,
cacheWriteInputTokens: 3,
reasoningTokens: 2,
@@ -110,6 +112,156 @@ describe('ModelAdapter stream and error normalization', () => {
},
);
});
+
+ test('preserves DeepSeek and OpenAI-compatible raw usage fields', () => {
+ assert.deepEqual(
+ normalizeAiSdkUsage(
+ {
+ promptTokens: 100,
+ completionTokens: 20,
+ prompt_cache_hit_tokens: 40,
+ prompt_cache_miss_tokens: 60,
+ prompt_tokens_details: {
+ cached_tokens: 35,
+ },
+ completion_tokens_details: {
+ reasoning_tokens: 8,
+ },
+ },
+ { rawFinishReason: { unified: 'stop', raw: 'provider_stop' } },
+ ),
+ {
+ inputTokens: 100,
+ outputTokens: 20,
+ cacheHitInputTokens: 40,
+ cacheMissInputTokens: 60,
+ cachedInputTokens: 40,
+ cacheWriteInputTokens: 0,
+ reasoningTokens: 8,
+ totalTokens: 120,
+ rawFinishReason: 'provider_stop',
+ raw: {
+ prompt_cache_hit_tokens: 40,
+ prompt_cache_miss_tokens: 60,
+ prompt_tokens_details: {
+ cached_tokens: 35,
+ },
+ completion_tokens_details: {
+ reasoning_tokens: 8,
+ },
+ },
+ },
+ );
+ });
+
+ test('normalizes AI SDK raw DeepSeek usage metadata and no-cache token details', () => {
+ assert.deepEqual(
+ normalizeAiSdkUsage(
+ {
+ inputTokens: 100,
+ outputTokens: 20,
+ inputTokenDetails: {
+ noCacheTokens: 25,
+ cacheReadTokens: 75,
+ },
+ outputTokenDetails: {
+ reasoningTokens: 9,
+ },
+ raw: {
+ prompt_cache_hit_tokens: 70,
+ prompt_cache_miss_tokens: 30,
+ prompt_tokens_details: {
+ cached_tokens: 70,
+ },
+ completion_tokens_details: {
+ reasoning_tokens: 11,
+ },
+ },
+ },
+ { rawFinishReason: 'stop' },
+ ),
+ {
+ inputTokens: 100,
+ outputTokens: 20,
+ cacheHitInputTokens: 70,
+ cacheMissInputTokens: 30,
+ cachedInputTokens: 70,
+ cacheWriteInputTokens: 0,
+ reasoningTokens: 9,
+ totalTokens: 120,
+ rawFinishReason: 'stop',
+ raw: {
+ prompt_cache_hit_tokens: 70,
+ prompt_cache_miss_tokens: 30,
+ prompt_tokens_details: {
+ cached_tokens: 70,
+ },
+ completion_tokens_details: {
+ reasoning_tokens: 11,
+ },
+ },
+ },
+ );
+ });
+
+ test('normalizes direct DeepSeek snake_case usage totals', () => {
+ assert.deepEqual(
+ normalizeAiSdkUsage(
+ {
+ prompt_tokens: 1460,
+ completion_tokens: 2,
+ total_tokens: 1462,
+ prompt_cache_hit_tokens: 1408,
+ prompt_cache_miss_tokens: 52,
+ prompt_tokens_details: {
+ cached_tokens: 1408,
+ },
+ },
+ { rawFinishReason: 'stop' },
+ ),
+ {
+ inputTokens: 1460,
+ outputTokens: 2,
+ cacheHitInputTokens: 1408,
+ cacheMissInputTokens: 52,
+ cachedInputTokens: 1408,
+ cacheWriteInputTokens: 0,
+ reasoningTokens: 0,
+ totalTokens: 1462,
+ rawFinishReason: 'stop',
+ raw: {
+ prompt_tokens: 1460,
+ completion_tokens: 2,
+ total_tokens: 1462,
+ prompt_cache_hit_tokens: 1408,
+ prompt_cache_miss_tokens: 52,
+ prompt_tokens_details: {
+ cached_tokens: 1408,
+ },
+ },
+ },
+ );
+ });
+
+ test('derives cache miss input when explicit miss is absent and treats no cache data as fresh', () => {
+ assert.equal(
+ normalizeAiSdkUsage({
+ inputTokens: 100,
+ outputTokens: 10,
+ cachedInputTokens: 30,
+ cacheWriteInputTokens: 20,
+ })?.cacheMissInputTokens,
+ 50,
+ );
+
+ assert.equal(
+ normalizeAiSdkUsage({
+ inputTokens: 100,
+ outputTokens: 10,
+ })?.cacheMissInputTokens,
+ 100,
+ );
+ });
});
function newAdapter(): ModelAdapter {
diff --git a/packages/runtime/src/__tests__/tool-runtime-extraction-contract.test.ts b/packages/runtime/src/__tests__/tool-runtime-extraction-contract.test.ts
index 6248e37c32..13f68b9520 100644
--- a/packages/runtime/src/__tests__/tool-runtime-extraction-contract.test.ts
+++ b/packages/runtime/src/__tests__/tool-runtime-extraction-contract.test.ts
@@ -69,7 +69,7 @@ describe('ModelAdapter extraction contract', () => {
assert.match(backend, /this\.modelAdapter\.resolveModel\(\)/);
assert.match(backend, /this\.modelAdapter\.startStream\(/);
assert.match(backend, /this\.modelAdapter\.handleStreamChunk\(/);
- assert.match(backend, /normalizeAiSdkUsage\(await result\.usage\)/);
+ assert.match(backend, /normalizeAiSdkUsage\(await result\.usage,[\s\S]*?rawFinishReason[\s\S]*?\)/);
assert.match(backend, /this\.modelAdapter\.classifyError\(/);
assert.match(
backend,
diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts
index 7e21081f2f..b8e7315db3 100644
--- a/packages/runtime/src/ai-sdk-backend.ts
+++ b/packages/runtime/src/ai-sdk-backend.ts
@@ -74,6 +74,7 @@ import {
import {
ModelAdapter,
normalizeAiSdkUsage,
+ rawFinishReasonString,
type ModelFactory,
type ModelFactoryInput,
type NormalizedAiSdkUsage,
@@ -267,6 +268,7 @@ export class AiSdkBackend implements AgentBackend {
let tokenUsage: NormalizedAiSdkUsage | undefined;
let streamStatus: LlmCallRecord['status'] = 'success';
let streamErrorClass: string | undefined;
+ let rawFinishReason: string | undefined;
const trace = new RunTrace({
sessionId: this.sessionId,
turnId,
@@ -363,6 +365,9 @@ export class AiSdkBackend implements AgentBackend {
for await (const chunk of result.fullStream) {
if (this.aborted) break;
watchdog.markActivity();
+ if (chunk.type === 'finish' || chunk.type === 'step-finish') {
+ rawFinishReason = rawFinishReasonString(chunk.finishReason) ?? rawFinishReason;
+ }
this.modelAdapter.handleStreamChunk(chunk, turnId, assistantMessageId, queue, {
onText: (t) => { assistantText += t; },
onTextComplete: (t) => { assistantText = t; },
@@ -380,6 +385,7 @@ export class AiSdkBackend implements AgentBackend {
// "step cap reached" notice so the UI has SOMETHING and the
// user can choose to send "继续" for a fresh turn.
const finishReasonForGrace = await result.finishReason.catch(() => 'stop');
+ rawFinishReason = rawFinishReason ?? rawFinishReasonString(finishReasonForGrace);
if (
!this.aborted
&& assistantText.length === 0
@@ -421,7 +427,7 @@ export class AiSdkBackend implements AgentBackend {
// Final usage event (await result.usage which resolves once stream ends).
try {
- tokenUsage = normalizeAiSdkUsage(await result.usage);
+ tokenUsage = normalizeAiSdkUsage(await result.usage, { rawFinishReason });
if (tokenUsage) {
trace.usageRecorded(tokenUsage);
const tu: TokenUsageMessage = {
@@ -431,6 +437,12 @@ export class AiSdkBackend implements AgentBackend {
ts: this.now(),
input: tokenUsage.inputTokens,
output: tokenUsage.outputTokens,
+ cacheHitInput: tokenUsage.cacheHitInputTokens,
+ cacheMissInput: tokenUsage.cacheMissInputTokens,
+ cacheWriteInput: tokenUsage.cacheWriteInputTokens,
+ reasoning: tokenUsage.reasoningTokens,
+ total: tokenUsage.totalTokens,
+ ...(tokenUsage.rawFinishReason !== undefined ? { rawFinishReason: tokenUsage.rawFinishReason } : {}),
...(tokenUsage.cachedInputTokens > 0 ? { cacheRead: tokenUsage.cachedInputTokens } : {}),
...(tokenUsage.cacheWriteInputTokens > 0 ? { cacheCreation: tokenUsage.cacheWriteInputTokens } : {}),
};
@@ -442,6 +454,12 @@ export class AiSdkBackend implements AgentBackend {
ts: this.now(),
input: tokenUsage.inputTokens,
output: tokenUsage.outputTokens,
+ cacheHitInput: tokenUsage.cacheHitInputTokens,
+ cacheMissInput: tokenUsage.cacheMissInputTokens,
+ cacheWriteInput: tokenUsage.cacheWriteInputTokens,
+ reasoning: tokenUsage.reasoningTokens,
+ total: tokenUsage.totalTokens,
+ ...(tokenUsage.rawFinishReason !== undefined ? { rawFinishReason: tokenUsage.rawFinishReason } : {}),
...(tokenUsage.cachedInputTokens > 0 ? { cacheRead: tokenUsage.cachedInputTokens } : {}),
...(tokenUsage.cacheWriteInputTokens > 0 ? { cacheCreation: tokenUsage.cacheWriteInputTokens } : {}),
} satisfies TokenUsageEvent);
@@ -502,10 +520,14 @@ export class AiSdkBackend implements AgentBackend {
modelId: this.input.modelId,
inputTokens: tokenUsage?.inputTokens ?? 0,
outputTokens: tokenUsage?.outputTokens ?? 0,
+ cacheHitInputTokens: tokenUsage?.cacheHitInputTokens ?? 0,
+ cacheMissInputTokens: tokenUsage?.cacheMissInputTokens ?? 0,
cachedInputTokens: tokenUsage?.cachedInputTokens ?? 0,
cacheWriteInputTokens: tokenUsage?.cacheWriteInputTokens ?? 0,
reasoningTokens: tokenUsage?.reasoningTokens ?? 0,
totalTokens: tokenUsage?.totalTokens,
+ ...(tokenUsage?.rawFinishReason !== undefined ? { rawFinishReason: tokenUsage.rawFinishReason } : {}),
+ ...(tokenUsage?.raw !== undefined ? { rawUsage: tokenUsage.raw } : {}),
latencyMs: Math.max(0, this.now() - startedAt),
status: streamStatus,
...(streamErrorClass ? { errorClass: streamErrorClass } : {}),
diff --git a/packages/runtime/src/ai-sdk-flow.ts b/packages/runtime/src/ai-sdk-flow.ts
index 345915dec3..4b75b364c4 100644
--- a/packages/runtime/src/ai-sdk-flow.ts
+++ b/packages/runtime/src/ai-sdk-flow.ts
@@ -303,6 +303,12 @@ export function mapSessionEventToRuntimeEvent(
tokenUsage: {
input: event.input,
output: event.output,
+ ...(event.cacheHitInput !== undefined ? { cacheHitInput: event.cacheHitInput } : {}),
+ ...(event.cacheMissInput !== undefined ? { cacheMissInput: event.cacheMissInput } : {}),
+ ...(event.cacheWriteInput !== undefined ? { cacheWriteInput: event.cacheWriteInput } : {}),
+ ...(event.reasoning !== undefined ? { reasoning: event.reasoning } : {}),
+ ...(event.total !== undefined ? { total: event.total } : {}),
+ ...(event.rawFinishReason !== undefined ? { rawFinishReason: event.rawFinishReason } : {}),
...(event.cacheRead !== undefined ? { cacheRead: event.cacheRead } : {}),
...(event.cacheCreation !== undefined ? { cacheCreation: event.cacheCreation } : {}),
...(event.costUsd !== undefined ? { costUsd: event.costUsd } : {}),
diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts
index e18342afac..8d9a54fdfb 100644
--- a/packages/runtime/src/model-adapter.ts
+++ b/packages/runtime/src/model-adapter.ts
@@ -211,29 +211,67 @@ export interface AiSdkStreamChunk {
args?: unknown;
result?: unknown;
usage?: AiSdkUsageLike;
- finishReason?: string;
+ finishReason?: unknown;
error?: unknown;
}
export interface StreamTextResult {
fullStream: AsyncIterable;
usage: Promise;
- finishReason: Promise;
+ finishReason: Promise;
+}
+
+type TokenCountBreakdown = {
+ total?: number;
+ noCache?: number;
+ cacheRead?: number;
+ cacheWrite?: number;
+ text?: number;
+ reasoning?: number;
+};
+
+export interface AiSdkRawUsageFields {
+ prompt_tokens?: number;
+ completion_tokens?: number;
+ total_tokens?: number;
+ prompt_cache_hit_tokens?: number;
+ prompt_cache_miss_tokens?: number;
+ prompt_tokens_details?: {
+ cached_tokens?: number;
+ };
+ completion_tokens_details?: {
+ reasoning_tokens?: number;
+ };
}
export interface AiSdkUsageLike {
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
- inputTokens?: number;
- outputTokens?: number;
+ prompt_tokens?: number;
+ completion_tokens?: number;
+ total_tokens?: number;
+ inputTokens?: number | TokenCountBreakdown;
+ outputTokens?: number | TokenCountBreakdown;
+ cacheHitInputTokens?: number;
+ cacheMissInputTokens?: number;
cachedInputTokens?: number;
cacheWriteInputTokens?: number;
reasoningTokens?: number;
cacheReadInputTokens?: number;
cacheCreationInputTokens?: number;
+ prompt_cache_hit_tokens?: number;
+ prompt_cache_miss_tokens?: number;
+ prompt_tokens_details?: {
+ cached_tokens?: number;
+ };
+ completion_tokens_details?: {
+ reasoning_tokens?: number;
+ };
inputTokenDetails?: {
cachedTokens?: number;
+ cacheMissTokens?: number;
+ noCacheTokens?: number;
cacheReadTokens?: number;
cacheWriteTokens?: number;
reasoningTokens?: number;
@@ -241,48 +279,158 @@ export interface AiSdkUsageLike {
outputTokenDetails?: {
reasoningTokens?: number;
};
+ raw?: AiSdkRawUsageFields;
}
export interface NormalizedAiSdkUsage {
inputTokens: number;
outputTokens: number;
- cachedInputTokens: number;
+ cacheHitInputTokens: number;
+ cacheMissInputTokens: number;
cacheWriteInputTokens: number;
reasoningTokens: number;
totalTokens: number;
+ rawFinishReason?: string;
+ raw?: AiSdkRawUsageFields;
+ /** Backward-compatible alias for cacheHitInputTokens. */
+ cachedInputTokens: number;
}
-export function normalizeAiSdkUsage(usage: AiSdkUsageLike | undefined): NormalizedAiSdkUsage | undefined {
+export function normalizeAiSdkUsage(
+ usage: AiSdkUsageLike | undefined,
+ options: { rawFinishReason?: unknown } = {},
+): NormalizedAiSdkUsage | undefined {
if (!usage) return undefined;
- const inputTokens = finiteToken(usage.inputTokens) ?? finiteToken(usage.promptTokens) ?? 0;
- const outputTokens = finiteToken(usage.outputTokens) ?? finiteToken(usage.completionTokens) ?? 0;
- const cachedInputTokens =
- finiteToken(usage.cachedInputTokens)
+ const inputTokens =
+ finiteTokenFromValueOrBreakdown(usage.inputTokens, 'total')
+ ?? finiteToken(usage.promptTokens)
+ ?? finiteToken(usage.raw?.prompt_tokens)
+ ?? finiteToken(usage.prompt_tokens)
+ ?? 0;
+ const outputTokens =
+ finiteTokenFromValueOrBreakdown(usage.outputTokens, 'total')
+ ?? finiteToken(usage.completionTokens)
+ ?? finiteToken(usage.raw?.completion_tokens)
+ ?? finiteToken(usage.completion_tokens)
+ ?? 0;
+ const cacheHitInputTokens =
+ finiteToken(usage.cacheHitInputTokens)
+ ?? finiteToken(usage.cachedInputTokens)
?? finiteToken(usage.cacheReadInputTokens)
+ ?? finiteToken(usage.raw?.prompt_cache_hit_tokens)
+ ?? finiteToken(usage.prompt_cache_hit_tokens)
+ ?? finiteToken(usage.raw?.prompt_tokens_details?.cached_tokens)
+ ?? finiteToken(usage.prompt_tokens_details?.cached_tokens)
+ ?? finiteTokenFromBreakdown(usage.inputTokens, 'cacheRead')
?? finiteToken(usage.inputTokenDetails?.cacheReadTokens)
?? finiteToken(usage.inputTokenDetails?.cachedTokens)
?? 0;
const cacheWriteInputTokens =
finiteToken(usage.cacheWriteInputTokens)
?? finiteToken(usage.cacheCreationInputTokens)
+ ?? finiteTokenFromBreakdown(usage.inputTokens, 'cacheWrite')
?? finiteToken(usage.inputTokenDetails?.cacheWriteTokens)
?? 0;
+ const explicitCacheMissInputTokens =
+ finiteToken(usage.cacheMissInputTokens)
+ ?? finiteToken(usage.raw?.prompt_cache_miss_tokens)
+ ?? finiteToken(usage.prompt_cache_miss_tokens)
+ ?? finiteTokenFromBreakdown(usage.inputTokens, 'noCache')
+ ?? finiteToken(usage.inputTokenDetails?.noCacheTokens)
+ ?? finiteToken(usage.inputTokenDetails?.cacheMissTokens);
+ const cacheMissInputTokens =
+ explicitCacheMissInputTokens
+ ?? Math.max(0, inputTokens - cacheHitInputTokens - cacheWriteInputTokens);
const reasoningTokens =
finiteToken(usage.reasoningTokens)
+ ?? finiteTokenFromBreakdown(usage.outputTokens, 'reasoning')
?? finiteToken(usage.outputTokenDetails?.reasoningTokens)
+ ?? finiteToken(usage.raw?.completion_tokens_details?.reasoning_tokens)
+ ?? finiteToken(usage.completion_tokens_details?.reasoning_tokens)
?? finiteToken(usage.inputTokenDetails?.reasoningTokens)
?? 0;
- const totalTokens = finiteToken(usage.totalTokens) ?? inputTokens + outputTokens;
+ const totalTokens =
+ finiteToken(usage.totalTokens)
+ ?? finiteToken(usage.raw?.total_tokens)
+ ?? finiteToken(usage.total_tokens)
+ ?? inputTokens + outputTokens;
+ const raw = rawUsageFields(usage);
+ const rawFinishReason = rawFinishReasonString(options.rawFinishReason);
return {
inputTokens,
outputTokens,
- cachedInputTokens,
+ cacheHitInputTokens,
+ cacheMissInputTokens,
cacheWriteInputTokens,
reasoningTokens,
totalTokens,
+ ...(rawFinishReason !== undefined ? { rawFinishReason } : {}),
+ ...(raw !== undefined ? { raw } : {}),
+ cachedInputTokens: cacheHitInputTokens,
};
}
function finiteToken(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
}
+
+function finiteTokenFromBreakdown(
+ value: number | TokenCountBreakdown | undefined,
+ key: keyof TokenCountBreakdown,
+): number | undefined {
+ if (!value || typeof value !== 'object') return undefined;
+ return finiteToken(value[key]);
+}
+
+function finiteTokenFromValueOrBreakdown(
+ value: number | TokenCountBreakdown | undefined,
+ key: keyof TokenCountBreakdown,
+): number | undefined {
+ return finiteToken(value) ?? finiteTokenFromBreakdown(value, key);
+}
+
+function rawUsageFields(usage: AiSdkUsageLike): AiSdkRawUsageFields | undefined {
+ const raw: AiSdkRawUsageFields = {};
+ const promptTokens =
+ finiteToken(usage.prompt_tokens)
+ ?? finiteToken(usage.raw?.prompt_tokens);
+ if (promptTokens !== undefined) raw.prompt_tokens = promptTokens;
+ const completionTokens =
+ finiteToken(usage.completion_tokens)
+ ?? finiteToken(usage.raw?.completion_tokens);
+ if (completionTokens !== undefined) raw.completion_tokens = completionTokens;
+ const totalTokens =
+ finiteToken(usage.total_tokens)
+ ?? finiteToken(usage.raw?.total_tokens);
+ if (totalTokens !== undefined) raw.total_tokens = totalTokens;
+ const promptCacheHitTokens =
+ finiteToken(usage.prompt_cache_hit_tokens)
+ ?? finiteToken(usage.raw?.prompt_cache_hit_tokens);
+ if (promptCacheHitTokens !== undefined) raw.prompt_cache_hit_tokens = promptCacheHitTokens;
+ const promptCacheMissTokens =
+ finiteToken(usage.prompt_cache_miss_tokens)
+ ?? finiteToken(usage.raw?.prompt_cache_miss_tokens);
+ if (promptCacheMissTokens !== undefined) raw.prompt_cache_miss_tokens = promptCacheMissTokens;
+ const cachedTokens =
+ finiteToken(usage.prompt_tokens_details?.cached_tokens)
+ ?? finiteToken(usage.raw?.prompt_tokens_details?.cached_tokens);
+ if (cachedTokens !== undefined) raw.prompt_tokens_details = { cached_tokens: cachedTokens };
+ const reasoningTokens =
+ finiteToken(usage.completion_tokens_details?.reasoning_tokens)
+ ?? finiteToken(usage.raw?.completion_tokens_details?.reasoning_tokens);
+ if (reasoningTokens !== undefined) {
+ raw.completion_tokens_details = { reasoning_tokens: reasoningTokens };
+ }
+ return Object.keys(raw).length > 0 ? raw : undefined;
+}
+
+export function rawFinishReasonString(reason: unknown): string | undefined {
+ if (typeof reason === 'string') return reason;
+ if (reason && typeof reason === 'object') {
+ const raw = (reason as { raw?: unknown }).raw;
+ if (typeof raw === 'string') return raw;
+ const unified = (reason as { unified?: unknown }).unified;
+ if (typeof unified === 'string') return unified;
+ }
+ return undefined;
+}
diff --git a/packages/runtime/src/run-trace.ts b/packages/runtime/src/run-trace.ts
index 44d40a6379..7446319d20 100644
--- a/packages/runtime/src/run-trace.ts
+++ b/packages/runtime/src/run-trace.ts
@@ -112,18 +112,24 @@ export class RunTrace {
usageRecorded(usage: {
inputTokens: number;
outputTokens: number;
+ cacheHitInputTokens: number;
+ cacheMissInputTokens: number;
cachedInputTokens: number;
cacheWriteInputTokens: number;
reasoningTokens: number;
totalTokens: number;
+ rawFinishReason?: string;
}): void {
this.emit('usage', 'usage_recorded', 'Token usage recorded', {
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
+ cacheHitInputTokens: usage.cacheHitInputTokens,
+ cacheMissInputTokens: usage.cacheMissInputTokens,
cachedInputTokens: usage.cachedInputTokens,
cacheWriteInputTokens: usage.cacheWriteInputTokens,
reasoningTokens: usage.reasoningTokens,
totalTokens: usage.totalTokens,
+ ...(usage.rawFinishReason !== undefined ? { rawFinishReason: usage.rawFinishReason } : {}),
});
}
diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts
index a9c23297e5..e664c40cbe 100644
--- a/packages/runtime/src/runtime-event-read-model.ts
+++ b/packages/runtime/src/runtime-event-read-model.ts
@@ -480,6 +480,12 @@ function projectTokenUsage(
ts: event.ts,
input: usage.input,
output: usage.output,
+ ...(usage.cacheHitInput !== undefined ? { cacheHitInput: usage.cacheHitInput } : {}),
+ ...(usage.cacheMissInput !== undefined ? { cacheMissInput: usage.cacheMissInput } : {}),
+ ...(usage.cacheWriteInput !== undefined ? { cacheWriteInput: usage.cacheWriteInput } : {}),
+ ...(usage.reasoning !== undefined ? { reasoning: usage.reasoning } : {}),
+ ...(usage.total !== undefined ? { total: usage.total } : {}),
+ ...(usage.rawFinishReason !== undefined ? { rawFinishReason: usage.rawFinishReason } : {}),
...(usage.cacheRead !== undefined ? { cacheRead: usage.cacheRead } : {}),
...(usage.cacheCreation !== undefined ? { cacheCreation: usage.cacheCreation } : {}),
...(usage.costUsd !== undefined ? { costUsd: usage.costUsd } : {}),
@@ -796,6 +802,12 @@ function semanticMessage(message: StoredMessage): unknown {
turnId: message.turnId,
input: message.input,
output: message.output,
+ cacheHitInput: message.cacheHitInput,
+ cacheMissInput: message.cacheMissInput,
+ cacheWriteInput: message.cacheWriteInput,
+ reasoning: message.reasoning,
+ total: message.total,
+ rawFinishReason: message.rawFinishReason,
cacheRead: message.cacheRead,
cacheCreation: message.cacheCreation,
costUsd: message.costUsd,
diff --git a/packages/runtime/src/telemetry/cost.ts b/packages/runtime/src/telemetry/cost.ts
index e0d2e3b8c6..6768da2782 100644
--- a/packages/runtime/src/telemetry/cost.ts
+++ b/packages/runtime/src/telemetry/cost.ts
@@ -3,6 +3,9 @@ import type { PricingConfig } from '@maka/core/usage-stats/types';
export interface CostInput {
inputTokens: number;
outputTokens: number;
+ cacheHitInputTokens?: number;
+ cacheMissInputTokens?: number;
+ /** Backward-compatible alias for cacheHitInputTokens. */
cachedInputTokens?: number;
cacheWriteInputTokens?: number;
}
@@ -19,13 +22,18 @@ export function computeCost(usage: CostInput, pricing: PricingConfig | null): Co
if (!pricing) {
return { inputCost: 0, outputCost: 0, cacheReadCost: 0, cacheWriteCost: 0, totalCost: 0 };
}
- const inputCost = (usage.inputTokens / 1_000_000) * pricing.inputUsdPer1M;
+ const cacheHitInputTokens = usage.cacheHitInputTokens ?? usage.cachedInputTokens ?? 0;
+ const cacheWriteInputTokens = usage.cacheWriteInputTokens ?? 0;
+ const cacheMissInputTokens =
+ usage.cacheMissInputTokens
+ ?? Math.max(0, usage.inputTokens - cacheHitInputTokens - cacheWriteInputTokens);
+ const inputCost = (cacheMissInputTokens / 1_000_000) * pricing.inputUsdPer1M;
const outputCost = (usage.outputTokens / 1_000_000) * pricing.outputUsdPer1M;
- const cacheReadCost = pricing.cacheReadUsdPer1M && usage.cachedInputTokens
- ? (usage.cachedInputTokens / 1_000_000) * pricing.cacheReadUsdPer1M
+ const cacheReadCost = pricing.cacheReadUsdPer1M && cacheHitInputTokens
+ ? (cacheHitInputTokens / 1_000_000) * pricing.cacheReadUsdPer1M
: 0;
- const cacheWriteCost = pricing.cacheWriteUsdPer1M && usage.cacheWriteInputTokens
- ? (usage.cacheWriteInputTokens / 1_000_000) * pricing.cacheWriteUsdPer1M
+ const cacheWriteCost = pricing.cacheWriteUsdPer1M && cacheWriteInputTokens
+ ? (cacheWriteInputTokens / 1_000_000) * pricing.cacheWriteUsdPer1M
: 0;
return {
inputCost,
diff --git a/packages/runtime/src/telemetry/record-llm-call.ts b/packages/runtime/src/telemetry/record-llm-call.ts
index 80089947a9..435550fa55 100644
--- a/packages/runtime/src/telemetry/record-llm-call.ts
+++ b/packages/runtime/src/telemetry/record-llm-call.ts
@@ -12,15 +12,20 @@ export interface LlmRecorderDeps {
export function recordLlmCall(deps: LlmRecorderDeps, record: LlmCallRecord): void {
queueMicrotask(() => {
try {
- const cachedInputTokens = record.cachedInputTokens ?? 0;
+ const cacheHitInputTokens = record.cacheHitInputTokens ?? record.cachedInputTokens ?? 0;
const cacheWriteInputTokens = record.cacheWriteInputTokens ?? 0;
+ const cacheMissInputTokens =
+ record.cacheMissInputTokens
+ ?? Math.max(0, record.inputTokens - cacheHitInputTokens - cacheWriteInputTokens);
+ const cachedInputTokens = cacheHitInputTokens;
const reasoningTokens = record.reasoningTokens ?? 0;
const totalTokens = record.totalTokens ?? record.inputTokens + record.outputTokens + reasoningTokens;
const costUsd = computeCost(
{
inputTokens: record.inputTokens,
outputTokens: record.outputTokens,
- cachedInputTokens,
+ cacheHitInputTokens,
+ cacheMissInputTokens,
cacheWriteInputTokens,
},
deps.lookupPricing(`${record.providerId}:${record.modelId}`),
@@ -29,6 +34,8 @@ export function recordLlmCall(deps: LlmRecorderDeps, record: LlmCallRecord): voi
deps.repo.insertLlmCall({
...record,
id: `usage_${record.turnId ?? randomUUID()}`,
+ cacheHitInputTokens,
+ cacheMissInputTokens,
cachedInputTokens,
cacheWriteInputTokens,
reasoningTokens,
diff --git a/packages/runtime/src/telemetry/types.ts b/packages/runtime/src/telemetry/types.ts
index 4b75bd799d..a670138cfa 100644
--- a/packages/runtime/src/telemetry/types.ts
+++ b/packages/runtime/src/telemetry/types.ts
@@ -7,6 +7,8 @@ export interface TelemetryRepoLite {
export type PersistedLlmCallRecord = LlmCallRecord & {
id: string;
+ cacheHitInputTokens: number;
+ cacheMissInputTokens: number;
cachedInputTokens: number;
cacheWriteInputTokens: number;
reasoningTokens: number;
diff --git a/packages/storage/src/__tests__/settings-store-usage.test.ts b/packages/storage/src/__tests__/settings-store-usage.test.ts
index 50c3f7f133..5d6555b63b 100644
--- a/packages/storage/src/__tests__/settings-store-usage.test.ts
+++ b/packages/storage/src/__tests__/settings-store-usage.test.ts
@@ -77,8 +77,10 @@ describe('SettingsStore.usageStats request logs', () => {
ts: 20,
input: 120,
output: 30,
+ cacheMissInput: 105,
cacheRead: 10,
cacheCreation: 5,
+ reasoning: 4,
costUsd: 0.01,
},
]);
@@ -88,6 +90,10 @@ describe('SettingsStore.usageStats request logs', () => {
assert.equal(stats.summary.totalRequests, 1, 'summary counts model requests only');
assert.equal(stats.summary.totalTokens, 150);
assert.equal(stats.summary.totalCostUsd, 0.01);
+ assert.equal(stats.summary.cacheMiss, 105);
+ assert.equal(stats.summary.cacheRead, 10);
+ assert.equal(stats.summary.cacheCreation, 5);
+ assert.equal(stats.summary.reasoning, 4);
assert.equal(stats.byProvider.length, 1, 'provider aggregates remain model-only');
assert.equal(stats.byModel.length, 1, 'model aggregates remain model-only');
@@ -98,6 +104,10 @@ describe('SettingsStore.usageStats request logs', () => {
assert.equal(modelLog.model, 'claude-sonnet-4-runtime');
assert.equal(modelLog.inputTokens, 120);
assert.equal(modelLog.outputTokens, 30);
+ assert.equal(modelLog.cacheMiss, 105);
+ assert.equal(modelLog.cacheRead, 10);
+ assert.equal(modelLog.cacheCreation, 5);
+ assert.equal(modelLog.reasoning, 4);
const toolLog = stats.logs.find((log) => log.kind === 'tool');
assert.ok(toolLog);
diff --git a/packages/storage/src/__tests__/telemetry-repo.test.ts b/packages/storage/src/__tests__/telemetry-repo.test.ts
index 1b390c9175..00bb24e326 100644
--- a/packages/storage/src/__tests__/telemetry-repo.test.ts
+++ b/packages/storage/src/__tests__/telemetry-repo.test.ts
@@ -9,8 +9,20 @@ describe('FileTelemetryRepo', () => {
test('upserts LLM calls by id and aggregates the latest record', async () => {
await withRepo(async (repo) => {
await repo.load();
- repo.insertLlmCall(llmRecord({ id: 'usage_turn_1', inputTokens: 10, outputTokens: 20, totalTokens: 30 }));
- repo.insertLlmCall(llmRecord({ id: 'usage_turn_1', inputTokens: 30, outputTokens: 40, totalTokens: 70 }));
+ repo.insertLlmCall(llmRecord({
+ id: 'usage_turn_1',
+ inputTokens: 10,
+ outputTokens: 20,
+ cacheMissInputTokens: 10,
+ totalTokens: 30,
+ }));
+ repo.insertLlmCall(llmRecord({
+ id: 'usage_turn_1',
+ inputTokens: 30,
+ outputTokens: 40,
+ cacheMissInputTokens: 30,
+ totalTokens: 70,
+ }));
await flushWrites();
const summary = repo.summary({ range: 'all' });
@@ -19,9 +31,11 @@ describe('FileTelemetryRepo', () => {
assert.equal(summary.totalRequests, 1);
assert.equal(summary.totalTokens.input, 30);
assert.equal(summary.totalTokens.output, 40);
+ assert.equal(summary.totalTokens.cacheMiss, 30);
assert.equal(summary.totalTokens.total, 70);
assert.equal(logs.total, 1);
assert.equal(logs.rows[0]?.inputTokens, 30);
+ assert.equal(logs.rows[0]?.cacheMissTokens, 30);
});
});
@@ -140,6 +154,8 @@ function llmRecord(overrides: Record = {}) {
modelId: 'gpt-4o',
inputTokens: 10,
outputTokens: 20,
+ cacheHitInputTokens: 0,
+ cacheMissInputTokens: 10,
cachedInputTokens: 0,
cacheWriteInputTokens: 0,
reasoningTokens: 0,
diff --git a/packages/storage/src/settings-store.ts b/packages/storage/src/settings-store.ts
index d1c02af4fe..56a85fc384 100644
--- a/packages/storage/src/settings-store.ts
+++ b/packages/storage/src/settings-store.ts
@@ -183,8 +183,10 @@ class FileSettingsStore implements SettingsStore {
model: assistantByTurn.get(message.turnId) ?? header.model,
inputTokens: message.input,
outputTokens: message.output,
+ cacheMiss: message.cacheMissInput,
cacheRead: message.cacheRead,
cacheCreation: message.cacheCreation,
+ reasoning: message.reasoning,
costUsd: message.costUsd,
status: 'success' as const,
}));
@@ -195,8 +197,10 @@ class FileSettingsStore implements SettingsStore {
const logs = [...modelLogs, ...toolLogs].sort((a, b) => b.ts - a.ts);
const totalInput = sum(modelLogs.map((log) => log.inputTokens));
const totalOutput = sum(modelLogs.map((log) => log.outputTokens));
+ const cacheMiss = sum(modelLogs.map((log) => log.cacheMiss ?? 0));
const cacheRead = sum(modelLogs.map((log) => log.cacheRead ?? 0));
const cacheCreation = sum(modelLogs.map((log) => log.cacheCreation ?? 0));
+ const reasoning = sum(modelLogs.map((log) => log.reasoning ?? 0));
return {
summary: {
totalRequests: modelLogs.length,
@@ -205,8 +209,10 @@ class FileSettingsStore implements SettingsStore {
inputTokens: totalInput,
outputTokens: totalOutput,
cacheTokens: cacheRead + cacheCreation,
+ cacheMiss,
cacheRead,
cacheCreation,
+ reasoning,
},
logs,
byProvider: aggregateBy(modelLogs, 'provider'),
diff --git a/packages/storage/src/telemetry-repo.ts b/packages/storage/src/telemetry-repo.ts
index 7a91003a49..6d75f5f137 100644
--- a/packages/storage/src/telemetry-repo.ts
+++ b/packages/storage/src/telemetry-repo.ts
@@ -13,6 +13,8 @@ import type {
type PersistedLlmCallRecord = LlmCallRecord & {
id: string;
+ cacheHitInputTokens: number;
+ cacheMissInputTokens: number;
cachedInputTokens: number;
cacheWriteInputTokens: number;
reasoningTokens: number;
@@ -91,7 +93,8 @@ class FileTelemetryRepo implements TelemetryRepo {
const rows = this.filteredUsageRows(query, from, to);
const input = sum(rows.map((row) => row.inputTokens));
const output = sum(rows.map((row) => row.outputTokens));
- const cacheRead = sum(rows.map((row) => row.cachedInputTokens));
+ const cacheMiss = sum(rows.map((row) => row.cacheMissInputTokens));
+ const cacheRead = sum(rows.map((row) => row.cacheHitInputTokens));
const cacheWrite = sum(rows.map((row) => row.cacheWriteInputTokens));
const reasoning = sum(rows.map((row) => row.reasoningTokens));
return {
@@ -101,12 +104,13 @@ class FileTelemetryRepo implements TelemetryRepo {
totalTokens: {
input,
output,
+ cacheMiss,
cacheRead,
cacheWrite,
reasoning,
total: sum(rows.map((row) => row.totalTokens)),
},
- cacheHitRequests: rows.filter((row) => row.cachedInputTokens > 0).length,
+ cacheHitRequests: rows.filter((row) => row.cacheHitInputTokens > 0).length,
cacheCreateRequests: rows.filter((row) => row.cacheWriteInputTokens > 0).length,
errorRequests: rows.filter((row) => row.status === 'error').length,
};
@@ -140,7 +144,8 @@ class FileTelemetryRepo implements TelemetryRepo {
modelId: row.modelId,
inputTokens: row.inputTokens,
outputTokens: row.outputTokens,
- cacheReadTokens: row.cachedInputTokens,
+ cacheMissTokens: row.cacheMissInputTokens,
+ cacheReadTokens: row.cacheHitInputTokens,
cacheWriteTokens: row.cacheWriteInputTokens,
reasoningTokens: row.reasoningTokens,
totalTokens: row.totalTokens,
@@ -221,12 +226,47 @@ function normalizeFile(input: unknown): TelemetryFile {
if (!input || typeof input !== 'object') return emptyFile();
const value = input as Partial;
return {
- usageRecords: Array.isArray(value.usageRecords) ? value.usageRecords : [],
+ usageRecords: Array.isArray(value.usageRecords) ? value.usageRecords.map(normalizeLlmCallRecord) : [],
toolInvocations: Array.isArray(value.toolInvocations) ? value.toolInvocations : [],
pricingOverrides: Array.isArray(value.pricingOverrides) ? value.pricingOverrides : [],
};
}
+function normalizeLlmCallRecord(input: unknown): PersistedLlmCallRecord {
+ const row = input as Partial;
+ const inputTokens = finiteNumber(row.inputTokens) ?? 0;
+ const outputTokens = finiteNumber(row.outputTokens) ?? 0;
+ const cacheHitInputTokens =
+ finiteNumber(row.cacheHitInputTokens)
+ ?? finiteNumber(row.cachedInputTokens)
+ ?? 0;
+ const cacheWriteInputTokens = finiteNumber(row.cacheWriteInputTokens) ?? 0;
+ const cacheMissInputTokens =
+ finiteNumber(row.cacheMissInputTokens)
+ ?? Math.max(0, inputTokens - cacheHitInputTokens - cacheWriteInputTokens);
+ const reasoningTokens = finiteNumber(row.reasoningTokens) ?? 0;
+ return {
+ ...row,
+ id: typeof row.id === 'string' ? row.id : `usage_${row.turnId ?? row.ts ?? 'unknown'}`,
+ providerId: typeof row.providerId === 'string' ? row.providerId : 'unknown',
+ modelId: typeof row.modelId === 'string' ? row.modelId : 'unknown',
+ inputTokens,
+ outputTokens,
+ cacheHitInputTokens,
+ cacheMissInputTokens,
+ cachedInputTokens: cacheHitInputTokens,
+ cacheWriteInputTokens,
+ reasoningTokens,
+ totalTokens: finiteNumber(row.totalTokens) ?? inputTokens + outputTokens + reasoningTokens,
+ costUsd: finiteNumber(row.costUsd) ?? 0,
+ latencyMs: finiteNumber(row.latencyMs) ?? 0,
+ status: row.status === 'error' || row.status === 'aborted' ? row.status : 'success',
+ startedAt: finiteNumber(row.startedAt) ?? finiteNumber(row.ts) ?? 0,
+ date: typeof row.date === 'string' ? row.date : new Date(finiteNumber(row.ts) ?? 0).toISOString().slice(0, 10),
+ ts: finiteNumber(row.ts) ?? 0,
+ };
+}
+
function upsertById(rows: T[], row: T): T[] {
return [...rows.filter((current) => current.id !== row.id), row];
}
@@ -260,7 +300,8 @@ function usageBucket(key: string, rows: PersistedLlmCallRecord[]): UsageBucket {
requests: rows.length,
inputTokens: sum(rows.map((row) => row.inputTokens)),
outputTokens: sum(rows.map((row) => row.outputTokens)),
- cacheReadTokens: sum(rows.map((row) => row.cachedInputTokens)),
+ cacheMissTokens: sum(rows.map((row) => row.cacheMissInputTokens)),
+ cacheReadTokens: sum(rows.map((row) => row.cacheHitInputTokens)),
cacheWriteTokens: sum(rows.map((row) => row.cacheWriteInputTokens)),
reasoningTokens: sum(rows.map((row) => row.reasoningTokens)),
totalTokens: sum(rows.map((row) => row.totalTokens)),
@@ -288,6 +329,7 @@ function toolBuckets(rows: PersistedToolInvocationRecord[]): UsageBucket[] {
requests: groupRows.length,
inputTokens: inputBytes,
outputTokens: outputBytes,
+ cacheMissTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
@@ -303,3 +345,7 @@ function toolBuckets(rows: PersistedToolInvocationRecord[]): UsageBucket[] {
function sum(values: number[]): number {
return values.reduce((total, value) => total + value, 0);
}
+
+function finiteNumber(value: unknown): number | undefined {
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
+}
diff --git a/packages/ui/src/materialize.ts b/packages/ui/src/materialize.ts
index bb18a3fccb..80f9043e27 100644
--- a/packages/ui/src/materialize.ts
+++ b/packages/ui/src/materialize.ts
@@ -199,8 +199,10 @@ export interface TurnViewModel {
tokens?: {
input: number;
output: number;
+ cacheMiss?: number;
cacheRead?: number;
cacheCreation?: number;
+ reasoning?: number;
costUsd?: number;
};
}
@@ -286,8 +288,10 @@ export function materializeTurns(
const totals = turn.tokens ?? { input: 0, output: 0 };
totals.input += message.input;
totals.output += message.output;
+ if (message.cacheMissInput !== undefined) totals.cacheMiss = (totals.cacheMiss ?? 0) + message.cacheMissInput;
if (message.cacheRead !== undefined) totals.cacheRead = (totals.cacheRead ?? 0) + message.cacheRead;
if (message.cacheCreation !== undefined) totals.cacheCreation = (totals.cacheCreation ?? 0) + message.cacheCreation;
+ if (message.reasoning !== undefined) totals.reasoning = (totals.reasoning ?? 0) + message.reasoning;
if (message.costUsd !== undefined) totals.costUsd = (totals.costUsd ?? 0) + message.costUsd;
turn.tokens = totals;
}