@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
@@ -1,11 +1,12 @@
1
1
  import type { ServerWebSocket } from "bun";
2
- import { FORWARD_HEADERS } from "./adapters/openai-responses";
3
- import type { CodexAuthContext } from "./codex-auth-context";
4
- import { headersForCodexAuthContext } from "./codex-auth-context";
5
- import type { ResponsesTerminalStatus } from "./bridge";
2
+ import { FORWARD_HEADERS } from "../adapters/openai-responses";
3
+ import type { CodexAuthContext } from "../codex/auth-context";
4
+ import { headersForCodexAuthContext } from "../codex/auth-context";
5
+ import type { ResponsesTerminalStatus } from "../bridge";
6
6
 
7
7
  const OPEN = 1;
8
8
  type ResponsesTerminalReporter = (status: ResponsesTerminalStatus) => void;
9
+ type ResponsesPayloadObserver = (payload: string) => void;
9
10
  const SAFE_RESPONSE_HEADER_EXACT = new Set([
10
11
  "retry-after",
11
12
  "x-request-id",
@@ -169,7 +170,11 @@ function sendProtocolError(ws: ServerWebSocket<WsData>, status: number, message:
169
170
  export async function pumpResponsesSseToWebSocket(
170
171
  ws: ServerWebSocket<WsData>,
171
172
  sseStream: ReadableStream<Uint8Array>,
172
- options: { isCurrent?: () => boolean; onTerminal?: ResponsesTerminalReporter } = {},
173
+ options: {
174
+ isCurrent?: () => boolean;
175
+ onTerminal?: ResponsesTerminalReporter;
176
+ onSsePayload?: ResponsesPayloadObserver;
177
+ } = {},
173
178
  ): Promise<void> {
174
179
  const reader = sseStream.getReader();
175
180
  const isCurrent = options.isCurrent ?? (() => true);
@@ -193,6 +198,11 @@ export async function pumpResponsesSseToWebSocket(
193
198
  const handlePayload = (payload: string): boolean => {
194
199
  if (!isCurrent()) return true;
195
200
  if (payload === "[DONE]") return false;
201
+ try {
202
+ options.onSsePayload?.(payload);
203
+ } catch {
204
+ /* payload observation must not affect WebSocket delivery */
205
+ }
196
206
  const type = payloadType(payload);
197
207
  if (!type) {
198
208
  reportTerminal("incomplete");
@@ -248,14 +258,24 @@ export function sendResponsesJsonAsEvents(
248
258
  ws: ServerWebSocket<WsData>,
249
259
  response: Record<string, unknown>,
250
260
  onTerminal?: ResponsesTerminalReporter,
261
+ onPayload?: ResponsesPayloadObserver,
251
262
  ): void {
263
+ const sendObservedFrame = (payload: Record<string, unknown>) => {
264
+ const text = JSON.stringify(payload);
265
+ try {
266
+ onPayload?.(text);
267
+ } catch {
268
+ /* payload observation must not affect WebSocket delivery */
269
+ }
270
+ sendTextFrame(ws, text);
271
+ };
252
272
  const output = Array.isArray(response.output) ? response.output : [];
253
- sendJsonFrame(ws, {
273
+ sendObservedFrame({
254
274
  type: "response.created",
255
275
  response: { ...response, status: "in_progress", output: [] },
256
276
  });
257
277
  output.forEach((item, outputIndex) => {
258
- sendJsonFrame(ws, {
278
+ sendObservedFrame({
259
279
  type: "response.output_item.done",
260
280
  output_index: outputIndex,
261
281
  item,
@@ -264,7 +284,7 @@ export function sendResponsesJsonAsEvents(
264
284
  const finalStatus = response.status === "failed" || response.status === "incomplete"
265
285
  ? response.status
266
286
  : "completed";
267
- sendJsonFrame(ws, {
287
+ sendObservedFrame({
268
288
  type: `response.${finalStatus}` as "response.completed" | "response.failed" | "response.incomplete",
269
289
  response: { ...response, status: finalStatus },
270
290
  });
@@ -290,7 +310,10 @@ export async function sendResponseToWebSocket(
290
310
  ws: ServerWebSocket<WsData>,
291
311
  response: Response,
292
312
  isCurrent: () => boolean,
293
- options: { onTerminal?: ResponsesTerminalReporter } = {},
313
+ options: {
314
+ onTerminal?: ResponsesTerminalReporter;
315
+ onSsePayload?: ResponsesPayloadObserver;
316
+ } = {},
294
317
  ): Promise<void> {
295
318
  if (!isCurrent()) {
296
319
  await response.body?.cancel().catch(() => {});
@@ -316,7 +339,11 @@ export async function sendResponseToWebSocket(
316
339
  }
317
340
 
318
341
  if (contentType.includes("text/event-stream")) {
319
- await pumpResponsesSseToWebSocket(ws, response.body, { isCurrent, onTerminal: options.onTerminal });
342
+ await pumpResponsesSseToWebSocket(ws, response.body, {
343
+ isCurrent,
344
+ onTerminal: options.onTerminal,
345
+ onSsePayload: options.onSsePayload,
346
+ });
320
347
  return;
321
348
  }
322
349
 
@@ -324,7 +351,7 @@ export async function sendResponseToWebSocket(
324
351
  const text = await response.text();
325
352
  if (!isCurrent()) return;
326
353
  const json = JSON.parse(text) as Record<string, unknown>;
327
- sendResponsesJsonAsEvents(ws, json, options.onTerminal);
354
+ sendResponsesJsonAsEvents(ws, json, options.onTerminal, options.onSsePayload);
328
355
  return;
329
356
  }
330
357
 
@@ -334,7 +361,11 @@ export async function sendResponseToWebSocket(
334
361
  return;
335
362
  }
336
363
  if (looksLikeSse(prefix)) {
337
- await pumpResponsesSseToWebSocket(ws, stream, { isCurrent, onTerminal: options.onTerminal });
364
+ await pumpResponsesSseToWebSocket(ws, stream, {
365
+ isCurrent,
366
+ onTerminal: options.onTerminal,
367
+ onSsePayload: options.onSsePayload,
368
+ });
338
369
  return;
339
370
  }
340
371
 
@@ -343,7 +374,7 @@ export async function sendResponseToWebSocket(
343
374
  const trimmed = text.trim();
344
375
  if (trimmed.startsWith("{")) {
345
376
  const json = JSON.parse(trimmed) as Record<string, unknown>;
346
- sendResponsesJsonAsEvents(ws, json, options.onTerminal);
377
+ sendResponsesJsonAsEvents(ws, json, options.onTerminal, options.onSsePayload);
347
378
  return;
348
379
  }
349
380
 
package/src/service.ts CHANGED
@@ -11,11 +11,11 @@ import { homedir } from "node:os";
11
11
  import { dirname, join, resolve } from "node:path";
12
12
  import { expandUserPath, getConfigDir, readPid, removePid, removeRuntimePort } from "./config";
13
13
  import { loadConfig } from "./config";
14
- import { restoreNativeCodex } from "./codex-inject";
15
- import { durableBunPath, durableBunRuntime } from "./bun-runtime";
16
- import { isProcessAlive, stopProxy } from "./process-control";
17
- import { serviceApiTokenFilePath } from "./service-secrets";
18
- import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "./win-paths";
14
+ import { restoreNativeCodex } from "./codex/inject";
15
+ import { durableBunPath, durableBunRuntime } from "./lib/bun-runtime";
16
+ import { isProcessAlive, stopProxy } from "./lib/process-control";
17
+ import { serviceApiTokenFilePath } from "./lib/service-secrets";
18
+ import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "./lib/win-paths";
19
19
 
20
20
  const LABEL = "com.opencodex.proxy";
21
21
  const TASK = "opencodex-proxy";
@@ -23,8 +23,8 @@ const TASK = "opencodex-proxy";
23
23
  function cliEntry(): { bun: string; cli: string } {
24
24
  // Bake the bundled Bun (npm global prefix, survives `ocx update`) rather than
25
25
  // a transient system Bun, so launchd/systemd/schtasks keep resolving even if a
26
- // standalone Bun is later removed. cli.ts sits next to this module.
27
- return { bun: durableBunPath(), cli: join(import.meta.dir, "cli.ts") };
26
+ // standalone Bun is later removed. The CLI entry lives at src/cli/index.ts.
27
+ return { bun: durableBunPath(), cli: join(import.meta.dir, "cli", "index.ts") };
28
28
  }
29
29
 
30
30
  function plistPath(): string {
@@ -709,8 +709,11 @@ export async function serviceCommand(sub?: string): Promise<void> {
709
709
  assertServiceEnvironmentMatchesInstall();
710
710
  ops.stop();
711
711
  await stopTrackedProxyForServiceCommand();
712
- restoreNativeCodex();
713
- console.log("✅ service stopped + native Codex restored.");
712
+ {
713
+ const restore = restoreNativeCodex();
714
+ if (restore.success) console.log("✅ service stopped + native Codex restored.");
715
+ else console.error(`⚠️ service stopped, but native Codex restore FAILED: ${restore.message}\nRun \`ocx restore\` (or check $CODEX_HOME/config.toml) before using native Codex.`);
716
+ }
714
717
  break;
715
718
  case "status": {
716
719
  const s = ops.status();
@@ -724,10 +727,15 @@ export async function serviceCommand(sub?: string): Promise<void> {
724
727
  ops.stop();
725
728
  await stopTrackedProxyForServiceCommand();
726
729
  ops.uninstall();
727
- restoreNativeCodex();
730
+ {
731
+ const restore = restoreNativeCodex();
732
+ if (!restore.success) {
733
+ console.error(`⚠️ native Codex restore FAILED: ${restore.message}\nRun \`ocx restore\` before using native Codex.`);
734
+ }
735
+ }
728
736
  removeServiceInstallState();
729
737
  try { if (existsSync(serviceApiTokenFilePath())) unlinkSync(serviceApiTokenFilePath()); } catch { /* best-effort */ }
730
- console.log("✅ service uninstalled + native Codex restored.");
738
+ console.log("✅ service uninstalled.");
731
739
  break;
732
740
  default:
733
741
  console.error("Usage: ocx service [install|start|stop|status|uninstall|remove]");
package/src/types.ts CHANGED
@@ -5,6 +5,8 @@ export interface OcxParsedRequest {
5
5
  stream: boolean;
6
6
  options: OcxRequestOptions;
7
7
  _rawBody?: unknown;
8
+ /** True when the proxy expanded a previous_response_id request into a full input replay. */
9
+ _previousResponseInputExpanded?: boolean;
8
10
  /** Provider-private stable Cursor conversation id resolved from the Responses previous_response_id chain. */
9
11
  _cursorConversationId?: string;
10
12
  /**
@@ -19,6 +21,13 @@ export interface OcxParsedRequest {
19
21
  * answer/"Sources:" text can't bleed into and corrupt the model's schema-constrained output.
20
22
  */
21
23
  _structuredOutput?: boolean;
24
+ /**
25
+ * True when the input carried `{type:"compaction_trigger"}` — Codex remote compaction v2 asking
26
+ * this turn to produce a `{type:"compaction"}` output item. Routed adapters can't natively;
27
+ * the server runs the model as a summarizer and the bridge emits a synthetic compaction item
28
+ * (see src/responses/compaction.ts).
29
+ */
30
+ _compactionRequest?: boolean;
22
31
  }
23
32
 
24
33
  export interface OcxContext {
@@ -85,6 +94,8 @@ export interface OcxThinkingContent {
85
94
  thinking: string;
86
95
  signature?: string;
87
96
  itemId?: string;
97
+ /** Raw Anthropic redacted_thinking block payloads to replay verbatim (order preserved). */
98
+ redacted?: string[];
88
99
  }
89
100
 
90
101
  export interface OcxToolCall {
@@ -182,6 +193,10 @@ export type AdapterEvent =
182
193
  | { type: "heartbeat" }
183
194
  | { type: "text_delta"; text: string }
184
195
  | { type: "thinking_delta"; thinking: string }
196
+ // Anthropic extended-thinking round-trip: signature_delta for the current thinking block, and
197
+ // opaque redacted_thinking blocks. Both must be replayed verbatim or tool-use turns 400.
198
+ | { type: "thinking_signature"; signature: string }
199
+ | { type: "redacted_thinking"; data: string }
185
200
  | { type: "reasoning_raw_delta"; text: string }
186
201
  | { type: "tool_call_start"; id: string; name: string }
187
202
  | { type: "tool_call_delta"; arguments: string }
@@ -337,6 +352,12 @@ export interface OcxProviderConfig {
337
352
  /** Keep provider settings on disk but exclude it from routing and model/catalog listings. */
338
353
  disabled?: boolean;
339
354
  apiKey?: string;
355
+ /**
356
+ * Multi-key pool (API-key twin of OAuth multiauth). `apiKey` always mirrors the ACTIVE
357
+ * entry so routing stays single-key; managed via /api/providers/keys. A legacy bare
358
+ * `apiKey` seeds a one-entry pool on first management touch.
359
+ */
360
+ apiKeyPool?: Array<{ id: string; key: string; label?: string; addedAt?: number }>;
340
361
  defaultModel?: string;
341
362
  models?: string[];
342
363
  /**
@@ -400,6 +421,12 @@ export interface OcxProviderConfig {
400
421
  autoToolChoiceOnlyModels?: string[];
401
422
  /** Model ids that expect prior assistant `reasoning_content` to be preserved in chat history. */
402
423
  preserveReasoningContentModels?: string[];
424
+ /**
425
+ * Model ids whose reasoning is a vendor `thinking: {type: enabled|disabled}` toggle on the
426
+ * chat-completions wire (MiMo v2.x, GLM 5/5.1 style), NOT an OpenAI `reasoning_effort` ladder.
427
+ * The openai-chat adapter translates the mapped effort into the thinking toggle for these.
428
+ */
429
+ thinkingToggleModels?: string[];
403
430
  /** Anthropic-compatible gateways that need custom tool names escaped on the wire. */
404
431
  escapeBuiltinToolNames?: boolean;
405
432
  /**
@@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process";
2
2
  import { readFileSync, readdirSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { dirname, join } from "node:path";
5
- import { getConfigDir, readPid, readRuntimePort } from "./config";
5
+ import { getConfigDir, readPid, readRuntimePort } from "../config";
6
6
 
7
7
  /**
8
8
  * A `codex-history-backup-*.json` surviving a stop means the native-history restore was
@@ -19,7 +19,7 @@ export function historyRestoreIncomplete(configDir = getConfigDir()): boolean {
19
19
  }
20
20
 
21
21
  export const PKG = "@bitkyc08/opencodex";
22
- const HERE = dirname(fileURLToPath(import.meta.url)); // .../opencodex/src
22
+ const HERE = dirname(fileURLToPath(import.meta.url)); // .../opencodex/src/update
23
23
 
24
24
  export type Installer = "bun" | "npm" | "source";
25
25
  export type Channel = "latest" | "preview";
@@ -32,7 +32,7 @@ export function detectInstall(): Installer {
32
32
 
33
33
  export function currentVersion(): string {
34
34
  try {
35
- return (JSON.parse(readFileSync(join(HERE, "..", "package.json"), "utf8")).version as string) ?? "?";
35
+ return (JSON.parse(readFileSync(join(HERE, "..", "..", "package.json"), "utf8")).version as string) ?? "?";
36
36
  } catch {
37
37
  return "?";
38
38
  }
@@ -105,7 +105,7 @@ export async function runUpdate(): Promise<void> {
105
105
  // unloads it permanently, so a successful update must reinstall/restart it afterwards.
106
106
  let serviceWasInstalled = false;
107
107
  try {
108
- const { isServiceInstalled } = await import("./service");
108
+ const { isServiceInstalled } = await import("../service");
109
109
  serviceWasInstalled = isServiceInstalled();
110
110
  } catch { /* best-effort */ }
111
111
 
@@ -140,7 +140,7 @@ export async function runUpdate(): Promise<void> {
140
140
  // Re-bake the bundled Bun path into the Codex autostart shim on every
141
141
  // platform when one is installed (refresh-only; never installs fresh).
142
142
  try {
143
- const { isCodexShimInstalled, installCodexShim } = await import("./codex-shim");
143
+ const { isCodexShimInstalled, installCodexShim } = await import("../codex/shim");
144
144
  if (isCodexShimInstalled()) {
145
145
  const result = installCodexShim();
146
146
  if (result.installed) console.log(`🔧 ${result.message}`);
@@ -2,9 +2,9 @@ import { spawn, spawnSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { atomicWriteFile, getConfigDir, readPid } from "./config";
6
- import { killProxy } from "./process-control";
7
- import { isServiceInstalled } from "./service";
5
+ import { atomicWriteFile, getConfigDir, readPid } from "../config";
6
+ import { killProxy } from "../lib/process-control";
7
+ import { isServiceInstalled } from "../service";
8
8
  import {
9
9
  type Channel,
10
10
  type Installer,
@@ -14,8 +14,8 @@ import {
14
14
  latestVersion,
15
15
  updateCommand,
16
16
  updateCommandStr,
17
- } from "./update";
18
- import { isNewer } from "./update-notify";
17
+ } from "./index";
18
+ import { isNewer } from "./notify";
19
19
 
20
20
  const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/latest";
21
21
  const UPDATE_JOB_FILENAME = "update-job.json";
@@ -79,7 +79,8 @@ function nodeBin(): string {
79
79
  }
80
80
 
81
81
  function packageLauncherPath(): string {
82
- return join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "ocx.mjs");
82
+ // This module lives at src/update/job.ts — the launcher is <pkg-root>/bin/ocx.mjs.
83
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "..", "bin", "ocx.mjs");
83
84
  }
84
85
 
85
86
  function formatCommand(bin: string, args: string[]): string {
@@ -2,8 +2,8 @@ import { spawn } from "node:child_process";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { createInterface } from "node:readline/promises";
5
- import { atomicWriteFile, getConfigDir } from "./config";
6
- import { hasStarPromptRun } from "./star-prompt";
5
+ import { atomicWriteFile, getConfigDir } from "../config";
6
+ import { hasStarPromptRun } from "../cli/star-prompt";
7
7
  import {
8
8
  type Channel,
9
9
  currentVersion,
@@ -12,7 +12,7 @@ import {
12
12
  runUpdate,
13
13
  updateCommandStr,
14
14
  updateTag,
15
- } from "./update";
15
+ } from "./index";
16
16
 
17
17
  const VERSION_FILENAME = "version.json";
18
18
  const REFRESH_INTERVAL_MS = 20 * 60 * 60 * 1000; // 20h, matching codex-rs
@@ -1,8 +1,8 @@
1
1
  import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { getConfigDir } from "./config";
4
- import { redactSecretString, redactSecrets } from "./redact";
5
- import type { OcxUsage } from "./types";
3
+ import { getConfigDir } from "../config";
4
+ import { redactSecretString, redactSecrets } from "../lib/redact";
5
+ import type { OcxUsage } from "../types";
6
6
 
7
7
  export const USAGE_DEBUG_ENV = "OPENCODEX_USAGE_DEBUG";
8
8
  export const USAGE_DEBUG_BODY_SAMPLE_BYTES = 2048;
@@ -1,8 +1,8 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, appendFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { getConfigDir } from "./config";
4
- import { usageDisplayTotalTokens } from "./usage-totals";
5
- import type { OcxUsage } from "./types";
3
+ import { getConfigDir } from "../config";
4
+ import { usageDisplayTotalTokens } from "./totals";
5
+ import type { OcxUsage } from "../types";
6
6
 
7
7
  export type UsageStatus = "reported" | "unreported" | "unsupported" | "estimated";
8
8
 
@@ -1,6 +1,6 @@
1
- import { baseProviderLabel } from "./provider-label";
2
- import { usageDisplayTotalTokens } from "./usage-totals";
3
- import type { PersistedUsageEntry, UsageStatus } from "./usage-log";
1
+ import { baseProviderLabel } from "../providers/label";
2
+ import { usageDisplayTotalTokens } from "./totals";
3
+ import type { PersistedUsageEntry, UsageStatus } from "./log";
4
4
 
5
5
  export type UsageRange = "7d" | "30d" | "all";
6
6
 
@@ -1,4 +1,4 @@
1
- import type { OcxUsage } from "./types";
1
+ import type { OcxUsage } from "../types";
2
2
 
3
3
  function cacheDetailTokens(usage: OcxUsage): number | undefined {
4
4
  const hasRead = typeof usage.cacheReadInputTokens === "number";
@@ -1,8 +1,8 @@
1
1
  import type { OcxProviderConfig } from "../types";
2
2
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
3
- import { signalWithTimeout, cancelBodyOnAbort } from "../abort";
4
- import { sidecarEnter } from "../sidecar-tracker";
5
- import { fetchWithResetRetry } from "../upstream-retry";
3
+ import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort";
4
+ import { sidecarEnter } from "../lib/sidecar-tracker";
5
+ import { fetchWithResetRetry } from "../lib/upstream-retry";
6
6
  import { parseSidecarSSE } from "../web-search/parse";
7
7
  import type { SidecarOutcomeRecorder } from "../web-search/executor";
8
8
 
@@ -1,7 +1,7 @@
1
1
  import type { OcxConfig, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent } from "../types";
2
2
  import { modelInList } from "../types";
3
3
  import { describeImage, type VisionSettings } from "./describe";
4
- import type { CodexAuthContext } from "../codex-auth-context";
4
+ import type { CodexAuthContext } from "../codex/auth-context";
5
5
  import type { SidecarOutcomeRecorder } from "../web-search/executor";
6
6
 
7
7
  export { describeImage } from "./describe";
@@ -145,3 +145,23 @@ export async function describeImagesInPlace(
145
145
  msg.content = newParts;
146
146
  }
147
147
  }
148
+
149
+ /**
150
+ * Fail-closed image strip for sidecar-covered models when NO sidecar plan exists (no forward
151
+ * provider / missing forwarded auth / sidecar disabled): the upstream is text-only, so forwarding
152
+ * raw images would 400 or silently confuse it. Replace each image with an explicit marker so the
153
+ * model (and the user, via its reply) knows the image was dropped rather than ignored.
154
+ */
155
+ export function stripImagesInPlace(parsed: OcxParsedRequest): boolean {
156
+ let stripped = false;
157
+ for (const msg of parsed.context.messages) {
158
+ if (!carriesImages(msg.role) || !Array.isArray(msg.content)) continue;
159
+ const parts = msg.content as OcxContentPart[];
160
+ if (!parts.some(p => p.type === "image")) continue;
161
+ msg.content = parts.map(p => p.type === "image"
162
+ ? { type: "text", text: "[image omitted: this model is text-only and the vision sidecar is unavailable (no ChatGPT login)]" } as OcxContentPart
163
+ : p);
164
+ stripped = true;
165
+ }
166
+ return stripped;
167
+ }
@@ -1,10 +1,10 @@
1
1
  import type { OcxProviderConfig } from "../types";
2
2
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
3
- import { signalWithTimeout, cancelBodyOnAbort } from "../abort";
4
- import { sidecarEnter } from "../sidecar-tracker";
5
- import { fetchWithResetRetry } from "../upstream-retry";
3
+ import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort";
4
+ import { sidecarEnter } from "../lib/sidecar-tracker";
5
+ import { fetchWithResetRetry } from "../lib/upstream-retry";
6
6
  import { parseSidecarSSE, type WebSearchResult } from "./parse";
7
- import type { CodexUpstreamOutcome } from "../codex-routing";
7
+ import type { CodexUpstreamOutcome } from "../codex/routing";
8
8
 
9
9
  export interface SidecarSettings {
10
10
  model: string;
@@ -1,7 +1,7 @@
1
1
  import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types";
2
2
  import { modelInList } from "../types";
3
3
  import type { SidecarSettings } from "./executor";
4
- import type { CodexAuthContext } from "../codex-auth-context";
4
+ import type { CodexAuthContext } from "../codex/auth-context";
5
5
 
6
6
  export { runWithWebSearch } from "./loop";
7
7
  export { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "./synthetic-tool";
@@ -1,10 +1,10 @@
1
1
  import type { ProviderAdapter } from "../adapters/base";
2
- import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig } from "../types";
2
+ import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxThinkingContent } from "../types";
3
3
  import { namespacedToolName } from "../types";
4
4
  import { bridgeToResponsesSSE } from "../bridge";
5
5
  import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor";
6
- import { cancelBodyOnAbort } from "../abort";
7
- import { fetchWithResetRetry } from "../upstream-retry";
6
+ import { cancelBodyOnAbort } from "../lib/abort";
7
+ import { fetchWithResetRetry } from "../lib/upstream-retry";
8
8
  import { formatWebSearchResults } from "./format-result";
9
9
  import { WEB_SEARCH_TOOL_NAME } from "./synthetic-tool";
10
10
 
@@ -92,6 +92,32 @@ async function* replay(events: AdapterEvent[]): AsyncGenerator<AdapterEvent> {
92
92
  for (const e of events) yield e;
93
93
  }
94
94
 
95
+ /**
96
+ * Collect the thinking block that preceded a web_search call in this iteration's events, so the
97
+ * replayed assistant turn can carry it. Anthropic extended thinking REQUIRES the assistant
98
+ * message that contains tool_use to start with its signed thinking/redacted_thinking blocks —
99
+ * replaying a bare toolCall 400s ("Expected `thinking` or `redacted_thinking`, but found
100
+ * `tool_use`"). The signature validity gate stays in the anthropic adapter; other adapters
101
+ * ignore or serialize the part harmlessly.
102
+ */
103
+ function extractIterationThinking(events: AdapterEvent[]): OcxThinkingContent | null {
104
+ let thinking = "";
105
+ let signature: string | undefined;
106
+ const redacted: string[] = [];
107
+ for (const e of events) {
108
+ if (e.type === "thinking_delta") thinking += e.thinking;
109
+ else if (e.type === "thinking_signature") signature = e.signature;
110
+ else if (e.type === "redacted_thinking") redacted.push(e.data);
111
+ }
112
+ if (!thinking && !signature && redacted.length === 0) return null;
113
+ return {
114
+ type: "thinking",
115
+ thinking,
116
+ ...(signature ? { signature } : {}),
117
+ ...(redacted.length > 0 ? { redacted } : {}),
118
+ };
119
+ }
120
+
95
121
  /** Normalize a query for failed-query de-duplication (case/whitespace-insensitive). */
96
122
  function normalizeQuery(q: string): string {
97
123
  return q.trim().toLowerCase().replace(/\s+/g, " ");
@@ -142,6 +168,13 @@ export interface WebSearchLoopDeps {
142
168
  forceEmptyResponseId?: boolean;
143
169
  abortSignal?: AbortSignal;
144
170
  recordSidecarOutcome?: SidecarOutcomeRecorder;
171
+ /** Per-iteration deadline for routed model calls (mirrors the normal path's connectTimeoutMs). */
172
+ connectTimeoutMs?: number;
173
+ /**
174
+ * 429 key-failover hook: rotate the provider's active pool key and return a rebuilt adapter,
175
+ * or null when the pool is exhausted (same semantics as the normal routed path).
176
+ */
177
+ on429?: (retryAfterHeader: string | null) => ProviderAdapter | null;
145
178
  }
146
179
 
147
180
  /**
@@ -151,7 +184,9 @@ export interface WebSearchLoopDeps {
151
184
  * streamed Responses SSE. web_search calls are executed internally and never relayed to Codex.
152
185
  */
153
186
  export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Response> {
154
- const { parsed, adapter, selectedForwardHeaders, forwardProvider, hostedTool, settings, maxSearches, abortSignal, recordSidecarOutcome } = deps;
187
+ const { parsed, selectedForwardHeaders, forwardProvider, hostedTool, settings, maxSearches, abortSignal, recordSidecarOutcome } = deps;
188
+ // Mutable: 429 key-failover (deps.on429) can swap in a rebuilt adapter mid-loop.
189
+ let adapter = deps.adapter;
155
190
  if (!adapter.parseResponse) return jsonError(500, "web-search sidecar requires a non-streaming adapter");
156
191
 
157
192
  const messages: OcxMessage[] = [...parsed.context.messages];
@@ -196,22 +231,41 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
196
231
  ...parsed, stream: false,
197
232
  context: { ...parsed.context, messages: iterMessages, tools: forceAnswer ? toolsNoWebSearch : allTools },
198
233
  };
199
- const request = await adapter.buildRequest(iterParsed, { headers: selectedForwardHeaders });
200
- let resp: Response;
201
- try {
202
- resp = adapter.fetchResponse
203
- ? await adapter.fetchResponse(request, { abortSignal: signal })
204
- : await fetchWithResetRetry(
205
- () => fetch(request.url, {
206
- method: request.method,
207
- headers: request.headers,
208
- body: request.body,
209
- signal,
210
- }),
211
- { abortSignal: signal, label: "web-search-loop" },
212
- );
213
- } catch (e) {
214
- throw new LoopError(502, `Provider unreachable: ${e instanceof Error ? e.message : String(e)}`);
234
+ // Per-iteration deadline: routed calls elsewhere carry connectTimeoutMs; without it a hung
235
+ // upstream would stall the whole loop until the client gives up.
236
+ const iterationSignal = deps.connectTimeoutMs
237
+ ? AbortSignal.any([signal, AbortSignal.timeout(deps.connectTimeoutMs)])
238
+ : signal;
239
+ const fetchOnce = async (): Promise<Response> => {
240
+ const request = await adapter.buildRequest(iterParsed, { headers: selectedForwardHeaders });
241
+ try {
242
+ return adapter.fetchResponse
243
+ ? await adapter.fetchResponse(request, { abortSignal: iterationSignal, ...(deps.connectTimeoutMs ? { timeoutMs: deps.connectTimeoutMs } : {}) })
244
+ : await fetchWithResetRetry(
245
+ () => fetch(request.url, {
246
+ method: request.method,
247
+ headers: request.headers,
248
+ body: request.body,
249
+ signal: iterationSignal,
250
+ }),
251
+ { abortSignal: iterationSignal, label: "web-search-loop" },
252
+ );
253
+ } catch (e) {
254
+ if (!signal.aborted && iterationSignal.aborted) {
255
+ throw new LoopError(504, `Provider timeout after ${deps.connectTimeoutMs}ms during web-search`);
256
+ }
257
+ throw new LoopError(502, `Provider unreachable: ${e instanceof Error ? e.message : String(e)}`);
258
+ }
259
+ };
260
+ let resp = await fetchOnce();
261
+ // 429 key-failover parity with the normal routed path: rotate pool keys until one responds
262
+ // or the pool is exhausted (deps.on429 returns null — cooldown map guarantees termination).
263
+ while (resp.status === 429 && deps.on429) {
264
+ const rotated = deps.on429(resp.headers.get("retry-after"));
265
+ if (!rotated?.parseResponse) break;
266
+ try { void resp.body?.cancel(); } catch { /* already consumed */ }
267
+ adapter = rotated;
268
+ resp = await fetchOnce();
215
269
  }
216
270
  if (!resp.ok) {
217
271
  const t = await resp.text().catch(() => "");
@@ -242,7 +296,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
242
296
  // valid, and surface as ONE search cell carrying every attempted query. A real search (one that
243
297
  // hits the sidecar) shows the spinner WHILE the batch runs. Empty/limit/repeat placeholders never
244
298
  // emit a cell (matching the prior single-query behavior).
245
- async function* runSearchCall(call: WebSearchCall): AsyncGenerator<AdapterEvent> {
299
+ async function* runSearchCall(call: WebSearchCall, precedingThinking?: OcxThinkingContent | null): AsyncGenerator<AdapterEvent> {
246
300
  const results: { query: string; outcome: SidecarOutcome }[] = [];
247
301
  let beganCell = false;
248
302
  if (call.queries.length === 0) {
@@ -279,7 +333,11 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
279
333
  : { query: call.queries[0] ?? "" };
280
334
  messages.push({
281
335
  role: "assistant",
282
- content: [{ type: "toolCall", id: call.id, name: WEB_SEARCH_TOOL_NAME, arguments: callArgs }],
336
+ content: [
337
+ // Signed thinking must precede tool_use on replay (Anthropic extended thinking).
338
+ ...(precedingThinking ? [precedingThinking] : []),
339
+ { type: "toolCall" as const, id: call.id, name: WEB_SEARCH_TOOL_NAME, arguments: callArgs },
340
+ ],
283
341
  timestamp: now,
284
342
  });
285
343
  // One aggregated tool result. isError only when EVERY query failed (a partial success is usable).
@@ -357,8 +415,10 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
357
415
  yield* replay(split.passthrough);
358
416
  return;
359
417
  }
360
- for (const call of split.calls) {
361
- yield* runSearchCall(call);
418
+ // The thinking that led to the search belongs to the FIRST call's assistant replay turn.
419
+ const iterationThinking = extractIterationThinking(split.passthrough);
420
+ for (const [callIndex, call] of split.calls.entries()) {
421
+ yield* runSearchCall(call, callIndex === 0 ? iterationThinking : null);
362
422
  }
363
423
  }
364
424
  }