dsh-plugin-local-infer 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +10 -2
  2. package/docs/zh/README.md +10 -2
  3. package/dsh/client.js +669 -191
  4. package/package.json +6 -1
  5. package/packages/core/lib/adapter.d.ts +44 -2
  6. package/packages/core/lib/deps.d.ts +4 -3
  7. package/packages/core/lib/index.d.ts +1 -1
  8. package/packages/core/lib/registrar.d.ts +5 -5
  9. package/packages/core/lib/registrar.js +3 -3
  10. package/packages/core/package.json +1 -1
  11. package/packages/providers/lib/acquire.d.ts +5 -3
  12. package/packages/providers/lib/acquire.js +30 -9
  13. package/packages/providers/lib/adapters.d.ts +19 -209
  14. package/packages/providers/lib/adapters.js +13 -886
  15. package/packages/providers/lib/deps.d.ts +24 -2
  16. package/packages/providers/lib/deps.js +23 -4
  17. package/packages/providers/lib/engines/hyperqwenAdapter.d.ts +38 -0
  18. package/packages/providers/lib/engines/hyperqwenAdapter.js +105 -0
  19. package/packages/providers/lib/{infernix.js → engines/infernix.js} +1 -1
  20. package/packages/providers/lib/engines/infernixAdapter.d.ts +53 -0
  21. package/packages/providers/lib/engines/infernixAdapter.js +202 -0
  22. package/packages/providers/lib/engines/llamacppAdapter.d.ts +51 -0
  23. package/packages/providers/lib/engines/llamacppAdapter.js +232 -0
  24. package/packages/providers/lib/{ninfer.js → engines/ninfer.js} +1 -1
  25. package/packages/providers/lib/engines/ninferAdapter.d.ts +82 -0
  26. package/packages/providers/lib/engines/ninferAdapter.js +324 -0
  27. package/packages/providers/lib/engines/strata.d.ts +53 -0
  28. package/packages/providers/lib/engines/strata.js +197 -0
  29. package/packages/providers/lib/engines/strataAdapter.d.ts +98 -0
  30. package/packages/providers/lib/engines/strataAdapter.js +427 -0
  31. package/packages/providers/lib/engines.d.ts +5 -4
  32. package/packages/providers/lib/engines.js +10 -10
  33. package/packages/providers/lib/guidelines.js +1 -1
  34. package/packages/providers/lib/index.d.ts +14 -9
  35. package/packages/providers/lib/index.js +17 -7
  36. package/packages/providers/lib/model-defaults.d.ts +5 -4
  37. package/packages/providers/lib/model-defaults.js +22 -4
  38. package/packages/providers/lib/platform.js +7 -1
  39. package/packages/providers/package.json +1 -1
  40. package/packages/service/lib/index.d.ts +13 -4
  41. package/packages/service/lib/index.js +79 -5
  42. package/packages/service/lib/types.d.ts +5 -4
  43. package/packages/service/package.json +1 -1
  44. package/packages/tool/package.json +1 -1
  45. package/packages/ui/lib/config.d.ts +79 -0
  46. package/packages/ui/lib/config.js +235 -0
  47. package/packages/ui/lib/deps.d.ts +3 -3
  48. package/packages/ui/lib/ops/engine.d.ts +108 -0
  49. package/packages/ui/lib/ops/engine.js +493 -0
  50. package/packages/ui/lib/ops/hf.d.ts +27 -0
  51. package/packages/ui/lib/ops/hf.js +60 -0
  52. package/packages/ui/lib/ops/logs.d.ts +23 -0
  53. package/packages/ui/lib/ops/logs.js +58 -0
  54. package/packages/ui/lib/ops/model.d.ts +79 -0
  55. package/packages/ui/lib/ops/model.js +130 -0
  56. package/packages/ui/lib/request.d.ts +106 -0
  57. package/packages/ui/lib/request.js +80 -0
  58. package/packages/ui/lib/route.d.ts +88 -420
  59. package/packages/ui/lib/route.js +111 -1843
  60. package/packages/ui/lib/routes/chat-template.d.ts +14 -0
  61. package/packages/ui/lib/routes/chat-template.js +37 -0
  62. package/packages/ui/lib/routes/deps.d.ts +16 -0
  63. package/packages/ui/lib/routes/deps.js +27 -0
  64. package/packages/ui/lib/routes/download.d.ts +16 -0
  65. package/packages/ui/lib/routes/download.js +48 -0
  66. package/packages/ui/lib/routes/get.d.ts +18 -0
  67. package/packages/ui/lib/routes/get.js +57 -0
  68. package/packages/ui/lib/routes/install.d.ts +16 -0
  69. package/packages/ui/lib/routes/install.js +36 -0
  70. package/packages/ui/lib/routes/launch.d.ts +18 -0
  71. package/packages/ui/lib/routes/launch.js +50 -0
  72. package/packages/ui/lib/routes/provider-delete.d.ts +15 -0
  73. package/packages/ui/lib/routes/provider-delete.js +37 -0
  74. package/packages/ui/lib/routes/register.d.ts +16 -0
  75. package/packages/ui/lib/routes/register.js +28 -0
  76. package/packages/ui/lib/routes/save.d.ts +18 -0
  77. package/packages/ui/lib/routes/save.js +139 -0
  78. package/packages/ui/lib/routes/stop.d.ts +16 -0
  79. package/packages/ui/lib/routes/stop.js +29 -0
  80. package/packages/ui/lib/routes/summary.d.ts +15 -0
  81. package/packages/ui/lib/routes/summary.js +26 -0
  82. package/packages/ui/lib/routes/unregister.d.ts +14 -0
  83. package/packages/ui/lib/routes/unregister.js +26 -0
  84. package/packages/ui/lib/viewmodels/deps.d.ts +31 -0
  85. package/packages/ui/lib/viewmodels/deps.js +89 -0
  86. package/packages/ui/lib/viewmodels/depsHelpers.d.ts +28 -0
  87. package/packages/ui/lib/viewmodels/depsHelpers.js +33 -0
  88. package/packages/ui/lib/viewmodels/summary.d.ts +140 -0
  89. package/packages/ui/lib/viewmodels/summary.js +225 -0
  90. package/packages/ui/lib/viewmodels/summaryHelpers.d.ts +30 -0
  91. package/packages/ui/lib/viewmodels/summaryHelpers.js +42 -0
  92. package/packages/ui/package.json +1 -1
  93. package/packages/providers/lib/strata.d.ts +0 -15
  94. package/packages/providers/lib/strata.js +0 -14
  95. /package/packages/providers/lib/{hyperqwen.d.ts → engines/hyperqwen.d.ts} +0 -0
  96. /package/packages/providers/lib/{hyperqwen.js → engines/hyperqwen.js} +0 -0
  97. /package/packages/providers/lib/{infernix.d.ts → engines/infernix.d.ts} +0 -0
  98. /package/packages/providers/lib/{llamacpp.d.ts → engines/llamacpp.d.ts} +0 -0
  99. /package/packages/providers/lib/{llamacpp.js → engines/llamacpp.js} +0 -0
  100. /package/packages/providers/lib/{ninfer.d.ts → engines/ninfer.d.ts} +0 -0
@@ -12,517 +12,31 @@
12
12
  * ~/.dsh/local-infer/config.json, shared with the CLI, so the browser never
13
13
  * sees a secret it did not store. The routes are held by an effect, so they
14
14
  * go when the plugin does.
15
+ *
16
+ * This module holds the HTTP plumbing every route builds on: the RouteError
17
+ * the domain operations throw with their own status, the RouteResponder the
18
+ * handlers answer through, and defineRoute, the template method that guards
19
+ * the method (405), reads the bounded body (413), and maps the handler's
20
+ * answer to a status. The request parsing lives in ./request.ts, the stored-
21
+ * config persistence in ./config.ts, the view models in ./viewmodels/, the
22
+ * operations in ./ops/, and each route definition in its own file under
23
+ * ./routes/. registerLocalInferRoute wires them onto the web server.
15
24
  * @module dsh-plugin-local-infer/ui/route
16
25
  */
17
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
18
- import { homedir } from 'node:os';
19
- import { dirname, join } from 'node:path';
20
- // Relative, not the root package subpath: a self-referencing bare specifier
21
- // only resolves when the package sits inside a node_modules tree, so a
22
- // local-path or pnpm-linked install of this repo would fail the import.
23
- import { CHAT_TEMPLATE_FILE, buildModelEntry, checkDeps, DEFAULT_LOG_PATH_TEMPLATE, formatLaunchCommand, hfChatTemplateCommand, hfDownloadCommand, launchRecordFromPlan, modelArtifactPath, modelEntriesForEngine, providerIdFor, renderInstallString, resolveLogPath, } from '../../core/lib/index.js';
24
- import { CHAT_TEMPLATE_OPTIONS, buildEngineRepo, defaultModelFor, defaultModelsForEngine, ENGINES, enginePlatformDefaults, infernixGetDownloads, nodeLifecycleHost, syncEngineRepo, } from '../../providers/lib/index.js';
25
- import { renderDeps } from "./deps.js";
26
- import { renderForm } from "./form.js";
27
- import { renderMeter } from "./meter.js";
28
- import { renderWarnings } from "./warnings.js";
29
- /**
30
- * isHfRepoReference reports whether a chat template value is a Hugging Face
31
- * repo reference (an owner/name pair) rather than a local path: it carries a
32
- * slash and does not start with one. Only repo references are auto-downloaded
33
- * into the model directory; a local path is used in place.
34
- */
35
- function isHfRepoReference(value) {
36
- return value !== '' && !value.startsWith('/') && value.includes('/');
37
- }
38
- /**
39
- * chatTemplateLabel resolves the display label for a chat template repo: the
40
- * preset's label when the repo is a shipped preset, else the repo itself.
41
- */
42
- function chatTemplateLabel(repo) {
43
- const preset = CHAT_TEMPLATE_OPTIONS.find((option) => option.value === repo);
44
- return preset?.label ?? repo;
45
- }
46
- /**
47
- * resolveModelRegistryId maps the card's model value onto the VramModel
48
- * registry key the estimator prices. The card's Model field carries either a
49
- * registry key (a default-model selection) or an artifact file name (a
50
- * directory-scan selection, e.g. a .ninfer file); the default list maps the
51
- * file name to the registry key the estimator knows, so a scanned model prices
52
- * a real VramModel rather than reading as not-measured. A value that is
53
- * already a registry key passes through, and a missing value falls back to
54
- * the engine's first default model.
55
- */
56
- function resolveModelRegistryId(engineId, model) {
57
- if (typeof model === 'string' && model !== '') {
58
- const entry = defaultModelsForEngine(engineId).find((candidate) => candidate.file === model);
59
- return entry?.modelId ?? model;
60
- }
61
- return defaultModelsForEngine(engineId)[0]?.modelId ?? engineId;
62
- }
63
- /**
64
- * The config file the route owns. It sits under the dsh home so a web user and
65
- * a CLI user share one file, the way modsearch shares ~/.modsearch.
66
- */
67
- export function localInferConfigPath() {
68
- return join(homedir(), '.dsh', 'local-infer', 'config.json');
69
- }
70
- /**
71
- * readConfig reads the stored engine, config, trust gate, and per-engine
72
- * platform configs. A missing or broken file reads as the scaffold default
73
- * (NInfer, empty config, gate off, no platform configs) so the card always has
74
- * a form to draw. A platform entry that is not an object (or whose fields are
75
- * not strings) is dropped, so a by-hand edit never poisons the deploy step.
76
- */
77
- export function readConfig() {
78
- const path = localInferConfigPath();
79
- if (!existsSync(path)) {
80
- return {
81
- engine: 'ninfer',
82
- config: {},
83
- allowRunDeps: false,
84
- platform: {},
85
- showHelp: true,
86
- providers: [],
87
- logPath: DEFAULT_LOG_PATH_TEMPLATE,
88
- lastLogs: '',
89
- };
90
- }
91
- try {
92
- const parsed = JSON.parse(readFileSync(path, 'utf8'));
93
- return {
94
- engine: typeof parsed.engine === 'string' ? parsed.engine : 'ninfer',
95
- config: parsed.config && typeof parsed.config === 'object' ? parsed.config : {},
96
- allowRunDeps: typeof parsed.allowRunDeps === 'boolean' ? parsed.allowRunDeps : false,
97
- platform: parsePlatformConfigs(parsed.platform),
98
- // A file that predates the key (or carries a non-boolean) reads as
99
- // descriptions visible, the default the card draws on first open.
100
- showHelp: typeof parsed.showHelp === 'boolean' ? parsed.showHelp : true,
101
- providers: parseProviders(parsed.providers),
102
- // A file that predates the key reads as the plugin's default log dir,
103
- // and the last-run logs start empty until a launch fills them.
104
- logPath: typeof parsed.logPath === 'string' && parsed.logPath !== ''
105
- ? parsed.logPath
106
- : DEFAULT_LOG_PATH_TEMPLATE,
107
- lastLogs: typeof parsed.lastLogs === 'string' ? parsed.lastLogs : '',
108
- };
109
- }
110
- catch {
111
- return {
112
- engine: 'ninfer',
113
- config: {},
114
- allowRunDeps: false,
115
- platform: {},
116
- showHelp: true,
117
- providers: [],
118
- logPath: DEFAULT_LOG_PATH_TEMPLATE,
119
- lastLogs: '',
120
- };
121
- }
122
- }
123
- /**
124
- * parseProviders reads the stored provider list, keeping only entries with a
125
- * non-blank string id. A non-object entry or an entry with no id is dropped,
126
- * so a by-hand edit never poisons the card's provider list. The per-engine
127
- * platform config is read through the same string-field rule the platform map
128
- * uses, so a provider never carries a platform field it cannot price.
129
- */
130
- function parseProviders(raw) {
131
- if (raw === null || typeof raw !== 'object' || !Array.isArray(raw)) {
132
- return [];
133
- }
134
- const providers = [];
135
- for (const entry of raw) {
136
- if (entry === null || typeof entry !== 'object' || Array.isArray(entry))
137
- continue;
138
- const fields = entry;
139
- if (typeof fields.id !== 'string' || fields.id.trim() === '')
140
- continue;
141
- const engine = typeof fields.engine === 'string' ? fields.engine : 'ninfer';
142
- const config = fields.config && typeof fields.config === 'object' ? fields.config : {};
143
- const platform = parseStoredPlatformConfig(fields.platform) ?? {};
144
- const provider = { id: fields.id, engine, config, platform };
145
- const launch = parseLaunchRecord(fields.launch);
146
- if (launch !== undefined) {
147
- provider.launch = launch;
148
- }
149
- providers.push(provider);
150
- }
151
- return providers;
152
- }
153
- /**
154
- * parseLaunchRecord reads one stored launch record, keeping only the fields
155
- * the re-link rule prices (the engine pid, the start time, the argv, the
156
- * parent and previous pids, and the engine/model/port the detector ties the
157
- * engine to). A record that is not an object, or that names no integer pid
158
- * or engine id, is dropped, so a by-hand edit never poisons the re-link.
159
- */
160
- function parseLaunchRecord(raw) {
161
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
162
- return undefined;
163
- }
164
- const fields = raw;
165
- if (typeof fields.pid !== 'number' || !Number.isInteger(fields.pid)) {
166
- return undefined;
167
- }
168
- if (typeof fields.startedAt !== 'string' || fields.startedAt === '') {
169
- return undefined;
170
- }
171
- if (typeof fields.engineId !== 'string' || fields.engineId.trim() === '') {
172
- return undefined;
173
- }
174
- const record = {
175
- pid: fields.pid,
176
- startedAt: fields.startedAt,
177
- argv: Array.isArray(fields.argv)
178
- ? fields.argv.filter((part) => typeof part === 'string')
179
- : [],
180
- previousPids: Array.isArray(fields.previousPids)
181
- ? fields.previousPids.filter((part) => typeof part === 'number')
182
- : [],
183
- engineId: fields.engineId,
184
- };
185
- if (typeof fields.parentPid === 'number') {
186
- record.parentPid = fields.parentPid;
187
- }
188
- if (typeof fields.modelId === 'string' && fields.modelId !== '') {
189
- record.modelId = fields.modelId;
190
- }
191
- if (typeof fields.port === 'number') {
192
- record.port = fields.port;
193
- }
194
- return record;
195
- }
196
- /**
197
- * newProviderId mints the stable id a new provider entry carries: a
198
- * timestamp base plus a random tail, so two Saves in the same tick never
199
- * collide. The card edits and deletes against this id.
200
- */
201
- function newProviderId() {
202
- return `provider-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
203
- }
204
- /**
205
- * writeConfig persists the engine, config, trust gate, per-engine platform
206
- * configs, and the help toggle, creating the directory tree on first write.
207
- */
208
- export function writeConfig(engine, config, allowRunDeps, platform, showHelp, providers, logPath, lastLogs) {
209
- const path = localInferConfigPath();
210
- mkdirSync(dirname(path), { recursive: true });
211
- writeFileSync(path, JSON.stringify({
212
- engine,
213
- config,
214
- allowRunDeps,
215
- platform: platform ?? {},
216
- showHelp: showHelp ?? true,
217
- providers: providers ?? [],
218
- logPath: logPath ?? DEFAULT_LOG_PATH_TEMPLATE,
219
- lastLogs: lastLogs ?? '',
220
- }, null, 2));
221
- }
222
- /**
223
- * parsePlatformConfigs reads the stored platform map, keeping only entries
224
- * whose fields are strings. A non-object map or a non-object entry reads as
225
- * absent, so the deploy step never sees a platform config it cannot price.
226
- */
227
- function parsePlatformConfigs(raw) {
228
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
229
- return {};
230
- }
231
- const platform = {};
232
- for (const [engineId, entry] of Object.entries(raw)) {
233
- if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
234
- continue;
235
- }
236
- const stored = {};
237
- const fields = entry;
238
- if (typeof fields.engineDirectory === 'string') {
239
- stored.engineDirectory = fields.engineDirectory;
240
- }
241
- if (typeof fields.engineFork === 'string') {
242
- stored.engineFork = fields.engineFork;
243
- }
244
- if (typeof fields.buildCommands === 'string') {
245
- stored.buildCommands = fields.buildCommands;
246
- }
247
- if (typeof fields.dockerTag === 'string') {
248
- stored.dockerTag = fields.dockerTag;
249
- }
250
- if (stored.engineDirectory !== undefined ||
251
- stored.engineFork !== undefined ||
252
- stored.buildCommands !== undefined ||
253
- stored.dockerTag !== undefined) {
254
- platform[engineId] = stored;
255
- }
256
- }
257
- return platform;
258
- }
259
- /**
260
- * parseStoredPlatformConfig reads one engine's platform config (the launch
261
- * request's `platform` key), keeping only string fields. A non-object or an
262
- * object with no string fields reads as absent, so the launch keeps the stored
263
- * entry the save route synced.
264
- */
265
- function parseStoredPlatformConfig(raw) {
266
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
267
- return undefined;
268
- }
269
- const fields = raw;
270
- const stored = {};
271
- if (typeof fields.engineDirectory === 'string') {
272
- stored.engineDirectory = fields.engineDirectory;
273
- }
274
- if (typeof fields.engineFork === 'string') {
275
- stored.engineFork = fields.engineFork;
276
- }
277
- if (typeof fields.buildCommands === 'string') {
278
- stored.buildCommands = fields.buildCommands;
279
- }
280
- if (typeof fields.dockerTag === 'string') {
281
- stored.dockerTag = fields.dockerTag;
282
- }
283
- if (stored.engineDirectory === undefined &&
284
- stored.engineFork === undefined &&
285
- stored.buildCommands === undefined &&
286
- stored.dockerTag === undefined) {
287
- return undefined;
288
- }
289
- return stored;
290
- }
291
- /**
292
- * relinkStoredProviders prices every stored provider launch record against the
293
- * live process table through the service's re-link rule and returns the
294
- * provider list the card should read: an adopted engine is re-marked plugin-
295
- * launched and tied to its model by the service, and a liveness watcher starts
296
- * on its port so a later pkill settles the stored state through the death
297
- * observer without another summary read; a dead engine's record is cleared on
298
- * disk here (the previous-pid list with it), so a recycled pid cannot re-link
299
- * to a config whose engine is down. The route calls it on mount and the
300
- * summary calls it per read, making re-attach independent of the card opening.
301
- */
302
- export async function relinkStoredProviders(localInfer, stored) {
303
- const recorded = stored.providers.filter((provider) => provider.launch !== undefined);
304
- if (recorded.length === 0)
305
- return stored.providers;
306
- const outcomes = await localInfer.reLinkProviders(recorded.map((provider) => ({ id: provider.id, record: provider.launch })));
307
- if (!recorded.some((provider) => outcomes[provider.id] === 'dead')) {
308
- return stored.providers;
309
- }
310
- const providers = stored.providers.map((provider) => {
311
- if (provider.launch === undefined || outcomes[provider.id] !== 'dead') {
312
- return provider;
313
- }
314
- // Rebuild the dead entry without the launch key, so the record (including
315
- // the previous-pid list) is gone from the file, not just ignored.
316
- return {
317
- id: provider.id,
318
- engine: provider.engine,
319
- config: provider.config,
320
- platform: provider.platform,
321
- };
322
- });
323
- writeConfig(stored.engine, stored.config, stored.allowRunDeps, stored.platform, stored.showHelp, providers, stored.logPath, stored.lastLogs);
324
- return providers;
325
- }
326
- /**
327
- * buildSummary assembles the card's read model for one engine and config: the
328
- * static engine and model lists, the detected hardware, the form model the
329
- * selected engine's parameter surface renders to, the meter model the
330
- * service's estimate prices, and the warning model the service's guideline
331
- * engine fires. The estimate is the single source the meter and the verdict
332
- * banner agree on, and the guideline results are the single source the red
333
- * banner and the inline warnings agree on.
334
- */
335
- export async function buildSummary(localInfer, request) {
336
- const stored = readConfig();
337
- const engineId = request?.engine ?? stored.engine;
338
- // ENGINES is a known non-empty constant, so the fallback is always defined.
339
- const engine = ENGINES.find((entry) => entry.id === engineId) ?? ENGINES[0];
340
- // The Model Directory field defaults to the plugin's model download
341
- // directory (~/.dsh/models, created when absent), so the card always
342
- // seeds a real path the deploy step can target. A stored or requested
343
- // directory that is a non-blank string wins over the default.
344
- const modelDirectory = localInfer.defaultModelDirectory();
345
- const rawConfig = request?.config ?? stored.config;
346
- const config = typeof rawConfig.modelDirectory === 'string' && rawConfig.modelDirectory.trim() !== ''
347
- ? rawConfig
348
- : { ...rawConfig, modelDirectory };
349
- const modelDir = typeof config.modelDirectory === 'string' ? config.modelDirectory : modelDirectory;
350
- const hardware = await localInfer.detectHardware();
351
- // The model value the card carries maps onto the VramModel registry key the
352
- // estimator prices: a default-model selection is already a registry key, a
353
- // directory-scan selection is an artifact file name the default list maps
354
- // to its registry key, and a missing value falls back to the engine's first
355
- // default model. Pricing the resolved key is what keeps the meter from
356
- // reading as not-measured (an empty chart) on a scanned model.
357
- const modelId = resolveModelRegistryId(engine.id, config.model);
358
- const pricedConfig = { ...config, model: modelId };
359
- // Resolve the starting config over the registered presets and the engine's
360
- // parameter surface, so the estimate and the model-aware guidelines price a
361
- // sane budget (the preset's kvDtype and max-context) rather than a bare
362
- // config that names no KV format. The resolved config carries the explicit
363
- // values the user set over the preset defaults, and the engine's first
364
- // default model when the user named none. The engine id rides into both
365
- // entry points: the estimate's OS-reserve resolution runs the plugin-
366
- // launched check against it, and the model-aware guidelines price the same
367
- // usable budget the meter does.
368
- const defaults = await localInfer.resolveDefaults(engine.id, modelId, hardware, pricedConfig);
369
- const estimate = await localInfer.estimate(defaults.config, hardware, engine.id);
370
- const guidelineResults = await localInfer.guidelines(defaults.config, hardware, engine.id);
371
- // The directory scan lists the model files the selected engine can serve
372
- // straight from the model directory, so the card's Model dropdown offers
373
- // them beside the defaults. A missing directory reads as an empty list.
374
- const modelFiles = modelEntriesForEngine(localInfer.listModelDirectory(modelDir), engine.id);
375
- // The model status checks the selected model against the model directory
376
- // and probes the hf CLI, so the card shows its Download row (or the URL
377
- // row) only while the model is absent.
378
- const entry = defaultModelFor(engine.id, modelId);
379
- let modelStatus = null;
380
- if (entry !== undefined) {
381
- const present = existsSync(modelArtifactPath(entry, modelDir));
382
- const hfCli = (await localInfer.hfCliAvailable()) !== null;
383
- modelStatus = {
384
- modelId: entry.modelId,
385
- label: entry.label,
386
- url: entry.url,
387
- present,
388
- hfCli,
389
- downloadCommand: hfDownloadCommand(entry, modelDir),
390
- };
391
- }
392
- // The chat-template status checks the selected template against the model
393
- // directory and probes the hf CLI, so the card auto-downloads an absent
394
- // template (and the System dependencies box shows the download command when
395
- // the CLI is missing). Only HF repo references are downloadable; a local
396
- // path or an empty template reads as null.
397
- const chatTemplateValue = typeof config.chatTemplate === 'string' ? config.chatTemplate : '';
398
- let chatTemplateStatus = null;
399
- if (isHfRepoReference(chatTemplateValue)) {
400
- const present = existsSync(join(modelDir, CHAT_TEMPLATE_FILE));
401
- const hfCli = (await localInfer.hfCliAvailable()) !== null;
402
- chatTemplateStatus = {
403
- repo: chatTemplateValue,
404
- label: chatTemplateLabel(chatTemplateValue),
405
- present,
406
- hfCli,
407
- downloadCommand: hfChatTemplateCommand(chatTemplateValue, modelDir),
408
- };
409
- }
410
- // The restart re-link: a provider entry whose launch record names a still-
411
- // running engine (the DSH that launched it died, and the engine re-parented
412
- // to init) is re-adopted as plugin-launched, so its status dot reads as
413
- // running for its model and the OS-reserve resolver keeps the default. A
414
- // record whose engine is dead is cleared (the previous-pid list with it),
415
- // so a recycled pid cannot re-link to a config whose engine is down. The
416
- // adopted engines also get a liveness watcher, so a later pkill settles the
417
- // stored state without waiting for another summary read.
418
- const linkedProviders = await relinkStoredProviders(localInfer, stored);
419
- // The provider list the card renders as collapsed entries: each saved
420
- // config's engine and model feed the detector, which resolves the running-
421
- // state its status dot paints. A provider the detector cannot tie to a
422
- // confirmed model reads as unsure, so the dot never overstates a live
423
- // engine. The config rides into the detector so the signature prices the
424
- // provider's own build mode and port, not the adapter's default-port guess.
425
- const providerRefs = linkedProviders.map((provider) => {
426
- const model = typeof provider.config.model === 'string' ? provider.config.model : undefined;
427
- // exactOptionalPropertyTypes: include the model key only when it is a
428
- // string, so a config that names no model never writes `undefined`.
429
- return model !== undefined
430
- ? { id: provider.id, engine: provider.engine, model, config: provider.config }
431
- : { id: provider.id, engine: provider.engine, config: provider.config };
432
- });
433
- const providerStatuses = providerRefs.length > 0 ? await localInfer.providerStatuses(providerRefs) : {};
434
- const providers = linkedProviders.map((provider) => ({
435
- id: provider.id,
436
- engine: provider.engine,
437
- model: typeof provider.config.model === 'string' ? provider.config.model : '',
438
- status: providerStatuses[provider.id] ?? 'unsure',
439
- config: provider.config,
440
- platform: provider.platform,
441
- }));
442
- // The "unsure" banner: shown when no provider is confirmed running but at
443
- // least one reads as unsure (a foreign engine the detector saw but could
444
- // not tie to a model, or a busy card). The banner offers bash kill commands
445
- // for the engine processes the detector saw holding VRAM, so the user can
446
- // stop a provider launched in another session.
447
- const anyRunning = providers.some((p) => p.status === 'running');
448
- const anyUnsure = providers.some((p) => p.status === 'unsure');
449
- const unsureBanner = !anyRunning && anyUnsure
450
- ? {
451
- message: 'Unable to link to a provider (it may have been launched in another session). \n To stop, run:',
452
- killCommands: await localInfer.unsureBannerKillCommands(),
453
- }
454
- : null;
455
- // The registered flag tells the card whether the current config's provider
456
- // and model are already in the DSH settings, so the Remove button shows
457
- // only when there is something to remove. A profile without the settings
458
- // seam (a host that predates the typert remote) or a host without the
459
- // engine adapter registered reads as unregistered.
460
- let registered = false;
461
- try {
462
- const regAdapter = resolveAdapter(localInfer, engine.id);
463
- const regPlan = regAdapter.renderLaunch(config, hardware);
464
- const regRow = resolveModelRow(regAdapter, regPlan, config);
465
- const regEntry = buildModelEntry(regPlan, regRow);
466
- registered = await localInfer.modelIsRegistered(regEntry.providerId, regRow.id);
467
- }
468
- catch {
469
- // A missing adapter, a missing settings seam, or a refused read all
470
- // read as unregistered, which hides the Remove button (a no-op removal).
471
- }
472
- return {
473
- engines: ENGINES.map((entry) => {
474
- // The platform defaults ride the engine entry so the card's Engine
475
- // platform section seeds the build-command and docker-tag placeholders
476
- // from the same data the deploy step consumes.
477
- const defaults = enginePlatformDefaults(entry.id);
478
- return {
479
- id: entry.id,
480
- label: entry.label,
481
- supports: entry.supports,
482
- ...(defaults.defaultBuildCommand !== undefined
483
- ? { defaultBuildCommand: defaults.defaultBuildCommand }
484
- : {}),
485
- ...(defaults.defaultDockerTag !== undefined
486
- ? { defaultDockerTag: defaults.defaultDockerTag }
487
- : {}),
488
- };
489
- }),
490
- models: localInfer.registeredVramModels().map((model) => ({ id: model.id, source: model.source })),
491
- hardware,
492
- engineId: engine.id,
493
- // The hardware profile prices the form's hardware-dependent controls (the
494
- // host-KV slider cap), so the render model and the meter agree on the
495
- // machine they run on.
496
- formModel: renderForm(engine.parameters, config, hardware),
497
- meterModel: renderMeter(estimate.breakdown, estimate.verdict, hardware.gpu?.vramGiB ?? 0, estimate.source, estimate.reserveSource,
498
- // The "Unknown Engine(s)" readout shows the VRAM the detector's foreign
499
- // engine programs are holding: the OS-reserve segment is the GPU's used
500
- // VRAM when a foreign engine is detected, so the readout names the
501
- // footprint a provider launched in another session is eating off the
502
- // card. Absent when no provider reads as unsure.
503
- anyUnsure ? { bytes: estimate.breakdown.reserve } : null),
504
- verdict: estimate.verdict,
505
- warningsModel: renderWarnings(guidelineResults),
506
- config,
507
- platformConfigs: stored.platform,
508
- defaults: { config: defaults.config, provenance: defaults.provenance },
509
- modelDirectory,
510
- modelDefaults: defaultModelsForEngine(engine.id).map((entry) => ({
511
- id: entry.modelId,
512
- label: entry.label,
513
- url: entry.url,
514
- })),
515
- modelStatus,
516
- chatTemplateStatus,
517
- modelFiles,
518
- showHelp: stored.showHelp,
519
- providers,
520
- logPath: stored.logPath,
521
- lastLogs: stored.lastLogs,
522
- unsureBanner,
523
- registered,
524
- };
525
- }
26
+ import { readConfig } from "./config.js";
27
+ import { handleEngineDeath, relinkStoredProviders, syncPlatformConfigs, } from "./ops/engine.js";
28
+ import { chatTemplateRoute } from "./routes/chat-template.js";
29
+ import { depsRoute } from "./routes/deps.js";
30
+ import { downloadRoute } from "./routes/download.js";
31
+ import { getRoute } from "./routes/get.js";
32
+ import { installRoute } from "./routes/install.js";
33
+ import { launchRoute } from "./routes/launch.js";
34
+ import { providerDeleteRoute } from "./routes/provider-delete.js";
35
+ import { registerRoute } from "./routes/register.js";
36
+ import { saveRoute } from "./routes/save.js";
37
+ import { stopRoute } from "./routes/stop.js";
38
+ import { summaryRoute } from "./routes/summary.js";
39
+ import { unregisterRoute } from "./routes/unregister.js";
526
40
  /**
527
41
  * RouteError is a route failure that carries its own HTTP status, so the
528
42
  * handlers map the domain answer (a gate that is off, an unsupported build
@@ -537,1348 +51,122 @@ export class RouteError extends Error {
537
51
  }
538
52
  }
539
53
  /**
540
- * The route's view of which engine platform configs are registered with the
541
- * service, keyed by engine id. The service registry is the single source the
542
- * launch step reads, so the route keeps it in sync with the stored config:
543
- * on route registration, after every save, and when a launch request carries
544
- * a platform override. A disposer is tracked per engine, so an engine whose
545
- * stored config is cleared is unregistered rather than left stale.
546
- */
547
- const platformDisposers = new Map();
548
- /**
549
- * setPlatformConfig registers one engine's platform config with the service,
550
- * replacing any stored entry. A config with no fields is read as "no platform
551
- * config", which unregisters the engine so the deploy step falls back to the
552
- * adapter's default source and cache directory.
553
- */
554
- function setPlatformConfig(localInfer, engineId, config) {
555
- platformDisposers.get(engineId)?.();
556
- platformDisposers.delete(engineId);
557
- if (config === undefined) {
558
- return;
559
- }
560
- if (config.engineDirectory === undefined &&
561
- config.engineFork === undefined &&
562
- config.buildCommands === undefined &&
563
- config.dockerTag === undefined) {
564
- return;
565
- }
566
- const platform = { engineId, ...config };
567
- platformDisposers.set(engineId, localInfer.registerEnginePlatformConfig(platform));
568
- }
569
- /**
570
- * syncPlatformConfigs re-syncs the service registry with the stored platform
571
- * map: it registers (or re-registers) every engine that carries a config, and
572
- * unregisters every engine that no longer does, so a launch always reads the
573
- * platform config the user last saved.
574
- */
575
- function syncPlatformConfigs(localInfer, stored) {
576
- const wanted = stored.platform;
577
- for (const engineId of [...platformDisposers.keys()]) {
578
- if (wanted[engineId] === undefined) {
579
- setPlatformConfig(localInfer, engineId, undefined);
580
- }
581
- }
582
- for (const [engineId, config] of Object.entries(wanted)) {
583
- setPlatformConfig(localInfer, engineId, config);
584
- }
585
- }
586
- /**
587
- * resolveBuildMode picks the deploy mode the dep plan prices: the config's
588
- * buildMode when the selected engine supports it, else the engine's default
589
- * (local when the engine can build locally, docker when it cannot). The
590
- * docker-not-installed guideline reads this same value, so the panel and the
591
- * warning never disagree about the mode.
592
- */
593
- export function resolveBuildMode(engine, config) {
594
- const defaultMode = engine.supports.local ? 'local' : 'docker';
595
- const requested = typeof config.buildMode === 'string' ? config.buildMode : undefined;
596
- if ((requested === 'docker' || requested === 'local') && engine.supports[requested]) {
597
- return requested;
598
- }
599
- return defaultMode;
600
- }
601
- /**
602
- * buildDeps assembles the deps panel read model for one engine and config:
603
- * the detected hardware (with its package manager), the dep plan the service
604
- * resolves, the per-dep statuses, the copy/paste install string, and the
605
- * Play/Run trust gate the stored config carries.
54
+ * The request body size cap the POST routes enforce: a config payload is a
55
+ * few kilobytes at most, so a body past 64 KiB is a client bug, not a config.
606
56
  */
607
- export async function buildDeps(localInfer, request) {
608
- const stored = readConfig();
609
- const engineId = request?.engine ?? stored.engine;
610
- const config = request?.config ?? stored.config;
611
- // ENGINES is a known non-empty constant, so the fallback is always defined.
612
- const engine = ENGINES.find((entry) => entry.id === engineId) ?? ENGINES[0];
613
- const buildMode = resolveBuildMode(engine, config);
614
- const hardware = await localInfer.detectHardware();
615
- const deps = await localInfer.deps(engine.id, buildMode, hardware);
616
- const checks = await checkDeps(deps, hardware);
617
- // The chat-template row: when a selected template is an HF repo reference
618
- // and its file is not in the model directory, the System dependencies box
619
- // shows the download command (the auto-download's fallback when the CLI is
620
- // missing or the fetch failed). A present template, a local path, or no
621
- // template leaves the row off.
622
- const modelDirectory = localInfer.defaultModelDirectory();
623
- const rawModelDir = config.modelDirectory;
624
- const modelDir = typeof rawModelDir === 'string' && rawModelDir.trim() !== '' ? rawModelDir : modelDirectory;
625
- const chatTemplateValue = typeof config.chatTemplate === 'string' ? config.chatTemplate : '';
626
- let chatTemplate;
627
- if (isHfRepoReference(chatTemplateValue) && !existsSync(join(modelDir, CHAT_TEMPLATE_FILE))) {
628
- chatTemplate = {
629
- repo: chatTemplateValue,
630
- present: false,
631
- hfCli: (await localInfer.hfCliAvailable()) !== null,
632
- downloadCommand: hfChatTemplateCommand(chatTemplateValue, modelDir),
633
- };
634
- }
635
- // exactOptionalPropertyTypes: the chat-template row only lands while a
636
- // selected template is missing, so the key is included conditionally.
637
- const depsInput = {
638
- engineId: engine.id,
639
- buildMode,
640
- packageManager: hardware.packageManager ?? null,
641
- checks,
642
- installString: renderInstallString(deps, hardware),
643
- runAllowed: stored.allowRunDeps,
644
- };
645
- if (chatTemplate !== undefined) {
646
- depsInput.chatTemplate = chatTemplate;
647
- }
648
- return renderDeps(depsInput);
649
- }
650
- /**
651
- * runInstallFor is the Play/Run path: it refuses with a 409 while the
652
- * trust gate is off (the copy/paste path is always the fallback), runs the
653
- * install through the service, and refreshes the deps model from the
654
- * re-check, so the "Get" gate opens only after a re-check passes.
655
- */
656
- export async function runInstallFor(localInfer, request) {
657
- if (readConfig().allowRunDeps !== true) {
658
- throw new RouteError(409, 'one-click install is not allowed; copy the install string instead');
659
- }
660
- const model = await buildDeps(localInfer, request);
661
- const hardware = await localInfer.detectHardware();
662
- const result = await localInfer.runInstall(model.engineId, model.buildMode, hardware);
663
- const deps = model.deps.map((dep) => {
664
- const entry = result.results.find((entry) => entry.id === dep.id);
665
- return entry === undefined ? dep : { ...dep, status: entry.status };
666
- });
667
- return {
668
- result,
669
- deps: { ...model, deps, allSatisfied: result.ok },
670
- };
671
- }
57
+ const MAX_BODY_BYTES = 64 * 1024;
672
58
  /**
673
- * downloadFor runs the hf CLI download for the request's default model: it
674
- * resolves the entry through the providers' default list (a 404 when no
675
- * entry names the pair), resolves the directory against the request, the
676
- * stored config, and the plugin's default model directory in that order, and
677
- * runs the download through the service.
59
+ * readJsonBody collects a bounded JSON request body: chunks up to the cap,
60
+ * then parses. A body past the cap destroys the request and throws a 413
61
+ * RouteError carrying the route's own message, so the answer names the route
62
+ * that rejected the body.
678
63
  */
679
- export async function downloadFor(localInfer, request) {
680
- const stored = readConfig();
681
- const engineId = request.engine ?? stored.engine;
682
- const modelId = request.model ?? (typeof stored.config.model === 'string' ? stored.config.model : '');
683
- const entry = defaultModelFor(engineId, modelId);
684
- if (entry === undefined) {
685
- throw new RouteError(404, `no default model is registered for "${engineId}/${modelId}"`);
686
- }
687
- const storedDir = stored.config.modelDirectory;
688
- const directory = typeof request.directory === 'string' && request.directory.trim() !== ''
689
- ? request.directory
690
- : typeof storedDir === 'string' && storedDir.trim() !== ''
691
- ? storedDir
692
- : localInfer.defaultModelDirectory();
693
- return localInfer.downloadModel(entry, directory);
694
- }
695
- /**
696
- * chatTemplateDownloadFor runs the hf CLI download for the request's chat
697
- * template: it resolves the template repo reference from the request or the
698
- * stored config's chatTemplate (a 400 when neither names an HF repo
699
- * reference), resolves the directory against the request, the stored config,
700
- * and the plugin's default model directory in that order, and runs the
701
- * download through the service.
702
- */
703
- export async function chatTemplateDownloadFor(localInfer, request) {
704
- const stored = readConfig();
705
- let template = typeof request.template === 'string' ? request.template : '';
706
- if (template.trim() === '') {
707
- template = typeof stored.config.chatTemplate === 'string' ? stored.config.chatTemplate : '';
708
- }
709
- if (!isHfRepoReference(template)) {
710
- throw new RouteError(400, `the chat template must be an HF repo reference, got "${template || '(none)'}"`);
711
- }
712
- const storedDir = stored.config.modelDirectory;
713
- const directory = typeof request.directory === 'string' && request.directory.trim() !== ''
714
- ? request.directory
715
- : typeof storedDir === 'string' && storedDir.trim() !== ''
716
- ? storedDir
717
- : localInfer.defaultModelDirectory();
718
- return localInfer.downloadChatTemplate(template, directory);
719
- }
720
- /**
721
- * resolveAdapter picks the registered engine adapter an engine id routes to. A
722
- * card that names an engine no adapter serves gets a 404, not a blank panel.
723
- */
724
- function resolveAdapter(localInfer, engineId) {
725
- const adapter = localInfer.registeredEngines().find((engine) => engine.id === engineId);
726
- if (adapter === undefined) {
727
- throw new RouteError(404, `no engine adapter is registered for "${engineId}"`);
728
- }
729
- return adapter;
730
- }
731
- /**
732
- * launchCommandNote renders the plan's child-process launch line for the
733
- * card's Logs section: the core formatter's command under a [local-infer]
734
- * launch prefix, newline-terminated. A plan that names no command (a bare
735
- * docker plan with no argv) has no note, and the run logs stay as they are.
736
- */
737
- function launchCommandNote(plan) {
738
- const command = formatLaunchCommand(plan);
739
- if (command === undefined)
740
- return undefined;
741
- return `[local-infer] launch: ${command}\n`;
742
- }
743
- /**
744
- * applyComposeArgs seeds a compose plan's compose arguments from the stored
745
- * platform config (the card's docker-tag field for a compose engine, e.g.
746
- * `--profile single`), so the up line the deploy runs and the down line the
747
- * stop runs both select the profile the user named. A compose plan with no
748
- * stored docker-tag keeps the engine's default profile; a non-compose plan is
749
- * untouched.
750
- */
751
- function applyComposeArgs(plan, localInfer, engineId) {
752
- if (plan.kind !== 'compose')
753
- return;
754
- const platform = localInfer.enginePlatformConfig(engineId);
755
- if (platform?.dockerTag !== undefined) {
756
- plan.composeArgs = platform.dockerTag;
757
- }
758
- }
759
- /**
760
- * persistStoredLogs overwrites the stored last-run logs (the card's Logs
761
- * section shows them after a reload) and leaves every other stored field as
762
- * the launch reads it.
763
- */
764
- function persistStoredLogs(logs) {
765
- const stored = readConfig();
766
- writeConfig(stored.engine, stored.config, stored.allowRunDeps, stored.platform, stored.showHelp, stored.providers, stored.logPath, logs);
767
- }
768
- /**
769
- * launchEngine renders the plan the registered adapter produces for the config
770
- * and the detected hardware, then runs the service's launch entry point
771
- * (deploy, then the health poll). The detail the launch returns names the
772
- * broken step when the engine does not come up. The launch line (the engine
773
- * binary and its arguments) leads the run logs the card's Logs section and
774
- * the log file carry, so a broken setup diagnoses against the exact child
775
- * process the launch started. The launch step persists the
776
- * launch record (the engine pid, start time, launch arguments, and the DSH
777
- * process pid that launched it) on the request's provider entry, so a DSH
778
- * restart can re-link a surviving engine to its config.
779
- */
780
- export async function launchEngine(localInfer, request, onProgress) {
781
- const stored = readConfig();
782
- const engineId = request.engine ?? stored.engine;
783
- const config = request.config ?? stored.config;
784
- const adapter = resolveAdapter(localInfer, engineId);
785
- const hardware = await localInfer.detectHardware();
786
- // Register the request's platform override (the card's unsaved Engine
787
- // Directory / Engine Fork edits) so the service's launch resolves it against
788
- // the adapter's deploy step. An absent platform keeps the stored entry,
789
- // which the save route and route registration already synced.
790
- if (request.platform !== undefined) {
791
- setPlatformConfig(localInfer, engineId, request.platform);
792
- }
793
- const plan = adapter.renderLaunch(config, hardware);
794
- applyComposeArgs(plan, localInfer, engineId);
795
- const commandNote = launchCommandNote(plan);
796
- if (commandNote !== undefined) {
797
- // Persist before the deploy starts: a slow or hung launch keeps the
798
- // command readable in the Logs section across a card reload, where the
799
- // engine output has not landed yet.
800
- persistStoredLogs(commandNote);
801
- }
802
- // The prereqs phase: the system-dependency install the local build needs. It
803
- // runs only while the Play/Run trust gate is on, the same rule the Get step
804
- // applies; a failed install aborts the launch before the deploy starts. The
805
- // card's button reads "Prereqs..." for this phase, so the phase is reported
806
- // only while an install actually runs (the gate on), not when the deps are
807
- // already satisfied and the phase is skipped.
808
- if (stored.allowRunDeps === true) {
809
- onProgress?.('prereqs');
810
- const buildMode = plan.kind === 'docker' ? 'docker' : 'local';
811
- const install = await localInfer.runInstall(engineId, buildMode, hardware);
812
- if (!install.ok) {
813
- const result = {
814
- plan,
815
- healthy: false,
816
- detail: 'a system dependency install failed',
817
- };
818
- persistLaunchLogs(result, engineId, config);
819
- persistLaunchRecord(result, engineId, config, request.providerId);
820
- return result;
64
+ async function readJsonBody(req, tooLargeMessage) {
65
+ const chunks = [];
66
+ let total = 0;
67
+ for await (const chunk of req) {
68
+ total += chunk.length;
69
+ if (total > MAX_BODY_BYTES) {
70
+ req.destroy();
71
+ throw new RouteError(413, tooLargeMessage);
821
72
  }
73
+ chunks.push(chunk);
822
74
  }
823
- // The trust gate and the auto-rebuild flag ride into the deploy step, which
824
- // reads them to decide whether a rebuild's missing-binary symlink into
825
- // /usr/local/bin runs (on) or is written to the card's Logs section (off),
826
- // and whether the git sync (fetch, rebase, rebuild) runs at all.
827
- const result = await localInfer.launch(plan, {
828
- ...(onProgress !== undefined ? { onProgress } : {}),
829
- allowRunDeps: stored.allowRunDeps,
830
- ...(request.autoRebuild !== undefined ? { autoRebuild: request.autoRebuild } : {}),
831
- });
832
- // Order the run logs: the build output (the rebuild decision, the build
833
- // command, and its output) leads, the launch command follows it (after a
834
- // successful build, or a direct launch with no build), and the engine's
835
- // runtime output comes last. A deploy that failed before the engine printed
836
- // anything still reports the command it tried to run.
837
- const buildLogs = result.buildLogs ?? '';
838
- const engineLogs = result.logs ?? '';
839
- result.logs = buildLogs + (commandNote ?? '') + engineLogs;
840
- if (result.buildLogs !== undefined) {
841
- delete result.buildLogs;
842
- }
843
- persistLaunchLogs(result, engineId, config);
844
- persistLaunchRecord(result, engineId, config, request.providerId);
845
- return result;
846
- }
847
- /**
848
- * findLaunchTarget resolves the stored provider entry a launch or stop
849
- * targets: the entry the request names by id, or (an id the card does not
850
- * send, a launch of the active config) the stored entry with the same engine
851
- * and model. A launch with no resolvable entry persists no record, which
852
- * reads as "no provider config to re-link" after a restart.
853
- */
854
- function findLaunchTarget(stored, engineId, providerId, config) {
855
- if (providerId !== undefined) {
856
- const byId = stored.providers.find((entry) => entry.id === providerId);
857
- if (byId !== undefined)
858
- return byId;
859
- }
860
- const model = typeof config.model === 'string' ? config.model : undefined;
861
- if (model === undefined)
862
- return undefined;
863
- return stored.providers.find((entry) => entry.engine === engineId &&
864
- (typeof entry.config.model === 'string' ? entry.config.model : undefined) === model);
865
- }
866
- /**
867
- * persistLaunchRecord stores a launch's record on the target provider entry:
868
- * the engine pid the deploy step started, the start time, the plan's argv,
869
- * the DSH process pid the launch ran under (only when it identified as DSH),
870
- * and the previous-pid list the re-link rule prices (the entry's own list
871
- * plus the current DSH pid, capped by the core builder). A launch whose
872
- * deploy started no tracked process (a compose up) persists nothing, and a
873
- * launch with no resolvable entry persists nothing.
874
- */
875
- function persistLaunchRecord(result, engineId, config, providerId) {
876
- if (result.pid === undefined)
877
- return;
878
- const stored = readConfig();
879
- const target = findLaunchTarget(stored, engineId, providerId, config);
880
- if (target === undefined)
881
- return;
882
- const record = launchRecordFromPlan(result.plan, result.pid, result.parentPid, target.launch?.previousPids ?? []);
883
- const providers = stored.providers.map((entry) => entry.id === target.id ? { ...entry, launch: record } : entry);
884
- writeConfig(stored.engine, stored.config, stored.allowRunDeps, stored.platform, stored.showHelp, providers, stored.logPath, stored.lastLogs);
885
- }
886
- /**
887
- * clearLaunchRecord removes the target provider entry's launch record (the
888
- * engine pid and the previous-pid list with it), so a stopped engine's pid
889
- * cannot be re-linked to its config by a recycled pid. A stop with no
890
- * resolvable entry, or an entry that carries no record, is a no-op.
891
- */
892
- function clearLaunchRecord(engineId, config, providerId) {
893
- const stored = readConfig();
894
- const target = findLaunchTarget(stored, engineId, providerId, config);
895
- if (target === undefined || target.launch === undefined)
896
- return;
897
- const providers = stored.providers.map((entry) => {
898
- if (entry.id !== target.id)
899
- return entry;
900
- // Rebuild the entry without the launch key, so the record (including the
901
- // previous-pid list) is gone from the file, not just ignored.
902
- return { id: entry.id, engine: entry.engine, config: entry.config, platform: entry.platform };
903
- });
904
- writeConfig(stored.engine, stored.config, stored.allowRunDeps, stored.platform, stored.showHelp, providers, stored.logPath, stored.lastLogs);
905
- }
906
- /**
907
- * handleEngineDeath persists a provider entry's state when its engine process
908
- * dies outside the plugin's stop path: a crash, a pkill of a plugin-launched
909
- * child seen through the service's exit watch, or the poll-detected exit of a
910
- * process adopted after a DSH restart. The entry whose launch record names the
911
- * dead pid (or the dead engine-and-port, when the report carries no pid match)
912
- * has its record cleared, so a restart never re-links a dead pid, and the exit
913
- * line lands in the card's last-run logs beside the engine's own output. An
914
- * event that matches no entry carrying a record (a stop the route already
915
- * cleared, an engine the plugin never tracked) is a no-op, which keeps an
916
- * expected stop quiet.
917
- */
918
- export function handleEngineDeath(event) {
919
- const stored = readConfig();
920
- let matched = false;
921
- const providers = stored.providers.map((entry) => {
922
- const record = entry.launch;
923
- if (record === undefined)
924
- return entry;
925
- const byPid = event.pid !== undefined && record.pid === event.pid;
926
- const byEnginePort = event.port !== undefined && record.engineId === entry.engine && record.port === event.port;
927
- if (!byPid && !byEnginePort)
928
- return entry;
929
- matched = true;
930
- // Rebuild the entry without the launch key, so the record (including the
931
- // previous-pid list) is gone from the file, not just ignored.
932
- return { id: entry.id, engine: entry.engine, config: entry.config, platform: entry.platform };
933
- });
934
- if (!matched)
935
- return;
936
- const signalNote = event.signal !== null && event.signal !== undefined
937
- ? ` on signal ${event.signal}`
938
- : ` with exit code ${event.code}`;
939
- const note = `[local-infer] engine ${event.engineId}` +
940
- `${event.port !== undefined ? ` (port ${event.port})` : ''}` +
941
- ` pid ${event.pid ?? 'unknown'} exited${signalNote}` +
942
- `${event.detail !== undefined ? `: ${event.detail}` : ''}\n`;
943
- writeConfig(stored.engine, stored.config, stored.allowRunDeps, stored.platform, stored.showHelp, providers, stored.logPath,
944
- // Keep the last-run tail bounded, the same rule the launch logs cap uses.
945
- (stored.lastLogs + note).slice(-65536));
946
- }
947
- /**
948
- * persistLaunchLogs stores a launch's captured output as the last run and
949
- * writes it to the configured log file. The log file is only written when the
950
- * run produced output and the log location is set; the last-run logs are always
951
- * persisted (an empty string clears a stale run) so the card's Logs section
952
- * shows the previous run after a reload.
953
- */
954
- export function persistLaunchLogs(result, engineId, config) {
955
- const stored = readConfig();
956
- const logs = result.logs ?? '';
957
- writeConfig(stored.engine, stored.config, stored.allowRunDeps, stored.platform, stored.showHelp, stored.providers, stored.logPath, logs);
958
- if (logs === '') {
959
- return;
960
- }
961
- const modelId = typeof config.model === 'string' ? config.model : undefined;
962
- const logPath = resolveLogPath(stored.logPath, engineId, modelId, new Date(), homedir());
963
- try {
964
- mkdirSync(dirname(logPath), { recursive: true });
965
- writeFileSync(logPath, logs);
966
- }
967
- catch {
968
- // A log file that cannot be written (a read-only dir, a bad path) must not
969
- // fail the launch; the in-memory last-run logs still reach the card.
970
- }
971
- }
972
- /**
973
- * stopEngine renders the same plan and runs the service's stop entry point, so
974
- * the stop step targets the exact container or process the launch started. The
975
- * stop then clears the target entry's launch record (the engine pid and the
976
- * previous-pid list with it), so a recycled pid cannot re-link an engine the
977
- * user just stopped.
978
- */
979
- export async function stopEngine(localInfer, request) {
980
- const stored = readConfig();
981
- const engineId = request.engine ?? stored.engine;
982
- const config = request.config ?? stored.config;
983
- const adapter = resolveAdapter(localInfer, engineId);
984
- const hardware = await localInfer.detectHardware();
985
- const plan = adapter.renderLaunch(config, hardware);
986
- applyComposeArgs(plan, localInfer, engineId);
987
- await localInfer.stop(plan);
988
- clearLaunchRecord(engineId, config, request.providerId);
75
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
989
76
  }
990
77
  /**
991
- * resolveEngineDirectory picks the checkout the Get step syncs: the Engine
992
- * Directory override when it names a path, else a per-engine folder inside
993
- * the model directory (the default model directory, or the config's
994
- * override), so a Get with no override still has a real place to live.
78
+ * RouteResponder is the answer side of a route: the JSON writer every route
79
+ * answers through, the stream openers the Get and launch routes use for live
80
+ * output, and the chunk writer a fake response may omit (the route then
81
+ * degrades to collecting only).
995
82
  */
996
- function resolveEngineDirectory(localInfer, config, engineId, platform) {
997
- const override = platform?.engineDirectory;
998
- if (override !== undefined && override.trim() !== '') {
999
- return override;
83
+ export class RouteResponder {
84
+ res;
85
+ constructor(res) {
86
+ this.res = res;
1000
87
  }
1001
- const rawModelDir = config.modelDirectory;
1002
- const modelDir = typeof rawModelDir === 'string' && rawModelDir.trim() !== ''
1003
- ? rawModelDir
1004
- : localInfer.defaultModelDirectory();
1005
- return join(modelDir, engineId);
1006
- }
1007
- /**
1008
- * resolveModelDirectory picks the model directory a Get run downloads into:
1009
- * the config's modelDirectory override when it names a path, else the
1010
- * plugin's default model directory. The same rule the summary and deps routes
1011
- * apply, so a Get and the card's Model Directory field agree on the target.
1012
- */
1013
- function resolveModelDirectory(localInfer, config) {
1014
- const rawModelDir = config.modelDirectory;
1015
- return typeof rawModelDir === 'string' && rawModelDir.trim() !== ''
1016
- ? rawModelDir
1017
- : localInfer.defaultModelDirectory();
1018
- }
1019
- /**
1020
- * getEngine is the Get step: it syncs the engine checkout (clone the git
1021
- * repo, or the fork when the platform entry provides one; fetch and rebase
1022
- * a clean checkout; abort with a Logs alert when the checkout has local
1023
- * changes), runs the system dependency plan, and runs the engine's build
1024
- * steps. Every command and its output streams through log as it lands, so
1025
- * the card appends it to the Logs section live. The dependency install only
1026
- * runs while the Play/Run trust gate is on; with the gate off the deps are
1027
- * re-checked and a missing one aborts the step with the copy/paste note.
1028
- */
1029
- export async function getEngine(localInfer, request, log, host = nodeLifecycleHost) {
1030
- const stored = readConfig();
1031
- const engineId = request.engine ?? stored.engine;
1032
- const config = request.config ?? stored.config;
1033
- // ENGINES is a known non-empty constant, so the fallback is always defined.
1034
- const engine = ENGINES.find((entry) => entry.id === engineId) ?? ENGINES[0];
1035
- // Register the request's platform override the same way launchEngine does,
1036
- // so the next launch reads the Engine Directory / Engine Fork the Get used.
1037
- if (request.platform !== undefined) {
1038
- setPlatformConfig(localInfer, engineId, request.platform);
88
+ /** Write a JSON answer: the status and content-type head, then the body. */
89
+ json(status, body) {
90
+ this.res.writeHead(status, { 'content-type': 'application/json' });
91
+ this.res.end(JSON.stringify(body));
1039
92
  }
1040
- const platform = request.platform ?? stored.platform[engineId];
1041
- const directory = resolveEngineDirectory(localInfer, config, engineId, platform);
1042
- const fork = platform?.engineFork;
1043
- log(`get ${engineId} in ${directory}\n`);
1044
- const sync = await syncEngineRepo(host, { engineId, directory, ...(fork !== undefined && fork.trim() !== '' ? { fork } : {}) }, log);
1045
- if (!sync.ok) {
1046
- // A dirty checkout already alerted in the sync step; any other failure
1047
- // names its step here.
1048
- if (sync.aborted !== true && sync.detail !== undefined) {
1049
- log(`!! ${sync.detail}\n`);
1050
- }
1051
- return { ok: false, ...(sync.detail !== undefined ? { detail: sync.detail } : {}) };
93
+ /** Open a plain-text stream (the Get route's live log output). */
94
+ textStream() {
95
+ this.res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
1052
96
  }
1053
- const hardware = await localInfer.detectHardware();
1054
- const buildMode = resolveBuildMode(engine, config);
1055
- log(`system dependencies (${buildMode})\n`);
1056
- if (stored.allowRunDeps === true) {
1057
- const result = await localInfer.runInstall(engineId, buildMode, hardware);
1058
- for (const entry of result.results) {
1059
- log(` ${entry.id}: ${entry.status}${entry.detail !== undefined ? ` (${entry.detail})` : ''}\n`);
1060
- }
1061
- if (!result.ok) {
1062
- log('!! a system dependency install failed; copy the install string from the panel\n');
1063
- return { ok: false, detail: 'a system dependency install failed' };
1064
- }
97
+ /** Open an NDJSON stream (the launch route's progress events). */
98
+ ndjsonStream() {
99
+ this.res.writeHead(200, { 'content-type': 'application/x-ndjson; charset=utf-8' });
1065
100
  }
1066
- else {
1067
- const deps = await localInfer.deps(engineId, buildMode, hardware);
1068
- const checks = await checkDeps(deps, hardware);
1069
- let missing = false;
1070
- for (const check of checks) {
1071
- log(` ${check.dep.id}: ${check.status}\n`);
1072
- if (check.status !== 'satisfied')
1073
- missing = true;
1074
- }
1075
- if (missing) {
1076
- log('!! a system dependency is missing and one-click install is off; copy the install string from the panel\n');
1077
- return { ok: false, detail: 'a system dependency is missing' };
101
+ /** Stream one chunk; a fake response without write() collects nothing. */
102
+ write(chunk) {
103
+ if (typeof this.res.write === 'function') {
104
+ this.res.write(chunk);
1078
105
  }
1079
106
  }
1080
- const build = await buildEngineRepo(host, engineId, directory, log);
1081
- if (!build.ok) {
1082
- if (build.detail !== undefined) {
1083
- log(`!! ${build.detail}\n`);
1084
- }
1085
- return { ok: false, ...(build.detail !== undefined ? { detail: build.detail } : {}) };
107
+ /** End the response, optionally with a final body chunk. */
108
+ end(body) {
109
+ this.res.end(body);
1086
110
  }
1087
- // The model artifacts a Get leaves ready to launch: the Infernix DFlash2
1088
- // drafter (when a preset is selected) and the PLE n-gram volume (when the
1089
- // model key names one), pulled through the hf CLI into the model directory.
1090
- // A download failure aborts the Get, so a launch never reads a missing
1091
- // drafter or table.
1092
- if (engineId === 'infernix') {
1093
- const modelDir = resolveModelDirectory(localInfer, config);
1094
- for (const command of infernixGetDownloads(config, modelDir)) {
1095
- log(`$ ${command}\n`);
1096
- const outcome = host.runStream !== undefined
1097
- ? await host.runStream(command, (chunk) => log(chunk))
1098
- : await host.run(command);
1099
- if (!outcome.ok) {
1100
- log(`!! ${command} failed: ${outcome.detail ?? 'unknown'}\n`);
1101
- return { ok: false, detail: 'a model artifact download failed' };
1102
- }
1103
- }
1104
- }
1105
- log('get complete\n');
1106
- return { ok: true };
1107
- }
1108
- /**
1109
- * persistGetLogs appends one Get run's streamed output to the stored last-
1110
- * run logs (the card's Logs section shows it after a reload) and writes no
1111
- * log file: the launch step owns the per-run files. The tail rule matches
1112
- * the launch logs' cap, so a long build cannot grow the config without
1113
- * bound.
1114
- */
1115
- function persistGetLogs(output) {
1116
- if (output === '')
1117
- return;
1118
- const stored = readConfig();
1119
- const joined = `${stored.lastLogs}${stored.lastLogs === '' ? '' : '\n'}${output}`;
1120
- writeConfig(stored.engine, stored.config, stored.allowRunDeps, stored.platform, stored.showHelp, stored.providers, stored.logPath, joined.slice(-65536));
1121
- }
1122
- /**
1123
- * resolveContextWindow picks the context window the registrar publishes: the
1124
- * config's maxContext when it names one, else the native ceiling of the
1125
- * VramModel the config resolves to (the named model, or the single
1126
- * registered model), else the 32768 spec default. The DSH contextWindow must
1127
- * not promise a context the engine cannot serve, so the ceiling is the
1128
- * model's own, not an optimistic number.
1129
- */
1130
- export function resolveContextWindow(config, models) {
1131
- const configured = config.maxContext;
1132
- if (typeof configured === 'number' && configured > 0) {
1133
- return configured;
1134
- }
1135
- let model;
1136
- if (typeof config.model === 'string') {
1137
- model = models.find((entry) => entry.id === config.model);
1138
- }
1139
- else if (models.length === 1) {
1140
- model = models[0];
1141
- }
1142
- return model?.nativeMaxContext ?? 32768;
1143
- }
1144
- /**
1145
- * resolveModelRow picks the model row the registrar publishes for an engine
1146
- * and config: the adapter's modelRef (the id the engine serves on its
1147
- * OpenAI endpoint) when it declares one, else the plan's model id, else the
1148
- * engine id. The display name follows the same fall-through.
1149
- */
1150
- function resolveModelRow(adapter, plan, config) {
1151
- const ref = adapter.modelRef?.(config);
1152
- const id = ref?.id ?? plan.modelId ?? adapter.id;
1153
- return { id, name: ref?.name ?? id, contextWindow: 0 };
1154
- }
1155
- /**
1156
- * registerFor renders the plan through the registered adapter, resolves the
1157
- * model row and the context window, and runs the service's register entry
1158
- * point, which upserts the provider and model through the settings seam.
1159
- * The result carries the entry the registrar wrote, so the card names the
1160
- * model it published.
1161
- */
1162
- export async function registerFor(localInfer, request) {
1163
- const stored = readConfig();
1164
- const engineId = request?.engine ?? stored.engine;
1165
- const config = request?.config ?? stored.config;
1166
- const adapter = resolveAdapter(localInfer, engineId);
1167
- const hardware = await localInfer.detectHardware();
1168
- const plan = adapter.renderLaunch(config, hardware);
1169
- const row = resolveModelRow(adapter, plan, config);
1170
- row.contextWindow = resolveContextWindow(config, localInfer.registeredVramModels());
1171
- // The vision flag publishes the Image input type on the DSH model row, so a
1172
- // vision engine advertises ['text', 'image'] and a text-only engine
1173
- // ['text']. The registrar copies it into the entry it writes.
1174
- row.input = config.vision === true ? ['text', 'image'] : ['text'];
1175
- const options = request?.agentDefault === true ? { agentDefault: true } : undefined;
1176
- return localInfer.registerModel(plan, row, options);
1177
- }
1178
- /**
1179
- * syncRegisteredModel re-runs the registration for a saved config when the
1180
- * model is already registered in the DSH settings, so the registered model's
1181
- * input types (the vision flag) stay in step with the saved config. A model
1182
- * that is not registered (a fresh save) is a no-op, and a profile without the
1183
- * settings seam throws, which the save route catches and swallows so the save
1184
- * itself never fails on a sync that cannot run.
1185
- */
1186
- export async function syncRegisteredModel(localInfer, engine, config) {
1187
- const adapter = resolveAdapter(localInfer, engine);
1188
- const hardware = await localInfer.detectHardware();
1189
- const plan = adapter.renderLaunch(config, hardware);
1190
- const row = resolveModelRow(adapter, plan, config);
1191
- const buildMode = plan.kind === 'docker' ? 'docker' : 'local';
1192
- const providerId = providerIdFor(adapter.id, row.id, buildMode);
1193
- if (await localInfer.modelIsRegistered(providerId, row.id)) {
1194
- await registerFor(localInfer, { engine, config });
1195
- }
1196
- }
1197
- /**
1198
- * unregisterFor runs the service's removeModel entry point for the engine's
1199
- * provider and the model row the register step resolved, so the removal
1200
- * targets the exact entry the registration wrote.
1201
- */
1202
- export async function unregisterFor(localInfer, request) {
1203
- const stored = readConfig();
1204
- const engineId = request?.engine ?? stored.engine;
1205
- const config = request?.config ?? stored.config;
1206
- const adapter = resolveAdapter(localInfer, engineId);
1207
- const hardware = await localInfer.detectHardware();
1208
- const plan = adapter.renderLaunch(config, hardware);
1209
- const row = resolveModelRow(adapter, plan, config);
1210
- // The removal must target the exact entry the register step wrote, so the
1211
- // provider id is computed through buildModelEntry (the same function the
1212
- // register step uses) rather than a parallel providerIdFor call, which
1213
- // could drift if plan.engineId and adapter.id ever diverge.
1214
- const entry = buildModelEntry(plan, row);
1215
- await localInfer.removeModel(entry.providerId, row.id);
1216
- return { removed: true };
1217
111
  }
1218
112
  /**
1219
- * registerLocalInferRoute registers the summary, save, deps, install, get,
1220
- * launch, stop, register, and unregister routes on the web server and holds
1221
- * them by an effect, so a bundle switch off removes them before the next
1222
- * switch on re-registers. The scoped context carries the webServer; the
1223
- * localInfer service is passed in because the scope only injects what it
1224
- * names. The optional getHost is the lifecycle host the Get route's git and
1225
- * build steps run through; it defaults to the node host, and a test injects
1226
- * a stub so the route never clones a real repo in CI.
113
+ * defineRoute is the template every route file builds on: it guards the
114
+ * method (405), reads the bounded body (413, POST only), and maps the
115
+ * handler's answer to a status: a RouteError carries its own status, any
116
+ * other failure answers with the spec's failStatus. The route files carry
117
+ * only the handler body.
1227
118
  */
1228
- export function registerLocalInferRoute(scope, localInfer, getHost) {
1229
- const summary = async (url) => {
1230
- const engineParam = url.searchParams.get('engine');
1231
- const configParam = url.searchParams.get('config');
1232
- let config;
1233
- if (configParam !== null) {
1234
- try {
1235
- config = JSON.parse(configParam);
1236
- }
1237
- catch {
1238
- config = undefined;
1239
- }
1240
- }
1241
- // exactOptionalPropertyTypes: build the request object with only the keys
1242
- // that are present, so an absent value never writes `undefined`.
1243
- const request = {};
1244
- if (engineParam !== null) {
1245
- request.engine = engineParam;
1246
- }
1247
- if (config !== undefined) {
1248
- request.config = config;
1249
- }
1250
- return buildSummary(localInfer, request);
1251
- };
1252
- const route = {
1253
- name: 'local-infer-config',
1254
- kind: 'exact',
1255
- path: '/local-infer/summary',
1256
- handler: async (req, res) => {
1257
- const send = (status, body) => {
1258
- res.writeHead(status, { 'content-type': 'application/json' });
1259
- res.end(JSON.stringify(body));
1260
- };
1261
- if (req.method === 'GET') {
1262
- try {
1263
- send(200, await summary(new URL(req.url, 'http://localhost')));
1264
- }
1265
- catch (error) {
1266
- send(500, { error: String(error?.message ?? error) });
1267
- }
1268
- return;
1269
- }
1270
- res.writeHead(405).end();
1271
- },
1272
- };
1273
- const save = {
1274
- name: 'local-infer-save',
1275
- kind: 'exact',
1276
- path: '/local-infer/save',
1277
- handler: async (req, res) => {
1278
- const send = (status, body) => {
1279
- res.writeHead(status, { 'content-type': 'application/json' });
1280
- res.end(JSON.stringify(body));
1281
- };
1282
- if (req.method !== 'POST') {
1283
- res.writeHead(405).end();
1284
- return;
1285
- }
1286
- try {
1287
- const chunks = [];
1288
- let total = 0;
1289
- for await (const chunk of req) {
1290
- total += chunk.length;
1291
- if (total > 64 * 1024) {
1292
- send(413, { error: 'config payload too large' });
1293
- req.destroy();
1294
- return;
1295
- }
1296
- chunks.push(chunk);
1297
- }
1298
- const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
1299
- const engine = typeof body.engine === 'string' ? body.engine : 'ninfer';
1300
- const config = body.config && typeof body.config === 'object' ? body.config : {};
1301
- const allowRunDeps = typeof body.allowRunDeps === 'boolean' ? body.allowRunDeps : false;
1302
- const platform = parsePlatformConfigs(body.platform);
1303
- // A body that predates the key keeps the descriptions visible.
1304
- const showHelp = typeof body.showHelp === 'boolean' ? body.showHelp : true;
1305
- const providerId = typeof body.id === 'string' && body.id.trim() !== '' ? body.id : undefined;
1306
- // A body that omits the log location (or sends a blank one) keeps the
1307
- // stored template, so an unsaved edit never wipes a custom path.
1308
- const logPath = typeof body.logPath === 'string' && body.logPath !== ''
1309
- ? body.logPath
1310
- : readConfig().logPath;
1311
- // Save upserts a provider entry: an id updates the stored entry in
1312
- // place, an absent id mints a new id and prepends the entry, so a Save
1313
- // always adds (or refreshes) a card entry at the top of the list. The
1314
- // provider's platform config is the per-engine entry the deploy step
1315
- // reads, so a provider never carries a field it cannot price. A save
1316
- // that updates an entry in place carries the entry's launch record
1317
- // over, so editing a running provider's config never drops the record
1318
- // the restart re-link prices. A settings-only save skips the upsert:
1319
- // it persists the global fields the toggle edits and keeps the stored
1320
- // list intact, so the help and allow-run toggles never mint a
1321
- // duplicate entry.
1322
- const stored = readConfig();
1323
- let providers;
1324
- if (body.settingsOnly === true) {
1325
- providers = stored.providers;
1326
- }
1327
- else {
1328
- const provider = {
1329
- id: providerId ?? newProviderId(),
1330
- engine,
1331
- config,
1332
- platform: platform[engine] ?? {},
1333
- };
1334
- if (providerId !== undefined) {
1335
- const existing = stored.providers.find((entry) => entry.id === providerId);
1336
- if (existing?.launch !== undefined) {
1337
- provider.launch = existing.launch;
1338
- }
1339
- }
1340
- providers =
1341
- providerId !== undefined
1342
- ? stored.providers.map((entry) => (entry.id === providerId ? provider : entry))
1343
- : [provider, ...stored.providers];
1344
- }
1345
- writeConfig(engine, config, allowRunDeps, platform, showHelp, providers, logPath);
1346
- // Re-sync the service registry with the saved platform map, so the next
1347
- // launch reads the platform config the user just saved.
1348
- syncPlatformConfigs(localInfer, {
1349
- engine,
1350
- config,
1351
- allowRunDeps,
1352
- platform,
1353
- showHelp,
1354
- providers,
1355
- logPath,
1356
- lastLogs: stored.lastLogs,
1357
- });
1358
- // A Save on an already-registered model re-runs the registration, so
1359
- // the DSH model row's input types (the vision flag) stay in step with
1360
- // the config the user just saved. Best-effort: a profile without the
1361
- // settings seam, or an engine no adapter serves, leaves the save intact.
1362
- try {
1363
- await syncRegisteredModel(localInfer, engine, config);
1364
- }
1365
- catch {
1366
- // A sync that cannot run is a no-op for the save.
1367
- }
1368
- send(200, await buildSummary(localInfer, { engine, config }));
1369
- }
1370
- catch (error) {
1371
- send(400, { error: String(error?.message ?? error) });
1372
- }
1373
- },
1374
- };
1375
- const deleteProvider = {
1376
- name: 'local-infer-provider-delete',
1377
- kind: 'exact',
1378
- path: '/local-infer/provider-delete',
1379
- handler: async (req, res) => {
1380
- const send = (status, body) => {
1381
- res.writeHead(status, { 'content-type': 'application/json' });
1382
- res.end(JSON.stringify(body));
1383
- };
1384
- if (req.method !== 'POST') {
1385
- res.writeHead(405).end();
1386
- return;
1387
- }
1388
- try {
1389
- const chunks = [];
1390
- let total = 0;
1391
- for await (const chunk of req) {
1392
- total += chunk.length;
1393
- if (total > 64 * 1024) {
1394
- send(413, { error: 'payload too large' });
1395
- req.destroy();
1396
- return;
1397
- }
1398
- chunks.push(chunk);
1399
- }
1400
- const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
1401
- const id = typeof body.id === 'string' ? body.id : undefined;
1402
- if (id === undefined) {
1403
- send(400, { error: 'the provider id is required' });
1404
- return;
1405
- }
1406
- // Delete removes the stored provider entry by id, keeping the rest of
1407
- // the list and the active config intact, then re-syncs the registry so
1408
- // a launch never reads a platform config the user just removed.
1409
- const stored = readConfig();
1410
- const providers = stored.providers.filter((entry) => entry.id !== id);
1411
- writeConfig(stored.engine, stored.config, stored.allowRunDeps, stored.platform, stored.showHelp, providers, stored.logPath, stored.lastLogs);
1412
- syncPlatformConfigs(localInfer, stored);
1413
- send(200, await buildSummary(localInfer));
1414
- }
1415
- catch (error) {
1416
- send(400, { error: String(error?.message ?? error) });
1417
- }
1418
- },
1419
- };
1420
- const deps = {
1421
- name: 'local-infer-deps',
1422
- kind: 'exact',
1423
- path: '/local-infer/deps',
1424
- handler: async (req, res) => {
1425
- const send = (status, body) => {
1426
- res.writeHead(status, { 'content-type': 'application/json' });
1427
- res.end(JSON.stringify(body));
1428
- };
1429
- if (req.method !== 'GET') {
1430
- res.writeHead(405).end();
1431
- return;
1432
- }
1433
- const url = new URL(req.url, 'http://localhost');
1434
- const engineParam = url.searchParams.get('engine');
1435
- const configParam = url.searchParams.get('config');
1436
- let config;
1437
- if (configParam !== null) {
1438
- try {
1439
- config = JSON.parse(configParam);
1440
- }
1441
- catch {
1442
- config = undefined;
1443
- }
1444
- }
1445
- // exactOptionalPropertyTypes: build the request object with only the
1446
- // keys that are present, so an absent value never writes `undefined`.
1447
- const request = {};
1448
- if (engineParam !== null) {
1449
- request.engine = engineParam;
1450
- }
1451
- if (config !== undefined) {
1452
- request.config = config;
1453
- }
1454
- try {
1455
- send(200, await buildDeps(localInfer, request));
1456
- }
1457
- catch (error) {
1458
- send(500, { error: String(error?.message ?? error) });
1459
- }
1460
- },
1461
- };
1462
- const install = {
1463
- name: 'local-infer-install',
1464
- kind: 'exact',
1465
- path: '/local-infer/install',
1466
- handler: async (req, res) => {
1467
- const send = (status, body) => {
1468
- res.writeHead(status, { 'content-type': 'application/json' });
1469
- res.end(JSON.stringify(body));
1470
- };
1471
- if (req.method !== 'POST') {
1472
- res.writeHead(405).end();
1473
- return;
1474
- }
1475
- try {
1476
- const chunks = [];
1477
- let total = 0;
1478
- for await (const chunk of req) {
1479
- total += chunk.length;
1480
- if (total > 64 * 1024) {
1481
- send(413, { error: 'install payload too large' });
1482
- req.destroy();
1483
- return;
1484
- }
1485
- chunks.push(chunk);
1486
- }
1487
- const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
1488
- const request = {};
1489
- if (typeof body.engine === 'string') {
1490
- request.engine = body.engine;
1491
- }
1492
- if (body.config && typeof body.config === 'object') {
1493
- request.config = body.config;
1494
- }
1495
- send(200, await runInstallFor(localInfer, request));
1496
- }
1497
- catch (error) {
1498
- if (error instanceof RouteError) {
1499
- send(error.status, { error: error.message });
1500
- return;
1501
- }
1502
- send(500, { error: String(error?.message ?? error) });
1503
- }
1504
- },
1505
- };
1506
- const download = {
1507
- name: 'local-infer-download',
1508
- kind: 'exact',
1509
- path: '/local-infer/download',
1510
- handler: async (req, res) => {
1511
- const send = (status, body) => {
1512
- res.writeHead(status, { 'content-type': 'application/json' });
1513
- res.end(JSON.stringify(body));
1514
- };
1515
- if (req.method !== 'POST') {
1516
- res.writeHead(405).end();
1517
- return;
1518
- }
1519
- try {
1520
- const chunks = [];
1521
- let total = 0;
1522
- for await (const chunk of req) {
1523
- total += chunk.length;
1524
- if (total > 64 * 1024) {
1525
- send(413, { error: 'download payload too large' });
1526
- req.destroy();
1527
- return;
1528
- }
1529
- chunks.push(chunk);
1530
- }
1531
- const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
1532
- const request = {};
1533
- if (typeof body.engine === 'string') {
1534
- request.engine = body.engine;
1535
- }
1536
- if (typeof body.model === 'string') {
1537
- request.model = body.model;
1538
- }
1539
- if (typeof body.directory === 'string') {
1540
- request.directory = body.directory;
1541
- }
1542
- send(200, await downloadFor(localInfer, request));
1543
- }
1544
- catch (error) {
1545
- if (error instanceof RouteError) {
1546
- send(error.status, { error: error.message });
1547
- return;
1548
- }
1549
- send(500, { error: String(error?.message ?? error) });
1550
- }
1551
- },
1552
- };
1553
- // The chat-template download route: the card's auto-download (and the
1554
- // System-dependencies fallback) pulls a selected template into the model
1555
- // directory through this route, so a picked preset works out of the box.
1556
- const chatTemplate = {
1557
- name: 'local-infer-chat-template',
1558
- kind: 'exact',
1559
- path: '/local-infer/chat-template',
1560
- handler: async (req, res) => {
1561
- const send = (status, body) => {
1562
- res.writeHead(status, { 'content-type': 'application/json' });
1563
- res.end(JSON.stringify(body));
1564
- };
1565
- if (req.method !== 'POST') {
1566
- res.writeHead(405).end();
1567
- return;
1568
- }
1569
- try {
1570
- const chunks = [];
1571
- let total = 0;
1572
- for await (const chunk of req) {
1573
- total += chunk.length;
1574
- if (total > 64 * 1024) {
1575
- send(413, { error: 'chat template payload too large' });
1576
- req.destroy();
1577
- return;
1578
- }
1579
- chunks.push(chunk);
1580
- }
1581
- const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
1582
- const request = {};
1583
- if (typeof body.engine === 'string') {
1584
- request.engine = body.engine;
1585
- }
1586
- if (typeof body.template === 'string') {
1587
- request.template = body.template;
1588
- }
1589
- if (typeof body.directory === 'string') {
1590
- request.directory = body.directory;
1591
- }
1592
- send(200, await chatTemplateDownloadFor(localInfer, request));
1593
- }
1594
- catch (error) {
1595
- if (error instanceof RouteError) {
1596
- send(error.status, { error: error.message });
1597
- return;
1598
- }
1599
- send(500, { error: String(error?.message ?? error) });
1600
- }
1601
- },
1602
- };
1603
- // The Get route: a POST whose answer streams as plain text, so the card
1604
- // appends the git sync, the dependency run, and the build output to its
1605
- // Logs section as it lands. The full run persists as the last-run logs.
1606
- const get = {
1607
- name: 'local-infer-get',
1608
- kind: 'exact',
1609
- path: '/local-infer/get',
1610
- handler: async (req, res) => {
1611
- const send = (status, body) => {
1612
- res.writeHead(status, { 'content-type': 'application/json' });
1613
- res.end(JSON.stringify(body));
1614
- };
1615
- if (req.method !== 'POST') {
1616
- res.writeHead(405).end();
1617
- return;
1618
- }
1619
- try {
1620
- const chunks = [];
1621
- let total = 0;
1622
- for await (const chunk of req) {
1623
- total += chunk.length;
1624
- if (total > 64 * 1024) {
1625
- send(413, { error: 'get payload too large' });
1626
- req.destroy();
1627
- return;
1628
- }
1629
- chunks.push(chunk);
1630
- }
1631
- const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
1632
- const request = {};
1633
- if (typeof body.engine === 'string') {
1634
- request.engine = body.engine;
1635
- }
1636
- if (body.config && typeof body.config === 'object') {
1637
- request.config = body.config;
1638
- }
1639
- const platform = parseStoredPlatformConfig(body.platform);
1640
- if (platform !== undefined) {
1641
- request.platform = platform;
1642
- }
1643
- res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
1644
- let collected = '';
1645
- const log = (text) => {
1646
- collected += text;
1647
- if (typeof res.write === 'function') {
1648
- res.write(text);
1649
- }
1650
- };
1651
- try {
1652
- await getEngine(localInfer, request, log, getHost);
1653
- }
1654
- catch (error) {
1655
- log(`!! get failed: ${String(error?.message ?? error)}\n`);
1656
- }
1657
- persistGetLogs(collected);
1658
- res.end();
1659
- }
1660
- catch (error) {
1661
- send(400, { error: String(error?.message ?? error) });
1662
- }
1663
- },
1664
- };
1665
- // The launch and stop routes share a POST body parser: the engine, config,
1666
- // and platform override the plan renders from, falling back to the stored
1667
- // config per key. The stop route ignores the platform key. `providerId`
1668
- // names the saved provider entry the launch record (or its stop's clear)
1669
- // targets.
1670
- const launchRequestFromBody = (body) => {
1671
- const request = {};
1672
- if (typeof body.engine === 'string') {
1673
- request.engine = body.engine;
1674
- }
1675
- if (body.config && typeof body.config === 'object') {
1676
- request.config = body.config;
1677
- }
1678
- const platform = parseStoredPlatformConfig(body.platform);
1679
- if (platform !== undefined) {
1680
- request.platform = platform;
1681
- }
1682
- if (typeof body.providerId === 'string' && body.providerId.trim() !== '') {
1683
- request.providerId = body.providerId;
1684
- }
1685
- if (typeof body.autoRebuild === 'boolean') {
1686
- request.autoRebuild = body.autoRebuild;
1687
- }
1688
- return request;
1689
- };
1690
- const launch = {
1691
- name: 'local-infer-launch',
1692
- kind: 'exact',
1693
- path: '/local-infer/launch',
1694
- handler: async (req, res) => {
1695
- const send = (status, body) => {
1696
- res.writeHead(status, { 'content-type': 'application/json' });
1697
- res.end(JSON.stringify(body));
1698
- };
1699
- if (req.method !== 'POST') {
1700
- res.writeHead(405).end();
1701
- return;
1702
- }
1703
- try {
1704
- const chunks = [];
1705
- let total = 0;
1706
- for await (const chunk of req) {
1707
- total += chunk.length;
1708
- if (total > 64 * 1024) {
1709
- send(413, { error: 'launch payload too large' });
1710
- req.destroy();
1711
- return;
1712
- }
1713
- chunks.push(chunk);
1714
- }
1715
- const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
1716
- // The launch streams its progress as NDJSON: a step event as each phase
1717
- // (prereqs, download, build, launch) begins, then a final result event.
1718
- // The card's Launch control reads the step that is running and times
1719
- // out only the launch (health-poll) step; the rebuild steps run as long
1720
- // as they need to.
1721
- res.writeHead(200, { 'content-type': 'application/x-ndjson; charset=utf-8' });
1722
- const writeLine = (line) => {
1723
- if (typeof res.write === 'function') {
1724
- res.write(JSON.stringify(line) + '\n');
1725
- }
1726
- };
1727
- const onProgress = (step) => {
1728
- writeLine({ step });
1729
- };
1730
- try {
1731
- const result = await launchEngine(localInfer, launchRequestFromBody(body), onProgress);
1732
- writeLine({ result });
1733
- }
1734
- catch (error) {
1735
- // A launch that breaks before a plan renders (an unknown engine, a
1736
- // hardware probe failure) still settles the card's control to the red
1737
- // Logs state through a result event, not a bare 500.
1738
- const detail = error instanceof RouteError
1739
- ? error.message
1740
- : String(error?.message ?? error);
1741
- writeLine({ result: { healthy: false, detail } });
1742
- }
1743
- res.end();
1744
- }
1745
- catch (error) {
1746
- if (error instanceof RouteError) {
1747
- send(error.status, { error: error.message });
1748
- return;
1749
- }
1750
- send(500, { error: String(error?.message ?? error) });
1751
- }
1752
- },
1753
- };
1754
- const stop = {
1755
- name: 'local-infer-stop',
1756
- kind: 'exact',
1757
- path: '/local-infer/stop',
1758
- handler: async (req, res) => {
1759
- const send = (status, body) => {
1760
- res.writeHead(status, { 'content-type': 'application/json' });
1761
- res.end(JSON.stringify(body));
1762
- };
1763
- if (req.method !== 'POST') {
1764
- res.writeHead(405).end();
1765
- return;
1766
- }
1767
- try {
1768
- const chunks = [];
1769
- let total = 0;
1770
- for await (const chunk of req) {
1771
- total += chunk.length;
1772
- if (total > 64 * 1024) {
1773
- send(413, { error: 'stop payload too large' });
1774
- req.destroy();
1775
- return;
1776
- }
1777
- chunks.push(chunk);
1778
- }
1779
- const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
1780
- await stopEngine(localInfer, launchRequestFromBody(body));
1781
- send(200, { stopped: true });
1782
- }
1783
- catch (error) {
1784
- if (error instanceof RouteError) {
1785
- send(error.status, { error: error.message });
1786
- return;
1787
- }
1788
- send(500, { error: String(error?.message ?? error) });
1789
- }
1790
- },
1791
- };
1792
- // The register and unregister routes share a body parser: the engine and
1793
- // config the plan renders from, plus agentDefault for the register route,
1794
- // falling back to the stored config per key.
1795
- const registerRequestFromBody = (body) => {
1796
- const request = {};
1797
- if (typeof body.engine === 'string') {
1798
- request.engine = body.engine;
1799
- }
1800
- if (body.config && typeof body.config === 'object') {
1801
- request.config = body.config;
1802
- }
1803
- if (typeof body.agentDefault === 'boolean') {
1804
- request.agentDefault = body.agentDefault;
1805
- }
1806
- return request;
1807
- };
1808
- const register = {
1809
- name: 'local-infer-register',
1810
- kind: 'exact',
1811
- path: '/local-infer/register',
1812
- handler: async (req, res) => {
1813
- const send = (status, body) => {
1814
- res.writeHead(status, { 'content-type': 'application/json' });
1815
- res.end(JSON.stringify(body));
1816
- };
1817
- if (req.method !== 'POST') {
1818
- res.writeHead(405).end();
1819
- return;
1820
- }
1821
- try {
1822
- const chunks = [];
1823
- let total = 0;
1824
- for await (const chunk of req) {
1825
- total += chunk.length;
1826
- if (total > 64 * 1024) {
1827
- send(413, { error: 'register payload too large' });
1828
- req.destroy();
1829
- return;
1830
- }
1831
- chunks.push(chunk);
1832
- }
1833
- const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
1834
- send(200, await registerFor(localInfer, registerRequestFromBody(body)));
1835
- }
1836
- catch (error) {
1837
- if (error instanceof RouteError) {
1838
- send(error.status, { error: error.message });
1839
- return;
1840
- }
1841
- send(500, { error: String(error?.message ?? error) });
1842
- }
1843
- },
1844
- };
1845
- const unregister = {
1846
- name: 'local-infer-unregister',
119
+ export function defineRoute(spec) {
120
+ return {
121
+ name: spec.name,
1847
122
  kind: 'exact',
1848
- path: '/local-infer/unregister',
123
+ path: spec.path,
1849
124
  handler: async (req, res) => {
1850
- const send = (status, body) => {
1851
- res.writeHead(status, { 'content-type': 'application/json' });
1852
- res.end(JSON.stringify(body));
1853
- };
1854
- if (req.method !== 'POST') {
125
+ if (req.method !== spec.method) {
1855
126
  res.writeHead(405).end();
1856
127
  return;
1857
128
  }
129
+ const responder = new RouteResponder(res);
1858
130
  try {
1859
- const chunks = [];
1860
- let total = 0;
1861
- for await (const chunk of req) {
1862
- total += chunk.length;
1863
- if (total > 64 * 1024) {
1864
- send(413, { error: 'unregister payload too large' });
1865
- req.destroy();
1866
- return;
1867
- }
1868
- chunks.push(chunk);
1869
- }
1870
- const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
1871
- send(200, await unregisterFor(localInfer, registerRequestFromBody(body)));
131
+ const body = spec.method === 'POST' ? await readJsonBody(req, spec.tooLargeMessage) : {};
132
+ await spec.run({ url: new URL(req.url, 'http://localhost'), body }, responder);
1872
133
  }
1873
134
  catch (error) {
1874
135
  if (error instanceof RouteError) {
1875
- send(error.status, { error: error.message });
136
+ responder.json(error.status, { error: error.message });
1876
137
  return;
1877
138
  }
1878
- send(500, { error: String(error?.message ?? error) });
139
+ responder.json(spec.failStatus, { error: String(error?.message ?? error) });
1879
140
  }
1880
141
  },
1881
142
  };
143
+ }
144
+ /**
145
+ * registerLocalInferRoute registers the summary, save, provider-delete, deps,
146
+ * install, download, chat-template, get, launch, stop, register, and
147
+ * unregister routes on the web server and holds them by an effect, so a
148
+ * bundle switch off removes them before the next switch on re-registers. The
149
+ * scoped context carries the webServer; the localInfer service is passed in
150
+ * because the scope only injects what it names. The optional getHost is the
151
+ * lifecycle host the Get route's git and build steps run through; it
152
+ * defaults to the node host, and a test injects a stub so the route never
153
+ * clones a real repo in CI.
154
+ */
155
+ export function registerLocalInferRoute(scope, localInfer, getHost) {
156
+ const routes = [
157
+ summaryRoute(localInfer),
158
+ saveRoute(localInfer),
159
+ providerDeleteRoute(localInfer),
160
+ depsRoute(localInfer),
161
+ installRoute(localInfer),
162
+ downloadRoute(localInfer),
163
+ chatTemplateRoute(localInfer),
164
+ getRoute(localInfer, getHost),
165
+ launchRoute(localInfer),
166
+ stopRoute(localInfer),
167
+ registerRoute(localInfer),
168
+ unregisterRoute(localInfer),
169
+ ];
1882
170
  scope.effect(() => {
1883
171
  // Sync the service's platform-config registry with the stored config on
1884
172
  // route registration, so a launch before the first save reads the
@@ -1894,32 +182,12 @@ export function registerLocalInferRoute(scope, localInfer, getHost) {
1894
182
  // open the summary route, so a DSH that restarts re-attaches its engines
1895
183
  // and a pkill after the restart still settles the stored state.
1896
184
  void relinkStoredProviders(localInfer, readConfig()).catch(() => undefined);
1897
- const disposeSummary = scope.webServer.register(route);
1898
- const disposeSave = scope.webServer.register(save);
1899
- const disposeDeleteProvider = scope.webServer.register(deleteProvider);
1900
- const disposeDeps = scope.webServer.register(deps);
1901
- const disposeInstall = scope.webServer.register(install);
1902
- const disposeDownload = scope.webServer.register(download);
1903
- const disposeChatTemplate = scope.webServer.register(chatTemplate);
1904
- const disposeGet = scope.webServer.register(get);
1905
- const disposeLaunch = scope.webServer.register(launch);
1906
- const disposeStop = scope.webServer.register(stop);
1907
- const disposeRegister = scope.webServer.register(register);
1908
- const disposeUnregister = scope.webServer.register(unregister);
185
+ const disposers = routes.map((route) => scope.webServer.register(route));
1909
186
  return () => {
1910
187
  localInfer.setEngineDeathObserver(undefined);
1911
- disposeSummary();
1912
- disposeSave();
1913
- disposeDeleteProvider();
1914
- disposeDeps();
1915
- disposeInstall();
1916
- disposeDownload();
1917
- disposeChatTemplate();
1918
- disposeGet();
1919
- disposeLaunch();
1920
- disposeStop();
1921
- disposeRegister();
1922
- disposeUnregister();
188
+ for (const dispose of disposers) {
189
+ dispose();
190
+ }
1923
191
  };
1924
192
  }, 'local-infer: settings routes');
1925
193
  }