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
34 changes: 34 additions & 0 deletions .claude/docs/oauth-continuation.md
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,40 @@ Trade-off: a request whose `tools` omit a tool that appears in its own history g
that tool, which is the pre-fix behaviour. Subagent histories never contain the parent's calls, so
the residual is narrow.

### Account-meter diagnostics

The Responses socket carries the account meter. Native Codex parses a `codex.rate_limits` event off
this same connection (`codex-rs/codex-api/src/endpoint/responses_websocket.rs`,
`parse_rate_limit_event`, rust-v0.154.0). With WS diagnostics enabled, clodex records each one as a
`ws_rate_limits` event:

- `phase` is `during_response` when the frame arrived while a request was in flight and `idle` when
it did not. Keep the two apart when attributing a debit: an idle frame's change belongs to no
particular response.
- Correlation follows the phase. A `during_response` frame carries the in-flight request's
`requestId` and `claudeSessionId`; an `idle` frame carries neither. Socket callbacks run in the
async context of the request that created the socket, so reading the ambient diagnostic context
there would stamp an older request's ids on a reused head's frames. The connection sink passes an
explicit empty correlation for that reason.
- `rateLimits`, `additionalRateLimits`, `codeReviewRateLimits`, `credits` and `promo` are the
frame's `rate_limits`, `additional_rate_limits`, `code_review_rate_limits`, `credits` and `promo`
values passed through uncoerced, so a fractional percent survives and a field the server omitted
stays absent instead of reading as zero. Each has a `…Bytes` sibling with its serialized size in UTF-8
bytes, and the value itself is dropped when that exceeds 8,000 bytes. `additionalRateLimits` holds the
separately metered allowances, keyed by allowance name, so it answers whether one of those moved.
- `fieldCount` is the number of top-level keys; `fieldsPresent` lists their names (at most 24, each
through `boundedDiagnosticIdentifier`); `planType` passes through the same helper.
- Unlike the rest of this log, which records upstream strings only as bounded identifiers or hashes,
these values are the server's own objects, recorded verbatim. They include account state such as
credit balance and promotions.

Before this, `handleSocketMessage` returned before parsing whenever no request was in flight, so
those frames were never read. The idle path needs a connection-scoped sink because
`RequestContext.emitDiagnostic` only exists mid-request; it is wired at every `createConnection`
caller, including the transport-retry replacement.

Observation only: nothing here changes a request, a head decision or what is sent upstream.

### Mismatch diagnostics

On a history mismatch the head-decision log includes `expected_hash`/`actual_hash` (SHA-256 of each
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ Common options (both modes):
| `--save-mode` | With `--endpoint`/`--proxy`: save that mode as the `server` default |
| `--port <1-65535>` | Listen port (default 17645) |
| `--no-discovery` | Don't advertise this server in `~/.clodex/server-runtime.json` (`CLODEX_NO_DISCOVERY=1` also works). Use it for a standalone endpoint the `clodex-claude` wrapper should ignore. |
| `--ws-diagnostics` | Log sanitized request envelopes and WebSocket head decisions |
| `--ws-diagnostics` | Log sanitized request envelopes and WebSocket head decisions, plus the usage-limit reports the OpenAI socket sends, verbatim (they include account state such as credits) |
| `--help`, `--version` | Help / version |

Endpoint mode only (an error if combined with `--proxy`):
Expand Down
5 changes: 3 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,8 +514,9 @@ ${pc.bold('Common options (both modes):')}
~/.clodex/server-runtime.json, so the
clodex-claude wrapper never bridges to it
(CLODEX_NO_DISCOVERY=1 works too)
--ws-diagnostics Log sanitized request envelopes and WebSocket
head decisions
--ws-diagnostics Log sanitized request envelopes, WebSocket
head decisions, and the server's usage-limit
reports verbatim (includes account credits)
--help, --version Help / version

${pc.bold('Endpoint mode only')} ${pc.dim('(error if combined with --proxy)')}:
Expand Down
126 changes: 125 additions & 1 deletion src/oauth/responses-websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,14 @@ interface ConnectionEntry {
canonicalToolDefaultsId?: string;
options: Required<Pick<ResponsesWebSocketFetchOptions, 'hardTtlMs' | 'idleTtlMs' | 'nurseryIdleTtlMs' | 'maxConnections' | 'now'>>;
debug: (message: string) => void;
/**
* Connection-scoped diagnostic sink. `RequestContext.emitDiagnostic` only exists
* while a request is in flight, and `codex.rate_limits` frames can arrive between
* or after responses, so they belong to the CONNECTION. Without this they
* are observed by nobody: the message handler returns before parsing when there is
* no active context.
*/
connectionDiagnostic?: (event: { event: string } & Record<string, unknown>) => void;
}

// A Claude session partition can have multiple valid conversation heads at
Expand Down Expand Up @@ -1799,7 +1807,11 @@ function transportReplaySafe(ctx: RequestContext): boolean {

function handleSocketMessage(entry: ConnectionEntry, data: RawData): void {
const ctx = entry.current;
if (!ctx || ctx.closed) return;
if (!ctx || ctx.closed) {
// A usage-limit frame between or after responses is only read when diagnostics are on.
if (entry.connectionDiagnostic) observeIdleFrame(entry, data);
return;
}
const text = Array.isArray(data) ? Buffer.concat(data).toString('utf8') : data.toString('utf8');
ctx.frameCount += 1;
if (ctx.transportRetryPending) {
Expand All @@ -1820,6 +1832,10 @@ function handleSocketMessage(entry: ConnectionEntry, data: RawData): void {
}

const type = eventType(event);
// Emitted through the request's own sink so the frame carries THIS request's ids.
// The connection sink would not: socket callbacks run in the async context of
// whichever request created the socket, which on a reused head is an older one.
if (isQuotaEvent(type)) observeQuotaEvent(entry, event, 'during_response', ctx.emitDiagnostic);
trackReasoningProtocol(entry, ctx, event, type);
captureOutput(ctx, event);
if (type === 'response.completed') {
Expand Down Expand Up @@ -2134,6 +2150,101 @@ function numericRetryAfterHeader(value: string | string[] | undefined): number |
: undefined;
}

/** The upstream event that carries account-meter state rather than response data. */
function isQuotaEvent(type: string | undefined): boolean {
return type === 'codex.rate_limits';
}

/** Largest serialized ledger (UTF-8 bytes) recorded verbatim; bigger ones keep only their size. */
const QUOTA_LEDGER_MAX_BYTES = 8000;
/** Bound on how many top-level field names one event may list. */
const QUOTA_FIELDS_MAX_COUNT = 24;

function boundedLedger(value: unknown): { value?: unknown; bytes?: number } {
let serialized: string | undefined;
try {
serialized = JSON.stringify(value);
} catch {
return {};
}
if (serialized === undefined) return {};
const bytes = Buffer.byteLength(serialized);
return bytes <= QUOTA_LEDGER_MAX_BYTES ? { value, bytes } : { bytes };
}

/**
* Record what an upstream frame says about the ACCOUNT's allowance, verbatim.
*
* Native Codex parses `codex.rate_limits` off this same socket
* (`codex-rs/codex-api/src/endpoint/responses_websocket.rs` → `parse_rate_limit_event`
* at `rust-v0.154.0`), so the protocol carries the signal even though clodex has
* never looked at it. Nothing here changes inference: it observes and returns.
*
* Two rules the measurement depends on:
* - values are passed through uncoerced — no `?? 0`, no Number() coercion — so a
* fractional percent survives and a missing field stays distinguishable from a
* measured zero (`fieldsPresent` says which keys actually existed);
* - `phase` records whether the frame arrived inside a response or between them.
* An idle frame belongs to the connection; attributing its debit to the last
* response would invent a number.
*
* `emit` decides the correlation: the in-flight request's sink during a response,
* the uncorrelated connection sink while idle.
*/
function observeQuotaEvent(
entry: ConnectionEntry,
event: unknown,
phase: 'during_response' | 'idle',
emit: ConnectionEntry['connectionDiagnostic'],
): void {
if (!emit) return;
const record = event as Record<string, unknown>;
// The sibling ledgers ride the same frame: `additional_rate_limits` holds the
// separately metered allowances, `code_review_rate_limits` the code-review one,
// and `credits` and `promo` the account's credit and promotion state. Capturing
// only `rate_limits` would leave "did a separate allowance move" unanswerable.
const rate = boundedLedger(record.rate_limits);
const additional = boundedLedger(record.additional_rate_limits);
const codeReview = boundedLedger(record.code_review_rate_limits);
const credits = boundedLedger(record.credits);
const promo = boundedLedger(record.promo);
emit({
event: 'ws_rate_limits',
connectionId: entry.debugId,
generation: entry.generation,
phase,
upstreamEventType: 'codex.rate_limits',
fieldCount: Object.keys(record).length,
fieldsPresent: Object.keys(record)
.slice(0, QUOTA_FIELDS_MAX_COUNT)
.map(boundedDiagnosticIdentifier)
.filter((name): name is string => name !== undefined),
rateLimits: rate.value,
rateLimitsBytes: rate.bytes,
additionalRateLimits: additional.value,
additionalRateLimitsBytes: additional.bytes,
codeReviewRateLimits: codeReview.value,
codeReviewRateLimitsBytes: codeReview.bytes,
credits: credits.value,
creditsBytes: credits.bytes,
promo: promo.value,
promoBytes: promo.bytes,
planType: boundedDiagnosticIdentifier(record.plan_type),
});
}

/** Parse a frame that arrived with no request in flight, purely to observe quota. */
function observeIdleFrame(entry: ConnectionEntry, data: RawData): void {
let event: unknown;
try {
event = JSON.parse(Array.isArray(data) ? Buffer.concat(data).toString('utf8') : data.toString('utf8'));
} catch {
return;
}
if (!isQuotaEvent(eventType(event))) return;
observeQuotaEvent(entry, event, 'idle', entry.connectionDiagnostic);
}

function createConnection(
WebSocket: WebSocketConstructor,
wsUrl: string,
Expand All @@ -2144,6 +2255,7 @@ function createConnection(
debug: ConnectionEntry['debug'],
/** Optional HTTP(S)_PROXY CONNECT-tunnel agent (see src/outbound-proxy.ts). */
agent?: import('node:http').Agent,
connectionDiagnostic?: ConnectionEntry['connectionDiagnostic'],
): ConnectionEntry {
const now = options.now();
const socket = new WebSocket(wsUrl, agent ? { headers, agent } : { headers });
Expand All @@ -2160,6 +2272,7 @@ function createConnection(
inFlight: false,
options,
debug,
connectionDiagnostic,
};
if (persistent && key) registerEntry(entry);
debug(
Expand Down Expand Up @@ -2748,6 +2861,15 @@ export function createResponsesWebSocketFetch(
evictions,
}, diagnosticCorrelation);

// Connection-scoped sink, deliberately uncorrelated. Its caller is a socket
// callback, and those run in the async context of the request that CREATED the
// socket, so the default (`diagnosticContext.getStore()`) would stamp an idle frame
// with that first request's ids. The explicit empty
// correlation keeps them unattributed; in-response frames use `ctx.emitDiagnostic`.
const connectionDiagnostic: ConnectionEntry['connectionDiagnostic'] = options.onDiagnostic
? event => emitDiagnostic(options, event, {})
: undefined;

let activeContext: RequestContext | undefined;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
Expand Down Expand Up @@ -2783,6 +2905,7 @@ export function createResponsesWebSocketFetch(
resolvedOptions,
debug,
proxyAgent,
connectionDiagnostic,
),
};
activeContext = ctx;
Expand All @@ -2796,6 +2919,7 @@ export function createResponsesWebSocketFetch(
resolvedOptions,
debug,
proxyAgent,
connectionDiagnostic,
);
dispatchContext(entry, ctx);

Expand Down
Loading
Loading