@bitkyc08/opencodex 2.26.0 → 2.28.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 (83) hide show
  1. package/gui/dist/assets/{index-RL6b1bTV.js → index-D2sP-biU.js} +14 -14
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/anthropic.ts +60 -1
  5. package/src/adapters/base.ts +16 -2
  6. package/src/adapters/command-code.ts +4 -3
  7. package/src/adapters/cursor/cursor-errors.ts +15 -0
  8. package/src/adapters/cursor/live-transport.ts +14 -1
  9. package/src/adapters/google-antigravity-replay.ts +16 -8
  10. package/src/adapters/google.ts +22 -5
  11. package/src/adapters/openai-chat.ts +189 -60
  12. package/src/adapters/openai-responses.ts +37 -0
  13. package/src/adapters/tool-catalog-nudge.ts +1 -1
  14. package/src/bridge.ts +11 -5
  15. package/src/cli/doctor.ts +76 -0
  16. package/src/cli/help.ts +2 -0
  17. package/src/cli/index.ts +19 -6
  18. package/src/cli/models.ts +13 -6
  19. package/src/codex/account-usability.ts +3 -0
  20. package/src/codex/app-server-processes.ts +269 -37
  21. package/src/codex/auth-api.ts +22 -5
  22. package/src/codex/auth-context.ts +108 -3
  23. package/src/codex/catalog/aggregation.ts +3 -0
  24. package/src/codex/catalog/metadata.ts +17 -3
  25. package/src/codex/catalog/native-models.ts +22 -14
  26. package/src/codex/catalog/parsing.ts +20 -3
  27. package/src/codex/catalog/provider-fetch.ts +8 -0
  28. package/src/codex/catalog/sync.ts +63 -15
  29. package/src/codex/convergence.ts +61 -13
  30. package/src/codex/log-guard/path-safety.ts +52 -3
  31. package/src/codex/model-entitlements.ts +353 -0
  32. package/src/codex/native-profile-startup.ts +100 -2
  33. package/src/codex/quota.ts +28 -3
  34. package/src/codex/routing.ts +14 -8
  35. package/src/codex/user-identity.ts +21 -1
  36. package/src/config/provider-name.ts +24 -0
  37. package/src/config.ts +11 -24
  38. package/src/generated/compatibility-version.json +110 -70
  39. package/src/images/loop.ts +11 -4
  40. package/src/lib/destination-policy.ts +47 -0
  41. package/src/lib/shadow-call.ts +15 -0
  42. package/src/lib/state-store-registrations.ts +8 -2
  43. package/src/oauth/index.ts +33 -5
  44. package/src/oauth/store.ts +11 -5
  45. package/src/providers/antigravity-models.ts +70 -5
  46. package/src/providers/derive.ts +12 -2
  47. package/src/providers/fastwire.ts +39 -8
  48. package/src/providers/quota.ts +9 -2
  49. package/src/providers/registry.ts +120 -6
  50. package/src/providers/service-tier.ts +50 -15
  51. package/src/responses/parser.ts +59 -11
  52. package/src/responses/state.ts +162 -5
  53. package/src/responses/tool-search-compat.ts +301 -0
  54. package/src/router.ts +17 -3
  55. package/src/routing/capability.ts +26 -9
  56. package/src/routing/compatibility/behavior.ts +44 -6
  57. package/src/routing/profile.ts +1 -1
  58. package/src/server/chat-native.ts +11 -2
  59. package/src/server/index.ts +59 -8
  60. package/src/server/management/agent-settings-routes.ts +16 -2
  61. package/src/server/management/shared.ts +3 -1
  62. package/src/server/request-log.ts +31 -0
  63. package/src/server/responses/collaboration.ts +34 -9
  64. package/src/server/responses/compact.ts +54 -7
  65. package/src/server/responses/core.ts +259 -43
  66. package/src/server/responses/input-admission.ts +7 -2
  67. package/src/server/responses/responses-field-backfill.ts +88 -6
  68. package/src/server/responses/terminal-guard.ts +10 -0
  69. package/src/server/responses-tool-search-repair.ts +217 -0
  70. package/src/server/system-env.ts +74 -5
  71. package/src/service-manager-probe.ts +99 -0
  72. package/src/service.ts +86 -6
  73. package/src/tray/windows.ts +25 -5
  74. package/src/types/accounts.ts +37 -0
  75. package/src/types/config.ts +818 -0
  76. package/src/types/provider.ts +521 -0
  77. package/src/types/request.ts +358 -0
  78. package/src/types/tools.ts +131 -0
  79. package/src/types/wire.ts +80 -0
  80. package/src/types.ts +103 -1883
  81. package/src/usage/cost.ts +37 -1
  82. package/src/usage/log.ts +4 -0
  83. package/src/web-search/loop.ts +11 -4
@@ -1,4 +1,5 @@
1
1
  import { chmodSync, existsSync, lstatSync, mkdirSync, opendirSync, readFileSync, rmSync, statSync, unlinkSync } from "node:fs";
2
+ import { uptime } from "node:os";
2
3
  import { dirname, join } from "node:path";
3
4
  import { atomicWriteFileAsync, getConfigDir, resolveWriteTarget } from "../config";
4
5
  import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/app-owned-memory";
@@ -31,6 +32,17 @@ const SNAPSHOT_FILE_MAX_BYTES = 32 * 1024 * 1024;
31
32
  const STALE_TEMP_GRACE_MS = 15 * 60 * 1_000;
32
33
  const STALE_TEMP_MAX_ENTRIES = 4_096;
33
34
  const STALE_TEMP_MAX_CLEANUPS = 512;
35
+ /** Absorbs `os.uptime()` granularity only. It is deliberately NOT the safety margin:
36
+ * the unconditional 15-minute grace above is (see the boot floor in the scan loop). */
37
+ const BOOT_FLOOR_SKEW_MS = 60 * 1_000;
38
+ /** Per-tick budget for the periodic reclaim. Smaller than the startup budget because the
39
+ * periodic pass runs synchronously on the serving process's event loop every 60 s. */
40
+ const PERIODIC_TEMP_MAX_ENTRIES = 512;
41
+ const PERIODIC_TEMP_MAX_CLEANUPS = 64;
42
+ /** Wall-clock ceiling for one periodic scan. An entry cap bounds syscalls, not time: on a
43
+ * network-mounted config dir each `lstat` can cost 10-20 ms, which would stall in-flight
44
+ * streams. Reclaim is idempotent, so a truncated tick simply resumes on the next one. */
45
+ const PERIODIC_TEMP_SCAN_DEADLINE_MS = 25;
34
46
  const RESPONSE_STATE_TEMP_NAME = /^responses-state\.json\.ocx\.(\d+)\.(\d+)\.tmp$/;
35
47
  const MAX_SNAPSHOT_REWRITE_ATTEMPTS = 4;
36
48
 
@@ -494,19 +506,37 @@ export interface ResponseStateTempRecoveryResult {
494
506
  removed: number;
495
507
  failed: number;
496
508
  bytesRemoved: number;
509
+ /** Entries that passed EVERY gate and would be reclaimed. In a dry run nothing is
510
+ * unlinked, so this is the only honest count to show an operator: `matched` is
511
+ * incremented before the file-type, age, boot-floor, and liveness gates. */
512
+ eligible: number;
513
+ /** Total size of the `eligible` entries. */
514
+ eligibleBytes: number;
515
+ /** The scan stopped on a budget (entry cap, cleanup cap, or deadline) rather than reaching
516
+ * the end of the directory, so the counts below describe a prefix of the backlog and not
517
+ * the backlog. `eligible > removed + failed` cannot express this: outside a dry run every
518
+ * eligible entry is unlinked or failed on the same iteration, so the two are always equal
519
+ * and a comparison between them is dead code. */
520
+ truncated: boolean;
497
521
  }
498
522
 
499
523
  interface ResponseStateTempRecoveryIO {
500
524
  now: () => number;
525
+ /** Approximate epoch ms of the current boot; see the boot floor in the scan loop. */
526
+ bootTime: () => number;
501
527
  list: (dir: string) => Iterable<string>;
502
528
  inspect: (path: string) => { isFile: boolean; mtimeMs: number; size: number };
503
529
  isProcessAlive: (pid: number) => boolean;
504
530
  unlink: (path: string) => void;
505
531
  }
506
532
 
507
- type ResponseStateTempRecoveryOptions = Partial<ResponseStateTempRecoveryIO> & {
533
+ export type ResponseStateTempRecoveryOptions = Partial<ResponseStateTempRecoveryIO> & {
508
534
  maxEntries?: number;
509
535
  maxCleanups?: number;
536
+ /** Wall-clock ceiling for the scan, or null/undefined for no deadline (startup path). */
537
+ deadlineMs?: number | null;
538
+ /** Report only: apply every gate, count what would be reclaimed, unlink nothing. */
539
+ dryRun?: boolean;
510
540
  };
511
541
 
512
542
  function processIsAlive(pid: number): boolean {
@@ -523,6 +553,7 @@ function processIsAlive(pid: number): boolean {
523
553
 
524
554
  const responseStateTempRecoveryIO: ResponseStateTempRecoveryIO = {
525
555
  now: Date.now,
556
+ bootTime: () => Date.now() - uptime() * 1_000,
526
557
  list: function* list(dir) {
527
558
  const handle = opendirSync(dir);
528
559
  try {
@@ -549,26 +580,56 @@ export function recoverStaleResponseStateTemps(
549
580
  dir = getConfigDir(),
550
581
  options: ResponseStateTempRecoveryOptions = {},
551
582
  ): ResponseStateTempRecoveryResult {
552
- const { maxEntries = STALE_TEMP_MAX_ENTRIES, maxCleanups = STALE_TEMP_MAX_CLEANUPS, ...overrides } = options;
583
+ const {
584
+ maxEntries = STALE_TEMP_MAX_ENTRIES,
585
+ maxCleanups = STALE_TEMP_MAX_CLEANUPS,
586
+ deadlineMs = null,
587
+ dryRun = false,
588
+ ...overrides
589
+ } = options;
553
590
  const io = { ...responseStateTempRecoveryIO, ...overrides };
554
591
  const result: ResponseStateTempRecoveryResult = {
555
592
  matched: 0,
556
593
  removed: 0,
557
594
  failed: 0,
558
595
  bytesRemoved: 0,
596
+ eligible: 0,
597
+ eligibleBytes: 0,
598
+ truncated: false,
559
599
  };
600
+ const startedAt = io.now();
601
+ // One probe per scan, not one per entry. A non-finite or future-dated boot is anomalous, and
602
+ // clamping it to "now" would be the WORST response: the floor would then retire the liveness
603
+ // probe for every file older than the skew, which is every file past the grace. Disable it
604
+ // instead -- an absent floor only costs a missed reclaim, never a wrong one.
605
+ const rawBoot = io.bootTime();
606
+ const bootMs = Number.isFinite(rawBoot) && rawBoot <= startedAt ? rawBoot : Number.NEGATIVE_INFINITY;
560
607
  let names: Iterable<string>;
561
608
  try { names = io.list(dir); } catch { return result; }
562
609
  let iterator: Iterator<string>;
563
610
  try { iterator = names[Symbol.iterator](); } catch { return result; }
564
611
  let scanned = 0;
612
+ // Every early exit runs through this. The production `list` is a generator that closes its
613
+ // directory handle in a `finally`, and a `finally` does NOT run when the consumer simply
614
+ // stops calling `next()` -- only `return()` resumes the generator to completion. Breaking
615
+ // out of the loop directly therefore leaked one directory handle per truncated scan, and the
616
+ // periodic reclaim truncates on purpose (entry cap, cleanup cap, deadline), so on a slow
617
+ // filesystem that is a leak per tick, forever.
618
+ const stopScan = (): ResponseStateTempRecoveryResult => {
619
+ try { iterator.return?.(); } catch { /* closing is best-effort; never fail a reclaim on it */ }
620
+ return result;
621
+ };
565
622
  for (;;) {
566
623
  let next: IteratorResult<string>;
567
624
  try { next = iterator.next(); } catch { return result; }
568
625
  if (next.done) break;
569
626
  const name = next.value;
570
627
  scanned += 1;
571
- if (scanned > maxEntries || result.removed + result.failed >= maxCleanups) break;
628
+ // A dry run performs no cleanups, so bounding it by the cleanup budget would truncate
629
+ // the very report an operator uses to size the problem.
630
+ if (scanned > maxEntries) { result.truncated = true; return stopScan(); }
631
+ if (!dryRun && result.removed + result.failed >= maxCleanups) { result.truncated = true; return stopScan(); }
632
+ if (deadlineMs !== null && io.now() - startedAt > deadlineMs) { result.truncated = true; return stopScan(); }
572
633
  const match = RESPONSE_STATE_TEMP_NAME.exec(name);
573
634
  if (!match) continue;
574
635
  result.matched += 1;
@@ -579,13 +640,34 @@ export function recoverStaleResponseStateTemps(
579
640
  let file: ReturnType<ResponseStateTempRecoveryIO["inspect"]>;
580
641
  try { file = io.inspect(path); } catch { continue; }
581
642
  if (!file.isFile || io.now() - file.mtimeMs < STALE_TEMP_GRACE_MS) continue;
582
- if (pid === process.pid || io.isProcessAlive(pid)) continue;
643
+ // Boot floor. After a reboot the original writer's pid is routinely reused, which makes
644
+ // the liveness skip PERMANENT: the 15-minute grace above is a lower bound and never
645
+ // expires it, so the file is skipped on every future pass forever. A temp older than
646
+ // this boot cannot be owned by the pid we would probe, so the probe is vacuous and we
647
+ // retire it. This does NOT claim the file is provably dead: under a shared-volume
648
+ // container, suspend-excluding uptime, or a network config dir the computed boot can
649
+ // land after the real one. The unconditional 15-minute grace above remains the safety
650
+ // floor, and this process's own temps are never touched.
651
+ const predatesBoot = file.mtimeMs < bootMs - BOOT_FLOOR_SKEW_MS;
652
+ if (pid === process.pid) continue;
653
+ if (!predatesBoot && io.isProcessAlive(pid)) continue;
654
+
655
+ result.eligible += 1;
656
+ result.eligibleBytes += file.size;
657
+ if (dryRun) continue;
583
658
 
584
659
  try {
585
660
  io.unlink(path);
586
661
  result.removed += 1;
587
662
  result.bytesRemoved += file.size;
588
- } catch {
663
+ } catch (error) {
664
+ // Another proxy sharing this config dir may have won the race. A file that is already
665
+ // gone is reclaimed, not a failure -- reporting it as one would surface "in use or
666
+ // locked" to an operator for a file nobody holds.
667
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
668
+ result.removed += 1;
669
+ continue;
670
+ }
589
671
  // Locked files remain for a later startup. Do not truncate by path: a same-user
590
672
  // replacement could turn that fallback into an arbitrary symlink-target write.
591
673
  result.failed += 1;
@@ -594,6 +676,23 @@ export function recoverStaleResponseStateTemps(
594
676
  return result;
595
677
  }
596
678
 
679
+ /**
680
+ * Literal config dir plus the snapshot's resolved dir. Atomic writes place their temp beside
681
+ * the RESOLVED target, so a symlinked snapshot (dotfiles-managed config dir) strands temps in
682
+ * the link's real directory where a scan of the literal dir would never see them. The two
683
+ * collapse to one when nothing is symlinked.
684
+ */
685
+ function responseStateSweepDirectories(): Set<string> {
686
+ const path = snapshotPath();
687
+ let resolvedDir = dirname(path);
688
+ try {
689
+ resolvedDir = dirname(resolveWriteTarget(path));
690
+ } catch {
691
+ /* unresolvable link: sweep the literal dir only */
692
+ }
693
+ return new Set([dirname(path), resolvedDir]);
694
+ }
695
+
597
696
  /**
598
697
  * Best-effort disk snapshot so previous_response_id chains survive a proxy restart (the
599
698
  * dominant expansion-miss cause: an in-memory-only store dies with the process, and the next
@@ -898,6 +997,64 @@ export function sweepExpiredResponseStates(at = now()): number {
898
997
  return removed;
899
998
  }
900
999
 
1000
+ /**
1001
+ * Periodic disk reclaim for abandoned atomic-write temps.
1002
+ *
1003
+ * `ensureLoaded` sweeps once per process, at load, BEFORE that process writes anything:
1004
+ * every `schedulePersist` site is downstream of it. So a process that abandons a temp has
1005
+ * already had its only look, the 15-minute grace hides the temp its predecessor's crash
1006
+ * just produced, and `maxCleanups` caps a single pass below a large backlog. A restart
1007
+ * loop therefore accumulates monotonically. Repeating the reclaim on a timer fixes all
1008
+ * three: the grace expires into a later tick and the per-pass cap becomes a per-tick rate.
1009
+ *
1010
+ * Registered on the sweeper's LIVENESS tick, not the TTL tick: `sweepExpiredOnWrite` puts
1011
+ * `sweepExpired` on hot write paths, and a directory scan does not belong there.
1012
+ */
1013
+ export function reclaimAbandonedResponseStateTemps(
1014
+ options: ResponseStateTempRecoveryOptions = {},
1015
+ ): ResponseStateTempRecoveryResult {
1016
+ const total: ResponseStateTempRecoveryResult = {
1017
+ matched: 0, removed: 0, failed: 0, bytesRemoved: 0, eligible: 0, eligibleBytes: 0, truncated: false,
1018
+ };
1019
+ // The try encloses responseStateSweepDirectories() deliberately: recoverStaleResponseStateTemps
1020
+ // already swallows its own enumeration failures, so a catch around only that call would be
1021
+ // unreachable. snapshotPath()/getConfigDir() are the paths that can genuinely throw.
1022
+ try {
1023
+ for (const dir of responseStateSweepDirectories()) {
1024
+ const result = recoverStaleResponseStateTemps(dir, options);
1025
+ total.matched += result.matched;
1026
+ total.removed += result.removed;
1027
+ total.failed += result.failed;
1028
+ total.bytesRemoved += result.bytesRemoved;
1029
+ total.eligible += result.eligible;
1030
+ total.eligibleBytes += result.eligibleBytes;
1031
+ // Truncation anywhere makes the whole total a prefix.
1032
+ total.truncated ||= result.truncated;
1033
+ }
1034
+ } catch {
1035
+ /* best-effort: disk reclaim must never destabilize the caller */
1036
+ }
1037
+ return total;
1038
+ }
1039
+
1040
+ /**
1041
+ * Report-only counterpart for `ocx doctor`: applies every selection gate and unlinks
1042
+ * nothing. It runs the SAME predicate as the reclaim, so the report and the subsequent
1043
+ * removal cannot disagree about which files are reclaimable.
1044
+ */
1045
+ export function inspectAbandonedResponseStateTemps(): ResponseStateTempRecoveryResult {
1046
+ return reclaimAbandonedResponseStateTemps({ dryRun: true });
1047
+ }
1048
+
1049
+ /** Sweeper adapter: narrows the reclaim to the `() => number` the liveness tick expects. */
1050
+ export function sweepAbandonedResponseStateTemps(): number {
1051
+ return reclaimAbandonedResponseStateTemps({
1052
+ maxEntries: PERIODIC_TEMP_MAX_ENTRIES,
1053
+ maxCleanups: PERIODIC_TEMP_MAX_CLEANUPS,
1054
+ deadlineMs: PERIODIC_TEMP_SCAN_DEADLINE_MS,
1055
+ }).removed;
1056
+ }
1057
+
901
1058
  export function responseContinuationRetainedStoreSnapshot(): RetainedStoreSnapshot {
902
1059
  return {
903
1060
  count: states.size,
@@ -0,0 +1,301 @@
1
+ export const TOOL_SEARCH_FUNCTION_NAME = "tool_search";
2
+ export const TOOL_SEARCH_DEFAULT_DESCRIPTION = "Search for additional tools to load for the next turn.";
3
+ const TOOL_SEARCH_WIRE_ALIAS_PREFIX = "opencodex_tool_search";
4
+
5
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
6
+ return !!value && typeof value === "object" && !Array.isArray(value);
7
+ }
8
+
9
+ export function toolSearchDescription(tool: unknown): string {
10
+ return isPlainObject(tool) && typeof tool.description === "string"
11
+ ? tool.description
12
+ : TOOL_SEARCH_DEFAULT_DESCRIPTION;
13
+ }
14
+
15
+ export function toolSearchParameters(tool: unknown): Record<string, unknown> {
16
+ if (isPlainObject(tool) && isPlainObject(tool.parameters)) return tool.parameters;
17
+ // Fresh per parse/build: downstream normalizers are allowed to clone or extend schemas, and a
18
+ // shared mutable default would couple otherwise unrelated requests.
19
+ return {
20
+ type: "object",
21
+ properties: {
22
+ query: { type: "string", description: "Search query for tools to load." },
23
+ limit: { type: "number", description: "Maximum number of tools to return." },
24
+ },
25
+ required: ["query"],
26
+ };
27
+ }
28
+
29
+ function collectToolDeclarationNames(tools: unknown[], names: Set<string>, namespace?: string): void {
30
+ for (const tool of tools) {
31
+ if (!isPlainObject(tool) || tool.type === "tool_search") continue;
32
+ if (tool.type === "function" && isPlainObject(tool.function) && typeof tool.function.name === "string") {
33
+ names.add(tool.function.name);
34
+ }
35
+ if (typeof tool.name === "string") {
36
+ names.add(tool.name);
37
+ if (namespace) names.add(`${namespace}__${tool.name}`);
38
+ }
39
+ if (tool.type === "namespace" && Array.isArray(tool.tools)) {
40
+ collectToolDeclarationNames(tool.tools, names, typeof tool.name === "string" ? tool.name : undefined);
41
+ }
42
+ }
43
+ }
44
+
45
+ function declaredToolNames(body: Record<string, unknown>): Set<string> {
46
+ const names = new Set<string>();
47
+ if (Array.isArray(body.tools)) collectToolDeclarationNames(body.tools, names);
48
+ if (Array.isArray(body.input)) {
49
+ for (const item of body.input) {
50
+ if (isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools)) {
51
+ collectToolDeclarationNames(item.tools, names);
52
+ }
53
+ }
54
+ }
55
+ return names;
56
+ }
57
+
58
+ function hasToolSearchDeclaration(tools: unknown[]): boolean {
59
+ return tools.some(tool => isPlainObject(tool) && tool.type === "tool_search");
60
+ }
61
+
62
+ function chooseToolSearchWireName(usedNames: ReadonlySet<string>): string {
63
+ if (!usedNames.has(TOOL_SEARCH_FUNCTION_NAME)) return TOOL_SEARCH_FUNCTION_NAME;
64
+ if (!usedNames.has(TOOL_SEARCH_WIRE_ALIAS_PREFIX)) return TOOL_SEARCH_WIRE_ALIAS_PREFIX;
65
+ for (let suffix = 2; ; suffix++) {
66
+ const candidate = `${TOOL_SEARCH_WIRE_ALIAS_PREFIX}_${suffix}`;
67
+ if (!usedNames.has(candidate)) return candidate;
68
+ }
69
+ }
70
+
71
+ function rewriteToolList(tools: unknown[], wireName: string): { tools: unknown[]; changed: boolean } {
72
+ let changed = false;
73
+ const rewritten = tools.map(tool => {
74
+ if (!isPlainObject(tool) || tool.type !== "tool_search") return tool;
75
+ const {
76
+ execution: _execution,
77
+ defer_loading: _deferLoading,
78
+ ...rest
79
+ } = tool;
80
+ changed = true;
81
+ return {
82
+ ...rest,
83
+ type: "function",
84
+ name: wireName,
85
+ description: toolSearchDescription(tool),
86
+ parameters: toolSearchParameters(tool),
87
+ };
88
+ });
89
+ return changed ? { tools: rewritten, changed: true } : { tools, changed: false };
90
+ }
91
+
92
+ function rewriteToolChoice(choice: unknown, wireName: string): unknown {
93
+ if (!isPlainObject(choice)) return choice;
94
+ if (choice.type === "tool_search") {
95
+ return { type: "function", name: wireName };
96
+ }
97
+ if (choice.type !== "allowed_tools" || !Array.isArray(choice.tools)) return choice;
98
+ let changed = false;
99
+ const tools = choice.tools.map(tool => {
100
+ if (!isPlainObject(tool) || tool.type !== "tool_search") return tool;
101
+ changed = true;
102
+ return { type: "function", name: wireName };
103
+ });
104
+ return changed ? { ...choice, tools } : choice;
105
+ }
106
+
107
+ function toolChoiceAllowsPrivateSearch(choice: unknown): boolean {
108
+ if (choice === undefined || choice === null || choice === "auto" || choice === "required") return true;
109
+ if (choice === "none") return false;
110
+ if (!isPlainObject(choice)) return true;
111
+ if (choice.type === "tool_search") return true;
112
+ if (choice.type === "allowed_tools" && Array.isArray(choice.tools)) {
113
+ return choice.tools.some(tool => isPlainObject(tool) && tool.type === "tool_search");
114
+ }
115
+ // A forced ordinary function named `tool_search` is distinct from the private tool kind.
116
+ return false;
117
+ }
118
+
119
+ function upstreamToolSearchItemId(id: unknown): unknown {
120
+ if (typeof id !== "string") return id;
121
+ return id.startsWith("tsc_") ? `fc_${id.slice(4)}` : id;
122
+ }
123
+
124
+ function toolSearchArgumentsText(value: unknown): string {
125
+ if (typeof value === "string") return value;
126
+ return JSON.stringify(isPlainObject(value) ? value : {});
127
+ }
128
+
129
+ function toolSearchOutputText(value: Record<string, unknown>): string {
130
+ const payload: Record<string, unknown> = {
131
+ tools: Array.isArray(value.tools) ? value.tools : [],
132
+ };
133
+ if (typeof value.status === "string") payload.status = value.status;
134
+ return JSON.stringify(payload);
135
+ }
136
+
137
+ function rewriteHistoryItem(item: Record<string, unknown>, wireName: string): Record<string, unknown> {
138
+ if (item.type === "tool_search_call") {
139
+ const {
140
+ execution: _execution,
141
+ arguments: argumentsValue,
142
+ id,
143
+ ...rest
144
+ } = item;
145
+ return {
146
+ ...rest,
147
+ type: "function_call",
148
+ ...(id === undefined ? {} : { id: upstreamToolSearchItemId(id) }),
149
+ name: wireName,
150
+ arguments: toolSearchArgumentsText(argumentsValue),
151
+ };
152
+ }
153
+ if (item.type === "tool_search_output") {
154
+ const {
155
+ execution: _execution,
156
+ id: _id,
157
+ tools: _tools,
158
+ status: _status,
159
+ ...rest
160
+ } = item;
161
+ return {
162
+ ...rest,
163
+ type: "function_call_output",
164
+ output: toolSearchOutputText(item),
165
+ };
166
+ }
167
+ return item;
168
+ }
169
+
170
+ /**
171
+ * Third-party Responses gateways generally implement the public function-tool schema, not Codex's
172
+ * private client-executed `tool_search` declaration. Lower only request catalogs sent to a
173
+ * noncanonical upstream; the caller-facing response is restored separately.
174
+ */
175
+ export function rewriteRoutedToolSearchForUpstream(body: unknown): {
176
+ body: unknown;
177
+ names: Set<string>;
178
+ } {
179
+ const names = new Set<string>();
180
+ if (!isPlainObject(body)) return { body, names };
181
+
182
+ const topLevelSearch = Array.isArray(body.tools) && hasToolSearchDeclaration(body.tools);
183
+ const inputItems = Array.isArray(body.input) ? body.input : [];
184
+ const additionalSearch = inputItems.some(item =>
185
+ isPlainObject(item)
186
+ && item.type === "additional_tools"
187
+ && Array.isArray(item.tools)
188
+ && hasToolSearchDeclaration(item.tools));
189
+ const historySearch = inputItems.some(item =>
190
+ isPlainObject(item)
191
+ && (item.type === "tool_search_call" || item.type === "tool_search_output"));
192
+ if (!topLevelSearch && !additionalSearch && !historySearch) return { body, names };
193
+
194
+ const wireName = chooseToolSearchWireName(declaredToolNames(body));
195
+ const declarationChanged = topLevelSearch || additionalSearch;
196
+
197
+ let tools = body.tools;
198
+ if (Array.isArray(tools)) {
199
+ const result = rewriteToolList(tools, wireName);
200
+ tools = result.tools;
201
+ }
202
+
203
+ let input = body.input;
204
+ let historyLowered = false;
205
+ if (Array.isArray(input)) {
206
+ input = input.map(item => {
207
+ if (!isPlainObject(item)) return item;
208
+ if (item.type === "additional_tools" && Array.isArray(item.tools)) {
209
+ const result = rewriteToolList(item.tools, wireName);
210
+ return result.changed ? { ...item, tools: result.tools } : item;
211
+ }
212
+ const rewritten = rewriteHistoryItem(item, wireName);
213
+ if (rewritten !== item) historyLowered = true;
214
+ return rewritten;
215
+ });
216
+ }
217
+
218
+ // A turn can replay `tool_search_call` history WITHOUT re-declaring the tool — Codex normally
219
+ // sends the declaration, but a history-only body is legal. The history is lowered to
220
+ // `function_call` either way, so restoration has to be armed on that too: leaving `names`
221
+ // empty there would hand the client a public `function_call` for what it issued as a private
222
+ // search call, and the round trip would silently stop matching.
223
+ if ((declarationChanged || historyLowered) && toolChoiceAllowsPrivateSearch(body.tool_choice)) {
224
+ names.add(wireName);
225
+ }
226
+ const toolChoice = declarationChanged ? rewriteToolChoice(body.tool_choice, wireName) : body.tool_choice;
227
+ return {
228
+ body: {
229
+ ...body,
230
+ ...(tools !== body.tools ? { tools } : {}),
231
+ ...(input !== body.input ? { input } : {}),
232
+ ...(toolChoice !== body.tool_choice ? { tool_choice: toolChoice } : {}),
233
+ },
234
+ names,
235
+ };
236
+ }
237
+
238
+ export function toolSearchItemId(id: unknown): unknown {
239
+ if (typeof id !== "string") return id;
240
+ return id.startsWith("fc_") ? `tsc_${id.slice(3)}` : id;
241
+ }
242
+
243
+ function toolSearchArguments(value: unknown): Record<string, unknown> {
244
+ if (isPlainObject(value)) return value;
245
+ if (typeof value !== "string" || value.length === 0) return {};
246
+ try {
247
+ const parsed: unknown = JSON.parse(value);
248
+ return isPlainObject(parsed) ? parsed : {};
249
+ } catch {
250
+ return {};
251
+ }
252
+ }
253
+
254
+ export function restoreRoutedToolSearchCalls(
255
+ value: unknown,
256
+ names: ReadonlySet<string>,
257
+ ): { value: unknown; changed: boolean } {
258
+ if (Array.isArray(value)) {
259
+ let changed = false;
260
+ const restored = value.map(entry => {
261
+ const result = restoreRoutedToolSearchCalls(entry, names);
262
+ changed ||= result.changed;
263
+ return result.value;
264
+ });
265
+ return changed ? { value: restored, changed: true } : { value, changed: false };
266
+ }
267
+ if (!isPlainObject(value)) return { value, changed: false };
268
+
269
+ let changed = false;
270
+ const restored: Record<string, unknown> = {};
271
+ for (const [key, entry] of Object.entries(value)) {
272
+ const result = restoreRoutedToolSearchCalls(entry, names);
273
+ restored[key] = result.value;
274
+ changed ||= result.changed;
275
+ }
276
+
277
+ if (value.type === "function_call" && typeof value.name === "string" && names.has(value.name)) {
278
+ restored.type = "tool_search_call";
279
+ restored.id = toolSearchItemId(value.id);
280
+ restored.execution = "client";
281
+ restored.arguments = toolSearchArguments(value.arguments);
282
+ delete restored.name;
283
+ changed = true;
284
+ }
285
+ return changed ? { value: restored, changed: true } : { value, changed: false };
286
+ }
287
+
288
+ export function restoreRoutedToolSearchCallsInJson(
289
+ text: string,
290
+ names: ReadonlySet<string>,
291
+ ): string {
292
+ if (names.size === 0) return text;
293
+ let payload: unknown;
294
+ try {
295
+ payload = JSON.parse(text);
296
+ } catch {
297
+ return text;
298
+ }
299
+ const restored = restoreRoutedToolSearchCalls(payload, names);
300
+ return restored.changed ? JSON.stringify(restored.value) : text;
301
+ }
package/src/router.ts CHANGED
@@ -8,10 +8,16 @@ import {
8
8
  type ComboPick,
9
9
  } from "./combos";
10
10
  import type { NormalizedComboConfig } from "./combos/types";
11
- import { hasOwnProvider, resolveEnvValue } from "./config";
11
+ import { hasOwnProvider } from "./config/provider-name";
12
+ import { resolveEnvValue } from "./config";
12
13
  import { assertProviderDestinationAllowed } from "./lib/destination-policy";
13
14
  import { redactSecretString, redactUrlForLog } from "./lib/redact";
14
- import { PROVIDER_REGISTRY, providerCodexAccountMode } from "./providers/registry";
15
+ import {
16
+ PROVIDER_REGISTRY,
17
+ mergeRegistryStaticHeaders,
18
+ providerCodexAccountMode,
19
+ registryModelServiceTierCapabilityApplies,
20
+ } from "./providers/registry";
15
21
  import { applyDirectReasoningEffortContracts, hasLegacyClinePassReasoningEfforts } from "./providers/derive";
16
22
  import { cloneFastWire } from "./providers/fastwire";
17
23
  import {
@@ -291,12 +297,19 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
291
297
  ? mergePositiveNumberCaps(registryEntry.modelContextWindows, provider.modelContextWindows)
292
298
  : mergeRecordFill(registryEntry.modelContextWindows, provider.modelContextWindows);
293
299
  const modelInputModalities = mergeRecordFill(registryEntry.modelInputModalities, provider.modelInputModalities);
300
+ // Registry static headers are documented as applying to every upstream request, so they are
301
+ // filled at resolve time rather than only at seed time: a config written before a header
302
+ // existed, or one carrying any header of its own, would otherwise never receive it. User
303
+ // headers win, matched case-insensitively so an override replaces rather than duplicates.
304
+ const headers = mergeRegistryStaticHeaders(registryEntry.staticHeaders, provider.headers);
294
305
  const modelMaxInputTokens = providerName === OPENAI_API_PROVIDER_ID
295
306
  ? mergePositiveNumberCaps(registryEntry.modelMaxInputTokens, provider.modelMaxInputTokens)
296
307
  : mergeRecordFill(registryEntry.modelMaxInputTokens, provider.modelMaxInputTokens);
297
308
  const modelMaxOutputTokens = mergeRecordFill(registryEntry.modelMaxOutputTokens, provider.modelMaxOutputTokens);
298
309
  const modelSupportsServiceTier = mergeRecordFill(
299
- registryEntry.modelSupportsServiceTier,
310
+ registryModelServiceTierCapabilityApplies(registryEntry, provider)
311
+ ? registryEntry.modelSupportsServiceTier
312
+ : undefined,
300
313
  provider.modelSupportsServiceTier,
301
314
  );
302
315
  const noVisionModels = mergeStringArray(registryEntry.noVisionModels, provider.noVisionModels);
@@ -365,6 +378,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
365
378
  authMode: canonicalAuthMode,
366
379
  apiKey: resolvedApiKey,
367
380
  ...(staticModelCatalog ? { liveModels: false } : {}),
381
+ ...(headers ? { headers } : {}),
368
382
  // Backfill the Google wire mode + Vertex project/location from the registry when the user
369
383
  // config omits them, so a minimal `google-vertex`/`google-antigravity` entry still routes
370
384
  // through the correct branch (CCA/Vertex) instead of falling back to AI Studio.
@@ -10,7 +10,7 @@
10
10
  * how that affects eligibility.
11
11
  */
12
12
 
13
- import type { OcxConfig } from "../types";
13
+ import { modelInList, type OcxConfig } from "../types";
14
14
  import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
15
15
  import { serviceTierSupportForModel } from "../providers/service-tier";
16
16
  import { PROVIDER_REGISTRY } from "../providers/registry";
@@ -21,6 +21,7 @@ import {
21
21
  nativeReasoningEfforts,
22
22
  } from "../codex/catalog/metadata";
23
23
  import { readCatalog, readCodexCatalogPath } from "../codex/catalog/parsing";
24
+ import { modelRecordValue } from "../reasoning-effort";
24
25
  import { statSync } from "node:fs";
25
26
  import type { RouteCapabilityEvidence } from "./trace";
26
27
 
@@ -159,9 +160,14 @@ export function candidateCapabilityEvidence(
159
160
  const catalogRow = cachedCatalogModels().find(model => model.provider === providerName && model.id === modelId);
160
161
  const isNative = providerName === OPENAI_CODEX_PROVIDER_ID && !modelId.includes("/");
161
162
 
162
- const rawContextWindow = provider?.modelContextWindows?.[modelId]
163
+ // `modelRecordValue`, not a bare lookup: every runtime reader of these three maps
164
+ // resolves them that way, so a `gpt-oss` entry covers `gpt-oss:120b`. Reading raw
165
+ // made the evidence disagree with the resolver it claims to describe — and for the
166
+ // window it did not even degrade to unknown, it fell through to the provider-wide
167
+ // value, which is a definite wrong answer rather than an absent one.
168
+ const rawContextWindow = modelRecordValue(provider?.modelContextWindows, modelId)
163
169
  ?? provider?.contextWindow
164
- ?? registryEntry?.modelContextWindows?.[modelId]
170
+ ?? modelRecordValue(registryEntry?.modelContextWindows, modelId)
165
171
  ?? catalogRow?.contextWindow
166
172
  ?? (isNative ? nativeOpenAiContextWindow(modelId, nativeContextLimits(config)) : undefined);
167
173
  // Native rows go through the accessor (raise-to-ceiling + opt-in). Routed rows keep
@@ -170,10 +176,21 @@ export function candidateCapabilityEvidence(
170
176
  ? (nativeOpenAiContextWindow(modelId, nativeContextLimits(config)) ?? rawContextWindow)
171
177
  : rawContextWindow;
172
178
 
173
- const modalities = provider?.modelInputModalities?.[modelId]
174
- ?? registryEntry?.modelInputModalities?.[modelId]
175
- ?? catalogRow?.inputModalities
176
- ?? (isNative ? nativeInputModalities(modelId) : undefined);
179
+ // `noVisionModels` is checked before the modality chain because that is the order
180
+ // `isModelTextOnly` uses: it matches the no-vision list and returns true before it
181
+ // ever reads `modelInputModalities` (`src/vision/index.ts:32`). So a `gpt-oss`
182
+ // no-vision entry beats an exact `gpt-oss:120b` entry that lists "image", and
183
+ // deriving `image` from the modality chain alone reported vision on a model the
184
+ // runtime refuses it for. That matters more here than on the CLI surface fixed in
185
+ // #2086: routing *acts* on this evidence, so it would select the candidate for image
186
+ // work that execution then rejects.
187
+ const noVision = modelInList(provider?.noVisionModels, modelId);
188
+ const modalities = noVision
189
+ ? ["text"]
190
+ : (modelRecordValue(provider?.modelInputModalities, modelId)
191
+ ?? modelRecordValue(registryEntry?.modelInputModalities, modelId)
192
+ ?? catalogRow?.inputModalities
193
+ ?? (isNative ? nativeInputModalities(modelId) : undefined));
177
194
  const image = Array.isArray(modalities)
178
195
  ? modalities.includes("image")
179
196
  : undefined;
@@ -196,8 +213,8 @@ export function candidateCapabilityEvidence(
196
213
  || provider?.parallelToolCalls === true
197
214
  || undefined;
198
215
 
199
- const reasoningEfforts = provider?.modelReasoningEfforts?.[modelId]
200
- ?? registryEntry?.modelReasoningEfforts?.[modelId]
216
+ const reasoningEfforts = modelRecordValue(provider?.modelReasoningEfforts, modelId)
217
+ ?? modelRecordValue(registryEntry?.modelReasoningEfforts, modelId)
201
218
  ?? (isNative ? nativeReasoningEfforts(modelId) : undefined);
202
219
 
203
220
  const tierSupport = provider