@bitkyc08/opencodex 2.8.2-preview.20260731 → 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 (166) 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 +86 -5
  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 +89 -14
  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/doctor.ts +12 -0
  42. package/src/cli/export-command.ts +187 -0
  43. package/src/cli/help.ts +11 -0
  44. package/src/cli/index.ts +13 -3
  45. package/src/cli/opencode.ts +36 -151
  46. package/src/cli/star-prompt.ts +13 -4
  47. package/src/cli/status-oauth.ts +12 -2
  48. package/src/clients/config-export.ts +377 -0
  49. package/src/codex/account-runtime-state.ts +19 -1
  50. package/src/codex/account-store.ts +162 -82
  51. package/src/codex/auth-api.ts +467 -159
  52. package/src/codex/auth-context.ts +15 -2
  53. package/src/codex/catalog/aggregation.ts +15 -0
  54. package/src/codex/catalog/effort.ts +16 -6
  55. package/src/codex/catalog/parsing.ts +3 -1
  56. package/src/codex/catalog/provider-fetch.ts +29 -0
  57. package/src/codex/catalog/sync.ts +64 -7
  58. package/src/codex/inject.ts +5 -5
  59. package/src/codex/main-account-cache.ts +8 -1
  60. package/src/codex/model-cache.ts +81 -2
  61. package/src/codex/pool-rotation.ts +39 -0
  62. package/src/codex/project-config-warnings.ts +12 -1
  63. package/src/codex/quota.ts +35 -3
  64. package/src/codex/routing.ts +46 -1
  65. package/src/codex/shim.ts +10 -4
  66. package/src/codex/subagent-model-fallback.ts +12 -0
  67. package/src/codex/websocket-registry.ts +27 -0
  68. package/src/combos/failover.ts +31 -1
  69. package/src/combos/request.ts +9 -0
  70. package/src/combos/resolve.ts +60 -4
  71. package/src/combos/types.ts +12 -0
  72. package/src/config.ts +510 -55
  73. package/src/github/star-state.ts +13 -1
  74. package/src/images/fulfill.ts +39 -1
  75. package/src/images/loop.ts +52 -12
  76. package/src/lib/admission.ts +83 -0
  77. package/src/lib/app-owned-memory-stores.ts +173 -0
  78. package/src/lib/app-owned-memory.ts +265 -0
  79. package/src/lib/bun-stream-caps.ts +31 -7
  80. package/src/lib/config-ownership.ts +33 -0
  81. package/src/lib/crash-guard.ts +65 -5
  82. package/src/lib/debug-log-buffer.ts +47 -6
  83. package/src/lib/errors.ts +3 -0
  84. package/src/lib/gcp-adc.ts +40 -2
  85. package/src/lib/injection-debug-log.ts +26 -2
  86. package/src/lib/sidecar-tracker.ts +5 -2
  87. package/src/lib/sse-decoder.ts +257 -37
  88. package/src/lib/state-store-registrations.ts +109 -0
  89. package/src/lib/state-store-sweeper.ts +184 -0
  90. package/src/lib/translator-budget.ts +356 -0
  91. package/src/lib/windows-secret-acl.ts +33 -12
  92. package/src/lib/winsw.ts +8 -1
  93. package/src/oauth/anthropic-routing.ts +31 -7
  94. package/src/oauth/google-antigravity.ts +2 -1
  95. package/src/oauth/health.ts +30 -12
  96. package/src/oauth/index.ts +127 -23
  97. package/src/oauth/kiro.ts +10 -2
  98. package/src/oauth/store.ts +165 -18
  99. package/src/oauth/token-guardian.ts +43 -4
  100. package/src/oauth/types.ts +2 -1
  101. package/src/providers/base-url-choices.ts +10 -0
  102. package/src/providers/derive.ts +12 -0
  103. package/src/providers/key-failover.ts +12 -0
  104. package/src/providers/openai-sidecar.ts +4 -1
  105. package/src/providers/quota.ts +68 -7
  106. package/src/providers/registry.ts +279 -3
  107. package/src/responses/parser.ts +5 -1
  108. package/src/responses/spill-store.ts +394 -0
  109. package/src/responses/state.ts +520 -102
  110. package/src/router.ts +18 -1
  111. package/src/server/adapter-resolve.ts +20 -3
  112. package/src/server/auth-cors.ts +121 -28
  113. package/src/server/chat-completions.ts +57 -12
  114. package/src/server/claude-messages.ts +85 -13
  115. package/src/server/index.ts +239 -97
  116. package/src/server/lifecycle.ts +155 -25
  117. package/src/server/management/agent-settings-routes.ts +75 -32
  118. package/src/server/management/api-key-usage.ts +167 -0
  119. package/src/server/management/body.ts +35 -0
  120. package/src/server/management/combo-routes.ts +5 -1
  121. package/src/server/management/config-routes.ts +37 -12
  122. package/src/server/management/logs-usage-routes.ts +41 -21
  123. package/src/server/management/model-routes.ts +173 -53
  124. package/src/server/management/oauth-account-routes.ts +115 -26
  125. package/src/server/management/provider-routes.ts +56 -6
  126. package/src/server/management/sidebar-routes.ts +50 -1
  127. package/src/server/management/system-restart.ts +13 -6
  128. package/src/server/management/system-routes.ts +15 -3
  129. package/src/server/management/usage-summary-cache.ts +86 -0
  130. package/src/server/management-api.ts +39 -5
  131. package/src/server/management-auth.ts +65 -14
  132. package/src/server/port-reclaim.ts +58 -12
  133. package/src/server/ports.ts +2 -0
  134. package/src/server/proxy-liveness.ts +51 -12
  135. package/src/server/relay-eager.ts +20 -4
  136. package/src/server/relay.ts +548 -154
  137. package/src/server/request-decompress.ts +51 -4
  138. package/src/server/request-log.ts +134 -15
  139. package/src/server/responses/collaboration.ts +15 -4
  140. package/src/server/responses/compact.ts +3 -0
  141. package/src/server/responses/core.ts +241 -66
  142. package/src/server/responses-image-gen-repair.ts +19 -5
  143. package/src/server/responses-item-id-repair.ts +23 -5
  144. package/src/server/sse-payload-rewrite.ts +71 -12
  145. package/src/server/startup-health-cache.ts +14 -1
  146. package/src/server/system-env.ts +8 -1
  147. package/src/server/windows-tcp-drop.ts +15 -5
  148. package/src/server/ws-bridge.ts +25 -0
  149. package/src/service.ts +23 -3
  150. package/src/storage/policy-job.ts +93 -23
  151. package/src/storage/policy-worker.ts +6 -0
  152. package/src/storage/restore-job.ts +62 -16
  153. package/src/storage/restore-worker.ts +6 -0
  154. package/src/storage/storage-mutation-coordinator.ts +36 -6
  155. package/src/storage/worker-lifecycle.ts +181 -47
  156. package/src/tray/windows.ts +45 -18
  157. package/src/types.ts +34 -6
  158. package/src/update/index.ts +24 -5
  159. package/src/update/job.ts +598 -73
  160. package/src/usage/log.ts +115 -17
  161. package/src/usage/summary.ts +67 -2
  162. package/src/vision/index.ts +112 -22
  163. package/src/web-search/loop.ts +38 -6
  164. package/src/web-search/progress-stream.ts +14 -3
  165. package/gui/dist/assets/index-BHsKRFh9.css +0 -1
  166. package/gui/dist/assets/index-GC0Vlu1Z.js +0 -67
@@ -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;
@@ -676,7 +682,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
676
682
  };
677
683
  },
678
684
 
679
- async *parseStream(response: Response): AsyncGenerator<AdapterEvent> {
685
+ async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator<AdapterEvent> {
680
686
  if (!response.body) {
681
687
  yield { type: "error", message: "No response body" };
682
688
  return;
@@ -684,7 +690,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
684
690
 
685
691
  const reader = response.body.getReader();
686
692
  const decoder = new TextDecoder();
693
+ const budgetEncoder = new TextEncoder();
687
694
  let buffer = "";
695
+ let bufferBytes = 0;
688
696
  // Streamed tool calls are BUFFERED until a terminal signal, then flushed as atomic
689
697
  // start/delta/end sequences. The bridge treats text/reasoning deltas as barriers that
690
698
  // close an open tool-call item (bridge.ts closeCurrentToolCall on text_delta), so
@@ -693,15 +701,18 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
693
701
  // chunks, whole-chunk calls) cannot be represented live without overlapping sequences.
694
702
  // Keyed by `index` (OpenAI wire standard), falling back to `id`, falling back to the
695
703
  // last-seen call for providers that omit both on continuation chunks.
696
- interface PendingToolCall { key: string; id: string; name: string; args: string }
704
+ interface PendingToolCall { key: string; id: string; name: string; args: string; argsBytes: number }
697
705
  const pendingToolCalls: PendingToolCall[] = [];
698
706
  let toolCallSeq = 0;
699
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.
700
710
  for (const call of pendingToolCalls) {
701
711
  if (!call.id) call.id = `call_${++toolCallSeq}`;
702
712
  yield { type: "tool_call_start", id: call.id, name: call.name };
703
713
  if (call.args.length > 0) yield { type: "tool_call_delta", arguments: call.args };
704
714
  yield { type: "tool_call_end" };
715
+ budget.closeCall(call.key);
705
716
  }
706
717
  pendingToolCalls.length = 0;
707
718
  };
@@ -711,6 +722,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
711
722
  // explicit `[DONE]` sentinel OR a chunk carrying a non-null `finish_reason` (some
712
723
  // OpenAI-compatible providers omit `[DONE]` but do send finish_reason).
713
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;
714
728
 
715
729
  // Single per-line handler shared by the streaming loop and the EOF residual-frame flush, so
716
730
  // a final frame is parsed identically wherever it lands (no duplicated, drift-prone parsing).
@@ -780,6 +794,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
780
794
  yield { type: "reasoning_raw_delta", text: delta.reasoning_content };
781
795
  }
782
796
  if (typeof delta.content === "string" && delta.content.length > 0) {
797
+ sawUserFacingOutput = true;
783
798
  yield { type: "text_delta", text: delta.content };
784
799
  }
785
800
 
@@ -797,12 +812,27 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
797
812
  // splitting into two calls that share one call_id downstream.
798
813
  if (!call && tc.id) call = pendingToolCalls.find(c => c.id === tc.id);
799
814
  if (!call) {
800
- call = { key: key ?? `seq:${pendingToolCalls.length}`, id: "", name: "", args: "" };
815
+ call = { key: key ?? `seq:${pendingToolCalls.length}`, id: "", name: "", args: "", argsBytes: 0 };
801
816
  pendingToolCalls.push(call);
817
+ budget.openCall(call.key);
802
818
  }
803
819
  if (tc.id && !call.id) call.id = tc.id;
804
820
  if (tc.function?.name && !call.name) call.name = tc.function.name;
805
- 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
+ }
806
836
  }
807
837
  }
808
838
  }
@@ -819,10 +849,31 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
819
849
  while (true) {
820
850
  const { done, value } = await reader.read();
821
851
  if (done) break;
822
- 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;
823
869
 
824
870
  const lines = buffer.split("\n");
825
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;
826
877
 
827
878
  for (const line of lines) {
828
879
  if ((yield* handleDataLine(line)) === "terminate") return;
@@ -837,10 +888,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
837
888
  if (buffer.length > 0) {
838
889
  if ((yield* handleDataLine(buffer)) === "terminate") return;
839
890
  }
840
- // Reader EOF. A graceful close shows at least one terminal signal: `[DONE]` (returns above),
841
- // a non-null finish_reason (sawFinish), or a trailing usage chunk (providers emit usage only
842
- // at end-of-generation). If NONE of those were seen, the stream was cut mid-flight — fail
843
- // 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.
844
893
  //
845
894
  // Checked BEFORE flushToolCalls(), because that helper emits tool_call_end and there is no
846
895
  // taking it back: a half-assembled argument string would reach the client as a completed
@@ -856,29 +905,51 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
856
905
  yield { type: "error", message: "upstream stream ended mid tool call without a terminal signal — possible truncation" };
857
906
  return;
858
907
  }
859
- yield* flushToolCalls();
860
- if (!sawFinish && pendingUsage === undefined) {
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) {
861
912
  debugProviderDiagnostic("openai-chat", "stream-truncated", {
862
913
  finishReason: finishReason ?? null,
863
- hadUsage: false,
914
+ hadUsage: pendingUsage !== undefined,
864
915
  });
865
916
  yield { type: "error", message: "upstream stream ended without a terminal signal ([DONE] or finish_reason) — possible truncation" };
866
917
  return;
867
918
  }
868
- // 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.
869
921
  const stopReason = finishReason === "length"
870
922
  ? "max_tokens"
871
923
  : finishReason === "content_filter"
872
924
  ? "content_filter"
873
925
  : undefined;
874
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;
875
941
  } finally {
942
+ budget.releaseRetained(bufferBytes, { kind: "live_transient" });
943
+ for (const call of pendingToolCalls) budget.closeCall(call.key);
876
944
  reader.releaseLock();
877
945
  }
878
946
  },
879
947
 
880
- async parseResponse(response: Response): Promise<AdapterEvent[]> {
948
+ async parseResponse(response: Response, budget: TranslatorBudget): Promise<AdapterEvent[]> {
881
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 {
882
953
  if (json.error) {
883
954
  const upstreamError = json.error as { message?: unknown; code?: unknown; type?: unknown; status?: unknown };
884
955
  const message = typeof upstreamError.message === "string" ? upstreamError.message : "upstream error";
@@ -924,7 +995,11 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
924
995
  type: "done",
925
996
  usage: usageFromOpenAIChat(usage),
926
997
  });
998
+ retainTranslatedEventBatch(events, budget);
927
999
  return events;
1000
+ } finally {
1001
+ budget.releaseRetained(responseBytes, { kind: "retained_collectors" });
1002
+ }
928
1003
  },
929
1004
  };
930
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 };