@openpalm/lib 0.11.1 → 0.11.2-rc.2
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/package.json +1 -1
- package/src/control-plane/docker.test.ts +61 -0
- package/src/control-plane/docker.ts +98 -1
- package/src/control-plane/hardware-detect.ts +33 -1
- package/src/control-plane/lifecycle.ts +26 -10
- package/src/control-plane/model-runner.test.ts +95 -0
- package/src/control-plane/model-runner.ts +210 -55
- package/src/control-plane/setup-recommendation.test.ts +129 -2
- package/src/control-plane/setup-recommendation.ts +63 -4
- package/src/control-plane/upgrade-path.test.ts +13 -4
- package/src/index.ts +4 -2
package/package.json
CHANGED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
detectExistingProject,
|
|
4
|
+
isProjectOurs,
|
|
5
|
+
resolveComposeProjectName,
|
|
6
|
+
} from "./docker.js";
|
|
7
|
+
|
|
8
|
+
describe("isProjectOurs (ours-vs-foreign decision)", () => {
|
|
9
|
+
it("treats a matching working_dir as ours", () => {
|
|
10
|
+
expect(isProjectOurs("/home/me/.openpalm", "/home/me/.openpalm")).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("treats a different working_dir as foreign", () => {
|
|
14
|
+
expect(isProjectOurs("/home/other/.openpalm", "/home/me/.openpalm")).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("treats an empty/unknown working_dir as ours (reconcile, don't refuse)", () => {
|
|
18
|
+
expect(isProjectOurs("", "/home/me/.openpalm")).toBe(true);
|
|
19
|
+
expect(isProjectOurs(" ", "/home/me/.openpalm")).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("ignores surrounding whitespace on the label", () => {
|
|
23
|
+
expect(isProjectOurs(" /home/me/.openpalm \n", "/home/me/.openpalm")).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("detectExistingProject", () => {
|
|
28
|
+
// Use a project name that cannot possibly match any running container so the
|
|
29
|
+
// result is deterministic whether or not a docker daemon is present:
|
|
30
|
+
// - docker error (no daemon) → { exists:false }
|
|
31
|
+
// - docker ok, no matching label → { exists:false }
|
|
32
|
+
const ghostName = `openpalm-detect-test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
33
|
+
|
|
34
|
+
it("returns exists:false when no project matches (or docker is unavailable)", async () => {
|
|
35
|
+
const result = await detectExistingProject({
|
|
36
|
+
projectName: ghostName,
|
|
37
|
+
expectedWorkingDir: "/nonexistent/op_home",
|
|
38
|
+
});
|
|
39
|
+
expect(result.exists).toBe(false);
|
|
40
|
+
expect(result.isOurs).toBe(false);
|
|
41
|
+
expect(result.workingDir).toBe("");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("resolveComposeProjectName", () => {
|
|
46
|
+
const saved = process.env.OP_PROJECT_NAME;
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
if (saved === undefined) delete process.env.OP_PROJECT_NAME;
|
|
49
|
+
else process.env.OP_PROJECT_NAME = saved;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("defaults to openpalm", () => {
|
|
53
|
+
delete process.env.OP_PROJECT_NAME;
|
|
54
|
+
delete process.env.COMPOSE_PROJECT_NAME;
|
|
55
|
+
expect(resolveComposeProjectName({})).toBe("openpalm");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("honors OP_PROJECT_NAME from overrides first", () => {
|
|
59
|
+
expect(resolveComposeProjectName({ OP_PROJECT_NAME: "openpalm-dev" })).toBe("openpalm-dev");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -51,6 +51,83 @@ export function resolveComposeProjectName(envOverrides: Record<string, string> =
|
|
|
51
51
|
);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Result of probing the Docker daemon for an existing compose project that
|
|
56
|
+
* shares our project name.
|
|
57
|
+
*
|
|
58
|
+
* - `exists` — at least one running container carries the project label.
|
|
59
|
+
* - `isOurs` — those containers were launched from THIS install's working
|
|
60
|
+
* dir (compose working_dir label === expectedWorkingDir). When
|
|
61
|
+
* true the caller should reconcile in place (up --force-recreate).
|
|
62
|
+
* When false a DIFFERENT OpenPalm install (e.g. dev vs host) owns
|
|
63
|
+
* the name and the caller must refuse.
|
|
64
|
+
* - `workingDir` — the working_dir label read off the first container, for
|
|
65
|
+
* error messages. Empty string when unknown.
|
|
66
|
+
*/
|
|
67
|
+
export type ExistingProject = {
|
|
68
|
+
exists: boolean;
|
|
69
|
+
isOurs: boolean;
|
|
70
|
+
workingDir: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Decide whether a running compose project (identified by its
|
|
75
|
+
* `com.docker.compose.project.working_dir` label) is OURS — i.e. was launched
|
|
76
|
+
* from this install's working dir. An empty/unknown label can't prove foreign,
|
|
77
|
+
* so it counts as ours (reconcile rather than wrongly refuse a redeploy).
|
|
78
|
+
*
|
|
79
|
+
* Pure decision split out from detectExistingProject so the ours-vs-foreign
|
|
80
|
+
* rule is unit-testable without a Docker daemon.
|
|
81
|
+
*/
|
|
82
|
+
export function isProjectOurs(workingDirLabel: string, expectedWorkingDir: string): boolean {
|
|
83
|
+
const label = workingDirLabel.trim();
|
|
84
|
+
return label === "" || label === expectedWorkingDir;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Probe the Docker daemon for a running compose project that shares
|
|
89
|
+
* `projectName`. Decides ours-vs-foreign by comparing the project's
|
|
90
|
+
* `com.docker.compose.project.working_dir` label against `expectedWorkingDir`
|
|
91
|
+
* (the install's OP_HOME / compose context).
|
|
92
|
+
*
|
|
93
|
+
* Returns `{ exists:false }` on any docker error (daemon down, no permission) —
|
|
94
|
+
* detection is best-effort and never blocks the caller; a real failure surfaces
|
|
95
|
+
* later through composeUp.
|
|
96
|
+
*/
|
|
97
|
+
export function detectExistingProject(opts: {
|
|
98
|
+
projectName: string;
|
|
99
|
+
expectedWorkingDir: string;
|
|
100
|
+
}): Promise<ExistingProject> {
|
|
101
|
+
const none: ExistingProject = { exists: false, isOurs: false, workingDir: "" };
|
|
102
|
+
return new Promise((resolve) => {
|
|
103
|
+
execFile(
|
|
104
|
+
"docker",
|
|
105
|
+
["ps", "-q", "--filter", `label=com.docker.compose.project=${opts.projectName}`],
|
|
106
|
+
{ timeout: 10_000 },
|
|
107
|
+
(err, stdout) => {
|
|
108
|
+
if (err) return resolve(none);
|
|
109
|
+
const ids = stdout.toString().trim().split(/\s+/).filter(Boolean);
|
|
110
|
+
if (ids.length === 0) return resolve(none);
|
|
111
|
+
execFile(
|
|
112
|
+
"docker",
|
|
113
|
+
[
|
|
114
|
+
"inspect",
|
|
115
|
+
"--format",
|
|
116
|
+
'{{ index .Config.Labels "com.docker.compose.project.working_dir" }}',
|
|
117
|
+
ids[0],
|
|
118
|
+
],
|
|
119
|
+
{ timeout: 10_000 },
|
|
120
|
+
(err2, stdout2) => {
|
|
121
|
+
if (err2) return resolve({ exists: true, isOurs: false, workingDir: "" });
|
|
122
|
+
const workingDir = stdout2.toString().trim();
|
|
123
|
+
resolve({ exists: true, isOurs: isProjectOurs(workingDir, opts.expectedWorkingDir), workingDir });
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
54
131
|
/** Check if Docker is available */
|
|
55
132
|
export async function checkDocker(): Promise<DockerResult> {
|
|
56
133
|
return new Promise((resolve) => {
|
|
@@ -172,7 +249,21 @@ export async function composeUp(
|
|
|
172
249
|
if (options.forceRecreate) args.push("--force-recreate");
|
|
173
250
|
if (options.removeOrphans) args.push("--remove-orphans");
|
|
174
251
|
if (options.services?.length) args.push(...options.services);
|
|
175
|
-
return run(args, undefined,
|
|
252
|
+
return run(args, undefined, composeUpTimeoutMs(), collectEnvOverrides(options.envFiles));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Timeout budget for `compose up`. A first install extracts multi-GB images
|
|
257
|
+
* (voice CUDA ~7.6 GB) onto slow disks; the previous hard 5-minute cap
|
|
258
|
+
* SIGTERM-killed the start mid-extraction and surfaced as an empty/opaque
|
|
259
|
+
* error. Default 30 min, override with OP_COMPOSE_UP_TIMEOUT_MS. Kept bounded
|
|
260
|
+
* (never removed) so a genuinely hung start still eventually fails.
|
|
261
|
+
*/
|
|
262
|
+
function composeUpTimeoutMs(): number {
|
|
263
|
+
const raw = process.env.OP_COMPOSE_UP_TIMEOUT_MS?.trim();
|
|
264
|
+
const parsed = raw ? Number(raw) : NaN;
|
|
265
|
+
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
266
|
+
return 30 * 60_000;
|
|
176
267
|
}
|
|
177
268
|
|
|
178
269
|
/**
|
|
@@ -184,6 +275,11 @@ export async function composeDown(
|
|
|
184
275
|
profiles?: string[];
|
|
185
276
|
removeVolumes?: boolean;
|
|
186
277
|
envFiles?: string[];
|
|
278
|
+
// Remove containers for services NOT in the (profile-resolved) compose set.
|
|
279
|
+
// Needed to clean up a previously-enabled-then-disabled profile-gated addon
|
|
280
|
+
// (e.g. in-stack Ollama): with its profile now inactive, `down` alone leaves
|
|
281
|
+
// its stopped container behind because compose no longer "sees" the service.
|
|
282
|
+
removeOrphans?: boolean;
|
|
187
283
|
}
|
|
188
284
|
): Promise<DockerResult> {
|
|
189
285
|
await runPreflight(options);
|
|
@@ -193,6 +289,7 @@ export async function composeDown(
|
|
|
193
289
|
const args = buildComposeArgs(options);
|
|
194
290
|
args.push("down");
|
|
195
291
|
if (options.removeVolumes) args.push("-v");
|
|
292
|
+
if (options.removeOrphans) args.push("--remove-orphans");
|
|
196
293
|
return run(args, undefined);
|
|
197
294
|
}
|
|
198
295
|
|
|
@@ -11,7 +11,7 @@ import { createLogger } from "../logger.js";
|
|
|
11
11
|
|
|
12
12
|
const logger = createLogger("hardware-detect");
|
|
13
13
|
|
|
14
|
-
export type GpuVendor = "nvidia" | "amd" | "unknown";
|
|
14
|
+
export type GpuVendor = "nvidia" | "amd" | "apple" | "unknown";
|
|
15
15
|
|
|
16
16
|
export type GpuInfo = {
|
|
17
17
|
vendor: GpuVendor;
|
|
@@ -27,6 +27,8 @@ type GpuProbe = {
|
|
|
27
27
|
args: string[];
|
|
28
28
|
/** Pure parser: tool stdout -> detected GPUs. Must not throw. */
|
|
29
29
|
parse: (stdout: string) => GpuInfo[];
|
|
30
|
+
/** Optional gate — when present and false, the probe is skipped entirely. */
|
|
31
|
+
enabled?: boolean;
|
|
30
32
|
};
|
|
31
33
|
|
|
32
34
|
/** Parse `nvidia-smi --query-gpu=name,memory.total --format=csv,noheader,nounits`. */
|
|
@@ -68,6 +70,25 @@ export function parseRocmSmi(stdout: string): GpuInfo[] {
|
|
|
68
70
|
return out;
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Parse `sysctl -n hw.memsize hw.model` (two lines: total bytes, then model id)
|
|
75
|
+
* into an Apple-Silicon GpuInfo. `hw.memsize` is UNIFIED memory shared between
|
|
76
|
+
* CPU and GPU, carried here as vramMb for informational display only — callers
|
|
77
|
+
* must NOT treat it like discrete VRAM (see setup-recommendation). Pure; never throws.
|
|
78
|
+
*/
|
|
79
|
+
export function parseAppleSilicon(stdout: string): GpuInfo[] {
|
|
80
|
+
const lines = stdout
|
|
81
|
+
.split("\n")
|
|
82
|
+
.map((l) => l.trim())
|
|
83
|
+
.filter(Boolean);
|
|
84
|
+
if (lines.length === 0) return [];
|
|
85
|
+
const bytes = Number.parseInt(lines[0] ?? "", 10);
|
|
86
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return [];
|
|
87
|
+
const vramMb = Math.round(bytes / (1024 * 1024));
|
|
88
|
+
const model = lines[1] && lines[1].length > 0 ? lines[1] : "arm64";
|
|
89
|
+
return [{ vendor: "apple", name: `Apple Silicon (${model})`, vramMb }];
|
|
90
|
+
}
|
|
91
|
+
|
|
71
92
|
const GPU_PROBES: GpuProbe[] = [
|
|
72
93
|
{
|
|
73
94
|
vendor: "nvidia",
|
|
@@ -81,6 +102,16 @@ const GPU_PROBES: GpuProbe[] = [
|
|
|
81
102
|
args: ["--showmeminfo", "vram", "--showproductname", "--json"],
|
|
82
103
|
parse: parseRocmSmi,
|
|
83
104
|
},
|
|
105
|
+
{
|
|
106
|
+
// Apple Silicon Macs expose no nvidia-smi/rocm-smi. Probe macOS sysctl for
|
|
107
|
+
// unified-memory size + model id. Gated to darwin/arm64 so it never runs (and
|
|
108
|
+
// never spawns a missing binary) on Linux/Intel.
|
|
109
|
+
vendor: "apple",
|
|
110
|
+
command: "sysctl",
|
|
111
|
+
args: ["-n", "hw.memsize", "hw.model"],
|
|
112
|
+
parse: parseAppleSilicon,
|
|
113
|
+
enabled: process.platform === "darwin" && process.arch === "arm64",
|
|
114
|
+
},
|
|
84
115
|
];
|
|
85
116
|
|
|
86
117
|
function run(command: string, args: string[], timeoutMs = 3_000): Promise<string | null> {
|
|
@@ -100,6 +131,7 @@ export async function detectGpu(): Promise<GpuInfo | null> {
|
|
|
100
131
|
const found: GpuInfo[] = [];
|
|
101
132
|
await Promise.all(
|
|
102
133
|
GPU_PROBES.map(async (probe) => {
|
|
134
|
+
if (probe.enabled === false) return;
|
|
103
135
|
const stdout = await run(probe.command, probe.args);
|
|
104
136
|
if (stdout === null) return;
|
|
105
137
|
try {
|
|
@@ -369,17 +369,32 @@ export function buildComposeFileList(state: ControlPlaneState): string[] {
|
|
|
369
369
|
return discoverStackOverlays(state.stackDir, state.homeDir);
|
|
370
370
|
}
|
|
371
371
|
|
|
372
|
+
// Channel addons that require the guardian ingress. Mirrors the profile gate on
|
|
373
|
+
// the guardian service in channels.compose.yml (profiles: addon.{chat,api,
|
|
374
|
+
// discord,slack}) and the built-in channel id list used in registry.ts /
|
|
375
|
+
// config-persistence.ts. Guardian is shared infra for these, not an addon
|
|
376
|
+
// service of its own (getAddonServiceNames deliberately excludes it).
|
|
377
|
+
const CHANNEL_ADDON_IDS = ["api", "chat", "discord", "slack"];
|
|
378
|
+
|
|
372
379
|
export async function buildManagedServices(state: ControlPlaneState): Promise<string[]> {
|
|
373
380
|
const composeOpts = buildComposeOptions(state);
|
|
374
381
|
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
//
|
|
382
|
+
// The assistant is the only ALWAYS-on core service. The guardian is channel
|
|
383
|
+
// ingress — profile-gated to the channel addons in channels.compose.yml, so
|
|
384
|
+
// with zero channels enabled it is never deployed. Seeding it unconditionally
|
|
385
|
+
// made the installer health-wait on a guardian that never starts (a ~5-minute
|
|
386
|
+
// hang when no channel is selected). Add it back ONLY when a channel is
|
|
387
|
+
// enabled; that also preserves the #450 need to force-recreate guardian on
|
|
388
|
+
// upgrade when channel profiles ARE active (it is excluded from
|
|
389
|
+
// getAddonServiceNames, so the fallback below would otherwise drop it).
|
|
390
|
+
const enabledAddons = listEnabledAddonIds(state.homeDir);
|
|
391
|
+
const channelsEnabled = enabledAddons.some((a) => CHANNEL_ADDON_IDS.includes(a));
|
|
392
|
+
const services = new Set<string>(["assistant"]);
|
|
393
|
+
if (channelsEnabled) services.add("guardian");
|
|
394
|
+
|
|
395
|
+
// Prefer compose-derived service list when Docker is available. Resolved with
|
|
396
|
+
// the active profiles, this already includes guardian iff a channel profile
|
|
397
|
+
// is active — the explicit add above just guarantees it for the fallback.
|
|
383
398
|
if (composeOpts.files.length > 0 && !process.env.OP_SKIP_COMPOSE_PREFLIGHT) {
|
|
384
399
|
const result = await composeConfigServices(composeOpts);
|
|
385
400
|
if (result.ok && result.services.length > 0) {
|
|
@@ -388,8 +403,9 @@ export async function buildManagedServices(state: ControlPlaneState): Promise<st
|
|
|
388
403
|
}
|
|
389
404
|
}
|
|
390
405
|
|
|
391
|
-
// Fallback: static inference from
|
|
392
|
-
|
|
406
|
+
// Fallback: static inference from assistant (+ guardian when channels) +
|
|
407
|
+
// active addon overlays.
|
|
408
|
+
for (const addon of enabledAddons) {
|
|
393
409
|
for (const s of getAddonServiceNames(state.homeDir, addon)) services.add(s);
|
|
394
410
|
}
|
|
395
411
|
return [...services];
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parseOllamaHostEnv } from "./model-runner.js";
|
|
3
|
+
|
|
4
|
+
describe("parseOllamaHostEnv", () => {
|
|
5
|
+
// ── null / empty inputs ────────────────────────────────────────────────
|
|
6
|
+
test("undefined → null", () => {
|
|
7
|
+
expect(parseOllamaHostEnv(undefined)).toBeNull();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("empty string → null", () => {
|
|
11
|
+
expect(parseOllamaHostEnv("")).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("whitespace only → null", () => {
|
|
15
|
+
expect(parseOllamaHostEnv(" ")).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// ── garbage ────────────────────────────────────────────────────────────
|
|
19
|
+
test("/garbage → null", () => {
|
|
20
|
+
expect(parseOllamaHostEnv("/garbage")).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("has spaces → null", () => {
|
|
24
|
+
expect(parseOllamaHostEnv("my host:1234")).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("port out of range 0 → null", () => {
|
|
28
|
+
expect(parseOllamaHostEnv("0")).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("port out of range 99999 → null", () => {
|
|
32
|
+
expect(parseOllamaHostEnv("99999")).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("invalid host:port (port not numeric) → null", () => {
|
|
36
|
+
expect(parseOllamaHostEnv("localhost:abc")).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// ── bare port ──────────────────────────────────────────────────────────
|
|
40
|
+
test("bare port '9999' → http://localhost:9999", () => {
|
|
41
|
+
expect(parseOllamaHostEnv("9999")).toBe("http://localhost:9999");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("bare port '11434' → http://localhost:11434", () => {
|
|
45
|
+
expect(parseOllamaHostEnv("11434")).toBe("http://localhost:11434");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ── host:port ──────────────────────────────────────────────────────────
|
|
49
|
+
test("'127.0.0.1:9999' → http://127.0.0.1:9999", () => {
|
|
50
|
+
expect(parseOllamaHostEnv("127.0.0.1:9999")).toBe("http://127.0.0.1:9999");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("'0.0.0.0:9999' → http://0.0.0.0:9999", () => {
|
|
54
|
+
expect(parseOllamaHostEnv("0.0.0.0:9999")).toBe("http://0.0.0.0:9999");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("'myhost:1234' → http://myhost:1234", () => {
|
|
58
|
+
expect(parseOllamaHostEnv("myhost:1234")).toBe("http://myhost:1234");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ── full HTTP URL ──────────────────────────────────────────────────────
|
|
62
|
+
test("'http://127.0.0.1:9999' → http://127.0.0.1:9999", () => {
|
|
63
|
+
expect(parseOllamaHostEnv("http://127.0.0.1:9999")).toBe("http://127.0.0.1:9999");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("'http://127.0.0.1:9999/some/path' strips path", () => {
|
|
67
|
+
// URL.origin includes scheme+host+port, strips path
|
|
68
|
+
expect(parseOllamaHostEnv("http://127.0.0.1:9999/some/path")).toBe("http://127.0.0.1:9999");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ── HTTPS URL ─────────────────────────────────────────────────────────
|
|
72
|
+
test("'https://h:443' → https://h:443", () => {
|
|
73
|
+
// URL.origin suppresses default port 443 for https
|
|
74
|
+
const result = parseOllamaHostEnv("https://h:443");
|
|
75
|
+
expect(result).toBe("https://h");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("'https://secure.host:8443' → https://secure.host:8443", () => {
|
|
79
|
+
expect(parseOllamaHostEnv("https://secure.host:8443")).toBe("https://secure.host:8443");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ── bare hostname ──────────────────────────────────────────────────────
|
|
83
|
+
test("'localhost' → http://localhost:11434 (default port)", () => {
|
|
84
|
+
expect(parseOllamaHostEnv("localhost")).toBe("http://localhost:11434");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("'my-host.local' → http://my-host.local:11434", () => {
|
|
88
|
+
expect(parseOllamaHostEnv("my-host.local")).toBe("http://my-host.local:11434");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ── whitespace trimming ────────────────────────────────────────────────
|
|
92
|
+
test("leading/trailing whitespace is trimmed", () => {
|
|
93
|
+
expect(parseOllamaHostEnv(" 9999 ")).toBe("http://localhost:9999");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -35,63 +35,193 @@ async function validateOllamaResponse(res: Response): Promise<boolean> {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
38
|
+
// ── Env-based URL parsers ────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse an OLLAMA_HOST env value into a normalized base URL string, or null if
|
|
42
|
+
* the input is absent/malformed.
|
|
43
|
+
*
|
|
44
|
+
* Accepted forms:
|
|
45
|
+
* - bare port: "9999" → "http://localhost:9999"
|
|
46
|
+
* - host:port: "127.0.0.1:9999" → "http://127.0.0.1:9999"
|
|
47
|
+
* - full URL (http/https): "http://h:9999" → "http://h:9999"
|
|
48
|
+
* - bare hostname: "localhost" → "http://localhost:11434" (default port)
|
|
49
|
+
*
|
|
50
|
+
* Returns null for empty string, non-numeric bare tokens that aren't valid
|
|
51
|
+
* hostnames, and any other garbage.
|
|
52
|
+
*/
|
|
53
|
+
export function parseOllamaHostEnv(raw: string | undefined): string | null {
|
|
54
|
+
if (!raw || raw.trim() === "") return null;
|
|
55
|
+
const s = raw.trim();
|
|
56
|
+
|
|
57
|
+
// Already a full URL
|
|
58
|
+
if (s.startsWith("http://") || s.startsWith("https://")) {
|
|
59
|
+
try {
|
|
60
|
+
const u = new URL(s);
|
|
61
|
+
// Must have a usable host
|
|
62
|
+
if (!u.hostname) return null;
|
|
63
|
+
// Return origin (scheme + host + port, no path)
|
|
64
|
+
return u.origin;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Bare port number e.g. "9999"
|
|
71
|
+
if (/^\d+$/.test(s)) {
|
|
72
|
+
const port = parseInt(s, 10);
|
|
73
|
+
if (port < 1 || port > 65535) return null;
|
|
74
|
+
return `http://localhost:${port}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// host:port e.g. "127.0.0.1:9999" or "myhost:1234"
|
|
78
|
+
const colonIdx = s.lastIndexOf(":");
|
|
79
|
+
if (colonIdx > 0) {
|
|
80
|
+
const host = s.slice(0, colonIdx);
|
|
81
|
+
const portStr = s.slice(colonIdx + 1);
|
|
82
|
+
if (!/^\d+$/.test(portStr)) return null;
|
|
83
|
+
const port = parseInt(portStr, 10);
|
|
84
|
+
if (port < 1 || port > 65535) return null;
|
|
85
|
+
// Basic hostname/IP validity — must not contain spaces or slashes
|
|
86
|
+
if (/[\s/]/.test(host)) return null;
|
|
87
|
+
return `http://${host}:${port}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Bare hostname (no port) — use Ollama's default port
|
|
91
|
+
// Accept only simple hostname-like tokens (letters, digits, hyphens, dots)
|
|
92
|
+
if (/^[a-zA-Z0-9._-]+$/.test(s)) {
|
|
93
|
+
return `http://${s}:11434`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Parse a bare port env value (e.g. LMSTUDIO_PORT, MODEL_RUNNER_PORT) into an
|
|
101
|
+
* integer, or null if absent/malformed.
|
|
102
|
+
*/
|
|
103
|
+
function parsePortEnv(raw: string | undefined): number | null {
|
|
104
|
+
if (!raw || raw.trim() === "") return null;
|
|
105
|
+
const n = parseInt(raw.trim(), 10);
|
|
106
|
+
if (!Number.isFinite(n) || n < 1 || n > 65535) return null;
|
|
107
|
+
return n;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Probe timeout ────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Probe timeout in milliseconds.
|
|
114
|
+
*
|
|
115
|
+
* 5 000 ms is chosen to tolerate slow/loaded machines without blocking the
|
|
116
|
+
* caller for too long. Override with OP_LOCAL_PROBE_TIMEOUT_MS (clamped to
|
|
117
|
+
* a floor of 1 000 ms so the env value can't make probes never-timeout).
|
|
118
|
+
*/
|
|
119
|
+
function getProbeTimeoutMs(): number {
|
|
120
|
+
const floor = 1000;
|
|
121
|
+
const envRaw = process.env["OP_LOCAL_PROBE_TIMEOUT_MS"];
|
|
122
|
+
if (envRaw) {
|
|
123
|
+
const n = parseInt(envRaw, 10);
|
|
124
|
+
if (Number.isFinite(n) && n >= floor) return n;
|
|
125
|
+
}
|
|
126
|
+
return 5000;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Dynamic probe builders ───────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/** Build the ordered probe list for model-runner, prepending any env-configured port. */
|
|
132
|
+
function buildModelRunnerProbes(): ProviderProbe[] {
|
|
133
|
+
const defaults: ProviderProbe[] = [
|
|
134
|
+
{
|
|
135
|
+
url: "http://model-runner.docker.internal/engines/v1/models",
|
|
136
|
+
baseUrl: "http://model-runner.docker.internal/engines",
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
url: "http://model-runner.docker.internal:12434/engines/v1/models",
|
|
140
|
+
baseUrl: "http://model-runner.docker.internal:12434/engines",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
url: "http://host.docker.internal:12434/engines/v1/models",
|
|
144
|
+
baseUrl: "http://host.docker.internal:12434/engines",
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
url: "http://localhost:12434/engines/v1/models",
|
|
148
|
+
baseUrl: "http://localhost:12434/engines",
|
|
149
|
+
},
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
const port = parsePortEnv(process.env["MODEL_RUNNER_PORT"]);
|
|
153
|
+
if (port !== null) {
|
|
154
|
+
return [
|
|
69
155
|
{
|
|
70
|
-
url:
|
|
71
|
-
baseUrl:
|
|
72
|
-
validate: validateOllamaResponse,
|
|
156
|
+
url: `http://localhost:${port}/engines/v1/models`,
|
|
157
|
+
baseUrl: `http://localhost:${port}/engines`,
|
|
73
158
|
},
|
|
159
|
+
...defaults,
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
return defaults;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Build the ordered probe list for ollama, prepending any env-configured endpoint. */
|
|
166
|
+
function buildOllamaProbes(): ProviderProbe[] {
|
|
167
|
+
const defaults: ProviderProbe[] = [
|
|
168
|
+
{
|
|
169
|
+
// In-stack Ollama (compose service on assistant_net)
|
|
170
|
+
url: "http://ollama:11434/api/tags",
|
|
171
|
+
baseUrl: "http://ollama:11434",
|
|
172
|
+
validate: validateOllamaResponse,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
url: "http://host.docker.internal:11434/api/tags",
|
|
176
|
+
baseUrl: "http://host.docker.internal:11434",
|
|
177
|
+
validate: validateOllamaResponse,
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
url: "http://localhost:11434/api/tags",
|
|
181
|
+
baseUrl: "http://localhost:11434",
|
|
182
|
+
validate: validateOllamaResponse,
|
|
183
|
+
},
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
const base = parseOllamaHostEnv(process.env["OLLAMA_HOST"]);
|
|
187
|
+
if (base !== null) {
|
|
188
|
+
return [
|
|
74
189
|
{
|
|
75
|
-
url:
|
|
76
|
-
baseUrl:
|
|
190
|
+
url: `${base}/api/tags`,
|
|
191
|
+
baseUrl: base,
|
|
77
192
|
validate: validateOllamaResponse,
|
|
78
193
|
},
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
194
|
+
...defaults,
|
|
195
|
+
];
|
|
196
|
+
}
|
|
197
|
+
return defaults;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Build the ordered probe list for lmstudio, prepending any env-configured port. */
|
|
201
|
+
function buildLmStudioProbes(): ProviderProbe[] {
|
|
202
|
+
const defaults: ProviderProbe[] = [
|
|
203
|
+
{
|
|
204
|
+
url: "http://host.docker.internal:1234/v1/models",
|
|
205
|
+
baseUrl: "http://host.docker.internal:1234",
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
url: "http://localhost:1234/v1/models",
|
|
209
|
+
baseUrl: "http://localhost:1234",
|
|
210
|
+
},
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
const port = parsePortEnv(process.env["LMSTUDIO_PORT"] ?? process.env["LM_STUDIO_PORT"]);
|
|
214
|
+
if (port !== null) {
|
|
215
|
+
return [
|
|
88
216
|
{
|
|
89
|
-
url:
|
|
90
|
-
baseUrl:
|
|
217
|
+
url: `http://localhost:${port}/v1/models`,
|
|
218
|
+
baseUrl: `http://localhost:${port}`,
|
|
91
219
|
},
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
220
|
+
...defaults,
|
|
221
|
+
];
|
|
222
|
+
}
|
|
223
|
+
return defaults;
|
|
224
|
+
}
|
|
95
225
|
|
|
96
226
|
// ── Detection ────────────────────────────────────────────────────────────
|
|
97
227
|
|
|
@@ -100,17 +230,42 @@ const LOCAL_PROVIDER_PROBES: { provider: string; probes: ProviderProbe[] }[] = [
|
|
|
100
230
|
* Returns results for all providers (available or not) in parallel.
|
|
101
231
|
*/
|
|
102
232
|
export async function detectLocalProviders(): Promise<LocalProviderDetection[]> {
|
|
233
|
+
const probeTimeoutMs = getProbeTimeoutMs();
|
|
234
|
+
|
|
235
|
+
const providerProbes = [
|
|
236
|
+
{ provider: "model-runner", probes: buildModelRunnerProbes() },
|
|
237
|
+
{ provider: "ollama", probes: buildOllamaProbes() },
|
|
238
|
+
{ provider: "lmstudio", probes: buildLmStudioProbes() },
|
|
239
|
+
];
|
|
240
|
+
|
|
103
241
|
const results = await Promise.all(
|
|
104
|
-
|
|
242
|
+
providerProbes.map(async ({ provider, probes }) => {
|
|
105
243
|
for (const { url: probeUrl, baseUrl, validate } of probes) {
|
|
106
244
|
try {
|
|
107
245
|
const res = await fetch(probeUrl, {
|
|
108
|
-
signal: AbortSignal.timeout(
|
|
246
|
+
signal: AbortSignal.timeout(probeTimeoutMs),
|
|
109
247
|
});
|
|
110
248
|
if (res.ok) {
|
|
111
|
-
if (validate
|
|
112
|
-
|
|
113
|
-
|
|
249
|
+
if (validate) {
|
|
250
|
+
// Clone so we can read the body for debug logging without consuming it
|
|
251
|
+
const resForValidate = res.clone();
|
|
252
|
+
const valid = await validate(res);
|
|
253
|
+
if (!valid) {
|
|
254
|
+
// Read a snippet of the body to aid debugging — 500-char cap
|
|
255
|
+
let bodySnippet = "(unreadable)";
|
|
256
|
+
try {
|
|
257
|
+
const raw = await resForValidate.text();
|
|
258
|
+
bodySnippet = raw.slice(0, 500);
|
|
259
|
+
} catch {
|
|
260
|
+
// ignore
|
|
261
|
+
}
|
|
262
|
+
logger.debug("provider probe response failed validation", {
|
|
263
|
+
provider,
|
|
264
|
+
url: probeUrl,
|
|
265
|
+
bodySnippet,
|
|
266
|
+
});
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
114
269
|
}
|
|
115
270
|
logger.debug("detected local provider", { provider, url: baseUrl });
|
|
116
271
|
return { provider, url: baseUrl, available: true };
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
MIN_LOCAL_GPU_VRAM_MB,
|
|
6
6
|
type SetupRecommendationInput,
|
|
7
7
|
} from "./setup-recommendation.js";
|
|
8
|
-
import { parseNvidiaSmi, parseRocmSmi, type GpuInfo } from "./hardware-detect.js";
|
|
8
|
+
import { parseNvidiaSmi, parseRocmSmi, parseAppleSilicon, type GpuInfo } from "./hardware-detect.js";
|
|
9
9
|
|
|
10
10
|
const base: SetupRecommendationInput = { cloudProviders: [], hostProviders: [], gpu: null };
|
|
11
11
|
const gpu = (vendor: GpuInfo["vendor"], vramMb: number, name = "Test GPU"): GpuInfo => ({ vendor, name, vramMb });
|
|
@@ -53,6 +53,39 @@ describe("recommendSetup", () => {
|
|
|
53
53
|
expect(r.action).toBe("connect-manually");
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
+
test("darwin + apple GPU + no provider -> connect-manually (NOT enable-ollama), Mac-tailored alert", () => {
|
|
57
|
+
const r = recommendSetup({ ...base, platform: "darwin", gpu: gpu("apple", 65536, "Apple Silicon (Mac15,7)") });
|
|
58
|
+
expect(r.action).toBe("connect-manually");
|
|
59
|
+
expect(r.action).not.toBe("enable-ollama");
|
|
60
|
+
if (r.action === "connect-manually") {
|
|
61
|
+
expect(r.alert).toContain("macOS");
|
|
62
|
+
expect(r.alert).toContain("Metal");
|
|
63
|
+
expect(r.alert.toLowerCase()).toContain("ollama");
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("darwin + apple GPU never selects cuda/rocm (no in-stack enable)", () => {
|
|
68
|
+
// Even with huge unified memory, darwin+apple must not enable in-stack ollama.
|
|
69
|
+
const r = recommendSetup({ ...base, platform: "darwin", gpu: gpu("apple", 131072) });
|
|
70
|
+
expect(r.action).not.toBe("enable-ollama");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("darwin + host ollama running -> still use-host-providers (wins over apple guidance)", () => {
|
|
74
|
+
const r = recommendSetup({
|
|
75
|
+
...base,
|
|
76
|
+
platform: "darwin",
|
|
77
|
+
hostProviders: [{ provider: "ollama", url: "http://localhost:11434" }],
|
|
78
|
+
gpu: gpu("apple", 65536),
|
|
79
|
+
});
|
|
80
|
+
expect(r.action).toBe("use-host-providers");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("linux + nvidia >= threshold -> still enable-ollama cuda (unchanged)", () => {
|
|
84
|
+
const r = recommendSetup({ ...base, platform: "linux", gpu: gpu("nvidia", 24576) });
|
|
85
|
+
expect(r.action).toBe("enable-ollama");
|
|
86
|
+
if (r.action === "enable-ollama") expect(r.profileVariant).toBe("cuda");
|
|
87
|
+
});
|
|
88
|
+
|
|
56
89
|
test("no cloud, no host, no GPU -> connect-manually", () => {
|
|
57
90
|
const r = recommendSetup(base);
|
|
58
91
|
expect(r.action).toBe("connect-manually");
|
|
@@ -60,14 +93,108 @@ describe("recommendSetup", () => {
|
|
|
60
93
|
});
|
|
61
94
|
});
|
|
62
95
|
|
|
96
|
+
describe("hostCredentialCount precedence", () => {
|
|
97
|
+
test("(a) host-configured + capable GPU + no cloud -> NOT enable-ollama (host wins)", () => {
|
|
98
|
+
const r = recommendSetup({
|
|
99
|
+
cloudProviders: [],
|
|
100
|
+
hostProviders: [],
|
|
101
|
+
gpu: gpu("nvidia", 24576),
|
|
102
|
+
hostCredentialCount: 2,
|
|
103
|
+
});
|
|
104
|
+
expect(r.action).not.toBe("enable-ollama");
|
|
105
|
+
expect(r.action).toBe("connect-manually");
|
|
106
|
+
if (r.action === "connect-manually") {
|
|
107
|
+
expect(r.alert).toContain("host OpenCode");
|
|
108
|
+
expect(r.alert).toContain("Import");
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("(b) cloud still wins over host-configured", () => {
|
|
113
|
+
const r = recommendSetup({
|
|
114
|
+
cloudProviders: ["openai"],
|
|
115
|
+
hostProviders: [],
|
|
116
|
+
gpu: null,
|
|
117
|
+
hostCredentialCount: 3,
|
|
118
|
+
});
|
|
119
|
+
expect(r.action).toBe("use-cloud");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("(c) host-configured beats a running host Ollama (import hint over auto-add)", () => {
|
|
123
|
+
// When the user has both a running host Ollama AND host OpenCode credentials,
|
|
124
|
+
// the richer "import your existing setup" guidance wins over the auto-add path.
|
|
125
|
+
const r = recommendSetup({
|
|
126
|
+
cloudProviders: [],
|
|
127
|
+
hostProviders: [{ provider: "ollama", url: "http://localhost:11434" }],
|
|
128
|
+
gpu: null,
|
|
129
|
+
hostCredentialCount: 1,
|
|
130
|
+
});
|
|
131
|
+
expect(r.action).toBe("connect-manually");
|
|
132
|
+
expect(r.action).not.toBe("use-host-providers");
|
|
133
|
+
if (r.action === "connect-manually") expect(r.alert).toContain("host OpenCode");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("host-configured with zero credentials -> falls through to normal rules", () => {
|
|
137
|
+
// hostCredentialCount: 0 (or absent) must not suppress the normal GPU path.
|
|
138
|
+
const r = recommendSetup({ ...base, gpu: gpu("nvidia", 24576), hostCredentialCount: 0 });
|
|
139
|
+
expect(r.action).toBe("enable-ollama");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("host-configured omitted (undefined) -> falls through to normal rules", () => {
|
|
143
|
+
// No regression: callers that don't pass hostCredentialCount get the old behaviour.
|
|
144
|
+
const r = recommendSetup({ ...base, gpu: gpu("nvidia", 24576) });
|
|
145
|
+
expect(r.action).toBe("enable-ollama");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("host-configured + no GPU + no cloud -> connect-manually with import alert", () => {
|
|
149
|
+
const r = recommendSetup({ ...base, hostCredentialCount: 1 });
|
|
150
|
+
expect(r.action).toBe("connect-manually");
|
|
151
|
+
if (r.action === "connect-manually") expect(r.alert).toContain("host OpenCode");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("host-configured + darwin apple GPU -> connect-manually (host wins, not apple guidance)", () => {
|
|
155
|
+
// Both host-configured and darwin+apple would return connect-manually, but
|
|
156
|
+
// host-configured takes priority so the alert is the import one, not the Metal one.
|
|
157
|
+
const r = recommendSetup({
|
|
158
|
+
...base,
|
|
159
|
+
platform: "darwin",
|
|
160
|
+
gpu: gpu("apple", 65536),
|
|
161
|
+
hostCredentialCount: 2,
|
|
162
|
+
});
|
|
163
|
+
expect(r.action).toBe("connect-manually");
|
|
164
|
+
if (r.action === "connect-manually") {
|
|
165
|
+
expect(r.alert).toContain("host OpenCode");
|
|
166
|
+
expect(r.alert).not.toContain("Metal");
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
63
171
|
describe("gpuToProfileVariant", () => {
|
|
64
|
-
test("nvidia->cuda, amd->rocm, unknown->cpu", () => {
|
|
172
|
+
test("nvidia->cuda, amd->rocm, apple->cpu, unknown->cpu", () => {
|
|
65
173
|
expect(gpuToProfileVariant(gpu("nvidia", 8192))).toBe("cuda");
|
|
66
174
|
expect(gpuToProfileVariant(gpu("amd", 8192))).toBe("rocm");
|
|
175
|
+
expect(gpuToProfileVariant(gpu("apple", 65536))).toBe("cpu");
|
|
67
176
|
expect(gpuToProfileVariant(gpu("unknown", 8192))).toBe("cpu");
|
|
68
177
|
});
|
|
69
178
|
});
|
|
70
179
|
|
|
180
|
+
describe("parseAppleSilicon", () => {
|
|
181
|
+
test("parses hw.memsize bytes -> MiB + vendor apple + model name", () => {
|
|
182
|
+
const stdout = `${16 * 1024 * 1024 * 1024}\nMac15,7\n`;
|
|
183
|
+
const out = parseAppleSilicon(stdout);
|
|
184
|
+
expect(out).toEqual([{ vendor: "apple", name: "Apple Silicon (Mac15,7)", vramMb: 16384 }]);
|
|
185
|
+
});
|
|
186
|
+
test("missing model line -> falls back to arm64", () => {
|
|
187
|
+
const out = parseAppleSilicon(`${8 * 1024 * 1024 * 1024}\n`);
|
|
188
|
+
expect(out[0]?.vendor).toBe("apple");
|
|
189
|
+
expect(out[0]?.name).toBe("Apple Silicon (arm64)");
|
|
190
|
+
expect(out[0]?.vramMb).toBe(8192);
|
|
191
|
+
});
|
|
192
|
+
test("garbage / empty -> []", () => {
|
|
193
|
+
expect(parseAppleSilicon("")).toEqual([]);
|
|
194
|
+
expect(parseAppleSilicon("not-a-number\nMac15,7")).toEqual([]);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
71
198
|
describe("parseNvidiaSmi", () => {
|
|
72
199
|
test("parses name + VRAM (MiB), handles commas in name", () => {
|
|
73
200
|
const out = parseNvidiaSmi("NVIDIA GeForce RTX 4090, 24564\nNVIDIA A100, 81920\n");
|
|
@@ -18,6 +18,10 @@ export const MIN_LOCAL_GPU_VRAM_MB = 8 * 1024;
|
|
|
18
18
|
const VENDOR_PROFILE_VARIANT: Record<GpuVendor, "cuda" | "rocm" | "cpu"> = {
|
|
19
19
|
nvidia: "cuda",
|
|
20
20
|
amd: "rocm",
|
|
21
|
+
// The in-stack Ollama container on a Mac is a Linux container with no Metal
|
|
22
|
+
// access, so it can only ever run CPU. (On darwin apple GPUs are routed to
|
|
23
|
+
// host-Ollama guidance and never reach enable-ollama — see recommendSetup.)
|
|
24
|
+
apple: "cpu",
|
|
21
25
|
unknown: "cpu",
|
|
22
26
|
};
|
|
23
27
|
|
|
@@ -34,6 +38,23 @@ export type SetupRecommendationInput = {
|
|
|
34
38
|
hostProviders: DetectedHostProvider[];
|
|
35
39
|
/** Best detected GPU, or null. */
|
|
36
40
|
gpu: GpuInfo | null;
|
|
41
|
+
/**
|
|
42
|
+
* Host platform. Defaults to `process.platform` when omitted, but the decision
|
|
43
|
+
* logic only reads this field (never `process.*`) so the function stays pure.
|
|
44
|
+
* On darwin the in-stack Linux Ollama can't reach the Mac's Metal GPU, so an
|
|
45
|
+
* apple GPU is routed to host-Ollama guidance instead of enable-ollama.
|
|
46
|
+
*/
|
|
47
|
+
platform?: NodeJS.Platform;
|
|
48
|
+
/**
|
|
49
|
+
* Number of credentials found in the host user's OpenCode auth.json
|
|
50
|
+
* (~/.local/share/opencode/auth.json). When > 0 the host OpenCode has
|
|
51
|
+
* configured providers that should be imported rather than bypassed by
|
|
52
|
+
* auto-enabling the bundled in-stack Ollama.
|
|
53
|
+
*
|
|
54
|
+
* Gathered by the caller via detectHostOpenCode() — kept out of this module
|
|
55
|
+
* so the function stays pure and unit-testable.
|
|
56
|
+
*/
|
|
57
|
+
hostCredentialCount?: number;
|
|
37
58
|
};
|
|
38
59
|
|
|
39
60
|
export type SetupRecommendation =
|
|
@@ -56,18 +77,40 @@ const labelHostProviders = (h: DetectedHostProvider[]): string =>
|
|
|
56
77
|
* Decide what setup should do, given detected providers + hardware.
|
|
57
78
|
*
|
|
58
79
|
* Order (first match wins):
|
|
59
|
-
* 1. cloud provider connected
|
|
60
|
-
* 2. host
|
|
61
|
-
* 3.
|
|
62
|
-
* 4.
|
|
80
|
+
* 1. cloud provider connected -> use it.
|
|
81
|
+
* 2. host OpenCode has credentials -> steer to import; NEVER auto-enable Ollama.
|
|
82
|
+
* 3. host-local provider running -> add it, proceed.
|
|
83
|
+
* 4. darwin + apple GPU -> guide to HOST Ollama (Metal); never in-stack.
|
|
84
|
+
* 5. capable GPU (>= threshold) -> enable in-stack Ollama.
|
|
85
|
+
* 6. otherwise -> ask the user to connect a provider.
|
|
63
86
|
*/
|
|
64
87
|
export function recommendSetup(input: SetupRecommendationInput): SetupRecommendation {
|
|
65
88
|
const { cloudProviders, hostProviders, gpu } = input;
|
|
89
|
+
const platform = input.platform ?? process.platform;
|
|
90
|
+
const hostCredentialCount = input.hostCredentialCount ?? 0;
|
|
66
91
|
|
|
67
92
|
if (cloudProviders.length > 0) {
|
|
68
93
|
return { action: "use-cloud", cloudProviders };
|
|
69
94
|
}
|
|
70
95
|
|
|
96
|
+
// A host OpenCode installation with credentials outranks auto-enabling the
|
|
97
|
+
// bundled in-stack Ollama. The user already has configured providers — they
|
|
98
|
+
// should import them rather than spin up a new Ollama container. We reuse
|
|
99
|
+
// the existing `connect-manually` action (already handled by the wizard's
|
|
100
|
+
// Providers step) with an import-oriented alert so no new wizard branch is
|
|
101
|
+
// needed. This rule runs BEFORE host-local-provider detection so that even a
|
|
102
|
+
// running host Ollama does not shadow the richer "import your existing setup"
|
|
103
|
+
// guidance when host credentials are present.
|
|
104
|
+
if (hostCredentialCount > 0) {
|
|
105
|
+
return {
|
|
106
|
+
action: "connect-manually",
|
|
107
|
+
alert:
|
|
108
|
+
"Your host OpenCode installation has configured AI providers. " +
|
|
109
|
+
"Import them now to use your existing setup — click \"Import from host OpenCode\" " +
|
|
110
|
+
"on the Providers step, or connect a provider manually.",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
71
114
|
if (hostProviders.length > 0) {
|
|
72
115
|
return {
|
|
73
116
|
action: "use-host-providers",
|
|
@@ -78,6 +121,22 @@ export function recommendSetup(input: SetupRecommendationInput): SetupRecommenda
|
|
|
78
121
|
};
|
|
79
122
|
}
|
|
80
123
|
|
|
124
|
+
// macOS: the in-stack Ollama is a Linux container with no access to the Mac's
|
|
125
|
+
// Metal GPU, so enabling it would silently fall back to slow CPU. When the Mac
|
|
126
|
+
// has an Apple-Silicon GPU and nothing is connected yet, steer the user to a
|
|
127
|
+
// native host Ollama (which DOES use Metal) via connect-manually — reusing the
|
|
128
|
+
// existing action avoids a new wizard branch (chosen for minimal UI impact).
|
|
129
|
+
if (platform === "darwin" && gpu && gpu.vendor === "apple") {
|
|
130
|
+
return {
|
|
131
|
+
action: "connect-manually",
|
|
132
|
+
alert:
|
|
133
|
+
"No AI provider was detected. On macOS, fast local models need Ollama running " +
|
|
134
|
+
"natively (it uses your Apple Silicon / Metal GPU) — the bundled in-stack Ollama " +
|
|
135
|
+
"runs in Linux and cannot reach Metal. Install Ollama for macOS (https://ollama.com/download), " +
|
|
136
|
+
"or connect a provider on the next step.",
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
81
140
|
if (gpu && gpu.vramMb >= MIN_LOCAL_GPU_VRAM_MB) {
|
|
82
141
|
return {
|
|
83
142
|
action: "enable-ollama",
|
|
@@ -104,10 +104,19 @@ describe("performUpgrade force-recreates managed services (#450)", () => {
|
|
|
104
104
|
expect(src).toMatch(/composeUp\(\{[^}]*forceRecreate:\s*true/);
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
-
test("buildManagedServices always
|
|
107
|
+
test("buildManagedServices always manages the assistant", () => {
|
|
108
108
|
const src = readFileSync(join(LIB_CONTROL_PLANE_DIR, "lifecycle.ts"), "utf-8");
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
// The assistant is the only ALWAYS-on core service in the managed set.
|
|
110
|
+
expect(src).toContain('new Set<string>(["assistant"])');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("buildManagedServices adds guardian ONLY when a channel addon is enabled", () => {
|
|
114
|
+
const src = readFileSync(join(LIB_CONTROL_PLANE_DIR, "lifecycle.ts"), "utf-8");
|
|
115
|
+
// Guardian is channel ingress: profile-gated to the channel addons, so it
|
|
116
|
+
// must be added conditionally — never unconditionally seeded (that hung the
|
|
117
|
+
// installer on a guardian that never starts when no channel is enabled).
|
|
118
|
+
expect(src).toContain("channelsEnabled");
|
|
119
|
+
expect(src).toMatch(/if \(channelsEnabled\) services\.add\("guardian"\)/);
|
|
120
|
+
expect(src).not.toContain("new Set<string>(CORE_SERVICES)");
|
|
112
121
|
});
|
|
113
122
|
});
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ export {
|
|
|
11
11
|
LLM_PROVIDERS,
|
|
12
12
|
EMBEDDING_DIMS,
|
|
13
13
|
PROVIDER_KEY_MAP,
|
|
14
|
+
OLLAMA_DEFAULT_MODELS,
|
|
14
15
|
lookupEmbeddingDims,
|
|
15
16
|
} from "./provider-constants.js";
|
|
16
17
|
|
|
@@ -260,10 +261,11 @@ export {
|
|
|
260
261
|
} from "./control-plane/lifecycle.js";
|
|
261
262
|
|
|
262
263
|
// ── Docker ──────────────────────────────────────────────────────────────
|
|
263
|
-
export type { DockerResult } from "./control-plane/docker.js";
|
|
264
|
+
export type { DockerResult, ExistingProject } from "./control-plane/docker.js";
|
|
264
265
|
export {
|
|
265
266
|
checkDocker,
|
|
266
267
|
checkDockerCompose,
|
|
268
|
+
detectExistingProject,
|
|
267
269
|
resolveComposeProjectName,
|
|
268
270
|
composePreflight,
|
|
269
271
|
composeUp,
|
|
@@ -299,7 +301,7 @@ export { detectLocalProviders } from "./control-plane/model-runner.js";
|
|
|
299
301
|
|
|
300
302
|
// ── Hardware detection + setup recommendation ───────────────────────────
|
|
301
303
|
export type { GpuInfo, GpuVendor } from "./control-plane/hardware-detect.js";
|
|
302
|
-
export { detectGpu, parseNvidiaSmi, parseRocmSmi } from "./control-plane/hardware-detect.js";
|
|
304
|
+
export { detectGpu, parseNvidiaSmi, parseRocmSmi, parseAppleSilicon } from "./control-plane/hardware-detect.js";
|
|
303
305
|
export type {
|
|
304
306
|
DetectedHostProvider,
|
|
305
307
|
SetupRecommendation,
|