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

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";
@@ -19,11 +20,25 @@ import OpenAI from "openai";
19
20
  const execFile = promisify(execFileCallback);
20
21
 
21
22
  const ENV_FILE = join(homedir(), ".gonext", "worker.env");
22
- dotenv.config({ path: ENV_FILE });
23
+ const preloadedWorkerKey = String(process.env.GONEXT_WORKER_KEY ?? "").trim();
24
+ const envFileLoad = dotenv.config({ path: ENV_FILE });
23
25
  dotenv.config();
24
26
 
25
27
  const args = process.argv.slice(2);
26
28
 
29
+ function workerKeyFingerprint(secret) {
30
+ const v = String(secret ?? "").trim();
31
+ if (!v) return "";
32
+ return createHash("sha256").update(v, "utf8").digest("hex").slice(0, 12);
33
+ }
34
+
35
+ function maskWorkerKey(secret) {
36
+ const v = String(secret ?? "").trim();
37
+ if (!v) return "(empty)";
38
+ if (v.length <= 12) return `${v.slice(0, 3)}***`;
39
+ return `${v.slice(0, 8)}...${v.slice(-4)}`;
40
+ }
41
+
27
42
  function printHelp() {
28
43
  console.log(`
29
44
  gonext-local-worker
@@ -44,6 +59,7 @@ Env (optional):
44
59
  GONEXT_SIMULATE_TEXT default body for simulate-chat when no args
45
60
  GONEXT_MLX_LM_PYTHON Python executable for MLX LM native probe (default: python3)
46
61
  GONEXT_OCR_MODEL_PATH Local GLM-OCR-bf16 directory (default: ~/mlx-models/GLM-OCR-bf16)
62
+ GONEXT_OCR_DEBUG=1 Print raw OCR stdout/stderr snippets for troubleshooting
47
63
  `);
48
64
  }
49
65
 
@@ -96,16 +112,28 @@ if (args[0] === "set") {
96
112
  process.exit(0);
97
113
  }
98
114
 
99
- const apiBase = (process.env.GONEXT_API_BASE ?? "").replace(/\/+$/, "");
100
- const workerKey = process.env.GONEXT_WORKER_KEY ?? "";
115
+ const apiBase = String(process.env.GONEXT_API_BASE ?? "")
116
+ .trim()
117
+ .replace(/\/+$/, "");
118
+ const workerKey = String(process.env.GONEXT_WORKER_KEY ?? "").trim();
119
+ const envFileWorkerKey = String(envFileLoad.parsed?.GONEXT_WORKER_KEY ?? "").trim();
120
+ const workerKeySource = (() => {
121
+ if (preloadedWorkerKey && workerKey === preloadedWorkerKey) {
122
+ return "process.env";
123
+ }
124
+ if (envFileWorkerKey && workerKey === envFileWorkerKey) {
125
+ return ENV_FILE;
126
+ }
127
+ return "unknown";
128
+ })();
101
129
  const pollMs = 500;
102
130
  const localHealthConcurrency = 4;
103
131
 
104
132
  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.";
133
+ const OCR_PROMPT = "Text Recognition:";
107
134
  const OCR_MAX_TOKENS = 2048;
108
135
  const OCR_TIMEOUT_MS = 180_000;
136
+ const OCR_DEBUG = String(process.env.GONEXT_OCR_DEBUG ?? "").trim() === "1";
109
137
 
110
138
  async function workerFetch(path, init = {}) {
111
139
  const url = `${apiBase}${path.startsWith("/") ? path : `/${path}`}`;
@@ -125,13 +153,23 @@ async function ensureWorkerOk(res, context) {
125
153
  );
126
154
  }
127
155
 
128
- if (args[0] === "ws-ping-test") {
129
- if (!apiBase || !workerKey) {
156
+ function assertWorkerRuntimeConfig(mode) {
157
+ if (!workerKey) {
158
+ console.error(
159
+ `[gonext-worker] ${mode}: missing GONEXT_WORKER_KEY. Run: gonext-local-worker set <workerKey> --api-base <url>`
160
+ );
161
+ process.exit(1);
162
+ }
163
+ if (!apiBase) {
130
164
  console.error(
131
- "ws-ping-test needs GONEXT_API_BASE and GONEXT_WORKER_KEY (run: gonext-local-worker set <key> --api-base <url>)"
165
+ `[gonext-worker] ${mode}: missing GONEXT_API_BASE. Run: gonext-local-worker set <workerKey> --api-base <url>`
132
166
  );
133
167
  process.exit(1);
134
168
  }
169
+ }
170
+
171
+ if (args[0] === "ws-ping-test") {
172
+ assertWorkerRuntimeConfig("ws-ping-test");
135
173
  const url = `${apiBase}/api/worker/ws-ping-test`;
136
174
  const res = await fetch(url, {
137
175
  method: "POST",
@@ -151,17 +189,21 @@ if (args[0] === "ws-ping-test") {
151
189
  }
152
190
  process.exit(1);
153
191
  }
154
- console.log("[gonext-worker] ws-ping-test OK:", text);
192
+ try {
193
+ const parsed = JSON.parse(text);
194
+ console.log(
195
+ `[gonext-worker] ws-ping-test OK user=${parsed.userId ?? "unknown"} host=${
196
+ parsed.workerHostId ?? "legacy"
197
+ } keyfp=${parsed.workerKeyFingerprint ?? workerKeyFingerprint(workerKey)}`
198
+ );
199
+ } catch {
200
+ console.log("[gonext-worker] ws-ping-test OK:", text);
201
+ }
155
202
  process.exit(0);
156
203
  }
157
204
 
158
205
  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
- }
206
+ assertWorkerRuntimeConfig("simulate-chat");
165
207
  const reply =
166
208
  args.slice(1).join(" ").trim() ||
167
209
  String(process.env.GONEXT_SIMULATE_TEXT ?? "").trim() ||
@@ -263,15 +305,17 @@ if (args[0] === "simulate-chat") {
263
305
  process.exit(0);
264
306
  }
265
307
 
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
- }
308
+ assertWorkerRuntimeConfig("worker");
272
309
 
273
310
  console.log(
274
- `[gonext-worker] API base ${apiBase} — streaming chunks POST ${apiBase}${CHUNK_PATH}`
311
+ `[gonext-worker] API base ${apiBase} — streaming chunks POST ${apiBase}${CHUNK_PATH} — keyfp=${workerKeyFingerprint(
312
+ workerKey
313
+ )}`
314
+ );
315
+ console.log(
316
+ `[gonext-worker] worker key ${maskWorkerKey(workerKey)} (keyfp=${workerKeyFingerprint(
317
+ workerKey
318
+ )}, source=${workerKeySource})`
275
319
  );
276
320
 
277
321
  function toOpenAIMessages(messages) {
@@ -522,10 +566,18 @@ function resolveImageExtension(mimeType, fileName) {
522
566
  return ext || ".png";
523
567
  }
524
568
 
525
- function resolveOcrModelPath() {
526
- const raw = String(process.env.GONEXT_OCR_MODEL_PATH ?? "").trim();
527
- if (raw) {
528
- return raw.replace(/^~(?=\/)/, homedir());
569
+ function resolveOcrModelPath(modelFromJob) {
570
+ const fromJob = String(modelFromJob ?? "").trim();
571
+ if (fromJob) {
572
+ const expanded = fromJob.replace(/^~(?=\/)/, homedir());
573
+ if (expanded.startsWith("/")) {
574
+ return expanded;
575
+ }
576
+ return join(homedir(), "mlx-models", expanded);
577
+ }
578
+ const fromEnv = String(process.env.GONEXT_OCR_MODEL_PATH ?? "").trim();
579
+ if (fromEnv) {
580
+ return fromEnv.replace(/^~(?=\/)/, homedir());
529
581
  }
530
582
  return join(homedir(), "mlx-models", "GLM-OCR-bf16");
531
583
  }
@@ -581,7 +633,7 @@ async function runMlxVlmGenerate(modelPath, imagePath) {
581
633
  String(OCR_MAX_TOKENS),
582
634
  ];
583
635
  try {
584
- return await execFile(
636
+ const result = await execFile(
585
637
  "python3",
586
638
  ["-m", "mlx_vlm.generate", ...sharedArgs],
587
639
  {
@@ -589,6 +641,7 @@ async function runMlxVlmGenerate(modelPath, imagePath) {
589
641
  maxBuffer: 10 * 1024 * 1024,
590
642
  }
591
643
  );
644
+ return { ...result, invocation: "python3 -m mlx_vlm.generate" };
592
645
  } catch (primaryError) {
593
646
  const stderr =
594
647
  primaryError && typeof primaryError === "object" && "stderr" in primaryError
@@ -602,7 +655,7 @@ async function runMlxVlmGenerate(modelPath, imagePath) {
602
655
  if (!missingLegacyModule) {
603
656
  throw primaryError;
604
657
  }
605
- return execFile(
658
+ const result = await execFile(
606
659
  "python3",
607
660
  ["-m", "mlx_vlm", "generate", ...sharedArgs],
608
661
  {
@@ -610,9 +663,24 @@ async function runMlxVlmGenerate(modelPath, imagePath) {
610
663
  maxBuffer: 10 * 1024 * 1024,
611
664
  }
612
665
  );
666
+ return { ...result, invocation: "python3 -m mlx_vlm generate" };
613
667
  }
614
668
  }
615
669
 
670
+ function compactPreview(text, max = 800) {
671
+ const normalized = String(text ?? "")
672
+ .replace(/\r\n/g, "\n")
673
+ .replace(/\s+/g, " ")
674
+ .trim();
675
+ if (!normalized) {
676
+ return "(empty)";
677
+ }
678
+ if (normalized.length <= max) {
679
+ return normalized;
680
+ }
681
+ return `${normalized.slice(0, max)} …[truncated ${normalized.length - max} chars]`;
682
+ }
683
+
616
684
  async function runOcrJob(job) {
617
685
  const { jobId, payload } = job;
618
686
  const start = Date.now();
@@ -628,17 +696,41 @@ async function runOcrJob(job) {
628
696
  const attachment = payload.attachment;
629
697
  const mimeType = typeof attachment?.mimeType === "string" ? attachment.mimeType : "";
630
698
  const data = typeof attachment?.data === "string" ? attachment.data : "";
699
+ const s3Object =
700
+ attachment && typeof attachment === "object" ? attachment.s3Object : undefined;
701
+ const s3GetUrl =
702
+ s3Object && typeof s3Object.getUrl === "string" ? s3Object.getUrl.trim() : "";
703
+ const sourceLabel = s3GetUrl
704
+ ? `s3://${s3Object?.bucket ?? "unknown"}/${s3Object?.key ?? "unknown"}`
705
+ : "inline_base64";
706
+ const ocrModel =
707
+ typeof payload.model === "string" && payload.model.trim()
708
+ ? payload.model.trim()
709
+ : "GLM-OCR-bf16";
631
710
  const name = typeof attachment?.name === "string" ? attachment.name : "";
632
711
  if (!mimeType.startsWith("image/")) {
633
712
  throw new Error("OCR job attachment must be an image.");
634
713
  }
635
- if (!data) {
714
+ if (!data && !s3GetUrl) {
636
715
  throw new Error("OCR job attachment is empty.");
637
716
  }
638
- const bytes = Buffer.from(data, "base64");
717
+ let bytes;
718
+ if (s3GetUrl) {
719
+ const dlRes = await fetch(s3GetUrl, { method: "GET" });
720
+ if (!dlRes.ok) {
721
+ throw new Error(
722
+ `Failed to download OCR image from S3 (${dlRes.status}).`
723
+ );
724
+ }
725
+ const arr = await dlRes.arrayBuffer();
726
+ bytes = Buffer.from(arr);
727
+ } else {
728
+ bytes = Buffer.from(data, "base64");
729
+ }
639
730
  if (!bytes.length) {
640
- throw new Error("OCR job attachment data is not valid base64.");
731
+ throw new Error("OCR job attachment data is not valid.");
641
732
  }
733
+ console.log(`[gonext-worker] OCR source jobId=${jobId} ${sourceLabel}`);
642
734
 
643
735
  const tempDir = await mkdtemp(join(tmpdir(), "gonext-ocr-worker-"));
644
736
  let extractedText = "";
@@ -648,16 +740,27 @@ async function runOcrJob(job) {
648
740
  `input${resolveImageExtension(mimeType, name)}`
649
741
  );
650
742
  await writeFile(imagePath, bytes);
651
- const modelPath = resolveOcrModelPath();
652
- const { stdout } = await runMlxVlmGenerate(modelPath, imagePath);
743
+ const modelPath = resolveOcrModelPath(ocrModel);
744
+ const { stdout, stderr, invocation } = await runMlxVlmGenerate(
745
+ modelPath,
746
+ imagePath
747
+ );
748
+ if (OCR_DEBUG) {
749
+ console.log(
750
+ `[gonext-worker][ocr-debug] jobId=${jobId} invocation=${invocation} model=${modelPath} image=${imagePath} source=${
751
+ sourceLabel
752
+ } stdout=${compactPreview(stdout)} stderr=${compactPreview(stderr)}`
753
+ );
754
+ }
653
755
  extractedText = normalizeOcrOutput(stdout);
756
+ if (!extractedText) {
757
+ throw new Error(
758
+ `OCR returned empty text for this image. invocation=${invocation} stdout=${compactPreview(stdout)} stderr=${compactPreview(stderr)}`
759
+ );
760
+ }
654
761
  } finally {
655
762
  await rm(tempDir, { recursive: true, force: true }).catch(() => {});
656
763
  }
657
-
658
- if (!extractedText) {
659
- throw new Error("OCR returned empty text for this image.");
660
- }
661
764
  const totalTimeSeconds = (Date.now() - start) / 1000;
662
765
  const doneRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
663
766
  method: "PATCH",
@@ -670,7 +773,7 @@ async function runOcrJob(job) {
670
773
  });
671
774
  await ensureWorkerOk(doneRes, `complete OCR jobId=${jobId}`);
672
775
  console.log(
673
- `[gonext-worker] completed OCR ${jobId} (${totalTimeSeconds.toFixed(1)}s)`
776
+ `[gonext-worker] completed OCR ${jobId} (${totalTimeSeconds.toFixed(1)}s) model=${ocrModel}`
674
777
  );
675
778
  } catch (e) {
676
779
  const message = e instanceof Error ? e.message : String(e);
@@ -742,20 +845,30 @@ async function checkOllamaTags(base) {
742
845
  }
743
846
  }
744
847
 
745
- async function checkOpenAiModels(base, apiKey) {
848
+ async function checkOpenAiModels(base, apiKey, source) {
746
849
  const endpoint = `${base}/models`;
747
850
  const headers = {};
748
851
  if (apiKey?.trim()) {
749
852
  headers.Authorization = `Bearer ${apiKey.trim()}`;
750
853
  }
854
+ const hostSourceId =
855
+ typeof source?.workerHostId === "string" ? source.workerHostId.trim() : "";
856
+ const hostSourceLabel =
857
+ typeof source?.hostName === "string" ? source.hostName.trim() : "";
751
858
  try {
752
859
  const res = await fetch(endpoint, { method: "GET", headers });
753
860
  if (!res.ok) return { online: false, endpoint, models: [] };
754
861
  const j = await res.json();
862
+ const modelSuffix = hostSourceId ? `@@host:${hostSourceId}` : "";
863
+ const displaySuffix = hostSourceLabel ? ` (${hostSourceLabel})` : "";
755
864
  const models = (j.data ?? [])
756
865
  .map((d) => d.id)
757
866
  .filter(Boolean)
758
- .map((id) => ({ id, name: id, value: `mlx:${id}` }));
867
+ .map((id) => ({
868
+ id: hostSourceId ? `${id}@@${hostSourceId}` : id,
869
+ name: `${id}${displaySuffix}`,
870
+ value: `mlx:${id}${modelSuffix}`,
871
+ }));
759
872
  return { online: true, endpoint, models };
760
873
  } catch {
761
874
  return { online: false, endpoint, models: [] };
@@ -848,13 +961,24 @@ async function runLocalHealthJob(job) {
848
961
  }
849
962
  }
850
963
  const mlxRoot = normalizeOpenAiV1Root(payload?.mlxOpenAiBaseUrl);
964
+ const targetWorkerHostId =
965
+ typeof payload?.targetWorkerHostId === "string"
966
+ ? payload.targetWorkerHostId.trim()
967
+ : "";
968
+ const targetWorkerHostName =
969
+ typeof payload?.targetWorkerHostName === "string"
970
+ ? payload.targetWorkerHostName.trim()
971
+ : "";
851
972
  let mlxHttp = null;
852
973
  let mlxNative = null;
853
974
 
854
975
  if (mlxRoot) {
855
976
  const mlxStart = Date.now();
856
977
  console.log(`[gonext-worker] local_health ${jobId} check mlx HTTP ${mlxRoot}`);
857
- mlxHttp = await checkOpenAiModels(mlxRoot, payload?.mlxApiKey ?? "");
978
+ mlxHttp = await checkOpenAiModels(mlxRoot, payload?.mlxApiKey ?? "", {
979
+ workerHostId: targetWorkerHostId,
980
+ hostName: targetWorkerHostName || sourceLabelFromBase(mlxRoot),
981
+ });
858
982
  console.log(
859
983
  `[gonext-worker] local_health ${jobId} mlx HTTP online=${mlxHttp.online} models=${mlxHttp.models.length} took=${((Date.now() - mlxStart) / 1000).toFixed(2)}s`
860
984
  );
@@ -881,6 +1005,16 @@ async function runLocalHealthJob(job) {
881
1005
  if (mlxRoot || mlxNative?.available) {
882
1006
  const httpOk = Boolean(mlxHttp?.online && (mlxHttp?.models?.length ?? 0) > 0);
883
1007
  const nativeOk = mlxNative?.available === true;
1008
+ const hostName = targetWorkerHostName || sourceLabelFromBase(mlxRoot || "mlx");
1009
+ const sources = [
1010
+ {
1011
+ workerHostId: targetWorkerHostId || undefined,
1012
+ hostName,
1013
+ endpoint: mlxHttp?.endpoint,
1014
+ online: httpOk || nativeOk,
1015
+ modelCount: httpOk ? mlxHttp.models.length : nativeOk ? 1 : 0,
1016
+ },
1017
+ ];
884
1018
  mlx = {
885
1019
  configured: httpOk || nativeOk,
886
1020
  online: httpOk || nativeOk,
@@ -906,6 +1040,7 @@ async function runLocalHealthJob(job) {
906
1040
  }
907
1041
  : undefined,
908
1042
  native: mlxNative ?? undefined,
1043
+ sources,
909
1044
  };
910
1045
  }
911
1046
  const result = {
@@ -1014,7 +1149,8 @@ async function pollOnce() {
1014
1149
  job.payload &&
1015
1150
  typeof job.payload === "object" &&
1016
1151
  typeof job.payload.ocrId === "string" &&
1017
- typeof job.payload.attachment?.data === "string";
1152
+ (typeof job.payload.attachment?.data === "string" ||
1153
+ typeof job.payload.attachment?.s3Object?.getUrl === "string");
1018
1154
  if (isOcrByType || isOcrByPayload) {
1019
1155
  await runOcrJob(job);
1020
1156
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.42",
3
+ "version": "1.0.45",
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",