diff --git a/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts b/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts index a2d24ccae1..45ae681eba 100644 --- a/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts @@ -21,10 +21,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { SessionEvent } from '@maka/core/events'; import type { SessionSummary } from '@maka/core/session'; +import type { UsageStats } from '@maka/core/settings'; import { projectDesktopSessionEvent, projectDesktopSessionSummary, projectDesktopTurnRecord, + projectDesktopUsageStats, } from '../../shared/desktop-session-projection.js'; test('keeps equal raw Session ids distinct across Runtime Hosts', () => { @@ -145,6 +147,56 @@ test('projects queued Session attachments into the Desktop host namespace', () = ); }); +test('projects only present Usage Session ids into the Desktop host namespace', () => { + const stats: UsageStats = { + summary: { + totalRequests: 2, + totalCostUsd: 0, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheTokens: 0, + cacheMiss: 0, + cacheRead: 0, + cacheCreation: 0, + reasoning: 0, + }, + logs: [ + { + id: 'with-session', + ts: 1, + kind: 'model', + sessionId: 'session-1', + turnId: 'turn-1', + provider: 'provider', + model: 'model', + inputTokens: 0, + outputTokens: 0, + status: 'success', + }, + { + id: 'without-session', + ts: 2, + kind: 'model', + provider: 'provider', + model: 'model', + inputTokens: 0, + outputTokens: 0, + status: 'aborted', + }, + ], + byProvider: [], + byModel: [], + byTool: [], + pricing: [], + }; + + const projected = projectDesktopUsageStats({ hostId: 'remote-root' }, stats); + + assert.equal(projected.logs[0]?.sessionId, JSON.stringify(['remote-root', 'session-1'])); + assert.equal(projected.logs[1]?.sessionId, undefined); +}); + function summary(id: string): SessionSummary { return { id, diff --git a/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts new file mode 100644 index 0000000000..22a96627a5 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts @@ -0,0 +1,339 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { UsageStats } from "@maka/core/settings"; +import type { UsageQueryInput, UsageQueryResult } from "@maka/runtime-host/protocol"; +import type { IpcHandler } from "../ipc-reconnect-policy.js"; +import type { DesktopRuntimeHostClient } from "../runtime-host-client.js"; +import { registerRuntimeHostUsageIpc } from "../runtime-host-usage-ipc-main.js"; + +test("settings usage stats use the canonical model-call total and load every activity page", async () => { + const handlers = new Map(); + const calls: Array<{ source?: "llm" | "tool"; offset?: number }> = []; + const ranges: UsageQueryInput["query"]["range"][] = []; + registerRuntimeHostUsageIpc({ + ipcMain: { + handle: (channel, listener) => handlers.set(channel, listener), + handleReconnectableRead: (channel, listener) => handlers.set(channel, listener), + }, + client: { + queryUsage: async (input: UsageQueryInput) => { + ranges.push(input.query.range); + if (input.kind === "summary") { + return { + kind: "summary", + summary: { + range: { from: 1, to: 2 }, + totalRequests: 151, + totalCostUsd: 12.5, + totalTokens: { + input: 3_000_000, + output: 500_000, + cacheMiss: 100_000, + cacheRead: 400_000, + cacheWrite: 43_090, + reasoning: 90, + total: 4_043_090, + }, + cacheHitRequests: 10, + cacheCreateRequests: 5, + errorRequests: 2, + }, + provenance: provenance(), + } satisfies UsageQueryResult; + } + if (input.kind !== "logs") throw new Error("unexpected usage query"); + calls.push({ source: input.source, offset: input.offset }); + if (input.source === "llm") { + const offset = input.offset ?? 0; + const count = offset === 0 ? 100 : 51; + return { + kind: "logs", + source: "llm", + rows: Array.from({ length: count }, (_, index) => llmRow(offset + index)), + offset, + total: 151, + nextOffset: offset === 0 ? 100 : null, + provenance: provenance(), + } satisfies UsageQueryResult; + } + const offset = input.offset ?? 0; + const count = offset === 0 ? 100 : 71; + return { + kind: "logs", + source: "tool", + rows: Array.from({ length: count }, (_, index) => toolRow(offset + index)), + offset, + total: 171, + nextOffset: offset === 0 ? 100 : null, + } satisfies UsageQueryResult; + }, + loadPricingSnapshot: async () => ({ + hostEpoch: "host-epoch", + connectionId: "connection-id", + revision: 1, + entries: [ + { + source: "custom", + resetEffect: "become_unpriced", + pricing: { + modelKey: "provider-a:model-a", + inputUsdPer1M: 1, + outputUsdPer1M: 2, + }, + }, + ], + }), + } as unknown as DesktopRuntimeHostClient, + sendToRenderer: () => undefined, + }); + + const handler = handlers.get("settings:usageStats"); + assert.ok(handler); + const stats = await handler({} as never, "24h") as UsageStats; + + assert.equal(stats.summary.totalRequests, 151); + assert.equal(stats.summary.totalTokens, 4_043_090); + assert.equal(stats.logs.length, 322); + assert.equal(stats.logs.filter((row) => row.kind === "model").length, 151); + assert.equal(stats.logs.filter((row) => row.kind === "tool").length, 171); + const expectedCalls: Array<{ source?: "llm" | "tool"; offset?: number }> = [ + { source: "llm", offset: 0 }, + { source: "llm", offset: 100 }, + { source: "tool", offset: 0 }, + { source: "tool", offset: 100 }, + ]; + assert.deepEqual(calls.sort(compareCall), expectedCalls.sort(compareCall)); + assert.ok(ranges.every((range) => typeof range === "object")); + assert.ok(ranges.every((range) => JSON.stringify(range) === JSON.stringify(ranges[0]))); + assert.equal(stats.logs.find((row) => row.id === "llm-150")?.status, "aborted"); + assert.equal(stats.logs.find((row) => row.id === "llm-150")?.sessionId, undefined); + assert.equal(stats.logs.find((row) => row.id === "llm-150")?.costUsd, undefined); + assert.equal(stats.logs.find((row) => row.id === "tool-170")?.status, "aborted"); + assert.deepEqual(stats.byProvider, [ + { provider: "provider-a", requests: 151, tokens: 604, costUsd: 150 }, + ]); + assert.deepEqual(stats.byTool, [ + { tool: "Read", calls: 171, success: 170, errors: 0, avgDurationMs: 25 }, + ]); + assert.deepEqual(stats.pricing, [ + { + provider: "provider-a", + model: "model-a", + inputPerMTokUsd: 1, + outputPerMTokUsd: 2, + }, + ]); +}); + +test("settings usage stats reject a non-advancing activity page", async () => { + const handlers = new Map(); + registerRuntimeHostUsageIpc({ + ipcMain: { + handle: (channel, listener) => handlers.set(channel, listener), + handleReconnectableRead: (channel, listener) => handlers.set(channel, listener), + }, + client: { + queryUsage: async (input: UsageQueryInput) => { + if (input.kind === "summary") { + return { + kind: "summary", + summary: { + range: { from: 1, to: 2 }, + totalRequests: 0, + totalCostUsd: 0, + totalTokens: { + input: 0, + output: 0, + cacheMiss: 0, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + total: 0, + }, + cacheHitRequests: 0, + cacheCreateRequests: 0, + errorRequests: 0, + }, + provenance: provenance(), + } satisfies UsageQueryResult; + } + if (input.kind !== "logs") throw new Error("unexpected usage query"); + return input.source === "llm" + ? ({ + kind: "logs", + source: "llm", + rows: [], + offset: 0, + total: 1, + nextOffset: 0, + provenance: provenance(), + } satisfies UsageQueryResult) + : ({ + kind: "logs", + source: "tool", + rows: [], + offset: 0, + total: 0, + nextOffset: null, + } satisfies UsageQueryResult); + }, + loadPricingSnapshot: async () => ({ + hostEpoch: "host-epoch", + connectionId: "connection-id", + revision: 0, + entries: [], + }), + } as unknown as DesktopRuntimeHostClient, + sendToRenderer: () => undefined, + }); + + const handler = handlers.get("settings:usageStats"); + assert.ok(handler); + await assert.rejects(() => handler({} as never, "24h"), /invalid Usage projection/); +}); + +test("settings usage stats reject model logs that disagree with the canonical summary", async () => { + const handlers = new Map(); + registerRuntimeHostUsageIpc({ + ipcMain: { + handle: (channel, listener) => handlers.set(channel, listener), + handleReconnectableRead: (channel, listener) => handlers.set(channel, listener), + }, + client: { + queryUsage: async (input: UsageQueryInput) => { + if (input.kind === "summary") { + return { + kind: "summary", + summary: { + range: { from: 1, to: 2 }, + totalRequests: 2, + totalCostUsd: 0, + totalTokens: { + input: 0, + output: 0, + cacheMiss: 0, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + total: 0, + }, + cacheHitRequests: 0, + cacheCreateRequests: 0, + errorRequests: 0, + }, + provenance: provenance(), + } satisfies UsageQueryResult; + } + if (input.kind !== "logs") throw new Error("unexpected usage query"); + return input.source === "llm" + ? ({ + kind: "logs", + source: "llm", + rows: [llmRow(0)], + offset: 0, + total: 1, + nextOffset: null, + provenance: provenance(), + } satisfies UsageQueryResult) + : ({ + kind: "logs", + source: "tool", + rows: [], + offset: 0, + total: 0, + nextOffset: null, + } satisfies UsageQueryResult); + }, + loadPricingSnapshot: async () => ({ + hostEpoch: "host-epoch", + connectionId: "connection-id", + revision: 0, + entries: [], + }), + } as unknown as DesktopRuntimeHostClient, + sendToRenderer: () => undefined, + }); + + const handler = handlers.get("settings:usageStats"); + assert.ok(handler); + await assert.rejects(() => handler({} as never, "all"), /invalid Usage projection/); +}); + +function llmRow(index: number) { + return { + source: "llm" as const, + id: `llm-${index}`, + ts: 1_000 + index, + providerId: "provider-a", + modelId: "model-a", + inputTokens: 3, + outputTokens: 1, + cacheMissTokens: 1, + cacheReadTokens: 2, + cacheWriteTokens: 0, + reasoningTokens: 0, + totalTokens: 7, + ...(index === 150 ? { costBasis: "unpriced" as const } : { costUsd: 1 }), + latencyMs: 10, + status: index === 150 ? ("aborted" as const) : ("success" as const), + ...(index === 150 ? {} : { sessionId: "session-a", turnId: `turn-${index}` }), + }; +} + +function toolRow(index: number) { + return { + source: "tool" as const, + id: `tool-${index}`, + ts: 2_000 + index, + toolName: "Read", + durationMs: 25, + status: index === 170 ? ("aborted" as const) : ("success" as const), + bytesIn: 0, + bytesOut: 0, + startedAt: 1_975 + index, + sessionId: "session-a", + turnId: `turn-${index}`, + }; +} + +function provenance() { + return { + coverage: { + attempts: 148, + pricedAttempts: 147, + unpricedAttempts: 1, + usageReportedAttempts: 148, + usagePartialAttempts: 0, + usageMissingAttempts: 0, + }, + legacyRecords: 3, + unreadableRecords: 0, + pendingRepairs: 0, + }; +} + +function compareCall( + left: { source?: "llm" | "tool"; offset?: number }, + right: { source?: "llm" | "tool"; offset?: number }, +): number { + return `${left.source}:${left.offset}`.localeCompare(`${right.source}:${right.offset}`); +} diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts index 25360bffd0..7efbd1eb69 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -22,7 +22,11 @@ import { join } from 'node:path'; import type { E2eFixtureScenario, E2eFixtureState } from '@maka/core/e2e-fixture'; import type { UiLocale } from '@maka/core/ui-locale'; import { createProjectCatalog } from '@maka/storage/project-catalog'; -import { resolveStorageRoot } from '@maka/storage/root-authority'; +import { + resolveStorageRoot, + tryAcquireInteractiveRootOwner, +} from '@maka/storage/root-authority'; +import { openInteractiveUsageStoresForWrite } from '@maka/storage/usage-stores'; import { E2E_FIXTURE_NOW, LONG_SIDEBAR_PROJECT_ID, @@ -46,7 +50,7 @@ import { writeScheduledTasks, writeSettings, } from './e2e-fixture/scenarios-settings.js'; -import { usageStatsSessions } from './e2e-fixture/scenarios-usage.js'; +import { usageStatsRecords, usageStatsSessions } from './e2e-fixture/scenarios-usage.js'; const E2E_FIXTURE_SCENARIOS = new Set([ 'settings-models', @@ -211,7 +215,7 @@ export async function seedE2eFixture(input: { const scenario = input.fixture.scenario; await rm(input.workspaceRoot, { recursive: true, force: true }); await mkdir(input.workspaceRoot, { recursive: true }); - await resolveStorageRoot({ path: input.workspaceRoot, kind: 'interactive' }); + const storageRoot = await resolveStorageRoot({ path: input.workspaceRoot, kind: 'interactive' }); await writeSettings(input.workspaceRoot, scenario); await writeConnections(input.workspaceRoot, now, scenario); await writeSession(input.workspaceRoot, turnSession(now), turnMessages(now)); @@ -242,5 +246,17 @@ export async function seedE2eFixture(input: { for (const seed of usageStatsSessions(now)) { await writeSession(input.workspaceRoot, seed.header, seed.messages); } + const owner = await tryAcquireInteractiveRootOwner(storageRoot); + if (!owner) throw new Error('Unable to acquire the E2E fixture storage root'); + const usage = await openInteractiveUsageStoresForWrite(owner.lease); + try { + const records = usageStatsRecords(now); + for (const record of records.llm) await usage.telemetry.recordLlmCall(record); + for (const record of records.tools) await usage.telemetry.recordToolInvocation(record); + await usage.flush(); + } finally { + await usage.close(); + await owner.close(); + } } } diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-usage.ts b/apps/desktop/src/main/e2e-fixture/scenarios-usage.ts index 7ed957643e..0cf11b987d 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-usage.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-usage.ts @@ -18,20 +18,24 @@ */ import type { SessionHeader, StoredMessage } from '@maka/core/session'; +import type { TelemetryIndexWriter } from '@maka/storage/usage-stores'; import { header } from './seed-helpers.js'; -// Settings → 使用统计 fixture. `usageStats` aggregates `token_usage` + tool -// messages across ALL sessions in the workspace, so the settings-usage capture -// only shows real tables if the seed contains enough varied traffic. These -// sessions are gated to the `settings-usage` scenario so no other capture is -// disturbed; every value is a literal keyed off the fixed `now`, so the tables -// render deterministically. +type PersistedLlmCallRecord = Parameters[0]; +type PersistedToolInvocationRecord = Parameters< + TelemetryIndexWriter['recordToolInvocation'] +>[0]; + +// Settings → 使用统计 fixture. Session messages keep the task links realistic, +// while `usageStatsRecords` seeds the Runtime Host's canonical usage surface. +// These records are gated to `settings-usage` so no other capture is disturbed; +// every value is derived from the fixed fixture clock for deterministic tables. // // The shape below intentionally spreads across: // - 3 providers (zai-live / relay-fallback / needs-reauth) → 供应商统计 // - 5 models (glm / claude / gpt families) → 模型统计 // - 6 tools with 2 failures → 工具统计 (exercises the error column) -// - a dozen request-log rows mixing model + tool + success/error → 请求日志 +// - a dozen activity rows mixing model + tool + success/error → 活动记录 interface UsageTurnSpec { turnId: string; @@ -215,3 +219,79 @@ export function usageStatsSessions( }, ]; } + +export function usageStatsRecords(now: number): { + llm: PersistedLlmCallRecord[]; + tools: PersistedToolInvocationRecord[]; +} { + const sessions = usageStatsSessions(now); + const llm: PersistedLlmCallRecord[] = []; + const tools: PersistedToolInvocationRecord[] = []; + for (const { header: session, messages } of sessions) { + const modelByTurn = new Map( + messages + .filter((message) => message.type === 'assistant') + .map((message) => [message.turnId, message.modelId]), + ); + const toolResults = new Map( + messages + .filter((message) => message.type === 'tool_result') + .map((message) => [message.toolUseId, message]), + ); + for (const message of messages) { + if (message.type === 'token_usage') { + const inputTokens = message.input; + const outputTokens = message.output; + const cacheRead = message.cacheRead ?? 0; + const cacheMiss = message.cacheMissInput ?? Math.max(0, inputTokens - cacheRead); + const cacheWrite = message.cacheCreation ?? 0; + llm.push({ + id: message.id, + sessionId: session.id, + turnId: message.turnId, + callKind: 'main', + callId: message.id, + connectionSlug: session.llmConnectionSlug, + providerId: session.llmConnectionSlug, + modelId: modelByTurn.get(message.turnId) ?? session.model, + inputTokens, + outputTokens, + cacheHitInputTokens: cacheRead, + cacheMissInputTokens: cacheMiss, + cachedInputTokens: cacheRead, + cacheWriteInputTokens: cacheWrite, + reasoningTokens: message.reasoning ?? 0, + totalTokens: inputTokens + outputTokens, + costUsd: message.costUsd ?? 0, + latencyMs: 2_000, + status: 'success', + startedAt: message.ts - 2_000, + date: new Date(message.ts).toISOString().slice(0, 10), + ts: message.ts, + }); + } + if (message.type === 'tool_call') { + const result = toolResults.get(message.id); + const durationMs = result?.durationMs ?? 0; + const ts = result?.ts ?? message.ts; + tools.push({ + id: `tool:${message.id}`, + sessionId: session.id, + turnId: message.turnId, + toolCallId: message.id, + toolName: message.displayName ?? message.toolName, + providerId: session.llmConnectionSlug, + modelId: modelByTurn.get(message.turnId) ?? session.model, + durationMs, + status: result?.isError ? 'error' : 'success', + bytesIn: 0, + bytesOut: 0, + startedAt: message.ts, + date: new Date(ts).toISOString().slice(0, 10), + ts, + }); + } + } + } + return { llm, tools }; +} diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 32ac4b5c9e..fa92627da6 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -31,7 +31,6 @@ import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import { basename, join } from "node:path"; import { type ConnectionEvent } from '@maka/core/connections'; -import type { UsageRange } from '@maka/core/settings'; import { type SessionChangedEvent, type SessionChangedReason } from '@maka/core/session'; import { isBotDeliveryProvider } from '@maka/core/bot-chat-settings'; import { resolveSystemUiLocale } from '@maka/core/ui-locale'; @@ -201,9 +200,6 @@ import { import { resolveDesktopStorageRoot } from "./storage-root-startup.js"; import { startupStep } from "./startup-step.js"; import { registerWorkspaceSearchIpc } from "./workspace-search-ipc-main.js"; -import { - projectDesktopUsageStats, -} from "../shared/desktop-session-projection.js"; import { parseDesktopSessionResourceKey, requireDesktopTargetScope, @@ -302,7 +298,6 @@ if (!startupLocalStorageRoot) { await new Promise(() => {}); throw new Error("Desktop storage root resolution did not complete"); } -const localRuntimeHostId = startupLocalStorageRoot.rootId; const settingsStore = createSettingsStore(workspaceRoot); const desktopLocale = createDesktopLocaleAuthority({ readSettings: () => settingsStore.get(), @@ -1375,12 +1370,6 @@ function registerPersistentClientIpc(): void { } : {}), }); - ipcMain.handle("settings:usageStats", async (_event, range?: UsageRange) => - projectDesktopUsageStats( - { hostId: localRuntimeHostId }, - await settingsStore.usageStats(range), - ), - ); ipcMain.handle("sessions:unobserve", async (_event, observerId: unknown) => { if (typeof observerId !== "string" || observerId.length === 0 || observerId.length > 256) { throw new Error("Invalid Session observer identity"); 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 a950f033f2..69353f7617 100644 --- a/apps/desktop/src/main/runtime-host-usage-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-usage-ipc-main.ts @@ -17,16 +17,24 @@ * under the License. */ +import { resolveUsageRange } from "@maka/core/model-call-usage-projection"; import { tryResult } from "@maka/core/result"; +import type { UsageRange, UsageStats } from "@maka/core/settings"; import { normalizePricingConfig, normalizePricingModelKey, } from "@maka/core/usage-stats/pricing"; import type { PricingConfig, + TimeRange, UsageGroupBy, UsageQuery, } from "@maka/core/usage-stats/types"; +import { + USAGE_PAGE_MAX_ITEMS, + type LlmUsageLogProjection, + type ToolUsageLogProjection, +} from "@maka/runtime-host/protocol"; import { handleReconnectableRead, type ReconnectableReadIpcMain, @@ -40,7 +48,7 @@ interface RuntimeHostUsageIpcDeps { readonly sendToRenderer: (channel: string, ...args: unknown[]) => void; } -const PAGE_LIMIT = 100; +const MAX_ACTIVITY_RECORDS = 50_000; export function registerRuntimeHostUsageIpc( deps: RuntimeHostUsageIpcDeps, @@ -55,6 +63,12 @@ export function registerRuntimeHostUsageIpc( return result; }; + handleReconnectableRead( + deps.ipcMain, + "settings:usageStats", + (_event, range: UsageRange = "24h") => + loadUsageStats(deps.client, normalizeUsageRange(range)), + ); handleReconnectableRead( deps.ipcMain, "usage:summary", @@ -142,6 +156,215 @@ export function registerRuntimeHostUsageIpc( ); } +async function loadUsageStats( + client: DesktopRuntimeHostClient, + range: UsageRange, +): Promise { + const query = { range: resolveUsageRange(range, Date.now()) } satisfies UsageQuery; + const [summaryResult, llmLogs, toolLogs, pricing] = await Promise.all([ + client.queryUsage({ kind: "summary", query }), + loadAllLogs(client, "llm", query), + loadAllLogs(client, "tool", query), + client.loadPricingSnapshot(), + ]); + if (summaryResult.kind !== "summary") throw invalidUsageProjection(); + if ( + summaryResult.summary.totalRequests !== llmLogs.length || + llmLogs.length + toolLogs.length > MAX_ACTIVITY_RECORDS + ) { + throw invalidUsageProjection(); + } + + return { + summary: { + totalRequests: summaryResult.summary.totalRequests, + totalCostUsd: summaryResult.summary.totalCostUsd, + totalTokens: summaryResult.summary.totalTokens.total, + inputTokens: summaryResult.summary.totalTokens.input, + outputTokens: summaryResult.summary.totalTokens.output, + cacheTokens: + summaryResult.summary.totalTokens.cacheRead + + summaryResult.summary.totalTokens.cacheWrite, + cacheMiss: summaryResult.summary.totalTokens.cacheMiss, + cacheRead: summaryResult.summary.totalTokens.cacheRead, + cacheCreation: summaryResult.summary.totalTokens.cacheWrite, + reasoning: summaryResult.summary.totalTokens.reasoning, + }, + logs: [...llmLogs.map(projectLlmLog), ...toolLogs.map(projectToolLog)].sort( + (left, right) => right.ts - left.ts, + ), + byProvider: aggregateModelLogs(llmLogs, "provider"), + byModel: aggregateModelLogs(llmLogs, "model"), + byTool: aggregateToolLogs(toolLogs), + pricing: pricing.entries + .filter((entry) => entry.source === "custom") + .map(({ pricing: entry }) => projectPricing(entry)) + .sort( + (left, right) => + left.provider.localeCompare(right.provider) || left.model.localeCompare(right.model), + ), + }; +} + +async function loadAllLogs( + client: DesktopRuntimeHostClient, + source: "llm", + query: UsageQuery & { range: TimeRange }, +): Promise; +async function loadAllLogs( + client: DesktopRuntimeHostClient, + source: "tool", + query: UsageQuery & { range: TimeRange }, +): Promise; +async function loadAllLogs( + client: DesktopRuntimeHostClient, + source: "llm" | "tool", + query: UsageQuery & { range: TimeRange }, +): Promise> { + const rows: Array = []; + let offset = 0; + let total: number | undefined; + while (true) { + const result = await client.queryUsage( + source === "llm" + ? { + kind: "logs", + source, + query: toLlmQuery(query), + offset, + limit: USAGE_PAGE_MAX_ITEMS, + } + : { + kind: "logs", + source, + query: toToolQuery(query), + offset, + limit: USAGE_PAGE_MAX_ITEMS, + }, + ); + if (result.kind !== "logs" || result.source !== source || result.offset !== offset) { + throw invalidUsageProjection(); + } + total ??= result.total; + if (result.total !== total) throw invalidUsageProjection(); + rows.push(...result.rows); + if (rows.length > MAX_ACTIVITY_RECORDS || rows.length > result.total) { + throw invalidUsageProjection(); + } + if (result.nextOffset === null) { + if (rows.length !== total) throw invalidUsageProjection(); + return rows; + } + if (result.nextOffset <= offset) throw invalidUsageProjection(); + offset = result.nextOffset; + } +} + +function projectLlmLog(row: LlmUsageLogProjection): UsageStats["logs"][number] { + return { + id: row.id, + ts: row.ts, + kind: "model", + ...(row.sessionId === undefined ? {} : { sessionId: row.sessionId }), + ...(row.turnId === undefined ? {} : { turnId: row.turnId }), + provider: row.providerId, + model: row.modelId, + inputTokens: row.inputTokens, + outputTokens: row.outputTokens, + cacheMiss: row.cacheMissTokens, + cacheRead: row.cacheReadTokens, + cacheCreation: row.cacheWriteTokens, + reasoning: row.reasoningTokens, + ...(row.costUsd === undefined ? {} : { costUsd: row.costUsd }), + latencyMs: row.latencyMs, + status: row.status, + }; +} + +function projectToolLog(row: ToolUsageLogProjection): UsageStats["logs"][number] { + return { + id: row.id, + ts: row.ts, + kind: "tool", + ...(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, + }; +} + +function aggregateModelLogs( + logs: readonly LlmUsageLogProjection[], + key: "provider", +): UsageStats["byProvider"]; +function aggregateModelLogs( + logs: readonly LlmUsageLogProjection[], + key: "model", +): UsageStats["byModel"]; +function aggregateModelLogs( + logs: readonly LlmUsageLogProjection[], + key: "provider" | "model", +): UsageStats["byProvider"] | UsageStats["byModel"] { + const rows = new Map(); + for (const log of logs) { + const id = key === "provider" ? log.providerId : log.modelId; + const current = rows.get(id) ?? { requests: 0, tokens: 0, costUsd: 0 }; + current.requests += 1; + current.tokens += log.inputTokens + log.outputTokens; + current.costUsd += log.costUsd ?? 0; + rows.set(id, current); + } + return [...rows.entries()] + .map(([id, row]) => ({ [key]: id, ...row })) + .sort((left, right) => right.requests - left.requests) as + | UsageStats["byProvider"] + | UsageStats["byModel"]; +} + +function aggregateToolLogs(logs: readonly ToolUsageLogProjection[]): UsageStats["byTool"] { + const rows = new Map< + string, + { calls: number; success: number; errors: number; totalDurationMs: number } + >(); + for (const log of logs) { + const current = rows.get(log.toolName) ?? { + calls: 0, + success: 0, + errors: 0, + totalDurationMs: 0, + }; + current.calls += 1; + if (log.status === "success") current.success += 1; + if (log.status === "error") current.errors += 1; + current.totalDurationMs += log.durationMs; + rows.set(log.toolName, current); + } + return [...rows.entries()] + .map(([tool, row]) => ({ + tool, + calls: row.calls, + success: row.success, + errors: row.errors, + avgDurationMs: row.calls === 0 ? 0 : Math.round(row.totalDurationMs / row.calls), + })) + .sort((left, right) => right.calls - left.calls || left.tool.localeCompare(right.tool)); +} + +function projectPricing(pricing: PricingConfig): UsageStats["pricing"][number] { + const separator = pricing.modelKey.indexOf(":"); + return { + provider: separator < 0 ? "" : pricing.modelKey.slice(0, separator), + model: separator < 0 ? pricing.modelKey : pricing.modelKey.slice(separator + 1), + inputPerMTokUsd: pricing.inputUsdPer1M, + outputPerMTokUsd: pricing.outputUsdPer1M, + }; +} + async function loadAllBuckets( client: DesktopRuntimeHostClient, query: UsageQuery & { groupBy: UsageGroupBy }, @@ -156,14 +379,14 @@ async function loadAllBuckets( query: toToolQuery(query), groupBy: "tool", offset, - limit: PAGE_LIMIT, + limit: USAGE_PAGE_MAX_ITEMS, } : { kind: "buckets", query: toLlmQuery(query), groupBy: query.groupBy, offset, - limit: PAGE_LIMIT, + limit: USAGE_PAGE_MAX_ITEMS, }, ); if (result.kind !== "buckets" || result.offset !== offset) @@ -180,6 +403,12 @@ function toLlmQuery(query: UsageQuery) { return llmQuery; } +function normalizeUsageRange(range: unknown): UsageRange { + return range === "24h" || range === "7d" || range === "30d" || range === "all" + ? range + : "24h"; +} + function toToolQuery(query: UsageQuery) { return { range: query.range, diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index d478543721..5ac3286979 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1023,7 +1023,7 @@ export interface MakaBridge { subscribeExternalChanged(handler: () => void, host?: DesktopRuntimeHostRef): () => void; testNetworkProxy(input?: TestProxyInput, host?: DesktopRuntimeHostRef): Promise; testBotChannel(provider: BotProvider): Promise; - usageStats(range?: UsageRange): Promise; + usageStats(range?: UsageRange, host?: DesktopRuntimeHostRef): Promise; bots: { listStatuses(): Promise>; restart(provider: BotProvider): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 656472bf20..414a5b7e0e 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -227,6 +227,7 @@ import { projectDesktopSessionEvent, projectDesktopSessionSummary, projectDesktopTurnRecord, + projectDesktopUsageStats, type DesktopSessionSummary, } from '../shared/desktop-session-projection.js'; @@ -2626,8 +2627,10 @@ const makaBridge = { testBotChannel(provider: BotProvider): Promise { return ipcRenderer.invoke('settings:testBotChannel', provider); }, - usageStats(range?: UsageRange): Promise { - return ipcRenderer.invoke('settings:usageStats', range); + async usageStats(range?: UsageRange, host?: DesktopRuntimeHostRef): Promise { + const scope = await selectedRuntimeHostScope(host); + const stats = await ipcRenderer.invoke('settings:usageStats', scope, range) as UsageStats; + return projectDesktopUsageStats(scope, stats); }, bots: { listStatuses(): Promise> { diff --git a/apps/desktop/src/renderer/locales/settings-usage-copy.ts b/apps/desktop/src/renderer/locales/settings-usage-copy.ts index 5d26a59edc..ebf4efff53 100644 --- a/apps/desktop/src/renderer/locales/settings-usage-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-usage-copy.ts @@ -24,12 +24,12 @@ export type UsageSettingsCopy = { 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; 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; unknown: string; openSession(label: string): string; success: string; error: string; aborted: string; providerEmptyTitle: string; providerEmptyBody: string; modelEmptyTitle: string; modelEmptyBody: string; toolEmptyTitle: string; toolEmptyBody: string; pricingEmptyBody: string; }; @@ -38,40 +38,40 @@ export type UsageSettingsCopy = { const SETTINGS_USAGE_COPY = { zh: { saveFailed: '保存使用统计设置失败', toolbarAria: '使用统计范围与刷新', rangeAria: '使用统计时间范围', ranges: ['24h', '7天', '30天', '全部'], - refreshingAria: '正在刷新使用统计', refreshAria: '刷新使用统计', summaryAria: '使用统计汇总指标', totalRequests: '总请求', totalCost: '总费用', costHelp: '以模型供应商最终结算为准', + refreshingAria: '正在刷新使用统计', refreshAria: '刷新使用统计', summaryAria: '使用统计汇总指标', totalRequests: '模型调用', totalCost: '总费用', costHelp: '以模型供应商最终结算为准', totalTokens: '总 Token', tokenDetail: (input, output) => `输入 ${input} / 输出 ${output}`, cacheTokens: '缓存 Token', - cacheDetail: (miss, read, creation) => `新 ${miss} / 命中 ${read} / 创建 ${creation}`, viewAria: '使用统计视图', tabs: ['请求日志', '供应商统计', '模型统计', '工具统计', '定价配置'], - filtersAria: '请求记录筛选', filterPlaceholder: '按模型或工具筛选…', filterAria: '按模型或工具筛选请求记录', statusAria: '请求状态筛选', - statuses: ['全部状态', '成功', '错误'], details: '详情记录', detailsAria: '显示使用统计详情记录', recordCount: (count) => `共 ${count} 条记录`, clearFilters: '清除筛选', - summaryOnly: '当前仅显示汇总指标。打开详情记录后,可以查看逐条模型请求和工具调用,按模型、工具或状态筛选,并用于排查费用与失败请求。', - showDetails: '显示明细', filteredEmpty: '没有符合筛选条件的请求记录', filteredEmptyHelp: '调整或清除筛选条件后可查看全部请求记录。', requestEmpty: '暂无请求记录', + cacheDetail: (miss, read, creation) => `新 ${miss} / 命中 ${read} / 创建 ${creation}`, viewAria: '使用统计视图', tabs: ['活动记录', '供应商统计', '模型统计', '工具统计', '定价配置'], + filtersAria: '活动记录筛选', filterPlaceholder: '按模型或工具筛选…', filterAria: '按模型或工具筛选活动记录', statusAria: '活动状态筛选', + statuses: ['全部状态', '成功', '错误', '已中止'], details: '详情记录', detailsAria: '显示使用统计详情记录', recordCount: (count) => `共 ${count} 条记录`, clearFilters: '清除筛选', + summaryOnly: '当前仅显示汇总指标。打开详情记录后,可以查看逐条模型调用和工具调用,按模型、工具或状态筛选,并用于排查费用与失败调用。', + showDetails: '显示明细', filteredEmpty: '没有符合筛选条件的活动记录', filteredEmptyHelp: '调整或清除筛选条件后可查看全部活动记录。', requestEmpty: '暂无活动记录', tables: { - providersAria: '使用统计供应商统计表', modelsAria: '使用统计模型统计表', toolsAria: '使用统计工具统计表', pricingAria: '使用统计定价配置表', requestsAria: '使用统计请求日志表', - providerHeaders: ['供应商', '请求', 'Token', '费用'], modelHeaders: ['模型', '请求', 'Token', '费用'], toolHeaders: ['工具', '调用', '成功', '错误', '平均耗时'], + providersAria: '使用统计供应商统计表', modelsAria: '使用统计模型统计表', toolsAria: '使用统计工具统计表', pricingAria: '使用统计定价配置表', requestsAria: '使用统计活动记录表', + providerHeaders: ['供应商', '调用', 'Token', '费用'], modelHeaders: ['模型', '调用', 'Token', '费用'], toolHeaders: ['工具', '调用', '成功', '错误', '平均耗时'], pricingHeaders: ['供应商', '模型', '输入 / 1M', '输出 / 1M'], requestHeaders: ['时间', '类型', '对象', '任务', 'Token', '费用', '延迟', '状态'], - noPricing: '暂无定价覆盖配置', modelKind: '模型', toolKind: '工具', openSession: (label) => `打开 ${label}`, success: '成功', error: '错误', - providerEmptyTitle: '暂无供应商用量', providerEmptyBody: '完成一次模型请求后,这里会按供应商聚合请求数、Token 与费用。', - modelEmptyTitle: '暂无模型用量', modelEmptyBody: '完成一次模型请求后,这里会按模型聚合请求数、Token 与费用。', + noPricing: '暂无定价覆盖配置', modelKind: '模型', toolKind: '工具', unknown: '未知', openSession: (label) => `打开 ${label}`, success: '成功', error: '错误', aborted: '已中止', + providerEmptyTitle: '暂无供应商用量', providerEmptyBody: '完成一次模型调用后,这里会按供应商聚合调用数、Token 与费用。', + modelEmptyTitle: '暂无模型用量', modelEmptyBody: '完成一次模型调用后,这里会按模型聚合调用数、Token 与费用。', toolEmptyTitle: '暂无工具调用', toolEmptyBody: '智能体调用工具后,这里会按工具聚合调用次数、成功、错误与平均耗时。', pricingEmptyBody: '未配置定价覆盖时,费用按内置模型定价表结算;在此可为特定模型登记自定义价格。', }, }, en: { saveFailed: 'Failed to save usage settings', toolbarAria: 'Usage range and refresh', rangeAria: 'Usage time range', ranges: ['24h', '7 days', '30 days', 'All'], - 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', + refreshingAria: 'Refreshing usage', refreshAria: 'Refresh usage', summaryAria: 'Usage summary metrics', totalRequests: 'Model calls', 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'], - 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', - 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', + cacheDetail: (miss, read, creation) => `New ${miss} / hit ${read} / created ${creation}`, viewAria: 'Usage view', tabs: ['Activity log', 'Providers', 'Models', 'Tools', 'Pricing'], + filtersAria: 'Activity filters', filterPlaceholder: 'Filter by model or tool…', filterAria: 'Filter activity by model or tool', statusAria: 'Filter by activity status', + 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 calls and tool calls, filter by model, tool, or status, and investigate costs or failures.', + showDetails: 'Show details', filteredEmpty: 'No activity matches these filters', filteredEmptyHelp: 'Adjust or clear the filters to see all activity records.', requestEmpty: 'No activity 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'], + providersAria: 'Usage by provider', modelsAria: 'Usage by model', toolsAria: 'Usage by tool', pricingAria: 'Usage pricing configuration', requestsAria: 'Usage activity log', + providerHeaders: ['Provider', 'Calls', 'Tokens', 'Cost'], modelHeaders: ['Model', 'Calls', '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', - 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.', + noPricing: 'No pricing overrides', modelKind: 'Model', toolKind: 'Tool', unknown: 'Unknown', openSession: (label) => `Open ${label}`, success: 'Success', error: 'Error', aborted: 'Aborted', + providerEmptyTitle: 'No provider usage', providerEmptyBody: 'After a model call, provider call counts, tokens, and costs appear here.', + modelEmptyTitle: 'No model usage', modelEmptyBody: 'After a model call, call 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.', pricingEmptyBody: 'Without pricing overrides, costs use the built-in model pricing table. Add custom prices here for specific models.', }, diff --git a/apps/desktop/src/renderer/settings/settings-nav.ts b/apps/desktop/src/renderer/settings/settings-nav.ts index 79dfee5a06..7c949a2a88 100644 --- a/apps/desktop/src/renderer/settings/settings-nav.ts +++ b/apps/desktop/src/renderer/settings/settings-nav.ts @@ -110,7 +110,7 @@ const SETTINGS_SECTION_SCOPES: Record< memory: 'runtime-host', 'bot-chat': 'client', search: 'runtime-host', - usage: 'client', + usage: 'runtime-host', 'archived-tasks': 'client', 'import-tasks': 'runtime-host', 'daily-review': 'runtime-host', diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx index 6ac34ebe35..96feeb2f03 100644 --- a/apps/desktop/src/renderer/settings/settings-surface.tsx +++ b/apps/desktop/src/renderer/settings/settings-surface.tsx @@ -294,7 +294,11 @@ export function SettingsSurface(props: { const defaultRuntimeHostProfileIdRef = useRef( initialRuntimeHostCatalog?.defaultProfileId, ); - const [usageStats, setUsageStats] = useState(null); + const [usageStats, setUsageStats] = useState<{ + hostKey: string; + range: UsageRange; + value: UsageStats; + } | null>(null); const [clientLoading, setClientLoading] = useState(initialClientSettings === undefined); const settingsModalMountedRef = useMountedRef(); const clientSettingsTicketRef = useRef(0); @@ -344,6 +348,8 @@ export function SettingsSurface(props: { const selectedRuntimeHostKey = selectedRuntimeHost ? runtimeHostSettingsKey(selectedRuntimeHost) : undefined; + const selectedRuntimeHostKeyRef = useRef(selectedRuntimeHostKey); + selectedRuntimeHostKeyRef.current = selectedRuntimeHostKey; function commitSelectedRuntimeHostProfile( profileId: string, snapshot = runtimeHosts, @@ -353,10 +359,14 @@ export function SettingsSurface(props: { const nextKey = nextHost ? runtimeHostSettingsKey(nextHost) : undefined; // Reject old-Host reads and writes synchronously with the authority // change, before React renders the newly selected profile. - runtimeHostRequestAuthority.selectTarget( + const targetChanged = runtimeHostRequestAuthority.selectTarget( nextKey, lifecycle?.epoch, ); + if (targetChanged) { + usageReloadTicketRef.current += 1; + setUsageStats(null); + } selectedProfileIdRef.current = profileId; setSelectedProfileId(profileId); } @@ -603,15 +613,30 @@ export function SettingsSurface(props: { } async function reloadUsage(range: UsageRange = settings.usage.range) { + const host = selectedRuntimeHost; + if (!host) { + usageReloadTicketRef.current += 1; + setUsageStats(null); + return; + } + const hostKey = runtimeHostSettingsKey(host); const ticket = usageReloadTicketRef.current + 1; usageReloadTicketRef.current = ticket; try { - const next = await window.maka.settings.usageStats(range); - if (settingsModalMountedRef.current && ticket === usageReloadTicketRef.current) { - setUsageStats(next); + const next = await window.maka.settings.usageStats(range, host); + if ( + settingsModalMountedRef.current && + ticket === usageReloadTicketRef.current && + selectedRuntimeHostKeyRef.current === hostKey + ) { + setUsageStats({ hostKey, range, value: next }); } } catch (error) { - if (settingsModalMountedRef.current && ticket === usageReloadTicketRef.current) { + if ( + settingsModalMountedRef.current && + ticket === usageReloadTicketRef.current && + selectedRuntimeHostKeyRef.current === hostKey + ) { toast.error(copy.usageLoadFailed, settingsActionErrorMessage(error, locale)); } } @@ -682,6 +707,8 @@ export function SettingsSurface(props: { // Fence synchronously, before the catalog refresh can resolve. The // previous generation's snapshots stay visible but no Host-backed // control may treat them as current write authority. + usageReloadTicketRef.current += 1; + setUsageStats(null); setRuntimeHostCatalog(invalidateSettingsResourceGeneration); setRuntimeHostSettings(invalidateSettingsResourceGeneration); setRuntimeHostConnections(invalidateSettingsResourceGeneration); @@ -747,18 +774,11 @@ export function SettingsSurface(props: { }, [connectionsBridge, selectedRuntimeHost]); useEffect(() => { - // Keyed on the EFFECTIVE range, not just the section: usage is - // client-owned (settings-ownership.ts), and the persisted range rides - // in with the async getClient() load — which lands after this effect - // first fires when a Settings window is restored directly onto - // 使用统计. The initial fetch then used the '24h' default while the - // chip showed the persisted range, and nothing refetched — every - // metric read 0 until a manual refresh or a tab round-trip. With the - // range in the deps, the truth's arrival (or any later range change, - // including the page's own persisted range clicks) is the trigger, and - // this effect is the single owner of range-driven fetches. + // Usage records are Host-owned while the display preferences remain + // client-owned. Refetch when either the persisted range arrives or the + // selected Host changes so labels and numbers always describe one Host. if (section === 'usage') void reloadUsage(settings.usage.range); - }, [section, settings.usage.range]); + }, [section, settings.usage.range, selectedRuntimeHostKey]); // PR-SETTINGS-HEADER-COPY-MAP-0 (U1): the page header derives its title // and description from the section→copy map keyed by the active section, @@ -964,7 +984,13 @@ export function SettingsSurface(props: { normalizedModelFilter.length === 0 || log.model.toLowerCase().includes(normalizedModelFilter) || + log.provider.toLowerCase().includes(normalizedModelFilter) || (log.toolName ?? '').toLowerCase().includes(normalizedModelFilter) ); }, [stats, usageDraft.status, normalizedModelFilter]); @@ -296,6 +297,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'])} @@ -339,8 +341,8 @@ function UsageRequestsPanel(props: { 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` : '-', + row.kind === 'model' && row.costUsd !== undefined ? `$${row.costUsd.toFixed(2)}` : '-', + row.latencyMs !== undefined ? `${row.latencyMs}ms` : '-', usageRequestStatusLabel(row.status, props.copy), ])} empty={{ @@ -438,14 +440,16 @@ function usageRequestKindLabel(kind: UsageStats['logs'][number]['kind'], copy: U } function usageRequestTarget(row: UsageStats['logs'][number]) { - return row.kind === 'tool' ? row.toolName ?? row.model : row.model; + return row.kind === 'tool' ? row.toolName || row.model || row.provider || '-' : row.model || row.provider || '-'; } function usageRequestSessionCell(row: UsageStats['logs'][number], copy: UsageSettingsCopy, onOpenSession?: (sessionId: string) => void) { - const label = shortUsageSessionId(row.sessionId); + if (!row.sessionId) return copy.tables.unknown; + const sessionId = row.sessionId; + const label = shortUsageSessionId(sessionId); if (!onOpenSession) return label; return ( -