@bitkyc08/opencodex 2.7.21 → 2.7.22

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.
@@ -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 */
@@ -14,8 +14,10 @@ import { injectionDebugLog } from "../lib/injection-debug-log";
14
14
  import { modelInList, namespacedToolName } from "../types";
15
15
  import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types";
16
16
  import {
17
+ forceRefreshOAuthAccessSnapshot,
17
18
  getOAuthCredentialProjectId,
18
- getValidAccessToken,
19
+ getValidAccessTokenSnapshot,
20
+ type OAuthAccessSnapshot,
19
21
  UnsupportedOAuthProviderError,
20
22
  } from "../oauth";
21
23
  import { buildWebSearchTool, planWebSearch, runWithWebSearch } from "../web-search";
@@ -36,7 +38,7 @@ import {
36
38
  recordCodexUpstreamOutcome,
37
39
  type CodexUpstreamOutcome,
38
40
  } from "../codex/routing";
39
- import { fetchWithResetRetry } from "../lib/upstream-retry";
41
+ import { fetchWithResetRetry, fetchWithTransientRetry } from "../lib/upstream-retry";
40
42
  import { isUsageDebugEnabled } from "../usage/debug";
41
43
  import { readJsonRequestBody, DecompressedBodyTooLargeError, UnsupportedContentEncodingError } from "./request-decompress";
42
44
  import { resolveAdapter, resolveWireProtocolOverride } from "./adapter-resolve";
@@ -611,9 +613,13 @@ export async function handleResponses(
611
613
 
612
614
  // OAuth providers: swap in a fresh access token (auto-refreshed) as the Bearer key, so the
613
615
  // existing openai-chat / anthropic adapters authenticate with no change.
616
+ const isXaiOAuthRequest = route.providerName === "xai" && route.provider.authMode === "oauth";
617
+ let sentOAuthSnapshot: OAuthAccessSnapshot | undefined;
614
618
  if (route.provider.authMode === "oauth") {
615
619
  try {
616
- route.provider = { ...route.provider, apiKey: await getValidAccessToken(route.providerName) };
620
+ const resolved = await getValidAccessTokenSnapshot(route.providerName);
621
+ if (isXaiOAuthRequest) sentOAuthSnapshot = resolved;
622
+ route.provider = { ...route.provider, apiKey: resolved.accessToken };
617
623
  // Antigravity (cloud-code-assist) needs the discovered Cloud Code Assist project id in the
618
624
  // CCA envelope; the server injects only the bare token, so pull project from the credential.
619
625
  if (route.provider.googleMode === "cloud-code-assist" && !route.provider.project) {
@@ -694,12 +700,15 @@ export async function handleResponses(
694
700
  const connectMs = config.connectTimeoutMs ?? 200_000;
695
701
  let upstreamResponse: Response;
696
702
  try {
697
- upstreamResponse = await fetchWithResetRetry(
703
+ // Transient-5xx pre-stream retry (devlog/_plan/260716_claudecode_hardening/010):
704
+ // the ChatGPT backend emits transient 502/520s that an immediate retry absorbs.
705
+ // Body is a replayable string; nothing has streamed to the client yet.
706
+ upstreamResponse = await fetchWithTransientRetry(
698
707
  () => fetchWithHeaderTimeout(request.url, {
699
708
  method: request.method,
700
709
  headers: request.headers,
701
710
  body: request.body,
702
- }, upstream.signal, connectMs, parsed.stream),
711
+ }, upstream.signal, connectMs, parsed.stream, providerFetch(route.provider)),
703
712
  { abortSignal: upstream.signal, label: safeHostLabel(request.url) },
704
713
  );
705
714
  } catch (err) {
@@ -726,22 +735,24 @@ export async function handleResponses(
726
735
  const terminalRecorder = codexForwardTerminalOutcomeRecorder(config, authCtx, route.provider);
727
736
  const terminalBodyWillRecord = !!terminalRecorder && upstreamResponse.ok && isEventStream;
728
737
  // Capture quota from upstream response for multi-account tracking
729
- if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
730
- const weeklyRaw = upstreamResponse.headers.get("x-codex-secondary-used-percent");
731
- const fiveHourRaw = upstreamResponse.headers.get("x-codex-primary-used-percent");
738
+ if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
739
+ // primary was the 5h window; it now carries weekly data for GPT plans.
740
+ // Prefer primary when present, fall back to secondary for compatibility.
741
+ const primaryRaw = upstreamResponse.headers.get("x-codex-primary-used-percent");
742
+ const secondaryRaw = upstreamResponse.headers.get("x-codex-secondary-used-percent");
743
+ const weeklyRaw = primaryRaw ?? secondaryRaw;
732
744
  const monthlyRaw = upstreamResponse.headers.get("x-codex-tertiary-used-percent");
733
- const weeklyResetRaw = upstreamResponse.headers.get("x-codex-secondary-reset-at");
734
- const fiveHourResetRaw = upstreamResponse.headers.get("x-codex-primary-reset-at");
745
+ const primaryResetRaw = upstreamResponse.headers.get("x-codex-primary-reset-at");
746
+ const secondaryResetRaw = upstreamResponse.headers.get("x-codex-secondary-reset-at");
747
+ const weeklyResetRaw = primaryRaw ? primaryResetRaw : secondaryResetRaw;
735
748
  const monthlyResetRaw = upstreamResponse.headers.get("x-codex-tertiary-reset-at");
736
749
  const retryAfterRaw = upstreamResponse.headers.get("retry-after");
737
- if (weeklyRaw || fiveHourRaw || monthlyRaw) {
750
+ if (weeklyRaw || monthlyRaw) {
738
751
  const { updateAccountQuota } = await import("../codex/auth-api");
739
752
  updateAccountQuota(
740
753
  authCtx.accountId,
741
754
  weeklyRaw,
742
- fiveHourRaw,
743
755
  weeklyResetRaw,
744
- fiveHourResetRaw,
745
756
  monthlyRaw,
746
757
  monthlyResetRaw,
747
758
  );
@@ -753,8 +764,8 @@ export async function handleResponses(
753
764
  });
754
765
  } else {
755
766
  recordCodexUpstreamOutcome(config, authCtx.accountId, upstreamResponse.status, {
756
- retryAfter: retryAfterRaw,
757
- resetAt: [fiveHourResetRaw, weeklyResetRaw, monthlyResetRaw],
767
+ retryAfter: retryAfterRaw,
768
+ resetAt: [primaryResetRaw, secondaryResetRaw, monthlyResetRaw].filter(Boolean),
758
769
  });
759
770
  }
760
771
  }
@@ -945,7 +956,7 @@ export async function handleResponses(
945
956
  : await fetchWithResetRetry(
946
957
  () => fetchWithHeaderTimeout(request.url, {
947
958
  method: request.method, headers: request.headers, body: request.body,
948
- }, upstream.signal, connectMs, parsed.stream),
959
+ }, upstream.signal, connectMs, parsed.stream, providerFetch(route.provider)),
949
960
  { abortSignal: upstream.signal, label: safeHostLabel(request.url) },
950
961
  );
951
962
  } catch (err) {
@@ -966,6 +977,7 @@ export async function handleResponses(
966
977
  let activeAdapter = adapter;
967
978
  let imageTierBias = 0;
968
979
  let imageRetryAttempted = false;
980
+ let oauth401ReplayAttempted = false;
969
981
  const rebuildAndRefetch = async (): Promise<Response | { failed: Response }> => {
970
982
  const retryRequest = await activeAdapter.buildRequest(parsed, {
971
983
  headers: selectedForwardHeaders,
@@ -976,7 +988,7 @@ export async function handleResponses(
976
988
  ? await activeAdapter.fetchResponse(retryRequest, { abortSignal: upstream.signal, timeoutMs: connectMs, stream: parsed.stream })
977
989
  : await fetchWithHeaderTimeout(retryRequest.url, {
978
990
  method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body,
979
- }, upstream.signal, connectMs, parsed.stream);
991
+ }, upstream.signal, connectMs, parsed.stream, providerFetch(route.provider));
980
992
  } catch (err) {
981
993
  cleanupUpstreamAbort();
982
994
  upstream.abort();
@@ -987,6 +999,38 @@ export async function handleResponses(
987
999
  }
988
1000
  };
989
1001
  recovery: for (;;) {
1002
+ if (
1003
+ upstreamResponse.status === 401
1004
+ && isXaiOAuthRequest
1005
+ && sentOAuthSnapshot
1006
+ && !oauth401ReplayAttempted
1007
+ ) {
1008
+ oauth401ReplayAttempted = true;
1009
+ try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
1010
+ let refreshed: OAuthAccessSnapshot;
1011
+ try {
1012
+ refreshed = await forceRefreshOAuthAccessSnapshot(sentOAuthSnapshot);
1013
+ } catch (err) {
1014
+ cleanupUpstreamAbort();
1015
+ return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err));
1016
+ }
1017
+ sentOAuthSnapshot = refreshed;
1018
+ const refreshedProvider = resolveProviderTransport(
1019
+ route.providerName,
1020
+ { ...route.provider, apiKey: refreshed.accessToken },
1021
+ parsed.options.promptCacheKey,
1022
+ );
1023
+ route.provider = refreshedProvider;
1024
+ activeAdapter = resolveAdapter(
1025
+ resolveWireProtocolOverride(route.providerName, route.modelId, refreshedProvider),
1026
+ config.cacheRetention,
1027
+ );
1028
+ const result = await rebuildAndRefetch();
1029
+ if ("failed" in result) return result.failed;
1030
+ upstreamResponse = result;
1031
+ continue recovery;
1032
+ }
1033
+
990
1034
  // Multi-key 429 failover: rotate to the next pool key (cooldown-aware) and retry the
991
1035
  // SAME request once per remaining key. OAuth/forward providers and single-key pools
992
1036
  // return null immediately, so this stays a no-op for them (src/providers/key-failover.ts).
@@ -1222,12 +1266,17 @@ export function safeHostLabel(url: string): string {
1222
1266
  }
1223
1267
  }
1224
1268
 
1269
+ function providerFetch(provider: OcxProviderConfig): typeof globalThis.fetch {
1270
+ return (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? globalThis.fetch;
1271
+ }
1272
+
1225
1273
  export async function fetchWithHeaderTimeout(
1226
1274
  url: string,
1227
1275
  init: Omit<RequestInit, "signal">,
1228
1276
  abortSignal: AbortSignal,
1229
1277
  timeoutMs: number,
1230
1278
  preferIdentityEncoding = false,
1279
+ executor: typeof globalThis.fetch = globalThis.fetch,
1231
1280
  ): Promise<Response> {
1232
1281
  const timeout = new AbortController();
1233
1282
  const timer = setTimeout(() => {
@@ -1240,7 +1289,7 @@ export async function fetchWithHeaderTimeout(
1240
1289
  headers.set("accept-encoding", "identity");
1241
1290
  }
1242
1291
  try {
1243
- return await fetch(url, {
1292
+ return await executor(url, {
1244
1293
  ...init,
1245
1294
  headers,
1246
1295
  signal: AbortSignal.any([abortSignal, timeout.signal]),
package/src/types.ts CHANGED
@@ -258,6 +258,19 @@ export interface OcxClaudeCodeConfig {
258
258
  nativePassthrough?: boolean;
259
259
  /** Upstream for the native passthrough (tests/enterprise gateways). Default: https://api.anthropic.com */
260
260
  anthropicBaseUrl?: string;
261
+ /**
262
+ * Native passthrough body inactivity budget in SECONDS — raw upstream-byte silence
263
+ * while a read is pending, NOT total duration (slow-but-alive streams never trip it;
264
+ * devlog 260716_passthrough_followups/010). Default 90. Min 1. Exactly 0 disables;
265
+ * negative/non-finite values fall back to the default.
266
+ */
267
+ bodyStallSec?: number;
268
+ /**
269
+ * Native passthrough cumulative body byte cap (streamed SSE and buffered non-stream
270
+ * alike) — an OOM/occupancy guard, not a correctness limit. Default 67108864 (64 MiB).
271
+ * Exactly 0 disables; negative/non-finite values fall back to the default.
272
+ */
273
+ bodyMaxBytes?: number;
261
274
  /** Default model slot injected as ANTHROPIC_MODEL by `ocx claude`. */
262
275
  model?: string;
263
276
  /** Haiku/small-fast slot injected as ANTHROPIC_DEFAULT_HAIKU_MODEL (+ legacy SMALL_FAST). */
package/src/usage/log.ts CHANGED
@@ -18,6 +18,13 @@ export interface PersistedUsageEntry {
18
18
  usageStatus: UsageStatus;
19
19
  usage?: OcxUsage;
20
20
  totalTokens?: number;
21
+ // Failure diagnostics (devlog/_plan/260716_claudecode_hardening/030): persisted for
22
+ // status>=400 or non-completed terminals so incidents survive the in-memory ring buffer.
23
+ errorCode?: string;
24
+ terminalStatus?: string;
25
+ closeReason?: "terminal" | "client_cancel" | "non_stream" | "body_stall" | "body_overflow";
26
+ /** Already redacted + capped at capture (request-log.ts redactSecretString().slice(0,500)). */
27
+ upstreamError?: string;
21
28
  }
22
29
 
23
30
  export function usageLogPath(): string {
@@ -76,6 +83,10 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
76
83
  usageStatus: entry.usageStatus,
77
84
  ...(entry.usage ? { usage: normalizeUsageValue(entry.usage) } : {}),
78
85
  ...(typeof entry.totalTokens === "number" ? { totalTokens: entry.totalTokens } : {}),
86
+ ...(entry.errorCode ? { errorCode: entry.errorCode } : {}),
87
+ ...(entry.terminalStatus ? { terminalStatus: entry.terminalStatus } : {}),
88
+ ...(entry.closeReason ? { closeReason: entry.closeReason } : {}),
89
+ ...(entry.upstreamError ? { upstreamError: entry.upstreamError } : {}),
79
90
  };
80
91
  }
81
92