@bcmyguest/openmem 0.6.3 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/{skills → .agents/skills}/openmem/SKILL.md +5 -0
- package/README.md +80 -76
- package/cli/coding-agent/capabilities.mjs +14 -3
- package/cli/coding-agent/context-store.mjs +81 -0
- package/cli/coding-agent/context-trim.mjs +897 -0
- package/cli/coding-agent/file-io.mjs +72 -0
- package/cli/coding-agent/hook-harnesses.mjs +2 -1
- package/cli/coding-agent/hook-runner.mjs +28 -4
- package/cli/coding-agent/install-access.mjs +43 -17
- package/cli/coding-agent/install-context-proxy.mjs +333 -0
- package/cli/coding-agent/install-rtk.mjs +60 -0
- package/cli/coding-agent/install-shim.mjs +119 -15
- package/cli/coding-agent/opencode-config.mjs +21 -5
- package/cli/coding-agent/opencode-plugin.mjs +39 -3
- package/cli/coding-agent/opencode2-plugin/index.js +299 -0
- package/cli/coding-agent/recall-frame.mjs +29 -0
- package/cli/coding-agent/remove-upstream-access.mjs +122 -0
- package/cli/coding-agent/runtime.mjs +2 -1
- package/cli/coding-agent/session-db.mjs +626 -0
- package/cli/coding-agent/session-facade.mjs +30 -6
- package/cli/coding-agent/toml-edit.mjs +161 -0
- package/cli/context-management.mjs +254 -0
- package/cli/feed-sync.mjs +18 -2
- package/cli/install-coding-agent.mjs +173 -40
- package/cli/mcp/context-sql-runner.mjs +58 -0
- package/cli/mcp/context-store.mjs +670 -0
- package/cli/mcp/knowledge.mjs +333 -0
- package/cli/mcp/memory-facade.mjs +99 -33
- package/cli/openmem-mcp.mjs +3 -0
- package/cli/openmem.mjs +18 -1
- package/cli/schedule.mjs +176 -0
- package/cli/stack.mjs +2 -0
- package/cli/switch-memory-provider.mjs +1 -1
- package/compose.yaml +67 -4
- package/config/hindsight.env.example +2 -0
- package/config/openmem.toml.example +64 -2
- package/docs/architecture.md +35 -9
- package/docs/coding-agent-hooks.md +26 -2
- package/docs/coding-agents.md +27 -7
- package/docs/compatibility.md +80 -0
- package/docs/concepts.md +30 -7
- package/docs/context-management.md +255 -0
- package/docs/context-proxy.md +115 -0
- package/docs/context-research.md +215 -0
- package/docs/context-store.md +277 -0
- package/docs/hindsight.md +5 -2
- package/docs/mcp-setup.md +5 -2
- package/docs/mem0.md +39 -1
- package/docs/memory-providers.md +2 -2
- package/docs/meridian-litellm.md +198 -0
- package/docs/model-configuration.md +22 -0
- package/docs/npx-install.md +6 -4
- package/docs/openmem-config.md +2 -2
- package/docs/quickstart.md +12 -6
- package/docs/setup-wizard.md +5 -4
- package/docs/sources-feeds.md +9 -22
- package/package.json +5 -4
- package/providers/cognee/OPERATOR.md +2 -2
- package/providers/hindsight/lib/clack.mjs +122 -20
- package/providers/hindsight/lib/coding-agent.mjs +1 -1
- package/providers/hindsight/lib/config.mjs +6 -0
- package/providers/hindsight/lib/env-setup.mjs +2 -1
- package/providers/hindsight/lib/litellm-routes.mjs +21 -12
- package/providers/hindsight/lib/meridian.mjs +307 -0
- package/providers/hindsight/lib/model-backend.mjs +38 -9
- package/providers/hindsight/lib/probe.mjs +21 -0
- package/providers/hindsight/lib/stack-env.mjs +5 -12
- package/providers/hindsight/lib/state.mjs +6 -1
- package/providers/hindsight/lib/summary.mjs +16 -2
- package/providers/hindsight/lib/validate.mjs +6 -0
- package/providers/hindsight/wizard.mjs +3 -2
- package/providers/mem0/OPERATOR.md +21 -2
- package/providers/mem0/compose.yaml +24 -2
- package/providers/mem0/lib/coding-agent.mjs +1 -1
- package/providers/mem0/lib/configure.mjs +66 -0
- package/providers/mem0/lib/litellm.mjs +457 -0
- package/providers/mem0/lib/stack-env.mjs +40 -1
- package/providers/mem0/lib/summary.mjs +30 -2
- package/providers/mem0/manifest.json +1 -1
- package/providers/mem0/wizard.mjs +15 -2
- package/proxy/config.yaml +33 -0
- package/proxy/openmem_context/__init__.py +6 -0
- package/proxy/openmem_context/adapters.py +504 -0
- package/proxy/openmem_context/compress.py +53 -0
- package/proxy/openmem_context/core.py +606 -0
- package/proxy/openmem_context/embed.py +212 -0
- package/proxy/openmem_context/recall.py +49 -0
- package/proxy/openmem_context/session.py +70 -0
- package/proxy/openmem_context/sessiondb.py +276 -0
- package/proxy/openmem_context/settings.py +134 -0
- package/proxy/openmem_context/store.py +129 -0
- package/proxy/openmem_context/tokens.py +123 -0
- package/proxy/openmem_context/ttl.py +78 -0
- package/proxy/openmem_context_hook.py +34 -0
- package/scripts/check-pack.mjs +2 -1
- package/scripts/setup.mjs +14 -1
- package/server.json +2 -2
- package/wizard/lib/context-step.mjs +140 -0
- package/wizard/lib/litellm-env.mjs +55 -0
- package/wizard/lib/pm-config.mjs +212 -4
- package/wizard/lib/prompts.mjs +28 -0
- package/wizard/lib/run-wizard.mjs +26 -31
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Hindsight's questions for the Clack setup UI: model backend, models, reflect model
|
|
2
|
+
* Hindsight's questions for the Clack setup UI: model backend, models, reflect model
|
|
3
|
+
* (or, for Meridian, its URL, key and the two Claude models).
|
|
3
4
|
* The meta UI asks the provider-neutral questions (action, provider) and then calls
|
|
4
5
|
* clackSetup; the returned environment is handed to the wizard so it skips its own
|
|
5
6
|
* model prompts (WIZARD_SKIP_BACKEND_STEP).
|
|
6
7
|
*/
|
|
7
8
|
import { applyConfigEnv } from "./config.mjs";
|
|
9
|
+
import {
|
|
10
|
+
bridgeReport,
|
|
11
|
+
containerApiBase,
|
|
12
|
+
dockerBridgeIp,
|
|
13
|
+
existingMeridianKey,
|
|
14
|
+
MERIDIAN_DEFAULT_BASE,
|
|
15
|
+
MERIDIAN_GUIDE,
|
|
16
|
+
MERIDIAN_REFLECT_DEFAULT,
|
|
17
|
+
MERIDIAN_RETAIN_DEFAULT,
|
|
18
|
+
normalizeMeridianBase,
|
|
19
|
+
pickModel,
|
|
20
|
+
probeMeridian,
|
|
21
|
+
} from "./meridian.mjs";
|
|
8
22
|
import { fetchJson, normalizeOpenAiBase, ollamaModelNames, openAiModelIds } from "./probe.mjs";
|
|
9
23
|
import {
|
|
10
24
|
LEMOND_DEFAULT_MODEL,
|
|
@@ -51,10 +65,15 @@ export async function clackSetup(ctx, ui, h) {
|
|
|
51
65
|
{
|
|
52
66
|
value: "custom",
|
|
53
67
|
label: "OpenAI-compatible URL",
|
|
54
|
-
hint: "
|
|
68
|
+
hint: "hosted API, other",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
value: "meridian",
|
|
72
|
+
label: "Claude subscription (Meridian)",
|
|
73
|
+
hint: "a Meridian proxy you already run on this host",
|
|
55
74
|
},
|
|
56
75
|
],
|
|
57
|
-
initialValue: ["lemond", "ollama", "custom"].includes(backendDefault)
|
|
76
|
+
initialValue: ["lemond", "ollama", "custom", "meridian"].includes(backendDefault)
|
|
58
77
|
? backendDefault
|
|
59
78
|
: "lemond",
|
|
60
79
|
});
|
|
@@ -67,7 +86,6 @@ export async function clackSetup(ctx, ui, h) {
|
|
|
67
86
|
let apiBase = "";
|
|
68
87
|
let reflectModel =
|
|
69
88
|
process.env.PM_REFLECT_MODEL ||
|
|
70
|
-
machine.PM_REFLECT_MODEL ||
|
|
71
89
|
h.envValue(wizardConf, "MEMORY_REFLECT_MODEL") ||
|
|
72
90
|
"";
|
|
73
91
|
|
|
@@ -85,8 +103,6 @@ export async function clackSetup(ctx, ui, h) {
|
|
|
85
103
|
);
|
|
86
104
|
}
|
|
87
105
|
const defaultModel =
|
|
88
|
-
machine.PM_DEFAULT_MODEL ||
|
|
89
|
-
machine.MEMORY_MODEL ||
|
|
90
106
|
h.envValue(wizardConf, "MEMORY_MODEL") ||
|
|
91
107
|
LEMOND_DEFAULT_MODEL;
|
|
92
108
|
if (ids.length > 0) {
|
|
@@ -108,7 +124,7 @@ export async function clackSetup(ctx, ui, h) {
|
|
|
108
124
|
}
|
|
109
125
|
apiBase = "http://host.docker.internal:13305/v1";
|
|
110
126
|
} else if (backend === "ollama") {
|
|
111
|
-
const ollamaBase =
|
|
127
|
+
const ollamaBase = "http://127.0.0.1:11434";
|
|
112
128
|
const spin = ui.spinner();
|
|
113
129
|
spin.start(`Checking Ollama at ${ollamaBase}`);
|
|
114
130
|
const body = await fetchJson(`${ollamaBase}/api/tags`);
|
|
@@ -137,8 +153,6 @@ export async function clackSetup(ctx, ui, h) {
|
|
|
137
153
|
}
|
|
138
154
|
}
|
|
139
155
|
const defaultModel =
|
|
140
|
-
machine.PM_DEFAULT_MODEL ||
|
|
141
|
-
machine.MEMORY_MODEL ||
|
|
142
156
|
h.envValue(wizardConf, "MEMORY_MODEL") ||
|
|
143
157
|
OLLAMA_DEFAULT_MODEL;
|
|
144
158
|
if (names.length > 0) {
|
|
@@ -160,12 +174,15 @@ export async function clackSetup(ctx, ui, h) {
|
|
|
160
174
|
}
|
|
161
175
|
apiBase = ollamaInCompose ? OLLAMA_COMPOSE_API_BASE : OLLAMA_HOST_API_BASE;
|
|
162
176
|
if (ollamaInCompose) handoff.MEMORY_OLLAMA_IN_COMPOSE = "1";
|
|
177
|
+
} else if (backend === "meridian") {
|
|
178
|
+
const m = await askMeridian(ctx, ui, h);
|
|
179
|
+
model = m.model;
|
|
180
|
+
reflectModel = m.reflectModel;
|
|
181
|
+
apiBase = m.apiBase;
|
|
182
|
+
Object.assign(handoff, m.env);
|
|
163
183
|
} else {
|
|
164
|
-
const defaultBase =
|
|
165
|
-
machine.CUSTOM_DEFAULT_BASE || "http://host.docker.internal:3456/v1";
|
|
184
|
+
const defaultBase = "http://host.docker.internal:3456/v1";
|
|
166
185
|
const defaultModel =
|
|
167
|
-
machine.CUSTOM_DEFAULT_MODEL ||
|
|
168
|
-
machine.PM_DEFAULT_MODEL ||
|
|
169
186
|
h.envValue(wizardConf, "MEMORY_MODEL") ||
|
|
170
187
|
LEMOND_DEFAULT_MODEL;
|
|
171
188
|
const baseInput = await ui.text({
|
|
@@ -205,6 +222,24 @@ export async function clackSetup(ctx, ui, h) {
|
|
|
205
222
|
}
|
|
206
223
|
}
|
|
207
224
|
|
|
225
|
+
if (backend !== "meridian") reflectModel = await askReflect(ui, model, reflectModel);
|
|
226
|
+
|
|
227
|
+
ui.note("Next: Docker, secrets, indexing, and starting the stack.", "Backend configured");
|
|
228
|
+
|
|
229
|
+
handoff.WIZARD_SKIP_BACKEND_STEP = "1";
|
|
230
|
+
handoff.PM_BACKEND = /** @type {string} */ (backend);
|
|
231
|
+
handoff.PM_DEFAULT_MODEL = model;
|
|
232
|
+
handoff.MEMORY_API_BASE = apiBase;
|
|
233
|
+
handoff.PM_REFLECT_MODEL = reflectModel;
|
|
234
|
+
return handoff;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* @param {typeof import("@clack/prompts")} ui
|
|
239
|
+
* @param {string} model retain model
|
|
240
|
+
* @param {string} reflectModel stored or env reflect model
|
|
241
|
+
*/
|
|
242
|
+
async function askReflect(ui, model, reflectModel) {
|
|
208
243
|
const reflectChoice = await ui.select({
|
|
209
244
|
message: "Reflect model (needs tool calls; used for memory-reflect)",
|
|
210
245
|
options: [
|
|
@@ -233,13 +268,80 @@ export async function clackSetup(ctx, ui, h) {
|
|
|
233
268
|
} else {
|
|
234
269
|
reflectModel = "";
|
|
235
270
|
}
|
|
271
|
+
return reflectModel;
|
|
272
|
+
}
|
|
236
273
|
|
|
237
|
-
|
|
274
|
+
/**
|
|
275
|
+
* Meridian URL, key and models. Detect only: Meridian must already run.
|
|
276
|
+
* @param {{ home: string, wizardConf: string }} ctx
|
|
277
|
+
* @param {typeof import("@clack/prompts")} ui
|
|
278
|
+
* @param {Helpers} h
|
|
279
|
+
*/
|
|
280
|
+
async function askMeridian(ctx, ui, h) {
|
|
281
|
+
ui.log.info(`The wizard does not install or start Meridian; see ${MERIDIAN_GUIDE}.`);
|
|
282
|
+
const stored =
|
|
283
|
+
h.envValue(ctx.wizardConf, "MEMORY_BACKEND") === "meridian"
|
|
284
|
+
? h.envValue(ctx.wizardConf, "MEMORY_MERIDIAN_URL")
|
|
285
|
+
: "";
|
|
286
|
+
const defaultBase = process.env.MERIDIAN_BASE || stored || MERIDIAN_DEFAULT_BASE;
|
|
287
|
+
const baseInput = await ui.text({
|
|
288
|
+
message: "Meridian URL from this host",
|
|
289
|
+
placeholder: defaultBase,
|
|
290
|
+
defaultValue: defaultBase,
|
|
291
|
+
});
|
|
292
|
+
if (ui.isCancel(baseInput)) process.exit(0);
|
|
293
|
+
const meridianBase = normalizeMeridianBase(/** @type {string} */ (baseInput));
|
|
294
|
+
const paths = {
|
|
295
|
+
meridianEnvFile: `${ctx.home}/.config/meridian/meridian.env`,
|
|
296
|
+
litellmEnv: `${ctx.home}/.config/hindsight/litellm.env`,
|
|
297
|
+
};
|
|
298
|
+
const existing = existingMeridianKey(paths, h);
|
|
299
|
+
const keyInput = await ui.password({
|
|
300
|
+
message: `MERIDIAN_API_KEY (blank uses ${existing.source || "no key"})`,
|
|
301
|
+
});
|
|
302
|
+
if (ui.isCancel(keyInput)) process.exit(0);
|
|
303
|
+
const typedKey = String(keyInput || "").trim();
|
|
304
|
+
const key = typedKey || existing.key;
|
|
238
305
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
306
|
+
const bridgeIp = dockerBridgeIp();
|
|
307
|
+
const spin = ui.spinner();
|
|
308
|
+
spin.start(`Checking Meridian at ${meridianBase}`);
|
|
309
|
+
const probe = await probeMeridian(meridianBase, key, bridgeIp);
|
|
310
|
+
spin.stop(
|
|
311
|
+
probe.answered ? `Meridian answered (${probe.ids.length} models)` : "Meridian not reachable",
|
|
312
|
+
);
|
|
313
|
+
if (!probe.answered) {
|
|
314
|
+
ui.log.warn(`Start Meridian first (${MERIDIAN_GUIDE}); the route uses the guide's default models.`);
|
|
315
|
+
} else if (probe.modelsStatus === 401 || probe.modelsStatus === 403) {
|
|
316
|
+
ui.log.warn(`Meridian rejected the key on /v1/models (HTTP ${probe.modelsStatus}).`);
|
|
317
|
+
}
|
|
318
|
+
const apiBase = containerApiBase(meridianBase);
|
|
319
|
+
const report = bridgeReport({ ...paths, meridianBase, apiBase, dockerBridgeIp: bridgeIp }, h.envValue, probe);
|
|
320
|
+
if (report) (report.level === "warn" ? ui.log.warn : ui.log.info)(report.text);
|
|
321
|
+
|
|
322
|
+
const choose = async (message, preferred, family, candidates) => {
|
|
323
|
+
const def = pickModel(probe.ids, preferred, family, candidates);
|
|
324
|
+
const picked = probe.ids.length
|
|
325
|
+
? await ui.select({
|
|
326
|
+
message,
|
|
327
|
+
options: probe.ids.map((id) => ({ value: id, label: id })),
|
|
328
|
+
initialValue: def,
|
|
329
|
+
})
|
|
330
|
+
: await ui.text({ message, placeholder: def, defaultValue: def });
|
|
331
|
+
if (ui.isCancel(picked)) process.exit(0);
|
|
332
|
+
return /** @type {string} */ (picked);
|
|
333
|
+
};
|
|
334
|
+
const sameBackend = h.envValue(ctx.wizardConf, "MEMORY_BACKEND") === "meridian";
|
|
335
|
+
const model = await choose("Retain / consolidate model", MERIDIAN_RETAIN_DEFAULT, /haiku/, [
|
|
336
|
+
process.env.PM_DEFAULT_MODEL,
|
|
337
|
+
sameBackend ? h.envValue(ctx.wizardConf, "MEMORY_MODEL") : "",
|
|
338
|
+
]);
|
|
339
|
+
const reflectModel = await choose("Reflect model (needs tool calls)", MERIDIAN_REFLECT_DEFAULT, /sonnet/, [
|
|
340
|
+
process.env.PM_REFLECT_MODEL,
|
|
341
|
+
sameBackend ? h.envValue(ctx.wizardConf, "MEMORY_REFLECT_MODEL") : "",
|
|
342
|
+
]);
|
|
343
|
+
/** @type {Record<string, string>} */
|
|
344
|
+
const env = { MERIDIAN_BASE: meridianBase };
|
|
345
|
+
if (typedKey) env.MERIDIAN_API_KEY = typedKey;
|
|
346
|
+
return { model, reflectModel, apiBase, env };
|
|
245
347
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Step "
|
|
2
|
+
* Step "9. Coding-agent plugin", Hindsight half: ensure ~/.hindsight/coding-agent.json
|
|
3
3
|
* exists (seeded from the tracked template when missing) and check that its optInPaths
|
|
4
4
|
* cover the indexed roots. Machine-local keys such as notebooklmAccess must live in a
|
|
5
5
|
* real file — never in the repo template — so we do not replace a materialised config
|
|
@@ -12,6 +12,7 @@ export function defaultsFromConfig(cfg) {
|
|
|
12
12
|
const model = /** @type {Record<string, unknown>} */ (cfg.model || {});
|
|
13
13
|
const lemond = /** @type {Record<string, unknown>} */ (model.lemond || {});
|
|
14
14
|
const ollama = /** @type {Record<string, unknown>} */ (model.ollama || {});
|
|
15
|
+
const meridian = /** @type {Record<string, unknown>} */ (model.meridian || {});
|
|
15
16
|
return {
|
|
16
17
|
apiPort: stack.api_port != null ? String(stack.api_port) : "",
|
|
17
18
|
cpPort: stack.control_plane_port != null ? String(stack.control_plane_port) : "",
|
|
@@ -21,6 +22,7 @@ export function defaultsFromConfig(cfg) {
|
|
|
21
22
|
modelApiBase: String(model.api_base || ""),
|
|
22
23
|
lemondHost: String(lemond.host || ""),
|
|
23
24
|
ollamaHost: String(ollama.host || ""),
|
|
25
|
+
meridianHost: String(meridian.host || ""),
|
|
24
26
|
};
|
|
25
27
|
}
|
|
26
28
|
|
|
@@ -43,6 +45,7 @@ export function applyConfigEnv(cfg) {
|
|
|
43
45
|
process.env.MEMORY_API_BASE = d.modelApiBase;
|
|
44
46
|
}
|
|
45
47
|
if (d.lemondHost && !process.env.LEMOND_BASE) process.env.LEMOND_BASE = d.lemondHost;
|
|
48
|
+
if (d.meridianHost && !process.env.MERIDIAN_BASE) process.env.MERIDIAN_BASE = d.meridianHost;
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
/**
|
|
@@ -63,6 +66,9 @@ export function configToml(ctx) {
|
|
|
63
66
|
if (ctx.backend === "ollama" || ctx.ollamaBase) {
|
|
64
67
|
model.ollama = { host: ctx.ollamaBase };
|
|
65
68
|
}
|
|
69
|
+
if (ctx.backend === "meridian" && ctx.meridianBase) {
|
|
70
|
+
model.meridian = { host: ctx.meridianBase };
|
|
71
|
+
}
|
|
66
72
|
return {
|
|
67
73
|
stack: {
|
|
68
74
|
api_port: Number(ctx.portApi || 8888),
|
|
@@ -30,7 +30,8 @@ export function llmWired(ctx, h) {
|
|
|
30
30
|
export function stackEnv(ctx) {
|
|
31
31
|
ensureState(ctx);
|
|
32
32
|
let backend = ctx.backend;
|
|
33
|
-
|
|
33
|
+
// Own-route backends run from litellm.yaml; setup.mjs only knows lemond and ollama.
|
|
34
|
+
if (backend === "custom" || backend === "meridian") backend = "lemond";
|
|
34
35
|
/** @type {Record<string, string>} */
|
|
35
36
|
const env = { MEMORY_BACKEND: backend };
|
|
36
37
|
if (ctx.ollamaInCompose) env.MEMORY_OLLAMA_IN_COMPOSE = "1";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { configToml } from "./config.mjs";
|
|
4
|
+
import { renderMeridianRoutes } from "./meridian.mjs";
|
|
4
5
|
import { OLLAMA_HOST_API_BASE } from "./state.mjs";
|
|
5
6
|
|
|
6
7
|
/** @typedef {import("../../../wizard/lib/provider-wizard/helpers.mjs").ProviderWizardHelpers} Helpers */
|
|
@@ -80,6 +81,7 @@ function renderShipped(ctx, litellmModel, reflectLitellmModel) {
|
|
|
80
81
|
* @returns {string}
|
|
81
82
|
*/
|
|
82
83
|
export function litellmConfig(ctx) {
|
|
84
|
+
if (ctx.backend === "meridian") return renderMeridianRoutes(ctx);
|
|
83
85
|
let prefix = "openai/";
|
|
84
86
|
if (ctx.backend === "ollama") prefix = "ollama_chat/";
|
|
85
87
|
if (ctx.backend === "custom") return renderOverride(ctx, `${prefix}${ctx.model}`);
|
|
@@ -112,6 +114,13 @@ export async function removeOverrideIfAsked(ctx, h) {
|
|
|
112
114
|
* @param {Helpers} h
|
|
113
115
|
*/
|
|
114
116
|
export async function writeOverride(ctx, h) {
|
|
117
|
+
if (ctx.dry && ctx.backend === "meridian") {
|
|
118
|
+
const backup = fs.existsSync(ctx.litellmOverride) ? " (backs up the existing file)" : "";
|
|
119
|
+
h.info(
|
|
120
|
+
`[dry-run] write ${ctx.litellmOverride}${backup}: Meridian routes, retain and consolidate -> anthropic/${ctx.model}, reflect -> anthropic/${ctx.reflectModel || ctx.model}, at ${ctx.apiBase}`,
|
|
121
|
+
);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
115
124
|
if (ctx.dry) {
|
|
116
125
|
h.info(
|
|
117
126
|
`[dry-run] write ${ctx.litellmOverride} from config/litellm.${ctx.backend}.yaml: retain and consolidate -> ${ctx.model}, reflect -> ${ctx.reflectModel || "shipped"}, at ${ctx.apiBase}`,
|
|
@@ -150,7 +159,8 @@ export async function applyRoute(ctx, h) {
|
|
|
150
159
|
if (ctx.backend === "ollama") shipped = ctx.ollamaDefaultModel;
|
|
151
160
|
const defaultBase = shippedApiBase(ctx.backend);
|
|
152
161
|
const apiMatchesShipped = !ctx.apiBase || ctx.apiBase === defaultBase;
|
|
153
|
-
|
|
162
|
+
const ownRoute = ctx.backend === "custom" || ctx.backend === "meridian";
|
|
163
|
+
if (!ownRoute && ctx.model === shipped && !ctx.reflectModel && apiMatchesShipped) {
|
|
154
164
|
await removeOverrideIfAsked(ctx, h);
|
|
155
165
|
} else {
|
|
156
166
|
await writeOverride(ctx, h);
|
|
@@ -173,15 +183,14 @@ export function saveConf(ctx, h) {
|
|
|
173
183
|
h.info(`[dry-run] record backend ${ctx.backend} in ${ctx.wizardConf}`);
|
|
174
184
|
return;
|
|
175
185
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
);
|
|
186
|
+
/** @type {Record<string, string>} */
|
|
187
|
+
const keys = {
|
|
188
|
+
MEMORY_BACKEND: ctx.backend,
|
|
189
|
+
MEMORY_MODEL: ctx.model,
|
|
190
|
+
MEMORY_API_BASE: ctx.apiBase,
|
|
191
|
+
MEMORY_REFLECT_MODEL: ctx.reflectModel,
|
|
192
|
+
MEMORY_OLLAMA_IN_COMPOSE: ctx.ollamaInCompose ? "1" : "0",
|
|
193
|
+
};
|
|
194
|
+
if (ctx.backend === "meridian") keys.MEMORY_MERIDIAN_URL = ctx.meridianBase;
|
|
195
|
+
h.saveWizardConf(ctx, keys, { pmSections: configToml(ctx) });
|
|
187
196
|
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect-only "Claude subscription (Meridian)" backend: probe a Meridian proxy the user
|
|
3
|
+
* already runs, write the LiteLLM route from docs/meridian-litellm.md, and keep
|
|
4
|
+
* MERIDIAN_API_KEY in litellm.env. The wizard never installs or starts Meridian.
|
|
5
|
+
*/
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { fetchStatus, openAiModelIds } from "./probe.mjs";
|
|
10
|
+
import { ensureLitellmEnv } from "./stack-env.mjs";
|
|
11
|
+
import { storedOr } from "./state.mjs";
|
|
12
|
+
|
|
13
|
+
/** @typedef {import("../../../wizard/lib/provider-wizard/helpers.mjs").ProviderWizardHelpers} Helpers */
|
|
14
|
+
|
|
15
|
+
export const MERIDIAN_DEFAULT_PORT = "3456";
|
|
16
|
+
export const MERIDIAN_DEFAULT_BASE = `http://127.0.0.1:${MERIDIAN_DEFAULT_PORT}`;
|
|
17
|
+
/** Guide defaults (docs/meridian-litellm.md#route-file). */
|
|
18
|
+
export const MERIDIAN_RETAIN_DEFAULT = "claude-haiku-5-5";
|
|
19
|
+
export const MERIDIAN_REFLECT_DEFAULT = "claude-sonnet-5-5";
|
|
20
|
+
export const MERIDIAN_TERMS_URL = "https://www.anthropic.com/legal/consumer-terms";
|
|
21
|
+
export const MERIDIAN_GUIDE = "docs/meridian-litellm.md";
|
|
22
|
+
/** litellm.env value when Meridian runs without a key; LiteLLM still needs a non-empty one. */
|
|
23
|
+
export const MERIDIAN_NO_KEY = "placeholder";
|
|
24
|
+
|
|
25
|
+
const BRIDGE_TIMEOUT_MS = 2000;
|
|
26
|
+
const PROBE_TIMEOUT_MS = 5000;
|
|
27
|
+
|
|
28
|
+
/** IPv4 address of the default Docker bridge (docker0), which `host-gateway` resolves to. */
|
|
29
|
+
export function dockerBridgeIp() {
|
|
30
|
+
const nics = os.networkInterfaces().docker0 || [];
|
|
31
|
+
const v4 = nics.find((n) => n.family === "IPv4" || n.family === 4);
|
|
32
|
+
return v4 ? v4.address : "";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @param {string} base */
|
|
36
|
+
export function normalizeMeridianBase(base) {
|
|
37
|
+
return base.trim().replace(/\/+$/, "").replace(/\/v1$/, "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @param {string} hostname */
|
|
41
|
+
function isLoopback(hostname) {
|
|
42
|
+
return hostname === "localhost" || hostname === "[::1]" || hostname.startsWith("127.");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @param {URL} u */
|
|
46
|
+
function portOf(u) {
|
|
47
|
+
return u.port || (u.protocol === "https:" ? "443" : "80");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The api_base the LiteLLM container uses: host.docker.internal for a loopback URL,
|
|
52
|
+
* otherwise the URL itself. No /v1: LiteLLM appends /v1/messages.
|
|
53
|
+
* @param {string} hostBase
|
|
54
|
+
*/
|
|
55
|
+
export function containerApiBase(hostBase) {
|
|
56
|
+
const u = new URL(hostBase);
|
|
57
|
+
if (!isLoopback(u.hostname)) return `${u.protocol}//${u.host}`;
|
|
58
|
+
return `http://host.docker.internal:${portOf(u)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* First usable model: a valid candidate, else `preferred` when listed, else the first id
|
|
63
|
+
* matching `family`, else the first id, else `preferred`.
|
|
64
|
+
* @param {string[]} ids ids from Meridian's /v1/models (may be empty)
|
|
65
|
+
* @param {string} preferred
|
|
66
|
+
* @param {RegExp} family
|
|
67
|
+
* @param {string[]} candidates explicit or stored choices, in priority order
|
|
68
|
+
*/
|
|
69
|
+
export function pickModel(ids, preferred, family, candidates = []) {
|
|
70
|
+
const valid = (m) => Boolean(m) && (ids.length ? ids.includes(m) : m.startsWith("claude-"));
|
|
71
|
+
const hit = candidates.find(valid);
|
|
72
|
+
if (hit) return hit;
|
|
73
|
+
if (!ids.length || ids.includes(preferred)) return preferred;
|
|
74
|
+
return ids.find((id) => family.test(id)) || ids[0];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Where an existing Meridian key comes from: the environment, Meridian's own env file
|
|
79
|
+
* (the guide's ~/.config/meridian/meridian.env), or litellm.env.
|
|
80
|
+
* @param {Record<string, any>} ctx
|
|
81
|
+
* @param {Helpers} h
|
|
82
|
+
* @returns {{ key: string, source: string }}
|
|
83
|
+
*/
|
|
84
|
+
export function existingMeridianKey(ctx, h) {
|
|
85
|
+
if (process.env.MERIDIAN_API_KEY) {
|
|
86
|
+
return { key: process.env.MERIDIAN_API_KEY, source: "MERIDIAN_API_KEY from the environment" };
|
|
87
|
+
}
|
|
88
|
+
const fromMeridian = h.envValue(ctx.meridianEnvFile, "MERIDIAN_API_KEY");
|
|
89
|
+
if (fromMeridian) return { key: fromMeridian, source: ctx.meridianEnvFile };
|
|
90
|
+
const fromLitellm = h.envValue(ctx.litellmEnv, "MERIDIAN_API_KEY");
|
|
91
|
+
if (fromLitellm && fromLitellm !== MERIDIAN_NO_KEY) return { key: fromLitellm, source: ctx.litellmEnv };
|
|
92
|
+
return { key: "", source: "" };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Probe Meridian from the host: /health (open), /v1/models (needs the key when set), and
|
|
97
|
+
* /health on the Docker bridge address to tell a loopback-only bind from 0.0.0.0.
|
|
98
|
+
* @param {string} base host URL without /v1
|
|
99
|
+
* @param {string} key MERIDIAN_API_KEY or ""
|
|
100
|
+
* @param {string} bridgeIp docker0 IPv4, or "" when unknown
|
|
101
|
+
*/
|
|
102
|
+
export async function probeMeridian(base, key, bridgeIp) {
|
|
103
|
+
const headers = key ? { "x-api-key": key } : {};
|
|
104
|
+
const health = await fetchStatus(`${base}/health`, { timeoutMs: PROBE_TIMEOUT_MS });
|
|
105
|
+
const models = await fetchStatus(`${base}/v1/models`, { headers, timeoutMs: PROBE_TIMEOUT_MS });
|
|
106
|
+
const u = new URL(base);
|
|
107
|
+
/** @type {"ok"|"unreachable"|"unknown"|"n/a"} */
|
|
108
|
+
let bridge = "n/a";
|
|
109
|
+
const answered = health.status > 0 || models.status > 0;
|
|
110
|
+
if (answered && isLoopback(u.hostname)) {
|
|
111
|
+
if (!bridgeIp) bridge = "unknown";
|
|
112
|
+
else {
|
|
113
|
+
const viaBridge = await fetchStatus(`http://${bridgeIp}:${portOf(u)}/health`, {
|
|
114
|
+
timeoutMs: BRIDGE_TIMEOUT_MS,
|
|
115
|
+
});
|
|
116
|
+
bridge = viaBridge.status > 0 ? "ok" : "unreachable";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
answered,
|
|
121
|
+
healthOk: health.status >= 200 && health.status < 300,
|
|
122
|
+
modelsStatus: models.status,
|
|
123
|
+
ids: models.status === 200 ? openAiModelIds(models.json) : [],
|
|
124
|
+
bridge,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** @param {string[]} names @param {Helpers} h */
|
|
129
|
+
function showModels(names, h) {
|
|
130
|
+
for (const n of names) h.info(` ${n}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* What the probe says about reachability from the LiteLLM container.
|
|
135
|
+
* @param {{ meridianBase: string, apiBase: string, dockerBridgeIp: string, meridianEnvFile: string }} ctx
|
|
136
|
+
* @param {(file: string, key: string) => string} envValue
|
|
137
|
+
* @param {Awaited<ReturnType<typeof probeMeridian>>} probe
|
|
138
|
+
* @returns {{ level: "ok"|"warn"|"info", text: string } | null}
|
|
139
|
+
*/
|
|
140
|
+
export function bridgeReport(ctx, envValue, probe) {
|
|
141
|
+
const u = new URL(ctx.meridianBase);
|
|
142
|
+
const port = portOf(u);
|
|
143
|
+
const bindHint = `Restart Meridian with MERIDIAN_HOST=0.0.0.0 (${MERIDIAN_GUIDE}#run-meridian).`;
|
|
144
|
+
const declaredHost = process.env.MERIDIAN_HOST || envValue(ctx.meridianEnvFile, "MERIDIAN_HOST");
|
|
145
|
+
if (probe.bridge === "ok") {
|
|
146
|
+
return {
|
|
147
|
+
level: "ok",
|
|
148
|
+
text: `Meridian also answers on the Docker bridge address ${ctx.dockerBridgeIp}:${port}; LiteLLM calls ${ctx.apiBase}. A firewall can still block containers; run the guide's smoke test.`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (probe.bridge === "unreachable") {
|
|
152
|
+
return {
|
|
153
|
+
level: "warn",
|
|
154
|
+
text: `Meridian answers on ${u.host} but not on the Docker bridge address ${ctx.dockerBridgeIp}:${port}: it binds 127.0.0.1 only, so the LiteLLM container cannot reach ${ctx.apiBase}. ${bindHint}`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (declaredHost && isLoopback(declaredHost)) {
|
|
158
|
+
return {
|
|
159
|
+
level: "warn",
|
|
160
|
+
text: `MERIDIAN_HOST is ${declaredHost}: the LiteLLM container cannot reach a loopback-only Meridian. ${bindHint}`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
if (isLoopback(u.hostname)) {
|
|
164
|
+
return {
|
|
165
|
+
level: "info",
|
|
166
|
+
text: `LiteLLM calls Meridian at ${ctx.apiBase}. On Linux, a Meridian bound to 127.0.0.1 (its default) is unreachable from the container; run it with MERIDIAN_HOST=0.0.0.0.`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Interactive and --yes Meridian questions. Fills ctx.meridianBase, ctx.meridianKey,
|
|
174
|
+
* ctx.apiBase, ctx.model and ctx.reflectModel.
|
|
175
|
+
* @param {Record<string, any>} ctx
|
|
176
|
+
* @param {Helpers} h
|
|
177
|
+
*/
|
|
178
|
+
export async function chooseMeridian(ctx, h) {
|
|
179
|
+
h.info(`The wizard does not install or start Meridian; see ${MERIDIAN_GUIDE}.`);
|
|
180
|
+
if (ctx.dockerBridgeIp === undefined) ctx.dockerBridgeIp = dockerBridgeIp();
|
|
181
|
+
const port = h.envValue(ctx.meridianEnvFile, "MERIDIAN_PORT") || MERIDIAN_DEFAULT_PORT;
|
|
182
|
+
const defaultBase =
|
|
183
|
+
process.env.MERIDIAN_BASE ||
|
|
184
|
+
storedOr(ctx, h, "meridian", "MEMORY_MERIDIAN_URL", `http://127.0.0.1:${port}`);
|
|
185
|
+
ctx.meridianBase = normalizeMeridianBase(await h.ask(ctx, "Meridian URL from this host", defaultBase));
|
|
186
|
+
try {
|
|
187
|
+
ctx.apiBase = containerApiBase(ctx.meridianBase);
|
|
188
|
+
} catch {
|
|
189
|
+
h.die(`not a URL: ${ctx.meridianBase}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const existing = existingMeridianKey(ctx, h);
|
|
193
|
+
const typed = await h.ask(
|
|
194
|
+
ctx,
|
|
195
|
+
`MERIDIAN_API_KEY (typed input is visible; blank uses ${existing.source || "no key"})`,
|
|
196
|
+
"",
|
|
197
|
+
);
|
|
198
|
+
ctx.meridianKey = typed || existing.key;
|
|
199
|
+
|
|
200
|
+
const probe = await probeMeridian(ctx.meridianBase, ctx.meridianKey, ctx.dockerBridgeIp);
|
|
201
|
+
if (!probe.answered) {
|
|
202
|
+
h.warn(
|
|
203
|
+
`Meridian is not answering on ${ctx.meridianBase}. Start it first (npm install -g @rynfar/meridian; claude login; meridian), see ${MERIDIAN_GUIDE}. The route uses the guide's default models.`,
|
|
204
|
+
);
|
|
205
|
+
} else {
|
|
206
|
+
h.ok(`Meridian answers on ${ctx.meridianBase}${probe.healthOk ? "" : " (/health not OK)"}`);
|
|
207
|
+
if (probe.modelsStatus === 401 || probe.modelsStatus === 403) {
|
|
208
|
+
h.warn(
|
|
209
|
+
`Meridian rejected ${ctx.meridianKey ? "the key" : "a request without a key"} on /v1/models (HTTP ${probe.modelsStatus}): set MERIDIAN_API_KEY to the key Meridian runs with.`,
|
|
210
|
+
);
|
|
211
|
+
} else if (probe.ids.length) {
|
|
212
|
+
h.ok(`Meridian lists ${probe.ids.length} models`);
|
|
213
|
+
showModels(probe.ids, h);
|
|
214
|
+
} else h.warn(`Meridian returned no model list (HTTP ${probe.modelsStatus}); using the guide's defaults`);
|
|
215
|
+
}
|
|
216
|
+
const bridge = bridgeReport(ctx, h.envValue, probe);
|
|
217
|
+
if (bridge) h[bridge.level](bridge.text);
|
|
218
|
+
if (!ctx.meridianKey) {
|
|
219
|
+
h.warn("No MERIDIAN_API_KEY: with MERIDIAN_HOST=0.0.0.0 anyone on the network can use your subscription.");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const retainDefault = pickModel(probe.ids, MERIDIAN_RETAIN_DEFAULT, /haiku/, [
|
|
223
|
+
process.env.PM_DEFAULT_MODEL,
|
|
224
|
+
storedOr(ctx, h, "meridian", "MEMORY_MODEL", ""),
|
|
225
|
+
]);
|
|
226
|
+
const reflectDefault = pickModel(probe.ids, MERIDIAN_REFLECT_DEFAULT, /sonnet/, [
|
|
227
|
+
process.env.PM_REFLECT_MODEL,
|
|
228
|
+
storedOr(ctx, h, "meridian", "MEMORY_REFLECT_MODEL", ""),
|
|
229
|
+
]);
|
|
230
|
+
ctx.model = await h.ask(ctx, "Meridian retain / consolidate model", retainDefault);
|
|
231
|
+
ctx.reflectModel = await h.ask(ctx, "Meridian reflect model (needs tool calls)", reflectDefault);
|
|
232
|
+
for (const m of [ctx.model, ctx.reflectModel]) {
|
|
233
|
+
if (probe.ids.length && !probe.ids.includes(m)) h.warn(`${m} is not listed by Meridian's /v1/models`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* One alias of the guide's route file.
|
|
239
|
+
* @param {string} alias
|
|
240
|
+
* @param {string} model
|
|
241
|
+
* @param {string} apiBase
|
|
242
|
+
*/
|
|
243
|
+
function renderAlias(alias, model, apiBase) {
|
|
244
|
+
return (
|
|
245
|
+
` - model_name: ${alias}\n` +
|
|
246
|
+
" litellm_params:\n" +
|
|
247
|
+
` model: anthropic/${model}\n` +
|
|
248
|
+
` api_base: ${apiBase}\n` +
|
|
249
|
+
" api_key: os.environ/MERIDIAN_API_KEY\n" +
|
|
250
|
+
" max_tokens: 4096\n" +
|
|
251
|
+
" timeout: 300\n" +
|
|
252
|
+
' additional_drop_params: ["reasoning_effort"]\n'
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The route file from docs/meridian-litellm.md#route-file. The key stays in litellm.env.
|
|
258
|
+
* @param {Record<string, any>} ctx
|
|
259
|
+
*/
|
|
260
|
+
export function renderMeridianRoutes(ctx) {
|
|
261
|
+
const reflect = ctx.reflectModel || ctx.model;
|
|
262
|
+
return (
|
|
263
|
+
"# Written by ./setup: LiteLLM routes for Hindsight, backend Meridian on the host (Claude subscription).\n" +
|
|
264
|
+
`# See ${MERIDIAN_GUIDE}. MERIDIAN_API_KEY lives in ~/.config/hindsight/litellm.env.\n` +
|
|
265
|
+
"# No thinking parameter: Meridian rejects thinking: disabled on Haiku 5.5, so reasoning_effort is dropped.\n" +
|
|
266
|
+
"model_list:\n" +
|
|
267
|
+
renderAlias("memory-retain", ctx.model, ctx.apiBase) +
|
|
268
|
+
renderAlias("memory-reflect", reflect, ctx.apiBase) +
|
|
269
|
+
renderAlias("memory-consolidate", ctx.model, ctx.apiBase) +
|
|
270
|
+
"\ngeneral_settings:\n master_key: os.environ/LITELLM_MASTER_KEY\n"
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Set MERIDIAN_API_KEY in litellm.env (mode 600), creating the file with a master key
|
|
276
|
+
* when it does not exist yet. Never prints the value.
|
|
277
|
+
* @param {Record<string, any>} ctx
|
|
278
|
+
* @param {Helpers} h
|
|
279
|
+
*/
|
|
280
|
+
export function writeMeridianKey(ctx, h) {
|
|
281
|
+
const value = ctx.meridianKey || h.envValue(ctx.litellmEnv, "MERIDIAN_API_KEY") || MERIDIAN_NO_KEY;
|
|
282
|
+
if (/[\r\n]/.test(value)) h.die("MERIDIAN_API_KEY must be one line");
|
|
283
|
+
if (ctx.dry) {
|
|
284
|
+
h.info(`[dry-run] set MERIDIAN_API_KEY in ${ctx.litellmEnv} (mode 600; value not printed)`);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
h.ensureConfigDir(ctx);
|
|
288
|
+
ensureLitellmEnv(ctx.litellmEnv);
|
|
289
|
+
const lines = fs.readFileSync(ctx.litellmEnv, "utf8").replace(/\n$/, "").split("\n");
|
|
290
|
+
const line = `MERIDIAN_API_KEY=${value}`;
|
|
291
|
+
const at = lines.findIndex((l) => l.startsWith("MERIDIAN_API_KEY="));
|
|
292
|
+
if (at >= 0 && lines[at] === line) {
|
|
293
|
+
fs.chmodSync(ctx.litellmEnv, 0o600);
|
|
294
|
+
h.ok(`${ctx.litellmEnv} already has MERIDIAN_API_KEY`);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (at >= 0) lines[at] = line;
|
|
298
|
+
else {
|
|
299
|
+
if (value === MERIDIAN_NO_KEY) lines.push("# Meridian runs without MERIDIAN_API_KEY; LiteLLM needs a non-empty value.");
|
|
300
|
+
lines.push(line);
|
|
301
|
+
}
|
|
302
|
+
const tmp = path.join(path.dirname(ctx.litellmEnv), `.litellm.env.${process.pid}`);
|
|
303
|
+
fs.writeFileSync(tmp, `${lines.join("\n")}\n`, { mode: 0o600 });
|
|
304
|
+
fs.chmodSync(tmp, 0o600);
|
|
305
|
+
fs.renameSync(tmp, ctx.litellmEnv);
|
|
306
|
+
h.ok(`set MERIDIAN_API_KEY in ${ctx.litellmEnv} (mode 600)`);
|
|
307
|
+
}
|