@bitkyc08/opencodex 2.57.0 → 2.58.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 (86) hide show
  1. package/gui/dist/assets/{index-Cz7CLdif.js → index-BbrHOIY0.js} +2 -2
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -2
  4. package/src/adapters/codebuddy/scaffold-guard.ts +5 -4
  5. package/src/adapters/command-code.ts +13 -4
  6. package/src/adapters/cursor/cursor-errors.ts +15 -0
  7. package/src/adapters/cursor/discovery.ts +65 -1
  8. package/src/adapters/cursor/live-transport.ts +5 -1
  9. package/src/adapters/cursor/protobuf-events.ts +110 -11
  10. package/src/adapters/cursor/protobuf-request.ts +19 -1
  11. package/src/adapters/cursor/text-toolcall.ts +230 -0
  12. package/src/adapters/cursor/thread-continuity.ts +67 -0
  13. package/src/adapters/cursor/types.ts +5 -0
  14. package/src/adapters/cursor.ts +55 -5
  15. package/src/adapters/google-http.ts +38 -13
  16. package/src/adapters/mimo-free.ts +32 -17
  17. package/src/adapters/ollama-native.ts +42 -8
  18. package/src/adapters/openai-responses/passthrough.ts +30 -4
  19. package/src/adapters/openai-responses/request-strips.ts +43 -0
  20. package/src/adapters/physical-send.ts +50 -0
  21. package/src/bridge/response-json.ts +1 -1
  22. package/src/bridge/sse.ts +1 -1
  23. package/src/claude/outbound.ts +14 -4
  24. package/src/cli/config-command.ts +35 -18
  25. package/src/cli/dispatch.ts +17 -4
  26. package/src/cli/index.ts +44 -2
  27. package/src/cli/system-command.ts +70 -1
  28. package/src/cli/uninstall-client-state.ts +12 -0
  29. package/src/codex/auth-context.ts +42 -8
  30. package/src/codex/desktop-switches.ts +145 -0
  31. package/src/codex/history-job.ts +5 -1
  32. package/src/codex/history-provider.ts +33 -4
  33. package/src/codex/history-worker.ts +14 -1
  34. package/src/codex/inject/remove.ts +145 -7
  35. package/src/codex/inject/restore.ts +204 -32
  36. package/src/codex/inject.ts +3 -7
  37. package/src/codex/loopback-target.ts +9 -0
  38. package/src/codex/native-profile-startup.ts +64 -20
  39. package/src/config/atomic-write.ts +83 -8
  40. package/src/config/schema/config-schema.ts +2 -0
  41. package/src/config/schema/leaf-validators.ts +1 -0
  42. package/src/generated/compatibility-version.json +132 -68
  43. package/src/lib/bounded-subprocess.ts +62 -10
  44. package/src/lib/windows-secret-acl.ts +151 -15
  45. package/src/lib/windows-user-principal.ts +5 -1
  46. package/src/providers/derive.ts +6 -0
  47. package/src/providers/model-discovery.ts +19 -7
  48. package/src/providers/registry/entries-core.ts +11 -0
  49. package/src/providers/registry/entries-extended.ts +50 -28
  50. package/src/providers/registry/model-seeds.ts +67 -17
  51. package/src/providers/registry/types.ts +9 -0
  52. package/src/responses/spill-store.ts +17 -0
  53. package/src/responses/state/body-policy.ts +25 -0
  54. package/src/responses/state/spill-queue.ts +8 -6
  55. package/src/responses/state.ts +3 -22
  56. package/src/router.ts +4 -0
  57. package/src/server/auth-cors.ts +1 -0
  58. package/src/server/index/websocket-handler.ts +48 -1
  59. package/src/server/management/config-routes.ts +27 -5
  60. package/src/server/models-capabilities.ts +24 -3
  61. package/src/server/responses/codex-ws-exchange.ts +65 -4
  62. package/src/server/responses/combo-stream-preflight.ts +68 -5
  63. package/src/server/responses/core-combo.ts +26 -0
  64. package/src/server/responses/core-options.ts +3 -0
  65. package/src/server/responses/fetch-helpers.ts +4 -1
  66. package/src/server/responses/native-injection-protocol.ts +42 -0
  67. package/src/server/responses/native-injection-replay.ts +105 -0
  68. package/src/server/responses/native-injection.ts +242 -0
  69. package/src/server/responses/native-response-control.ts +56 -0
  70. package/src/server/responses/native-response-json.ts +14 -0
  71. package/src/server/responses/native-response-output.ts +37 -0
  72. package/src/server/responses/native-steering-log.ts +44 -0
  73. package/src/server/responses/native-steering-policy.ts +49 -0
  74. package/src/server/responses/native-steering-replay.ts +126 -0
  75. package/src/server/responses/native-steering-settings.ts +76 -0
  76. package/src/server/responses/native-steering.ts +400 -0
  77. package/src/server/responses/native-tool-results.ts +130 -0
  78. package/src/server/responses/passthrough-delivery.ts +11 -0
  79. package/src/server/responses/passthrough-dispatch.ts +33 -1
  80. package/src/server/responses/request-prepare.ts +41 -0
  81. package/src/server/responses/ws-upstream.ts +21 -1
  82. package/src/server/stop-teardown.ts +8 -1
  83. package/src/server/ws-bridge.ts +16 -1
  84. package/src/service/cli.ts +13 -1
  85. package/src/types/config.ts +4 -0
  86. package/src/types/provider.ts +13 -0
@@ -9,28 +9,80 @@ export interface BoundedSubprocessExit {
9
9
  timedOut: boolean;
10
10
  }
11
11
 
12
- /** Kill at the deadline and abandon immediately; late exit/rejection remains observed. */
12
+ export type SubprocessDeadlineScheduler = (
13
+ callback: () => void,
14
+ milliseconds: number,
15
+ ) => () => void;
16
+
17
+ const scheduleDeadline: SubprocessDeadlineScheduler = (callback, milliseconds) => {
18
+ const timer = setTimeout(callback, milliseconds);
19
+ return () => clearTimeout(timer);
20
+ };
21
+
22
+ /**
23
+ * Compatibility allowance used by the ACL runner's outer watchdog.
24
+ *
25
+ * `kill()` only REQUESTS termination. It returns before the kernel has torn the process down, and
26
+ * every handle that process holds stays held until it does. On Windows that is not a detail: file
27
+ * locking is mandatory, so a directory an abandoned `icacls.exe` still has open cannot be removed
28
+ * by anyone, and the removal fails with EPERM rather than waiting.
29
+ */
30
+ export const SUBPROCESS_KILL_GRACE_MS = 2_000;
31
+
32
+ /**
33
+ * Wait for a child until the deadline. At the deadline, kill it AND wait for it to actually die.
34
+ *
35
+ * This used to kill, `unref`, and resolve in the same tick, which made every caller's "I waited
36
+ * for my child" guarantee false precisely when it mattered. The ACL runner now waits here until
37
+ * actual exit; if its separate caller-facing belt fires first, that layer registers the target so
38
+ * removal can wait for the reap without making ordinary startup or shutdown unbounded.
39
+ *
40
+ * That cost three failed fixes. #4789 blamed the removal retry budget and asked for more than
41
+ * 2.5s; #4796 gave it a 15s exponential schedule; a later change awaited the hardening flight from
42
+ * the test hook. Windows shard 1/6 failed identically through all three, because none of them
43
+ * addressed a live process holding the handle -- run 35108652486 burned the full 15s budget and
44
+ * still threw `EPERM ... rm ocx-management-auth-fDchUb`, with two
45
+ * `ACL hardening timed out (ETIMEDOUT) - transient icacls stall` lines logged beside it.
46
+ *
47
+ * The old grace still abandoned a live child after two seconds. That recreated the same false
48
+ * ownership contract on a slower clock: the ACL flight settled, cleanup removed the directory,
49
+ * and Windows returned EPERM because the child still held it. A handle-bearing caller therefore
50
+ * has no second deadline after kill. The child's actual exit is the only release signal.
51
+ *
52
+ * Pass `0` to opt out for a child that holds no path anyone will remove. The numeric form is kept
53
+ * for compatibility with the existing callers; any positive value means that reaping is required.
54
+ * The injected scheduler is a test seam so deadline and exit ordering can be proved without sleep.
55
+ */
13
56
  export function waitForSubprocessExit(
14
57
  proc: KillableSubprocess,
15
58
  timeoutMs: number,
59
+ reapAfterKill: number = SUBPROCESS_KILL_GRACE_MS,
60
+ schedule: SubprocessDeadlineScheduler = scheduleDeadline,
16
61
  ): Promise<BoundedSubprocessExit> {
17
62
  return new Promise(resolve => {
18
63
  let settled = false;
19
- let timer: ReturnType<typeof setTimeout> | undefined;
64
+ let deadlineFired = false;
65
+ let cancelDeadline: (() => void) | undefined;
20
66
  const finish = (result: BoundedSubprocessExit): void => {
21
67
  if (settled) return;
22
68
  settled = true;
23
- if (timer !== undefined) clearTimeout(timer);
69
+ cancelDeadline?.();
24
70
  resolve(result);
25
71
  };
26
- timer = setTimeout(() => {
72
+ const reaped = proc.exited.then(
73
+ exitCode => finish(deadlineFired
74
+ ? { exitCode: null, timedOut: true }
75
+ : { exitCode, timedOut: false }),
76
+ () => finish({ exitCode: null, timedOut: deadlineFired }),
77
+ );
78
+ cancelDeadline = schedule(() => {
79
+ deadlineFired = true;
27
80
  try { proc.kill(); } catch { /* already exited */ }
28
- try { proc.unref?.(); } catch { /* abandonment is still authoritative */ }
29
- finish({ exitCode: null, timedOut: true });
81
+ if (reapAfterKill <= 0) {
82
+ try { proc.unref?.(); } catch { /* abandonment is still authoritative */ }
83
+ finish({ exitCode: null, timedOut: true });
84
+ return;
85
+ }
30
86
  }, Math.max(1, timeoutMs));
31
- void proc.exited.then(
32
- exitCode => finish({ exitCode, timedOut: false }),
33
- () => finish({ exitCode: null, timedOut: false }),
34
- );
35
87
  });
36
88
  }
@@ -30,8 +30,13 @@
30
30
  */
31
31
 
32
32
  import { existsSync, statSync } from "node:fs";
33
+ import { isAbsolute, relative, resolve } from "node:path";
33
34
  import { env, platform } from "node:process";
34
- import { waitForSubprocessExit } from "./bounded-subprocess";
35
+ import {
36
+ SUBPROCESS_KILL_GRACE_MS,
37
+ waitForSubprocessExit,
38
+ type SubprocessDeadlineScheduler,
39
+ } from "./bounded-subprocess";
35
40
  import { resolveTrustedWindowsIcaclsExe } from "./windows-elevation";
36
41
  import {
37
42
  cachedCurrentWindowsIdentity,
@@ -48,17 +53,32 @@ const hardenedPaths = new Map<string, HardenedIdentity>();
48
53
  * that attempt was consumed. Ordinary callers never consume it.
49
54
  */
50
55
  const timedOutPaths = new Map<string, boolean>();
56
+ /** Compatibility slack before the outer belt releases a caller whose killed child has not reaped. */
57
+ const ASYNC_ICACLS_BELT_MARGIN_MS = 250;
58
+ const pendingAsyncIcaclsReaps = new Map<string, Set<Promise<void>>>();
59
+
60
+ const scheduleAsyncIcaclsBelt: SubprocessDeadlineScheduler = (callback, milliseconds) => {
61
+ const timer = setTimeout(callback, milliseconds);
62
+ return () => clearTimeout(timer);
63
+ };
64
+ let asyncIcaclsBeltScheduler: SubprocessDeadlineScheduler = scheduleAsyncIcaclsBelt;
51
65
 
52
66
  /**
53
- * The memo value: `object:freshness` for a file a harden was actually attributed
54
- * to.
67
+ * The memo value: the `object` plus `freshness` of a file a harden was actually
68
+ * attributed to.
55
69
  *
56
70
  * There is deliberately no null member. An observation that cannot be read is
57
71
  * not stored at all — the entry is deleted — because a "recorded as unverifiable"
58
72
  * value was dead code the moment attribution became a before/after comparison,
59
73
  * and a branch nothing can reach is a branch no test can defend.
74
+ *
75
+ * It is the observation itself rather than a joined string so that the two
76
+ * questions stay separately askable after storage. `reattributeHardenedSecretPath`
77
+ * has to compare the object while deliberately ignoring the freshness, and
78
+ * recovering one half out of `dev:ino:ctimeNs` by counting colons would make that
79
+ * comparison depend on a format nothing declares.
60
80
  */
61
- type HardenedIdentity = string;
81
+ type HardenedIdentity = PathObservation;
62
82
 
63
83
  /**
64
84
  * What a stat can tell us about WHICH OBJECT is at a path.
@@ -128,8 +148,8 @@ function observe(targetPath: string): PathObservation | null {
128
148
  }
129
149
  }
130
150
 
131
- function memoValue(seen: PathObservation): HardenedIdentity {
132
- return `${seen.object}:${seen.freshness}`;
151
+ function sameObservation(a: PathObservation, b: PathObservation): boolean {
152
+ return a.object === b.object && a.freshness === b.freshness;
133
153
  }
134
154
 
135
155
  /**
@@ -155,7 +175,7 @@ function memoSatisfied(cache: Map<string, HardenedIdentity>, targetPath: string)
155
175
  // without any ACL work. That needs exact-identity ABA to bite — outside the
156
176
  // proof bound this unit claims — but "the consequence is out of scope" is not a
157
177
  // reason to keep an entry we have just proven does not describe what is there.
158
- if (current === null || memoValue(current) !== remembered) {
178
+ if (current === null || !sameObservation(current, remembered)) {
159
179
  cache.delete(targetPath);
160
180
  return false;
161
181
  }
@@ -203,7 +223,7 @@ function recordHarden(
203
223
  cache.delete(targetPath);
204
224
  return false;
205
225
  }
206
- cache.set(targetPath, memoValue(after));
226
+ cache.set(targetPath, after);
207
227
  return true;
208
228
  }
209
229
 
@@ -339,7 +359,7 @@ function defaultIcaclsRunner(args: string[], timeoutMs: number): IcaclsResult {
339
359
  /**
340
360
  * Async icacls runner (#612): yields the event loop while waiting for the child.
341
361
  * Async Subprocess has no exitedDueToTimeout, so the shared settlement helper
342
- * classifies the deadline and abandons a child that does not settle after kill.
362
+ * classifies the deadline and keeps waiting for a killed child to actually exit.
343
363
  */
344
364
  async function defaultAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise<IcaclsResult> {
345
365
  const proc = trySpawnIcacls(args);
@@ -359,21 +379,78 @@ async function defaultAsyncIcaclsRunner(args: string[], timeoutMs: number): Prom
359
379
  function awaitAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise<IcaclsResult> {
360
380
  return new Promise(resolve => {
361
381
  let settled = false;
362
- let timer: ReturnType<typeof setTimeout> | undefined;
382
+ let cancelBelt: (() => void) | undefined;
363
383
  const finish = (result: IcaclsResult): void => {
364
384
  if (settled) return;
365
385
  settled = true;
366
- if (timer !== undefined) clearTimeout(timer);
386
+ cancelBelt?.();
367
387
  resolve(result);
368
388
  };
369
- timer = setTimeout(
370
- () => finish({ success: false, exitCode: null, timedOut: true, stdout: "" }),
371
- Math.max(1, timeoutMs),
389
+ const runner = asyncIcaclsRunner(args, timeoutMs).then(
390
+ result => { finish(result); },
391
+ () => { finish(spawnFailedResult()); },
392
+ );
393
+ // The belt has to outlast the runner it is guarding, or it is not a belt -- it is the
394
+ // deadline. The runner may now legitimately outlive it while a killed child is reaped. The
395
+ // caller is still released, but the target is registered so removal can wait for the distinct
396
+ // handle-release question instead of treating flight settlement as proof that the child died.
397
+ cancelBelt = asyncIcaclsBeltScheduler(
398
+ () => {
399
+ const targetPath = args[0];
400
+ if (targetPath) registerPendingAsyncIcaclsReap(targetPath, runner);
401
+ finish({ success: false, exitCode: null, timedOut: true, stdout: "" });
402
+ },
403
+ Math.max(1, timeoutMs) + SUBPROCESS_KILL_GRACE_MS + ASYNC_ICACLS_BELT_MARGIN_MS,
372
404
  );
373
- void asyncIcaclsRunner(args, timeoutMs).then(finish, () => finish(spawnFailedResult()));
374
405
  });
375
406
  }
376
407
 
408
+ function registerPendingAsyncIcaclsReap(targetPath: string, reap: Promise<void>): void {
409
+ let pending = pendingAsyncIcaclsReaps.get(targetPath);
410
+ if (!pending) {
411
+ pending = new Set();
412
+ pendingAsyncIcaclsReaps.set(targetPath, pending);
413
+ }
414
+ pending.add(reap);
415
+ void reap.finally(() => {
416
+ pending!.delete(reap);
417
+ if (pending!.size === 0) pendingAsyncIcaclsReaps.delete(targetPath);
418
+ });
419
+ }
420
+
421
+ function pathIsAtOrBelow(targetPath: string, rootPath: string): boolean {
422
+ const relativePath = relative(resolve(rootPath), resolve(targetPath));
423
+ return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
424
+ }
425
+
426
+ /** True while an async icacls runner still owns this exact path after its caller's belt fired. */
427
+ export function windowsSecretAclReapPendingForPath(targetPath: string): boolean {
428
+ return (pendingAsyncIcaclsReaps.get(targetPath)?.size ?? 0) > 0;
429
+ }
430
+
431
+ /** Non-blocking removal guard for callers that must refuse rather than wait for a stuck child. */
432
+ export function windowsSecretAclReapPendingAtOrBelow(rootPath: string): boolean {
433
+ return [...pendingAsyncIcaclsReaps.keys()]
434
+ .some(targetPath => pathIsAtOrBelow(targetPath, rootPath));
435
+ }
436
+
437
+ /**
438
+ * Removal barrier for a file or tree that may still be held by a timed-out icacls child.
439
+ *
440
+ * This wait is deliberately separate from ordinary startup and shutdown: a genuinely stuck child
441
+ * must not defeat the caller-facing belt. Code that chooses to remove the target has the stricter
442
+ * contract and must not proceed until every registered runner at or below it has actually reaped.
443
+ */
444
+ export async function flushWindowsSecretAclReapsBeforeRemoval(rootPath: string): Promise<void> {
445
+ while (true) {
446
+ const pending = [...pendingAsyncIcaclsReaps]
447
+ .filter(([targetPath]) => pathIsAtOrBelow(targetPath, rootPath))
448
+ .flatMap(([, reaps]) => [...reaps]);
449
+ if (pending.length === 0) return;
450
+ await Promise.all(pending);
451
+ }
452
+ }
453
+
377
454
  let icaclsRunner: IcaclsRunner = defaultIcaclsRunner;
378
455
  let asyncIcaclsRunner: AsyncIcaclsRunner = defaultAsyncIcaclsRunner;
379
456
  let platformOverride: string | null = null;
@@ -389,6 +466,13 @@ export function setAsyncIcaclsRunnerForTests(runner: AsyncIcaclsRunner | null):
389
466
  asyncIcaclsRunner = runner ?? defaultAsyncIcaclsRunner;
390
467
  }
391
468
 
469
+ /** Test seam: fire the outer caller-facing belt without sleeping. */
470
+ export function setAsyncIcaclsBeltSchedulerForTests(
471
+ scheduler: SubprocessDeadlineScheduler | null,
472
+ ): void {
473
+ asyncIcaclsBeltScheduler = scheduler ?? scheduleAsyncIcaclsBelt;
474
+ }
475
+
392
476
  /**
393
477
  * Test seam: force the platform gate (e.g. "win32") so CI on POSIX reaches the runner.
394
478
  *
@@ -423,6 +507,58 @@ export function forgetHardenedSecretPath(targetPath: string): void {
423
507
  hardenedPaths.delete(targetPath);
424
508
  }
425
509
 
510
+ /**
511
+ * Re-attribute an existing file memo to the SAME object after the caller wrote
512
+ * content to it through a descriptor whose identity it verified.
513
+ *
514
+ * This exists because `freshness` is `ctimeNs`, and on Windows libuv reports
515
+ * `st_ctim` from the NTFS ChangeTime, which moves when file DATA is written. An
516
+ * atomic writer therefore invalidated its own memo between the harden that
517
+ * protects the empty temp and the harden before the rename, and paid a second
518
+ * full `/grant:r` + `/inheritance:r` + `/remove:g` sequence to reapply the ACL
519
+ * that was already on the file. Every secret write on Windows paid it twice.
520
+ *
521
+ * Only the freshness moves, and only for an unchanged object: a different object
522
+ * retires the entry instead. A caller must have proven, immediately beforehand,
523
+ * that `targetPath` resolves to the object its own descriptor refers to.
524
+ *
525
+ * The cost of this is worth stating exactly, because `PathObservation` documents
526
+ * that freshness also moves when PERMISSIONS change, and this call cannot tell
527
+ * the two apart. So a DACL change landing between the harden and this call is
528
+ * absorbed instead of forcing a re-harden. That window is the caller's own
529
+ * content write; every permission change after this call still moves ctime
530
+ * again and still misses the memo, so the detection this memo provides is
531
+ * relocated, not removed.
532
+ *
533
+ * What makes the absorbed window acceptable is who can be in it. Once the harden
534
+ * has run, the DACL is an explicit owner-only ACE with inheritance removed, so
535
+ * no other principal can open the file for `WRITE_DAC` at all. The one principal
536
+ * who can still rewrite that DACL is one holding a handle opened BEFORE the
537
+ * harden, and Windows keeps the access granted to an open handle: that principal
538
+ * can equally rewrite the DACL after any later harden, and after the rename, on
539
+ * the same object. A second mutation pass never bounded that capability — it
540
+ * stripped an ACE the holder could immediately re-add — so declining to repeat
541
+ * it removes no guarantee anyone had.
542
+ *
543
+ * Refusal is cheap and safe in either direction: an unmoved memo simply means the
544
+ * caller's next harden runs in full.
545
+ *
546
+ * Returns whether the memo now describes what is at the path.
547
+ */
548
+ export function reattributeHardenedSecretPath(targetPath: string): boolean {
549
+ const remembered = hardenedPaths.get(targetPath);
550
+ if (remembered === undefined) return false;
551
+ const current = observe(targetPath);
552
+ // Unreadable, or a different object: this is exactly the case the memo must
553
+ // not cover. Retire it so the next harden is a real one.
554
+ if (current === null || current.object !== remembered.object) {
555
+ hardenedPaths.delete(targetPath);
556
+ return false;
557
+ }
558
+ hardenedPaths.set(targetPath, current);
559
+ return true;
560
+ }
561
+
426
562
  /**
427
563
  * Ephemeral-path lifecycle release: clears the success memo AND any timeout
428
564
  * memo keyed by THIS TEMP path in both namespaces. Call only after the temp is
@@ -160,7 +160,11 @@ async function defaultAsyncWindowsPrincipalRunner(
160
160
  stderr: "ignore",
161
161
  windowsHide: true,
162
162
  });
163
- const { exitCode, timedOut } = await waitForSubprocessExit(proc, timeoutMs);
163
+ // No kill grace. The grace exists so a dying child releases a path someone is about to
164
+ // remove; this lookup holds no such path, and it runs during `ocx start`, where the composed
165
+ // acceptance cases already measure real startups at up to 38.8s against a bounded watchdog.
166
+ // Paying two extra seconds per timed-out resolution there buys nothing and costs margin.
167
+ const { exitCode, timedOut } = await waitForSubprocessExit(proc, timeoutMs, 0);
164
168
  // `.bytes()` rather than `.text()`, for the same reason as the sync runner above.
165
169
  const stdout: string | Uint8Array = !timedOut && proc.stdout
166
170
  ? await new Response(proc.stdout).bytes().catch(() => new Uint8Array())
@@ -266,6 +266,9 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
266
266
  ...(entry.requiresAdjacentResponsesToolResults !== undefined
267
267
  ? { requiresAdjacentResponsesToolResults: entry.requiresAdjacentResponsesToolResults }
268
268
  : {}),
269
+ ...(entry.requiresPairedResponsesToolResults !== undefined
270
+ ? { requiresPairedResponsesToolResults: entry.requiresPairedResponsesToolResults }
271
+ : {}),
269
272
  ...(entry.annotateEmptyToolOutputs !== undefined
270
273
  ? { annotateEmptyToolOutputs: entry.annotateEmptyToolOutputs }
271
274
  : {}),
@@ -541,6 +544,9 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
541
544
  if (prov.requiresAdjacentResponsesToolResults === undefined && seed.requiresAdjacentResponsesToolResults !== undefined) {
542
545
  prov.requiresAdjacentResponsesToolResults = seed.requiresAdjacentResponsesToolResults;
543
546
  }
547
+ if (prov.requiresPairedResponsesToolResults === undefined && seed.requiresPairedResponsesToolResults !== undefined) {
548
+ prov.requiresPairedResponsesToolResults = seed.requiresPairedResponsesToolResults;
549
+ }
544
550
  if (prov.annotateEmptyToolOutputs === undefined && seed.annotateEmptyToolOutputs !== undefined) {
545
551
  prov.annotateEmptyToolOutputs = seed.annotateEmptyToolOutputs;
546
552
  }
@@ -129,6 +129,14 @@ export function providerModelDiscoverySpecError(spec: ProviderModelDiscoverySpec
129
129
  if (queryEntries.some(([key, value]) => !key.trim() || key.length > 128 || typeof value !== "string" || value.length > 512)) {
130
130
  return "discovery query keys/values exceed their bounds";
131
131
  }
132
+ for (const [field, value] of [
133
+ ["envelopeKey", spec.envelopeKey],
134
+ ["idField", spec.idField],
135
+ ] as const) {
136
+ if (value !== undefined && (
137
+ typeof value !== "string" || !value || value !== value.trim() || value.length > 128
138
+ )) return `${field} must be a nonblank field name up to 128 characters`;
139
+ }
132
140
  for (const [field, value, hardLimit] of [
133
141
  ["maxResponseBytes", spec.maxResponseBytes, MODEL_DISCOVERY_MAX_RESPONSE_BYTES],
134
142
  ["maxModels", spec.maxModels, MODEL_DISCOVERY_MAX_MODELS],
@@ -422,7 +430,7 @@ export function extractModelEnvelopeRows(
422
430
  return { ok: true, rows };
423
431
  }
424
432
 
425
- /** Validate, bound, deduplicate, and declaratively filter OpenAI `{data:[...]}` or top-level arrays (Together `#617`). */
433
+ /** Validate, bound, deduplicate, and filter the declared envelope or a top-level array (Together `#617`). */
426
434
  /**
427
435
  * Metadata a sibling `models[]` array may contribute to an ALREADY-ADMITTED
428
436
  * `data[]` row (#1797).
@@ -501,24 +509,26 @@ export function extractProviderModelItems(
501
509
  let data: unknown[];
502
510
  let siblings: SiblingIndex | null = null;
503
511
  if (Array.isArray(value)) {
504
- // Together-style top-level /models arrays. Catalog discovery must not treat a stray
505
- // `models` key on openai-chat responses as valid — only `data` envelopes or top-level arrays.
512
+ // Together-style top-level /models arrays. The default contract must not treat a stray
513
+ // `models` key on openai-chat responses as valid; only a provider spec may opt into it.
506
514
  if (value.length > limit) return { ok: false, reason: "too_many_models" };
507
515
  data = value;
508
516
  } else {
509
- const envelope = extractModelEnvelopeRows(value, discovery.maxModels, ["data"]);
517
+ const envelopeKey = discovery.spec?.envelopeKey ?? "data";
518
+ const envelope = extractModelEnvelopeRows(value, discovery.maxModels, [envelopeKey]);
510
519
  if (!envelope.ok) return envelope;
511
520
  data = envelope.rows;
512
- siblings = buildSiblingIndex(value, limit);
521
+ siblings = envelopeKey === "data" ? buildSiblingIndex(value, limit) : null;
513
522
  }
514
523
 
515
524
  const items: ProviderModelsApiItem[] = [];
516
525
  const seen = new Set<string>();
526
+ const idField = discovery.spec?.idField ?? "id";
517
527
  for (const raw of data) {
518
528
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
519
529
  return { ok: false, reason: "invalid_shape" };
520
530
  }
521
- const id = (raw as { id?: unknown }).id;
531
+ const id = (raw as Record<string, unknown>)[idField];
522
532
  if (!isValidModelDiscoveryModelId(id)) return { ok: false, reason: "invalid_shape" };
523
533
  const prefix = discovery.spec?.stripIdPrefix;
524
534
  let finalId = id;
@@ -526,7 +536,9 @@ export function extractProviderModelItems(
526
536
  finalId = finalId.slice(prefix.length);
527
537
  if (!isValidModelDiscoveryModelId(finalId)) continue;
528
538
  }
529
- const item = finalId === id ? raw as ProviderModelsApiItem : { ...(raw as ProviderModelsApiItem), id: finalId };
539
+ const item = finalId === id && idField === "id"
540
+ ? raw as ProviderModelsApiItem
541
+ : { ...(raw as Record<string, unknown>), id: finalId };
530
542
  // Admission is decided on the ORIGINAL `data[]` row, before any sibling
531
543
  // enrichment. Merging first let a `models[]` entry supply the very field a
532
544
  // provider filter requires — reproduced against the real Chutes policy,
@@ -282,6 +282,17 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [
282
282
  forwardCallerServiceTier: false,
283
283
  },
284
284
  },
285
+ // Grok 4.6/4.5 OAuth Responses replays Codex tool history. After a mid-stream 502/reset,
286
+ // the client can resend a function_call without a matching output, or with hook-injected
287
+ // developer context between the pair. Google already synthesizes a missing tool_result
288
+ // (#2199). xAI's Responses parser does not, so the next turns 400 and the thread snowballs.
289
+ // Reuse the existing adjacency capability (Kimi #4726, DeepSeek #1292). Do not set
290
+ // statelessResponses: xAI stores responses for 30 days and documents previous_response_id.
291
+ // https://docs.x.ai/developers/model-capabilities/text/comparison
292
+ requiresAdjacentResponsesToolResults: true,
293
+ // The dangling half of the same failure: a call whose output never arrived. Kimi accepts that
294
+ // shape, so this is a second capability rather than a widening of the one above.
295
+ requiresPairedResponsesToolResults: true,
285
296
  // Vision lineup per docs.x.ai model-capabilities/images/understanding: the grok-4.x chat
286
297
  // models accept image input (JPEG/PNG, URL or base64). Without this the catalog leaves
287
298
  // inputModalities undefined, and deriveComboCatalogModel defaults an undefined member to
@@ -56,6 +56,11 @@ import {
56
56
  ALIBABA_TOKEN_PLAN_MODELS,
57
57
  ALIBABA_TOKEN_PLAN_QWEN_MODELS,
58
58
  ALIBABA_TOKEN_PLAN_INPUT_MODALITIES,
59
+ ALIBABA_TOKEN_PLAN_CONTEXT_WINDOWS,
60
+ ALIBABA_TOKEN_PLAN_MAX_OUTPUT_TOKENS,
61
+ ALIBABA_TOKEN_PLAN_NO_VISION,
62
+ ALIBABA_TOKEN_PLAN_PRESERVE_REASONING,
63
+ QWEN38_FAMILY,
59
64
  ALIBABA_INTL_TOKEN_PLAN_MODELS,
60
65
  ALIBABA_INTL_TOKEN_PLAN_QWEN_MODELS,
61
66
  TENCENT_CODING_PLAN_MODELS,
@@ -427,6 +432,7 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [
427
432
  // model_access_denied, which is why the Chat path cannot simply hang off the new base.
428
433
  responsesPath: "/api/v1/responses",
429
434
  chatCompletionsPath: "/api/coding/paas/v4/chat/completions",
435
+ modelDiscovery: { path: "/api/v1/models", envelopeKey: "models", idField: "slug" },
430
436
  // The address this row occupied before the move. A saved custom provider still pointing
431
437
  // at the Chat endpoint keeps receiving this row's metadata (#1100).
432
438
  destinationAliases: [{ baseUrl: "https://api.z.ai/api/coding/paas/v4", adapter: "openai-chat" }],
@@ -724,22 +730,35 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [
724
730
  liveModels: false,
725
731
  note: "Token Plan Personal Edition · China (Beijing)",
726
732
  modelInputModalities: ALIBABA_TOKEN_PLAN_INPUT_MODALITIES,
727
- modelContextWindows: {
728
- "qwen3.8-max": 983_616, "qwen3.7-max": 1_000_000, "qwen3.7-plus": 1_000_000,
729
- "qwen3.6-flash": 1_000_000, "glm-5.3": 1_000_000, "glm-5.3-flash": 1_000_000, "glm-5.2": 1_000_000,
730
- },
733
+ modelContextWindows: ALIBABA_TOKEN_PLAN_CONTEXT_WINDOWS,
734
+ modelMaxOutputTokens: ALIBABA_TOKEN_PLAN_MAX_OUTPUT_TOKENS,
731
735
  modelReasoningEfforts: {
732
736
  ...Object.fromEntries(ALIBABA_TOKEN_PLAN_QWEN_MODELS.map(id => [id, THINKING_BUDGET_EFFORTS])),
733
- "qwen3.8-max": QWEN38_REASONING_EFFORTS,
734
- "glm-5.3": ZAI_GLM_53_REASONING_EFFORTS,
735
- "glm-5.3-flash": ZAI_GLM_53_REASONING_EFFORTS,
737
+ ...Object.fromEntries(QWEN38_FAMILY.map(id => [id, QWEN38_REASONING_EFFORTS])),
736
738
  "glm-5.2": ZAI_GLM_52_REASONING_EFFORTS,
739
+ "deepseek-v4-pro": deepseekThinkingEffortsFor("deepseek-v4-pro"),
740
+ "deepseek-v4-pro-0813": deepseekThinkingEffortsFor("deepseek-v4-pro-0813"),
741
+ "deepseek-v4-flash-0731": deepseekThinkingEffortsFor("deepseek-v4-flash-0731"),
742
+ "deepseek-v4.1-flash": deepseekThinkingEffortsFor("deepseek-v4.1-flash"),
743
+ },
744
+ modelReasoningEffortMap: {
745
+ "deepseek-v4-pro": deepseekReasoningMapFor("deepseek-v4-pro"),
746
+ "deepseek-v4-pro-0813": deepseekReasoningMapFor("deepseek-v4-pro-0813"),
747
+ "deepseek-v4-flash-0731": deepseekReasoningMapFor("deepseek-v4-flash-0731"),
748
+ "deepseek-v4.1-flash": deepseekReasoningMapFor("deepseek-v4.1-flash"),
737
749
  },
738
- modelDefaultReasoningEfforts: { "qwen3.8-max": "xhigh" },
739
- directReasoningEffortModels: ["qwen3.8-max"],
740
- thinkingBudgetModels: ALIBABA_TOKEN_PLAN_QWEN_MODELS.filter(id => id !== "qwen3.8-max"),
741
- preserveReasoningContentModels: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-flash"],
742
- noVisionModels: ["glm-5.3", "glm-5.2"],
750
+ // Probed 260915 on the plan gateway: json_object returns valid JSON, strict
751
+ // json_schema is rejected 400 ("This response_format type is unavailable now")
752
+ // in both thinking modes, so requests downgrade to json_object rather than
753
+ // sending a schema the gateway refuses.
754
+ noJsonSchemaModels: ["deepseek-v4.1-flash"],
755
+ modelDefaultReasoningEfforts: Object.fromEntries(QWEN38_FAMILY.map(id => [id, "xhigh"])),
756
+ directReasoningEffortModels: QWEN38_FAMILY,
757
+ thinkingBudgetModels: ALIBABA_TOKEN_PLAN_QWEN_MODELS.filter(id => !QWEN38_FAMILY.includes(id)),
758
+ preserveReasoningContentModels: ALIBABA_TOKEN_PLAN_PRESERVE_REASONING,
759
+ noVisionModels: ALIBABA_TOKEN_PLAN_NO_VISION,
760
+ // The gateway accepts prompt_cache_key on every Token Plan chat model (probed 260902).
761
+ promptCacheKey: true,
743
762
  },
744
763
  {
745
764
  id: "alibaba-token-plan-intl",
@@ -756,31 +775,34 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [
756
775
  note: "Token Plan Team Edition · Singapore (ap-southeast-1)",
757
776
  metadataModelIdNormalize: "case-insensitive",
758
777
  modelInputModalities: ALIBABA_INTL_TOKEN_PLAN_INPUT_MODALITIES,
759
- modelContextWindows: {
760
- "qwen3.8-max": 983_616,
761
- "qwen3.7-max": 1_000_000, "qwen3.7-plus": 1_000_000, "qwen3.6-plus": 1_000_000, "qwen3.6-flash": 1_000_000,
762
- "deepseek-v4-flash": 1_000_000, "deepseek-v3.2": 131_072,
763
- "kimi-k2.7-code": 262_144, "kimi-k2.6": 262_144, "kimi-k2.5": 262_144,
764
- "glm-5.3": 1_000_000, "glm-5.3-flash": 1_000_000, "glm-5.2": 1_000_000, "glm-5.1": 1_000_000, "glm-5": 1_000_000,
765
- "MiniMax-M2.5": 204_800,
766
- },
778
+ modelContextWindows: ALIBABA_TOKEN_PLAN_CONTEXT_WINDOWS,
779
+ modelMaxOutputTokens: ALIBABA_TOKEN_PLAN_MAX_OUTPUT_TOKENS,
767
780
  modelReasoningEfforts: {
768
781
  ...Object.fromEntries(ALIBABA_INTL_TOKEN_PLAN_QWEN_MODELS.map(id => [id, THINKING_BUDGET_EFFORTS])),
769
- "qwen3.8-max": QWEN38_REASONING_EFFORTS,
770
- "glm-5.3": ZAI_GLM_53_REASONING_EFFORTS,
771
- "glm-5.3-flash": ZAI_GLM_53_REASONING_EFFORTS,
782
+ ...Object.fromEntries(QWEN38_FAMILY.map(id => [id, QWEN38_REASONING_EFFORTS])),
772
783
  "glm-5.2": ZAI_GLM_52_REASONING_EFFORTS,
784
+ "deepseek-v4-pro": deepseekThinkingEffortsFor("deepseek-v4-pro"),
785
+ "deepseek-v4-pro-0813": deepseekThinkingEffortsFor("deepseek-v4-pro-0813"),
773
786
  "deepseek-v4-flash": deepseekThinkingEffortsFor("deepseek-v4-flash"),
787
+ "deepseek-v4-flash-0731": deepseekThinkingEffortsFor("deepseek-v4-flash-0731"),
788
+ "deepseek-v4.1-flash": deepseekThinkingEffortsFor("deepseek-v4.1-flash"),
774
789
  },
775
790
  modelReasoningEffortMap: {
791
+ "deepseek-v4-pro": deepseekReasoningMapFor("deepseek-v4-pro"),
792
+ "deepseek-v4-pro-0813": deepseekReasoningMapFor("deepseek-v4-pro-0813"),
776
793
  "deepseek-v4-flash": deepseekReasoningMapFor("deepseek-v4-flash"),
794
+ "deepseek-v4-flash-0731": deepseekReasoningMapFor("deepseek-v4-flash-0731"),
795
+ "deepseek-v4.1-flash": deepseekReasoningMapFor("deepseek-v4.1-flash"),
777
796
  },
778
- directReasoningEffortModels: ["qwen3.8-max"],
779
- thinkingBudgetModels: ALIBABA_INTL_TOKEN_PLAN_QWEN_MODELS.filter(id => id !== "qwen3.8-max"),
780
- preserveReasoningContentModels: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "deepseek-v4-flash", "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash"],
781
- noVisionModels: ["deepseek-v4-flash", "deepseek-v3.2", "glm-5.3", "glm-5.2", "glm-5.1", "glm-5", "MiniMax-M2.5"],
797
+ // Same 260915 json_schema rejection probe as the Beijing entry.
798
+ noJsonSchemaModels: ["deepseek-v4.1-flash"],
799
+ directReasoningEffortModels: QWEN38_FAMILY,
800
+ thinkingBudgetModels: ALIBABA_INTL_TOKEN_PLAN_QWEN_MODELS.filter(id => !QWEN38_FAMILY.includes(id)),
801
+ preserveReasoningContentModels: ALIBABA_TOKEN_PLAN_PRESERVE_REASONING,
802
+ noVisionModels: ALIBABA_TOKEN_PLAN_NO_VISION,
782
803
  noReasoningModels: ["kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", "deepseek-v3.2", "glm-5.1", "glm-5", "MiniMax-M2.5"],
783
- modelDefaultReasoningEfforts: { "qwen3.8-max": "xhigh" },
804
+ modelDefaultReasoningEfforts: Object.fromEntries(QWEN38_FAMILY.map(id => [id, "xhigh"])),
805
+ promptCacheKey: true,
784
806
  },
785
807
  // NEEDS_HUMAN 2026-07-10: kept for config compatibility, but this is a dashboard URL,
786
808
  // no /models endpoint is documented, and tools are silently ignored upstream per docs.parallel.ai.