local-embed 0.2.2__tar.gz → 0.2.3__tar.gz

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.

Potentially problematic release.


This version of local-embed might be problematic. Click here for more details.

@@ -1,7 +1,7 @@
1
- # Runtime config — personal per-user settings; template is slife.template.json5.
2
- slife.json5
3
- mcp-plugin.json5
4
- local_embed.json5
1
+ # Runtime config — personal per-user settings. The three initial configs
2
+ # (slife.json5 / local_embed.json5 / mcp-plugin.json5) are git-tracked so the
3
+ # one-click installers can seed them as out-of-the-box defaults. Copies in a
4
+ # user's data dir (~/.slife/) remain personal and are NOT tracked.
5
5
 
6
6
  # Playwright MCP browser traces (test artifacts)
7
7
  .playwright-mcp/
@@ -0,0 +1,457 @@
1
+ Metadata-Version: 2.3
2
+ Name: local-embed
3
+ Version: 0.2.3
4
+ Summary: Standalone local embedding server — GGUF / transformer models as an OpenAI-compatible /v1/embeddings service
5
+ Project-URL: Homepage, https://github.com/juzcn/slife
6
+ Project-URL: Repository, https://github.com/juzcn/slife
7
+ Project-URL: Documentation, https://github.com/juzcn/slife/blob/main/local-embed/README.md
8
+ Author-email: juzcn <zhangjun@cueb.edu.cn>
9
+ License: MIT
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Terminals
17
+ Requires-Python: >=3.13
18
+ Requires-Dist: fastmcp>=2.0.0
19
+ Requires-Dist: json5>=0.15.0
20
+ Requires-Dist: starlette>=0.37.0
21
+ Provides-Extra: gguf
22
+ Requires-Dist: llama-cpp-python==0.3.34; extra == 'gguf'
23
+ Provides-Extra: transformer
24
+ Requires-Dist: sentence-transformers; extra == 'transformer'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # local-embed
28
+
29
+ **A standalone local embedding service.** It loads one local GGUF
30
+ (llama-cpp) or HF transformer embedding model and exposes it as an
31
+ [OpenAI-compatible](https://platform.openai.com/docs/api-reference/embeddings)
32
+ `/v1/embeddings` URL — any OpenAI-compatible client can embed text through it.
33
+
34
+ On one port it serves two surfaces: an OpenAI-compatible `/v1/*` API for
35
+ embedding, and a Streamable HTTP MCP endpoint at `/mcp`.
36
+
37
+ local-embed is three things at once: an independent app, a Streamable HTTP
38
+ **MCP server** (see [As an MCP server](#as-an-mcp-server)), and a conforming
39
+ **slife plugin** (see [Loading as a slife plugin](#loading-as-a-slife-plugin)).
40
+ The product is the embedding URL; the MCP and plugin surfaces are how hosts
41
+ manage the same standalone service.
42
+
43
+ Two backends: **`gguf`** loads a local `.gguf` file (llama-cpp-python);
44
+ **`transformer`** loads an HF repo id from the local hub cache
45
+ (sentence-transformers). The server never downloads weights — you pre-download
46
+ them ([Model weights](#model-weights)).
47
+
48
+ ```
49
+ any OpenAI-compatible client ──┐
50
+ slife (memdb / memfiles) ──────┤── POST /v1/embeddings ──┐
51
+ vector DB / RAG pipeline ──────┘ ▼
52
+ ┌───────────────────────┐
53
+ slife (plugin host) ─── /mcp ────────────> │ local-embed │
54
+ │ ONE loaded model │
55
+ └───────────────────────┘
56
+ ```
57
+
58
+ ## Features
59
+
60
+ - **OpenAI-compatible** `/v1/embeddings` + Models API + `/health`.
61
+ - Two backends (GGUF / transformer), **many models configured, one active**.
62
+ - **Lazy load** — startup is fast; the model materialises on first use.
63
+ - **Real dimension** reported after load (a guessed width is never served).
64
+ - Runs standalone, and can also be **loaded as a slife plugin** (see
65
+ [Loading as a slife plugin](#loading-as-a-slife-plugin)).
66
+
67
+ Requires Python ≥ 3.13.
68
+
69
+ ## Install
70
+
71
+ The core package is `fastmcp` + `starlette` + `json5`; the model backends are
72
+ optional extras.
73
+
74
+ ```bash
75
+ # the app + CLI (standalone tool)
76
+ uv tool install local-embed
77
+
78
+ # with a backend:
79
+ uv tool install "local-embed[transformer]"
80
+ uv tool install "local-embed[gguf]"
81
+ ```
82
+
83
+ If you run local-embed inside an *existing* environment (alongside another
84
+ tool), install the backend into **that** environment's interpreter instead of
85
+ creating a separate one:
86
+
87
+ ```bash
88
+ uv pip install --python <venv-python> llama-cpp-python==0.3.34
89
+ ```
90
+
91
+ where `<venv-python>` is the interpreter that runs `local-embed`.
92
+
93
+ ### `gguf` backend — platform matrix
94
+
95
+ `gguf` (llama-cpp-python) is platform-sensitive: PyPI ships **only the sdist**
96
+ (no prebuilt wheels), so on Linux / WSL / macOS a plain install **compiles from
97
+ source** (needs a C compiler + CMake ≥ 3.21). **Windows has no default C
98
+ toolchain** (no MSVC), so it uses the upstream prebuilt CPU wheel — the one
99
+ workaround. GPU variants pass `CMAKE_ARGS`:
100
+
101
+ | Platform | Command |
102
+ |---|---|
103
+ | Linux / WSL / macOS, CPU | `uv pip install --python <venv-python> llama-cpp-python==0.3.34` (compiles from source) |
104
+ | macOS arm64 (Metal) | `CMAKE_ARGS="-DGGML_METAL=on" uv pip install --python <venv-python> llama-cpp-python==0.3.34` |
105
+ | NVIDIA CUDA (Linux) | `CMAKE_ARGS="-DGGML_CUDA=on" uv pip install --python <venv-python> llama-cpp-python==0.3.34` (needs the CUDA toolkit) |
106
+ | **Windows, CPU** | `uv pip install --python <venv-python> --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu llama-cpp-python==0.3.34` (prebuilt wheel — no MSVC) |
107
+
108
+ The Windows CPU row is the only workaround — everywhere else uses the standard
109
+ source build from PyPI. If the `gguf` backend is missing, the server errors at
110
+ startup with the exact command for your platform.
111
+
112
+ **Both backends at once** — one `uv pip install`:
113
+
114
+ ```bash
115
+ uv pip install --python <venv-python> sentence-transformers llama-cpp-python==0.3.34
116
+ # Windows CPU: add the upstream wheel index (see the matrix above)
117
+ ```
118
+
119
+ Installing does **not** fetch a model — get the weights first.
120
+
121
+ ## Quick start
122
+
123
+ 1. **Install** the app and a backend (above).
124
+ 2. **Download weights** — see [Model weights](#model-weights).
125
+ 3. **Configure a model** — the CLI helper writes `local_embed.json5`:
126
+
127
+ ```bash
128
+ local-embed set BAAI/bge-m3 --HF_HUB_CACHE <dir> # transformer
129
+ local-embed set-gguf bge-m3 --path D:\models\bge.gguf # gguf
130
+ ```
131
+
132
+ 4. **Start the service**:
133
+
134
+ ```bash
135
+ local-embed # serve on http://127.0.0.1:17347
136
+ ```
137
+
138
+ 5. **Embed text** — point any OpenAI-compatible client at
139
+ `http://127.0.0.1:17347/v1` (see [HTTP API](#http-api-openai-compatible)).
140
+
141
+ ## Configuration: `local_embed.json5`
142
+
143
+ Everything — host, port, models, active model, backend — comes from
144
+ `local_embed.json5`, written by the CLI helpers or by hand. Path resolution:
145
+ `$LOCAL_EMBED_FILE` > slife project root (dev) > `~/.local-embed/local_embed.json5`.
146
+
147
+ ```json5
148
+ {
149
+ active_model: "bge-m3", // model served by default
150
+ models: {
151
+ "bge-m3": { backend: "gguf", gguf_path: "…", device: "" },
152
+ "bge-m3-transformer": { backend: "transformer", model: "BAAI/bge-m3", device: "" }
153
+ },
154
+ env: { // injected into this process before any model loads
155
+ HF_HUB_CACHE: "C:\\…\\HuggingFace\\hub", // where transformer repos resolve
156
+ HF_HUB_OFFLINE: "1" // force offline
157
+ },
158
+ host: "127.0.0.1", // standalone only
159
+ port: 17347 // standalone only
160
+ }
161
+ ```
162
+
163
+ - `models` — map of name → `{backend, gguf_path | model, device, max_tokens}`.
164
+ - `active_model` — key into `models`.
165
+ - `env:` — injected before any backend loads; an existing shell env var wins.
166
+ Without `HF_HUB_CACHE`, transformer repos resolve against the default cache
167
+ and a model downloaded elsewhere is silently re-fetched.
168
+ - Single-model convenience — top-level `backend`/`model`/`gguf_path`/`device`
169
+ — still works.
170
+
171
+ Reads are read-only at runtime — the server has no config-mutating tools.
172
+
173
+ ## Model weights
174
+
175
+ The server never downloads models; both backends fail at load time when their
176
+ weights are missing (`gguf` needs the `gguf_path` file, `transformer` needs the
177
+ repo in the HF cache).
178
+
179
+ ### GGUF
180
+
181
+ `.gguf` files are, in general, **community conversions** — upstream releases
182
+ are PyTorch weights, not GGUF — so there is **no single authoritative source**:
183
+ conversions are scattered across Hugging Face, ModelScope, Ollama, llama.cpp
184
+ community uploads and various project sites. Pick any source you trust; the
185
+ transport doesn't matter, local-embed only needs the file on disk.
186
+
187
+ HF is the most common host and its CLI pulls a single file:
188
+
189
+ ```bash
190
+ uv tool install "huggingface-hub[cli]" # provides `hf`
191
+ hf download <owner>/<repo> <model>.gguf --local-dir D:\models\bge-m3
192
+ # one-off, no permanent tool install:
193
+ uvx --from huggingface-hub hf download <owner>/<repo> <model>.gguf --local-dir D:\models\bge-m3
194
+ ```
195
+
196
+ Any source also works from a browser or `wget`/`curl` — on HF, every file is
197
+ fetchable from `https://huggingface.co/<owner>/<repo>/resolve/main/<model>.gguf`.
198
+
199
+ Prefer a high-fidelity quant such as **Q8_0** (~99 % of the original accuracy at
200
+ roughly a third of the size). Point `gguf_path` at the file:
201
+
202
+ ```json5
203
+ models: {
204
+ "bge-m3": { backend: "gguf", gguf_path: "D:\\models\\bge-m3\\bge-m3-q8_0.gguf" }
205
+ }
206
+ ```
207
+
208
+ ### Transformer
209
+
210
+ `sentence-transformers` resolves an HF repo id against the local hub cache:
211
+
212
+ ```bash
213
+ hf download BAAI/bge-m3 # -> ~/.cache/huggingface/hub/models--BAAI--bge-m3
214
+ ```
215
+
216
+ ```json5
217
+ models: {
218
+ "bge-m3": { backend: "transformer", model: "BAAI/bge-m3" }
219
+ }
220
+ ```
221
+
222
+ ## HTTP API (OpenAI-compatible)
223
+
224
+ The endpoint surface is a superset of the OpenAI Embeddings + Models APIs, all
225
+ on one port.
226
+
227
+ ### `POST /v1/embeddings`
228
+
229
+ ```bash
230
+ curl http://127.0.0.1:17347/v1/embeddings \
231
+ -H 'Content-Type: application/json' \
232
+ -d '{"model": "bge-m3", "input": ["hello world", "another text"]}'
233
+ ```
234
+
235
+ `input` is a string or a list of strings; `model` names any configured model
236
+ (defaults to the active one). Returns the standard shape:
237
+
238
+ ```json
239
+ {
240
+ "object": "list",
241
+ "data": [
242
+ {"object": "embedding", "index": 0, "embedding": [0.012, ...]},
243
+ {"object": "embedding", "index": 1, "embedding": [...]}
244
+ ],
245
+ "model": "bge-m3",
246
+ "usage": {"prompt_tokens": 3, "total_tokens": 3}
247
+ }
248
+ ```
249
+
250
+ Empty/whitespace inputs get a zero vector of the model's dimension, keeping
251
+ row alignment.
252
+
253
+ ### `GET /v1/models`
254
+
255
+ Every configured model, each with its real embedding dimension
256
+ (`dimension` / `dimension_known`), backend, load state, and an `active` flag.
257
+
258
+ ### `GET /v1/models/{id}`
259
+
260
+ One model's detail (the OpenAI `retrieve` endpoint); 404 with the standard
261
+ error envelope when the id is unknown.
262
+
263
+ ### `POST /v1/models/{id}/activate`
264
+
265
+ Switch the active model (loads it on demand). A local-embed extension, not an
266
+ OpenAI endpoint.
267
+
268
+ ### `GET /health`
269
+
270
+ `{status, active_model, backend, model, dimension, dimension_known, loaded}`.
271
+
272
+ ### Any OpenAI client
273
+
274
+ Point `base_url` at `http://127.0.0.1:17347/v1` — the `openai` package, or any
275
+ OpenAI-compatible SDK:
276
+
277
+ ```python
278
+ from openai import OpenAI
279
+
280
+ client = OpenAI(base_url="http://127.0.0.1:17347/v1", api_key="local")
281
+ vecs = client.embeddings.create(model="bge-m3", input=["hello"])
282
+ ```
283
+
284
+ ## CLI (auxiliary tools)
285
+
286
+ The `local-embed` CLI is a **helper**, not the product: it starts the service
287
+ and writes the config file. The service is configured by `local_embed.json5`
288
+ and consumed over the HTTP API — the CLI only makes both steps convenient.
289
+
290
+ ### `local-embed` — run the service
291
+
292
+ ```bash
293
+ local-embed # binds 127.0.0.1:17347 by default
294
+ ```
295
+
296
+ The CLI takes no model/endpoint flags — the config is the only source of truth.
297
+ A port already in use is a hard error with one actionable line (no silent
298
+ fallback to a free port): stop the other instance or change `port` in the
299
+ config.
300
+
301
+ ### `local-embed set` / `set-gguf` — write the config
302
+
303
+ `set` (transformer) and `set-gguf` (gguf) upsert a model in the config, make it
304
+ **active**, and pin port (and, for `set`, the HF cache + offline flag). Both
305
+ are **idempotent** — re-running yields the same config — and leave other models
306
+ untouched.
307
+
308
+ | | `set` (transformer) | `set-gguf` |
309
+ |---|---|---|
310
+ | `<model_name>` | required — HF repo id (key + repo loaded) | required — config key |
311
+ | weight ref | `--HF_HUB_CACHE <dir>` — cache must contain the repo | `--path <PATH>` required — existing `.gguf` file |
312
+ | cache fallback | env `HF_HUB_CACHE`, else error | — |
313
+ | env written | `HF_HUB_CACHE` + `HF_HUB_OFFLINE: "1"` (offline server) | — |
314
+ | `--port <n>` | default `17347` | default `17347` |
315
+
316
+ ```bash
317
+ local-embed set BAAI/bge-m3 [--HF_HUB_CACHE <dir>] [--port 17347]
318
+ local-embed set-gguf bge-m3 --path D:\models\bge-m3\bge-m3-q8_0.gguf [--port 17347]
319
+ ```
320
+
321
+ Any error — cache unset, repo not in cache, file missing — exits non-zero and
322
+ writes nothing. Changes apply on the next server start.
323
+
324
+ ### Model download — offline by default
325
+
326
+ `HF_HUB_OFFLINE=1` by default, so the server **never downloads** a model (it's
327
+ too slow). Make the model available yourself — see
328
+ [Model weights](#model-weights): `hf download BAAI/bge-m3` for the transformer
329
+ route (into the HF cache), or drop a GGUF file and point `gguf_path` at it.
330
+
331
+ ## Dimension
332
+
333
+ The real output width is only known once the model is loaded (`n_embd` /
334
+ `get_sentence_embedding_dimension`). `GET /v1/models` (or `/v1/models/{id}`)
335
+ reports the real dimension, so a client can size its vector table correctly —
336
+ a wrong width silently drops every mis-sized embedding. A guessed dimension is
337
+ never served as authoritative.
338
+
339
+ ## As an MCP server
340
+
341
+ `/mcp` is a standard **MCP Streamable HTTP** endpoint, so any MCP client that
342
+ supports Streamable HTTP can register local-embed as an MCP server. Start it
343
+ first:
344
+
345
+ ```bash
346
+ local-embed # serve on http://127.0.0.1:17347
347
+ ```
348
+
349
+ then register the URL. The MCP client config shape is `{"type": "http",
350
+ "url": …}` — for example, Claude Code's `.mcp.json` (or `~/.claude.json`):
351
+
352
+ ```json
353
+ {
354
+ "mcpServers": {
355
+ "local-embed": {
356
+ "type": "http",
357
+ "url": "http://127.0.0.1:17347/mcp"
358
+ }
359
+ }
360
+ }
361
+ ```
362
+
363
+ Or with the Claude Code CLI:
364
+
365
+ ```bash
366
+ claude mcp add --transport http local-embed http://127.0.0.1:17347/mcp
367
+ ```
368
+
369
+ > **Claude Desktop** registers remote HTTP MCP servers through its **connectors
370
+ > UI** (Settings → Connectors → Add connector → Remote), not
371
+ > `claude_desktop_config.json` — and a remote connector expects a hosted HTTPS
372
+ > URL, so a `127.0.0.1` server needs a tunnel or a stdio→HTTP bridge to be
373
+ > reachable from the desktop app.
374
+
375
+ **Over MCP you get status, not embeddings.** The only MCP tool is the internal
376
+ `__check` status probe (active model, dimensions, load state) — local-embed is
377
+ a *service-provider* MCP server, not a tool provider. To embed text, point
378
+ your consumer at the OpenAI-compatible `/v1/*` API instead. A client that only
379
+ speaks stdio can reach it through a stdio→HTTP bridge (e.g. `mcp-remote`).
380
+
381
+ ## Loading as a slife plugin
382
+
383
+ local-embed is a standalone app, but it also conforms to the **slife plugin
384
+ contract** (defined in slife's `slife/server_utils.py`), so slife can load and
385
+ manage it as an external plugin — spawn the process, discover the port, and
386
+ monitor status — without any slife code changes. Conformance is additive:
387
+ none of it constrains standalone use, and local-embed never imports slife. The
388
+ few pieces of the contract it needs are carried as minimal, self-contained
389
+ copies (`local_embed.server_utils`, `local_embed.threads`,
390
+ `local_embed.logging`).
391
+
392
+ The MCP endpoint's single tool is the internal **`__check`**, which returns
393
+ engine status (active model, model list, dimensions, load state) as JSON. It
394
+ is a **service-provider** surface — slife probes `__check` programmatically and
395
+ consumes embeddings over the HTTP API, never through MCP tools.
396
+
397
+ How it conforms:
398
+
399
+ - **Spawn target** — `local_embed/server.py` exposes `main()`, spawnable via
400
+ `python -m local_embed.server` (the contract's `sys.executable -m <module>`
401
+ shape).
402
+ - **Port signal** — emits `{"port": N}` on stdout (then closes stdout) only
403
+ after the FastMCP app is ready to serve (its lifespan completed), so the
404
+ host's first `initialize` always lands on a ready server.
405
+ - **Readiness = MCP `initialize` handshake** — startup is handshake-fast:
406
+ backend imports are resolved lazily and the model warms up *after* the first
407
+ `tools/list` (`warm_after_handshake`), never in the lifespan.
408
+ - **Internal `__check` tool** — `__`-prefixed, so the host's tool registration
409
+ filters it out of the LLM registry and `system_health` probes it
410
+ programmatically.
411
+ - **Non-MCP routes on the same port** — the OpenAI endpoints are
412
+ `@mcp.custom_route` handlers on the same uvicorn app (the "one port, two
413
+ protocols" pattern).
414
+ - **Logging adoption** — when spawned by slife it adopts `SLIFE_LOG_DIR` /
415
+ `SLIFE_AGENT_NAME` / `SLIFE_SESSION_ID` / `SLIFE_PLUGIN_NAME`, so the session
416
+ log lands next to the main log; an uncaught startup exception prints one
417
+ clean stderr line (the host relays it) with the full traceback in the file.
418
+ Standalone falls back to `~/.local-embed/logs`.
419
+ - **Config path** — honors `$LOCAL_EMBED_FILE` (slife exports it as
420
+ `<dir of slife.json5>/local_embed.json5`), else the standalone default.
421
+ - **Stable port** — binds the configured `17347` rather than an OS-assigned
422
+ port, so slife's `base_url` is fixed; a taken port is a hard error, not a
423
+ silent fallback.
424
+
425
+ ### Registering with slife
426
+
427
+ Register local-embed as an external plugin and point slife's embedding config
428
+ at it (the unified OpenAI-compatible format slife uses for every embedding
429
+ model):
430
+
431
+ ```json5
432
+ plugins: {
433
+ external: [
434
+ { name: "local-embed", module: "local_embed.server" }
435
+ ]
436
+ },
437
+ embeddings: {
438
+ providers: {
439
+ "local-embed": {
440
+ base_url: "http://127.0.0.1:17347/v1", // stable port from local_embed.json5
441
+ api_key: "local",
442
+ }
443
+ },
444
+ active_model: "local-embed", // provider-id only, or "local-embed/<model>"
445
+ enabled: true
446
+ }
447
+ ```
448
+
449
+ slife treats every embedding model as a remote OpenAI-compatible endpoint —
450
+ local-embed is one such endpoint. The model is **determined by the plugin's
451
+ active model**: slife discovers it from `GET /v1/models` (the entry flagged
452
+ `active: true`) on load. When the service is unreachable, slife degrades
453
+ gracefully to keyword search.
454
+
455
+ ## License
456
+
457
+ MIT — see the repository root `LICENSE`.