@bitkyc08/opencodex 2.89.0 → 2.91.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 (242) hide show
  1. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  2. package/gui/dist/assets/App-nZ34cR5G.js +58 -0
  3. package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-BuLy7hL1.js} +1 -1
  4. package/gui/dist/assets/{index-uchD-ngf.js → index-DGXS51sp.js} +15 -15
  5. package/gui/dist/assets/quota-summary-DO1Sx5TM.js +1 -0
  6. package/gui/dist/index.html +1 -1
  7. package/package.json +2 -2
  8. package/src/adapters/anthropic.ts +2 -5
  9. package/src/adapters/base.ts +4 -0
  10. package/src/adapters/claude-cli/adapter.ts +13 -7
  11. package/src/adapters/claude-cli/usage-admission.ts +28 -2
  12. package/src/adapters/coding-agent/turn.ts +14 -4
  13. package/src/adapters/cursor/protobuf-request.ts +3 -0
  14. package/src/adapters/google-progress-history.ts +19 -1
  15. package/src/adapters/google.ts +28 -9
  16. package/src/adapters/image.ts +5 -3
  17. package/src/adapters/input-media-guard.ts +5 -2
  18. package/src/adapters/kiro-refusal.ts +41 -1
  19. package/src/adapters/ollama-native.ts +1 -0
  20. package/src/adapters/openai-chat/messages.ts +34 -4
  21. package/src/adapters/openai-chat/passthrough.ts +37 -4
  22. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  23. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  24. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  25. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  26. package/src/adapters/openai-chat.ts +7 -6
  27. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  28. package/src/adapters/openai-responses/passthrough.ts +7 -2
  29. package/src/adapters/openai-responses/request-strips.ts +32 -0
  30. package/src/adapters/openai-responses/transport.ts +19 -1
  31. package/src/chat/inbound.ts +20 -2
  32. package/src/chat/outbound.ts +36 -4
  33. package/src/chat/reasoning-details.ts +137 -0
  34. package/src/claude/desktop-3p-library.ts +43 -3
  35. package/src/claude/desktop-3p.ts +20 -10
  36. package/src/claude/desktop-picker-profile.ts +18 -9
  37. package/src/claude/desktop-remote-store-io.ts +2 -2
  38. package/src/cli/capabilities-agents-routing.ts +50 -0
  39. package/src/cli/capabilities-base.ts +15 -2
  40. package/src/cli/capabilities-observe-system.ts +4 -4
  41. package/src/cli/codex-shim-autorestore.ts +5 -0
  42. package/src/cli/dispatch.ts +30 -0
  43. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  44. package/src/cli/fallback.ts +127 -0
  45. package/src/cli/help.ts +1 -0
  46. package/src/cli/index.ts +1 -2
  47. package/src/cli/link-apply-restart.ts +72 -0
  48. package/src/cli/link-child.ts +6 -2
  49. package/src/cli/link-cleanup.ts +95 -0
  50. package/src/cli/link-enrollment.ts +4 -0
  51. package/src/cli/link.ts +84 -9
  52. package/src/cli/provider.ts +44 -2
  53. package/src/cli/registry.ts +13 -1
  54. package/src/cli/restart-failure.ts +4 -0
  55. package/src/cli/runtime-api.ts +1 -1
  56. package/src/cli/status-codex-settings.ts +56 -0
  57. package/src/cli/status-fallback.ts +29 -0
  58. package/src/cli/status-install.ts +52 -0
  59. package/src/cli/status.ts +20 -1
  60. package/src/cli/system-restart-client.ts +32 -1
  61. package/src/cli/update-restart-child.ts +4 -3
  62. package/src/cli/update-restart-home.ts +20 -8
  63. package/src/client/connect.ts +82 -6
  64. package/src/client/home-unreachable.ts +16 -0
  65. package/src/client/hub-relay.ts +8 -2
  66. package/src/client/link-home.ts +119 -6
  67. package/src/client/link-parity-reporter.ts +107 -0
  68. package/src/client/link-relay-transport.ts +8 -1
  69. package/src/client/link-relay.ts +27 -3
  70. package/src/client/machine-listener.ts +83 -1
  71. package/src/client/runtime-restart.ts +127 -0
  72. package/src/client/runtime.ts +137 -25
  73. package/src/codex/account-lifecycle.ts +19 -6
  74. package/src/codex/account-store.ts +27 -8
  75. package/src/codex/account-usability.ts +4 -1
  76. package/src/codex/auth-api/account-list.ts +10 -5
  77. package/src/codex/auth-api/account-pause-group.ts +2 -22
  78. package/src/codex/auth-api/login-collision.ts +59 -0
  79. package/src/codex/auth-api/login-flow.ts +12 -7
  80. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  81. package/src/codex/auth-collision.ts +31 -4
  82. package/src/codex/auth-context.ts +58 -12
  83. package/src/codex/catalog/parsing.ts +4 -0
  84. package/src/codex/cli-update-plan.ts +18 -3
  85. package/src/codex/collision-identity.ts +78 -0
  86. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  87. package/src/codex/main-account-hard-lock.ts +14 -0
  88. package/src/codex/quota-history.ts +7 -0
  89. package/src/codex/quota.ts +10 -0
  90. package/src/codex/routing/cooldown-math.ts +5 -0
  91. package/src/codex/routing/failure-window.ts +61 -9
  92. package/src/codex/routing.ts +4 -3
  93. package/src/codex/shim-diagnostics.ts +2 -1
  94. package/src/codex/shim-fingerprint.ts +11 -2
  95. package/src/codex/shim-templates.ts +16 -5
  96. package/src/codex/shim-windows-refresh.ts +224 -0
  97. package/src/codex/shim.ts +45 -21
  98. package/src/codex/subagent-model-fallback.ts +24 -1
  99. package/src/combos/failover.ts +8 -1
  100. package/src/combos/index.ts +1 -0
  101. package/src/combos/resolve.ts +12 -0
  102. package/src/config/diagnostics.ts +5 -0
  103. package/src/config/load-degrade.ts +2 -0
  104. package/src/config/provider-validation.ts +7 -2
  105. package/src/config/schema/config-schema.ts +3 -0
  106. package/src/config/schema/fallback-policy.ts +40 -0
  107. package/src/generated/compatibility-version.json +360 -192
  108. package/src/generated/model-metadata.ts +1 -1
  109. package/src/images/loop.ts +2 -2
  110. package/src/lib/authorization-scheme.ts +95 -0
  111. package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
  112. package/src/lib/provider-admission-wait.ts +14 -0
  113. package/src/lib/provider-client-headers.ts +1 -0
  114. package/src/lib/redact.ts +2 -2
  115. package/src/lib/service-secrets.ts +37 -2
  116. package/src/lib/system-restart-contract.ts +24 -0
  117. package/src/lib/test-home-guard.ts +78 -11
  118. package/src/lib/translator-budget.ts +1 -1
  119. package/src/lib/upstream-retry.ts +15 -0
  120. package/src/link/child-parity.ts +124 -0
  121. package/src/link/codex-settings.ts +37 -0
  122. package/src/link/compatibility.ts +119 -0
  123. package/src/link/management-routes.ts +2 -0
  124. package/src/link/peer-report.ts +100 -0
  125. package/src/link/remote-platform.ts +78 -0
  126. package/src/link/ssh-argv.ts +19 -0
  127. package/src/link/status-projection.ts +24 -2
  128. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  129. package/src/oauth/account-import/parser.ts +15 -2
  130. package/src/oauth/account-import/types.ts +7 -0
  131. package/src/oauth/chatgpt-shared.ts +10 -1
  132. package/src/oauth/dsh.ts +45 -2
  133. package/src/oauth/index.ts +1 -1
  134. package/src/oauth/kimi.ts +2 -0
  135. package/src/oauth/kiro.ts +44 -1
  136. package/src/oauth/login-flow-state.ts +11 -2
  137. package/src/oauth/nous.ts +4 -1
  138. package/src/oauth/store.ts +2 -1
  139. package/src/protocols/codecs/chat.ts +3 -3
  140. package/src/protocols/encoders/adapter-events.ts +26 -2
  141. package/src/protocols/encoders/chat.ts +55 -3
  142. package/src/providers/claude-cli-usage.ts +20 -5
  143. package/src/providers/initial-model-selection.ts +2 -2
  144. package/src/providers/key-failover.ts +10 -2
  145. package/src/providers/key-store.ts +8 -3
  146. package/src/providers/mistral-transport.ts +240 -0
  147. package/src/providers/openai-sidecar.ts +5 -0
  148. package/src/providers/quota/anthropic-account-quota.ts +18 -6
  149. package/src/providers/quota/antigravity.ts +8 -10
  150. package/src/providers/quota/failure-codes.ts +18 -0
  151. package/src/providers/quota/vendor-probes-oauth.ts +12 -3
  152. package/src/providers/quota-routing-cache.ts +1 -1
  153. package/src/providers/quota.ts +5 -1
  154. package/src/providers/registry/entries-extended.ts +6 -1
  155. package/src/providers/routed-config.ts +1 -1
  156. package/src/providers/xai-transport.ts +2 -1
  157. package/src/quota-pool/assemble.ts +326 -0
  158. package/src/quota-pool/contract.ts +58 -0
  159. package/src/quota-pool/identity.ts +203 -0
  160. package/src/remote/features.ts +74 -0
  161. package/src/remote/protocol.ts +2 -0
  162. package/src/remote/served-by.ts +57 -0
  163. package/src/responses/audio.ts +46 -0
  164. package/src/responses/input-media.ts +37 -15
  165. package/src/responses/parser-content.ts +22 -27
  166. package/src/responses/parser.ts +7 -0
  167. package/src/responses/reasoning-envelope.ts +12 -0
  168. package/src/responses/reasoning-retention.ts +218 -11
  169. package/src/responses/schema.ts +3 -4
  170. package/src/responses/web-search-replay.ts +11 -2
  171. package/src/routing/fallback-policy.ts +290 -0
  172. package/src/routing/fallback-state.ts +179 -0
  173. package/src/server/audio-client.ts +2 -1
  174. package/src/server/audio-dictation.ts +2 -1
  175. package/src/server/audio-upstream.ts +2 -1
  176. package/src/server/auth-cors.ts +6 -5
  177. package/src/server/background-lifecycle.ts +4 -0
  178. package/src/server/chat-completions.ts +8 -1
  179. package/src/server/chat-native.ts +8 -7
  180. package/src/server/gui-session.ts +2 -1
  181. package/src/server/index/link-listener.ts +11 -1
  182. package/src/server/index/link-management.ts +47 -0
  183. package/src/server/index/link-relay-sessions.ts +11 -2
  184. package/src/server/index/optional-listeners.ts +7 -2
  185. package/src/server/index/serve-options.ts +27 -4
  186. package/src/server/index/websocket-handler.ts +10 -0
  187. package/src/server/lifecycle.ts +17 -1
  188. package/src/server/live.ts +5 -0
  189. package/src/server/management/context.ts +1 -1
  190. package/src/server/management/fallback-routes.ts +75 -0
  191. package/src/server/management/link-enrollment.ts +21 -3
  192. package/src/server/management/link-route-state.ts +2 -0
  193. package/src/server/management/link-routes.ts +12 -1
  194. package/src/server/management/oauth-account-routes.ts +2 -1
  195. package/src/server/management/quota-pool-routes.ts +24 -0
  196. package/src/server/management/route-registry.ts +3 -0
  197. package/src/server/management-api.ts +16 -0
  198. package/src/server/management-auth.ts +2 -1
  199. package/src/server/messages-native.ts +11 -0
  200. package/src/server/request-log.ts +16 -7
  201. package/src/server/responses/adapter-continuation.ts +2 -2
  202. package/src/server/responses/adapter-dispatch.ts +2 -2
  203. package/src/server/responses/agent-task-recovery.ts +1 -1
  204. package/src/server/responses/codex-ws-exchange.ts +3 -0
  205. package/src/server/responses/codex-ws-metadata.ts +44 -0
  206. package/src/server/responses/codex-ws-session.ts +21 -1
  207. package/src/server/responses/combo-stream-preflight.ts +7 -2
  208. package/src/server/responses/compact.ts +4 -2
  209. package/src/server/responses/core-auth.ts +31 -1
  210. package/src/server/responses/core-codex-account.ts +21 -14
  211. package/src/server/responses/core-combo.ts +75 -1
  212. package/src/server/responses/core-opaque-recovery.ts +20 -11
  213. package/src/server/responses/deepseek-image-files.ts +121 -0
  214. package/src/server/responses/empty-completion-guard.ts +8 -0
  215. package/src/server/responses/encrypted-payload.ts +3 -3
  216. package/src/server/responses/fetch-helpers.ts +30 -10
  217. package/src/server/responses/input-admission.ts +6 -2
  218. package/src/server/responses/main-hard-lock-routing.ts +85 -8
  219. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  220. package/src/server/responses/passthrough-completion.ts +41 -0
  221. package/src/server/responses/passthrough-delivery.ts +2 -0
  222. package/src/server/responses/passthrough-dispatch.ts +13 -13
  223. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  224. package/src/server/responses/policy-fallback.ts +44 -1
  225. package/src/server/responses/reasoning-archive-error.ts +21 -0
  226. package/src/server/responses/request-prepare.ts +19 -1
  227. package/src/server/responses/request-transport.ts +61 -3
  228. package/src/server/restart-replacement.ts +50 -2
  229. package/src/server/ws-bridge.ts +5 -0
  230. package/src/service/cli.ts +25 -2
  231. package/src/service/diagnostics.ts +20 -6
  232. package/src/service/orchestration.ts +17 -0
  233. package/src/service/status-json.ts +116 -0
  234. package/src/types/config.ts +7 -0
  235. package/src/types/fallback.ts +38 -0
  236. package/src/types/provider.ts +2 -0
  237. package/src/types/request.ts +16 -1
  238. package/src/types.ts +1 -0
  239. package/src/web-search/loop.ts +2 -2
  240. package/gui/dist/assets/App-BnRSujOq.js +0 -58
  241. package/gui/dist/assets/App-dnkJ85te.css +0 -1
  242. package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The served-by response header, the Home instance id it carries, and whether this build sends it.
3
+ *
4
+ * Every `GET /v1/models` response the server answers itself names the serving Home in
5
+ * `x-opencodex-served-by`, and `GET /healthz` publishes the same value as `instanceId`, so a peer
6
+ * (GOAT's parity check P4) can prove which Home answered without reading logs. A Remote Link Child
7
+ * never answers `/v1/models` itself: its relay copies the Home's response headers, so the value a
8
+ * Child returns is the Home's. A Hub-mode client's Codex calls the Hub's data listener directly, so
9
+ * the Hub stamps its own response.
10
+ *
11
+ * The instance id is the install's `homeId` from `src/quota-pool/identity.ts`: a random
12
+ * `home_<32 hex>` minted once per install, persisted owner-only, and stable across restarts. It is
13
+ * never a hostname and is not derived from one. Until the identity exists (it is created in the
14
+ * background after the listener starts) or when its file is unreadable, the header and the
15
+ * `/healthz` field are omitted; an empty or malformed value is never sent.
16
+ *
17
+ * `SERVED_BY_HEADER_EMITTED` tells `/readyz` to advertise `served-by-header`
18
+ * (`src/remote/features.ts`); `tests/server/readyz-features.test.ts` fails if the advertisement
19
+ * and live responses disagree.
20
+ */
21
+ import { readQuotaPoolIdentity } from "../quota-pool/identity";
22
+
23
+ export const SERVED_BY_HEADER = "x-opencodex-served-by";
24
+
25
+ /** Whether responses from this build carry {@link SERVED_BY_HEADER}. */
26
+ export const SERVED_BY_HEADER_EMITTED: boolean = true;
27
+
28
+ const INSTANCE_ID = /^home_[0-9a-f]{32}$/;
29
+
30
+ /** This Home's opaque instance id, or undefined while the install identity is unavailable. */
31
+ export function homeInstanceId(): string | undefined {
32
+ let homeId: unknown;
33
+ try { homeId = readQuotaPoolIdentity()?.homeId; } catch { return undefined; }
34
+ return typeof homeId === "string" && INSTANCE_ID.test(homeId) ? homeId : undefined;
35
+ }
36
+
37
+ /** Stamp {@link SERVED_BY_HEADER} with this Home's instance id; leaves the response alone without one. */
38
+ export function withServedBy(response: Response, instanceId = homeInstanceId()): Response {
39
+ if (!instanceId) return response;
40
+ try {
41
+ response.headers.set(SERVED_BY_HEADER, instanceId);
42
+ return response;
43
+ } catch {
44
+ const headers = new Headers(response.headers);
45
+ headers.set(SERVED_BY_HEADER, instanceId);
46
+ return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
47
+ }
48
+ }
49
+
50
+ /**
51
+ * The server's last step for every answered request: `GET /v1/models` names the serving Home,
52
+ * whichever branch produced the response. `pathname` is the dispatch path the router matched.
53
+ */
54
+ export function stampServedBy(method: string, pathname: string, response: Response): Response {
55
+ if (!response || method !== "GET" || pathname !== "/v1/models") return response;
56
+ return withServedBy(response);
57
+ }
@@ -0,0 +1,46 @@
1
+ import type { OcxAudioContent } from "../types";
2
+
3
+ export type ChatAudioFormat = OcxAudioContent["format"];
4
+
5
+ const MIME_FORMATS: Readonly<Record<string, ChatAudioFormat>> = {
6
+ "audio/mp3": "mp3",
7
+ "audio/mpeg": "mp3",
8
+ "audio/wav": "wav",
9
+ "audio/wave": "wav",
10
+ "audio/x-wav": "wav",
11
+ };
12
+
13
+ function normalizedFormat(value: unknown): ChatAudioFormat | undefined {
14
+ if (typeof value !== "string") return undefined;
15
+ const format = value.trim().toLowerCase();
16
+ return format === "mp3" || format === "wav" ? format : undefined;
17
+ }
18
+
19
+ function validBase64(value: string): boolean {
20
+ return value.length > 0
21
+ && value.length % 4 === 0
22
+ && /^[A-Za-z0-9+/]+={0,2}$/.test(value);
23
+ }
24
+
25
+ /**
26
+ * Parse the inline audio shape Codex puts in a Responses content block. Chat Completions
27
+ * needs bare base64 plus `wav`/`mp3`, while Codex supplies an RFC 2397 data URL. A raw
28
+ * base64 value with an explicit supported format is accepted as well; remote URLs are not.
29
+ */
30
+ export function audioContentFromRaw(audioUrl: unknown, declaredFormat?: unknown): OcxAudioContent | undefined {
31
+ if (typeof audioUrl !== "string" || audioUrl.length === 0) return undefined;
32
+ const dataUrl = /^data:([^;,]+);base64,([\s\S]*)$/.exec(audioUrl);
33
+ let data: string;
34
+ let format: ChatAudioFormat | undefined;
35
+ if (dataUrl) {
36
+ const declared = normalizedFormat(declaredFormat);
37
+ const mime = dataUrl[1].trim().toLowerCase();
38
+ format = MIME_FORMATS[mime] ?? (mime === "application/octet-stream" ? declared : undefined);
39
+ data = dataUrl[2].replace(/\s+/g, "");
40
+ } else {
41
+ format = normalizedFormat(declaredFormat);
42
+ data = audioUrl.replace(/\s+/g, "");
43
+ }
44
+ if (!format || !validBase64(data)) return undefined;
45
+ return { type: "audio", data, format, text: `[audio: ${format}]` };
46
+ }
@@ -1,4 +1,5 @@
1
1
  import { isInlineDocumentDataUrl } from "./inline-document";
2
+ import { audioContentFromRaw } from "./audio";
2
3
 
3
4
  /** Input kinds for which the normalized request has no lossless content carrier. */
4
5
  export type UntranslatedInputMedia = "audio" | "file";
@@ -10,23 +11,35 @@ function isRecord(value: unknown): value is RecordValue {
10
11
  }
11
12
 
12
13
  /**
13
- * Whether the position being scanned has a converter that builds a document part.
14
+ * Whether the position being scanned has a converter that builds a lossless media part.
14
15
  *
15
- * Only user content does. A tool output, a system or assistant message, and a Chat `developer`
16
- * message are all flattened to text by their converters, so exempting an attachment there would
17
- * turn today's explicit refusal into the silent drop this scanner exists to prevent.
16
+ * User content carries documents and inline audio; developer content carries documents only.
17
+ * Tool output carries inline audio through the Chat adapter's follow-up user message. System and
18
+ * assistant content have no lossless translated media carrier, so exempting an attachment there
19
+ * would turn today's explicit refusal into the silent drop this scanner exists to prevent.
18
20
  */
19
- type CarrierPosition = "user-content" | "flattened";
21
+ type CarrierPosition = "user-content" | "developer-content" | "tool-output" | "flattened";
20
22
 
21
- function mediaKind(value: unknown, position: CarrierPosition): UntranslatedInputMedia | undefined {
23
+ function mediaKind(value: unknown, position: CarrierPosition, allowAudio: boolean): UntranslatedInputMedia | undefined {
22
24
  if (!isRecord(value)) return undefined;
23
- if (value.type === "input_audio" || value.type === "audio") return "audio";
25
+ if (value.type === "input_audio" || value.type === "audio") {
26
+ // A Chat content part has a nested input_audio object, while Codex's Responses
27
+ // block uses audio_url. Only the latter is carried by the translated Chat adapter; the
28
+ // Chat-shaped `audio` spelling has no parser path and must never pass the guard.
29
+ return value.type === "input_audio"
30
+ && allowAudio
31
+ && (position === "user-content" || position === "tool-output")
32
+ && audioContentFromRaw(value.audio_url, value.format)
33
+ ? undefined
34
+ : "audio";
35
+ }
24
36
  if (value.type === "input_file" || value.type === "file" || value.type === "document") {
25
37
  // A document that carries its own bytes has a lossless carrier in user content, so refusing
26
38
  // it there would reject the very request #5212 exists to preserve. A reference with no
27
39
  // payload has none anywhere: translated adapters cannot dereference a file_id or a remote
28
40
  // source.
29
- return position === "user-content" && carriesInlineDocumentBytes(value) ? undefined : "file";
41
+ return (position === "user-content" || position === "developer-content")
42
+ && carriesInlineDocumentBytes(value) ? undefined : "file";
30
43
  }
31
44
  // A file-id-only image is not pixels: translated adapters cannot dereference it.
32
45
  if (value.type === "input_image" && typeof value.file_id === "string" && value.file_id.length > 0
@@ -46,10 +59,10 @@ function carriesInlineDocumentBytes(value: RecordValue): boolean {
46
59
  && value.source.data.length > 0;
47
60
  }
48
61
 
49
- function contentMedia(content: unknown, position: CarrierPosition): UntranslatedInputMedia | undefined {
62
+ function contentMedia(content: unknown, position: CarrierPosition, allowAudio: boolean): UntranslatedInputMedia | undefined {
50
63
  if (!Array.isArray(content)) return undefined;
51
64
  for (const part of content) {
52
- const kind = mediaKind(part, position);
65
+ const kind = mediaKind(part, position, allowAudio);
53
66
  if (kind) return kind;
54
67
  }
55
68
  return undefined;
@@ -60,20 +73,29 @@ function contentMedia(content: unknown, position: CarrierPosition): Untranslated
60
73
  * arguments, schema properties, or arbitrary nested objects. No payload is copied,
61
74
  * decoded, fetched or included in the returned value.
62
75
  */
63
- export function untranslatedResponsesInputMedia(body: unknown): UntranslatedInputMedia | undefined {
76
+ export function untranslatedResponsesInputMedia(
77
+ body: unknown,
78
+ options: { allowAudio?: boolean } = {},
79
+ ): UntranslatedInputMedia | undefined {
64
80
  if (!isRecord(body) || !Array.isArray(body.input)) return undefined;
65
81
  for (const item of body.input) {
66
82
  if (!isRecord(item)) continue;
67
- const direct = mediaKind(item, "flattened");
83
+ const direct = mediaKind(item, "flattened", options.allowAudio === true);
68
84
  if (direct) return direct;
69
85
  if (item.type === "function_call_output" || item.type === "custom_tool_call_output") {
70
- const kind = contentMedia(item.output, "flattened");
86
+ const kind = contentMedia(item.output, "tool-output", options.allowAudio === true);
71
87
  if (kind) return kind;
72
88
  } else if (item.type === "message" || item.type === undefined) {
73
89
  // `inputContentParts` runs for user and developer messages only; a system message is
74
90
  // flattened to text by the parser.
75
91
  const role = item.role;
76
- const kind = contentMedia(item.content, role === "user" || role === "developer" ? "user-content" : "flattened");
92
+ const kind = contentMedia(
93
+ item.content,
94
+ role === "user" ? "user-content"
95
+ : role === "developer" ? "developer-content"
96
+ : "flattened",
97
+ options.allowAudio === true,
98
+ );
77
99
  if (kind) return kind;
78
100
  }
79
101
  }
@@ -87,7 +109,7 @@ export function untranslatedChatInputMedia(body: unknown): UntranslatedInputMedi
87
109
  if (!isRecord(message)) continue;
88
110
  // Only the `user` branch of the Chat projection builds content blocks. `system`,
89
111
  // `developer`, `assistant` and `tool` all reduce their content to a string.
90
- const kind = contentMedia(message.content, message.role === "user" ? "user-content" : "flattened");
112
+ const kind = contentMedia(message.content, message.role === "user" ? "user-content" : "flattened", false);
91
113
  if (kind) return kind;
92
114
  }
93
115
  return undefined;
@@ -1,4 +1,5 @@
1
1
  import type { OcxContentPart, OcxTextContent } from "../types";
2
+ import { audioContentFromRaw } from "./audio";
2
3
  import { inlineDocumentFromDataUrl } from "./inline-document";
3
4
 
4
5
  export function isObj(v: unknown): v is Record<string, unknown> {
@@ -65,23 +66,15 @@ export function inputContentParts(blocks: unknown): string | OcxContentPart[] {
65
66
  const processing = nonEmptyString((block as { processing?: string }).processing);
66
67
  if (videoUrl) parts.push({ type: "video", videoUrl, ...(processing ? { processing } : {}) });
67
68
  } else if (block.type === "input_audio") {
68
- // Upstream Codex sends input_audio with an audio_url (codex-rs
69
- // protocol/src/models.rs). The IR has no audio carrier and no adapter consumes
70
- // one, so this part used to vanish with no trace at all.
71
- //
72
- // This records PRESENCE only and is NOT audio support: never the payload, which
73
- // is large base64 and would explode the token count, and never the URL, which
74
- // can carry a signed token. This parser must stay non-throwing — the native
75
- // Responses passthrough also runs through parseRequest before the adapter
76
- // forwards _rawBody, so refusing here would regress legitimate raw passthrough.
77
- //
78
- // No adapter refuses audio at its wire today: by this point the part is already a
79
- // text marker, so downstream adapters see text and continue. A typed unsupported-
80
- // modality carrier that survives to final adapter dispatch is a separate, recorded
81
- // residual — do not describe this branch as a refusal.
82
69
  const b = block as { audio_url?: string; format?: string };
83
- const format = safeAudioFormat(b.format);
84
- if (nonEmptyString(b.audio_url)) {
70
+ // Keep bytes only when they have a Chat-compatible inline form. Unsupported or
71
+ // remote references retain a marker for text-only wires; the final adapter guard
72
+ // refuses them rather than allowing a silent drop.
73
+ const audio = audioContentFromRaw(b.audio_url, b.format);
74
+ if (audio) {
75
+ parts.push(audio);
76
+ } else if (nonEmptyString(b.audio_url)) {
77
+ const format = safeAudioFormat(b.format);
85
78
  parts.push({ type: "text", text: format ? `[audio: ${format}]` : "[audio]" });
86
79
  }
87
80
  } else if (block.type === "input_file") {
@@ -104,7 +97,7 @@ export function inputContentParts(blocks: unknown): string | OcxContentPart[] {
104
97
  // fabricating a "[file: ...]" marker for an attachment that was never sent.
105
98
  }
106
99
  }
107
- // Collapse to a plain string only for a single TEXT part; images must stay structured.
100
+ // Collapse to a plain string only for a single TEXT part; media must stay structured.
108
101
  if (parts.length === 1 && parts[0].type === "text") return parts[0].text;
109
102
  return parts;
110
103
  }
@@ -129,15 +122,15 @@ export function outputTextOf(blocks: unknown): OcxTextContent[] {
129
122
  }
130
123
 
131
124
  /**
132
- * Tool-call output content. Preserves images (e.g. Codex `view_image` returns
133
- * `input_image` items): returns content parts when any image is present, else a plain joined string.
134
- * Never inlines an image_url as text (that would explode the token count).
125
+ * Tool-call output content. Preserves images (e.g. Codex `view_image` returns)
126
+ * and inline audio: returns content parts when either is present, else a plain joined string.
127
+ * Never inlines an image_url or audio payload as text (that would explode the token count).
135
128
  */
136
129
  export function outputToToolResultContent(output: string | unknown[] | undefined): string | OcxContentPart[] {
137
130
  if (typeof output === "string") return output;
138
131
  if (!Array.isArray(output)) return "";
139
132
  const parts: OcxContentPart[] = [];
140
- let hasImage = false;
133
+ let hasMedia = false;
141
134
  for (const raw of output) {
142
135
  if (!isObj(raw)) continue;
143
136
  if (raw.type === "output_text" || raw.type === "text" || raw.type === "input_text") {
@@ -149,15 +142,17 @@ export function outputToToolResultContent(output: string | unknown[] | undefined
149
142
  const fileId = nonEmptyString(raw.file_id);
150
143
  if (imageUrl) {
151
144
  parts.push({ type: "image", imageUrl, ...(typeof raw.detail === "string" ? { detail: normalizeImageDetail(raw.detail) } : {}) });
152
- hasImage = true;
145
+ hasMedia = true;
153
146
  } else if (fileId) {
154
147
  parts.push({ type: "text", text: `[image: ${fileId}]` });
155
148
  }
156
149
  } else if (raw.type === "input_audio") {
157
- // Same presence-only contract as the user-content branch above: Codex returns
158
- // audio in tool output too, and it previously disappeared without trace.
159
- const format = safeAudioFormat(raw.format);
160
- if (nonEmptyString(raw.audio_url)) {
150
+ const audio = audioContentFromRaw(raw.audio_url, raw.format);
151
+ if (audio) {
152
+ parts.push(audio);
153
+ hasMedia = true;
154
+ } else if (nonEmptyString(raw.audio_url)) {
155
+ const format = safeAudioFormat(raw.format);
161
156
  parts.push({ type: "text", text: format ? `[audio: ${format}]` : "[audio]" });
162
157
  }
163
158
  } else if (raw.type === "encrypted_content") {
@@ -165,7 +160,7 @@ export function outputToToolResultContent(output: string | unknown[] | undefined
165
160
  parts.push({ type: "text", text: "[encrypted content omitted]" });
166
161
  }
167
162
  }
168
- if (!hasImage) return parts.map(p => (p.type === "text" ? p.text : "")).join("");
163
+ if (!hasMedia) return parts.map(p => (p.type === "text" ? p.text : "")).join("");
169
164
  return parts;
170
165
  }
171
166
 
@@ -329,6 +329,13 @@ export function parseRequest(
329
329
  continue;
330
330
  }
331
331
 
332
+ if (effectiveType === "configuration_update") {
333
+ // Codex 0.164 positional reasoning-effort control. It is not a turn, so a translated route
334
+ // drops it without touching pending reasoning; the native passthrough keeps it verbatim in
335
+ // _rawBody. Mapping its effort onto the routed request is deliberately not done (#6895).
336
+ continue;
337
+ }
338
+
332
339
  if (isCompactionItemType(effectiveType)) {
333
340
  // A stored summary from a previous compaction. Decode our ocx1/ocx2 envelopes into plain text so
334
341
  // the routed model keeps the compacted context; real OpenAI-encrypted blobs degrade to a note.
@@ -121,6 +121,18 @@ export function isProviderIssuedThinkingSignature(
121
121
  }
122
122
  }
123
123
 
124
+ /**
125
+ * Whether a value has the spelling of a signature Anthropic issued. An allow-list on top of
126
+ * `isProviderIssuedThinkingSignature`: Anthropic's signature is base64, so item ids and the
127
+ * parser's serialized placeholder never pass. Shared by the adapter, which forwards only such a
128
+ * signature, and the Chat `reasoning_details` carrier, which accepts and emits only such a one.
129
+ */
130
+ export function isLikelyRealAnthropicThinkingSignature(signature: string | undefined): signature is string {
131
+ if (typeof signature !== "string" || signature.length < 16) return false;
132
+ if (/^(fc|call|msg|rs|resp|reasoning|item|ws|tool|func|function)[-_]/i.test(signature)) return false;
133
+ return /^[A-Za-z0-9+/_=-]+$/.test(signature);
134
+ }
135
+
124
136
  /** Decode an ocxr1 envelope; returns null for native (OpenAI-encrypted) blobs or garbage. */
125
137
  export function decodeReasoningEnvelope(encryptedContent: string, budget?: TranslatorBudget): ReasoningEnvelope | null {
126
138
  if (!encryptedContent.startsWith(OCX_REASONING_PREFIX)) return null;
@@ -4,14 +4,21 @@
4
4
  * Provider ciphertext and signatures are never included in the portable retained items.
5
5
  */
6
6
 
7
+ import { Database } from "bun:sqlite";
7
8
  import { expandRetainedCompactionInput } from "./retained-compaction";
8
- import { closeSync, mkdirSync, openSync, rmSync, writeFileSync } from "node:fs";
9
- import { join } from "node:path";
9
+ import { chmodSync, closeSync, lstatSync, mkdirSync, openSync, opendirSync, rmSync, writeFileSync } from "node:fs";
10
+ import { basename, dirname, join } from "node:path";
11
+ import { getConfigDir } from "../config/paths";
12
+ import { recordOwnedConfigPath } from "../lib/config-ownership";
13
+ import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
10
14
  import { estimateTokens } from "../lib/token-estimate";
11
15
  import { decodeReasoningEnvelope } from "./reasoning-envelope";
12
16
 
13
17
  export const DEFAULT_REASONING_RETENTION_PERCENT = 20;
14
18
  export const DEFAULT_MAX_REASONING_TOKENS = 100_000;
19
+ /** Compiled-in aggregate ceiling for the on-disk reasoning archive directory. */
20
+ export const DEFAULT_ARCHIVE_MAX_BYTES = 512 * 1024 * 1024;
21
+ export const DEFAULT_ARCHIVE_MAX_FILES = 256;
15
22
 
16
23
  export interface ReasoningRetentionOptions {
17
24
  /** Directory that receives archived reasoning files. */
@@ -24,6 +31,10 @@ export interface ReasoningRetentionOptions {
24
31
  maxTokens?: number;
25
32
  /** Clock injection for tests. */
26
33
  now?: number;
34
+ /** Aggregate archive byte ceiling. Defaults to 512 MiB; smaller values are a test seam. */
35
+ archiveMaxBytes?: number;
36
+ /** Aggregate archive file-count ceiling. Defaults to 256; smaller values are a test seam. */
37
+ archiveMaxFiles?: number;
27
38
  }
28
39
 
29
40
  export interface ReasoningArchive {
@@ -100,6 +111,183 @@ function retentionTokenCap(value: number | undefined): number {
100
111
  : DEFAULT_MAX_REASONING_TOKENS;
101
112
  }
102
113
 
114
+ /** Aggregate-capacity refusal: the archive directory is at or over its compiled-in ceiling. */
115
+ export class ReasoningArchiveQuotaError extends Error {
116
+ constructor(message: string, options?: { cause?: unknown }) {
117
+ super(message, options);
118
+ this.name = "ReasoningArchiveQuotaError";
119
+ }
120
+ }
121
+
122
+ /** The sibling archive lock is held by another cooperating writer. */
123
+ export class ReasoningArchiveBusyError extends Error {
124
+ constructor(message: string, options?: { cause?: unknown }) {
125
+ super(message, options);
126
+ this.name = "ReasoningArchiveBusyError";
127
+ }
128
+ }
129
+
130
+ /** Unsafe or uninspectable archive state; the attempt is refused without touching anything. */
131
+ export class ReasoningArchiveError extends Error {
132
+ constructor(message: string, options?: { cause?: unknown }) {
133
+ super(message, options);
134
+ this.name = "ReasoningArchiveError";
135
+ }
136
+ }
137
+
138
+ const ARCHIVE_SEPARATOR = "\n\n---\n\n";
139
+ const ARCHIVE_SEPARATOR_BYTES = Buffer.byteLength(ARCHIVE_SEPARATOR, "utf8");
140
+ const ARCHIVE_LOCK_SIDECARS = ["-journal", "-wal", "-shm"] as const;
141
+
142
+ function archiveCap(value: number | undefined, fallback: number): number {
143
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : fallback;
144
+ }
145
+
146
+ /** lstat that treats a vanished path as absent and refuses anything it cannot classify. */
147
+ function archiveLstat(path: string): ReturnType<typeof lstatSync> | undefined {
148
+ try {
149
+ return lstatSync(path);
150
+ } catch (error) {
151
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
152
+ throw new ReasoningArchiveError(`reasoning archive path is not inspectable: ${path}`, { cause: error });
153
+ }
154
+ }
155
+
156
+ function assertRegularArchiveDirectory(archiveDir: string): void {
157
+ const entry = archiveLstat(archiveDir);
158
+ if (!entry?.isDirectory() || entry.isSymbolicLink()) {
159
+ throw new ReasoningArchiveError(`reasoning archive directory is not a regular directory: ${archiveDir}`);
160
+ }
161
+ }
162
+
163
+ function assertRegularArchiveLockPath(lockPath: string): void {
164
+ const entry = archiveLstat(lockPath);
165
+ if (entry !== undefined && (!entry.isFile() || entry.isSymbolicLink())) {
166
+ throw new ReasoningArchiveError(`reasoning archive lock path is not a regular file: ${lockPath}`);
167
+ }
168
+ }
169
+
170
+ /** Exact UTF-8 byte total of the would-be archive payload, computed before any joined string exists. */
171
+ function archivePayloadBytes(texts: string[]): number {
172
+ let bytes = texts.length > 0 ? (texts.length - 1) * ARCHIVE_SEPARATOR_BYTES : 0;
173
+ for (const text of texts) {
174
+ const next = bytes + Buffer.byteLength(text, "utf8");
175
+ if (!Number.isSafeInteger(next)) {
176
+ throw new ReasoningArchiveError("reasoning archive payload byte size is not representable");
177
+ }
178
+ bytes = next;
179
+ }
180
+ return bytes;
181
+ }
182
+
183
+ interface ArchiveUsage {
184
+ files: number;
185
+ bytes: number;
186
+ }
187
+
188
+ /**
189
+ * Non-recursive accounting of every committed, remnant and foreign file. Anything that is not a
190
+ * plain regular file refuses closed; an entry vanishing mid-scan is already gone and skipped.
191
+ */
192
+ function scanArchiveUsage(archiveDir: string, maxBytes: number, maxFiles: number): ArchiveUsage {
193
+ let directory: ReturnType<typeof opendirSync> | undefined;
194
+ let files = 0;
195
+ let bytes = 0;
196
+ let inspected = 0;
197
+ let failure: ReasoningArchiveError | undefined;
198
+ try {
199
+ // One entry per read: a legacy over-capacity directory must not first become an array
200
+ // or an unbounded read-ahead buffer on this synchronous request path.
201
+ directory = opendirSync(archiveDir, { bufferSize: 1 });
202
+ let entry;
203
+ while ((entry = directory.readSync()) !== null) {
204
+ inspected += 1;
205
+ if (!entry.isFile()) {
206
+ throw new ReasoningArchiveError(`reasoning archive entry is not a regular file: ${entry.name}`);
207
+ }
208
+ const stat = archiveLstat(join(archiveDir, entry.name));
209
+ if (stat === undefined) {
210
+ if (inspected >= maxFiles) throw new ReasoningArchiveError("reasoning archive scan changed beyond its inspection limit");
211
+ continue;
212
+ }
213
+ if (!stat.isFile() || stat.isSymbolicLink()) {
214
+ throw new ReasoningArchiveError(`reasoning archive entry is not a regular file: ${entry.name}`);
215
+ }
216
+ files += 1;
217
+ bytes += Number(stat.size);
218
+ if (files >= maxFiles || bytes > maxBytes) break; // refusal is already certain
219
+ if (inspected >= maxFiles) throw new ReasoningArchiveError("reasoning archive scan changed beyond its inspection limit");
220
+ }
221
+ } catch (error) {
222
+ failure = error instanceof ReasoningArchiveError ? error
223
+ : new ReasoningArchiveError("reasoning archive directory cannot be scanned", { cause: error });
224
+ } finally {
225
+ try { directory?.closeSync(); } catch (error) {
226
+ failure ??= new ReasoningArchiveError("reasoning archive directory could not be closed", { cause: error });
227
+ }
228
+ }
229
+ if (failure !== undefined) throw failure;
230
+ return { files, bytes };
231
+ }
232
+
233
+ function isSqliteBusy(error: unknown): boolean {
234
+ const code = error && typeof error === "object" && "code" in error
235
+ ? String((error as { code?: unknown }).code)
236
+ : "";
237
+ const message = error instanceof Error ? error.message : "";
238
+ return code === "SQLITE_BUSY" || code === "SQLITE_LOCKED" || /database (?:is|table is) locked/i.test(message);
239
+ }
240
+
241
+ /**
242
+ * Serialize usage scan, exclusive create, write and close for one physical archive directory
243
+ * across cooperating processes. The sibling SQLite write transaction is the lock: the OS
244
+ * releases it on process exit, so no stale-owner reclamation is needed. busy_timeout=0 keeps
245
+ * the synchronous request path fail-fast instead of freezing the event loop.
246
+ */
247
+ function withArchiveQuotaLockSync<T>(archiveDir: string, fn: () => T): T {
248
+ const normalized = archiveDir.replace(/[/\\]+$/, "");
249
+ const base = basename(normalized);
250
+ if (!base || base === "." || base === "..") {
251
+ throw new ReasoningArchiveError(`reasoning archive directory is not a usable path: ${archiveDir}`);
252
+ }
253
+ const parent = dirname(normalized);
254
+ assertNotRealHomeUnderTest(parent);
255
+ const lockPath = `${normalized}.lock`;
256
+ // The lock lives beside the archive inside the config home; foreign parents need no manifest entry.
257
+ if (parent === getConfigDir()) {
258
+ recordOwnedConfigPath(parent, lockPath);
259
+ for (const suffix of ARCHIVE_LOCK_SIDECARS) recordOwnedConfigPath(parent, `${lockPath}${suffix}`);
260
+ }
261
+ assertRegularArchiveLockPath(lockPath);
262
+ let database: Database;
263
+ try {
264
+ database = new Database(lockPath, { create: true });
265
+ } catch (error) {
266
+ throw new ReasoningArchiveError(`reasoning archive lock could not be opened: ${lockPath}`, { cause: error });
267
+ }
268
+ try {
269
+ try { chmodSync(lockPath, 0o600); } catch { /* platform may ignore chmod */ }
270
+ assertRegularArchiveLockPath(lockPath);
271
+ database.exec("PRAGMA locking_mode = NORMAL; PRAGMA busy_timeout = 0; BEGIN IMMEDIATE");
272
+ } catch (error) {
273
+ try { database.close(); } catch { /* acquisition already failed */ }
274
+ if (error instanceof ReasoningArchiveError) throw error;
275
+ if (isSqliteBusy(error)) {
276
+ throw new ReasoningArchiveBusyError(
277
+ `reasoning archive lock is held by another writer: ${lockPath}`,
278
+ { cause: error },
279
+ );
280
+ }
281
+ throw new ReasoningArchiveError(`reasoning archive lock could not be acquired: ${lockPath}`, { cause: error });
282
+ }
283
+ try {
284
+ return fn();
285
+ } finally {
286
+ try { database.exec("ROLLBACK"); } catch { /* close below still releases the OS lock */ }
287
+ try { database.close(); } catch { /* the OS lock is released with the handle */ }
288
+ }
289
+ }
290
+
103
291
  /** Separate reasoning locally before dispatch; unknown windows enforce the absolute cap. */
104
292
  export function applyReasoningRetention(
105
293
  input: unknown,
@@ -130,17 +318,36 @@ export function applyReasoningRetention(
130
318
  content: [{ type: "input_text", text }] })),
131
319
  };
132
320
 
133
- mkdirSync(options.archiveDir, { recursive: true, mode: 0o700 });
321
+ const archiveBytes = archiveCap(options.archiveMaxBytes, DEFAULT_ARCHIVE_MAX_BYTES);
322
+ const archiveFiles = archiveCap(options.archiveMaxFiles, DEFAULT_ARCHIVE_MAX_FILES);
323
+ const incomingBytes = archivePayloadBytes(texts);
134
324
  const stamp = new Date(options.now ?? Date.now()).toISOString().replace(/[:.]/g, "-");
135
325
  const path = join(options.archiveDir, "reasoning-" + stamp + "-" + Math.random().toString(36).slice(2, 8) + ".md");
136
- // Only a successful exclusive open grants ownership; an occupied path is never removed.
137
- const fd = openSync(path, "wx", 0o600);
138
- let failure: unknown;
139
- try { writeFileSync(fd, texts.join("\n\n---\n\n"), "utf-8"); } catch (error) { failure = error; }
140
- try { closeSync(fd); } catch (error) { failure ??= error; }
141
- if (failure !== undefined) {
142
- try { rmSync(path); } catch { /* preserve the original I/O error; cleanup is best-effort */ }
143
- throw failure;
326
+ try {
327
+ mkdirSync(options.archiveDir, { recursive: true, mode: 0o700 });
328
+ assertRegularArchiveDirectory(options.archiveDir);
329
+ withArchiveQuotaLockSync(options.archiveDir, () => {
330
+ assertRegularArchiveDirectory(options.archiveDir);
331
+ const usage = scanArchiveUsage(options.archiveDir, archiveBytes, archiveFiles);
332
+ if (usage.files >= archiveFiles || incomingBytes > archiveBytes - usage.bytes) {
333
+ throw new ReasoningArchiveQuotaError(
334
+ `reasoning archive is at capacity: ${usage.files} file(s), ${usage.bytes} byte(s) under ${options.archiveDir}; delete older archives to archive more`,
335
+ );
336
+ }
337
+ // Only a successful exclusive open grants ownership; an occupied path is never removed.
338
+ const fd = openSync(path, "wx", 0o600);
339
+ let failure: unknown;
340
+ try { writeFileSync(fd, texts.join(ARCHIVE_SEPARATOR), "utf-8"); } catch (error) { failure = error; }
341
+ try { closeSync(fd); } catch (error) { failure ??= error; }
342
+ if (failure !== undefined) {
343
+ try { rmSync(path); } catch { /* preserve the original I/O error; cleanup is best-effort */ }
344
+ throw failure;
345
+ }
346
+ });
347
+ } catch (error) {
348
+ if (error instanceof ReasoningArchiveQuotaError || error instanceof ReasoningArchiveBusyError
349
+ || error instanceof ReasoningArchiveError) throw error;
350
+ throw new ReasoningArchiveError("reasoning archive could not be written", { cause: error });
144
351
  }
145
352
 
146
353
  const limitDescription = windowKnown
@@ -27,10 +27,9 @@ const inputFileBlockSchema = z.object({
27
27
  file_data: z.string().optional(),
28
28
  });
29
29
  // codex-rs protocol/src/models.rs sends audio as input_audio with an audio_url, in
30
- // both user content and tool output. Accepting the block keeps a legitimate audio turn
31
- // out of the malformed-item catch-all. The translated IR records only its PRESENCE —
32
- // there is no audio carrier and no adapter-level refusal; a typed unsupported-modality
33
- // signal reaching final adapter dispatch remains a recorded residual.
30
+ // both user content and tool output. The translated IR preserves supported inline
31
+ // WAV/MP3 bytes and the Chat adapter sends them as input_audio; other wires and
32
+ // unsupported formats are refused at the final translated-adapter boundary.
34
33
  const inputAudioBlockSchema = z.object({
35
34
  type: z.literal("input_audio"),
36
35
  audio_url: z.string().min(1),
@@ -91,9 +91,18 @@ export function rewriteReplayedWebSearchCells(input: readonly unknown[]): Rewrit
91
91
  if (!input.some(item => isObj(item) && item.type === "web_search_call")) return undefined;
92
92
  // A synthetic call id must not collide with any client call id, nor with another cell's.
93
93
  const taken = new Set(input.flatMap(item => isObj(item) && typeof item.call_id === "string" ? [item.call_id] : []));
94
+ // First suffix each base has not settled yet; taken stays the collision check on every candidate,
95
+ // so seeded ids and ids handed out under another base still block the smallest free suffix.
96
+ const nextSuffix = new Map<string, number>();
94
97
  const allocate = (base: string): string => {
95
- let id = base;
96
- for (let n = 1; taken.has(id); n++) id = `${base}_${n}`;
98
+ if (!taken.has(base)) {
99
+ taken.add(base);
100
+ return base;
101
+ }
102
+ let n = nextSuffix.get(base) ?? 1;
103
+ let id = `${base}_${n}`;
104
+ while (taken.has(id)) id = `${base}_${++n}`;
105
+ nextSuffix.set(base, n + 1);
97
106
  taken.add(id);
98
107
  return id;
99
108
  };