@bitkyc08/opencodex 2.8.0 → 2.9.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 +24 -0
  2. package/bin/ocx.mjs +32 -4
  3. package/gui/dist/assets/index-CHwf3tTD.css +1 -0
  4. package/gui/dist/assets/index-u5eFOv2y.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-image-normalize.ts +114 -20
  8. package/src/adapters/anthropic.ts +126 -10
  9. package/src/adapters/azure.ts +3 -3
  10. package/src/adapters/base.ts +7 -3
  11. package/src/adapters/cursor/discovery.ts +14 -4
  12. package/src/adapters/cursor/effort-map.ts +18 -6
  13. package/src/adapters/cursor/framing.ts +102 -27
  14. package/src/adapters/cursor/kv-store.ts +30 -3
  15. package/src/adapters/cursor/live-models.ts +22 -2
  16. package/src/adapters/cursor/live-transport.ts +245 -49
  17. package/src/adapters/cursor/mcp-manager.ts +105 -8
  18. package/src/adapters/cursor/native-exec-mcp.ts +5 -3
  19. package/src/adapters/cursor/native-exec-shell.ts +296 -14
  20. package/src/adapters/cursor/native-exec.ts +381 -33
  21. package/src/adapters/cursor/protobuf-events.ts +28 -1
  22. package/src/adapters/cursor/protobuf-request.ts +71 -39
  23. package/src/adapters/cursor/request-builder.ts +2 -2
  24. package/src/adapters/cursor/transport.ts +2 -0
  25. package/src/adapters/cursor.ts +13 -2
  26. package/src/adapters/google-antigravity-replay.ts +184 -17
  27. package/src/adapters/google.ts +58 -8
  28. package/src/adapters/kiro-thinking.ts +23 -9
  29. package/src/adapters/kiro-tools.ts +49 -18
  30. package/src/adapters/kiro.ts +377 -133
  31. package/src/adapters/mimo-free.ts +36 -4
  32. package/src/adapters/openai-chat.ts +143 -17
  33. package/src/adapters/openai-responses.ts +130 -14
  34. package/src/adapters/run-turn-queue.ts +7 -1
  35. package/src/bridge.ts +466 -69
  36. package/src/chat/outbound.ts +144 -38
  37. package/src/claude/inbound-debug.ts +53 -8
  38. package/src/claude/outbound.ts +224 -38
  39. package/src/cli/agent-driven.ts +34 -1
  40. package/src/cli/catalog-prewarm.ts +5 -2
  41. package/src/cli/claude-desktop.ts +2 -2
  42. package/src/cli/doctor.ts +12 -0
  43. package/src/cli/export-command.ts +187 -0
  44. package/src/cli/help.ts +11 -0
  45. package/src/cli/index.ts +13 -3
  46. package/src/cli/init.ts +129 -102
  47. package/src/cli/opencode.ts +36 -151
  48. package/src/cli/star-prompt.ts +13 -4
  49. package/src/cli/status-oauth.ts +12 -2
  50. package/src/clients/config-export.ts +377 -0
  51. package/src/codex/account-runtime-state.ts +19 -1
  52. package/src/codex/account-store.ts +162 -82
  53. package/src/codex/auth-api.ts +467 -159
  54. package/src/codex/auth-context.ts +15 -2
  55. package/src/codex/catalog/aggregation.ts +15 -0
  56. package/src/codex/catalog/effort.ts +16 -6
  57. package/src/codex/catalog/metadata.ts +6 -0
  58. package/src/codex/catalog/parsing.ts +3 -1
  59. package/src/codex/catalog/provider-fetch.ts +29 -0
  60. package/src/codex/catalog/sync.ts +64 -7
  61. package/src/codex/catalog.ts +2 -2
  62. package/src/codex/inject.ts +5 -5
  63. package/src/codex/main-account-cache.ts +8 -1
  64. package/src/codex/model-cache.ts +81 -2
  65. package/src/codex/pool-rotation.ts +39 -0
  66. package/src/codex/project-config-warnings.ts +12 -1
  67. package/src/codex/quota.ts +35 -3
  68. package/src/codex/routing.ts +46 -1
  69. package/src/codex/shim.ts +10 -4
  70. package/src/codex/subagent-model-fallback.ts +12 -0
  71. package/src/codex/websocket-registry.ts +27 -0
  72. package/src/combos/failover.ts +31 -1
  73. package/src/combos/request.ts +9 -0
  74. package/src/combos/resolve.ts +60 -4
  75. package/src/combos/types.ts +12 -0
  76. package/src/config.ts +510 -55
  77. package/src/github/star-state.ts +13 -1
  78. package/src/images/fulfill.ts +39 -1
  79. package/src/images/loop.ts +52 -12
  80. package/src/lib/admission.ts +83 -0
  81. package/src/lib/app-owned-memory-stores.ts +173 -0
  82. package/src/lib/app-owned-memory.ts +265 -0
  83. package/src/lib/bun-stream-caps.ts +31 -7
  84. package/src/lib/config-ownership.ts +33 -0
  85. package/src/lib/crash-guard.ts +65 -5
  86. package/src/lib/debug-log-buffer.ts +47 -6
  87. package/src/lib/destination-policy.ts +12 -1
  88. package/src/lib/errors.ts +3 -0
  89. package/src/lib/gcp-adc.ts +40 -2
  90. package/src/lib/injection-debug-log.ts +26 -2
  91. package/src/lib/provider-outbound.ts +3 -0
  92. package/src/lib/sidecar-tracker.ts +5 -2
  93. package/src/lib/sse-decoder.ts +257 -37
  94. package/src/lib/state-store-registrations.ts +109 -0
  95. package/src/lib/state-store-sweeper.ts +184 -0
  96. package/src/lib/translator-budget.ts +356 -0
  97. package/src/lib/windows-secret-acl.ts +33 -12
  98. package/src/lib/winsw.ts +14 -1
  99. package/src/oauth/anthropic-routing.ts +31 -7
  100. package/src/oauth/google-antigravity.ts +2 -1
  101. package/src/oauth/health.ts +30 -12
  102. package/src/oauth/index.ts +127 -23
  103. package/src/oauth/kiro-credentials.ts +72 -1
  104. package/src/oauth/kiro.ts +23 -4
  105. package/src/oauth/store.ts +165 -18
  106. package/src/oauth/token-guardian.ts +43 -4
  107. package/src/oauth/types.ts +2 -1
  108. package/src/providers/base-url-choices.ts +10 -0
  109. package/src/providers/derive.ts +12 -0
  110. package/src/providers/free-directory.ts +4 -1
  111. package/src/providers/key-failover.ts +12 -0
  112. package/src/providers/openai-sidecar.ts +4 -1
  113. package/src/providers/quota.ts +68 -7
  114. package/src/providers/registry.ts +279 -3
  115. package/src/responses/parser.ts +5 -1
  116. package/src/responses/spill-store.ts +394 -0
  117. package/src/responses/state.ts +520 -102
  118. package/src/router.ts +18 -1
  119. package/src/server/adapter-resolve.ts +20 -3
  120. package/src/server/auth-cors.ts +121 -28
  121. package/src/server/chat-completions.ts +57 -12
  122. package/src/server/claude-messages.ts +85 -13
  123. package/src/server/index.ts +242 -100
  124. package/src/server/lifecycle.ts +155 -25
  125. package/src/server/management/agent-settings-routes.ts +79 -36
  126. package/src/server/management/api-key-usage.ts +167 -0
  127. package/src/server/management/body.ts +35 -0
  128. package/src/server/management/combo-routes.ts +5 -1
  129. package/src/server/management/config-routes.ts +42 -12
  130. package/src/server/management/logs-usage-routes.ts +41 -21
  131. package/src/server/management/model-routes.ts +188 -54
  132. package/src/server/management/oauth-account-routes.ts +115 -26
  133. package/src/server/management/provider-routes.ts +56 -6
  134. package/src/server/management/shared.ts +16 -3
  135. package/src/server/management/sidebar-routes.ts +50 -1
  136. package/src/server/management/system-restart.ts +13 -6
  137. package/src/server/management/system-routes.ts +15 -3
  138. package/src/server/management/usage-summary-cache.ts +86 -0
  139. package/src/server/management-api.ts +39 -5
  140. package/src/server/management-auth.ts +65 -14
  141. package/src/server/port-reclaim.ts +58 -12
  142. package/src/server/ports.ts +2 -0
  143. package/src/server/proxy-liveness.ts +60 -14
  144. package/src/server/relay-eager.ts +20 -4
  145. package/src/server/relay.ts +548 -154
  146. package/src/server/request-decompress.ts +51 -4
  147. package/src/server/request-log.ts +134 -15
  148. package/src/server/responses/collaboration.ts +15 -4
  149. package/src/server/responses/compact.ts +3 -0
  150. package/src/server/responses/core.ts +241 -66
  151. package/src/server/responses-image-gen-repair.ts +19 -5
  152. package/src/server/responses-item-id-repair.ts +23 -5
  153. package/src/server/sse-payload-rewrite.ts +71 -12
  154. package/src/server/startup-health-cache.ts +14 -1
  155. package/src/server/system-env.ts +8 -1
  156. package/src/server/windows-tcp-drop.ts +15 -5
  157. package/src/server/ws-bridge.ts +25 -0
  158. package/src/service.ts +179 -13
  159. package/src/storage/policy-job.ts +93 -23
  160. package/src/storage/policy-worker.ts +6 -0
  161. package/src/storage/restore-job.ts +62 -16
  162. package/src/storage/restore-worker.ts +6 -0
  163. package/src/storage/storage-mutation-coordinator.ts +36 -6
  164. package/src/storage/worker-lifecycle.ts +181 -47
  165. package/src/tray/windows.ts +97 -25
  166. package/src/types.ts +39 -6
  167. package/src/update/index.ts +24 -5
  168. package/src/update/job.ts +598 -73
  169. package/src/usage/log.ts +115 -17
  170. package/src/usage/summary.ts +67 -2
  171. package/src/vision/index.ts +112 -22
  172. package/src/web-search/loop.ts +38 -6
  173. package/src/web-search/progress-stream.ts +14 -3
  174. package/gui/dist/assets/index-BDjpkcRN.js +0 -67
  175. package/gui/dist/assets/index-BHsKRFh9.css +0 -1
@@ -5,7 +5,7 @@ import { getConfigDir } from "../config";
5
5
  import { recordOwnedConfigPath } from "../lib/config-ownership";
6
6
  import type { OcxProviderConfig, OcxParsedRequest } from "../types";
7
7
  import { createOpenAIChatAdapter } from "./openai-chat";
8
- import type { ProviderAdapter, AdapterRequest } from "./base";
8
+ import type { ProviderAdapter, AdapterRequest, IncomingMeta } from "./base";
9
9
 
10
10
  const BOOTSTRAP_URL = "https://api.xiaomimimo.com/api/free-ai/bootstrap";
11
11
  export const MIMO_CHAT_URL = "https://api.xiaomimimo.com/api/free-ai/openai/chat";
@@ -27,6 +27,8 @@ const USER_AGENTS = [
27
27
  const JWT_FALLBACK_TTL_MS = 3_000_000; // 50 min
28
28
  const JWT_EXPIRY_BUFFER_MS = 300_000; // 5 min early refresh
29
29
  const BOOTSTRAP_TIMEOUT_MS = 15_000;
30
+ const MIMO_BOOTSTRAP_MAX_BYTES = 128 * 1024;
31
+ const MIMO_JWT_MAX_BYTES = 64 * 1024;
30
32
 
31
33
  // In-process JWT cache -- survives across requests, reset on restart.
32
34
  let cachedJwt: string | null = null;
@@ -107,8 +109,38 @@ async function fetchJwt(signal?: AbortSignal): Promise<string> {
107
109
  try { await response.body?.cancel(); } catch { /* already consumed */ }
108
110
  throw new Error(`MiMo bootstrap failed: ${response.status}`);
109
111
  }
110
- const data = await response.json() as { jwt?: string };
112
+ const announced = Number(response.headers.get("content-length") ?? 0);
113
+ if (Number.isFinite(announced) && announced > MIMO_BOOTSTRAP_MAX_BYTES) {
114
+ await response.body?.cancel().catch(() => {});
115
+ throw new Error("MiMo bootstrap response too large");
116
+ }
117
+ const reader = response.body?.getReader();
118
+ const chunks: Uint8Array[] = [];
119
+ let bytes = 0;
120
+ if (reader) {
121
+ while (true) {
122
+ const { done, value } = await reader.read();
123
+ if (done) break;
124
+ if (!value) continue;
125
+ bytes += value.byteLength;
126
+ if (bytes > MIMO_BOOTSTRAP_MAX_BYTES) {
127
+ await reader.cancel().catch(() => {});
128
+ throw new Error("MiMo bootstrap response too large");
129
+ }
130
+ chunks.push(value);
131
+ }
132
+ }
133
+ const body = new Uint8Array(bytes);
134
+ let offset = 0;
135
+ for (const chunk of chunks) {
136
+ body.set(chunk, offset);
137
+ offset += chunk.byteLength;
138
+ }
139
+ const data = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body)) as { jwt?: string };
111
140
  if (!data.jwt) throw new Error("MiMo bootstrap returned no JWT");
141
+ if (new TextEncoder().encode(data.jwt).byteLength > MIMO_JWT_MAX_BYTES) {
142
+ throw new Error("MiMo bootstrap response too large");
143
+ }
112
144
  return data.jwt;
113
145
  }
114
146
 
@@ -170,12 +202,12 @@ export function createMimoFreeAdapter(provider: OcxProviderConfig): ProviderAdap
170
202
  ...base,
171
203
  name: "mimo-free",
172
204
 
173
- async buildRequest(parsed: OcxParsedRequest): Promise<AdapterRequest> {
205
+ async buildRequest(parsed: OcxParsedRequest, incoming: IncomingMeta): Promise<AdapterRequest> {
174
206
  const jwt = await getMimoJwt();
175
207
 
176
208
  // Let the base adapter build the wire body (handles reasoning, tools, etc.)
177
209
  // but override the URL and headers after.
178
- const baseReq = base.buildRequest(parsed) as AdapterRequest;
210
+ const baseReq = base.buildRequest(parsed, incoming) as AdapterRequest;
179
211
  const baseBody = JSON.parse(baseReq.body as string) as unknown;
180
212
  const markedBody = injectMimoSystemMarker(baseBody);
181
213
 
@@ -10,6 +10,12 @@ import { contentPartsToText } from "./image";
10
10
  import { neutralizeIdentity } from "./identity";
11
11
  import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge";
12
12
  import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing";
13
+ import {
14
+ isTranslatorBudgetExceededError,
15
+ retainTranslatedEventBatch,
16
+ TRANSLATOR_MAX_SSE_EVENT_BYTES,
17
+ type TranslatorBudget,
18
+ } from "../lib/translator-budget";
13
19
 
14
20
  // Providers may opt into stripping one trailing "[...]" group from the wire model id.
15
21
  // Z.AI needs this because its OpenAI path rejects glm-5.2[1m] with 400 code 1211;
@@ -206,10 +212,10 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
206
212
  }));
207
213
  // "" instead of null: strict validators (xAI: "Each message must have at least one
208
214
  // content element", langchain#34140) reject content-less assistant history entries.
209
- if (!chatMsg.content) chatMsg.content = "";
215
+ if (!chatMsg.content) chatMsg.content = emptyAssistantContent(provider);
210
216
  }
211
217
  if (chatMsg.reasoning_content !== undefined && chatMsg.content === undefined && chatMsg.tool_calls === undefined) {
212
- chatMsg.content = "";
218
+ chatMsg.content = emptyAssistantContent(provider);
213
219
  }
214
220
  out.push(chatMsg);
215
221
  pendingToolCalls = wireToolCalls.map(({ tc, id }) => ({ id, name: namespacedToolName(tc.namespace, tc.name) }));
@@ -238,7 +244,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
238
244
  const name = safeToolName(msg.toolName);
239
245
  out.push({
240
246
  role: "assistant",
241
- content: "",
247
+ content: emptyAssistantContent(provider),
242
248
  tool_calls: [{
243
249
  id: toolCallId,
244
250
  type: "function",
@@ -372,6 +378,43 @@ function isKimiSchemaTarget(provider: OcxProviderConfig): boolean {
372
378
  }
373
379
  }
374
380
 
381
+ // Volcengine Ark regional endpoints. Ark validates an assistant message's text field as a
382
+ // REQUIRED parameter and treats "" as absent, so a tool-call-only assistant in history 400s with
383
+ // `MissingParameter: input.content.text` (#796). Every other OpenAI-compatible provider accepts
384
+ // "", and xAI actively requires it ("Each message must have at least one content element"), so
385
+ // the two contracts are in direct conflict and this cannot be a global change.
386
+ const VOLCENGINE_ARK_HOSTNAMES = new Set([
387
+ "ark.cn-beijing.volces.com",
388
+ "ark.ap-southeast.volces.com",
389
+ ]);
390
+
391
+ function isVolcengineArkTarget(provider: OcxProviderConfig): boolean {
392
+ try {
393
+ return VOLCENGINE_ARK_HOSTNAMES.has(new URL(provider.baseUrl).hostname);
394
+ } catch {
395
+ return false;
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Placeholder content for an assistant history entry carrying only tool calls or reasoning.
401
+ *
402
+ * UNVERIFIED HYPOTHESIS for Ark. The reported error names `input.content.text`, a nested path,
403
+ * which suggests Ark wants the structured content form `[{type:"text",text:""}]` rather than a
404
+ * bare string — no string value, `""` or `" "`, exposes a `content.text` path at all. But Ark's
405
+ * published examples only show array content for MULTIMODAL USER input, never for an assistant
406
+ * history entry, so this shape is inferred from the error message and not confirmed by the docs
407
+ * or by a live request. The empty inner text at least adds no tokens either way.
408
+ *
409
+ * Confirm against a real Ark endpoint before relying on this; #796 records what is still missing.
410
+ *
411
+ * Every other provider keeps the bare `""`, which xAI's validator specifically requires ("Each
412
+ * message must have at least one content element"), so this cannot be applied globally.
413
+ */
414
+ function emptyAssistantContent(provider: OcxProviderConfig): string | { type: "text"; text: string }[] {
415
+ return isVolcengineArkTarget(provider) ? [{ type: "text", text: "" }] : "";
416
+ }
417
+
375
418
  /**
376
419
  * Kimi requires function.parameters.type to be exactly "object" at the root.
377
420
  * Codex tools with oneOf/anyOf schemas omit the root type, causing 400 errors.
@@ -639,7 +682,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
639
682
  };
640
683
  },
641
684
 
642
- async *parseStream(response: Response): AsyncGenerator<AdapterEvent> {
685
+ async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator<AdapterEvent> {
643
686
  if (!response.body) {
644
687
  yield { type: "error", message: "No response body" };
645
688
  return;
@@ -647,7 +690,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
647
690
 
648
691
  const reader = response.body.getReader();
649
692
  const decoder = new TextDecoder();
693
+ const budgetEncoder = new TextEncoder();
650
694
  let buffer = "";
695
+ let bufferBytes = 0;
651
696
  // Streamed tool calls are BUFFERED until a terminal signal, then flushed as atomic
652
697
  // start/delta/end sequences. The bridge treats text/reasoning deltas as barriers that
653
698
  // close an open tool-call item (bridge.ts closeCurrentToolCall on text_delta), so
@@ -656,15 +701,18 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
656
701
  // chunks, whole-chunk calls) cannot be represented live without overlapping sequences.
657
702
  // Keyed by `index` (OpenAI wire standard), falling back to `id`, falling back to the
658
703
  // last-seen call for providers that omit both on continuation chunks.
659
- interface PendingToolCall { key: string; id: string; name: string; args: string }
704
+ interface PendingToolCall { key: string; id: string; name: string; args: string; argsBytes: number }
660
705
  const pendingToolCalls: PendingToolCall[] = [];
661
706
  let toolCallSeq = 0;
662
707
  const flushToolCalls = function* (): Generator<AdapterEvent> {
708
+ // Do not treat flushed tool calls as user-facing output for the finish-less EOF
709
+ // fallback — incomplete tool args must stay on the truncation path.
663
710
  for (const call of pendingToolCalls) {
664
711
  if (!call.id) call.id = `call_${++toolCallSeq}`;
665
712
  yield { type: "tool_call_start", id: call.id, name: call.name };
666
713
  if (call.args.length > 0) yield { type: "tool_call_delta", arguments: call.args };
667
714
  yield { type: "tool_call_end" };
715
+ budget.closeCall(call.key);
668
716
  }
669
717
  pendingToolCalls.length = 0;
670
718
  };
@@ -674,6 +722,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
674
722
  // explicit `[DONE]` sentinel OR a chunk carrying a non-null `finish_reason` (some
675
723
  // OpenAI-compatible providers omit `[DONE]` but do send finish_reason).
676
724
  let finishReason: string | undefined;
725
+ // Only answer text enables the finish-less EOF fallback. Reasoning-only streams can be
726
+ // suppressed by hideThinkingSummary and must not complete as empty successful turns.
727
+ let sawUserFacingOutput = false;
677
728
 
678
729
  // Single per-line handler shared by the streaming loop and the EOF residual-frame flush, so
679
730
  // a final frame is parsed identically wherever it lands (no duplicated, drift-prone parsing).
@@ -743,6 +794,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
743
794
  yield { type: "reasoning_raw_delta", text: delta.reasoning_content };
744
795
  }
745
796
  if (typeof delta.content === "string" && delta.content.length > 0) {
797
+ sawUserFacingOutput = true;
746
798
  yield { type: "text_delta", text: delta.content };
747
799
  }
748
800
 
@@ -760,12 +812,27 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
760
812
  // splitting into two calls that share one call_id downstream.
761
813
  if (!call && tc.id) call = pendingToolCalls.find(c => c.id === tc.id);
762
814
  if (!call) {
763
- call = { key: key ?? `seq:${pendingToolCalls.length}`, id: "", name: "", args: "" };
815
+ call = { key: key ?? `seq:${pendingToolCalls.length}`, id: "", name: "", args: "", argsBytes: 0 };
764
816
  pendingToolCalls.push(call);
817
+ budget.openCall(call.key);
765
818
  }
766
819
  if (tc.id && !call.id) call.id = tc.id;
767
820
  if (tc.function?.name && !call.name) call.name = tc.function.name;
768
- if (tc.function?.arguments) call.args += tc.function.arguments;
821
+ if (tc.function?.arguments) {
822
+ const previousBytes = call.argsBytes;
823
+ const nextBytes = previousBytes + budgetEncoder.encode(tc.function.arguments).byteLength;
824
+ const scope = { kind: "tool_args" as const, callId: call.key };
825
+ const reservation = budget.reserveTransient(nextBytes, scope);
826
+ try {
827
+ call.args += tc.function.arguments;
828
+ reservation.commitRetained();
829
+ budget.releaseRetained(previousBytes, scope);
830
+ call.argsBytes = nextBytes;
831
+ } catch (error) {
832
+ reservation.release();
833
+ throw error;
834
+ }
835
+ }
769
836
  }
770
837
  }
771
838
  }
@@ -782,10 +849,31 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
782
849
  while (true) {
783
850
  const { done, value } = await reader.read();
784
851
  if (done) break;
785
- buffer += decoder.decode(value, { stream: true });
852
+ const decoded = decoder.decode(value, { stream: true });
853
+ const nextBufferBytes = bufferBytes + budgetEncoder.encode(decoded).byteLength;
854
+ if (nextBufferBytes > TRANSLATOR_MAX_SSE_EVENT_BYTES) {
855
+ throw new Error(`translation SSE event exceeded ${TRANSLATOR_MAX_SSE_EVENT_BYTES} bytes`, {
856
+ cause: { code: "translation_buffer_limit" },
857
+ });
858
+ }
859
+ const appendReservation = budget.reserveTransient(nextBufferBytes, { kind: "live_transient" });
860
+ try {
861
+ buffer += decoded;
862
+ appendReservation.commitRetained();
863
+ budget.releaseRetained(bufferBytes, { kind: "live_transient" });
864
+ } catch (error) {
865
+ appendReservation.release();
866
+ throw error;
867
+ }
868
+ bufferBytes = nextBufferBytes;
786
869
 
787
870
  const lines = buffer.split("\n");
788
871
  buffer = lines.pop() ?? "";
872
+ const residualBytes = budgetEncoder.encode(buffer).byteLength;
873
+ const residualReservation = budget.reserveTransient(residualBytes, { kind: "live_transient" });
874
+ residualReservation.commitRetained();
875
+ budget.releaseRetained(bufferBytes, { kind: "live_transient" });
876
+ bufferBytes = residualBytes;
789
877
 
790
878
  for (const line of lines) {
791
879
  if ((yield* handleDataLine(line)) === "terminate") return;
@@ -800,34 +888,68 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
800
888
  if (buffer.length > 0) {
801
889
  if ((yield* handleDataLine(buffer)) === "terminate") return;
802
890
  }
803
- yield* flushToolCalls();
804
- // Reader EOF. A graceful close shows at least one terminal signal: `[DONE]` (returns above),
805
- // a non-null finish_reason (sawFinish), or a trailing usage chunk (providers emit usage only
806
- // at end-of-generation). If NONE of those were seen, the stream was cut mid-flight — fail
807
- // closed so the bridge emits a classified response.failed rather than a silent truncation.
891
+ // Reader EOF. Prefer failing closed before flushing pending tool calls so the bridge
892
+ // never sees a fabricated tool_call_end on a truncated mid-assembly stream.
893
+ //
894
+ // Checked BEFORE flushToolCalls(), because that helper emits tool_call_end and there is no
895
+ // taking it back: a half-assembled argument string would reach the client as a completed
896
+ // call. Tool calls are buffered here (unlike the Anthropic adapter, which forwards
897
+ // fragments live), so this adapter can still decide.
808
898
  const sawFinish = finishReason !== undefined;
809
- if (!sawFinish && pendingUsage === undefined) {
899
+ if (!sawFinish && pendingToolCalls.length > 0) {
900
+ debugProviderDiagnostic("openai-chat", "stream-truncated", {
901
+ finishReason: null,
902
+ hadUsage: pendingUsage !== undefined,
903
+ pendingToolCalls: pendingToolCalls.length,
904
+ });
905
+ yield { type: "error", message: "upstream stream ended mid tool call without a terminal signal — possible truncation" };
906
+ return;
907
+ }
908
+ // Finish-less EOF is only safe when answer text was emitted. Reasoning-only / usage-only
909
+ // truncations must stay on the error path (hideThinkingSummary can suppress reasoning).
910
+ // Trailing usage alone is not a terminal signal for this adapter (#735 / restore #773).
911
+ if (!sawFinish && !sawUserFacingOutput) {
810
912
  debugProviderDiagnostic("openai-chat", "stream-truncated", {
811
913
  finishReason: finishReason ?? null,
812
- hadUsage: false,
914
+ hadUsage: pendingUsage !== undefined,
813
915
  });
814
916
  yield { type: "error", message: "upstream stream ended without a terminal signal ([DONE] or finish_reason) — possible truncation" };
815
917
  return;
816
918
  }
817
- // Graceful close that omitted [DONE] but delivered finish_reason and/or final usage.
919
+ yield* flushToolCalls();
920
+ // Graceful close that omitted [DONE] but delivered finish_reason and/or answer text.
818
921
  const stopReason = finishReason === "length"
819
922
  ? "max_tokens"
820
923
  : finishReason === "content_filter"
821
924
  ? "content_filter"
822
925
  : undefined;
823
926
  yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) };
927
+ } catch (error) {
928
+ if (isTranslatorBudgetExceededError(error)
929
+ || (error instanceof Error && (error.cause as { code?: unknown } | undefined)?.code === "translation_buffer_limit")) {
930
+ yield {
931
+ type: "error",
932
+ status: 502,
933
+ errorType: "upstream_error",
934
+ code: "translation_buffer_limit",
935
+ message: "upstream translation buffer exceeded the safe limit",
936
+ };
937
+ try { await reader.cancel(error); } catch { /* already closed */ }
938
+ return;
939
+ }
940
+ throw error;
824
941
  } finally {
942
+ budget.releaseRetained(bufferBytes, { kind: "live_transient" });
943
+ for (const call of pendingToolCalls) budget.closeCall(call.key);
825
944
  reader.releaseLock();
826
945
  }
827
946
  },
828
947
 
829
- async parseResponse(response: Response): Promise<AdapterEvent[]> {
948
+ async parseResponse(response: Response, budget: TranslatorBudget): Promise<AdapterEvent[]> {
830
949
  const json = await response.json() as Record<string, unknown>;
950
+ const responseBytes = new TextEncoder().encode(JSON.stringify(json)).byteLength;
951
+ budget.chargeRetained(responseBytes, { kind: "retained_collectors" });
952
+ try {
831
953
  if (json.error) {
832
954
  const upstreamError = json.error as { message?: unknown; code?: unknown; type?: unknown; status?: unknown };
833
955
  const message = typeof upstreamError.message === "string" ? upstreamError.message : "upstream error";
@@ -873,7 +995,11 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
873
995
  type: "done",
874
996
  usage: usageFromOpenAIChat(usage),
875
997
  });
998
+ retainTranslatedEventBatch(events, budget);
876
999
  return events;
1000
+ } finally {
1001
+ budget.releaseRetained(responseBytes, { kind: "retained_collectors" });
1002
+ }
877
1003
  },
878
1004
  };
879
1005
  }
@@ -8,6 +8,7 @@ import { decodeServerSentEvents } from "../lib/sse-decoder";
8
8
  import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
9
9
  import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope";
10
10
  import { modelRecordValue } from "../reasoning-effort";
11
+ import type { TranslatorBudget } from "../lib/translator-budget";
11
12
 
12
13
  // Headers relayed verbatim from the caller in OAuth-passthrough ("forward") mode.
13
14
  // Exported so the web-search sidecar reuses the exact same forwarded-auth set for its ChatGPT call.
@@ -354,6 +355,47 @@ function isPlainObject(v: unknown): v is Record<string, unknown> {
354
355
  return !!v && typeof v === "object" && !Array.isArray(v);
355
356
  }
356
357
 
358
+ function normalizeFunctionToolSchema(tool: unknown): unknown {
359
+ if (!isPlainObject(tool) || tool.type !== "function") return tool;
360
+ if (isPlainObject(tool.parameters) && tool.parameters.type === "object") return tool;
361
+ return {
362
+ ...tool,
363
+ parameters: { ...(isPlainObject(tool.parameters) ? tool.parameters : {}), type: "object" },
364
+ };
365
+ }
366
+
367
+ function normalizeToolSchemas(body: unknown): unknown {
368
+ if (!isPlainObject(body)) return body;
369
+
370
+ const normalizeTools = (tools: unknown[]): unknown[] => {
371
+ let changed = false;
372
+ const normalized = tools.map((tool) => {
373
+ const fixed = normalizeFunctionToolSchema(tool);
374
+ if (fixed !== tool) changed = true;
375
+ return fixed;
376
+ });
377
+ return changed ? normalized : tools;
378
+ };
379
+
380
+ let normalizedBody = body;
381
+ if (Array.isArray(body.tools)) {
382
+ const tools = normalizeTools(body.tools);
383
+ if (tools !== body.tools) normalizedBody = { ...normalizedBody, tools };
384
+ }
385
+ if (Array.isArray(normalizedBody.input)) {
386
+ let inputChanged = false;
387
+ const input = normalizedBody.input.map((item) => {
388
+ if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item;
389
+ const tools = normalizeTools(item.tools);
390
+ if (tools === item.tools) return item;
391
+ inputChanged = true;
392
+ return { ...item, tools };
393
+ });
394
+ if (inputChanged) normalizedBody = { ...normalizedBody, input };
395
+ }
396
+ return normalizedBody;
397
+ }
398
+
357
399
  const MAX_RESPONSES_CALL_ID_LENGTH = 64;
358
400
  const REPAIRED_CALL_ID_PREFIX = "call_ocx_";
359
401
  const REPAIRED_CALL_ID_DIGEST_LENGTH = MAX_RESPONSES_CALL_ID_LENGTH - REPAIRED_CALL_ID_PREFIX.length;
@@ -484,6 +526,40 @@ function stripPreviousResponseId(body: unknown, strip: boolean): unknown {
484
526
  return rest;
485
527
  }
486
528
 
529
+ /**
530
+ * Drop request parameters a stateless Responses upstream cannot implement, and pin
531
+ * `store` false.
532
+ *
533
+ * `previous_response_id` is listed here as well as in `stripPreviousResponseId`
534
+ * because that helper's strip is conditional on replay expansion, and it keeps the
535
+ * field for API-key providers on the premise that the platform offers real
536
+ * server-side storage. DeepSeek documents the opposite: "the API is stateless:
537
+ * responses and conversations are not stored on the server", so the field can never
538
+ * be honoured regardless of expansion state.
539
+ *
540
+ * `prompt` is a reference to a server-stored prompt template — the most stateful
541
+ * field in the accepted schema.
542
+ *
543
+ * `service_tier` is deliberately NOT dropped: the server writes it for fast mode
544
+ * (`responses/core.ts`), and silently deleting a configured knob inside an adapter is
545
+ * worse than forwarding a parameter the upstream ignores.
546
+ *
547
+ * MUST run before the composed sanitize chain below: `stripItemIdsWhenUnstored` keys
548
+ * off `store === false`, and a stateless upstream cannot resolve a stored item id.
549
+ * Returns a copy, so `parsed._rawBody` keeps the client's original `store` value and
550
+ * the local replay cache still records the turn.
551
+ */
552
+ function stripStatefulResponsesParams(body: unknown): unknown {
553
+ if (!isPlainObject(body)) return body;
554
+ const drop = ["previous_response_id", "conversation", "background", "metadata", "prompt"] as const;
555
+ const present = drop.some(key => Object.prototype.hasOwnProperty.call(body, key));
556
+ if (!present && body.store === false) return body;
557
+ const next: Record<string, unknown> = { ...body };
558
+ for (const key of drop) delete next[key];
559
+ next.store = false;
560
+ return next;
561
+ }
562
+
487
563
  /**
488
564
  * Remove top-level parameters the ChatGPT backend (`authMode: "forward"`) rejects
489
565
  * with `{"detail":"Unsupported parameter: …"}` (strict allowlist). Codex CLI never
@@ -881,7 +957,8 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
881
957
  name: "openai-responses",
882
958
  passthrough: true as const,
883
959
 
884
- buildRequest(parsed: OcxParsedRequest, incoming?: IncomingMeta) {
960
+ buildRequest(parsed: OcxParsedRequest, incoming: IncomingMeta) {
961
+ const translatorBudget = incoming.translatorBudget;
885
962
  const headers: Record<string, string> = { "Content-Type": "application/json" };
886
963
  let url: string;
887
964
 
@@ -923,8 +1000,17 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
923
1000
  parsed._rawBody,
924
1001
  forward || parsed._previousResponseInputExpanded === true,
925
1002
  );
926
- if (forward) {
1003
+ const stateless = provider.statelessResponses === true;
1004
+ if (stateless) outBody = stripStatefulResponsesParams(outBody);
1005
+ // A replay miss can leave a function_call_output whose paired function_call sat
1006
+ // in the prefix that was never expanded. A stateless upstream cannot resolve the
1007
+ // pair from its own storage either, so it needs the same repair the forward
1008
+ // backend gets — dropping previous_response_id is not much use if the body that
1009
+ // reaches the wire is unparseable.
1010
+ if (forward || stateless) {
927
1011
  outBody = repairOrphanedInputItems(outBody, unexpandedMiss);
1012
+ }
1013
+ if (forward) {
928
1014
  outBody = stripUnsupportedForwardParams(outBody);
929
1015
  }
930
1016
  else outBody = normalizeImageGenClientTools(outBody);
@@ -938,41 +1024,62 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
938
1024
  if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) {
939
1025
  outBody = buildRoutedCompactionBody(outBody);
940
1026
  }
941
- const sanitizedBody = stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody)))))));
1027
+ const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody))))))));
1028
+ const body = JSON.stringify(stripDisabledReasoningSummaries(
1029
+ normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId),
1030
+ provider,
1031
+ parsed.modelId,
1032
+ ));
1033
+ const releaseBodyObservation = translatorBudget.observeExternallyCapped(
1034
+ "passthrough_serialization",
1035
+ new TextEncoder().encode(body).byteLength,
1036
+ );
942
1037
  return {
943
1038
  url,
944
1039
  method: "POST",
945
1040
  headers,
946
- body: JSON.stringify(stripDisabledReasoningSummaries(
947
- normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId),
948
- provider,
949
- parsed.modelId,
950
- )),
1041
+ body,
1042
+ releaseBodyObservation,
951
1043
  };
952
1044
  },
953
1045
 
954
1046
  // The passthrough normally relays the upstream stream verbatim and never parses.
955
1047
  // The exception is a routed compaction turn: the server drives this adapter like
956
1048
  // an ordinary one so the bridge can build the single compaction item (#422).
957
- async *parseStream(response: Response): AsyncGenerator<AdapterEvent> {
1049
+ async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator<AdapterEvent> {
958
1050
  if (!response.body) {
959
1051
  yield { type: "error", message: "passthrough adapter received no response body" };
960
1052
  return;
961
1053
  }
1054
+ const budgetEncoder = new TextEncoder();
962
1055
  let deltas = "";
963
1056
  let doneText = "";
964
1057
  let snapshot = "";
965
1058
  let usage: OcxUsage | undefined;
966
- for await (const event of decodeServerSentEvents(response.body)) {
1059
+ for await (const event of decodeServerSentEvents(response.body, { translatorBudget: budget })) {
967
1060
  let payload: unknown;
968
1061
  try { payload = JSON.parse(event.data); } catch { continue; }
969
1062
  if (!isPlainObject(payload)) continue;
970
1063
  switch (payload.type) {
971
1064
  case "response.output_text.delta":
972
- if (typeof payload.delta === "string") deltas += payload.delta;
1065
+ if (typeof payload.delta === "string") {
1066
+ const next = deltas + payload.delta;
1067
+ const previousBytes = budgetEncoder.encode(deltas).byteLength;
1068
+ const reservation = budget.reserveTransient(budgetEncoder.encode(next).byteLength, { kind: "retained_collectors" });
1069
+ deltas = next;
1070
+ reservation.commitRetained();
1071
+ budget.releaseRetained(previousBytes, { kind: "retained_collectors" });
1072
+ }
973
1073
  break;
974
1074
  case "response.output_text.done":
975
- if (typeof payload.text === "string") doneText += payload.text;
1075
+ if (typeof payload.text === "string") {
1076
+ const next = doneText + payload.text;
1077
+ const previousBytes = budgetEncoder.encode(doneText).byteLength;
1078
+ const reservation = budget.reserveTransient(budgetEncoder.encode(next).byteLength, { kind: "retained_collectors" });
1079
+ doneText = next;
1080
+ reservation.commitRetained();
1081
+ budget.releaseRetained(previousBytes, { kind: "retained_collectors" });
1082
+ }
976
1083
  break;
977
1084
  case "response.failed":
978
1085
  case "error":
@@ -982,7 +1089,14 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
982
1089
  yield { type: "incomplete", reason: responsesErrorMessage(payload.response ?? payload) };
983
1090
  return;
984
1091
  case "response.completed":
985
- snapshot = responsesPayloadText(payload.response);
1092
+ {
1093
+ const next = responsesPayloadText(payload.response);
1094
+ const previousBytes = budgetEncoder.encode(snapshot).byteLength;
1095
+ const reservation = budget.reserveTransient(budgetEncoder.encode(next).byteLength, { kind: "retained_collectors" });
1096
+ snapshot = next;
1097
+ reservation.commitRetained();
1098
+ budget.releaseRetained(previousBytes, { kind: "retained_collectors" });
1099
+ }
986
1100
  usage = usageFromResponsesPayload(payload.response);
987
1101
  break;
988
1102
  }
@@ -991,14 +1105,16 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
991
1105
  // completed snapshot so text is never double-counted.
992
1106
  const text = snapshot || doneText || deltas;
993
1107
  if (text) yield { type: "text_delta", text };
1108
+ budget.releaseRetained(budgetEncoder.encode(deltas).byteLength + budgetEncoder.encode(doneText).byteLength + budgetEncoder.encode(snapshot).byteLength, { kind: "retained_collectors" });
994
1109
  yield { type: "done", ...(usage ? { usage } : {}) };
995
1110
  },
996
1111
 
997
- async parseResponse(response: Response): Promise<AdapterEvent[]> {
1112
+ async parseResponse(response: Response, budget: TranslatorBudget): Promise<AdapterEvent[]> {
998
1113
  let payload: unknown;
999
1114
  try { payload = await response.json(); } catch {
1000
1115
  return [{ type: "error", message: "malformed upstream compaction response" }];
1001
1116
  }
1117
+ budget.chargeRetained(new TextEncoder().encode(JSON.stringify(payload)).byteLength, { kind: "retained_collectors" });
1002
1118
  if (!isPlainObject(payload)) {
1003
1119
  return [{ type: "error", message: "malformed upstream compaction response" }];
1004
1120
  }
@@ -2,6 +2,8 @@ import type { AdapterEvent } from "../types";
2
2
 
3
3
  type QueueReader = (result: IteratorResult<AdapterEvent>) => void;
4
4
 
5
+ export const PREFLIGHT_HEARTBEAT_RETAIN_LIMIT = 16;
6
+
5
7
  export interface AdapterEventQueue {
6
8
  push(event: AdapterEvent): void;
7
9
  close(): void;
@@ -39,8 +41,12 @@ export async function preflightAdapterEvents(
39
41
  while (true) {
40
42
  const next = await iterator.next();
41
43
  if (next.done) return { stream: replay(buffered, iterator), empty: true };
44
+ if (next.value.type === "heartbeat") {
45
+ buffered.push(next.value);
46
+ if (buffered.length > PREFLIGHT_HEARTBEAT_RETAIN_LIMIT) buffered.shift();
47
+ continue;
48
+ }
42
49
  buffered.push(next.value);
43
- if (next.value.type === "heartbeat") continue;
44
50
  if (next.value.type === "error") {
45
51
  await iterator.return?.();
46
52
  return { stream: replay(buffered, iterator), error: next.value, empty: false };