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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol]
- ChatGPT, API key, and client-provided custom gateway authentication.
- Model, reasoning effort, fast mode, approval, and sandbox mode configuration.
- Text prompts, embedded context, images, resource links, and additional workspace directories.
- Shell command, file change, [permission request](docs/permission-extension.md), MCP tool call, terminal output, reasoning, plan, web search, image generation, image view, token usage, and review events.
- Shell command, file change, [permission request](docs/permission-extension.md), MCP tool call, terminal output, reasoning, plan, web search, image generation, image view, [token usage](docs/usage-accounting.md), and review events.
- [Native ACP subagent sessions](docs/subagent-sessions.md) (after capability negotiation) with separate child histories and root-routed permissions; a legacy tool-call fallback otherwise.
- [Background terminal tasks](docs/async-tasks.md) in AIR, with task status and targeted stop support after capability negotiation.
- Session-scoped long-running goals through the provider-neutral [goal extension](docs/goal-extension.md).
Expand Down
36 changes: 36 additions & 0 deletions docs/usage-accounting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Prompt usage

`PromptResponse.usage` and `_meta.quota.token_count` report observed usage for
the root Codex thread during this ACP prompt. They include all model requests
in the prompt, including a plan and its approved implementation. They exclude
earlier prompts, native child threads, and background title generation.

The adapter subtracts the thread total captured before the prompt from each
subsequent total. Repeated notifications add zero. New threads start at zero;
load/resume snapshots supply the historical baseline. A missing baseline is
unknown: the first snapshot establishes it without charging historical usage.
Later observations are retained, but the result is partial.

`_meta.usageAccounting` identifies the contract:

```json
{
"version": 1,
"source": "codex/thread-token-usage-delta",
"scope": "root_thread_prompt",
"completeness": "reported"
}
```

`reported` means the observed counters were usable. It does not certify provider
billing or include work outside the stated scope. `partial` means a baseline or
usage was missing, a counter was replaced or invalid, or the prompt was cancelled
or returned a typed failure. Known counts remain available; no observations
produce `usage: null`. A transport error without a prompt response still has no
terminal usage result.

Cached reads are separated from input. Cache writes remain included in non-read
input, as before; reasoning is a subset of output. Neither is added twice.
`session/update.usage_update.used` remains context occupancy and must not be
summed as token consumption. Reports from adapter versions before this change
describe only the last model request and cannot be repaired retroactively.
16 changes: 16 additions & 0 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type {
import type {ServiceTier} from "./app-server/ServiceTier";
import type {JsonValue} from "./app-server/serde_json/JsonValue";
import {ModelId} from "./ModelId";
import {toTokenCount, type TokenCount} from "./TokenCount";
import {AgentMode} from "./AgentMode";
import path from "node:path";
import {logger} from "./Logger";
Expand Down Expand Up @@ -126,6 +127,7 @@ export class CodexAcpClient {
private readonly subagents: CodexSubagentSubscriptions;
private skillExtraRoots: string[] = [];
private configPath: string | null = null;
private readonly threadTokenUsage = new Map<string, TokenCount>();


constructor(codexClient: CodexAppServerClient, codexConfig?: JsonObject, modelProvider?: string) {
Expand All @@ -135,6 +137,16 @@ export class CodexAcpClient {
this.gatewayConfig = null;
this.gatewayConfigSource = null;
this.subagents = new CodexSubagentSubscriptions(codexClient);
// Capture restored totals even before the ACP session handler is installed.
codexClient.onClientTransportEvent(event => {
if (event.eventType === "notification" && event.method === "thread/tokenUsage/updated") {
this.threadTokenUsage.set(event.params.threadId, toTokenCount(event.params.tokenUsage.total));
}
});
}

getThreadTokenUsage(sessionId: string): TokenCount | null {
return this.threadTokenUsage.get(sessionId) ?? null;
}

get appServerClient(): CodexAppServerClient {
Expand Down Expand Up @@ -638,7 +650,11 @@ export class CodexAcpClient {
await this.codexClient.threadUnsubscribe({threadId: sessionId});
} finally {
this.codexClient.clearThreadHandlers(sessionId);
for (const childSessionId of this.subagents.childSessionIds(sessionId)) {
this.threadTokenUsage.delete(childSessionId);
}
this.subagents.clear(sessionId);
this.threadTokenUsage.delete(sessionId);
}
}

Expand Down
29 changes: 17 additions & 12 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,10 @@ import {
REASONING_EFFORT_CONFIG_ID,
} from "./ModelConfigOption";
import type {TokenCount} from "./TokenCount";
import {PromptTokenUsage, ZERO_TOKEN_COUNT} from "./PromptTokenUsage";
import {toPromptUsage} from "./TokenCount";
import {CodexCommands, GOAL_CONTINUATION_PROMPT} from "./CodexCommands";
import {SteeringQueue} from "./SteeringQueue";
import type {QuotaMeta} from "./QuotaMeta";
import {logger} from "./Logger";
import {sanitizeMcpServerName} from "./McpServerName";
import {createResponseItemHistoryFallbackUpdates} from "./ResponseItemHistoryFallback";
Expand Down Expand Up @@ -162,6 +162,7 @@ export interface SessionState {
currentTurnId: string | null;
lastTokenUsage: TokenCount | null;
totalTokenUsage: TokenCount | null;
promptTokenUsage?: PromptTokenUsage;
modelContextWindow: number | null;
rateLimits: RateLimitsMap | null;
account: Account | null;
Expand Down Expand Up @@ -672,7 +673,7 @@ export class CodexAcpServer {
collaborationMode: sessionMetadata.collaborationMode,
currentTurnId: null,
lastTokenUsage: null,
totalTokenUsage: null,
totalTokenUsage: operation === "new" ? ZERO_TOKEN_COUNT : null,
modelContextWindow: null,
rateLimits: null,
account: authState.account,
Expand Down Expand Up @@ -2751,6 +2752,9 @@ export class CodexAcpServer {
let agentFileChangeReportUnavailableReason: AgentFileChangeReportUnavailableReason = "providerError";
let promptWasCancelled = false;
let recoverableSessionFailure = sessionState.sessionFailure;
sessionState.promptTokenUsage = new PromptTokenUsage(
this.codexAcpClient.getThreadTokenUsage(params.sessionId) ?? sessionState.totalTokenUsage,
);
sessionState.currentTurnId = null;
const activePrompt = this.trackActivePrompt(params.sessionId);
let pendingTurnStart: PendingTurnStart | null = null;
Expand Down Expand Up @@ -2911,7 +2915,7 @@ export class CodexAcpServer {
await clearRecoveredSessionFailure(eventHandler);
return {
stopReason: "end_turn",
usage: this.buildPromptUsage(sessionState.lastTokenUsage),
usage: this.buildPromptUsage(sessionState.promptTokenUsage?.tokenCount() ?? null),
_meta: this.buildQuotaMeta(sessionState),
};
}
Expand Down Expand Up @@ -3145,7 +3149,7 @@ export class CodexAcpServer {

return {
stopReason: "end_turn",
usage: this.buildPromptUsage(sessionState.lastTokenUsage),
usage: this.buildPromptUsage(sessionState.promptTokenUsage?.tokenCount() ?? null),
_meta: this.buildQuotaMeta(sessionState),
};
} catch (err) {
Expand Down Expand Up @@ -3246,8 +3250,8 @@ export class CodexAcpServer {
private cancelledPromptResponse(sessionState: SessionState): acp.PromptResponse {
return {
stopReason: "cancelled",
usage: this.buildPromptUsage(sessionState.lastTokenUsage),
_meta: this.buildQuotaMeta(sessionState),
usage: this.buildPromptUsage(sessionState.promptTokenUsage?.tokenCount() ?? null),
_meta: this.buildQuotaMeta(sessionState, true),
};
}

Expand All @@ -3263,16 +3267,16 @@ export class CodexAcpServer {
}
return {
stopReason: "end_turn",
usage: this.buildPromptUsage(sessionState.lastTokenUsage),
usage: this.buildPromptUsage(sessionState.promptTokenUsage?.tokenCount() ?? null),
_meta: {
...this.buildQuotaMeta(sessionState),
...this.buildQuotaMeta(sessionState, true),
...failureMeta,
},
};
}

private buildQuotaMeta(sessionState: SessionState): { quota: QuotaMeta } {
const lastTokenUsage = sessionState.lastTokenUsage;
private buildQuotaMeta(sessionState: SessionState, interrupted = false) {
const lastTokenUsage = sessionState.promptTokenUsage?.tokenCount() ?? null;

// Remove the "[reasoning-level]" suffix from currentModelId if present
const modelName = sessionState.currentModelId.replace(/\[.*?]$/, '');
Expand All @@ -3284,9 +3288,10 @@ export class CodexAcpServer {

return {
quota: {
token_count: sessionState.lastTokenUsage,
token_count: lastTokenUsage,
model_usage: modelUsage
}
},
usageAccounting: sessionState.promptTokenUsage?.accounting(interrupted)
};
}

Expand Down
6 changes: 6 additions & 0 deletions src/CodexEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1280,6 +1280,12 @@ export class CodexEventHandler {
}

private handleTokenUsageUpdated(params: ThreadTokenUsageUpdatedNotification): void {
if (params.threadId === this.sessionState.sessionId
&& params.turnId === this.sessionState.currentTurnId) {
this.sessionState.promptTokenUsage?.observe(params.tokenUsage);
} else if (params.threadId === this.sessionState.sessionId && this.sessionState.currentTurnId === null) {
this.sessionState.promptTokenUsage?.restoreBaseline(toTokenCount(params.tokenUsage.total));
}
this.sessionState.lastTokenUsage = toTokenCount(params.tokenUsage.last);
this.sessionState.totalTokenUsage = toTokenCount(params.tokenUsage.total);
this.sessionState.modelContextWindow = params.tokenUsage.modelContextWindow;
Expand Down
63 changes: 63 additions & 0 deletions src/PromptTokenUsage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type {ThreadTokenUsage} from "./app-server/v2";
import {toTokenCount, type TokenCount} from "./TokenCount";

const fields = ["totalTokens", "inputTokens", "cachedInputTokens", "outputTokens", "reasoningOutputTokens"] as const;
export const ZERO_TOKEN_COUNT: TokenCount = {
totalTokens: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0,
};

/** Usage observed during one ACP prompt. Context occupancy is not usage. */
export class PromptTokenUsage {
private counts: TokenCount | null = null;
private incomplete = false;
private started = false;

constructor(private previous: TokenCount | null) {}

restoreBaseline(total: TokenCount): void {
// A resume snapshot can arrive after session/resume but before turn/start.
if (!this.started) this.previous = total;
}

observe(usage: ThreadTokenUsage): void {
this.started = true;
const total = toTokenCount(usage.total);
const previous = this.previous;
this.previous = total;
if (previous === null) {
// A first notification can repeat historical usage on a rate-limit
// update. Without a baseline, even `last` cannot safely be charged.
this.incomplete = true;
return;
}
const delta = {...total};
for (const field of fields) delta[field] -= previous[field];
if (fields.some(field => !Number.isSafeInteger(delta[field]) || delta[field] < 0)
|| delta.reasoningOutputTokens > delta.outputTokens
|| delta.totalTokens !== delta.inputTokens + delta.cachedInputTokens + delta.outputTokens) {
// Codex can replace counters with a synthetic context-window estimate.
this.incomplete = true;
return;
}
const next = {...(this.counts ?? ZERO_TOKEN_COUNT)};
for (const field of fields) next[field] += delta[field];
if (fields.some(field => !Number.isSafeInteger(next[field]))) {
this.incomplete = true;
return;
}
this.counts = next;
}

tokenCount(): TokenCount | null {
return this.counts;
}

accounting(interrupted = false) {
return {
version: 1,
source: "codex/thread-token-usage-delta",
scope: "root_thread_prompt",
completeness: interrupted || this.incomplete || this.counts === null ? "partial" : "reported",
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"sessionUpdate": "agent_message_chunk",
"content": {
"type": "text",
"text": "**Model:** model-id[effort] \n**Directory:** /test/cwd \n**Approval:** on-request \n**Sandbox:** workspace-write \n**Account:** not logged in \n**Session:** `session-id` \n \n**Token usage:** data not available yet \n**Context window:** data not available yet \n**Standard 1h limit:** 75% left \n**Fast 1d limit:** 20% left"
"text": "**Model:** model-id[effort] \n**Directory:** /test/cwd \n**Approval:** on-request \n**Sandbox:** workspace-write \n**Account:** not logged in \n**Session:** `session-id` \n \n**Token usage:** 0 total (0 input + 0 cached input, 0 output) \n**Context window:** data not available yet \n**Standard 1h limit:** 75% left \n**Fast 1d limit:** 20% left"
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/CodexACPAgent/data/command-status.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"sessionUpdate": "agent_message_chunk",
"content": {
"type": "text",
"text": "**Model:** model-id[effort] \n**Directory:** /test/cwd \n**Approval:** on-request \n**Sandbox:** workspace-write \n**Account:** not logged in \n**Session:** `session-id` \n \n**Token usage:** data not available yet \n**Context window:** data not available yet \n**Limits:** data not available yet"
"text": "**Model:** model-id[effort] \n**Directory:** /test/cwd \n**Approval:** on-request \n**Sandbox:** workspace-write \n**Account:** not logged in \n**Session:** `session-id` \n \n**Token usage:** 0 total (0 input + 0 cached input, 0 output) \n**Context window:** data not available yet \n**Limits:** data not available yet"
}
}
}
Expand Down
24 changes: 15 additions & 9 deletions src/__tests__/CodexACPAgent/data/token-usage-cancelled.json
Original file line number Diff line number Diff line change
@@ -1,33 +1,39 @@
{
"stopReason": "cancelled",
"usage": {
"totalTokens": 1500,
"inputTokens": 1200,
"totalTokens": 3000,
"inputTokens": 2500,
"cachedReadTokens": 0,
"outputTokens": 300,
"outputTokens": 500,
"thoughtTokens": 0
},
"_meta": {
"quota": {
"token_count": {
"totalTokens": 1500,
"inputTokens": 1200,
"totalTokens": 3000,
"inputTokens": 2500,
"cachedInputTokens": 0,
"outputTokens": 300,
"outputTokens": 500,
"reasoningOutputTokens": 0
},
"model_usage": [
{
"model": "model-id",
"token_count": {
"totalTokens": 1500,
"inputTokens": 1200,
"totalTokens": 3000,
"inputTokens": 2500,
"cachedInputTokens": 0,
"outputTokens": 300,
"outputTokens": 500,
"reasoningOutputTokens": 0
}
}
]
},
"usageAccounting": {
"version": 1,
"source": "codex/thread-token-usage-delta",
"scope": "root_thread_prompt",
"completeness": "partial"
}
}
}
36 changes: 21 additions & 15 deletions src/__tests__/CodexACPAgent/data/token-usage-end-turn.json
Original file line number Diff line number Diff line change
@@ -1,33 +1,39 @@
{
"stopReason": "end_turn",
"usage": {
"totalTokens": 2500,
"inputTokens": 1500,
"cachedReadTokens": 500,
"outputTokens": 450,
"thoughtTokens": 50
"totalTokens": 5000,
"inputTokens": 3000,
"cachedReadTokens": 1000,
"outputTokens": 1000,
"thoughtTokens": 100
},
"_meta": {
"quota": {
"token_count": {
"totalTokens": 2500,
"inputTokens": 1500,
"cachedInputTokens": 500,
"outputTokens": 450,
"reasoningOutputTokens": 50
"totalTokens": 5000,
"inputTokens": 3000,
"cachedInputTokens": 1000,
"outputTokens": 1000,
"reasoningOutputTokens": 100
},
"model_usage": [
{
"model": "model-id",
"token_count": {
"totalTokens": 2500,
"inputTokens": 1500,
"cachedInputTokens": 500,
"outputTokens": 450,
"reasoningOutputTokens": 50
"totalTokens": 5000,
"inputTokens": 3000,
"cachedInputTokens": 1000,
"outputTokens": 1000,
"reasoningOutputTokens": 100
}
}
]
},
"usageAccounting": {
"version": 1,
"source": "codex/thread-token-usage-delta",
"scope": "root_thread_prompt",
"completeness": "reported"
}
}
}
Loading