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..2ab121f450 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,13 @@ 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 { EMPTY_USAGE_PROVENANCE } from '@maka/core/usage-ledger-merge'; import { projectDesktopSessionEvent, projectDesktopSessionSummary, projectDesktopTurnRecord, + projectDesktopUsageStats, } from '../../shared/desktop-session-projection.js'; test('keeps equal raw Session ids distinct across Runtime Hosts', () => { @@ -145,6 +148,57 @@ 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: [], + provenance: EMPTY_USAGE_PROVENANCE, + }; + + 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..6199baa55b --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts @@ -0,0 +1,500 @@ +/* + * 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, + }, + ]); + // The canonical summary provenance is carried through so the page can qualify + // a cost that reads low; the full range fit under the cap, so not truncated. + assert.deepEqual(stats.provenance, provenance()); + assert.equal(stats.logsTruncated, undefined); +}); + +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 degrade instead of erroring when logs 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); + // A catch-up race (summary read before a repair commits, logs read after) must + // not error the whole page. The canonical summary total stays authoritative, + // the activity list holds what actually loaded, and provenance still rides along. + const stats = await handler({} as never, "all") as UsageStats; + assert.equal(stats.summary.totalRequests, 2); + assert.equal(stats.logs.filter((row) => row.kind === "model").length, 1); + assert.deepEqual(stats.provenance, provenance()); +}); + +test("settings usage stats group the provider breakdown by connection", 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"); + // Two connections to the SAME provider type must stay two rows. + return input.source === "llm" + ? ({ + kind: "logs", + source: "llm", + rows: [ + { ...llmRow(0), connectionSlug: "conn-a", providerId: "provider-x" }, + { ...llmRow(1), connectionSlug: "conn-b", providerId: "provider-x" }, + ], + offset: 0, + total: 2, + 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); + const stats = await handler({} as never, "all") as UsageStats; + assert.deepEqual( + stats.byProvider.map((row) => row.provider).sort(), + ["conn-a", "conn-b"], + ); +}); + +test("settings usage stats truncate the activity log at the cap instead of erroring", async () => { + const handlers = new Map(); + const PAGE = 100; + // Above MAX_ACTIVITY_RECORDS (50_000) so paging must stop and flag truncation. + const TOTAL = 50_150; + 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: TOTAL, + 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"); + if (input.source === "llm") { + const offset = input.offset ?? 0; + const count = Math.min(PAGE, TOTAL - offset); + const nextOffset = offset + count < TOTAL ? offset + count : null; + return { + kind: "logs", + source: "llm", + rows: Array.from({ length: count }, (_, index) => llmRow(offset + index)), + offset, + total: TOTAL, + nextOffset, + provenance: provenance(), + } satisfies UsageQueryResult; + } + return { + 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); + const stats = await handler({} as never, "all") as UsageStats; + assert.equal(stats.logsTruncated, true); + assert.equal(stats.logs.filter((row) => row.kind === "model").length, 50_000); +}); + +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..b8643cab17 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -20,9 +20,15 @@ import { mkdir, rm } from 'node:fs/promises'; import { join } from 'node:path'; import type { E2eFixtureScenario, E2eFixtureState } from '@maka/core/e2e-fixture'; +import { MODEL_CALL_ATTEMPT_EVENT_TYPE } from '@maka/core/model-call-attempt'; import type { UiLocale } from '@maka/core/ui-locale'; +import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; 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 +52,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 +217,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 +248,41 @@ 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); + // The AgentRun store and the interactive usage stores share one refcounted + // operational-state DB keyed by the lease's resolved path, so the canonical + // model-call events written here are visible to `catchUpModelCallProjection` + // below. It MUST be the lease's canonicalPath, not the raw workspaceRoot — + // a /var vs /private/var realpath difference would open a different DB. + const runStore = createSqliteAgentRunStore(owner.lease.canonicalPath); + try { + const records = usageStatsRecords(now); + // Model calls seed the CANONICAL ledger through the AgentRun event stream; + // tools stay on the legacy telemetry table (there is no canonical tool + // ledger). This is what actually exercises the canonical merge branch. + for (const { header: runHeader, attempt } of records.modelCalls) { + await runStore.createRun(runHeader); + await runStore.appendEvent(attempt.sessionId, attempt.runId, { + id: attempt.attemptId, + type: MODEL_CALL_ATTEMPT_EVENT_TYPE, + ts: attempt.completedAt, + sessionId: attempt.sessionId, + runId: attempt.runId, + turnId: attempt.turnId, + data: { ...attempt }, + }); + } + for (const record of records.tools) await usage.telemetry.recordToolInvocation(record); + await runStore.close?.(); + // Fold the appended attempts into the read model so the page's first read + // sees canonical usage (production's readCanonicalUsage also repairs). + await usage.modelCalls.catchUpModelCallProjection(); + 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..14af6b6249 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-usage.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-usage.ts @@ -17,21 +17,33 @@ * under the License. */ +import type { AgentRunHeader } from '@maka/core/agent-run'; +import { + MODEL_CALL_ATTEMPT_SCHEMA_VERSION, + type ModelCallAttempt, +} from '@maka/core/model-call-attempt'; 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 PersistedToolInvocationRecord = Parameters< + TelemetryIndexWriter['recordToolInvocation'] +>[0]; + +// Settings → 使用统计 fixture. Session messages keep the task links realistic, +// while `usageStatsRecords` seeds the two Host-owned surfaces the page reads: +// model calls go through the CANONICAL model-call ledger (AgentRun +// `model_call_attempt_recorded` events, projected by +// `catchUpModelCallProjection`), and tool invocations stay on the legacy +// telemetry table (tools have no canonical ledger). 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) → 供应商统计 +// - 3 connections (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 +227,101 @@ export function usageStatsSessions( }, ]; } + +export function usageStatsRecords(now: number): { + modelCalls: Array<{ header: AgentRunHeader; attempt: ModelCallAttempt }>; + tools: PersistedToolInvocationRecord[]; +} { + const sessions = usageStatsSessions(now); + const modelCalls: Array<{ header: AgentRunHeader; attempt: ModelCallAttempt }> = []; + 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; + const modelId = modelByTurn.get(message.turnId) ?? session.model; + // Run/attempt ids must match SAFE_ID_PATTERN ([A-Za-z0-9_-]); no colons. + const runId = `run-${message.id}`; + modelCalls.push({ + header: { + runId, + sessionId: session.id, + turnId: message.turnId, + status: 'created', + backendKind: 'fake', + llmConnectionSlug: session.llmConnectionSlug, + modelId, + cwd: '/tmp/e2e-usage', + permissionMode: 'ask', + createdAt: message.ts - 2_000, + updatedAt: message.ts, + }, + attempt: { + schemaVersion: MODEL_CALL_ATTEMPT_SCHEMA_VERSION, + logicalCallId: message.id, + attemptId: message.id, + traceId: message.turnId, + sessionId: session.id, + runId, + turnId: message.turnId, + step: 0, + attempt: 0, + callKind: 'main', + connectionSlug: session.llmConnectionSlug, + providerId: session.llmConnectionSlug, + modelId, + startedAt: message.ts - 2_000, + completedAt: message.ts, + latencyMs: 2_000, + status: 'completed', + usageBasis: 'reported', + inputTokens, + outputTokens, + cacheReadInputTokens: cacheRead, + cacheMissInputTokens: cacheMiss, + cacheWriteInputTokens: cacheWrite, + reasoningTokens: message.reasoning ?? 0, + costBasis: 'priced', + costUsd: message.costUsd ?? 0, + }, + }); + } + 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 { modelCalls, tools }; +} diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index b9a3771e17..2ff8b49ffd 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -31,7 +31,6 @@ import { import { randomUUID } from "node:crypto"; 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'; @@ -200,9 +199,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, @@ -301,7 +297,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(), @@ -1369,12 +1364,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..91a162a0ad 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,234 @@ export function registerRuntimeHostUsageIpc( ); } +async function loadUsageStats( + client: DesktopRuntimeHostClient, + range: UsageRange, +): Promise { + const query = { range: resolveUsageRange(range, Date.now()) } satisfies UsageQuery; + const [summaryResult, llmResult, toolResult, 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(); + const llmLogs = llmResult.rows; + const toolLogs = toolResult.rows; + const logsTruncated = llmResult.truncated || toolResult.truncated; + // The canonical summary is the authoritative headline count. We no longer + // throw when it disagrees with the number of activity rows we managed to + // load: a Host restart with pending repairs can make the summary read land + // before a catch-up commits and the logs read land after, and truncation + // (above) deliberately shortens the list. Either way the summary total stays + // correct; `provenance`/`logsTruncated` tell the page the activity list may + // be incomplete instead of erroring the whole page. + + 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), + ), + provenance: summaryResult.provenance, + ...(logsTruncated ? { logsTruncated: true } : {}), + }; +} + +async function loadAllLogs( + client: DesktopRuntimeHostClient, + source: "llm", + query: UsageQuery & { range: TimeRange }, +): Promise<{ rows: LlmUsageLogProjection[]; truncated: boolean }>; +async function loadAllLogs( + client: DesktopRuntimeHostClient, + source: "tool", + query: UsageQuery & { range: TimeRange }, +): Promise<{ rows: ToolUsageLogProjection[]; truncated: boolean }>; +async function loadAllLogs( + client: DesktopRuntimeHostClient, + source: "llm" | "tool", + query: UsageQuery & { range: TimeRange }, +): Promise<{ + rows: Array; + truncated: boolean; +}> { + 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); + // Structural integrity: the Host must never return more rows than it claims. + if (rows.length > total) throw invalidUsageProjection(); + // Client-side cap: when a range holds more activity than we render, keep the + // newest MAX_ACTIVITY_RECORDS and stop paging. This is truncation, not a + // protocol error, and the exhaustiveness check below is skipped for it — the + // caller surfaces `logsTruncated` so the page can say the list is partial. + if (total > MAX_ACTIVITY_RECORDS && rows.length >= MAX_ACTIVITY_RECORDS) { + return { rows: rows.slice(0, MAX_ACTIVITY_RECORDS), truncated: true }; + } + if (result.nextOffset === null) { + if (rows.length !== total) throw invalidUsageProjection(); + return { rows, truncated: false }; + } + 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) { + // Provider breakdown keys on the connection the user configured, not the + // raw provider type: two connections to the same provider are two rows, not + // one collapsed row. `connectionSlug` is optional on pre-cutover rows, so + // fall back to the provider id. + const id = key === "provider" ? (log.connectionSlug ?? 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 +398,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 +422,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 1eb3a0e72a..8703523bf1 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1160,7 +1160,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 45a6fc19ca..b321fc035d 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -236,6 +236,7 @@ import { projectDesktopSessionEvent, projectDesktopSessionSummary, projectDesktopTurnRecord, + projectDesktopUsageStats, type DesktopSessionSummary, } from '../shared/desktop-session-projection.js'; @@ -2713,8 +2714,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 303a86963a..f5160b4e2c 100644 --- a/apps/desktop/src/renderer/locales/settings-usage-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-usage-copy.ts @@ -24,12 +24,13 @@ 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; + costUnavailable: string; incompleteTitle: string; incompleteBody: 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; untitledSession: string; success: string; error: string; + noPricing: string; modelKind: string; toolKind: string; unknown: string; untitledSession: 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 +39,44 @@ 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: '暂无活动记录', + costUnavailable: '费用未知', incompleteTitle: '统计可能不完整', + incompleteBody: '部分记录未能读取、尚未纳入统计或超出展示上限,实际用量可能高于此处显示。', 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}」`, untitledSession: '未命名会话', success: '成功', error: '错误', - providerEmptyTitle: '暂无供应商用量', providerEmptyBody: '完成一次模型请求后,这里会按供应商聚合请求数、Token 与费用。', - modelEmptyTitle: '暂无模型用量', modelEmptyBody: '完成一次模型请求后,这里会按模型聚合请求数、Token 与费用。', + noPricing: '暂无定价覆盖配置', modelKind: '模型', toolKind: '工具', unknown: '未知', untitledSession: '未命名会话', 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', + costUnavailable: 'Cost unavailable', incompleteTitle: 'These numbers may be incomplete', + incompleteBody: 'Some records could not be read, are not folded in yet, or exceed the display limit, so real usage may be higher than shown.', 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 session "${label}"`, untitledSession: 'Untitled session', 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', untitledSession: 'Untitled session', openSession: (label) => `Open session "${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 9d261aea79..d5003d0e32 100644 --- a/apps/desktop/src/renderer/settings/settings-surface.tsx +++ b/apps/desktop/src/renderer/settings/settings-surface.tsx @@ -294,7 +294,12 @@ export function SettingsSurface(props: { const defaultRuntimeHostProfileIdRef = useRef( initialRuntimeHostCatalog?.defaultProfileId, ); - const [usageStats, setUsageStats] = useState(null); + const [usageStats, setUsageStats] = useState<{ + hostKey: string; + epoch: string | undefined; + range: UsageRange; + value: UsageStats; + } | null>(null); const [clientLoading, setClientLoading] = useState(initialClientSettings === undefined); const settingsModalMountedRef = useMountedRef(); const clientSettingsTicketRef = useRef(0); @@ -344,6 +349,16 @@ export function SettingsSurface(props: { const selectedRuntimeHostKey = selectedRuntimeHost ? runtimeHostSettingsKey(selectedRuntimeHost) : undefined; + const selectedRuntimeHostKeyRef = useRef(selectedRuntimeHostKey); + selectedRuntimeHostKeyRef.current = selectedRuntimeHostKey; + // A same-key Host can be replaced in place (hostId stable, epoch bumped) on + // reconnect. `runtimeHostSettingsKey` is epoch-free, so usage must key on the + // epoch too — otherwise a reconnect clears the page but never refetches. + const selectedRuntimeHostEpoch = selectedProfileId + ? runtimeHostLifecycleByProfile.get(selectedProfileId)?.epoch + : undefined; + const selectedRuntimeHostEpochRef = useRef(selectedRuntimeHostEpoch); + selectedRuntimeHostEpochRef.current = selectedRuntimeHostEpoch; function commitSelectedRuntimeHostProfile( profileId: string, snapshot = runtimeHosts, @@ -353,10 +368,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 +622,33 @@ 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 epoch = selectedRuntimeHostEpochRef.current; 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 && + selectedRuntimeHostEpochRef.current === epoch + ) { + setUsageStats({ hostKey, epoch, range, value: next }); } } catch (error) { - if (settingsModalMountedRef.current && ticket === usageReloadTicketRef.current) { + if ( + settingsModalMountedRef.current && + ticket === usageReloadTicketRef.current && + selectedRuntimeHostKeyRef.current === hostKey && + selectedRuntimeHostEpochRef.current === epoch + ) { toast.error(copy.usageLoadFailed, settingsActionErrorMessage(error, locale)); } } @@ -682,6 +719,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 +786,12 @@ 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 the persisted range arrives, the selected Host + // changes, or the selected Host is replaced in place (epoch bump) so labels + // and numbers always describe one live Host generation. if (section === 'usage') void reloadUsage(settings.usage.range); - }, [section, settings.usage.range]); + }, [section, settings.usage.range, selectedRuntimeHostKey, selectedRuntimeHostEpoch]); // 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 +997,14 @@ 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]); @@ -136,8 +138,32 @@ export function UsageSettingsPage(props: { void updateUsage({ status: 'all', modelFilter: '' }); } + // `stats == null` means "not loaded for this Host/range yet", which is not the + // same as a real zero — render an em dash so the cards do not fabricate 0s + // (mirrors the '-' the activity cost cell already uses for unknown values). + const usageIncomplete = + stats != null && (hasUnavailableUsage(stats.provenance) || stats.logsTruncated === true); + const totalCostDisplay = stats + ? (() => { + const cost = estimatedUsageCost(stats.provenance, stats.summary.totalCostUsd); + if (cost !== undefined) return `$${cost.toFixed(2)}`; + // No priced/legacy basis to trust: a genuinely empty range is $0.00, but + // a range that had spend we could not qualify reads as unavailable + // rather than a misleading $0.00. + return stats.summary.totalRequests === 0 ? '$0.00' : copy.costUnavailable; + })() + : '—'; + return ( + {usageIncomplete ? ( + + ) : null}
- - - - + + + +
@@ -297,6 +323,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'])} @@ -340,8 +367,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={{ @@ -439,10 +466,14 @@ 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) { + // Canonical activity rows can lack a session (e.g. an aborted call before a + // session was attached); show it as unknown rather than a blank link. + if (!row.sessionId) return copy.tables.unknown; + const sessionId = row.sessionId; const label = usageSessionDisplayLabel(row, copy); if (!onOpenSession) return label; return ( @@ -450,7 +481,7 @@ function usageRequestSessionCell(row: UsageStats['logs'][number], copy: UsageSet className="settingsUsageSessionCell" variant="ghost" size="sm" - onClick={() => onOpenSession(row.sessionId)} + onClick={() => onOpenSession(sessionId)} label={label} tooltip={copy.tables.openSession(label)} /> @@ -464,7 +495,7 @@ function usageRequestSessionCell(row: UsageStats['logs'][number], copy: UsageSet function usageSessionDisplayLabel(row: UsageStats['logs'][number], copy: UsageSettingsCopy) { const name = row.sessionName?.trim(); if (name) return name; - return `${copy.tables.untitledSession} · ${shortRealSessionId(row.sessionId)}`; + return `${copy.tables.untitledSession} · ${shortRealSessionId(row.sessionId ?? '')}`; } function shortRealSessionId(sessionKey: string) { @@ -483,6 +514,7 @@ function usageRequestStatusLabel(status: UsageStats['logs'][number]['status'], c switch (status) { case 'success': return copy.tables.success; case 'error': return copy.tables.error; + case 'aborted': return copy.tables.aborted; } } diff --git a/apps/desktop/src/shared/desktop-session-projection.ts b/apps/desktop/src/shared/desktop-session-projection.ts index c2c4b0a0a6..daf09d2b1e 100644 --- a/apps/desktop/src/shared/desktop-session-projection.ts +++ b/apps/desktop/src/shared/desktop-session-projection.ts @@ -240,7 +240,9 @@ export function projectDesktopUsageStats( ...stats, logs: stats.logs.map((log) => ({ ...log, - sessionId: projectSessionId(host, log.sessionId), + ...(log.sessionId === undefined + ? {} + : { sessionId: projectSessionId(host, log.sessionId) }), })), }; } diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index b3609bdd00..f60373cd3a 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -30,6 +30,7 @@ import type { UsageRange, UsageStats, } from '@maka/core/settings'; +import { EMPTY_USAGE_PROVENANCE } from '@maka/core/usage-ledger-merge'; import type { CapabilitySnapshot, CapabilitySnapshotCollection, @@ -203,7 +204,7 @@ function makeUsageLog(input: { kind: 'model' | 'tool'; model: string; toolName?: string; - status?: 'success' | 'error'; + status?: 'success' | 'error' | 'aborted'; minutesAgo: number; sessionName?: string; }): UsageStats['logs'][number] { @@ -245,8 +246,26 @@ const usageLogs: UsageStats['logs'] = [ // No sessionName → renders the "未命名会话 · " fallback. makeUsageLog({ id: '3', kind: 'model', model: 'glm-4.7', status: 'error', minutesAgo: 16 }), makeUsageLog({ id: '4', kind: 'tool', model: 'glm-4.7', toolName: 'Bash', sessionName: 'Bash 环境探查', minutesAgo: 25 }), + { + ...makeUsageLog({ id: '5', kind: 'model', model: 'gpt-5', status: 'aborted', minutesAgo: 31 }), + sessionId: undefined, + turnId: undefined, + costUsd: undefined, + }, ]; +// Priced provenance so the fixtures' costs read as authoritative +// (pricedAttempts > 0); the empty fixture keeps the all-zero provenance. +const STORY_USAGE_PROVENANCE = { + ...EMPTY_USAGE_PROVENANCE, + coverage: { + ...EMPTY_USAGE_PROVENANCE.coverage, + attempts: 1, + pricedAttempts: 1, + usageReportedAttempts: 1, + }, +}; + const usageStats: UsageStats = { summary: { totalRequests: 420, @@ -282,6 +301,7 @@ const usageStats: UsageStats = { { tool: 'Bash', calls: 120, success: 118, errors: 2, avgDurationMs: 840 }, ], pricing: [{ provider: 'zai-coding-plan', model: 'glm-4.7', inputPerMTokUsd: 0, outputPerMTokUsd: 0 }], + provenance: STORY_USAGE_PROVENANCE, }; const emptyUsageStats: UsageStats = { @@ -302,6 +322,7 @@ const emptyUsageStats: UsageStats = { byModel: [], byTool: [], pricing: [], + provenance: EMPTY_USAGE_PROVENANCE, }; const singleProviderUsageStats: UsageStats = { @@ -315,6 +336,7 @@ const singleProviderUsageStats: UsageStats = { outputTokens: 5_200, }, byProvider: [{ provider: 'zai-coding-plan', requests: 37, tokens: 24_800, costUsd: 0.18 }], + provenance: STORY_USAGE_PROVENANCE, }; const multiModelUsageStats: UsageStats = { @@ -336,6 +358,7 @@ const multiModelUsageStats: UsageStats = { { model: 'gemini-2.5-pro', requests: 48, tokens: 96_000, costUsd: 0.52 }, { model: 'qwen3-coder-480b-a35b-instruct', requests: 20, tokens: 32_000, costUsd: 0.1 }, ], + provenance: STORY_USAGE_PROVENANCE, }; function makeMemoryEntry(input: { @@ -1954,13 +1977,26 @@ export const UsageMultiModel: Story = { decorators: [withUsageMultiModelBridge], render: () => , }; -// Real path: 设置 → 使用统计 → 详情记录 on → 请求日志, with long model and tool names. +// Real path: 设置 → 使用统计 → 详情记录 on → 活动记录, with long model and tool names. export const UsageLongTail: Story = { decorators: [withUsageLongTailBridge], render: () => , play: async ({ canvasElement, globals }) => { const canvas = within(canvasElement); const usageCopy = getUsageSettingsCopy(globals.locale === 'en' ? 'en' : 'zh'); + expect( + await canvas.findByText(usageCopy.totalRequests, { + selector: '[data-slot="stat-tile-label"]', + }), + ).toBeInTheDocument(); + // Astryx `TabList` is a