@openpalm/lib 0.10.1 → 0.11.0-beta.1
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 +2 -2
- package/package.json +7 -3
- package/src/control-plane/admin-token.ts +73 -0
- package/src/control-plane/akm-vault.test.ts +108 -0
- package/src/control-plane/akm-vault.ts +307 -0
- package/src/control-plane/audit.ts +3 -2
- package/src/control-plane/channels.ts +3 -3
- package/src/control-plane/cleanup-guardrails.test.ts +8 -9
- package/src/control-plane/compose-args.test.ts +25 -21
- package/src/control-plane/config-persistence.ts +103 -64
- package/src/control-plane/core-assets.test.ts +104 -0
- package/src/control-plane/core-assets.ts +54 -57
- package/src/control-plane/docker.ts +55 -21
- package/src/control-plane/env.test.ts +25 -1
- package/src/control-plane/env.ts +80 -0
- package/src/control-plane/home.ts +66 -69
- package/src/control-plane/host-opencode.test.ts +263 -0
- package/src/control-plane/host-opencode.ts +229 -0
- package/src/control-plane/install-edge-cases.test.ts +182 -244
- package/src/control-plane/install-lock.ts +157 -0
- package/src/control-plane/lifecycle.ts +57 -56
- package/src/control-plane/markdown-task.ts +200 -0
- package/src/control-plane/paths.ts +75 -0
- package/src/control-plane/provider-config.ts +2 -2
- package/src/control-plane/provider-models.ts +154 -0
- package/src/control-plane/registry-components.test.ts +102 -25
- package/src/control-plane/registry.test.ts +49 -47
- package/src/control-plane/registry.ts +71 -50
- package/src/control-plane/rollback.ts +17 -16
- package/src/control-plane/scheduler.ts +75 -262
- package/src/control-plane/secret-backend.test.ts +98 -108
- package/src/control-plane/secret-backend.ts +221 -181
- package/src/control-plane/secret-mappings.ts +3 -6
- package/src/control-plane/secrets.ts +83 -47
- package/src/control-plane/setup-config.schema.json +2 -14
- package/src/control-plane/setup-status.ts +4 -29
- package/src/control-plane/setup-validation.ts +21 -21
- package/src/control-plane/setup.test.ts +122 -227
- package/src/control-plane/setup.ts +224 -125
- package/src/control-plane/skeleton-guardrail.test.ts +151 -0
- package/src/control-plane/spec-to-env.test.ts +59 -58
- package/src/control-plane/spec-to-env.ts +39 -140
- package/src/control-plane/spec-validator.ts +2 -99
- package/src/control-plane/stack-spec.test.ts +21 -77
- package/src/control-plane/stack-spec.ts +7 -83
- package/src/control-plane/types.ts +17 -15
- package/src/control-plane/ui-assets.ts +349 -0
- package/src/control-plane/validate.ts +44 -79
- package/src/index.ts +77 -44
- package/src/logger.test.ts +228 -0
- package/src/logger.ts +71 -1
- package/src/provider-constants.ts +22 -1
- package/src/control-plane/env-schema-validation.test.ts +0 -118
- package/src/control-plane/memory-config.ts +0 -298
- package/src/control-plane/redact-schema.ts +0 -50
|
@@ -2,20 +2,9 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
|
2
2
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { deriveSystemEnvFromSpec,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
function makeSpec(overrides?: Partial<StackSpec>): StackSpec {
|
|
9
|
-
return {
|
|
10
|
-
version: 2,
|
|
11
|
-
capabilities: {
|
|
12
|
-
llm: "openai/gpt-4o",
|
|
13
|
-
embeddings: { provider: "openai", model: "text-embedding-3-small", dims: 1536 },
|
|
14
|
-
memory: { userId: "default_user" },
|
|
15
|
-
},
|
|
16
|
-
...overrides,
|
|
17
|
-
};
|
|
18
|
-
}
|
|
5
|
+
import { deriveSystemEnvFromSpec, writeVoiceVars } from "./spec-to-env.js";
|
|
6
|
+
|
|
7
|
+
const MINIMAL_SPEC = { version: 2 as const };
|
|
19
8
|
|
|
20
9
|
let tempDir = "";
|
|
21
10
|
|
|
@@ -29,72 +18,84 @@ afterEach(() => {
|
|
|
29
18
|
|
|
30
19
|
describe("deriveSystemEnvFromSpec", () => {
|
|
31
20
|
test("produces OP_HOME", () => {
|
|
32
|
-
const result = deriveSystemEnvFromSpec(
|
|
21
|
+
const result = deriveSystemEnvFromSpec(MINIMAL_SPEC, "/home/op");
|
|
33
22
|
expect(result.OP_HOME).toBe("/home/op");
|
|
34
23
|
});
|
|
35
24
|
|
|
36
25
|
test("produces default port values", () => {
|
|
37
|
-
const result = deriveSystemEnvFromSpec(
|
|
26
|
+
const result = deriveSystemEnvFromSpec(MINIMAL_SPEC, "/home/op");
|
|
38
27
|
expect(result.OP_ASSISTANT_PORT).toBe("3800");
|
|
39
|
-
expect(result.OP_MEMORY_PORT).toBe("3898");
|
|
40
28
|
expect(result.OP_GUARDIAN_PORT).toBe("3899");
|
|
41
29
|
});
|
|
42
30
|
|
|
43
|
-
test("does not include
|
|
44
|
-
const result = deriveSystemEnvFromSpec(
|
|
45
|
-
|
|
46
|
-
expect(result
|
|
31
|
+
test("does not include the retired memory service port", () => {
|
|
32
|
+
const result = deriveSystemEnvFromSpec(MINIMAL_SPEC, "/home/op");
|
|
33
|
+
const retired = "OP_" + "MEMORY_PORT";
|
|
34
|
+
expect(result[retired]).toBeUndefined();
|
|
47
35
|
});
|
|
48
36
|
|
|
49
|
-
test("does not include
|
|
50
|
-
const result = deriveSystemEnvFromSpec(
|
|
51
|
-
expect(result.
|
|
52
|
-
expect(result.
|
|
37
|
+
test("does not include LLM provider in system env", () => {
|
|
38
|
+
const result = deriveSystemEnvFromSpec(MINIMAL_SPEC, "/home/op");
|
|
39
|
+
expect(result.SYSTEM_LLM_PROVIDER).toBeUndefined();
|
|
40
|
+
expect(result.SYSTEM_LLM_MODEL).toBeUndefined();
|
|
53
41
|
});
|
|
54
42
|
|
|
55
43
|
test("does not include removed feature flags", () => {
|
|
56
|
-
const
|
|
57
|
-
const result = deriveSystemEnvFromSpec(spec, "/home/op");
|
|
44
|
+
const result = deriveSystemEnvFromSpec(MINIMAL_SPEC, "/home/op");
|
|
58
45
|
expect(result.OP_OLLAMA_ENABLED).toBeUndefined();
|
|
59
46
|
expect(result.OP_ADMIN_ENABLED).toBeUndefined();
|
|
60
47
|
});
|
|
61
48
|
});
|
|
62
49
|
|
|
63
|
-
describe("
|
|
64
|
-
test("writes
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
writeFileSync(join(vaultDir, "stack", "stack.env"), "# stack env\n");
|
|
77
|
-
|
|
78
|
-
writeCapabilityVars(spec, vaultDir);
|
|
79
|
-
|
|
80
|
-
const stackEnvContent = readFileSync(join(vaultDir, "stack", "stack.env"), "utf-8");
|
|
81
|
-
expect(stackEnvContent).toContain("OP_CAP_LLM_PROVIDER=openai");
|
|
82
|
-
expect(stackEnvContent).toContain("OP_CAP_LLM_MODEL=gpt-4o");
|
|
83
|
-
expect(stackEnvContent).toContain("OP_CAP_EMBEDDINGS_MODEL=text-embedding-3-small");
|
|
84
|
-
expect(stackEnvContent).toContain("OP_CAP_EMBEDDINGS_DIMS=1536");
|
|
85
|
-
expect(stackEnvContent).toContain("MEMORY_USER_ID=default_user");
|
|
50
|
+
describe("writeVoiceVars", () => {
|
|
51
|
+
test("writes TTS vars to stack.env", () => {
|
|
52
|
+
mkdirSync(tempDir, { recursive: true });
|
|
53
|
+
writeFileSync(join(tempDir, "stack.env"), "# stack env\n");
|
|
54
|
+
|
|
55
|
+
writeVoiceVars({
|
|
56
|
+
tts: { baseURL: "https://tts.example.com/v1", model: "tts-1", voice: "alloy" },
|
|
57
|
+
}, tempDir);
|
|
58
|
+
|
|
59
|
+
const content = readFileSync(join(tempDir, "stack.env"), "utf-8");
|
|
60
|
+
expect(content).toContain("TTS_BASE_URL=https://tts.example.com/v1");
|
|
61
|
+
expect(content).toContain("TTS_MODEL=tts-1");
|
|
62
|
+
expect(content).toContain("TTS_VOICE=alloy");
|
|
86
63
|
});
|
|
87
64
|
|
|
88
|
-
test("
|
|
89
|
-
|
|
65
|
+
test("writes STT vars to stack.env", () => {
|
|
66
|
+
mkdirSync(tempDir, { recursive: true });
|
|
67
|
+
writeFileSync(join(tempDir, "stack.env"), "# stack env\n");
|
|
68
|
+
|
|
69
|
+
writeVoiceVars({
|
|
70
|
+
stt: { baseURL: "https://stt.example.com/v1", model: "whisper-1", language: "en" },
|
|
71
|
+
}, tempDir);
|
|
72
|
+
|
|
73
|
+
const content = readFileSync(join(tempDir, "stack.env"), "utf-8");
|
|
74
|
+
expect(content).toContain("STT_BASE_URL=https://stt.example.com/v1");
|
|
75
|
+
expect(content).toContain("STT_MODEL=whisper-1");
|
|
76
|
+
expect(content).toContain("STT_LANGUAGE=en");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("creates stack.env if it does not exist", () => {
|
|
80
|
+
mkdirSync(tempDir, { recursive: true });
|
|
81
|
+
|
|
82
|
+
writeVoiceVars({
|
|
83
|
+
tts: { baseURL: "https://tts.example.com/v1", model: "tts-1" },
|
|
84
|
+
}, tempDir);
|
|
85
|
+
|
|
86
|
+
const content = readFileSync(join(tempDir, "stack.env"), "utf-8");
|
|
87
|
+
expect(content).toContain("TTS_BASE_URL=https://tts.example.com/v1");
|
|
88
|
+
});
|
|
90
89
|
|
|
91
|
-
|
|
92
|
-
mkdirSync(
|
|
93
|
-
|
|
90
|
+
test("is a no-op when no vars are provided", () => {
|
|
91
|
+
mkdirSync(tempDir, { recursive: true });
|
|
92
|
+
const stackEnvPath = join(tempDir, "stack.env");
|
|
93
|
+
writeFileSync(stackEnvPath, "EXISTING=value\n");
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
writeVoiceVars({}, tempDir);
|
|
96
96
|
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
// File should be unchanged
|
|
98
|
+
const content = readFileSync(stackEnvPath, "utf-8");
|
|
99
|
+
expect(content).toBe("EXISTING=value\n");
|
|
99
100
|
});
|
|
100
101
|
});
|
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Config-to-env derivation pipeline.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* 2. Resolved capability vars (OP_CAP_*) written to stack.env
|
|
4
|
+
* Produces system env vars for stack.env (non-secret infrastructure config).
|
|
5
|
+
* Voice channel vars (TTS/STT) are written separately via writeVoiceVars.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
8
|
import type { StackSpec } from "./stack-spec.js";
|
|
10
|
-
import { SPEC_DEFAULTS
|
|
9
|
+
import { SPEC_DEFAULTS } from "./stack-spec.js";
|
|
11
10
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
-
import {
|
|
13
|
-
import { mergeEnvContent, parseEnvContent } from "./env.js";
|
|
14
|
-
import { PROVIDER_DEFAULT_URLS, PROVIDER_KEY_MAP, OLLAMA_INSTACK_URL } from "../provider-constants.js";
|
|
15
|
-
import { listEnabledAddonIds } from "./registry.js";
|
|
11
|
+
import { mergeEnvContent } from "./env.js";
|
|
16
12
|
|
|
17
13
|
/**
|
|
18
14
|
* Derive the system.env key-value pairs from the StackSpec.
|
|
@@ -34,8 +30,6 @@ export function deriveSystemEnvFromSpec(
|
|
|
34
30
|
result["OP_HOME"] = homeDir;
|
|
35
31
|
result["OP_UID"] = String(uid);
|
|
36
32
|
result["OP_GID"] = String(gid);
|
|
37
|
-
result["OP_DOCKER_SOCK"] = process.env.OP_DOCKER_SOCK ?? "/var/run/docker.sock";
|
|
38
|
-
|
|
39
33
|
// Image
|
|
40
34
|
result["OP_IMAGE_NAMESPACE"] = image.namespace;
|
|
41
35
|
result["OP_IMAGE_TAG"] = image.tag;
|
|
@@ -44,151 +38,56 @@ export function deriveSystemEnvFromSpec(
|
|
|
44
38
|
result["OP_ASSISTANT_PORT"] = String(ports.assistant);
|
|
45
39
|
result["OP_ADMIN_PORT"] = String(ports.admin);
|
|
46
40
|
result["OP_ADMIN_OPENCODE_PORT"] = String(ports.adminOpencode);
|
|
47
|
-
result["OP_MEMORY_PORT"] = String(ports.memory);
|
|
48
41
|
result["OP_GUARDIAN_PORT"] = String(ports.guardian);
|
|
49
42
|
result["OP_ASSISTANT_SSH_PORT"] = String(ports.assistantSsh);
|
|
50
43
|
|
|
44
|
+
void spec; // spec reserved for future use; ports/image come from SPEC_DEFAULTS
|
|
45
|
+
|
|
51
46
|
return result;
|
|
52
47
|
}
|
|
53
48
|
|
|
54
|
-
// ──
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Resolve all capabilities from stack.yml and write OP_CAP_* vars into stack.env.
|
|
58
|
-
*
|
|
59
|
-
* Reads raw API keys from the current stack.env, resolves provider → base URL → API key
|
|
60
|
-
* for each capability, and merges the OP_CAP_* section into stack.env.
|
|
61
|
-
*
|
|
62
|
-
* Services consume these via compose ${VAR} substitution in their environment blocks.
|
|
63
|
-
*/
|
|
64
|
-
export function writeCapabilityVars(spec: StackSpec, vaultDir: string): void {
|
|
65
|
-
const stackEnvPath = `${vaultDir}/stack/stack.env`;
|
|
66
|
-
const stackEnv = existsSync(stackEnvPath)
|
|
67
|
-
? parseEnvContent(readFileSync(stackEnvPath, "utf-8"))
|
|
68
|
-
: {};
|
|
69
|
-
|
|
70
|
-
const resolveKey = (provider: string): string => {
|
|
71
|
-
const keyVar = PROVIDER_KEY_MAP[provider];
|
|
72
|
-
return keyVar ? (stackEnv[keyVar] || "") : "";
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
/** Providers that do NOT use an OpenAI-compatible /v1 path prefix. */
|
|
76
|
-
const NO_V1_SUFFIX = new Set(["ollama", "google"]);
|
|
77
|
-
|
|
78
|
-
const ensureV1 = (url: string, provider: string): string => {
|
|
79
|
-
if (!url || NO_V1_SUFFIX.has(provider)) return url;
|
|
80
|
-
return url.endsWith("/v1") ? url : `${url.replace(/\/+$/, "")}/v1`;
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
/** Map provider → env var for user-configured base URL overrides. */
|
|
84
|
-
const BASE_URL_ENV_MAP: Record<string, string> = {
|
|
85
|
-
openai: "OPENAI_BASE_URL",
|
|
86
|
-
anthropic: "ANTHROPIC_BASE_URL",
|
|
87
|
-
groq: "GROQ_BASE_URL",
|
|
88
|
-
mistral: "MISTRAL_BASE_URL",
|
|
89
|
-
together: "TOGETHER_BASE_URL",
|
|
90
|
-
deepseek: "DEEPSEEK_BASE_URL",
|
|
91
|
-
xai: "XAI_BASE_URL",
|
|
92
|
-
google: "GOOGLE_BASE_URL",
|
|
93
|
-
huggingface: "HF_BASE_URL",
|
|
94
|
-
ollama: "OLLAMA_BASE_URL",
|
|
95
|
-
lmstudio: "LMSTUDIO_BASE_URL",
|
|
96
|
-
"model-runner": "MODEL_RUNNER_BASE_URL",
|
|
97
|
-
"openai-compatible": "OPENAI_COMPATIBLE_BASE_URL",
|
|
98
|
-
};
|
|
49
|
+
// ── Voice Channel Env Vars ────────────────────────────────────────────────
|
|
99
50
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
const defaultUrl = PROVIDER_DEFAULT_URLS[provider] || "";
|
|
108
|
-
return ensureV1(defaultUrl, provider);
|
|
51
|
+
export type VoiceVarsConfig = {
|
|
52
|
+
tts?: {
|
|
53
|
+
enabled?: boolean;
|
|
54
|
+
baseURL?: string;
|
|
55
|
+
model?: string;
|
|
56
|
+
voice?: string;
|
|
109
57
|
};
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
for (const f of fields) caps[`${prefix}_${f}`] = "";
|
|
58
|
+
stt?: {
|
|
59
|
+
enabled?: boolean;
|
|
60
|
+
baseURL?: string;
|
|
61
|
+
model?: string;
|
|
62
|
+
language?: string;
|
|
116
63
|
};
|
|
64
|
+
};
|
|
117
65
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (spec.capabilities.slm) {
|
|
127
|
-
const { provider: slmP, model: slmM } = parseCapabilityString(spec.capabilities.slm);
|
|
128
|
-
caps.OP_CAP_SLM_PROVIDER = slmP;
|
|
129
|
-
caps.OP_CAP_SLM_MODEL = slmM;
|
|
130
|
-
caps.OP_CAP_SLM_BASE_URL = resolveUrl(slmP);
|
|
131
|
-
caps.OP_CAP_SLM_API_KEY = resolveKey(slmP);
|
|
132
|
-
} else {
|
|
133
|
-
clearCapVars("OP_CAP_SLM", ["PROVIDER", "MODEL", "BASE_URL", "API_KEY"]);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// ── Embeddings ──
|
|
137
|
-
const emb = spec.capabilities.embeddings;
|
|
138
|
-
caps.OP_CAP_EMBEDDINGS_PROVIDER = emb.provider;
|
|
139
|
-
caps.OP_CAP_EMBEDDINGS_MODEL = emb.model;
|
|
140
|
-
caps.OP_CAP_EMBEDDINGS_BASE_URL = resolveUrl(emb.provider);
|
|
141
|
-
caps.OP_CAP_EMBEDDINGS_API_KEY = resolveKey(emb.provider);
|
|
142
|
-
caps.OP_CAP_EMBEDDINGS_DIMS = String(emb.dims);
|
|
143
|
-
|
|
144
|
-
// ── TTS ──
|
|
145
|
-
const tts = spec.capabilities.tts;
|
|
146
|
-
if (tts?.enabled) {
|
|
147
|
-
const p = tts.provider || llmP;
|
|
148
|
-
caps.OP_CAP_TTS_PROVIDER = p;
|
|
149
|
-
caps.OP_CAP_TTS_MODEL = tts.model || "";
|
|
150
|
-
caps.OP_CAP_TTS_BASE_URL = resolveUrl(p);
|
|
151
|
-
caps.OP_CAP_TTS_API_KEY = resolveKey(p);
|
|
152
|
-
caps.OP_CAP_TTS_VOICE = tts.voice || "";
|
|
153
|
-
caps.OP_CAP_TTS_FORMAT = tts.format || "";
|
|
154
|
-
} else {
|
|
155
|
-
clearCapVars("OP_CAP_TTS", ["PROVIDER", "MODEL", "BASE_URL", "API_KEY", "VOICE", "FORMAT"]);
|
|
156
|
-
}
|
|
66
|
+
/**
|
|
67
|
+
* Write TTS/STT env vars to stack.env for the voice channel container.
|
|
68
|
+
* Only vars with non-empty values are written; missing values are left unchanged.
|
|
69
|
+
*/
|
|
70
|
+
export function writeVoiceVars(config: VoiceVarsConfig, stackDir: string): void {
|
|
71
|
+
const stackEnvPath = `${stackDir}/stack.env`;
|
|
72
|
+
const base = existsSync(stackEnvPath) ? readFileSync(stackEnvPath, "utf-8") : "";
|
|
73
|
+
const vars: Record<string, string> = {};
|
|
157
74
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
caps.OP_CAP_STT_MODEL = stt.model || "";
|
|
164
|
-
caps.OP_CAP_STT_BASE_URL = resolveUrl(p);
|
|
165
|
-
caps.OP_CAP_STT_API_KEY = resolveKey(p);
|
|
166
|
-
caps.OP_CAP_STT_LANGUAGE = stt.language || "";
|
|
167
|
-
} else {
|
|
168
|
-
clearCapVars("OP_CAP_STT", ["PROVIDER", "MODEL", "BASE_URL", "API_KEY", "LANGUAGE"]);
|
|
75
|
+
const { tts, stt } = config;
|
|
76
|
+
if (tts?.enabled !== false) {
|
|
77
|
+
if (tts?.baseURL) vars["TTS_BASE_URL"] = tts.baseURL;
|
|
78
|
+
if (tts?.model) vars["TTS_MODEL"] = tts.model;
|
|
79
|
+
if (tts?.voice) vars["TTS_VOICE"] = tts.voice;
|
|
169
80
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const p = rr.provider || llmP;
|
|
175
|
-
caps.OP_CAP_RERANKING_PROVIDER = p;
|
|
176
|
-
caps.OP_CAP_RERANKING_MODEL = rr.model || "";
|
|
177
|
-
caps.OP_CAP_RERANKING_BASE_URL = resolveUrl(p);
|
|
178
|
-
caps.OP_CAP_RERANKING_API_KEY = resolveKey(p);
|
|
179
|
-
caps.OP_CAP_RERANKING_TOP_K = rr.topK ? String(rr.topK) : "";
|
|
180
|
-
caps.OP_CAP_RERANKING_TOP_N = rr.topN ? String(rr.topN) : "";
|
|
181
|
-
} else {
|
|
182
|
-
clearCapVars("OP_CAP_RERANKING", ["PROVIDER", "MODEL", "BASE_URL", "API_KEY", "TOP_K", "TOP_N"]);
|
|
81
|
+
if (stt?.enabled !== false) {
|
|
82
|
+
if (stt?.baseURL) vars["STT_BASE_URL"] = stt.baseURL;
|
|
83
|
+
if (stt?.model) vars["STT_MODEL"] = stt.model;
|
|
84
|
+
if (stt?.language) vars["STT_LANGUAGE"] = stt.language;
|
|
183
85
|
}
|
|
184
86
|
|
|
185
|
-
|
|
186
|
-
caps.MEMORY_USER_ID = spec.capabilities.memory.userId || "default_user";
|
|
87
|
+
if (Object.keys(vars).length === 0) return;
|
|
187
88
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
let content = mergeEnvContent(base, caps, {
|
|
191
|
-
sectionHeader: "# ── Resolved Capabilities (from stack.yml) ─────────────────────────",
|
|
89
|
+
let content = mergeEnvContent(base, vars, {
|
|
90
|
+
sectionHeader: "# ── Voice Channel (TTS/STT) ──────────────────────────────────────────",
|
|
192
91
|
});
|
|
193
92
|
if (!content.endsWith("\n")) content += "\n";
|
|
194
93
|
writeFileSync(stackEnvPath, content, { mode: 0o600 });
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* so users can quickly identify and fix configuration issues.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { StackSpec
|
|
8
|
+
import type { StackSpec } from "./stack-spec.js";
|
|
9
9
|
|
|
10
10
|
export type ValidationError = {
|
|
11
11
|
code: string;
|
|
@@ -41,18 +41,6 @@ export function validateStackSpec(input: unknown): ValidationError[] {
|
|
|
41
41
|
return errors;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
// Capabilities
|
|
45
|
-
if (typeof spec.capabilities !== "object" || spec.capabilities === null) {
|
|
46
|
-
errors.push({
|
|
47
|
-
code: "OP-CFG-001",
|
|
48
|
-
message: "No capabilities defined",
|
|
49
|
-
path: "capabilities",
|
|
50
|
-
hint: "Add capabilities.llm and capabilities.embeddings sections",
|
|
51
|
-
});
|
|
52
|
-
} else {
|
|
53
|
-
validateCapabilities(spec.capabilities as StackSpecCapabilities, errors);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
44
|
// Image
|
|
57
45
|
if (spec.image && typeof spec.image === "object") {
|
|
58
46
|
const img = spec.image as Record<string, unknown>;
|
|
@@ -69,91 +57,6 @@ export function validateStackSpec(input: unknown): ValidationError[] {
|
|
|
69
57
|
}
|
|
70
58
|
}
|
|
71
59
|
|
|
60
|
+
void (spec as unknown as StackSpec); // reserved for future validations
|
|
72
61
|
return errors;
|
|
73
62
|
}
|
|
74
|
-
|
|
75
|
-
function validateCapabilities(
|
|
76
|
-
capabilities: StackSpecCapabilities,
|
|
77
|
-
errors: ValidationError[],
|
|
78
|
-
): void {
|
|
79
|
-
// LLM (required, "provider/model" string)
|
|
80
|
-
if (!capabilities.llm || typeof capabilities.llm !== "string") {
|
|
81
|
-
errors.push({
|
|
82
|
-
code: "OP-CFG-008",
|
|
83
|
-
message: "capabilities.llm is required (format: provider/model)",
|
|
84
|
-
path: "capabilities.llm",
|
|
85
|
-
hint: 'Example: "anthropic/claude-sonnet-4-5" or "ollama/qwen2.5-coder:3b"',
|
|
86
|
-
});
|
|
87
|
-
} else if (!capabilities.llm.includes("/")) {
|
|
88
|
-
errors.push({
|
|
89
|
-
code: "OP-CFG-008",
|
|
90
|
-
message: `capabilities.llm "${capabilities.llm}" must be in provider/model format`,
|
|
91
|
-
path: "capabilities.llm",
|
|
92
|
-
hint: 'Example: "anthropic/claude-sonnet-4-5"',
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// SLM (optional, same format)
|
|
97
|
-
if (capabilities.slm !== undefined) {
|
|
98
|
-
if (typeof capabilities.slm !== "string") {
|
|
99
|
-
errors.push({
|
|
100
|
-
code: "OP-CFG-008",
|
|
101
|
-
message: "capabilities.slm must be a string (format: provider/model)",
|
|
102
|
-
path: "capabilities.slm",
|
|
103
|
-
});
|
|
104
|
-
} else if (!capabilities.slm.includes("/")) {
|
|
105
|
-
errors.push({
|
|
106
|
-
code: "OP-CFG-008",
|
|
107
|
-
message: `capabilities.slm "${capabilities.slm}" must be in provider/model format`,
|
|
108
|
-
path: "capabilities.slm",
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Embeddings (required object)
|
|
114
|
-
if (!capabilities.embeddings || typeof capabilities.embeddings !== "object") {
|
|
115
|
-
errors.push({
|
|
116
|
-
code: "OP-CFG-002",
|
|
117
|
-
message: "capabilities.embeddings is required",
|
|
118
|
-
path: "capabilities.embeddings",
|
|
119
|
-
hint: "Add provider, model, and dims fields",
|
|
120
|
-
});
|
|
121
|
-
} else {
|
|
122
|
-
const emb = capabilities.embeddings;
|
|
123
|
-
if (!emb.provider || typeof emb.provider !== "string") {
|
|
124
|
-
errors.push({
|
|
125
|
-
code: "OP-CFG-004",
|
|
126
|
-
message: "capabilities.embeddings.provider is required",
|
|
127
|
-
path: "capabilities.embeddings.provider",
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
if (!emb.model || typeof emb.model !== "string") {
|
|
131
|
-
errors.push({
|
|
132
|
-
code: "OP-CFG-008",
|
|
133
|
-
message: "capabilities.embeddings.model is required",
|
|
134
|
-
path: "capabilities.embeddings.model",
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
if (
|
|
138
|
-
emb.dims !== undefined &&
|
|
139
|
-
(typeof emb.dims !== "number" || emb.dims < 1)
|
|
140
|
-
) {
|
|
141
|
-
errors.push({
|
|
142
|
-
code: "OP-CFG-009",
|
|
143
|
-
message: "capabilities.embeddings.dims must be a positive integer",
|
|
144
|
-
path: "capabilities.embeddings.dims",
|
|
145
|
-
hint: "Common values: nomic-embed-text: 768, text-embedding-3-small: 1536",
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Memory (required object)
|
|
151
|
-
if (!capabilities.memory || typeof capabilities.memory !== "object") {
|
|
152
|
-
errors.push({
|
|
153
|
-
code: "OP-CFG-002",
|
|
154
|
-
message: "capabilities.memory is required",
|
|
155
|
-
path: "capabilities.memory",
|
|
156
|
-
hint: "Add at minimum a userId field",
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Stack spec parser tests.
|
|
3
3
|
*
|
|
4
|
-
* Verifies that readStackSpec / writeStackSpec produce consistent results
|
|
5
|
-
* and that all addon resolution goes through the canonical lib functions.
|
|
4
|
+
* Verifies that readStackSpec / writeStackSpec produce consistent results.
|
|
6
5
|
*/
|
|
7
6
|
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
8
7
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -13,9 +12,6 @@ import {
|
|
|
13
12
|
writeStackSpec,
|
|
14
13
|
STACK_SPEC_FILENAME,
|
|
15
14
|
stackSpecPath,
|
|
16
|
-
parseCapabilityString,
|
|
17
|
-
formatCapabilityString,
|
|
18
|
-
updateCapability,
|
|
19
15
|
} from "./stack-spec.js";
|
|
20
16
|
import type { StackSpec } from "./stack-spec.js";
|
|
21
17
|
|
|
@@ -29,38 +25,35 @@ afterEach(() => {
|
|
|
29
25
|
rmSync(configDir, { recursive: true, force: true });
|
|
30
26
|
});
|
|
31
27
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
function makeSpec(): StackSpec {
|
|
35
|
-
return {
|
|
36
|
-
version: 2,
|
|
37
|
-
capabilities: {
|
|
38
|
-
llm: "openai/gpt-4o",
|
|
39
|
-
embeddings: { provider: "openai", model: "text-embedding-3-small", dims: 1536 },
|
|
40
|
-
memory: { userId: "test-user" },
|
|
41
|
-
},
|
|
42
|
-
};
|
|
43
|
-
}
|
|
28
|
+
const MINIMAL_SPEC: StackSpec = { version: 2 };
|
|
44
29
|
|
|
45
30
|
// ── readStackSpec / writeStackSpec round-trip ────────────────────────────
|
|
46
31
|
|
|
47
32
|
describe("readStackSpec / writeStackSpec round-trip", () => {
|
|
48
|
-
it("round-trips a spec
|
|
49
|
-
|
|
50
|
-
writeStackSpec(configDir, spec);
|
|
33
|
+
it("round-trips a minimal spec", () => {
|
|
34
|
+
writeStackSpec(configDir, MINIMAL_SPEC);
|
|
51
35
|
const read = readStackSpec(configDir);
|
|
52
36
|
expect(read).not.toBeNull();
|
|
53
37
|
expect(read!.version).toBe(2);
|
|
54
|
-
expect(read!.capabilities.llm).toBe("openai/gpt-4o");
|
|
55
38
|
});
|
|
56
39
|
|
|
57
40
|
it("writes to the canonical filename", () => {
|
|
58
|
-
writeStackSpec(configDir,
|
|
41
|
+
writeStackSpec(configDir, MINIMAL_SPEC);
|
|
59
42
|
const expectedPath = join(configDir, STACK_SPEC_FILENAME);
|
|
60
43
|
const read = readStackSpec(configDir);
|
|
61
44
|
expect(read).not.toBeNull();
|
|
62
45
|
expect(stackSpecPath(configDir)).toBe(expectedPath);
|
|
63
46
|
});
|
|
47
|
+
|
|
48
|
+
it("ignores legacy capabilities fields on read", () => {
|
|
49
|
+
// On upgraded installs, old stack.yml may have capabilities — should still parse
|
|
50
|
+
writeFileSync(join(configDir, STACK_SPEC_FILENAME),
|
|
51
|
+
"version: 2\ncapabilities:\n llm: openai/gpt-4o\n embeddings:\n provider: openai\n model: text-embedding-3-small\n dims: 1536\n"
|
|
52
|
+
);
|
|
53
|
+
const read = readStackSpec(configDir);
|
|
54
|
+
expect(read).not.toBeNull();
|
|
55
|
+
expect(read!.version).toBe(2);
|
|
56
|
+
});
|
|
64
57
|
});
|
|
65
58
|
|
|
66
59
|
// ── readStackSpec edge cases ────────────────────────────────────────────
|
|
@@ -80,71 +73,22 @@ describe("readStackSpec edge cases", () => {
|
|
|
80
73
|
expect(readStackSpec(configDir)).toBeNull();
|
|
81
74
|
});
|
|
82
75
|
|
|
83
|
-
it("returns
|
|
76
|
+
it("returns valid spec for version 2 with no other fields", () => {
|
|
84
77
|
writeFileSync(join(configDir, STACK_SPEC_FILENAME), "version: 2\n");
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
// ── Capability helpers ──────────────────────────────────────────────────
|
|
90
|
-
|
|
91
|
-
describe("parseCapabilityString", () => {
|
|
92
|
-
it("splits provider/model", () => {
|
|
93
|
-
expect(parseCapabilityString("openai/gpt-4o")).toEqual({ provider: "openai", model: "gpt-4o" });
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it("handles model with slashes", () => {
|
|
97
|
-
expect(parseCapabilityString("ollama/qwen/2.5-coder:3b")).toEqual({ provider: "ollama", model: "qwen/2.5-coder:3b" });
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("handles missing slash", () => {
|
|
101
|
-
expect(parseCapabilityString("openai")).toEqual({ provider: "openai", model: "" });
|
|
102
|
-
});
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
describe("formatCapabilityString", () => {
|
|
106
|
-
it("joins provider and model", () => {
|
|
107
|
-
expect(formatCapabilityString("openai", "gpt-4o")).toBe("openai/gpt-4o");
|
|
108
|
-
});
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
// ── updateCapability ────────────────────────────────────────────────────
|
|
112
|
-
|
|
113
|
-
describe("updateCapability", () => {
|
|
114
|
-
it("updates a single capability key", () => {
|
|
115
|
-
writeStackSpec(configDir, makeSpec());
|
|
116
|
-
updateCapability(configDir, "llm", "anthropic/claude-sonnet-4");
|
|
117
|
-
const read = readStackSpec(configDir);
|
|
118
|
-
expect(read).not.toBeNull();
|
|
119
|
-
expect(read!.capabilities.llm).toBe("anthropic/claude-sonnet-4");
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("throws when spec is missing", () => {
|
|
123
|
-
expect(() => updateCapability(configDir, "llm", "test")).toThrow("stack.yml not found or invalid");
|
|
78
|
+
const spec = readStackSpec(configDir);
|
|
79
|
+
expect(spec).not.toBeNull();
|
|
80
|
+
expect(spec!.version).toBe(2);
|
|
124
81
|
});
|
|
125
82
|
});
|
|
126
83
|
|
|
127
84
|
// ── stackSpecPath / STACK_SPEC_FILENAME ──────────────────────────────────
|
|
128
85
|
|
|
129
86
|
describe("stackSpecPath", () => {
|
|
130
|
-
it("returns
|
|
131
|
-
expect(stackSpecPath("/foo/config")).toBe("/foo/config/stack.yml");
|
|
87
|
+
it("returns stackDir/stack.yml", () => {
|
|
88
|
+
expect(stackSpecPath("/foo/config/stack")).toBe("/foo/config/stack/stack.yml");
|
|
132
89
|
});
|
|
133
90
|
|
|
134
91
|
it("uses STACK_SPEC_FILENAME constant", () => {
|
|
135
92
|
expect(STACK_SPEC_FILENAME).toBe("stack.yml");
|
|
136
|
-
expect(stackSpecPath(configDir)).toBe(`${configDir}/${STACK_SPEC_FILENAME}`);
|
|
137
|
-
});
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
// ── writeStackSpec creates directory ─────────────────────────────────────
|
|
141
|
-
|
|
142
|
-
describe("writeStackSpec", () => {
|
|
143
|
-
it("creates configDir if it does not exist", () => {
|
|
144
|
-
const nestedDir = join(configDir, "nested", "deep");
|
|
145
|
-
writeStackSpec(nestedDir, makeSpec());
|
|
146
|
-
const read = readStackSpec(nestedDir);
|
|
147
|
-
expect(read).not.toBeNull();
|
|
148
|
-
expect(read!.version).toBe(2);
|
|
149
93
|
});
|
|
150
94
|
});
|