@bitkyc08/opencodex 2.11.1 → 2.12.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 (175) hide show
  1. package/README.md +1 -1
  2. package/bin/ocx.mjs +4 -0
  3. package/gui/dist/assets/index-BNVYzdn0.css +1 -0
  4. package/gui/dist/assets/index-Cw1_mxO8.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/command-code.ts +94 -5
  8. package/src/adapters/cursor/live-transport.ts +14 -1
  9. package/src/adapters/google-tool-schema.ts +80 -15
  10. package/src/adapters/google.ts +24 -7
  11. package/src/adapters/openai-chat.ts +233 -305
  12. package/src/adapters/openai-responses.ts +62 -0
  13. package/src/adapters/tool-catalog-nudge.ts +2 -11
  14. package/src/claude/outbound.ts +42 -14
  15. package/src/cli/claude.ts +61 -9
  16. package/src/cli/codex-shim-autorestore.ts +2 -0
  17. package/src/cli/codex-shim-readiness.ts +8 -1
  18. package/src/cli/config-command.ts +4 -1
  19. package/src/cli/doctor.ts +10 -0
  20. package/src/cli/help.ts +15 -0
  21. package/src/cli/index.ts +27 -11
  22. package/src/cli/lab.ts +367 -0
  23. package/src/cli/provider.ts +9 -1
  24. package/src/clients/config-export.ts +100 -3
  25. package/src/codex/app-server-processes.ts +11 -8
  26. package/src/codex/auth-api.ts +92 -28
  27. package/src/codex/auth-collision.ts +5 -3
  28. package/src/codex/catalog/metadata.ts +7 -3
  29. package/src/codex/catalog/provider-fetch.ts +191 -47
  30. package/src/codex/catalog.ts +2 -2
  31. package/src/codex/history-job.ts +83 -18
  32. package/src/codex/history-migration-guardian.ts +9 -16
  33. package/src/codex/history-provider.ts +178 -1
  34. package/src/codex/history-transition.ts +5 -5
  35. package/src/codex/history-worker.ts +22 -2
  36. package/src/codex/native-profile-store.ts +1 -1
  37. package/src/codex/plan.ts +15 -0
  38. package/src/codex/quota.ts +6 -6
  39. package/src/codex/routing.ts +3 -3
  40. package/src/codex/subagent-model-fallback.ts +246 -22
  41. package/src/codex/user-identity.ts +63 -12
  42. package/src/config.ts +265 -32
  43. package/src/images/loop.ts +3 -1
  44. package/src/images/plan.ts +23 -13
  45. package/src/integrations/omp-yaml-source.ts +225 -0
  46. package/src/integrations/ownership.ts +5 -5
  47. package/src/integrations/state.ts +45 -6
  48. package/src/integrations/writer.ts +38 -3
  49. package/src/lab/artifacts/sanitize.ts +586 -0
  50. package/src/lab/artifacts/secure-fs.ts +475 -0
  51. package/src/lab/artifacts/store.ts +310 -0
  52. package/src/lab/conformance/assertion.ts +325 -0
  53. package/src/lab/conformance/digest.ts +22 -0
  54. package/src/lab/conformance/executor.ts +741 -0
  55. package/src/lab/conformance/fixture-provider.ts +27 -0
  56. package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
  57. package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
  58. package/src/lab/conformance/harness-budget.ts +47 -0
  59. package/src/lab/conformance/index.ts +5 -0
  60. package/src/lab/conformance/jcs.ts +24 -0
  61. package/src/lab/conformance/json-pointer.ts +39 -0
  62. package/src/lab/conformance/manifest.ts +180 -0
  63. package/src/lab/conformance/mcp-stub.ts +179 -0
  64. package/src/lab/conformance/negative-controls.ts +160 -0
  65. package/src/lab/conformance/observation.ts +355 -0
  66. package/src/lab/conformance/runner.ts +57 -0
  67. package/src/lab/conformance/sse-normalize.ts +59 -0
  68. package/src/lab/conformance/suite-manifest.ts +78 -0
  69. package/src/lab/conformance/types.ts +191 -0
  70. package/src/lab/constants.ts +126 -0
  71. package/src/lab/digest.ts +64 -0
  72. package/src/lab/events/errors.ts +9 -0
  73. package/src/lab/events/limits.ts +117 -0
  74. package/src/lab/events/types.ts +228 -0
  75. package/src/lab/events/validate.ts +780 -0
  76. package/src/lab/index.ts +36 -0
  77. package/src/lab/ledger/artifact-refs.ts +127 -0
  78. package/src/lab/ledger/invalidation.ts +136 -0
  79. package/src/lab/ledger/purge.ts +241 -0
  80. package/src/lab/ledger/store.ts +263 -0
  81. package/src/lab/live/credential-lease.ts +53 -0
  82. package/src/lab/live/destination.ts +155 -0
  83. package/src/lab/live/executor.ts +311 -0
  84. package/src/lab/live/inert-tools.ts +56 -0
  85. package/src/lab/live/manifest.ts +85 -0
  86. package/src/lab/live/mcp-loopback.ts +57 -0
  87. package/src/lab/live/runner.ts +19 -0
  88. package/src/lab/live/sandbox.ts +61 -0
  89. package/src/lab/live/suite-manifest.ts +41 -0
  90. package/src/lab/live/transport.ts +116 -0
  91. package/src/lab/live/types.ts +197 -0
  92. package/src/lab/observe/from-conformance.ts +344 -0
  93. package/src/lab/observe/from-live.ts +103 -0
  94. package/src/lab/paths.ts +65 -0
  95. package/src/lab/projection/rebuild.ts +477 -0
  96. package/src/lab/projection/schema.ts +122 -0
  97. package/src/lab/projection/verdicts.ts +438 -0
  98. package/src/lab/projection/verification.ts +332 -0
  99. package/src/lab/query/catalog.ts +72 -0
  100. package/src/lab/query/connection.ts +107 -0
  101. package/src/lab/query/constants.ts +4 -0
  102. package/src/lab/query/cursor.ts +132 -0
  103. package/src/lab/query/dto-map.ts +277 -0
  104. package/src/lab/query/errors.ts +22 -0
  105. package/src/lab/query/index.ts +34 -0
  106. package/src/lab/query/queries.ts +444 -0
  107. package/src/lab/query/types.ts +266 -0
  108. package/src/lab/subject/behavior-fingerprint.ts +77 -0
  109. package/src/lab/subject/installation-salt.ts +81 -0
  110. package/src/lab/subject/route-subject.ts +73 -0
  111. package/src/lib/bounded-body.ts +104 -0
  112. package/src/lib/lab-live-execution-authority.ts +13 -0
  113. package/src/lib/lab-live-host.ts +30 -0
  114. package/src/lib/lab-live-pinned-sender.ts +45 -0
  115. package/src/lib/pinned-http.ts +36 -7
  116. package/src/oauth/index.ts +20 -1
  117. package/src/oauth/local-token-detect.ts +11 -2
  118. package/src/oauth/login-cli.ts +38 -4
  119. package/src/providers/alibaba-region-migration.ts +1 -1
  120. package/src/providers/codex-capacity.ts +7 -4
  121. package/src/providers/command-code-efforts.ts +5 -2
  122. package/src/providers/derive.ts +105 -0
  123. package/src/providers/label.ts +1 -1
  124. package/src/providers/openai-tiers.ts +59 -3
  125. package/src/providers/opencode-zen-rate-limit.ts +102 -0
  126. package/src/providers/quota.ts +67 -21
  127. package/src/providers/registry.ts +118 -5
  128. package/src/router.ts +15 -1
  129. package/src/routing/analytics.ts +8 -7
  130. package/src/routing/evaluator.ts +51 -5
  131. package/src/routing/profile.ts +10 -1
  132. package/src/routing/trace.ts +42 -6
  133. package/src/server/auth-cors.ts +15 -0
  134. package/src/server/background-lifecycle.ts +169 -0
  135. package/src/server/chat-completions.ts +18 -3
  136. package/src/server/claude-messages.ts +1 -0
  137. package/src/server/images.ts +78 -11
  138. package/src/server/index.ts +72 -18
  139. package/src/server/lifecycle.ts +6 -0
  140. package/src/server/live.ts +5 -1
  141. package/src/server/management/agent-settings-routes.ts +41 -24
  142. package/src/server/management/body.ts +6 -0
  143. package/src/server/management/config-routes.ts +51 -8
  144. package/src/server/management/lab-routes.ts +388 -0
  145. package/src/server/management/logs-usage-routes.ts +19 -1
  146. package/src/server/management/model-rows.ts +25 -11
  147. package/src/server/management/native-integration-routes.ts +3 -3
  148. package/src/server/management/provider-routes.ts +5 -0
  149. package/src/server/management/shared.ts +8 -1
  150. package/src/server/management/usage-summary-cache.ts +2 -0
  151. package/src/server/management/vision-sidecar-options.ts +125 -0
  152. package/src/server/management-api.ts +2 -0
  153. package/src/server/request-decompress.ts +106 -5
  154. package/src/server/responses/collaboration.ts +4 -1
  155. package/src/server/responses/core.ts +117 -31
  156. package/src/server/responses-json-events.ts +50 -12
  157. package/src/server/search.ts +23 -7
  158. package/src/server/ws-bridge.ts +3 -1
  159. package/src/service.ts +40 -20
  160. package/src/types.ts +74 -0
  161. package/src/update/job.ts +10 -2
  162. package/src/usage/cost.ts +0 -0
  163. package/src/usage/expected-prices.ts +9 -0
  164. package/src/usage/log.ts +9 -9
  165. package/src/usage/user-cost-overlay-reconciler.ts +313 -0
  166. package/src/usage/user-cost-overlays.ts +314 -0
  167. package/src/vision/describe.ts +2 -2
  168. package/src/vision/eligibility.ts +229 -0
  169. package/src/vision/index.ts +20 -2
  170. package/src/web-search/executor.ts +2 -2
  171. package/src/web-search/index.ts +4 -2
  172. package/src/web-search/loop.ts +3 -1
  173. package/src/web-search/parse.ts +72 -3
  174. package/gui/dist/assets/index-BA1dgo4q.css +0 -1
  175. package/gui/dist/assets/index-DDZpgzKk.js +0 -70
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The one place that decides which models the management API offers as vision
3
+ * describers, and which it refuses to persist.
4
+ *
5
+ * Two routes write a vision sidecar model — `PUT /api/sidecar-settings` and the
6
+ * `visionSidecar` override in `PUT /api/claude-code`. They share this module so
7
+ * the policy cannot drift: a gate on one route and a stale copy on the other is
8
+ * the same as no gate at all.
9
+ */
10
+ import type { OcxConfig } from "../../types";
11
+ import { findAnthropicVisionProvider, type AnthropicVisionProvider } from "../../vision";
12
+ import {
13
+ modelAcceptsImageInput,
14
+ visionEligibleModelOptions,
15
+ type VisionCandidateModel,
16
+ type VisionModelOption,
17
+ type VisionSidecarBackend,
18
+ } from "../../vision/eligibility";
19
+ import { listOpenAiForwardSidecarCandidates } from "../../providers/openai-sidecar";
20
+ import { listManagementModelRows } from "./model-rows";
21
+
22
+ /**
23
+ * Backends whose executor could actually run: openai forward, anthropic OAuth.
24
+ *
25
+ * `anthropicSidecar` is REQUIRED rather than defaulted. `findAnthropicVisionProvider`
26
+ * reads the OAuth account store from disk, and a default argument made every helper
27
+ * in this chain re-resolve it whenever a caller passed an explicit `undefined` —
28
+ * which is exactly the no-executor case. Passing it in keeps one read per request.
29
+ */
30
+ export function enabledVisionBackends(
31
+ config: OcxConfig,
32
+ anthropicSidecar: AnthropicVisionProvider | undefined,
33
+ ): VisionSidecarBackend[] {
34
+ const backends: VisionSidecarBackend[] = [];
35
+ // The OpenAI describer needs a CANONICAL ChatGPT forward provider, not merely a
36
+ // provider keyed "openai" — same predicate the runtime sidecar resolver uses.
37
+ if (listOpenAiForwardSidecarCandidates(config).length > 0) backends.push("openai");
38
+ if (anthropicSidecar) backends.push("anthropic");
39
+ // Neither side resolvable (fresh install, no login): fall back to both so the
40
+ // picker is populated rather than empty, matching the permissive-unknown rule.
41
+ return backends.length > 0 ? backends : ["openai", "anthropic"];
42
+ }
43
+
44
+ /** Visible catalog rows in the shape the eligibility predicate consumes. */
45
+ export async function visionCandidateRows(config: OcxConfig): Promise<VisionCandidateModel[]> {
46
+ let rows: Awaited<ReturnType<typeof listManagementModelRows>> = [];
47
+ // A catalog outage must not 500 the settings route nor reject a write; with []
48
+ // the option list degrades to the baselines, which is the intended floor.
49
+ try { rows = await listManagementModelRows(config); } catch { rows = []; }
50
+ return rows.filter(row => row.disabled !== true).map(row => ({
51
+ provider: row.provider,
52
+ id: row.id,
53
+ ...(row.inputModalities ? { inputModalities: row.inputModalities } : {}),
54
+ ...(row.native ? { native: true } : {}),
55
+ }));
56
+ }
57
+
58
+ export function visionModelOptionsFrom(
59
+ config: OcxConfig,
60
+ candidates: readonly VisionCandidateModel[],
61
+ anthropicSidecar: AnthropicVisionProvider | undefined,
62
+ ): VisionModelOption[] {
63
+ return visionEligibleModelOptions(
64
+ config,
65
+ candidates,
66
+ enabledVisionBackends(config, anthropicSidecar),
67
+ anthropicSidecar?.providerName,
68
+ );
69
+ }
70
+
71
+ /** Convenience for read paths that have no candidate list in hand yet. */
72
+ export async function visionModelOptionsFor(
73
+ config: OcxConfig,
74
+ anthropicSidecar: AnthropicVisionProvider | undefined,
75
+ ): Promise<VisionModelOption[]> {
76
+ return visionModelOptionsFrom(config, await visionCandidateRows(config), anthropicSidecar);
77
+ }
78
+
79
+ /**
80
+ * Can we PROVE the requested describer is blind?
81
+ *
82
+ * Only a positive `false` rejects. An id no source knows stays allowed, because
83
+ * the runtime never required catalog membership and an operator may be ahead of
84
+ * our tables.
85
+ *
86
+ * When no catalog row matches, the caller's `backend` is only a HINT, never the
87
+ * authority. Trusting it let a client launder a known-blind OpenAI model past the
88
+ * gate by claiming `backend: "anthropic"`, since the id is absent from the
89
+ * Anthropic table and absence reads as "unknown". Both families are therefore
90
+ * consulted and any positive text-only verdict wins. That is safe precisely
91
+ * because the two vendor tables share no bare model id, so they can never
92
+ * disagree about one.
93
+ */
94
+ export function visionDescriberIsProvablyBlind(
95
+ config: OcxConfig,
96
+ requested: string,
97
+ candidates: readonly VisionCandidateModel[],
98
+ backendHint: VisionSidecarBackend | undefined,
99
+ ): boolean {
100
+ // Catalog rows can carry operator-authored modalities. A positive claim from one
101
+ // must not hide another row or canonical metadata that proves this id is blind.
102
+ if (candidates.some(candidate => candidate.id === requested
103
+ && modelAcceptsImageInput(config, candidate) === false)) return true;
104
+
105
+ const hinted: VisionSidecarBackend = backendHint === "anthropic" ? "anthropic" : "openai";
106
+ const probed: VisionSidecarBackend[] = hinted === "anthropic"
107
+ ? ["anthropic", "openai"]
108
+ : ["openai", "anthropic"];
109
+ return probed.some(provider => modelAcceptsImageInput(config, { provider, id: requested }) === false);
110
+ }
111
+
112
+ /** The 400 body both routes return, so the two errors cannot diverge either. */
113
+ export function visionDescriberRejection(
114
+ field: "vision.model" | "visionSidecar.model",
115
+ requested: string,
116
+ config: OcxConfig,
117
+ candidates: readonly VisionCandidateModel[],
118
+ ): { error: string; allowed: string[] } {
119
+ return {
120
+ error: `${field} "${requested}" cannot describe images: it has no image input support, or it is a model the vision sidecar describes FOR.`,
121
+ // The rejection path is not hot, so it resolves the executor itself rather than
122
+ // making every 400 caller thread one through.
123
+ allowed: visionModelOptionsFrom(config, candidates, findAnthropicVisionProvider(config)).map(option => option.value),
124
+ };
125
+ }
@@ -68,6 +68,7 @@ import { handleAgentSettingsRoutes } from "./management/agent-settings-routes";
68
68
  import { handleOauthAccountRoutes } from "./management/oauth-account-routes";
69
69
  import { handleComboRoutes } from "./management/combo-routes";
70
70
  import { handleSystemRoutes } from "./management/system-routes";
71
+ import { handleLabRoutes } from "./management/lab-routes";
71
72
  import { handleSidebarRoutes } from "./management/sidebar-routes";
72
73
  import { handleIntegrationRoutes } from "./management/integration-routes";
73
74
  import { handleNativeIntegrationRoutes } from "./management/native-integration-routes";
@@ -187,6 +188,7 @@ export async function handleManagementAPI(
187
188
  ?? (await handleOauthAccountRoutes(ctx))
188
189
  ?? (await handleComboRoutes(ctx))
189
190
  ?? (await handleSystemRoutes(ctx))
191
+ ?? (await handleLabRoutes(ctx))
190
192
  ?? (await handleSidebarRoutes(ctx));
191
193
  } catch (error) {
192
194
  const tooLarge = managementBodyTooLargeResponse(error, req, config);
@@ -45,6 +45,101 @@ function declaredBodyLength(req: Request): number | null {
45
45
  return Number.isFinite(length) && length >= 0 ? length : null;
46
46
  }
47
47
 
48
+ function cancelStreamWithoutWaiting(stream: ReadableStream<Uint8Array> | null, reason: unknown): void {
49
+ if (!stream || stream.locked) return;
50
+ try {
51
+ void stream.cancel(reason).catch(() => undefined);
52
+ } catch {
53
+ // A non-standard stream may throw synchronously from cancel().
54
+ }
55
+ }
56
+
57
+ function cancelReaderWithoutWaiting(reader: ReadableStreamDefaultReader<Uint8Array>, reason: unknown): void {
58
+ // Request.clone() tees can leave cancel() pending until the other branch
59
+ // drains. Cancellation must never extend this reader's own admission bound.
60
+ try {
61
+ void reader.cancel(reason).catch(() => undefined);
62
+ } catch {
63
+ // A non-standard reader may throw synchronously from cancel().
64
+ }
65
+ }
66
+
67
+ async function readRequestBodyBytesCapped(
68
+ body: ReadableStream<Uint8Array> | null,
69
+ maxBytes: number,
70
+ signal?: AbortSignal,
71
+ ): Promise<Uint8Array> {
72
+ if (signal?.aborted) {
73
+ cancelStreamWithoutWaiting(body, signal.reason);
74
+ throw signal.reason;
75
+ }
76
+ if (!body) return new Uint8Array(0);
77
+
78
+ const reader = body.getReader();
79
+ // Keep one geometric buffer instead of one object per transport chunk. A
80
+ // hostile peer can fragment a bounded payload into arbitrarily many chunks.
81
+ let retained = new Uint8Array(Math.min(maxBytes, 64 * 1024));
82
+ let retainedBytes = 0;
83
+ let aborted = false;
84
+ let abortReason: unknown;
85
+ let cancellationStarted = false;
86
+ const cancel = (reason: unknown): void => {
87
+ if (cancellationStarted) return;
88
+ cancellationStarted = true;
89
+ cancelReaderWithoutWaiting(reader, reason);
90
+ };
91
+ const onAbort = (): void => {
92
+ aborted = true;
93
+ abortReason = signal?.reason;
94
+ cancel(abortReason);
95
+ };
96
+ signal?.addEventListener("abort", onAbort, { once: true });
97
+ // Close the narrow race between the preflight check and listener install.
98
+ if (signal?.aborted) onAbort();
99
+
100
+ try {
101
+ while (true) {
102
+ if (aborted) throw abortReason;
103
+ const { value, done } = await reader.read();
104
+ // cancel() can resolve a pending read as EOF. Preserve the caller's
105
+ // original abort reason instead of misclassifying that as a clean body.
106
+ if (aborted) throw abortReason;
107
+ if (done) {
108
+ return retainedBytes === retained.byteLength
109
+ ? retained
110
+ : retained.slice(0, retainedBytes);
111
+ }
112
+ if (!value || value.byteLength === 0) continue;
113
+
114
+ if (value.byteLength > maxBytes - retainedBytes) {
115
+ const error = new DecompressedBodyTooLargeError(retainedBytes + value.byteLength, maxBytes);
116
+ cancel(error);
117
+ throw error;
118
+ }
119
+
120
+ const required = retainedBytes + value.byteLength;
121
+ if (required > retained.byteLength) {
122
+ const grown = new Uint8Array(Math.min(maxBytes, Math.max(retained.byteLength * 2, required)));
123
+ grown.set(retained.subarray(0, retainedBytes));
124
+ retained = grown;
125
+ }
126
+ retained.set(value, retainedBytes);
127
+ retainedBytes = required;
128
+ }
129
+ } catch (error) {
130
+ const failure = aborted ? abortReason : error;
131
+ cancel(failure);
132
+ throw failure;
133
+ } finally {
134
+ signal?.removeEventListener("abort", onAbort);
135
+ try {
136
+ reader.releaseLock();
137
+ } catch {
138
+ // A pending cancellation can retain the lock briefly; never await it.
139
+ }
140
+ }
141
+ }
142
+
48
143
  function inflateDeflateBody(compressed: Uint8Array<ArrayBuffer>, opts: { maxOutputLength: number }): Uint8Array {
49
144
  // HTTP "deflate" appears both zlib-wrapped and raw in the wild (Bun.deflateSync emits raw,
50
145
  // which the previous Bun.inflateSync accepted). Try zlib-wrapped first, fall back to raw —
@@ -90,24 +185,27 @@ export async function readBoundedJsonRequestBody(
90
185
  req: Request,
91
186
  maxBytes: number,
92
187
  budget?: TranslatorBudget,
188
+ options?: { emptyBodyFallback?: unknown },
93
189
  ): Promise<unknown> {
94
190
  const encoding = req.headers.get("content-encoding");
95
191
  const declaredLength = declaredBodyLength(req);
96
- // Reject an honest oversized declaration before req.arrayBuffer() can allocate it.
97
- // Missing, malformed, or dishonest declarations remain covered by decodeRequestBody's
98
- // post-read cap below.
192
+ // Reject an honest oversized declaration before reading. Missing, malformed,
193
+ // and dishonest declarations remain bounded by the streaming reader below.
99
194
  if (declaredLength !== null && declaredLength > maxBytes) {
100
- throw new DecompressedBodyTooLargeError(declaredLength, maxBytes);
195
+ const error = new DecompressedBodyTooLargeError(declaredLength, maxBytes);
196
+ cancelStreamWithoutWaiting(req.body, error);
197
+ throw error;
101
198
  }
102
199
  const releaseReservation = budget && declaredLength !== null && declaredLength > 0
103
200
  ? budget.observeAcceptedRequestCopy(declaredLength)
104
201
  : undefined;
105
202
  let raw: Uint8Array;
106
203
  try {
107
- raw = new Uint8Array(await req.arrayBuffer());
204
+ raw = await readRequestBodyBytesCapped(req.body, maxBytes, req.signal);
108
205
  } finally {
109
206
  releaseReservation?.();
110
207
  }
208
+ assertBodySizeWithinLimit(raw, maxBytes);
111
209
  const releaseRaw = budget?.observeAcceptedRequestCopy(raw.byteLength);
112
210
  let releaseDecoded: (() => void) | undefined;
113
211
  let releaseText: (() => void) | undefined;
@@ -116,6 +214,9 @@ export async function readBoundedJsonRequestBody(
116
214
  releaseDecoded = decoded === raw ? undefined : budget?.observeAcceptedRequestCopy(decoded.byteLength);
117
215
  const text = new TextDecoder().decode(decoded);
118
216
  releaseText = budget?.observeAcceptedRequestCopy(new TextEncoder().encode(text).byteLength);
217
+ if (options && "emptyBodyFallback" in options && text.trim() === "") {
218
+ return options.emptyBodyFallback;
219
+ }
119
220
  const parsed = JSON.parse(text);
120
221
  budget?.observeAcceptedRequestCopy(new TextEncoder().encode(JSON.stringify(parsed)).byteLength);
121
222
  return parsed;
@@ -26,7 +26,7 @@ import {
26
26
  } from "../../combos";
27
27
  import { isInjectionDebugEnabled } from "../../lib/debug-settings";
28
28
  import { injectionDebugLog } from "../../lib/injection-debug-log";
29
- import { modelInList, namespacedToolName } from "../../types";
29
+ import { modelInList, namespacedToolName, toolChoiceToolPredicate } from "../../types";
30
30
  import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxProviderContinuationState, OcxUsage } from "../../types";
31
31
  import {
32
32
  forceRefreshOAuthAccessSnapshot,
@@ -108,7 +108,10 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
108
108
  const toolNsMap = new Map<string, { namespace: string; name: string }>();
109
109
  const freeformToolNames = new Set<string>();
110
110
  const toolSearchToolNames = new Set<string>();
111
+ const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice);
111
112
  for (const t of parsed.context.tools ?? []) {
113
+ // Upstream output is untrusted: only restore calls for tools the caller authorized.
114
+ if (!toolAllowed(t)) continue;
112
115
  if (t.namespace) {
113
116
  const wireName = namespacedToolName(t.namespace, t.name);
114
117
  budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([wireName, t.namespace, t.name])).byteLength, { kind: "retained_collectors" });
@@ -16,7 +16,13 @@ import {
16
16
  previousResponseReplayFailure,
17
17
  rememberResponseState,
18
18
  } from "../../responses/state";
19
- import { comboRouteDecisionTrace, NoEligiblePolicyCandidateError, routeModel, type RouteResult } from "../../router";
19
+ import {
20
+ comboRouteDecisionTrace,
21
+ NoEligiblePolicyCandidateError,
22
+ routeConcreteModel,
23
+ routeModel,
24
+ type RouteResult,
25
+ } from "../../router";
20
26
  import { evidenceFromBody } from "../../routing/request-evidence";
21
27
  import {
22
28
  advanceComboAfterFailure,
@@ -35,6 +41,7 @@ import {
35
41
  import { isInjectionDebugEnabled } from "../../lib/debug-settings";
36
42
  import { injectionDebugLog } from "../../lib/injection-debug-log";
37
43
  import { resolveClientRetryAfter } from "../../lib/retry-after";
44
+ import { enrichOpenCodeZenRateLimitMessage } from "../../providers/opencode-zen-rate-limit";
38
45
  import { modelInList, namespacedToolName } from "../../types";
39
46
  import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxProviderContinuationState, OcxUsage } from "../../types";
40
47
  import {
@@ -199,7 +206,7 @@ import {
199
206
  relaySseWithBlockRewrite,
200
207
  } from "../sse-payload-rewrite";
201
208
  import { createGithubCopilotResponsesBlockRewrite } from "../github-copilot-responses-repair";
202
- import { responsesJsonToSseBody } from "../responses-json-events";
209
+ import { responsesJsonToSseStream } from "../responses-json-events";
203
210
  import { guardTerminalEventStream } from "./terminal-guard";
204
211
 
205
212
  /**
@@ -434,7 +441,11 @@ async function retryCodexPoolOnAlternateAccount(
434
441
  firstAuthCtx.writerGeneration,
435
442
  );
436
443
  }
437
- if (!shouldDeferCodexResetDerivedCooldown(firstResponse, options.deferCodexResetDerivedCooldown)) {
444
+ const deferFirstOutcome = shouldDeferCodexResetDerivedCooldown(
445
+ firstResponse,
446
+ options.deferCodexResetDerivedCooldown,
447
+ );
448
+ const recordFirstOutcome = (): void => {
438
449
  recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
439
450
  ...quotaMeta,
440
451
  threadId: req.headers.get("x-codex-parent-thread-id"),
@@ -445,8 +456,10 @@ async function retryCodexPoolOnAlternateAccount(
445
456
  // Retry already advanced the RR ring via excludeAccountId — reuse for promotion.
446
457
  ...(retryAuthCtx.accountId ? { promoteAccountId: retryAuthCtx.accountId } : {}),
447
458
  });
448
- }
449
-
459
+ };
460
+ // Only a combo reset-derived outcome is deferred. Retry-After, defaults, and
461
+ // ordinary requests must block the first account before the alternate send.
462
+ if (!deferFirstOutcome) recordFirstOutcome();
450
463
  const retryHeaders = headersForCodexAuthContext(req.headers, retryAuthCtx);
451
464
  const retryProvider = applyCodexAuthContextToProvider(
452
465
  stripCodexRuntimeProviderFields(route.provider),
@@ -473,8 +486,9 @@ async function retryCodexPoolOnAlternateAccount(
473
486
  );
474
487
 
475
488
  noteAttemptSend(logCtx.activeAttempt, passthroughEstimate);
489
+ let upstreamResponse: Response;
476
490
  try {
477
- const upstreamResponse = await fetchWithHeaderTimeout(
491
+ upstreamResponse = await fetchWithHeaderTimeout(
478
492
  request.url,
479
493
  {
480
494
  method: request.method,
@@ -489,26 +503,33 @@ async function retryCodexPoolOnAlternateAccount(
489
503
  // dead-host rejection after the credential was seen (#914).
490
504
  route.provider.authMode === "forward",
491
505
  );
492
- // A real HTTP response proves the host was reached (#914).
493
- const retryHostKey = upstreamHostHealthKey(route.providerName, safeOriginLabel(request.url));
494
- if (normalizeUpstreamHostCircuitThreshold(config.upstreamHostCircuitThreshold) > 0) {
495
- resetUpstreamHostHealth(retryHostKey, null);
496
- } else {
497
- resetUpstreamHostHealth(retryHostKey);
498
- }
499
- return {
500
- kind: "retried",
501
- authCtx: retryAuthCtx,
502
- request,
503
- upstreamResponse,
504
- selectedForwardHeaders: retryHeaders,
505
- };
506
506
  } catch (error) {
507
507
  // Attribute the transport failure to the alternate account (already selected).
508
508
  return { kind: "transport", error, authCtx: retryAuthCtx };
509
509
  } finally {
510
510
  request.releaseBodyObservation?.();
511
511
  }
512
+ // A real HTTP response proves the host was reached (#914).
513
+ const retryHostKey = upstreamHostHealthKey(route.providerName, safeOriginLabel(request.url));
514
+ if (normalizeUpstreamHostCircuitThreshold(config.upstreamHostCircuitThreshold) > 0) {
515
+ resetUpstreamHostHealth(retryHostKey, null);
516
+ } else {
517
+ resetUpstreamHostHealth(retryHostKey);
518
+ }
519
+ if (deferFirstOutcome && upstreamResponse.ok) {
520
+ // Deferral keeps the first account eligible for a later combo model while an
521
+ // alternate attempt is still fallible. Commit its quota outcome only once the
522
+ // alternate account returns a successful HTTP response; otherwise the combo may
523
+ // still need the first account for its next target.
524
+ recordFirstOutcome();
525
+ }
526
+ return {
527
+ kind: "retried",
528
+ authCtx: retryAuthCtx,
529
+ request,
530
+ upstreamResponse,
531
+ selectedForwardHeaders: retryHeaders,
532
+ };
512
533
  }
513
534
 
514
535
 
@@ -630,6 +651,12 @@ export interface HandleResponsesOptions {
630
651
  inboundWire?: InboundWire;
631
652
  /** Internal transport identity for route-scoped upstream compatibility policy. */
632
653
  inboundTransport?: "websocket";
654
+ /**
655
+ * Claude replay may add native-main auth so OpenAI sidecars remain available.
656
+ * Strip only that internal credential when the final route is a noncanonical
657
+ * forward destination; final routing can differ from Claude's preflight route.
658
+ */
659
+ stripClaudeMainAuthForNoncanonicalForward?: boolean;
633
660
  /** Internal recursion guard; callers outside this module must not set it. */
634
661
  comboAttempt?: boolean;
635
662
  /** Internal combo handoff: allow a later same-provider model after a reset-derived 429/402. */
@@ -961,6 +988,10 @@ async function applyFinalRouteRequestNormalization(args: {
961
988
  parsed.options.serviceTier = tier;
962
989
  }
963
990
  applyServiceTierGate(route.provider, parsed._rawBody, parsed.options);
991
+ if (route.provider.adapter === "openai-responses" && route.provider.supportsServiceTier === false) {
992
+ logCtx.requestedServiceTier = undefined;
993
+ logCtx.requestedSpeedLabel = undefined;
994
+ }
964
995
 
965
996
  {
966
997
  const guidance = await multiAgentGuidanceText(parsed, {
@@ -1062,7 +1093,7 @@ export async function handleComboResponses(
1062
1093
  const provider = config.providers[target.provider];
1063
1094
  if (!provider || provider.disabled === true) return false;
1064
1095
  try {
1065
- const route = routeModel(config, `${target.provider}/${target.model}`);
1096
+ const route = routeConcreteModel(config, `${target.provider}/${target.model}`);
1066
1097
  return isCanonicalOpenAiForwardProvider(route.provider);
1067
1098
  } catch {
1068
1099
  return false;
@@ -1096,7 +1127,7 @@ export async function handleComboResponses(
1096
1127
  ...(logCtx.conversationId ? { conversationId: logCtx.conversationId } : {}),
1097
1128
  ...(logCtx.surface ? { surface: logCtx.surface } : {}),
1098
1129
  };
1099
- const targetRoute = routeModel(config, `${pick.target.provider}/${pick.target.model}`);
1130
+ const targetRoute = routeConcreteModel(config, `${pick.target.provider}/${pick.target.model}`);
1100
1131
  const childBody = concreteComboRequestBody(
1101
1132
  rawBody,
1102
1133
  pick.target,
@@ -1462,7 +1493,9 @@ async function handleResponsesInner(
1462
1493
 
1463
1494
  let route: RouteResult;
1464
1495
  try {
1465
- route = routeModel(config, parsed.modelId, evidenceFromBody(parsed._rawBody));
1496
+ route = options.comboAttempt
1497
+ ? routeConcreteModel(config, parsed.modelId)
1498
+ : routeModel(config, parsed.modelId, evidenceFromBody(parsed._rawBody));
1466
1499
  logCtx.routeDecision = route.routeDecision;
1467
1500
  } catch (err) {
1468
1501
  if (err instanceof NoAvailableComboTargetsError) {
@@ -1700,7 +1733,24 @@ async function handleResponsesInner(
1700
1733
  parsed.options.promptCacheKey,
1701
1734
  route.providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(route.providerName) : undefined,
1702
1735
  );
1703
- const adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire);
1736
+ let adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire);
1737
+ const stripClaudeMainAuth = options.stripClaudeMainAuthForNoncanonicalForward === true
1738
+ && adapterProvider.adapter === "openai-responses"
1739
+ && adapterProvider.authMode === "forward"
1740
+ && !isCanonicalOpenAiForwardProvider(adapterProvider);
1741
+ if (stripClaudeMainAuth) {
1742
+ releaseCodexAuthContextProbeLease(authCtx);
1743
+ authCtx = { kind: "main", accountId: null };
1744
+ route.provider = stripCodexRuntimeProviderFields(route.provider);
1745
+ adapterProvider = stripCodexRuntimeProviderFields(adapterProvider);
1746
+ selectedForwardHeaders = new Headers(selectedForwardHeaders);
1747
+ selectedForwardHeaders.delete("authorization");
1748
+ selectedForwardHeaders.delete("chatgpt-account-id");
1749
+ delete route.codexAccountMode;
1750
+ delete route.codexAccountId;
1751
+ delete route.codexAccountNamespace;
1752
+ logCtx.provider = route.providerName;
1753
+ }
1704
1754
  const adapter = resolveAdapter(adapterProvider, config.cacheRetention);
1705
1755
  logCtx.providerAdapter = adapter.name;
1706
1756
  // Ordinary requests receive one durable attempt only after their final initial
@@ -2437,25 +2487,46 @@ async function handleResponsesInner(
2437
2487
  && options.inboundTransport !== "websocket"
2438
2488
  && providerModelResponsesUpstreamStreaming(route.providerName, route.provider, route.modelId) === false
2439
2489
  && route.provider.adapter === "openai-responses") {
2490
+ let completed: Record<string, unknown> | undefined;
2440
2491
  try {
2441
- let completed = JSON.parse(clientJson) as Record<string, unknown>;
2492
+ const parsedCompleted = JSON.parse(clientJson) as unknown;
2493
+ if (!parsedCompleted || typeof parsedCompleted !== "object" || Array.isArray(parsedCompleted)) {
2494
+ throw new TypeError("bounded Responses JSON is not an object");
2495
+ }
2496
+ let candidate = parsedCompleted as Record<string, unknown>;
2442
2497
  // The bounded-JSON answer bypasses the SSE relay, so it also bypasses
2443
2498
  // the SSE item-id rewrite. Apply the same client-facing normalization
2444
2499
  // here or this policy would silently disable id repair for the very
2445
2500
  // providers that need it (raw record already happened above).
2446
2501
  if (hasResponsesItemIdRepair(route.provider.responsesItemIdRepair)) {
2447
- completed = repairResponsesJsonItemIds(completed, route.provider.responsesItemIdRepair!, translatorBudget);
2502
+ candidate = repairResponsesJsonItemIds(candidate, route.provider.responsesItemIdRepair!, translatorBudget);
2503
+ }
2504
+ completed = candidate;
2505
+ } catch {
2506
+ // Non-JSON despite content-type: fall through to the plain relay.
2507
+ }
2508
+ if (completed) {
2509
+ let stream: ReadableStream<Uint8Array>;
2510
+ try {
2511
+ stream = responsesJsonToSseStream(completed);
2512
+ } catch (error) {
2513
+ if (error instanceof RangeError) {
2514
+ return formatErrorResponse(
2515
+ 502,
2516
+ "upstream_error",
2517
+ "upstream JSON response exceeded the synthesized SSE item limit",
2518
+ );
2519
+ }
2520
+ throw error;
2448
2521
  }
2449
2522
  const sseHeaders = sanitizePassthroughHeaders(headers);
2450
2523
  sseHeaders.set("content-type", "text/event-stream");
2451
2524
  sseHeaders.set("cache-control", "no-store");
2452
- return new Response(responsesJsonToSseBody(completed), {
2525
+ return new Response(stream, {
2453
2526
  status: upstreamResponse.status,
2454
2527
  statusText: upstreamResponse.statusText,
2455
2528
  headers: sseHeaders,
2456
2529
  });
2457
- } catch {
2458
- // Non-JSON despite content-type: fall through to the plain relay.
2459
2530
  }
2460
2531
  }
2461
2532
  // WS turns reframe this JSON into events in the bridge, which is the
@@ -3154,11 +3225,26 @@ async function handleResponsesInner(
3154
3225
  }
3155
3226
  // Upstreams occasionally echo request details in error bodies — scrub token-shaped
3156
3227
  // material before it reaches the client-facing error surface.
3157
- const message = `Provider error ${upstreamResponse.status}: ${redactSecretString(errorText.slice(0, 500))}`;
3228
+ const upstreamRetryAfter = upstreamResponse.headers.get("retry-after");
3229
+ const message = enrichOpenCodeZenRateLimitMessage(
3230
+ `Provider error ${upstreamResponse.status}: ${redactSecretString(errorText.slice(0, 500))}`,
3231
+ {
3232
+ status: upstreamResponse.status,
3233
+ providerName: route.providerName,
3234
+ baseUrl: route.provider.baseUrl,
3235
+ adapter: route.provider.adapter,
3236
+ authMode: route.provider.authMode,
3237
+ hasApiKey: Boolean(route.provider.apiKey?.trim()),
3238
+ upstreamRetryAfter,
3239
+ // This recovery path is the HTTP Responses wire; custom runTurn transports
3240
+ // never reach enrichOpenCodeZenRateLimitMessage here.
3241
+ supportsHttpSameKeyRetry: true,
3242
+ },
3243
+ );
3158
3244
  const retryAfter = resolveClientRetryAfter({
3159
3245
  status: upstreamResponse.status,
3160
3246
  message,
3161
- upstreamRetryAfter: upstreamResponse.headers.get("retry-after"),
3247
+ upstreamRetryAfter,
3162
3248
  });
3163
3249
  return formatErrorResponse(upstreamResponse.status, "upstream_error", message, {
3164
3250
  ...(retryAfter !== undefined ? { retryAfter } : {}),
@@ -7,6 +7,8 @@
7
7
 
8
8
  export type ResponsesJsonEventFrame = Record<string, unknown>;
9
9
 
10
+ export const MAX_SYNTHESIZED_OUTPUT_ITEMS = 10_000;
11
+
10
12
  /**
11
13
  * The canonical minimal sequence Codex commits: response.created (empty
12
14
  * output, in_progress) → one response.output_item.done per output item → a
@@ -16,26 +18,38 @@ export function responsesJsonEventSequence(
16
18
  response: Record<string, unknown>,
17
19
  rewritePayload?: (payload: Record<string, unknown>) => Record<string, unknown>,
18
20
  ): ResponsesJsonEventFrame[] {
21
+ return [...iterateResponsesJsonEvents(response, rewritePayload)];
22
+ }
23
+
24
+ function* iterateResponsesJsonEvents(
25
+ response: Record<string, unknown>,
26
+ rewritePayload?: (payload: Record<string, unknown>) => Record<string, unknown>,
27
+ ): Generator<ResponsesJsonEventFrame> {
19
28
  const rewrite = rewritePayload ?? ((payload: Record<string, unknown>) => payload);
20
29
  const output = Array.isArray(response.output) ? response.output : [];
30
+ if (output.length > MAX_SYNTHESIZED_OUTPUT_ITEMS) {
31
+ throw new RangeError(
32
+ `Responses JSON output contains ${output.length} items; maximum is ${MAX_SYNTHESIZED_OUTPUT_ITEMS}`,
33
+ );
34
+ }
21
35
  const finalStatus = response.status === "failed" || response.status === "incomplete"
22
36
  ? response.status
23
37
  : "completed";
24
- return [
25
- rewrite({
26
- type: "response.created",
27
- response: { ...response, status: "in_progress", output: [] },
28
- }),
29
- ...output.map((item, outputIndex) => rewrite({
38
+ yield rewrite({
39
+ type: "response.created",
40
+ response: { ...response, status: "in_progress", output: [] },
41
+ });
42
+ for (const [outputIndex, item] of output.entries()) {
43
+ yield rewrite({
30
44
  type: "response.output_item.done",
31
45
  output_index: outputIndex,
32
46
  item,
33
- })),
34
- rewrite({
35
- type: `response.${finalStatus}`,
36
- response: { ...response, status: finalStatus },
37
- }),
38
- ];
47
+ });
48
+ }
49
+ yield rewrite({
50
+ type: `response.${finalStatus}`,
51
+ response: { ...response, status: finalStatus },
52
+ });
39
53
  }
40
54
 
41
55
  /**
@@ -50,3 +64,27 @@ export function responsesJsonToSseBody(
50
64
  .map(frame => `data: ${JSON.stringify(frame)}\n\n`);
51
65
  return `${frames.join("")}data: [DONE]\n\n`;
52
66
  }
67
+
68
+ /** Stream synthesized SSE frames without retaining the expanded body in memory. */
69
+ export function responsesJsonToSseStream(
70
+ response: Record<string, unknown>,
71
+ rewritePayload?: (payload: Record<string, unknown>) => Record<string, unknown>,
72
+ ): ReadableStream<Uint8Array> {
73
+ const output = Array.isArray(response.output) ? response.output : [];
74
+ if (output.length > MAX_SYNTHESIZED_OUTPUT_ITEMS) {
75
+ throw new RangeError(
76
+ `Responses JSON output contains ${output.length} items; maximum is ${MAX_SYNTHESIZED_OUTPUT_ITEMS}`,
77
+ );
78
+ }
79
+ const frames = iterateResponsesJsonEvents(response, rewritePayload);
80
+ const encoder = new TextEncoder();
81
+ return new ReadableStream({
82
+ pull(controller) {
83
+ const next = frames.next();
84
+ controller.enqueue(encoder.encode(
85
+ next.done ? "data: [DONE]\n\n" : `data: ${JSON.stringify(next.value)}\n\n`,
86
+ ));
87
+ if (next.done) controller.close();
88
+ },
89
+ });
90
+ }