@tiens.nguyen/gonext-local-worker 1.0.45 → 1.0.47

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.
@@ -7,7 +7,7 @@
7
7
  * - `gonext-local-worker simulate-chat [text]` — claim next chat job, push fake reply like the real worker (needs GONEXT_* env)
8
8
  * - `gonext-local-worker` — starts polling loop (claims jobs and runs models)
9
9
  */
10
- import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
10
+ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
11
11
  import { execFile as execFileCallback } from "node:child_process";
12
12
  import { createHash } from "node:crypto";
13
13
  import { homedir, platform, tmpdir } from "node:os";
@@ -814,6 +814,26 @@ function sourceLabelFromBase(base) {
814
814
  }
815
815
  }
816
816
 
817
+ function expandHomePath(rawPath) {
818
+ const v = String(rawPath ?? "").trim();
819
+ if (!v) return "";
820
+ return v.replace(/^~(?=\/)/, homedir());
821
+ }
822
+
823
+ async function checkModelPathExists(rawPath) {
824
+ const inputPath = String(rawPath ?? "").trim();
825
+ const resolvedPath = expandHomePath(inputPath);
826
+ if (!resolvedPath) {
827
+ return { path: inputPath, resolvedPath: "", exists: false };
828
+ }
829
+ try {
830
+ const st = await stat(resolvedPath);
831
+ return { path: inputPath, resolvedPath, exists: st.isDirectory() || st.isFile() };
832
+ } catch {
833
+ return { path: inputPath, resolvedPath, exists: false };
834
+ }
835
+ }
836
+
817
837
  /** Log assistant text to stdout; cap size so huge replies do not flood the terminal. */
818
838
  function logModelResponseToWorker(jobId, modelId, text) {
819
839
  const max = 12000;
@@ -855,20 +875,26 @@ async function checkOpenAiModels(base, apiKey, source) {
855
875
  typeof source?.workerHostId === "string" ? source.workerHostId.trim() : "";
856
876
  const hostSourceLabel =
857
877
  typeof source?.hostName === "string" ? source.hostName.trim() : "";
878
+ const sourcePort = typeof source?.port === "number" ? source.port : null;
858
879
  try {
859
880
  const res = await fetch(endpoint, { method: "GET", headers });
860
881
  if (!res.ok) return { online: false, endpoint, models: [] };
861
882
  const j = await res.json();
862
- const modelSuffix = hostSourceId ? `@@host:${hostSourceId}` : "";
863
- const displaySuffix = hostSourceLabel ? ` (${hostSourceLabel})` : "";
864
- const models = (j.data ?? [])
865
- .map((d) => d.id)
866
- .filter(Boolean)
867
- .map((id) => ({
868
- id: hostSourceId ? `${id}@@${hostSourceId}` : id,
869
- name: `${id}${displaySuffix}`,
870
- value: `mlx:${id}${modelSuffix}`,
871
- }));
883
+ const hostSuffix = hostSourceId ? `@@host:${hostSourceId}` : "";
884
+ const portSuffix = sourcePort ? `@@port:${sourcePort}` : "";
885
+ const modelSuffix = `${hostSuffix}${portSuffix}`;
886
+ const portLabel = sourcePort ? `:${sourcePort}` : "";
887
+ const displaySuffix = hostSourceLabel ? ` (${hostSourceLabel}${portLabel})` : portLabel ? ` (${portLabel})` : "";
888
+ let rawIds = (j.data ?? []).map((d) => d.id).filter(Boolean);
889
+ // mlx_lm.server registers both the local path and the HF model ID.
890
+ // Prefer local absolute paths so each server shows exactly one model entry.
891
+ const localPaths = rawIds.filter((id) => id.startsWith("/") || id.startsWith("~"));
892
+ if (localPaths.length > 0) rawIds = localPaths;
893
+ const models = rawIds.map((id) => ({
894
+ id: hostSourceId ? `${id}@@${hostSourceId}` : id,
895
+ name: `${id}${displaySuffix}`,
896
+ value: `mlx:${id}${modelSuffix}`,
897
+ }));
872
898
  return { online: true, endpoint, models };
873
899
  } catch {
874
900
  return { online: false, endpoint, models: [] };
@@ -925,8 +951,11 @@ async function runLocalHealthJob(job) {
925
951
  const ollamaPayloadCount = Array.isArray(payload?.ollamaBaseUrls)
926
952
  ? payload.ollamaBaseUrls.length
927
953
  : 0;
954
+ const mlxUrlCount = Array.isArray(payload?.mlxOpenAiBaseUrls)
955
+ ? payload.mlxOpenAiBaseUrls.length
956
+ : payload?.mlxOpenAiBaseUrl ? 1 : 0;
928
957
  console.log(
929
- `[gonext-worker] local_health ${jobId} start (ollamaUrls=${ollamaPayloadCount}, mlx=${payload?.mlxOpenAiBaseUrl ? "yes" : "no"})`
958
+ `[gonext-worker] local_health ${jobId} start (ollamaUrls=${ollamaPayloadCount}, mlxUrls=${mlxUrlCount})`
930
959
  );
931
960
  const runRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
932
961
  method: "PATCH",
@@ -960,7 +989,13 @@ async function runLocalHealthJob(job) {
960
989
  if (!dedup.has(m.value)) dedup.set(m.value, m);
961
990
  }
962
991
  }
963
- const mlxRoot = normalizeOpenAiV1Root(payload?.mlxOpenAiBaseUrl);
992
+ // Support both single mlxOpenAiBaseUrl and multi-port mlxOpenAiBaseUrls
993
+ const mlxRootSingle = normalizeOpenAiV1Root(payload?.mlxOpenAiBaseUrl);
994
+ const mlxRootsMulti = Array.isArray(payload?.mlxOpenAiBaseUrls)
995
+ ? payload.mlxOpenAiBaseUrls.map(normalizeOpenAiV1Root).filter(Boolean)
996
+ : [];
997
+ const mlxRoots = mlxRootsMulti.length > 0 ? mlxRootsMulti : (mlxRootSingle ? [mlxRootSingle] : []);
998
+ const mlxRoot = mlxRoots[0] || null;
964
999
  const targetWorkerHostId =
965
1000
  typeof payload?.targetWorkerHostId === "string"
966
1001
  ? payload.targetWorkerHostId.trim()
@@ -969,20 +1004,52 @@ async function runLocalHealthJob(job) {
969
1004
  typeof payload?.targetWorkerHostName === "string"
970
1005
  ? payload.targetWorkerHostName.trim()
971
1006
  : "";
972
- let mlxHttp = null;
973
1007
  let mlxNative = null;
1008
+ const modelPathChecksRaw = Array.isArray(payload?.modelPathChecks)
1009
+ ? payload.modelPathChecks
1010
+ : [];
1011
+ const modelPathChecks = [...new Set(modelPathChecksRaw)]
1012
+ .filter((v) => typeof v === "string" && v.trim())
1013
+ .map((v) => String(v).trim())
1014
+ .slice(0, 8);
1015
+ const modelPathStatus = [];
974
1016
 
975
- if (mlxRoot) {
1017
+ // Collect results from all MLX endpoints (one per port)
1018
+ const mlxHttpResults = [];
1019
+ for (const root of mlxRoots) {
1020
+ // Extract port from URL for model value encoding
1021
+ let port = null;
1022
+ try {
1023
+ const u = new URL(root.replace(/\/v1\/?$/i, ""));
1024
+ const p = parseInt(u.port, 10);
1025
+ if (p > 0) port = p;
1026
+ } catch { /* ignore */ }
976
1027
  const mlxStart = Date.now();
977
- console.log(`[gonext-worker] local_health ${jobId} check mlx HTTP ${mlxRoot}`);
978
- mlxHttp = await checkOpenAiModels(mlxRoot, payload?.mlxApiKey ?? "", {
1028
+ console.log(`[gonext-worker] local_health ${jobId} check mlx HTTP ${root} port=${port ?? "default"}`);
1029
+ const result = await checkOpenAiModels(root, payload?.mlxApiKey ?? "", {
979
1030
  workerHostId: targetWorkerHostId,
980
- hostName: targetWorkerHostName || sourceLabelFromBase(mlxRoot),
1031
+ hostName: targetWorkerHostName || sourceLabelFromBase(root),
1032
+ port,
981
1033
  });
982
1034
  console.log(
983
- `[gonext-worker] local_health ${jobId} mlx HTTP online=${mlxHttp.online} models=${mlxHttp.models.length} took=${((Date.now() - mlxStart) / 1000).toFixed(2)}s`
1035
+ `[gonext-worker] local_health ${jobId} mlx HTTP online=${result.online} models=${result.models.length} took=${((Date.now() - mlxStart) / 1000).toFixed(2)}s`
984
1036
  );
1037
+ mlxHttpResults.push(result);
985
1038
  }
1039
+ // Merge all per-port results
1040
+ const mlxModelDedup = new Map();
1041
+ let mlxHttpOnline = false;
1042
+ for (const r of mlxHttpResults) {
1043
+ if (r.online) mlxHttpOnline = true;
1044
+ for (const m of r.models) {
1045
+ if (!mlxModelDedup.has(m.value)) mlxModelDedup.set(m.value, m);
1046
+ }
1047
+ }
1048
+ const mlxHttp = mlxRoots.length > 0 ? {
1049
+ online: mlxHttpOnline,
1050
+ endpoint: mlxHttpResults[0]?.endpoint,
1051
+ models: [...mlxModelDedup.values()],
1052
+ } : null;
986
1053
 
987
1054
  const wantNativeFallback =
988
1055
  mlxRoot &&
@@ -1001,6 +1068,11 @@ async function runLocalHealthJob(job) {
1001
1068
  );
1002
1069
  }
1003
1070
 
1071
+ for (const rawPath of modelPathChecks) {
1072
+ const check = await checkModelPathExists(rawPath);
1073
+ modelPathStatus.push(check);
1074
+ }
1075
+
1004
1076
  let mlx = null;
1005
1077
  if (mlxRoot || mlxNative?.available) {
1006
1078
  const httpOk = Boolean(mlxHttp?.online && (mlxHttp?.models?.length ?? 0) > 0);
@@ -1055,6 +1127,12 @@ async function runLocalHealthJob(job) {
1055
1127
  }
1056
1128
  : undefined,
1057
1129
  mlx,
1130
+ setup:
1131
+ modelPathStatus.length > 0
1132
+ ? {
1133
+ modelPaths: modelPathStatus,
1134
+ }
1135
+ : undefined,
1058
1136
  };
1059
1137
  const totalTimeSeconds = (Date.now() - start) / 1000;
1060
1138
  const doneRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
@@ -1133,7 +1211,9 @@ async function pollOnce() {
1133
1211
  const isLocalHealthByType = job.jobType === "local_health";
1134
1212
  const isLocalHealthByModelKey = job.modelKey === "local_health";
1135
1213
  const isLocalHealthByPayload =
1136
- Array.isArray(job.payload?.ollamaBaseUrls) || !!job.payload?.mlxOpenAiBaseUrl;
1214
+ Array.isArray(job.payload?.ollamaBaseUrls) ||
1215
+ !!job.payload?.mlxOpenAiBaseUrl ||
1216
+ Array.isArray(job.payload?.mlxOpenAiBaseUrls);
1137
1217
  if (isLocalHealthByType || isLocalHealthByModelKey || isLocalHealthByPayload) {
1138
1218
  const task = runLocalHealthJob(job).catch((e) => {
1139
1219
  console.error("[gonext-worker] local_health task error:", e);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.45",
3
+ "version": "1.0.47",
4
4
  "description": "Polls GoNext cloud API for async local LLM jobs and runs them against Ollama/OpenAI-compatible servers on this Mac",
5
5
  "type": "module",
6
6
  "license": "MIT",