@khanglvm/llm-router 2.4.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import http from "node:http";
2
+ import os from "node:os";
2
3
  import path from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { spawn, spawnSync } from "node:child_process";
@@ -61,6 +62,27 @@ import {
61
62
  } from "./ollama-client.js";
62
63
  import { estimateMaxContext, estimateModelVram, formatBytes } from "./ollama-hardware.js";
63
64
  import { detectOllamaInstallation, installOllama, startOllamaServer, stopOllamaServer, isOllamaRunning } from "./ollama-install.js";
65
+ import { browseForLocalModelPath, scanLocalModelPath } from "./local-model-browser.js";
66
+ import {
67
+ detectLlamacppCandidates,
68
+ startConfiguredLlamacppRuntime,
69
+ stopManagedLlamacppRuntime,
70
+ validateLlamacppCommand
71
+ } from "./llamacpp-runtime.js";
72
+ import {
73
+ getManagedLocalModelsDir,
74
+ reconcileLocalModelPaths,
75
+ registerAttachedLlamacppModel,
76
+ registerManagedLlamacppModel,
77
+ removeLocalBaseModel,
78
+ saveLocalModelVariant
79
+ ,
80
+ updateLocalBaseModelPath
81
+ } from "./local-models-service.js";
82
+ import {
83
+ downloadManagedHuggingFaceGguf,
84
+ searchHuggingFaceGgufCandidates
85
+ } from "./huggingface-gguf.js";
64
86
  import {
65
87
  CONFIG_VERSION,
66
88
  DEFAULT_MODEL_ALIAS_ID,
@@ -846,6 +868,80 @@ function routeSnapshotDocument(configState) {
846
868
  return configState.parseError ? null : (configState.normalizedConfig || buildDefaultConfigObject());
847
869
  }
848
870
 
871
+ function readConfiguredLlamacppRuntime(config = {}) {
872
+ const runtime = config?.metadata?.localModels?.runtime?.llamacpp;
873
+ if (!runtime || typeof runtime !== "object") {
874
+ return {
875
+ selectedCommand: "",
876
+ selectedDirectory: "",
877
+ manualCommand: "",
878
+ host: "127.0.0.1",
879
+ port: 39391,
880
+ startWithRouter: false,
881
+ status: ""
882
+ };
883
+ }
884
+
885
+ const selectedCommand = String(runtime.selectedCommand || runtime.manualCommand || runtime.command || "").trim();
886
+ return {
887
+ ...runtime,
888
+ selectedCommand,
889
+ selectedDirectory: selectedCommand ? path.dirname(selectedCommand) : "",
890
+ manualCommand: String(runtime.manualCommand || "").trim(),
891
+ host: String(runtime.host || "127.0.0.1").trim() || "127.0.0.1",
892
+ port: Number.isInteger(Number(runtime.port)) ? Number(runtime.port) : 39391,
893
+ startWithRouter: runtime.startWithRouter === true,
894
+ status: String(runtime.status || "").trim()
895
+ };
896
+ }
897
+
898
+ function buildLlamacppRuntimePayload(runtime = {}, validation = {}, candidates = []) {
899
+ const selectedCommand = String(runtime.selectedCommand || runtime.manualCommand || runtime.command || "").trim();
900
+ return {
901
+ ...runtime,
902
+ selectedCommand,
903
+ selectedDirectory: selectedCommand ? path.dirname(selectedCommand) : "",
904
+ ...(validation && typeof validation === "object" ? validation : {}),
905
+ candidates
906
+ };
907
+ }
908
+
909
+ function updateLlamacppRuntimeConfig(config = {}, runtimePatch = {}) {
910
+ const nextConfig = JSON.parse(JSON.stringify(config || {}));
911
+ if (!nextConfig.metadata || typeof nextConfig.metadata !== "object" || Array.isArray(nextConfig.metadata)) {
912
+ nextConfig.metadata = {};
913
+ }
914
+ if (!nextConfig.metadata.localModels || typeof nextConfig.metadata.localModels !== "object" || Array.isArray(nextConfig.metadata.localModels)) {
915
+ nextConfig.metadata.localModels = {};
916
+ }
917
+ if (!nextConfig.metadata.localModels.runtime || typeof nextConfig.metadata.localModels.runtime !== "object" || Array.isArray(nextConfig.metadata.localModels.runtime)) {
918
+ nextConfig.metadata.localModels.runtime = {};
919
+ }
920
+
921
+ const currentRuntime = readConfiguredLlamacppRuntime(nextConfig);
922
+ const nextRuntime = {
923
+ ...currentRuntime,
924
+ ...runtimePatch
925
+ };
926
+ const {
927
+ selectedDirectory: _selectedDirectory,
928
+ candidates: _candidates,
929
+ ...persistedRuntime
930
+ } = nextRuntime;
931
+ const selectedCommand = String(nextRuntime.selectedCommand || nextRuntime.manualCommand || nextRuntime.command || "").trim();
932
+
933
+ nextConfig.metadata.localModels.runtime.llamacpp = {
934
+ ...nextConfig.metadata.localModels.runtime.llamacpp,
935
+ ...persistedRuntime,
936
+ selectedCommand,
937
+ manualCommand: selectedCommand,
938
+ command: selectedCommand,
939
+ path: selectedCommand,
940
+ status: String(nextRuntime.status || "").trim()
941
+ };
942
+ return nextConfig;
943
+ }
944
+
849
945
  export async function startWebConsoleServer(options = {}, deps = {}) {
850
946
  const {
851
947
  host = "127.0.0.1",
@@ -884,6 +980,40 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
884
980
  ? deps.waitForRuntimeMatch
885
981
  : (startOptions, waitOptions = {}) => waitForRuntimeMatch(startOptions, waitOptions);
886
982
  const loginSubscriptionFn = typeof deps.loginSubscription === "function" ? deps.loginSubscription : loginSubscription;
983
+ const getLocalModelSystemInfoFn = typeof deps.getLocalModelSystemInfo === "function"
984
+ ? deps.getLocalModelSystemInfo
985
+ : () => ({
986
+ platform: process.platform,
987
+ totalMemoryBytes: os.totalmem(),
988
+ unifiedMemory: process.platform === "darwin"
989
+ });
990
+ const searchHuggingFaceGgufCandidatesFn = typeof deps.searchHuggingFaceGgufCandidates === "function"
991
+ ? deps.searchHuggingFaceGgufCandidates
992
+ : searchHuggingFaceGgufCandidates;
993
+ const downloadManagedHuggingFaceGgufFn = typeof deps.downloadManagedHuggingFaceGguf === "function"
994
+ ? deps.downloadManagedHuggingFaceGguf
995
+ : (request, runtimeOptions = {}) => downloadManagedHuggingFaceGguf(request, runtimeOptions);
996
+ const localModelPathExistsFn = typeof deps.localModelPathExists === "function"
997
+ ? deps.localModelPathExists
998
+ : undefined;
999
+ const browseForLocalModelPathFn = typeof deps.browseForLocalModelPath === "function"
1000
+ ? deps.browseForLocalModelPath
1001
+ : browseForLocalModelPath;
1002
+ const scanLocalModelPathFn = typeof deps.scanLocalModelPath === "function"
1003
+ ? deps.scanLocalModelPath
1004
+ : scanLocalModelPath;
1005
+ const detectLlamacppCandidatesFn = typeof deps.detectLlamacppCandidates === "function"
1006
+ ? deps.detectLlamacppCandidates
1007
+ : detectLlamacppCandidates;
1008
+ const startConfiguredLlamacppRuntimeFn = typeof deps.startConfiguredLlamacppRuntime === "function"
1009
+ ? deps.startConfiguredLlamacppRuntime
1010
+ : (config, callbacks = {}, runtimeDeps = {}) => startConfiguredLlamacppRuntime(config, callbacks, runtimeDeps);
1011
+ const stopManagedLlamacppRuntimeFn = typeof deps.stopManagedLlamacppRuntime === "function"
1012
+ ? deps.stopManagedLlamacppRuntime
1013
+ : (callbacks = {}) => stopManagedLlamacppRuntime(callbacks);
1014
+ const validateLlamacppCommandFn = typeof deps.validateLlamacppCommand === "function"
1015
+ ? deps.validateLlamacppCommand
1016
+ : validateLlamacppCommand;
887
1017
  const ampClientEnv = deps.ampClientEnv && typeof deps.ampClientEnv === "object" ? deps.ampClientEnv : process.env;
888
1018
  const ampClientCwd = typeof deps.ampClientCwd === "string" && deps.ampClientCwd.trim() ? deps.ampClientCwd : process.cwd();
889
1019
  const codexCliEnv = deps.codexCliEnv && typeof deps.codexCliEnv === "object" ? deps.codexCliEnv : process.env;
@@ -3131,6 +3261,451 @@ export async function startWebConsoleServer(options = {}, deps = {}) {
3131
3261
  return;
3132
3262
  }
3133
3263
 
3264
+ if (method === "POST" && requestUrl.pathname === "/api/local-models/attach") {
3265
+ const body = await readJsonBody(req);
3266
+ const id = String(body.id || "").trim();
3267
+ const filePath = String(body.filePath || "").trim();
3268
+ if (!id || !filePath) {
3269
+ sendJson(res, 400, {
3270
+ error: "id and filePath are required."
3271
+ });
3272
+ return;
3273
+ }
3274
+ const configState = await readConfigState(configPath);
3275
+ if (configState.parseError) {
3276
+ sendJson(res, 400, {
3277
+ error: `Config JSON must parse before attaching a local model: ${configState.parseError}`
3278
+ });
3279
+ return;
3280
+ }
3281
+
3282
+ const updated = await registerAttachedLlamacppModel(configState.rawConfig || {}, {
3283
+ id,
3284
+ displayName: String(body.displayName || "").trim(),
3285
+ filePath,
3286
+ metadata: body.metadata || {}
3287
+ });
3288
+ const { savedConfig } = await writeAndBroadcastConfig(updated, {
3289
+ source: "local-models-attach"
3290
+ });
3291
+ sendJson(res, 200, {
3292
+ ok: true,
3293
+ library: savedConfig?.metadata?.localModels?.library || {}
3294
+ });
3295
+ return;
3296
+ }
3297
+
3298
+ if (method === "POST" && requestUrl.pathname === "/api/local-models/locate") {
3299
+ const body = await readJsonBody(req);
3300
+ const baseModelId = String(body.baseModelId || "").trim();
3301
+ const filePath = String(body.filePath || "").trim();
3302
+ if (!baseModelId || !filePath) {
3303
+ sendJson(res, 400, {
3304
+ error: "baseModelId and filePath are required."
3305
+ });
3306
+ return;
3307
+ }
3308
+ const configState = await readConfigState(configPath);
3309
+ if (configState.parseError) {
3310
+ sendJson(res, 400, {
3311
+ error: `Config JSON must parse before locating a local model: ${configState.parseError}`
3312
+ });
3313
+ return;
3314
+ }
3315
+
3316
+ try {
3317
+ const relocated = await updateLocalBaseModelPath(configState.rawConfig || {}, baseModelId, filePath);
3318
+ const reconciled = await reconcileLocalModelPaths(relocated, {
3319
+ ...(localModelPathExistsFn ? { pathExists: localModelPathExistsFn } : {})
3320
+ });
3321
+ const { savedConfig } = await writeAndBroadcastConfig(reconciled, {
3322
+ source: "local-models-locate"
3323
+ });
3324
+ sendJson(res, 200, {
3325
+ ok: true,
3326
+ library: savedConfig?.metadata?.localModels?.library || {},
3327
+ variants: savedConfig?.metadata?.localModels?.variants || {}
3328
+ });
3329
+ } catch (error) {
3330
+ sendJson(res, 400, {
3331
+ error: error instanceof Error ? error.message : String(error)
3332
+ });
3333
+ }
3334
+ return;
3335
+ }
3336
+
3337
+ if (method === "POST" && requestUrl.pathname === "/api/local-models/remove") {
3338
+ const body = await readJsonBody(req);
3339
+ const baseModelId = String(body.baseModelId || "").trim();
3340
+ if (!baseModelId) {
3341
+ sendJson(res, 400, {
3342
+ error: "baseModelId is required."
3343
+ });
3344
+ return;
3345
+ }
3346
+ const configState = await readConfigState(configPath);
3347
+ if (configState.parseError) {
3348
+ sendJson(res, 400, {
3349
+ error: `Config JSON must parse before removing a local model: ${configState.parseError}`
3350
+ });
3351
+ return;
3352
+ }
3353
+
3354
+ const updated = await removeLocalBaseModel(configState.rawConfig || {}, baseModelId);
3355
+ const { savedConfig } = await writeAndBroadcastConfig(updated, {
3356
+ source: "local-models-remove"
3357
+ });
3358
+ sendJson(res, 200, {
3359
+ ok: true,
3360
+ library: savedConfig?.metadata?.localModels?.library || {},
3361
+ variants: savedConfig?.metadata?.localModels?.variants || {}
3362
+ });
3363
+ return;
3364
+ }
3365
+
3366
+ if (method === "POST" && requestUrl.pathname === "/api/local-models/reconcile") {
3367
+ const configState = await readConfigState(configPath);
3368
+ if (configState.parseError) {
3369
+ sendJson(res, 400, {
3370
+ error: `Config JSON must parse before refreshing local model status: ${configState.parseError}`
3371
+ });
3372
+ return;
3373
+ }
3374
+
3375
+ const updated = await reconcileLocalModelPaths(configState.rawConfig || {}, {
3376
+ ...(localModelPathExistsFn ? { pathExists: localModelPathExistsFn } : {})
3377
+ });
3378
+ const { savedConfig } = await writeAndBroadcastConfig(updated, {
3379
+ source: "local-models-reconcile"
3380
+ });
3381
+ sendJson(res, 200, {
3382
+ ok: true,
3383
+ library: savedConfig?.metadata?.localModels?.library || {},
3384
+ variants: savedConfig?.metadata?.localModels?.variants || {}
3385
+ });
3386
+ return;
3387
+ }
3388
+
3389
+ if (method === "POST" && requestUrl.pathname === "/api/local-models/runtime/discover") {
3390
+ const configState = await readConfigState(configPath);
3391
+ if (configState.parseError) {
3392
+ sendJson(res, 400, {
3393
+ error: `Config JSON must parse before discovering llama.cpp runtimes: ${configState.parseError}`
3394
+ });
3395
+ return;
3396
+ }
3397
+
3398
+ const configuredRuntime = readConfiguredLlamacppRuntime(configState.rawConfig || {});
3399
+ const candidates = detectLlamacppCandidatesFn();
3400
+ const hydratedCandidates = candidates.map((candidate) => {
3401
+ const validation = validateLlamacppCommandFn(candidate.path);
3402
+ return {
3403
+ ...candidate,
3404
+ directory: path.dirname(candidate.path),
3405
+ ...validation,
3406
+ current: candidate.path === configuredRuntime.selectedCommand
3407
+ };
3408
+ });
3409
+
3410
+ sendJson(res, 200, {
3411
+ runtime: buildLlamacppRuntimePayload(configuredRuntime, {}, hydratedCandidates)
3412
+ });
3413
+ return;
3414
+ }
3415
+
3416
+ if (method === "POST" && requestUrl.pathname === "/api/local-models/runtime/select") {
3417
+ const body = await readJsonBody(req);
3418
+ const command = String(body.command || "").trim();
3419
+ if (!command) {
3420
+ sendJson(res, 400, {
3421
+ error: "command is required."
3422
+ });
3423
+ return;
3424
+ }
3425
+
3426
+ const configState = await readConfigState(configPath);
3427
+ if (configState.parseError) {
3428
+ sendJson(res, 400, {
3429
+ error: `Config JSON must parse before selecting a llama.cpp runtime: ${configState.parseError}`
3430
+ });
3431
+ return;
3432
+ }
3433
+
3434
+ const validation = validateLlamacppCommandFn(command);
3435
+ if (!validation?.ok) {
3436
+ sendJson(res, 400, {
3437
+ error: validation?.errorMessage || `Failed validating llama.cpp runtime '${command}'.`,
3438
+ runtime: buildLlamacppRuntimePayload(readConfiguredLlamacppRuntime(configState.rawConfig || {}), validation)
3439
+ });
3440
+ return;
3441
+ }
3442
+
3443
+ const updated = updateLlamacppRuntimeConfig(configState.rawConfig || {}, {
3444
+ selectedCommand: command,
3445
+ manualCommand: command,
3446
+ status: "stopped"
3447
+ });
3448
+ const { savedConfig } = await writeAndBroadcastConfig(updated, {
3449
+ source: "local-models-runtime-select"
3450
+ });
3451
+ const configuredRuntime = readConfiguredLlamacppRuntime(savedConfig || {});
3452
+ sendJson(res, 200, {
3453
+ ok: true,
3454
+ runtime: buildLlamacppRuntimePayload(configuredRuntime, {
3455
+ ...validation,
3456
+ status: configuredRuntime.status || "stopped"
3457
+ })
3458
+ });
3459
+ return;
3460
+ }
3461
+
3462
+ if (method === "POST" && requestUrl.pathname === "/api/local-models/runtime/settings") {
3463
+ const body = await readJsonBody(req);
3464
+ const configState = await readConfigState(configPath);
3465
+ if (configState.parseError) {
3466
+ sendJson(res, 400, {
3467
+ error: `Config JSON must parse before updating llama.cpp runtime settings: ${configState.parseError}`
3468
+ });
3469
+ return;
3470
+ }
3471
+
3472
+ const updated = updateLlamacppRuntimeConfig(configState.rawConfig || {}, {
3473
+ ...(body?.startWithRouter !== undefined ? { startWithRouter: body.startWithRouter === true } : {})
3474
+ });
3475
+ const { savedConfig } = await writeAndBroadcastConfig(updated, {
3476
+ source: "local-models-runtime-settings"
3477
+ });
3478
+ sendJson(res, 200, {
3479
+ ok: true,
3480
+ runtime: buildLlamacppRuntimePayload(readConfiguredLlamacppRuntime(savedConfig || {}))
3481
+ });
3482
+ return;
3483
+ }
3484
+
3485
+ if (method === "POST" && requestUrl.pathname === "/api/local-models/runtime/start") {
3486
+ const configState = await readConfigState(configPath);
3487
+ if (configState.parseError) {
3488
+ sendJson(res, 400, {
3489
+ error: `Config JSON must parse before starting llama.cpp runtime: ${configState.parseError}`
3490
+ });
3491
+ return;
3492
+ }
3493
+
3494
+ const configuredRuntime = readConfiguredLlamacppRuntime(configState.rawConfig || {});
3495
+ if (!configuredRuntime.selectedCommand) {
3496
+ sendJson(res, 400, {
3497
+ error: "Select a llama.cpp runtime before starting it."
3498
+ });
3499
+ return;
3500
+ }
3501
+
3502
+ const listenerPids = await Promise.resolve(listListeningPidsFn(configuredRuntime.port)).catch(() => []);
3503
+ let validation = validateLlamacppCommandFn(configuredRuntime.selectedCommand);
3504
+ if ((listenerPids || []).length === 0) {
3505
+ const started = await startConfiguredLlamacppRuntimeFn(configState.rawConfig || {}, {
3506
+ line: (message) => addLog("info", message),
3507
+ error: (message) => addLog("warn", message)
3508
+ });
3509
+ if (!started?.ok) {
3510
+ sendJson(res, 502, {
3511
+ error: started?.errorMessage || "Failed to start llama.cpp runtime."
3512
+ });
3513
+ return;
3514
+ }
3515
+ validation = started?.validation || validation;
3516
+ }
3517
+
3518
+ const updated = updateLlamacppRuntimeConfig(configState.rawConfig || {}, {
3519
+ status: "running"
3520
+ });
3521
+ const { savedConfig } = await writeAndBroadcastConfig(updated, {
3522
+ source: "local-models-runtime-start"
3523
+ });
3524
+ sendJson(res, 200, {
3525
+ ok: true,
3526
+ runtime: buildLlamacppRuntimePayload(readConfiguredLlamacppRuntime(savedConfig || {}), {
3527
+ ...(validation && typeof validation === "object" ? validation : {}),
3528
+ status: "running"
3529
+ })
3530
+ });
3531
+ return;
3532
+ }
3533
+
3534
+ if (method === "POST" && requestUrl.pathname === "/api/local-models/runtime/stop") {
3535
+ const configState = await readConfigState(configPath);
3536
+ if (configState.parseError) {
3537
+ sendJson(res, 400, {
3538
+ error: `Config JSON must parse before stopping llama.cpp runtime: ${configState.parseError}`
3539
+ });
3540
+ return;
3541
+ }
3542
+
3543
+ const configuredRuntime = readConfiguredLlamacppRuntime(configState.rawConfig || {});
3544
+ await stopManagedLlamacppRuntimeFn({
3545
+ line: (message) => addLog("info", message),
3546
+ error: (message) => addLog("warn", message)
3547
+ });
3548
+ const listenerPids = await Promise.resolve(listListeningPidsFn(configuredRuntime.port)).catch(() => []);
3549
+ for (const pid of listenerPids) {
3550
+ if (!Number.isInteger(Number(pid))) continue;
3551
+ await stopProcessByPidFn(Number(pid)).catch(() => {});
3552
+ }
3553
+
3554
+ const updated = updateLlamacppRuntimeConfig(configState.rawConfig || {}, {
3555
+ status: "stopped"
3556
+ });
3557
+ const { savedConfig } = await writeAndBroadcastConfig(updated, {
3558
+ source: "local-models-runtime-stop"
3559
+ });
3560
+ sendJson(res, 200, {
3561
+ ok: true,
3562
+ runtime: buildLlamacppRuntimePayload(readConfiguredLlamacppRuntime(savedConfig || {}), {
3563
+ status: "stopped"
3564
+ })
3565
+ });
3566
+ return;
3567
+ }
3568
+
3569
+ if (method === "POST" && requestUrl.pathname === "/api/local-models/browse") {
3570
+ const body = await readJsonBody(req);
3571
+ const selection = String(body.selection || "file").trim() || "file";
3572
+ try {
3573
+ const picked = await browseForLocalModelPathFn({ selection });
3574
+ const matches = picked?.canceled || !picked?.path || selection === "runtime"
3575
+ ? []
3576
+ : await scanLocalModelPathFn(picked.path);
3577
+ sendJson(res, 200, {
3578
+ ok: true,
3579
+ selection: picked,
3580
+ matches
3581
+ });
3582
+ } catch (error) {
3583
+ sendJson(res, 500, {
3584
+ error: error instanceof Error ? error.message : String(error)
3585
+ });
3586
+ }
3587
+ return;
3588
+ }
3589
+
3590
+ if (method === "POST" && requestUrl.pathname === "/api/local-models/scan-path") {
3591
+ const body = await readJsonBody(req);
3592
+ const targetPath = String(body.path || "").trim();
3593
+ if (!targetPath) {
3594
+ sendJson(res, 400, { error: "path is required." });
3595
+ return;
3596
+ }
3597
+
3598
+ try {
3599
+ const matches = await scanLocalModelPathFn(targetPath);
3600
+ sendJson(res, 200, {
3601
+ ok: true,
3602
+ path: targetPath,
3603
+ matches
3604
+ });
3605
+ } catch (error) {
3606
+ sendJson(res, 400, {
3607
+ error: error instanceof Error ? error.message : String(error)
3608
+ });
3609
+ }
3610
+ return;
3611
+ }
3612
+
3613
+ if (method === "POST" && requestUrl.pathname === "/api/local-models/search-huggingface") {
3614
+ const body = await readJsonBody(req);
3615
+ const results = await searchHuggingFaceGgufCandidatesFn(String(body.query || "").trim(), {
3616
+ limit: body.limit,
3617
+ totalMemoryBytes: os.totalmem(),
3618
+ expectedContextWindow: Number.isInteger(Number(body.expectedContextWindow))
3619
+ ? Number(body.expectedContextWindow)
3620
+ : 200000
3621
+ });
3622
+ sendJson(res, 200, { results });
3623
+ return;
3624
+ }
3625
+
3626
+ if (method === "POST" && requestUrl.pathname === "/api/local-models/download-managed") {
3627
+ const body = await readJsonBody(req);
3628
+ const id = String(body.id || "").trim();
3629
+ const repo = String(body.repo || "").trim();
3630
+ const file = String(body.file || "").trim();
3631
+ if (!id || !repo || !file) {
3632
+ sendJson(res, 400, { error: "id, repo, and file are required." });
3633
+ return;
3634
+ }
3635
+
3636
+ startJsonLineStream(res);
3637
+ writeJsonLine(res, { type: "start", id, repo, file });
3638
+
3639
+ try {
3640
+ const destinationPath = path.join(getManagedLocalModelsDir(), repo, file);
3641
+ const downloaded = await downloadManagedHuggingFaceGgufFn({
3642
+ id,
3643
+ displayName: String(body.displayName || "").trim() || file,
3644
+ repo,
3645
+ file,
3646
+ destinationPath
3647
+ }, {
3648
+ onProgress: (event) => writeJsonLine(res, { type: "progress", event })
3649
+ });
3650
+ const configState = await readConfigState(configPath);
3651
+ const updated = await registerManagedLlamacppModel(configState.rawConfig || {}, {
3652
+ id,
3653
+ displayName: String(body.displayName || "").trim() || file,
3654
+ filePath: downloaded.filePath,
3655
+ repo,
3656
+ file,
3657
+ sizeBytes: downloaded.sizeBytes
3658
+ });
3659
+ const { savedConfig } = await writeAndBroadcastConfig(updated, {
3660
+ source: "local-models-download-managed"
3661
+ });
3662
+ writeJsonLine(res, {
3663
+ type: "result",
3664
+ result: {
3665
+ library: savedConfig?.metadata?.localModels?.library || {}
3666
+ }
3667
+ });
3668
+ } catch (error) {
3669
+ writeJsonLine(res, {
3670
+ type: "error",
3671
+ statusCode: Number(error?.statusCode) || 500,
3672
+ error: error instanceof Error ? error.message : String(error)
3673
+ });
3674
+ } finally {
3675
+ res.end();
3676
+ }
3677
+ return;
3678
+ }
3679
+
3680
+ if (method === "POST" && requestUrl.pathname === "/api/local-models/variants/save") {
3681
+ const body = await readJsonBody(req);
3682
+ const configState = await readConfigState(configPath);
3683
+ if (configState.parseError) {
3684
+ sendJson(res, 400, {
3685
+ error: `Config JSON must parse before saving a local variant: ${configState.parseError}`
3686
+ });
3687
+ return;
3688
+ }
3689
+
3690
+ try {
3691
+ const updated = await saveLocalModelVariant(configState.rawConfig || {}, body.variant || {}, {
3692
+ system: getLocalModelSystemInfoFn()
3693
+ });
3694
+ const { savedConfig } = await writeAndBroadcastConfig(updated, {
3695
+ source: "local-models-variant-save"
3696
+ });
3697
+ sendJson(res, 200, {
3698
+ ok: true,
3699
+ variants: savedConfig?.metadata?.localModels?.variants || {}
3700
+ });
3701
+ } catch (error) {
3702
+ sendJson(res, 400, {
3703
+ error: error instanceof Error ? error.message : String(error)
3704
+ });
3705
+ }
3706
+ return;
3707
+ }
3708
+
3134
3709
  if (method === "POST" && requestUrl.pathname === "/api/amp/apply") {
3135
3710
  const body = await readJsonBody(req);
3136
3711
  let parsed;