@openpalm/lib 0.11.2-rc.1 → 0.11.2-rc.3
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/core-assets.ts +16 -1
- package/src/control-plane/docker.ts +6 -0
- 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 +75 -0
- package/src/control-plane/setup-recommendation.ts +35 -5
- package/src/control-plane/upgrade-path.test.ts +13 -4
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -40,7 +40,22 @@ export function readCoreCompose(): string {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export function readBundledStackAsset(name: string): string {
|
|
43
|
-
|
|
43
|
+
// The bundled `.openpalm` assets are resolved relative to import.meta.url,
|
|
44
|
+
// which does not survive bundling into the UI/Electron build (the path lands
|
|
45
|
+
// outside the packaged app). When OP_HOME is already seeded this fallback is
|
|
46
|
+
// never reached; when it is NOT (e.g. a fresh Electron first-run) the read
|
|
47
|
+
// fails. Degrade gracefully to "" so callers (addon profile/service lookups)
|
|
48
|
+
// return empty rather than throwing a 500 — the live OP_HOME assets are the
|
|
49
|
+
// source of truth once seeded.
|
|
50
|
+
try {
|
|
51
|
+
return readFileSync(bundledAssetPath(`config/stack/${name}`), 'utf-8');
|
|
52
|
+
} catch (err) {
|
|
53
|
+
logger.warn('bundled stack asset unavailable (returning empty)', {
|
|
54
|
+
name,
|
|
55
|
+
error: err instanceof Error ? err.message : String(err),
|
|
56
|
+
});
|
|
57
|
+
return '';
|
|
58
|
+
}
|
|
44
59
|
}
|
|
45
60
|
|
|
46
61
|
// ── OpenCode System Config ──────────────────────────────────────────
|
|
@@ -275,6 +275,11 @@ export async function composeDown(
|
|
|
275
275
|
profiles?: string[];
|
|
276
276
|
removeVolumes?: boolean;
|
|
277
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;
|
|
278
283
|
}
|
|
279
284
|
): Promise<DockerResult> {
|
|
280
285
|
await runPreflight(options);
|
|
@@ -284,6 +289,7 @@ export async function composeDown(
|
|
|
284
289
|
const args = buildComposeArgs(options);
|
|
285
290
|
args.push("down");
|
|
286
291
|
if (options.removeVolumes) args.push("-v");
|
|
292
|
+
if (options.removeOrphans) args.push("--remove-orphans");
|
|
287
293
|
return run(args, undefined);
|
|
288
294
|
}
|
|
289
295
|
|
|
@@ -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 };
|
|
@@ -93,6 +93,81 @@ describe("recommendSetup", () => {
|
|
|
93
93
|
});
|
|
94
94
|
});
|
|
95
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
|
+
|
|
96
171
|
describe("gpuToProfileVariant", () => {
|
|
97
172
|
test("nvidia->cuda, amd->rocm, apple->cpu, unknown->cpu", () => {
|
|
98
173
|
expect(gpuToProfileVariant(gpu("nvidia", 8192))).toBe("cuda");
|
|
@@ -45,6 +45,16 @@ export type SetupRecommendationInput = {
|
|
|
45
45
|
* apple GPU is routed to host-Ollama guidance instead of enable-ollama.
|
|
46
46
|
*/
|
|
47
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;
|
|
48
58
|
};
|
|
49
59
|
|
|
50
60
|
export type SetupRecommendation =
|
|
@@ -67,20 +77,40 @@ const labelHostProviders = (h: DetectedHostProvider[]): string =>
|
|
|
67
77
|
* Decide what setup should do, given detected providers + hardware.
|
|
68
78
|
*
|
|
69
79
|
* Order (first match wins):
|
|
70
|
-
* 1. cloud provider connected
|
|
71
|
-
* 2. host
|
|
72
|
-
* 3.
|
|
73
|
-
* 4.
|
|
74
|
-
* 5.
|
|
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.
|
|
75
86
|
*/
|
|
76
87
|
export function recommendSetup(input: SetupRecommendationInput): SetupRecommendation {
|
|
77
88
|
const { cloudProviders, hostProviders, gpu } = input;
|
|
78
89
|
const platform = input.platform ?? process.platform;
|
|
90
|
+
const hostCredentialCount = input.hostCredentialCount ?? 0;
|
|
79
91
|
|
|
80
92
|
if (cloudProviders.length > 0) {
|
|
81
93
|
return { action: "use-cloud", cloudProviders };
|
|
82
94
|
}
|
|
83
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
|
+
|
|
84
114
|
if (hostProviders.length > 0) {
|
|
85
115
|
return {
|
|
86
116
|
action: "use-host-providers",
|
|
@@ -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
|
});
|