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.
- local_embed/__init__.py +29 -0
- local_embed/__main__.py +8 -0
- local_embed/cli.py +88 -0
- local_embed/config.py +200 -0
- local_embed/engine.py +555 -0
- local_embed/logging.py +122 -0
- local_embed/server.py +370 -0
- local_embed/server_utils.py +197 -0
- local_embed/threads.py +71 -0
- local_embed-0.1.0.dist-info/METADATA +195 -0
- local_embed-0.1.0.dist-info/RECORD +13 -0
- local_embed-0.1.0.dist-info/WHEEL +4 -0
- local_embed-0.1.0.dist-info/entry_points.txt +2 -0
local_embed/__init__.py
ADDED
|
@@ -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__"]
|
local_embed/__main__.py
ADDED
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
|
+
}
|