@bitkyc08/opencodex 2.7.21 → 2.7.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -228,6 +228,20 @@ export function parseRequest(body: unknown): OcxParsedRequest {
228
228
  const now = Date.now();
229
229
  const messages: OcxMessage[] = [];
230
230
  const systemPrompt: string[] = [];
231
+ // Responses reasoning siblings belong to the following assistant, including across call items.
232
+ // Keep them off the message list until that assistant arrives; turn boundaries clear the array.
233
+ const pendingReasoning: Array<{ part: OcxThinkingContent; envelopeSigned: boolean }> = [];
234
+ // Assistant placeholder that first folds any pending reasoning into the same turn (official
235
+ // grok-build preserves reasoning across call items; Anthropic replay requires thinking to
236
+ // precede tool_use inside one assistant message).
237
+ const assistantHolderWithReasoning = (): OcxAssistantMessage => {
238
+ const holder = ensureAssistantPlaceholder(messages, data.model, now);
239
+ if (pendingReasoning.length > 0) {
240
+ holder.content.push(...pendingReasoning.map(entry => entry.part));
241
+ pendingReasoning.length = 0;
242
+ }
243
+ return holder;
244
+ };
231
245
  // Tool specs surfaced by a prior tool_search (deferred tools, e.g. subagents). Codex does not
232
246
  // re-list these in `tools`, but chat models can only call listed tools — so we re-inject them.
233
247
  const loadedToolSpecs: unknown[] = [];
@@ -270,6 +284,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
270
284
  // is dropped silently. It must NOT flag _compactionRequest.
271
285
  const encrypted = (item as { encrypted_content?: unknown }).encrypted_content;
272
286
  if (effectiveType === "context_compaction" && typeof encrypted !== "string") continue;
287
+ pendingReasoning.length = 0;
273
288
  messages.push({
274
289
  role: "user",
275
290
  content: compactionItemToText(typeof encrypted === "string" ? encrypted : undefined),
@@ -297,6 +312,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
297
312
  // An agent_message is external input delivered to the parent agent.
298
313
  // Preserve it as a user-role turn so signed Anthropic thinking blocks
299
314
  // on either side are never merged into one modified assistant response.
315
+ pendingReasoning.length = 0;
300
316
  messages.push({
301
317
  role: "user",
302
318
  content: hasContent ? content : "(sub-agent message received)",
@@ -310,6 +326,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
310
326
  const msg = item as { role?: string; content?: unknown };
311
327
  switch (msg.role) {
312
328
  case "system": {
329
+ pendingReasoning.length = 0;
313
330
  const text = inputContentParts(msg.content as unknown[] | string | undefined);
314
331
  const flat = typeof text === "string" ? text : text.map(p => (p.type === "text" ? p.text : "")).join("");
315
332
  if (flat.length > 0) systemPrompt.push(flat);
@@ -317,13 +334,22 @@ export function parseRequest(body: unknown): OcxParsedRequest {
317
334
  }
318
335
  case "user":
319
336
  case "developer": {
337
+ pendingReasoning.length = 0;
320
338
  const content = inputContentParts(msg.content as unknown[] | string | undefined);
321
339
  messages.push({ role: msg.role, content, timestamp: now });
322
340
  break;
323
341
  }
324
342
  case "assistant": {
325
343
  const parts = outputTextOf(msg.content as unknown[] | string | undefined);
326
- messages.push({ role: "assistant", content: parts, model: data.model, timestamp: now });
344
+ messages.push({
345
+ role: "assistant",
346
+ content: pendingReasoning.length > 0
347
+ ? [...pendingReasoning.map(entry => entry.part), ...parts]
348
+ : parts,
349
+ model: data.model,
350
+ timestamp: now,
351
+ });
352
+ pendingReasoning.length = 0;
327
353
  break;
328
354
  }
329
355
  }
@@ -334,20 +360,33 @@ export function parseRequest(body: unknown): OcxParsedRequest {
334
360
  const reasoning = item as { id?: string; summary?: { text: string }[]; content?: { text: string }[]; encrypted_content?: string };
335
361
  const fromSummary = (reasoning.summary ?? []).map(c => c.text).join("");
336
362
  const text = fromSummary || (reasoning.content ?? []).map(c => c.text).join("");
337
- // ocxr1 envelope: the REAL Anthropic signature (+ redacted blocks, + hidden signed text)
338
- // captured by the bridge. Native OpenAI-encrypted blobs decode to null and keep today's
339
- // placeholder signature (which the anthropic adapter correctly rejects on replay).
340
363
  const envelope = typeof reasoning.encrypted_content === "string"
341
364
  ? decodeReasoningEnvelope(reasoning.encrypted_content)
342
365
  : null;
343
- const thinking: OcxThinkingContent = {
344
- type: "thinking",
345
- thinking: envelope?.txt || text,
346
- signature: envelope?.sig ?? JSON.stringify(reasoning),
347
- ...(envelope?.red ? { redacted: envelope.red } : {}),
348
- ...(reasoning.id ? { itemId: reasoning.id } : {}),
349
- };
350
- ensureAssistantPlaceholder(messages, data.model, now).content.push(thinking);
366
+ const thinkingText = envelope?.txt || text;
367
+
368
+ // Native/non-ocxr1 encrypted-only reasoning is opaque here. Do not create a detached
369
+ // assistant turn or invent replayable plaintext/signatures from the encrypted payload.
370
+ if (thinkingText.length > 0) {
371
+ const part: OcxThinkingContent = {
372
+ type: "thinking",
373
+ thinking: thinkingText,
374
+ signature: envelope?.sig ?? JSON.stringify(reasoning),
375
+ ...(envelope?.red ? { redacted: envelope.red } : {}),
376
+ ...(reasoning.id ? { itemId: reasoning.id } : {}),
377
+ };
378
+ const envelopeSigned = typeof envelope?.sig === "string";
379
+ const previous = pendingReasoning[pendingReasoning.length - 1];
380
+
381
+ if (!envelopeSigned && previous && !previous.envelopeSigned) {
382
+ previous.part = {
383
+ ...part,
384
+ thinking: `${previous.part.thinking}\n${part.thinking}`,
385
+ };
386
+ } else {
387
+ pendingReasoning.push({ part, envelopeSigned });
388
+ }
389
+ }
351
390
  continue;
352
391
  }
353
392
 
@@ -370,7 +409,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
370
409
  ...(call.id ? { thoughtSignature: call.id } : {}),
371
410
  ...(call.namespace ? { namespace: call.namespace } : {}),
372
411
  };
373
- ensureAssistantPlaceholder(messages, data.model, now).content.push(toolCall);
412
+ assistantHolderWithReasoning().content.push(toolCall);
374
413
  continue;
375
414
  }
376
415
 
@@ -382,7 +421,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
382
421
  customWireName: call.name,
383
422
  ...(call.id ? { thoughtSignature: call.id } : {}),
384
423
  };
385
- ensureAssistantPlaceholder(messages, data.model, now).content.push(toolCall);
424
+ assistantHolderWithReasoning().content.push(toolCall);
386
425
  continue;
387
426
  }
388
427
 
@@ -393,7 +432,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
393
432
  const callId = call.call_id ?? call.id;
394
433
  if (callId) {
395
434
  const command = Array.isArray(call.action?.command) ? call.action.command : [];
396
- ensureAssistantPlaceholder(messages, data.model, now).content.push({
435
+ assistantHolderWithReasoning().content.push({
397
436
  type: "toolCall", id: callId, name: "shell",
398
437
  arguments: command.length > 0 ? { command } : {},
399
438
  });
@@ -406,7 +445,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
406
445
  // knows the search already ran (prevents re-search loops); there is no output to pair.
407
446
  const call = item as { action?: { type?: string; query?: string } };
408
447
  const query = typeof call.action?.query === "string" ? call.action.query : "";
409
- ensureAssistantPlaceholder(messages, data.model, now).content.push({
448
+ assistantHolderWithReasoning().content.push({
410
449
  type: "text", text: query ? `[web search performed: ${query}]` : "[web search performed]",
411
450
  });
412
451
  continue;
@@ -417,7 +456,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
417
456
  // history stays complete (otherwise the model re-issues tool_search forever).
418
457
  const call = item as { id?: string; call_id?: string; arguments?: unknown };
419
458
  const callId = call.call_id ?? call.id ?? "";
420
- ensureAssistantPlaceholder(messages, data.model, now).content.push({
459
+ assistantHolderWithReasoning().content.push({
421
460
  type: "toolCall", id: callId, name: "tool_search",
422
461
  arguments: isObj(call.arguments) ? call.arguments : {},
423
462
  });
@@ -425,6 +464,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
425
464
  }
426
465
 
427
466
  if (effectiveType === "tool_search_output") {
467
+ pendingReasoning.length = 0;
428
468
  // Pair the tool_search call with its result so the model sees what was loaded.
429
469
  const out = item as { call_id?: string; status?: string; tools?: unknown[] };
430
470
  const specs = Array.isArray(out.tools) ? (out.tools as Record<string, unknown>[]) : [];
@@ -455,6 +495,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
455
495
  }
456
496
 
457
497
  if (effectiveType === "function_call_output") {
498
+ pendingReasoning.length = 0;
458
499
  const output = item as { call_id: string; output?: string | unknown[] };
459
500
  const toolInfo = findToolById(messages, output.call_id);
460
501
  messages.push({
@@ -466,6 +507,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
466
507
  }
467
508
 
468
509
  if (effectiveType === "custom_tool_call_output") {
510
+ pendingReasoning.length = 0;
469
511
  const output = item as { call_id: string; output: string | unknown[] };
470
512
  const toolInfo = findToolById(messages, output.call_id);
471
513
  messages.push({
@@ -12,6 +12,7 @@ import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize"
12
12
  import { AnthropicRequestError, anthropicToResponsesTranslation, extractOcxRouteDirective, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound";
13
13
  import { stripOneMillionMarker } from "../claude/context-windows";
14
14
  import { captureClaudeInbound } from "../claude/inbound-debug";
15
+ import { isTransientUpstreamStatus } from "../lib/upstream-retry";
15
16
  import {
16
17
  anthropicErrorBody,
17
18
  anthropicErrorResponse,
@@ -19,7 +20,7 @@ import {
19
20
  responsesJsonToAnthropicMessage,
20
21
  responsesSseToAnthropicSse,
21
22
  } from "../claude/outbound";
22
- import { clearableDeadline } from "../lib/abort";
23
+ import { clearableDeadline, idleDeadline } from "../lib/abort";
23
24
  import { estimateTokens } from "../lib/token-estimate";
24
25
  import { routeModel } from "../router";
25
26
  import type { OcxConfig } from "../types";
@@ -117,13 +118,35 @@ function anthropicUsageToOcx(usage: Rec | undefined): { inputTokens: number; out
117
118
  };
118
119
  }
119
120
 
120
- /** Tap an Anthropic-vocabulary SSE stream for the request log (usage + terminal). */
121
- function tapAnthropicSseForLog(
121
+ /** Body-occupancy guard for the native passthrough (devlog 260716_passthrough_followups/010). */
122
+ export interface PassthroughBodyGuard {
123
+ /** Idle window in ms — raw upstream-byte inactivity while a read is pending. 0 disables. */
124
+ stallMs: number;
125
+ /** Cumulative body byte cap. 0 disables. */
126
+ maxBytes: number;
127
+ /** Client request signal for deterministic cancel classification. */
128
+ reqSignal?: AbortSignal;
129
+ }
130
+
131
+ type PassthroughCloseReason = "terminal" | "client_cancel" | "body_stall" | "body_overflow";
132
+
133
+ /**
134
+ * Tap an Anthropic-vocabulary SSE stream for the request log (usage + terminal),
135
+ * bounding body occupancy: idle (silence-only, timed ONLY while a reader.read() is
136
+ * pending so downstream backpressure never counts as upstream inactivity) and a
137
+ * cumulative byte cap. On stall/overflow it appends a protocol-compatible Anthropic
138
+ * `event: error` terminal frame after a blank-line boundary, closes, and cancels the
139
+ * upstream reader — never a total-wall-clock bound (slow-but-alive streams live).
140
+ * Exported for deterministic unit tests.
141
+ */
142
+ export function tapAnthropicSseForLog(
122
143
  upstream: ReadableStream<Uint8Array>,
123
144
  logCtx: RequestLogContext,
124
- finalize: (status: number, meta: { closeReason: "terminal" | "client_cancel" }) => void,
145
+ finalize: (status: number, meta: { closeReason: PassthroughCloseReason }) => void,
146
+ guard?: PassthroughBodyGuard,
125
147
  ): ReadableStream<Uint8Array> {
126
148
  const decoder = new TextDecoder();
149
+ const encoder = new TextEncoder();
127
150
  let buffer = "";
128
151
  let usageAcc: Rec = {};
129
152
  const inspect = (chunk: Uint8Array) => {
@@ -145,25 +168,110 @@ function tapAnthropicSseForLog(
145
168
  }
146
169
  };
147
170
  const reader = upstream.getReader();
171
+ let settled = false;
172
+ let bodyBytes = 0;
173
+ let tapController: ReadableStreamDefaultController<Uint8Array> | undefined;
174
+
175
+ const recordUsage = () => {
176
+ logCtx.usage = anthropicUsageToOcx(Object.keys(usageAcc).length > 0 ? usageAcc : undefined);
177
+ };
178
+ const failBody = (closeReason: "body_stall" | "body_overflow", errType: string, message: string) => {
179
+ if (settled) return;
180
+ settled = true;
181
+ idle.cancel();
182
+ detachAbort();
183
+ recordUsage();
184
+ finalize(200, { closeReason });
185
+ const payload = JSON.stringify({ type: "error", error: { type: errType, message } });
186
+ try {
187
+ // Leading blank line terminates any partial SSE block so the frame parses cleanly
188
+ // (relaySseWithFailedTail policy, Anthropic wire shape).
189
+ tapController?.enqueue(encoder.encode(`\n\nevent: error\ndata: ${payload}\n\n`));
190
+ tapController?.close();
191
+ } catch { /* client already torn down */ }
192
+ reader.cancel(new DOMException(message, closeReason === "body_stall" ? "TimeoutError" : "QuotaExceededError")).catch(() => {});
193
+ };
194
+ const idle = idleDeadline(guard?.stallMs ?? 0, () => {
195
+ failBody(
196
+ "body_stall",
197
+ "timeout_error",
198
+ `anthropic passthrough body stalled: no upstream bytes for ${Math.round((guard?.stallMs ?? 0) / 1000)}s`,
199
+ );
200
+ });
201
+ // Deterministic client-cancel classification: Bun may surface a client abort as a
202
+ // reader.read() rejection OR a resolved done (src/lib/abort.ts cancelBodyOnAbort
203
+ // rationale), so the listener performs first-wins settlement itself instead of
204
+ // relying on which shape the read takes.
205
+ const onClientAbort = () => {
206
+ if (settled) return;
207
+ settled = true;
208
+ idle.cancel();
209
+ detachAbort();
210
+ finalize(499, { closeReason: "client_cancel" });
211
+ try { tapController?.close(); } catch { /* downstream already torn down */ }
212
+ reader.cancel(guard?.reqSignal?.reason).catch(() => {});
213
+ };
214
+ const detachAbort = (() => {
215
+ const signal = guard?.reqSignal;
216
+ if (!signal) return () => {};
217
+ if (signal.aborted) {
218
+ queueMicrotask(onClientAbort);
219
+ return () => {};
220
+ }
221
+ signal.addEventListener("abort", onClientAbort, { once: true });
222
+ return () => signal.removeEventListener("abort", onClientAbort);
223
+ })();
224
+
148
225
  return new ReadableStream<Uint8Array>({
226
+ start(controller) {
227
+ tapController = controller;
228
+ },
149
229
  async pull(controller) {
230
+ if (settled) return;
150
231
  try {
232
+ idle.reset();
151
233
  const { done, value } = await reader.read();
234
+ idle.pause();
235
+ if (settled) return; // stall/overflow/abort won the race while we awaited
152
236
  if (done) {
153
- logCtx.usage = anthropicUsageToOcx(Object.keys(usageAcc).length > 0 ? usageAcc : undefined);
237
+ settled = true;
238
+ idle.cancel();
239
+ detachAbort();
240
+ recordUsage();
154
241
  finalize(200, { closeReason: "terminal" });
155
242
  controller.close();
156
243
  return;
157
244
  }
245
+ if (value.byteLength > 0) {
246
+ bodyBytes += value.byteLength;
247
+ if (guard && guard.maxBytes > 0 && bodyBytes > guard.maxBytes) {
248
+ failBody(
249
+ "body_overflow",
250
+ "api_error",
251
+ `anthropic passthrough body exceeded ${guard.maxBytes} bytes`,
252
+ );
253
+ return;
254
+ }
255
+ }
158
256
  inspect(value);
159
257
  controller.enqueue(value);
160
258
  } catch (err) {
259
+ if (settled) return;
260
+ settled = true;
261
+ idle.cancel();
262
+ detachAbort();
263
+ recordUsage();
161
264
  finalize(200, { closeReason: "terminal" });
162
265
  try { controller.error(err); } catch { /* torn down */ }
163
266
  }
164
267
  },
165
268
  cancel(reason) {
166
- finalize(499, { closeReason: "client_cancel" });
269
+ if (!settled) {
270
+ settled = true;
271
+ idle.cancel();
272
+ detachAbort();
273
+ finalize(499, { closeReason: "client_cancel" });
274
+ }
167
275
  reader.cancel(reason).catch(() => {});
168
276
  },
169
277
  });
@@ -182,7 +290,7 @@ async function anthropicNativePassthrough(
182
290
  logCtx.provider = "anthropic-native";
183
291
  logCtx.requestedModel = model;
184
292
  let logged = false;
185
- const finalize = (status: number, meta: { closeReason: "terminal" | "client_cancel" | "non_stream" }) => {
293
+ const finalize = (status: number, meta: { closeReason: PassthroughCloseReason | "non_stream" }) => {
186
294
  if (!logIds || logged) return;
187
295
  logged = true;
188
296
  addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, meta);
@@ -208,7 +316,7 @@ async function anthropicNativePassthrough(
208
316
  const result = await fetchWithHeaderDeadline(
209
317
  `${base}${pathname}${search}`,
210
318
  { method: "POST", headers, body: JSON.stringify(body) },
211
- config.connectTimeoutMs ?? 120_000,
319
+ config.connectTimeoutMs ?? 200_000,
212
320
  req.signal,
213
321
  );
214
322
  if (result.kind === "timeout") {
@@ -223,8 +331,9 @@ async function anthropicNativePassthrough(
223
331
  const upstream = result.upstream;
224
332
 
225
333
  const contentType = upstream.headers.get("content-type") ?? "application/json";
334
+ const bodyGuard = resolvePassthroughBodyGuard(config, req.signal);
226
335
  if (upstream.ok && contentType.includes("text/event-stream") && upstream.body) {
227
- return new Response(tapAnthropicSseForLog(upstream.body, logCtx, finalize), {
336
+ return new Response(tapAnthropicSseForLog(upstream.body, logCtx, finalize, bodyGuard), {
228
337
  status: upstream.status,
229
338
  headers: {
230
339
  "Content-Type": contentType,
@@ -233,8 +342,22 @@ async function anthropicNativePassthrough(
233
342
  },
234
343
  });
235
344
  }
236
- // Non-stream (count_tokens, errors, stream:false): relay verbatim, log on the spot.
237
- const text = await upstream.text();
345
+ // Non-stream (count_tokens, errors, stream:false): relay verbatim under the same
346
+ // idle/size bounds headers are NOT yet sent here, so real statuses are available.
347
+ const bodyResult = await readBoundedPassthroughBody(upstream, bodyGuard);
348
+ if (bodyResult.kind === "client_cancel") {
349
+ finalize(499, { closeReason: "client_cancel" });
350
+ return anthropicErrorResponse(499, "client closed request during anthropic passthrough", "api_error");
351
+ }
352
+ if (bodyResult.kind === "stall") {
353
+ finalize(504, { closeReason: "body_stall" });
354
+ return anthropicErrorResponse(504, `anthropic passthrough body stalled: no upstream bytes for ${Math.round(bodyGuard.stallMs / 1000)}s`, "timeout_error");
355
+ }
356
+ if (bodyResult.kind === "overflow") {
357
+ finalize(502, { closeReason: "body_overflow" });
358
+ return anthropicErrorResponse(502, `anthropic passthrough body exceeded ${bodyGuard.maxBytes} bytes`, "api_error");
359
+ }
360
+ const text = bodyResult.text;
238
361
  if (upstream.ok) {
239
362
  try {
240
363
  const parsed = JSON.parse(text) as { usage?: Rec };
@@ -249,6 +372,99 @@ async function anthropicNativePassthrough(
249
372
  });
250
373
  }
251
374
 
375
+ const DEFAULT_BODY_STALL_SEC = 90;
376
+ const DEFAULT_BODY_MAX_BYTES = 64 * 1024 * 1024;
377
+
378
+ /**
379
+ * Normalize the claudeCode body-guard config (devlog 260716_passthrough_followups/010).
380
+ * Policy: exactly 0 disables; finite positive values are honored (stall clamped to
381
+ * min 1s); negative/non-finite/absent values fall back to the defaults.
382
+ */
383
+ export function resolvePassthroughBodyGuard(config: OcxConfig, reqSignal?: AbortSignal): PassthroughBodyGuard {
384
+ const rawSec = config.claudeCode?.bodyStallSec;
385
+ const stallSec = rawSec === 0
386
+ ? 0
387
+ : typeof rawSec === "number" && Number.isFinite(rawSec) && rawSec > 0
388
+ ? Math.max(1, rawSec)
389
+ : DEFAULT_BODY_STALL_SEC;
390
+ const rawBytes = config.claudeCode?.bodyMaxBytes;
391
+ const maxBytes = rawBytes === 0
392
+ ? 0
393
+ : typeof rawBytes === "number" && Number.isFinite(rawBytes) && rawBytes > 0
394
+ ? Math.floor(rawBytes)
395
+ : DEFAULT_BODY_MAX_BYTES;
396
+ return { stallMs: stallSec * 1000, maxBytes, ...(reqSignal ? { reqSignal } : {}) };
397
+ }
398
+
399
+ type BoundedPassthroughBody =
400
+ | { kind: "ok"; text: string }
401
+ | { kind: "stall" }
402
+ | { kind: "overflow" }
403
+ | { kind: "client_cancel" };
404
+
405
+ /**
406
+ * Bounded replacement for `await upstream.text()` on the non-stream passthrough
407
+ * branch: same idle-only + size-cap semantics as the SSE tap. NOTE: reader.cancel()
408
+ * resolves a pending read as done rather than rejecting, so the stalled flag is
409
+ * re-checked after every read settlement (audit round 3).
410
+ */
411
+ export async function readBoundedPassthroughBody(
412
+ upstream: Response,
413
+ guard: PassthroughBodyGuard,
414
+ ): Promise<BoundedPassthroughBody> {
415
+ if (!upstream.body) return { kind: "ok", text: await upstream.text() };
416
+ const reader = upstream.body.getReader();
417
+ const decoder = new TextDecoder();
418
+ let text = "";
419
+ let bytes = 0;
420
+ let stalled = false;
421
+ let aborted = false;
422
+ const idle = idleDeadline(guard.stallMs, () => {
423
+ stalled = true;
424
+ reader.cancel(new DOMException("anthropic passthrough body stalled", "TimeoutError")).catch(() => {});
425
+ });
426
+ // Deterministic client-abort classification (audit round 4): Bun may surface the
427
+ // abort as a read rejection OR a resolved done, so we cancel the reader ourselves
428
+ // and classify via the flag rather than the read's settlement shape.
429
+ const signal = guard.reqSignal;
430
+ const onAbort = () => {
431
+ aborted = true;
432
+ reader.cancel(signal?.reason).catch(() => {});
433
+ };
434
+ if (signal?.aborted) onAbort();
435
+ else signal?.addEventListener("abort", onAbort, { once: true });
436
+ try {
437
+ while (true) {
438
+ idle.reset();
439
+ let result: Awaited<ReturnType<typeof reader.read>>;
440
+ try {
441
+ result = await reader.read();
442
+ } catch (err) {
443
+ if (aborted) return { kind: "client_cancel" };
444
+ if (stalled) return { kind: "stall" };
445
+ throw err;
446
+ } finally {
447
+ idle.pause();
448
+ }
449
+ if (aborted) return { kind: "client_cancel" };
450
+ if (stalled) return { kind: "stall" };
451
+ if (result.done) break;
452
+ if (result.value.byteLength === 0) continue;
453
+ bytes += result.value.byteLength;
454
+ if (guard.maxBytes > 0 && bytes > guard.maxBytes) {
455
+ reader.cancel(new DOMException("anthropic passthrough body exceeded byte cap", "QuotaExceededError")).catch(() => {});
456
+ return { kind: "overflow" };
457
+ }
458
+ text += decoder.decode(result.value, { stream: true });
459
+ }
460
+ text += decoder.decode();
461
+ return { kind: "ok", text };
462
+ } finally {
463
+ idle.cancel();
464
+ signal?.removeEventListener("abort", onAbort);
465
+ }
466
+ }
467
+
252
468
  /**
253
469
  * Header-phase fetch guarded by a clearable deadline (PR #136 follow-up hardening).
254
470
  *
@@ -461,9 +677,19 @@ export async function handleClaudeMessages(
461
677
  }
462
678
  } catch { /* keep fallback message */ }
463
679
  const retryAfter = response.headers.get("retry-after");
464
- const out = new Response(JSON.stringify(anthropicErrorBody(response.status, message)), {
465
- status: response.status,
466
- headers: { "Content-Type": "application/json", ...(retryAfter ? { "Retry-After": retryAfter } : {}) },
680
+ // Transient upstream 5xx (already retried pre-stream, 010): reclassify as Anthropic
681
+ // 529 overloaded_error so the Claude Code client applies its built-in backoff retry
682
+ // instead of dying on a fatal api_error (260716 sol-builder incident). The request
683
+ // log keeps the upstream status (captured in the deferred-log closure before this
684
+ // rewrite): log = upstream truth, client = retry signal.
685
+ const transient = isTransientUpstreamStatus(response.status);
686
+ const outStatus = transient ? 529 : response.status;
687
+ const out = new Response(JSON.stringify(anthropicErrorBody(outStatus, message)), {
688
+ status: outStatus,
689
+ headers: {
690
+ "Content-Type": "application/json",
691
+ ...(retryAfter ? { "Retry-After": retryAfter } : (transient ? { "Retry-After": "2" } : {})),
692
+ },
467
693
  });
468
694
  return out;
469
695
  }
@@ -1071,7 +1071,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
1071
1071
  if (url.pathname === "/api/oauth/logout" && req.method === "POST") {
1072
1072
  const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
1073
1073
  if (!isOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
1074
- removeCredential(provider);
1074
+ await removeCredential(provider);
1075
1075
  clearLoginState(provider);
1076
1076
  return jsonResponse({ success: true });
1077
1077
  }
@@ -1090,7 +1090,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
1090
1090
  if (!isOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
1091
1091
  if (!body.accountId) return jsonResponse({ error: "missing accountId" }, 400);
1092
1092
  const { setActiveAccount } = await import("../oauth/store");
1093
- if (!setActiveAccount(provider, body.accountId)) return jsonResponse({ error: "account not found" }, 404);
1093
+ if (!(await setActiveAccount(provider, body.accountId))) return jsonResponse({ error: "account not found" }, 404);
1094
1094
  const { clearProviderQuotaCache } = await import("../providers/quota");
1095
1095
  clearProviderQuotaCache();
1096
1096
  return jsonResponse({ ok: true, provider, activeAccountId: body.accountId });
@@ -1101,7 +1101,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
1101
1101
  if (!isOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
1102
1102
  if (!id) return jsonResponse({ error: "missing id" }, 400);
1103
1103
  const { removeAccount, getAccountSet } = await import("../oauth/store");
1104
- if (!removeAccount(provider, id)) return jsonResponse({ error: "account not found" }, 404);
1104
+ if (!(await removeAccount(provider, id))) return jsonResponse({ error: "account not found" }, 404);
1105
1105
  if (!getAccountSet(provider)) clearLoginState(provider);
1106
1106
  const { clearProviderQuotaCache } = await import("../providers/quota");
1107
1107
  clearProviderQuotaCache();
@@ -68,7 +68,7 @@ export interface RequestLogEntry {
68
68
  durationMs: number;
69
69
  errorCode?: string;
70
70
  terminalStatus?: ResponsesTerminalStatus;
71
- closeReason?: "terminal" | "client_cancel" | "non_stream";
71
+ closeReason?: "terminal" | "client_cancel" | "non_stream" | "body_stall" | "body_overflow";
72
72
  /** Secret-redacted upstream error reason, surfaced in /api/logs and the GUI detail modal. */
73
73
  upstreamError?: string;
74
74
  usageStatus: UsageStatus;
@@ -84,6 +84,17 @@ export function addRequestLog(entry: RequestLogEntry) {
84
84
  requestLog.push(entry);
85
85
  if (requestLog.length > MAX_LOG_SIZE) requestLog.shift();
86
86
  try {
87
+ // Failure diagnostics survive the 200-entry ring buffer by riding the persisted
88
+ // usage entry (devlog/_plan/260716_claudecode_hardening/030). Success rows stay
89
+ // in their existing shape; the >=400 gate deliberately includes 499 client-cancels.
90
+ const failureDiagnostics = entry.status >= 400 || (entry.terminalStatus && entry.terminalStatus !== "completed")
91
+ ? {
92
+ ...(entry.errorCode ? { errorCode: entry.errorCode } : {}),
93
+ ...(entry.terminalStatus ? { terminalStatus: entry.terminalStatus } : {}),
94
+ ...(entry.closeReason ? { closeReason: entry.closeReason } : {}),
95
+ ...(entry.upstreamError ? { upstreamError: entry.upstreamError } : {}),
96
+ }
97
+ : {};
87
98
  appendUsageEntry({
88
99
  requestId: entry.requestId,
89
100
  timestamp: entry.timestamp,
@@ -96,6 +107,7 @@ export function addRequestLog(entry: RequestLogEntry) {
96
107
  usageStatus: entry.usageStatus,
97
108
  ...(entry.usage ? { usage: entry.usage } : {}),
98
109
  ...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}),
110
+ ...failureDiagnostics,
99
111
  });
100
112
  } catch {
101
113
  /* request logging must never fail a user request */