@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
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-DDZpgzKk.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-BA1dgo4q.css">
19
+ <script type="module" crossorigin src="/assets/index-Cw1_mxO8.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-BNVYzdn0.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.11.1",
3
+ "version": "2.12.0",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -21,6 +21,10 @@ const COMMAND_CODE_MODEL_ALIASES: Readonly<Record<string, string>> = {
21
21
  "glm-5.2": "zai-org/GLM-5.2",
22
22
  };
23
23
 
24
+ function canonicalCommandCodeModelId(modelId: string): string {
25
+ return Object.hasOwn(COMMAND_CODE_MODEL_ALIASES, modelId) ? COMMAND_CODE_MODEL_ALIASES[modelId]! : modelId;
26
+ }
27
+
24
28
  /** Flatten tool-result content for the text-only wire output, keeping an `[image]` marker per image part in content order. */
25
29
  function toolResultText(content: string | OcxContentPart[]): string {
26
30
  if (typeof content === "string") return content;
@@ -44,20 +48,79 @@ function wireImagePart(imageUrl: string): Record<string, unknown> {
44
48
  return { type: "image", image: imageUrl, ...(mediaType ? { mediaType } : {}) };
45
49
  }
46
50
 
51
+ /**
52
+ * The /alpha/generate wire pairs every assistant `tool-call` with a following `tool` message
53
+ * whose `tool-result.toolCallId` matches it. Codex history is not guaranteed to carry that
54
+ * pairing (interrupted turns, compacted threads, and multi-step tool rounds all can leave an
55
+ * assistant call with no recorded result), and the upstream rejects an unpaired call with
56
+ * `Tool result is missing for tool call <id>`, which currently surfaces as a generic 502
57
+ * (#1383). This builder keeps the pairing invariant:
58
+ *
59
+ * - a `toolResult` that matches a declared assistant call emits the native `tool-result`;
60
+ * - a `toolResult` with no matching declared call degrades to a text carrier so the model
61
+ * still sees the outcome without a 400-prone standalone `tool` message;
62
+ * - every declared assistant call that never received a result gets an explicit error
63
+ * `tool-result`, so the upstream never sees an unpaired call.
64
+ */
47
65
  function wireMessages(messages: OcxMessage[]): Array<Record<string, unknown>> {
48
66
  const out: Array<Record<string, unknown>> = [];
67
+ const pendingCalls: Array<{ id: string; name: string }> = [];
68
+ // Image parts returned by a tool cannot live inside the text-only `tool-result` wire
69
+ // output. They ride a follow-up user message, but that user message must not break the
70
+ // adjacency of the assistant turn's tool results, so carriers are buffered and flushed
71
+ // only after every declared call has its native or synthesized result.
72
+ const pendingImageCarriers: Array<Record<string, unknown>> = [];
73
+ // The /alpha/generate wire requires every assistant tool-call to be closed by a matching
74
+ // `tool-result` immediately after the declaring assistant message. Close any declared call
75
+ // that never received a result before a non-toolResult message moves the turn forward, so a
76
+ // synthesized `tool` message never lands after a user message or a new assistant turn.
77
+ const closePendingCalls = (): void => {
78
+ for (const call of pendingCalls) {
79
+ out.push({ role: "tool", content: [{
80
+ type: "tool-result",
81
+ toolCallId: call.id,
82
+ toolName: call.name,
83
+ output: { type: "error-text", value: "[ocx] no tool result was recorded for this tool call; execution status unknown." },
84
+ }] });
85
+ }
86
+ pendingCalls.length = 0;
87
+ if (pendingImageCarriers.length > 0) {
88
+ out.push(...pendingImageCarriers);
89
+ pendingImageCarriers.length = 0;
90
+ }
91
+ };
49
92
  for (const message of messages) {
50
93
  if (message.role === "assistant") {
94
+ closePendingCalls();
51
95
  const content: Array<Record<string, unknown>> = [];
52
96
  for (const part of message.content) {
53
97
  if (part.type === "text") content.push({ type: "text", text: part.text });
54
98
  else if (part.type === "thinking") content.push({ type: "reasoning", text: part.thinking });
55
- else content.push({ type: "tool-call", toolCallId: part.id, toolName: namespacedToolName(part.namespace, part.name), input: part.arguments });
99
+ else {
100
+ const wireName = namespacedToolName(part.namespace, part.name);
101
+ content.push({ type: "tool-call", toolCallId: part.id, toolName: wireName, input: part.arguments });
102
+ pendingCalls.push({ id: part.id, name: wireName });
103
+ }
56
104
  }
57
105
  out.push({ role: "assistant", content });
58
106
  continue;
59
107
  }
60
108
  if (message.role === "toolResult") {
109
+ const callIndex = pendingCalls.findIndex(call => call.id === message.toolCallId);
110
+ const paired = callIndex >= 0;
111
+ if (paired) pendingCalls.splice(callIndex, 1);
112
+ if (!paired) {
113
+ // Pending calls from an earlier assistant turn must still be closed before any user
114
+ // message lands, or their synthesized results would follow the orphan carrier.
115
+ closePendingCalls();
116
+ // The upstream rejects a standalone tool message whose call was never declared by an
117
+ // assistant turn. Preserve the outcome as text so the model can still act on it.
118
+ const label = message.toolName ? `${message.toolName} (${message.toolCallId})` : message.toolCallId;
119
+ const text = toolResultText(message.content);
120
+ // The orphan result cannot ride a `tool` message; carry it in a user message instead.
121
+ out.push({ role: "user", content: [{ type: "text", text: `[tool result without adjacent tool call: ${label}]\n${text}` }] });
122
+ continue;
123
+ }
61
124
  out.push({ role: "tool", content: [{
62
125
  type: "tool-result",
63
126
  toolCallId: message.toolCallId,
@@ -69,10 +132,12 @@ function wireMessages(messages: OcxMessage[]): Array<Record<string, unknown>> {
69
132
  // message using the same image encoding as the user branch so the bytes reach the model.
70
133
  const images = typeof message.content === "string" ? [] : message.content.filter(part => part.type === "image");
71
134
  if (images.length > 0) {
72
- out.push({ role: "user", content: images.map(part => wireImagePart((part as { imageUrl: string }).imageUrl)) });
135
+ pendingImageCarriers.push({ role: "user", content: images.map(part => wireImagePart((part as { imageUrl: string }).imageUrl)) });
73
136
  }
74
137
  continue;
75
138
  }
139
+ // User/developer message: no pending tool results may follow it on the wire.
140
+ closePendingCalls();
76
141
  const content: Array<Record<string, unknown>> = [];
77
142
  if (typeof message.content === "string") content.push({ type: "text", text: message.content });
78
143
  else for (const part of message.content) {
@@ -81,6 +146,7 @@ function wireMessages(messages: OcxMessage[]): Array<Record<string, unknown>> {
81
146
  }
82
147
  out.push({ role: "user", content });
83
148
  }
149
+ closePendingCalls();
84
150
  return out;
85
151
  }
86
152
 
@@ -237,6 +303,18 @@ function eventError(value: unknown): string {
237
303
  return "Command Code stream error";
238
304
  }
239
305
 
306
+ /**
307
+ * True when the upstream rejected a tool-result continuation because an assistant tool call
308
+ * had no matching result (`Tool result is missing for tool call <id>`). The proxy now keeps
309
+ * that pairing invariant before sending, so this error is a distinct provider-side
310
+ * validation failure rather than a generic stream fault; classify it as such for the
311
+ * dashboard/logs instead of a plain upstream 502.
312
+ */
313
+ function isMissingToolResultError(value: unknown): boolean {
314
+ const text = eventError(value).toLowerCase();
315
+ return text.includes("tool result is missing") || text.includes("tool_result is missing");
316
+ }
317
+
240
318
  async function*ndjson(response: Response, budget: TranslatorBudget): AsyncGenerator<Record<string, unknown>> {
241
319
  if (!response.body) throw new Error("Command Code response body missing");
242
320
  const reader = response.body.getReader();
@@ -318,7 +396,7 @@ function supportedCommandCodeEffort(provider: OcxProviderConfig, modelId: string
318
396
  // Compatibility ids (deepseek-v4-flash / glm-5.2) must resolve to their canonical
319
397
  // Command Code id before the effort lookup, or legacy requests silently lose the
320
398
  // reasoning effort because the official table is keyed by the canonical ids.
321
- const canonicalId = COMMAND_CODE_MODEL_ALIASES[modelId] ?? modelId;
399
+ const canonicalId = canonicalCommandCodeModelId(modelId);
322
400
  const supported = commandCodeReasoningEfforts(canonicalId) ?? configuredReasoningEfforts(provider, canonicalId);
323
401
  if (!supported) return undefined;
324
402
  // Command Code's official profiles describe xhigh and ultra as the CLI labels that map to
@@ -347,7 +425,7 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA
347
425
  config: await commandCodeConfig(cwd), memory: "", taste: null, skills: null,
348
426
  permissionMode: "standard", mode: "agent",
349
427
  params: {
350
- model: COMMAND_CODE_MODEL_ALIASES[parsed.modelId] ?? parsed.modelId,
428
+ model: canonicalCommandCodeModelId(parsed.modelId),
351
429
  messages: wireMessages(parsed.context.messages),
352
430
  tools: wireTools(tools),
353
431
  system,
@@ -437,7 +515,18 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA
437
515
  yield { type: "done", usage: usage(usageValue), stopReason };
438
516
  break;
439
517
  }
440
- case "error": yield { type: "error", message: eventError(event.error), status: 502 }; break;
518
+ case "error": {
519
+ const message = eventError(event.error);
520
+ if (isMissingToolResultError(message)) {
521
+ // Provider-side tool-result validation: the request carried an assistant tool
522
+ // call the upstream refused to accept. This is not a network/stream stall; the
523
+ // proxy normally prevents it by pairing every call, so flag it distinctly.
524
+ yield { type: "error", message, status: 502, errorType: "upstream_error", code: "missing_tool_result" };
525
+ } else {
526
+ yield { type: "error", message, status: 502 };
527
+ }
528
+ break;
529
+ }
441
530
  }
442
531
  }
443
532
  // A stream that ends without a finish event still needs a terminal done so the
@@ -1086,12 +1086,25 @@ class LiveCursorTransport implements CursorTransport {
1086
1086
  }
1087
1087
  return;
1088
1088
  }
1089
+ // A completion may carry only callId. Capture its ownership before mapping removes the open
1090
+ // call, because the embedded-tool classifier cannot identify that valid compact frame.
1091
+ const update = message.message.case === "interactionUpdate" ? message.message.value.message : undefined;
1092
+ const completesOpenClientTool = update?.case === "toolCallCompleted"
1093
+ && state.openToolCalls.has(update.value.callId);
1089
1094
  const mapped = mapCursorProtobufServerMessage(message, state);
1090
1095
  if (mapped.length > 0) {
1091
1096
  // A client tool call announced/committed via interactionUpdate (toolCallStarted/partialToolCall/
1092
1097
  // toolCallCompleted) changes the call set, so revoke any finalize armed by an earlier drain.
1093
- if (isClientToolFrame(message)) this.noteClientToolActivity();
1098
+ // A completion can also commit and drain a late call without a following mcpArgs frame; in
1099
+ // that case re-arm finalization here so the Responses bridge does not wait forever.
1100
+ const clientToolFrame = completesOpenClientTool || isClientToolFrame(message);
1101
+ if (clientToolFrame) this.noteClientToolActivity();
1094
1102
  for (const event of mapped) push(event);
1103
+ if (
1104
+ clientToolFrame
1105
+ && state.openToolCalls.size === 0
1106
+ && mapped.some(event => event.type === "tool_call_end")
1107
+ ) this.scheduleClientToolFinalize(state, push);
1095
1108
  return;
1096
1109
  }
1097
1110
  // The frame produced no outward Responses event (e.g. toolCallStarted / partialToolCall args
@@ -7,6 +7,27 @@ type Schema = Record<string, unknown>;
7
7
  const ALLOWED_TYPES = new Set(["string", "integer", "number", "boolean", "array", "object"]);
8
8
  const MAX_SCHEMA_DEPTH = 24; // Google's documented nesting limit is 32; leave headroom for CCA.
9
9
  const MAX_DEREF_DEPTH = 16;
10
+ const MAX_SCHEMA_NODES = 1_024;
11
+ const BUDGET_EXHAUSTED = Symbol("schema-budget-exhausted");
12
+ const MERGED_SCHEMA_KEYS = [
13
+ "type",
14
+ "nullable",
15
+ "description",
16
+ "format",
17
+ "enum",
18
+ "const",
19
+ "properties",
20
+ "items",
21
+ "required",
22
+ "anyOf",
23
+ ] as const;
24
+
25
+ type SanitizeResult = Schema | typeof BUDGET_EXHAUSTED;
26
+
27
+ interface SanitizeState {
28
+ activeRefs: Set<string>;
29
+ remainingNodes: number;
30
+ }
10
31
 
11
32
  function isRecord(value: unknown): value is Schema {
12
33
  return !!value && typeof value === "object" && !Array.isArray(value);
@@ -34,6 +55,16 @@ function collectDefs(root: unknown, defs: Map<string, unknown>): void {
34
55
  }
35
56
  }
36
57
 
58
+ function mergeRefTarget(target: Schema, overlay: Schema): Schema {
59
+ const merged: Schema = {};
60
+ if (Object.hasOwn(target, "$ref")) merged.$ref = target.$ref;
61
+ for (const key of MERGED_SCHEMA_KEYS) {
62
+ if (Object.hasOwn(overlay, key)) merged[key] = overlay[key];
63
+ else if (Object.hasOwn(target, key)) merged[key] = target[key];
64
+ }
65
+ return merged;
66
+ }
67
+
37
68
  function normalizeType(value: unknown, out: Schema, preserveNullType: boolean): void {
38
69
  const candidates = Array.isArray(value) ? value : [value];
39
70
  let sawNull = false;
@@ -65,9 +96,16 @@ function normalizeAnyOf(
65
96
  defs: Map<string, unknown>,
66
97
  depth: number,
67
98
  refDepth: number,
99
+ state: SanitizeState,
68
100
  ): Schema {
69
101
  if (!Array.isArray(value) || value.length === 0) return {};
70
- const schemas = value.map(item => sanitizeSchema(item, defs, depth + 1, refDepth, true));
102
+ const schemas: Schema[] = [];
103
+ for (let index = 0; index < value.length; index++) {
104
+ if (state.remainingNodes <= 0) return {};
105
+ const schema = sanitizeSchema(value[index], defs, depth + 1, refDepth, true, state);
106
+ if (schema === BUDGET_EXHAUSTED) return {};
107
+ schemas.push(schema);
108
+ }
71
109
 
72
110
  const nonNullSchemas = schemas.filter(schema => schema.type !== "null");
73
111
  const nullSchemas = schemas.filter(schema => schema.type === "null");
@@ -100,12 +138,17 @@ function sanitizeProperties(
100
138
  defs: Map<string, unknown>,
101
139
  depth: number,
102
140
  refDepth: number,
141
+ state: SanitizeState,
103
142
  ): Record<string, Schema> | undefined {
104
143
  if (!isRecord(value)) return undefined;
105
144
  const properties: Record<string, Schema> = Object.create(null) as Record<string, Schema>;
106
- for (const [name, schema] of Object.entries(value)) {
145
+ for (const name in value) {
146
+ if (!Object.hasOwn(value, name)) continue;
147
+ if (state.remainingNodes <= 0) break;
107
148
  // Property names form a name bag and must never be interpreted as schema keywords.
108
- properties[name] = sanitizeSchema(schema, defs, depth + 1, refDepth, false);
149
+ const schema = sanitizeSchema(value[name], defs, depth + 1, refDepth, false, state);
150
+ if (schema === BUDGET_EXHAUSTED) break;
151
+ properties[name] = schema;
109
152
  }
110
153
  return properties;
111
154
  }
@@ -116,17 +159,25 @@ function sanitizeSchema(
116
159
  depth: number,
117
160
  refDepth: number,
118
161
  preserveNullType: boolean,
119
- ): Schema {
162
+ state: SanitizeState,
163
+ ): SanitizeResult {
164
+ if (state.remainingNodes <= 0) return BUDGET_EXHAUSTED;
165
+ state.remainingNodes -= 1;
120
166
  if (depth >= MAX_SCHEMA_DEPTH || !isRecord(node)) return {};
121
167
 
122
168
  if (typeof node.$ref === "string" && refDepth < MAX_DEREF_DEPTH) {
123
169
  const target = resolveRef(node.$ref, defs);
124
170
  if (isRecord(target)) {
125
- const merged: Schema = { ...target };
126
- for (const [key, value] of Object.entries(node)) {
127
- if (key !== "$ref") merged[key] = value;
171
+ if (state.activeRefs.has(node.$ref)) return {};
172
+ state.activeRefs.add(node.$ref);
173
+ // Select only inputs the sanitizer can consume. Spreading an untrusted definition here would
174
+ // enumerate and allocate every unsupported annotation before the node budget can stop work.
175
+ const merged = mergeRefTarget(target, node);
176
+ try {
177
+ return sanitizeSchema(merged, defs, depth, refDepth + 1, preserveNullType, state);
178
+ } finally {
179
+ state.activeRefs.delete(node.$ref);
128
180
  }
129
- return sanitizeSchema(merged, defs, depth, refDepth + 1, preserveNullType);
130
181
  }
131
182
  }
132
183
 
@@ -140,18 +191,27 @@ function sanitizeSchema(
140
191
  const enumValues = sanitizeEnum(node.enum ?? (typeof node.const === "string" ? [node.const] : undefined));
141
192
  if (enumValues) out.enum = enumValues;
142
193
 
143
- const properties = sanitizeProperties(node.properties, defs, depth, refDepth);
194
+ const properties = sanitizeProperties(node.properties, defs, depth, refDepth, state);
144
195
  if (properties) out.properties = properties;
145
196
 
146
- if (isRecord(node.items)) {
147
- out.items = sanitizeSchema(node.items, defs, depth + 1, refDepth, false);
197
+ if (properties && Array.isArray(node.required)) {
198
+ const required = [...new Set(node.required.filter((item): item is string => (
199
+ typeof item === "string" && Object.hasOwn(properties, item)
200
+ )))];
201
+ if (required.length > 0) out.required = required;
148
202
  }
149
203
 
150
- if (Array.isArray(node.required)) {
151
- out.required = [...new Set(node.required.filter((item): item is string => typeof item === "string"))];
204
+ if (state.remainingNodes <= 0) return out;
205
+
206
+ if (isRecord(node.items)) {
207
+ const items = sanitizeSchema(node.items, defs, depth + 1, refDepth, false, state);
208
+ if (items !== BUDGET_EXHAUSTED) out.items = items;
152
209
  }
153
210
 
154
- if (node.anyOf !== undefined) Object.assign(out, normalizeAnyOf(node.anyOf, defs, depth, refDepth));
211
+ if (state.remainingNodes <= 0) return out;
212
+ if (node.anyOf !== undefined) {
213
+ Object.assign(out, normalizeAnyOf(node.anyOf, defs, depth, refDepth, state));
214
+ }
155
215
  return out;
156
216
  }
157
217
 
@@ -159,7 +219,12 @@ export function sanitizeGeminiToolParameters(parameters: unknown): Record<string
159
219
  try {
160
220
  const defs = new Map<string, unknown>();
161
221
  collectDefs(parameters, defs);
162
- const root = sanitizeSchema(parameters, defs, 0, 0, false);
222
+ const state: SanitizeState = {
223
+ activeRefs: new Set(),
224
+ remainingNodes: MAX_SCHEMA_NODES,
225
+ };
226
+ const sanitized = sanitizeSchema(parameters, defs, 0, 0, false, state);
227
+ const root = sanitized === BUDGET_EXHAUSTED ? {} : sanitized;
163
228
 
164
229
  // Function arguments are always an object. Claude additionally rejects root composition and a
165
230
  // missing root type even when those forms are valid general-purpose JSON Schema.
@@ -54,8 +54,8 @@ function resolveVertexApiKey(optKey?: string): string | undefined {
54
54
  /** Prefer Codex's stable opaque thread key; retain the existing deterministic fallback for clients
55
55
  * that omit it. The replay store hashes this value and never retains the raw session identifier. */
56
56
  function vertexReplaySessionId(parsed: OcxParsedRequest): string {
57
- const promptCacheKey = parsed.options.promptCacheKey?.trim();
58
- return promptCacheKey || antigravitySessionId(parsed);
57
+ const threadId = parsed._clientThreadId?.trim();
58
+ return threadId || antigravitySessionId(parsed);
59
59
  }
60
60
 
61
61
  /**
@@ -577,15 +577,32 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
577
577
  pendingUsage = usageFromGemini(usageMeta);
578
578
  sawTerminalSignal = true;
579
579
  }
580
- const candidates = root.candidates as { content?: { parts?: unknown[] }; finishReason?: string }[] | undefined;
581
- if (!candidates?.length) return "continue";
580
+ const rawCandidates = root.candidates;
581
+ if (rawCandidates === undefined) return "continue";
582
+ if (!Array.isArray(rawCandidates)) {
583
+ yield { type: "error", message: "google response contained invalid candidates" };
584
+ return "terminate";
585
+ }
586
+ if (rawCandidates.length === 0) return "continue";
587
+ const rawCandidate = rawCandidates[0];
588
+ if (rawCandidate === null || typeof rawCandidate !== "object" || Array.isArray(rawCandidate)) {
589
+ // Unlike a root `data: null` keepalive, this is a claimed response candidate. Treat it
590
+ // as terminal protocol corruption so the turn cannot complete after silently losing
591
+ // a candidate or tool call (#1325).
592
+ yield { type: "error", message: "google response contained invalid candidates" };
593
+ return "terminate";
594
+ }
595
+ const candidate = rawCandidate as {
596
+ content?: { parts?: unknown[] };
597
+ finishReason?: string;
598
+ };
582
599
 
583
- if (typeof candidates[0].finishReason === "string" && candidates[0].finishReason) {
584
- lastFinishReason = candidates[0].finishReason;
600
+ if (typeof candidate.finishReason === "string" && candidate.finishReason) {
601
+ lastFinishReason = candidate.finishReason;
585
602
  sawTerminalSignal = true;
586
603
  }
587
604
 
588
- const parts = candidates[0].content?.parts as { text?: string; functionCall?: { name: string; args: unknown } }[] | undefined;
605
+ const parts = candidate.content?.parts as { text?: string; functionCall?: { name: string; args: unknown } }[] | undefined;
589
606
  // Record Gemini thought signatures for the next stateless tool-result turn. Vertex and
590
607
  // Antigravity use separate model namespaces so opaque provider state cannot cross routes.
591
608
  const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel;