@openpalm/lib 0.9.4
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/README.md +83 -0
- package/package.json +30 -0
- package/src/control-plane/audit.ts +40 -0
- package/src/control-plane/channels.ts +196 -0
- package/src/control-plane/connection-mapping.ts +191 -0
- package/src/control-plane/connection-migration-flags.ts +40 -0
- package/src/control-plane/connection-profiles.ts +317 -0
- package/src/control-plane/core-asset-provider.ts +20 -0
- package/src/control-plane/core-assets.ts +292 -0
- package/src/control-plane/docker.ts +448 -0
- package/src/control-plane/env.ts +70 -0
- package/src/control-plane/fs-asset-provider.ts +61 -0
- package/src/control-plane/fs-registry-provider.ts +46 -0
- package/src/control-plane/lifecycle.ts +373 -0
- package/src/control-plane/memory-config.ts +424 -0
- package/src/control-plane/model-runner.ts +101 -0
- package/src/control-plane/paths.ts +77 -0
- package/src/control-plane/registry-provider.ts +19 -0
- package/src/control-plane/scheduler.ts +498 -0
- package/src/control-plane/secrets.ts +177 -0
- package/src/control-plane/setup-status.ts +31 -0
- package/src/control-plane/setup.test.ts +476 -0
- package/src/control-plane/setup.ts +474 -0
- package/src/control-plane/staging.ts +376 -0
- package/src/control-plane/types.ts +165 -0
- package/src/index.ts +295 -0
- package/src/logger.ts +14 -0
- package/src/provider-constants.ts +106 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory LLM & Embedding configuration management.
|
|
3
|
+
*/
|
|
4
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
|
|
5
|
+
import { loadSecretsEnvFile } from "./secrets.js";
|
|
6
|
+
import {
|
|
7
|
+
LLM_PROVIDERS,
|
|
8
|
+
EMBEDDING_DIMS,
|
|
9
|
+
PROVIDER_DEFAULT_URLS,
|
|
10
|
+
} from "../provider-constants.js";
|
|
11
|
+
|
|
12
|
+
// Re-export shared constants for barrel compatibility
|
|
13
|
+
export { LLM_PROVIDERS, EMBEDDING_DIMS, PROVIDER_DEFAULT_URLS };
|
|
14
|
+
|
|
15
|
+
// ── Types ────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export type MemoryConfig = {
|
|
18
|
+
mem0: {
|
|
19
|
+
llm: { provider: string; config: Record<string, unknown> };
|
|
20
|
+
embedder: { provider: string; config: Record<string, unknown> };
|
|
21
|
+
vector_store: {
|
|
22
|
+
provider: "sqlite-vec" | "qdrant";
|
|
23
|
+
config: {
|
|
24
|
+
collection_name: string;
|
|
25
|
+
db_path?: string;
|
|
26
|
+
path?: string;
|
|
27
|
+
embedding_model_dims: number;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
memory: { custom_instructions: string };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// ── Constants (module-specific) ─────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export const EMBED_PROVIDERS = [
|
|
37
|
+
"openai", "ollama", "huggingface", "lmstudio"
|
|
38
|
+
] as const;
|
|
39
|
+
|
|
40
|
+
/** Static model list for Anthropic (no listing API available). */
|
|
41
|
+
const ANTHROPIC_MODELS = [
|
|
42
|
+
"claude-opus-4-6",
|
|
43
|
+
"claude-sonnet-4-6",
|
|
44
|
+
"claude-opus-4-20250514",
|
|
45
|
+
"claude-sonnet-4-20250514",
|
|
46
|
+
"claude-haiku-4-5-20251001",
|
|
47
|
+
"claude-3-5-sonnet-20241022",
|
|
48
|
+
"claude-3-5-haiku-20241022",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
// ── API Key Resolution ──────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
export function resolveApiKey(apiKeyRef: string, configDir: string): string {
|
|
54
|
+
if (!apiKeyRef) return "";
|
|
55
|
+
if (!apiKeyRef.startsWith("env:")) return apiKeyRef;
|
|
56
|
+
|
|
57
|
+
const varName = apiKeyRef.slice(4);
|
|
58
|
+
if (process.env[varName]) return process.env[varName]!;
|
|
59
|
+
|
|
60
|
+
const secrets = loadSecretsEnvFile(configDir);
|
|
61
|
+
return secrets[varName] ?? "";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Provider Model Listing ──────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
export type ModelDiscoveryReason =
|
|
67
|
+
| 'none'
|
|
68
|
+
| 'provider_static'
|
|
69
|
+
| 'provider_http'
|
|
70
|
+
| 'missing_base_url'
|
|
71
|
+
| 'timeout'
|
|
72
|
+
| 'network';
|
|
73
|
+
|
|
74
|
+
export type ProviderModelsResult = {
|
|
75
|
+
models: string[];
|
|
76
|
+
status: 'ok' | 'recoverable_error';
|
|
77
|
+
reason: ModelDiscoveryReason;
|
|
78
|
+
error?: string;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function describeHttpStatus(status: number): string {
|
|
82
|
+
switch (status) {
|
|
83
|
+
case 401: return 'Invalid or missing API key';
|
|
84
|
+
case 403: return 'Access denied — check API key permissions';
|
|
85
|
+
case 404: return 'Endpoint not found — verify the base URL';
|
|
86
|
+
case 429: return 'Rate limited — try again shortly';
|
|
87
|
+
case 500: return 'Provider internal error';
|
|
88
|
+
case 502: return 'Provider returned a bad gateway error';
|
|
89
|
+
case 503: return 'Provider is temporarily unavailable';
|
|
90
|
+
default: return `HTTP ${status}`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function extractProviderErrorDetail(res: Response): Promise<string> {
|
|
95
|
+
try {
|
|
96
|
+
const text = await res.text();
|
|
97
|
+
const json = JSON.parse(text) as Record<string, unknown>;
|
|
98
|
+
if (
|
|
99
|
+
typeof json.error === 'object' && json.error !== null &&
|
|
100
|
+
typeof (json.error as Record<string, unknown>).message === 'string'
|
|
101
|
+
) {
|
|
102
|
+
return (json.error as Record<string, unknown>).message as string;
|
|
103
|
+
}
|
|
104
|
+
if (typeof json.error === 'string') return json.error;
|
|
105
|
+
if (typeof json.message === 'string') return json.message;
|
|
106
|
+
if (typeof json.detail === 'string') return json.detail;
|
|
107
|
+
return '';
|
|
108
|
+
} catch {
|
|
109
|
+
return '';
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function fetchProviderModels(
|
|
114
|
+
provider: string,
|
|
115
|
+
apiKeyRef: string,
|
|
116
|
+
baseUrl: string,
|
|
117
|
+
configDir: string
|
|
118
|
+
): Promise<ProviderModelsResult> {
|
|
119
|
+
try {
|
|
120
|
+
if (provider === "anthropic") {
|
|
121
|
+
return { models: [...ANTHROPIC_MODELS], status: 'ok', reason: 'provider_static' };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const resolvedKey = resolveApiKey(apiKeyRef, configDir);
|
|
125
|
+
|
|
126
|
+
if (provider === "ollama") {
|
|
127
|
+
const base = baseUrl?.trim() || PROVIDER_DEFAULT_URLS.ollama;
|
|
128
|
+
const url = `${base.replace(/\/+$/, "")}/api/tags`;
|
|
129
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
|
130
|
+
if (!res.ok) {
|
|
131
|
+
return {
|
|
132
|
+
models: [],
|
|
133
|
+
status: 'recoverable_error',
|
|
134
|
+
reason: 'provider_http',
|
|
135
|
+
error: `Ollama API returned ${res.status}: ${describeHttpStatus(res.status)}`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const data = (await res.json()) as { models?: { name: string }[] };
|
|
139
|
+
const models = (data.models ?? []).map((m) => m.name).sort();
|
|
140
|
+
return { models, status: 'ok', reason: 'none' };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const base = baseUrl?.trim() || PROVIDER_DEFAULT_URLS[provider] || "";
|
|
144
|
+
if (!base) {
|
|
145
|
+
return {
|
|
146
|
+
models: [],
|
|
147
|
+
status: 'recoverable_error',
|
|
148
|
+
reason: 'missing_base_url',
|
|
149
|
+
error: `No base URL configured for provider "${provider}"`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const url = `${base.replace(/\/+$/, "")}/v1/models`;
|
|
153
|
+
|
|
154
|
+
const headers: Record<string, string> = {};
|
|
155
|
+
if (resolvedKey) {
|
|
156
|
+
headers["Authorization"] = `Bearer ${resolvedKey}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(5000) });
|
|
160
|
+
if (!res.ok) {
|
|
161
|
+
const detail = await extractProviderErrorDetail(res);
|
|
162
|
+
return {
|
|
163
|
+
models: [],
|
|
164
|
+
status: 'recoverable_error',
|
|
165
|
+
reason: 'provider_http',
|
|
166
|
+
error: detail
|
|
167
|
+
? `Provider API returned ${res.status}: ${detail}`
|
|
168
|
+
: `Provider API returned ${res.status}: ${describeHttpStatus(res.status)}`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const data = (await res.json()) as { data?: { id: string }[] };
|
|
172
|
+
const models = (data.data ?? []).map((m) => m.id).sort();
|
|
173
|
+
return { models, status: 'ok', reason: 'none' };
|
|
174
|
+
} catch (err) {
|
|
175
|
+
const message =
|
|
176
|
+
err instanceof Error && err.name === "TimeoutError"
|
|
177
|
+
? "Request timed out after 5s"
|
|
178
|
+
: String(err);
|
|
179
|
+
return {
|
|
180
|
+
models: [],
|
|
181
|
+
status: 'recoverable_error',
|
|
182
|
+
reason: err instanceof Error && err.name === 'TimeoutError' ? 'timeout' : 'network',
|
|
183
|
+
error: message,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Default Config ───────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
export function getDefaultConfig(): MemoryConfig {
|
|
191
|
+
return {
|
|
192
|
+
mem0: {
|
|
193
|
+
llm: {
|
|
194
|
+
provider: "openai",
|
|
195
|
+
config: {
|
|
196
|
+
model: "gpt-4o-mini",
|
|
197
|
+
temperature: 0.1,
|
|
198
|
+
max_tokens: 2000,
|
|
199
|
+
api_key: "env:OPENAI_API_KEY",
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
embedder: {
|
|
203
|
+
provider: "openai",
|
|
204
|
+
config: {
|
|
205
|
+
model: "text-embedding-3-small",
|
|
206
|
+
api_key: "env:OPENAI_API_KEY",
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
vector_store: {
|
|
210
|
+
provider: "sqlite-vec",
|
|
211
|
+
config: {
|
|
212
|
+
collection_name: "memory",
|
|
213
|
+
db_path: "/data/memory.db",
|
|
214
|
+
embedding_model_dims: 1536,
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
memory: { custom_instructions: "" },
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── File I/O ─────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
const CONFIG_FILENAME = "memory/default_config.json";
|
|
225
|
+
|
|
226
|
+
function configPath(dataDir: string): string {
|
|
227
|
+
return `${dataDir}/${CONFIG_FILENAME}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function readMemoryConfig(dataDir: string): MemoryConfig {
|
|
231
|
+
const path = configPath(dataDir);
|
|
232
|
+
if (!existsSync(path)) return getDefaultConfig();
|
|
233
|
+
try {
|
|
234
|
+
const raw = readFileSync(path, "utf-8");
|
|
235
|
+
return JSON.parse(raw) as MemoryConfig;
|
|
236
|
+
} catch {
|
|
237
|
+
return getDefaultConfig();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function writeMemoryConfig(
|
|
242
|
+
dataDir: string,
|
|
243
|
+
config: MemoryConfig
|
|
244
|
+
): void {
|
|
245
|
+
const path = configPath(dataDir);
|
|
246
|
+
mkdirSync(`${dataDir}/memory`, { recursive: true });
|
|
247
|
+
writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function ensureMemoryConfig(dataDir: string): void {
|
|
251
|
+
const path = configPath(dataDir);
|
|
252
|
+
if (existsSync(path)) return;
|
|
253
|
+
writeMemoryConfig(dataDir, getDefaultConfig());
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── Config Resolution ────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
export function resolveConfigForPush(
|
|
259
|
+
config: MemoryConfig,
|
|
260
|
+
configDir: string
|
|
261
|
+
): MemoryConfig {
|
|
262
|
+
const resolved = structuredClone(config);
|
|
263
|
+
|
|
264
|
+
if (typeof resolved.mem0.llm.config.api_key === "string") {
|
|
265
|
+
resolved.mem0.llm.config.api_key = resolveApiKey(
|
|
266
|
+
resolved.mem0.llm.config.api_key as string,
|
|
267
|
+
configDir
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (typeof resolved.mem0.embedder.config.api_key === "string") {
|
|
272
|
+
resolved.mem0.embedder.config.api_key = resolveApiKey(
|
|
273
|
+
resolved.mem0.embedder.config.api_key as string,
|
|
274
|
+
configDir
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return resolved;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── Dimension Checking ──────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
export type VectorDimensionResult = {
|
|
284
|
+
match: boolean;
|
|
285
|
+
currentDims?: number;
|
|
286
|
+
expectedDims: number;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/** @deprecated Use checkVectorDimensions instead */
|
|
290
|
+
export type QdrantDimensionResult = VectorDimensionResult;
|
|
291
|
+
|
|
292
|
+
export function checkVectorDimensions(
|
|
293
|
+
dataDir: string,
|
|
294
|
+
newConfig: MemoryConfig
|
|
295
|
+
): VectorDimensionResult {
|
|
296
|
+
const expectedDims = newConfig.mem0.vector_store.config.embedding_model_dims;
|
|
297
|
+
const persisted = readMemoryConfig(dataDir);
|
|
298
|
+
const currentDims = persisted.mem0.vector_store.config.embedding_model_dims;
|
|
299
|
+
return { match: currentDims === expectedDims, currentDims, expectedDims };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** @deprecated Use checkVectorDimensions instead */
|
|
303
|
+
export const checkQdrantDimensions = checkVectorDimensions;
|
|
304
|
+
|
|
305
|
+
export function resetVectorStore(
|
|
306
|
+
dataDir: string
|
|
307
|
+
): { ok: boolean; error?: string } {
|
|
308
|
+
const persisted = readMemoryConfig(dataDir);
|
|
309
|
+
const configuredPath = persisted.mem0.vector_store.config.db_path;
|
|
310
|
+
|
|
311
|
+
let dbPath: string;
|
|
312
|
+
if (configuredPath && configuredPath.startsWith('/data/')) {
|
|
313
|
+
dbPath = `${dataDir}/memory/${configuredPath.slice('/data/'.length)}`;
|
|
314
|
+
} else if (configuredPath && !configuredPath.startsWith('/')) {
|
|
315
|
+
dbPath = `${dataDir}/memory/${configuredPath}`;
|
|
316
|
+
} else {
|
|
317
|
+
dbPath = `${dataDir}/memory/memory.db`;
|
|
318
|
+
}
|
|
319
|
+
const qdrantPath = `${dataDir}/memory/qdrant`;
|
|
320
|
+
try {
|
|
321
|
+
if (existsSync(dbPath)) {
|
|
322
|
+
rmSync(dbPath, { force: true });
|
|
323
|
+
}
|
|
324
|
+
for (const suffix of ['-wal', '-shm']) {
|
|
325
|
+
const walPath = `${dbPath}${suffix}`;
|
|
326
|
+
if (existsSync(walPath)) rmSync(walPath, { force: true });
|
|
327
|
+
}
|
|
328
|
+
if (existsSync(qdrantPath)) {
|
|
329
|
+
rmSync(qdrantPath, { recursive: true, force: true });
|
|
330
|
+
}
|
|
331
|
+
return { ok: true };
|
|
332
|
+
} catch (err) {
|
|
333
|
+
return { ok: false, error: String(err) };
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** @deprecated Use resetVectorStore instead */
|
|
338
|
+
export const resetQdrantCollection = resetVectorStore;
|
|
339
|
+
|
|
340
|
+
// ── Runtime API ──────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
function getMemoryApiBases(): string[] {
|
|
343
|
+
const configured =
|
|
344
|
+
process.env.MEMORY_API_URL?.trim() ||
|
|
345
|
+
process.env.OPENPALM_MEMORY_API_URL?.trim();
|
|
346
|
+
|
|
347
|
+
const bases = configured
|
|
348
|
+
? [configured]
|
|
349
|
+
: ["http://memory:8765", "http://127.0.0.1:8765"];
|
|
350
|
+
|
|
351
|
+
return Array.from(new Set(bases.map((base) => base.replace(/\/+$/, ""))));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function getMemoryAuthHeaders(): Record<string, string> {
|
|
355
|
+
const token = process.env.MEMORY_AUTH_TOKEN?.trim();
|
|
356
|
+
return token ? { authorization: `Bearer ${token}` } : {};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function callMemoryApi(
|
|
360
|
+
path: string,
|
|
361
|
+
init?: RequestInit,
|
|
362
|
+
): Promise<Response> {
|
|
363
|
+
const bases = getMemoryApiBases();
|
|
364
|
+
const authHeaders = getMemoryAuthHeaders();
|
|
365
|
+
let lastError: unknown;
|
|
366
|
+
|
|
367
|
+
for (let i = 0; i < bases.length; i++) {
|
|
368
|
+
const url = `${bases[i]}${path}`;
|
|
369
|
+
try {
|
|
370
|
+
const headers = { ...authHeaders, ...(init?.headers as Record<string, string>) };
|
|
371
|
+
return await fetch(url, { ...init, headers });
|
|
372
|
+
} catch (err) {
|
|
373
|
+
lastError = err;
|
|
374
|
+
if (i === bases.length - 1) throw err;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
throw lastError ?? new Error("Memory API request failed");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export async function pushConfigToMemory(
|
|
382
|
+
config: MemoryConfig
|
|
383
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
384
|
+
try {
|
|
385
|
+
const res = await callMemoryApi("/api/v1/config/", {
|
|
386
|
+
method: "PUT",
|
|
387
|
+
headers: { "content-type": "application/json" },
|
|
388
|
+
body: JSON.stringify(config),
|
|
389
|
+
});
|
|
390
|
+
if (!res.ok) {
|
|
391
|
+
const text = await res.text().catch(() => "");
|
|
392
|
+
return { ok: false, error: `HTTP ${res.status}: ${text}` };
|
|
393
|
+
}
|
|
394
|
+
return { ok: true };
|
|
395
|
+
} catch (err) {
|
|
396
|
+
return { ok: false, error: String(err) };
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export async function fetchConfigFromMemory(): Promise<MemoryConfig | null> {
|
|
401
|
+
try {
|
|
402
|
+
const res = await callMemoryApi("/api/v1/config/");
|
|
403
|
+
if (!res.ok) return null;
|
|
404
|
+
return (await res.json()) as MemoryConfig;
|
|
405
|
+
} catch {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export async function provisionMemoryUser(
|
|
411
|
+
userId: string,
|
|
412
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
413
|
+
try {
|
|
414
|
+
const res = await callMemoryApi("/api/v1/users", {
|
|
415
|
+
method: "POST",
|
|
416
|
+
headers: { "content-type": "application/json" },
|
|
417
|
+
body: JSON.stringify({ user_id: userId }),
|
|
418
|
+
signal: AbortSignal.timeout(5_000),
|
|
419
|
+
});
|
|
420
|
+
return { ok: res.ok };
|
|
421
|
+
} catch (err) {
|
|
422
|
+
return { ok: false, error: String(err) };
|
|
423
|
+
}
|
|
424
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local provider detection for OpenPalm.
|
|
3
|
+
*
|
|
4
|
+
* Probes well-known endpoints for Docker Model Runner, Ollama, and LM Studio.
|
|
5
|
+
*/
|
|
6
|
+
import { createLogger } from "../logger.js";
|
|
7
|
+
|
|
8
|
+
const logger = createLogger("local-providers");
|
|
9
|
+
|
|
10
|
+
// ── Types ────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export type LocalProviderDetection = {
|
|
13
|
+
provider: string;
|
|
14
|
+
url: string;
|
|
15
|
+
available: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// ── Probe Configuration ──────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const LOCAL_PROVIDER_PROBES: { provider: string; probes: { url: string; baseUrl: string }[] }[] = [
|
|
21
|
+
{
|
|
22
|
+
provider: "model-runner",
|
|
23
|
+
probes: [
|
|
24
|
+
{
|
|
25
|
+
url: "http://model-runner.docker.internal/engines/v1/models",
|
|
26
|
+
baseUrl: "http://model-runner.docker.internal/engines",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
url: "http://model-runner.docker.internal:12434/engines/v1/models",
|
|
30
|
+
baseUrl: "http://model-runner.docker.internal:12434/engines",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
url: "http://host.docker.internal:12434/engines/v1/models",
|
|
34
|
+
baseUrl: "http://host.docker.internal:12434/engines",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
url: "http://localhost:12434/engines/v1/models",
|
|
38
|
+
baseUrl: "http://localhost:12434/engines",
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
provider: "ollama",
|
|
44
|
+
probes: [
|
|
45
|
+
{
|
|
46
|
+
// In-stack Ollama (compose service on assistant_net)
|
|
47
|
+
url: "http://ollama:11434/api/tags",
|
|
48
|
+
baseUrl: "http://ollama:11434",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
url: "http://host.docker.internal:11434/api/tags",
|
|
52
|
+
baseUrl: "http://host.docker.internal:11434",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
url: "http://localhost:11434/api/tags",
|
|
56
|
+
baseUrl: "http://localhost:11434",
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
provider: "lmstudio",
|
|
62
|
+
probes: [
|
|
63
|
+
{
|
|
64
|
+
url: "http://host.docker.internal:1234/v1/models",
|
|
65
|
+
baseUrl: "http://host.docker.internal:1234",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
url: "http://localhost:1234/v1/models",
|
|
69
|
+
baseUrl: "http://localhost:1234",
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
// ── Detection ────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Detect all available local providers by probing well-known endpoints.
|
|
79
|
+
* Returns results for all providers (available or not) in parallel.
|
|
80
|
+
*/
|
|
81
|
+
export async function detectLocalProviders(): Promise<LocalProviderDetection[]> {
|
|
82
|
+
const results = await Promise.all(
|
|
83
|
+
LOCAL_PROVIDER_PROBES.map(async ({ provider, probes }) => {
|
|
84
|
+
for (const { url: probeUrl, baseUrl } of probes) {
|
|
85
|
+
try {
|
|
86
|
+
const res = await fetch(probeUrl, {
|
|
87
|
+
signal: AbortSignal.timeout(3000),
|
|
88
|
+
});
|
|
89
|
+
if (res.ok) {
|
|
90
|
+
logger.debug("detected local provider", { provider, url: baseUrl });
|
|
91
|
+
return { provider, url: baseUrl, available: true };
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// Endpoint not reachable — try next
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { provider, url: "", available: false };
|
|
98
|
+
})
|
|
99
|
+
);
|
|
100
|
+
return results;
|
|
101
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XDG path resolution and directory setup for the OpenPalm control plane.
|
|
3
|
+
*
|
|
4
|
+
* Directory model (XDG-compliant):
|
|
5
|
+
* CONFIG_HOME (~/.config/openpalm) — user-editable: secrets.env, channels/, assistant/
|
|
6
|
+
* DATA_HOME (~/.local/share/openpalm) — opaque service data (memory, etc.)
|
|
7
|
+
* STATE_HOME (~/.local/state/openpalm) — assembled runtime, audit logs
|
|
8
|
+
*/
|
|
9
|
+
import { mkdirSync } from "node:fs";
|
|
10
|
+
import { resolve as resolvePath } from "node:path";
|
|
11
|
+
|
|
12
|
+
export function resolveHome(): string {
|
|
13
|
+
return process.env.HOME ?? "/tmp";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function resolveConfigHome(): string {
|
|
17
|
+
const raw = process.env.OPENPALM_CONFIG_HOME;
|
|
18
|
+
if (!raw) return `${resolveHome()}/.config/openpalm`;
|
|
19
|
+
return resolvePath(raw);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveStateHome(): string {
|
|
23
|
+
const raw = process.env.OPENPALM_STATE_HOME;
|
|
24
|
+
if (!raw) return `${resolveHome()}/.local/state/openpalm`;
|
|
25
|
+
return resolvePath(raw);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveDataHome(): string {
|
|
29
|
+
const raw = process.env.OPENPALM_DATA_HOME;
|
|
30
|
+
if (!raw) return `${resolveHome()}/.local/share/openpalm`;
|
|
31
|
+
return resolvePath(raw);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Create the full XDG directory tree.
|
|
36
|
+
*
|
|
37
|
+
* CONFIG_HOME (~/.config/openpalm) — user-editable configuration
|
|
38
|
+
* DATA_HOME (~/.local/share/openpalm) — opaque persistent service data
|
|
39
|
+
* STATE_HOME (~/.local/state/openpalm) — generated artifacts, audit logs
|
|
40
|
+
*/
|
|
41
|
+
export function ensureXdgDirs(): void {
|
|
42
|
+
const dataHome = resolveDataHome();
|
|
43
|
+
const configHome = resolveConfigHome();
|
|
44
|
+
const stateHome = resolveStateHome();
|
|
45
|
+
|
|
46
|
+
for (const dir of [
|
|
47
|
+
// CONFIG_HOME — user-editable
|
|
48
|
+
configHome,
|
|
49
|
+
`${configHome}/channels`,
|
|
50
|
+
`${configHome}/connections`,
|
|
51
|
+
`${configHome}/assistant`,
|
|
52
|
+
`${configHome}/automations`,
|
|
53
|
+
`${configHome}/stash`,
|
|
54
|
+
|
|
55
|
+
// DATA_HOME — persistent service data (pre-created to avoid root-owned dirs)
|
|
56
|
+
dataHome,
|
|
57
|
+
`${dataHome}/admin`,
|
|
58
|
+
`${dataHome}/memory`,
|
|
59
|
+
`${dataHome}/assistant`,
|
|
60
|
+
`${dataHome}/guardian`,
|
|
61
|
+
`${dataHome}/caddy`,
|
|
62
|
+
`${dataHome}/caddy/data`,
|
|
63
|
+
`${dataHome}/caddy/config`,
|
|
64
|
+
`${dataHome}/automations`,
|
|
65
|
+
`${dataHome}/opencode`,
|
|
66
|
+
|
|
67
|
+
// STATE_HOME — assembled runtime
|
|
68
|
+
stateHome,
|
|
69
|
+
`${stateHome}/artifacts`,
|
|
70
|
+
`${stateHome}/audit`,
|
|
71
|
+
`${stateHome}/artifacts/channels`,
|
|
72
|
+
`${stateHome}/automations`,
|
|
73
|
+
`${stateHome}/opencode`
|
|
74
|
+
]) {
|
|
75
|
+
mkdirSync(dir, { recursive: true });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RegistryProvider interface — dependency injection for registry catalog.
|
|
3
|
+
*
|
|
4
|
+
* Admin implements this with Vite import.meta.glob (ViteRegistryProvider).
|
|
5
|
+
* CLI/lib implements this by reading from registry/ directory (FilesystemRegistryProvider).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface RegistryProvider {
|
|
9
|
+
/** Channel compose overlay YMLs, keyed by channel name. */
|
|
10
|
+
channelYml(): Record<string, string>;
|
|
11
|
+
/** Channel Caddy routes (optional), keyed by channel name. */
|
|
12
|
+
channelCaddy(): Record<string, string>;
|
|
13
|
+
/** Names of available registry channels. */
|
|
14
|
+
channelNames(): string[];
|
|
15
|
+
/** Automation configs, keyed by automation name. */
|
|
16
|
+
automationYml(): Record<string, string>;
|
|
17
|
+
/** Names of available registry automations. */
|
|
18
|
+
automationNames(): string[];
|
|
19
|
+
}
|