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.
- lorebase_inference/__init__.py +70 -0
- lorebase_inference/__main__.py +6 -0
- lorebase_inference/chat.py +447 -0
- lorebase_inference/cli.py +214 -0
- lorebase_inference/errors.py +54 -0
- lorebase_inference/fake.py +317 -0
- lorebase_inference/interfaces.py +151 -0
- lorebase_inference/local_embeddings.py +58 -0
- lorebase_inference/local_relations.py +157 -0
- lorebase_inference/local_spans.py +93 -0
- lorebase_inference/registry.py +313 -0
- lorebase_inference/remote_embeddings.py +100 -0
- lorebase_inference/settings.py +338 -0
- lorebase_inference/transport.py +233 -0
- lorebase_inference-0.2.3.dist-info/METADATA +13 -0
- lorebase_inference-0.2.3.dist-info/RECORD +18 -0
- lorebase_inference-0.2.3.dist-info/WHEEL +4 -0
- lorebase_inference-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -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,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])
|