@bitkyc08/opencodex 2.6.26-preview.20260705 → 2.6.28-preview.20260707

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 (118) hide show
  1. package/README.md +1 -0
  2. package/bin/ocx.mjs +4 -4
  3. package/gui/dist/assets/index-ByGC8-Bm.css +1 -0
  4. package/gui/dist/assets/index-CkV5xFA8.js +15 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +4 -4
  7. package/src/adapters/anthropic-image-guard.ts +195 -0
  8. package/src/adapters/anthropic.ts +85 -14
  9. package/src/adapters/cursor/cursor-errors.ts +2 -2
  10. package/src/adapters/cursor/live-transport.ts +1 -1
  11. package/src/adapters/cursor/transport-retry.ts +2 -2
  12. package/src/adapters/google-errors.ts +1 -1
  13. package/src/adapters/google-http.ts +1 -1
  14. package/src/adapters/google-truncation.ts +1 -1
  15. package/src/adapters/google.ts +1 -1
  16. package/src/adapters/kiro-errors.ts +1 -1
  17. package/src/adapters/kiro-retry.ts +1 -1
  18. package/src/adapters/kiro-truncation.ts +1 -1
  19. package/src/adapters/kiro.ts +1 -1
  20. package/src/adapters/openai-chat.ts +12 -2
  21. package/src/adapters/openai-responses.ts +126 -3
  22. package/src/bridge.ts +164 -7
  23. package/src/{doctor.ts → cli/doctor.ts} +21 -4
  24. package/src/{cli-help.ts → cli/help.ts} +1 -1
  25. package/src/cli/index.ts +584 -0
  26. package/src/{init.ts → cli/init.ts} +6 -6
  27. package/src/{cli-models.ts → cli/models.ts} +2 -2
  28. package/src/{cli-provider.ts → cli/provider.ts} +12 -8
  29. package/src/{star-prompt.ts → cli/star-prompt.ts} +1 -1
  30. package/src/{cli-status.ts → cli/status.ts} +7 -7
  31. package/src/cli.ts +9 -575
  32. package/src/{codex-account-label.ts → codex/account-label.ts} +1 -1
  33. package/src/{codex-account-lifecycle.ts → codex/account-lifecycle.ts} +6 -6
  34. package/src/{codex-account-store.ts → codex/account-store.ts} +2 -2
  35. package/src/{codex-account-usability.ts → codex/account-usability.ts} +4 -4
  36. package/src/{codex-auth-api.ts → codex/auth-api.ts} +18 -18
  37. package/src/{codex-auth-collision.ts → codex/auth-collision.ts} +4 -4
  38. package/src/{codex-auth-context.ts → codex/auth-context.ts} +9 -9
  39. package/src/{codex-catalog.ts → codex/catalog.ts} +49 -20
  40. package/src/codex/history-migration-guardian.ts +102 -0
  41. package/src/{codex-history-provider.ts → codex/history-provider.ts} +111 -7
  42. package/src/{codex-home.ts → codex/home.ts} +1 -1
  43. package/src/{codex-inject.ts → codex/inject.ts} +204 -26
  44. package/src/{codex-journal.ts → codex/journal.ts} +2 -2
  45. package/src/{codex-main-account.ts → codex/main-account.ts} +2 -2
  46. package/src/{model-cache.ts → codex/model-cache.ts} +1 -1
  47. package/src/{codex-paths.ts → codex/paths.ts} +2 -2
  48. package/src/{codex-plugins-doctor.ts → codex/plugins-doctor.ts} +2 -2
  49. package/src/{codex-refresh.ts → codex/refresh.ts} +4 -4
  50. package/src/{codex-routing.ts → codex/routing.ts} +8 -8
  51. package/src/{codex-shim.ts → codex/shim.ts} +6 -5
  52. package/src/{codex-sync.ts → codex/sync.ts} +4 -4
  53. package/src/{codex-websocket-registry.ts → codex/websocket-registry.ts} +1 -1
  54. package/src/config.ts +2 -0
  55. package/src/generated/jawcode-model-metadata.ts +2 -0
  56. package/src/{bun-runtime.ts → lib/bun-runtime.ts} +1 -1
  57. package/src/{crash-guard.ts → lib/crash-guard.ts} +1 -1
  58. package/src/{process-control.ts → lib/process-control.ts} +1 -1
  59. package/src/{service-secrets.ts → lib/service-secrets.ts} +1 -1
  60. package/src/oauth/callback-server.ts +1 -1
  61. package/src/oauth/google-antigravity.ts +7 -4
  62. package/src/oauth/index.ts +67 -16
  63. package/src/oauth/login-cli.ts +2 -2
  64. package/src/oauth/store.ts +236 -20
  65. package/src/oauth/token-guardian.ts +24 -20
  66. package/src/oauth/types.ts +16 -0
  67. package/src/providers/api-keys.ts +121 -0
  68. package/src/{provider-context-cap.ts → providers/context-cap.ts} +1 -1
  69. package/src/providers/derive.ts +2 -0
  70. package/src/providers/key-failover.ts +145 -0
  71. package/src/{provider-label.ts → providers/label.ts} +1 -1
  72. package/src/{provider-quota.ts → providers/quota.ts} +12 -7
  73. package/src/providers/registry.ts +66 -4
  74. package/src/responses/compaction.ts +117 -0
  75. package/src/responses/parser.ts +89 -13
  76. package/src/responses/reasoning-envelope.ts +52 -0
  77. package/src/responses/schema.ts +15 -3
  78. package/src/responses/state.ts +117 -2
  79. package/src/router.ts +2 -0
  80. package/src/server/auth-cors.ts +231 -0
  81. package/src/server/index.ts +523 -0
  82. package/src/server/lifecycle.ts +73 -0
  83. package/src/server/management-api.ts +628 -0
  84. package/src/{proxy-liveness.ts → server/proxy-liveness.ts} +1 -1
  85. package/src/server/relay.ts +534 -0
  86. package/src/server/request-decompress.ts +46 -0
  87. package/src/server/request-log.ts +310 -0
  88. package/src/server/responses.ts +775 -0
  89. package/src/{ws-bridge.ts → server/ws-bridge.ts} +44 -13
  90. package/src/service.ts +19 -11
  91. package/src/types.ts +27 -0
  92. package/src/{update.ts → update/index.ts} +5 -5
  93. package/src/{update-job.ts → update/job.ts} +7 -6
  94. package/src/{update-notify.ts → update/notify.ts} +3 -3
  95. package/src/{usage-debug.ts → usage/debug.ts} +3 -3
  96. package/src/{usage-log.ts → usage/log.ts} +3 -3
  97. package/src/{usage-summary.ts → usage/summary.ts} +3 -3
  98. package/src/{usage-totals.ts → usage/totals.ts} +1 -1
  99. package/src/vision/describe.ts +3 -3
  100. package/src/vision/index.ts +21 -1
  101. package/src/web-search/executor.ts +4 -4
  102. package/src/web-search/index.ts +1 -1
  103. package/src/web-search/loop.ts +84 -24
  104. package/gui/dist/assets/index-BcHhxo1I.css +0 -1
  105. package/gui/dist/assets/index-DCC1q_Jx.js +0 -15
  106. package/src/server.ts +0 -2501
  107. /package/src/{codex-account-runtime-state.ts → codex/account-runtime-state.ts} +0 -0
  108. /package/src/{codex-quota.ts → codex/quota.ts} +0 -0
  109. /package/src/{abort.ts → lib/abort.ts} +0 -0
  110. /package/src/{debug.ts → lib/debug.ts} +0 -0
  111. /package/src/{errors.ts → lib/errors.ts} +0 -0
  112. /package/src/{open-url.ts → lib/open-url.ts} +0 -0
  113. /package/src/{privacy.ts → lib/privacy.ts} +0 -0
  114. /package/src/{redact.ts → lib/redact.ts} +0 -0
  115. /package/src/{sidecar-tracker.ts → lib/sidecar-tracker.ts} +0 -0
  116. /package/src/{upstream-retry.ts → lib/upstream-retry.ts} +0 -0
  117. /package/src/{win-paths.ts → lib/win-paths.ts} +0 -0
  118. /package/src/{ports.ts → server/ports.ts} +0 -0
@@ -12,6 +12,8 @@ import type {
12
12
  } from "../types";
13
13
  import { namespacedToolName } from "../types";
14
14
  import { responsesRequestSchema } from "./schema";
15
+ import { compactionItemToText } from "./compaction";
16
+ import { decodeReasoningEnvelope } from "./reasoning-envelope";
15
17
  import { extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "../web-search/synthetic-tool";
16
18
 
17
19
  function isObj(v: unknown): v is Record<string, unknown> {
@@ -37,7 +39,7 @@ function inputContentParts(blocks: unknown[] | string | undefined): string | Ocx
37
39
  if (b.image_url) {
38
40
  // Preserve the image as a structured part — adapters send it as a native image block.
39
41
  // NEVER inline the (often base64 data-URL) image_url as text: that explodes the token count.
40
- parts.push({ type: "image", imageUrl: b.image_url, ...(b.detail ? { detail: b.detail } : {}) });
42
+ parts.push({ type: "image", imageUrl: b.image_url, ...(b.detail ? { detail: normalizeImageDetail(b.detail) } : {}) });
41
43
  } else {
42
44
  parts.push({ type: "text", text: `[image: ${b.file_id ?? "?"}]` }); // file_id ref → no inline data
43
45
  }
@@ -180,19 +182,30 @@ function outputToToolResultContent(output: string | unknown[] | undefined): stri
180
182
  let hasImage = false;
181
183
  for (const raw of output) {
182
184
  if (!isObj(raw)) continue;
183
- if (raw.type === "output_text" || raw.type === "text") {
185
+ if (raw.type === "output_text" || raw.type === "text" || raw.type === "input_text") {
184
186
  if (typeof raw.text === "string") parts.push({ type: "text", text: raw.text });
185
187
  } else if (raw.type === "refusal" && typeof raw.refusal === "string") {
186
188
  parts.push({ type: "text", text: `[refusal: ${raw.refusal}]` });
187
189
  } else if (raw.type === "input_image" && typeof raw.image_url === "string") {
188
- parts.push({ type: "image", imageUrl: raw.image_url, ...(typeof raw.detail === "string" ? { detail: raw.detail } : {}) });
190
+ parts.push({ type: "image", imageUrl: raw.image_url, ...(typeof raw.detail === "string" ? { detail: normalizeImageDetail(raw.detail) } : {}) });
189
191
  hasImage = true;
192
+ } else if (raw.type === "encrypted_content") {
193
+ // codex-rs FunctionCallOutputContentItem::EncryptedContent — opaque to routed models.
194
+ parts.push({ type: "text", text: "[encrypted content omitted]" });
190
195
  }
191
196
  }
192
197
  if (!hasImage) return parts.map(p => (p.type === "text" ? p.text : "")).join("");
193
198
  return parts;
194
199
  }
195
200
 
201
+ /**
202
+ * codex-rs ImageDetail allows "original", but chat-completions providers only accept
203
+ * auto|low|high on image_url.detail — degrade "original" to "high" (the codex default).
204
+ */
205
+ function normalizeImageDetail(detail: string): string {
206
+ return detail === "original" ? "high" : detail;
207
+ }
208
+
196
209
  function findToolById(messages: OcxMessage[], callId: string): { name: string; namespace?: string } {
197
210
  for (let i = messages.length - 1; i >= 0; i--) {
198
211
  const m = messages[i];
@@ -218,6 +231,9 @@ export function parseRequest(body: unknown): OcxParsedRequest {
218
231
  // Tool specs surfaced by a prior tool_search (deferred tools, e.g. subagents). Codex does not
219
232
  // re-list these in `tools`, but chat models can only call listed tools — so we re-inject them.
220
233
  const loadedToolSpecs: unknown[] = [];
234
+ // Remote compaction v2: the input tail carries `{type:"compaction_trigger"}` and Codex expects a
235
+ // synthetic `{type:"compaction"}` output item (src/responses/compaction.ts). Flagged for the server.
236
+ let compactionRequest = false;
221
237
 
222
238
  if (typeof data.instructions === "string" && data.instructions.length > 0) {
223
239
  systemPrompt.push(data.instructions);
@@ -229,6 +245,27 @@ export function parseRequest(body: unknown): OcxParsedRequest {
229
245
  for (const item of data.input) {
230
246
  const effectiveType = (item as { type?: string }).type ?? ("role" in item ? "message" : undefined);
231
247
 
248
+ if (effectiveType === "compaction_trigger") {
249
+ compactionRequest = true;
250
+ continue;
251
+ }
252
+
253
+ if (effectiveType === "compaction" || effectiveType === "compaction_summary" || effectiveType === "context_compaction") {
254
+ // A stored summary from a previous compaction. Decode our ocx1 envelope into plain text so
255
+ // the routed model keeps the compacted context; real OpenAI-encrypted blobs degrade to a note.
256
+ // `context_compaction` (encrypted_content optional) is codex-rs's local-compaction marker;
257
+ // with no payload it is a pure marker (the summary follows as its own user message), so it
258
+ // is dropped silently. It must NOT flag _compactionRequest.
259
+ const encrypted = (item as { encrypted_content?: unknown }).encrypted_content;
260
+ if (effectiveType === "context_compaction" && typeof encrypted !== "string") continue;
261
+ messages.push({
262
+ role: "user",
263
+ content: compactionItemToText(typeof encrypted === "string" ? encrypted : undefined),
264
+ timestamp: now,
265
+ });
266
+ continue;
267
+ }
268
+
232
269
  if (effectiveType === "message") {
233
270
  const msg = item as { role?: string; content?: unknown };
234
271
  switch (msg.role) {
@@ -254,13 +291,20 @@ export function parseRequest(body: unknown): OcxParsedRequest {
254
291
  }
255
292
 
256
293
  if (effectiveType === "reasoning") {
257
- const reasoning = item as { id?: string; summary?: { text: string }[]; content?: { text: string }[] };
294
+ const reasoning = item as { id?: string; summary?: { text: string }[]; content?: { text: string }[]; encrypted_content?: string };
258
295
  const fromSummary = (reasoning.summary ?? []).map(c => c.text).join("");
259
296
  const text = fromSummary || (reasoning.content ?? []).map(c => c.text).join("");
297
+ // ocxr1 envelope: the REAL Anthropic signature (+ redacted blocks, + hidden signed text)
298
+ // captured by the bridge. Native OpenAI-encrypted blobs decode to null and keep today's
299
+ // placeholder signature (which the anthropic adapter correctly rejects on replay).
300
+ const envelope = typeof reasoning.encrypted_content === "string"
301
+ ? decodeReasoningEnvelope(reasoning.encrypted_content)
302
+ : null;
260
303
  const thinking: OcxThinkingContent = {
261
304
  type: "thinking",
262
- thinking: text,
263
- signature: JSON.stringify(reasoning),
305
+ thinking: envelope?.txt || text,
306
+ signature: envelope?.sig ?? JSON.stringify(reasoning),
307
+ ...(envelope?.red ? { redacted: envelope.red } : {}),
264
308
  ...(reasoning.id ? { itemId: reasoning.id } : {}),
265
309
  };
266
310
  ensureAssistantPlaceholder(messages, data.model, now).content.push(thinking);
@@ -302,6 +346,32 @@ export function parseRequest(body: unknown): OcxParsedRequest {
302
346
  continue;
303
347
  }
304
348
 
349
+ if (effectiveType === "local_shell_call") {
350
+ // codex-rs LocalShellCall replay: pair it as an assistant toolCall so the subsequent
351
+ // function_call_output (same call_id) doesn't become an orphaned tool result.
352
+ const call = item as { id?: string; call_id?: string; action?: { type?: string; command?: string[] } };
353
+ const callId = call.call_id ?? call.id;
354
+ if (callId) {
355
+ const command = Array.isArray(call.action?.command) ? call.action.command : [];
356
+ ensureAssistantPlaceholder(messages, data.model, now).content.push({
357
+ type: "toolCall", id: callId, name: "shell",
358
+ arguments: command.length > 0 ? { command } : {},
359
+ });
360
+ }
361
+ continue;
362
+ }
363
+
364
+ if (effectiveType === "web_search_call") {
365
+ // Replayed hosted web-search evidence. Textify it into assistant history so the model
366
+ // knows the search already ran (prevents re-search loops); there is no output to pair.
367
+ const call = item as { action?: { type?: string; query?: string } };
368
+ const query = typeof call.action?.query === "string" ? call.action.query : "";
369
+ ensureAssistantPlaceholder(messages, data.model, now).content.push({
370
+ type: "text", text: query ? `[web search performed: ${query}]` : "[web search performed]",
371
+ });
372
+ continue;
373
+ }
374
+
305
375
  if (effectiveType === "tool_search_call") {
306
376
  // Preserve the model's prior tool_search call as an assistant tool call so multi-turn
307
377
  // history stays complete (otherwise the model re-issues tool_search forever).
@@ -316,7 +386,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
316
386
 
317
387
  if (effectiveType === "tool_search_output") {
318
388
  // Pair the tool_search call with its result so the model sees what was loaded.
319
- const out = item as { call_id?: string; tools?: unknown[] };
389
+ const out = item as { call_id?: string; status?: string; tools?: unknown[] };
320
390
  const specs = Array.isArray(out.tools) ? (out.tools as Record<string, unknown>[]) : [];
321
391
  loadedToolSpecs.push(...specs);
322
392
  // List the EXACT wire names the model must call (flattened for namespaced specs), matching
@@ -331,12 +401,15 @@ export function parseRequest(body: unknown): OcxParsedRequest {
331
401
  wireNames.push(spec.name);
332
402
  }
333
403
  }
404
+ const failed = typeof out.status === "string" && out.status !== "completed" && out.status !== "success";
334
405
  messages.push({
335
406
  role: "toolResult", toolCallId: out.call_id ?? "", toolName: "tool_search",
336
- content: wireNames.length
337
- ? `Tool search loaded these tools — they are now in your available tools. Call one by its EXACT name: ${wireNames.join(", ")}.`
338
- : "Tool search returned no tools.",
339
- isError: false, timestamp: now,
407
+ content: failed && wireNames.length === 0
408
+ ? `Tool search failed (status: ${out.status}).`
409
+ : wireNames.length
410
+ ? `Tool search loaded these tools — they are now in your available tools. Call one by its EXACT name: ${wireNames.join(", ")}.`
411
+ : "Tool search returned no tools.",
412
+ isError: failed && wireNames.length === 0, timestamp: now,
340
413
  });
341
414
  continue;
342
415
  }
@@ -353,12 +426,14 @@ export function parseRequest(body: unknown): OcxParsedRequest {
353
426
  }
354
427
 
355
428
  if (effectiveType === "custom_tool_call_output") {
356
- const output = item as { call_id: string; output: string };
429
+ const output = item as { call_id: string; output: string | unknown[] };
357
430
  const toolInfo = findToolById(messages, output.call_id);
358
431
  messages.push({
359
432
  role: "toolResult", toolCallId: output.call_id,
360
433
  toolName: toolInfo.name, toolNamespace: toolInfo.namespace,
361
- content: output.output ?? "", isError: false, timestamp: now,
434
+ // Same payload shape as function_call_output (codex-rs FunctionCallOutputPayload):
435
+ // string or content items — normalize arrays instead of leaking raw wire blocks.
436
+ content: outputToToolResultContent(output.output), isError: false, timestamp: now,
362
437
  });
363
438
  }
364
439
  }
@@ -416,6 +491,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
416
491
  _rawBody: body,
417
492
  ...(webSearch ? { _webSearch: webSearch } : {}),
418
493
  ...(structuredOutput ? { _structuredOutput: true } : {}),
494
+ ...(compactionRequest ? { _compactionRequest: true } : {}),
419
495
  };
420
496
  }
421
497
 
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Anthropic extended-thinking signature round-trip through Codex's `encrypted_content` slot.
3
+ *
4
+ * Anthropic requires the previous assistant turn's `thinking`/`redacted_thinking` blocks to be
5
+ * replayed VERBATIM (with their signatures) while extended thinking is enabled; a signature-less
6
+ * replay 400s ("Expected `thinking` or `redacted_thinking`, but found `tool_use`"). Codex round-trips
7
+ * whatever `encrypted_content` a reasoning output item carries (include: reasoning.encrypted_content
8
+ * is set whenever reasoning is on — codex-rs client.rs), so the proxy smuggles the real Anthropic
9
+ * signature (and any redacted blocks) inside a transparent `ocxr1:` + base64(JSON) envelope.
10
+ *
11
+ * Native OpenAI-encrypted blobs (no ocxr1 prefix) are left untouched by the decoder, and the
12
+ * passthrough scrub strips ocxr1 envelopes before native forwarding.
13
+ */
14
+
15
+ export const OCX_REASONING_PREFIX = "ocxr1:";
16
+
17
+ export interface ReasoningEnvelope {
18
+ /** Anthropic thinking-block signature (signature_delta), if captured. */
19
+ sig?: string;
20
+ /** Raw redacted_thinking block data payloads, order preserved. */
21
+ red?: string[];
22
+ /**
23
+ * Hidden thinking text (hideThinkingSummary providers): the signature signs this exact text,
24
+ * so replay needs it even though the visible summary was suppressed.
25
+ */
26
+ txt?: string;
27
+ }
28
+
29
+ export function encodeReasoningEnvelope(envelope: ReasoningEnvelope): string {
30
+ return OCX_REASONING_PREFIX + Buffer.from(JSON.stringify(envelope), "utf-8").toString("base64");
31
+ }
32
+
33
+ /** Decode an ocxr1 envelope; returns null for native (OpenAI-encrypted) blobs or garbage. */
34
+ export function decodeReasoningEnvelope(encryptedContent: string): ReasoningEnvelope | null {
35
+ if (!encryptedContent.startsWith(OCX_REASONING_PREFIX)) return null;
36
+ try {
37
+ const parsed: unknown = JSON.parse(Buffer.from(encryptedContent.slice(OCX_REASONING_PREFIX.length), "base64").toString("utf-8"));
38
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
39
+ const obj = parsed as { sig?: unknown; red?: unknown };
40
+ const envelope: ReasoningEnvelope = {};
41
+ if (typeof obj.sig === "string") envelope.sig = obj.sig;
42
+ if (Array.isArray(obj.red)) {
43
+ const red = obj.red.filter((r): r is string => typeof r === "string");
44
+ if (red.length > 0) envelope.red = red;
45
+ }
46
+ const txt = (parsed as { txt?: unknown }).txt;
47
+ if (typeof txt === "string" && txt.length > 0) envelope.txt = txt;
48
+ return envelope.sig || envelope.red || envelope.txt ? envelope : null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
@@ -4,7 +4,8 @@ const inputTextSchema = z.object({ type: z.literal("input_text"), text: z.string
4
4
  const plainTextSchema = z.object({ type: z.literal("text"), text: z.string() });
5
5
  const inputImageBlockSchema = z.object({
6
6
  type: z.literal("input_image"),
7
- detail: z.enum(["auto", "low", "high"]).optional(),
7
+ // codex-rs ImageDetail: auto|low|high|original (view_image --detail original).
8
+ detail: z.enum(["auto", "low", "high", "original"]).optional(),
8
9
  image_url: z.string().optional(),
9
10
  file_id: z.string().optional(),
10
11
  }).refine(v => typeof v.image_url === "string" || typeof v.file_id === "string", {
@@ -20,9 +21,17 @@ const outputTextSchema = z.object({ type: z.literal("output_text"), text: z.stri
20
21
  const outputRefusalSchema = z.object({ type: z.literal("refusal"), refusal: z.string() });
21
22
  const summaryTextSchema = z.object({ type: z.literal("summary_text"), text: z.string() });
22
23
  const reasoningTextSchema = z.object({ type: z.literal("reasoning_text"), text: z.string() });
24
+ // codex-rs FunctionCallOutputContentItem (protocol/src/models.rs): input_text | input_image | encrypted_content.
25
+ const encryptedContentBlockSchema = z.object({ type: z.literal("encrypted_content"), encrypted_content: z.string() });
23
26
 
24
27
  const inputContentBlockSchema = z.union([inputTextSchema, plainTextSchema, inputImageBlockSchema, inputFileBlockSchema]);
25
28
  const outputContentBlockSchema = z.union([outputTextSchema, plainTextSchema, outputRefusalSchema]);
29
+ // Tool outputs on the wire mix codex-rs FunctionCallOutputContentItem with legacy output blocks.
30
+ const toolOutputContentBlockSchema = z.union([
31
+ outputTextSchema, plainTextSchema, outputRefusalSchema,
32
+ inputTextSchema, inputImageBlockSchema, encryptedContentBlockSchema,
33
+ ]);
34
+ const toolOutputSchema = z.union([z.string(), z.array(toolOutputContentBlockSchema)]);
26
35
 
27
36
  const userMessageItemSchema = z.object({
28
37
  type: z.literal("message").optional(),
@@ -44,6 +53,8 @@ const reasoningItemSchema = z.object({
44
53
  id: z.string().optional(),
45
54
  summary: z.array(summaryTextSchema).optional(),
46
55
  content: z.array(reasoningTextSchema).optional(),
56
+ // Round-tripped opaque payload (native OpenAI encryption OR the proxy's ocxr1 envelope).
57
+ encrypted_content: z.string().optional(),
47
58
  });
48
59
  const functionCallItemSchema = z.object({
49
60
  type: z.literal("function_call"),
@@ -56,7 +67,7 @@ const functionCallItemSchema = z.object({
56
67
  const functionCallOutputItemSchema = z.object({
57
68
  type: z.literal("function_call_output"),
58
69
  call_id: z.string().min(1),
59
- output: z.union([z.string(), z.array(z.union([outputContentBlockSchema, inputImageBlockSchema]))]).optional(),
70
+ output: toolOutputSchema.optional(),
60
71
  });
61
72
  const customToolCallItemSchema = z.object({
62
73
  type: z.literal("custom_tool_call"),
@@ -68,7 +79,8 @@ const customToolCallItemSchema = z.object({
68
79
  const customToolCallOutputItemSchema = z.object({
69
80
  type: z.literal("custom_tool_call_output"),
70
81
  call_id: z.string().min(1),
71
- output: z.string(),
82
+ // codex-rs CustomToolCallOutput carries FunctionCallOutputPayload: string OR content items.
83
+ output: toolOutputSchema,
72
84
  });
73
85
 
74
86
  export const inputItemSchema = z.union([
@@ -1,5 +1,14 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { atomicWriteFile, getConfigDir } from "../config";
4
+
1
5
  const MAX_STORED_RESPONSES = 1_000;
2
6
  const RESPONSE_TTL_MS = 60 * 60 * 1_000;
7
+ const SNAPSHOT_DEBOUNCE_MS = 2_000;
8
+ /** Entries whose serialized size exceeds this are kept in memory but skipped on disk: inputs can
9
+ * carry base64 `input_image` data URLs, and one screenshot-heavy thread must not balloon the file. */
10
+ const SNAPSHOT_ENTRY_MAX_BYTES = 2 * 1024 * 1024;
11
+ const SNAPSHOT_TOTAL_MAX_BYTES = 24 * 1024 * 1024;
3
12
 
4
13
  interface StoredResponseState {
5
14
  createdAt: number;
@@ -9,11 +18,92 @@ interface StoredResponseState {
9
18
  }
10
19
 
11
20
  const states = new Map<string, StoredResponseState>();
21
+ let loaded = false;
22
+ let persistTimer: ReturnType<typeof setTimeout> | null = null;
23
+ let pendingPersistPath: string | null = null;
12
24
 
13
25
  function now(): number {
14
26
  return Date.now();
15
27
  }
16
28
 
29
+ function snapshotPath(): string {
30
+ return join(getConfigDir(), "responses-state.json");
31
+ }
32
+
33
+ /**
34
+ * Best-effort disk snapshot so previous_response_id chains survive a proxy restart (the
35
+ * dominant expansion-miss cause: an in-memory-only store dies with the process, and the next
36
+ * chained turn then reaches the upstream as a naked delta). Load is lazy on first store access;
37
+ * persistence is debounced + unref'd so the hot path never blocks and the process can exit.
38
+ * Every disk failure is swallowed — the snapshot is a cache, not a source of truth.
39
+ */
40
+ function ensureLoaded(): void {
41
+ if (loaded) return;
42
+ loaded = true;
43
+ try {
44
+ const path = snapshotPath();
45
+ if (!existsSync(path)) return;
46
+ const raw = JSON.parse(readFileSync(path, "utf-8")) as { version?: unknown; states?: unknown };
47
+ if (raw.version !== 1 || !Array.isArray(raw.states)) return;
48
+ for (const entry of raw.states) {
49
+ if (!Array.isArray(entry) || entry.length !== 2) continue;
50
+ const [id, state] = entry as [unknown, unknown];
51
+ if (typeof id !== "string" || !state || typeof state !== "object") continue;
52
+ const rec = state as StoredResponseState;
53
+ if (typeof rec.createdAt !== "number" || !Array.isArray(rec.items)) continue;
54
+ states.set(id, rec);
55
+ }
56
+ pruneResponses();
57
+ } catch {
58
+ /* missing/corrupt snapshot: start empty */
59
+ }
60
+ }
61
+
62
+ function persistNow(path: string): void {
63
+ if (persistTimer) {
64
+ clearTimeout(persistTimer);
65
+ persistTimer = null;
66
+ }
67
+ pendingPersistPath = null;
68
+ try {
69
+ const entries: [string, StoredResponseState][] = [];
70
+ let total = 0;
71
+ // Newest-first so the most recent chains survive both caps.
72
+ for (const entry of [...states].reverse()) {
73
+ const size = JSON.stringify(entry).length;
74
+ if (size > SNAPSHOT_ENTRY_MAX_BYTES) continue;
75
+ if (total + size > SNAPSHOT_TOTAL_MAX_BYTES) break;
76
+ total += size;
77
+ entries.push(entry);
78
+ }
79
+ entries.reverse();
80
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
81
+ // mkdirSync's mode only applies on creation — re-harden an existing config dir so the
82
+ // conversation-content snapshot never lands in a group/world-readable directory.
83
+ try { chmodSync(dirname(path), 0o700); } catch { /* best-effort (e.g. Windows) */ }
84
+ atomicWriteFile(path, JSON.stringify({ version: 1, states: entries }));
85
+ } catch {
86
+ /* best-effort: disk trouble must never affect request handling */
87
+ }
88
+ }
89
+
90
+ function schedulePersist(): void {
91
+ if (persistTimer) return;
92
+ // Resolve the target path NOW: tests (and anything else) may swap OPENCODEX_HOME before the
93
+ // debounce fires, and a late write must land in the home that owned the recorded state.
94
+ pendingPersistPath = snapshotPath();
95
+ const path = pendingPersistPath;
96
+ persistTimer = setTimeout(() => persistNow(path), SNAPSHOT_DEBOUNCE_MS);
97
+ (persistTimer as { unref?: () => void }).unref?.();
98
+ }
99
+
100
+ /** Flush any pending debounced snapshot write (graceful shutdown / deterministic tests). */
101
+ export function flushResponseState(): void {
102
+ if (!persistTimer) return;
103
+ // Use the path captured when the write was scheduled — OPENCODEX_HOME may have moved since.
104
+ persistNow(pendingPersistPath ?? snapshotPath());
105
+ }
106
+
17
107
  function inputItems(input: unknown): unknown[] {
18
108
  if (input === undefined) return [];
19
109
  if (Array.isArray(input)) return input;
@@ -37,6 +127,7 @@ export function expandPreviousResponseInput(body: unknown): unknown {
37
127
  const request = body as Record<string, unknown>;
38
128
  const previousId = typeof request.previous_response_id === "string" ? request.previous_response_id : undefined;
39
129
  if (!previousId) return body;
130
+ ensureLoaded();
40
131
  pruneResponses();
41
132
  const previous = states.get(previousId);
42
133
  if (!previous) return body;
@@ -48,6 +139,7 @@ export function expandPreviousResponseInput(body: unknown): unknown {
48
139
 
49
140
  export function previousResponseConversationId(responseId: string | undefined): string | undefined {
50
141
  if (!responseId) return undefined;
142
+ ensureLoaded();
51
143
  pruneResponses();
52
144
  return states.get(responseId)?.conversationId;
53
145
  }
@@ -56,12 +148,19 @@ export function rememberResponseState(
56
148
  requestBody: unknown,
57
149
  response: { id?: unknown; output?: unknown; status?: unknown },
58
150
  conversationId?: string,
151
+ opts?: { force?: boolean },
59
152
  ): void {
60
153
  if (!requestBody || typeof requestBody !== "object" || Array.isArray(requestBody)) return;
61
154
  const request = requestBody as Record<string, unknown>;
62
- if (request.store === false) return;
155
+ // `force` bypasses only the store:false skip: Codex sends `store:false` on every non-Azure
156
+ // HTTP request (and WS inherits it), yet its WS turns still chain with previous_response_id.
157
+ // The passthrough branch records with force so those chains can be expanded locally; the
158
+ // store stays in-memory with a 1h TTL, so this is a proxy-internal continuation cache, not
159
+ // real server-side response storage.
160
+ if (request.store === false && !opts?.force) return;
63
161
  if (typeof response.id !== "string" || !Array.isArray(response.output)) return;
64
162
  if (response.status !== undefined && response.status !== "completed") return;
163
+ ensureLoaded();
65
164
  states.set(response.id, {
66
165
  createdAt: now(),
67
166
  items: [...inputItems(request.input), ...response.output],
@@ -76,8 +175,24 @@ export function rememberResponseState(
76
175
  }),
77
176
  });
78
177
  pruneResponses();
178
+ schedulePersist();
79
179
  }
80
180
 
81
- export function clearResponseStateForTests(): void {
181
+ /** Memory-only reset (simulates a process restart: the snapshot file survives). */
182
+ export function clearResponseStateMemoryForTests(): void {
183
+ if (persistTimer) {
184
+ clearTimeout(persistTimer);
185
+ persistTimer = null;
186
+ }
82
187
  states.clear();
188
+ loaded = false;
189
+ }
190
+
191
+ export function clearResponseStateForTests(): void {
192
+ clearResponseStateMemoryForTests();
193
+ try {
194
+ unlinkSync(snapshotPath());
195
+ } catch {
196
+ /* no snapshot on disk */
197
+ }
83
198
  }
package/src/router.ts CHANGED
@@ -96,6 +96,7 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
96
96
  const noPenaltyModels = mergeStringArray(registryEntry.noPenaltyModels, provider.noPenaltyModels);
97
97
  const autoToolChoiceOnlyModels = mergeStringArray(registryEntry.autoToolChoiceOnlyModels, provider.autoToolChoiceOnlyModels);
98
98
  const preserveReasoningContentModels = mergeStringArray(registryEntry.preserveReasoningContentModels, provider.preserveReasoningContentModels);
99
+ const thinkingToggleModels = mergeStringArray(registryEntry.thinkingToggleModels, provider.thinkingToggleModels);
99
100
 
100
101
  return {
101
102
  ...provider,
@@ -124,6 +125,7 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
124
125
  ...(noPenaltyModels ? { noPenaltyModels } : {}),
125
126
  ...(autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels } : {}),
126
127
  ...(preserveReasoningContentModels ? { preserveReasoningContentModels } : {}),
128
+ ...(thinkingToggleModels ? { thinkingToggleModels } : {}),
127
129
  };
128
130
  }
129
131