@duckmind/dm-windows-x64 0.61.3 → 0.61.5

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.
@@ -0,0 +1,385 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
4
+ import { detectModels } from "./detect.js";
5
+ import { deleteFromKeychain, isDirectApiKey, keychainCommand, storeInKeychain } from "./keychain.js";
6
+ const SETTINGS_FILE = path.join(os.homedir(), ".dm", "agent", "settings.json");
7
+ const SETTINGS_KEY = "localllm";
8
+ function readSettings() {
9
+ try {
10
+ if (!fs.existsSync(SETTINGS_FILE))
11
+ return { servers: [] };
12
+ const all = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf8"));
13
+ return all[SETTINGS_KEY] ?? { servers: [] };
14
+ } catch {
15
+ return { servers: [] };
16
+ }
17
+ }
18
+ function writeSettings(settings) {
19
+ let all = {};
20
+ try {
21
+ if (fs.existsSync(SETTINGS_FILE)) {
22
+ all = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf8"));
23
+ }
24
+ } catch {}
25
+ all[SETTINGS_KEY] = settings;
26
+ fs.writeFileSync(SETTINGS_FILE, JSON.stringify(all, null, 2), "utf8");
27
+ }
28
+ function generateId() {
29
+ return Math.random().toString(36).slice(2, 8);
30
+ }
31
+ function toProviderId(server) {
32
+ const slug = server.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
33
+ return `dm-localllm-${slug || server.id}`;
34
+ }
35
+ export function normalizeBaseUrl(raw) {
36
+ let stripped = raw.trim().replace(/\/+$/, "");
37
+ if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(stripped)) {
38
+ stripped = `http://${stripped}`;
39
+ }
40
+ return stripped.endsWith("/v1") ? stripped : `${stripped}/v1`;
41
+ }
42
+ function apiTypeLabel(apiType) {
43
+ switch (apiType) {
44
+ case "mtplx":
45
+ return "MTPLX";
46
+ case "omlx":
47
+ return "oMLX";
48
+ case "lmstudio":
49
+ return "LM Studio";
50
+ case "llamacpp":
51
+ return "llama.cpp";
52
+ case "ollama":
53
+ return "Ollama";
54
+ case "sglang":
55
+ return "SGLang";
56
+ case "vllm":
57
+ return "vLLM";
58
+ case "ds4":
59
+ return "ds4";
60
+ case "ninfer":
61
+ return "NInfer";
62
+ case "openai":
63
+ return "OpenAI-compatible";
64
+ }
65
+ }
66
+ function formatK(n) {
67
+ return n >= 1024 ? `${Math.round(n / 1024)}k` : `${n}`;
68
+ }
69
+ function formatBytes(bytes) {
70
+ return `${(bytes / 1024 ** 3).toFixed(1)}G`;
71
+ }
72
+ export function modelIdsChanged(before, after) {
73
+ if (before.length !== after.length)
74
+ return true;
75
+ const beforeIds = new Set(before.map((m) => m.id));
76
+ return after.some((m) => !beforeIds.has(m.id));
77
+ }
78
+ function loadedIcon(loaded) {
79
+ if (loaded === true)
80
+ return "✓ ";
81
+ if (loaded === false)
82
+ return "○ ";
83
+ return "";
84
+ }
85
+ export function modelsHeading(models) {
86
+ const reportsLoaded = models.some((m) => m.loaded !== undefined);
87
+ return reportsLoaded ? "Models: (✓ = loaded in memory, ○ = will be loaded on first message)" : "Models:";
88
+ }
89
+ export function formatModelLine(m) {
90
+ const caps = [m.reasoning ? "reasoning" : null, m.input.includes("image") ? "vision" : null].filter((c) => c !== null);
91
+ const parts = [`ctx ${formatK(m.contextWindow)}`, `max ${formatK(m.maxTokens)}`];
92
+ if (typeof m.sizeBytes === "number" && m.sizeBytes > 0)
93
+ parts.push(formatBytes(m.sizeBytes));
94
+ if (m.quantization)
95
+ parts.push(m.quantization);
96
+ parts.push(...caps);
97
+ return ` • ${loadedIcon(m.loaded)}${m.name} (${parts.join(", ")})`;
98
+ }
99
+ function registerServer(dm, server) {
100
+ dm.registerProvider(toProviderId(server), {
101
+ name: server.name,
102
+ baseUrl: server.baseUrl,
103
+ apiKey: server.apiKey || "no-key",
104
+ api: "openai-completions",
105
+ models: server.models.map((m) => ({
106
+ id: m.id,
107
+ name: m.name,
108
+ reasoning: m.reasoning,
109
+ input: m.input,
110
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
111
+ contextWindow: m.contextWindow,
112
+ maxTokens: m.maxTokens,
113
+ compat: {
114
+ supportsReasoningEffort: m.compat?.supportsReasoningEffort ?? false,
115
+ supportsDeveloperRole: m.compat?.supportsDeveloperRole ?? false
116
+ },
117
+ ...m.thinkingLevelMap ? { thinkingLevelMap: m.thinkingLevelMap } : {},
118
+ ...m.samplingParams ? { samplingParams: m.samplingParams } : {}
119
+ }))
120
+ });
121
+ }
122
+ function unregisterServer(dm, server) {
123
+ dm.unregisterProvider(toProviderId(server));
124
+ }
125
+ async function removeServer(dm, ctx, server) {
126
+ const ok = await ctx.ui.confirm(`Remove "${server.name}"?`, "Unregisters the provider and deletes its configuration.");
127
+ if (!ok)
128
+ return false;
129
+ unregisterServer(dm, server);
130
+ if (os.platform() === "darwin") {
131
+ await deleteFromKeychain(server.id);
132
+ }
133
+ const s = readSettings();
134
+ s.servers = s.servers.filter((sv) => sv.id !== server.id);
135
+ writeSettings(s);
136
+ ctx.ui.notify(`${server.name} removed.`, "info");
137
+ return true;
138
+ }
139
+ async function runWizard(ctx, existing) {
140
+ const name = await ctx.ui.input('Step 1/3 - Server name (e.g. "My vLLM", "Ollama", "LM Studio")', existing?.name ?? "");
141
+ if (!name?.trim())
142
+ return null;
143
+ const urlInput = await ctx.ui.input("Step 2/3 - Base URL", existing ? existing.baseUrl.replace(/\/v1$/, "") : "http://localhost:8000");
144
+ if (!urlInput?.trim())
145
+ return null;
146
+ const baseUrl = normalizeBaseUrl(urlInput);
147
+ const id = existing?.id ?? generateId();
148
+ const apiKeyInput = await ctx.ui.input("Step 3/3 - API key (leave blank if not required)", existing?.apiKey ?? "");
149
+ let apiKey = apiKeyInput?.trim() ?? "";
150
+ if (os.platform() === "darwin" && isDirectApiKey(apiKey)) {
151
+ const store = await ctx.ui.confirm("Store API key in macOS Keychain?", "Keeps the raw key out of settings.json — it'll be referenced via a !security command instead.");
152
+ if (store) {
153
+ try {
154
+ await storeInKeychain(id, apiKey);
155
+ apiKey = keychainCommand(id);
156
+ ctx.ui.notify("API key stored in Keychain.", "info");
157
+ } catch (err) {
158
+ ctx.ui.notify(`Failed to store in Keychain, keeping key in settings.json: ${err instanceof Error ? err.message : String(err)}`, "warning");
159
+ }
160
+ }
161
+ }
162
+ ctx.ui.notify(`Connecting to ${baseUrl} ...`, "info");
163
+ let result;
164
+ try {
165
+ result = await detectModels(baseUrl, apiKey, ctx.signal);
166
+ } catch (err) {
167
+ ctx.ui.notify(`Cannot reach server: ${err instanceof Error ? err.message : String(err)}`, "error");
168
+ return null;
169
+ }
170
+ if (result.models.length === 0) {
171
+ ctx.ui.notify(result.error ?? "Server responded but has no loaded models.", "error");
172
+ return null;
173
+ }
174
+ let selectedApiModels = result.models;
175
+ if (result.models.length > 1) {
176
+ const allOption = `All (${result.models.length} models)`;
177
+ const modelOptions = [allOption, ...result.models.map((m) => m.id)];
178
+ const picked = await ctx.ui.select(`${result.models.length} models found via ${apiTypeLabel(result.apiType)} - which to enable?`, modelOptions);
179
+ if (!picked)
180
+ return null;
181
+ if (picked !== allOption) {
182
+ selectedApiModels = result.models.filter((m) => m.id === picked);
183
+ }
184
+ }
185
+ return {
186
+ id,
187
+ name: name.trim(),
188
+ baseUrl,
189
+ apiKey,
190
+ apiType: result.apiType,
191
+ models: selectedApiModels
192
+ };
193
+ }
194
+ async function editModelCapabilities(dm, ctx, serverId) {
195
+ const server = readSettings().servers.find((s) => s.id === serverId);
196
+ if (!server || server.models.length === 0)
197
+ return;
198
+ let modelId = server.models[0].id;
199
+ if (server.models.length > 1) {
200
+ const picked = await ctx.ui.select("Which model?", server.models.map((m) => m.id));
201
+ if (!picked)
202
+ return;
203
+ modelId = picked;
204
+ }
205
+ while (true) {
206
+ const current = readSettings().servers.find((s) => s.id === serverId)?.models.find((m) => m.id === modelId);
207
+ if (!current)
208
+ return;
209
+ const vision = current.input.includes("image");
210
+ const temp = current.samplingParams?.temperature;
211
+ const OPT_VISION = `Vision: ${vision ? "on" : "off"} (tap to turn ${vision ? "off" : "on"})`;
212
+ const OPT_REASONING = `Reasoning: ${current.reasoning ? "on" : "off"} (tap to turn ${current.reasoning ? "off" : "on"})`;
213
+ const OPT_TEMP = `Temperature: ${temp ?? "server default"} (tap to change)`;
214
+ const OPT_DONE = "✓ Done";
215
+ const picked = await ctx.ui.select(`${current.name} - manual capability override
216
+ Overwritten by the next ↺ Refresh.`, [OPT_VISION, OPT_REASONING, OPT_TEMP, OPT_DONE]);
217
+ if (!picked || picked === OPT_DONE)
218
+ break;
219
+ let nextTemp;
220
+ if (picked === OPT_TEMP) {
221
+ const raw = await ctx.ui.input("Temperature (0 - 2, or empty to let the server decide)", temp === undefined ? "" : String(temp));
222
+ if (raw === undefined)
223
+ continue;
224
+ const trimmed = raw.trim();
225
+ if (trimmed === "") {
226
+ nextTemp = undefined;
227
+ } else {
228
+ const parsed = Number(trimmed);
229
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 2) {
230
+ ctx.ui.notify("Temperature must be a number between 0 and 2.", "error");
231
+ continue;
232
+ }
233
+ nextTemp = { temperature: parsed };
234
+ }
235
+ }
236
+ const s = readSettings();
237
+ const sv = s.servers.find((sv) => sv.id === serverId);
238
+ if (!sv)
239
+ return;
240
+ sv.models = sv.models.map((m) => {
241
+ if (m.id !== modelId)
242
+ return m;
243
+ if (picked === OPT_VISION) {
244
+ return { ...m, input: vision ? ["text"] : ["text", "image"] };
245
+ }
246
+ if (picked === OPT_TEMP) {
247
+ const { samplingParams: _dropped, ...rest } = m;
248
+ return nextTemp ? { ...rest, samplingParams: nextTemp } : rest;
249
+ }
250
+ return { ...m, reasoning: !m.reasoning };
251
+ });
252
+ writeSettings(s);
253
+ }
254
+ const s = readSettings();
255
+ const sv = s.servers.find((sv) => sv.id === serverId);
256
+ if (!sv)
257
+ return;
258
+ unregisterServer(dm, server);
259
+ registerServer(dm, sv);
260
+ ctx.ui.notify("Capabilities updated.", "info");
261
+ }
262
+ const OPT_REFRESH = "↺ Refresh model list from server";
263
+ const OPT_CAPS = "✎ Edit model capabilities (vision / reasoning / temperature)";
264
+ const OPT_EDIT = "✎ Reconfigure (name / URL / key)";
265
+ const OPT_REMOVE = "✕ Remove this server";
266
+ const OPT_BACK = "← Back";
267
+ async function showServerMenu(dm, ctx, serverId) {
268
+ while (true) {
269
+ const server = readSettings().servers.find((s) => s.id === serverId);
270
+ if (!server)
271
+ return;
272
+ const modelSummary = server.models.length === 0 ? " (no models)" : server.models.map(formatModelLine).join(`
273
+ `);
274
+ const backend = apiTypeLabel(server.apiType);
275
+ const picked = await ctx.ui.select(`${server.name} [${backend}]
276
+ URL: ${server.baseUrl}
277
+ ${modelsHeading(server.models)}
278
+ ${modelSummary}`, [OPT_REFRESH, ...server.models.length > 0 ? [OPT_CAPS] : [], OPT_EDIT, OPT_REMOVE, OPT_BACK]);
279
+ if (!picked || picked === OPT_BACK)
280
+ return;
281
+ if (picked === OPT_REFRESH) {
282
+ ctx.ui.notify(`Refreshing ${server.name} ...`, "info");
283
+ let result;
284
+ try {
285
+ result = await detectModels(server.baseUrl, server.apiKey, ctx.signal);
286
+ } catch (err) {
287
+ ctx.ui.notify(`Failed: ${err instanceof Error ? err.message : String(err)}`, "error");
288
+ continue;
289
+ }
290
+ if (result.models.length === 0 && result.error) {
291
+ ctx.ui.notify(`Refresh failed, keeping existing configuration: ${result.error}`, "error");
292
+ continue;
293
+ }
294
+ const updated = { ...server, apiType: result.apiType, models: result.models };
295
+ const modelsChanged = modelIdsChanged(server.models, updated.models);
296
+ const s = readSettings();
297
+ s.servers = s.servers.map((sv) => sv.id === serverId ? updated : sv);
298
+ writeSettings(s);
299
+ unregisterServer(dm, server);
300
+ registerServer(dm, updated);
301
+ ctx.ui.notify(`${server.name} (${apiTypeLabel(result.apiType)}): ${result.models.length} model(s) - ${result.models.map((m) => m.name).join(", ")}`, "info");
302
+ if (modelsChanged && updated.models.length === 1) {
303
+ await offerModelSwitch(dm, ctx, updated, updated.models[0]);
304
+ }
305
+ continue;
306
+ }
307
+ if (picked === OPT_CAPS) {
308
+ await editModelCapabilities(dm, ctx, serverId);
309
+ continue;
310
+ }
311
+ if (picked === OPT_EDIT) {
312
+ const updated = await runWizard(ctx, server);
313
+ if (!updated)
314
+ continue;
315
+ const s = readSettings();
316
+ s.servers = s.servers.map((sv) => sv.id === serverId ? updated : sv);
317
+ writeSettings(s);
318
+ unregisterServer(dm, server);
319
+ registerServer(dm, updated);
320
+ ctx.ui.notify(`${updated.name} updated.` + (updated.models.length > 1 ? " Switch models with /model." : ""), "info");
321
+ if (updated.models.length === 1) {
322
+ await offerModelSwitch(dm, ctx, updated, updated.models[0]);
323
+ }
324
+ return;
325
+ }
326
+ if (picked === OPT_REMOVE) {
327
+ if (await removeServer(dm, ctx, server))
328
+ return;
329
+ continue;
330
+ }
331
+ }
332
+ }
333
+ async function offerModelSwitch(dm, ctx, server, model) {
334
+ const switchNow = await ctx.ui.confirm(`Switch to ${model.name} now?`, `Makes it the active model for this session. You can always change it later with /model.`);
335
+ if (!switchNow)
336
+ return;
337
+ const resolved = ctx.modelRegistry.find(toProviderId(server), model.id);
338
+ if (!resolved) {
339
+ ctx.ui.notify("Couldn't find the newly registered model — switch with /model instead.", "warning");
340
+ return;
341
+ }
342
+ const ok = await dm.setModel(resolved);
343
+ if (!ok) {
344
+ ctx.ui.notify("Couldn't switch — no API key available for this model.", "warning");
345
+ }
346
+ }
347
+ const OPT_ADD = "+ Add server";
348
+ async function showMainMenu(dm, ctx) {
349
+ while (true) {
350
+ const { servers } = readSettings();
351
+ const serverLabels = servers.map((s) => `${s.name} [${apiTypeLabel(s.apiType)}] (${s.baseUrl}) ${s.models.length} model(s)`);
352
+ const picked = await ctx.ui.select(servers.length === 0 ? "LocalLLM - no servers configured" : `LocalLLM - ${servers.length} server(s)`, [...serverLabels, OPT_ADD]);
353
+ if (!picked)
354
+ return;
355
+ if (picked === OPT_ADD) {
356
+ const server = await runWizard(ctx);
357
+ if (!server)
358
+ continue;
359
+ const s = readSettings();
360
+ s.servers.push(server);
361
+ writeSettings(s);
362
+ registerServer(dm, server);
363
+ ctx.ui.notify(`${server.name} added - ${server.models.length} model(s): ${server.models.map((m) => m.name).join(", ")}.` + (server.models.length > 1 ? " Switch with /model." : ""), "info");
364
+ if (server.models.length === 1) {
365
+ await offerModelSwitch(dm, ctx, server, server.models[0]);
366
+ }
367
+ continue;
368
+ }
369
+ const idx = serverLabels.indexOf(picked);
370
+ if (idx >= 0) {
371
+ await showServerMenu(dm, ctx, servers[idx].id);
372
+ }
373
+ }
374
+ }
375
+ export default function dm_localllm_provider_default(dm) {
376
+ for (const server of readSettings().servers) {
377
+ registerServer(dm, server);
378
+ }
379
+ dm.registerCommand("localllm", {
380
+ description: "Manage LocalLLM providers - wizard-based setup for any OpenAI-compatible local server",
381
+ async handler(_args, ctx) {
382
+ await showMainMenu(dm, ctx);
383
+ }
384
+ });
385
+ }
@@ -0,0 +1,149 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { formatModelLine, modelIdsChanged, modelsHeading, normalizeBaseUrl } from "./index.js";
3
+ describe("normalizeBaseUrl", () => {
4
+ it("appends /v1 when missing", () => {
5
+ expect(normalizeBaseUrl("http://localhost:8000")).toBe("http://localhost:8000/v1");
6
+ });
7
+ it("leaves an existing /v1 suffix alone", () => {
8
+ expect(normalizeBaseUrl("http://localhost:8000/v1")).toBe("http://localhost:8000/v1");
9
+ });
10
+ it("strips trailing slashes before checking the suffix", () => {
11
+ expect(normalizeBaseUrl("http://localhost:8000/v1/")).toBe("http://localhost:8000/v1");
12
+ expect(normalizeBaseUrl("http://localhost:8000/")).toBe("http://localhost:8000/v1");
13
+ });
14
+ it("defaults to http:// for a bare host:port with no scheme", () => {
15
+ expect(normalizeBaseUrl("localhost:11434")).toBe("http://localhost:11434/v1");
16
+ expect(normalizeBaseUrl("192.168.1.50:8000")).toBe("http://192.168.1.50:8000/v1");
17
+ });
18
+ it("preserves an explicit https:// scheme", () => {
19
+ expect(normalizeBaseUrl("https://my.server.com")).toBe("https://my.server.com/v1");
20
+ });
21
+ it("trims surrounding whitespace", () => {
22
+ expect(normalizeBaseUrl(" localhost:11434 ")).toBe("http://localhost:11434/v1");
23
+ });
24
+ });
25
+ describe("formatModelLine", () => {
26
+ it("formats context window and max tokens in k, with no capability tags", () => {
27
+ expect(formatModelLine({
28
+ id: "m1",
29
+ name: "some-model",
30
+ contextWindow: 65536,
31
+ maxTokens: 8192,
32
+ reasoning: false,
33
+ input: ["text"]
34
+ })).toBe(" • some-model (ctx 64k, max 8k)");
35
+ });
36
+ it("appends reasoning and vision tags when present", () => {
37
+ expect(formatModelLine({
38
+ id: "m1",
39
+ name: "vision-model",
40
+ contextWindow: 65536,
41
+ maxTokens: 8192,
42
+ reasoning: true,
43
+ input: ["text", "image"]
44
+ })).toBe(" • vision-model (ctx 64k, max 8k, reasoning, vision)");
45
+ });
46
+ it("shows sub-1024 windows without a k suffix", () => {
47
+ expect(formatModelLine({
48
+ id: "m1",
49
+ name: "tiny",
50
+ contextWindow: 512,
51
+ maxTokens: 256,
52
+ reasoning: false,
53
+ input: ["text"]
54
+ })).toBe(" • tiny (ctx 512, max 256)");
55
+ });
56
+ it("prefixes a checkmark when loaded is true", () => {
57
+ expect(formatModelLine({
58
+ id: "m1",
59
+ name: "m",
60
+ contextWindow: 4096,
61
+ maxTokens: 2048,
62
+ reasoning: false,
63
+ input: ["text"],
64
+ loaded: true
65
+ })).toBe(" • ✓ m (ctx 4k, max 2k)");
66
+ });
67
+ it("prefixes a hollow circle when loaded is false", () => {
68
+ expect(formatModelLine({
69
+ id: "m1",
70
+ name: "m",
71
+ contextWindow: 4096,
72
+ maxTokens: 2048,
73
+ reasoning: false,
74
+ input: ["text"],
75
+ loaded: false
76
+ })).toBe(" • ○ m (ctx 4k, max 2k)");
77
+ });
78
+ it("omits the loaded prefix entirely when loaded is unknown", () => {
79
+ expect(formatModelLine({
80
+ id: "m1",
81
+ name: "m",
82
+ contextWindow: 4096,
83
+ maxTokens: 2048,
84
+ reasoning: false,
85
+ input: ["text"]
86
+ })).toBe(" • m (ctx 4k, max 2k)");
87
+ });
88
+ it("shows size and quantization when present, in order before capability tags", () => {
89
+ expect(formatModelLine({
90
+ id: "m1",
91
+ name: "m",
92
+ contextWindow: 4096,
93
+ maxTokens: 2048,
94
+ reasoning: true,
95
+ input: ["text", "image"],
96
+ sizeBytes: 4912898304,
97
+ quantization: "Q4_K_M"
98
+ })).toBe(" • m (ctx 4k, max 2k, 4.6G, Q4_K_M, reasoning, vision)");
99
+ });
100
+ });
101
+ describe("modelIdsChanged", () => {
102
+ const baseModel = {
103
+ id: "m1",
104
+ name: "m1",
105
+ contextWindow: 4096,
106
+ maxTokens: 2048,
107
+ reasoning: false,
108
+ input: ["text"]
109
+ };
110
+ it("is false when the same single model is refreshed with new metadata", () => {
111
+ expect(modelIdsChanged([baseModel], [{ ...baseModel, contextWindow: 8192 }])).toBe(false);
112
+ });
113
+ it("is false when the same set of models comes back in a different order", () => {
114
+ const a = { ...baseModel, id: "a" };
115
+ const b = { ...baseModel, id: "b" };
116
+ expect(modelIdsChanged([a, b], [b, a])).toBe(false);
117
+ });
118
+ it("is true when the model count changes", () => {
119
+ const a = { ...baseModel, id: "a" };
120
+ const b = { ...baseModel, id: "b" };
121
+ expect(modelIdsChanged([a], [a, b])).toBe(true);
122
+ });
123
+ it("is true when a same-count refresh swaps in a different model id", () => {
124
+ const a = { ...baseModel, id: "a" };
125
+ const c = { ...baseModel, id: "c" };
126
+ expect(modelIdsChanged([a], [c])).toBe(true);
127
+ });
128
+ it("is false for two empty lists", () => {
129
+ expect(modelIdsChanged([], [])).toBe(false);
130
+ });
131
+ });
132
+ describe("modelsHeading", () => {
133
+ const baseModel = {
134
+ id: "m1",
135
+ name: "m1",
136
+ contextWindow: 4096,
137
+ maxTokens: 2048,
138
+ reasoning: false,
139
+ input: ["text"]
140
+ };
141
+ it("adds the loaded-state legend when at least one model reports it", () => {
142
+ expect(modelsHeading([{ ...baseModel, loaded: true }])).toBe("Models: (✓ = loaded in memory, ○ = will be loaded on first message)");
143
+ expect(modelsHeading([{ ...baseModel }, { ...baseModel, loaded: false }])).toBe("Models: (✓ = loaded in memory, ○ = will be loaded on first message)");
144
+ });
145
+ it("omits the legend when no model reports loaded state", () => {
146
+ expect(modelsHeading([baseModel])).toBe("Models:");
147
+ expect(modelsHeading([])).toBe("Models:");
148
+ });
149
+ });
@@ -0,0 +1,33 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileAsync = promisify(execFile);
4
+ const KEYCHAIN_SERVICE = "dm-localllm-provider";
5
+ export function isDirectApiKey(key) {
6
+ return key.length > 0 && !key.startsWith("!") && !key.startsWith("$");
7
+ }
8
+ export function keychainCommand(account) {
9
+ return `!security find-generic-password -s '${KEYCHAIN_SERVICE}' -a '${account}' -w`;
10
+ }
11
+ export async function storeInKeychain(account, rawKey) {
12
+ await deleteFromKeychain(account);
13
+ await execFileAsync("security", [
14
+ "add-generic-password",
15
+ "-s",
16
+ KEYCHAIN_SERVICE,
17
+ "-a",
18
+ account,
19
+ "-w",
20
+ rawKey
21
+ ]);
22
+ }
23
+ export async function deleteFromKeychain(account) {
24
+ try {
25
+ await execFileAsync("security", [
26
+ "delete-generic-password",
27
+ "-s",
28
+ KEYCHAIN_SERVICE,
29
+ "-a",
30
+ account
31
+ ]);
32
+ } catch {}
33
+ }
@@ -0,0 +1,61 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ const execFileCalls = [];
3
+ let nextShouldFail = false;
4
+ vi.mock("node:child_process", () => ({
5
+ execFile: (file, args, cb) => {
6
+ execFileCalls.push({ file, args });
7
+ if (nextShouldFail) {
8
+ cb(new Error("no such keychain item"));
9
+ } else {
10
+ cb(null, "", "");
11
+ }
12
+ }
13
+ }));
14
+ const { isDirectApiKey, keychainCommand, storeInKeychain, deleteFromKeychain } = await import("./keychain.js");
15
+ afterEach(() => {
16
+ execFileCalls.length = 0;
17
+ nextShouldFail = false;
18
+ });
19
+ describe("isDirectApiKey", () => {
20
+ it("treats a plain string as a direct key", () => {
21
+ expect(isDirectApiKey("sk-abc123")).toBe(true);
22
+ });
23
+ it("rejects empty, !command, and $ENV_VAR forms", () => {
24
+ expect(isDirectApiKey("")).toBe(false);
25
+ expect(isDirectApiKey("!security find-generic-password -w")).toBe(false);
26
+ expect(isDirectApiKey("$MY_API_KEY")).toBe(false);
27
+ });
28
+ });
29
+ describe("keychainCommand", () => {
30
+ it("formats a !security find-generic-password command for the given account", () => {
31
+ expect(keychainCommand("abc123")).toBe("!security find-generic-password -s 'dm-localllm-provider' -a 'abc123' -w");
32
+ });
33
+ });
34
+ describe("storeInKeychain", () => {
35
+ it("deletes any existing entry, then adds the key as a single argv element", async () => {
36
+ await storeInKeychain("abc123", "sk-with-a-'-quote-and-$(dangerous)-chars");
37
+ expect(execFileCalls).toHaveLength(2);
38
+ expect(execFileCalls[0]).toEqual({
39
+ file: "security",
40
+ args: ["delete-generic-password", "-s", "dm-localllm-provider", "-a", "abc123"]
41
+ });
42
+ expect(execFileCalls[1]).toEqual({
43
+ file: "security",
44
+ args: [
45
+ "add-generic-password",
46
+ "-s",
47
+ "dm-localllm-provider",
48
+ "-a",
49
+ "abc123",
50
+ "-w",
51
+ "sk-with-a-'-quote-and-$(dangerous)-chars"
52
+ ]
53
+ });
54
+ });
55
+ });
56
+ describe("deleteFromKeychain", () => {
57
+ it("swallows errors when there is no existing entry", async () => {
58
+ nextShouldFail = true;
59
+ await expect(deleteFromKeychain("abc123")).resolves.toBeUndefined();
60
+ });
61
+ });
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "dm-localllm-provider",
3
+ "version": "0.5.0",
4
+ "description": "Wizard-based setup for local OpenAI-compatible LLM servers in DM",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "license": "MIT",
8
+ "author": "Eric Qian",
9
+ "keywords": [
10
+ "duckmind",
11
+ "dm",
12
+ "dm-package",
13
+ "dm-extension",
14
+ "llm",
15
+ "vllm",
16
+ "ollama",
17
+ "lm-studio"
18
+ ],
19
+ "dm": {
20
+ "extensions": [
21
+ "./index.js"
22
+ ]
23
+ }
24
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duckmind/dm-windows-x64",
3
- "version": "0.61.3",
3
+ "version": "0.61.5",
4
4
  "description": "DuckMind (dm) binary payload for windows x64",
5
5
  "license": "MIT",
6
6
  "os": [