lorebase-inference 0.2.3__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,313 @@
1
+ """Provider selection from configuration: the only place that names providers.
2
+
3
+ Chat providers: `openai_compatible`, `fake`. Embedding providers: `fastembed`,
4
+ `openai_compatible`, `fake`. Span extractor providers: `gliner`, `fake`. Relation extractor
5
+ providers: `glirel`, `cooccurrence`, `fake`.
6
+
7
+ The local model libraries are optional extras (`fastembed`, `local-extractors`). They are
8
+ imported lazily, only when a provider that needs them is created, and the small backend
9
+ functions below are the only code that calls them; everything else in the package works with
10
+ plain callables. Missing extras fail with an install hint, at `validate_providers(...,
11
+ require_installed=True)` or at creation.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import importlib
17
+ import importlib.util
18
+ from collections.abc import Callable, Mapping, Sequence
19
+ from dataclasses import dataclass
20
+ from typing import Any
21
+
22
+ from lorebase_inference.chat import EndpointChatModel
23
+ from lorebase_inference.errors import ProviderUnavailableError
24
+ from lorebase_inference.fake import FakeChatModel, FakeEmbeddingModel, FakeSpanExtractor
25
+ from lorebase_inference.interfaces import (
26
+ ChatModel,
27
+ EmbeddingModel,
28
+ RelationExtractor,
29
+ SpanExtractor,
30
+ )
31
+ from lorebase_inference.local_embeddings import EmbedBackend, LocalEmbeddingModel
32
+ from lorebase_inference.local_relations import (
33
+ CooccurrenceRelationExtractor,
34
+ LocalRelationExtractor,
35
+ RelationBackend,
36
+ )
37
+ from lorebase_inference.local_spans import LocalSpanExtractor, SpanBackend
38
+ from lorebase_inference.remote_embeddings import EndpointEmbeddingModel
39
+ from lorebase_inference.settings import (
40
+ EmbeddingSettings,
41
+ ExtractorSettings,
42
+ InferenceSettings,
43
+ LlmSettings,
44
+ )
45
+
46
+ ChatFactory = Callable[[InferenceSettings, LlmSettings], ChatModel]
47
+ EmbeddingFactory = Callable[[InferenceSettings], EmbeddingModel]
48
+ SpanFactory = Callable[[InferenceSettings], SpanExtractor]
49
+ RelationFactory = Callable[[InferenceSettings], RelationExtractor]
50
+
51
+
52
+ class UnknownProviderError(ValueError):
53
+ """The configuration names a provider that is not registered."""
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class Requirement:
58
+ """Optional Python modules a provider imports, and the extra that installs them."""
59
+
60
+ modules: tuple[str, ...]
61
+ extra: str
62
+
63
+ def missing(self) -> list[str]:
64
+ """Modules that cannot be imported."""
65
+ return [m for m in self.modules if importlib.util.find_spec(m) is None]
66
+
67
+ def ensure(self, provider: str) -> None:
68
+ """Raise `ProviderUnavailableError` with an install hint when a module is missing."""
69
+ missing = self.missing()
70
+ if missing:
71
+ raise ProviderUnavailableError(
72
+ f"provider {provider!r} needs {', '.join(missing)}, which is not installed; "
73
+ f"install it with: pip install 'lorebase-inference[{self.extra}]'"
74
+ )
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class _Entry[F]:
79
+ factory: F
80
+ requires: Requirement | None = None
81
+
82
+
83
+ class ProviderRegistry:
84
+ """Maps provider names to factories for each model kind."""
85
+
86
+ def __init__(self) -> None:
87
+ self._chat: dict[str, _Entry[ChatFactory]] = {}
88
+ self._embedding: dict[str, _Entry[EmbeddingFactory]] = {}
89
+ self._span: dict[str, _Entry[SpanFactory]] = {}
90
+ self._relation: dict[str, _Entry[RelationFactory]] = {}
91
+
92
+ def register_chat(
93
+ self, name: str, factory: ChatFactory, requires: Requirement | None = None
94
+ ) -> None:
95
+ self._chat[name] = _Entry(factory, requires)
96
+
97
+ def register_embedding(
98
+ self, name: str, factory: EmbeddingFactory, requires: Requirement | None = None
99
+ ) -> None:
100
+ self._embedding[name] = _Entry(factory, requires)
101
+
102
+ def register_span_extractor(
103
+ self, name: str, factory: SpanFactory, requires: Requirement | None = None
104
+ ) -> None:
105
+ self._span[name] = _Entry(factory, requires)
106
+
107
+ def register_relation_extractor(
108
+ self, name: str, factory: RelationFactory, requires: Requirement | None = None
109
+ ) -> None:
110
+ self._relation[name] = _Entry(factory, requires)
111
+
112
+ def validate(self, settings: InferenceSettings, *, require_installed: bool = False) -> None:
113
+ """Fail if any configured provider, including per-task ones, is not registered.
114
+
115
+ With `require_installed`, also fail when a selected provider's optional dependency is
116
+ missing (use at service start); without it the check is on names only.
117
+ """
118
+ llm = settings.llm
119
+ selected: list[tuple[str, _Entry[Any]]] = []
120
+ for task in (None, *llm.tasks):
121
+ chat = llm.for_task(task).provider
122
+ selected.append((chat, _lookup(self._chat, "chat", chat)))
123
+ embedding = settings.embeddings.provider
124
+ selected.append((embedding, _lookup(self._embedding, "embedding", embedding)))
125
+ extractors = settings.local_extractors
126
+ span = extractors.provider
127
+ selected.append((span, _lookup(self._span, "span extractor", span)))
128
+ relation = extractors.relation_provider
129
+ selected.append((relation, _lookup(self._relation, "relation extractor", relation)))
130
+ if require_installed:
131
+ problems: dict[str, None] = {}
132
+ for provider, entry in selected:
133
+ try:
134
+ _ensure(entry, provider)
135
+ except ProviderUnavailableError as exc:
136
+ problems[str(exc)] = None
137
+ if problems:
138
+ raise ProviderUnavailableError("; ".join(problems))
139
+
140
+ def create_chat_model(self, settings: InferenceSettings, task: str | None = None) -> ChatModel:
141
+ """Chat model for `task` (that task's overrides applied), or the default one.
142
+
143
+ The default model routes `ChatOptions.task` to the matching override by itself.
144
+ """
145
+ llm = settings.llm.for_task(task) if task else settings.llm
146
+ entry = _lookup(self._chat, "chat", llm.provider)
147
+ _ensure(entry, llm.provider)
148
+ return entry.factory(settings, llm)
149
+
150
+ def create_embedding_model(self, settings: InferenceSettings) -> EmbeddingModel:
151
+ """Embedding model of the configured provider."""
152
+ name = settings.embeddings.provider
153
+ entry = _lookup(self._embedding, "embedding", name)
154
+ _ensure(entry, name)
155
+ return entry.factory(settings)
156
+
157
+ def create_span_extractor(self, settings: InferenceSettings) -> SpanExtractor:
158
+ """Tier-1 span extractor of the configured provider."""
159
+ name = settings.local_extractors.provider
160
+ entry = _lookup(self._span, "span extractor", name)
161
+ _ensure(entry, name)
162
+ return entry.factory(settings)
163
+
164
+ def create_relation_extractor(self, settings: InferenceSettings) -> RelationExtractor:
165
+ """Tier-1 relation extractor of the configured provider."""
166
+ name = settings.local_extractors.relation_provider
167
+ entry = _lookup(self._relation, "relation extractor", name)
168
+ _ensure(entry, name)
169
+ return entry.factory(settings)
170
+
171
+
172
+ def _lookup[F](table: dict[str, _Entry[F]], kind: str, name: str) -> _Entry[F]:
173
+ try:
174
+ return table[name]
175
+ except KeyError:
176
+ known = ", ".join(sorted(table)) or "none"
177
+ raise UnknownProviderError(
178
+ f"unknown {kind} provider {name!r}; available: {known}"
179
+ ) from None
180
+
181
+
182
+ def _ensure(entry: _Entry[Any], name: str) -> None:
183
+ if entry.requires:
184
+ entry.requires.ensure(name)
185
+
186
+
187
+ # ---- local model backends: the only code that calls the optional libraries -----------------
188
+
189
+
190
+ def _import(module: str) -> Any:
191
+ return importlib.import_module(module)
192
+
193
+
194
+ def _fastembed_backend(embeddings: EmbeddingSettings) -> Callable[[], EmbedBackend]:
195
+ """Factory of a CPU ONNX embedding backend; the model is loaded when first needed."""
196
+
197
+ def build() -> EmbedBackend:
198
+ model = _import("fastembed").TextEmbedding(
199
+ model_name=embeddings.model,
200
+ cache_dir=str(embeddings.cache_dir) if embeddings.cache_dir else None,
201
+ threads=embeddings.threads,
202
+ providers=["CPUExecutionProvider"],
203
+ )
204
+
205
+ def embed(texts: Sequence[str]) -> list[list[float]]:
206
+ return [vector.tolist() for vector in model.embed(list(texts), batch_size=len(texts))]
207
+
208
+ return embed
209
+
210
+ return build
211
+
212
+
213
+ def _gliner_backend(extractor: ExtractorSettings) -> Callable[[], SpanBackend]:
214
+ """Factory of a CPU zero-shot entity backend; the checkpoint is loaded when first needed."""
215
+
216
+ def build() -> SpanBackend:
217
+ model = _import("gliner").GLiNER.from_pretrained(
218
+ extractor.model,
219
+ cache_dir=str(extractor.cache_dir) if extractor.cache_dir else None,
220
+ map_location="cpu",
221
+ )
222
+
223
+ def predict(text: str, labels: list[str], threshold: float) -> list[Mapping[str, Any]]:
224
+ entities: list[Mapping[str, Any]] = model.predict_entities(
225
+ text, labels, threshold=threshold
226
+ )
227
+ return entities
228
+
229
+ return predict
230
+
231
+ return build
232
+
233
+
234
+ def _glirel_backend(extractor: ExtractorSettings) -> Callable[[], RelationBackend]:
235
+ """Factory of a CPU zero-shot relation backend; loaded when first needed."""
236
+
237
+ def build() -> RelationBackend:
238
+ glirel = _import("glirel").GLiREL
239
+ cache = str(extractor.cache_dir) if extractor.cache_dir else None
240
+ try:
241
+ model = glirel.from_pretrained(extractor.model, cache_dir=cache)
242
+ except TypeError:
243
+ # glirel 1.2 with huggingface_hub 1.x: the hub mixin no longer passes two arguments
244
+ # that the library's loader requires. Call the loader directly with their old values.
245
+ model = glirel._from_pretrained(
246
+ model_id=extractor.model,
247
+ revision=None,
248
+ cache_dir=cache,
249
+ force_download=False,
250
+ proxies=None,
251
+ resume_download=False,
252
+ local_files_only=False,
253
+ token=None,
254
+ )
255
+
256
+ def predict(
257
+ tokens: list[str], entities: list[list[Any]], labels: list[str], threshold: float
258
+ ) -> list[Mapping[str, Any]]:
259
+ relations: list[Mapping[str, Any]] = model.predict_relations(
260
+ tokens, labels, threshold=threshold, ner=entities, top_k=-1
261
+ )
262
+ return relations
263
+
264
+ return predict
265
+
266
+ return build
267
+
268
+
269
+ EMBEDDINGS_EXTRA = Requirement(("fastembed",), "fastembed")
270
+ LOCAL_EXTRACTORS_EXTRA = Requirement(("gliner",), "local-extractors")
271
+ RELATION_EXTRA = Requirement(("glirel",), "local-extractors")
272
+
273
+
274
+ def build_default_registry() -> ProviderRegistry:
275
+ """Registry with every provider this package ships."""
276
+ registry = ProviderRegistry()
277
+ registry.register_chat("fake", lambda s, llm: FakeChatModel(llm, s.fake))
278
+ registry.register_chat("openai_compatible", lambda _s, llm: EndpointChatModel(llm))
279
+ registry.register_embedding("fake", lambda s: FakeEmbeddingModel(s.embeddings))
280
+ registry.register_embedding("openai_compatible", lambda s: EndpointEmbeddingModel(s.embeddings))
281
+ registry.register_embedding(
282
+ "fastembed",
283
+ lambda s: LocalEmbeddingModel(s.embeddings, _fastembed_backend(s.embeddings)),
284
+ EMBEDDINGS_EXTRA,
285
+ )
286
+ registry.register_span_extractor("fake", lambda s: FakeSpanExtractor(s.fake.dictionary))
287
+ registry.register_span_extractor(
288
+ "gliner",
289
+ lambda s: LocalSpanExtractor(
290
+ s.local_extractors.gliner, _gliner_backend(s.local_extractors.gliner)
291
+ ),
292
+ LOCAL_EXTRACTORS_EXTRA,
293
+ )
294
+ registry.register_relation_extractor(
295
+ "glirel",
296
+ lambda s: LocalRelationExtractor(
297
+ s.local_extractors.glirel, _glirel_backend(s.local_extractors.glirel)
298
+ ),
299
+ RELATION_EXTRA,
300
+ )
301
+ for name in ("cooccurrence", "fake"):
302
+ registry.register_relation_extractor(
303
+ name, lambda s: CooccurrenceRelationExtractor(s.local_extractors.cooccurrence)
304
+ )
305
+ return registry
306
+
307
+
308
+ default_registry = build_default_registry()
309
+ create_chat_model = default_registry.create_chat_model
310
+ create_embedding_model = default_registry.create_embedding_model
311
+ create_span_extractor = default_registry.create_span_extractor
312
+ create_relation_extractor = default_registry.create_relation_extractor
313
+ validate_providers = default_registry.validate
@@ -0,0 +1,100 @@
1
+ """Embeddings from a compatible HTTP endpoint: `POST {base_url}/embeddings`.
2
+
3
+ Works with any local server, proxy or hosted endpoint that implements the embeddings route
4
+ (docs/inference-providers.md lists the tested ones). Texts are sent in batches of `batch_size`;
5
+ the answer is checked for count and dimension so that a model swap cannot silently corrupt a
6
+ vector index.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from collections.abc import Sequence
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+ from lorebase_inference.errors import DimensionMismatchError, EndpointError
18
+ from lorebase_inference.interfaces import Usage
19
+ from lorebase_inference.settings import EmbeddingSettings
20
+ from lorebase_inference.transport import EndpointClient, Sleep
21
+
22
+ _CHARS_PER_TOKEN = 4
23
+
24
+
25
+ class EndpointEmbeddingModel:
26
+ """`EmbeddingModel` for an HTTP embeddings endpoint."""
27
+
28
+ def __init__(
29
+ self,
30
+ embeddings: EmbeddingSettings,
31
+ *,
32
+ max_concurrency: int = 2,
33
+ transport: httpx.AsyncBaseTransport | None = None,
34
+ sleep: Sleep | None = None,
35
+ ) -> None:
36
+ if not embeddings.base_url:
37
+ raise ValueError("embeddings.base_url is required for this provider")
38
+ self.name = embeddings.model
39
+ self.dims = embeddings.dims
40
+ self.usage = Usage()
41
+ """Tokens sent so far, summed over calls (estimated when the server omits counts)."""
42
+ self._batch_size = embeddings.batch_size
43
+ self._client = EndpointClient(
44
+ base_url=embeddings.base_url,
45
+ api_key=embeddings.api_key,
46
+ timeout_s=embeddings.timeout_s,
47
+ retry=embeddings.retry,
48
+ trust_env=embeddings.trust_env,
49
+ max_concurrency=max_concurrency,
50
+ transport=transport,
51
+ sleep=sleep,
52
+ )
53
+
54
+ async def embed(self, texts: Sequence[str]) -> list[list[float]]:
55
+ """One vector of `dims` floats per text, in order."""
56
+ vectors: list[list[float]] = []
57
+ for start in range(0, len(texts), self._batch_size):
58
+ vectors.extend(await self._embed_batch(list(texts[start : start + self._batch_size])))
59
+ return vectors
60
+
61
+ async def aclose(self) -> None:
62
+ """Release connections."""
63
+ await self._client.aclose()
64
+
65
+ async def _embed_batch(self, batch: list[str]) -> list[list[float]]:
66
+ body = await self._client.post("/embeddings", {"model": self.name, "input": batch})
67
+ data = body.get("data")
68
+ if not isinstance(data, list) or len(data) != len(batch):
69
+ raise EndpointError(
70
+ f"/embeddings: expected {len(batch)} vectors, got "
71
+ f"{len(data) if isinstance(data, list) else 'none'}"
72
+ )
73
+ ordered = sorted(
74
+ data, key=lambda item: item.get("index", 0) if isinstance(item, dict) else 0
75
+ )
76
+ vectors = [_vector(item) for item in ordered]
77
+ for vector in vectors:
78
+ if len(vector) != self.dims:
79
+ raise DimensionMismatchError(
80
+ f"model {self.name!r} returned {len(vector)} dimensions, "
81
+ f"embeddings.dims is {self.dims}"
82
+ )
83
+ raw = body.get("usage")
84
+ reported = raw.get("prompt_tokens") if isinstance(raw, dict) else None
85
+ tokens = (
86
+ reported
87
+ if isinstance(reported, int) and not isinstance(reported, bool)
88
+ else math.ceil(sum(len(t) for t in batch) / _CHARS_PER_TOKEN)
89
+ )
90
+ self.usage = Usage(self.usage.input_tokens + tokens, 0)
91
+ return vectors
92
+
93
+
94
+ def _vector(item: Any) -> list[float]:
95
+ embedding = item.get("embedding") if isinstance(item, dict) else None
96
+ if not isinstance(embedding, list) or not all(
97
+ isinstance(v, int | float) and not isinstance(v, bool) for v in embedding
98
+ ):
99
+ raise EndpointError("/embeddings: entry without a numeric vector")
100
+ return [float(v) for v in embedding]