@bitkyc08/opencodex 2.76.0 → 2.77.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/App-COdEeqSj.css +1 -0
- package/gui/dist/assets/App-DEpCJEP_.js +54 -0
- package/gui/dist/assets/{Tray-DjNwwrWS.js → Tray-gloIYNaW.js} +1 -1
- package/gui/dist/assets/index-Cd7ittMv.css +1 -0
- package/gui/dist/assets/index-DwJKFJm5.js +94 -0
- package/gui/dist/assets/quota-summary-DGcEJU3h.js +1 -0
- package/gui/dist/index.html +4 -3
- package/package.json +1 -1
- package/src/adapters/antigravity-refusal.ts +28 -0
- package/src/adapters/cursor/protobuf-request.ts +6 -0
- package/src/adapters/cursor/request-builder.ts +3 -0
- package/src/adapters/cursor/structured-output.ts +13 -0
- package/src/adapters/cursor/types.ts +2 -0
- package/src/adapters/ollama-native.ts +29 -2
- package/src/adapters/openai-responses/passthrough.ts +133 -103
- package/src/adapters/openai-responses/request-strips.ts +10 -4
- package/src/adapters/zed.ts +26 -4
- package/src/bridge/response-json.ts +14 -14
- package/src/bridge/sse.ts +15 -5
- package/src/chatgpt/app-server-shim/bundle-trust.ts +40 -10
- package/src/chatgpt/app-server-shim/gate-rewrite.ts +3 -0
- package/src/claude/agents-inject.ts +4 -43
- package/src/claude/gateway-cache.ts +16 -13
- package/src/claude/inbound-model-options.ts +9 -2
- package/src/claude/intercept/cli-catalog.ts +27 -16
- package/src/claude/intercept/picker-bootstrap.ts +37 -16
- package/src/claude/intercept/picker-budget.ts +41 -0
- package/src/claude/outbound.ts +2 -0
- package/src/claude/subagent-force-status.ts +53 -0
- package/src/claude/subagent-model.ts +93 -0
- package/src/cli/account-api.ts +3 -0
- package/src/cli/account.ts +3 -1
- package/src/cli/agent.ts +10 -0
- package/src/cli/capabilities.ts +9 -0
- package/src/cli/chatgpt-command.ts +10 -7
- package/src/cli/claude.ts +22 -6
- package/src/cli/codex-shim-readiness.ts +89 -0
- package/src/cli/connect.ts +19 -0
- package/src/cli/dispatch.ts +3 -3
- package/src/cli/export-command.ts +20 -9
- package/src/cli/gui.ts +8 -3
- package/src/cli/help-catalog.ts +84 -0
- package/src/cli/help-models-context.ts +17 -0
- package/src/cli/help-navigation.ts +50 -0
- package/src/cli/help-recovery.ts +66 -0
- package/src/cli/help.ts +52 -8
- package/src/cli/models-runtime.ts +2 -1
- package/src/cli/opencode.ts +54 -10
- package/src/cli/provider.ts +3 -29
- package/src/cli/registry.ts +29 -5
- package/src/cli/root.ts +30 -9
- package/src/cli/runtime-api.ts +1 -1
- package/src/cli/tray-proxy.ts +3 -2
- package/src/clients/config-export/contracts.ts +100 -0
- package/src/clients/config-export/droid.ts +54 -2
- package/src/clients/config-export/kilo.ts +12 -2
- package/src/clients/config-export/model-metadata.ts +10 -4
- package/src/clients/config-export/reasoning-metadata.ts +47 -0
- package/src/clients/config-export.ts +46 -66
- package/src/clients/omo-role-models.ts +17 -3
- package/src/codex/account-credit-use.ts +4 -2
- package/src/codex/account-runtime-state.ts +21 -0
- package/src/codex/account-store.ts +11 -47
- package/src/codex/account-usability.ts +11 -8
- package/src/codex/auth-api/account-list.ts +73 -63
- package/src/codex/auth-api/main-account-probe.ts +25 -12
- package/src/codex/auth-api/pool-mode-gate.ts +6 -9
- package/src/codex/auth-api/pool-quota-probe.ts +2 -4
- package/src/codex/auth-api/reset-credit-service.ts +3 -2
- package/src/codex/auth-context.ts +30 -11
- package/src/codex/catalog/aggregation.ts +3 -0
- package/src/codex/catalog/model-hints.ts +2 -1
- package/src/codex/catalog/model-visibility.ts +6 -3
- package/src/codex/catalog/parsing.ts +2 -0
- package/src/codex/catalog/provider-models.ts +13 -4
- package/src/codex/catalog/retained-sync.ts +2 -1
- package/src/codex/chatgpt-refresh-failure.ts +56 -0
- package/src/codex/convergence.ts +2 -1
- package/src/codex/main-account.ts +144 -11
- package/src/codex/quota-refresh-outcome.ts +17 -2
- package/src/codex/quota-types.ts +33 -1
- package/src/codex/quota.ts +56 -15
- package/src/codex/routing/cooldown-math.ts +10 -4
- package/src/codex/routing/selection.ts +11 -4
- package/src/codex/routing.ts +9 -9
- package/src/codex/shim-path-resolution.ts +133 -0
- package/src/codex/shim.ts +39 -39
- package/src/codex/subagent-model-fallback.ts +2 -1
- package/src/combos/failover.ts +82 -1
- package/src/combos/jev.ts +13 -3
- package/src/config/diagnostics.ts +4 -0
- package/src/config/load-degrade.ts +4 -0
- package/src/config/provider-validation.ts +3 -0
- package/src/config/schema/compaction-triggers.ts +8 -0
- package/src/config/schema/config-schema.ts +11 -0
- package/src/config/schema/leaf-validators.ts +5 -1
- package/src/config/subagent-models.ts +12 -0
- package/src/generated/compatibility-version.json +245 -153
- package/src/grok/session-identity.ts +33 -0
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/mutation-plan.ts +4 -2
- package/src/integrations/state.ts +68 -6
- package/src/integrations/writer.ts +18 -4
- package/src/lib/gui-pair-capability.ts +17 -0
- package/src/lib/provider-client-headers.ts +62 -0
- package/src/oauth/anthropic-continuity.ts +65 -0
- package/src/oauth/anthropic-identity.ts +75 -0
- package/src/oauth/anthropic-routing.ts +3 -3
- package/src/oauth/anthropic.ts +7 -1
- package/src/oauth/generic-account-failover.ts +21 -0
- package/src/oauth/health.ts +32 -5
- package/src/oauth/index.ts +36 -27
- package/src/oauth/local-token-detect.ts +7 -0
- package/src/oauth/refresh-policy.ts +2 -0
- package/src/oauth/store.ts +55 -6
- package/src/oauth/types.ts +9 -0
- package/src/protocols/encoders/messages.ts +4 -3
- package/src/providers/antigravity-effort-families.ts +75 -0
- package/src/providers/antigravity-models.ts +54 -16
- package/src/providers/antigravity-wire-snapshot.ts +66 -0
- package/src/providers/derive.ts +2 -0
- package/src/providers/model-rename-fields.ts +3 -0
- package/src/providers/new-model-policy.ts +48 -10
- package/src/providers/registry/entries-extended.ts +3 -0
- package/src/providers/registry/model-ids.ts +2 -0
- package/src/providers/registry/model-seeds.ts +5 -0
- package/src/providers/registry/types.ts +4 -0
- package/src/responses/compaction.ts +19 -0
- package/src/router.ts +6 -0
- package/src/server/auth-cors.ts +9 -0
- package/src/server/chat-completions.ts +10 -0
- package/src/server/claude-messages.ts +18 -11
- package/src/server/droid-reasoning-default.ts +50 -0
- package/src/server/github-copilot-responses-repair.ts +3 -6
- package/src/server/grok-responses-control-frame.ts +2 -2
- package/src/server/grok-responses-snapshot-repair.ts +4 -5
- package/src/server/gui-session.ts +33 -11
- package/src/server/index/claude-intercept-lifecycle.ts +4 -0
- package/src/server/index/serve-options.ts +2 -1
- package/src/server/management/agent-settings-routes.ts +6 -129
- package/src/server/management/config-routes.ts +1 -1
- package/src/server/management/integration-routes.ts +64 -6
- package/src/server/management/link-routes.ts +20 -13
- package/src/server/management/model-row-export-metadata.ts +150 -0
- package/src/server/management/model-rows.ts +148 -9
- package/src/server/management/route-registry.ts +4 -3
- package/src/server/management/shared.ts +19 -5
- package/src/server/management/subagent-model-routes.ts +214 -0
- package/src/server/relay.ts +14 -44
- package/src/server/request-log-generation-window.ts +38 -0
- package/src/server/request-log.ts +13 -0
- package/src/server/responses/adapter-dispatch.ts +100 -1
- package/src/server/responses/codex-auth-error.ts +10 -2
- package/src/server/responses/combo-stream-preflight.ts +29 -3
- package/src/server/responses/compaction-routing.ts +10 -1
- package/src/server/responses/core-auth.ts +57 -26
- package/src/server/responses/core-codex-account.ts +6 -3
- package/src/server/responses/core-combo-failure.ts +5 -1
- package/src/server/responses/core-combo-native.ts +7 -1
- package/src/server/responses/core-combo.ts +18 -1
- package/src/server/responses/core-normalize.ts +7 -0
- package/src/server/responses/core-options.ts +5 -0
- package/src/server/responses/fetch-helpers.ts +9 -0
- package/src/server/responses/passthrough-delivery.ts +1 -1
- package/src/server/responses/policy-fallback.ts +1 -1
- package/src/server/responses/request-prepare.ts +5 -0
- package/src/server/responses/request-spend.ts +30 -30
- package/src/server/responses-custom-tool-repair.ts +2 -2
- package/src/server/responses-hosted-image-display.ts +70 -9
- package/src/server/responses-terminal-repair.ts +15 -2
- package/src/server/sse-frame-buffer.ts +38 -36
- package/src/server/sse-payload-rewrite.ts +78 -49
- package/src/server/ws-bridge.ts +1 -11
- package/src/service/windows-taskxml.ts +1 -2
- package/src/service-manager-probe.ts +36 -2
- package/src/types/config.ts +4 -0
- package/src/types/provider.ts +18 -0
- package/src/update/npm-invocation.mjs +35 -3
- package/src/update/proxy-liveness-probe.mjs +38 -8
- package/src/usage/expected-prices.ts +9 -0
- package/src/usage/log.ts +7 -0
- package/src/usage/summary.ts +82 -1
- package/src/web-search/loop.ts +11 -8
- package/src/web-search/run-turn-loop.ts +10 -7
- package/gui/dist/assets/App-CddorHdA.js +0 -51
- package/gui/dist/assets/App-DMudtISW.css +0 -1
- package/gui/dist/assets/index-Btze1I3T.css +0 -1
- package/gui/dist/assets/index-CvnY7E6i.js +0 -86
- package/gui/dist/assets/quota-summary-B_AWpHfP.js +0 -1
package/src/codex/routing.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { codexAccountUsesCreditsAfterLimit } from "./account-credit-use";
|
|
1
2
|
import { clearIdleWindowSteering, pickIdleWindowAccount } from "./routing/idle-window";
|
|
2
3
|
import { getEffectiveCodexAutoSwitchThreshold } from "./account-auto-switch";
|
|
3
4
|
import { codexQuotaHasFreshUsage } from "./quota-observation-freshness";
|
|
@@ -522,7 +523,7 @@ function pickAffinityPriorityFailback(
|
|
|
522
523
|
|| now - quota.updatedAt >= CODEX_PRIORITY_FAILBACK_REFRESH_MS
|
|
523
524
|
|| (quota.shortObservedAt !== undefined
|
|
524
525
|
&& now - quota.shortObservedAt >= CODEX_PRIORITY_FAILBACK_REFRESH_MS)) return false;
|
|
525
|
-
const usage = computeCodexUsageScore(quota, plan, now);
|
|
526
|
+
const usage = computeCodexUsageScore(quota, plan, now, codexAccountUsesCreditsAfterLimit(config, id));
|
|
526
527
|
const threshold = getEffectiveCodexAutoSwitchThreshold(config, id);
|
|
527
528
|
return !isUnknownUsage(usage) && usage < 100 && (threshold <= 0 || usage < threshold);
|
|
528
529
|
});
|
|
@@ -575,7 +576,7 @@ function previewReusableAffinityAccount(
|
|
|
575
576
|
const usage = computeCodexUsageScore(
|
|
576
577
|
getAccountQuota(entry.accountId),
|
|
577
578
|
getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions),
|
|
578
|
-
|
|
579
|
+
now, codexAccountUsesCreditsAfterLimit(config, entry.accountId),
|
|
579
580
|
);
|
|
580
581
|
// Preview must agree with resolve: this is the second copy of the same rule, and the
|
|
581
582
|
// suite asserts the two answer identically.
|
|
@@ -605,7 +606,7 @@ function resetFirstAffinityReplacement(
|
|
|
605
606
|
): string | null {
|
|
606
607
|
const threshold = getEffectiveCodexAutoSwitchThreshold(config, entry.accountId);
|
|
607
608
|
if (threshold <= 0) return null;
|
|
608
|
-
const usage = computeCodexUsageScore(getAccountQuota(entry.accountId), getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions), now);
|
|
609
|
+
const usage = computeCodexUsageScore(getAccountQuota(entry.accountId), getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions), now, codexAccountUsesCreditsAfterLimit(config, entry.accountId));
|
|
609
610
|
if (!mayRebindAffinityForQuota(config, entry.accountId, usage, threshold, selectionOptions)) return null;
|
|
610
611
|
const candidates = getEligiblePoolAccounts(config, entry.accountId, now, quotaScope, selectionOptions, true)
|
|
611
612
|
// Headroom alone answers true for an UNMEASURED account, which is the right default for an
|
|
@@ -618,7 +619,7 @@ function resetFirstAffinityReplacement(
|
|
|
618
619
|
return !isUnknownUsage(computeCodexUsageScore(
|
|
619
620
|
getAccountQuota(id),
|
|
620
621
|
getPoolAccountPlanForSelection(config, id, selectionOptions),
|
|
621
|
-
now,
|
|
622
|
+
now, codexAccountUsesCreditsAfterLimit(config, id),
|
|
622
623
|
));
|
|
623
624
|
});
|
|
624
625
|
return pickResetFirstCodexAccount(config, candidates, now, selectionOptions);
|
|
@@ -664,10 +665,9 @@ function pickCacheSafeQuotaReplacement(
|
|
|
664
665
|
).filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now));
|
|
665
666
|
const best = pickLowestUsageAmong(config, candidates, selectionOptions, now);
|
|
666
667
|
if (best === null || best === boundAccountId) return null;
|
|
667
|
-
const bestUsage = computeCodexUsageScore(
|
|
668
|
-
getAccountQuota(best),
|
|
668
|
+
const bestUsage = computeCodexUsageScore(getAccountQuota(best),
|
|
669
669
|
getPoolAccountPlanForSelection(config, best, selectionOptions),
|
|
670
|
-
now,
|
|
670
|
+
now, codexAccountUsesCreditsAfterLimit(config, best),
|
|
671
671
|
);
|
|
672
672
|
return bestUsage < boundUsage ? best : null;
|
|
673
673
|
}
|
|
@@ -698,7 +698,7 @@ function reevaluateAffinityQuota(
|
|
|
698
698
|
? computeCodexUsageScore(
|
|
699
699
|
getAccountQuota(entry.accountId),
|
|
700
700
|
getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions),
|
|
701
|
-
|
|
701
|
+
now, codexAccountUsesCreditsAfterLimit(config, entry.accountId),
|
|
702
702
|
)
|
|
703
703
|
: 0;
|
|
704
704
|
// One bar, used for BOTH the rebind decision and the re-score interval. Keying the short
|
|
@@ -823,7 +823,7 @@ export function previewCodexAccountForRequest(
|
|
|
823
823
|
const usage = computeCodexUsageScore(
|
|
824
824
|
getAccountQuota(active),
|
|
825
825
|
getPoolAccountPlanForSelection(config, active, selectionOptions),
|
|
826
|
-
now,
|
|
826
|
+
now, codexAccountUsesCreditsAfterLimit(config, active),
|
|
827
827
|
);
|
|
828
828
|
if (!isUnknownUsage(usage) && usage >= threshold) {
|
|
829
829
|
active = pickLowerUsageAccount(config, active, usage, now, quotaScope, selectionOptions);
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { posix, win32 } from "node:path";
|
|
3
|
+
import { isWslRuntime, wslAutomountRoot } from "./home";
|
|
4
|
+
import { SHIM_MARKER } from "./shim-templates";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A PATH entry that reaches Windows through WSL drive interop
|
|
8
|
+
* (`<automount-root>/<drive>/...`; root defaults to /mnt, configurable via
|
|
9
|
+
* /etc/wsl.conf [automount] root).
|
|
10
|
+
*/
|
|
11
|
+
export function isWindowsInteropDir(dir: string, automountRoot = "/mnt"): boolean {
|
|
12
|
+
const root = automountRoot.replace(/\/+$/, "");
|
|
13
|
+
const escaped = root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
14
|
+
return new RegExp(`^${escaped}/[a-z](/|$)`, "i").test(dir);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type CodexPathScanDeps = {
|
|
18
|
+
pathValue?: string;
|
|
19
|
+
wsl?: boolean;
|
|
20
|
+
/** Treat PATH entries as POSIX paths (WSL context). Defaults to wsl || non-win32. */
|
|
21
|
+
posixPaths?: boolean;
|
|
22
|
+
automountRoot?: string;
|
|
23
|
+
exists?: (path: string) => boolean;
|
|
24
|
+
isShimFile?: (path: string) => boolean;
|
|
25
|
+
isDirectory?: (path: string) => boolean;
|
|
26
|
+
realpath?: (path: string) => string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type CodexPathCandidate = {
|
|
30
|
+
path: string;
|
|
31
|
+
isShim: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function realIsDirectory(path: string): boolean {
|
|
35
|
+
try {
|
|
36
|
+
return lstatSync(path).isDirectory();
|
|
37
|
+
} catch {
|
|
38
|
+
return true; // unreadable -> treat as unusable
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const SHIM_HEADER_MAX_BYTES = 16 * 1024;
|
|
43
|
+
|
|
44
|
+
function inspectShimFile(path: string): boolean | null {
|
|
45
|
+
let fd: number | undefined;
|
|
46
|
+
try {
|
|
47
|
+
// Follow npm launcher symlinks, but never read a known special file.
|
|
48
|
+
if (!statSync(path).isFile()) return null;
|
|
49
|
+
const flags = constants.O_RDONLY | (process.platform === "win32" ? 0 : constants.O_NONBLOCK);
|
|
50
|
+
fd = openSync(path, flags);
|
|
51
|
+
// The entry can change after stat; a replacement FIFO must not block open or read.
|
|
52
|
+
if (!fstatSync(fd).isFile()) return null;
|
|
53
|
+
const header = Buffer.allocUnsafe(SHIM_HEADER_MAX_BYTES);
|
|
54
|
+
let total = 0;
|
|
55
|
+
while (total < header.length) {
|
|
56
|
+
const count = readSync(fd, header, total, header.length - total, total);
|
|
57
|
+
if (count === 0) break;
|
|
58
|
+
total += count;
|
|
59
|
+
}
|
|
60
|
+
return header.subarray(0, total).toString("utf8").includes(SHIM_MARKER);
|
|
61
|
+
} catch {
|
|
62
|
+
// An unreadable command still shadows later PATH entries; do not claim they are active.
|
|
63
|
+
return false;
|
|
64
|
+
} finally {
|
|
65
|
+
if (fd !== undefined) closeSync(fd);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function commandNames(name: string): string[] {
|
|
70
|
+
if (process.platform !== "win32") return [name];
|
|
71
|
+
const exts = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD;.PS1").split(";").filter(Boolean);
|
|
72
|
+
return [name, ...exts.flatMap(ext => [`${name}${ext.toLowerCase()}`, `${name}${ext.toUpperCase()}`])];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The shell-local bin directory that fnm creates for one interactive shell. */
|
|
76
|
+
export function isFnmMultishellPath(path: string, posixPaths: boolean): boolean {
|
|
77
|
+
const normalized = (posixPaths
|
|
78
|
+
? posix.normalize(path)
|
|
79
|
+
: win32.normalize(path).replace(/\\/g, "/")).toLowerCase();
|
|
80
|
+
return /(?:^|\/)fnm_multishells?(?:\/|$)/.test(normalized);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Find the command the shell would actually use, including an OpenCodex shim. */
|
|
84
|
+
export function findFirstCodexOnPath(deps: CodexPathScanDeps = {}): CodexPathCandidate | null {
|
|
85
|
+
const exists = deps.exists ?? existsSync;
|
|
86
|
+
const shimFile = deps.isShimFile ?? inspectShimFile;
|
|
87
|
+
const isDir = deps.isDirectory ?? realIsDirectory;
|
|
88
|
+
const wsl = deps.wsl ?? (process.platform === "linux" && isWslRuntime());
|
|
89
|
+
const usePosix = deps.posixPaths ?? (wsl || process.platform !== "win32");
|
|
90
|
+
const joinPath = usePosix ? posix.join : win32.join;
|
|
91
|
+
const pathSep = usePosix ? ":" : win32.delimiter;
|
|
92
|
+
const automountRoot = deps.automountRoot ?? (wsl ? wslAutomountRoot() : "/mnt");
|
|
93
|
+
const interopNames = ["codex", "codex.exe", "codex.cmd", "codex.ps1"];
|
|
94
|
+
|
|
95
|
+
for (const dir of (deps.pathValue ?? process.env.PATH ?? "").split(pathSep).filter(Boolean)) {
|
|
96
|
+
if (wsl && isWindowsInteropDir(dir, automountRoot)) continue;
|
|
97
|
+
const names = isWindowsInteropDir(dir, automountRoot) ? interopNames : commandNames("codex");
|
|
98
|
+
for (const name of names) {
|
|
99
|
+
const path = joinPath(dir, name);
|
|
100
|
+
if (!exists(path) || isDir(path)) continue;
|
|
101
|
+
const isShim = shimFile(path);
|
|
102
|
+
if (isShim !== null) return { path, isShim };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolve a Codex command discovered through fnm's temporary PATH entry.
|
|
110
|
+
*
|
|
111
|
+
* A temporary path is safe to use only when its physical target is outside the
|
|
112
|
+
* temporary tree. Returning null is deliberate: callers must not wrap the
|
|
113
|
+
* shell-local path or silently choose a lower-priority Codex installation.
|
|
114
|
+
*/
|
|
115
|
+
export function resolveStableFnmCodexPath(
|
|
116
|
+
path: string,
|
|
117
|
+
posixPaths: boolean,
|
|
118
|
+
realpath: ((path: string) => string) | undefined = realpathSync.native,
|
|
119
|
+
): string | null {
|
|
120
|
+
if (!isFnmMultishellPath(path, posixPaths)) return path;
|
|
121
|
+
try {
|
|
122
|
+
// fnm normally links the temporary directory to the durable installation,
|
|
123
|
+
// while npm may link the command file again into its package internals.
|
|
124
|
+
// Resolve only the directory so the command basename remains the stable
|
|
125
|
+
// installation entry that the installer is allowed to replace.
|
|
126
|
+
const pathTools = posixPaths ? posix : win32;
|
|
127
|
+
const resolvedParent = realpath(pathTools.dirname(path));
|
|
128
|
+
if (!resolvedParent || isFnmMultishellPath(resolvedParent, posixPaths) || !pathTools.isAbsolute(resolvedParent)) return null;
|
|
129
|
+
return pathTools.join(resolvedParent, pathTools.basename(path));
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
package/src/codex/shim.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
unlinkSync,
|
|
9
9
|
writeFileSync,
|
|
10
10
|
} from "node:fs";
|
|
11
|
-
import { basename, delimiter, dirname, extname, join, posix } from "node:path";
|
|
11
|
+
import { basename, delimiter, dirname, extname, join, posix, win32 } from "node:path";
|
|
12
12
|
import { durableBunRuntime } from "../lib/bun-runtime";
|
|
13
13
|
import type { BunRuntimeSource } from "../lib/bun-runtime";
|
|
14
14
|
import { serviceApiTokenFilePath } from "../lib/service-secrets";
|
|
@@ -53,12 +53,20 @@ import {
|
|
|
53
53
|
type UnixShimProbeResult,
|
|
54
54
|
} from "./shim-probe";
|
|
55
55
|
import { tryAcquireShimRestoreLock } from "./shim-restore-lock";
|
|
56
|
+
import {
|
|
57
|
+
isWindowsInteropDir,
|
|
58
|
+
realIsDirectory,
|
|
59
|
+
resolveStableFnmCodexPath,
|
|
60
|
+
type CodexPathScanDeps,
|
|
61
|
+
} from "./shim-path-resolution";
|
|
56
62
|
|
|
57
63
|
export { buildUnixCodexShim, buildWindowsCodexShim, buildWindowsPowerShellCodexShim } from "./shim-templates";
|
|
58
64
|
export { isVersionManagerOwnedCodexPath } from "./shim-fingerprint";
|
|
59
65
|
export { CODEX_SHIM_STATE_MAX_BYTES } from "./shim-state-file";
|
|
60
66
|
export { setCodexShimProbeHookForTests, setCodexShimProbeShellForTests, setCodexShimProbeObservationMsForTests } from "./shim-probe";
|
|
61
67
|
export type { CodexShimBackingForCommand } from "./shim-inspect";
|
|
68
|
+
export type { CodexPathScanDeps } from "./shim-path-resolution";
|
|
69
|
+
export { isWindowsInteropDir } from "./shim-path-resolution";
|
|
62
70
|
export { isLocalAbsoluteInspectionPath, inspectCodexShimBackingForCommand } from "./shim-inspect";
|
|
63
71
|
|
|
64
72
|
export const CODEX_SHIM_REPLACEMENT_STABLE_MS = 100;
|
|
@@ -75,6 +83,17 @@ interface InstallCodexShimInternalOptions {
|
|
|
75
83
|
beforeGuardedRefresh?: (wrapperPath: string, index: number) => void;
|
|
76
84
|
}
|
|
77
85
|
|
|
86
|
+
function discoveredCodexPath(path: string, posixPaths: boolean, realpath?: (path: string) => string): string | null {
|
|
87
|
+
const resolved = resolveStableFnmCodexPath(path, posixPaths, realpath);
|
|
88
|
+
if (resolved !== null) return resolved;
|
|
89
|
+
lastShimDiscoveryError = truncateRetainedUtf8(
|
|
90
|
+
`Found codex at ${path} inside fnm's temporary multishell path, but it did not resolve to a durable Node installation. `
|
|
91
|
+
+ "Refusing to install a shim; retry after fnm selects a stable installation.",
|
|
92
|
+
MAX_DIAGNOSTIC_VALUE_BYTES,
|
|
93
|
+
);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
78
97
|
export type CodexShimAutoRestoreResult =
|
|
79
98
|
| { status: "not-installed" | "healthy" | "disabled" }
|
|
80
99
|
| { status: "ineligible" | "deferred"; message?: string }
|
|
@@ -115,36 +134,6 @@ function isHealthyShim(path: string, platform: NodeJS.Platform): boolean {
|
|
|
115
134
|
}
|
|
116
135
|
}
|
|
117
136
|
|
|
118
|
-
/**
|
|
119
|
-
* A PATH entry that reaches Windows through WSL drive interop
|
|
120
|
-
* (`<automount-root>/<drive>/...`; root defaults to /mnt, configurable via
|
|
121
|
-
* /etc/wsl.conf [automount] root).
|
|
122
|
-
*/
|
|
123
|
-
export function isWindowsInteropDir(dir: string, automountRoot = "/mnt"): boolean {
|
|
124
|
-
const root = automountRoot.replace(/\/+$/, "");
|
|
125
|
-
const escaped = root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
126
|
-
return new RegExp(`^${escaped}/[a-z](/|$)`, "i").test(dir);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export type CodexPathScanDeps = {
|
|
130
|
-
pathValue?: string;
|
|
131
|
-
wsl?: boolean;
|
|
132
|
-
/** Treat PATH entries as POSIX paths (WSL context). Defaults to wsl || non-win32. */
|
|
133
|
-
posixPaths?: boolean;
|
|
134
|
-
automountRoot?: string;
|
|
135
|
-
exists?: (path: string) => boolean;
|
|
136
|
-
isShimFile?: (path: string) => boolean;
|
|
137
|
-
isDirectory?: (path: string) => boolean;
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
function realIsDirectory(path: string): boolean {
|
|
141
|
-
try {
|
|
142
|
-
return lstatSync(path).isDirectory();
|
|
143
|
-
} catch {
|
|
144
|
-
return true; // unreadable -> treat as unusable
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
137
|
export function findCodexOnPath(deps: CodexPathScanDeps = {}): string | null {
|
|
149
138
|
lastShimDiscoveryError = null;
|
|
150
139
|
const exists = deps.exists ?? existsSync;
|
|
@@ -152,8 +141,10 @@ export function findCodexOnPath(deps: CodexPathScanDeps = {}): string | null {
|
|
|
152
141
|
const isDir = deps.isDirectory ?? realIsDirectory;
|
|
153
142
|
const wsl = deps.wsl ?? (process.platform === "linux" && isWslRuntime());
|
|
154
143
|
const usePosix = deps.posixPaths ?? (wsl || process.platform !== "win32");
|
|
155
|
-
|
|
156
|
-
|
|
144
|
+
// The injected path flavour governs join and separator alike; host-native `join` and
|
|
145
|
+
// `delimiter` made a Windows-flavoured scan split `C:\...` on a Linux host.
|
|
146
|
+
const joinPath = usePosix ? posix.join : win32.join;
|
|
147
|
+
const pathSep = usePosix ? ":" : win32.delimiter;
|
|
157
148
|
const automountRoot = deps.automountRoot ?? (wsl ? wslAutomountRoot() : "/mnt");
|
|
158
149
|
// Windows npm prefixes ship codex.exe/codex.cmd next to the extensionless sh launcher.
|
|
159
150
|
const interopNames = ["codex", "codex.exe", "codex.cmd", "codex.ps1"];
|
|
@@ -176,7 +167,11 @@ export function findCodexOnPath(deps: CodexPathScanDeps = {}): string | null {
|
|
|
176
167
|
for (const name of names) {
|
|
177
168
|
const path = joinPath(dir, name);
|
|
178
169
|
if (!exists(path) || shimFile(path)) continue;
|
|
179
|
-
if (!isDir(path))
|
|
170
|
+
if (!isDir(path)) {
|
|
171
|
+
const resolved = discoveredCodexPath(path, usePosix, deps.realpath);
|
|
172
|
+
if (resolved !== null) return resolved;
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
180
175
|
}
|
|
181
176
|
}
|
|
182
177
|
|
|
@@ -195,11 +190,13 @@ function findWindowsCodexTargets(): ShimFileState[] | null {
|
|
|
195
190
|
lastShimDiscoveryError = null;
|
|
196
191
|
for (const dir of (process.env.PATH ?? "").split(delimiter).filter(Boolean)) {
|
|
197
192
|
const exe = join(dir, "codex.exe");
|
|
198
|
-
|
|
193
|
+
const stableExe = existsSync(exe) && !isShim(exe) ? discoveredCodexPath(exe, false) : exe;
|
|
194
|
+
if (stableExe === null) return null;
|
|
195
|
+
if (existsSync(stableExe) && !isShim(stableExe)) {
|
|
199
196
|
try {
|
|
200
|
-
if (!lstatSync(
|
|
197
|
+
if (!lstatSync(stableExe).isDirectory()) {
|
|
201
198
|
lastShimDiscoveryError = truncateRetainedUtf8(
|
|
202
|
-
`Found codex.exe at ${
|
|
199
|
+
`Found codex.exe at ${stableExe}. Refusing to rename a real .exe because exact codex.exe invocations would break; ` +
|
|
203
200
|
"install a codex.cmd/codex.ps1 launcher or use `ocx service install` for autostart.",
|
|
204
201
|
MAX_DIAGNOSTIC_VALUE_BYTES,
|
|
205
202
|
);
|
|
@@ -216,9 +213,12 @@ function findWindowsCodexTargets(): ShimFileState[] | null {
|
|
|
216
213
|
const targets: ShimFileState[] = [];
|
|
217
214
|
for (const path of [cmd, ps1, gitBashLauncher]) {
|
|
218
215
|
if (!existsSync(path) || isShim(path)) continue;
|
|
216
|
+
const stablePath = discoveredCodexPath(path, false);
|
|
217
|
+
if (stablePath === null) return null;
|
|
218
|
+
if (!existsSync(stablePath) || isShim(stablePath)) continue;
|
|
219
219
|
try {
|
|
220
|
-
if (!lstatSync(
|
|
221
|
-
targets.push({ wrapperPath:
|
|
220
|
+
if (!lstatSync(stablePath).isDirectory()) {
|
|
221
|
+
targets.push({ wrapperPath: stablePath, originalPath: stablePath, backupPath: backupPathFor(stablePath) });
|
|
222
222
|
}
|
|
223
223
|
} catch { /* keep scanning */ }
|
|
224
224
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { codexAccountUsesCreditsAfterLimit } from "./account-credit-use";
|
|
1
2
|
/**
|
|
2
3
|
* Quota-aware subagent model fallback (issue #374).
|
|
3
4
|
*
|
|
@@ -233,7 +234,7 @@ export function isNativeModelQuotaExhausted(
|
|
|
233
234
|
// Subagent fallback reads the same score, so a stale terminal reading would push
|
|
234
235
|
// subagents off a native model whose window has already reset. Thread the caller's clock
|
|
235
236
|
// rather than letting the scorer read wall time - the two would silently diverge.
|
|
236
|
-
const usage = computeCodexUsageScore(quota, getPoolAccountPlan(config, resolvedAccountId), now);
|
|
237
|
+
const usage = computeCodexUsageScore(quota, getPoolAccountPlan(config, resolvedAccountId), now, codexAccountUsesCreditsAfterLimit(config, resolvedAccountId));
|
|
237
238
|
if (usage >= CODEX_UNKNOWN_USAGE_SCORE) return false;
|
|
238
239
|
return usage >= quotaThreshold(config, resolvedAccountId);
|
|
239
240
|
}
|
package/src/combos/failover.ts
CHANGED
|
@@ -670,10 +670,84 @@ function isDefiniteContextOverflow(status: number, message: string): boolean {
|
|
|
670
670
|
return false;
|
|
671
671
|
}
|
|
672
672
|
|
|
673
|
+
const CODEX_ACCOUNT_MODEL_REFUSAL = /^The '[^']{1,256}' model is not supported when using Codex with a ChatGPT account\.$/;
|
|
674
|
+
|
|
675
|
+
export type CodexAccountModelRefusal = "other" | "refusal" | "ambiguous";
|
|
676
|
+
|
|
677
|
+
/** Inspect only the original root and its own response record, before carrier selection. */
|
|
678
|
+
export function hasConflictingCodexModelRefusalEnvelopes(status: number, payload: unknown): boolean {
|
|
679
|
+
if (status !== 400 || !payload || typeof payload !== "object" || Array.isArray(payload)) return false;
|
|
680
|
+
const response = Object.hasOwn(payload, "response") ? (payload as Record<string, unknown>).response : undefined;
|
|
681
|
+
const nested = response && typeof response === "object" && !Array.isArray(response) ? response : undefined;
|
|
682
|
+
return (Object.hasOwn(payload, "detail") || !!nested && Object.hasOwn(nested, "detail"))
|
|
683
|
+
&& (Object.hasOwn(payload, "error") || !!nested && Object.hasOwn(nested, "error"));
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Inspect an already parsed, bounded error frame without copying or walking its body. */
|
|
687
|
+
export function codexAccountModelRefusalPayload(status: number, payload: unknown): CodexAccountModelRefusal {
|
|
688
|
+
if (status !== 400 || !payload || typeof payload !== "object" || Array.isArray(payload)) return "other";
|
|
689
|
+
if (Object.hasOwn(payload, "detail") && Object.hasOwn(payload, "error")) return "ambiguous";
|
|
690
|
+
const { detail, error } = payload as { detail?: unknown; error?: unknown };
|
|
691
|
+
const field = typeof detail === "string" ? detail
|
|
692
|
+
: error && typeof error === "object" && !Array.isArray(error)
|
|
693
|
+
&& typeof (error as Record<string, unknown>).message === "string"
|
|
694
|
+
? (error as { message: string }).message : undefined;
|
|
695
|
+
return field !== undefined && CODEX_ACCOUNT_MODEL_REFUSAL.test(field) ? "refusal" : "other";
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** One complete, bounded HTTP envelope; accept only a single exact status prefix. */
|
|
699
|
+
function parseCodexRefusalEnvelope(status: number, message: string): { text: string; payload: unknown } | undefined {
|
|
700
|
+
if (status !== 400 || message.length > 16_384) return undefined;
|
|
701
|
+
let text = message.trim();
|
|
702
|
+
if (text.startsWith("Provider error 400: ")) text = text.slice("Provider error 400: ".length);
|
|
703
|
+
let payload: unknown;
|
|
704
|
+
try { payload = JSON.parse(text); } catch { /* Bare refusal text is also a supported carrier. */ }
|
|
705
|
+
return { text, payload };
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** Only allowlisted hard stops cross the same HTTP boundary as positive refusal evidence. */
|
|
709
|
+
export function codexAccountModelRefusalHardStopCode(status: number, message: string): string | undefined {
|
|
710
|
+
const payload = parseCodexRefusalEnvelope(status, message)?.payload;
|
|
711
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
|
|
712
|
+
const root = payload as Record<string, unknown>;
|
|
713
|
+
const response = Object.hasOwn(root, "response") ? root.response : undefined;
|
|
714
|
+
const records = response && typeof response === "object" && !Array.isArray(response)
|
|
715
|
+
? [root, response as Record<string, unknown>] : [root];
|
|
716
|
+
for (const record of records) {
|
|
717
|
+
const error = record.error && typeof record.error === "object" && !Array.isArray(record.error)
|
|
718
|
+
? record.error as Record<string, unknown> : undefined;
|
|
719
|
+
// Diagnostic type is never code evidence, even when a code is absent or malformed.
|
|
720
|
+
for (const code of [error?.code, record.code]) {
|
|
721
|
+
if (typeof code !== "string") continue;
|
|
722
|
+
if (isNonReplayableUpstreamCode(code) || isCyberPolicyCode(code)) return code;
|
|
723
|
+
if (normalizedFailureCode(code) === "origin_rejected") return "origin_rejected";
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
return undefined;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** A bounded fallback signal, never credential or account-entitlement evidence. */
|
|
730
|
+
export function codexAccountModelRefusal(
|
|
731
|
+
status: number, message: string, options?: { allowNestedResponse?: boolean },
|
|
732
|
+
): CodexAccountModelRefusal {
|
|
733
|
+
const envelope = parseCodexRefusalEnvelope(status, message);
|
|
734
|
+
if (!envelope) return "other";
|
|
735
|
+
if (CODEX_ACCOUNT_MODEL_REFUSAL.test(envelope.text)) return "refusal";
|
|
736
|
+
const payload = envelope.payload;
|
|
737
|
+
if (hasConflictingCodexModelRefusalEnvelopes(status, payload)) return "ambiguous";
|
|
738
|
+
// A present root carrier stays authoritative, even when malformed or nonmatching.
|
|
739
|
+
if (options?.allowNestedResponse !== false && payload && typeof payload === "object" && !Array.isArray(payload)
|
|
740
|
+
&& !Object.hasOwn(payload, "detail") && !Object.hasOwn(payload, "error")
|
|
741
|
+
&& Object.hasOwn(payload, "response")) {
|
|
742
|
+
return codexAccountModelRefusalPayload(status, (payload as Record<string, unknown>).response);
|
|
743
|
+
}
|
|
744
|
+
return codexAccountModelRefusalPayload(status, payload);
|
|
745
|
+
}
|
|
746
|
+
|
|
673
747
|
export function comboFailureDecision(
|
|
674
748
|
status: number,
|
|
675
749
|
message: string,
|
|
676
|
-
options?: { code?: string | null },
|
|
750
|
+
options?: { code?: string | null; codexModelRefusal?: CodexAccountModelRefusal },
|
|
677
751
|
): ComboFailureDecision {
|
|
678
752
|
if (status === 499) return "stop";
|
|
679
753
|
if (message.toLowerCase().includes("origin_rejected")) return "stop";
|
|
@@ -688,6 +762,12 @@ export function comboFailureDecision(
|
|
|
688
762
|
// Cyber policy is a hard non-retryable refusal — honor structured code even when
|
|
689
763
|
// classificationText was truncated before the JSON code field.
|
|
690
764
|
if (isCyberPolicyCode(options?.code)) return "stop";
|
|
765
|
+
// HTTP consumers retain hard codes before truncation; SSE metadata owns its selected carrier.
|
|
766
|
+
if (options?.codexModelRefusal === undefined && codexAccountModelRefusalHardStopCode(status, message)) return "stop";
|
|
767
|
+
const modelRefusal = status === 400
|
|
768
|
+
? options?.codexModelRefusal ?? codexAccountModelRefusal(status, message) : "other";
|
|
769
|
+
// Competing envelopes cannot grant a hop through an earlier structured-code rule either.
|
|
770
|
+
if (modelRefusal === "ambiguous") return "stop";
|
|
691
771
|
// HTTP 410 is normally terminal. A model-specific lifecycle verdict is target-local,
|
|
692
772
|
// however: another provider/model in the declared combo can still serve the request.
|
|
693
773
|
// Require structured lifecycle code or explicit model+lifecycle prose so unrelated
|
|
@@ -738,6 +818,7 @@ export function comboFailureDecision(
|
|
|
738
818
|
if (["model_not_found", "model_unavailable", "unsupported_model"].includes(failureCode)) {
|
|
739
819
|
return "hop";
|
|
740
820
|
}
|
|
821
|
+
if (modelRefusal === "refusal") return "hop";
|
|
741
822
|
// `free_rate_limited` no longer routes through `isProviderScopedQuotaCap` (it is a
|
|
742
823
|
// per-request cap, not provider-wide evidence), so keep its hop verdict explicit here.
|
|
743
824
|
if (failureCode === "free_rate_limited") return "hop";
|
package/src/combos/jev.ts
CHANGED
|
@@ -127,6 +127,8 @@ export interface ResolveJevDecisionOptions {
|
|
|
127
127
|
* any other id names a configured `jev-decision` row (for example a self-hosted Ollama `tev1`).
|
|
128
128
|
*/
|
|
129
129
|
decisionProvider?: string;
|
|
130
|
+
/** Request-local authorization of the concrete decision destination, before credential access. */
|
|
131
|
+
isDestinationAllowed?: (providerName: string, modelId: string) => boolean;
|
|
130
132
|
/** Decision deadline; values outside 1000..120000 ms keep the four-second default. */
|
|
131
133
|
timeoutMs?: number;
|
|
132
134
|
signal?: AbortSignal;
|
|
@@ -674,14 +676,20 @@ function selfHostedApiKey(name: string, apiKey: string | undefined): string | un
|
|
|
674
676
|
* custom destination. Any other id must be an enabled `jev-decision` row whose baseUrl is a
|
|
675
677
|
* `/systemone` endpoint and which names its own model; only its own key may accompany it, so no
|
|
676
678
|
* TypeSafe credential can reach a self-hosted service. `undefined` means no usable decision
|
|
677
|
-
* service (reported through the existing `missing_key` gate)
|
|
679
|
+
* service (reported through the existing `missing_key` gate); `null` means the request's
|
|
680
|
+
* destination scope refused it before any credential access.
|
|
678
681
|
*/
|
|
679
|
-
function jevDecisionEndpoint(
|
|
682
|
+
function jevDecisionEndpoint(
|
|
683
|
+
config: OcxConfig,
|
|
684
|
+
decisionProvider: string,
|
|
685
|
+
isDestinationAllowed?: ResolveJevDecisionOptions["isDestinationAllowed"],
|
|
686
|
+
): JevDecisionEndpoint | undefined | null {
|
|
680
687
|
const configured = Object.hasOwn(config.providers, decisionProvider)
|
|
681
688
|
? config.providers[decisionProvider]
|
|
682
689
|
: undefined;
|
|
683
690
|
if (configured?.disabled === true) return undefined;
|
|
684
691
|
if (decisionProvider === JEV_PROVIDER_ID) {
|
|
692
|
+
if (isDestinationAllowed?.(JEV_PROVIDER_ID, JEV_MODEL) === false) return null;
|
|
685
693
|
const configuredOwnsJev = configured
|
|
686
694
|
&& providerMatchesRegistryTransport(JEV_PROVIDER_ID, configured);
|
|
687
695
|
const apiKey = (
|
|
@@ -704,6 +712,7 @@ function jevDecisionEndpoint(config: OcxConfig, decisionProvider: string): JevDe
|
|
|
704
712
|
// `jev-latest` is TypeSafe's model name; a self-hosted host must name its own.
|
|
705
713
|
const model = configured.defaultModel?.trim() || configured.models?.[0]?.trim();
|
|
706
714
|
if (!model) return undefined;
|
|
715
|
+
if (isDestinationAllowed?.(decisionProvider, model) === false) return null;
|
|
707
716
|
const apiKey = selfHostedApiKey(decisionProvider, configured.apiKey);
|
|
708
717
|
if (apiKey === null) return undefined;
|
|
709
718
|
return {
|
|
@@ -733,7 +742,8 @@ export async function resolveJevDecision(options: ResolveJevDecisionOptions): Pr
|
|
|
733
742
|
if (options.candidates.length === 0) return failed("no_choices");
|
|
734
743
|
if (!candidatesFitRequestBounds(options.candidates)) return failed("invalid");
|
|
735
744
|
|
|
736
|
-
const endpoint = jevDecisionEndpoint(options.config, options.decisionProvider ?? JEV_PROVIDER_ID);
|
|
745
|
+
const endpoint = jevDecisionEndpoint(options.config, options.decisionProvider ?? JEV_PROVIDER_ID, options.isDestinationAllowed);
|
|
746
|
+
if (endpoint === null) return failed("invalid");
|
|
737
747
|
if (!endpoint) return failed("missing_key");
|
|
738
748
|
|
|
739
749
|
let requestBody: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isSubagentModelEntry, rawSubagentModelForce } from "./subagent-models";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import { lstatSync, readFileSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
@@ -100,9 +101,11 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf
|
|
|
100
101
|
// the entire config, which would hide unrelated providers/accounts. The next
|
|
101
102
|
// ordinary save persists the normalized absence.
|
|
102
103
|
const syncDisabledReason = nativeSubagentSyncDisabledReason(config, rawParsed);
|
|
104
|
+
const rawForce = rawSubagentModelForce(rawParsed);
|
|
103
105
|
const rawEffort = rawClaudeSubagentEffort(rawParsed);
|
|
104
106
|
const normalized = normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, rawParsed), rawParsed);
|
|
105
107
|
const warnings = configPlaceholderWarnings(normalized);
|
|
108
|
+
if (rawForce !== undefined && !isSubagentModelEntry(rawForce)) warnings.push("claudeCode.subagentModelForce ignored: expected a safe roster-style model id");
|
|
106
109
|
if (normalized.chatgptDesktop?.appServerShim === true && process.platform !== "darwin") {
|
|
107
110
|
warnings.push("chatgptDesktop.appServerShim is experimental and macOS only; ignored on this platform");
|
|
108
111
|
}
|
|
@@ -652,6 +655,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
|
|
|
652
655
|
const boundaryError = blockedModelRedirectsError(value)
|
|
653
656
|
?? compactionRecoveryConfigError(value) ?? configReasoningPinsConfigError(value)
|
|
654
657
|
?? blankHostnameError(value)
|
|
658
|
+
?? (rawSubagentModelForce(value) !== undefined && !isSubagentModelEntry(rawSubagentModelForce(value)) ? "schema_invalid: claudeCode.subagentModelForce: expected a safe roster-style model id" : null)
|
|
655
659
|
?? claudeSubagentEffortError(value)
|
|
656
660
|
?? appOwnedMemoryBudgetError(value)
|
|
657
661
|
?? upstreamHostCircuitThresholdError(value)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isSubagentModelEntry, rawSubagentModelForce } from "./subagent-models";
|
|
1
2
|
import { chmodSync, existsSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { compactionRecoveryConfigError } from "./schema/compaction-recovery";
|
|
@@ -592,6 +593,7 @@ export function normalizePersistedClaudeCode(claudeCode: unknown): OcxConfig["cl
|
|
|
592
593
|
return claudeCode as OcxConfig["claudeCode"];
|
|
593
594
|
}
|
|
594
595
|
const normalized = { ...claudeCode } as Record<string, unknown>;
|
|
596
|
+
if (Object.hasOwn(normalized, "subagentModelForce") && !isSubagentModelEntry(normalized.subagentModelForce)) delete normalized.subagentModelForce;
|
|
595
597
|
// A malformed hand edit must not arm CLI interception or discard the whole config.
|
|
596
598
|
if (Object.hasOwn(normalized, "cliFirstParty") && typeof normalized.cliFirstParty !== "boolean") {
|
|
597
599
|
delete normalized.cliFirstParty;
|
|
@@ -634,6 +636,8 @@ export function normalizeClaudeSubagentEffort(config: OcxConfig, _rawParsed: unk
|
|
|
634
636
|
}
|
|
635
637
|
|
|
636
638
|
export function warnDegradedClaudeSubagentEffort(rawParsed: unknown): void {
|
|
639
|
+
const force = rawSubagentModelForce(rawParsed);
|
|
640
|
+
if (force !== undefined && !isSubagentModelEntry(force)) console.warn("⚠️ config.json claudeCode.subagentModelForce is invalid — ignoring it. Other settings were preserved.");
|
|
637
641
|
const rawEffort = rawClaudeSubagentEffort(rawParsed);
|
|
638
642
|
if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) {
|
|
639
643
|
console.warn(`⚠️ config.json claudeCode.subagentEffort is invalid (expected ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}) — ignoring it. Other settings were preserved.`);
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from "../providers/model-discovery-limits";
|
|
7
7
|
import { isDeclaredReasoningEffort, modelRecordValue } from "../reasoning-effort";
|
|
8
8
|
import { encodeRoutedModelId } from "../providers/slug-codec";
|
|
9
|
+
import { providerForwardClientHeadersConfigError } from "../lib/provider-client-headers";
|
|
9
10
|
import {
|
|
10
11
|
isWirePinnedModel,
|
|
11
12
|
MODEL_ADAPTER_OVERRIDE_ALLOWED,
|
|
@@ -125,6 +126,8 @@ export function providerHeadersConfigError(headers: unknown): string | null {
|
|
|
125
126
|
return null;
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
export { providerForwardClientHeadersConfigError };
|
|
130
|
+
|
|
128
131
|
/** Keep the configured API-key header style scoped to Anthropic-compatible key auth. */
|
|
129
132
|
export function apiKeyTransportConfigError(
|
|
130
133
|
provider: Pick<OcxProviderConfig, "adapter" | "authMode" | "apiKeyTransport">,
|
|
@@ -9,3 +9,11 @@
|
|
|
9
9
|
* with "Cannot access 'runtimeRoleSchema' before initialization".
|
|
10
10
|
*/
|
|
11
11
|
export const COMPACTION_TRIGGERS = ["manual", "auto"] as const;
|
|
12
|
+
|
|
13
|
+
/** Exact selectors or a provider-qualified trailing wildcard; never a global wildcard. */
|
|
14
|
+
export function validCompactionSourceModels(value: unknown): value is string[] {
|
|
15
|
+
return Array.isArray(value) && value.length > 0 && new Set(value).size === value.length
|
|
16
|
+
&& value.every(entry => typeof entry === "string" && entry.length > 0
|
|
17
|
+
&& entry === entry.trim() && !/\s/.test(entry)
|
|
18
|
+
&& (!entry.includes("*") || /^[^/*]+\/\*$/.test(entry)));
|
|
19
|
+
}
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
positiveIntegerRecordConfigError,
|
|
46
46
|
providerBaseUrlConfigError,
|
|
47
47
|
providerHeadersConfigError,
|
|
48
|
+
providerForwardClientHeadersConfigError,
|
|
48
49
|
reasoningSummaryDeliveryRecordConfigError,
|
|
49
50
|
} from "../provider-validation";
|
|
50
51
|
import {
|
|
@@ -506,6 +507,16 @@ export const configSchema = z.object({
|
|
|
506
507
|
message: headersError,
|
|
507
508
|
});
|
|
508
509
|
}
|
|
510
|
+
const forwardClientHeadersError = providerForwardClientHeadersConfigError(
|
|
511
|
+
(provider as { forwardClientHeaders?: unknown }).forwardClientHeaders,
|
|
512
|
+
);
|
|
513
|
+
if (forwardClientHeadersError) {
|
|
514
|
+
ctx.addIssue({
|
|
515
|
+
code: "custom",
|
|
516
|
+
path: ["providers", redactSecretString(name), "forwardClientHeaders"],
|
|
517
|
+
message: forwardClientHeadersError,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
509
520
|
const modelCostsError = providerModelCostsConfigError((provider as { modelCosts?: unknown }).modelCosts);
|
|
510
521
|
if (modelCostsError) {
|
|
511
522
|
ctx.addIssue({
|
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
isHostedToolUnsupportedForModel,
|
|
42
42
|
} from "../../responses/hosted-tool-policy";
|
|
43
43
|
import { getConfigDir } from "../paths";
|
|
44
|
-
import { COMPACTION_TRIGGERS } from "./compaction-triggers";
|
|
44
|
+
import { COMPACTION_TRIGGERS, validCompactionSourceModels } from "./compaction-triggers";
|
|
45
45
|
|
|
46
46
|
/** Experimental macOS ChatGPT app-server shim; only a strict optional boolean is accepted. */
|
|
47
47
|
export const chatgptDesktopSchema = z.object({ appServerShim: z.boolean().optional() }).strict();
|
|
@@ -53,6 +53,8 @@ export function isUsableApiKeySecret(value: unknown): value is string {
|
|
|
53
53
|
|
|
54
54
|
export const compactionRoutingSchema = z.object({
|
|
55
55
|
model: z.string().trim().min(1),
|
|
56
|
+
sourceModels: z.array(z.string()).refine(validCompactionSourceModels,
|
|
57
|
+
"sourceModels requires unique exact selectors or provider/* patterns").optional(),
|
|
56
58
|
reasoningEffort: z.string().refine(value => pinnedReasoningEffortConfigError(value) === null).optional(),
|
|
57
59
|
triggers: z.array(z.enum(COMPACTION_TRIGGERS)).nonempty()
|
|
58
60
|
.refine(values => new Set(values).size === values.length, "triggers must not repeat a value")
|
|
@@ -327,6 +329,8 @@ export const providerConfigSchema = z.object({
|
|
|
327
329
|
modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(),
|
|
328
330
|
modelSuppressSyntheticMax: z.record(z.string().min(1), z.boolean()).optional(),
|
|
329
331
|
preserveResponsesReasoningContent: z.boolean().optional(),
|
|
332
|
+
preserveResponsesInputItemIds: z.boolean().optional(),
|
|
333
|
+
preserveResponsesMessageMetadata: z.boolean().optional(),
|
|
330
334
|
dropResponsesReasoningItems: z.boolean().optional(),
|
|
331
335
|
modelReasoningEffortsAuthoritative: z.boolean().optional(),
|
|
332
336
|
decodesNativeCompactionBlobs: z.boolean().optional(),
|