@bitkyc08/opencodex 2.89.0 → 2.90.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-DgxkE6Md.js +58 -0
- package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-DbTNc_88.js} +1 -1
- package/gui/dist/assets/{index-uchD-ngf.js → index-TmaHa54u.js} +5 -5
- package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/base.ts +4 -0
- package/src/adapters/claude-cli/adapter.ts +13 -7
- package/src/adapters/claude-cli/usage-admission.ts +14 -2
- package/src/adapters/coding-agent/turn.ts +14 -4
- package/src/adapters/cursor/protobuf-request.ts +3 -0
- package/src/adapters/google-progress-history.ts +19 -1
- package/src/adapters/google.ts +28 -9
- package/src/adapters/image.ts +5 -3
- package/src/adapters/input-media-guard.ts +5 -2
- package/src/adapters/kiro-refusal.ts +41 -1
- package/src/adapters/ollama-native.ts +1 -0
- package/src/adapters/openai-chat/messages.ts +34 -4
- package/src/adapters/openai-chat/passthrough.ts +37 -4
- package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
- package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
- package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
- package/src/adapters/openai-chat.ts +7 -6
- package/src/adapters/openai-responses/opengateway-input.ts +139 -0
- package/src/adapters/openai-responses/passthrough.ts +5 -1
- package/src/adapters/openai-responses/transport.ts +13 -1
- package/src/claude/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +20 -10
- package/src/claude/desktop-picker-profile.ts +18 -9
- package/src/claude/desktop-remote-store-io.ts +2 -2
- package/src/cli/ensure-desktop-cold-start.ts +2 -2
- package/src/codex/account-lifecycle.ts +19 -6
- package/src/codex/account-store.ts +27 -8
- package/src/codex/account-usability.ts +4 -1
- package/src/codex/auth-api/account-list.ts +10 -5
- package/src/codex/auth-api/account-pause-group.ts +2 -22
- package/src/codex/auth-api/login-collision.ts +59 -0
- package/src/codex/auth-api/login-flow.ts +12 -7
- package/src/codex/auth-api/pool-mode-gate.ts +81 -13
- package/src/codex/auth-collision.ts +31 -4
- package/src/codex/auth-context.ts +48 -6
- package/src/codex/cli-update-plan.ts +18 -3
- package/src/codex/collision-identity.ts +78 -0
- package/src/codex/main-account-hard-lock.ts +14 -0
- package/src/codex/routing/cooldown-math.ts +5 -0
- package/src/codex/routing/failure-window.ts +61 -9
- package/src/codex/routing.ts +4 -3
- package/src/codex/shim-diagnostics.ts +2 -1
- package/src/codex/shim-fingerprint.ts +11 -2
- package/src/codex/shim-templates.ts +16 -5
- package/src/codex/shim-windows-refresh.ts +224 -0
- package/src/codex/shim.ts +45 -21
- package/src/combos/failover.ts +8 -1
- package/src/config/provider-validation.ts +7 -2
- package/src/generated/compatibility-version.json +168 -108
- package/src/generated/model-metadata.ts +1 -1
- package/src/images/loop.ts +2 -2
- package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
- package/src/lib/provider-admission-wait.ts +14 -0
- package/src/lib/service-secrets.ts +37 -2
- package/src/lib/translator-budget.ts +1 -1
- package/src/lib/upstream-retry.ts +15 -0
- package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
- package/src/oauth/account-import/parser.ts +15 -2
- package/src/oauth/account-import/types.ts +7 -0
- package/src/oauth/chatgpt-shared.ts +10 -1
- package/src/oauth/dsh.ts +45 -2
- package/src/oauth/kiro.ts +44 -1
- package/src/oauth/login-flow-state.ts +11 -2
- package/src/providers/claude-cli-usage.ts +20 -5
- package/src/providers/initial-model-selection.ts +2 -2
- package/src/providers/key-failover.ts +10 -2
- package/src/providers/key-store.ts +8 -3
- package/src/providers/mistral-transport.ts +207 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/quota/anthropic-account-quota.ts +18 -6
- package/src/providers/quota/antigravity.ts +6 -2
- package/src/providers/quota/failure-codes.ts +18 -0
- package/src/providers/quota/vendor-probes-oauth.ts +12 -3
- package/src/providers/quota-routing-cache.ts +1 -1
- package/src/providers/quota.ts +5 -1
- package/src/providers/registry/entries-extended.ts +6 -1
- package/src/providers/routed-config.ts +1 -1
- package/src/providers/xai-transport.ts +2 -1
- package/src/responses/audio.ts +46 -0
- package/src/responses/input-media.ts +37 -15
- package/src/responses/parser-content.ts +22 -27
- package/src/responses/reasoning-retention.ts +218 -11
- package/src/responses/schema.ts +3 -4
- package/src/responses/web-search-replay.ts +11 -2
- package/src/server/chat-native.ts +8 -7
- package/src/server/index/serve-options.ts +8 -2
- package/src/server/index/websocket-handler.ts +10 -0
- package/src/server/lifecycle.ts +17 -1
- package/src/server/management/oauth-account-routes.ts +2 -1
- package/src/server/messages-native.ts +11 -0
- package/src/server/request-log.ts +16 -7
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +2 -2
- package/src/server/responses/codex-ws-exchange.ts +3 -0
- package/src/server/responses/codex-ws-metadata.ts +44 -0
- package/src/server/responses/codex-ws-session.ts +21 -1
- package/src/server/responses/combo-stream-preflight.ts +7 -2
- package/src/server/responses/compact.ts +2 -0
- package/src/server/responses/core-auth.ts +20 -0
- package/src/server/responses/core-codex-account.ts +21 -14
- package/src/server/responses/core-combo.ts +3 -0
- package/src/server/responses/core-opaque-recovery.ts +20 -11
- package/src/server/responses/deepseek-image-files.ts +121 -0
- package/src/server/responses/empty-completion-guard.ts +8 -0
- package/src/server/responses/fetch-helpers.ts +30 -10
- package/src/server/responses/input-admission.ts +6 -2
- package/src/server/responses/main-hard-lock-routing.ts +33 -8
- package/src/server/responses/opengateway-completion-guard.ts +232 -0
- package/src/server/responses/passthrough-completion.ts +41 -0
- package/src/server/responses/passthrough-delivery.ts +2 -0
- package/src/server/responses/passthrough-dispatch.ts +13 -13
- package/src/server/responses/passthrough-key-rotation.ts +40 -0
- package/src/server/responses/reasoning-archive-error.ts +21 -0
- package/src/server/responses/request-prepare.ts +11 -1
- package/src/server/responses/request-transport.ts +61 -3
- package/src/service/orchestration.ts +17 -0
- package/src/types/provider.ts +2 -0
- package/src/types/request.ts +16 -1
- package/src/types.ts +1 -0
- package/src/web-search/loop.ts +2 -2
- package/gui/dist/assets/App-BnRSujOq.js +0 -58
- package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
package/src/codex/routing.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
failureWindowSteersNewThreads,
|
|
24
24
|
forgetCodexFailureWindow,
|
|
25
25
|
forgetCodexFailureWindows,
|
|
26
|
-
|
|
26
|
+
noteCodexFailureWindowOutcome,
|
|
27
27
|
settleCodexSuccessStreak,
|
|
28
28
|
} from "./routing/failure-window";
|
|
29
29
|
|
|
@@ -220,6 +220,7 @@ export {
|
|
|
220
220
|
isEffectiveCodexAccountPinned,
|
|
221
221
|
} from "./routing/active-account";
|
|
222
222
|
export { codexAccountPinDrainReason } from "./routing/pin-drain";
|
|
223
|
+
export { codexFailureWindowOutcomeMeta } from "./routing/failure-window";
|
|
223
224
|
export type { CodexPinDrainReason } from "./routing/pin-drain";
|
|
224
225
|
|
|
225
226
|
// A shared refresh can outlive the request that opened it. Register the affinity
|
|
@@ -1305,7 +1306,7 @@ export function recordCodexUpstreamOutcome(
|
|
|
1305
1306
|
*/
|
|
1306
1307
|
dropSpentCredentialFailure(accountId);
|
|
1307
1308
|
if (outcomeClass === "success") {
|
|
1308
|
-
|
|
1309
|
+
noteCodexFailureWindowOutcome(config, accountId, false, now, meta);
|
|
1309
1310
|
// The operator's one-shot is spent by a dispatch that actually worked, and only by that.
|
|
1310
1311
|
// A failed lookup leaves it unspent so the intent survives the failure.
|
|
1311
1312
|
consumeManualPreference(accountId, codexPoolKeyForScope(quotaScope));
|
|
@@ -1555,7 +1556,7 @@ export function recordCodexUpstreamOutcome(
|
|
|
1555
1556
|
// A transient failure concludes an owning probe; an unrelated 5xx must not
|
|
1556
1557
|
// consume someone else's live lease or drop hard-cooldown bookkeeping (#433).
|
|
1557
1558
|
const transientBase = ownsProbeLease(current, meta) ? withProbeLeaseReleased(current!, now) : current;
|
|
1558
|
-
|
|
1559
|
+
noteCodexFailureWindowOutcome(config, accountId, true, now, meta);
|
|
1559
1560
|
const stale = current?.lastFailureAt ? now - current.lastFailureAt > CODEX_FAILURE_WINDOW_MS : false;
|
|
1560
1561
|
const hardCooldownUntil = getCodexAccountCooldownUntil(accountId, now) ?? undefined;
|
|
1561
1562
|
// Soft avoid + affinity clears are part of failover. When threshold is 0, leave
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
2
|
-
import { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER } from "./shim-templates";
|
|
2
|
+
import { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER, WINDOWS_SHIM_REVISION_MARKER } from "./shim-templates";
|
|
3
3
|
import { readStateResult, stateFiles, statePath } from "./shim-state-file";
|
|
4
4
|
import { overlayDiagnostic } from "./shim-overlay";
|
|
5
5
|
|
|
@@ -16,6 +16,7 @@ function isHealthyShim(path: string, platform: NodeJS.Platform): boolean {
|
|
|
16
16
|
const content = readFileSync(path, "utf8");
|
|
17
17
|
if (content.length < 180 || !content.includes(SHIM_MARKER) || !content.includes("ensure")) return false;
|
|
18
18
|
if (platform !== "win32" && !content.includes(UNIX_SHIM_REVISION_MARKER)) return false;
|
|
19
|
+
if (platform === "win32" && !content.includes(WINDOWS_SHIM_REVISION_MARKER) && !content.includes(UNIX_SHIM_REVISION_MARKER)) return false;
|
|
19
20
|
if (platform !== "win32" && (lstatSync(path).mode & 0o111) === 0) return false;
|
|
20
21
|
return true;
|
|
21
22
|
} catch {
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
unlinkSync,
|
|
14
14
|
} from "node:fs";
|
|
15
15
|
import { posix, win32 } from "node:path";
|
|
16
|
-
import { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER } from "./shim-templates";
|
|
16
|
+
import { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER, WINDOWS_SHIM_REVISION_MARKER } from "./shim-templates";
|
|
17
17
|
import type { ShimFileState } from "./shim-state-file";
|
|
18
18
|
|
|
19
19
|
const CODEX_SHIM_PROBE_BYTES = 16 * 1024;
|
|
@@ -188,6 +188,15 @@ function isCurrentUnixShimProbe(probe: StableShimPathProbe): boolean {
|
|
|
188
188
|
return probe.prefix.includes(UNIX_SHIM_REVISION_MARKER);
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
/** A Windows wrapper is current when it carries its flavor's revision (CMD/PowerShell, or Git-Bash's Unix one). */
|
|
192
|
+
function isCurrentWindowsShimProbe(probe: StableShimPathProbe): boolean {
|
|
193
|
+
return probe.prefix.includes(WINDOWS_SHIM_REVISION_MARKER) || probe.prefix.includes(UNIX_SHIM_REVISION_MARKER);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function isCurrentShimProbe(probe: StableShimPathProbe, platform: NodeJS.Platform): boolean {
|
|
197
|
+
return platform === "win32" ? isCurrentWindowsShimProbe(probe) : isCurrentUnixShimProbe(probe);
|
|
198
|
+
}
|
|
199
|
+
|
|
191
200
|
function hasUsableBackingPath(file: ShimFileState): boolean {
|
|
192
201
|
return [existsSync(file.backupPath) ? file.backupPath : undefined, file.realPath]
|
|
193
202
|
.some(path => {
|
|
@@ -225,4 +234,4 @@ export function isVersionManagerOwnedCodexPath(
|
|
|
225
234
|
}
|
|
226
235
|
|
|
227
236
|
export type { ShimPathFingerprint, StableShimPathProbe };
|
|
228
|
-
export { statFingerprint, sameFingerprint, sameFingerprintAfterRename, stableShimPathProbe, sameStableShimPathProbe, shimPathFingerprint, restoreWithoutReplacing, isHealthyShimProbe, isCurrentUnixShimProbe, hasUsableBackingPath };
|
|
237
|
+
export { statFingerprint, sameFingerprint, sameFingerprintAfterRename, stableShimPathProbe, sameStableShimPathProbe, shimPathFingerprint, restoreWithoutReplacing, isHealthyShimProbe, isCurrentUnixShimProbe, isCurrentShimProbe, hasUsableBackingPath };
|
|
@@ -5,7 +5,14 @@ import { selfLaunchArgv } from "../lib/self-launch-argv";
|
|
|
5
5
|
import { windowsEnvIndirectBatchValue } from "../lib/win-paths";
|
|
6
6
|
|
|
7
7
|
const SHIM_MARKER = "opencodex codex autostart shim";
|
|
8
|
-
|
|
8
|
+
// Revision 4: OCX_SHIM_BYPASS skips loading the service API token. Bumping the marker
|
|
9
|
+
// makes installed revision-3 wrappers obsolete so the existing refresh path rewrites them.
|
|
10
|
+
const UNIX_SHIM_REVISION_MARKER = "opencodex unix codex shim revision 4";
|
|
11
|
+
// Windows CMD and PowerShell wrappers carry their own revision. Revision 1 is the first one that
|
|
12
|
+
// skips the service token under OCX_SHIM_BYPASS; a marked wrapper without it is obsolete and
|
|
13
|
+
// install/auto-restore rewrite it in place. (The extensionless Git-Bash wrapper is the Unix
|
|
14
|
+
// template and carries the Unix revision.)
|
|
15
|
+
const WINDOWS_SHIM_REVISION_MARKER = "opencodex windows codex shim revision 1";
|
|
9
16
|
|
|
10
17
|
const CODEX_SHIM_REENTRY_EXIT_CODE = 126;
|
|
11
18
|
const CODEX_SHIM_REENTRY_DIAGNOSTIC = "opencodex: saved Codex launcher resolved back to the autostart shim; run ocx codex-shim uninstall and reinstall Codex before enabling codexAutoStart.";
|
|
@@ -122,7 +129,9 @@ esac
|
|
|
122
129
|
# redispatch reaches depth 2 and is rejected before it can form an infinite chain.
|
|
123
130
|
OCX_SHIM_ACTIVE_PID=$$
|
|
124
131
|
export OCX_SHIM_ACTIVE_PID OCX_SHIM_ACTIVE_DEPTH
|
|
125
|
-
|
|
132
|
+
# A bypassed launch is native passthrough: it never talks to this proxy, so it must not
|
|
133
|
+
# hand the proxy's data-plane token to the real Codex and everything Codex spawns.
|
|
134
|
+
if [ -z "$OCX_SHIM_BYPASS" ] && [ -z "$OPENCODEX_API_AUTH_TOKEN" ] && [ -f ${shQuote(tokenFile)} ]; then
|
|
126
135
|
OPENCODEX_API_AUTH_TOKEN="$(cat ${shQuote(tokenFile)})"
|
|
127
136
|
export OPENCODEX_API_AUTH_TOKEN
|
|
128
137
|
fi
|
|
@@ -190,13 +199,14 @@ export function buildWindowsCodexShim(realCodexPath: string, bunPath: string, cl
|
|
|
190
199
|
const cliArgs = selfLaunchArgv([], { sourceEntrypoint: cliPath, isStandaloneExecutable: bunRuntimeSource === "standalone" });
|
|
191
200
|
return `@echo off\r
|
|
192
201
|
rem ${SHIM_MARKER}\r
|
|
202
|
+
rem ${WINDOWS_SHIM_REVISION_MARKER}\r
|
|
193
203
|
setlocal\r
|
|
194
204
|
${windowsBatchSet("OCX_REAL_CODEX", realCodexPath)}\r
|
|
195
205
|
${windowsBatchSet("OCX_BUN", bunPath)}\r
|
|
196
206
|
${cliArgs.length ? windowsBatchSet("OCX_CLI", cliArgs[0]!) : ""}\r
|
|
197
207
|
${windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath())}\r
|
|
198
|
-
if "%OPENCODEX_API_AUTH_TOKEN%"=="" if exist "%OCX_API_TOKEN_FILE%" set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"\r
|
|
199
208
|
if not "%OCX_SHIM_BYPASS%"=="" goto run_codex\r
|
|
209
|
+
if "%OPENCODEX_API_AUTH_TOKEN%"=="" if exist "%OCX_API_TOKEN_FILE%" set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"\r
|
|
200
210
|
goto scan_codex_args\r
|
|
201
211
|
:scan_codex_args\r
|
|
202
212
|
if "%~1"=="" goto ensure_ocx\r
|
|
@@ -240,10 +250,11 @@ export function buildWindowsPowerShellCodexShim(realCodexPath: string, bunPath:
|
|
|
240
250
|
.map(arg => `${psString(arg)} `).join("");
|
|
241
251
|
return `#!/usr/bin/env pwsh
|
|
242
252
|
# ${SHIM_MARKER}
|
|
253
|
+
# ${WINDOWS_SHIM_REVISION_MARKER}
|
|
243
254
|
$hadApiAuthToken = Test-Path Env:\\OPENCODEX_API_AUTH_TOKEN
|
|
244
255
|
$priorApiAuthToken = $env:OPENCODEX_API_AUTH_TOKEN
|
|
245
256
|
try {
|
|
246
|
-
if (-not $env:OPENCODEX_API_AUTH_TOKEN -and (Test-Path -LiteralPath ${psString(tokenFile)})) {
|
|
257
|
+
if (-not $env:OCX_SHIM_BYPASS -and -not $env:OPENCODEX_API_AUTH_TOKEN -and (Test-Path -LiteralPath ${psString(tokenFile)})) {
|
|
247
258
|
$env:OPENCODEX_API_AUTH_TOKEN = (Get-Content -Raw -LiteralPath ${psString(tokenFile)}).Trim()
|
|
248
259
|
}
|
|
249
260
|
$internalCommands = @(${internalCommands})
|
|
@@ -294,4 +305,4 @@ function gitBashPath(path: string): string {
|
|
|
294
305
|
return path.replace(/\\/g, "/");
|
|
295
306
|
}
|
|
296
307
|
|
|
297
|
-
export { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER, CODEX_SHIM_REENTRY_EXIT_CODE, CODEX_SHIM_REENTRY_DIAGNOSTIC, shQuote, windowsBatchSet, psString, gitBashPath };
|
|
308
|
+
export { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER, WINDOWS_SHIM_REVISION_MARKER, CODEX_SHIM_REENTRY_EXIT_CODE, CODEX_SHIM_REENTRY_DIAGNOSTIC, shQuote, windowsBatchSet, psString, gitBashPath };
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, existsSync, fstatSync, linkSync, lstatSync, openSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, extname, join } from "node:path";
|
|
4
|
+
import { sameFingerprintAfterRename, sameStableShimPathProbe, shimPathFingerprint, stableShimPathProbe, type StableShimPathProbe } from "./shim-fingerprint";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Identity of a directory entry: device, inode and whether the entry is itself a symbolic link.
|
|
8
|
+
*
|
|
9
|
+
* dev/ino alone is not an identity on Linux: ext4 and tmpfs hand a freed inode number straight to the
|
|
10
|
+
* next file or link created, so a stage unlinked and replaced by a link (or a new file) can carry the
|
|
11
|
+
* very numbers this refresh recorded. Every identity this module compares therefore belongs to a file
|
|
12
|
+
* it still holds open (an open inode cannot be freed, so its number cannot be reused), and the link
|
|
13
|
+
* flag is compared too, so a symlink never matches a regular file whatever the platform's link(2)
|
|
14
|
+
* does with a symlink source.
|
|
15
|
+
*/
|
|
16
|
+
interface EntryIdentity { dev: bigint; ino: bigint; link: boolean }
|
|
17
|
+
|
|
18
|
+
/** Identity of the directory entry itself (a link is its own entry); null when absent. */
|
|
19
|
+
function entryIdentity(path: string): EntryIdentity | null {
|
|
20
|
+
try {
|
|
21
|
+
const entry = lstatSync(path, { bigint: true });
|
|
22
|
+
return { dev: entry.dev, ino: entry.ino, link: entry.isSymbolicLink() };
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Identity of what a path resolves to; null when absent or dangling. */
|
|
29
|
+
function targetIdentity(path: string): EntryIdentity | null {
|
|
30
|
+
try {
|
|
31
|
+
const target = statSync(path, { bigint: true });
|
|
32
|
+
return { dev: target.dev, ino: target.ino, link: false };
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const same = (left: EntryIdentity | null, right: EntryIdentity | null): boolean =>
|
|
39
|
+
!!left && !!right && left.dev === right.dev && left.ino === right.ino && left.link === right.link;
|
|
40
|
+
|
|
41
|
+
/** The entry at `path` is exactly `identity` (same inode, same link-ness). */
|
|
42
|
+
function entryIs(path: string, identity: EntryIdentity): boolean {
|
|
43
|
+
return same(entryIdentity(path), identity);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** An open regular file and its identity; the descriptor pins the inode for as long as it is open. */
|
|
47
|
+
interface HeldFile { fd: number; identity: EntryIdentity }
|
|
48
|
+
|
|
49
|
+
function release(held: HeldFile | null): void {
|
|
50
|
+
if (held) { try { closeSync(held.fd); } catch { /* already closed */ } }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Create `path` exclusively (never through an existing entry or link), write it, and keep it open. */
|
|
54
|
+
function createExclusive(path: string, content: string): HeldFile {
|
|
55
|
+
const flags = constants.O_RDWR | constants.O_CREAT | constants.O_EXCL
|
|
56
|
+
| (process.platform === "win32" ? 0 : constants.O_NOFOLLOW);
|
|
57
|
+
const fd = openSync(path, flags, 0o644);
|
|
58
|
+
try {
|
|
59
|
+
writeFileSync(fd, content, "utf8");
|
|
60
|
+
const stat = fstatSync(fd, { bigint: true });
|
|
61
|
+
return { fd, identity: { dev: stat.dev, ino: stat.ino, link: false } };
|
|
62
|
+
} catch (error) {
|
|
63
|
+
try { closeSync(fd); } catch { /* already closed */ }
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Open the regular file at `path` without following a link and confirm the entry is the opened file. */
|
|
69
|
+
function holdExisting(path: string): HeldFile | null {
|
|
70
|
+
const entry = entryIdentity(path);
|
|
71
|
+
if (!entry || entry.link) return null;
|
|
72
|
+
const flags = process.platform === "win32"
|
|
73
|
+
? constants.O_RDONLY
|
|
74
|
+
: constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK;
|
|
75
|
+
let fd: number;
|
|
76
|
+
try { fd = openSync(path, flags); } catch { return null; }
|
|
77
|
+
try {
|
|
78
|
+
const stat = fstatSync(fd, { bigint: true });
|
|
79
|
+
const identity: EntryIdentity = { dev: stat.dev, ino: stat.ino, link: false };
|
|
80
|
+
if (stat.isFile() && same(identity, entry) && entryIs(path, identity)) return { fd, identity };
|
|
81
|
+
} catch { /* unreadable descriptor: treated like a mismatch */ }
|
|
82
|
+
// Every unsuccessful path releases the descriptor; the caller then hands the moved file back.
|
|
83
|
+
try { closeSync(fd); } catch { /* already closed */ }
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Hard-link `source` onto the empty `destination` and keep the result only if the published entry
|
|
89
|
+
* is exactly `expected`. If `source` was swapped (for another file or a link) between the caller's
|
|
90
|
+
* check and the link, the entry this call created is withdrawn; an entry that matches neither the
|
|
91
|
+
* source nor its target belongs to an independent later publisher and stays.
|
|
92
|
+
*/
|
|
93
|
+
function linkExpected(source: string, destination: string, expected: EntryIdentity): "published" | "occupied" | "refused" {
|
|
94
|
+
try {
|
|
95
|
+
linkSync(source, destination);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
return (error as NodeJS.ErrnoException).code === "EEXIST" ? "occupied" : "refused";
|
|
98
|
+
}
|
|
99
|
+
const published = entryIdentity(destination);
|
|
100
|
+
if (same(published, expected)) return "published";
|
|
101
|
+
if (published && (same(published, entryIdentity(source)) || same(published, targetIdentity(source)))) {
|
|
102
|
+
try { unlinkSync(destination); } catch { /* nothing to withdraw */ }
|
|
103
|
+
}
|
|
104
|
+
return "refused";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Whether `directory` supports hard links, proven with a throwaway pair before anything is displaced. */
|
|
108
|
+
function hardLinksWork(directory: string): boolean {
|
|
109
|
+
const probe = join(directory, `.ocx-link-probe-${randomUUID()}`);
|
|
110
|
+
const linked = `${probe}-linked`;
|
|
111
|
+
let held: HeldFile;
|
|
112
|
+
try { held = createExclusive(probe, ""); } catch { return false; }
|
|
113
|
+
try {
|
|
114
|
+
linkSync(probe, linked);
|
|
115
|
+
return entryIs(linked, held.identity);
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
} finally {
|
|
119
|
+
for (const path of [linked, probe]) if (entryIs(path, held.identity)) { try { unlinkSync(path); } catch { /* best effort */ } }
|
|
120
|
+
release(held);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Replace an owned, obsolete Windows wrapper without ever truncating, replacing or writing through
|
|
126
|
+
* a file someone else published (an npm or version-manager install racing this refresh, or an entry
|
|
127
|
+
* planted beside the wrapper).
|
|
128
|
+
*
|
|
129
|
+
* 1. Move the observed wrapper aside, open it, and prove the moved file is the one that was probed;
|
|
130
|
+
* a different file means a concurrent replacement, which is put back untouched.
|
|
131
|
+
* 2. Create the stage exclusively under an unpredictable name and keep it open.
|
|
132
|
+
* 3. Publish by hard link onto the empty path, keeping the result only if it is that stage file.
|
|
133
|
+
*
|
|
134
|
+
* Every restore, withdrawal and cleanup is bound to the identity of a file this refresh holds open:
|
|
135
|
+
* only the stage it created and only the moved copy of the observed wrapper are ever published or
|
|
136
|
+
* unlinked. Returns true only when the current wrapper was published; a filesystem that refuses hard
|
|
137
|
+
* links leaves the obsolete wrapper restored and the refresh deferred.
|
|
138
|
+
*/
|
|
139
|
+
export function refreshObsoleteWindowsShim(
|
|
140
|
+
wrapperPath: string,
|
|
141
|
+
observed: StableShimPathProbe,
|
|
142
|
+
render: () => string,
|
|
143
|
+
/** Test seam only: a fixed stage path, so a planted entry can be placed where the stage goes. */
|
|
144
|
+
stagedPathForTests?: string,
|
|
145
|
+
): boolean {
|
|
146
|
+
const extension = extname(wrapperPath);
|
|
147
|
+
const aside = `${wrapperPath}.ocx-obsolete-${randomUUID()}${extension}`;
|
|
148
|
+
const staged = stagedPathForTests ?? `${wrapperPath}.ocx-refresh-${randomUUID()}${extension}`;
|
|
149
|
+
let moved: HeldFile | null = null;
|
|
150
|
+
let stage: HeldFile | null = null;
|
|
151
|
+
const discard = (path: string, held: HeldFile | null): void => {
|
|
152
|
+
if (held && entryIs(path, held.identity)) { try { unlinkSync(path); } catch { /* already gone */ } }
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* Put the moved wrapper back only onto an empty path, and only while the aside is still it. If the
|
|
156
|
+
* link fails (hard links became unavailable mid-refresh), an identity-checked rename keeps the
|
|
157
|
+
* launcher from staying missing.
|
|
158
|
+
*/
|
|
159
|
+
const restoreAside = (): void => {
|
|
160
|
+
if (!moved || existsSync(wrapperPath) || !entryIs(aside, moved.identity)) return;
|
|
161
|
+
if (linkExpected(aside, wrapperPath, moved.identity) === "published") { discard(aside, moved); return; }
|
|
162
|
+
if (!existsSync(wrapperPath) && entryIs(aside, moved.identity)) { try { renameSync(aside, wrapperPath); } catch { /* the aside copy stays recoverable */ } }
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Never displace a working wrapper on a filesystem that cannot publish its replacement.
|
|
166
|
+
if (!hardLinksWork(dirname(wrapperPath))) return false;
|
|
167
|
+
try { renameSync(wrapperPath, aside); } catch { return false; }
|
|
168
|
+
try {
|
|
169
|
+
const movedFingerprint = shimPathFingerprint(aside);
|
|
170
|
+
moved = holdExisting(aside);
|
|
171
|
+
if (!moved || !movedFingerprint || !sameFingerprintAfterRename(observed.fingerprint, movedFingerprint)) {
|
|
172
|
+
// Not the observed wrapper: hand the path back to whatever was moved, untouched.
|
|
173
|
+
const foreign = entryIdentity(aside);
|
|
174
|
+
if (foreign && !existsSync(wrapperPath) && linkExpected(aside, wrapperPath, foreign) === "published" && entryIs(aside, foreign)) {
|
|
175
|
+
try { unlinkSync(aside); } catch { /* the restored link keeps the content */ }
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
stage = createExclusive(staged, render());
|
|
181
|
+
} catch (error) {
|
|
182
|
+
restoreAside();
|
|
183
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
184
|
+
if (code === "EEXIST" || code === "ELOOP") return false;
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
if (!entryIs(staged, stage.identity)) {
|
|
188
|
+
restoreAside();
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
const outcome = linkExpected(staged, wrapperPath, stage.identity);
|
|
192
|
+
discard(staged, stage);
|
|
193
|
+
if (outcome === "published") {
|
|
194
|
+
discard(aside, moved);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
// Occupied: a concurrent publisher owns the path now; only our obsolete copy is discarded.
|
|
198
|
+
if (outcome === "occupied") discard(aside, moved);
|
|
199
|
+
else restoreAside();
|
|
200
|
+
return false;
|
|
201
|
+
} finally {
|
|
202
|
+
release(stage);
|
|
203
|
+
release(moved);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Automatic Windows revision recovery: refresh exactly the admitted obsolete wrappers, and only
|
|
209
|
+
* while every current sibling is still the file observed before the stability wait. It never
|
|
210
|
+
* adopts a replacement launcher or rewrites a backup; anything unexpected defers.
|
|
211
|
+
*/
|
|
212
|
+
export function refreshAdmittedWindowsShims(
|
|
213
|
+
obsolete: ReadonlyMap<string, StableShimPathProbe>,
|
|
214
|
+
current: ReadonlyMap<string, StableShimPathProbe>,
|
|
215
|
+
render: (wrapperPath: string) => string,
|
|
216
|
+
): boolean {
|
|
217
|
+
for (const [path, first] of current) {
|
|
218
|
+
const second = stableShimPathProbe(path);
|
|
219
|
+
if (!second || !sameStableShimPathProbe(first, second)) return false;
|
|
220
|
+
}
|
|
221
|
+
let all = true;
|
|
222
|
+
for (const [path, observed] of obsolete) all = refreshObsoleteWindowsShim(path, observed, () => render(path)) && all;
|
|
223
|
+
return all;
|
|
224
|
+
}
|
package/src/codex/shim.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
} from "./shim-templates";
|
|
27
27
|
import {
|
|
28
28
|
hasUsableBackingPath,
|
|
29
|
+
isCurrentShimProbe,
|
|
29
30
|
isCurrentUnixShimProbe,
|
|
30
31
|
isHealthyShimProbe,
|
|
31
32
|
isVersionManagerOwnedCodexPath,
|
|
@@ -55,6 +56,7 @@ import {
|
|
|
55
56
|
type UnixShimProbeResult,
|
|
56
57
|
} from "./shim-probe";
|
|
57
58
|
import { tryAcquireShimRestoreLock } from "./shim-restore-lock";
|
|
59
|
+
import { refreshAdmittedWindowsShims, refreshObsoleteWindowsShim } from "./shim-windows-refresh";
|
|
58
60
|
import {
|
|
59
61
|
isWindowsInteropDir,
|
|
60
62
|
isExecutableCodexCandidate,
|
|
@@ -348,24 +350,22 @@ function rollbackFreshShimInstall(journal: readonly FreshShimInstallJournalEntry
|
|
|
348
350
|
* updater's wrapper can carry them, and a replacement landing between the write
|
|
349
351
|
* and the observation is otherwise indistinguishable from our own file.
|
|
350
352
|
*/
|
|
353
|
+
/** The Windows wrapper for `wrapperPath`'s flavor (.ps1, .cmd/.bat, or extensionless Git-Bash sh). */
|
|
354
|
+
function windowsShimContent(wrapperPath: string, realCodexPath: string, runtime?: DurableBunRuntime): string {
|
|
355
|
+
const { bun, bunRuntimeSource, cli } = cliEntry(runtime);
|
|
356
|
+
const lower = wrapperPath.toLowerCase();
|
|
357
|
+
// UTF-8 BOM: Windows PowerShell 5.1 decodes BOM-less .ps1 files in the ANSI
|
|
358
|
+
// codepage, which mangles non-ASCII paths embedded in the shim.
|
|
359
|
+
if (lower.endsWith(".ps1")) return `\uFEFF${buildWindowsPowerShellCodexShim(realCodexPath, bun, cli, bunRuntimeSource)}`;
|
|
360
|
+
if (lower.endsWith(".cmd") || lower.endsWith(".bat")) return buildWindowsCodexShim(realCodexPath, bun, cli, bunRuntimeSource);
|
|
361
|
+
// Extensionless Git-Bash sh launcher: sh shim with forward-slash paths.
|
|
362
|
+
return buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), bunRuntimeSource, gitBashPath(serviceApiTokenFilePath()));
|
|
363
|
+
}
|
|
364
|
+
|
|
351
365
|
function writeShim(wrapperPath: string, realCodexPath: string, runtime?: DurableBunRuntime): { dev: number; ino: number } | undefined {
|
|
352
366
|
const { bun, bunRuntimeSource, cli } = cliEntry(runtime);
|
|
353
367
|
if (process.platform === "win32") {
|
|
354
|
-
|
|
355
|
-
if (lower.endsWith(".ps1")) {
|
|
356
|
-
// UTF-8 BOM: Windows PowerShell 5.1 decodes BOM-less .ps1 files in the ANSI
|
|
357
|
-
// codepage, which mangles non-ASCII paths embedded in the shim.
|
|
358
|
-
writeFileSync(wrapperPath, `\uFEFF${buildWindowsPowerShellCodexShim(realCodexPath, bun, cli, bunRuntimeSource)}`, "utf8");
|
|
359
|
-
} else if (lower.endsWith(".cmd") || lower.endsWith(".bat")) {
|
|
360
|
-
writeFileSync(wrapperPath, buildWindowsCodexShim(realCodexPath, bun, cli, bunRuntimeSource), "utf8");
|
|
361
|
-
} else {
|
|
362
|
-
// Extensionless Git-Bash sh launcher: sh shim with forward-slash paths.
|
|
363
|
-
writeFileSync(
|
|
364
|
-
wrapperPath,
|
|
365
|
-
buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), bunRuntimeSource, gitBashPath(serviceApiTokenFilePath())),
|
|
366
|
-
"utf8",
|
|
367
|
-
);
|
|
368
|
-
}
|
|
368
|
+
writeFileSync(wrapperPath, windowsShimContent(wrapperPath, realCodexPath, runtime), "utf8");
|
|
369
369
|
return undefined;
|
|
370
370
|
} else {
|
|
371
371
|
// Stage the wrapper as its own inode and rename it into place, so ownership
|
|
@@ -509,6 +509,15 @@ function refreshShimFile(file: ShimFileState, runtime: DurableBunRuntime): boole
|
|
|
509
509
|
writeShim(file.wrapperPath, file.realPath ?? file.backupPath, runtime);
|
|
510
510
|
return true;
|
|
511
511
|
}
|
|
512
|
+
// An owned Windows wrapper from an older template revision (e.g. one that loads the service
|
|
513
|
+
// token before honoring OCX_SHIM_BYPASS) is replaced over the same backing launcher through an
|
|
514
|
+
// ownership-checked stage-and-link transaction that never clobbers a concurrent replacement.
|
|
515
|
+
if (process.platform === "win32" && existsSync(file.wrapperPath) && isShim(file.wrapperPath) && hasUsableBackingPath(file)) {
|
|
516
|
+
const probe = stableShimPathProbe(file.wrapperPath);
|
|
517
|
+
if (probe && probe.prefix.includes(SHIM_MARKER) && !isCurrentShimProbe(probe, "win32")) {
|
|
518
|
+
return refreshObsoleteWindowsShim(file.wrapperPath, probe, () => windowsShimContent(file.wrapperPath, file.realPath ?? file.backupPath, runtime));
|
|
519
|
+
}
|
|
520
|
+
}
|
|
512
521
|
return false;
|
|
513
522
|
}
|
|
514
523
|
|
|
@@ -884,7 +893,8 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i
|
|
|
884
893
|
if (filesBefore.length > 0 && filesBefore.every(file => {
|
|
885
894
|
if (file.preserveOnly) return existsSync(file.backupPath) && !existsSync(file.originalPath);
|
|
886
895
|
const probe = stableShimPathProbe(file.wrapperPath);
|
|
887
|
-
return hasUsableBackingPath(file) && probe !== null && isHealthyShimProbe(probe, process.platform)
|
|
896
|
+
return hasUsableBackingPath(file) && probe !== null && isHealthyShimProbe(probe, process.platform)
|
|
897
|
+
&& (process.platform !== "win32" || isCurrentShimProbe(probe, "win32"));
|
|
888
898
|
})) {
|
|
889
899
|
return { installed: false, message: "Codex autostart shim is already installed." };
|
|
890
900
|
}
|
|
@@ -934,6 +944,12 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i
|
|
|
934
944
|
}
|
|
935
945
|
let refreshed = false;
|
|
936
946
|
for (const file of files) refreshed = refreshShimFile(file, runtime) || refreshed;
|
|
947
|
+
// A Windows refresh that refused to publish over a concurrent replacement is not a success.
|
|
948
|
+
if (process.platform === "win32" && files.some(file => {
|
|
949
|
+
if (file.preserveOnly) return false;
|
|
950
|
+
const probe = stableShimPathProbe(file.wrapperPath);
|
|
951
|
+
return !probe || !probe.prefix.includes(SHIM_MARKER) || !isCurrentShimProbe(probe, "win32");
|
|
952
|
+
})) return { installed: false, message: "Codex shim refresh deferred because a tracked launcher changed during the refresh." };
|
|
937
953
|
const allInstalled = files.every(file => file.preserveOnly
|
|
938
954
|
? existsSync(file.backupPath) && !existsSync(file.originalPath)
|
|
939
955
|
: existsSync(file.wrapperPath)
|
|
@@ -1106,7 +1122,7 @@ export function autoRestoreCodexShim(options: RuntimePreflightDeps & {
|
|
|
1106
1122
|
const replacementProbes = new Map<string, StableShimPathProbe>();
|
|
1107
1123
|
const obsoleteShimProbes = new Map<string, StableShimPathProbe>();
|
|
1108
1124
|
const seen = new Set<string>();
|
|
1109
|
-
|
|
1125
|
+
const currentShimProbes = new Map<string, StableShimPathProbe>();
|
|
1110
1126
|
for (const file of files) {
|
|
1111
1127
|
if (seen.has(file.wrapperPath)) return { status: "ineligible" };
|
|
1112
1128
|
seen.add(file.wrapperPath);
|
|
@@ -1125,11 +1141,11 @@ export function autoRestoreCodexShim(options: RuntimePreflightDeps & {
|
|
|
1125
1141
|
if (!isHealthyShimProbe(probe, state.platform)) {
|
|
1126
1142
|
return { status: "ineligible", message: destroyedShimMessage(file) };
|
|
1127
1143
|
}
|
|
1128
|
-
if (state.platform
|
|
1144
|
+
if (!isCurrentShimProbe(probe, state.platform)) {
|
|
1129
1145
|
obsoleteShimProbes.set(file.wrapperPath, probe);
|
|
1130
1146
|
continue;
|
|
1131
1147
|
}
|
|
1132
|
-
|
|
1148
|
+
currentShimProbes.set(file.wrapperPath, probe);
|
|
1133
1149
|
continue;
|
|
1134
1150
|
}
|
|
1135
1151
|
// A surviving backup would otherwise let the replacement path below wrap the
|
|
@@ -1143,7 +1159,10 @@ export function autoRestoreCodexShim(options: RuntimePreflightDeps & {
|
|
|
1143
1159
|
|
|
1144
1160
|
if (replacementProbes.size === 0 && obsoleteShimProbes.size === 0) return { status: "healthy" };
|
|
1145
1161
|
if (!options.enabled()) return { status: "disabled" };
|
|
1146
|
-
|
|
1162
|
+
// Windows current + obsolete siblings (a partial revision upgrade) resume: only the obsolete
|
|
1163
|
+
// wrappers are refreshed, so a transient publish failure cannot block recovery forever.
|
|
1164
|
+
const revisionOnlyMix = state.platform === "win32" && replacementProbes.size === 0;
|
|
1165
|
+
if (files.length > 1 && ((currentShimProbes.size > 0 && !revisionOnlyMix) || (replacementProbes.size > 0 && obsoleteShimProbes.size > 0))) {
|
|
1147
1166
|
return {
|
|
1148
1167
|
status: "deferred",
|
|
1149
1168
|
message: "Codex shim auto-restore deferred because tracked launcher siblings are in a mixed shim/replacement state.",
|
|
@@ -1166,9 +1185,14 @@ export function autoRestoreCodexShim(options: RuntimePreflightDeps & {
|
|
|
1166
1185
|
if (obsoleteShimProbes.size > 0) {
|
|
1167
1186
|
for (const [path, firstProbe] of obsoleteShimProbes) {
|
|
1168
1187
|
const secondProbe = stableShimPathProbe(path);
|
|
1169
|
-
if (!secondProbe ||
|
|
1188
|
+
if (!secondProbe || isCurrentShimProbe(secondProbe, state.platform)
|
|
1170
1189
|
|| !sameStableShimPathProbe(firstProbe, secondProbe)) return { status: "deferred" };
|
|
1171
1190
|
}
|
|
1191
|
+
if (state.platform === "win32") {
|
|
1192
|
+
const realFor = (path: string) => { const file = files.find(entry => entry.wrapperPath === path)!; return file.realPath ?? file.backupPath; };
|
|
1193
|
+
return refreshAdmittedWindowsShims(obsoleteShimProbes, currentShimProbes, path => windowsShimContent(path, realFor(path), runtime))
|
|
1194
|
+
? { status: "restored", message: `Upgraded Codex autostart shim at ${[...obsoleteShimProbes.keys()].join(", ")}.` } : { status: "deferred" };
|
|
1195
|
+
}
|
|
1172
1196
|
const result = refreshObsoleteUnixShims(files);
|
|
1173
1197
|
return result.installed
|
|
1174
1198
|
? { status: "restored", message: result.message }
|
package/src/combos/failover.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { parseResetCooldownMs } from "../codex/routing";
|
|
|
2
2
|
import { classifyError, isCyberPolicyCode } from "../lib/errors";
|
|
3
3
|
import { isNonReplayableUpstreamCode } from "../lib/upstream-retry";
|
|
4
4
|
import { isAnthropicBillingFailure } from "../providers/anthropic-billing-refusal";
|
|
5
|
+
import { isKiroAccountProfileRefusal } from "../adapters/kiro-refusal";
|
|
5
6
|
import type { OcxComboTarget } from "../types";
|
|
6
7
|
import { targetKey } from "./types";
|
|
7
8
|
import {
|
|
@@ -772,7 +773,7 @@ export function codexAccountModelRefusal(
|
|
|
772
773
|
export function comboFailureDecision(
|
|
773
774
|
status: number,
|
|
774
775
|
message: string,
|
|
775
|
-
options?: { code?: string | null; codexModelRefusal?: CodexAccountModelRefusal },
|
|
776
|
+
options?: { code?: string | null; codexModelRefusal?: CodexAccountModelRefusal; failedAdapter?: string },
|
|
776
777
|
): ComboFailureDecision {
|
|
777
778
|
if (status === 499) return "stop";
|
|
778
779
|
if (message.toLowerCase().includes("origin_rejected")) return "stop";
|
|
@@ -856,6 +857,12 @@ export function comboFailureDecision(
|
|
|
856
857
|
// could have served the request (#4903).
|
|
857
858
|
if (isResponseFormatCapabilityRefusal(status, message, options?.code)) return "hop";
|
|
858
859
|
if (isAnthropicBillingFailure(status, message, options?.code)) return "hop";
|
|
860
|
+
// Kiro's body validator rejects the request for the identity it was routed under. The account
|
|
861
|
+
// layer rotates logins first; this is the no-alternate case, so the next combo target is the
|
|
862
|
+
// only remaining chance to serve the turn. Only a caller that knows the failed target is a Kiro
|
|
863
|
+
// adapter grants it (the words alone prove nothing for another provider); it must precede the
|
|
864
|
+
// generic invalid_request_error stop.
|
|
865
|
+
if (options?.failedAdapter === "kiro" && isKiroAccountProfileRefusal(status, message)) return "hop";
|
|
859
866
|
if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) {
|
|
860
867
|
return "stop";
|
|
861
868
|
}
|
|
@@ -463,7 +463,7 @@ export function modelCapabilitiesConfigError(value: unknown, allowTombstones = f
|
|
|
463
463
|
if (allowTombstones && row === null) continue;
|
|
464
464
|
if (!capabilityRecord(row)) return "modelCapabilities rows must be plain objects";
|
|
465
465
|
for (const [axis, declaration] of Object.entries(row)) {
|
|
466
|
-
if (!["inputModalities", "contextTier", "video"].includes(axis)) return "modelCapabilities contains an unknown axis";
|
|
466
|
+
if (!["inputModalities", "contextTier", "video", "deepseekImageFiles"].includes(axis)) return "modelCapabilities contains an unknown axis";
|
|
467
467
|
if (allowTombstones && declaration === null) continue;
|
|
468
468
|
if (axis === "inputModalities") {
|
|
469
469
|
if (!Array.isArray(declaration) || declaration.length === 0
|
|
@@ -472,6 +472,8 @@ export function modelCapabilitiesConfigError(value: unknown, allowTombstones = f
|
|
|
472
472
|
}
|
|
473
473
|
} else if (axis === "contextTier") {
|
|
474
474
|
if (declaration !== "default" && declaration !== "long_context") return "modelCapabilities contextTier must be default or long_context";
|
|
475
|
+
} else if (axis === "deepseekImageFiles") {
|
|
476
|
+
if (typeof declaration !== "boolean") return "modelCapabilities deepseekImageFiles must be a boolean";
|
|
475
477
|
} else {
|
|
476
478
|
if (!capabilityRecord(declaration) || Object.keys(declaration).some(key => key !== "processing")) {
|
|
477
479
|
return "modelCapabilities video must be a plain object containing only processing";
|
|
@@ -501,6 +503,9 @@ export function mergeModelCapabilities(
|
|
|
501
503
|
} else if (axis === "contextTier") {
|
|
502
504
|
if (value === null) delete row.contextTier;
|
|
503
505
|
else row.contextTier = value as ModelCapabilities["contextTier"];
|
|
506
|
+
} else if (axis === "deepseekImageFiles") {
|
|
507
|
+
if (value === null) delete row.deepseekImageFiles;
|
|
508
|
+
else row.deepseekImageFiles = value as boolean;
|
|
504
509
|
} else if (axis === "video") {
|
|
505
510
|
if (value === null) delete row.video;
|
|
506
511
|
else {
|
|
@@ -528,7 +533,7 @@ export function sanitizeModelCapabilitiesForLoad(value: unknown): Record<string,
|
|
|
528
533
|
for (const [id, raw] of Object.entries(value).slice(0, MODEL_DISCOVERY_MAX_MODELS)) {
|
|
529
534
|
if (!capabilityRecord(raw) || !isValidModelDiscoveryModelId(id) || ["__proto__", "prototype", "constructor"].includes(id)) continue;
|
|
530
535
|
const row: Record<string, unknown> = {};
|
|
531
|
-
for (const axis of ["inputModalities", "contextTier", "video"] as const) {
|
|
536
|
+
for (const axis of ["inputModalities", "contextTier", "video", "deepseekImageFiles"] as const) {
|
|
532
537
|
if (!Object.hasOwn(raw, axis)) continue;
|
|
533
538
|
const declaration = raw[axis];
|
|
534
539
|
if (modelCapabilitiesConfigError({ [id]: { [axis]: declaration } }) === null) row[axis] = declaration;
|