@bitkyc08/opencodex 2.39.0 → 2.40.0

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.
Files changed (144) hide show
  1. package/README.md +49 -21
  2. package/gui/dist/assets/index-BHe2rl_C.js +112 -0
  3. package/gui/dist/assets/index-CJSb3HPe.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/catalog.ts +140 -7
  7. package/src/adapters/cursor/discovery.ts +79 -87
  8. package/src/adapters/cursor/effort-map.ts +11 -0
  9. package/src/adapters/cursor/request-builder.ts +31 -4
  10. package/src/adapters/cursor.ts +17 -0
  11. package/src/adapters/openai-chat.ts +95 -9
  12. package/src/adapters/openai-responses.ts +73 -9
  13. package/src/bridge.ts +58 -13
  14. package/src/claude/auth-mode.ts +6 -9
  15. package/src/claude/gateway-cache.ts +23 -3
  16. package/src/claude/model-info.ts +13 -2
  17. package/src/cli/access.ts +30 -0
  18. package/src/cli/account-extended.ts +19 -10
  19. package/src/cli/capabilities.ts +40 -1
  20. package/src/cli/claude-agent-startup-sync.ts +3 -0
  21. package/src/cli/claude.ts +134 -36
  22. package/src/cli/connect.ts +226 -0
  23. package/src/cli/dispatch.ts +126 -21
  24. package/src/cli/gui-pair-client.ts +170 -0
  25. package/src/cli/gui.ts +87 -0
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/index.ts +76 -22
  28. package/src/cli/models-runtime-subcommands.ts +34 -0
  29. package/src/cli/models-runtime.ts +4 -0
  30. package/src/cli/models.ts +2 -1
  31. package/src/cli/opencode.ts +2 -1
  32. package/src/cli/provider-runtime.ts +31 -1
  33. package/src/cli/registry.ts +32 -2
  34. package/src/cli/runtime-api.ts +10 -1
  35. package/src/cli/status.ts +26 -0
  36. package/src/cli/system-command.ts +9 -3
  37. package/src/client/connect.ts +650 -0
  38. package/src/client/hub-client.ts +457 -0
  39. package/src/client/hub-relay.ts +288 -0
  40. package/src/client/machine-api.ts +139 -0
  41. package/src/client/machine-auth.ts +54 -0
  42. package/src/client/machine-listener.ts +143 -0
  43. package/src/client/runtime.ts +93 -0
  44. package/src/client/state.ts +175 -0
  45. package/src/clients/config-export.ts +16 -5
  46. package/src/codex/auth-api.ts +169 -15
  47. package/src/codex/auth-context.ts +61 -6
  48. package/src/codex/catalog/aggregation.ts +17 -13
  49. package/src/codex/catalog/metadata.ts +28 -0
  50. package/src/codex/catalog/provider-fetch.ts +38 -5
  51. package/src/codex/catalog.ts +1 -1
  52. package/src/codex/desired-state.ts +13 -3
  53. package/src/codex/inject.ts +212 -31
  54. package/src/codex/journal.ts +54 -6
  55. package/src/codex/main-account.ts +105 -33
  56. package/src/codex/native-main-claim.ts +23 -1
  57. package/src/codex/native-residue.ts +7 -0
  58. package/src/codex/reset-credit-auto-redeem.ts +237 -0
  59. package/src/combos/request.ts +15 -3
  60. package/src/combos/types.ts +12 -0
  61. package/src/config/paths.ts +15 -1
  62. package/src/config/provider-validation.ts +40 -0
  63. package/src/config.ts +447 -3
  64. package/src/generated/compatibility-version.json +224 -108
  65. package/src/images/artifacts.ts +73 -37
  66. package/src/images/fulfill.ts +6 -1
  67. package/src/images/index.ts +1 -1
  68. package/src/images/plan.ts +16 -2
  69. package/src/images/synthetic-tool.ts +5 -0
  70. package/src/images/xai-client.ts +34 -1
  71. package/src/integrations/cursor-detect.ts +133 -0
  72. package/src/integrations/cursor-seen.ts +31 -0
  73. package/src/lib/errors.ts +18 -0
  74. package/src/lib/gui-pair-capability.ts +104 -0
  75. package/src/lib/lab-live-route-production.ts +2 -1
  76. package/src/lib/process-control.ts +4 -3
  77. package/src/lib/service-secrets.ts +161 -1
  78. package/src/lib/windows-system-proxy.ts +115 -0
  79. package/src/oauth/index.ts +3 -2
  80. package/src/oauth/pool-settings-capability.ts +55 -0
  81. package/src/providers/api-keys.ts +3 -1
  82. package/src/providers/codex-capacity.ts +34 -4
  83. package/src/providers/derive.ts +10 -0
  84. package/src/providers/fastwire.ts +10 -2
  85. package/src/providers/key-store.ts +197 -0
  86. package/src/providers/model-rename-migration.ts +3 -0
  87. package/src/providers/openai-sidecar.ts +3 -3
  88. package/src/providers/quota.ts +91 -42
  89. package/src/providers/registry.ts +33 -3
  90. package/src/reasoning-effort.ts +32 -0
  91. package/src/remote/protocol.ts +109 -0
  92. package/src/responses/citation-markers.ts +101 -0
  93. package/src/responses/parser.ts +43 -3
  94. package/src/responses/spill-store.ts +9 -4
  95. package/src/router.ts +72 -8
  96. package/src/server/auth-cors.ts +37 -1
  97. package/src/server/gui-session.ts +449 -0
  98. package/src/server/gui-static.ts +24 -7
  99. package/src/server/images.ts +237 -10
  100. package/src/server/index.ts +356 -30
  101. package/src/server/management/agent-settings-routes.ts +7 -1
  102. package/src/server/management/api-key-rotation.ts +74 -0
  103. package/src/server/management/combo-routes.ts +18 -6
  104. package/src/server/management/config-routes.ts +22 -3
  105. package/src/server/management/context.ts +5 -1
  106. package/src/server/management/cursor-integration-routes.ts +98 -0
  107. package/src/server/management/logs-usage-routes.ts +2 -1
  108. package/src/server/management/model-routes.ts +79 -1
  109. package/src/server/management/model-rows.ts +23 -1
  110. package/src/server/management/oauth-account-routes.ts +135 -2
  111. package/src/server/management/provider-routes.ts +143 -9
  112. package/src/server/management/route-registry.ts +10 -0
  113. package/src/server/management/session-routes.ts +13 -0
  114. package/src/server/management/system-routes.ts +13 -1
  115. package/src/server/management-api.ts +8 -3
  116. package/src/server/management-auth.ts +124 -82
  117. package/src/server/models-capabilities.ts +124 -0
  118. package/src/server/ports.ts +7 -0
  119. package/src/server/proxy-liveness.ts +7 -0
  120. package/src/server/request-log.ts +11 -5
  121. package/src/server/responses/agent-task-recovery.ts +1 -0
  122. package/src/server/responses/codex-auth-error.ts +3 -1
  123. package/src/server/responses/collaboration.ts +26 -1
  124. package/src/server/responses/combo-stream-preflight.ts +25 -6
  125. package/src/server/responses/compact.ts +32 -12
  126. package/src/server/responses/context-overflow.ts +49 -0
  127. package/src/server/responses/core.ts +196 -12
  128. package/src/server/responses/fetch-helpers.ts +2 -1
  129. package/src/server/responses/outbound-body-guard.ts +110 -0
  130. package/src/server/responses/ws-upstream.ts +87 -6
  131. package/src/server/responses-self-named-namespace-scrub.ts +181 -0
  132. package/src/server/system-env.ts +78 -25
  133. package/src/service.ts +218 -42
  134. package/src/types/config.ts +134 -0
  135. package/src/types/provider.ts +54 -1
  136. package/src/types/request.ts +2 -0
  137. package/src/types.ts +5 -0
  138. package/src/update/job.ts +33 -5
  139. package/src/usage/cost.ts +32 -1
  140. package/src/usage/expected-prices.ts +16 -0
  141. package/src/usage/log.ts +6 -2
  142. package/src/usage/summary.ts +48 -6
  143. package/gui/dist/assets/index-D-lchsPw.js +0 -112
  144. package/gui/dist/assets/index-uvENYLin.css +0 -1
@@ -3,13 +3,13 @@ import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type Resp
3
3
  import {
4
4
  getConfigPath,
5
5
  multiAgentGuidanceEnabled,
6
- resolveEnvValue,
7
6
  } from "../../config";
7
+ import { resolveProviderApiKey } from "../../providers/key-store";
8
8
  import { parseRequest } from "../../responses/parser";
9
9
  import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction";
10
10
  import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses";
11
11
  import { expandPreviousResponseInput, previousResponseProviderState, rememberResponseState } from "../../responses/state";
12
- import { NoEligiblePolicyCandidateError, routeModel } from "../../router";
12
+ import { NoEligiblePolicyCandidateError, routeCompactionModel } from "../../router";
13
13
  import { evidenceFromBody } from "../../routing/request-evidence";
14
14
  import {
15
15
  advanceComboAfterFailure,
@@ -267,6 +267,9 @@ async function refreshNativeMainCompactContext(args: {
267
267
  }
268
268
  return { ok: true, authCtx: refreshedAuthCtx, provider: refreshedProvider, headers };
269
269
  } catch (error) {
270
+ if (req.signal.aborted) {
271
+ return { ok: false, response: formatErrorResponse(499, "client_cancelled", "Client cancelled compact request") };
272
+ }
270
273
  return { ok: false, response: nativeMainRefreshFailureResponse(error) };
271
274
  }
272
275
  }
@@ -382,7 +385,10 @@ async function resolveAlternateCompactContext(args: {
382
385
  requestScopedMainCredential: hasForwardableCodexBearer(req.headers, config),
383
386
  beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease),
384
387
  });
385
- if (!authCtx.accountId || authCtx.accountId === excludeAccountId) return null;
388
+ // Caller-owned main has no Pool account id. It is still a valid one-shot alternate after a
389
+ // stored account fails; resolveCodexAuthContext already prevents returning it when main is the
390
+ // excluded credential.
391
+ if (authCtx.accountId === excludeAccountId) return null;
386
392
  const provider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode);
387
393
  const headers = new Headers({ "content-type": "application/json" });
388
394
  const selected = headersForCodexAuthContext(req.headers, authCtx);
@@ -395,7 +401,7 @@ async function resolveAlternateCompactContext(args: {
395
401
  headers.set("authorization", `Bearer ${override.accessToken}`);
396
402
  headers.set("chatgpt-account-id", override.chatgptAccountId);
397
403
  }
398
- if (provider.apiKey) headers.set("authorization", `Bearer ${resolveEnvValue(provider.apiKey)}`);
404
+ if (provider.apiKey) headers.set("authorization", `Bearer ${resolveProviderApiKey(provider.apiKey)}`);
399
405
  return { authCtx, provider, headers };
400
406
  } catch (err) {
401
407
  if (err instanceof CodexMainProfileDrainingError) {
@@ -503,7 +509,10 @@ export async function handleResponsesCompact(
503
509
  // Compact requests route through the same policy evaluation as normal
504
510
  // turns, so body-derived evidence (tools/image) must reach the first
505
511
  // evaluation too - not only the later handleResponses dispatch.
506
- route = routeModel(config, raw.model, evidenceFromBody(raw));
512
+ // Codex selects a bare native model for compaction even when the operator
513
+ // routes ordinary turns elsewhere (#2901); the compaction-scoped router
514
+ // may land that on the configured default provider instead of 404.
515
+ route = routeCompactionModel(config, raw.model, evidenceFromBody(raw));
507
516
  } catch (err) {
508
517
  if (err instanceof NoEligiblePolicyCandidateError) {
509
518
  // Persist the evaluation trace (per-candidate exclusions + the
@@ -556,7 +565,10 @@ export async function handleResponsesCompact(
556
565
  // Native /responses/compact exists on the canonical ChatGPT backend and on the
557
566
  // official OpenAI API. Any other Responses-shaped gateway must take the routed
558
567
  // summarizer path below, or compaction fails against an endpoint it never had (#422).
559
- if (supportsNativeResponsesCompactEndpoint(route.providerName, route.provider) && !accountGatedCompactWireModel) {
568
+ // Combo-resolved targets skip native compact so failover can advance through the
569
+ // combo target list when the picked model returns 429/5xx — the routed path below
570
+ // dispatches through handleResponses → handleComboResponses with full failover.
571
+ if (supportsNativeResponsesCompactEndpoint(route.providerName, route.provider) && !accountGatedCompactWireModel && !route.combo) {
560
572
  if (req.signal.aborted) {
561
573
  return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
562
574
  }
@@ -614,6 +626,9 @@ export async function handleResponsesCompact(
614
626
  }
615
627
  }
616
628
  } catch (err) {
629
+ if (req.signal.aborted) {
630
+ return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
631
+ }
617
632
  const response = mapCodexAuthContextErrorToResponse(err, {
618
633
  accountSelector: route.codexAccountNamespace,
619
634
  now: Date.now(),
@@ -625,7 +640,7 @@ export async function handleResponsesCompact(
625
640
  ? CODEX_FORWARD_BASE_URL
626
641
  : (compactProvider.baseUrl ?? "").replace(/\/+$/, "");
627
642
  if (compactProvider.authMode !== "forward" && compactProvider.apiKey) {
628
- headers.set("authorization", `Bearer ${resolveEnvValue(compactProvider.apiKey)}`);
643
+ headers.set("authorization", `Bearer ${resolveProviderApiKey(compactProvider.apiKey)}`);
629
644
  }
630
645
  const { reasoning: _reasoning, ...compactBodyRaw } = raw as typeof raw & { reasoning?: unknown };
631
646
  // The regular /v1/responses path applies sanitizeReasoningInputContent via the adapter's
@@ -994,8 +1009,10 @@ export async function handleResponsesCompact(
994
1009
  ...raw,
995
1010
  // Canonical ChatGPT Responses rejects non-streaming turns. Daybreak cannot use the
996
1011
  // native compact endpoint either, so run its synthetic compaction as SSE and collapse
997
- // the completed event back into the v1 compact JSON contract below.
998
- stream: accountGatedCompactWireModel ? true : false,
1012
+ // the completed event back into the v1 compact JSON contract below. Combo-dispatched
1013
+ // turns also go out as SSE: failover can land on a canonical child that rejects a
1014
+ // non-streaming turn, and every combo-capable provider already serves streaming traffic.
1015
+ stream: accountGatedCompactWireModel || route.combo ? true : false,
999
1016
  input: [...inputItems, { type: "compaction_trigger" }],
1000
1017
  };
1001
1018
  const internalHeaders = new Headers({ "content-type": "application/json" });
@@ -1069,9 +1086,12 @@ export async function handleResponsesCompact(
1069
1086
  `compaction turn produced ${compactionItems.length} compaction items, expected exactly 1`,
1070
1087
  );
1071
1088
  }
1072
- // The canonical Responses stream returns a real OpenAI-encrypted compaction item. OCX cannot
1073
- // and should not decrypt it; /responses/compact callers can consume that item directly.
1074
- if (accountGatedCompactWireModel) {
1089
+ // Native Responses backends return a real opaque OpenAI-encrypted compaction item. OCX cannot
1090
+ // and should not decrypt it; preserve that item for /responses/compact callers. Synthetic
1091
+ // routed summaries are our `ocx1:` envelope and must be decoded into v1 history items.
1092
+ if (typeof compactionItems[0]!.encrypted_content === "string"
1093
+ && compactionItems[0]!.encrypted_content.trim().length > 0
1094
+ && !compactionItems[0]!.encrypted_content.startsWith("ocx1:")) {
1075
1095
  const result = new Response(JSON.stringify({ output: compactionItems }), {
1076
1096
  headers: { "Content-Type": "application/json" },
1077
1097
  });
@@ -0,0 +1,49 @@
1
+ import { bridgeToResponsesSSE } from "../../bridge";
2
+ import type { TranslatorBudget } from "../../lib/translator-budget";
3
+ import type { AdapterEvent } from "../../types";
4
+
5
+ export const PROVIDER_INPUT_TOO_LARGE_MESSAGE =
6
+ "The provider rejected this turn because its input exceeds the provider size or context limit. Reduce the current input or compact the conversation before retrying.";
7
+
8
+ async function* contextOverflowEvents(): AsyncGenerator<AdapterEvent> {
9
+ yield {
10
+ type: "error",
11
+ message: PROVIDER_INPUT_TOO_LARGE_MESSAGE,
12
+ status: 413,
13
+ errorType: "invalid_request_error",
14
+ code: "context_length_exceeded",
15
+ retryable: false,
16
+ };
17
+ }
18
+
19
+ /**
20
+ * Convert a pre-stream provider 413 into the terminal Responses event Codex understands.
21
+ *
22
+ * Codex treats an HTTP 413 as an unexpected, retryable transport failure and resends the
23
+ * same oversized body through its reconnect budget. A `response.failed` event carrying
24
+ * `context_length_exceeded` is instead terminal and marks the client context as full, so
25
+ * its next-turn compaction policy can run. The message is proxy-owned on purpose: upstream
26
+ * 413 bodies can echo request data and are not needed to classify an unambiguous status.
27
+ */
28
+ export function streamingContextOverflowResponse(
29
+ modelId: string,
30
+ translatorBudget: TranslatorBudget,
31
+ ): Response {
32
+ return new Response(bridgeToResponsesSSE(
33
+ contextOverflowEvents(),
34
+ modelId,
35
+ undefined,
36
+ undefined,
37
+ undefined,
38
+ undefined,
39
+ 2_000,
40
+ { translatorBudget },
41
+ ), {
42
+ headers: {
43
+ "Content-Type": "text/event-stream",
44
+ "Cache-Control": "no-cache",
45
+ Connection: "keep-alive",
46
+ "X-Accel-Buffering": "no",
47
+ },
48
+ });
49
+ }
@@ -7,6 +7,10 @@ import {
7
7
  backfillResponsesFieldsJson,
8
8
  } from "./responses-field-backfill";
9
9
  import { checkInputAdmission } from "./input-admission";
10
+ import {
11
+ checkOutboundBodySize,
12
+ describeOutboundBodyRefusal,
13
+ } from "./outbound-body-guard";
10
14
  import { nativeContextLimits } from "../../codex/catalog";
11
15
  import { describeUpstreamConnectFailure } from "./upstream-error";
12
16
  import {
@@ -54,6 +58,7 @@ import {
54
58
  import {
55
59
  comboRouteDecisionTrace,
56
60
  NoEligiblePolicyCandidateError,
61
+ routeCompactionModel,
57
62
  routeConcreteModel,
58
63
  routeModel,
59
64
  type RouteResult,
@@ -241,6 +246,10 @@ import {
241
246
  } from "../lifecycle";
242
247
  import { redactSecretString, sanitizeLogMetadataString } from "../../lib/redact";
243
248
  import { readBoundedResponseBody } from "../../lib/bounded-body";
249
+ import {
250
+ isRateLimitOrQuotaFailureMessage,
251
+ upstreamErrorMessageFromPayload,
252
+ } from "../../lib/errors";
244
253
  import type { AdmissionLease } from "../../lib/admission";
245
254
  import { supportedLadderFor } from "../effort-policy";
246
255
  import { isThreadSpawnRequest } from "../effort-policy";
@@ -317,6 +326,11 @@ import {
317
326
  restoreImageGenCallsInJson,
318
327
  } from "../responses-image-gen-repair";
319
328
  import { createResponsesModelPayloadRewrite, rewriteResponsesModelJson } from "../responses-model-rewrite";
329
+ import {
330
+ collectSelfNamedNamespaceScrubAuthorization,
331
+ createSelfNamedToolCallNamespaceScrubRewrite,
332
+ scrubSelfNamedToolCallNamespaceInJson,
333
+ } from "../responses-self-named-namespace-scrub";
320
334
  import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog";
321
335
 
322
336
  import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration";
@@ -370,6 +384,7 @@ import {
370
384
  } from "../responses-undeclared-tool-guard";
371
385
  import { createGithubCopilotResponsesBlockRewrite } from "../github-copilot-responses-repair";
372
386
  import { responsesJsonToSseStream } from "../responses-json-events";
387
+ import { streamingContextOverflowResponse } from "./context-overflow";
373
388
  import { guardTerminalEventStream } from "./terminal-guard";
374
389
  import {
375
390
  emptyCompletionRetryEnabled,
@@ -904,8 +919,40 @@ async function shouldRetryCodexPoolAccountModel400(
904
919
  }
905
920
 
906
921
  /** Pre-stream quota/billing rejections that warrant one alternate-account attempt (#584). */
907
- export function shouldRetryCodexPoolAccountQuota(response: Response): boolean {
908
- return response.status === 402 || response.status === 429;
922
+ function codexQuotaFailureMessage(body: string): string | undefined {
923
+ try {
924
+ const payload = JSON.parse(body) as unknown;
925
+ const canonical = upstreamErrorMessageFromPayload(payload);
926
+ if (canonical !== undefined) return canonical;
927
+ if (typeof payload === "string") return payload;
928
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
929
+ const record = payload as Record<string, unknown>;
930
+ if (typeof record.message === "string") return record.message;
931
+ return typeof record.error === "string" ? record.error : undefined;
932
+ } catch {
933
+ // Plain-text gateways remain supported. Valid JSON is inspected only at recognized
934
+ // message fields so echoed request content elsewhere cannot trigger account cooldown.
935
+ return body;
936
+ }
937
+ }
938
+
939
+ export async function shouldRetryCodexPoolAccountQuota(
940
+ response: Response,
941
+ signal?: AbortSignal,
942
+ ): Promise<boolean> {
943
+ if (response.status === 402 || response.status === 429) return true;
944
+ if (response.status < 500 || response.status >= 600) return false;
945
+ try {
946
+ // Reject malformed UTF-8 instead of matching quota words around replacement characters.
947
+ const body = await readBoundedResponseBody(response.clone(), { signal, fatalUtf8: true });
948
+ const message = body.displaySafe && !body.truncated
949
+ ? codexQuotaFailureMessage(body.text)
950
+ : undefined;
951
+ return message !== undefined
952
+ && isRateLimitOrQuotaFailureMessage(message);
953
+ } catch {
954
+ return false;
955
+ }
909
956
  }
910
957
 
911
958
  interface CodexPoolAccountRetryArgs {
@@ -945,7 +992,7 @@ interface CodexPoolAccountRetryArgs {
945
992
  stream: boolean;
946
993
  onResponse?: (
947
994
  response: Response,
948
- authCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>,
995
+ authCtx: CodexAuthContext,
949
996
  request: Awaited<ReturnType<ReturnType<typeof resolveAdapter>["buildRequest"]>>,
950
997
  ) => void;
951
998
  }
@@ -953,7 +1000,7 @@ interface CodexPoolAccountRetryArgs {
953
1000
  type CodexPoolAccountRetryResult =
954
1001
  | {
955
1002
  kind: "retried";
956
- authCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>;
1003
+ authCtx: CodexAuthContext;
957
1004
  request: Awaited<ReturnType<ReturnType<typeof resolveAdapter>["buildRequest"]>>;
958
1005
  upstreamResponse: Response;
959
1006
  selectedForwardHeaders: Headers;
@@ -962,7 +1009,7 @@ type CodexPoolAccountRetryResult =
962
1009
  | {
963
1010
  kind: "transport";
964
1011
  error: unknown;
965
- authCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>;
1012
+ authCtx: CodexAuthContext;
966
1013
  };
967
1014
 
968
1015
  /** Keep retry-stage entitlement snapshots inside the native-main selection fence. */
@@ -1129,7 +1176,27 @@ async function retryCodexPoolOnAlternateAccount(
1129
1176
  throw error;
1130
1177
  }
1131
1178
  }
1132
- if (retryAuthCtx?.kind !== "pool" && retryAuthCtx?.kind !== "main-pool") {
1179
+ // A validated request-owned main bearer is a real alternate when the failed credential was a
1180
+ // stored Pool account. It has no Pool account id to promote or cool, but it can own this one
1181
+ // bounded replay. The resolver already refuses it when main itself is the excluded credential.
1182
+ if (
1183
+ retryAuthCtx?.kind !== "pool"
1184
+ && retryAuthCtx?.kind !== "main-pool"
1185
+ && retryAuthCtx?.kind !== "main"
1186
+ ) {
1187
+ // A body-confirmed quota response may arrive under HTTP 5xx. Without an alternate,
1188
+ // the ordinary terminal recorder sees only that wire status and would misclassify it
1189
+ // as transient, leaving the exhausted account immediately selectable next turn.
1190
+ if (outcomeStatus !== firstResponse.status && (outcomeStatus === 429 || outcomeStatus === 402)) {
1191
+ recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
1192
+ ...codexQuotaOutcomeMeta(firstResponse),
1193
+ threadId: firstAuthCtx.affinityKey,
1194
+ modelId: route.modelId,
1195
+ probeLeaseId: codexProbeLeaseId(firstAuthCtx),
1196
+ probeQuotaScope: codexProbeQuotaScope(firstAuthCtx),
1197
+ writerGeneration: firstAuthCtx.writerGeneration,
1198
+ });
1199
+ }
1133
1200
  return { kind: "no-alternate" };
1134
1201
  }
1135
1202
 
@@ -1243,6 +1310,9 @@ async function retryCodexPoolOnAlternateAccount(
1243
1310
  retrySendCount += 1;
1244
1311
  args.onResponse?.(upstreamResponse, retryAuthCtx, request);
1245
1312
  if (!retrySameConfirmedAccount || retrySendCount >= maxRetrySends) break;
1313
+ // Caller-owned main is an alternate-account replay and can never enter the bounded
1314
+ // same-stored-account 400 loop above. Keep that invariant explicit for the account-id reads.
1315
+ if (retryAuthCtx.kind === "main") break;
1246
1316
  if (!await shouldRetryCodexPoolAccountModel400(
1247
1317
  upstreamResponse,
1248
1318
  route.modelId,
@@ -1757,6 +1827,9 @@ async function resolveResponsesCodexAuth(
1757
1827
  substituteMainCredential,
1758
1828
  };
1759
1829
  } catch (err) {
1830
+ if (options.abortSignal?.aborted || req.signal.aborted) {
1831
+ return { ok: false, response: clientCancelledResponse() };
1832
+ }
1760
1833
  if (err instanceof CodexAuthContextError) {
1761
1834
  const safeAccountLabel = route.codexAccountNamespace
1762
1835
  ? `${route.providerName}-${route.codexAccountNamespace}`
@@ -1902,6 +1975,9 @@ async function refreshNativeMainForwardAuth(args: {
1902
1975
  });
1903
1976
  return { ok: true, authCtx: refreshedAuthCtx, provider, headers };
1904
1977
  } catch (error) {
1978
+ if (options.abortSignal?.aborted || req.signal.aborted) {
1979
+ return { ok: false, response: clientCancelledResponse() };
1980
+ }
1905
1981
  return { ok: false, response: nativeMainRefreshFailureResponse(error) };
1906
1982
  }
1907
1983
  }
@@ -2102,7 +2178,7 @@ export async function handleComboResponses(
2102
2178
  comboId: string,
2103
2179
  config: OcxConfig,
2104
2180
  logCtx: RequestLogContext,
2105
- options: HandleResponsesOptions,
2181
+ options: HandleResponsesOptions & { translatorBudget: TranslatorBudget },
2106
2182
  ): Promise<Response> {
2107
2183
  const requestedModel = typeof (rawBody as { model?: unknown } | null)?.model === "string"
2108
2184
  ? (rawBody as { model: string }).model
@@ -2451,6 +2527,12 @@ export async function handleComboResponses(
2451
2527
  code: failure.upstreamCode,
2452
2528
  }) === "stop") {
2453
2529
  adoptFailedChildLog(childLog);
2530
+ if (
2531
+ failure.response.status === 413
2532
+ && (rawBody as { stream?: unknown } | null)?.stream === true
2533
+ ) {
2534
+ return streamingContextOverflowResponse(requestedModel, options.translatorBudget);
2535
+ }
2454
2536
  return lastFailure;
2455
2537
  }
2456
2538
  console.warn(
@@ -2464,6 +2546,12 @@ export async function handleComboResponses(
2464
2546
  if (!nextPick) adoptFailedChildLog(childLog);
2465
2547
  pick = nextPick;
2466
2548
  }
2549
+ if (
2550
+ lastFailure?.status === 413
2551
+ && (rawBody as { stream?: unknown } | null)?.stream === true
2552
+ ) {
2553
+ return streamingContextOverflowResponse(requestedModel, options.translatorBudget);
2554
+ }
2467
2555
  return lastFailure!;
2468
2556
  }
2469
2557
 
@@ -2750,9 +2838,15 @@ async function handleResponsesInner(
2750
2838
 
2751
2839
  let route: RouteResult;
2752
2840
  try {
2841
+ // A `compaction_trigger` turn may name a bare native model the operator has
2842
+ // no canonical OpenAI route for (#2901). Only the initial compaction route
2843
+ // may fall back to the configured default provider; combo attempts and the
2844
+ // later fallback/recovery re-routes keep the ordinary reservation.
2753
2845
  const resolveRoute = (modelId: string) => options.comboAttempt
2754
2846
  ? routeConcreteModel(config, modelId)
2755
- : routeModel(config, modelId, evidenceFromBody(parsed._rawBody));
2847
+ : parsed._compactionRequest === true
2848
+ ? routeCompactionModel(config, modelId, evidenceFromBody(parsed._rawBody))
2849
+ : routeModel(config, modelId, evidenceFromBody(parsed._rawBody));
2756
2850
  const _sci = config.shadowCallIntercept;
2757
2851
  let shadowRoute: RouteResult | undefined;
2758
2852
  if (_sci?.enabled && _sci.model && isShadowSourceModel(parsed.modelId, _sci.sourceModels)) {
@@ -3097,6 +3191,12 @@ async function handleResponsesInner(
3097
3191
  // instead of treating the first incompatible candidate as the end of the chain. The
3098
3192
  // distinct code is what lets the fallback layer tell the two apart -- an upstream
3099
3193
  // `context_length_exceeded` still stops, because retrying it elsewhere is guesswork.
3194
+ if (clientRequestedStream && !options.comboAttempt) {
3195
+ return streamingContextOverflowResponse(
3196
+ parsed._responseModelId ?? parsed.modelId,
3197
+ translatorBudget,
3198
+ );
3199
+ }
3100
3200
  return formatErrorResponse(
3101
3201
  413,
3102
3202
  "input_admission_refused",
@@ -3562,6 +3662,11 @@ async function handleResponsesInner(
3562
3662
  parsed._rawBody,
3563
3663
  replayedInputPrefixLength,
3564
3664
  );
3665
+ const selfNamedNamespaceScrubAuthorization = collectSelfNamedNamespaceScrubAuthorization(
3666
+ clientToolAuthorizationBody,
3667
+ toolBridgeMaps.bareCustomToolNames,
3668
+ toolBridgeMaps.bareFunctionToolNames,
3669
+ );
3565
3670
  const clientExplicitWireToolCatalog = hasExplicitWireToolCatalog(clientToolAuthorizationBody);
3566
3671
  const clientDeclaredWireToolNames = collectDeclaredWireToolNames(clientToolAuthorizationBody);
3567
3672
  const clientDeclaredNamelessCallTypes = collectDeclaredNamelessClientCallTypes(
@@ -3819,6 +3924,48 @@ async function handleResponsesInner(
3819
3924
  linkAbortSignal(upstream, options.abortSignal);
3820
3925
  const connectMs = config.connectTimeoutMs ?? 200_000;
3821
3926
  let upstreamResponse: Response;
3927
+ /**
3928
+ * Refuse a built body that exceeds the operator's configured ceiling, before it is sent.
3929
+ *
3930
+ * Unconfigured this measures nothing and returns undefined, so an unset proxy behaves
3931
+ * exactly as it does today. Runs at every point a body is built or rebuilt, because a
3932
+ * rebuild can produce a payload the initial check never saw.
3933
+ */
3934
+ const refuseOversizedOutboundBody = (
3935
+ builtRequest: AdapterRequest,
3936
+ refusalAuthCtx: CodexAuthContext = authCtx,
3937
+ ): Response | undefined => {
3938
+ const result = checkOutboundBodySize(builtRequest.body, config.maxUpstreamBodyBytes);
3939
+ if (result.admitted) return undefined;
3940
+
3941
+ // This returns before the surrounding fetch/finally owns the observation, so release
3942
+ // it here or one refused body holds translator budget for the process lifetime.
3943
+ builtRequest.releaseBodyObservation?.();
3944
+ upstream.abort();
3945
+ releaseUpstreamHostAdmission(hostAdmissionLease);
3946
+ hostAdmissionLease = null;
3947
+ releaseCodexAuthContextProbeLease(refusalAuthCtx);
3948
+ logCtx.errorCode = "outbound_body_too_large";
3949
+ console.warn(
3950
+ `[responses] refused an oversized outbound body: bytes=${result.bytes} limit=${result.limit} `
3951
+ + `input_images=${result.imageCount} image_bytes=${result.imageBytes} `
3952
+ + `model=${JSON.stringify(parsed.modelId)}`,
3953
+ );
3954
+ // A streaming client treats HTTP 413 as a retryable transport error and resends the same
3955
+ // oversized body — the reconnect loop #3177 exists to stop. Terminal overflow is the
3956
+ // honest shape, and it is what the upstream-413 path already returns.
3957
+ if (clientRequestedStream) {
3958
+ return streamingContextOverflowResponse(
3959
+ parsed._responseModelId ?? parsed.modelId,
3960
+ translatorBudget,
3961
+ );
3962
+ }
3963
+ return formatErrorResponse(
3964
+ 413,
3965
+ "outbound_body_too_large",
3966
+ describeOutboundBodyRefusal(result),
3967
+ );
3968
+ };
3822
3969
  const transportFailureResponse = (err: unknown): Response => {
3823
3970
  upstream.abort();
3824
3971
  if (options.abortSignal?.aborted) {
@@ -3861,6 +4008,8 @@ async function handleResponsesInner(
3861
4008
  : describeUpstreamConnectFailure(err, connectMs);
3862
4009
  return formatErrorResponse(502, "upstream_error", msg);
3863
4010
  };
4011
+ const initialBodyRefusal = refuseOversizedOutboundBody(request);
4012
+ if (initialBodyRefusal) return initialBodyRefusal;
3864
4013
  try {
3865
4014
  // Transient-5xx pre-stream retry (devlog/_plan/260716_claudecode_hardening/010):
3866
4015
  // the ChatGPT backend emits transient 502/520s that an immediate retry absorbs.
@@ -3935,6 +4084,8 @@ async function handleResponsesInner(
3935
4084
  retryAdapter.name,
3936
4085
  logCtx.accountLogLabel,
3937
4086
  );
4087
+ const rebuiltBodyRefusal = refuseOversizedOutboundBody(request);
4088
+ if (rebuiltBodyRefusal) return { failed: rebuiltBodyRefusal };
3938
4089
  try {
3939
4090
  return await fetchWithTransientRetry(
3940
4091
  innerRecovery => {
@@ -4027,6 +4178,10 @@ async function handleResponsesInner(
4027
4178
  recordAdapterReasoning(logCtx, request);
4028
4179
  recordAdapterTier(logCtx, request);
4029
4180
  refreshUndeclaredToolGuard(request);
4181
+ // The 401 replay rebuilds the body before sending, so it needs the same ceiling as
4182
+ // every other build site; a replay is exactly when a grown payload reappears.
4183
+ const replayBodyRefusal = refuseOversizedOutboundBody(request);
4184
+ if (replayBodyRefusal) return replayBodyRefusal;
4030
4185
  noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, "oauth-401");
4031
4186
  upstreamResponse = await fetchWithHeaderTimeout(
4032
4187
  request.url,
@@ -4136,6 +4291,8 @@ async function handleResponsesInner(
4136
4291
  return formatErrorResponse(400, "invalid_request_error", redactSecretString(msg));
4137
4292
  }
4138
4293
  refreshUndeclaredToolGuard(request);
4294
+ const refreshedBodyRefusal = refuseOversizedOutboundBody(request);
4295
+ if (refreshedBodyRefusal) return refreshedBodyRefusal;
4139
4296
  try {
4140
4297
  upstreamResponse = await fetchWithTransientRetry(
4141
4298
  recovery => {
@@ -4255,9 +4412,14 @@ async function handleResponsesInner(
4255
4412
  options.abortSignal,
4256
4413
  )) {
4257
4414
  poolRetryOutcome = 400;
4258
- } else if (!authCtx.fixedAccount && shouldRetryCodexPoolAccountQuota(upstreamResponse)) {
4415
+ } else if (!authCtx.fixedAccount && await shouldRetryCodexPoolAccountQuota(
4416
+ upstreamResponse,
4417
+ options.abortSignal,
4418
+ )) {
4259
4419
  // Pre-stream only: once SSE has begun, mid-stream quota stays terminal.
4260
- poolRetryOutcome = upstreamResponse.status;
4420
+ // ChatGPT sometimes wraps quota exhaustion in a generic 5xx. Normalize only
4421
+ // body-confirmed cases to quota evidence so cooldown and rotation both apply.
4422
+ poolRetryOutcome = upstreamResponse.status >= 500 ? 429 : upstreamResponse.status;
4261
4423
  }
4262
4424
 
4263
4425
  if (poolRetryOutcome !== undefined) {
@@ -4285,7 +4447,12 @@ async function handleResponsesInner(
4285
4447
  passthroughEstimate,
4286
4448
  stream: parsed.stream,
4287
4449
  onResponse: (response, retryAuthCtx, retryRequest) => {
4288
- captureAffinityResponse(response, retryAuthCtx, retryRequest, true);
4450
+ captureAffinityResponse(
4451
+ response,
4452
+ retryAuthCtx,
4453
+ retryRequest,
4454
+ retryAuthCtx.kind !== "main",
4455
+ );
4289
4456
  },
4290
4457
  });
4291
4458
  if (retry.kind === "transport") {
@@ -4432,6 +4599,12 @@ async function handleResponsesInner(
4432
4599
  // The bounded reader owns the original body, deadline, abort settlement, and lock.
4433
4600
  // Unsafe partial data falls back to #452's non-empty status-only JSON.
4434
4601
  const errorText = await readDisplaySafeErrorText(upstreamResponse, upstream.signal, "");
4602
+ if (upstreamResponse.status === 413 && clientRequestedStream) {
4603
+ return streamingContextOverflowResponse(
4604
+ parsed._responseModelId ?? parsed.modelId,
4605
+ translatorBudget,
4606
+ );
4607
+ }
4435
4608
  return formatPassthroughUpstreamError(upstreamResponse.status, errorText, {
4436
4609
  statusText: upstreamResponse.statusText,
4437
4610
  headers,
@@ -4478,6 +4651,8 @@ async function handleResponsesInner(
4478
4651
  // Compose opt-in payload rewrites into one parse/stringify pass (image-gen restore first).
4479
4652
  const payloadRewrites = [
4480
4653
  createImageGenCallRestoreRewrite(imageGenCallAliases),
4654
+ // #3217: a call whose namespace repeats its own name is unroutable in codex-rs.
4655
+ createSelfNamedToolCallNamespaceScrubRewrite(selfNamedNamespaceScrubAuthorization),
4481
4656
  routedNamespaceToolAliases.size > 0
4482
4657
  ? createRoutedNamespaceCallRestoreRewrite(routedNamespaceToolAliases)
4483
4658
  : undefined,
@@ -4710,7 +4885,10 @@ async function handleResponsesInner(
4710
4885
  inspectResponseLogJson(logCtx, text);
4711
4886
  const clientJson = (() => {
4712
4887
  const restoredNamespace = restoreRoutedNamespaceCallsInJson(
4713
- restoreImageGenCallsInJson(text, imageGenCallAliases),
4888
+ scrubSelfNamedToolCallNamespaceInJson(
4889
+ restoreImageGenCallsInJson(text, imageGenCallAliases),
4890
+ selfNamedNamespaceScrubAuthorization,
4891
+ ),
4714
4892
  routedNamespaceToolAliases,
4715
4893
  );
4716
4894
  const restoredAuthorizedBareNamespace = restoreRoutedNamespaceCallsInJson(
@@ -5986,6 +6164,12 @@ async function handleResponsesInner(
5986
6164
  } finally {
5987
6165
  cleanupUpstreamAbort();
5988
6166
  }
6167
+ if (upstreamResponse.status === 413 && clientRequestedStream && !options.comboAttempt) {
6168
+ return streamingContextOverflowResponse(
6169
+ parsed._responseModelId ?? parsed.modelId,
6170
+ translatorBudget,
6171
+ );
6172
+ }
5989
6173
  if (!isFixedCodexAccount(authCtx)) {
5990
6174
  recordSubagentQuotaFailureForThreadSpawn(
5991
6175
  req.headers,
@@ -76,7 +76,8 @@ export function providerFetch(
76
76
  // transport (measured ~3s faster TTFT than the SSE POST queue); everything
77
77
  // else keeps the provider's HTTP fetch. See ws-upstream.ts for the details.
78
78
  const unpaced = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
79
- if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init, runtime)) {
79
+ const upstreamWebsocket = provider.upstreamWebsocket === true;
80
+ if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init, runtime, upstreamWebsocket)) {
80
81
  // The fallback has to be the same HTTP fetch the non-WS branch would have
81
82
  // used, protocol pin included: a WS turn that falls back is serving the
82
83
  // request over HTTP, and dropping the provider's `upstreamHttpVersion`