@bitkyc08/opencodex 2.25.0 → 2.26.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 (99) hide show
  1. package/bin/ocx.mjs +59 -7
  2. package/gui/dist/assets/index-RL6b1bTV.js +102 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/src/adapters/base.ts +18 -0
  6. package/src/adapters/client-fingerprint.ts +0 -2
  7. package/src/adapters/cursor/http1-bidi.ts +361 -0
  8. package/src/adapters/cursor/live-models.ts +117 -30
  9. package/src/adapters/cursor/live-transport.ts +137 -56
  10. package/src/adapters/cursor/native-exec-fs.ts +1 -1
  11. package/src/adapters/cursor/native-exec-network.ts +1 -1
  12. package/src/adapters/cursor/native-exec-shell.ts +0 -1
  13. package/src/adapters/cursor/protobuf-request.ts +89 -21
  14. package/src/adapters/cursor/tool-definitions.ts +18 -7
  15. package/src/adapters/cursor/tool-result-normalize.ts +92 -0
  16. package/src/adapters/cursor/transport.ts +7 -0
  17. package/src/adapters/cursor.ts +2 -0
  18. package/src/adapters/google-http.ts +38 -10
  19. package/src/adapters/google.ts +23 -3
  20. package/src/adapters/openai-chat.ts +38 -13
  21. package/src/adapters/openai-responses.ts +129 -9
  22. package/src/adapters/registry.ts +30 -1
  23. package/src/bridge.ts +50 -12
  24. package/src/chat/inbound.ts +1 -0
  25. package/src/claude/agents-inject.ts +3 -2
  26. package/src/cli/dispatch.ts +19 -6
  27. package/src/cli/help.ts +2 -1
  28. package/src/cli/integrations.ts +35 -0
  29. package/src/cli/minimax.ts +8 -2
  30. package/src/cli/registry.ts +13 -2
  31. package/src/clients/config-export.ts +120 -1
  32. package/src/codex/account-store.ts +17 -1
  33. package/src/codex/auth-api.ts +51 -17
  34. package/src/codex/catalog/effort.ts +8 -5
  35. package/src/codex/catalog/provider-fetch.ts +39 -27
  36. package/src/codex/catalog/sync.ts +30 -8
  37. package/src/codex/inject.ts +39 -13
  38. package/src/codex/main-account.ts +29 -1
  39. package/src/codex/plan-from-token.ts +140 -0
  40. package/src/codex/plan.ts +25 -0
  41. package/src/codex/prompt-journal.ts +6 -2
  42. package/src/codex/quota-rejection.ts +21 -7
  43. package/src/codex/refresh.ts +4 -2
  44. package/src/codex/sync.ts +106 -3
  45. package/src/codex/warmup.ts +187 -81
  46. package/src/config.ts +85 -46
  47. package/src/generated/compatibility-version.json +127 -83
  48. package/src/grok/inject.ts +1 -1
  49. package/src/images/loop.ts +1 -0
  50. package/src/integrations/registry.ts +7 -0
  51. package/src/lab/automation/config-persistence.ts +2 -2
  52. package/src/lab/automation/persistence.ts +2 -2
  53. package/src/lab/ledger/purge.ts +2 -2
  54. package/src/lab/subject/behavior-fingerprint.ts +2 -2
  55. package/src/lib/config-ownership.ts +5 -2
  56. package/src/lib/destination-policy.ts +18 -1
  57. package/src/lib/provider-outbound.ts +7 -0
  58. package/src/lib/redact.ts +11 -0
  59. package/src/lib/tool-argument-integers.ts +50 -6
  60. package/src/lib/upstream-http-version.ts +57 -0
  61. package/src/lib/upstream-retry.ts +2 -1
  62. package/src/lib/windows-atomic-replace.ts +155 -0
  63. package/src/lib/windows-service-wrappers.ts +72 -0
  64. package/src/oauth/google-antigravity.ts +2 -2
  65. package/src/oauth/index.ts +60 -8
  66. package/src/providers/antigravity-models.ts +58 -4
  67. package/src/providers/command-code-efforts.ts +36 -9
  68. package/src/providers/context-cap.ts +9 -0
  69. package/src/providers/derive.ts +4 -0
  70. package/src/providers/fastwire.ts +453 -0
  71. package/src/providers/registry.ts +21 -1
  72. package/src/providers/service-tier.ts +173 -89
  73. package/src/responses/parser.ts +25 -16
  74. package/src/responses/reasoning-replay-cache.ts +30 -0
  75. package/src/responses/thought-signature-replay.ts +74 -4
  76. package/src/router.ts +6 -0
  77. package/src/routing/compatibility/behavior.ts +27 -16
  78. package/src/server/index.ts +10 -1
  79. package/src/server/management/oauth-account-routes.ts +10 -2
  80. package/src/server/management/shared.ts +1 -1
  81. package/src/server/management/system-routes.ts +15 -0
  82. package/src/server/request-log.ts +57 -3
  83. package/src/server/responses/agent-task-recovery.ts +6 -1
  84. package/src/server/responses/core.ts +209 -40
  85. package/src/server/responses/fetch-helpers.ts +15 -36
  86. package/src/server/responses/responses-field-backfill.ts +173 -0
  87. package/src/server/responses-reasoning-summary-rewrite.ts +171 -0
  88. package/src/service.ts +38 -36
  89. package/src/storage/cleanup.ts +2 -2
  90. package/src/tray/windows.ts +3 -2
  91. package/src/types.ts +101 -14
  92. package/src/update/job.ts +9 -18
  93. package/src/update/transactional-install.d.mts +22 -0
  94. package/src/update/transactional-install.mjs +259 -0
  95. package/src/usage/cost.ts +34 -5
  96. package/src/usage/log.ts +74 -4
  97. package/src/vision/anthropic-describe.ts +10 -6
  98. package/src/web-search/anthropic-executor.ts +8 -6
  99. package/gui/dist/assets/index-DxJ7kXj9.js +0 -102
@@ -1,19 +1,18 @@
1
+ import { readBoundedResponseBody } from "../lib/bounded-body";
2
+
1
3
  export class CodexWarmupError extends Error {
2
- code: "http_status" | "missing_body" | "stream_failed" | "stream_incomplete" | "stream_error" | "invalid_sse" | "no_terminal" | "transport";
4
+ code: "http_status" | "missing_body" | "stream_failed" | "stream_incomplete" | "stream_error" | "stream_too_large" | "invalid_sse" | "no_terminal" | "transport";
3
5
  status?: number;
4
- /** Upstream error detail extracted from the response body (truncated to 512 chars). */
5
- upstreamDetail?: string;
6
6
 
7
7
  constructor(
8
8
  code: CodexWarmupError["code"],
9
9
  message = "Codex warmup failed",
10
- options: { status?: number; cause?: unknown; upstreamDetail?: string } = {},
10
+ options: { status?: number; cause?: unknown } = {},
11
11
  ) {
12
12
  super(message);
13
13
  this.name = "CodexWarmupError";
14
14
  this.code = code;
15
15
  this.status = options.status;
16
- this.upstreamDetail = options.upstreamDetail;
17
16
  if (options.cause !== undefined) this.cause = options.cause;
18
17
  }
19
18
  }
@@ -29,37 +28,31 @@ const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
29
28
  const DEFAULT_MODEL = "gpt-5.4-mini";
30
29
  const FALLBACK_MODELS = ["gpt-5.5"];
31
30
  const DEFAULT_TIMEOUT_MS = 30_000;
31
+ const MAX_TIMEOUT_MS = 0x7fff_ffff;
32
32
  const MAX_ERROR_BODY_BYTES = 2048;
33
+ const MAX_WARMUP_STREAM_BYTES = 1024 * 1024;
33
34
 
34
- /** Read the first MAX_ERROR_BODY_BYTES of a response body and extract an error message. */
35
- async function readErrorDetail(res: Response): Promise<string | undefined> {
35
+ /** Bound and release an upstream error body without exposing provider-controlled text. */
36
+ async function drainErrorBody(res: Response, signal: AbortSignal): Promise<void> {
36
37
  try {
37
- const text = await res.text();
38
- const trimmed = text.slice(0, MAX_ERROR_BODY_BYTES);
39
- try {
40
- const json = JSON.parse(trimmed) as Record<string, unknown>;
41
- // ChatGPT backend error shape: { error: { message: "..." } } or { detail: "..." }
42
- const nested = json.error;
43
- if (nested && typeof nested === "object" && typeof (nested as Record<string, unknown>).message === "string") {
44
- return ((nested as Record<string, unknown>).message as string).slice(0, 512);
45
- }
46
- if (typeof json.detail === "string") return json.detail.slice(0, 512);
47
- if (typeof json.error === "string") return (json.error as string).slice(0, 512);
48
- if (typeof json.message === "string") return json.message.slice(0, 512);
49
- } catch {
50
- // Non-JSON response body may contain sensitive data (tokens, credentials).
51
- // Only surface structured error messages, never raw text.
38
+ await readBoundedResponseBody(res, {
39
+ signal,
40
+ maxBytes: MAX_ERROR_BODY_BYTES,
41
+ fatalUtf8: true,
42
+ });
43
+ } catch (error) {
44
+ if (signal.aborted) {
45
+ throw new CodexWarmupError("transport", "Codex warmup request failed", {
46
+ cause: error,
47
+ });
52
48
  }
53
- return undefined;
54
- } catch {
55
- return undefined;
49
+ // The bounded reader owns cancellation for oversized, invalid, or stalled bodies.
56
50
  }
57
51
  }
58
52
 
59
53
  function safeWarmupReason(err: unknown): string {
60
54
  if (err instanceof CodexWarmupError) {
61
- const base = err.status ? `${err.code}:${err.status}` : err.code;
62
- return err.upstreamDetail ? `${base} — ${err.upstreamDetail}` : base;
55
+ return err.status ? `${err.code}:${err.status}` : err.code;
63
56
  }
64
57
  return "transport";
65
58
  }
@@ -89,83 +82,196 @@ function parseSseFrame(frame: string): unknown | null {
89
82
  }
90
83
  }
91
84
 
92
- async function drainWarmupSse(body: ReadableStream<Uint8Array>): Promise<void> {
85
+ async function drainWarmupSse(body: ReadableStream<Uint8Array>, signal: AbortSignal): Promise<void> {
93
86
  const reader = body.getReader();
94
87
  const decoder = new TextDecoder();
95
- let buffer = "";
88
+ let buffer = new Uint8Array(Math.min(MAX_WARMUP_STREAM_BYTES, 64 * 1024));
89
+ let bufferedBytes = 0;
90
+ let scanOffset = 0;
91
+ let bytesRead = 0;
92
+ const abortError = () => new CodexWarmupError("transport", "Codex warmup request failed", {
93
+ cause: signal.reason,
94
+ });
95
+ const cancelReader = () => {
96
+ try {
97
+ void reader.cancel(signal.reason).catch(() => {});
98
+ } catch {
99
+ // Some custom stream implementations throw synchronously from cancel().
100
+ }
101
+ };
102
+ // Fetch implementations usually error the response body when their signal is
103
+ // aborted, but a ReadableStream is not intrinsically coupled to that signal.
104
+ // Race only the currently pending read against a removable abort listener;
105
+ // Bun 1.3 can leave read() parked until a custom source's cancel promise
106
+ // settles, while a shared never-settled race promise would retain one handler
107
+ // per chunk. Cancellation remains best-effort and is never awaited.
108
+ const readWithSignal = (): Promise<Awaited<ReturnType<typeof reader.read>>> => {
109
+ if (signal.aborted) {
110
+ cancelReader();
111
+ return Promise.reject(abortError());
112
+ }
113
+ const read = reader.read();
114
+ void read.catch(() => {});
115
+ return new Promise((resolve, reject) => {
116
+ let settled = false;
117
+ const finish = (action: () => void) => {
118
+ if (settled) return;
119
+ settled = true;
120
+ signal.removeEventListener("abort", onAbort);
121
+ action();
122
+ };
123
+ const onAbort = () => {
124
+ cancelReader();
125
+ finish(() => reject(abortError()));
126
+ };
127
+ signal.addEventListener("abort", onAbort, { once: true });
128
+ if (signal.aborted) {
129
+ onAbort();
130
+ return;
131
+ }
132
+ read.then(
133
+ result => finish(() => resolve(result)),
134
+ error => finish(() => reject(error)),
135
+ );
136
+ });
137
+ };
138
+ const ensureCapacity = (requiredBytes: number) => {
139
+ if (requiredBytes <= buffer.byteLength) return;
140
+ const grown = new Uint8Array(Math.min(
141
+ MAX_WARMUP_STREAM_BYTES,
142
+ Math.max(requiredBytes, buffer.byteLength * 2),
143
+ ));
144
+ grown.set(buffer.subarray(0, bufferedBytes));
145
+ buffer = grown;
146
+ };
147
+ const findFrameDelimiter = (start: number): { index: number; length: 2 | 3 | 4 } | undefined => {
148
+ for (let index = start; index < bufferedBytes - 1; index += 1) {
149
+ const firstLength = buffer[index] === 10
150
+ ? 1
151
+ : buffer[index] === 13 && buffer[index + 1] === 10 ? 2 : 0;
152
+ if (firstLength === 0) continue;
153
+ const secondStart = index + firstLength;
154
+ const secondLength = buffer[secondStart] === 10
155
+ ? 1
156
+ : buffer[secondStart] === 13 && buffer[secondStart + 1] === 10 ? 2 : 0;
157
+ if (secondLength > 0) return { index, length: (firstLength + secondLength) as 2 | 3 | 4 };
158
+ }
159
+ return undefined;
160
+ };
161
+ const acceptFrame = (frame: Uint8Array): boolean => {
162
+ const parsed = parseSseFrame(decoder.decode(frame));
163
+ const type = eventTypeFromData(parsed);
164
+ if (type === "response.completed") return true;
165
+ if (type === "response.failed") throw new CodexWarmupError("stream_failed");
166
+ if (type === "response.incomplete") throw new CodexWarmupError("stream_incomplete");
167
+ if (type === "error") throw new CodexWarmupError("stream_error");
168
+ return false;
169
+ };
96
170
 
97
171
  try {
172
+ if (signal.aborted) throw abortError();
98
173
  for (;;) {
99
- const { done, value } = await reader.read();
174
+ const { done, value } = await readWithSignal();
175
+ if (signal.aborted) throw abortError();
100
176
  if (done) break;
101
- buffer += decoder.decode(value, { stream: true });
177
+ if (value.byteLength > MAX_WARMUP_STREAM_BYTES - bytesRead) {
178
+ throw new CodexWarmupError("stream_too_large", "Codex warmup stream exceeded the size limit");
179
+ }
180
+ bytesRead += value.byteLength;
181
+ ensureCapacity(bufferedBytes + value.byteLength);
182
+ buffer.set(value, bufferedBytes);
183
+ bufferedBytes += value.byteLength;
102
184
 
185
+ let consumedBytes = 0;
103
186
  for (;;) {
104
- const frameEnd = buffer.search(/\r?\n\r?\n/);
105
- if (frameEnd < 0) break;
106
- const frame = buffer.slice(0, frameEnd);
107
- const delimiterLength = buffer[frameEnd] === "\r" ? 4 : 2;
108
- buffer = buffer.slice(frameEnd + delimiterLength);
109
- const parsed = parseSseFrame(frame);
110
- const type = eventTypeFromData(parsed);
111
- if (type === "response.completed") return;
112
- if (type === "response.failed") throw new CodexWarmupError("stream_failed");
113
- if (type === "response.incomplete") throw new CodexWarmupError("stream_incomplete");
114
- if (type === "error") throw new CodexWarmupError("stream_error");
187
+ const delimiter = findFrameDelimiter(scanOffset);
188
+ if (!delimiter) {
189
+ // A delimiter can start at most three bytes before the next chunk.
190
+ scanOffset = Math.max(consumedBytes, bufferedBytes - 3);
191
+ break;
192
+ }
193
+ if (acceptFrame(buffer.subarray(consumedBytes, delimiter.index))) return;
194
+ consumedBytes = delimiter.index + delimiter.length;
195
+ scanOffset = consumedBytes;
196
+ }
197
+ if (consumedBytes > 0) {
198
+ buffer.copyWithin(0, consumedBytes, bufferedBytes);
199
+ bufferedBytes -= consumedBytes;
200
+ scanOffset = Math.max(0, scanOffset - consumedBytes);
115
201
  }
116
202
  }
117
203
 
118
- if (buffer.trim()) {
119
- const parsed = parseSseFrame(buffer);
120
- const type = eventTypeFromData(parsed);
121
- if (type === "response.completed") return;
122
- if (type === "response.failed") throw new CodexWarmupError("stream_failed");
123
- if (type === "response.incomplete") throw new CodexWarmupError("stream_incomplete");
124
- if (type === "error") throw new CodexWarmupError("stream_error");
125
- }
204
+ if (bufferedBytes > 0 && acceptFrame(buffer.subarray(0, bufferedBytes))) return;
126
205
 
127
206
  throw new CodexWarmupError("no_terminal", "Codex warmup ended before completion");
128
207
  } finally {
129
- reader.releaseLock();
208
+ try {
209
+ reader.releaseLock();
210
+ } catch {
211
+ // A hostile cancel promise may keep the final read locked after timeout.
212
+ }
130
213
  }
131
214
  }
132
215
 
133
216
  async function tryWarmup(options: CodexWarmupOptions, model: string): Promise<void> {
134
- let res: Response;
135
- try {
136
- res = await fetch(CODEX_RESPONSES_URL, {
137
- method: "POST",
138
- headers: {
139
- Authorization: `Bearer ${options.accessToken}`,
140
- "ChatGPT-Account-Id": options.chatgptAccountId,
141
- "Content-Type": "application/json",
142
- },
143
- body: JSON.stringify({
144
- model,
145
- instructions: "Reply with OK.",
146
- input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }],
147
- stream: true,
148
- store: false,
149
- }),
150
- signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
151
- });
152
- } catch (err) {
153
- throw new CodexWarmupError("transport", "Codex warmup request failed", { cause: err });
154
- }
155
-
156
- if (!res.ok) {
157
- const upstreamDetail = await readErrorDetail(res);
158
- throw new CodexWarmupError("http_status", "Codex warmup was rejected", {
159
- status: res.status,
160
- upstreamDetail,
217
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
218
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > MAX_TIMEOUT_MS) {
219
+ throw new CodexWarmupError("transport", "Codex warmup request failed", {
220
+ cause: new RangeError("Codex warmup timeout is outside the supported range"),
161
221
  });
162
222
  }
163
- if (!res.body) throw new CodexWarmupError("missing_body");
223
+ // Bun 1.3 can leave AbortSignal.timeout() dormant while a custom response
224
+ // stream has a pending read. A ref'ed timer and explicit controller make the
225
+ // same deadline cover response headers and the full success/error body.
226
+ const deadline = new AbortController();
227
+ const signal = deadline.signal;
228
+ const timer = setTimeout(() => {
229
+ deadline.abort(new DOMException("Codex warmup timed out", "TimeoutError"));
230
+ }, timeoutMs);
164
231
 
165
232
  try {
166
- await drainWarmupSse(res.body);
233
+ let res: Response;
234
+ try {
235
+ res = await fetch(CODEX_RESPONSES_URL, {
236
+ method: "POST",
237
+ headers: {
238
+ Authorization: `Bearer ${options.accessToken}`,
239
+ "ChatGPT-Account-Id": options.chatgptAccountId,
240
+ "Content-Type": "application/json",
241
+ },
242
+ body: JSON.stringify({
243
+ model,
244
+ instructions: "Reply with OK.",
245
+ input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }],
246
+ stream: true,
247
+ store: false,
248
+ }),
249
+ signal,
250
+ });
251
+ } catch (err) {
252
+ throw new CodexWarmupError("transport", "Codex warmup request failed", { cause: err });
253
+ }
254
+
255
+ if (!res.ok) {
256
+ await drainErrorBody(res, signal);
257
+ throw new CodexWarmupError("http_status", "Codex warmup was rejected", {
258
+ status: res.status,
259
+ });
260
+ }
261
+ const body = res.body;
262
+ if (!body) throw new CodexWarmupError("missing_body");
263
+
264
+ try {
265
+ await drainWarmupSse(body, signal);
266
+ } finally {
267
+ try {
268
+ void body.cancel().catch(() => {});
269
+ } catch {
270
+ // Some custom stream implementations throw synchronously from cancel().
271
+ }
272
+ }
167
273
  } finally {
168
- await res.body?.cancel().catch(() => {});
274
+ clearTimeout(timer);
169
275
  }
170
276
  }
171
277
 
package/src/config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
- import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, truncateSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync, truncateSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join, resolve } from "node:path";
6
6
  import { Database } from "bun:sqlite";
@@ -65,9 +65,11 @@ import {
65
65
  type OcxConfig,
66
66
  type OcxApiKeyEntry,
67
67
  type OcxProviderConfig,
68
+ type FastWire,
68
69
  type ProviderCostOverlay,
69
70
  } from "./types";
70
71
  import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers";
72
+ import { fastWireDeclarationError, hasFastWireCapabilityConflict } from "./providers/fastwire";
71
73
  import {
72
74
  getProviderRegistryEntry,
73
75
  providerMatchesRegistryTransport,
@@ -93,34 +95,13 @@ import { isHostedToolUnsupportedForModel } from "./responses/hosted-tool-policy"
93
95
 
94
96
  let _atomicSeq = 0;
95
97
 
96
- interface AtomicRenameIO {
97
- platform: NodeJS.Platform;
98
- rename: (source: string, destination: string) => void;
99
- sleep: (milliseconds: number) => void;
100
- }
101
-
102
- export function renameAtomicFile(
103
- source: string,
104
- destination: string,
105
- io: AtomicRenameIO = {
106
- platform: process.platform,
107
- rename: renameSync,
108
- sleep: Bun.sleepSync,
109
- },
110
- ): void {
111
- for (let attempt = 0; ; attempt += 1) {
112
- try {
113
- io.rename(source, destination);
114
- return;
115
- } catch (error) {
116
- const code = (error as NodeJS.ErrnoException).code;
117
- const transientWindowsError = io.platform === "win32"
118
- && (code === "EBUSY" || code === "EPERM" || code === "EACCES");
119
- if (!transientWindowsError || attempt >= 2) throw error;
120
- io.sleep(25 * (attempt + 1));
121
- }
122
- }
123
- }
98
+ // The Windows-tolerant replace lives in lib/windows-atomic-replace: config-ownership
99
+ // is one of its callers and this module already imports config-ownership, so
100
+ // exporting it from here would close an import cycle. Re-exported because these
101
+ // names are part of this module's public surface and its callers.
102
+ export type { AtomicRenameIO } from "./lib/windows-atomic-replace";
103
+ export { renameAtomicFile } from "./lib/windows-atomic-replace";
104
+ import { renameAtomicFile, renameAtomicFileAsync } from "./lib/windows-atomic-replace";
124
105
 
125
106
  /**
126
107
  * Write a file atomically (temp + rename) so concurrent writers — e.g. `ocx stop` and the
@@ -284,21 +265,6 @@ export interface AtomicWriteAsyncTestSeam {
284
265
  afterTempWrite?: (tempPath: string) => void | Promise<void>;
285
266
  }
286
267
 
287
- async function renameAtomicFileAsync(source: string, destination: string): Promise<void> {
288
- for (let attempt = 0; ; attempt += 1) {
289
- try {
290
- renameSync(source, destination);
291
- return;
292
- } catch (error) {
293
- const code = (error as NodeJS.ErrnoException).code;
294
- const transientWindowsError = process.platform === "win32"
295
- && (code === "EBUSY" || code === "EPERM" || code === "EACCES");
296
- if (!transientWindowsError || attempt >= 2) throw error;
297
- await Bun.sleep(25 * (attempt + 1));
298
- }
299
- }
300
- }
301
-
302
268
  /**
303
269
  * Async atomic write (#612): same temp+harden+rename and residual-temp policy as
304
270
  * atomicWriteFile, but Windows ACL harden yields the event loop. Timeout memo is keyed
@@ -662,12 +628,14 @@ function resolveRuntimePortPath(): string {
662
628
  }
663
629
 
664
630
  const warnedConfigFallbacks = new Set<string>();
631
+ const warnedInheritedFastWireConflicts = new Set<string>();
665
632
  let lastWarningReconciledGeneration = 0;
666
633
 
667
634
  export function reconcileConfigWarningMemos(generation: number): number {
668
635
  if (generation <= lastWarningReconciledGeneration) return 0;
669
- const removed = warnedConfigFallbacks.size;
636
+ const removed = warnedConfigFallbacks.size + warnedInheritedFastWireConflicts.size;
670
637
  warnedConfigFallbacks.clear();
638
+ warnedInheritedFastWireConflicts.clear();
671
639
  lastWarningReconciledGeneration = generation;
672
640
  return removed;
673
641
  }
@@ -716,6 +684,16 @@ export function requestPacingConfigError(value: unknown): string | null {
716
684
  return "requestPacing must contain enabled and a valid requestsPerMinute/minIntervalMs provider rule or model overrides";
717
685
  }
718
686
 
687
+ const fastWireSchema = z.object({
688
+ kind: z.string(),
689
+ canonicalToWire: z.record(z.string().trim(), z.string().trim()),
690
+ foreignCallerTiers: z.string(),
691
+ betas: z.array(z.string().trim()).optional(),
692
+ }).strict().superRefine((fastWire, ctx) => {
693
+ const error = fastWireDeclarationError({ fastWire });
694
+ if (error) ctx.addIssue({ code: "custom", message: error });
695
+ }).transform(fastWire => fastWire as FastWire);
696
+
719
697
  /**
720
698
  * Zod schema for one provider entry: known fields are validated strictly while unknown
721
699
  * fields pass through (preserved for runtime extensions).
@@ -731,6 +709,7 @@ const providerConfigSchema = z.object({
731
709
  responsesPath: z.string().min(1).optional(),
732
710
  statelessResponses: z.boolean().optional(),
733
711
  requiresAdjacentResponsesToolResults: z.boolean().optional(),
712
+ fastWire: fastWireSchema.nullable().optional(),
734
713
  supportsServiceTier: z.boolean().optional(),
735
714
  modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(),
736
715
  preserveResponsesReasoningContent: z.boolean().optional(),
@@ -1416,6 +1395,13 @@ const configSchema = z.object({
1416
1395
  });
1417
1396
  }
1418
1397
  const provider = config.providers[name];
1398
+ if (hasFastWireCapabilityConflict(provider)) {
1399
+ ctx.addIssue({
1400
+ code: "custom",
1401
+ path: ["providers", redactSecretString(name), "fastWire"],
1402
+ message: "fastWire=null conflicts with supportsServiceTier=true",
1403
+ });
1404
+ }
1419
1405
  const openRouterRoutingError = openRouterRoutingConfigError(provider);
1420
1406
  if (openRouterRoutingError) {
1421
1407
  ctx.addIssue({
@@ -2154,6 +2140,52 @@ function warnDegradedNativeSubagentConfig(rawParsed: unknown, config: OcxConfig)
2154
2140
  }
2155
2141
  }
2156
2142
 
2143
+ /**
2144
+ * Registry metadata can gain service-tier capability after a config was written. An explicit
2145
+ * `fastWire: null` remains authoritative on load and on whole-document writes; rejecting either
2146
+ * would discard or lock access to unrelated providers and API keys. Direct contradictions within
2147
+ * one provider row remain schema errors through the outer config refinement, where the dynamic
2148
+ * provider name can be redacted before it reaches diagnostics.
2149
+ */
2150
+ function inheritedFastWireConflictProviderNames(
2151
+ config: Pick<OcxConfig, "providers">,
2152
+ ): string[] {
2153
+ const conflicts: string[] = [];
2154
+ for (const [name, provider] of Object.entries(config.providers)) {
2155
+ if (provider.fastWire !== null || provider.supportsServiceTier === false) continue;
2156
+ const registry = providerMatchesRegistryTransport(name, provider)
2157
+ ? getProviderRegistryEntry(name)
2158
+ : undefined;
2159
+ if (!registry) continue;
2160
+ const effectiveProviderCapability = provider.supportsServiceTier ?? registry.supportsServiceTier;
2161
+ const effectiveModelCapabilities = {
2162
+ ...(registry.modelSupportsServiceTier ?? {}),
2163
+ ...(provider.modelSupportsServiceTier ?? {}),
2164
+ };
2165
+ if (
2166
+ effectiveProviderCapability === true
2167
+ || Object.values(effectiveModelCapabilities).some(value => value === true)
2168
+ ) {
2169
+ conflicts.push(name);
2170
+ }
2171
+ }
2172
+ return conflicts;
2173
+ }
2174
+
2175
+ function inheritedFastWireConflictWarning(name: string): string {
2176
+ return `providers.${redactSecretString(name)}.fastWire=null overrides service-tier capability inherited from the matching registry entry`;
2177
+ }
2178
+
2179
+ function warnInheritedFastWireConflicts(configPath: string, config: OcxConfig): void {
2180
+ const names = inheritedFastWireConflictProviderNames(config);
2181
+ if (names.length === 0 || warnedInheritedFastWireConflicts.has(configPath)) return;
2182
+ warnedInheritedFastWireConflicts.add(configPath);
2183
+ console.warn(
2184
+ `⚠️ config.json ${names.map(inheritedFastWireConflictWarning).join("; ")}. `
2185
+ + "The persisted providers and API keys were preserved.",
2186
+ );
2187
+ }
2188
+
2157
2189
  /**
2158
2190
  * Load and validate config.json into an OcxConfig. Missing files reset to
2159
2191
  * defaults and clear stale overlays. Broken existing files also fall back to
@@ -2178,6 +2210,7 @@ export function loadConfig(): OcxConfig {
2178
2210
  const result = configSchema.safeParse(parsed);
2179
2211
  if (result.success) {
2180
2212
  const config = normalizeApiKeyIds(result.data as OcxConfig);
2213
+ warnInheritedFastWireConflicts(configPath, config);
2181
2214
  warnDegradedStreamMode(parsed, config);
2182
2215
  warnDegradedHostname(parsed, config);
2183
2216
  warnDegradedApiKeys(parsed, config);
@@ -2202,6 +2235,7 @@ export function loadConfig(): OcxConfig {
2202
2235
  if (retryResult.success) {
2203
2236
  warnConfigRepaired(configPath, result.error);
2204
2237
  const config = normalizeApiKeyIds(retryResult.data as OcxConfig);
2238
+ warnInheritedFastWireConflicts(configPath, config);
2205
2239
  warnDegradedHostname(parsed, config);
2206
2240
  warnDegradedApiKeys(parsed, config);
2207
2241
  warnDegradedCodexAccountPriorities(parsed, config);
@@ -2221,6 +2255,7 @@ export function loadConfig(): OcxConfig {
2221
2255
  {
2222
2256
  warnDroppedConfigSections(configPath, salvaged.dropped, salvaged.issues);
2223
2257
  const config = normalizeApiKeyIds(salvaged.parsed);
2258
+ warnInheritedFastWireConflicts(configPath, config);
2224
2259
  warnDegradedHostname(parsed, config);
2225
2260
  warnDegradedApiKeys(parsed, config);
2226
2261
  warnDegradedCodexAccountPriorities(parsed, config);
@@ -2280,6 +2315,7 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf
2280
2315
  const rawEffort = rawClaudeSubagentEffort(rawParsed);
2281
2316
  const normalized = normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, rawParsed), rawParsed);
2282
2317
  const warnings = configPlaceholderWarnings(normalized);
2318
+ warnings.push(...inheritedFastWireConflictProviderNames(normalized).map(inheritedFastWireConflictWarning));
2283
2319
  warnings.push(...degradedCodexAccountPriorityWarnings(rawParsed, normalized));
2284
2320
  if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) {
2285
2321
  warnings.push(`claudeCode.subagentEffort ignored: expected one of ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}`);
@@ -2493,7 +2529,10 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
2493
2529
  ?? loopbackListenerPortError(value);
2494
2530
  if (boundaryError) return { ok: false, error: boundaryError };
2495
2531
  const result = configSchema.safeParse(value);
2496
- if (result.success) return { ok: true, config: normalizeApiKeyIds(result.data as OcxConfig) };
2532
+ if (result.success) {
2533
+ const config = normalizeApiKeyIds(result.data as OcxConfig);
2534
+ return { ok: true, config };
2535
+ }
2497
2536
  return { ok: false, error: schemaDiagnosticsError(result.error) };
2498
2537
  }
2499
2538