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,70 @@
1
+ """Model-agnostic inference: interfaces, provider registry and a deterministic fake provider.
2
+
3
+ Importing the package registers the `inference` settings section (with the `LLM_*` environment
4
+ aliases) with `lorebase_platform`.
5
+ """
6
+
7
+ from lorebase_inference.errors import (
8
+ DimensionMismatchError,
9
+ EndpointError,
10
+ InferenceError,
11
+ InvalidOutputError,
12
+ ProviderUnavailableError,
13
+ )
14
+ from lorebase_inference.interfaces import (
15
+ ChatModel,
16
+ ChatOptions,
17
+ ChatResult,
18
+ EmbeddingModel,
19
+ JsonSchema,
20
+ Label,
21
+ Message,
22
+ RelationExtractor,
23
+ RelationLabel,
24
+ Span,
25
+ SpanExtractor,
26
+ SpanRelation,
27
+ Usage,
28
+ )
29
+ from lorebase_inference.registry import (
30
+ ProviderRegistry,
31
+ UnknownProviderError,
32
+ create_chat_model,
33
+ create_embedding_model,
34
+ create_relation_extractor,
35
+ create_span_extractor,
36
+ validate_providers,
37
+ )
38
+ from lorebase_inference.settings import InferenceSettings, inference_settings
39
+
40
+ __version__ = "0.2.3"
41
+
42
+ __all__ = [
43
+ "ChatModel",
44
+ "ChatOptions",
45
+ "ChatResult",
46
+ "DimensionMismatchError",
47
+ "EmbeddingModel",
48
+ "EndpointError",
49
+ "InferenceError",
50
+ "InferenceSettings",
51
+ "InvalidOutputError",
52
+ "JsonSchema",
53
+ "Label",
54
+ "Message",
55
+ "ProviderRegistry",
56
+ "ProviderUnavailableError",
57
+ "RelationExtractor",
58
+ "RelationLabel",
59
+ "Span",
60
+ "SpanExtractor",
61
+ "SpanRelation",
62
+ "UnknownProviderError",
63
+ "Usage",
64
+ "create_chat_model",
65
+ "create_embedding_model",
66
+ "create_relation_extractor",
67
+ "create_span_extractor",
68
+ "inference_settings",
69
+ "validate_providers",
70
+ ]
@@ -0,0 +1,6 @@
1
+ """Run the command line: `python -m lorebase_inference check`."""
2
+
3
+ from lorebase_inference.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,447 @@
1
+ """Chat completions with schema-valid JSON over any compatible HTTP endpoint.
2
+
3
+ One client covers every server and proxy, local or hosted, that speaks the chat completions
4
+ protocol (see docs/inference-providers.md for the tested list): `POST {base_url}/chat/completions`
5
+ with `Authorization: Bearer <key>` when a key is configured, plus the configured `headers`;
6
+ `extra_body` fields are merged into every request. The model has no tools and nothing is
7
+ streamed.
8
+
9
+ Structured output is requested in decreasing order of strength and the answer is always
10
+ validated against the schema locally:
11
+
12
+ 1. `json_schema`: `response_format` of type `json_schema` carrying the schema;
13
+ 2. `json_mode`: `response_format` of type `json_object`, with the schema in the system prompt;
14
+ 3. `prompt`: no `response_format`, schema and a strict instruction in the system prompt.
15
+
16
+ The first mode is chosen by `structured_output`. When the server rejects a mode with HTTP
17
+ 400 or 422 and an error text that names the response format, the client steps down one mode
18
+ and remembers it per endpoint, model and output settings, in the process and, when a
19
+ `ModeMemory` is installed (`use_mode_memory`), across restarts, so later clients start at the
20
+ mode that works. A parse or validation failure triggers up
21
+ to `repair_attempts` repair calls that show the model its mistakes; usage of all calls is
22
+ summed in the result.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import hashlib
29
+ import hmac
30
+ import json
31
+ import logging
32
+ import math
33
+ import re
34
+ import threading
35
+ from collections import OrderedDict
36
+ from collections.abc import Sequence
37
+ from dataclasses import dataclass
38
+ from typing import Any, Protocol
39
+
40
+ import httpx
41
+ from jsonschema import exceptions as schema_exceptions
42
+ from jsonschema import validators
43
+
44
+ from lorebase_inference.errors import EndpointError, InvalidOutputError
45
+ from lorebase_inference.interfaces import ChatOptions, ChatResult, JsonSchema, Message, Usage
46
+ from lorebase_inference.settings import LlmSettings
47
+ from lorebase_inference.transport import EndpointClient, Sleep
48
+
49
+ logger = logging.getLogger("lorebase.inference")
50
+
51
+ MODES = ("json_schema", "json_mode", "prompt")
52
+ _UNSUPPORTED_STATUS = frozenset({400, 422})
53
+ _FORMAT_HINT = re.compile(
54
+ r"response_format|json_schema|json_object|structured|grammar|schema", re.IGNORECASE
55
+ )
56
+ _MODE_CACHE_LIMIT = 256
57
+ _FENCE = re.compile(r"^```[a-zA-Z0-9_-]*\s*\n?(.*?)\n?```\s*$", re.DOTALL)
58
+ _THINK = re.compile(r"<think>.*?</think>", re.DOTALL)
59
+ _ECHO_LIMIT = 4000
60
+ _MAX_PROBLEMS = 5
61
+ _CHARS_PER_TOKEN = 4
62
+
63
+ _SCHEMA_INSTRUCTION = (
64
+ "Answer with a single JSON value that conforms to the JSON Schema below. "
65
+ "Output only the JSON: no prose, no comments, no code fences.\n\nJSON Schema:\n"
66
+ )
67
+ _REPAIR_INSTRUCTION = (
68
+ "Your previous answer is not valid: {problems}\n"
69
+ "Answer again with only the corrected JSON value that conforms to the schema."
70
+ )
71
+
72
+
73
+ _ModeKey = tuple[str, str, str, bool | None]
74
+ _known_modes: OrderedDict[_ModeKey, int] = OrderedDict()
75
+ _known_modes_lock = threading.Lock()
76
+
77
+
78
+ def reset_known_modes() -> None:
79
+ """Forget the structured output modes learned from server rejections (for tests)."""
80
+ with _known_modes_lock:
81
+ _known_modes.clear()
82
+
83
+
84
+ class ModeMemory(Protocol):
85
+ """Durable memory of the modes learned from rejections, keyed by `mode_digest`.
86
+
87
+ `max_age_s` is how long an entry counts: an older one reads as absent and is replaced by
88
+ the next `remember`. Failures of the memory never fail a call: the client falls back to
89
+ the in-process cache.
90
+ """
91
+
92
+ async def recall(self, digest: bytes, max_age_s: float) -> int | None: ...
93
+
94
+ async def remember(self, digest: bytes, index: int, max_age_s: float) -> None: ...
95
+
96
+
97
+ @dataclass(frozen=True, slots=True)
98
+ class _Installed:
99
+ memory: ModeMemory
100
+ secret: bytes | None
101
+ max_age_s: float
102
+
103
+
104
+ _installed: _Installed | None = None
105
+ _DIGEST_LABEL = b"lorebase/inference/structured-output-mode/v1"
106
+
107
+
108
+ def use_mode_memory(
109
+ memory: ModeMemory | None, *, secret: str | None = None, max_age_s: float = 7 * 86400.0
110
+ ) -> None:
111
+ """Install (or, with None, remove) the durable memory shared by new and live clients.
112
+
113
+ `secret` keys the digests (`inference.mode_memory_key`); entries expire after `max_age_s`.
114
+ """
115
+ global _installed
116
+ _installed = (
117
+ None
118
+ if memory is None
119
+ else _Installed(memory, None if not secret else secret.encode(), max_age_s)
120
+ )
121
+
122
+
123
+ def mode_digest(key: _ModeKey, secret: bytes | None = None) -> bytes:
124
+ """HMAC-SHA256 of the endpoint, model and output settings, keyed by a fixed label and the
125
+ optional secret: no address is stored in the clear and, with a secret, a stored digest
126
+ cannot be matched to a guessed address."""
127
+ hmac_key = _DIGEST_LABEL + b"\0" + secret if secret else _DIGEST_LABEL
128
+ return hmac.new(hmac_key, json.dumps(key).encode(), hashlib.sha256).digest()
129
+
130
+
131
+ def _mode_key(llm: LlmSettings) -> _ModeKey:
132
+ return (llm.base_url, llm.model, llm.structured_output, llm.json_schema_strict)
133
+
134
+
135
+ def _known_mode(key: _ModeKey) -> int | None:
136
+ with _known_modes_lock:
137
+ index = _known_modes.get(key)
138
+ if index is not None:
139
+ _known_modes.move_to_end(key)
140
+ return index
141
+
142
+
143
+ def _remember_mode(key: _ModeKey, index: int) -> None:
144
+ with _known_modes_lock:
145
+ _known_modes[key] = max(index, _known_modes.get(key, 0))
146
+ _known_modes.move_to_end(key)
147
+ while len(_known_modes) > _MODE_CACHE_LIMIT:
148
+ _known_modes.popitem(last=False)
149
+
150
+
151
+ @dataclass(frozen=True, slots=True)
152
+ class _Problem:
153
+ path: str
154
+ keyword: str
155
+ message: str
156
+
157
+
158
+ class EndpointChatModel:
159
+ """`ChatModel` for an HTTP chat completions endpoint."""
160
+
161
+ def __init__(
162
+ self,
163
+ llm: LlmSettings,
164
+ *,
165
+ transport: httpx.AsyncBaseTransport | None = None,
166
+ sleep: Sleep | None = None,
167
+ ) -> None:
168
+ self._llm = llm
169
+ self._transport = transport
170
+ self._sleep = sleep
171
+ self._mode_key = _mode_key(llm)
172
+ self._mode_index = max(MODES.index(llm.structured_output), _known_mode(self._mode_key) or 0)
173
+ self._recalled = False
174
+ self._recall_lock = asyncio.Lock()
175
+ self._client = EndpointClient(
176
+ base_url=llm.base_url,
177
+ api_key=llm.api_key,
178
+ timeout_s=llm.timeout_s,
179
+ retry=llm.retry,
180
+ trust_env=llm.trust_env,
181
+ max_concurrency=llm.max_concurrency,
182
+ headers=llm.headers,
183
+ transport=transport,
184
+ sleep=sleep,
185
+ )
186
+ self._routes: dict[str, EndpointChatModel] = {}
187
+
188
+ @property
189
+ def model(self) -> str:
190
+ """Configured model name."""
191
+ return self._llm.model
192
+
193
+ @property
194
+ def output_mode(self) -> str:
195
+ """Structured output mode in use; lower than configured after a server rejection."""
196
+ return MODES[self._mode_index]
197
+
198
+ async def complete_json(
199
+ self,
200
+ messages: Sequence[Message],
201
+ schema: JsonSchema,
202
+ options: ChatOptions | None = None,
203
+ ) -> ChatResult:
204
+ """Answer `messages` with JSON that conforms to `schema`.
205
+
206
+ Raises `InvalidOutputError` when no attempt satisfies the schema, `EndpointError` when
207
+ the endpoint keeps failing, and `ValueError` when `schema` is not a valid JSON Schema.
208
+ """
209
+ options = options or ChatOptions()
210
+ if options.task and options.task in self._llm.tasks:
211
+ return await self._route(options.task).complete_json(messages, schema, options)
212
+ validator = _validator(schema)
213
+ conversation = list(messages)
214
+ usage = Usage()
215
+ problems: list[_Problem] = []
216
+ attempts = self._llm.repair_attempts + 1
217
+ for attempt in range(1, attempts + 1):
218
+ text, call_usage, model, truncated = await self._call(conversation, schema, options)
219
+ usage = Usage(
220
+ usage.input_tokens + call_usage.input_tokens,
221
+ usage.output_tokens + call_usage.output_tokens,
222
+ )
223
+ data, problems = _check(text, validator)
224
+ if not problems:
225
+ return ChatResult(data=data, usage=usage, model=model)
226
+ if truncated:
227
+ break
228
+ if attempt < attempts:
229
+ conversation = [
230
+ *messages,
231
+ Message("assistant", text[:_ECHO_LIMIT]),
232
+ Message("user", _REPAIR_INSTRUCTION.format(problems=_describe(problems))),
233
+ ]
234
+ reason = ""
235
+ if truncated:
236
+ reason = (
237
+ " (answer cut off by the token limit)"
238
+ if text.strip()
239
+ else " (token limit reached before any answer: a reasoning model may have spent "
240
+ "it on reasoning; raise max_tokens or turn reasoning off through extra_body)"
241
+ )
242
+ raise InvalidOutputError(
243
+ f"model {self._llm.model!r} did not produce JSON satisfying the schema after "
244
+ f"{attempt} attempt(s){reason}: "
245
+ + "; ".join(f"{p.path or '/'} [{p.keyword}]" for p in problems[:_MAX_PROBLEMS]),
246
+ problems=[f"{p.path or '/'}: {p.keyword}" for p in problems],
247
+ attempts=attempt,
248
+ usage=usage,
249
+ )
250
+
251
+ async def aclose(self) -> None:
252
+ """Release connections, including those of per-task routes."""
253
+ await self._client.aclose()
254
+ for route in self._routes.values():
255
+ await route.aclose()
256
+
257
+ def _route(self, task: str) -> EndpointChatModel:
258
+ if task not in self._routes:
259
+ self._routes[task] = EndpointChatModel(
260
+ self._llm.for_task(task), transport=self._transport, sleep=self._sleep
261
+ )
262
+ return self._routes[task]
263
+
264
+ async def _call(
265
+ self, conversation: list[Message], schema: JsonSchema, options: ChatOptions
266
+ ) -> tuple[str, Usage, str, bool]:
267
+ """One completion; steps down the structured output mode when the server rejects it."""
268
+ await self._recall()
269
+ while True:
270
+ payload = self._payload(conversation, schema, options, MODES[self._mode_index])
271
+ try:
272
+ body = await self._client.post("/chat/completions", payload)
273
+ except EndpointError as exc:
274
+ if (
275
+ exc.status in _UNSUPPORTED_STATUS
276
+ and self._mode_index < len(MODES) - 1
277
+ and _FORMAT_HINT.search(exc.detail)
278
+ ):
279
+ previous = MODES[self._mode_index]
280
+ self._mode_index += 1
281
+ _remember_mode(self._mode_key, self._mode_index)
282
+ await self._persist()
283
+ logger.info(
284
+ "endpoint rejected structured output mode %s; using %s",
285
+ previous,
286
+ MODES[self._mode_index],
287
+ )
288
+ continue
289
+ raise
290
+ return self._read(body, conversation)
291
+
292
+ async def _recall(self) -> None:
293
+ """Start at the mode a previous process learned, once per client."""
294
+ installed = _installed
295
+ if self._recalled or installed is None:
296
+ return
297
+ async with self._recall_lock:
298
+ if self._recalled:
299
+ return
300
+ try:
301
+ index = await installed.memory.recall(
302
+ mode_digest(self._mode_key, installed.secret), installed.max_age_s
303
+ )
304
+ except Exception:
305
+ logger.warning("structured output mode memory unavailable", exc_info=True)
306
+ self._recalled = True
307
+ return
308
+ self._recalled = True
309
+ if index is not None and 0 <= index < len(MODES):
310
+ self._mode_index = max(self._mode_index, index)
311
+ _remember_mode(self._mode_key, self._mode_index)
312
+
313
+ async def _persist(self) -> None:
314
+ installed = _installed
315
+ if installed is None:
316
+ return
317
+ try:
318
+ await installed.memory.remember(
319
+ mode_digest(self._mode_key, installed.secret),
320
+ self._mode_index,
321
+ installed.max_age_s,
322
+ )
323
+ except Exception:
324
+ logger.warning("structured output mode memory unavailable", exc_info=True)
325
+
326
+ def _payload(
327
+ self, conversation: list[Message], schema: JsonSchema, options: ChatOptions, mode: str
328
+ ) -> dict[str, Any]:
329
+ messages = [{"role": m.role, "content": m.content} for m in conversation]
330
+ payload: dict[str, Any] = {
331
+ **self._llm.extra_body,
332
+ "model": self._llm.model,
333
+ "messages": messages,
334
+ "stream": False,
335
+ }
336
+ temperature = self._llm.temperature if options.temperature is None else options.temperature
337
+ if temperature is not None:
338
+ payload["temperature"] = temperature
339
+ max_tokens = options.max_tokens or self._llm.max_tokens
340
+ if max_tokens:
341
+ payload["max_tokens"] = max_tokens
342
+ if options.seed is not None:
343
+ payload["seed"] = options.seed
344
+ if mode == "json_schema":
345
+ json_schema: dict[str, Any] = {"name": "answer", "schema": dict(schema)}
346
+ if self._llm.json_schema_strict is not None:
347
+ json_schema["strict"] = self._llm.json_schema_strict
348
+ payload["response_format"] = {"type": "json_schema", "json_schema": json_schema}
349
+ else:
350
+ _add_instruction(messages, schema)
351
+ if mode == "json_mode":
352
+ payload["response_format"] = {"type": "json_object"}
353
+ return payload
354
+
355
+ def _read(
356
+ self, body: dict[str, Any], conversation: list[Message]
357
+ ) -> tuple[str, Usage, str, bool]:
358
+ choices = body.get("choices")
359
+ choice = choices[0] if isinstance(choices, list) and choices else None
360
+ message = choice.get("message") if isinstance(choice, dict) else None
361
+ content = message.get("content") if isinstance(message, dict) else None
362
+ truncated = isinstance(choice, dict) and choice.get("finish_reason") == "length"
363
+ if content is None and truncated:
364
+ content = ""
365
+ if not isinstance(content, str):
366
+ raise EndpointError("/chat/completions: response has no text content")
367
+ reported = body.get("usage")
368
+ raw_usage: dict[str, Any] = reported if isinstance(reported, dict) else {}
369
+ usage = Usage(
370
+ input_tokens=_count(
371
+ raw_usage.get("prompt_tokens"), sum(len(m.content) for m in conversation)
372
+ ),
373
+ output_tokens=_count(raw_usage.get("completion_tokens"), len(content)),
374
+ )
375
+ model = body.get("model")
376
+ return (
377
+ content,
378
+ usage,
379
+ model if isinstance(model, str) and model else self._llm.model,
380
+ truncated,
381
+ )
382
+
383
+
384
+ def _count(reported: object, chars: int) -> int:
385
+ """Reported token count; estimated from the text length when the server omits it."""
386
+ if isinstance(reported, int) and not isinstance(reported, bool) and reported >= 0:
387
+ return reported
388
+ return math.ceil(chars / _CHARS_PER_TOKEN)
389
+
390
+
391
+ def _add_instruction(messages: list[dict[str, Any]], schema: JsonSchema) -> None:
392
+ """Put the schema instruction into the first system message, creating it if needed."""
393
+ instruction = _SCHEMA_INSTRUCTION + json.dumps(schema, ensure_ascii=False)
394
+ if messages and messages[0]["role"] == "system":
395
+ messages[0] = {"role": "system", "content": f"{messages[0]['content']}\n\n{instruction}"}
396
+ else:
397
+ messages.insert(0, {"role": "system", "content": instruction})
398
+
399
+
400
+ def _validator(schema: JsonSchema) -> Any:
401
+ document = dict(schema)
402
+ cls = validators.validator_for(document)
403
+ try:
404
+ cls.check_schema(document)
405
+ except schema_exceptions.SchemaError as exc:
406
+ raise ValueError(f"invalid JSON Schema: {exc.message}") from None
407
+ return cls(document)
408
+
409
+
410
+ def parse_json_text(text: str) -> Any:
411
+ """Parse a model answer as JSON, tolerating reasoning blocks, code fences and prose.
412
+
413
+ Raises `ValueError` when no JSON value can be found.
414
+ """
415
+ text = _THINK.sub("", text).strip()
416
+ fenced = _FENCE.match(text)
417
+ if fenced:
418
+ text = fenced.group(1).strip()
419
+ try:
420
+ return json.loads(text)
421
+ except ValueError:
422
+ pass
423
+ decoder = json.JSONDecoder()
424
+ for match in re.finditer(r"[{\[]", text):
425
+ try:
426
+ value, _end = decoder.raw_decode(text, match.start())
427
+ except ValueError:
428
+ continue
429
+ return value
430
+ raise ValueError("no JSON value found in the answer")
431
+
432
+
433
+ def _check(text: str, validator: Any) -> tuple[Any, list[_Problem]]:
434
+ try:
435
+ data = parse_json_text(text)
436
+ except ValueError as exc:
437
+ return None, [_Problem("", "json", str(exc))]
438
+ errors = sorted(validator.iter_errors(data), key=lambda e: [str(p) for p in e.absolute_path])
439
+ problems = [
440
+ _Problem("/" + "/".join(str(p) for p in e.absolute_path), str(e.validator), e.message)
441
+ for e in errors
442
+ ]
443
+ return data, problems
444
+
445
+
446
+ def _describe(problems: list[_Problem]) -> str:
447
+ return "; ".join(f"{p.path or '/'}: {p.message[:200]}" for p in problems[:_MAX_PROBLEMS])