diff --git a/.agents/notes/proposed/bug-fix/2026-09-12-grok-token-accounting.md b/.agents/notes/proposed/bug-fix/2026-09-12-grok-token-accounting.md index c4bbd0518e..69ca3bbd08 100644 --- a/.agents/notes/proposed/bug-fix/2026-09-12-grok-token-accounting.md +++ b/.agents/notes/proposed/bug-fix/2026-09-12-grok-token-accounting.md @@ -201,6 +201,14 @@ change, package publication, historical data repair, or Kimi artifact rollout is authorized/performed by these scoped fixes. Model-unattributed Codex USD remains unknown; do not restore guessed UI-model pricing just to fill the field. +2026-09-15 correction: the pinned Codex runtime emits exact +`rawResponse/completed` usage with a `responseId`. The adapter now attributes +those events to the resolved thread/turn model and keeps only the thread-total +remainder in `codex:unattributed`; a `$CODEX_HOME` sidecar restores the ledger on +resume and fork source history is excluded. See +[Codex per-model usage attribution](2026-09-15-codex-per-model-attribution.md). +The durable consumer accounting identity remains unresolved. + ## Alternatives and limits - Rejected: queue per-prompt deltas into a cumulative consumer; still undercounts. diff --git a/.agents/notes/proposed/bug-fix/2026-09-12-grok-token-accounting.zh.md b/.agents/notes/proposed/bug-fix/2026-09-12-grok-token-accounting.zh.md index a391d729f0..e88aa41bbb 100644 --- a/.agents/notes/proposed/bug-fix/2026-09-12-grok-token-accounting.zh.md +++ b/.agents/notes/proposed/bug-fix/2026-09-12-grok-token-accounting.zh.md @@ -164,6 +164,13 @@ Prettier。定向格式化、文档检查、公共/平台边界检查通过。 这些范围内修复未授权/实施私有 backend 改动、包发布、历史数据修复或 Kimi artifact 上线。Codex 未归属模型的 USD 仍未知,不能为了填值恢复按 UI 模型猜价。 +2026-09-15 更正:锁定 Codex runtime 会发送带 `responseId` 的精确 +`rawResponse/completed` 用量。适配器现在把这些事件归到已解析的 thread/turn 模型, +只把 thread 总量中未被覆盖的余量留在 `codex:unattributed`;`$CODEX_HOME` sidecar +会在 resume 时恢复模型账本,fork 会话排除源历史。详见 +[Codex 分模型用量归因](2026-09-15-codex-per-model-attribution.zh.md)。 +消费端持久 accounting identity 仍未解决。 + ## 取舍与限制 - 否决:向累计消费端排队发送本轮增量,仍会偏低。 diff --git a/.agents/notes/proposed/bug-fix/2026-09-15-codex-per-model-attribution.md b/.agents/notes/proposed/bug-fix/2026-09-15-codex-per-model-attribution.md new file mode 100644 index 0000000000..198e6ae47c --- /dev/null +++ b/.agents/notes/proposed/bug-fix/2026-09-15-codex-per-model-attribution.md @@ -0,0 +1,68 @@ +# Codex usage: native snapshots attributed per turn + +Status: proposed +Translation: current + +[中文](2026-09-15-codex-per-model-attribution.zh.md) + +## Abstract + +Codex usage now comes from native cumulative snapshots, with each turn's new +tokens attributed to the model submitted for that turn. This replaces both the +initial raw-response/sidecar ledger proposal and the intermediate unattributed +snapshot design. The adapter keeps only an in-memory previous snapshot and active +turn counters; the CLI preserves turn-scoped accounting identities using the +existing hosted API. Exact crash, fork, reset and reroute accounting is not promised. + +## Decision and responsibilities + +The initial proposal preserved historical model totals across restarts through +raw completions and a local sidecar. The subsequent native-snapshot simplification +removed that ledger but emitted unattributed totals. The clarified requirement +is to attribute **new usage in the current turn**, not to relabel historical totals +with the current UI model. + +```text +native root cumulative snapshot + -> adapter snapshot difference + submitted turn model + -> turn-cumulative update + notification-local usageTurnId + -> CLI stable nativeSessionId:turn:encodedTurnId accounting key + -> existing hosted per-key/model/field maximum +``` + +The adapter freezes the submitted model before asynchronous prompt dispatch, so +a later UI model switch does not relabel the active turn. Native Goal continuation +turns retain that submitted model. Duplicate snapshots produce no new tokens. +A resumed native snapshot anchors the comparison without billing its history; +when unavailable, the first snapshot only anchors and may omit a new response. + +The CLI recognizes the marker only for Codex. Its accounting identity is separate +from the real ACP session ID and never changes session routing or metadata. +Separate turn keys allow A=10000 followed by B=2000 to total 12000 instead of +taking a session-wide maximum. Unmarked adapters retain their existing scope. +No private backend, Core schema, session metadata baseline, disk sidecar, +raw-response ledger or historical model map is added. + +## Tradeoffs and rollout + +- Ship the adapter with its matching CLI: an older CLI cannot interpret the new + turn-local cumulative scope correctly. +- Native child-thread usage is not added to the root. Reroute, compaction/reset, + fork and crash recovery remain best effort; there is no durable replay ledger. +- Existing experimental usage rows are not migrated or reconciled. Old sidecar + files are ignored, not deleted. Runtime artifacts still need rebuild/release. +- Both intentional gitlink changes remain uncommitted. + +## Evidence and limits + +Behavioral tests cover model switches, submitted-model freezing, Goal continuation, +duplicate snapshots, resumed history, missing resume snapshots and counter resets. +The parser-to-usage-service test covers turn A=10000, turn B=2000 and a restarted +service's turn C=500, with simulated hosted maximum persistence totaling 12500. +The final validation results are reported with the commits; earlier snapshot-only +validation is not evidence for this revision. No paid model completion, deployed +cloud reconciliation or real crash test has run. + +- [Adapter PR #45](https://github.com/LodyAI/acp-extension-codex/pull/45) +- [Lody PR #736](https://github.com/LodyAI/Lody/pull/736) +- [Usage delivery Spec](../../../../specs/usage-delivery.md) diff --git a/.agents/notes/proposed/bug-fix/2026-09-15-codex-per-model-attribution.zh.md b/.agents/notes/proposed/bug-fix/2026-09-15-codex-per-model-attribution.zh.md new file mode 100644 index 0000000000..c62a9c639b --- /dev/null +++ b/.agents/notes/proposed/bug-fix/2026-09-15-codex-per-model-attribution.zh.md @@ -0,0 +1,59 @@ +# Codex 用量:按 turn 归因原生快照 + +Status: proposed +Translation: current + +[English](2026-09-15-codex-per-model-attribution.md) + +## 摘要 + +Codex 用量来自原生累计快照,每个 turn 的新增 token 归给该 turn 提交的模型。 +这取代了最初的 raw-response/sidecar 账本方案,以及中间的未归属快照方案。 +adapter 只在内存保留上一条快照和当前 turn 计数;CLI 使用现有托管 API, +以 turn 区分计量身份。不承诺 crash、fork、reset 和 reroute 的精确计量。 + +## 决定与职责 + +最初方案通过 raw completion 和本地 sidecar 跨重启保留历史模型总量。 +随后原生快照简化移除了账本,但上报未归属总量。澄清后的要求是归因 +**当前 turn 的新增用量**,而不是把历史累计量改归当前 UI 模型。 + +```text +原生根 thread 累计快照 + -> adapter 快照差值 + 提交时的 turn 模型 + -> turn 内累计更新 + 通知局部 usageTurnId + -> CLI 稳定 nativeSessionId:turn:encodedTurnId 计量 key + -> 现有托管端逐 key/model/字段取最大值 +``` + +adapter 在异步提交请求前冻结模型,之后切换 UI 模型不会改写当前 turn 归因。 +原生 Goal 后续 turn 保留提交模型。重复快照不产生新增 token。 +恢复时已有原生快照只作为比较起点、不计入历史;若快照缺失,首条仅作为起点, +因此可能漏掉一条新增响应。 + +CLI 仅对 Codex 识别此标记。计量身份与真实 ACP session ID 分开, +不修改会话路由或 metadata。分 turn key 使 A=10000、随后 B=2000 累计为 +12000,而不是按整段 session 取最大值。未标记 adapter 保持原有范围。 +不增加私有后端、Core schema、session metadata baseline、磁盘 sidecar、 +raw-response 账本或历史模型映射。 + +## 取舍与发布 + +- adapter 必须与匹配的 CLI 一起发布,旧 CLI 无法正确解释新的 turn 内累计范围。 +- 不将子 thread 用量加入根会话。reroute、compaction/reset、fork 和 crash + 恢复尽力而为,不维护持久化回放账本。 +- 不迁移或修复已有实验计量行;旧 sidecar 文件忽略、不删除。 + Runtime 产物仍需重新构建/发布。 +- 两个有意保留的 gitlink 变化均不提交。 + +## 证据与限制 + +行为测试覆盖切换模型、提交模型冻结、Goal 延续、重复快照、恢复历史、 +恢复快照缺失和计数重置。parser 到 usage service 测试覆盖 turn A=10000、 +turn B=2000、服务重启后 turn C=500,模拟托管端最大值持久化后合计 12500。 +最终验证结果随提交报告;此前仅原生快照版本的验证不能作为本版本证据。 +未运行付费模型 completion、线上云端数据修复或真实崩溃测试。 + +- [Adapter PR #45](https://github.com/LodyAI/acp-extension-codex/pull/45) +- [Lody PR #736](https://github.com/LodyAI/Lody/pull/736) +- [用量投递 Spec](../../../../specs/usage-delivery.zh.md) diff --git a/apps/cli/src/agent/agent-client.ts b/apps/cli/src/agent/agent-client.ts index 7d4ebe3c74..e6a4c0caff 100644 --- a/apps/cli/src/agent/agent-client.ts +++ b/apps/cli/src/agent/agent-client.ts @@ -302,23 +302,6 @@ function withAbort(promise: Promise, abortPromise?: Promise): Promi return Promise.race([promise, abortPromise]); } -type SessionModelUsage = NonNullable[string]; - -const toModelUsageFromUsage = (usage: SessionUsageUpdate['usage']): SessionModelUsage => { - const rawCostUSD = (usage as { costUSD?: unknown }).costUSD; - const costUSD = - typeof rawCostUSD === 'number' && Number.isFinite(rawCostUSD) ? rawCostUSD : undefined; - - return { - inputTokens: usage.inputTokens, - outputTokens: usage.outputTokens, - cacheReadInputTokens: usage.cacheReadInputTokens, - cacheCreationInputTokens: usage.cacheCreationInputTokens, - reasoningOutputTokens: usage.reasoningOutputTokens, - costUSD, - }; -}; - const sanitizeModelUsage = ( modelUsage: SessionUsageUpdate['modelUsage'] ): SessionUsageUpdate['modelUsage'] => { @@ -605,7 +588,7 @@ export interface AgentClientOptions { requestId: string, request: acp.RequestPermissionRequest ): Promise; - onUsageUpdate?(usage: SessionUsageUpdate): void; + onUsageUpdate?(usage: SessionUsageUpdate, accountingId?: string): void; onContextWindowUsageUpdate?(usage: SessionContextWindowUsage): void; onRateLimitUpdate?(limits: RateLimit): void; onThreadGoalUpdated?(goal: SessionGoalContent): void; @@ -1479,11 +1462,15 @@ export class AgentClient implements acp.Client { } switch (event.type) { case 'usage': { - const modelUsage = - event.update.modelUsage == null && this.currentModel - ? { [this.currentModel.modelId]: toModelUsageFromUsage(event.update.usage) } - : sanitizeModelUsage(event.update.modelUsage); - this.options.onUsageUpdate?.({ ...event.update, modelUsage }); + // Never invent a model from the UI selection. Legacy adapters without + // modelUsage stay unattributed/skipped instead of being misattributed. + this.options.onUsageUpdate?.( + { + ...event.update, + modelUsage: sanitizeModelUsage(event.update.modelUsage), + }, + event.accountingId + ); return; } case 'rateLimits': diff --git a/apps/cli/src/agent/lody-acp-extension.test.ts b/apps/cli/src/agent/lody-acp-extension.test.ts index 15e324c1d3..c79f5601f3 100644 --- a/apps/cli/src/agent/lody-acp-extension.test.ts +++ b/apps/cli/src/agent/lody-acp-extension.test.ts @@ -8,6 +8,31 @@ import { } from './lody-acp-extension'; describe('Core usage accounting boundary', () => { + it('scopes only marked Codex turn snapshots and keeps their native routing session unchanged', () => { + const usage = { inputTokens: 2000, outputTokens: 0, cacheReadInputTokens: 0 }; + const params = { + sessionId: 'native', + usage, + modelUsage: { 'model-b': usage }, + _meta: { codex: { usageTurnId: 'turn-b' } }, + }; + const parse = (provider: string, value = params) => + parseLodyExtensionMessage({ + method: LODY_EXTENSION_METHODS.sessionUsageUpdate, + params: value, + sessionId: 'native', + provider, + }); + expect(parse('codex')).toEqual({ + type: 'usage', + accountingId: 'native:turn:turn-b', + update: { sessionId: 'native', usage, modelUsage: { 'model-b': usage } }, + }); + expect(parse('claude')).toEqual({ + type: 'usage', + update: { sessionId: 'native', usage, modelUsage: { 'model-b': usage } }, + }); + }); it.each(['codex', 'claude', 'kimi', 'grok', 'deepseek'] as const)( 'preserves %s optional delta separately from cumulative totals and rejects invalid buckets', (provider) => { diff --git a/apps/cli/src/agent/lody-acp-extension.ts b/apps/cli/src/agent/lody-acp-extension.ts index 2cc7f747ac..82eb35e1cc 100644 --- a/apps/cli/src/agent/lody-acp-extension.ts +++ b/apps/cli/src/agent/lody-acp-extension.ts @@ -155,7 +155,7 @@ const LEGACY_METHODS = { } as const; export type LodyExtensionEvent = - | { readonly type: 'usage'; readonly update: SessionUsageUpdate } + | { readonly type: 'usage'; readonly update: SessionUsageUpdate; readonly accountingId?: string } | { readonly type: 'rateLimits'; readonly snapshot: RateLimitsSnapshot } | { readonly type: 'legacyProposedPlan'; @@ -219,7 +219,22 @@ export function parseLodyExtensionMessage(args: { }): LodyExtensionEvent | null { const method = normalizeLodyExtensionMethod(args.method); if (method === LODY_EXTENSION_METHODS.sessionUsageUpdate) { - return { type: 'usage', update: SessionUsageUpdateSchema.parse(args.params) }; + const update = SessionUsageUpdateSchema.parse(args.params); + // Codex turn totals use a stable per-turn identity; unmarked adapters retain their scope. + const scope = z + .object({ + _meta: z.object({ codex: z.object({ usageTurnId: z.string().min(1).max(256) }) }), + }) + .safeParse(args.params); + return { + type: 'usage', + update, + ...(args.provider === 'codex' && scope.success + ? { + accountingId: `${args.sessionId}:turn:${encodeURIComponent(scope.data._meta.codex.usageTurnId)}`, + } + : {}), + }; } if (method === LODY_EXTENSION_METHODS.rateLimitsUpdate) { return { type: 'rateLimits', snapshot: parseRateLimitsSnapshot(args.params) }; diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index e5ebec15eb..14c8ffcc26 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -1063,7 +1063,7 @@ export class MessageHandler { private async handleUsageUpdate( sessionId: SessionId, - acpSessionId: ACPSessionId, + acpSessionId: string, update: SessionUsageUpdate ): Promise { try { @@ -3580,8 +3580,8 @@ export class MessageHandler { ); }); - this.sessionManager.on('onUsageUpdate', ({ sessionId, acpSessionId, usage }) => { - const promise = this.handleUsageUpdate(sessionId, acpSessionId, usage); + this.sessionManager.on('onUsageUpdate', ({ sessionId, acpSessionId, usage, accountingId }) => { + const promise = this.handleUsageUpdate(sessionId, accountingId ?? acpSessionId, usage); const usageState = this.store.get(sessionId); usageState.pendingUsageHandlers.add(promise); void promise.finally(() => { diff --git a/apps/cli/src/lib/usage/AGENTS.md b/apps/cli/src/lib/usage/AGENTS.md index bd835b6416..45da9202c4 100644 --- a/apps/cli/src/lib/usage/AGENTS.md +++ b/apps/cli/src/lib/usage/AGENTS.md @@ -10,9 +10,11 @@ managed-runtime download catalog. Local composition still has no cloud service. - Failed delivery retains the exact payload ahead of newer updates. Concurrent flushes share one drain; rejection waits for a later flush instead of spinning. -- Keep legacy Codex compaction offsets across acknowledged flushes, separate from - adapter-owned cumulative updates carrying delta. Never turn unknown costs into zero - while queueing or retrying; adapters own request-level price estimates. Preserve - provider costs; omit estimates when cache-write pricing is unavailable. +- Codex turn snapshots carry notification-local `_meta.codex.usageTurnId`. + The provider parser passes a stable native-session/turn accounting identity to + delivery; never overwrite the real ACP session ID or session metadata. + Coalesce within a turn identity, never across turns. Unmarked adapters retain + their existing cumulative identity. No CLI reset offsets or model guessing. + Preserve provider costs; unknown tariffs remain unknown. - The queue is process-local; it is not a restart-safe ledger. Local composition still has no cloud usage service. See [delivery Spec](../../../../../specs/usage-delivery.md). diff --git a/apps/cli/src/lib/usage/usage-tracking-service.test.ts b/apps/cli/src/lib/usage/usage-tracking-service.test.ts index b718eaaf76..c8a8c77e68 100644 --- a/apps/cli/src/lib/usage/usage-tracking-service.test.ts +++ b/apps/cli/src/lib/usage/usage-tracking-service.test.ts @@ -2,6 +2,8 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; import { ConvexHttpClient } from 'convex/browser'; import type { Logger } from '@/utils/logger'; import { UsageTrackingService, type RecordSessionUsageInput } from './usage-tracking-service'; +import { parseLodyExtensionMessage } from '@/agent/lody-acp-extension'; +import { LODY_EXTENSION_METHODS } from 'acp-extension-core'; type Payload = Omit & RecordSessionUsageInput['update']; const logger: Logger = { @@ -83,6 +85,56 @@ describe('usage delivery', () => { }); afterEach(() => vi.restoreAllMocks()); + it('persists turn snapshots independently across model switches, retries and adapter restarts', async () => { + const report = (turnId: string, model: string, tokens: number) => { + const usage = { inputTokens: tokens, outputTokens: 0, cacheReadInputTokens: 0 }; + const event = parseLodyExtensionMessage({ + method: LODY_EXTENSION_METHODS.sessionUsageUpdate, + sessionId: 'native', + provider: 'codex', + params: { + sessionId: 'native', + usage, + modelUsage: { [model]: usage }, + _meta: { codex: { usageTurnId: turnId } }, + }, + }); + if (event?.type !== 'usage' || !event.accountingId) + throw new Error('Missing accounting scope'); + service.recordSessionUsageUpdate({ + ...input(0, 'codex'), + acpSessionId: event.accountingId, + update: event.update, + }); + }; + report('a', 'model-a', 10000); + report('b', 'model-b', 1000); + report('b', 'model-b', 2000); + await service.flushSessionUsage('s'); + report('b', 'model-b', 2000); // Replay uses the same hosted idempotency key. + await service.flushSessionUsage('s'); + service = new UsageTrackingService({ + convexUrl: 'https://synthetic.convex.cloud', + cliToken: 'synthetic', + logger, + }); + report('c', 'model-b', 500); + await service.flushSessionUsage('s'); + const stored = new Map(); + for (const payload of persisted) { + for (const [model, usage] of Object.entries(payload.modelUsage ?? {})) { + const key = `${payload.acpSessionId}:${model}`; + stored.set(key, Math.max(stored.get(key) ?? 0, usage.inputTokens)); + } + } + expect([...stored.entries()]).toEqual([ + ['native:turn:a:model-a', 10000], + ['native:turn:b:model-b', 2000], + ['native:turn:c:model-b', 500], + ]); + expect([...stored.values()].reduce((a, b) => a + b, 0)).toBe(12500); + }); + it('projects provider fields without losing token buckets, known costs or unknown costs', async () => { const report = input(100); report.update.usage.webSearchRequests = 2; @@ -214,7 +266,7 @@ describe('usage delivery', () => { expect(persisted.map((p) => p.usage.inputTokens)).toEqual([100, 200]); }); - it('retains cumulative-provider coalescing and Codex compaction', async () => { + it('coalesces the latest snapshot without compensating Codex resets', async () => { service.recordSessionUsageUpdate(input(100, 'claude')); service.recordSessionUsageUpdate(input(200, 'claude')); await service.flushSessionUsage('s'); @@ -227,10 +279,10 @@ describe('usage delivery', () => { service.recordSessionUsageUpdate(reset); service.recordSessionUsageUpdate(input(20, 'codex')); await service.flushSessionUsage('s'); - expect(persisted.map((p) => p.usage.inputTokens)).toEqual([120]); + expect(persisted.map((p) => p.usage.inputTokens)).toEqual([20]); }); - it('keeps legacy Codex compaction offsets after acknowledgement without inventing cost', async () => { + it('forwards native Codex resets after acknowledgement without inventing history or cost', async () => { service.recordSessionUsageUpdate(input(1000, 'codex')); await service.flushSessionUsage('s'); const reset = input(0, 'codex'); @@ -241,11 +293,11 @@ describe('usage delivery', () => { service.recordSessionUsageUpdate(input(50, 'codex')); await service.flushSessionUsage('s'); await service.flushSessionUsage('s'); - expect(persisted.map((p) => p.modelUsage?.synthetic.inputTokens)).toEqual([1000, 1000, 1050]); + expect(persisted.map((p) => p.modelUsage?.synthetic.inputTokens)).toEqual([1000, 0, 50]); expect(persisted[2]?.modelUsage?.synthetic.costUSD).toBeUndefined(); }); - it('does not apply legacy compaction to adapter-owned cumulative accounting', async () => { + it('preserves cumulative model snapshots independently of aggregate usage', async () => { const update = input(1000, 'codex'); update.update.delta = { usage: update.update.usage, diff --git a/apps/cli/src/lib/usage/usage-tracking-service.ts b/apps/cli/src/lib/usage/usage-tracking-service.ts index 9ee7ef977a..f34e06f7b8 100644 --- a/apps/cli/src/lib/usage/usage-tracking-service.ts +++ b/apps/cli/src/lib/usage/usage-tracking-service.ts @@ -27,16 +27,12 @@ type PendingKey = string; type PendingState = { latestMeta: Omit; staged: SessionUsageUpdate | null; - compacted: SessionUsageUpdate | null; - // Legacy Codex accounting baseline outlives delivery acknowledgement. - lastLegacyCodex: SessionUsageUpdate | null; // Keep one unacknowledged payload; newer updates coalesce separately in staged. unacknowledged: RecordSessionUsageInput | null; inFlight: Promise | null; }; -// Keep per-ACP-session accumulation isolated to avoid snapshot baseline resets -// when one Lody session is resumed as a brand new ACP session. +// Keep delivery queues isolated by native session identity. const toPendingKey = ( input: Pick ): PendingKey => `${input.workspaceId}:${input.sessionId}:${input.acpSessionId}:${input.userId}`; @@ -79,61 +75,6 @@ const persistedCounters = (usage: SessionUsageUpdate['usage']) => { return counters; }; -const mergeModelUsage = ( - base: SessionUsageUpdate['modelUsage'], - delta: SessionUsageUpdate['modelUsage'] -): SessionUsageUpdate['modelUsage'] => { - const merged: NonNullable = {}; - if (base) { - for (const [model, usage] of Object.entries(base)) { - merged[model] = { ...usage }; - } - } - if (!delta) { - return Object.keys(merged).length > 0 ? merged : undefined; - } - - for (const [model, usage] of Object.entries(delta)) { - const prev = merged[model]; - merged[model] = { - inputTokens: (prev?.inputTokens ?? 0) + usage.inputTokens, - outputTokens: (prev?.outputTokens ?? 0) + usage.outputTokens, - cacheReadInputTokens: (prev?.cacheReadInputTokens ?? 0) + usage.cacheReadInputTokens, - cacheCreationInputTokens: - (prev?.cacheCreationInputTokens ?? 0) + (usage.cacheCreationInputTokens ?? 0), - reasoningOutputTokens: - (prev?.reasoningOutputTokens ?? 0) + (usage.reasoningOutputTokens ?? 0), - ...((!prev || prev.costUSD !== undefined) && usage.costUSD !== undefined - ? { costUSD: (prev?.costUSD ?? 0) + usage.costUSD } - : {}), - contextWindow: usage.contextWindow ?? prev?.contextWindow, - }; - } - - return Object.keys(merged).length > 0 ? merged : undefined; -}; - -const mergeUsageUpdate = ( - base: SessionUsageUpdate, - delta: SessionUsageUpdate -): SessionUsageUpdate => ({ - sessionId: delta.sessionId, - usage: { - inputTokens: base.usage.inputTokens + delta.usage.inputTokens, - outputTokens: base.usage.outputTokens + delta.usage.outputTokens, - cacheReadInputTokens: base.usage.cacheReadInputTokens + delta.usage.cacheReadInputTokens, - cacheCreationInputTokens: - (base.usage.cacheCreationInputTokens ?? 0) + (delta.usage.cacheCreationInputTokens ?? 0), - reasoningOutputTokens: - (base.usage.reasoningOutputTokens ?? 0) + (delta.usage.reasoningOutputTokens ?? 0), - ...(base.usage.costUSD !== undefined && delta.usage.costUSD !== undefined - ? { costUSD: base.usage.costUSD + delta.usage.costUSD } - : {}), - contextWindow: delta.usage.contextWindow ?? base.usage.contextWindow, - }, - modelUsage: mergeModelUsage(base.modelUsage, delta.modelUsage), -}); - export class UsageTrackingService { private readonly client: ConvexHttpClient; private readonly cliToken: string; @@ -149,7 +90,7 @@ export class UsageTrackingService { recordSessionUsageUpdate(input: RecordSessionUsageInput): void { const key = toPendingKey(input); - // Take ownership once; staged/compaction/delivery only replace or merge snapshots. + // Own the snapshot while coalescing or retrying delivery. const update = this.calculatePrice(cloneUsageUpdate(input.update), input.cliType); const latestMeta = { workspaceId: input.workspaceId, @@ -163,19 +104,16 @@ export class UsageTrackingService { const existing = this.pending.get(key); if (existing) { existing.latestMeta = latestMeta; - this.applyUpdateToState(existing, input.cliType, update); + existing.staged = update; return; } const state: PendingState = { latestMeta, - staged: null, - compacted: null, - lastLegacyCodex: null, + staged: update, unacknowledged: null, inFlight: null, }; - this.applyUpdateToState(state, input.cliType, update); this.pending.set(key, state); this.addPendingKeyToSession(input.sessionId, key); } @@ -204,7 +142,7 @@ export class UsageTrackingService { private async drainPending(state: PendingState): Promise { for (;;) { if (!state.unacknowledged) { - const update = this.buildFinalUpdate(state); + const update = state.staged; if (!update) return; state.unacknowledged = { ...state.latestMeta, update }; state.staged = null; @@ -248,49 +186,6 @@ export class UsageTrackingService { } } - private applyUpdateToState( - state: PendingState, - cliType: BuiltinAgentType, - update: SessionUsageUpdate - ): void { - if (cliType === 'codex' && !update.delta && this.isCodexCompaction(update)) { - if (state.lastLegacyCodex && !this.isCodexCompaction(state.lastLegacyCodex)) { - state.compacted = state.compacted - ? mergeUsageUpdate(state.compacted, state.lastLegacyCodex) - : state.lastLegacyCodex; - } - state.lastLegacyCodex = update; - state.staged = update; - return; - } - if (cliType === 'codex' && !update.delta) state.lastLegacyCodex = update; - else { - state.lastLegacyCodex = null; - state.compacted = null; - } - state.staged = update; - } - - private buildFinalUpdate(state: PendingState): SessionUsageUpdate | null { - if (!state.staged) { - return null; - } - if (!state.compacted) { - return state.staged; - } - return mergeUsageUpdate(state.compacted, state.staged); - } - - private isCodexCompaction(update: SessionUsageUpdate): boolean { - return ( - update.usage.inputTokens === 0 && - update.usage.outputTokens === 0 && - update.usage.cacheReadInputTokens === 0 && - (update.usage.cacheCreationInputTokens ?? 0) === 0 && - (update.usage.reasoningOutputTokens ?? 0) === 0 - ); - } - private addPendingKeyToSession(sessionId: string, key: PendingKey): void { const keys = this.sessionToPendingKeys.get(sessionId); if (keys) { @@ -313,8 +208,7 @@ export class UsageTrackingService { const state = this.pending.get(key); if (!state) return; if (state.inFlight) return; - if (state.staged || state.compacted || state.unacknowledged) return; - if (state.lastLegacyCodex) return; + if (state.staged || state.unacknowledged) return; this.pending.delete(key); this.removePendingKeyFromSession(state.latestMeta.sessionId, key); diff --git a/apps/cli/src/session/session-manager.ts b/apps/cli/src/session/session-manager.ts index 1e5968ce8b..f122642dd2 100644 --- a/apps/cli/src/session/session-manager.ts +++ b/apps/cli/src/session/session-manager.ts @@ -389,7 +389,7 @@ export interface CreateAgentConfig { requestId: string, request: RequestPermissionRequest ) => Promise; - onUsageUpdate: (usage: SessionUsageUpdate) => void; + onUsageUpdate: (usage: SessionUsageUpdate, accountingId?: string) => void; onContextWindowUsageUpdate: (usage: SessionContextWindowUsage) => void; onRateLimitUpdate: (limits: RateLimit) => void; onThreadGoalUpdated: (goal: Extract) => void; @@ -433,6 +433,7 @@ interface SessionManagerEvents { sessionId: SessionId; acpSessionId: ACPSessionId; usage: SessionUsageUpdate; + accountingId?: string; }) => void; onContextWindowUsageUpdate: (sessionId: SessionId, usage: SessionContextWindowUsage) => void; onRateLimitUpdate: (machineId: MachineId, cliType: CliType, limits: RateLimit) => void; @@ -1273,7 +1274,7 @@ export class SessionManager extends EventEmitter { } return this.requestPermissionHandler(sessionId, requestId, request, session.agentClient!); }, - onUsageUpdate: (usage: SessionUsageUpdate) => { + onUsageUpdate: (usage: SessionUsageUpdate, accountingId?: string) => { dispatchEvent(() => { const currentAcpSessionId = session.acpSessionId; if (!currentAcpSessionId) { @@ -1282,7 +1283,12 @@ export class SessionManager extends EventEmitter { ); return; } - this.emit('onUsageUpdate', { sessionId, acpSessionId: currentAcpSessionId, usage }); + this.emit('onUsageUpdate', { + sessionId, + acpSessionId: currentAcpSessionId, + usage, + accountingId, + }); }); }, onContextWindowUsageUpdate: (usage: SessionContextWindowUsage) => { diff --git a/packages/acp-extension-codex b/packages/acp-extension-codex index 38f29ed001..08b1154727 160000 --- a/packages/acp-extension-codex +++ b/packages/acp-extension-codex @@ -1 +1 @@ -Subproject commit 38f29ed0016a3ab9dfd1189a6b9e65559b2a1433 +Subproject commit 08b1154727456e75a23249248057b633dd42a04b diff --git a/packages/platform/src/cloud-port.ts b/packages/platform/src/cloud-port.ts index bce6078f35..abf98e0dfa 100644 --- a/packages/platform/src/cloud-port.ts +++ b/packages/platform/src/cloud-port.ts @@ -5,7 +5,6 @@ import type { MachineBugReportResponse, PermissionRequestKind, SessionPullRequestMeta, - ACPSessionId, BillingPlanTier, MachineId, SessionId, @@ -184,7 +183,8 @@ export interface CloudNotificationsPort { export interface CloudUsageUpdateInput { workspaceId: WorkspaceId; sessionId: SessionId; - acpSessionId: ACPSessionId; + /** Accounting scope; may be a native session ID or a provider turn key. */ + acpSessionId: string; userId: string; machineId: MachineId; cliType: string; diff --git a/specs/usage-delivery.md b/specs/usage-delivery.md index a8df9fc048..91ea3908f7 100644 --- a/specs/usage-delivery.md +++ b/specs/usage-delivery.md @@ -19,7 +19,7 @@ previous emitted update, already included in `modelUsage`: never add both. Delta delivery is not an exactly-once ledger. Cache reads/writes, ordinary input/ output and reasoning are disjoint. Unknown costs are omitted, not zero. -Replay adds nothing; model changes and compaction preserve counters. A new +For adapter-owned ledgers, replay adds nothing; model changes and compaction preserve counters. A new accounting lifetime requires a fresh consumer accounting identity or a restored baseline. Process-local state does not guarantee restart continuity. Grok deduplicates prompt contributions across both completion channels and permits @@ -33,12 +33,24 @@ their attribution until acknowledged; concurrent flushes share one drain. Delta is neither added to totals nor forwarded to the legacy persistence endpoint. Persistence projects only token/cost fields and aggregate contextWindow; search request counts and model-level contextWindow are not forwarded. -Codex's legacy compaction offsets survive successful flushes in the same process; -adapter-owned cumulative updates carrying delta bypass that compatibility path. -Codex thread totals without model attribution use an explicit unattributed bucket, -never the currently selected UI model or its price. Claude query and Kimi activation -snapshots can provide delta without changing their cumulative scope. Kimi source -changes require a new managed artifact before they affect the consuming runtime. +Codex attributes native root-thread counter increments to the model frozen from +the submitted turn parameters. A native turn is one accounting lifetime. +Its cumulative turn snapshot carries notification-local `_meta.codex.usageTurnId`; +the CLI uses `nativeSessionId:turn:encodedTurnId` as the existing usage endpoint's +accounting identity. The actual ACP session ID and session metadata stay unchanged. +This keeps A's earlier tokens out of B's new turn and makes repeat delivery +idempotent without modifying hosted persistence. + +Only the previous native snapshot and current turn are kept in memory. Restored +snapshots are comparison points; if missing, the first notification is skipped +rather than billing historical usage. There is no sidecar, persisted baseline, +historical model ledger or raw-response bookkeeping. Child usage is not added. +Resets, reroutes and crashes remain best effort, not an exact billing guarantee. +Unmarked adapters keep their old scope; the new adapter requires the matching CLI. + +Claude query and Kimi activation snapshots can provide delta without +changing their cumulative scope. Kimi source changes require a new managed artifact +before they affect the consuming runtime. Provider costs are preserved; missing cache-write tariffs cannot be replaced with cache-read prices. An empty aggregate does not imply a known zero cost. @@ -65,3 +77,4 @@ pricing boundary may differ from billing. Unreported runtime activity cannot be Publish Core 0.1.5 before adapters requiring its accumulator, then rebuild/release adapters before updating consuming gitlinks/artifacts. Local changes do not publish packages, repair historical data, or prove deployed hosted behavior. +Old experimental accounting rows and sidecar files are not automatically migrated. diff --git a/specs/usage-delivery.zh.md b/specs/usage-delivery.zh.md index 4e0a49c3bc..dd3bf26895 100644 --- a/specs/usage-delivery.zh.md +++ b/specs/usage-delivery.zh.md @@ -16,7 +16,7 @@ Local 组合仍完全禁用云用量服务。 合计与分模型桶,已包含在 `modelUsage` 中,不能再次累加。增量通知不是 exactly-once 账本。缓存读/写、普通输入/输出、推理桶互不重叠。未知费用省略而非填零。 -Replay 不新增用量,模型切换与压缩不清零。新计量生命周期必须使用新的消费端 +对于 adapter 自有账本,Replay 不新增用量,模型切换与压缩不清零。新计量生命周期必须使用新的消费端 计量身份或恢复基线;进程内状态不保证重启连续性。Grok 在两个完成通道之间 按 prompt 标识贡献,允许单调补全。DSH 按持久化请求事件标识贡献,使用请求 实际路由而非 UI 当前模型。 @@ -26,11 +26,21 @@ Replay 不新增用量,模型切换与压缩不清零。新计量生命周期 CLI 合并待发累计快照,包括 Grok。失败 payload 保留原归属直至确认, 并发 flush 共用发送过程。delta 不再加到总量,也不传给旧持久化端点。 持久化仅投影 Token/费用字段及顶层 contextWindow;不转发搜索请求次数或模型级 contextWindow。 -Codex 旧压缩偏移在同一进程内跨成功 flush 保留;带 delta 的 adapter 自有累计值不走 -该兼容路径。没有模型归属的 Codex thread 总量使用明确的未归属桶,不能使用当前 UI -模型或其价格。Claude query 和 Kimi activation 快照可提供 delta,而不改变累计范围。 -Kimi 源码变更需新 managed artifact 才会影响实际运行版本。保留 provider 费用; -缺失 cache-write 费率不能用 cache-read 价格替代。空聚合不代表已知零费用。 +Codex 将 native 根 thread 计数的增量归给实际提交参数中冻结的模型, +每个 native turn 是一个计量生命周期。累计 turn 快照带有通知级 +`_meta.codex.usageTurnId`;CLI 将 `nativeSessionId:turn:encodedTurnId` +用作现有用量接口的计量身份,真实 ACP session ID 和 session metadata 不变。 +因此 A 的历史用量不会记到 B 的新 turn,重试保持幂等,无需修改托管持久化逻辑。 + +内存中仅保留前一份原生快照和当前 turn。恢复快照仅作比较起点;缺失时保守跳过 +首条通知,不把历史计入新增。不使用 sidecar、持久化基线、历史模型账本或 raw-response +计量,也不汇总子 agent。reset、reroute 和崩溃仍尽力而为,不保证精确计费。 +无标记旧 adapter 保留原累计范围;新版 adapter 需要配套 CLI。 + +Claude query 和 Kimi activation 快照可提供 +delta,而不改变累计范围。Kimi 源码变更需新 managed artifact 才会影响实际运行版本。 +保留 provider 费用;缺失 cache-write 费率不能用 cache-read 价格替代。空聚合不代表已知 +零费用。 是否接收用量取决于 builtin agent catalog(包含 DeepSeek Harness),而非 managed 下载列表。收到 provider 的 delta 不证明其累计值已符合生命周期契约; @@ -51,3 +61,5 @@ DSH 按请求完成事件时间,使用官方 UTC 工作日高峰/非高峰价 先发布 Core 0.1.5,再构建/发布依赖累加器的 adapters,之后更新消费端 gitlink/ 产物。本地修改不发布包、不修复历史数据,也不证明线上托管行为。 + +旧实验版计量记录及 sidecar 文件不会自动迁移。