@bitkyc08/opencodex 2.60.0 → 2.61.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 (249) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-CH6C5H7x.js +50 -0
  5. package/gui/dist/assets/Tray-CLZh48fM.js +1 -0
  6. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  7. package/gui/dist/assets/index-wpTOyepx.js +86 -0
  8. package/gui/dist/assets/usage-companion-chart-a0N58rRI.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +5 -1
  13. package/src/adapters/anthropic.ts +16 -0
  14. package/src/adapters/coding-agent/protocol.ts +36 -6
  15. package/src/adapters/coding-agent/turn.ts +10 -2
  16. package/src/adapters/command-code.ts +2 -1
  17. package/src/adapters/cursor/catalog.ts +51 -7
  18. package/src/adapters/cursor/protobuf-request.ts +6 -3
  19. package/src/adapters/cursor/request-builder.ts +13 -3
  20. package/src/adapters/cursor.ts +11 -2
  21. package/src/adapters/declaration-carrier.ts +45 -0
  22. package/src/adapters/devin.ts +75 -23
  23. package/src/adapters/google-antigravity-wire.ts +5 -2
  24. package/src/adapters/google-errors.ts +7 -1
  25. package/src/adapters/google.ts +29 -5
  26. package/src/adapters/image.ts +4 -1
  27. package/src/adapters/input-media-guard.ts +21 -9
  28. package/src/adapters/kiro/usage.ts +3 -2
  29. package/src/adapters/kiro-tool-fallback.ts +1 -1
  30. package/src/adapters/ollama-native.ts +6 -0
  31. package/src/adapters/openai-chat/developer-role.ts +61 -0
  32. package/src/adapters/openai-chat/messages.ts +46 -27
  33. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  34. package/src/adapters/openai-chat/passthrough.ts +33 -9
  35. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  36. package/src/adapters/openai-chat.ts +18 -57
  37. package/src/adapters/openai-responses/passthrough.ts +2 -0
  38. package/src/adapters/registry.ts +3 -2
  39. package/src/adapters/run-turn-queue.ts +178 -29
  40. package/src/adapters/xai-web-search.ts +16 -1
  41. package/src/bridge/errors.ts +8 -2
  42. package/src/bridge/response-json.ts +9 -1
  43. package/src/bridge/sse.ts +10 -0
  44. package/src/chat/inbound.ts +141 -5
  45. package/src/claude/desktop-3p.ts +7 -1
  46. package/src/claude/desktop-first-party.ts +183 -0
  47. package/src/claude/desktop-gateway-state.ts +41 -0
  48. package/src/claude/inbound-content-options.ts +6 -0
  49. package/src/claude/inbound.ts +32 -6
  50. package/src/claude/intercept/connect-proxy.ts +179 -0
  51. package/src/claude/intercept/listener.ts +122 -0
  52. package/src/claude/intercept/local-ca.ts +298 -0
  53. package/src/claude/intercept/runtime.ts +98 -0
  54. package/src/claude/intercept/settings.ts +189 -0
  55. package/src/cli/access.ts +87 -0
  56. package/src/cli/account-auth.ts +19 -0
  57. package/src/cli/capabilities.ts +31 -0
  58. package/src/cli/claude-desktop.ts +206 -16
  59. package/src/cli/codex-shim-autorestore.ts +3 -0
  60. package/src/cli/companion.ts +56 -0
  61. package/src/cli/dispatch.ts +43 -4
  62. package/src/cli/ensure-desired-integrations.ts +43 -5
  63. package/src/cli/help.ts +7 -9
  64. package/src/cli/index.ts +200 -61
  65. package/src/cli/init.ts +8 -0
  66. package/src/cli/integrations.ts +7 -1
  67. package/src/cli/registry.ts +41 -2
  68. package/src/cli/resolve.ts +230 -0
  69. package/src/cli/root.ts +24 -1
  70. package/src/cli/start-ownership-publication.ts +56 -0
  71. package/src/cli/status-probes.ts +2 -18
  72. package/src/cli/status.ts +62 -0
  73. package/src/cli/stop-report.ts +143 -0
  74. package/src/cli/uninstall-plan.ts +9 -0
  75. package/src/client/machine-listener.ts +2 -5
  76. package/src/clients/aside-profiles.ts +4 -0
  77. package/src/clients/config-export/zcode-store.ts +157 -0
  78. package/src/clients/config-export.ts +36 -0
  79. package/src/codex/app-server-processes.ts +72 -40
  80. package/src/codex/auth-api/login-flow.ts +6 -1
  81. package/src/codex/autostart-health.ts +28 -0
  82. package/src/codex/catalog/build-entries.ts +2 -2
  83. package/src/codex/catalog/effort.ts +3 -3
  84. package/src/codex/catalog/provider-models.ts +24 -15
  85. package/src/codex/catalog/retained-sync.ts +2 -2
  86. package/src/codex/convergence.ts +2 -2
  87. package/src/codex/history-provider.ts +12 -1
  88. package/src/codex/inject/config-toml.ts +41 -6
  89. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  90. package/src/codex/inject.ts +18 -15
  91. package/src/codex/injected-marker.ts +18 -0
  92. package/src/codex/main-account.ts +6 -0
  93. package/src/codex/model-cache.ts +52 -6
  94. package/src/codex/model-entitlement-admission.ts +59 -0
  95. package/src/codex/model-entitlements.ts +87 -44
  96. package/src/codex/native-main-admission.ts +83 -0
  97. package/src/codex/routing/health-store.ts +39 -0
  98. package/src/codex/routing/selection.ts +37 -1
  99. package/src/codex/routing.ts +5 -41
  100. package/src/codex/shim-templates.ts +29 -3
  101. package/src/companion/settings.ts +132 -0
  102. package/src/config/atomic-write.ts +117 -5
  103. package/src/config/load-degrade.ts +34 -7
  104. package/src/config/process-state.ts +1 -1
  105. package/src/config/schema/config-schema.ts +27 -1
  106. package/src/config/schema/leaf-validators.ts +47 -0
  107. package/src/config.ts +1 -1
  108. package/src/generated/compatibility-version.json +418 -174
  109. package/src/integrations/config-io.ts +44 -10
  110. package/src/integrations/merge.ts +120 -13
  111. package/src/integrations/mutation-plan.ts +124 -18
  112. package/src/integrations/registry.ts +38 -0
  113. package/src/integrations/state.ts +78 -45
  114. package/src/integrations/target.ts +208 -0
  115. package/src/integrations/writer.ts +49 -11
  116. package/src/lab/conformance/fixture-provider.ts +5 -0
  117. package/src/lib/browser-launch-notice.ts +59 -0
  118. package/src/lib/bun-runtime.ts +6 -2
  119. package/src/lib/debug.ts +40 -0
  120. package/src/lib/open-url.ts +51 -7
  121. package/src/lib/package-tree-integrity.ts +2 -1
  122. package/src/lib/package-version.ts +8 -0
  123. package/src/lib/provider-egress.ts +310 -0
  124. package/src/lib/provider-outbound.ts +59 -14
  125. package/src/lib/proxy-env.ts +82 -7
  126. package/src/lib/request-execution-budget.ts +72 -0
  127. package/src/lib/request-failure-attribution.ts +183 -0
  128. package/src/lib/request-failure-model.ts +236 -0
  129. package/src/lib/request-resend-gate.ts +138 -0
  130. package/src/lib/standalone.ts +16 -0
  131. package/src/lib/upstream-retry.ts +167 -16
  132. package/src/lib/winsw.ts +2 -2
  133. package/src/oauth/index.ts +24 -1
  134. package/src/oauth/login-cli.ts +80 -29
  135. package/src/providers/api-key-resolve.ts +133 -0
  136. package/src/providers/api-key-selection.ts +5 -1
  137. package/src/providers/key-failover.ts +31 -1
  138. package/src/providers/key-store.ts +34 -110
  139. package/src/providers/model-rename-fields.ts +147 -0
  140. package/src/providers/model-rename-migration.ts +124 -37
  141. package/src/providers/quota/vendor-probes-key.ts +37 -22
  142. package/src/providers/reasoning-metadata.ts +43 -18
  143. package/src/providers/registry/entries-core.ts +9 -4
  144. package/src/providers/registry/entries-extended.ts +29 -4
  145. package/src/providers/registry/model-seeds.ts +47 -10
  146. package/src/providers/xai-transport.ts +12 -1
  147. package/src/reasoning-effort.ts +8 -0
  148. package/src/responses/function-call-compat.ts +38 -1
  149. package/src/responses/inline-document.ts +65 -0
  150. package/src/responses/input-media.ts +42 -8
  151. package/src/responses/muse-tool-name-alias.ts +19 -0
  152. package/src/responses/parser-content.ts +8 -2
  153. package/src/responses/parser-tools.ts +3 -0
  154. package/src/responses/parser.ts +3 -1
  155. package/src/responses/schema.ts +3 -0
  156. package/src/router.ts +17 -2
  157. package/src/server/admission-model-scope.ts +219 -0
  158. package/src/server/audio-live.ts +9 -3
  159. package/src/server/audio-upstream.ts +18 -0
  160. package/src/server/auth-cors.ts +26 -0
  161. package/src/server/chat-completions.ts +55 -2
  162. package/src/server/chat-native.ts +19 -4
  163. package/src/server/claude-messages.ts +55 -17
  164. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  165. package/src/server/gui-freshness.ts +103 -0
  166. package/src/server/gui-static.ts +7 -9
  167. package/src/server/images.ts +59 -6
  168. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  169. package/src/server/index/serve-options.ts +56 -10
  170. package/src/server/index/spend-ledger-lifecycle.ts +34 -8
  171. package/src/server/index/startup-warnings.ts +24 -0
  172. package/src/server/index.ts +21 -28
  173. package/src/server/lifecycle.ts +4 -4
  174. package/src/server/live-call-bindings.ts +6 -0
  175. package/src/server/live.ts +88 -3
  176. package/src/server/management/agent-settings-routes.ts +121 -36
  177. package/src/server/management/companion-routes.ts +77 -0
  178. package/src/server/management/logs-usage-routes.ts +19 -0
  179. package/src/server/management/native-integration-routes.ts +103 -6
  180. package/src/server/management/oauth-account-routes.ts +45 -7
  181. package/src/server/management/route-registry.ts +6 -0
  182. package/src/server/management/shared.ts +18 -1
  183. package/src/server/management/usage-timeline-routes.ts +44 -0
  184. package/src/server/management-api.ts +8 -9
  185. package/src/server/proxy-liveness.ts +75 -0
  186. package/src/server/relay.ts +19 -2
  187. package/src/server/request-log-failure-attribution.ts +99 -0
  188. package/src/server/request-log.ts +114 -0
  189. package/src/server/request-metrics.ts +92 -30
  190. package/src/server/responses/codex-ws-wire.ts +34 -8
  191. package/src/server/responses/combo-stream-preflight.ts +168 -6
  192. package/src/server/responses/compact.ts +11 -0
  193. package/src/server/responses/core-opaque-recovery.ts +90 -0
  194. package/src/server/responses/fetch-helpers.ts +124 -8
  195. package/src/server/responses/input-admission.ts +10 -0
  196. package/src/server/responses/passthrough-delivery.ts +14 -1
  197. package/src/server/responses/passthrough-dispatch.ts +179 -35
  198. package/src/server/responses/passthrough-error.ts +27 -8
  199. package/src/server/responses/request-prepare.ts +42 -1
  200. package/src/server/responses/request-send-budget.ts +12 -0
  201. package/src/server/responses/request-transport.ts +24 -4
  202. package/src/server/responses/reset-replay.ts +108 -0
  203. package/src/server/responses-request-tool-scope.ts +214 -0
  204. package/src/server/responses-undeclared-tool-guard.ts +4 -1
  205. package/src/server/search.ts +25 -1
  206. package/src/server/usage-ledger-retention.ts +73 -0
  207. package/src/service/cli.ts +48 -2
  208. package/src/service/health.ts +3 -2
  209. package/src/service/install-state-contract.d.mts +27 -0
  210. package/src/service/install-state-contract.mjs +34 -0
  211. package/src/service/launchd.ts +1 -1
  212. package/src/service/orchestration.ts +2 -4
  213. package/src/service/ownership-compatibility.ts +164 -0
  214. package/src/service/ownership-mutation-lease.d.mts +32 -0
  215. package/src/service/ownership-mutation-lease.mjs +211 -0
  216. package/src/service/repair.ts +45 -1
  217. package/src/service/state-lock.ts +269 -0
  218. package/src/service/state-record.d.mts +36 -0
  219. package/src/service/state-record.mjs +138 -0
  220. package/src/service/state.ts +582 -68
  221. package/src/service/windows-taskxml.ts +11 -10
  222. package/src/service.ts +7 -3
  223. package/src/tray/windows-tray.ps1 +1 -1
  224. package/src/types/config.ts +37 -0
  225. package/src/types/provider.ts +73 -0
  226. package/src/types/request.ts +28 -2
  227. package/src/types/tools.ts +19 -0
  228. package/src/types.ts +3 -0
  229. package/src/update/index.ts +207 -63
  230. package/src/update/job.ts +9 -5
  231. package/src/update/ownership-transaction.ts +47 -0
  232. package/src/update/restart-ownership.ts +54 -0
  233. package/src/update/runtime-ownership.d.mts +40 -0
  234. package/src/update/runtime-ownership.mjs +122 -0
  235. package/src/usage/attempt-delivery.ts +198 -0
  236. package/src/usage/cache-diagnostic.ts +305 -0
  237. package/src/usage/failure-fingerprint.ts +118 -0
  238. package/src/usage/failure-projection-cache.ts +174 -0
  239. package/src/usage/failure-projection.ts +174 -0
  240. package/src/usage/ledger-retention.ts +165 -0
  241. package/src/usage/log.ts +126 -79
  242. package/src/usage/request-outcome.ts +150 -0
  243. package/src/usage/retention-contract.ts +28 -0
  244. package/src/usage/summary.ts +2 -2
  245. package/src/usage/telemetry-contract.ts +237 -0
  246. package/src/usage/timeline.ts +236 -0
  247. package/src/web-search/alpha-search.ts +21 -1
  248. package/gui/dist/assets/index-BTuCbqQd.css +0 -1
  249. package/gui/dist/assets/index-DoBVdPHP.js +0 -134
@@ -1,6 +1,7 @@
1
1
  import { openAIChatTransport, stripBracketedModelSuffix } from "./wire";
2
2
  import type { AdapterRequest } from "../base";
3
3
  import { frameAgentRouterMessages } from "../agentrouter";
4
+ import { applyExplicitChatDeveloperRole } from "./developer-role";
4
5
  import { openRouterProviderPayload, resolveOpenRouterRouting } from "../../providers/openrouter-routing";
5
6
  import { resolveVercelGatewayRouting, vercelGatewayProviderPayload } from "../../providers/vercel-gateway-routing";
6
7
  import { fastPolicyForModel } from "../../providers/service-tier";
@@ -9,6 +10,8 @@ import { debugProviderDiagnostic } from "../../lib/debug";
9
10
  import { isDebugEnabled } from "../../lib/debug-settings";
10
11
  import { modelRecordValue } from "../../reasoning-effort";
11
12
  import { modelInList, type OcxProviderConfig } from "../../types";
13
+ import { chatParallelToolCallsWireValue } from "./parallel-tool-calls";
14
+ import { applyExplicitChatReasoningWirePolicy } from "./reasoning-wire";
12
15
 
13
16
  const CHAT_PASSTHROUGH_FIELDS = [
14
17
  "audio",
@@ -54,16 +57,37 @@ export function buildOpenAIChatPassthroughRequest(
54
57
 
55
58
  const body: Record<string, unknown> = {
56
59
  model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(modelId) : modelId,
57
- messages: frameAgentRouterMessages(provider.baseUrl, rawBody.messages),
60
+ // The caller's messages are forwarded as they arrived, with one exception: an operator who
61
+ // recorded that this destination rejects the `developer` role gets that role converted in
62
+ // place. Verbatim was not neutral there — it sent the role anyway and the turn failed
63
+ // upstream with a 400 before the model saw it. An unrecorded destination is still verbatim,
64
+ // and the conversion changes the role of those messages and nothing else, so the position
65
+ // of every message and every other field survive unchanged.
66
+ messages: applyExplicitChatDeveloperRole(
67
+ frameAgentRouterMessages(provider.baseUrl, rawBody.messages),
68
+ provider,
69
+ ),
58
70
  stream,
59
71
  };
60
72
  for (const field of CHAT_PASSTHROUGH_FIELDS) {
61
73
  if (rawBody[field] !== undefined) body[field] = rawBody[field];
62
74
  }
63
75
  const rawEfforts = modelRecordValue(provider.modelReasoningEfforts, modelId) ?? provider.reasoningEfforts;
64
- if (modelInList(provider.noReasoningModels, modelId) || rawEfforts?.length === 0) {
76
+ const reasoningDisabled = modelInList(provider.noReasoningModels, modelId) || rawEfforts?.length === 0;
77
+ if (reasoningDisabled) {
65
78
  delete body.reasoning_effort;
66
79
  }
80
+ const hasTools = Array.isArray(rawBody.tools) && rawBody.tools.length > 0;
81
+ const requestedEffort = typeof body.reasoning_effort === "string" ? body.reasoning_effort : undefined;
82
+ applyExplicitChatReasoningWirePolicy({
83
+ provider,
84
+ modelId,
85
+ hasTools,
86
+ requestedEffort,
87
+ wireEffort: requestedEffort,
88
+ reasoningDisabled,
89
+ body,
90
+ });
67
91
 
68
92
  const openRouterRouting = resolveOpenRouterRouting(provider, modelId);
69
93
  if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting);
@@ -107,13 +131,13 @@ export function buildOpenAIChatPassthroughRequest(
107
131
  if (provider.promptCacheKey && rawBody.prompt_cache_key !== undefined) {
108
132
  body.prompt_cache_key = rawBody.prompt_cache_key;
109
133
  }
110
- if (Array.isArray(rawBody.tools) && rawBody.tools.length > 0) {
111
- if (provider.parallelToolCalls === true) {
112
- body.parallel_tool_calls = rawBody.parallel_tool_calls !== false;
113
- } else if (provider.parallelToolCalls === false
114
- && (provider.baseUrl === "https://integrate.api.nvidia.com/v1" || provider.pinParallelToolCallsFalse === true)) {
115
- body.parallel_tool_calls = false;
116
- }
134
+ if (hasTools) {
135
+ // Same three provider states as the translated path, and the same defect in the unset one:
136
+ // a caller's explicit false was dropped here too (#5211). The native route reads the bit off
137
+ // the raw request rather than the parsed options, since nothing projects this body.
138
+ const requested = typeof rawBody.parallel_tool_calls === "boolean" ? rawBody.parallel_tool_calls : undefined;
139
+ const parallelToolCalls = chatParallelToolCallsWireValue(provider, requested);
140
+ if (parallelToolCalls !== undefined) body.parallel_tool_calls = parallelToolCalls;
117
141
  }
118
142
  if (stream) {
119
143
  const callerOptions = rawBody.stream_options !== null
@@ -0,0 +1,89 @@
1
+ import { modelInList, type OcxProviderConfig } from "../../types";
2
+ import type { AdapterRequest } from "../base";
3
+ import { isNativeOpenAIChatTarget } from "./wire";
4
+
5
+ export type ExplicitChatReasoningWireResult =
6
+ | { handled: false }
7
+ | { handled: true; reasoningLog?: AdapterRequest["reasoningLog"] };
8
+
9
+ /**
10
+ * Apply provider-declared reasoning wire policy after the effective provider is resolved.
11
+ * Unset declarations are a no-op so native Chat keeps forwarding the caller's raw field.
12
+ */
13
+ export function applyExplicitChatReasoningWirePolicy(options: {
14
+ provider: OcxProviderConfig;
15
+ modelId: string;
16
+ hasTools: boolean;
17
+ requestedEffort: string | undefined;
18
+ wireEffort: string | undefined;
19
+ reasoningDisabled: boolean;
20
+ body: Record<string, unknown>;
21
+ }): ExplicitChatReasoningWireResult {
22
+ const {
23
+ provider,
24
+ modelId,
25
+ hasTools,
26
+ requestedEffort,
27
+ wireEffort,
28
+ reasoningDisabled,
29
+ body,
30
+ } = options;
31
+
32
+ if (reasoningDisabled) return { handled: false };
33
+ if (hasTools && modelInList(provider.omitReasoningEffortWithToolsModels, modelId)) {
34
+ delete body.reasoning_effort;
35
+ delete body.reasoning;
36
+ return { handled: true };
37
+ }
38
+ if (provider.reasoningWireFormat !== "gateway-object") return { handled: false };
39
+
40
+ const nativeOpenAI = isNativeOpenAIChatTarget(provider);
41
+ if (requestedEffort === "none") {
42
+ if (nativeOpenAI) {
43
+ delete body.reasoning;
44
+ body.reasoning_effort = "none";
45
+ return {
46
+ handled: true,
47
+ reasoningLog: {
48
+ effectiveEffort: "none",
49
+ wireField: "reasoning_effort",
50
+ wireValue: "none",
51
+ },
52
+ };
53
+ }
54
+ delete body.reasoning_effort;
55
+ body.reasoning = { enabled: false };
56
+ return {
57
+ handled: true,
58
+ reasoningLog: {
59
+ effectiveEffort: "none",
60
+ wireField: "reasoning.enabled",
61
+ wireValue: false,
62
+ },
63
+ };
64
+ }
65
+ if (wireEffort === undefined) return { handled: false };
66
+
67
+ if (nativeOpenAI) {
68
+ delete body.reasoning;
69
+ body.reasoning_effort = wireEffort;
70
+ return {
71
+ handled: true,
72
+ reasoningLog: {
73
+ effectiveEffort: wireEffort,
74
+ wireField: "reasoning_effort",
75
+ wireValue: wireEffort,
76
+ },
77
+ };
78
+ }
79
+ delete body.reasoning_effort;
80
+ body.reasoning = { enabled: true, effort: wireEffort };
81
+ return {
82
+ handled: true,
83
+ reasoningLog: {
84
+ effectiveEffort: wireEffort,
85
+ wireField: "reasoning.effort",
86
+ wireValue: wireEffort,
87
+ },
88
+ };
89
+ }
@@ -1,4 +1,6 @@
1
1
  import { hasShrinkableOpenAIChatImages, normalizeOpenAIChatImages } from "./openai-chat-images";
2
+ import { chatParallelToolCallsWireValue } from "./openai-chat/parallel-tool-calls";
3
+ import { applyExplicitChatReasoningWirePolicy } from "./openai-chat/reasoning-wire";
2
4
  import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "./base";
3
5
  import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../types";
4
6
  import { modelInList } from "../types";
@@ -40,7 +42,7 @@ import {
40
42
  } from "./openai-chat/errors";
41
43
  import { messagesToChatFormat } from "./openai-chat/messages";
42
44
  import { withOpenAIChatToolNames } from "./openai-chat/tool-name-registry";
43
- import { isNativeOpenAIChatTarget, openAIChatTransport, stripBracketedModelSuffix } from "./openai-chat/wire";
45
+ import { openAIChatTransport, stripBracketedModelSuffix } from "./openai-chat/wire";
44
46
  import { toolChoiceToChatFormat, toolsToChatFormatForProvider } from "./openai-chat/tool-schema";
45
47
 
46
48
  export { stripBracketedModelSuffix } from "./openai-chat/wire";
@@ -142,51 +144,21 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
142
144
  }
143
145
  if (parsed.options.stopSequences !== undefined) body.stop = parsed.options.stopSequences;
144
146
  const reasoningDisabled = modelInList(provider.noReasoningModels, parsed.modelId);
145
- // Some gateways accept a reasoning-effort field on a plain turn but reject the
146
- // effort + tools combination. `noReasoningModels` would fix that only by
147
- // stripping reasoning everywhere, costing the model its whole picker. This keeps
148
- // the ladder advertised and drops the wire field for tool-bearing requests only.
149
- const omitReasoningEffortWithTools = !!tools
150
- && modelInList(provider.omitReasoningEffortWithToolsModels, parsed.modelId);
151
- const reasoningEffort = omitReasoningEffortWithTools
152
- ? undefined
153
- : mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning);
154
- const nativeOpenAI = isNativeOpenAIChatTarget(provider);
147
+ const reasoningEffort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning);
148
+ const explicitReasoning = applyExplicitChatReasoningWirePolicy({
149
+ provider,
150
+ modelId: parsed.modelId,
151
+ hasTools: !!tools,
152
+ requestedEffort: parsed.options.reasoning,
153
+ wireEffort: reasoningEffort,
154
+ reasoningDisabled,
155
+ body,
156
+ });
155
157
  let reasoningLog: AdapterRequest["reasoningLog"];
156
- if (!reasoningDisabled && !omitReasoningEffortWithTools && provider.reasoningWireFormat === "gateway-object" && parsed.options.reasoning === "none") {
157
- if (nativeOpenAI) {
158
- body.reasoning_effort = "none";
159
- reasoningLog = {
160
- effectiveEffort: "none",
161
- wireField: "reasoning_effort",
162
- wireValue: "none",
163
- };
164
- } else {
165
- body.reasoning = { enabled: false };
166
- reasoningLog = {
167
- effectiveEffort: "none",
168
- wireField: "reasoning.enabled",
169
- wireValue: false,
170
- };
171
- }
158
+ if (explicitReasoning.handled) {
159
+ reasoningLog = explicitReasoning.reasoningLog;
172
160
  } else if (reasoningEffort !== undefined) {
173
- if (provider.reasoningWireFormat === "gateway-object") {
174
- if (nativeOpenAI) {
175
- body.reasoning_effort = reasoningEffort;
176
- reasoningLog = {
177
- effectiveEffort: reasoningEffort,
178
- wireField: "reasoning_effort",
179
- wireValue: reasoningEffort,
180
- };
181
- } else {
182
- body.reasoning = { enabled: true, effort: reasoningEffort };
183
- reasoningLog = {
184
- effectiveEffort: reasoningEffort,
185
- wireField: "reasoning.effort",
186
- wireValue: reasoningEffort,
187
- };
188
- }
189
- } else if (modelInList(provider.thinkingBudgetModels, parsed.modelId)) {
161
+ if (modelInList(provider.thinkingBudgetModels, parsed.modelId)) {
190
162
  const budget = thinkingBudgetForEffort(parsed, reasoningEffort, maxTokens);
191
163
  if (budget !== undefined) {
192
164
  body.thinking_budget = budget;
@@ -249,19 +221,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
249
221
  }
250
222
 
251
223
  if (tools) {
252
- if (provider.parallelToolCalls === false) {
253
- // NIM documents the Boolean defaulting to false and kimi rejects true; pin the
254
- // wire bit so Codex cannot opt in via request.options. Other opted-out providers
255
- // omit the field by default so strict OpenAI-compatible hosts never see an
256
- // unsupported knob, but a self-hosted gateway that DOES honor the field and keeps
257
- // emitting parallel calls without it can opt in via pinParallelToolCallsFalse.
258
- if (provider.baseUrl === "https://integrate.api.nvidia.com/v1"
259
- || provider.pinParallelToolCallsFalse === true) {
260
- body.parallel_tool_calls = false;
261
- }
262
- } else if (provider.parallelToolCalls === true) {
263
- body.parallel_tool_calls = parsed.options.parallelToolCalls !== false;
264
- }
224
+ const parallelToolCalls = chatParallelToolCallsWireValue(provider, parsed.options.parallelToolCalls);
225
+ if (parallelToolCalls !== undefined) body.parallel_tool_calls = parallelToolCalls;
265
226
  }
266
227
  if (parsed.stream) body.stream_options = { include_usage: true };
267
228
 
@@ -42,6 +42,7 @@ import { bridgeSearchReplayScope } from "../../responses/bridge-search-replay-ca
42
42
  import { applyTierDecisionToResponsesBody, normalizeCanonicalForwardContinuationEnvelope, normalizeCanonicalForwardPromptEnvelope, stripCanonicalForwardSamplingParams, stripPreviousResponseId, stripStatefulResponsesParams, stripUnsupportedForwardParams } from "./canonical-forward";
43
43
  import { normalizeImageGenClientTools, preferConfiguredHostedTools } from "./image-gen";
44
44
  import { stripMuseSparkUnsupportedWebSearchFields, stripOpenAiOnlyWebSearchFields } from "./web-search";
45
+ import { observeOutbound } from "../../usage/cache-diagnostic";
45
46
 
46
47
  /**
47
48
  * Identifies DeepSeek's strict Responses replay contract: tool-bearing continuations need
@@ -501,6 +502,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
501
502
  // here, on the serialized body, not on the parsed selector. One place covers both the
502
503
  // HTTP and the WebSocket outbound, because the WS path transports this same request
503
504
  // instead of rebuilding it.
505
+ observeOutbound(parsed._rawBody, finalBody, headers);
504
506
  const body = JSON.stringify(finalBody);
505
507
  const releaseBodyObservation = translatorBudget.observeExternallyCapped(
506
508
  "passthrough_serialization",
@@ -181,8 +181,9 @@ export function createRegisteredAdapter(
181
181
  const definition = getAdapterDefinition(provider.adapter);
182
182
  if (!definition) throw new Error(`Unknown adapter: ${provider.adapter}`);
183
183
  const adapter = definition.create(provider, context);
184
- if (effectiveAdapterContract(provider.adapter).wire !== "openai-responses") {
185
- withInputMediaGuard(adapter);
184
+ const wire = effectiveAdapterContract(provider.adapter).wire;
185
+ if (wire !== "openai-responses") {
186
+ withInputMediaGuard(adapter, wire);
186
187
  }
187
188
  const buildRequest = adapter.buildRequest.bind(adapter);
188
189
  adapter.buildRequest = (parsed, incoming) => {
@@ -6,11 +6,107 @@ export const PREFLIGHT_HEARTBEAT_RETAIN_LIMIT = 16;
6
6
 
7
7
  /**
8
8
  * Coalescing threshold for adjacent text/thinking deltas buffered with no
9
- * waiting reader (UTF-16 code units). This is a merge-size ceiling, not a
10
- * byte-memory cap: a single oversized incoming event stays one item.
9
+ * waiting reader (UTF-16 code units). This is a merge-size ceiling; the two
10
+ * retention budgets below are what bound memory.
11
11
  */
12
12
  export const COALESCE_MAX_CHUNK_LENGTH = 64 * 1024;
13
13
 
14
+ /**
15
+ * Retained-string budget for the WHOLE queue, in UTF-16 code units.
16
+ *
17
+ * This bounds what the queue is holding at one moment, not how much a turn
18
+ * streams: every dequeue gives its charge back, so a long healthy stream with a
19
+ * consumer attached never accumulates and is never capped by total length.
20
+ *
21
+ * 32 MiB is deliberately far above any single legitimate burst. A synchronous
22
+ * producer can legally fill the queue before its consumer is scheduled — the
23
+ * image loop does exactly that with over a million one-character deltas, which
24
+ * coalesce into roughly 1.2 MB of retained text — so a budget near that size
25
+ * aborts healthy turns rather than stalled ones. The previous effective bound
26
+ * was the 1024-event cap times the 64 KiB merge ceiling, so 64 MiB; this halves
27
+ * it while leaving that legitimate burst an order of magnitude of headroom.
28
+ */
29
+ export const DEFAULT_MAX_BACKLOG_CODE_UNITS = 32 * 1024 * 1024;
30
+
31
+ /**
32
+ * Retained-string budget for ONE queued event, in UTF-16 code units.
33
+ *
34
+ * Separate from the aggregate on purpose, because the two describe different
35
+ * failures. Passing the aggregate means the consumer is not keeping up. Passing
36
+ * this one means a single event is malformed or unbounded, which stays true
37
+ * however empty the queue is, so it must be refused even with the whole
38
+ * aggregate free. They also report different terminal messages, so an operator
39
+ * reading the turn's error learns which happened.
40
+ *
41
+ * Like the aggregate, this governs what the queue RETAINS. An event handed
42
+ * straight to a waiting consumer is never held here, so neither budget applies
43
+ * to it: refusing it would abort a turn over memory this queue does not own,
44
+ * and the consumer's own per-event bound governs that payload instead.
45
+ */
46
+ export const DEFAULT_MAX_EVENT_CODE_UNITS = 8 * 1024 * 1024;
47
+
48
+ const BACKLOG_EXCEEDED_MESSAGE = "consumer stalled: adapter event backlog exceeded — turn aborted";
49
+ const EVENT_TOO_LARGE_MESSAGE = "adapter event exceeds the single-event retained-string budget — turn aborted";
50
+
51
+ /**
52
+ * Bound on how far the retention measure walks into one event. AdapterEvent is
53
+ * a plain-data union, but two of its members carry open provider-shaped bags
54
+ * (`providerState`, `usage.rawUsage`) whose depth no type here controls. The
55
+ * ceilings keep a single push O(1)-ish rather than O(whatever an adapter
56
+ * attached), and under-counting a pathological object is the safe direction:
57
+ * the event-count cap still bounds how many of them can be retained.
58
+ */
59
+ const RETENTION_MAX_DEPTH = 8;
60
+ const RETENTION_MAX_NODES = 4096;
61
+
62
+ /**
63
+ * Retained UTF-16 code units carried by one event's string payload.
64
+ *
65
+ * Measured by walking own enumerable properties rather than by naming each
66
+ * variant's string fields: a hand-written per-variant table is exhaustive over
67
+ * a union, so adding an event type on one branch while a consumer lands on
68
+ * another produces a measure that silently stops counting the new payload.
69
+ * The walk is the derived answer and needs no update when the union grows.
70
+ *
71
+ * `type` is skipped because it is the discriminant, identical for every event
72
+ * of a kind and not payload anyone is buffering.
73
+ */
74
+ export function retainedEventCodeUnits(event: AdapterEvent): number {
75
+ // Defensive: this measures values an adapter produced. A malformed emission
76
+ // has to become a terminal event, not a TypeError thrown out of push() with
77
+ // the queue half-updated.
78
+ if (!event || typeof event !== "object") return 0;
79
+ let total = 0;
80
+ let nodes = 0;
81
+ const seen = new Set<object>();
82
+ const visit = (value: unknown, depth: number): void => {
83
+ if (typeof value === "string") {
84
+ total += value.length;
85
+ return;
86
+ }
87
+ if (!value || typeof value !== "object" || depth >= RETENTION_MAX_DEPTH || seen.has(value)) return;
88
+ seen.add(value);
89
+ for (const nested of Object.values(value)) {
90
+ if (nodes++ >= RETENTION_MAX_NODES) return;
91
+ visit(nested, depth + 1);
92
+ }
93
+ };
94
+ for (const [key, value] of Object.entries(event)) {
95
+ if (key === "type") continue;
96
+ if (nodes++ >= RETENTION_MAX_NODES) break;
97
+ visit(value, 1);
98
+ }
99
+ return total;
100
+ }
101
+
102
+ function positiveBudget(value: number | undefined, fallback: number, name: string): number {
103
+ const resolved = value ?? fallback;
104
+ if (!Number.isSafeInteger(resolved) || resolved <= 0) {
105
+ throw new RangeError(`${name} must be a positive safe integer`);
106
+ }
107
+ return resolved;
108
+ }
109
+
14
110
  export interface AdapterEventQueue {
15
111
  /**
16
112
  * Returns true when the event was merged into the buffered tail instead of
@@ -22,6 +118,13 @@ export interface AdapterEventQueue {
22
118
  close(): void;
23
119
  stream(): AsyncIterable<AdapterEvent>;
24
120
  collect(): Promise<AdapterEvent[]>;
121
+ /**
122
+ * Retained string payload the queue is currently holding, in UTF-16 code
123
+ * units. Exposed so a caller — and a regression — can assert the counter
124
+ * returns to zero on every terminal path instead of inferring it from an
125
+ * abort that happened to fire.
126
+ */
127
+ retainedCodeUnits(): number;
25
128
  }
26
129
 
27
130
  export interface AdapterEventPreflight {
@@ -73,47 +176,74 @@ export async function preflightAdapterEvents(
73
176
 
74
177
  export function createAdapterEventQueue(opts?: {
75
178
  maxBacklog?: number;
179
+ maxBacklogCodeUnits?: number;
180
+ maxEventCodeUnits?: number;
76
181
  onBacklogExceeded?: () => void;
77
182
  }): AdapterEventQueue {
78
183
  const queued: AdapterEvent[] = [];
184
+ /**
185
+ * What each queued item was charged, in lockstep with `queued`. Releasing the
186
+ * recorded charge rather than re-measuring is what makes the accounting exact
187
+ * on every path: a merge, a terminal record admitted past the budget and a
188
+ * plain event all give back precisely what they took, so the counter cannot
189
+ * drift positive (a leak) or negative (a budget the next turn gets for free).
190
+ */
191
+ const charged: number[] = [];
79
192
  const readers: QueueReader[] = [];
80
193
  const maxBacklog = opts?.maxBacklog ?? 1_024;
194
+ const maxBacklogCodeUnits = positiveBudget(opts?.maxBacklogCodeUnits, DEFAULT_MAX_BACKLOG_CODE_UNITS, "maxBacklogCodeUnits");
195
+ const maxEventCodeUnits = positiveBudget(opts?.maxEventCodeUnits, DEFAULT_MAX_EVENT_CODE_UNITS, "maxEventCodeUnits");
196
+ let retained = 0;
81
197
  let closed = false;
82
198
 
83
199
  // Merge an incoming delta into the buffered tail when no reader is waiting.
84
- // The backlog cap counts events, not tokens, so a detached or briefly
85
- // stalled consumer (e.g. a Codex app mid-reconnect whose disconnect Bun has
86
- // not yet delivered) used to hit the cap within seconds of token-granular
87
- // streaming and abort a healthy turn. Adjacent same-phase text deltas,
88
- // adjacent thinking deltas, and consecutive heartbeats carry no ordering
89
- // information between themselves, so merging them preserves every consumer
90
- // contract while making the cap approximate buffered items again.
200
+ // The event cap counts events, not tokens, so a detached or briefly stalled
201
+ // consumer (e.g. a Codex app mid-reconnect whose disconnect Bun has not yet
202
+ // delivered) used to hit it within seconds of token-granular streaming and
203
+ // abort a healthy turn. Adjacent same-phase text deltas, adjacent thinking
204
+ // deltas, and consecutive heartbeats carry no ordering information between
205
+ // themselves, so merging them preserves every consumer contract while making
206
+ // the cap approximate buffered items again.
91
207
  // Pushed objects may be retained by adapters, so the tail is REPLACED with
92
- // a fresh object — never mutated (alias safety).
93
- const coalesceIntoTail = (event: AdapterEvent): boolean => {
94
- const tail = queued[queued.length - 1];
95
- if (!tail) return false;
208
+ // a fresh object — never mutated (alias safety). Returning the replacement
209
+ // instead of installing it lets push price the merge before committing to it.
210
+ const planTailMerge = (tail: AdapterEvent, event: AdapterEvent): AdapterEvent | null => {
96
211
  if (event.type === "heartbeat") {
97
- if (tail.type !== "heartbeat") return false;
212
+ if (tail.type !== "heartbeat") return null;
98
213
  // Heartbeats carry no ordering between themselves, but the replay-unsafe
99
214
  // marker is not ordering — it is a latch. Dropping the incoming event
100
215
  // would discard the only record that Cursor already performed a local
101
216
  // side effect, and preflight would then permit an OAuth replay of it.
102
217
  if (event.replayUnsafe === true && tail.replayUnsafe !== true) {
103
- queued[queued.length - 1] = { type: "heartbeat", replayUnsafe: true };
218
+ return { type: "heartbeat", replayUnsafe: true };
104
219
  }
105
- return true;
220
+ return tail;
106
221
  }
107
222
  if (event.type === "text_delta" && tail.type === "text_delta" && tail.phase === event.phase) {
108
- if (tail.text.length + event.text.length > COALESCE_MAX_CHUNK_LENGTH) return false;
109
- queued[queued.length - 1] = { type: "text_delta", text: tail.text + event.text, phase: tail.phase };
110
- return true;
223
+ if (tail.text.length + event.text.length > COALESCE_MAX_CHUNK_LENGTH) return null;
224
+ return { type: "text_delta", text: tail.text + event.text, phase: tail.phase };
111
225
  }
112
226
  if (event.type === "thinking_delta" && tail.type === "thinking_delta") {
113
- if (tail.thinking.length + event.thinking.length > COALESCE_MAX_CHUNK_LENGTH) return false;
114
- queued[queued.length - 1] = { type: "thinking_delta", thinking: tail.thinking + event.thinking };
115
- return true;
227
+ if (tail.thinking.length + event.thinking.length > COALESCE_MAX_CHUNK_LENGTH) return null;
228
+ return { type: "thinking_delta", thinking: tail.thinking + event.thinking };
116
229
  }
230
+ return null;
231
+ };
232
+
233
+ /**
234
+ * Record why the turn is ending and close. The terminal error is admitted
235
+ * past both budgets — refusing to retain the explanation of a refusal would
236
+ * leave the consumer with a silent truncation — but it is charged like any
237
+ * other item so the counter stays exact through the final drain.
238
+ */
239
+ const abortWith = (message: string): false => {
240
+ opts?.onBacklogExceeded?.();
241
+ const terminal: AdapterEvent = { type: "error", message };
242
+ const cost = retainedEventCodeUnits(terminal);
243
+ queued.push(terminal);
244
+ charged.push(cost);
245
+ retained += cost;
246
+ close();
117
247
  return false;
118
248
  };
119
249
 
@@ -121,17 +251,35 @@ export function createAdapterEventQueue(opts?: {
121
251
  if (closed) return false;
122
252
  const reader = readers.shift();
123
253
  if (reader) {
254
+ // Handed straight to a waiting consumer, so the queue retains nothing and
255
+ // charges nothing. Neither budget applies to an event it never holds.
124
256
  reader({ done: false, value: event });
125
257
  return false;
126
258
  }
127
- if (coalesceIntoTail(event)) return true;
128
- if (queued.length >= maxBacklog) {
129
- opts?.onBacklogExceeded?.();
130
- queued.push({ type: "error", message: "consumer stalled: adapter event backlog exceeded — turn aborted" });
131
- close();
132
- return false;
259
+ const tail = queued[queued.length - 1];
260
+ const merged = tail ? planTailMerge(tail, event) : null;
261
+ if (merged && tail) {
262
+ const replacement = retainedEventCodeUnits(merged);
263
+ if (replacement > maxEventCodeUnits) return abortWith(EVENT_TOO_LARGE_MESSAGE);
264
+ // Charge only what the backlog actually gains. A merge keeps the tail's
265
+ // own fields, so the incoming event's duplicated phase is never retained
266
+ // twice and an unchanged tail costs nothing at all.
267
+ const delta = replacement - charged[charged.length - 1]!;
268
+ if (delta > maxBacklogCodeUnits - retained) return abortWith(BACKLOG_EXCEEDED_MESSAGE);
269
+ queued[queued.length - 1] = merged;
270
+ charged[charged.length - 1] = replacement;
271
+ retained += delta;
272
+ return true;
133
273
  }
274
+ const cost = retainedEventCodeUnits(event);
275
+ if (cost > maxEventCodeUnits) return abortWith(EVENT_TOO_LARGE_MESSAGE);
276
+ // Both refusals are priced before anything is retained, so an event that is
277
+ // turned away is never charged for.
278
+ if (queued.length >= maxBacklog) return abortWith(BACKLOG_EXCEEDED_MESSAGE);
279
+ if (cost > maxBacklogCodeUnits - retained) return abortWith(BACKLOG_EXCEEDED_MESSAGE);
134
280
  queued.push(event);
281
+ charged.push(cost);
282
+ retained += cost;
135
283
  return false;
136
284
  };
137
285
 
@@ -147,6 +295,7 @@ export function createAdapterEventQueue(opts?: {
147
295
  while (true) {
148
296
  const next = queued.shift();
149
297
  if (next) {
298
+ retained -= charged.shift() ?? 0;
150
299
  yield next;
151
300
  continue;
152
301
  }
@@ -165,5 +314,5 @@ export function createAdapterEventQueue(opts?: {
165
314
  return events;
166
315
  };
167
316
 
168
- return { push, close, stream, collect };
317
+ return { push, close, stream, collect, retainedCodeUnits: () => retained };
169
318
  }
@@ -1,4 +1,5 @@
1
1
  import type { OcxProviderConfig } from "../types";
2
+ import { debugProviderDiagnostic } from "../lib/debug";
2
3
  import { isXaiResponsesDestination } from "../providers/xai-transport";
3
4
 
4
5
  const CODEX_WEB_SEARCH_TOOL = "web_search";
@@ -192,7 +193,21 @@ export function normalizeXaiResponsesWebSearch(
192
193
  if (inputChanged) next = { ...next, input };
193
194
  }
194
195
 
195
- return normalizeToolChoice(next);
196
+ const normalized = normalizeToolChoice(next);
197
+ const choice = normalized.tool_choice;
198
+ if ((choice === "auto" || choice === "none") && !hasAnyDeclaredTool(normalized)) {
199
+ debugProviderDiagnostic("xai", "tool-choice-omitted", { choice });
200
+ const { tool_choice: _toolChoice, ...rest } = normalized;
201
+ // `auto` selects from the catalog, so a catalog with nothing in it makes it meaningless and
202
+ // the omission says nothing the request did not already say. `none` is the opposite: it is a
203
+ // prohibition, and on a request whose catalog this normalizer just emptied it is the only
204
+ // place the turn's client-call boundary is written down. Downstream repair reads that
205
+ // boundary off the final outbound body, so omitting the word alone would hand back a call the
206
+ // caller ruled out. Restate it as the explicit empty catalog, which carries the same deny-all
207
+ // and which this destination already receives whenever a caller sends one itself.
208
+ return choice === "none" && !Array.isArray(rest.tools) ? { ...rest, tools: [] } : rest;
209
+ }
210
+ return normalized;
196
211
  }
197
212
 
198
213
  function isLiveWebSearchTool(tool: unknown): boolean {
@@ -1,9 +1,10 @@
1
1
  import {
2
+ applyReplayRefusalClientHeaders,
2
3
  isNonReplayableUpstreamCode,
3
4
  isReplayRefusalCode,
4
- markReplayRefusalResponse,
5
5
  markResponseNonReplayable,
6
6
  REPLAY_REFUSED_STATUS,
7
+ retainReplayRefusal,
7
8
  } from "../lib/upstream-retry";
8
9
  import {
9
10
  adapterFailureFromMessage,
@@ -45,6 +46,11 @@ export function formatErrorResponse(
45
46
  && retryAfter.length <= 128) {
46
47
  headers.set("Retry-After", retryAfter);
47
48
  }
49
+ // The refusal's client policy, restated here for the same reason its status is: this
50
+ // formatter is the last thing several adapter and combo paths touch before the client,
51
+ // and no wait of its own does not stop a client that retries every 429 by default.
52
+ const refusal = isReplayRefusalCode(error.code) && replayBlocked;
53
+ if (refusal) applyReplayRefusalClientHeaders(headers);
48
54
  const response = new Response(JSON.stringify({ error }), {
49
55
  status: finalStatus,
50
56
  headers,
@@ -53,6 +59,6 @@ export function formatErrorResponse(
53
59
  // Re-wrapping is where the refusal loses its provenance: combo failure consumption parses
54
60
  // the JSON and builds a new Response, and the code alone does not tell a later quota
55
61
  // recorder that no upstream produced this status. Carry the narrower marker across too.
56
- if (replayBlocked && isReplayRefusalCode(error.code)) markReplayRefusalResponse(response);
62
+ if (refusal) retainReplayRefusal(response);
57
63
  return response;
58
64
  }
@@ -7,6 +7,7 @@ import type {
7
7
  OcxUsage,
8
8
  } from "../types";
9
9
  import { coerceIntegerToolArguments } from "../lib/tool-argument-integers";
10
+ import { attemptDeliveryRecorder } from "../usage/attempt-delivery";
10
11
  import {
11
12
  adapterFailureFromMessage,
12
13
  classifyError,
@@ -51,7 +52,14 @@ export function buildResponseJSON(
51
52
  ): Record<string, unknown> {
52
53
  // Default-budget safety net: a caller that omits the budget gets a bounded
53
54
  // default (disposed with the call), never the unbounded append path.
54
- if (options?.translatorBudget) return buildResponseJSONWithBudget(events, modelId, options);
55
+ if (options?.translatorBudget) {
56
+ const body = buildResponseJSONWithBudget(events, modelId, options);
57
+ // A buffered turn delivers its whole answer as one body, so nothing calls the per-frame
58
+ // recorder on the SSE bridge. Without this the attempt would persist adapter events with
59
+ // zero relayed ones, which is the loss signal -- raised on every non-streaming request.
60
+ attemptDeliveryRecorder(options.translatorBudget)?.noteBufferedDelivery(body);
61
+ return body;
62
+ }
55
63
  const budget = createTranslatorBudget();
56
64
  try {
57
65
  return buildResponseJSONWithBudget(events, modelId, { ...options, translatorBudget: budget });