@tiens.nguyen/gonext-cli 1.0.350

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.
@@ -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())
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ gonext probe agent (agentic mode for the gonext local worker).
4
+
5
+ Reads a JSON probe config on stdin and writes a single JSON object on stdout:
6
+
7
+ in: {"request": {"method","url"},
8
+ "measurement": {"status","statusText","latencyMs","headers",
9
+ "bodySnippet","bodyBytes"} | null,
10
+ "category": str, "error": str | null,
11
+ "agentBaseURL","agentApiKey","agentModelId"}
12
+ out: {"agentSummary": str, "error": str | null}
13
+
14
+ The worker performs the authoritative HTTP measurement (reliable TLS via Node
15
+ fetch) and passes it here; this script drives a smolagents agent on the selected
16
+ local model to produce the natural-language health assessment. The agent's
17
+ `send_request` tool returns the already-measured result, so there is exactly one
18
+ network call (the worker's) and the measured status stays the source of truth.
19
+
20
+ smolagents/rich writes its console UI to stdout, so we redirect stdout to stderr
21
+ during the agent run and only emit our JSON result on stdout.
22
+
23
+ See docs/plans/smolagents-local-worker-http-probe.md.
24
+ """
25
+ import contextlib
26
+ import json
27
+ import sys
28
+
29
+
30
+ def describe(measurement, category, error):
31
+ if not measurement or error:
32
+ return f"The request did not get a response ({category}): {error or 'unknown error'}."
33
+ return (
34
+ f"HTTP {measurement.get('status')} {measurement.get('statusText', '')} "
35
+ f"({category}) in {measurement.get('latencyMs')} ms. "
36
+ f"Body preview: {str(measurement.get('bodySnippet', ''))[:400]}"
37
+ )
38
+
39
+
40
+ def run_agent_summary(cfg):
41
+ """Returns (summary, error). Never raises."""
42
+ try:
43
+ from smolagents import CodeAgent, OpenAIServerModel, tool
44
+ except Exception as e: # noqa: BLE001
45
+ return "", f"smolagents not installed ({e})"
46
+
47
+ request = cfg.get("request") or {}
48
+ method = (request.get("method") or "GET").upper()
49
+ url = request.get("url") or ""
50
+ measurement = cfg.get("measurement")
51
+ category = cfg.get("category") or ""
52
+ error = cfg.get("error")
53
+ measured_text = describe(measurement, category, error)
54
+
55
+ @tool
56
+ def send_request() -> str:
57
+ """Send the configured HTTP request to the target endpoint and return its
58
+ status code and a short body preview."""
59
+ return measured_text
60
+
61
+ try:
62
+ model = OpenAIServerModel(
63
+ model_id=cfg.get("agentModelId") or "",
64
+ api_base=cfg.get("agentBaseURL") or "",
65
+ api_key=cfg.get("agentApiKey") or "local",
66
+ )
67
+ agent = CodeAgent(tools=[send_request], model=model, max_steps=4)
68
+ task = (
69
+ f"Use send_request to call {method} {url}. Then report the HTTP status "
70
+ "code, classify it (2xx success, 3xx redirect, 4xx client error, 5xx "
71
+ "server error, or network failure), and give a one-sentence health "
72
+ "assessment of the endpoint."
73
+ )
74
+ # smolagents/rich logs to stdout; keep stdout clean for our JSON result.
75
+ with contextlib.redirect_stdout(sys.stderr):
76
+ summary = str(agent.run(task)).strip()
77
+ return summary, None
78
+ except Exception as e: # noqa: BLE001
79
+ return "", f"agent run failed ({e})"
80
+
81
+
82
+ def main():
83
+ try:
84
+ cfg = json.load(sys.stdin)
85
+ except Exception as e: # noqa: BLE001
86
+ json.dump({"agentSummary": "", "error": f"invalid input: {e}"}, sys.stdout)
87
+ return
88
+ summary, agent_error = run_agent_summary(cfg)
89
+ json.dump({"agentSummary": summary, "error": agent_error}, sys.stdout)
90
+
91
+
92
+ if __name__ == "__main__":
93
+ main()
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env python3
2
+ """Voice transcription for the GoNext local worker (Audio support, task #21).
3
+
4
+ Reads a JSON job on stdin: {"audioPath": str, "model": str, "language": str?}.
5
+ Transcribes with mlx-whisper and prints {"text": "..."} on stdout.
6
+
7
+ Design notes:
8
+ - The browser sends a 16 kHz mono 16-bit PCM WAV, so we decode it with the
9
+ stdlib `wave` module + numpy and hand mlx-whisper a float32 array. Passing an
10
+ array (not a file path) skips mlx-whisper's ffmpeg-based loader, so NO ffmpeg
11
+ install is required on the worker.
12
+ - The model is resolved by HF repo id ("mlx-community/<model>") from the HF hub
13
+ cache (~/.cache/huggingface/hub, honoring HF_HOME/HF_HUB_CACHE). This is the
14
+ same location the wizard installs to and the readiness probe checks.
15
+ - On failure we exit non-zero and print a machine-parseable reason token as the
16
+ first stderr line: "lib-missing" | "model-missing" | "audio-error" | "error".
17
+ """
18
+
19
+ import json
20
+ import sys
21
+ import wave
22
+
23
+
24
+ TARGET_SR = 16000
25
+
26
+
27
+ def fail(reason: str, detail: str = "") -> None:
28
+ sys.stderr.write(reason + "\n")
29
+ if detail:
30
+ sys.stderr.write(detail + "\n")
31
+ sys.exit(1)
32
+
33
+
34
+ def load_wav_mono_16k(path: str):
35
+ """Decode a PCM WAV to a float32 mono array at 16 kHz without ffmpeg."""
36
+ import numpy as np
37
+
38
+ with wave.open(path, "rb") as w:
39
+ n_channels = w.getnchannels()
40
+ sampwidth = w.getsampwidth()
41
+ framerate = w.getframerate()
42
+ n_frames = w.getnframes()
43
+ raw = w.readframes(n_frames)
44
+
45
+ if sampwidth == 2:
46
+ audio = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
47
+ elif sampwidth == 4:
48
+ audio = np.frombuffer(raw, dtype=np.int32).astype(np.float32) / 2147483648.0
49
+ elif sampwidth == 1:
50
+ audio = (np.frombuffer(raw, dtype=np.uint8).astype(np.float32) - 128.0) / 128.0
51
+ else:
52
+ raise ValueError(f"unsupported WAV sample width: {sampwidth} bytes")
53
+
54
+ if n_channels > 1:
55
+ audio = audio.reshape(-1, n_channels).mean(axis=1)
56
+
57
+ if framerate != TARGET_SR and audio.size > 0:
58
+ # Linear resample; the browser already sends 16 kHz so this is a safety net.
59
+ duration = audio.size / float(framerate)
60
+ target_len = int(round(duration * TARGET_SR))
61
+ if target_len > 0:
62
+ src_x = np.linspace(0.0, 1.0, num=audio.size, endpoint=False)
63
+ dst_x = np.linspace(0.0, 1.0, num=target_len, endpoint=False)
64
+ audio = np.interp(dst_x, src_x, audio).astype(np.float32)
65
+
66
+ return np.ascontiguousarray(audio, dtype=np.float32)
67
+
68
+
69
+ def main() -> None:
70
+ try:
71
+ payload = json.loads(sys.stdin.read() or "{}")
72
+ except Exception as e: # noqa: BLE001
73
+ fail("error", f"invalid job json: {e}")
74
+ return
75
+
76
+ audio_path = str(payload.get("audioPath") or "").strip()
77
+ model = str(payload.get("model") or "whisper-large-v3-turbo").strip()
78
+ language = str(payload.get("language") or "").strip() or None
79
+ if not audio_path:
80
+ fail("error", "missing audioPath")
81
+ return
82
+
83
+ try:
84
+ import mlx_whisper # noqa: F401
85
+ except Exception as e: # noqa: BLE001
86
+ fail("lib-missing", str(e))
87
+ return
88
+
89
+ try:
90
+ audio = load_wav_mono_16k(audio_path)
91
+ except Exception as e: # noqa: BLE001
92
+ fail("audio-error", str(e))
93
+ return
94
+
95
+ repo = f"mlx-community/{model}"
96
+ try:
97
+ result = mlx_whisper.transcribe(
98
+ audio,
99
+ path_or_hf_repo=repo,
100
+ task="transcribe",
101
+ language=language,
102
+ )
103
+ except Exception as e: # noqa: BLE001
104
+ msg = str(e).lower()
105
+ if any(
106
+ token in msg
107
+ for token in (
108
+ "not found",
109
+ "repository",
110
+ "couldn't find",
111
+ "could not find",
112
+ "no such file",
113
+ "connection",
114
+ "offline",
115
+ "resolve",
116
+ "404",
117
+ )
118
+ ):
119
+ fail("model-missing", str(e))
120
+ else:
121
+ fail("error", str(e))
122
+ return
123
+
124
+ text = str(result.get("text", "")).strip() if isinstance(result, dict) else ""
125
+ sys.stdout.write(json.dumps({"text": text}))
126
+ sys.stdout.flush()
127
+
128
+
129
+ if __name__ == "__main__":
130
+ main()