diff --git a/src/eval/gaia/__tests__/pricing.test.ts b/src/eval/gaia/__tests__/pricing.test.ts index c7778734..52fa1fa8 100644 --- a/src/eval/gaia/__tests__/pricing.test.ts +++ b/src/eval/gaia/__tests__/pricing.test.ts @@ -107,6 +107,30 @@ describe("calculateCost", () => { calculateCost("claude-sonnet-4-6", { input: 0, output: 0, total: 0 }), ).toBe(0); }); + + it("prices provider-reported cache reads and writes separately", () => { + const cost = calculateCost("claude-sonnet-4-6", { + input: 100_000, + output: 20_000, + cacheRead: 300_000, + cacheWrite: 10_000, + total: 430_000, + }); + + expect(cost).toBeCloseTo(0.7275, 6); + }); + + it("uses the official long-context tier for GPT-5.5", () => { + const cost = calculateCost("gpt-5.5", { + input: 100_000, + output: 10_000, + cacheRead: 200_000, + cacheWrite: 0, + total: 310_000, + }); + + expect(cost).toBeCloseTo(1.65, 6); + }); }); // ─── formatCost ────────────────────────────────────────────────────── diff --git a/src/eval/gaia/pricing.ts b/src/eval/gaia/pricing.ts index 6d5f78f4..da31c1c6 100644 --- a/src/eval/gaia/pricing.ts +++ b/src/eval/gaia/pricing.ts @@ -1,127 +1,26 @@ -/** - * 用于 GAIA 成本核算的模型价格表。 - * - * 价格单位为每百万 token 的美元数,取自各服务商在 PR 创建时的 - * 官方定价页。它们一定会漂移 —— 当某服务商重新调整价格档位时, - * 需更新此表,或在汇总中将成本字段置为 `null`。 - * - * 未知 / 未定价的模型会让 `calculateCost` 返回 `null`,以便 - * 下游调用方能区分"无法定价"与"$0"。 - */ - +import { calculateModelUsageCost } from "../../shared/model-pricing"; import type { TokenUsage } from "./types"; -export interface ModelPrice { - /** 每百万输入 token 的美元价(未命中缓存)。 */ - inputUsdPerMTok: number; - /** 每百万输出 token 的美元价。 */ - outputUsdPerMTok: number; - /** 每百万缓存读取 token 的美元价。Anthropic / OpenAI 提示缓存。 */ - cacheReadUsdPerMTok?: number; - /** 每百万缓存写入 token 的美元价。 */ - cacheWriteUsdPerMTok?: number; -} - -/** - * 剥除服务商特定的日期 / 区域后缀,使诸如 - * `claude-sonnet-4-6-20251022` 的配置映射到规范的 `claude-sonnet-4-6` - * 条目,无需为每个带日期的快照维护一行。 - */ -export const normalizeModelId = (id: string): string => - id - // `-YYYYMMDD` 后缀 - .replace(/-(2\d{3}\d{4})$/i, "") - // Anthropic `-latest` 别名 - .replace(/-latest$/i, ""); - -/** - * 静态价格表。键为规范化(剥除日期)后的模型 id。 - * 当有新模型加入项目的适配器列表时,在此新增一行。 - */ -export const MODEL_PRICES: Readonly> = Object.freeze( - { - // Anthropic Claude 4.x 系列 - "claude-opus-4-7": { - inputUsdPerMTok: 15, - outputUsdPerMTok: 75, - cacheReadUsdPerMTok: 1.5, - cacheWriteUsdPerMTok: 18.75, - }, - "claude-sonnet-4-6": { - inputUsdPerMTok: 3, - outputUsdPerMTok: 15, - cacheReadUsdPerMTok: 0.3, - cacheWriteUsdPerMTok: 3.75, - }, - "claude-sonnet-4-7": { - inputUsdPerMTok: 3, - outputUsdPerMTok: 15, - cacheReadUsdPerMTok: 0.3, - cacheWriteUsdPerMTok: 3.75, - }, - "claude-haiku-4-5": { - inputUsdPerMTok: 1, - outputUsdPerMTok: 5, - cacheReadUsdPerMTok: 0.1, - cacheWriteUsdPerMTok: 1.25, - }, - // OpenAI - "gpt-4o": { inputUsdPerMTok: 2.5, outputUsdPerMTok: 10 }, - "gpt-4o-mini": { inputUsdPerMTok: 0.15, outputUsdPerMTok: 0.6 }, - // DeepSeek - "deepseek-chat": { inputUsdPerMTok: 0.14, outputUsdPerMTok: 0.28 }, - "deepseek-reasoner": { inputUsdPerMTok: 0.55, outputUsdPerMTok: 2.19 }, - // MiniMax - "abab6.5s-chat": { inputUsdPerMTok: 1, outputUsdPerMTok: 1 }, - "minimax-text-01": { inputUsdPerMTok: 1, outputUsdPerMTok: 1 }, - // Xiaomi MiMo V2.5 系列(2026-05-27 调价公告生效)。官方报价为 CNY, - // 按 $1 ≈ ¥7 折算入表;漂移以后只需在此处调汇率: - // - V2.5-Pro:输入 ¥3 / 输出 ¥6 / 命中缓存 ¥0.025(per MTok) - // - V2.5: 输入 ¥1 / 输出 ¥2 / 命中缓存 ¥0.02 (per MTok) - // V2.1-TTS 限时免费、V2 系列官方建议迁移到 V2.5,均未录入。 - // calculateCost() 只用 input/output 两个字段;cache 字段记录在此供 - // 未来按 cache 命中拆账时复用。MiMo 不区分 cache write(首次写入按 - // 普通 input 计费),故 cacheWriteUsdPerMTok 留空。 - "mimo-v2.5-pro": { - inputUsdPerMTok: 0.43, - outputUsdPerMTok: 0.86, - cacheReadUsdPerMTok: 0.0036, - }, - "mimo-v2.5": { - inputUsdPerMTok: 0.14, - outputUsdPerMTok: 0.29, - cacheReadUsdPerMTok: 0.0029, - }, - }, -); - -/** - * 返回 `model` 对应的规范价格行,无对应条目时返回 `null`。 - * 适用于希望提示"该模型未定价 —— 请更新价格表"告警的工具。 - */ -export const getModelPrice = (model: string): ModelPrice | null => - MODEL_PRICES[normalizeModelId(model)] ?? null; +export { + getModelPrice, + MODEL_PRICES, + type ModelPrice, + normalizeModelId, +} from "../../shared/model-pricing"; -/** - * 计算单次 agent 运行的美元成本,使用基础的输入/输出定价 - * (不区分缓存差额 —— 调用方在 `TokenUsage` 中并未拆分出这些)。 - * - * 当模型不在 `MODEL_PRICES` 中时返回 `null`,使 runner 可以记录 - * "未定价",而非错误地按 $0 计算。 - */ export const calculateCost = ( model: string, usage: TokenUsage | undefined, ): number | null => { if (!usage) return null; - const price = getModelPrice(model); - if (!price) return null; - const inputCost = (usage.input * price.inputUsdPerMTok) / 1_000_000; - const outputCost = (usage.output * price.outputUsdPerMTok) / 1_000_000; - return inputCost + outputCost; + return calculateModelUsageCost(model, { + inputTokens: usage.input, + outputTokens: usage.output, + cacheReadTokens: usage.cacheRead, + cacheWriteTokens: usage.cacheWrite, + }); }; -/** 将成本数值格式化为便于人读的形式。将 `null` 视为 "—"。 */ export const formatCost = (usd: number | null | undefined): string => { if (usd === null || usd === undefined) return "—"; if (usd === 0) return "$0.00"; diff --git a/src/eval/gaia/types.ts b/src/eval/gaia/types.ts index acdb0a2a..97f77ae5 100644 --- a/src/eval/gaia/types.ts +++ b/src/eval/gaia/types.ts @@ -71,6 +71,8 @@ export interface TokenUsage { input: number; output: number; total: number; + cacheRead?: number; + cacheWrite?: number; } export interface QuestionResult { diff --git a/src/main/core/__tests__/agent-loop.test.ts b/src/main/core/__tests__/agent-loop.test.ts index 85020103..b82a6b8a 100644 --- a/src/main/core/__tests__/agent-loop.test.ts +++ b/src/main/core/__tests__/agent-loop.test.ts @@ -277,8 +277,8 @@ describe("AgentLoop", () => { expect(end.totalUsage?.inputTokens).toBe(1_000_000); expect(end.contextUsage).toEqual({ - latestStepContextTokens: 48_000, - maxStepContextTokens: 50_000, + latestStepContextTokens: 49_000, + maxStepContextTokens: 51_000, }); }); diff --git a/src/main/core/agent/__tests__/agent-loop-reasoning.test.ts b/src/main/core/agent/__tests__/agent-loop-reasoning.test.ts index 4071fcc5..066e2bcc 100644 --- a/src/main/core/agent/__tests__/agent-loop-reasoning.test.ts +++ b/src/main/core/agent/__tests__/agent-loop-reasoning.test.ts @@ -66,4 +66,24 @@ describe("mapUsage — reasoning token extraction", () => { }); expect(u?.cacheReadTokens).toBe(64); }); + + it("separates provider cache usage from uncached input tokens", () => { + const u = mapUsage({ + inputTokens: 100, + outputTokens: 10, + inputTokenDetails: { + noCacheTokens: 20, + cacheReadTokens: 64, + cacheWriteTokens: 16, + }, + }); + + expect(u).toMatchObject({ + inputTokens: 20, + outputTokens: 10, + totalTokens: 110, + cacheReadTokens: 64, + cacheWriteTokens: 16, + }); + }); }); diff --git a/src/main/core/agent/agent-loop.ts b/src/main/core/agent/agent-loop.ts index 5973eded..23a0b166 100644 --- a/src/main/core/agent/agent-loop.ts +++ b/src/main/core/agent/agent-loop.ts @@ -728,16 +728,16 @@ export class AgentLoop { } case "finish-step": { const stepUsage = mapUsage(part.usage); - const stepInputTokens = stepUsage?.inputTokens; + const stepContextTokens = stepUsage?.totalTokens; if ( - typeof stepInputTokens === "number" && - Number.isFinite(stepInputTokens) && - stepInputTokens >= 0 + typeof stepContextTokens === "number" && + Number.isFinite(stepContextTokens) && + stepContextTokens >= 0 ) { - latestStepContextTokens = stepInputTokens; + latestStepContextTokens = stepContextTokens; maxStepContextTokens = Math.max( maxStepContextTokens, - stepInputTokens, + stepContextTokens, ); } if (messageOpen) { @@ -1039,7 +1039,9 @@ interface RawUsage { reasoningTokens?: number | null; /** AI SDK v7 的输入 token 细分。 */ inputTokenDetails?: { + noCacheTokens?: number | null; cacheReadTokens?: number | null; + cacheWriteTokens?: number | null; } | null; /** AI SDK v6+ 的嵌套形式(优先于已废弃的扁平字段)。 */ outputTokenDetails?: { @@ -1051,11 +1053,23 @@ interface RawUsage { export function mapUsage(raw: unknown): TokenUsage | undefined { if (!raw || typeof raw !== "object") return undefined; const u = raw as RawUsage; - const input = u.inputTokens ?? null; + const cacheRead = + u.inputTokenDetails?.cacheReadTokens ?? u.cachedInputTokens ?? null; + const cacheWrite = u.inputTokenDetails?.cacheWriteTokens ?? null; + const input = + u.inputTokenDetails?.noCacheTokens ?? + (u.inputTokens != null && (cacheRead != null || cacheWrite != null) + ? Math.max(0, u.inputTokens - (cacheRead ?? 0) - (cacheWrite ?? 0)) + : (u.inputTokens ?? null)); const output = u.outputTokens ?? null; const total = u.totalTokens ?? - (input !== null || output !== null ? (input ?? 0) + (output ?? 0) : null); + (input !== null || + output !== null || + cacheRead !== null || + cacheWrite !== null + ? (input ?? 0) + (output ?? 0) + (cacheRead ?? 0) + (cacheWrite ?? 0) + : null); // 优先使用嵌套的 `outputTokenDetails.reasoningTokens`;对较旧的 SDK // 响应则回退到已废弃的扁平 `reasoningTokens` 字段。 const reasoning = @@ -1064,8 +1078,8 @@ export function mapUsage(raw: unknown): TokenUsage | undefined { inputTokens: input, outputTokens: output, totalTokens: total, - cacheReadTokens: - u.inputTokenDetails?.cacheReadTokens ?? u.cachedInputTokens ?? null, + cacheReadTokens: cacheRead, + cacheWriteTokens: cacheWrite, reasoningTokens: reasoning, }; } diff --git a/src/main/core/session/message-parts.ts b/src/main/core/session/message-parts.ts index 44c1bdaf..48f483d0 100644 --- a/src/main/core/session/message-parts.ts +++ b/src/main/core/session/message-parts.ts @@ -157,6 +157,9 @@ export interface UsagePart { inputTokens: number | null; outputTokens: number | null; totalTokens: number | null; + cacheReadTokens?: number | null; + cacheWriteTokens?: number | null; + costUsd?: number | null; modelId: string | null; provider: string | null; latestStepContextTokens?: number | null; diff --git a/src/main/db/__tests__/automation-runs-migration.test.ts b/src/main/db/__tests__/automation-runs-migration.test.ts index 65f3ed2a..160de629 100644 --- a/src/main/db/__tests__/automation-runs-migration.test.ts +++ b/src/main/db/__tests__/automation-runs-migration.test.ts @@ -200,6 +200,15 @@ describe("automation run migrations", () => { expect(sqliteMock.state.execCalls.join("\n")).toContain( "ALTER TABLE tasks ADD COLUMN updated_at TEXT", ); + expect(sqliteMock.state.execCalls.join("\n")).toContain( + "ALTER TABLE tasks ADD COLUMN cache_read_tokens INTEGER", + ); + expect(sqliteMock.state.execCalls.join("\n")).toContain( + "ALTER TABLE tasks ADD COLUMN cache_write_tokens INTEGER", + ); + expect(sqliteMock.state.execCalls.join("\n")).toContain( + "ALTER TABLE tasks ADD COLUMN cost_usd REAL", + ); }); it("adds context checkpoint columns to existing task summaries", async () => { diff --git a/src/main/db/index.ts b/src/main/db/index.ts index 7c1e290b..c5e00696 100644 --- a/src/main/db/index.ts +++ b/src/main/db/index.ts @@ -52,7 +52,15 @@ export const initDatabase = async () => { completed_at TEXT, session_id TEXT, assistant_message_id TEXT, - updated_at TEXT + updated_at TEXT, + input_tokens INTEGER, + output_tokens INTEGER, + total_tokens INTEGER, + cache_read_tokens INTEGER, + cache_write_tokens INTEGER, + cost_usd REAL, + model_id TEXT, + provider TEXT ); CREATE TABLE IF NOT EXISTS task_trace_events ( id TEXT PRIMARY KEY, @@ -567,6 +575,15 @@ export const initDatabase = async () => { if (!taskColumnNames.has("updated_at")) { sqlite.exec("ALTER TABLE tasks ADD COLUMN updated_at TEXT"); } + if (!taskColumnNames.has("cache_read_tokens")) { + sqlite.exec("ALTER TABLE tasks ADD COLUMN cache_read_tokens INTEGER"); + } + if (!taskColumnNames.has("cache_write_tokens")) { + sqlite.exec("ALTER TABLE tasks ADD COLUMN cache_write_tokens INTEGER"); + } + if (!taskColumnNames.has("cost_usd")) { + sqlite.exec("ALTER TABLE tasks ADD COLUMN cost_usd REAL"); + } // 迁移:task_trace_events 表缺失时创建(旧版数据库) sqlite.exec(` @@ -890,6 +907,9 @@ interface Task { inputTokens?: number | null; outputTokens?: number | null; totalTokens?: number | null; + cacheReadTokens?: number | null; + cacheWriteTokens?: number | null; + costUsd?: number | null; modelId?: string | null; provider?: string | null; } @@ -1115,6 +1135,11 @@ export const updateTask = (id: string, updates: Partial) => { mapped.outputTokens = updates.outputTokens; if (updates.totalTokens !== undefined) mapped.totalTokens = updates.totalTokens; + if (updates.cacheReadTokens !== undefined) + mapped.cacheReadTokens = updates.cacheReadTokens; + if (updates.cacheWriteTokens !== undefined) + mapped.cacheWriteTokens = updates.cacheWriteTokens; + if (updates.costUsd !== undefined) mapped.costUsd = updates.costUsd; if (updates.modelId !== undefined) mapped.modelId = updates.modelId; if (updates.provider !== undefined) mapped.provider = updates.provider; if (updates.updatedAt !== undefined) { diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index 685cdaa6..39b02ff5 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -26,6 +26,9 @@ export const tasks = sqliteTable("tasks", { inputTokens: integer("input_tokens"), outputTokens: integer("output_tokens"), totalTokens: integer("total_tokens"), + cacheReadTokens: integer("cache_read_tokens"), + cacheWriteTokens: integer("cache_write_tokens"), + costUsd: real("cost_usd"), modelId: text("model_id"), provider: text("provider"), }); diff --git a/src/main/ipc/__tests__/ai-handlers-media-runtime.test.ts b/src/main/ipc/__tests__/ai-handlers-media-runtime.test.ts index 57ae5c02..0c4d5260 100644 --- a/src/main/ipc/__tests__/ai-handlers-media-runtime.test.ts +++ b/src/main/ipc/__tests__/ai-handlers-media-runtime.test.ts @@ -485,6 +485,7 @@ describe("ai:executeTask media modality routing", () => { expect(dbMock.updateTask).toHaveBeenLastCalledWith( expect.any(String), expect.objectContaining({ + costUsd: null, inputTokens: 130, outputTokens: 30, totalTokens: 160, @@ -501,6 +502,31 @@ describe("ai:executeTask media modality routing", () => { ); }); + it("stores cost from each formal provider response usage", async () => { + await runGpt55ChatTaskWithProviderCount(300_000, {}, [ + { + type: "turn_end", + agentId: "parent-task", + turnIndex: 0, + reason: "finish", + usage: { + inputTokens: 100_000, + outputTokens: 10_000, + totalTokens: 310_000, + cacheReadTokens: 200_000, + cacheWriteTokens: 0, + }, + }, + ]); + + expect(dbMock.updateTask).toHaveBeenLastCalledWith( + expect.any(String), + expect.objectContaining({ + costUsd: 1.65, + }), + ); + }); + it("routes image configs through media runtime and streams an image part", async () => { const config = makeMediaConfig("image"); dbMock.getLlmConfig.mockReturnValue(config); @@ -915,7 +941,6 @@ describe("ai:executeTask media modality routing", () => { it("does not treat cumulative history usage as current request occupancy", async () => { await runGpt55ChatTaskWithProviderCount(500_000, { - contextInputTokens: 900_000, history: [ { id: "user-1", role: "user", content: "Earlier request" }, { diff --git a/src/main/ipc/__tests__/usage-aggregation.test.ts b/src/main/ipc/__tests__/usage-aggregation.test.ts index cb678575..bdf2648c 100644 --- a/src/main/ipc/__tests__/usage-aggregation.test.ts +++ b/src/main/ipc/__tests__/usage-aggregation.test.ts @@ -22,19 +22,34 @@ describe("usage aggregation", () => { inputTokens: 17, outputTokens: 8, totalTokens: 25, + cacheReadTokens: null, + cacheWriteTokens: null, }); }); it("merges parent and subagent usage without dropping partial fields", () => { expect( mergeTokenUsage( - { inputTokens: 100, outputTokens: 20, totalTokens: 120 }, - { inputTokens: 30, outputTokens: null, totalTokens: 45 }, + { + inputTokens: 100, + outputTokens: 20, + totalTokens: 140, + cacheReadTokens: 20, + }, + { + inputTokens: 30, + outputTokens: null, + totalTokens: 50, + cacheReadTokens: 15, + cacheWriteTokens: 5, + }, ), ).toEqual({ inputTokens: 130, outputTokens: 20, - totalTokens: 165, + totalTokens: 190, + cacheReadTokens: 35, + cacheWriteTokens: 5, }); }); @@ -43,6 +58,20 @@ describe("usage aggregation", () => { inputTokens: null, outputTokens: null, totalTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + }); + }); + + it("derives totals when a provider reports only cache usage", () => { + expect( + mergeTokenUsage({ cacheReadTokens: 40, cacheWriteTokens: 2 }), + ).toEqual({ + inputTokens: null, + outputTokens: null, + totalTokens: 42, + cacheReadTokens: 40, + cacheWriteTokens: 2, }); }); }); diff --git a/src/main/ipc/ai-handlers.ts b/src/main/ipc/ai-handlers.ts index 8caf2b40..42a0f39f 100644 --- a/src/main/ipc/ai-handlers.ts +++ b/src/main/ipc/ai-handlers.ts @@ -14,6 +14,7 @@ import { resolveChatPermissionRunConfig, } from "../../shared/chat-permissions"; import { isImageGenerationModelId } from "../../shared/llm-modalities"; +import { calculateModelUsageCost } from "../../shared/model-pricing"; import { resolveAdapterName } from "../ai/adapters"; import { runWithDevtoolsTaskScope } from "../ai/adapters/devtools"; import { @@ -281,7 +282,6 @@ const handleTaskExecutionInner = async ( /** 自动化手动运行记录 id。存在时,本 chat 任务会同步 automation_runs。 */ automationRunId?: string; chatPermissionMode?: ChatPermissionMode; - contextInputTokens?: number; llmConfigId?: string; history?: Array<{ id?: string; @@ -1014,6 +1014,8 @@ const handleTaskExecutionInner = async ( let agentOutputTokens: number | null = null; let agentTotalTokens: number | null = null; let agentProviderMeta: Record | undefined; + let agentCostUsd: number | null = 0; + let sawAgentUsage = false; let subagentUsage = mergeTokenUsage(); const workspace = @@ -1429,16 +1431,29 @@ const handleTaskExecutionInner = async ( }); break; case "turn_end": { - const inputTokens = ev.usage?.inputTokens; + const contextTokens = ev.usage?.totalTokens; if ( - typeof inputTokens === "number" && - Number.isFinite(inputTokens) + typeof contextTokens === "number" && + Number.isFinite(contextTokens) ) { + sawAgentUsage = true; + if (agentCostUsd != null && llmConfig.provider !== "ollama") { + const stepCost = calculateModelUsageCost(llmConfig.model, { + inputTokens: ev.usage?.inputTokens ?? 0, + outputTokens: ev.usage?.outputTokens ?? 0, + cacheReadTokens: ev.usage?.cacheReadTokens, + cacheWriteTokens: ev.usage?.cacheWriteTokens, + }); + agentCostUsd = + stepCost == null ? null : agentCostUsd + stepCost; + } emitContextBudgetTrace({ source: "provider-step", - originalTokens: inputTokens, + originalTokens: contextTokens, + tokenAccuracy: "actual", + tokenCountSource: "provider-response-usage", turnIndex: ev.turnIndex, - usedTokens: inputTokens, + usedTokens: contextTokens, providerNativeCompaction, }); } @@ -1554,9 +1569,19 @@ const handleTaskExecutionInner = async ( inputTokens: agentInputTokens, outputTokens: agentOutputTokens, totalTokens: agentTotalTokens, + cacheReadTokens: ev.totalUsage?.cacheReadTokens, + cacheWriteTokens: ev.totalUsage?.cacheWriteTokens, }, subagentUsage, ); + const hasSubagentUsage = + (subagentUsage.totalTokens ?? + (subagentUsage.inputTokens ?? 0) + + (subagentUsage.outputTokens ?? 0) + + (subagentUsage.cacheReadTokens ?? 0) + + (subagentUsage.cacheWriteTokens ?? 0)) > 0; + const costUsd = + hasSubagentUsage || !sawAgentUsage ? null : agentCostUsd; updateTask(id, { status: "completed", result: fullText, @@ -1566,6 +1591,9 @@ const handleTaskExecutionInner = async ( inputTokens: combinedUsage.inputTokens, outputTokens: combinedUsage.outputTokens, totalTokens: combinedUsage.totalTokens, + cacheReadTokens: combinedUsage.cacheReadTokens, + cacheWriteTokens: combinedUsage.cacheWriteTokens, + costUsd, }); finishAutomationRunForTask(payload.automationRunId, { status: wasCancelled ? "canceled" : "succeeded", diff --git a/src/main/ipc/usage-aggregation.ts b/src/main/ipc/usage-aggregation.ts index 680f8240..a2c6a663 100644 --- a/src/main/ipc/usage-aggregation.ts +++ b/src/main/ipc/usage-aggregation.ts @@ -2,6 +2,8 @@ export interface TokenUsageLike { inputTokens?: number | null; outputTokens?: number | null; totalTokens?: number | null; + cacheReadTokens?: number | null; + cacheWriteTokens?: number | null; } const valueOrZero = (value: number | null | undefined): number => @@ -13,12 +15,24 @@ const hasAnyUsage = ( usage !== undefined && (usage.inputTokens != null || usage.outputTokens != null || - usage.totalTokens != null); + usage.totalTokens != null || + usage.cacheReadTokens != null || + usage.cacheWriteTokens != null); const normalizedTotal = (usage: TokenUsageLike): number | null => { if (typeof usage.totalTokens === "number") return usage.totalTokens; - if (usage.inputTokens != null || usage.outputTokens != null) { - return valueOrZero(usage.inputTokens) + valueOrZero(usage.outputTokens); + if ( + usage.inputTokens != null || + usage.outputTokens != null || + usage.cacheReadTokens != null || + usage.cacheWriteTokens != null + ) { + return ( + valueOrZero(usage.inputTokens) + + valueOrZero(usage.outputTokens) + + valueOrZero(usage.cacheReadTokens) + + valueOrZero(usage.cacheWriteTokens) + ); } return null; }; @@ -28,7 +42,13 @@ export const mergeTokenUsage = ( ): Required => { const present = usages.filter(hasAnyUsage); if (present.length === 0) { - return { inputTokens: null, outputTokens: null, totalTokens: null }; + return { + inputTokens: null, + outputTokens: null, + totalTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + }; } return present.reduce>( @@ -45,8 +65,24 @@ export const mergeTokenUsage = ( acc.totalTokens === null && normalizedTotal(usage) === null ? null : valueOrZero(acc.totalTokens) + valueOrZero(normalizedTotal(usage)), + cacheReadTokens: + acc.cacheReadTokens === null && usage.cacheReadTokens == null + ? null + : valueOrZero(acc.cacheReadTokens) + + valueOrZero(usage.cacheReadTokens), + cacheWriteTokens: + acc.cacheWriteTokens === null && usage.cacheWriteTokens == null + ? null + : valueOrZero(acc.cacheWriteTokens) + + valueOrZero(usage.cacheWriteTokens), }), - { inputTokens: null, outputTokens: null, totalTokens: null }, + { + inputTokens: null, + outputTokens: null, + totalTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + }, ); }; diff --git a/src/main/ipc/usage-handlers.ts b/src/main/ipc/usage-handlers.ts index 92ce7123..e5be98b7 100644 --- a/src/main/ipc/usage-handlers.ts +++ b/src/main/ipc/usage-handlers.ts @@ -5,6 +5,7 @@ */ import { ipcMain } from "electron"; +import { calculateModelUsageCost } from "../../shared/model-pricing"; import { getTasks } from "../db"; /** @@ -22,6 +23,18 @@ export const registerUsageHandlers = () => { inputTokens: task.inputTokens ?? null, outputTokens: task.outputTokens ?? null, totalTokens: task.totalTokens ?? null, + cacheReadTokens: task.cacheReadTokens ?? null, + cacheWriteTokens: task.cacheWriteTokens ?? null, + costUsd: + task.costUsd ?? + (task.provider === "ollama" + ? 0 + : calculateModelUsageCost(task.modelId, { + inputTokens: task.inputTokens ?? 0, + outputTokens: task.outputTokens ?? 0, + cacheReadTokens: task.cacheReadTokens, + cacheWriteTokens: task.cacheWriteTokens, + })), modelId: task.modelId ?? null, provider: task.provider ?? null, }; diff --git a/src/preload/index.ts b/src/preload/index.ts index cbd3eecf..fa53677c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -228,8 +228,6 @@ const api = { assistantMessageId?: string; automationRunId?: string; chatPermissionMode?: ChatPermissionMode; - /** 最近一次 provider input tokens,用于对齐 Codex 风格 context 压缩触发。 */ - contextInputTokens?: number; llmConfigId?: string; history?: Array<{ id?: string; diff --git a/src/renderer/components/chat/ChatPanel.tsx b/src/renderer/components/chat/ChatPanel.tsx index 43d9d4d2..962bce45 100644 --- a/src/renderer/components/chat/ChatPanel.tsx +++ b/src/renderer/components/chat/ChatPanel.tsx @@ -102,6 +102,7 @@ import { ModelSelector } from "./ModelSelector"; import { ReasoningBlock } from "./ReasoningBlock"; import { SkillApprovalDialog } from "./SkillApprovalDialog"; import { SubagentCard } from "./SubagentCard"; +import { buildSessionStats } from "./session-stats"; import { TurnSummaryCard } from "./TurnSummaryCard"; import type { ArticleMetaPart, @@ -181,41 +182,27 @@ const getInitialContextUsageFromConfig = ( if (contextWindow == null) return null; return { contextWindow, - cumulativeInputTokens: null, maxOutputTokens: readPositiveNumber(config?.maxOutputTokens) ?? readPositiveNumber(config?.modelMaxOutputTokens), - originalTokens: 0, + originalTokens: null, safetyMargin: null, tokenBudget: null, }; }; -const sumUsageInputTokens = (messages: ChatMessage[]): number => { - let total = 0; - for (const message of messages) { - if (!message.parts) continue; - for (const part of message.parts) { - if (part.type !== "usage") continue; - const inputTokens = (part as UsagePart).inputTokens; - if (typeof inputTokens === "number" && Number.isFinite(inputTokens)) { - total += inputTokens; - } - } - } - return total; -}; - -const getLatestUsageInputTokens = (messages: ChatMessage[]): number | null => { +const getLatestFormalContextTokens = ( + messages: ChatMessage[], +): number | null => { for (let i = messages.length - 1; i >= 0; i -= 1) { const parts = messages[i]?.parts ?? []; for (let j = parts.length - 1; j >= 0; j -= 1) { const part = parts[j]; if (part.type !== "usage") continue; const usage = part as UsagePart; - const inputTokens = usage.latestStepContextTokens ?? usage.inputTokens; - if (typeof inputTokens === "number" && Number.isFinite(inputTokens)) { - return inputTokens; + const contextTokens = usage.latestStepContextTokens; + if (typeof contextTokens === "number" && Number.isFinite(contextTokens)) { + return contextTokens; } } } @@ -829,10 +816,10 @@ export const ChatPanel = ({ const [contextUsage, setContextUsage] = useState(null); const contextBudgetTraceReceivedRef = useRef(false); const contextUsageSessionIdRef = useRef(undefined); - const persistedContextInputTokensRef = useRef(0); - const lastPersistedContextInputTokensRef = useRef(0); - const liveProviderStepInputTokensRef = useRef(0); - const seenProviderStepKeysRef = useRef>(new Set()); + const sessionStats = useMemo( + () => buildSessionStats(chat.messages), + [chat.messages], + ); useEffect(() => { const handlePptxInlineChatSubmit = (event: Event) => { @@ -868,10 +855,6 @@ export const ChatPanel = ({ contextUsageSessionIdRef.current = activeSessionId; contextBudgetTraceReceivedRef.current = false; if (sessionChanged) { - persistedContextInputTokensRef.current = 0; - lastPersistedContextInputTokensRef.current = 0; - liveProviderStepInputTokensRef.current = 0; - seenProviderStepKeysRef.current = new Set(); setContextUsage(null); } const lastUsageModelId = @@ -916,22 +899,15 @@ export const ChatPanel = ({ usageModelId: lastUsageModelId, }); if (!initialUsage) return; - setContextUsage((prev) => { - const actualTokens = - prev?.accuracy === "actual" ? prev.originalTokens : null; - return { - ...initialUsage, - cumulativeInputTokens: - prev?.cumulativeInputTokens ?? initialUsage.cumulativeInputTokens, - originalTokens: - actualTokens ?? - prev?.originalTokens ?? - initialUsage.originalTokens, - accuracy: prev?.accuracy ?? initialUsage.accuracy, - tokenBudget: prev?.tokenBudget ?? initialUsage.tokenBudget, - safetyMargin: prev?.safetyMargin ?? initialUsage.safetyMargin, - }; - }); + setContextUsage((prev) => ({ + ...initialUsage, + originalTokens: prev?.originalTokens ?? initialUsage.originalTokens, + providerNativeCompaction: + prev?.providerNativeCompaction ?? + initialUsage.providerNativeCompaction, + tokenBudget: prev?.tokenBudget ?? initialUsage.tokenBudget, + safetyMargin: prev?.safetyMargin ?? initialUsage.safetyMargin, + })); } catch { // 仅用于发送前提示;任务 trace 仍是权威用量来源。 } @@ -945,47 +921,20 @@ export const ChatPanel = ({ }, [chat.activeSessionId, chat.lastUsage?.modelId, chat.selectedLlmConfigId]); useEffect(() => { - const persistedTokens = sumUsageInputTokens(chat.messages); - const latestUsageInputTokens = getLatestUsageInputTokens(chat.messages); - persistedContextInputTokensRef.current = persistedTokens; - if (persistedTokens !== lastPersistedContextInputTokensRef.current) { - lastPersistedContextInputTokensRef.current = persistedTokens; - liveProviderStepInputTokensRef.current = 0; - seenProviderStepKeysRef.current = new Set(); - } - - const cumulativeTokens = - persistedContextInputTokensRef.current + - liveProviderStepInputTokensRef.current; + const latestContextTokens = getLatestFormalContextTokens(chat.messages); + if (latestContextTokens == null) return; setContextUsage((prev) => { - if (!prev && cumulativeTokens <= 0) return prev; - const shouldUseUsageFallback = - typeof latestUsageInputTokens === "number" && - latestUsageInputTokens > 0 && - (prev == null || - prev.originalTokens == null || - prev.originalTokens <= 0 || - prev.accuracy !== "actual"); return prev ? { ...prev, - cumulativeInputTokens: cumulativeTokens, - originalTokens: shouldUseUsageFallback - ? latestUsageInputTokens - : prev.originalTokens, - accuracy: shouldUseUsageFallback ? "estimated" : prev.accuracy, + originalTokens: latestContextTokens, } : { contextWindow: null, - cumulativeInputTokens: cumulativeTokens, maxOutputTokens: null, - originalTokens: latestUsageInputTokens ?? 0, + originalTokens: latestContextTokens, safetyMargin: null, tokenBudget: null, - accuracy: - typeof latestUsageInputTokens === "number" - ? "estimated" - : undefined, }; }); }, [chat.messages]); @@ -1740,64 +1689,22 @@ export const ChatPanel = ({ typeof event.detail.usedTokens === "number" ? event.detail.usedTokens : originalTokens; - const accuracy = - event.detail.tokenAccuracy === "actual" || - event.detail.source === "provider-step" - ? "actual" - : "estimated"; + const hasFormalUsage = event.detail.tokenAccuracy === "actual"; const providerNativeCompaction = readProviderNativeCompaction( event.detail.providerNativeCompaction, ); - setContextUsage((prev) => { - const displayedTokens = usedTokens; - let cumulativeInputTokens = prev?.cumulativeInputTokens ?? null; - if (accuracy === "actual" && typeof usedTokens === "number") { - const turnIndex = - typeof event.detail.turnIndex === "number" - ? event.detail.turnIndex - : event.timestamp; - const stepKey = `${event.taskId}:${turnIndex}`; - if (!seenProviderStepKeysRef.current.has(stepKey)) { - seenProviderStepKeysRef.current.add(stepKey); - liveProviderStepInputTokensRef.current += usedTokens; - } - cumulativeInputTokens = - persistedContextInputTokensRef.current + - liveProviderStepInputTokensRef.current; - } - const next: ContextUsage = { - accuracy, - contextWindow, - cumulativeInputTokens, - maxOutputTokens, - originalTokens: displayedTokens, - providerNativeCompaction, - safetyMargin, - tokenBudget, - }; - - if ( - accuracy === "estimated" && - prev?.accuracy === "actual" && - typeof prev.originalTokens === "number" && - typeof displayedTokens === "number" && - displayedTokens < prev.originalTokens - ) { - return { - ...prev, - contextWindow: contextWindow ?? prev.contextWindow, - cumulativeInputTokens: - cumulativeInputTokens ?? prev.cumulativeInputTokens, - maxOutputTokens: maxOutputTokens ?? prev.maxOutputTokens, - providerNativeCompaction: - providerNativeCompaction ?? prev.providerNativeCompaction, - safetyMargin: safetyMargin ?? prev.safetyMargin, - tokenBudget: tokenBudget ?? prev.tokenBudget, - }; - } - - return next; - }); + setContextUsage((prev) => ({ + contextWindow: contextWindow ?? prev?.contextWindow ?? null, + maxOutputTokens: maxOutputTokens ?? prev?.maxOutputTokens ?? null, + originalTokens: + hasFormalUsage && typeof usedTokens === "number" + ? usedTokens + : (prev?.originalTokens ?? null), + providerNativeCompaction: + providerNativeCompaction ?? prev?.providerNativeCompaction ?? null, + safetyMargin: safetyMargin ?? prev?.safetyMargin ?? null, + tokenBudget: tokenBudget ?? prev?.tokenBudget ?? null, + })); return; } }); @@ -2086,7 +1993,7 @@ export const ChatPanel = ({ />
- + { if (value == null || !Number.isFinite(value)) return "未知"; - if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}m`; + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; if (value >= 1_000) return `${Math.round(value / 1_000)}k`; return String(value); }; @@ -33,9 +32,6 @@ const usagePercent = ( return Math.min(100, Math.max(0, Math.round((used / limit) * 100))); }; -const accuracyText = (usage: ContextUsage | null): string | null => - usage?.accuracy === "estimated" ? "估算" : null; - const providerDisplayName = (provider: string | undefined): string => { switch (provider) { case "anthropic": @@ -60,61 +56,86 @@ const RING_RADIUS = 7; const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; export const buildContextUsageLabel = (usage: ContextUsage | null): string => { - if (!usage) return "发送后更新"; + if (!usage) return "等待正式用量"; const used = usage.originalTokens; const limit = usage.contextWindow ?? usage.tokenBudget; const percent = usagePercent(used, limit); - const suffix = accuracyText(usage) ? "(估算)" : ""; const nativeCompaction = providerNativeCompactionText(usage); - const cumulative = - typeof usage.cumulativeInputTokens === "number" && - Number.isFinite(usage.cumulativeInputTokens) && - usage.cumulativeInputTokens > 0 - ? `;累计输入 ${formatCompactTokens(usage.cumulativeInputTokens)} 标记` - : ""; const nativeSuffix = nativeCompaction ? `;${nativeCompaction}` : ""; + if (used == null) { + return `等待正式用量;上下文窗口 ${formatCompactTokens(limit)}${nativeSuffix}`; + } if (percent == null) { - return `已用 ${formatCompactTokens(used)} 标记,共 ${formatCompactTokens(limit)}${suffix}${cumulative}${nativeSuffix}`; + return `已用 ${formatCompactTokens(used)} 标记,共 ${formatCompactTokens(limit)}${nativeSuffix}`; } - - return `${percent}% 已用${suffix};已用 ${formatCompactTokens(used)} 标记,共 ${formatCompactTokens(limit)}${cumulative}${nativeSuffix}`; + return `${percent}% 已用;已用 ${formatCompactTokens(used)} 标记,共 ${formatCompactTokens(limit)}${nativeSuffix}`; }; const buildUsageSummary = (usage: ContextUsage | null) => { if (!usage) { return { percentText: "--", - percentLabel: "已用", - usedLine: "发送后更新", - cumulativeLine: null, + usedLine: "等待正式用量", + nativeCompactionLine: null, }; } const limit = usage.contextWindow ?? usage.tokenBudget; const percent = usagePercent(usage.originalTokens, limit); - const cumulativeLine = - typeof usage.cumulativeInputTokens === "number" && - Number.isFinite(usage.cumulativeInputTokens) && - usage.cumulativeInputTokens > 0 - ? `累计输入 ${formatCompactTokens(usage.cumulativeInputTokens)} 标记` - : null; - const nativeCompactionLine = providerNativeCompactionText(usage); return { percentText: percent == null ? "--" : `${percent}%`, - percentLabel: "已用", - usedLine: `已用 ${formatCompactTokens(usage.originalTokens)} 标记,共 ${formatCompactTokens(limit)}`, - cumulativeLine, - nativeCompactionLine, + usedLine: + usage.originalTokens == null + ? "等待正式用量" + : `已用 ${formatCompactTokens(usage.originalTokens)} 标记,共 ${formatCompactTokens(limit)}`, + nativeCompactionLine: providerNativeCompactionText(usage), }; }; +const formatInteger = (value: number): string => value.toLocaleString(); + +const StatSection = ({ + title, + rows, +}: { + title: string; + rows: Array<[label: string, value: string]>; +}) => ( + + + {title} + + + {rows.map(([label, value]) => ( + + {label} + + {value} + + + ))} + + +); + +const contextStat = (usage: ContextUsage | null): string | null => { + const contextWindow = usage?.contextWindow; + if (!contextWindow) return null; + const tokens = usage.originalTokens; + const percent = + tokens == null ? null : Math.max(0, (tokens / contextWindow) * 100); + return `${percent == null ? "?" : `${percent.toFixed(1)}%`} / ${formatCompactTokens(contextWindow)}`; +}; + export const ContextUsageButton = ({ usage, + stats, className, }: { usage: ContextUsage | null; + stats?: SessionStats | null; className?: string; }) => { const percent = usage @@ -125,9 +146,40 @@ export const ContextUsageButton = ({ : null; const label = buildContextUsageLabel(usage); const summary = buildUsageSummary(usage); - const accuracy = accuracyText(usage); const ringPercent = percent ?? 0; const ringOffset = RING_CIRCUMFERENCE * (1 - ringPercent / 100); + const contextValue = contextStat(usage); + + const messageRows: Array<[string, string]> = stats + ? [ + ["用户", formatInteger(stats.userMessages)], + ["助手", formatInteger(stats.assistantMessages)], + ["工具调用", formatInteger(stats.toolCalls)], + ["工具结果", formatInteger(stats.toolResults)], + ["总计", formatInteger(stats.totalMessages)], + ] + : []; + const tokenRows: Array<[string, string]> = stats + ? [ + ["输入", formatInteger(stats.inputTokens)], + ["输出", formatInteger(stats.outputTokens)], + ["缓存读取", formatInteger(stats.cacheReadTokens)], + ...(stats.cacheWriteTokens > 0 + ? ([["缓存写入", formatInteger(stats.cacheWriteTokens)]] as Array< + [string, string] + >) + : []), + ["总计", formatInteger(stats.totalTokens)], + ...(stats.costUsd == null + ? [] + : ([["费用", `$${stats.costUsd.toFixed(4)}`]] as Array< + [string, string] + >)), + ...(contextValue + ? ([["上下文", contextValue]] as Array<[string, string]>) + : []), + ] + : []; return ( @@ -174,31 +226,38 @@ export const ContextUsageButton = ({ ); diff --git a/src/renderer/components/chat/__tests__/ChatPanel.rendering.test.tsx b/src/renderer/components/chat/__tests__/ChatPanel.rendering.test.tsx index 198cbe4a..e642c13e 100644 --- a/src/renderer/components/chat/__tests__/ChatPanel.rendering.test.tsx +++ b/src/renderer/components/chat/__tests__/ChatPanel.rendering.test.tsx @@ -747,6 +747,8 @@ describe("ChatPanel message rendering", () => { detail: { tokenBudget: 258_000, originalTokens: 183_000, + tokenAccuracy: "actual", + usedTokens: 183_000, }, }); }); @@ -807,13 +809,15 @@ describe("ChatPanel message rendering", () => { await Promise.resolve(); }); - const usageButton = document.querySelector('button[aria-label*="0% 已用"]'); + const usageButton = document.querySelector( + 'button[aria-label*="等待正式用量"]', + ); expect( usageButton?.querySelector('[data-context-usage-ring="true"]'), ).not.toBeNull(); expect(usageButton?.textContent).not.toContain("0%"); expect(document.getElementById("root")?.textContent ?? "").toContain( - "已用 0 标记,共 258k", + "? / 258k", ); }); @@ -837,17 +841,19 @@ describe("ChatPanel message rendering", () => { await Promise.resolve(); }); - const usageButton = document.querySelector('button[aria-label*="0% 已用"]'); + const usageButton = document.querySelector( + 'button[aria-label*="等待正式用量"]', + ); expect( usageButton?.querySelector('[data-context-usage-ring="true"]'), ).not.toBeNull(); expect(usageButton?.textContent).not.toContain("0%"); expect(document.getElementById("root")?.textContent ?? "").toContain( - "已用 0 标记,共 1.1m", + "? / 1.1M", ); }); - it("uses post-compression context tokens from context-budget trace", async () => { + it("does not expose estimated post-compression tokens as context usage", async () => { chatState.value = createChatState([]); await act(async () => { root?.render(); @@ -868,13 +874,15 @@ describe("ChatPanel message rendering", () => { }); const usageButton = document.querySelector( - 'button[aria-label*="70% 已用"]', + 'button[aria-label*="等待正式用量"]', ); expect( usageButton?.querySelector('[data-context-usage-ring="true"]'), ).not.toBeNull(); const rootText = document.getElementById("root")?.textContent ?? ""; - expect(rootText).toContain("已用 180k 标记,共 258k"); + expect(rootText).toContain("? / 258k"); + expect(rootText).not.toContain("70% 已用"); + expect(rootText).not.toContain("已用 180k 标记,共 258k"); expect(rootText).not.toContain("已用 475k 标记,共 258k"); }); @@ -907,7 +915,7 @@ describe("ChatPanel message rendering", () => { usageButton?.querySelector('[data-context-usage-ring="true"]'), ).not.toBeNull(); const rootText = document.getElementById("root")?.textContent ?? ""; - expect(rootText).toContain("已用 35k 标记,共 258k"); + expect(rootText).toContain("13.4% / 258k"); expect(rootText).not.toContain("估算"); }); @@ -932,6 +940,7 @@ describe("ChatPanel message rendering", () => { triggerTokens: 170_000, }, source: "provider-step", + tokenAccuracy: "actual", tokenBudget: 190_000, usedTokens: 170_000, }, @@ -963,6 +972,7 @@ describe("ChatPanel message rendering", () => { triggerTokens: 170_000, }, source: "provider-step", + tokenAccuracy: "actual", tokenBudget: 190_000, usedTokens: 170_000, }, @@ -1009,6 +1019,7 @@ describe("ChatPanel message rendering", () => { contextWindow: 258_000, originalTokens: 31_100, source: "provider-step", + tokenAccuracy: "actual", tokenBudget: 247_808, usedTokens: 31_100, }, @@ -1022,7 +1033,7 @@ describe("ChatPanel message rendering", () => { usageButton?.querySelector('[data-context-usage-ring="true"]'), ).not.toBeNull(); const rootText = document.getElementById("root")?.textContent ?? ""; - expect(rootText).toContain("已用 31k 标记,共 258k"); + expect(rootText).toContain("12.1% / 258k"); expect(rootText).not.toContain("已用 18 标记,共 258k"); }); @@ -1041,6 +1052,7 @@ describe("ChatPanel message rendering", () => { contextWindow: 258_000, originalTokens: 34_300, source: "provider-step", + tokenAccuracy: "actual", tokenBudget: 247_808, usedTokens: 34_300, }, @@ -1066,11 +1078,11 @@ describe("ChatPanel message rendering", () => { usageButton?.querySelector('[data-context-usage-ring="true"]'), ).not.toBeNull(); const rootText = document.getElementById("root")?.textContent ?? ""; - expect(rootText).toContain("已用 34k 标记,共 258k"); + expect(rootText).toContain("13.3% / 258k"); expect(rootText).not.toContain("已用 12k 标记,共 258k"); }); - it("keeps provider-measured context usage separate from cumulative input", async () => { + it("uses the latest formal provider response instead of cumulative input", async () => { chatState.value = createChatState([]); await act(async () => { root?.render(); @@ -1085,6 +1097,7 @@ describe("ChatPanel message rendering", () => { contextWindow: 258_000, originalTokens: 34_500, source: "provider-step", + tokenAccuracy: "actual", tokenBudget: 247_808, turnIndex: 0, usedTokens: 34_500, @@ -1098,6 +1111,7 @@ describe("ChatPanel message rendering", () => { contextWindow: 258_000, originalTokens: 34_900, source: "provider-step", + tokenAccuracy: "actual", tokenBudget: 247_808, turnIndex: 0, usedTokens: 34_900, @@ -1112,14 +1126,20 @@ describe("ChatPanel message rendering", () => { usageButton?.querySelector('[data-context-usage-ring="true"]'), ).not.toBeNull(); const rootText = document.getElementById("root")?.textContent ?? ""; - expect(rootText).toContain("已用 35k 标记,共 258k"); - expect(rootText).toContain("累计输入 69k 标记"); - expect(rootText).not.toContain("27% 已用"); + expect(rootText).toContain("13.5% / 258k"); + expect(rootText).not.toContain("26.9% / 258k"); }); - it("restores latest usage input as estimated context usage from existing rows", async () => { + it("restores formal context usage from the latest provider response", async () => { chatState.value = createChatState( [ + { + id: "user-1", + sessionId: "session-1", + role: "user", + content: "检查项目", + timestamp: "2026-06-29T03:59:59.000Z", + }, { id: "assistant-1", sessionId: "session-1", @@ -1127,6 +1147,14 @@ describe("ChatPanel message rendering", () => { content: "", timestamp: "2026-06-29T04:00:00.000Z", parts: [ + { + args: {}, + result: { ok: true }, + state: "output-available", + toolCallId: "tool-1", + toolName: "listDirectory", + type: "tool", + }, { inputTokens: 34_500, modelId: "gpt-5.5", @@ -1146,6 +1174,7 @@ describe("ChatPanel message rendering", () => { parts: [ { inputTokens: 34_900, + latestStepContextTokens: 34_922, modelId: "gpt-5.5", outputTokens: 22, provider: "openai", @@ -1173,10 +1202,12 @@ describe("ChatPanel message rendering", () => { usageButton?.querySelector('[data-context-usage-ring="true"]'), ).not.toBeNull(); const rootText = document.getElementById("root")?.textContent ?? ""; - expect(rootText).toContain("估算"); - expect(rootText).toContain("已用 35k 标记,共 258k"); - expect(rootText).toContain("累计输入 69k 标记"); - expect(rootText).not.toContain("已用 0 标记,共 258k"); + expect(rootText).not.toContain("估算"); + expect(rootText).toContain("13.5% / 258k"); + expect(rootText).toContain("69,400"); + expect(rootText).toContain("工具调用"); + expect(rootText).toContain("工具结果"); + expect(rootText).not.toContain("0.0% / 258k"); }); it("does not restore provider input tokens as context usage", async () => { @@ -1197,17 +1228,19 @@ describe("ChatPanel message rendering", () => { await Promise.resolve(); }); - const usageButton = document.querySelector('button[aria-label*="0% 已用"]'); + const usageButton = document.querySelector( + 'button[aria-label*="等待正式用量"]', + ); expect( usageButton?.querySelector('[data-context-usage-ring="true"]'), ).not.toBeNull(); expect(usageButton?.textContent).not.toContain("0%"); const rootText = document.getElementById("root")?.textContent ?? ""; - expect(rootText).toContain("已用 0 标记,共 258k"); + expect(rootText).toContain("? / 258k"); expect(rootText).not.toContain("已用 35k 标记,共 258k"); }); - it("keeps context usage from trace when final provider input tokens are cumulative", async () => { + it("does not use cumulative task totals as context usage", async () => { chatState.value = createChatState([]); await act(async () => { root?.render(); @@ -1224,7 +1257,9 @@ describe("ChatPanel message rendering", () => { }, }); }); - expect(document.getElementById("root")?.textContent ?? "").toContain("0%"); + expect(document.getElementById("root")?.textContent ?? "").not.toContain( + "0% 已用", + ); await act(async () => { taskTraceCallback?.({ @@ -1241,7 +1276,7 @@ describe("ChatPanel message rendering", () => { }); const usageButton = document.querySelector( - 'button[aria-label*="已用 300 标记,共 258k"]', + 'button[aria-label*="等待正式用量"]', ); expect( usageButton?.querySelector('[data-context-usage-ring="true"]'), diff --git a/src/renderer/components/chat/__tests__/ContextUsageButton.test.tsx b/src/renderer/components/chat/__tests__/ContextUsageButton.test.tsx index e62dfeb7..88dabeee 100644 --- a/src/renderer/components/chat/__tests__/ContextUsageButton.test.tsx +++ b/src/renderer/components/chat/__tests__/ContextUsageButton.test.tsx @@ -71,43 +71,26 @@ describe("ContextUsageButton", () => { expect(html).not.toContain("安全余量"); }); - it("marks estimated context usage without marking actual provider usage", () => { - const estimatedHtml = renderToStaticMarkup( - , - ); - expect(estimatedHtml).toContain("5% 已用(估算)"); - expect(estimatedHtml).toContain(">估算"); - - const actualHtml = renderToStaticMarkup( + it("never presents context usage as an estimate", () => { + const html = renderToStaticMarkup( , ); - expect(actualHtml).toContain("13% 已用"); - expect(actualHtml).toContain("已用 34k 标记,共 258k"); - expect(actualHtml).toContain("累计输入 69k 标记"); - expect(actualHtml).not.toContain("估算"); - expect(actualHtml).not.toContain("背景信息窗口"); + expect(html).toContain("13% 已用"); + expect(html).toContain("已用 34k 标记,共 258k"); + expect(html).not.toContain("估算"); + expect(html).not.toContain("背景信息窗口"); }); it("shows provider-native compaction status when enabled", () => { const html = renderToStaticMarkup( { , ); const buttonHtml = html.match(/]*>([\s\S]*?)<\/button>/)?.[1]; - expect(buttonHtml).toContain('data-context-usage-percent="0"'); + expect(buttonHtml).toContain('data-context-usage-percent="unknown"'); expect(buttonHtml).not.toContain("0%"); - expect(html).toContain("0% 已用"); - expect(html).toContain("已用 0 标记,共 258k"); + expect(html).toContain("-- 已用"); + expect(html).toContain("等待正式用量"); expect(html).not.toContain("背景信息窗口"); - expect(html).not.toContain("发送后更新"); + }); + + it("renders Pi-style session message and token statistics", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("消息"); + expect(html).toContain("用户"); + expect(html).toContain("工具调用"); + expect(html).toContain("工具结果"); + expect(html).toContain("Token"); + expect(html).toContain("28,076"); + expect(html).toContain("315,392"); + expect(html).toContain("348,743"); + expect(html).toContain("$0.0063"); + expect(html).toContain("3.3% / 1.1M"); }); }); diff --git a/src/renderer/components/chat/__tests__/useChatSession.test.tsx b/src/renderer/components/chat/__tests__/useChatSession.test.tsx index 0146e6b2..5cb4379b 100644 --- a/src/renderer/components/chat/__tests__/useChatSession.test.tsx +++ b/src/renderer/components/chat/__tests__/useChatSession.test.tsx @@ -476,7 +476,6 @@ describe("useChatSession", () => { expect.objectContaining({ assistantMessageId: latest?.messages[3].id, chatPermissionMode: "request", - contextInputTokens: 221_000, history: expect.arrayContaining([ expect.objectContaining({ id: latest?.messages[0].id, @@ -489,6 +488,10 @@ describe("useChatSession", () => { sessionId: "session-existing", }), ); + const executeCalls = filework.executeTask.mock.calls as unknown as Array< + [Record] + >; + expect(executeCalls[0]?.[0]).not.toHaveProperty("contextInputTokens"); }); it("persists streamed provider context on the assistant message", async () => { diff --git a/src/renderer/components/chat/session-stats.ts b/src/renderer/components/chat/session-stats.ts new file mode 100644 index 00000000..1564a27e --- /dev/null +++ b/src/renderer/components/chat/session-stats.ts @@ -0,0 +1,92 @@ +import { calculateModelUsageCost } from "../../../shared/model-pricing"; +import type { ChatMessage, ToolPart, UsagePart } from "./types"; + +export interface SessionStats { + userMessages: number; + assistantMessages: number; + toolCalls: number; + toolResults: number; + totalMessages: number; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + totalTokens: number; + costUsd: number | null; +} + +const isToolResult = (part: ToolPart): boolean => + part.state === "output-available" || part.state === "output-error"; + +export const buildSessionStats = (messages: ChatMessage[]): SessionStats => { + let userMessages = 0; + let assistantMessages = 0; + let toolCalls = 0; + let toolResults = 0; + let inputTokens = 0; + let outputTokens = 0; + let cacheReadTokens = 0; + let cacheWriteTokens = 0; + let totalTokens = 0; + let pricedUsageRows = 0; + let usageRows = 0; + let costUsd = 0; + + for (const message of messages) { + if (message.role === "user") { + userMessages += 1; + continue; + } + assistantMessages += 1; + for (const part of message.parts ?? []) { + if (part.type === "tool") { + toolCalls += 1; + if (isToolResult(part as ToolPart)) toolResults += 1; + continue; + } + if (part.type !== "usage") continue; + + const usage = part as UsagePart; + const input = usage.inputTokens ?? 0; + const output = usage.outputTokens ?? 0; + const cacheRead = usage.cacheReadTokens ?? 0; + const cacheWrite = usage.cacheWriteTokens ?? 0; + inputTokens += input; + outputTokens += output; + cacheReadTokens += cacheRead; + cacheWriteTokens += cacheWrite; + totalTokens += + usage.totalTokens ?? input + output + cacheRead + cacheWrite; + usageRows += 1; + + const rowCost = + usage.costUsd ?? + (usage.provider === "ollama" + ? 0 + : calculateModelUsageCost(usage.modelId, { + inputTokens: input, + outputTokens: output, + cacheReadTokens: cacheRead, + cacheWriteTokens: cacheWrite, + })); + if (rowCost != null) { + pricedUsageRows += 1; + costUsd += rowCost; + } + } + } + + return { + userMessages, + assistantMessages, + toolCalls, + toolResults, + totalMessages: userMessages + assistantMessages + toolResults, + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + totalTokens, + costUsd: usageRows > 0 && pricedUsageRows === usageRows ? costUsd : null, + }; +}; diff --git a/src/renderer/components/chat/useChatSession.ts b/src/renderer/components/chat/useChatSession.ts index a3a21225..d4994c34 100644 --- a/src/renderer/components/chat/useChatSession.ts +++ b/src/renderer/components/chat/useChatSession.ts @@ -23,7 +23,6 @@ import type { ChatSession, ClarificationPart, MessagePart, - UsagePart, } from "./types"; import { usePlanFlow } from "./usePlanFlow"; import { useSessionCrud } from "./useSessionCrud"; @@ -39,30 +38,15 @@ export interface UsageInfo { inputTokens: number | null; outputTokens: number | null; totalTokens: number | null; + cacheReadTokens?: number | null; + cacheWriteTokens?: number | null; + costUsd?: number | null; modelId: string | null; provider: string | null; latestStepContextTokens?: number | null; maxStepContextTokens?: number | null; } -const getLatestUsageInputTokens = ( - messages: Array<{ parts?: MessagePart[] | undefined }>, -): number => { - for (let i = messages.length - 1; i >= 0; i -= 1) { - const parts = messages[i]?.parts ?? []; - for (let j = parts.length - 1; j >= 0; j -= 1) { - const part = parts[j]; - if (part.type !== "usage") continue; - const usage = part as UsagePart; - const inputTokens = usage.latestStepContextTokens ?? usage.inputTokens; - if (typeof inputTokens === "number" && Number.isFinite(inputTokens)) { - return inputTokens; - } - } - } - return 0; -}; - export interface StreamErrorInfo { message: string; type?: string; @@ -525,8 +509,6 @@ export function useChatSession( parts: parts?.filter((p) => p.type !== "plan"), }); } - const contextInputTokens = getLatestUsageInputTokens(history); - window.filework .executeTask({ prompt: userMessage.content, @@ -535,7 +517,6 @@ export function useChatSession( sessionId, assistantMessageId: assistantId, chatPermissionMode: chatPermissionModeRef.current, - contextInputTokens, llmConfigId: selectedLlmConfigId || undefined, history, }) @@ -658,8 +639,6 @@ export function useChatSession( content, parts: parts?.filter((p) => p.type !== "plan"), })); - const contextInputTokens = getLatestUsageInputTokens(history); - window.filework .executeTask({ prompt, @@ -669,7 +648,6 @@ export function useChatSession( assistantMessageId: assistantId, automationRunId: prepared.id, chatPermissionMode: chatPermissionModeRef.current, - contextInputTokens, llmConfigId: prepared.modelId ?? automation.modelId ?? diff --git a/src/renderer/components/chat/useSessionCrud.ts b/src/renderer/components/chat/useSessionCrud.ts index 8cce44ef..867dac5f 100644 --- a/src/renderer/components/chat/useSessionCrud.ts +++ b/src/renderer/components/chat/useSessionCrud.ts @@ -272,6 +272,9 @@ export function useSessionCrud( inputTokens: usagePart.inputTokens, outputTokens: usagePart.outputTokens, totalTokens: usagePart.totalTokens, + cacheReadTokens: usagePart.cacheReadTokens ?? null, + cacheWriteTokens: usagePart.cacheWriteTokens ?? null, + costUsd: usagePart.costUsd ?? null, modelId: usagePart.modelId, provider: usagePart.provider, latestStepContextTokens: diff --git a/src/shared/model-pricing.ts b/src/shared/model-pricing.ts new file mode 100644 index 00000000..814fd44a --- /dev/null +++ b/src/shared/model-pricing.ts @@ -0,0 +1,162 @@ +export interface ModelPrice { + inputUsdPerMTok: number; + outputUsdPerMTok: number; + cacheReadUsdPerMTok?: number; + cacheWriteUsdPerMTok?: number; + longContext?: { + inputTokensAbove: number; + inputMultiplier: number; + outputMultiplier: number; + }; +} + +export interface ModelUsageForCost { + inputTokens: number; + outputTokens: number; + cacheReadTokens?: number | null; + cacheWriteTokens?: number | null; +} + +export const normalizeModelId = (id: string): string => + id + .toLowerCase() + .replace(/-(2\d{7})$/i, "") + .replace(/-latest$/i, ""); + +export const MODEL_PRICES: Readonly> = Object.freeze( + { + "claude-opus-4-7": { + inputUsdPerMTok: 15, + outputUsdPerMTok: 75, + cacheReadUsdPerMTok: 1.5, + cacheWriteUsdPerMTok: 18.75, + }, + "claude-sonnet-4-6": { + inputUsdPerMTok: 3, + outputUsdPerMTok: 15, + cacheReadUsdPerMTok: 0.3, + cacheWriteUsdPerMTok: 3.75, + }, + "claude-sonnet-4-7": { + inputUsdPerMTok: 3, + outputUsdPerMTok: 15, + cacheReadUsdPerMTok: 0.3, + cacheWriteUsdPerMTok: 3.75, + }, + "claude-haiku-4-5": { + inputUsdPerMTok: 1, + outputUsdPerMTok: 5, + cacheReadUsdPerMTok: 0.1, + cacheWriteUsdPerMTok: 1.25, + }, + "gpt-5.6-sol": { + inputUsdPerMTok: 5, + outputUsdPerMTok: 30, + cacheReadUsdPerMTok: 0.5, + cacheWriteUsdPerMTok: 6.25, + longContext: { + inputTokensAbove: 272_000, + inputMultiplier: 2, + outputMultiplier: 1.5, + }, + }, + "gpt-5.6": { + inputUsdPerMTok: 5, + outputUsdPerMTok: 30, + cacheReadUsdPerMTok: 0.5, + cacheWriteUsdPerMTok: 6.25, + longContext: { + inputTokensAbove: 272_000, + inputMultiplier: 2, + outputMultiplier: 1.5, + }, + }, + "gpt-5.6-terra": { + inputUsdPerMTok: 2.5, + outputUsdPerMTok: 15, + cacheReadUsdPerMTok: 0.25, + cacheWriteUsdPerMTok: 3.125, + longContext: { + inputTokensAbove: 272_000, + inputMultiplier: 2, + outputMultiplier: 1.5, + }, + }, + "gpt-5.6-luna": { + inputUsdPerMTok: 1, + outputUsdPerMTok: 6, + cacheReadUsdPerMTok: 0.1, + cacheWriteUsdPerMTok: 1.25, + longContext: { + inputTokensAbove: 272_000, + inputMultiplier: 2, + outputMultiplier: 1.5, + }, + }, + "gpt-5.5": { + inputUsdPerMTok: 5, + outputUsdPerMTok: 30, + cacheReadUsdPerMTok: 0.5, + longContext: { + inputTokensAbove: 272_000, + inputMultiplier: 2, + outputMultiplier: 1.5, + }, + }, + "gpt-4o": { inputUsdPerMTok: 2.5, outputUsdPerMTok: 10 }, + "gpt-4o-mini": { inputUsdPerMTok: 0.15, outputUsdPerMTok: 0.6 }, + "deepseek-chat": { inputUsdPerMTok: 0.14, outputUsdPerMTok: 0.28 }, + "deepseek-reasoner": { + inputUsdPerMTok: 0.55, + outputUsdPerMTok: 2.19, + }, + "abab6.5s-chat": { inputUsdPerMTok: 1, outputUsdPerMTok: 1 }, + "minimax-text-01": { inputUsdPerMTok: 1, outputUsdPerMTok: 1 }, + "mimo-v2.5-pro": { + inputUsdPerMTok: 0.43, + outputUsdPerMTok: 0.86, + cacheReadUsdPerMTok: 0.0036, + }, + "mimo-v2.5": { + inputUsdPerMTok: 0.14, + outputUsdPerMTok: 0.29, + cacheReadUsdPerMTok: 0.0029, + }, + }, +); + +export const getModelPrice = (model: string): ModelPrice | null => + MODEL_PRICES[normalizeModelId(model)] ?? null; + +export const calculateModelUsageCost = ( + model: string | null | undefined, + usage: ModelUsageForCost, +): number | null => { + if (!model) return null; + const price = getModelPrice(model); + if (!price) return null; + + const cacheReadTokens = usage.cacheReadTokens ?? 0; + const cacheWriteTokens = usage.cacheWriteTokens ?? 0; + if (cacheReadTokens > 0 && price.cacheReadUsdPerMTok == null) return null; + if (cacheWriteTokens > 0 && price.cacheWriteUsdPerMTok == null) return null; + + const promptTokens = usage.inputTokens + cacheReadTokens + cacheWriteTokens; + const usesLongContextTier = + price.longContext != null && + promptTokens > price.longContext.inputTokensAbove; + const inputMultiplier = usesLongContextTier + ? (price.longContext?.inputMultiplier ?? 1) + : 1; + const outputMultiplier = usesLongContextTier + ? (price.longContext?.outputMultiplier ?? 1) + : 1; + + return ( + (usage.inputTokens * price.inputUsdPerMTok * inputMultiplier + + usage.outputTokens * price.outputUsdPerMTok * outputMultiplier + + cacheReadTokens * (price.cacheReadUsdPerMTok ?? 0) * inputMultiplier + + cacheWriteTokens * (price.cacheWriteUsdPerMTok ?? 0) * inputMultiplier) / + 1_000_000 + ); +};