@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
@@ -1,6 +1,6 @@
1
1
  import type { AdapterRequest, ProviderAdapter } from "./base";
2
2
  import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall, OcxUsage } from "../types";
3
- import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice } from "../types";
3
+ import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../types";
4
4
  import { mapReasoningEffort, modelRecordValue } from "../reasoning-effort";
5
5
  import { debugProviderDiagnostic } from "../lib/debug";
6
6
  import { sseFieldValue } from "../lib/sse-decoder";
@@ -23,7 +23,14 @@ import {
23
23
  // Z.AI needs this because its OpenAI path rejects glm-5.2[1m] with 400 code 1211;
24
24
  // unflagged OpenAI-compatible providers and the Anthropic adapter keep ids verbatim.
25
25
  export function stripBracketedModelSuffix(modelId: string): string {
26
- return modelId.replace(/\[[^\]]*\]\s*$/, "");
26
+ const suffixEnd = modelId.trimEnd().length;
27
+ if (suffixEnd === 0 || modelId[suffixEnd - 1] !== "]") return modelId;
28
+
29
+ let suffixStart = -1;
30
+ for (let i = suffixEnd - 2; i >= 0 && modelId[i] !== "]"; i--) {
31
+ if (modelId[i] === "[") suffixStart = i;
32
+ }
33
+ return suffixStart === -1 ? modelId : modelId.slice(0, suffixStart);
27
34
  }
28
35
 
29
36
  // 260715 (issue #126): surface upstream error detail through the web-search sidecar loop.
@@ -48,14 +55,12 @@ function extractErrorDetail(parsed: unknown): string | undefined {
48
55
  if (typeof parsed === "string") return parsed.trim() || undefined;
49
56
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
50
57
  const obj = parsed as Record<string, unknown>;
51
- // OpenAI shape: { error: { message } } or { error: "..." }
52
58
  const err = obj.error;
53
59
  if (typeof err === "string" && err.trim()) return err.trim();
54
60
  if (err !== null && typeof err === "object" && !Array.isArray(err)) {
55
61
  const msg = (err as Record<string, unknown>).message;
56
62
  if (typeof msg === "string" && msg.trim()) return msg.trim();
57
63
  }
58
- // FastAPI/pydantic shape (NVIDIA NIM): { detail: "..." } or { detail: [{ msg, loc }, ...] }
59
64
  const det = obj.detail;
60
65
  if (typeof det === "string" && det.trim()) return det.trim();
61
66
  if (Array.isArray(det)) {
@@ -66,15 +71,11 @@ function extractErrorDetail(parsed: unknown): string | undefined {
66
71
  .filter(m => m.length > 0);
67
72
  if (msgs.length > 0) return msgs.join("; ");
68
73
  }
69
- // Generic fallbacks: { message } / RFC7807 { title }
70
74
  if (typeof obj.message === "string" && obj.message.trim()) return obj.message.trim();
71
75
  if (typeof obj.title === "string" && obj.title.trim()) return obj.title.trim();
72
76
  return undefined;
73
77
  }
74
78
 
75
- // ClinePass live responses observed 2026-08-02 wrap non-stream Chat Completions in
76
- // `{ success, error, data }`; its public Chat Completions docs do not currently describe that
77
- // envelope. Keep ordinary OpenAI-shaped responses on the direct path.
78
79
  function unwrapChatCompletionPayload(json: Record<string, unknown>): Record<string, unknown> {
79
80
  if ((json.error !== undefined && json.error !== null) || Array.isArray(json.choices)) return json;
80
81
  const data = json.data;
@@ -169,6 +170,18 @@ function invalidChoicesEvent(usage?: OcxUsage): Extract<AdapterEvent, { type: "e
169
170
  };
170
171
  }
171
172
 
173
+ function invalidToolCallsEvent(usage?: OcxUsage): Extract<AdapterEvent, { type: "error" }> {
174
+ return {
175
+ type: "error",
176
+ message: "upstream response contained invalid tool calls",
177
+ ...(usage !== undefined ? { usage } : {}),
178
+ };
179
+ }
180
+
181
+ function isRecord(value: unknown): value is Record<string, unknown> {
182
+ return value !== null && typeof value === "object" && !Array.isArray(value);
183
+ }
184
+
172
185
  function developerSystemText(message: OcxMessage): string | undefined {
173
186
  if (message.role !== "developer") return undefined;
174
187
  if (typeof message.content === "string") return message.content;
@@ -176,18 +189,34 @@ function developerSystemText(message: OcxMessage): string | undefined {
176
189
  return message.content.map(part => (part as OcxTextContent).text).join("");
177
190
  }
178
191
 
192
+ function isNativeOpenAIChatTarget(provider: OcxProviderConfig): boolean {
193
+ try {
194
+ return new URL(provider.baseUrl).hostname === "api.openai.com";
195
+ } catch {
196
+ return false;
197
+ }
198
+ }
199
+
179
200
  /**
180
201
  * Chat-completions image_url parts for images carried inside a tool result (issue #888). role:"tool"
181
202
  * content is text-only on every chat provider, so these ride in a follow-up user message instead of
182
203
  * being flattened to the "[image]" marker the model can't actually see. Data URLs and remote https
183
204
  * URLs are both valid in image_url.url, unlike Gemini inline_data which needs base64.
184
205
  */
206
+ function toolResultTextForWire(content: string | OcxContentPart[]): string {
207
+ if (typeof content === "string") return content;
208
+ const text = content.filter((p) => p.type === "text").map((p) => (p as OcxTextContent).text).join("");
209
+ if (text) {
210
+ const untransportableImages = content.filter((p) => p.type === "image" && !p.imageUrl).length;
211
+ return `${text}${"[image]".repeat(untransportableImages)}`;
212
+ }
213
+ return contentPartsToText(content);
214
+ }
215
+
185
216
  function toolResultImageChatParts(content: string | OcxContentPart[]): unknown[] {
186
217
  if (typeof content === "string") return [];
187
218
  const parts: unknown[] = [];
188
219
  for (const p of content) {
189
- // Skip parts without a usable URL (the tool-output parser accepts the empty file_id shape):
190
- // a {"url":""} part would fail the whole request where the "[image]" marker degrades safely.
191
220
  if (p.type !== "image" || !p.imageUrl) continue;
192
221
  parts.push({ type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } });
193
222
  }
@@ -197,17 +226,8 @@ function toolResultImageChatParts(content: string | OcxContentPart[]): unknown[]
197
226
  function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] {
198
227
  const out: unknown[] = [];
199
228
  const { context, options } = parsed;
200
- // Mirror the bridge's replay-cache scope (issue #950): provider call ids are
201
- // not globally unique, so reasoning must not cross conversation boundaries.
202
229
  const replayCacheScope = parsed._clientThreadId ?? "global";
203
230
 
204
- // 260718 dangling tool_calls hardening (devlog/_plan/260718_dangling_toolcall_hardening):
205
- // strict chat providers (Kimi/Moonshot) 400 when an assistant tool_call is not answered
206
- // immediately by role:"tool" messages. Repair order: (1) reattach a real result to its
207
- // original call (barrier messages are DEFERRED until the open tool round closes),
208
- // (2) synthesize an explicit unavailable-result only when no real result exists,
209
- // (3) manufacture an orphan assistant call only when no call occurrence matches at all.
210
- // Occurrences are kept as an ordered list (never a Map) so duplicated ids survive.
211
231
  interface PendingToolCall { id: string; name: string }
212
232
  let pendingToolCalls: PendingToolCall[] = [];
213
233
  let deferredBarrierMessages: unknown[] = [];
@@ -230,10 +250,6 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
230
250
  deferredBarrierMessages = [];
231
251
  };
232
252
 
233
- // Tool-result images collected during the open round land in ONE user vision message once the
234
- // round closes — never inside it, where strict providers (Kimi/Moonshot) 400 on interleaved
235
- // user messages. Released before deferred barriers so the images stay adjacent to the results
236
- // they came from (mirrors google.ts sibling inline_data parts and the Kiro carrier images).
237
253
  const flushToolResultImages = (): void => {
238
254
  if (pendingToolResultImageParts.length === 0) return;
239
255
  out.push({
@@ -246,9 +262,6 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
246
262
  pendingToolResultImageParts = [];
247
263
  };
248
264
 
249
- // Close an unresolved tool round with explicit unavailable-result messages. The wording
250
- // must not claim interruption, success, failure, or user intent: execution status is
251
- // UNKNOWN, and for user-input tools this must not read as an answer.
252
265
  const flushPendingToolCalls = (): void => {
253
266
  if (pendingToolCalls.length === 0) return;
254
267
  for (const call of pendingToolCalls) {
@@ -263,28 +276,21 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
263
276
  releaseDeferredBarriers();
264
277
  };
265
278
 
279
+ const nativeOpenAI = isNativeOpenAIChatTarget(provider);
266
280
  const toolCatalogNudge = shouldInjectNonOpenAIToolCatalogNudge(provider)
267
281
  ? buildNonOpenAIToolCatalogNudgeForTools(context.tools, options.toolChoice)
268
282
  : undefined;
269
- // Chat templates used by LM Studio, llama.cpp, and other strict OpenAI-compatible
270
- // backends require every system instruction to precede conversation history. Codex can
271
- // append developer reminders after user turns, so fold text-only developer messages into
272
- // the single leading system message instead of emitting role:"system" in place. Developer
273
- // messages with images cannot be represented as system content and remain user-compatible
274
- // vision messages at their original position below.
275
- const developerSystemParts = context.messages
276
- .map(developerSystemText)
277
- .filter((part): part is string => part !== undefined && part.length > 0);
283
+ const developerSystemParts = nativeOpenAI
284
+ ? []
285
+ : context.messages
286
+ .map(developerSystemText)
287
+ .filter((part): part is string => part !== undefined && part.length > 0);
278
288
  const systemParts = [
279
289
  ...(context.systemPrompt ?? []),
280
290
  ...developerSystemParts,
281
291
  ...(toolCatalogNudge ? [toolCatalogNudge] : []),
282
292
  ];
283
293
  if (systemParts.length > 0) {
284
- // Codex sends its GPT-5 identity prompt for EVERY model (the per-model catalog
285
- // base_instructions is ignored at request time). Neutralize that one identity line
286
- // so routed, non-OpenAI models don't misreport themselves as GPT-5 / OpenAI — without
287
- // leaking the proxy identity into the payload.
288
294
  const wireModelId = provider.modelSuffixBracketStrip
289
295
  ? stripBracketedModelSuffix(parsed.modelId)
290
296
  : parsed.modelId;
@@ -298,24 +304,23 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
298
304
  case "developer": {
299
305
  const parts = typeof msg.content === "string" ? undefined : msg.content as OcxContentPart[];
300
306
  const hasImages = parts?.some(p => p.type === "image") ?? false;
301
- if (msg.role === "developer" && !hasImages) break;
302
307
  let chatMsg: Record<string, unknown>;
303
- if (typeof msg.content === "string") {
308
+ if (msg.role === "developer" && !hasImages) {
309
+ if (!nativeOpenAI) break;
310
+ const text = typeof msg.content === "string"
311
+ ? msg.content
312
+ : parts!.map(p => (p as OcxTextContent).text).join("");
313
+ chatMsg = { role: "developer", content: text };
314
+ } else if (typeof msg.content === "string") {
304
315
  chatMsg = { role: "user", content: msg.content };
316
+ } else if (!hasImages) {
317
+ chatMsg = { role: "user", content: parts!.map(p => (p as OcxTextContent).text).join("") };
305
318
  } else {
306
- if (!hasImages) {
307
- chatMsg = { role: "user", content: parts!.map(p => (p as OcxTextContent).text).join("") };
308
- } else {
309
- // Vision: chat-completions content-parts array. Images are only valid on the user role,
310
- // and the data URL goes straight into image_url.url (never the token-exploding text path).
311
- const chatParts = parts!.map(p => p.type === "image"
312
- ? { type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } }
313
- : { type: "text", text: (p as OcxTextContent).text });
314
- chatMsg = { role: "user", content: chatParts };
315
- }
319
+ const chatParts = parts!.map(p => p.type === "image"
320
+ ? { type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } }
321
+ : { type: "text", text: (p as OcxTextContent).text });
322
+ chatMsg = { role: "user", content: chatParts };
316
323
  }
317
- // A barrier must not split an open tool round: defer it until the round closes
318
- // (real result arrives) or the round is synthesized shut.
319
324
  if (pendingToolCalls.length > 0) deferredBarrierMessages.push(chatMsg);
320
325
  else out.push(chatMsg);
321
326
  break;
@@ -326,15 +331,8 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
326
331
  const thinkingParts = aMsg.content.filter(p => p.type === "thinking") as OcxThinkingContent[];
327
332
  const toolCalls = aMsg.content.filter(p => p.type === "toolCall") as OcxToolCall[];
328
333
  const chatMsg: Record<string, unknown> = { role: "assistant" };
329
- if (textParts.length > 0) {
330
- chatMsg.content = textParts.map(p => p.text).join("");
331
- }
334
+ if (textParts.length > 0) chatMsg.content = textParts.map(p => p.text).join("");
332
335
  let reasoningContent = thinkingParts.map(p => p.thinking).join("");
333
- // History transformations (compaction, lost assistant turn, resumed
334
- // threads) can strip the reasoning item while the tool round survives.
335
- // Re-attach the reasoning the bridge recorded for these call ids so
336
- // preserveReasoningContentModels providers (DeepSeek thinking mode)
337
- // never receive a bare tool-call continuation (issue #950).
338
336
  if (
339
337
  reasoningContent.length === 0
340
338
  && toolCalls.length > 0
@@ -347,16 +345,24 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
347
345
  // recorded under every call id — join unique texts only.
348
346
  if (cached.length > 0) {
349
347
  reasoningContent = [...new Set(cached)].join("\n");
348
+ } else if (modelInList(provider.requiresReasoningPlaceholderModels ?? provider.preserveReasoningContentModels, parsed.modelId)) {
349
+ // Fallback (extends #950, closes #1193): the replay cache is
350
+ // bounded (64 entries / 256 KiB / 1 h TTL) and always misses on
351
+ // long sessions, and some tool rounds carry no recorded reasoning
352
+ // at all. DeepSeek thinking mode rejects ANY tool_call assistant
353
+ // message missing reasoning_content with HTTP 400, so inject a
354
+ // minimal placeholder rather than emit a bare continuation the
355
+ // upstream will reject. Scoped to requiresReasoningPlaceholderModels
356
+ // (defaulting to the preserve list): preserve-listed providers with
357
+ // toggleable thinking (MiniMax low effort) opt out with `[]` so
358
+ // non-thinking histories are never given a fabricated placeholder.
359
+ reasoningContent = " ";
350
360
  }
351
361
  }
352
362
  if (reasoningContent.length > 0 && modelInList(provider.preserveReasoningContentModels, parsed.modelId)) {
353
363
  chatMsg.reasoning_content = reasoningContent;
354
364
  }
355
- // Skip empty assistant messages: chat APIs like DeepSeek reject an assistant message
356
- // with neither content, tool calls, nor a provider-supported reasoning_content field.
357
365
  if (chatMsg.content === undefined && toolCalls.length === 0 && chatMsg.reasoning_content === undefined) break;
358
- // A new assistant starts while a previous round is still open: close the previous
359
- // round synthetically first so its tool_calls are never left dangling.
360
366
  flushPendingToolCalls();
361
367
  const wireToolCalls = toolCalls.map(tc => {
362
368
  let id = tc.id;
@@ -370,8 +376,6 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
370
376
  type: "function",
371
377
  function: { name: namespacedToolName(tc.namespace, tc.name), arguments: JSON.stringify(tc.arguments) },
372
378
  }));
373
- // "" instead of null: strict validators (xAI: "Each message must have at least one
374
- // content element", langchain#34140) reject content-less assistant history entries.
375
379
  if (!chatMsg.content) chatMsg.content = emptyAssistantContent(provider);
376
380
  }
377
381
  if (chatMsg.reasoning_content !== undefined && chatMsg.content === undefined && chatMsg.tool_calls === undefined) {
@@ -385,12 +389,10 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
385
389
  let toolCallId = msg.toolCallId;
386
390
  const matchIdx = toolCallId ? pendingToolCalls.findIndex(c => c.id === toolCallId) : -1;
387
391
  if (matchIdx >= 0 && toolCallId) {
388
- // Real result reattached to its original call. Barriers were deferred, so the
389
- // tool message lands immediately inside the open round.
390
392
  out.push({
391
393
  role: "tool",
392
394
  tool_call_id: toolCallId,
393
- content: contentPartsToText(msg.content),
395
+ content: toolResultTextForWire(msg.content),
394
396
  });
395
397
  pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content));
396
398
  pendingToolCalls.splice(matchIdx, 1);
@@ -400,23 +402,31 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
400
402
  }
401
403
  } else {
402
404
  if (!toolCallId) toolCallId = `call_orphan_${out.length}`;
403
- // No matching call in the open round. Close any unresolved round first so the
404
- // synthesized orphan pair never splits it, then keep the historical repair:
405
- // WS turns can arrive with only tool outputs; chat-completions providers reject a bare
406
- // role:"tool" message unless an assistant tool_call with the same id immediately precedes it.
407
405
  flushPendingToolCalls();
408
406
  const name = safeToolName(msg.toolName);
409
- // The orphan repair synthesizes an assistant tool call for a result
410
- // whose assistant turn was lost; carry the recorded reasoning so the
411
- // replayed round stays valid for thinking-mode providers (#950).
412
407
  const cachedReasoning =
413
408
  toolCallId && modelInList(provider.preserveReasoningContentModels, parsed.modelId)
414
409
  ? peekReasoningForCall(toolCallId, replayCacheScope)
415
410
  : undefined;
411
+ // Same fallback as the main-assistant path: never emit a bare orphan
412
+ // tool_call continuation on a thinking-mode provider — inject a
413
+ // placeholder when the replay cache missed (the bounded cache can
414
+ // always miss on long sessions), or DeepSeek thinking mode 400s.
415
+ // Gate on the preserve list too: reasoning_content is only ever
416
+ // serialized for preserve-listed models, so a requires-only custom
417
+ // entry must not fabricate it on this path (P2 on #1205).
418
+ // `||` (not `??`): the cache never stores empty strings, but treat a
419
+ // falsy hit as a miss so the placeholder still fires.
420
+ const orphanReasoning =
421
+ cachedReasoning
422
+ || (modelInList(provider.preserveReasoningContentModels, parsed.modelId)
423
+ && modelInList(provider.requiresReasoningPlaceholderModels ?? provider.preserveReasoningContentModels, parsed.modelId)
424
+ ? " "
425
+ : undefined);
416
426
  out.push({
417
427
  role: "assistant",
418
428
  content: emptyAssistantContent(provider),
419
- ...(cachedReasoning ? { reasoning_content: cachedReasoning } : {}),
429
+ ...(orphanReasoning ? { reasoning_content: orphanReasoning } : {}),
420
430
  tool_calls: [{
421
431
  id: toolCallId,
422
432
  type: "function",
@@ -427,7 +437,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
427
437
  out.push({
428
438
  role: "tool",
429
439
  tool_call_id: toolCallId,
430
- content: contentPartsToText(msg.content),
440
+ content: toolResultTextForWire(msg.content),
431
441
  });
432
442
  pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content));
433
443
  flushToolResultImages();
@@ -437,8 +447,6 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
437
447
  }
438
448
  }
439
449
 
440
- // Trailing dangle: a turn interrupted after the assistant requested tools leaves the
441
- // round open; close it synthetically (then release any deferred barriers in order).
442
450
  flushPendingToolCalls();
443
451
  releaseDeferredBarriers();
444
452
  return out;
@@ -544,11 +552,6 @@ function isXaiSchemaTarget(provider: OcxProviderConfig): boolean {
544
552
  }
545
553
  }
546
554
 
547
- // Volcengine Ark regional endpoints. Ark validates an assistant message's text field as a
548
- // REQUIRED parameter and treats "" as absent, so a tool-call-only assistant in history 400s with
549
- // `MissingParameter: input.content.text` (#796). Every other OpenAI-compatible provider accepts
550
- // "", and xAI actively requires it ("Each message must have at least one content element"), so
551
- // the two contracts are in direct conflict and this cannot be a global change.
552
555
  const VOLCENGINE_ARK_HOSTNAMES = new Set([
553
556
  "ark.cn-beijing.volces.com",
554
557
  "ark.ap-southeast.volces.com",
@@ -562,35 +565,10 @@ function isVolcengineArkTarget(provider: OcxProviderConfig): boolean {
562
565
  }
563
566
  }
564
567
 
565
- /**
566
- * Placeholder content for an assistant history entry carrying only tool calls or reasoning.
567
- *
568
- * UNVERIFIED HYPOTHESIS for Ark. The reported error names `input.content.text`, a nested path,
569
- * which suggests Ark wants the structured content form `[{type:"text",text:""}]` rather than a
570
- * bare string — no string value, `""` or `" "`, exposes a `content.text` path at all. But Ark's
571
- * published examples only show array content for MULTIMODAL USER input, never for an assistant
572
- * history entry, so this shape is inferred from the error message and not confirmed by the docs
573
- * or by a live request. The empty inner text at least adds no tokens either way.
574
- *
575
- * Confirm against a real Ark endpoint before relying on this; #796 records what is still missing.
576
- *
577
- * Every other provider keeps the bare `""`, which xAI's validator specifically requires ("Each
578
- * message must have at least one content element"), so this cannot be applied globally.
579
- */
580
568
  function emptyAssistantContent(provider: OcxProviderConfig): string | { type: "text"; text: string }[] {
581
569
  return isVolcengineArkTarget(provider) ? [{ type: "text", text: "" }] : "";
582
570
  }
583
571
 
584
- /**
585
- * Providers like Kimi and DeepSeek reject function parameter schemas whose root
586
- * `type` is missing or `null` — JSON Schema requires `"object"` at the root of
587
- * function parameters. Add `type: "object"` at the root while preserving
588
- * `oneOf`, `$defs`, and every other schema key.
589
- *
590
- * This mirrors `normalizeFunctionToolSchema` in openai-responses.ts, which
591
- * applies the same root-only normalization unconditionally on the responses
592
- * path. Nested schema content is intentionally left untouched.
593
- */
594
572
  function ensureRootObjectType(parameters: unknown): Record<string, unknown> {
595
573
  if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) {
596
574
  return { type: "object", properties: {} };
@@ -634,12 +612,7 @@ function normalizeXaiToolParameters(parameters: unknown): Record<string, unknown
634
612
 
635
613
  function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined {
636
614
  if (!parsed.context.tools || parsed.context.tools.length === 0) return undefined;
637
- const allowed = isAllowedToolChoice(parsed.options.toolChoice)
638
- ? new Set(parsed.options.toolChoice.allowedTools)
639
- : undefined;
640
- const tools = allowed
641
- ? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed))
642
- : parsed.context.tools;
615
+ const tools = parsed.context.tools.filter(toolChoiceToolPredicate(parsed.options.toolChoice));
643
616
  if (tools.length === 0) return undefined;
644
617
  const xaiTarget = isXaiSchemaTarget(provider);
645
618
  const formatted = tools.flatMap(t => {
@@ -649,13 +622,13 @@ function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig
649
622
 
650
623
  if (parameters === undefined) return [];
651
624
  return [{
652
- type: "function",
653
- function: {
654
- name: namespacedToolName(t.namespace, t.name),
655
- description: t.description,
656
- parameters,
657
- ...(t.strict !== undefined ? { strict: t.strict } : {}),
658
- },
625
+ type: "function",
626
+ function: {
627
+ name: namespacedToolName(t.namespace, t.name),
628
+ ...(t.description ? { description: t.description } : {}),
629
+ parameters,
630
+ ...(t.strict !== undefined ? { strict: t.strict } : {}),
631
+ },
659
632
  }];
660
633
  });
661
634
  return formatted.length > 0 ? formatted : undefined;
@@ -678,9 +651,18 @@ function toolsToChatFormatForProvider(parsed: OcxParsedRequest, provider: OcxPro
678
651
  });
679
652
  }
680
653
 
681
- function toolChoiceToChatFormat(tc: OcxParsedRequest["options"]["toolChoice"], tools: OcxParsedRequest["context"]["tools"]): unknown {
654
+ function toolChoiceToChatFormat(
655
+ tc: OcxParsedRequest["options"]["toolChoice"],
656
+ tools: OcxParsedRequest["context"]["tools"],
657
+ provider: OcxProviderConfig,
658
+ ): unknown {
682
659
  if (!tc) return undefined;
683
- if (isAllowedToolChoice(tc)) return tc.mode === "required" ? "required" : "auto";
660
+ if (isAllowedToolChoice(tc)) {
661
+ if (tc.mode === "required" && tc.allowedTools.length === 1 && isNativeOpenAIChatTarget(provider)) {
662
+ return { type: "function", function: { name: resolveToolChoiceWireName(tools, tc.allowedTools[0]) } };
663
+ }
664
+ return tc.mode === "required" ? "required" : "auto";
665
+ }
684
666
  if (tc === "auto" || tc === "none" || tc === "required") return tc;
685
667
  if ("name" in tc) return { type: "function", function: { name: resolveToolChoiceWireName(tools, tc.name) } };
686
668
  return undefined;
@@ -732,7 +714,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
732
714
 
733
715
  const messages = messagesToChatFormat(parsed, provider);
734
716
  const tools = toolsToChatFormatForProvider(parsed, provider);
735
- const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools);
717
+ const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools, provider);
736
718
 
737
719
  const body: Record<string, unknown> = {
738
720
  model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(parsed.modelId) : parsed.modelId,
@@ -759,24 +741,41 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
759
741
  if (parsed.options.stopSequences !== undefined) body.stop = parsed.options.stopSequences;
760
742
  const reasoningDisabled = modelInList(provider.noReasoningModels, parsed.modelId);
761
743
  const reasoningEffort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning);
744
+ const nativeOpenAI = isNativeOpenAIChatTarget(provider);
762
745
  let reasoningLog: AdapterRequest["reasoningLog"];
763
- // ClinePass live requests observed 2026-08-02 require this gateway-specific object; the
764
- // public API docs do not currently specify its request shape.
765
746
  if (!reasoningDisabled && provider.reasoningWireFormat === "gateway-object" && parsed.options.reasoning === "none") {
766
- body.reasoning = { enabled: false };
767
- reasoningLog = {
768
- effectiveEffort: "none",
769
- wireField: "reasoning.enabled",
770
- wireValue: false,
771
- };
772
- } else if (reasoningEffort !== undefined) {
773
- if (provider.reasoningWireFormat === "gateway-object") {
774
- body.reasoning = { enabled: true, effort: reasoningEffort };
747
+ if (nativeOpenAI) {
748
+ body.reasoning_effort = "none";
775
749
  reasoningLog = {
776
- effectiveEffort: reasoningEffort,
777
- wireField: "reasoning.effort",
778
- wireValue: reasoningEffort,
750
+ effectiveEffort: "none",
751
+ wireField: "reasoning_effort",
752
+ wireValue: "none",
779
753
  };
754
+ } else {
755
+ body.reasoning = { enabled: false };
756
+ reasoningLog = {
757
+ effectiveEffort: "none",
758
+ wireField: "reasoning.enabled",
759
+ wireValue: false,
760
+ };
761
+ }
762
+ } else if (reasoningEffort !== undefined) {
763
+ if (provider.reasoningWireFormat === "gateway-object") {
764
+ if (nativeOpenAI) {
765
+ body.reasoning_effort = reasoningEffort;
766
+ reasoningLog = {
767
+ effectiveEffort: reasoningEffort,
768
+ wireField: "reasoning_effort",
769
+ wireValue: reasoningEffort,
770
+ };
771
+ } else {
772
+ body.reasoning = { enabled: true, effort: reasoningEffort };
773
+ reasoningLog = {
774
+ effectiveEffort: reasoningEffort,
775
+ wireField: "reasoning.effort",
776
+ wireValue: reasoningEffort,
777
+ };
778
+ }
780
779
  } else if (modelInList(provider.thinkingBudgetModels, parsed.modelId)) {
781
780
  const budget = thinkingBudgetForEffort(parsed, reasoningEffort, maxTokens);
782
781
  if (budget !== undefined) {
@@ -788,9 +787,6 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
788
787
  };
789
788
  }
790
789
  } else if (modelInList(provider.thinkingToggleModels, parsed.modelId)) {
791
- // Vendor thinking-toggle wire: the mapped value is sent as `thinking: {type}` because
792
- // these models ignore/reject reasoning_effort. Most use enabled/disabled; MiniMax-M3
793
- // uses adaptive/disabled.
794
790
  if (reasoningEffort === "enabled" || reasoningEffort === "disabled" || reasoningEffort === "adaptive") {
795
791
  body.thinking = { type: reasoningEffort };
796
792
  reasoningLog = {
@@ -814,17 +810,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
814
810
  if (parsed.options.frequencyPenalty !== undefined && !modelInList(provider.noPenaltyModels, parsed.modelId)) {
815
811
  body.frequency_penalty = parsed.options.frequencyPenalty;
816
812
  }
817
- // prompt_cache_key is an OpenAI-specific chat extension; strict backends (Groq,
818
- // Cerebras, etc.) reject unknown fields. Only forward when the provider opts in.
819
813
  if (provider.promptCacheKey && parsed.options.promptCacheKey !== undefined) {
820
814
  body.prompt_cache_key = parsed.options.promptCacheKey;
821
815
  }
822
- // Responses `text.format` -> chat `response_format`. json_object maps 1:1; json_schema
823
- // re-nests the flattened Responses fields under `json_schema` — the exact inverse of
824
- // responseFormatToText in src/chat/inbound.ts. Forwarded unconditionally (like `stop`):
825
- // response_format is a first-class Chat Completions field, it is only present when the
826
- // caller explicitly asked for structured output, and a backend that rejects it should
827
- // fail loud rather than silently return prose the caller will try to JSON.parse.
828
816
  const textFormat = parsed.options.textFormat;
829
817
  if (textFormat?.type === "json_object") {
830
818
  body.response_format = { type: "json_object" };
@@ -841,30 +829,25 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
841
829
  }
842
830
 
843
831
  if (tools) {
844
- // Default-ON for chat-completions providers (user decision 260709): the buffered
845
- // parser assembles multi-call streams safely, so `parallelToolCalls: false` is the
846
- // only per-provider opt-out; Codex's request bit can still force false per request.
847
- // Rationale + provider evidence: devlog/_plan/260709_parallel_tool_calls.
848
- body.parallel_tool_calls = provider.parallelToolCalls === false
849
- ? false
850
- : parsed.options.parallelToolCalls !== false;
851
- }
852
- if (parsed.stream) {
853
- body.stream_options = { include_usage: true };
832
+ if (provider.parallelToolCalls === false) {
833
+ // NIM documents the Boolean defaulting to false and kimi rejects true; pin the
834
+ // wire bit so Codex cannot opt in via request.options. Other opted-out providers
835
+ // omit the field so strict OpenAI-compatible hosts never see an unsupported knob.
836
+ if (provider.baseUrl === "https://integrate.api.nvidia.com/v1") {
837
+ body.parallel_tool_calls = false;
838
+ }
839
+ } else if (provider.parallelToolCalls === true) {
840
+ body.parallel_tool_calls = parsed.options.parallelToolCalls !== false;
841
+ }
854
842
  }
843
+ if (parsed.stream) body.stream_options = { include_usage: true };
855
844
 
856
845
  const url = `${provider.baseUrl}/chat/completions`;
857
846
  const headers: Record<string, string> = { "Content-Type": "application/json" };
858
- // Precedence preserved from pre-#128 behavior: apiKey Authorization first, then
859
- // provider.headers may override (user/registry-configured headers win). Registry
860
- // staticHeaders (e.g. opencode-free x-opencode-client) flow in via derive.ts and
861
- // never carry Authorization, so keyless providers are unaffected.
862
847
  if (hasCredential) headers["Authorization"] = `Bearer ${provider.apiKey}`;
863
848
  if (provider.headers) Object.assign(headers, provider.headers);
864
849
 
865
850
  const bodyJson = JSON.stringify(body);
866
- // Never log pathname/query — tenant-scoped hosts (e.g. Cloudflare
867
- // /accounts/<account_id>/ai/v1) would otherwise leak account identifiers (#452).
868
851
  if (isDebugEnabled()) {
869
852
  let host = "upstream";
870
853
  try { host = new URL(url).host; } catch { /* keep fallback */ }
@@ -899,14 +882,6 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
899
882
  const budgetEncoder = new TextEncoder();
900
883
  let buffer = "";
901
884
  let bufferBytes = 0;
902
- // Streamed tool calls are BUFFERED until a terminal signal, then flushed as atomic
903
- // start/delta/end sequences. The bridge treats text/reasoning deltas as barriers that
904
- // close an open tool-call item (bridge.ts closeCurrentToolCall on text_delta), so
905
- // emitting calls incrementally would orphan later argument deltas whenever a provider
906
- // interleaves content — and parallel tool calls (multiple ids, index-keyed continuation
907
- // chunks, whole-chunk calls) cannot be represented live without overlapping sequences.
908
- // Keyed by `index` (OpenAI wire standard), falling back to `id`, falling back to the
909
- // last-seen call for providers that omit both on continuation chunks.
910
885
  interface PendingToolCall { key: string; id: string; name: string; args: string; argsBytes: number }
911
886
  const pendingToolCalls: PendingToolCall[] = [];
912
887
  let toolCallSeq = 0;
@@ -917,8 +892,6 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
917
892
  return calls;
918
893
  };
919
894
  const flushToolCalls = function* (): Generator<AdapterEvent> {
920
- // Do not treat flushed tool calls as user-facing output for the finish-less EOF
921
- // fallback — incomplete tool args must stay on the truncation path.
922
895
  for (const call of closeToolCalls()) {
923
896
  if (!call.id) call.id = `call_${++toolCallSeq}`;
924
897
  yield { type: "tool_call_start", id: call.id, name: call.name };
@@ -934,25 +907,13 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
934
907
  return "terminate";
935
908
  };
936
909
  let pendingUsage: OcxUsage | undefined;
937
- // Track terminal signals so a socket EOF without any terminator can fail closed instead of
938
- // being reported as a clean completion (silent truncation). A graceful close is either an
939
- // explicit `[DONE]` sentinel OR a chunk carrying a non-null `finish_reason` (some
940
- // OpenAI-compatible providers omit `[DONE]` but do send finish_reason).
941
910
  let finishReason: string | undefined;
942
- // Only answer text enables the finish-less EOF fallback. Reasoning-only streams can be
943
- // suppressed by hideThinkingSummary and must not complete as empty successful turns.
944
911
  let sawUserFacingOutput = false;
945
912
 
946
- // Single per-line handler shared by the streaming loop and the EOF residual-frame flush, so
947
- // a final frame is parsed identically wherever it lands (no duplicated, drift-prone parsing).
948
- // Yields adapter events and returns "terminate" for a terminal frame ([DONE] / error) that
949
- // must end the stream, or "continue" otherwise. Mutates the closure's terminal-signal state.
950
913
  const handleDataLine = function* (line: string): Generator<AdapterEvent, "continue" | "terminate"> {
951
914
  const rawPayload = sseFieldValue(line, "data");
952
915
  if (rawPayload === null) return "continue";
953
916
  const payload = rawPayload.trim();
954
- // A bare `data:` line carries nothing (heartbeat-style keep-alive on some gateways);
955
- // it is not a malformed frame, just nothing to parse.
956
917
  if (payload.length === 0) return "continue";
957
918
  if (payload === "[DONE]") {
958
919
  yield* flushToolCalls();
@@ -968,42 +929,20 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
968
929
  yield { type: "error", message: "malformed upstream SSE data frame" };
969
930
  return "terminate";
970
931
  }
971
- // Validate the shape instead of asserting it. `JSON.parse` yields a value, not necessarily
972
- // an object — `JSON.parse("null")` returns null without throwing, so the catch above never
973
- // sees it and the `chunk.error` read below crashed the stream mid-flight.
974
- //
975
- // Skip rather than terminate: `data: null` is emitted as a benign padding frame BETWEEN
976
- // content deltas by real OpenAI-compatible routes (issue #1219), so failing here would
977
- // discard the finish_reason chunk and [DONE] still in flight and turn a healthy response
978
- // into a failed turn. Skipping cannot mask a genuinely broken stream — a stream carrying
979
- // only such frames still sets neither finishReason nor sawUserFacingOutput and so trips
980
- // the EOF truncation guard below. An unparseable frame stays terminal.
981
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
982
- return "continue";
983
- }
932
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return "continue";
984
933
  const chunk = parsed as Record<string, unknown>;
985
934
 
986
- // A 200/OK chat-completions stream may carry an inline provider error envelope
987
- // instead of a clean [DONE]. Surface it as a terminal error so the bridge emits a
988
- // classified response.failed (bridge case "error") — never a truncated completion.
989
935
  if (chunk.error !== undefined && chunk.error !== null) {
990
936
  const event = upstreamErrorEvent(chunk.error, pendingUsage);
991
937
  debugProviderDiagnostic("openai-chat", "stream-error", { message: event.message });
992
938
  return yield* terminateWithError(event);
993
939
  }
994
940
 
995
- if (chunk.usage) {
996
- // Record usage but keep parsing: some providers send usage and the final content
997
- // delta in the SAME chunk; a bail here would drop that content. The choices
998
- // guard below no-ops a usage-only chunk.
999
- pendingUsage = usageFromOpenAIChat(chunk.usage as Record<string, unknown>);
1000
- }
941
+ if (chunk.usage) pendingUsage = usageFromOpenAIChat(chunk.usage as Record<string, unknown>);
1001
942
 
1002
943
  const choices = chunk.choices;
1003
944
  if (choices === undefined) return "continue";
1004
- if (!Array.isArray(choices)) {
1005
- return yield* terminateWithError(invalidChoicesEvent(pendingUsage));
1006
- }
945
+ if (!Array.isArray(choices)) return yield* terminateWithError(invalidChoicesEvent(pendingUsage));
1007
946
  if (choices.length === 0) return "continue";
1008
947
  const rawChoice = choices[0];
1009
948
  if (rawChoice === null || typeof rawChoice !== "object" || Array.isArray(rawChoice)) {
@@ -1019,34 +958,39 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1019
958
  debugProviderDiagnostic("openai-chat", "stream-error", { message: event.message });
1020
959
  return yield* terminateWithError(event);
1021
960
  }
1022
- // Observe the terminator BEFORE the delta guard: a finish-only chunk (finish_reason set,
1023
- // no delta) is a graceful close and must record finishReason even though we skip it below.
1024
- if (typeof choice.finish_reason === "string" && choice.finish_reason) {
1025
- finishReason = choice.finish_reason;
1026
- }
961
+ if (typeof choice.finish_reason === "string" && choice.finish_reason) finishReason = choice.finish_reason;
1027
962
  const delta = choice.delta;
1028
963
  if (delta) {
1029
964
  const reasoningText = reasoningTextFrom(delta);
1030
- if (reasoningText !== undefined) {
1031
- yield { type: "reasoning_raw_delta", text: reasoningText };
1032
- }
965
+ if (reasoningText !== undefined) yield { type: "reasoning_raw_delta", text: reasoningText };
1033
966
  if (typeof delta.content === "string" && delta.content.length > 0) {
1034
967
  sawUserFacingOutput = true;
1035
968
  yield { type: "text_delta", text: delta.content };
1036
969
  }
1037
970
 
1038
- const toolCalls = delta.tool_calls as { index?: number; id?: string; function?: { name?: string; arguments?: string } }[] | undefined;
1039
- if (toolCalls) {
1040
- for (const tc of toolCalls) {
971
+ const rawToolCalls = delta.tool_calls;
972
+ if (rawToolCalls !== undefined) {
973
+ // A claimed tool-call payload is not benign padding. Dropping it can leave the
974
+ // matching result permanently orphaned, so malformed nested shapes fail closed
975
+ // through the adapter error channel instead of escaping as TypeError (#1325).
976
+ if (!Array.isArray(rawToolCalls)) {
977
+ return yield* terminateWithError(invalidToolCallsEvent(pendingUsage));
978
+ }
979
+ for (const rawToolCall of rawToolCalls) {
980
+ if (!isRecord(rawToolCall)) {
981
+ return yield* terminateWithError(invalidToolCallsEvent(pendingUsage));
982
+ }
983
+ const tc = rawToolCall as {
984
+ index?: number;
985
+ id?: string;
986
+ function?: { name?: string; arguments?: string };
987
+ };
1041
988
  const key = typeof tc.index === "number"
1042
989
  ? `i:${tc.index}`
1043
990
  : tc.id
1044
- ? `id:${tc.id}`
1045
- : pendingToolCalls[pendingToolCalls.length - 1]?.key;
991
+ ? `id:${tc.id}`
992
+ : pendingToolCalls[pendingToolCalls.length - 1]?.key;
1046
993
  let call = key !== undefined ? pendingToolCalls.find(c => c.key === key) : undefined;
1047
- // Mixed keying rescue: a call opened under an index key must still absorb an
1048
- // id-only continuation for the same provider id (and vice versa) instead of
1049
- // splitting into two calls that share one call_id downstream.
1050
994
  if (!call && tc.id) call = pendingToolCalls.find(c => c.id === tc.id);
1051
995
  if (!call) {
1052
996
  call = { key: key ?? `seq:${pendingToolCalls.length}`, id: "", name: "", args: "", argsBytes: 0 };
@@ -1074,11 +1018,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1074
1018
  }
1075
1019
  }
1076
1020
 
1077
- // Any non-empty finish_reason ends the generation: flush assembled tool calls as
1078
- // atomic sequences (covers "tool_calls" AND providers that close tool turns with "stop").
1079
- if (typeof choice.finish_reason === "string" && choice.finish_reason) {
1080
- yield* flushToolCalls();
1081
- }
1021
+ if (typeof choice.finish_reason === "string" && choice.finish_reason) yield* flushToolCalls();
1082
1022
  return "continue";
1083
1023
  };
1084
1024
 
@@ -1117,21 +1057,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1117
1057
  }
1118
1058
  }
1119
1059
 
1120
- // Some providers send the terminal `data:` frame (carrying the final delta, finish_reason,
1121
- // and/or usage) WITHOUT a trailing newline before closing the socket, so it never crosses
1122
- // the split("\n") boundary and stays in `buffer`. Run it through the SAME handler so its
1123
- // content/tool-calls are emitted and its terminal signal observed — otherwise a genuinely
1124
- // complete stream loses its last frame and may be falsely failed below.
1125
1060
  if (buffer.length > 0) {
1126
1061
  if ((yield* handleDataLine(buffer)) === "terminate") return;
1127
1062
  }
1128
- // Reader EOF. Prefer failing closed before flushing pending tool calls so the bridge
1129
- // never sees a fabricated tool_call_end on a truncated mid-assembly stream.
1130
- //
1131
- // Checked BEFORE flushToolCalls(), because that helper emits tool_call_end and there is no
1132
- // taking it back: a half-assembled argument string would reach the client as a completed
1133
- // call. Tool calls are buffered here (unlike the Anthropic adapter, which forwards
1134
- // fragments live), so this adapter can still decide.
1135
1063
  const sawFinish = finishReason !== undefined;
1136
1064
  if (!sawFinish && pendingToolCalls.length > 0) {
1137
1065
  debugProviderDiagnostic("openai-chat", "stream-truncated", {
@@ -1142,9 +1070,6 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1142
1070
  yield { type: "error", message: "upstream stream ended mid tool call without a terminal signal — possible truncation" };
1143
1071
  return;
1144
1072
  }
1145
- // Finish-less EOF is only safe when answer text was emitted. Reasoning-only / usage-only
1146
- // truncations must stay on the error path (hideThinkingSummary can suppress reasoning).
1147
- // Trailing usage alone is not a terminal signal for this adapter (#735 / restore #773).
1148
1073
  if (!sawFinish && !sawUserFacingOutput) {
1149
1074
  debugProviderDiagnostic("openai-chat", "stream-truncated", {
1150
1075
  finishReason: finishReason ?? null,
@@ -1154,7 +1079,6 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1154
1079
  return;
1155
1080
  }
1156
1081
  yield* flushToolCalls();
1157
- // Graceful close that omitted [DONE] but delivered finish_reason and/or answer text.
1158
1082
  const stopReason = stopReasonFor(finishReason);
1159
1083
  yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) };
1160
1084
  } catch (error) {
@@ -1183,60 +1107,64 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1183
1107
  const responseBytes = new TextEncoder().encode(JSON.stringify(json)).byteLength;
1184
1108
  budget.chargeRetained(responseBytes, { kind: "retained_collectors" });
1185
1109
  try {
1186
- const payload = unwrapChatCompletionPayload(json);
1187
- const usage = usageFromOpenAIChat(payload.usage as Record<string, unknown> | undefined);
1188
- if (json.success === false && payload.error === undefined) {
1189
- return [{
1190
- type: "error",
1191
- message: "upstream reported failure without an error payload",
1192
- ...(usage ? { usage } : {}),
1193
- }];
1194
- }
1195
- if (payload.error !== undefined && payload.error !== null) {
1196
- return [upstreamErrorEvent(payload.error, usage)];
1197
- }
1198
-
1199
- const events: AdapterEvent[] = [];
1200
- const choices = payload.choices as {
1201
- message?: Record<string, unknown>;
1202
- finish_reason?: unknown;
1203
- error?: OpenAIChatError;
1204
- }[] | undefined;
1205
- if (!Array.isArray(choices) || choices.length === 0) {
1206
- return [{ type: "error", message: "upstream response contained no choices", ...(usage ? { usage } : {}) }];
1207
- }
1208
- const rawChoice = choices[0];
1209
- if (rawChoice === null || typeof rawChoice !== "object" || Array.isArray(rawChoice)) {
1210
- return [invalidChoicesEvent(usage)];
1211
- }
1212
- const choice = rawChoice;
1213
- if (choice.finish_reason === "error") return [upstreamErrorEvent(choice.error, usage)];
1214
- if (!choice.message) return [{ type: "error", message: "upstream response contained no choices", ...(usage ? { usage } : {}) }];
1215
-
1216
- const msg = choice.message;
1217
- const reasoningText = reasoningTextFrom(msg);
1218
- if (reasoningText !== undefined) {
1219
- events.push({ type: "reasoning_raw_delta", text: reasoningText });
1220
- }
1221
- if (typeof msg.content === "string") {
1222
- events.push({ type: "text_delta", text: msg.content });
1223
- }
1224
- const toolCalls = msg.tool_calls as { id: string; function: { name: string; arguments: string } }[] | undefined;
1225
- if (toolCalls) {
1226
- for (const tc of toolCalls) {
1227
- events.push({ type: "tool_call_start", id: tc.id, name: tc.function.name });
1228
- events.push({ type: "tool_call_delta", arguments: tc.function.arguments });
1229
- events.push({ type: "tool_call_end" });
1110
+ const payload = unwrapChatCompletionPayload(json);
1111
+ const usage = usageFromOpenAIChat(payload.usage as Record<string, unknown> | undefined);
1112
+ if (json.success === false && payload.error === undefined) {
1113
+ return [{
1114
+ type: "error",
1115
+ message: "upstream reported failure without an error payload",
1116
+ ...(usage ? { usage } : {}),
1117
+ }];
1230
1118
  }
1231
- }
1232
- const stopReason = stopReasonFor(choice.finish_reason);
1233
- events.push({
1234
- type: "done",
1235
- usage,
1236
- ...(stopReason ? { stopReason } : {}),
1237
- });
1238
- retainTranslatedEventBatch(events, budget);
1239
- return events;
1119
+ if (payload.error !== undefined && payload.error !== null) return [upstreamErrorEvent(payload.error, usage)];
1120
+
1121
+ const events: AdapterEvent[] = [];
1122
+ const choices = payload.choices as {
1123
+ message?: Record<string, unknown>;
1124
+ finish_reason?: unknown;
1125
+ error?: OpenAIChatError;
1126
+ }[] | undefined;
1127
+ if (!Array.isArray(choices) || choices.length === 0) {
1128
+ return [{ type: "error", message: "upstream response contained no choices", ...(usage ? { usage } : {}) }];
1129
+ }
1130
+ const rawChoice = choices[0];
1131
+ if (rawChoice === null || typeof rawChoice !== "object" || Array.isArray(rawChoice)) {
1132
+ return [invalidChoicesEvent(usage)];
1133
+ }
1134
+ const choice = rawChoice;
1135
+ if (choice.finish_reason === "error") return [upstreamErrorEvent(choice.error, usage)];
1136
+ if (!choice.message) return [{ type: "error", message: "upstream response contained no choices", ...(usage ? { usage } : {}) }];
1137
+
1138
+ const msg = choice.message;
1139
+ const reasoningText = reasoningTextFrom(msg);
1140
+ if (reasoningText !== undefined) events.push({ type: "reasoning_raw_delta", text: reasoningText });
1141
+ if (typeof msg.content === "string") events.push({ type: "text_delta", text: msg.content });
1142
+ const rawToolCalls = msg.tool_calls;
1143
+ if (rawToolCalls !== undefined) {
1144
+ if (!Array.isArray(rawToolCalls)) return [invalidToolCallsEvent(usage)];
1145
+ for (const rawToolCall of rawToolCalls) {
1146
+ if (!isRecord(rawToolCall) || !isRecord(rawToolCall.function)) {
1147
+ return [invalidToolCallsEvent(usage)];
1148
+ }
1149
+ const id = rawToolCall.id;
1150
+ const name = rawToolCall.function.name;
1151
+ const args = rawToolCall.function.arguments;
1152
+ if (typeof id !== "string" || typeof name !== "string" || typeof args !== "string") {
1153
+ return [invalidToolCallsEvent(usage)];
1154
+ }
1155
+ events.push({ type: "tool_call_start", id, name });
1156
+ events.push({ type: "tool_call_delta", arguments: args });
1157
+ events.push({ type: "tool_call_end" });
1158
+ }
1159
+ }
1160
+ const stopReason = stopReasonFor(choice.finish_reason);
1161
+ events.push({
1162
+ type: "done",
1163
+ usage,
1164
+ ...(stopReason ? { stopReason } : {}),
1165
+ });
1166
+ retainTranslatedEventBatch(events, budget);
1167
+ return events;
1240
1168
  } finally {
1241
1169
  budget.releaseRetained(responseBytes, { kind: "retained_collectors" });
1242
1170
  }