@tiens.nguyen/gonext-local-worker 1.0.41 → 1.0.44

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.
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
11
11
  import { execFile as execFileCallback } from "node:child_process";
12
+ import { createHash } from "node:crypto";
12
13
  import { homedir, platform, tmpdir } from "node:os";
13
14
  import { extname, join } from "node:path";
14
15
  import { promisify } from "node:util";
@@ -24,6 +25,12 @@ dotenv.config();
24
25
 
25
26
  const args = process.argv.slice(2);
26
27
 
28
+ function workerKeyFingerprint(secret) {
29
+ const v = String(secret ?? "").trim();
30
+ if (!v) return "";
31
+ return createHash("sha256").update(v, "utf8").digest("hex").slice(0, 12);
32
+ }
33
+
27
34
  function printHelp() {
28
35
  console.log(`
29
36
  gonext-local-worker
@@ -44,6 +51,7 @@ Env (optional):
44
51
  GONEXT_SIMULATE_TEXT default body for simulate-chat when no args
45
52
  GONEXT_MLX_LM_PYTHON Python executable for MLX LM native probe (default: python3)
46
53
  GONEXT_OCR_MODEL_PATH Local GLM-OCR-bf16 directory (default: ~/mlx-models/GLM-OCR-bf16)
54
+ GONEXT_OCR_DEBUG=1 Print raw OCR stdout/stderr snippets for troubleshooting
47
55
  `);
48
56
  }
49
57
 
@@ -96,16 +104,18 @@ if (args[0] === "set") {
96
104
  process.exit(0);
97
105
  }
98
106
 
99
- const apiBase = (process.env.GONEXT_API_BASE ?? "").replace(/\/+$/, "");
100
- const workerKey = process.env.GONEXT_WORKER_KEY ?? "";
107
+ const apiBase = String(process.env.GONEXT_API_BASE ?? "")
108
+ .trim()
109
+ .replace(/\/+$/, "");
110
+ const workerKey = String(process.env.GONEXT_WORKER_KEY ?? "").trim();
101
111
  const pollMs = 500;
102
112
  const localHealthConcurrency = 4;
103
113
 
104
114
  const CHUNK_PATH = "/api/worker/job-chunk";
105
- const OCR_PROMPT =
106
- "Extract all readable text from this image. Return plain text only and preserve line breaks.";
115
+ const OCR_PROMPT = "Text Recognition:";
107
116
  const OCR_MAX_TOKENS = 2048;
108
117
  const OCR_TIMEOUT_MS = 180_000;
118
+ const OCR_DEBUG = String(process.env.GONEXT_OCR_DEBUG ?? "").trim() === "1";
109
119
 
110
120
  async function workerFetch(path, init = {}) {
111
121
  const url = `${apiBase}${path.startsWith("/") ? path : `/${path}`}`;
@@ -125,13 +135,23 @@ async function ensureWorkerOk(res, context) {
125
135
  );
126
136
  }
127
137
 
128
- if (args[0] === "ws-ping-test") {
129
- if (!apiBase || !workerKey) {
138
+ function assertWorkerRuntimeConfig(mode) {
139
+ if (!workerKey) {
140
+ console.error(
141
+ `[gonext-worker] ${mode}: missing GONEXT_WORKER_KEY. Run: gonext-local-worker set <workerKey> --api-base <url>`
142
+ );
143
+ process.exit(1);
144
+ }
145
+ if (!apiBase) {
130
146
  console.error(
131
- "ws-ping-test needs GONEXT_API_BASE and GONEXT_WORKER_KEY (run: gonext-local-worker set <key> --api-base <url>)"
147
+ `[gonext-worker] ${mode}: missing GONEXT_API_BASE. Run: gonext-local-worker set <workerKey> --api-base <url>`
132
148
  );
133
149
  process.exit(1);
134
150
  }
151
+ }
152
+
153
+ if (args[0] === "ws-ping-test") {
154
+ assertWorkerRuntimeConfig("ws-ping-test");
135
155
  const url = `${apiBase}/api/worker/ws-ping-test`;
136
156
  const res = await fetch(url, {
137
157
  method: "POST",
@@ -151,17 +171,21 @@ if (args[0] === "ws-ping-test") {
151
171
  }
152
172
  process.exit(1);
153
173
  }
154
- console.log("[gonext-worker] ws-ping-test OK:", text);
174
+ try {
175
+ const parsed = JSON.parse(text);
176
+ console.log(
177
+ `[gonext-worker] ws-ping-test OK user=${parsed.userId ?? "unknown"} host=${
178
+ parsed.workerHostId ?? "legacy"
179
+ } keyfp=${parsed.workerKeyFingerprint ?? workerKeyFingerprint(workerKey)}`
180
+ );
181
+ } catch {
182
+ console.log("[gonext-worker] ws-ping-test OK:", text);
183
+ }
155
184
  process.exit(0);
156
185
  }
157
186
 
158
187
  if (args[0] === "simulate-chat") {
159
- if (!apiBase || !workerKey) {
160
- console.error(
161
- "simulate-chat needs GONEXT_API_BASE and GONEXT_WORKER_KEY (run: gonext-local-worker set <key> --api-base <url>)"
162
- );
163
- process.exit(1);
164
- }
188
+ assertWorkerRuntimeConfig("simulate-chat");
165
189
  const reply =
166
190
  args.slice(1).join(" ").trim() ||
167
191
  String(process.env.GONEXT_SIMULATE_TEXT ?? "").trim() ||
@@ -263,15 +287,12 @@ if (args[0] === "simulate-chat") {
263
287
  process.exit(0);
264
288
  }
265
289
 
266
- if (!apiBase || !workerKey) {
267
- console.error(
268
- "Set GONEXT_API_BASE (HTTP API origin, no /api suffix) and GONEXT_WORKER_KEY."
269
- );
270
- process.exit(1);
271
- }
290
+ assertWorkerRuntimeConfig("worker");
272
291
 
273
292
  console.log(
274
- `[gonext-worker] API base ${apiBase} — streaming chunks POST ${apiBase}${CHUNK_PATH}`
293
+ `[gonext-worker] API base ${apiBase} — streaming chunks POST ${apiBase}${CHUNK_PATH} — keyfp=${workerKeyFingerprint(
294
+ workerKey
295
+ )}`
275
296
  );
276
297
 
277
298
  function toOpenAIMessages(messages) {
@@ -522,10 +543,18 @@ function resolveImageExtension(mimeType, fileName) {
522
543
  return ext || ".png";
523
544
  }
524
545
 
525
- function resolveOcrModelPath() {
526
- const raw = String(process.env.GONEXT_OCR_MODEL_PATH ?? "").trim();
527
- if (raw) {
528
- return raw.replace(/^~(?=\/)/, homedir());
546
+ function resolveOcrModelPath(modelFromJob) {
547
+ const fromJob = String(modelFromJob ?? "").trim();
548
+ if (fromJob) {
549
+ const expanded = fromJob.replace(/^~(?=\/)/, homedir());
550
+ if (expanded.startsWith("/")) {
551
+ return expanded;
552
+ }
553
+ return join(homedir(), "mlx-models", expanded);
554
+ }
555
+ const fromEnv = String(process.env.GONEXT_OCR_MODEL_PATH ?? "").trim();
556
+ if (fromEnv) {
557
+ return fromEnv.replace(/^~(?=\/)/, homedir());
529
558
  }
530
559
  return join(homedir(), "mlx-models", "GLM-OCR-bf16");
531
560
  }
@@ -581,7 +610,7 @@ async function runMlxVlmGenerate(modelPath, imagePath) {
581
610
  String(OCR_MAX_TOKENS),
582
611
  ];
583
612
  try {
584
- return await execFile(
613
+ const result = await execFile(
585
614
  "python3",
586
615
  ["-m", "mlx_vlm.generate", ...sharedArgs],
587
616
  {
@@ -589,6 +618,7 @@ async function runMlxVlmGenerate(modelPath, imagePath) {
589
618
  maxBuffer: 10 * 1024 * 1024,
590
619
  }
591
620
  );
621
+ return { ...result, invocation: "python3 -m mlx_vlm.generate" };
592
622
  } catch (primaryError) {
593
623
  const stderr =
594
624
  primaryError && typeof primaryError === "object" && "stderr" in primaryError
@@ -602,7 +632,7 @@ async function runMlxVlmGenerate(modelPath, imagePath) {
602
632
  if (!missingLegacyModule) {
603
633
  throw primaryError;
604
634
  }
605
- return execFile(
635
+ const result = await execFile(
606
636
  "python3",
607
637
  ["-m", "mlx_vlm", "generate", ...sharedArgs],
608
638
  {
@@ -610,9 +640,24 @@ async function runMlxVlmGenerate(modelPath, imagePath) {
610
640
  maxBuffer: 10 * 1024 * 1024,
611
641
  }
612
642
  );
643
+ return { ...result, invocation: "python3 -m mlx_vlm generate" };
613
644
  }
614
645
  }
615
646
 
647
+ function compactPreview(text, max = 800) {
648
+ const normalized = String(text ?? "")
649
+ .replace(/\r\n/g, "\n")
650
+ .replace(/\s+/g, " ")
651
+ .trim();
652
+ if (!normalized) {
653
+ return "(empty)";
654
+ }
655
+ if (normalized.length <= max) {
656
+ return normalized;
657
+ }
658
+ return `${normalized.slice(0, max)} …[truncated ${normalized.length - max} chars]`;
659
+ }
660
+
616
661
  async function runOcrJob(job) {
617
662
  const { jobId, payload } = job;
618
663
  const start = Date.now();
@@ -628,17 +673,41 @@ async function runOcrJob(job) {
628
673
  const attachment = payload.attachment;
629
674
  const mimeType = typeof attachment?.mimeType === "string" ? attachment.mimeType : "";
630
675
  const data = typeof attachment?.data === "string" ? attachment.data : "";
676
+ const s3Object =
677
+ attachment && typeof attachment === "object" ? attachment.s3Object : undefined;
678
+ const s3GetUrl =
679
+ s3Object && typeof s3Object.getUrl === "string" ? s3Object.getUrl.trim() : "";
680
+ const sourceLabel = s3GetUrl
681
+ ? `s3://${s3Object?.bucket ?? "unknown"}/${s3Object?.key ?? "unknown"}`
682
+ : "inline_base64";
683
+ const ocrModel =
684
+ typeof payload.model === "string" && payload.model.trim()
685
+ ? payload.model.trim()
686
+ : "GLM-OCR-bf16";
631
687
  const name = typeof attachment?.name === "string" ? attachment.name : "";
632
688
  if (!mimeType.startsWith("image/")) {
633
689
  throw new Error("OCR job attachment must be an image.");
634
690
  }
635
- if (!data) {
691
+ if (!data && !s3GetUrl) {
636
692
  throw new Error("OCR job attachment is empty.");
637
693
  }
638
- const bytes = Buffer.from(data, "base64");
694
+ let bytes;
695
+ if (s3GetUrl) {
696
+ const dlRes = await fetch(s3GetUrl, { method: "GET" });
697
+ if (!dlRes.ok) {
698
+ throw new Error(
699
+ `Failed to download OCR image from S3 (${dlRes.status}).`
700
+ );
701
+ }
702
+ const arr = await dlRes.arrayBuffer();
703
+ bytes = Buffer.from(arr);
704
+ } else {
705
+ bytes = Buffer.from(data, "base64");
706
+ }
639
707
  if (!bytes.length) {
640
- throw new Error("OCR job attachment data is not valid base64.");
708
+ throw new Error("OCR job attachment data is not valid.");
641
709
  }
710
+ console.log(`[gonext-worker] OCR source jobId=${jobId} ${sourceLabel}`);
642
711
 
643
712
  const tempDir = await mkdtemp(join(tmpdir(), "gonext-ocr-worker-"));
644
713
  let extractedText = "";
@@ -648,16 +717,27 @@ async function runOcrJob(job) {
648
717
  `input${resolveImageExtension(mimeType, name)}`
649
718
  );
650
719
  await writeFile(imagePath, bytes);
651
- const modelPath = resolveOcrModelPath();
652
- const { stdout } = await runMlxVlmGenerate(modelPath, imagePath);
720
+ const modelPath = resolveOcrModelPath(ocrModel);
721
+ const { stdout, stderr, invocation } = await runMlxVlmGenerate(
722
+ modelPath,
723
+ imagePath
724
+ );
725
+ if (OCR_DEBUG) {
726
+ console.log(
727
+ `[gonext-worker][ocr-debug] jobId=${jobId} invocation=${invocation} model=${modelPath} image=${imagePath} source=${
728
+ sourceLabel
729
+ } stdout=${compactPreview(stdout)} stderr=${compactPreview(stderr)}`
730
+ );
731
+ }
653
732
  extractedText = normalizeOcrOutput(stdout);
733
+ if (!extractedText) {
734
+ throw new Error(
735
+ `OCR returned empty text for this image. invocation=${invocation} stdout=${compactPreview(stdout)} stderr=${compactPreview(stderr)}`
736
+ );
737
+ }
654
738
  } finally {
655
739
  await rm(tempDir, { recursive: true, force: true }).catch(() => {});
656
740
  }
657
-
658
- if (!extractedText) {
659
- throw new Error("OCR returned empty text for this image.");
660
- }
661
741
  const totalTimeSeconds = (Date.now() - start) / 1000;
662
742
  const doneRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
663
743
  method: "PATCH",
@@ -670,7 +750,7 @@ async function runOcrJob(job) {
670
750
  });
671
751
  await ensureWorkerOk(doneRes, `complete OCR jobId=${jobId}`);
672
752
  console.log(
673
- `[gonext-worker] completed OCR ${jobId} (${totalTimeSeconds.toFixed(1)}s)`
753
+ `[gonext-worker] completed OCR ${jobId} (${totalTimeSeconds.toFixed(1)}s) model=${ocrModel}`
674
754
  );
675
755
  } catch (e) {
676
756
  const message = e instanceof Error ? e.message : String(e);
@@ -742,20 +822,30 @@ async function checkOllamaTags(base) {
742
822
  }
743
823
  }
744
824
 
745
- async function checkOpenAiModels(base, apiKey) {
825
+ async function checkOpenAiModels(base, apiKey, source) {
746
826
  const endpoint = `${base}/models`;
747
827
  const headers = {};
748
828
  if (apiKey?.trim()) {
749
829
  headers.Authorization = `Bearer ${apiKey.trim()}`;
750
830
  }
831
+ const hostSourceId =
832
+ typeof source?.workerHostId === "string" ? source.workerHostId.trim() : "";
833
+ const hostSourceLabel =
834
+ typeof source?.hostName === "string" ? source.hostName.trim() : "";
751
835
  try {
752
836
  const res = await fetch(endpoint, { method: "GET", headers });
753
837
  if (!res.ok) return { online: false, endpoint, models: [] };
754
838
  const j = await res.json();
839
+ const modelSuffix = hostSourceId ? `@@host:${hostSourceId}` : "";
840
+ const displaySuffix = hostSourceLabel ? ` (${hostSourceLabel})` : "";
755
841
  const models = (j.data ?? [])
756
842
  .map((d) => d.id)
757
843
  .filter(Boolean)
758
- .map((id) => ({ id, name: id, value: `mlx:${id}` }));
844
+ .map((id) => ({
845
+ id: hostSourceId ? `${id}@@${hostSourceId}` : id,
846
+ name: `${id}${displaySuffix}`,
847
+ value: `mlx:${id}${modelSuffix}`,
848
+ }));
759
849
  return { online: true, endpoint, models };
760
850
  } catch {
761
851
  return { online: false, endpoint, models: [] };
@@ -848,13 +938,24 @@ async function runLocalHealthJob(job) {
848
938
  }
849
939
  }
850
940
  const mlxRoot = normalizeOpenAiV1Root(payload?.mlxOpenAiBaseUrl);
941
+ const targetWorkerHostId =
942
+ typeof payload?.targetWorkerHostId === "string"
943
+ ? payload.targetWorkerHostId.trim()
944
+ : "";
945
+ const targetWorkerHostName =
946
+ typeof payload?.targetWorkerHostName === "string"
947
+ ? payload.targetWorkerHostName.trim()
948
+ : "";
851
949
  let mlxHttp = null;
852
950
  let mlxNative = null;
853
951
 
854
952
  if (mlxRoot) {
855
953
  const mlxStart = Date.now();
856
954
  console.log(`[gonext-worker] local_health ${jobId} check mlx HTTP ${mlxRoot}`);
857
- mlxHttp = await checkOpenAiModels(mlxRoot, payload?.mlxApiKey ?? "");
955
+ mlxHttp = await checkOpenAiModels(mlxRoot, payload?.mlxApiKey ?? "", {
956
+ workerHostId: targetWorkerHostId,
957
+ hostName: targetWorkerHostName || sourceLabelFromBase(mlxRoot),
958
+ });
858
959
  console.log(
859
960
  `[gonext-worker] local_health ${jobId} mlx HTTP online=${mlxHttp.online} models=${mlxHttp.models.length} took=${((Date.now() - mlxStart) / 1000).toFixed(2)}s`
860
961
  );
@@ -881,6 +982,16 @@ async function runLocalHealthJob(job) {
881
982
  if (mlxRoot || mlxNative?.available) {
882
983
  const httpOk = Boolean(mlxHttp?.online && (mlxHttp?.models?.length ?? 0) > 0);
883
984
  const nativeOk = mlxNative?.available === true;
985
+ const hostName = targetWorkerHostName || sourceLabelFromBase(mlxRoot || "mlx");
986
+ const sources = [
987
+ {
988
+ workerHostId: targetWorkerHostId || undefined,
989
+ hostName,
990
+ endpoint: mlxHttp?.endpoint,
991
+ online: httpOk || nativeOk,
992
+ modelCount: httpOk ? mlxHttp.models.length : nativeOk ? 1 : 0,
993
+ },
994
+ ];
884
995
  mlx = {
885
996
  configured: httpOk || nativeOk,
886
997
  online: httpOk || nativeOk,
@@ -906,6 +1017,7 @@ async function runLocalHealthJob(job) {
906
1017
  }
907
1018
  : undefined,
908
1019
  native: mlxNative ?? undefined,
1020
+ sources,
909
1021
  };
910
1022
  }
911
1023
  const result = {
@@ -931,6 +1043,12 @@ async function runLocalHealthJob(job) {
931
1043
  totalTimeSeconds,
932
1044
  }),
933
1045
  });
1046
+ if (doneRes.status === 404) {
1047
+ console.warn(
1048
+ `[gonext-worker] local_health ${jobId} disappeared before completion PATCH (404 Job not found). Skipping.`
1049
+ );
1050
+ return;
1051
+ }
934
1052
  await ensureWorkerOk(doneRes, `complete local_health jobId=${jobId}`);
935
1053
  const onlineCount = ollamaSources.filter((s) => s.online).length;
936
1054
  console.log(
@@ -938,6 +1056,12 @@ async function runLocalHealthJob(job) {
938
1056
  );
939
1057
  } catch (e) {
940
1058
  const message = e instanceof Error ? e.message : String(e);
1059
+ if (/failed 404/i.test(message) || /job not found/i.test(message)) {
1060
+ console.warn(
1061
+ `[gonext-worker] local_health ${jobId} no longer exists (404). Skipping fail PATCH.`
1062
+ );
1063
+ return;
1064
+ }
941
1065
  const failRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
942
1066
  method: "PATCH",
943
1067
  body: JSON.stringify({
@@ -1002,7 +1126,8 @@ async function pollOnce() {
1002
1126
  job.payload &&
1003
1127
  typeof job.payload === "object" &&
1004
1128
  typeof job.payload.ocrId === "string" &&
1005
- typeof job.payload.attachment?.data === "string";
1129
+ (typeof job.payload.attachment?.data === "string" ||
1130
+ typeof job.payload.attachment?.s3Object?.getUrl === "string");
1006
1131
  if (isOcrByType || isOcrByPayload) {
1007
1132
  await runOcrJob(job);
1008
1133
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.41",
3
+ "version": "1.0.44",
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",