From e8df515f3886efd52c68306126fba9b69d17adcb Mon Sep 17 00:00:00 2001 From: likun Date: Tue, 16 Jun 2026 00:48:59 +0800 Subject: [PATCH] Add DeepSeek cache-aware usage accounting --- .../main/__tests__/materialize-turns.test.ts | 9 + .../src/renderer/settings/SettingsModal.tsx | 2 +- .../core/src/__tests__/daily-review.test.ts | 2 + packages/core/src/__tests__/health.test.ts | 4 + packages/core/src/events.ts | 8 + packages/core/src/runtime-event.ts | 8 + packages/core/src/session.ts | 8 + packages/core/src/settings.ts | 4 + packages/core/src/usage-stats/types.ts | 20 ++ .../src/__tests__/ai-sdk-backend.test.ts | 30 ++- packages/runtime/src/__tests__/cost.test.ts | 73 ++++++++ .../src/__tests__/model-adapter.test.ts | 152 ++++++++++++++++ .../tool-runtime-extraction-contract.test.ts | 2 +- packages/runtime/src/ai-sdk-backend.ts | 24 ++- packages/runtime/src/ai-sdk-flow.ts | 6 + packages/runtime/src/model-adapter.ts | 172 ++++++++++++++++-- packages/runtime/src/run-trace.ts | 6 + .../runtime/src/runtime-event-read-model.ts | 12 ++ packages/runtime/src/telemetry/cost.ts | 18 +- .../runtime/src/telemetry/record-llm-call.ts | 11 +- packages/runtime/src/telemetry/types.ts | 2 + .../__tests__/settings-store-usage.test.ts | 10 + .../src/__tests__/telemetry-repo.test.ts | 20 +- packages/storage/src/settings-store.ts | 6 + packages/storage/src/telemetry-repo.ts | 56 +++++- packages/ui/src/materialize.ts | 4 + 26 files changed, 639 insertions(+), 30 deletions(-) create mode 100644 packages/runtime/src/__tests__/cost.test.ts 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; }