@tiens.nguyen/gonext-local-worker 1.0.158 → 1.0.160
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.
- package/gonext-local-worker.mjs +52 -0
- package/gonext_agent_chat.py +102 -14
- package/gonext_mlx_embed.py +155 -0
- package/package.json +2 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* writes ~/.gonext/worker.env
|
|
6
6
|
* - `gonext-local-worker ws-ping-test` — POST worker ws-ping (needs GONEXT_* env)
|
|
7
7
|
* - `gonext-local-worker simulate-chat [text]` — claim next chat job, push fake reply like the real worker (needs GONEXT_* env)
|
|
8
|
+
* - `gonext-local-worker embed --model <mlx-embed-model> [--port 8085]` — run the MLX embeddings server (RAG); use a separate terminal
|
|
8
9
|
* - `gonext-local-worker` — starts polling loop (claims jobs and runs models)
|
|
9
10
|
*/
|
|
10
11
|
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
@@ -221,6 +222,57 @@ if (args[0] === "ws-ping-test") {
|
|
|
221
222
|
process.exit(0);
|
|
222
223
|
}
|
|
223
224
|
|
|
225
|
+
if (args[0] === "embed") {
|
|
226
|
+
// Start the MLX embeddings server (OpenAI-compatible /v1/embeddings) for the RAG
|
|
227
|
+
// feature, in the FOREGROUND of this terminal. Run it in a SEPARATE terminal from the
|
|
228
|
+
// worker (the worker keeps polling; this holds one terminal for the embed model).
|
|
229
|
+
// gonext-local-worker embed --model ~/mlx-models/<embed-model> [--port 8085] [--host 127.0.0.1]
|
|
230
|
+
// Then set web Settings → Agent → RAG → "Embedding server URL" to http://<host>:<port>.
|
|
231
|
+
const flag = (name, fallback) => {
|
|
232
|
+
const i = args.indexOf(name);
|
|
233
|
+
return i >= 0 && typeof args[i + 1] === "string" ? args[i + 1] : fallback;
|
|
234
|
+
};
|
|
235
|
+
let model = flag("--model", process.env.GONEXT_EMBED_MODEL || "");
|
|
236
|
+
const port = flag("--port", process.env.GONEXT_EMBED_PORT || "8085");
|
|
237
|
+
const host = flag("--host", process.env.GONEXT_EMBED_HOST || "127.0.0.1");
|
|
238
|
+
if (!model) {
|
|
239
|
+
console.error(
|
|
240
|
+
"[gonext-worker] embed: --model is required.\n" +
|
|
241
|
+
" Usage: gonext-local-worker embed --model ~/mlx-models/Qwen3-Embedding-8B-4bit-DWQ [--port 8085] [--host 127.0.0.1]\n" +
|
|
242
|
+
" (or set GONEXT_EMBED_MODEL). Then set the RAG embedding server URL to http://<host>:<port> in web Settings."
|
|
243
|
+
);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
// Expand a leading ~ to the home dir (spawn does not do shell expansion).
|
|
247
|
+
if (model.startsWith("~/") || model === "~") {
|
|
248
|
+
model = join(homedir(), model.slice(model.length > 1 ? 2 : 1));
|
|
249
|
+
}
|
|
250
|
+
const python =
|
|
251
|
+
(process.env.GONEXT_PROBE_PYTHON ?? process.env.GONEXT_MLX_LM_PYTHON ?? "").trim() ||
|
|
252
|
+
"python3";
|
|
253
|
+
const script = join(WORKER_DIR, "gonext_mlx_embed.py");
|
|
254
|
+
console.log(
|
|
255
|
+
`[gonext-worker] starting MLX embeddings server → ${python} ${script} --model ${model} --port ${port} --host ${host}`
|
|
256
|
+
);
|
|
257
|
+
const child = spawn(
|
|
258
|
+
python,
|
|
259
|
+
[script, "--model", model, "--port", String(port), "--host", String(host)],
|
|
260
|
+
{ stdio: "inherit" }
|
|
261
|
+
);
|
|
262
|
+
child.on("error", (err) => {
|
|
263
|
+
console.error(`[gonext-worker] embed: failed to start python (${python}): ${err.message}`);
|
|
264
|
+
process.exit(1);
|
|
265
|
+
});
|
|
266
|
+
// Forward Ctrl-C to the child so the model unloads cleanly.
|
|
267
|
+
const stop = () => child.kill("SIGINT");
|
|
268
|
+
process.on("SIGINT", stop);
|
|
269
|
+
process.on("SIGTERM", stop);
|
|
270
|
+
// Block the module from continuing to the polling loop; exit when the child exits.
|
|
271
|
+
await new Promise((resolve) => child.on("exit", (code) => resolve(code ?? 0))).then(
|
|
272
|
+
(code) => process.exit(code)
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
224
276
|
if (args[0] === "simulate-chat") {
|
|
225
277
|
assertWorkerRuntimeConfig("simulate-chat");
|
|
226
278
|
const reply =
|
package/gonext_agent_chat.py
CHANGED
|
@@ -1310,12 +1310,25 @@ def _embed(base: str, model: str, texts: list) -> list:
|
|
|
1310
1310
|
|
|
1311
1311
|
# 3) Legacy Ollama /api/embeddings (per text).
|
|
1312
1312
|
out = []
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1313
|
+
try:
|
|
1314
|
+
for t in texts:
|
|
1315
|
+
data = _post(f"{root}/api/embeddings", {"model": model, "prompt": t}, 120)
|
|
1316
|
+
out.append(data.get("embedding") or [])
|
|
1317
|
+
except (urllib.error.HTTPError, urllib.error.URLError) as e:
|
|
1318
|
+
code = getattr(e, "code", None)
|
|
1319
|
+
raise RuntimeError(
|
|
1320
|
+
f"No embeddings endpoint at {root} (tried /v1/embeddings, /api/embed, /api/embeddings; "
|
|
1321
|
+
f"last HTTP {code if code else getattr(e, 'reason', e)}). Make sure an EMBEDDINGS-capable "
|
|
1322
|
+
f"server is running there and the model '{model}' is available "
|
|
1323
|
+
f"(e.g. `ollama pull {model}`). NOTE: mlx_lm.server does NOT serve embeddings — point the "
|
|
1324
|
+
f"RAG embedding server URL at Ollama (or another embeddings server) instead."
|
|
1325
|
+
)
|
|
1326
|
+
if any(any(v) for v in out):
|
|
1317
1327
|
return out
|
|
1318
|
-
raise RuntimeError(
|
|
1328
|
+
raise RuntimeError(
|
|
1329
|
+
f"Embeddings server at {root} returned empty vectors for model '{model}' "
|
|
1330
|
+
f"(is '{model}' an embedding model that is installed there?)."
|
|
1331
|
+
)
|
|
1319
1332
|
|
|
1320
1333
|
|
|
1321
1334
|
def _cosine(a: list, b: list) -> float:
|
|
@@ -1349,6 +1362,24 @@ def _rag_parse_location(loc: str):
|
|
|
1349
1362
|
return bucket, prefix.strip("/")
|
|
1350
1363
|
|
|
1351
1364
|
|
|
1365
|
+
def _rag_read_allowed(path: str) -> str:
|
|
1366
|
+
"""Resolve `path` and ensure it stays within a safe root (worker cwd, temp dir, or
|
|
1367
|
+
~/.gonext) — so the agent's file tools can't read arbitrary files like ~/.ssh."""
|
|
1368
|
+
import os
|
|
1369
|
+
import tempfile
|
|
1370
|
+
rp = os.path.realpath(path)
|
|
1371
|
+
roots = [
|
|
1372
|
+
os.path.realpath(os.getcwd()),
|
|
1373
|
+
os.path.realpath(tempfile.gettempdir()),
|
|
1374
|
+
os.path.realpath(os.path.join(os.path.expanduser("~"), ".gonext")),
|
|
1375
|
+
]
|
|
1376
|
+
if any(rp == r or rp.startswith(r + os.sep) for r in roots):
|
|
1377
|
+
return rp
|
|
1378
|
+
raise ValueError(
|
|
1379
|
+
"Path is outside the allowed working directories (download/unzip outputs only)."
|
|
1380
|
+
)
|
|
1381
|
+
|
|
1382
|
+
|
|
1352
1383
|
def _rag_s3_client(region: str, akid: str, secret: str):
|
|
1353
1384
|
import boto3 # noqa: PLC0415
|
|
1354
1385
|
return boto3.client(
|
|
@@ -1724,22 +1755,29 @@ def run_agent_chat(cfg):
|
|
|
1724
1755
|
_tool_num += 1
|
|
1725
1756
|
_email_num = _tool_num
|
|
1726
1757
|
if _RAG_AVAILABLE:
|
|
1727
|
-
_tool_num +=
|
|
1758
|
+
_tool_num += 7 # download_file, unzip_file, list_dir, read_text_file, rag_index/add/search
|
|
1728
1759
|
_tool_count_word = {6: "SIX", 7: "SEVEN", 8: "EIGHT"}.get(_tool_num, str(_tool_num))
|
|
1729
1760
|
_rag_tool_block = (
|
|
1730
|
-
" KNOWLEDGE BASE
|
|
1761
|
+
" FILES & KNOWLEDGE BASE — to SUMMARIZE or ANSWER questions about a ZIP of files at a URL:\n"
|
|
1731
1762
|
" - download_file(url) — download a file (e.g. a .zip) to this machine.\n"
|
|
1732
1763
|
" - unzip_file(zip_path) — extract a downloaded .zip locally.\n"
|
|
1733
|
-
" -
|
|
1734
|
-
"
|
|
1764
|
+
" - list_dir(path) — list the files inside an unzipped folder.\n"
|
|
1765
|
+
" - read_text_file(path) — read ONE file's text (e.g. a README) directly. Best for a QUICK "
|
|
1766
|
+
"project summary — you do NOT need to index for that.\n"
|
|
1767
|
+
" - rag_index(path, source_url) — index MANY text/code files into a searchable knowledge "
|
|
1768
|
+
"base (embeds to S3). source_url MUST be the ORIGINAL url the user gave. Use for deep Q&A "
|
|
1769
|
+
"across the whole project.\n"
|
|
1735
1770
|
" - rag_add(source_url, text) — add extra info the user provides to that knowledge base.\n"
|
|
1736
1771
|
" - rag_search(source_url, query) — retrieve the most relevant chunks to answer a question.\n"
|
|
1772
|
+
" NOTE: you may ONLY read files with these tools — plain `import os`/`open()` is blocked.\n"
|
|
1737
1773
|
) if _RAG_AVAILABLE else ""
|
|
1738
1774
|
_rag_choose_line = (
|
|
1739
|
-
"- user gives a URL to a ZIP
|
|
1740
|
-
"
|
|
1741
|
-
"
|
|
1742
|
-
"
|
|
1775
|
+
"- user gives a URL to a ZIP and asks to SUMMARIZE it -> download_file(url) THEN unzip_file(zip) "
|
|
1776
|
+
"THEN list_dir(dir) to find the README, THEN read_text_file(that README) THEN answer from it. "
|
|
1777
|
+
"This is the FASTEST path for a summary — do NOT index unless deep Q&A across many files is needed.\n"
|
|
1778
|
+
"- user wants deep Q&A across the WHOLE project -> download_file THEN unzip_file THEN "
|
|
1779
|
+
"rag_index(dir, source_url=url) THEN rag_search(source_url=url, query=<question>) THEN answer. "
|
|
1780
|
+
"For FOLLOW-UPs on the SAME url, just rag_search (do NOT re-download). "
|
|
1743
1781
|
"If the user adds information, rag_add(source_url=url, text=...).\n"
|
|
1744
1782
|
) if _RAG_AVAILABLE else ""
|
|
1745
1783
|
_pdf_read_tool_line = (
|
|
@@ -2665,6 +2703,55 @@ def run_agent_chat(cfg):
|
|
|
2665
2703
|
except Exception as e: # noqa: BLE001
|
|
2666
2704
|
return f"Error: unzip failed: {type(e).__name__}: {e}"
|
|
2667
2705
|
|
|
2706
|
+
@tool
|
|
2707
|
+
def list_dir(path: str) -> str:
|
|
2708
|
+
"""List files and subfolders under a local directory (from unzip_file). Use to see what files are in the project before reading or indexing them.
|
|
2709
|
+
|
|
2710
|
+
Args:
|
|
2711
|
+
path: local directory path to list.
|
|
2712
|
+
"""
|
|
2713
|
+
try:
|
|
2714
|
+
import os
|
|
2715
|
+
root = _rag_read_allowed((path or ".").strip())
|
|
2716
|
+
if os.path.isfile(root):
|
|
2717
|
+
return f"{root} is a file ({os.path.getsize(root)} bytes)."
|
|
2718
|
+
entries = []
|
|
2719
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
2720
|
+
dirnames[:] = [d for d in dirnames if d not in _RAG_SKIP_DIRS]
|
|
2721
|
+
for fn in filenames:
|
|
2722
|
+
rel = os.path.relpath(os.path.join(dirpath, fn), root)
|
|
2723
|
+
entries.append(rel)
|
|
2724
|
+
if len(entries) >= 300:
|
|
2725
|
+
break
|
|
2726
|
+
if len(entries) >= 300:
|
|
2727
|
+
break
|
|
2728
|
+
entries.sort()
|
|
2729
|
+
more = " …(truncated)" if len(entries) >= 300 else ""
|
|
2730
|
+
return f"{len(entries)} files under {root}:\n" + "\n".join(entries) + more
|
|
2731
|
+
except Exception as e: # noqa: BLE001
|
|
2732
|
+
return f"Error: list_dir failed: {type(e).__name__}: {e}"
|
|
2733
|
+
|
|
2734
|
+
@tool
|
|
2735
|
+
def read_text_file(path: str, max_chars: int = 20000) -> str:
|
|
2736
|
+
"""Read the text contents of a single local file (e.g. a README) from a downloaded/unzipped project. Use this for a QUICK summary when you don't need full indexing.
|
|
2737
|
+
|
|
2738
|
+
Args:
|
|
2739
|
+
path: local path to the text file (e.g. project_files/tools-hook/README.md).
|
|
2740
|
+
max_chars: maximum characters to return (default 20000).
|
|
2741
|
+
"""
|
|
2742
|
+
try:
|
|
2743
|
+
import os
|
|
2744
|
+
rp = _rag_read_allowed((path or "").strip())
|
|
2745
|
+
if not os.path.isfile(rp):
|
|
2746
|
+
return f"Error: not a file: {path}"
|
|
2747
|
+
if os.path.getsize(rp) > 5 * 1024 * 1024:
|
|
2748
|
+
return "Error: file too large to read (> 5 MB)."
|
|
2749
|
+
with open(rp, "r", encoding="utf-8", errors="replace") as fh:
|
|
2750
|
+
data = fh.read(max(500, min(int(max_chars or 20000), 100000)))
|
|
2751
|
+
return data or "(empty file)"
|
|
2752
|
+
except Exception as e: # noqa: BLE001
|
|
2753
|
+
return f"Error: read_text_file failed: {type(e).__name__}: {e}"
|
|
2754
|
+
|
|
2668
2755
|
@tool
|
|
2669
2756
|
def rag_index(path: str, source_url: str) -> str:
|
|
2670
2757
|
"""Index a local file or directory of TEXT/code files into the knowledge base for source_url (stores embeddings on S3). Use after unzip_file so the files become searchable. source_url MUST be the original URL the user provided.
|
|
@@ -2785,7 +2872,8 @@ def run_agent_chat(cfg):
|
|
|
2785
2872
|
if _EMAIL_AVAILABLE:
|
|
2786
2873
|
agent_tools.append(send_email)
|
|
2787
2874
|
if _RAG_AVAILABLE:
|
|
2788
|
-
agent_tools += [download_file, unzip_file,
|
|
2875
|
+
agent_tools += [download_file, unzip_file, list_dir, read_text_file,
|
|
2876
|
+
rag_index, rag_add, rag_search]
|
|
2789
2877
|
agent_kwargs = dict(
|
|
2790
2878
|
tools=agent_tools,
|
|
2791
2879
|
model=model,
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
GoNext MLX embeddings server — an OpenAI-compatible /v1/embeddings endpoint for MLX
|
|
4
|
+
embedding models (e.g. Qwen3-Embedding-8B), which `mlx_lm.server` does NOT provide.
|
|
5
|
+
|
|
6
|
+
The GoNext worker's RAG tools call `POST {url}/v1/embeddings` first, so pointing the
|
|
7
|
+
"RAG embedding server URL" (web Settings → Agent → RAG) at this server makes MLX
|
|
8
|
+
embeddings work with no other changes.
|
|
9
|
+
|
|
10
|
+
Run:
|
|
11
|
+
python3 gonext_mlx_embed.py --model ~/mlx-models/Qwen3-Embedding-8B-4bit-DWQ --port 8085
|
|
12
|
+
|
|
13
|
+
Then set the RAG embedding server URL to http://127.0.0.1:8085
|
|
14
|
+
|
|
15
|
+
Endpoints:
|
|
16
|
+
POST /v1/embeddings {"model": <ignored>, "input": "text" | ["t1","t2",...]}
|
|
17
|
+
→ {"object":"list","data":[{"object":"embedding","index":i,
|
|
18
|
+
"embedding":[...]}], "model": <name>, "usage": {...}}
|
|
19
|
+
GET /v1/models → the single loaded model
|
|
20
|
+
GET /health → {"status":"ok"}
|
|
21
|
+
|
|
22
|
+
Pooling: last-token hidden state (Qwen3-Embedding convention), L2-normalized.
|
|
23
|
+
Requires: mlx, mlx_lm (already used by the worker). No extra deps.
|
|
24
|
+
"""
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
import threading
|
|
30
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
31
|
+
|
|
32
|
+
import mlx.core as mx
|
|
33
|
+
from mlx_lm import load
|
|
34
|
+
|
|
35
|
+
# Cap tokens per input so a pathologically long chunk can't OOM / stall the GPU.
|
|
36
|
+
MAX_TOKENS = 8192
|
|
37
|
+
|
|
38
|
+
_model = None
|
|
39
|
+
_tok = None
|
|
40
|
+
_inner = None # the transformer body that returns hidden states
|
|
41
|
+
_model_name = "mlx-embedding"
|
|
42
|
+
_lock = threading.Lock() # MLX eval isn't guaranteed thread-safe; serialize inference
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _load(model_path: str) -> None:
|
|
46
|
+
global _model, _tok, _inner, _model_name
|
|
47
|
+
print(f"[gonext-mlx-embed] loading {model_path} …", flush=True)
|
|
48
|
+
_model, _tok = load(model_path)
|
|
49
|
+
# mlx_lm wraps the transformer body as `.model` (returns hidden states); `_model(x)`
|
|
50
|
+
# would return logits from the LM head, which we do NOT want.
|
|
51
|
+
_inner = getattr(_model, "model", None) or getattr(_model, "language_model", None)
|
|
52
|
+
if _inner is None:
|
|
53
|
+
raise RuntimeError("Could not find the transformer body on the loaded model.")
|
|
54
|
+
_model_name = os.path.basename(os.path.normpath(model_path))
|
|
55
|
+
# Warm + sanity-check the embedding path so a bad model fails fast at startup.
|
|
56
|
+
v = _embed_one("warmup")
|
|
57
|
+
print(f"[gonext-mlx-embed] ready — {_model_name}, dim={len(v)}", flush=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _embed_one(text: str) -> list:
|
|
61
|
+
ids = _tok.encode(text or " ")
|
|
62
|
+
if not ids:
|
|
63
|
+
ids = _tok.encode(" ")
|
|
64
|
+
if len(ids) > MAX_TOKENS:
|
|
65
|
+
ids = ids[:MAX_TOKENS]
|
|
66
|
+
h = _inner(mx.array([ids])) # (1, seq, hidden)
|
|
67
|
+
v = h[0, -1, :] # last-token pooling
|
|
68
|
+
v = v / mx.maximum(mx.linalg.norm(v), 1e-9) # L2 normalize
|
|
69
|
+
mx.eval(v)
|
|
70
|
+
return [float(x) for x in v.tolist()]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _embed_batch(texts: list) -> list:
|
|
74
|
+
with _lock:
|
|
75
|
+
out = [_embed_one(t) for t in texts]
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Handler(BaseHTTPRequestHandler):
|
|
80
|
+
protocol_version = "HTTP/1.1"
|
|
81
|
+
|
|
82
|
+
def log_message(self, *a): # quieter logs
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
def _send(self, code: int, obj: dict) -> None:
|
|
86
|
+
body = json.dumps(obj).encode("utf-8")
|
|
87
|
+
self.send_response(code)
|
|
88
|
+
self.send_header("Content-Type", "application/json")
|
|
89
|
+
self.send_header("Content-Length", str(len(body)))
|
|
90
|
+
self.end_headers()
|
|
91
|
+
self.wfile.write(body)
|
|
92
|
+
|
|
93
|
+
def do_GET(self):
|
|
94
|
+
if self.path == "/health":
|
|
95
|
+
self._send(200, {"status": "ok", "model": _model_name})
|
|
96
|
+
elif self.path in ("/v1/models", "/models"):
|
|
97
|
+
self._send(200, {"object": "list", "data": [
|
|
98
|
+
{"id": _model_name, "object": "model", "owned_by": "gonext-mlx-embed"}
|
|
99
|
+
]})
|
|
100
|
+
else:
|
|
101
|
+
self._send(404, {"error": {"message": f"unknown path {self.path}"}})
|
|
102
|
+
|
|
103
|
+
def do_POST(self):
|
|
104
|
+
if self.path not in ("/v1/embeddings", "/embeddings"):
|
|
105
|
+
self._send(404, {"error": {"message": f"unknown path {self.path}"}})
|
|
106
|
+
return
|
|
107
|
+
try:
|
|
108
|
+
n = int(self.headers.get("Content-Length", 0) or 0)
|
|
109
|
+
payload = json.loads(self.rfile.read(n) or b"{}")
|
|
110
|
+
except Exception as e: # noqa: BLE001
|
|
111
|
+
self._send(400, {"error": {"message": f"bad JSON: {e}"}})
|
|
112
|
+
return
|
|
113
|
+
inp = payload.get("input")
|
|
114
|
+
if inp is None:
|
|
115
|
+
self._send(400, {"error": {"message": "missing 'input'"}})
|
|
116
|
+
return
|
|
117
|
+
texts = [inp] if isinstance(inp, str) else list(inp)
|
|
118
|
+
if not all(isinstance(t, str) for t in texts):
|
|
119
|
+
self._send(400, {"error": {"message": "'input' must be a string or list of strings"}})
|
|
120
|
+
return
|
|
121
|
+
try:
|
|
122
|
+
vecs = _embed_batch(texts)
|
|
123
|
+
except Exception as e: # noqa: BLE001
|
|
124
|
+
self._send(500, {"error": {"message": f"embedding failed: {type(e).__name__}: {e}"}})
|
|
125
|
+
return
|
|
126
|
+
total_tokens = sum(len(_tok.encode(t or " ")) for t in texts)
|
|
127
|
+
self._send(200, {
|
|
128
|
+
"object": "list",
|
|
129
|
+
"data": [
|
|
130
|
+
{"object": "embedding", "index": i, "embedding": v}
|
|
131
|
+
for i, v in enumerate(vecs)
|
|
132
|
+
],
|
|
133
|
+
"model": payload.get("model") or _model_name,
|
|
134
|
+
"usage": {"prompt_tokens": total_tokens, "total_tokens": total_tokens},
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def main():
|
|
139
|
+
ap = argparse.ArgumentParser(description="OpenAI-compatible MLX embeddings server.")
|
|
140
|
+
ap.add_argument("--model", required=True, help="Path to the MLX embedding model dir.")
|
|
141
|
+
ap.add_argument("--port", type=int, default=8085)
|
|
142
|
+
ap.add_argument("--host", default="127.0.0.1")
|
|
143
|
+
args = ap.parse_args()
|
|
144
|
+
_load(os.path.expanduser(args.model))
|
|
145
|
+
srv = ThreadingHTTPServer((args.host, args.port), Handler)
|
|
146
|
+
print(f"[gonext-mlx-embed] serving /v1/embeddings on http://{args.host}:{args.port}", flush=True)
|
|
147
|
+
try:
|
|
148
|
+
srv.serve_forever()
|
|
149
|
+
except KeyboardInterrupt:
|
|
150
|
+
print("\n[gonext-mlx-embed] shutting down", flush=True)
|
|
151
|
+
srv.shutdown()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
sys.exit(main())
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gonext-local-worker",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.160",
|
|
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",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"gonext_probe_agent.py",
|
|
26
26
|
"gonext_agent_chat.py",
|
|
27
27
|
"gonext_transcribe.py",
|
|
28
|
+
"gonext_mlx_embed.py",
|
|
28
29
|
"README.md",
|
|
29
30
|
"launchd/"
|
|
30
31
|
],
|