From 592351e9afdd09d823222dc93c1e96682e1f37ec Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 10 Aug 2026 18:01:28 +0800 Subject: [PATCH 01/20] feat(usage): preserve model usage basis in projections Generated-by: Maka --- .../model-call-usage-projection.test.ts | 27 +++++++++---------- .../core/src/model-call-usage-projection.ts | 1 + packages/core/src/usage-stats/types.ts | 8 ++++++ .../__tests__/usage-pricing-protocol.test.ts | 13 ++++++++- .../src/protocol/usage-pricing.ts | 5 ++++ .../src/server/usage-pricing-coordinator.ts | 1 + 6 files changed, 39 insertions(+), 16 deletions(-) diff --git a/packages/core/src/__tests__/model-call-usage-projection.test.ts b/packages/core/src/__tests__/model-call-usage-projection.test.ts index 60fe62c933..5736a7a4b6 100644 --- a/packages/core/src/__tests__/model-call-usage-projection.test.ts +++ b/packages/core/src/__tests__/model-call-usage-projection.test.ts @@ -99,24 +99,21 @@ describe('model-call usage projection', () => { }); test('usage-missing records are reported separately from unpriced ones', () => { - const summary = projectModelCallUsageSummary( - [ - attempt({ - attemptId: 'no-usage', - status: 'failed', - usageBasis: 'missing', - inputTokens: undefined, - outputTokens: undefined, - costBasis: 'unpriced', - costUsd: undefined, - }), - ], - { range: 'all' }, - NOW, - ); + const noUsage = attempt({ + attemptId: 'no-usage', + status: 'failed', + usageBasis: 'missing', + inputTokens: undefined, + outputTokens: undefined, + costBasis: 'unpriced', + costUsd: undefined, + }); + const summary = projectModelCallUsageSummary([noUsage], { range: 'all' }, NOW); + const logs = projectModelCallUsageLogs([noUsage], { range: 'all' }, NOW); assert.equal(summary.coverage.usageMissingAttempts, 1); assert.equal(summary.coverage.unpricedAttempts, 1); assert.equal(summary.totalTokens.total, 0); + assert.equal(logs.rows[0]?.usageBasis, 'missing'); }); test('a replayed attemptId is counted once', () => { diff --git a/packages/core/src/model-call-usage-projection.ts b/packages/core/src/model-call-usage-projection.ts index dd9206e5ce..915d778db5 100644 --- a/packages/core/src/model-call-usage-projection.ts +++ b/packages/core/src/model-call-usage-projection.ts @@ -254,6 +254,7 @@ export function projectModelCallUsageLogs( cacheWriteTokens: t.cacheWrite, reasoningTokens: t.reasoning, totalTokens: t.total, + usageBasis: attempt.usageBasis, // A row keeps its basis, not just its number. Collapsing an unpriced call // to 0 here would reproduce, per row, exactly the ambiguity the coverage // breakdown removes from the totals. diff --git a/packages/core/src/usage-stats/types.ts b/packages/core/src/usage-stats/types.ts index 3205ec8d21..6b9e7f64ee 100644 --- a/packages/core/src/usage-stats/types.ts +++ b/packages/core/src/usage-stats/types.ts @@ -1,3 +1,5 @@ +import type { ModelCallUsageBasis } from '../model-call-attempt.js'; + export const MODEL_CALL_KINDS = [ 'main', 'semantic_compact', @@ -74,6 +76,12 @@ export interface UsageLogRow { cacheWriteTokens: number; reasoningTokens: number; totalTokens: number; + /** + * How completely the provider reported token usage for this attempt. + * Frozen pre-ledger rows leave this absent because their schema did not + * preserve the distinction. + */ + usageBasis?: ModelCallUsageBasis; /** * Absent when `costBasis` is `'unpriced'`. Zero means the call was genuinely * free — it must never stand in for a price that could not be resolved. diff --git a/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts b/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts index 5a58488a10..12ff5bbf22 100644 --- a/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts @@ -169,7 +169,10 @@ describe('Usage/Pricing protocol', () => { usageResponse({ kind: 'logs', source: 'llm', - rows: [validLog(), { ...validLog(1), callKind: 'goal_evaluation' }], + rows: [ + { ...validLog(), usageBasis: 'missing' }, + { ...validLog(1), callKind: 'goal_evaluation', usageBasis: 'reported' }, + ], offset: 0, total: 2, nextOffset: null, @@ -259,6 +262,14 @@ describe('Usage/Pricing protocol', () => { total: 1, nextOffset: null, }, + { + kind: 'logs', + source: 'llm', + rows: [{ ...validLog(), usageBasis: 'guessed' }], + offset: 0, + total: 1, + nextOffset: null, + }, { kind: 'logs', source: 'tool', diff --git a/packages/runtime-host/src/protocol/usage-pricing.ts b/packages/runtime-host/src/protocol/usage-pricing.ts index 61dcc47ced..951e124f14 100644 --- a/packages/runtime-host/src/protocol/usage-pricing.ts +++ b/packages/runtime-host/src/protocol/usage-pricing.ts @@ -3,6 +3,7 @@ import { normalizePricingModelKey, validateCanonicalPricingConfig, } from '@maka/core/usage-stats/pricing'; +import { MODEL_CALL_USAGE_BASES, type ModelCallUsageBasis } from '@maka/core/model-call-attempt'; import type { CacheMissInputSource, ModelCallKind, @@ -76,6 +77,7 @@ const LLM_USAGE_LOG_FIELDS = new Set([ 'cacheMissInputSource', 'reasoningTokens', 'totalTokens', + 'usageBasis', 'costUsd', 'costBasis', 'latencyMs', @@ -139,6 +141,8 @@ export interface LlmUsageLogProjection { readonly cacheMissInputSource?: CacheMissInputSource; readonly reasoningTokens: number; readonly totalTokens: number; + /** Undefined for frozen rows whose schema predates the canonical ledger. */ + readonly usageBasis?: ModelCallUsageBasis; /** Absent when `costBasis` is `'unpriced'`. Zero means genuinely free. */ readonly costUsd?: number; /** Undefined for rows from the frozen table, which never recorded a basis. */ @@ -963,6 +967,7 @@ function decodeLlmUsageLog(value: unknown): LlmUsageLogProjection { : { cacheMissInputSource: decodeCacheMissInputSource(row.cacheMissInputSource) }), reasoningTokens: requireCount(row.reasoningTokens, 'usage log reasoning tokens'), totalTokens: requireCount(row.totalTokens, 'usage log total tokens'), + ...optionalEnum(row, 'usageBasis', MODEL_CALL_USAGE_BASES), ...(row.costUsd === undefined ? {} : { costUsd: nonnegativeFinite(row.costUsd, 'usage log cost') }), diff --git a/packages/runtime-host/src/server/usage-pricing-coordinator.ts b/packages/runtime-host/src/server/usage-pricing-coordinator.ts index f3097d8b3b..27b9f9edde 100644 --- a/packages/runtime-host/src/server/usage-pricing-coordinator.ts +++ b/packages/runtime-host/src/server/usage-pricing-coordinator.ts @@ -551,6 +551,7 @@ function projectUsageLog(row: UsageLogRow): LlmUsageLogProjection { : {}), reasoningTokens: row.reasoningTokens, totalTokens: row.totalTokens, + ...(row.usageBasis === undefined ? {} : { usageBasis: row.usageBasis }), ...(row.costUsd === undefined ? {} : { costUsd: row.costUsd }), ...(row.costBasis === undefined ? {} : { costBasis: row.costBasis }), latencyMs: row.latencyMs, From ca38f88b6f5dc8bf05a8d9d759d075ba173f6939 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 10 Aug 2026 18:21:01 +0800 Subject: [PATCH 02/20] fix(desktop): read usage from Runtime Host Generated-by: Maka --- .../src/main/runtime-host-usage-ipc-main.ts | 310 ++++++++++++++- .../renderer/locales/settings-usage-copy.ts | 28 +- .../renderer/settings/usage-settings-page.tsx | 85 ++++- .../settings/settings-pages.stories.tsx | 46 ++- packages/core/src/settings.ts | 22 +- .../__tests__/settings-store-usage.test.ts | 277 -------------- packages/storage/src/settings-store.ts | 8 - packages/storage/src/usage-stats-store.ts | 361 ------------------ 8 files changed, 460 insertions(+), 677 deletions(-) delete mode 100644 packages/storage/src/__tests__/settings-store-usage.test.ts delete mode 100644 packages/storage/src/usage-stats-store.ts diff --git a/apps/desktop/src/main/runtime-host-usage-ipc-main.ts b/apps/desktop/src/main/runtime-host-usage-ipc-main.ts index 84389d9d3e..fb37261b50 100644 --- a/apps/desktop/src/main/runtime-host-usage-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-usage-ipc-main.ts @@ -1,13 +1,22 @@ import { tryResult } from "@maka/core/result"; +import type { UsageRange, UsageStats } from "@maka/core"; import { normalizePricingConfig, normalizePricingModelKey, } from "@maka/core/usage-stats/pricing"; import type { PricingConfig, + TimeRange, + UsageBucket, UsageGroupBy, UsageQuery, + UsageSummaryV2, } from "@maka/core/usage-stats/types"; +import type { UsageProvenance } from "@maka/core/usage-ledger-merge"; +import type { + LlmUsageLogProjection, + ToolUsageLogProjection, +} from "@maka/runtime-host/protocol"; import { handleReconnectableRead, type ReconnectableReadIpcMain, @@ -19,9 +28,14 @@ interface RuntimeHostUsageIpcDeps { readonly ipcMain: ReconnectableReadIpcMain; readonly client: DesktopRuntimeHostClient; readonly sendToRenderer: (channel: string, ...args: unknown[]) => void; + readonly now?: () => number; } const PAGE_LIMIT = 100; +const SNAPSHOT_ATTEMPTS = 3; +type UsageTimeWindow = Extract; + +class UsageSnapshotChangedError extends Error {} export function registerRuntimeHostUsageIpc( deps: RuntimeHostUsageIpcDeps, @@ -36,6 +50,13 @@ export function registerRuntimeHostUsageIpc( return result; }; + handleReconnectableRead( + deps.ipcMain, + "settings:usageStats", + (_event, range: UsageRange = "24h") => + loadUsageStatsSnapshot(deps.client, range, deps.now?.() ?? Date.now()), + ); + handleReconnectableRead( deps.ipcMain, "usage:summary", @@ -54,7 +75,7 @@ export function registerRuntimeHostUsageIpc( "usage:buckets", (_event, query: UsageQuery & { groupBy: UsageGroupBy }) => tryReconnectableReadResult( - () => loadAllBuckets(deps.client, query), + () => loadAllBuckets(deps.client, query).then((result) => result.buckets), "USAGE_BUCKETS_FAILED", ), ); @@ -126,9 +147,11 @@ export function registerRuntimeHostUsageIpc( async function loadAllBuckets( client: DesktopRuntimeHostClient, query: UsageQuery & { groupBy: UsageGroupBy }, -) { - const buckets = []; +): Promise<{ buckets: UsageBucket[]; provenance: UsageProvenance }> { + const buckets: UsageBucket[] = []; let offset = 0; + let total: number | undefined; + let provenance: UsageProvenance | undefined; while (true) { const result = await client.queryUsage( query.groupBy === "tool" @@ -149,13 +172,292 @@ async function loadAllBuckets( ); if (result.kind !== "buckets" || result.offset !== offset) throw invalidUsageProjection(); + if ( + (total !== undefined && total !== result.total) || + (provenance && !sameProvenance(provenance, result.provenance)) + ) { + throw new UsageSnapshotChangedError(); + } + total = result.total; + provenance = result.provenance; buckets.push(...result.buckets); - if (result.nextOffset === null) return buckets; + if (result.nextOffset === null) { + if (buckets.length !== result.total) throw new UsageSnapshotChangedError(); + return { buckets, provenance: result.provenance }; + } if (result.nextOffset <= offset) throw invalidUsageProjection(); offset = result.nextOffset; } } +async function loadUsageStatsSnapshot( + client: DesktopRuntimeHostClient, + range: UsageRange, + now: number, +): Promise { + const query = { range: usageRangeWindow(range, now) } satisfies UsageQuery; + for (let attempt = 0; attempt < SNAPSHOT_ATTEMPTS; attempt += 1) { + let results; + try { + results = await Promise.all([ + client.queryUsage({ kind: "summary", query }), + loadAllBuckets(client, { ...query, groupBy: "provider" }), + loadAllBuckets(client, { ...query, groupBy: "model" }), + loadAllLlmLogs(client, query), + loadAllToolLogs(client, query), + ]); + } catch (error) { + if (error instanceof UsageSnapshotChangedError) continue; + throw error; + } + const [summaryResult, providerResult, modelResult, llmResult, toolResult] = results; + if (summaryResult.kind !== "summary") throw invalidUsageProjection(); + if ( + sameUsageSnapshot( + query.range, + summaryResult.summary, + summaryResult.provenance, + providerResult, + modelResult, + llmResult, + ) + ) { + return projectUsageStats( + summaryResult.summary, + summaryResult.provenance, + providerResult.buckets, + modelResult.buckets, + llmResult.rows, + toolResult.rows, + ); + } + } + throw new Error("Runtime Host Usage changed while Desktop read it"); +} + +async function loadAllLlmLogs( + client: DesktopRuntimeHostClient, + query: UsageQuery, +): Promise<{ + rows: LlmUsageLogProjection[]; + total: number; + provenance: UsageProvenance; +}> { + const rows: LlmUsageLogProjection[] = []; + let offset = 0; + let total: number | undefined; + let provenance: UsageProvenance | undefined; + while (true) { + const result = await client.queryUsage({ + kind: "logs", + source: "llm", + query: toLlmQuery(query), + offset, + limit: PAGE_LIMIT, + }); + if (result.kind !== "logs" || result.source !== "llm" || result.offset !== offset) + throw invalidUsageProjection(); + if ( + (total !== undefined && total !== result.total) || + (provenance && !sameProvenance(provenance, result.provenance)) + ) { + throw new UsageSnapshotChangedError(); + } + provenance = result.provenance; + total = result.total; + rows.push(...result.rows); + if (result.nextOffset === null) { + if (rows.length !== result.total) throw new UsageSnapshotChangedError(); + return { rows, total: result.total, provenance: result.provenance }; + } + if (result.nextOffset <= offset) throw invalidUsageProjection(); + offset = result.nextOffset; + } +} + +async function loadAllToolLogs( + client: DesktopRuntimeHostClient, + query: UsageQuery, +): Promise<{ rows: ToolUsageLogProjection[]; total: number }> { + const rows: ToolUsageLogProjection[] = []; + let offset = 0; + let total: number | undefined; + while (true) { + const result = await client.queryUsage({ + kind: "logs", + source: "tool", + query: toToolQuery(query), + offset, + limit: PAGE_LIMIT, + }); + if (result.kind !== "logs" || result.source !== "tool" || result.offset !== offset) + throw invalidUsageProjection(); + if (total !== undefined && total !== result.total) + throw new UsageSnapshotChangedError(); + total = result.total; + rows.push(...result.rows); + if (result.nextOffset === null) { + if (rows.length !== result.total) throw new UsageSnapshotChangedError(); + return { rows, total: result.total }; + } + if (result.nextOffset <= offset) throw invalidUsageProjection(); + offset = result.nextOffset; + } +} + +function sameUsageSnapshot( + expectedRange: UsageTimeWindow, + summary: { readonly range: { readonly from: number; readonly to: number }; readonly totalRequests: number }, + provenance: UsageProvenance, + providers: { readonly buckets: readonly UsageBucket[]; readonly provenance: UsageProvenance }, + models: { readonly buckets: readonly UsageBucket[]; readonly provenance: UsageProvenance }, + logs: { readonly total: number; readonly provenance: UsageProvenance }, +): boolean { + return ( + summary.range.from === expectedRange.from && + summary.range.to === expectedRange.to && + summary.totalRequests === logs.total && + summary.totalRequests === sumRequests(providers.buckets) && + summary.totalRequests === sumRequests(models.buckets) && + sameProvenance(provenance, providers.provenance) && + sameProvenance(provenance, models.provenance) && + sameProvenance(provenance, logs.provenance) + ); +} + +function sameProvenance(left: UsageProvenance, right: UsageProvenance): boolean { + return ( + left.legacyRecords === right.legacyRecords && + left.unreadableRecords === right.unreadableRecords && + left.pendingRepairs === right.pendingRepairs && + left.coverage.attempts === right.coverage.attempts && + left.coverage.pricedAttempts === right.coverage.pricedAttempts && + left.coverage.unpricedAttempts === right.coverage.unpricedAttempts && + left.coverage.usageReportedAttempts === right.coverage.usageReportedAttempts && + left.coverage.usagePartialAttempts === right.coverage.usagePartialAttempts && + left.coverage.usageMissingAttempts === right.coverage.usageMissingAttempts + ); +} + +function sumRequests(buckets: readonly UsageBucket[]): number { + return buckets.reduce((total, bucket) => total + bucket.requests, 0); +} + +function projectUsageStats( + summary: UsageSummaryV2, + provenance: UsageProvenance, + providerBuckets: readonly UsageBucket[], + modelBuckets: readonly UsageBucket[], + llmRows: readonly LlmUsageLogProjection[], + toolRows: readonly ToolUsageLogProjection[], +): UsageStats { + return { + provenance, + summary: { + totalRequests: summary.totalRequests, + totalCostUsd: summary.totalCostUsd, + totalTokens: summary.totalTokens.total, + inputTokens: summary.totalTokens.input, + outputTokens: summary.totalTokens.output, + cacheTokens: summary.totalTokens.cacheRead + summary.totalTokens.cacheWrite, + cacheMiss: summary.totalTokens.cacheMiss, + cacheRead: summary.totalTokens.cacheRead, + cacheCreation: summary.totalTokens.cacheWrite, + reasoning: summary.totalTokens.reasoning, + }, + logs: [ + ...llmRows.map((row) => ({ + id: row.id, + ts: row.ts, + kind: "model" as const, + ...(row.sessionId === undefined ? {} : { sessionId: row.sessionId }), + ...(row.turnId === undefined ? {} : { turnId: row.turnId }), + provider: row.connectionSlug ?? row.providerId, + model: row.modelId, + inputTokens: row.inputTokens, + outputTokens: row.outputTokens, + cacheMiss: row.cacheMissTokens, + cacheRead: row.cacheReadTokens, + cacheCreation: row.cacheWriteTokens, + reasoning: row.reasoningTokens, + ...(row.usageBasis === undefined ? {} : { usageBasis: row.usageBasis }), + ...(row.costUsd === undefined ? {} : { costUsd: row.costUsd }), + ...(row.costBasis === undefined ? {} : { costBasis: row.costBasis }), + latencyMs: row.latencyMs, + status: row.status, + })), + ...toolRows.map((row) => ({ + id: row.id, + ts: row.ts, + kind: "tool" as const, + ...(row.sessionId === undefined ? {} : { sessionId: row.sessionId }), + ...(row.turnId === undefined ? {} : { turnId: row.turnId }), + provider: row.providerId ?? "", + model: row.modelId ?? "", + toolName: row.toolName, + inputTokens: 0, + outputTokens: 0, + latencyMs: row.durationMs, + status: row.status, + })), + ].sort((left, right) => right.ts - left.ts), + byProvider: providerBuckets.map((bucket) => ({ + provider: bucket.label, + requests: bucket.requests, + tokens: bucket.totalTokens, + costUsd: bucket.costUsd, + })), + byModel: modelBuckets.map((bucket) => ({ + model: bucket.label, + requests: bucket.requests, + tokens: bucket.totalTokens, + costUsd: bucket.costUsd, + })), + byTool: projectToolBuckets(toolRows), + pricing: [], + }; +} + +function projectToolBuckets(rows: readonly ToolUsageLogProjection[]): UsageStats["byTool"] { + const buckets = new Map< + string, + { calls: number; success: number; errors: number; aborted: number; durationMs: number } + >(); + for (const row of rows) { + const bucket = buckets.get(row.toolName) ?? { + calls: 0, + success: 0, + errors: 0, + aborted: 0, + durationMs: 0, + }; + bucket.calls += 1; + bucket[row.status === "error" ? "errors" : row.status] += 1; + bucket.durationMs += row.durationMs; + buckets.set(row.toolName, bucket); + } + return [...buckets.entries()] + .map(([tool, bucket]) => ({ + tool, + calls: bucket.calls, + success: bucket.success, + errors: bucket.errors, + aborted: bucket.aborted, + avgDurationMs: bucket.calls === 0 ? 0 : Math.round(bucket.durationMs / bucket.calls), + })) + .sort((left, right) => right.calls - left.calls || left.tool.localeCompare(right.tool)); +} + +function usageRangeWindow(range: UsageRange, now: number): UsageTimeWindow { + if (range === "all") return { from: 0, to: now }; + const spans = { + "24h": 24 * 60 * 60 * 1_000, + "7d": 7 * 24 * 60 * 60 * 1_000, + "30d": 30 * 24 * 60 * 60 * 1_000, + } satisfies Record, number>; + return { from: now - spans[range], to: now }; +} + function toLlmQuery(query: UsageQuery) { const { toolName: _toolName, ...llmQuery } = query; return llmQuery; diff --git a/apps/desktop/src/renderer/locales/settings-usage-copy.ts b/apps/desktop/src/renderer/locales/settings-usage-copy.ts index c4c58c0dc4..38c771046e 100644 --- a/apps/desktop/src/renderer/locales/settings-usage-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-usage-copy.ts @@ -4,13 +4,15 @@ export type UsageSettingsCopy = { saveFailed: string; toolbarAria: string; rangeAria: string; ranges: readonly [string, string, string, string]; refreshingAria: string; refreshAria: string; summaryAria: string; totalRequests: string; totalCost: string; costHelp: string; totalTokens: string; tokenDetail(input: number, output: number): string; cacheTokens: string; cacheDetail(miss: number, read: number, creation: number): string; + incompleteValue(value: string): string; incompleteProjection(unreadable: number, pending: number): string; joinDetails(parts: string[]): string; incompleteCost(count: number): string; + incompleteTokens(input: number, output: number, count: number): string; incompleteCache(miss: number, read: number, creation: number, count: number): string; viewAria: string; tabs: readonly [string, string, string, string, string]; filtersAria: string; filterPlaceholder: string; filterAria: string; - statusAria: string; statuses: readonly [string, string, string]; details: string; detailsAria: string; recordCount(count: number): string; clearFilters: string; + statusAria: string; statuses: readonly [string, string, string, string]; details: string; detailsAria: string; recordCount(count: number): string; clearFilters: string; summaryOnly: string; showDetails: string; filteredEmpty: string; filteredEmptyHelp: string; requestEmpty: string; tables: { providersAria: string; modelsAria: string; toolsAria: string; pricingAria: string; requestsAria: string; providerHeaders: string[]; modelHeaders: string[]; toolHeaders: string[]; pricingHeaders: string[]; requestHeaders: string[]; - noPricing: string; modelKind: string; toolKind: string; openSession(label: string): string; success: string; error: string; + noPricing: string; modelKind: string; toolKind: string; openSession(label: string): string; success: string; error: string; aborted: string; unknown: string; partial(value: number): string; providerEmptyTitle: string; providerEmptyBody: string; modelEmptyTitle: string; modelEmptyBody: string; toolEmptyTitle: string; toolEmptyBody: string; pricingEmptyBody: string; }; @@ -22,15 +24,18 @@ const SETTINGS_USAGE_COPY = { refreshingAria: '正在刷新使用统计', refreshAria: '刷新使用统计', summaryAria: '使用统计汇总指标', totalRequests: '总请求', totalCost: '总费用', costHelp: '以模型供应商最终结算为准', totalTokens: '总 Token', tokenDetail: (input, output) => `输入 ${input} / 输出 ${output}`, cacheTokens: '缓存 Token', cacheDetail: (miss, read, creation) => `新 ${miss} / 命中 ${read} / 创建 ${creation}`, viewAria: '使用统计视图', tabs: ['请求日志', '供应商统计', '模型统计', '工具统计', '定价配置'], + incompleteValue: (value) => `${value} + 未知`, incompleteProjection: (unreadable, pending) => `使用统计投影不完整(无法读取 ${unreadable} 条记录,待修复 ${pending} 个运行)`, joinDetails: (parts) => parts.join(';'), + incompleteCost: (count) => `${count} 个请求的费用未知`, incompleteTokens: (input, output, count) => `已知输入 ${input} / 输出 ${output};${count} 个请求的 Token 不完整`, + incompleteCache: (miss, read, creation, count) => `已知新 ${miss} / 命中 ${read} / 创建 ${creation};${count} 个请求的 Token 不完整`, filtersAria: '请求记录筛选', filterPlaceholder: '按模型或工具筛选…', filterAria: '按模型或工具筛选请求记录', statusAria: '请求状态筛选', - statuses: ['全部状态', '成功', '错误'], details: '详情记录', detailsAria: '显示使用统计详情记录', recordCount: (count) => `共 ${count} 条记录`, clearFilters: '清除筛选', + statuses: ['全部状态', '成功', '错误', '已中断'], details: '详情记录', detailsAria: '显示使用统计详情记录', recordCount: (count) => `共 ${count} 条记录`, clearFilters: '清除筛选', summaryOnly: '当前仅显示汇总指标。打开详情记录后,可以查看逐条模型请求和工具调用,按模型、工具或状态筛选,并用于排查费用与失败请求。', showDetails: '显示明细', filteredEmpty: '没有符合筛选条件的请求记录', filteredEmptyHelp: '调整或清除筛选条件后可查看全部请求记录。', requestEmpty: '暂无请求记录', tables: { providersAria: '使用统计供应商统计表', modelsAria: '使用统计模型统计表', toolsAria: '使用统计工具统计表', pricingAria: '使用统计定价配置表', requestsAria: '使用统计请求日志表', - providerHeaders: ['供应商', '请求', 'Token', '费用'], modelHeaders: ['模型', '请求', 'Token', '费用'], toolHeaders: ['工具', '调用', '成功', '错误', '平均耗时'], - pricingHeaders: ['供应商', '模型', '输入 / 1M', '输出 / 1M'], requestHeaders: ['时间', '类型', '对象', '任务', 'Token', '费用', '延迟', '状态'], - noPricing: '暂无定价覆盖配置', modelKind: '模型', toolKind: '工具', openSession: (label) => `打开 ${label}`, success: '成功', error: '错误', + providerHeaders: ['供应商', '请求', 'Token', '费用'], modelHeaders: ['模型', '请求', 'Token', '费用'], toolHeaders: ['工具', '调用', '成功', '错误', '已中断', '平均耗时'], + pricingHeaders: ['供应商', '模型', '输入 / 1M', '输出 / 1M'], requestHeaders: ['时间', '类型', '对象', '会话', 'Token', '费用', '延迟', '状态'], + noPricing: '暂无定价覆盖配置', modelKind: '模型', toolKind: '工具', openSession: (label) => `打开 ${label}`, success: '成功', error: '错误', aborted: '已中断', unknown: '未知', partial: (value) => `${value}(部分)`, providerEmptyTitle: '暂无供应商用量', providerEmptyBody: '完成一次模型请求后,这里会按供应商聚合请求数、Token 与费用。', modelEmptyTitle: '暂无模型用量', modelEmptyBody: '完成一次模型请求后,这里会按模型聚合请求数、Token 与费用。', toolEmptyTitle: '暂无工具调用', toolEmptyBody: '智能体调用工具后,这里会按工具聚合调用次数、成功、错误与平均耗时。', @@ -42,15 +47,18 @@ const SETTINGS_USAGE_COPY = { refreshingAria: 'Refreshing usage', refreshAria: 'Refresh usage', summaryAria: 'Usage summary metrics', totalRequests: 'Total requests', totalCost: 'Total cost', costHelp: 'Final billing is determined by the model provider', totalTokens: 'Total tokens', tokenDetail: (input, output) => `Input ${input} / output ${output}`, cacheTokens: 'Cache tokens', cacheDetail: (miss, read, creation) => `New ${miss} / hit ${read} / created ${creation}`, viewAria: 'Usage view', tabs: ['Request log', 'Providers', 'Models', 'Tools', 'Pricing'], + incompleteValue: (value) => `${value} + unknown`, incompleteProjection: (unreadable, pending) => `Usage projection is incomplete (${unreadable} unreadable ${unreadable === 1 ? 'record' : 'records'}, ${pending} pending ${pending === 1 ? 'repair' : 'repairs'})`, joinDetails: (parts) => parts.join('; '), + incompleteCost: (count) => `${count} ${count === 1 ? 'request has' : 'requests have'} unknown cost`, incompleteTokens: (input, output, count) => `Known input ${input} / output ${output}; ${count} ${count === 1 ? 'request has' : 'requests have'} incomplete token usage`, + incompleteCache: (miss, read, creation, count) => `Known new ${miss} / hit ${read} / created ${creation}; ${count} ${count === 1 ? 'request has' : 'requests have'} incomplete token usage`, filtersAria: 'Request filters', filterPlaceholder: 'Filter by model or tool…', filterAria: 'Filter requests by model or tool', statusAria: 'Filter by request status', - statuses: ['All statuses', 'Success', 'Error'], details: 'Detailed records', detailsAria: 'Show detailed usage records', recordCount: (count) => `${count} ${count === 1 ? 'record' : 'records'}`, clearFilters: 'Clear filters', + statuses: ['All statuses', 'Success', 'Error', 'Aborted'], details: 'Detailed records', detailsAria: 'Show detailed usage records', recordCount: (count) => `${count} ${count === 1 ? 'record' : 'records'}`, clearFilters: 'Clear filters', summaryOnly: 'Only summary metrics are shown. Enable detailed records to inspect individual model requests and tool calls, filter by model, tool, or status, and investigate costs or failures.', showDetails: 'Show details', filteredEmpty: 'No requests match these filters', filteredEmptyHelp: 'Adjust or clear the filters to see all request records.', requestEmpty: 'No request records', tables: { providersAria: 'Usage by provider', modelsAria: 'Usage by model', toolsAria: 'Usage by tool', pricingAria: 'Usage pricing configuration', requestsAria: 'Usage request log', - providerHeaders: ['Provider', 'Requests', 'Tokens', 'Cost'], modelHeaders: ['Model', 'Requests', 'Tokens', 'Cost'], toolHeaders: ['Tool', 'Calls', 'Success', 'Errors', 'Average duration'], - pricingHeaders: ['Provider', 'Model', 'Input / 1M', 'Output / 1M'], requestHeaders: ['Time', 'Type', 'Target', 'Task', 'Tokens', 'Cost', 'Latency', 'Status'], - noPricing: 'No pricing overrides', modelKind: 'Model', toolKind: 'Tool', openSession: (label) => `Open ${label}`, success: 'Success', error: 'Error', + providerHeaders: ['Provider', 'Requests', 'Tokens', 'Cost'], modelHeaders: ['Model', 'Requests', 'Tokens', 'Cost'], toolHeaders: ['Tool', 'Calls', 'Success', 'Errors', 'Aborted', 'Average duration'], + pricingHeaders: ['Provider', 'Model', 'Input / 1M', 'Output / 1M'], requestHeaders: ['Time', 'Type', 'Target', 'Session', 'Tokens', 'Cost', 'Latency', 'Status'], + noPricing: 'No pricing overrides', modelKind: 'Model', toolKind: 'Tool', openSession: (label) => `Open ${label}`, success: 'Success', error: 'Error', aborted: 'Aborted', unknown: 'Unknown', partial: (value) => `${value} (partial)`, providerEmptyTitle: 'No provider usage', providerEmptyBody: 'After a model request, provider request counts, tokens, and costs appear here.', modelEmptyTitle: 'No model usage', modelEmptyBody: 'After a model request, request counts, tokens, and costs appear here by model.', toolEmptyTitle: 'No tool calls', toolEmptyBody: 'After an agent calls a tool, calls, successes, errors, and average duration appear here by tool.', diff --git a/apps/desktop/src/renderer/settings/usage-settings-page.tsx b/apps/desktop/src/renderer/settings/usage-settings-page.tsx index 767c3e9aad..f7884a7364 100644 --- a/apps/desktop/src/renderer/settings/usage-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/usage-settings-page.tsx @@ -87,6 +87,19 @@ export function UsageSettingsPage(props: { tools: stats?.byTool.length ?? 0, pricing: stats?.pricing.length ?? 0, }; + const projectionIncomplete = stats + ? stats.provenance.unreadableRecords > 0 || stats.provenance.pendingRepairs > 0 + : false; + const incompleteTokenRequests = stats + ? stats.provenance.coverage.usagePartialAttempts + + stats.provenance.coverage.usageMissingAttempts + : 0; + const incompleteCostRequests = stats + ? stats.provenance.coverage.unpricedAttempts + : 0; + const projectionDetail = stats && projectionIncomplete + ? copy.incompleteProjection(stats.provenance.unreadableRecords, stats.provenance.pendingRepairs) + : undefined; async function setRange(range: UsageRange) { const saved = await updateUsage({ range }); @@ -149,10 +162,39 @@ export function UsageSettingsPage(props: {
- - - - + + 0 || projectionIncomplete, copy)} + detail={copy.joinDetails([ + incompleteCostRequests > 0 ? copy.incompleteCost(incompleteCostRequests) : copy.costHelp, + ...(projectionDetail ? [projectionDetail] : []), + ])} + /> + 0 || projectionIncomplete, copy)} + detail={copy.joinDetails([ + incompleteTokenRequests > 0 + ? copy.incompleteTokens(stats?.summary.inputTokens ?? 0, stats?.summary.outputTokens ?? 0, incompleteTokenRequests) + : copy.tokenDetail(stats?.summary.inputTokens ?? 0, stats?.summary.outputTokens ?? 0), + ...(projectionDetail ? [projectionDetail] : []), + ])} + /> + 0 || projectionIncomplete, copy)} + detail={copy.joinDetails([ + incompleteTokenRequests > 0 + ? copy.incompleteCache(stats?.summary.cacheMiss ?? 0, stats?.summary.cacheRead ?? 0, stats?.summary.cacheCreation ?? 0, incompleteTokenRequests) + : copy.cacheDetail(stats?.summary.cacheMiss ?? 0, stats?.summary.cacheRead ?? 0, stats?.summary.cacheCreation ?? 0), + ...(projectionDetail ? [projectionDetail] : []), + ])} + />
@@ -276,6 +318,7 @@ function UsageRequestsPanel(props: { { value: 'all', label: props.copy.statuses[0] }, { value: 'success', label: props.copy.statuses[1] }, { value: 'error', label: props.copy.statuses[2] }, + { value: 'aborted', label: props.copy.statuses[3] }, ]} width={320} onChange={(value) => props.onStatusChange(value as AppSettings['usage']['status'])} @@ -318,9 +361,9 @@ function UsageRequestsPanel(props: { usageRequestKindLabel(row.kind, props.copy), usageRequestTarget(row), usageRequestSessionCell(row, props.copy, props.onOpenSession), - row.inputTokens + row.outputTokens, - row.kind === 'model' ? `$${(row.costUsd ?? 0).toFixed(2)}` : '-', - row.latencyMs ? `${row.latencyMs}ms` : '-', + usageRequestTokens(row, props.copy), + usageRequestCost(row, props.copy), + row.latencyMs === undefined ? '-' : `${row.latencyMs}ms`, usageRequestStatusLabel(row.status, props.copy), ])} empty={{ @@ -385,8 +428,9 @@ function UsageToolsPanel(props: { stats: UsageStats | null; copy: UsageSettingsC { header: props.copy.tables.toolHeaders[2], numeric: true }, { header: props.copy.tables.toolHeaders[3], numeric: true }, { header: props.copy.tables.toolHeaders[4], numeric: true }, + { header: props.copy.tables.toolHeaders[5], numeric: true }, ]} - rows={(props.stats?.byTool ?? []).map((row) => [row.tool, row.calls, row.success, row.errors, `${row.avgDurationMs}ms`])} + rows={(props.stats?.byTool ?? []).map((row) => [row.tool, row.calls, row.success, row.errors, row.aborted, `${row.avgDurationMs}ms`])} empty={{ Icon: Activity, title: props.copy.tables.toolEmptyTitle, body: props.copy.tables.toolEmptyBody }} /> ); @@ -422,10 +466,12 @@ function usageRequestTarget(row: UsageStats['logs'][number]) { } function usageRequestSessionCell(row: UsageStats['logs'][number], copy: UsageSettingsCopy, onOpenSession?: (sessionId: string) => void) { - const label = shortUsageSessionId(row.sessionId); + if (!row.sessionId) return '-'; + const sessionId = row.sessionId; + const label = shortUsageSessionId(sessionId); if (!onOpenSession) return label; return ( -