@bitkyc08/opencodex 2.38.0 → 2.39.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/bin/ocx.mjs +58 -6
- package/gui/dist/assets/index-D-lchsPw.js +112 -0
- package/gui/dist/assets/index-uvENYLin.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/baseten.svg +13 -0
- package/gui/dist/provider-icons/bizrouter.svg +41 -0
- package/gui/dist/provider-icons/cerebras.svg +26 -0
- package/gui/dist/provider-icons/deepinfra.svg +75 -0
- package/gui/dist/provider-icons/digitalocean.svg +10 -0
- package/gui/dist/provider-icons/featherless.svg +4 -0
- package/gui/dist/provider-icons/gajae-code.svg +410 -0
- package/gui/dist/provider-icons/hermes-agent.svg +207 -0
- package/gui/dist/provider-icons/hyperbolic.svg +18 -0
- package/gui/dist/provider-icons/kilo.svg +13 -0
- package/gui/dist/provider-icons/litellm.svg +1 -0
- package/gui/dist/provider-icons/minimax.svg +1 -0
- package/gui/dist/provider-icons/nanogpt.svg +74 -0
- package/gui/dist/provider-icons/nebius.svg +1 -0
- package/gui/dist/provider-icons/neuralwatt.svg +27 -0
- package/gui/dist/provider-icons/nous.svg +149 -0
- package/gui/dist/provider-icons/novita.svg +32 -0
- package/gui/dist/provider-icons/orcarouter.svg +175 -0
- package/gui/dist/provider-icons/parallel.svg +13 -0
- package/gui/dist/provider-icons/sambanova.svg +276 -0
- package/gui/dist/provider-icons/scaleway.svg +11 -0
- package/gui/dist/provider-icons/siliconflow.svg +18 -0
- package/gui/dist/provider-icons/synthetic.svg +12 -0
- package/gui/dist/provider-icons/together.svg +18 -0
- package/gui/dist/provider-icons/umans.svg +30 -0
- package/gui/dist/provider-icons/venice.svg +165 -0
- package/gui/dist/provider-icons/vultr.svg +15 -0
- package/gui/dist/provider-icons/zai.svg +218 -0
- package/gui/dist/provider-icons/zenmux.svg +1 -0
- package/package.json +1 -1
- package/src/adapters/cursor/live-models.ts +1 -0
- package/src/adapters/openai-responses.ts +72 -10
- package/src/bridge.ts +15 -15
- package/src/cli/dispatch.ts +54 -18
- package/src/cli/index.ts +420 -30
- package/src/cli/integrations.ts +24 -2
- package/src/cli/uninstall-plan.ts +86 -0
- package/src/codex/account-store.ts +121 -8
- package/src/codex/auth-api.ts +202 -33
- package/src/codex/catalog/provider-fetch.ts +76 -9
- package/src/codex/history-job.ts +10 -0
- package/src/codex/history-manifest.ts +35 -2
- package/src/codex/history-provider.ts +196 -19
- package/src/codex/history-worker.ts +3 -0
- package/src/codex/quota-401-recovery.ts +190 -0
- package/src/codex/quota-recovery-timing.ts +28 -0
- package/src/codex/quota.ts +6 -0
- package/src/codex/routing.ts +68 -16
- package/src/codex/subagent-model-fallback.ts +4 -1
- package/src/config/pending-teardown-names.d.mts +8 -0
- package/src/config/pending-teardown-names.mjs +69 -0
- package/src/config/pending-teardown.ts +286 -0
- package/src/generated/compatibility-version.json +84 -32
- package/src/integrations/journal.ts +12 -1
- package/src/integrations/writer.ts +82 -7
- package/src/lib/process-control.ts +30 -5
- package/src/lib/state-store-registrations.ts +8 -0
- package/src/oauth/index.ts +220 -13
- package/src/oauth/store.ts +220 -18
- package/src/responses/spill-store.ts +20 -0
- package/src/responses/state.ts +288 -2
- package/src/server/management/integration-routes.ts +36 -5
- package/src/server/management-api.ts +66 -12
- package/src/server/responses/encrypted-payload.ts +18 -1
- package/src/server/stop-teardown.ts +84 -0
- package/src/service.ts +155 -20
- package/src/update/index.ts +36 -4
- package/src/update/proxy-liveness-probe.d.mts +6 -0
- package/src/update/proxy-liveness-probe.mjs +84 -0
- package/src/update/stop-contract.d.mts +2 -0
- package/src/update/stop-contract.mjs +15 -0
- package/src/update/stop-decision.d.mts +10 -0
- package/src/update/stop-decision.mjs +34 -0
- package/src/vision/eligibility.ts +19 -1
- package/src/vision/index.ts +4 -18
- package/gui/dist/assets/index-C14iCj_Q.js +0 -112
- package/gui/dist/assets/index-D7PIz7_g.css +0 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { CodexNativeRestoreResult } from "../codex/inject";
|
|
2
|
+
import { deferralMatchesReceipt } from "../config/pending-teardown";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared-teardown decision and execution for `POST /api/stop` (#3008).
|
|
6
|
+
*
|
|
7
|
+
* Lives outside the route handler because the handler schedules `process.exit` 200ms
|
|
8
|
+
* after it answers, which makes it uncallable from a test. The part worth testing is
|
|
9
|
+
* exactly this: whether the deferral is honoured, whether the restores actually run, and
|
|
10
|
+
* whether the response says what happened.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type GrokStripResult = { ok: boolean; changed: boolean; message: string };
|
|
14
|
+
|
|
15
|
+
export type StopTeardownIo = {
|
|
16
|
+
/** Does the nonce this request carries name a readable obligation on disk? */
|
|
17
|
+
ownsReceipt?: (nonce: string | null) => boolean;
|
|
18
|
+
restoreNativeCodex?: () => Promise<CodexNativeRestoreResult>;
|
|
19
|
+
stripGrok?: () => GrokStripResult;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type StopTeardownBody = {
|
|
23
|
+
success: boolean;
|
|
24
|
+
message: string;
|
|
25
|
+
sharedTeardown: "deferred" | "performed";
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A deferral is honoured only when the caller proves it owns the obligation.
|
|
30
|
+
*
|
|
31
|
+
* The query flag names an intention; the receipt is the obligation. Without the second
|
|
32
|
+
* half any authenticated caller could ask the proxy to skip teardown and then exit,
|
|
33
|
+
* leaving native Codex and the Grok fence pointed at a proxy that no longer exists.
|
|
34
|
+
*
|
|
35
|
+
* "A receipt exists" is not that proof either: it would let any caller ride on another
|
|
36
|
+
* stop's outstanding obligation and get a deferral it never owns. The request has to name
|
|
37
|
+
* the receipt's nonce, which only the process that wrote it (and anything that can read
|
|
38
|
+
* the 0700 config directory, which is already the trust boundary for the admin token)
|
|
39
|
+
* can know.
|
|
40
|
+
*/
|
|
41
|
+
export function deferralHonored(url: URL, ownsReceipt: (nonce: string | null) => boolean): boolean {
|
|
42
|
+
if (url.searchParams.get("deferSharedTeardown") !== "1") return false;
|
|
43
|
+
return ownsReceipt(url.searchParams.get("teardownNonce"));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Run (or skip) the shared teardown and describe the outcome truthfully. */
|
|
47
|
+
export async function performStopTeardown(url: URL, io: StopTeardownIo = {}): Promise<StopTeardownBody> {
|
|
48
|
+
const ownsReceipt = io.ownsReceipt ?? deferralMatchesReceipt;
|
|
49
|
+
if (deferralHonored(url, ownsReceipt)) {
|
|
50
|
+
// Not "native Codex restored": nothing was restored here, and claiming otherwise
|
|
51
|
+
// would be a success message the operator cannot verify.
|
|
52
|
+
return {
|
|
53
|
+
success: true,
|
|
54
|
+
message: "Proxy stopping; shared teardown deferred to the stopping client.",
|
|
55
|
+
sharedTeardown: "deferred",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const restore = io.restoreNativeCodex
|
|
59
|
+
? await io.restoreNativeCodex()
|
|
60
|
+
: await (await import("../codex/inject")).restoreNativeCodexAsync();
|
|
61
|
+
const grok = io.stripGrok
|
|
62
|
+
? io.stripGrok()
|
|
63
|
+
: (await import("../grok/inject")).stripGrokConfig();
|
|
64
|
+
// Success means BOTH halves came down. Deciding it from the native restore alone and
|
|
65
|
+
// appending the Grok text let a caller read `success: true` while the fence still
|
|
66
|
+
// pointed at a proxy that was exiting — the teardown reported done with half of it
|
|
67
|
+
// undone (#3008).
|
|
68
|
+
const grokNote = grok.ok ? "" : ` Grok config cleanup failed: ${grok.message}`;
|
|
69
|
+
if (restore.success && grok.ok) {
|
|
70
|
+
return { success: true, message: "Proxy stopping, native Codex restored.", sharedTeardown: "performed" };
|
|
71
|
+
}
|
|
72
|
+
if (restore.success) {
|
|
73
|
+
return {
|
|
74
|
+
success: false,
|
|
75
|
+
message: `Proxy stopping, native Codex restored, but the Grok fence was not removed:${grokNote} Run \`ocx restore\`.`,
|
|
76
|
+
sharedTeardown: "performed",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
success: false,
|
|
81
|
+
message: `Proxy stopping, but native Codex restore failed: ${restore.message}. Run \`ocx restore\`.${grokNote}`,
|
|
82
|
+
sharedTeardown: "performed",
|
|
83
|
+
};
|
|
84
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -2998,6 +2998,22 @@ export function stopWindows(): void {
|
|
|
2998
2998
|
if (isWindowsSchedulerEndBenign(error)) return;
|
|
2999
2999
|
}
|
|
3000
3000
|
}
|
|
3001
|
+
|
|
3002
|
+
/**
|
|
3003
|
+
* `stopWindows` for callers that need to know whether it worked.
|
|
3004
|
+
*
|
|
3005
|
+
* The void form swallows a non-benign `/end` failure, which is right for best-effort
|
|
3006
|
+
* teardown and wrong for deciding whether an update may replace files: a scheduler that
|
|
3007
|
+
* refused to stop can respawn the proxy on top of a half-written install (#3008).
|
|
3008
|
+
*/
|
|
3009
|
+
export function stopWindowsChecked(): boolean {
|
|
3010
|
+
try {
|
|
3011
|
+
schtasks(["/end", "/tn", TASK]);
|
|
3012
|
+
return true;
|
|
3013
|
+
} catch (error) {
|
|
3014
|
+
return isWindowsSchedulerEndBenign(error);
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3001
3017
|
function statusWindows(): string { try { return schtasks(["/query", "/tn", TASK]); } catch { return ""; } }
|
|
3002
3018
|
function statusWindowsXml(): string { try { return schtasks(["/query", "/tn", TASK, "/xml"]); } catch { return ""; } }
|
|
3003
3019
|
|
|
@@ -3604,36 +3620,129 @@ export async function installFreshWindowsSchedulerSafely(
|
|
|
3604
3620
|
}
|
|
3605
3621
|
}
|
|
3606
3622
|
|
|
3623
|
+
// `stopServiceIfInstalled` (boolean) is deliberately gone. It collapsed "not installed",
|
|
3624
|
+
// "refused to stop" and "state could not be read" into the same `false`, and every caller
|
|
3625
|
+
// that trusted it eventually read a live manager as absence — the route, then uninstall
|
|
3626
|
+
// (#3008). Callers take `stopServiceIfInstalledDetailed` and handle the outcomes.
|
|
3607
3627
|
/**
|
|
3608
|
-
*
|
|
3609
|
-
*
|
|
3628
|
+
* Would stopping the installed manager leave something that can respawn the proxy?
|
|
3629
|
+
*
|
|
3630
|
+
* Answered WITHOUT stopping anything, because a caller that must refuse the stop has to
|
|
3631
|
+
* refuse before it acts: `POST /api/stop` briefly ended the Task Scheduler task and then
|
|
3632
|
+
* returned 409, which left the proxy running with its manager stopped — worse than either
|
|
3633
|
+
* outcome it was choosing between.
|
|
3634
|
+
*
|
|
3635
|
+
* Task Scheduler only. `schtasks /end` ends the task instance while the `cmd :loop`
|
|
3636
|
+
* wrapper survives and respawns its child (#764); launchd, systemd and WinSW are down when
|
|
3637
|
+
* they report stopped.
|
|
3610
3638
|
*/
|
|
3611
|
-
export function
|
|
3639
|
+
export function installedServiceRespawnRisk(
|
|
3640
|
+
probe: () => WindowsSchedulerTaskProbe = probeWindowsSchedulerTask,
|
|
3641
|
+
platform: NodeJS.Platform = process.platform,
|
|
3642
|
+
): "none" | "respawnable" | "unknown" {
|
|
3643
|
+
// launchd, systemd and WinSW are down when they report stopped; only the Task Scheduler
|
|
3644
|
+
// wrapper survives its task ending (#764).
|
|
3645
|
+
if (platform !== "win32") return "none";
|
|
3646
|
+
try {
|
|
3647
|
+
// `probeWindowsSchedulerTask` returns "unknown" as an ordinary value when its queries
|
|
3648
|
+
// fail — it does not throw — so testing for "present" let an unanswerable probe
|
|
3649
|
+
// through, and the route then killed scheduler wrappers before refusing.
|
|
3650
|
+
//
|
|
3651
|
+
// "unknown" is kept SEPARATE from "respawnable" because the remedies differ. Telling
|
|
3652
|
+
// an operator whose schtasks query is broken to run `ocx stop` is circular: that
|
|
3653
|
+
// command maps the same unknown to a stop failure, so it cannot finish either.
|
|
3654
|
+
const status = probe().status;
|
|
3655
|
+
if (status === "absent") return "none";
|
|
3656
|
+
return status === "present" ? "respawnable" : "unknown";
|
|
3657
|
+
} catch {
|
|
3658
|
+
// A probe that cannot answer is not evidence of absence either.
|
|
3659
|
+
return "unknown";
|
|
3660
|
+
}
|
|
3661
|
+
}
|
|
3662
|
+
|
|
3663
|
+
/**
|
|
3664
|
+
* Outcome of stopping an installed process manager.
|
|
3665
|
+
*
|
|
3666
|
+
* `stopServiceIfInstalled` collapses "no service was installed" and "a service was
|
|
3667
|
+
* installed and would not stop" into the same `false`, which is fine for a caller that
|
|
3668
|
+
* only wants to log. It is not fine for one deciding whether an update may replace package
|
|
3669
|
+
* files: a manager that refused to stop can respawn the proxy on top of a half-written
|
|
3670
|
+
* install (#3008).
|
|
3671
|
+
*/
|
|
3672
|
+
/**
|
|
3673
|
+
* `stopped-respawnable` is Task Scheduler specifically: `schtasks /end` ends the task
|
|
3674
|
+
* instance while the `cmd :loop` wrapper survives and respawns its child seconds later
|
|
3675
|
+
* (#764). Only that backend needs the restart-window wait — launchd, systemd and WinSW
|
|
3676
|
+
* are down when they report stopped, and making them pay a seven-second poll would be a
|
|
3677
|
+
* regression in every ordinary `ocx stop`.
|
|
3678
|
+
*/
|
|
3679
|
+
/**
|
|
3680
|
+
* `state-unknown` is kept apart from `failed` because the remedies differ. A manager that
|
|
3681
|
+
* refused to stop is a stop failure the operator can retry; a scheduler whose state cannot
|
|
3682
|
+
* be READ is a broken query, and telling that operator "the manager did not stop" sends
|
|
3683
|
+
* them looking for the wrong thing (#3008).
|
|
3684
|
+
*/
|
|
3685
|
+
export type ServiceStopOutcome = "absent" | "stopped" | "stopped-respawnable" | "failed" | "state-unknown";
|
|
3686
|
+
|
|
3687
|
+
/**
|
|
3688
|
+
* Collapse the Windows backend observations into one outcome.
|
|
3689
|
+
*
|
|
3690
|
+
* Extracted so the precedence is testable by calling it. The rule that matters: a readable
|
|
3691
|
+
* failure outranks an unreadable state, and an unreadable state outranks success — a
|
|
3692
|
+
* scheduler we cannot see may still respawn the proxy.
|
|
3693
|
+
*/
|
|
3694
|
+
export function classifyWindowsServiceStop(o: {
|
|
3695
|
+
stopped: boolean;
|
|
3696
|
+
failed: boolean;
|
|
3697
|
+
schedulerStopped: boolean;
|
|
3698
|
+
stateUnknown: boolean;
|
|
3699
|
+
}): ServiceStopOutcome {
|
|
3700
|
+
if (o.failed) return "failed";
|
|
3701
|
+
if (o.stateUnknown) return "state-unknown";
|
|
3702
|
+
if (o.stopped) return o.schedulerStopped ? "stopped-respawnable" : "stopped";
|
|
3703
|
+
return "absent";
|
|
3704
|
+
}
|
|
3705
|
+
|
|
3706
|
+
export function stopServiceIfInstalledDetailed(): ServiceStopOutcome {
|
|
3612
3707
|
assertServiceEnvironmentMatchesInstall();
|
|
3613
3708
|
if (process.platform === "darwin") {
|
|
3614
3709
|
if (existsSync(plistPath())) {
|
|
3615
|
-
try { stopLaunchd(); return
|
|
3710
|
+
try { stopLaunchd(); return "stopped"; } catch { return "failed"; }
|
|
3616
3711
|
}
|
|
3617
3712
|
} else if (process.platform === "win32") {
|
|
3618
3713
|
// Query BOTH backends regardless of state: a failed switch or stale state can leave
|
|
3619
3714
|
// two managers installed, and either one would respawn the proxy after `ocx stop`.
|
|
3620
3715
|
let stopped = false;
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3716
|
+
let failed = false;
|
|
3717
|
+
let schedulerStopped = false;
|
|
3718
|
+
let stateUnknown = false;
|
|
3719
|
+
// `probeWindowsSchedulerTask` is tri-state on purpose: a query that THROWS is not the
|
|
3720
|
+
// same as a task that is absent, and treating it as absent lets a live scheduler
|
|
3721
|
+
// survive a "successful" stop.
|
|
3722
|
+
const probe = probeWindowsSchedulerTask();
|
|
3723
|
+
if (probe.status === "present") {
|
|
3724
|
+
if (stopWindowsChecked()) { stopped = true; schedulerStopped = true; }
|
|
3725
|
+
else failed = true;
|
|
3726
|
+
} else if (probe.status === "unknown") {
|
|
3727
|
+
// Not "failed": nothing refused to stop. The query itself could not answer, which is
|
|
3728
|
+
// a different problem with a different fix.
|
|
3729
|
+
stateUnknown = true;
|
|
3730
|
+
}
|
|
3625
3731
|
if (statusWinswRaw() !== "nonexistent") {
|
|
3626
|
-
try { stopWinswService(); stopped = true; } catch {
|
|
3732
|
+
try { stopWinswService(); stopped = true; } catch { failed = true; }
|
|
3627
3733
|
}
|
|
3628
3734
|
// `schtasks /end` ends the task instance but the cmd `:loop` wrapper survives and
|
|
3629
3735
|
// respawns its child seconds later (issue #764), resurrecting the proxy during a
|
|
3630
3736
|
// stop or a tray restart. Kill the launcher/wrapper processes outright.
|
|
3631
3737
|
killWindowsServiceWrapperProcesses();
|
|
3632
|
-
|
|
3738
|
+
// A failure on either backend wins: the other one stopping does not make the live one
|
|
3739
|
+
// safe to update over.
|
|
3740
|
+
const outcome = classifyWindowsServiceStop({ stopped, failed, schedulerStopped, stateUnknown });
|
|
3741
|
+
if (outcome !== "absent") return outcome;
|
|
3633
3742
|
} else if (process.platform === "linux" && isSystemd() && existsSync(unitPath())) {
|
|
3634
|
-
try { stopSystemd(); return
|
|
3743
|
+
try { stopSystemd(); return "stopped"; } catch { return "failed"; }
|
|
3635
3744
|
}
|
|
3636
|
-
return
|
|
3745
|
+
return "absent";
|
|
3637
3746
|
}
|
|
3638
3747
|
|
|
3639
3748
|
/** Delete install-state files; stale state would make `ocx update` "reinstall" a service that no longer exists. */
|
|
@@ -3666,13 +3775,22 @@ export function setUninstallServiceHooksForTests(hooks: UninstallServiceHooksFor
|
|
|
3666
3775
|
* service or scheduler task that cannot be removed throws so the caller cannot erase state and
|
|
3667
3776
|
* report success.
|
|
3668
3777
|
*/
|
|
3669
|
-
|
|
3778
|
+
/**
|
|
3779
|
+
* Outcome of removing an installed manager.
|
|
3780
|
+
*
|
|
3781
|
+
* `false` used to mean both "nothing was installed" and "removal failed" on darwin and
|
|
3782
|
+
* linux, so a failed removal was reported as absence and authorized the shared teardown
|
|
3783
|
+
* while the service assets were still there (#3008).
|
|
3784
|
+
*/
|
|
3785
|
+
export type ServiceUninstallOutcome = "absent" | "removed" | "failed";
|
|
3786
|
+
|
|
3787
|
+
export function uninstallServiceDetailed(): ServiceUninstallOutcome {
|
|
3670
3788
|
const hooks = uninstallServiceHooksForTests;
|
|
3671
3789
|
(hooks?.assertEnvironment ?? assertServiceEnvironmentMatchesInstall)();
|
|
3672
3790
|
const platform = hooks?.platform ?? process.platform;
|
|
3673
3791
|
if (platform === "darwin") {
|
|
3674
3792
|
if (existsSync(plistPath())) {
|
|
3675
|
-
try { uninstallLaunchd(); removeServiceInstallState(); return
|
|
3793
|
+
try { uninstallLaunchd(); removeServiceInstallState(); return "removed"; } catch { return "failed"; }
|
|
3676
3794
|
}
|
|
3677
3795
|
} else if (platform === "win32") {
|
|
3678
3796
|
let removed = false;
|
|
@@ -3688,13 +3806,20 @@ export function uninstallServiceIfInstalled(): boolean {
|
|
|
3688
3806
|
(hooks?.uninstallNative ?? uninstallWinswService)();
|
|
3689
3807
|
removed = true;
|
|
3690
3808
|
}
|
|
3691
|
-
if (removed) { (hooks?.removeInstallState ?? removeServiceInstallState)(); return
|
|
3809
|
+
if (removed) { (hooks?.removeInstallState ?? removeServiceInstallState)(); return "removed"; }
|
|
3692
3810
|
} else if (platform === "linux" && existsSync(unitPath())) {
|
|
3693
|
-
try { uninstallSystemd(); removeServiceInstallState(); return
|
|
3694
|
-
try { unlinkSync(unitPath()); removeServiceInstallState(); return
|
|
3811
|
+
try { uninstallSystemd(); removeServiceInstallState(); return "removed"; } catch {
|
|
3812
|
+
try { unlinkSync(unitPath()); removeServiceInstallState(); return "removed"; } catch { return "failed"; }
|
|
3695
3813
|
}
|
|
3696
3814
|
}
|
|
3697
|
-
return
|
|
3815
|
+
return "absent";
|
|
3816
|
+
}
|
|
3817
|
+
|
|
3818
|
+
/** Boolean form for callers that only distinguish "something was removed". */
|
|
3819
|
+
export function uninstallServiceIfInstalled(): boolean {
|
|
3820
|
+
const outcome = uninstallServiceDetailed();
|
|
3821
|
+
if (outcome === "failed") throw new Error("the installed service could not be removed");
|
|
3822
|
+
return outcome === "removed";
|
|
3698
3823
|
}
|
|
3699
3824
|
|
|
3700
3825
|
/** True if a background service (launchd/systemd/Task Scheduler) is installed. */
|
|
@@ -4204,11 +4329,17 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
4204
4329
|
const restore = await restoreNativeCodexAsync();
|
|
4205
4330
|
if (restore.success) console.log("✅ service stopped + native Codex restored.");
|
|
4206
4331
|
else console.error(`⚠️ service stopped, but native Codex restore FAILED: ${restore.message}\nRun \`ocx restore\` (or check $CODEX_HOME/config.toml) before using native Codex.`);
|
|
4332
|
+
if (!restore.success) process.exitCode = 1;
|
|
4207
4333
|
// The Grok fence is the other managed config this command owns. Leaving it behind
|
|
4208
4334
|
// pointed grok at a dead endpoint while native Codex was already restored.
|
|
4209
4335
|
const grok = stripGrokConfig();
|
|
4210
4336
|
if (grok.changed) console.log(`↩️ ${grok.message}`);
|
|
4211
|
-
else if (!grok.ok)
|
|
4337
|
+
else if (!grok.ok) {
|
|
4338
|
+
// A failed strip leaves Grok aimed at a proxy this command just stopped. Exiting
|
|
4339
|
+
// 0 tells a script the teardown finished when half of it did not.
|
|
4340
|
+
console.error(`⚠️ ${grok.message}`);
|
|
4341
|
+
process.exitCode = 1;
|
|
4342
|
+
}
|
|
4212
4343
|
}
|
|
4213
4344
|
break;
|
|
4214
4345
|
}
|
|
@@ -4242,10 +4373,14 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
4242
4373
|
const restore = await restoreNativeCodexAsync();
|
|
4243
4374
|
if (!restore.success) {
|
|
4244
4375
|
console.error(`⚠️ native Codex restore FAILED: ${restore.message}\nRun \`ocx restore\` before using native Codex.`);
|
|
4376
|
+
process.exitCode = 1;
|
|
4245
4377
|
}
|
|
4246
4378
|
const grok = stripGrokConfig();
|
|
4247
4379
|
if (grok.changed) console.log(`↩️ ${grok.message}`);
|
|
4248
|
-
else if (!grok.ok)
|
|
4380
|
+
else if (!grok.ok) {
|
|
4381
|
+
console.error(`⚠️ ${grok.message}`);
|
|
4382
|
+
process.exitCode = 1;
|
|
4383
|
+
}
|
|
4249
4384
|
}
|
|
4250
4385
|
removeServiceInstallState();
|
|
4251
4386
|
try { if (existsSync(serviceApiTokenFilePath())) unlinkSync(serviceApiTokenFilePath()); } catch { /* best-effort */ }
|
package/src/update/index.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "./stop-contract.mjs";
|
|
3
|
+
import { proxyIdentityAt } from "../server/proxy-liveness";
|
|
4
|
+
import { probeProxyLiveness } from "./proxy-liveness-probe.mjs";
|
|
5
|
+
import { decidePostStopUpdate } from "./stop-decision.mjs";
|
|
2
6
|
import { readFileSync, readdirSync } from "node:fs";
|
|
3
7
|
import { fileURLToPath } from "node:url";
|
|
4
8
|
import { dirname, join } from "node:path";
|
|
5
9
|
import { getConfigDir, loadConfig } from "../config";
|
|
6
10
|
import { readPid, readRuntimePort } from "../config/process-state";
|
|
11
|
+
import { pendingTeardownOutstanding } from "../config/pending-teardown";
|
|
7
12
|
import { npmInvocation } from "./npm-invocation.mjs";
|
|
8
13
|
import {
|
|
9
14
|
npmCachePreflightFailureMessage,
|
|
@@ -246,8 +251,13 @@ export async function runUpdate(): Promise<void> {
|
|
|
246
251
|
// modules after startup, so an in-place update leaves it executing mixed old/new code.
|
|
247
252
|
// Gate on the service and the runtime-port record too, not just the pid file — a
|
|
248
253
|
// service-managed or orphaned proxy can be live while ocx.pid is stale/missing.
|
|
254
|
+
//
|
|
255
|
+
// An outstanding pending-teardown receipt is a fourth reason to run the stop. After a
|
|
256
|
+
// parent crashed mid-deferral all three of the other signals can be absent while the
|
|
257
|
+
// shared client config still points at a proxy that is gone; installing over that
|
|
258
|
+
// silently skips the recovery the receipt was written to trigger (#3008).
|
|
249
259
|
// Full `ocx stop` semantics (drain, service stop, restore).
|
|
250
|
-
if (serviceWasInstalled || readPid() || readRuntimePort()) {
|
|
260
|
+
if (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()) {
|
|
251
261
|
console.log("⏹ Stopping the running proxy before updating...");
|
|
252
262
|
const stopStdio = updateChildStdio();
|
|
253
263
|
const stop = spawnSync(process.execPath, selfLaunchArgv(["stop"]), {
|
|
@@ -256,17 +266,39 @@ export async function runUpdate(): Promise<void> {
|
|
|
256
266
|
windowsHide: true,
|
|
257
267
|
});
|
|
258
268
|
if (stopStdio === "pipe") logSpawnOutput("", stop);
|
|
259
|
-
|
|
269
|
+
// One decision, shared with the npm launcher (#3008). The two lanes disagreeing about
|
|
270
|
+
// the same situation is how this shipped fixed on one side only. Absent PID and runtime
|
|
271
|
+
// files are weak evidence - a crashed-but-listening proxy leaves none - so the captured
|
|
272
|
+
// endpoint is asked, and `null` from proxyIdentityAt covers refusal AND timeout alike.
|
|
273
|
+
const identity = await proxyIdentityAt(capturedListen.port, { hostname: capturedListen.hostname });
|
|
274
|
+
const decision = decidePostStopUpdate({
|
|
275
|
+
status: stop.status,
|
|
276
|
+
hasRuntimeState: !!(readPid() || readRuntimePort()),
|
|
277
|
+
// Re-checked AFTER the stop: a quarantined receipt lets the stop itself succeed
|
|
278
|
+
// (there is nothing left to stop), so a pre-stop check alone let the retry install
|
|
279
|
+
// over a teardown that never ran.
|
|
280
|
+
teardownOutstanding: pendingTeardownOutstanding(),
|
|
281
|
+
liveness: identity ? "live" : probeProxyLiveness(capturedListen.port, capturedListen.hostname),
|
|
282
|
+
});
|
|
283
|
+
const historyOnlyStop = decision.reason === "history-only";
|
|
284
|
+
if (!decision.proceed) {
|
|
260
285
|
if (trayWasRunning) {
|
|
261
286
|
try {
|
|
262
287
|
const { startWindowsTray } = await import("../tray/windows");
|
|
263
288
|
startWindowsTray();
|
|
264
289
|
} catch { /* preserve the proxy stop failure */ }
|
|
265
290
|
}
|
|
266
|
-
|
|
291
|
+
if (decision.reason === "teardown-outstanding") {
|
|
292
|
+
console.error("⚠️ A shared teardown from an earlier stop is still outstanding and needs manual review; aborting the update.");
|
|
293
|
+
console.error(" Confirm no proxy is running, run 'ocx restore', then remove the pending-teardown file in your opencodex home.");
|
|
294
|
+
} else {
|
|
295
|
+
console.error(decision.reason === "proxy-unknown"
|
|
296
|
+
? `⚠️ Could not confirm the proxy on ${capturedListen.hostname}:${capturedListen.port} is stopped; aborting the update. Run 'ocx stop' and retry.`
|
|
297
|
+
: "⚠️ Could not stop the running proxy; aborting the update. Run 'ocx stop' and retry.");
|
|
298
|
+
}
|
|
267
299
|
process.exit(1);
|
|
268
300
|
}
|
|
269
|
-
if (historyRestoreIncomplete()) {
|
|
301
|
+
if (historyOnlyStop || historyRestoreIncomplete()) {
|
|
270
302
|
console.warn(
|
|
271
303
|
"⚠️ Codex resume-history metadata restore is incomplete (a backup manifest remains).\n" +
|
|
272
304
|
" The DB may be busy or the manifest/target may need review; untracked routed history is intentionally unchanged.\n" +
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Is something still answering `/healthz` as an opencodex proxy on this endpoint?
|
|
5
|
+
*
|
|
6
|
+
* Absent PID and runtime-port files are weak evidence that the proxy is gone: a crashed
|
|
7
|
+
* but still-listening process, or one supervised outside our records, leaves no files and
|
|
8
|
+
* keeps the port. Replacing package files under it leaves a server running a mix of old
|
|
9
|
+
* and new modules, which is the hazard `ocx update` stops the proxy to avoid (#3008).
|
|
10
|
+
*
|
|
11
|
+
* Synchronous and dependency-free because it runs inside the plain-Node launcher's
|
|
12
|
+
* `runNpmSelfUpdate`, which is not async and cannot import the TypeScript liveness module.
|
|
13
|
+
* A separate Node child does the fetch so the caller keeps its straight-line control flow.
|
|
14
|
+
*
|
|
15
|
+
* Returns `"live" | "dead" | "unknown"`, and the caller treats `unknown` as a reason to
|
|
16
|
+
* stop. Fail-open was wrong here: a listener that accepts connections but withholds
|
|
17
|
+
* `/healthz`, or a probe that times out, is exactly the state where replacing package
|
|
18
|
+
* files is most dangerous, and "we could not tell" is not evidence the proxy is gone.
|
|
19
|
+
* Only a refused connection or a definitive non-OpenCodex answer earns `"dead"`.
|
|
20
|
+
*/
|
|
21
|
+
export function probeProxyLiveness(port, hostname = "127.0.0.1", timeoutMs = 1500) {
|
|
22
|
+
// An unusable port is not an ambiguous probe: there is nothing to ask.
|
|
23
|
+
if (!Number.isFinite(port) || port <= 0 || port > 65535) return "dead";
|
|
24
|
+
// Normalize HERE rather than at each call site. Leaving it to the callers put the fix in
|
|
25
|
+
// one lane and not the other, and a bracketed IPv6 literal handed to node:http answers
|
|
26
|
+
// nothing - which the tri-state correctly reports as "unknown" and the updater correctly
|
|
27
|
+
// treats as a reason to abort, turning a healthy stop into a refused update.
|
|
28
|
+
let host = typeof hostname === "string" && hostname.trim() !== "" ? hostname.trim() : "127.0.0.1";
|
|
29
|
+
// A wildcard bind answers on loopback; `node:http` cannot dial the wildcard itself.
|
|
30
|
+
if (host === "0.0.0.0" || host === "*") host = "127.0.0.1";
|
|
31
|
+
if (host === "::" ) host = "::1";
|
|
32
|
+
// `[::1]` is a URL spelling; the socket layer wants the bare address.
|
|
33
|
+
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
|
|
34
|
+
// `node:http` rather than `fetch`: the child inherits a parent whose event loop is
|
|
35
|
+
// blocked on `spawnSync`, and an aborted-before-dispatch fetch reports the same "not
|
|
36
|
+
// live" as a genuinely dead port. A request emitted on the socket cannot be confused
|
|
37
|
+
// with one that never left.
|
|
38
|
+
const script = [
|
|
39
|
+
"const http = require('node:http');",
|
|
40
|
+
"const [host, port, timeout] = process.argv.slice(1);",
|
|
41
|
+
"const req = http.get({ host, port: Number(port), path: '/healthz', timeout: Number(timeout) }, res => {",
|
|
42
|
+
" let body = '';",
|
|
43
|
+
" res.setEncoding('utf8');",
|
|
44
|
+
" res.on('data', chunk => { body += chunk; });",
|
|
45
|
+
" res.on('end', () => {",
|
|
46
|
+
" try {",
|
|
47
|
+
" const parsed = JSON.parse(body);",
|
|
48
|
+
" // Mirrors isOpencodexHealthz in src/server/proxy-liveness.ts. A foreign server",
|
|
49
|
+
" // that happens to expose /healthz must not be read as our proxy, and a",
|
|
50
|
+
" // pre-identity build of ours must not be read as foreign.",
|
|
51
|
+
" const isOpencodex = parsed && typeof parsed === 'object'",
|
|
52
|
+
" && (parsed.service === 'opencodex'",
|
|
53
|
+
" || (parsed.service === undefined",
|
|
54
|
+
" && parsed.status === 'ok'",
|
|
55
|
+
" && typeof parsed.version === 'string'",
|
|
56
|
+
" && typeof parsed.uptime === 'number'));",
|
|
57
|
+
" // Only a clean 200 decides anything. Any other status means the endpoint is",
|
|
58
|
+
" // answering but not telling us what it is, which is not evidence of absence.",
|
|
59
|
+
" if (res.statusCode !== 200) process.stdout.write('UNKNOWN');",
|
|
60
|
+
" else process.stdout.write(isOpencodex ? 'LIVE' : 'DEAD');",
|
|
61
|
+
" } catch { process.stdout.write('UNKNOWN'); }",
|
|
62
|
+
" });",
|
|
63
|
+
"});",
|
|
64
|
+
"req.on('timeout', () => { process.stdout.write('UNKNOWN'); req.destroy(); });",
|
|
65
|
+
"// ECONNREFUSED is the one error that proves nothing is listening. Everything else -",
|
|
66
|
+
"// reset, unreachable host, TLS confusion - leaves the question open.",
|
|
67
|
+
"req.on('error', err => process.stdout.write(err && err.code === 'ECONNREFUSED' ? 'DEAD' : 'UNKNOWN'));",
|
|
68
|
+
].join("\n");
|
|
69
|
+
try {
|
|
70
|
+
const probe = spawnSync(
|
|
71
|
+
process.execPath,
|
|
72
|
+
["-e", script, host, String(port), String(timeoutMs)],
|
|
73
|
+
{ encoding: "utf8", timeout: timeoutMs + 1500, windowsHide: true },
|
|
74
|
+
);
|
|
75
|
+
const out = probe.stdout ?? "";
|
|
76
|
+
if (out.includes("LIVE")) return "live";
|
|
77
|
+
if (out.includes("DEAD")) return "dead";
|
|
78
|
+
// A child that produced nothing, was killed by its own timeout, or failed to spawn
|
|
79
|
+
// leaves the question open rather than answering it.
|
|
80
|
+
return "unknown";
|
|
81
|
+
} catch {
|
|
82
|
+
return "unknown";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The exit code `ocx stop` uses to say "teardown succeeded, history cleanup did not".
|
|
3
|
+
*
|
|
4
|
+
* This is plain ESM rather than TypeScript because it has two consumers on opposite sides
|
|
5
|
+
* of a process boundary: `src/update/index.ts` and the Node launcher `bin/ocx.mjs`, which
|
|
6
|
+
* cannot import a `.ts` module. A TypeScript union would not survive `spawnSync` anyway —
|
|
7
|
+
* the value has to be on the wire, and an exit code is the wire.
|
|
8
|
+
*
|
|
9
|
+
* 79 is deliberate. It sits above the `sysexits.h` block (64-78), below `128 + signal`,
|
|
10
|
+
* and outside every code this CLI already uses: `src/cli/index.ts` emits 0, 1 and 130,
|
|
11
|
+
* and `src/cli/dispatch.ts` adds 2, 4 and 64. Picking one of those would have made a
|
|
12
|
+
* history-only stop indistinguishable from a config conflict, and `bin/ocx.mjs` mirrors
|
|
13
|
+
* the child's code faithfully enough to propagate the confusion.
|
|
14
|
+
*/
|
|
15
|
+
export const STOP_HISTORY_INCOMPLETE_EXIT_CODE = 79;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Declaration for the plain-ESM post-stop decision shared with `bin/ocx.mjs`. */
|
|
2
|
+
export declare function decidePostStopUpdate(input: {
|
|
3
|
+
status: number | null;
|
|
4
|
+
hasRuntimeState: boolean;
|
|
5
|
+
liveness: "live" | "dead" | "unknown";
|
|
6
|
+
teardownOutstanding?: boolean;
|
|
7
|
+
}): {
|
|
8
|
+
proceed: boolean;
|
|
9
|
+
reason: "stop-failed" | "runtime-state" | "teardown-outstanding" | "proxy-live" | "proxy-unknown" | "history-only" | "ok";
|
|
10
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "./stop-contract.mjs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* May an update replace package files after `ocx stop` returned?
|
|
5
|
+
*
|
|
6
|
+
* Both updaters ask this: `src/update/index.ts` on the Bun path and `bin/ocx.mjs` on the
|
|
7
|
+
* npm path the dashboard uses. It lives here as plain ESM so the Node launcher can import
|
|
8
|
+
* it, and so the two lanes cannot drift into disagreeing about the same situation — which
|
|
9
|
+
* is how #3008 shipped in the first place, with the fix on one side only.
|
|
10
|
+
*
|
|
11
|
+
* Returns `{ proceed, reason }`. The reasons are:
|
|
12
|
+
*
|
|
13
|
+
* - `stop-failed` — a nonzero status other than the history-only code, or a signal kill.
|
|
14
|
+
* A signal kill carries no evidence the teardown finished, so it is not a maybe.
|
|
15
|
+
* - `runtime-state` — a PID or runtime-port record survived the stop.
|
|
16
|
+
* - `teardown-outstanding` — a shared-teardown obligation survived the stop. That is a
|
|
17
|
+
* quarantined receipt awaiting a human: the stop itself can succeed (there was nothing
|
|
18
|
+
* left to stop), so checking only BEFORE the stop let the retry sail straight through
|
|
19
|
+
* and install over a teardown that never ran.
|
|
20
|
+
* - `proxy-live` — something is still answering as our proxy on the captured endpoint.
|
|
21
|
+
* - `proxy-unknown` — the probe could not answer. Absence of proof is not proof of
|
|
22
|
+
* absence, and replacing files under a live server leaves it running a mix of old and
|
|
23
|
+
* new modules.
|
|
24
|
+
* - `ok` / `history-only` — proceed; the second also prints the manifest warning.
|
|
25
|
+
*/
|
|
26
|
+
export function decidePostStopUpdate({ status, hasRuntimeState, liveness, teardownOutstanding = false }) {
|
|
27
|
+
const historyOnly = status === STOP_HISTORY_INCOMPLETE_EXIT_CODE;
|
|
28
|
+
if (status !== 0 && !historyOnly) return { proceed: false, reason: "stop-failed" };
|
|
29
|
+
if (hasRuntimeState) return { proceed: false, reason: "runtime-state" };
|
|
30
|
+
if (teardownOutstanding) return { proceed: false, reason: "teardown-outstanding" };
|
|
31
|
+
if (liveness === "live") return { proceed: false, reason: "proxy-live" };
|
|
32
|
+
if (liveness !== "dead") return { proceed: false, reason: "proxy-unknown" };
|
|
33
|
+
return { proceed: true, reason: historyOnly ? "history-only" : "ok" };
|
|
34
|
+
}
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* explicit modalities.
|
|
22
22
|
*/
|
|
23
23
|
import { modelInList, type OcxConfig, type OcxProviderConfig } from "../types";
|
|
24
|
+
import { modelRecordValue } from "../reasoning-effort";
|
|
24
25
|
import { getModelMetadataCaseInsensitive, resolveMetadataProvider } from "../generated/model-metadata";
|
|
25
26
|
import { nativeInputModalities } from "../codex/catalog/metadata";
|
|
26
27
|
import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../codex/catalog/native-models";
|
|
@@ -68,6 +69,22 @@ export interface VisionModelOption {
|
|
|
68
69
|
|
|
69
70
|
type EnrichedProviderCache = Map<string, OcxProviderConfig>;
|
|
70
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Whether the proxy must describe images for this model before dispatching its main request.
|
|
74
|
+
*
|
|
75
|
+
* `noVisionModels` is an explicit override. A modality declaration is only evidence for this
|
|
76
|
+
* path when it describes a text model that excludes image input: an audio-only declaration is
|
|
77
|
+
* not a text-only model and must not be widened to image through the vision sidecar.
|
|
78
|
+
*/
|
|
79
|
+
export function isModelVisionSidecarConsumer(
|
|
80
|
+
provider: Pick<OcxProviderConfig, "noVisionModels" | "modelInputModalities">,
|
|
81
|
+
modelId: string,
|
|
82
|
+
): boolean {
|
|
83
|
+
if (modelInList(provider.noVisionModels, modelId)) return true;
|
|
84
|
+
const modalities = modelRecordValue(provider.modelInputModalities, modelId);
|
|
85
|
+
return Array.isArray(modalities) && modalities.includes("text") && !modalities.includes("image");
|
|
86
|
+
}
|
|
87
|
+
|
|
71
88
|
function advertisesImageInput(modalities: readonly string[] | undefined): boolean | undefined {
|
|
72
89
|
if (!modalities || modalities.length === 0) return undefined;
|
|
73
90
|
return modalities.includes("image");
|
|
@@ -105,7 +122,8 @@ function isVisionSidecarConsumerWithCache(
|
|
|
105
122
|
modelId: string,
|
|
106
123
|
cache: EnrichedProviderCache,
|
|
107
124
|
): boolean {
|
|
108
|
-
|
|
125
|
+
const provider = enrichedProviderForVision(config, providerName, cache);
|
|
126
|
+
return provider !== undefined && isModelVisionSidecarConsumer(provider, modelId);
|
|
109
127
|
}
|
|
110
128
|
|
|
111
129
|
/**
|
package/src/vision/index.ts
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import type { OcxConfig, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent } from "../types";
|
|
3
|
-
import { modelInList } from "../types";
|
|
4
|
-
import { modelRecordValue } from "../reasoning-effort";
|
|
5
3
|
import type { VisionReasoningEffort } from "../reasoning-effort";
|
|
6
4
|
import { describeImage, type DescribeOutcome, type VisionSettings } from "./describe";
|
|
7
5
|
import { describeImageAnthropic } from "./anthropic-describe";
|
|
8
6
|
import { describeImageRouted } from "./routed-describe";
|
|
9
|
-
import { modelAcceptsImageInput } from "./eligibility";
|
|
7
|
+
import { isModelVisionSidecarConsumer as isModelTextOnly, modelAcceptsImageInput } from "./eligibility";
|
|
10
8
|
import { normalizeVisionReasoningForModel } from "./reasoning";
|
|
11
9
|
import type { CodexAuthContext } from "../codex/auth-context";
|
|
12
10
|
import { resolveSidecarAuth } from "../sidecar/auth";
|
|
@@ -22,24 +20,12 @@ import {
|
|
|
22
20
|
|
|
23
21
|
export { describeImage } from "./describe";
|
|
24
22
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
* `noVisionModels` or declared with `modelInputModalities` that exclude "image".
|
|
28
|
-
* Returns false for unknown models (no evidence either way) so they fall through
|
|
29
|
-
* to native image passthrough, which is the safe default for an unclassified model.
|
|
30
|
-
*/
|
|
31
|
-
export function isModelTextOnly(
|
|
32
|
-
provider: OcxProviderConfig,
|
|
33
|
-
modelId: string,
|
|
34
|
-
): boolean {
|
|
35
|
-
if (modelInList(provider.noVisionModels, modelId)) return true;
|
|
36
|
-
const modalities = modelRecordValue(provider.modelInputModalities, modelId);
|
|
37
|
-
if (Array.isArray(modalities) && modalities.length > 0 && !modalities.includes("image")) return true;
|
|
38
|
-
return false;
|
|
39
|
-
}
|
|
23
|
+
/** Backward-compatible request-time name for the shared vision-sidecar consumer predicate. */
|
|
24
|
+
export { isModelVisionSidecarConsumer as isModelTextOnly } from "./eligibility";
|
|
40
25
|
export { describeImageAnthropic, parseAnthropicVisionSSE } from "./anthropic-describe";
|
|
41
26
|
export {
|
|
42
27
|
BASELINE_VISION_MODELS,
|
|
28
|
+
isModelVisionSidecarConsumer,
|
|
43
29
|
isVisionEligibleModel,
|
|
44
30
|
isVisionSidecarConsumer,
|
|
45
31
|
modelAcceptsImageInput,
|