Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/air-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ The adapter keeps them where they are.
| `is_mcp_tool_call` | tool call `_meta.is_mcp_tool_call: true` | The tool call is an MCP tool call. |
| `is_mcp_tool_approval` | `session/request_permission` request `_meta.is_mcp_tool_approval: true` | The permission request approves an MCP tool call. |
| `steering` | `initialize` response `_meta.steering = {supported: true}` | The agent accepts `_session/steering` for a running turn; see [session steering](steering.md). |
| `quota` | `PromptResponse._meta.quota` | Token usage of the whole turn, the same counts as `PromptResponse.usage`: `token_count` and one `model_usage` entry for the current model, or `null` and `[]` when Codex reported no usage for the prompt, as for a prompt that started no turn. |
| `quota` | `PromptResponse._meta.quota` | Token usage of the whole turn. `token_count` is the usage of the session thread, the same counts as `PromptResponse.usage`. `model_usage` has one entry per model: the session thread under the current model, plus the subagents of the turn under their own models, so its entries can total more than `token_count`. A subagent that works on after the turn ends counts in the next turn. It is `null` and `[]` when Codex reported no usage for the prompt, as for a prompt that started no turn. |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This row should spell out a few more things a client needs:

  • v2. It now also arrives in ACP v2, on the idle state_update that ends the turn. The row only names PromptResponse._meta.quota.
  • No adding. Subagents count whether or not the client gets subagent sessions, so a client must not add usage it gets on child sessions to model_usage.
  • Lower bound. The adapter computes it, and it misses some work: Guardian review threads, title generation, and a child's first request when no item streams before its usage update. Right now these are only in the PR description.
  • null with rows. token_count can be null while model_usage is not empty. A child that kept working after the last prompt adds a row to a prompt that was cancelled before the root thread reported any usage. The last sentence currently ties null and [] together.

Possible wording:

In ACP v2 it is on the idle state_update that ends the turn. model_usage counts the subagents whether or not the client gets subagent sessions, so a client must not add usage reported on child sessions to it. The adapter computes it from the usage of each thread, so it is a lower bound: Guardian review threads and title generation are not counted. token_count is null when the session thread reported no usage, and model_usage can still hold the subagent rows.

| `authStatus` | `initialize` response `agentCapabilities._meta.authStatus` | The agent pushes `_auth/status_update`. The object carries no payload. |

## Auth source display
Expand Down
33 changes: 26 additions & 7 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ import {
} from "./ModelConfigOption";
import type {TokenCount} from "./TokenCount";
import {PromptTokenUsage, toPromptUsage, ZERO_TOKEN_COUNT} from "./TokenCount";
import {SubagentTokenUsage} from "./SubagentTokenUsage";
import {CodexCommands} from "./CodexCommands";
import {SteeringQueue} from "./SteeringQueue";
import type {QuotaMeta} from "./QuotaMeta";
Expand Down Expand Up @@ -266,6 +267,8 @@ export interface SessionState extends SessionIndexTitleState {
totalTokenUsage: TokenCount | null;
/** The token usage of the current or last prompt, which its PromptResponse reports. */
promptTokenUsage: PromptTokenUsage | null;
/** The token usage of the subagent threads, which `_meta.quota.model_usage` of a prompt adds. */
subagentTokenUsage: SubagentTokenUsage;
/** Whether the thread may have token usage from before this session, as a loaded, resumed or forked thread. */
threadHasHistory: boolean;
modelContextWindow: number | null;
Expand Down Expand Up @@ -762,6 +765,12 @@ export class CodexAcpServer {
}));
}

/** Sees each notification of the session and its child threads as the app-server sent it. */
private observeSessionEvent(sessionId: string, event: ServerNotification): void {
this.sessions.get(sessionId)?.subagentTokenUsage.observe(event);
this.observeSubagentWorkEnd(sessionId, event);
}

private observeSubagentWorkEnd(sessionId: string, event: ServerNotification): void {
const router = this.sessions.get(sessionId)?.subagents;
if (router instanceof StandardSubagentEventRouter) router.observeWorkEnd(event);
Expand Down Expand Up @@ -1338,6 +1347,7 @@ export class CodexAcpServer {
lastTokenUsage: null,
totalTokenUsage: null,
promptTokenUsage: null,
subagentTokenUsage: new SubagentTokenUsage(sessionId),
threadHasHistory: operation !== "new",
...(sessionMetadata.instructions ? {instructions: sessionMetadata.instructions} : {}),
modelContextWindow: null,
Expand Down Expand Up @@ -1623,7 +1633,7 @@ export class CodexAcpServer {
const state = await ready;
return state?.subagents.withUserAction?.(childId, itemId, request, turnId) ?? request();
},
event => this.observeSubagentWorkEnd(sessionId, event),
event => this.observeSessionEvent(sessionId, event),
);
}

Expand Down Expand Up @@ -3453,6 +3463,7 @@ export class CodexAcpServer {
lastTokenUsage: null,
totalTokenUsage: null,
promptTokenUsage: null,
subagentTokenUsage: new SubagentTokenUsage(sessionId),
threadHasHistory: true,
...(sessionMetadata.instructions ? {instructions: sessionMetadata.instructions} : {}),
modelContextWindow: null,
Expand Down Expand Up @@ -4918,7 +4929,10 @@ export class CodexAcpServer {
observeInteraction,
childThreadId => promptEventHandler.waitForNativeSubagentSession(childThreadId),
(childId, itemId, request, turnId) => sessionState.subagents.withUserAction?.(childId, itemId, request, turnId) ?? request(),
event => this.observeSubagentWorkEnd(params.sessionId, event));
event => {
sessionState.subagentTokenUsage.observe(event);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This repeats observeSessionEvent. Call that instead so the two subscriptions can't drift. If the captured sessionState matters here, let observeSessionEvent take the state instead of looking it up.

this.observeSubagentWorkEnd(params.sessionId, event);
});

if (activePrompt.signal.aborted) {
return cancelledPromptResponse();
Expand Down Expand Up @@ -5500,17 +5514,21 @@ export class CodexAcpServer {
};
}

/** `_meta.quota`: the usage of the whole prompt, the same counts as {@link buildPromptUsage}. */
/**
* `_meta.quota`: `token_count` is the usage of the root thread, the same counts as {@link buildPromptUsage}.
* `model_usage` adds the usage of the subagents by model, so its rows can total more than `token_count`.
*/
private buildQuotaMeta(sessionState: SessionState): { quota: QuotaMeta } {
const promptTokenUsage = sessionState.promptTokenUsage?.usage() ?? null;

// Remove the "[reasoning-level]" suffix from currentModelId if present
const modelName = sessionState.currentModelId.replace(/\[.*?]$/, '');

// FIXME: currently all tokens are reported for the current model
const modelUsage = (promptTokenUsage != null)
? [{ model: modelName, token_count: promptTokenUsage }]
: [];
// FIXME: the tokens of the root thread are reported for its current model.
// A prompt that started no model work keeps the subagent usage for the next prompt.
const modelUsage = sessionState.promptTokenUsage === null
? []
: sessionState.subagentTokenUsage.reportModelUsage(modelName, promptTokenUsage);

return {
quota: {
Expand All @@ -5526,6 +5544,7 @@ export class CodexAcpServer {
const startTotal = sessionState.totalTokenUsage
?? (sessionState.threadHasHistory ? null : ZERO_TOKEN_COUNT);
sessionState.promptTokenUsage = new PromptTokenUsage(startTotal);
sessionState.subagentTokenUsage.beginPrompt();
}

private buildPromptUsage(sessionState: SessionState): acp.Usage | null {
Expand Down
2 changes: 1 addition & 1 deletion src/CodexEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1378,7 +1378,7 @@ const NON_MODEL_OUTPUT_ITEM_TYPES: ReadonlySet<ThreadItem["type"]> = new Set([
"contextCompaction",
]);

function isModelOutputItem(item: ThreadItem): boolean {
export function isModelOutputItem(item: ThreadItem): boolean {
return !NON_MODEL_OUTPUT_ITEM_TYPES.has(item.type);
}

Expand Down
4 changes: 2 additions & 2 deletions src/QuotaMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ export interface ModelTokenCount {
}

export interface QuotaMeta {
// Aggregated token count across all models
// Token count of the root thread, the same counts as PromptResponse.usage
token_count: TokenCount | null;

// Token count for each model
// Token count for each model, including the subagents
model_usage: ModelTokenCount[];
}
133 changes: 133 additions & 0 deletions src/SubagentTokenUsage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import type {ServerNotification} from "./app-server";
import {isModelOutputItem} from "./CodexEventHandler";
import type {ModelTokenCount} from "./QuotaMeta";
import {isRootAgentPath} from "./subagents/CodexAgentPath";
import {type ChildModel, preferredModel} from "./subagents/CodexSubagentEventRouter";
import {
addTokenCounts,
sameTokenCount,
subtractTokenCounts,
toTokenCount,
type TokenCount,
ZERO_TOKEN_COUNT,
} from "./TokenCount";

type ChildThread = {
model?: ChildModel;
/** The last total of the thread, or null when the total it started from is not known. */
previousTotal: TokenCount | null;
modelOutputSeen: boolean;
/** The sum of the model requests of the thread. */
used: TokenCount;
/** The part of `used` that a prompt reported. */
reported: TokenCount;
/** The part of `used` that the prompts before the current one reported. */
reportedBeforePrompt: TokenCount;
};

/**
* Accumulates the token usage of the child threads of a session: its subagents and their subagents. Codex reports
* the usage of every thread on its own, and the usage of the root thread does not include its children, so a prompt
* reports theirs in `_meta.quota.model_usage` next to its own.
*
* It reads the notifications as the app-server sends them, before the session routes them: a client without
* subagent sessions gets the notifications of a child attributed to the root thread. A child that is still working
* when a prompt ends adds its later usage to the next prompt that starts model work.
*
* Codex 0.165 sends no `thread/started` for a subagent: the tracker then learns the child from its `subAgentActivity`
* item and its own notifications, with an unknown start, so the usage of a child counts from its first model output.
*/
export class SubagentTokenUsage {
private readonly threads = new Map<string, ChildThread>();

constructor(private readonly rootThreadId: string) {}

observe(notification: ServerNotification): void {
switch (notification.method) {
case "thread/started": {
const thread = notification.params.thread;
const parent = thread.parentThreadId;
// Every session gets every thread start, so only the children of this root count.
if (thread.id === this.rootThreadId || parent === null
|| (parent !== this.rootThreadId && !this.threads.has(parent))) return;
// A new thread starts at zero, while a forked one starts from the usage of its history.
const child = this.child(thread.id, thread.forkedFromId === null ? ZERO_TOKEN_COUNT : null);
if (thread.model) child.model = preferredModel(child.model, {model: thread.model, source: "threadStarted"});
return;
}
case "thread/settings/updated": {
const threadId = notification.params.threadId;
if (threadId === this.rootThreadId) return;
const child = this.child(threadId, null);
child.model = preferredModel(child.model, {model: notification.params.threadSettings.model, source: "settings"});
return;
}
case "item/started":
case "item/completed": {
const {threadId, item} = notification.params;
if (item.type === "subAgentActivity" && item.model && !isRootAgentPath(item.agentPath)) {
const child = this.child(item.agentThreadId, null);
child.model = preferredModel(child.model, {model: item.model, source: "activity"});
}
if (notification.method === "item/started" && threadId !== this.rootThreadId && isModelOutputItem(item)) {
this.child(threadId, null).modelOutputSeen = true;
}
return;
}
case "thread/tokenUsage/updated": {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SubagentTokenUsage copies the per-thread rules of PromptTokenUsage (src/TokenCount.ts) instead of reusing them. The rules match one to one:

Rule PromptTokenUsage.observe Here
An update that repeats the previous total is not a new request if (previous !== null && sameTokenCount(total, previous)) return; the same check
When the start is unknown, the first update before any model output carries the thread's history, so it is not counted if (previous === null && !this.modelOutputSeen) { this.startTotal = total; ... return; } if (previous === null && !child.modelOutputSeen) return;
A counted update adds its last summedLast += last child.used += last
Model output is detected with isModelOutputItem on item/started in CodexEventHandler.handleNotification, which calls observeModelOutput() in the item/started case, which sets modelOutputSeen

The comment below even says "as in PromptTokenUsage". These rules work around Codex quirks: Codex sends the current usage again when a request starts, and a resumed or forked thread's first update includes its history. When Codex changes one of these, someone has to remember to fix both places, and nothing will fail if only one gets fixed.

The two copies already differ in one place. With a known start, PromptTokenUsage reports the end total minus the start total, and falls back to summing last only when the start is unknown or the total went back. Here, a child started by thread/started has a known start (zero), but its last values are still summed. If that's on purpose, say why in a comment. Otherwise it's the kind of drift a shared helper would prevent.

Suggestion: move the shared part into one per-thread class in src/TokenCount.ts and build both on it, for example:

/** The usage of one thread from its `thread/tokenUsage/updated` notifications. */
export class ThreadTokenUsage {
    private modelOutputSeen = false;
    /** The `last` values of the updates that were counted. */
    summedLast: TokenCount = ZERO_TOKEN_COUNT;

    /** @param previousTotal the thread total at the start, or null when it is not known. */
    constructor(private previousTotal: TokenCount | null) {}

    observeModelOutput(): void { this.modelOutputSeen = true; }

    /** "repeat" and "history" updates are not counted. */
    observe(total: TokenCount, last: TokenCount): "repeat" | "history" | "counted" { /* the rules above */ }
}
  • PromptTokenUsage keeps only what is specific to a prompt (startTotal, latestTotal, totalWentBack) and uses a "history" result to record its start total.
  • Each ChildThread here keeps only model and the reported / reportedBeforePrompt bookkeeping, plus a ThreadTokenUsage in place of previousTotal, modelOutputSeen and used.

const threadId = notification.params.threadId;
if (threadId === this.rootThreadId) return;
const child = this.child(threadId, null);
const total = toTokenCount(notification.params.tokenUsage.total);
const previous = child.previousTotal;
// Codex sends the current usage again when a model request starts; it is not a new request.
if (previous !== null && sameTokenCount(total, previous)) return;
child.previousTotal = total;
// Before any output, an update of a thread with an unknown start carries its history, as in
// PromptTokenUsage.
if (previous === null && !child.modelOutputSeen) return;
child.used = addTokenCounts(child.used, toTokenCount(notification.params.tokenUsage.last));
return;
}
}
}

/** Starts a prompt that does model work: the child usage that no prompt reported yet belongs to it. */
beginPrompt(): void {
for (const thread of this.threads.values()) thread.reportedBeforePrompt = thread.reported;
}

/**
* The `model_usage` rows of the current prompt: the usage of the root thread, then the usage of the children by
* model, with the same model in one row. The child usage counts as reported. A child whose model Codex did not
* report is counted for the root model, which a spawn that names no model inherits.
*/
reportModelUsage(rootModel: string, rootUsage: TokenCount | null): ModelTokenCount[] {
const byModel = new Map<string, TokenCount>();
if (rootUsage !== null) byModel.set(rootModel, rootUsage);
for (const thread of this.threads.values()) {
const usage = subtractTokenCounts(thread.used, thread.reportedBeforePrompt);
thread.reported = thread.used;
if (sameTokenCount(usage, ZERO_TOKEN_COUNT)) continue;
const model = thread.model?.model ?? rootModel;
const current = byModel.get(model);
byModel.set(model, current === undefined ? usage : addTokenCounts(current, usage));
}
return [...byModel].map(([model, token_count]) => ({model, token_count}));
}

private child(threadId: string, startTotal: TokenCount | null): ChildThread {
let child = this.threads.get(threadId);
if (child === undefined) {
child = {
previousTotal: startTotal,
modelOutputSeen: false,
used: ZERO_TOKEN_COUNT,
reported: ZERO_TOKEN_COUNT,
reportedBeforePrompt: ZERO_TOKEN_COUNT,
};
this.threads.set(threadId, child);
}
return child;
}
}
12 changes: 10 additions & 2 deletions src/TokenCount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,18 @@ function combine(a: TokenCount, b: TokenCount, op: (x: number, y: number) => num
return result;
}

function sameCount(a: TokenCount, b: TokenCount): boolean {
export function sameTokenCount(a: TokenCount, b: TokenCount): boolean {
return TOKEN_COUNT_FIELDS.every(field => a[field] === b[field]);
}

export function addTokenCounts(a: TokenCount, b: TokenCount): TokenCount {
return combine(a, b, (x, y) => x + y);
}

export function subtractTokenCounts(a: TokenCount, b: TokenCount): TokenCount {
return combine(a, b, (x, y) => x - y);
}

/**
* Accumulates the token usage of one prompt from Codex's `thread/tokenUsage/updated` notifications.
*
Expand Down Expand Up @@ -124,7 +132,7 @@ export class PromptTokenUsage {

observe(total: TokenCount, last: TokenCount): void {
const previous = this.previousTotal;
if (previous !== null && sameCount(total, previous)) return;
if (previous !== null && sameTokenCount(total, previous)) return;
if (previous === null && !this.modelOutputSeen) {
this.startTotal = total;
this.previousTotal = total;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"stopReason": "end_turn",
"usage": {
"totalTokens": 1200,
"inputTokens": 600,
"outputTokens": 200,
"thoughtTokens": 0,
"cachedReadTokens": 400,
"cachedWriteTokens": 0
},
"_meta": {
"quota": {
"token_count": {
"totalTokens": 1200,
"inputTokens": 600,
"cachedInputTokens": 400,
"cacheWriteInputTokens": 0,
"outputTokens": 200,
"reasoningOutputTokens": 0
},
"model_usage": [
{
"model": "gpt-5",
"token_count": {
"totalTokens": 1200,
"inputTokens": 600,
"cachedInputTokens": 400,
"cacheWriteInputTokens": 0,
"outputTokens": 200,
"reasoningOutputTokens": 0
}
},
{
"model": "gpt-5.6-luna",
"token_count": {
"totalTokens": 30000,
"inputTokens": 4500,
"cachedInputTokens": 25000,
"cacheWriteInputTokens": 0,
"outputTokens": 500,
"reasoningOutputTokens": 0
}
}
]
}
},
"state": "idle",
"sessionUpdate": "state_update"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"stopReason": "end_turn",
"usage": {
"totalTokens": 1000,
"inputTokens": 800,
"cachedReadTokens": 0,
"cachedWriteTokens": 0,
"outputTokens": 200,
"thoughtTokens": 0
},
"_meta": {
"quota": {
"token_count": {
"totalTokens": 1000,
"inputTokens": 800,
"cachedInputTokens": 0,
"cacheWriteInputTokens": 0,
"outputTokens": 200,
"reasoningOutputTokens": 0
},
"model_usage": [
{
"model": "gpt-5.6-sol",
"token_count": {
"totalTokens": 1000,
"inputTokens": 800,
"cachedInputTokens": 0,
"cacheWriteInputTokens": 0,
"outputTokens": 200,
"reasoningOutputTokens": 0
}
},
{
"model": "gpt-5.6-luna",
"token_count": {
"totalTokens": 50000,
"inputTokens": 9000,
"cachedInputTokens": 40000,
"cacheWriteInputTokens": 0,
"outputTokens": 1000,
"reasoningOutputTokens": 0
}
}
]
}
}
}
Loading
Loading