@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.
@@ -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