local-embed 0.1.0__py3-none-any.whl

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,29 @@
1
+ """local-embed — standalone local embedding server.
2
+
3
+ Loads a local GGUF (llama-cpp) or HF transformer embedding model ONCE and
4
+ exposes it as an OpenAI-compatible ``/v1/embeddings`` HTTP endpoint plus
5
+ FastMCP tools. slife's memdb/memfiles plugins both call it over HTTP, so
6
+ the model is never loaded twice in one process tree.
7
+
8
+ Modules::
9
+
10
+ server.py FastMCP plugin — MCP tools + /v1/embeddings custom route
11
+ engine.py Model engine (gguf / transformer), lazy load + encode
12
+ cli.py Console entry point (``local-embed``)
13
+ threads.py run_daemon — daemon-thread offload for blocking model calls
14
+ logging.py Structured logging setup
15
+
16
+ Usage::
17
+
18
+ local-embed --backend gguf --gguf-path /path/to/model.gguf
19
+ local-embed --backend transformer --model BAAI/bge-m3
20
+ """
21
+
22
+ try:
23
+ from importlib.metadata import version as _version
24
+
25
+ __version__ = _version("local-embed")
26
+ except Exception:
27
+ __version__ = "0.0.0"
28
+
29
+ __all__ = ["__version__"]
@@ -0,0 +1,8 @@
1
+ """Entry point for ``python -m local_embed`` — delegates to the CLI."""
2
+
3
+ import sys
4
+
5
+ from local_embed.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
local_embed/cli.py ADDED
@@ -0,0 +1,88 @@
1
+ """Console entry point for local-embed.
2
+
3
+ Two entry paths share this module:
4
+
5
+ - ``local-embed …`` (console script / ``python -m local_embed``): run the
6
+ server on an explicit host:port as a standalone service.
7
+
8
+ - ``python -m local_embed.server``: the **plugin spawn target** used by a
9
+ host (slife) — binds a free port, serves MCP on ``/mcp`` and embeddings
10
+ on the same port, and signals the parent. That path lives in
11
+ :mod:`local_embed.server` and does NOT go through this CLI.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import logging
18
+ import sys
19
+
20
+ from local_embed.config import resolve_engine_settings
21
+ from local_embed.engine import Engine, check_backend_runtime
22
+ from local_embed.logging import setup_logging
23
+
24
+
25
+ def build_parser() -> argparse.ArgumentParser:
26
+ p = argparse.ArgumentParser(
27
+ prog="local-embed",
28
+ description=(
29
+ "Standalone local embedding server — expose a GGUF (llama-cpp) or "
30
+ "HF transformer model as an OpenAI-compatible /v1/embeddings service."
31
+ ),
32
+ )
33
+ p.add_argument("--host", default="127.0.0.1", help="bind host (default 127.0.0.1)")
34
+ p.add_argument("--port", type=int, default=8000, help="bind port (default 8000)")
35
+ p.add_argument(
36
+ "--backend",
37
+ choices=("gguf", "transformer"),
38
+ default="gguf",
39
+ help="model backend (default gguf)",
40
+ )
41
+ p.add_argument("--model", default="bge-m3", help="model name/id (for metadata and dim guessing)")
42
+ p.add_argument("--gguf-path", default="", help="path to a GGUF file (backend=gguf)")
43
+ p.add_argument("--device", default="", help="device for transformer backend: cpu | cuda | '' (auto)")
44
+ p.add_argument("--log-level", default="INFO", help="logging level (default INFO)")
45
+ return p
46
+
47
+
48
+ def main(argv: "list[str] | None" = None) -> int:
49
+ args = build_parser().parse_args(argv)
50
+ setup_logging(getattr(logging, args.log_level.upper(), logging.INFO))
51
+
52
+ # CLI flags are one-model overrides on top of local_embed.json5. A
53
+ # config file with a ``models`` map wins; explicit flags on a
54
+ # single-model config override its keys.
55
+ settings = resolve_engine_settings(
56
+ overrides={
57
+ "backend": args.backend,
58
+ "model": args.model,
59
+ "gguf_path": args.gguf_path,
60
+ "device": args.device,
61
+ }
62
+ )
63
+ engine = Engine(specs=settings["specs"], active=settings["active"])
64
+
65
+ # Validate the gguf backend actually has a model to load.
66
+ for spec in settings["specs"]:
67
+ if spec.backend == "gguf" and not spec.gguf_path:
68
+ print(
69
+ f"Error: no gguf_path for model '{spec.name}'. "
70
+ "Set gguf_path in local_embed.json5 or pass --gguf-path.",
71
+ file=sys.stderr,
72
+ )
73
+ return 2
74
+ if not check_backend_runtime(spec.backend):
75
+ print(
76
+ f"Error: {spec.backend} backend for model '{spec.name}' is not installed. "
77
+ f"Install with: uv pip install 'local-embed[{spec.backend}]'",
78
+ file=sys.stderr,
79
+ )
80
+ return 2
81
+
82
+ from local_embed.server import serve_standalone
83
+
84
+ return serve_standalone(engine, host=args.host, port=args.port)
85
+
86
+
87
+ if __name__ == "__main__":
88
+ sys.exit(main())
local_embed/config.py ADDED
@@ -0,0 +1,200 @@
1
+ """local-embed config — load ``local_embed.json5``, path resolution.
2
+
3
+ Path precedence (mirrors mcp-plugin / credstore):
4
+ 1. ``$LOCAL_EMBED_FILE`` — a host (slife) exports this =
5
+ ``<dir of slife.json5>/local_embed.json5`` before it launches the
6
+ plugin child, so the config sits next to the host's config
7
+ 2. slife project root (dev): CWD is the slife source root
8
+ (``pyproject.toml`` ``project.name == "slife"``) — ``./local_embed.json5``
9
+ (credstore's ``is_slife_dev`` pattern)
10
+ 3. ``~/.local-embed/local_embed.json5`` (standalone default, credstore-style)
11
+
12
+ Config shape::
13
+
14
+ {
15
+ active_model: "bge-m3",
16
+ models: {
17
+ "bge-m3": { backend: "gguf", gguf_path: "…", device: "" },
18
+ "bge-m3-transformer": { backend: "transformer", model: "BAAI/bge-m3" },
19
+ },
20
+ host: "127.0.0.1", // standalone only
21
+ port: 8000, // standalone only
22
+ }
23
+
24
+ ``env`` (optional, top level) is injected into this process's environment
25
+ by :func:`apply_env` before any backend loads — a ``transformer`` ``model``
26
+ given as a HF *repo name* (e.g. ``BAAI/bge-m3``) resolves against the local
27
+ hub cache via ``HF_HUB_CACHE`` / ``HF_HUB_OFFLINE`` without the host
28
+ exporting anything::
29
+
30
+ {
31
+ active_model: "bge-m3-transformer",
32
+ env: { HF_HUB_CACHE: "C:\\…\\HuggingFace\\hub", HF_HUB_OFFLINE: "1" },
33
+ models: { "bge-m3-transformer": { backend: "transformer", model: "BAAI/bge-m3" } },
34
+ }
35
+
36
+ Single-model convenience (still supported) — ``backend`` / ``model`` /
37
+ ``gguf_path`` / ``device`` at the top level, exactly one model::
38
+
39
+ { backend: "gguf", model: "bge-m3", gguf_path: "…", device: "" }
40
+
41
+ Reads are read-only at runtime — local-embed has no config-mutating tools
42
+ (mirrors mcp-plugin's self-hosted config, minus the persistence).
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import json5
48
+ import logging
49
+ import os
50
+ import tomllib
51
+ from pathlib import Path
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+ DEFAULT_HOST = "127.0.0.1"
56
+ DEFAULT_PORT = 8000
57
+
58
+
59
+ def default_config_path() -> Path:
60
+ """Standalone default: ``~/.local-embed/local_embed.json5``."""
61
+ return Path.home() / ".local-embed" / "local_embed.json5"
62
+
63
+
64
+ def resolve_config_path() -> Path:
65
+ """Return the local_embed.json5 path for this process.
66
+
67
+ Precedence (mirrors mcp-plugin's ``resolve_config_path``):
68
+ ``$LOCAL_EMBED_FILE`` > slife project root (dev) > standalone default.
69
+ """
70
+ env = os.environ.get("LOCAL_EMBED_FILE")
71
+ if env:
72
+ return Path(env).expanduser()
73
+ if is_slife_dev():
74
+ return Path("local_embed.json5")
75
+ return default_config_path()
76
+
77
+
78
+ def is_slife_dev() -> bool:
79
+ """Whether we're running from the slife source root (credstore-style).
80
+
81
+ Returns True when the CWD contains a ``pyproject.toml`` with
82
+ ``project.name == "slife"``.
83
+ """
84
+ try:
85
+ data = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
86
+ except Exception:
87
+ return False
88
+ return data.get("project", {}).get("name") == "slife"
89
+
90
+
91
+ def load_config(path: "Path | None" = None) -> dict:
92
+ """Load the local-embed config dict, ``{}`` when the file is absent.
93
+
94
+ A file that exists but cannot be parsed raises (a broken config must
95
+ not be silently replaced by defaults).
96
+ """
97
+ if path is None:
98
+ path = resolve_config_path()
99
+ try:
100
+ return json5.loads(path.read_text(encoding="utf-8"))
101
+ except FileNotFoundError:
102
+ logger.info("config_not_found path=%s", path)
103
+ return {}
104
+ except (ValueError, OSError) as e:
105
+ logger.error("config_parse_error path=%s err=%s", path, e)
106
+ raise ValueError(f"Cannot parse config {path}: {e}") from e
107
+
108
+
109
+ def apply_env() -> dict:
110
+ """Inject local-embed's ``env:`` config section into os.environ.
111
+
112
+ A transformer backend loads its model by HF *repo name* (e.g.
113
+ ``BAAI/bge-m3``); huggingface_hub resolves that name against the local
114
+ hub cache, which defaults to ``~/.cache/huggingface``. ``env:`` in the
115
+ config makes the server self-contained — it exports ``HF_HUB_CACHE`` /
116
+ ``HF_HUB_OFFLINE`` (or anything else) into its *own* process before any
117
+ backend loads, and no external ``HF_*`` export is needed from the host.
118
+
119
+ Precedence mirrors slife.json5's ``env:`` injection: an existing
120
+ ``os.environ`` value wins, so a host can always override the config
121
+ file. Returns the effective env vars (for tests).
122
+ """
123
+ cfg = load_config()
124
+ effective: dict = {}
125
+ for key, value in (cfg.get("env") or {}).items():
126
+ if os.environ.get(key):
127
+ logger.info("env_from_shell key=%s", key)
128
+ continue
129
+ os.environ[key] = str(value)
130
+ effective[key] = str(value)
131
+ logger.info("env_injected key=%s", key)
132
+ return effective
133
+
134
+
135
+ def resolve_engine_settings(overrides: "dict | None" = None) -> dict:
136
+ """Merge config file + env overrides into engine settings.
137
+
138
+ Precedence: env vars (plugin spawn) > config file > defaults. Returns
139
+ ``{"specs": [ModelSpec, ...], "active": str, "host", "port"}``.
140
+
141
+ A ``models`` map (multi-model) takes precedence; otherwise the
142
+ single-model top-level keys build one spec.
143
+ """
144
+ from local_embed.engine import ModelSpec
145
+
146
+ apply_env() # config env: → own process env, before any model loads
147
+ cfg = load_config()
148
+ overrides = overrides or {}
149
+
150
+ def _pick(key: str, default):
151
+ env_val = os.environ.get(f"LOCAL_EMBED_{key.upper()}")
152
+ if env_val not in (None, ""):
153
+ return env_val
154
+ if key in overrides and overrides[key] not in (None, ""):
155
+ return overrides[key]
156
+ if key in cfg and cfg[key] not in (None, ""):
157
+ return cfg[key]
158
+ return default
159
+
160
+ specs: list = []
161
+ models_cfg = cfg.get("models")
162
+ if isinstance(models_cfg, dict) and models_cfg:
163
+ for name, m in models_cfg.items():
164
+ if not isinstance(m, dict):
165
+ continue
166
+ # env override may point at the single model keyed by its name
167
+ specs.append(
168
+ ModelSpec(
169
+ name,
170
+ backend=m.get("backend", "gguf"),
171
+ model=m.get("model") or name,
172
+ gguf_path=m.get("gguf_path") or None,
173
+ device=m.get("device", ""),
174
+ max_tokens=int(m.get("max_tokens", 0) or 0),
175
+ )
176
+ )
177
+ # Precedence: env override > config file > default (mirrors _pick).
178
+ active = _pick("active_model", specs[0].name)
179
+ if active not in {s.name for s in specs}:
180
+ active = specs[0].name
181
+ else:
182
+ backend = _pick("backend", "gguf")
183
+ model = _pick("model", "bge-m3")
184
+ specs = [
185
+ ModelSpec(
186
+ model,
187
+ backend=backend,
188
+ model=model,
189
+ gguf_path=_pick("gguf_path", "") or None,
190
+ device=_pick("device", ""),
191
+ )
192
+ ]
193
+ active = model
194
+
195
+ return {
196
+ "specs": specs,
197
+ "active": active,
198
+ "host": cfg.get("host", overrides.get("host", DEFAULT_HOST)),
199
+ "port": int(cfg.get("port", overrides.get("port", DEFAULT_PORT))),
200
+ }