Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 仍未解决。

## 取舍与限制

- 否决:向累计消费端排队发送本轮增量,仍会偏低。
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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)
33 changes: 10 additions & 23 deletions apps/cli/src/agent/agent-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,23 +302,6 @@ function withAbort<T>(promise: Promise<T>, abortPromise?: Promise<never>): Promi
return Promise.race([promise, abortPromise]);
}

type SessionModelUsage = NonNullable<SessionUsageUpdate['modelUsage']>[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'] => {
Expand Down Expand Up @@ -605,7 +588,7 @@ export interface AgentClientOptions {
requestId: string,
request: acp.RequestPermissionRequest
): Promise<acp.RequestPermissionResponse>;
onUsageUpdate?(usage: SessionUsageUpdate): void;
onUsageUpdate?(usage: SessionUsageUpdate, accountingId?: string): void;
onContextWindowUsageUpdate?(usage: SessionContextWindowUsage): void;
onRateLimitUpdate?(limits: RateLimit): void;
onThreadGoalUpdated?(goal: SessionGoalContent): void;
Expand Down Expand Up @@ -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':
Expand Down
25 changes: 25 additions & 0 deletions apps/cli/src/agent/lody-acp-extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
19 changes: 17 additions & 2 deletions apps/cli/src/agent/lody-acp-extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) };
Expand Down
6 changes: 3 additions & 3 deletions apps/cli/src/lib/message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1063,7 +1063,7 @@ export class MessageHandler {

private async handleUsageUpdate(
sessionId: SessionId,
acpSessionId: ACPSessionId,
acpSessionId: string,
update: SessionUsageUpdate
): Promise<void> {
try {
Expand Down Expand Up @@ -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(() => {
Expand Down
10 changes: 6 additions & 4 deletions apps/cli/src/lib/usage/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Loading
Loading