@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.
- package/gui/dist/assets/{index-RL6b1bTV.js → index-D2sP-biU.js} +14 -14
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +60 -1
- package/src/adapters/base.ts +16 -2
- package/src/adapters/command-code.ts +4 -3
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/live-transport.ts +14 -1
- package/src/adapters/google-antigravity-replay.ts +16 -8
- package/src/adapters/google.ts +22 -5
- package/src/adapters/openai-chat.ts +189 -60
- package/src/adapters/openai-responses.ts +37 -0
- package/src/adapters/tool-catalog-nudge.ts +1 -1
- package/src/bridge.ts +11 -5
- package/src/cli/doctor.ts +76 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +19 -6
- package/src/cli/models.ts +13 -6
- package/src/codex/account-usability.ts +3 -0
- package/src/codex/app-server-processes.ts +269 -37
- package/src/codex/auth-api.ts +22 -5
- package/src/codex/auth-context.ts +108 -3
- package/src/codex/catalog/aggregation.ts +3 -0
- package/src/codex/catalog/metadata.ts +17 -3
- package/src/codex/catalog/native-models.ts +22 -14
- package/src/codex/catalog/parsing.ts +20 -3
- package/src/codex/catalog/provider-fetch.ts +8 -0
- package/src/codex/catalog/sync.ts +63 -15
- package/src/codex/convergence.ts +61 -13
- package/src/codex/log-guard/path-safety.ts +52 -3
- package/src/codex/model-entitlements.ts +353 -0
- package/src/codex/native-profile-startup.ts +100 -2
- package/src/codex/quota.ts +28 -3
- package/src/codex/routing.ts +14 -8
- package/src/codex/user-identity.ts +21 -1
- package/src/config/provider-name.ts +24 -0
- package/src/config.ts +11 -24
- package/src/generated/compatibility-version.json +110 -70
- package/src/images/loop.ts +11 -4
- package/src/lib/destination-policy.ts +47 -0
- package/src/lib/shadow-call.ts +15 -0
- package/src/lib/state-store-registrations.ts +8 -2
- package/src/oauth/index.ts +33 -5
- package/src/oauth/store.ts +11 -5
- package/src/providers/antigravity-models.ts +70 -5
- package/src/providers/derive.ts +12 -2
- package/src/providers/fastwire.ts +39 -8
- package/src/providers/quota.ts +9 -2
- package/src/providers/registry.ts +120 -6
- package/src/providers/service-tier.ts +50 -15
- package/src/responses/parser.ts +59 -11
- package/src/responses/state.ts +162 -5
- package/src/responses/tool-search-compat.ts +301 -0
- package/src/router.ts +17 -3
- package/src/routing/capability.ts +26 -9
- package/src/routing/compatibility/behavior.ts +44 -6
- package/src/routing/profile.ts +1 -1
- package/src/server/chat-native.ts +11 -2
- package/src/server/index.ts +59 -8
- package/src/server/management/agent-settings-routes.ts +16 -2
- package/src/server/management/shared.ts +3 -1
- package/src/server/request-log.ts +31 -0
- package/src/server/responses/collaboration.ts +34 -9
- package/src/server/responses/compact.ts +54 -7
- package/src/server/responses/core.ts +259 -43
- package/src/server/responses/input-admission.ts +7 -2
- package/src/server/responses/responses-field-backfill.ts +88 -6
- package/src/server/responses/terminal-guard.ts +10 -0
- package/src/server/responses-tool-search-repair.ts +217 -0
- package/src/server/system-env.ts +74 -5
- package/src/service-manager-probe.ts +99 -0
- package/src/service.ts +86 -6
- package/src/tray/windows.ts +25 -5
- package/src/types/accounts.ts +37 -0
- package/src/types/config.ts +818 -0
- package/src/types/provider.ts +521 -0
- package/src/types/request.ts +358 -0
- package/src/types/tools.ts +131 -0
- package/src/types/wire.ts +80 -0
- package/src/types.ts +103 -1883
- package/src/usage/cost.ts +37 -1
- package/src/usage/log.ts +4 -0
- package/src/web-search/loop.ts +11 -4
package/src/responses/state.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
200
|
-
?? registryEntry?.modelReasoningEfforts
|
|
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
|