jevify 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.
- jevify/__init__.py +7 -0
- jevify/__main__.py +9 -0
- jevify/adapters/__init__.py +1 -0
- jevify/adapters/embedding/__init__.py +1 -0
- jevify/adapters/embedding/adapter.py +203 -0
- jevify/adapters/embedding/local.py +82 -0
- jevify/adapters/encoder/__init__.py +1 -0
- jevify/adapters/encoder/adapter.py +165 -0
- jevify/adapters/encoder/local.py +77 -0
- jevify/adapters/endpoint/__init__.py +1 -0
- jevify/adapters/endpoint/adapter.py +281 -0
- jevify/adapters/endpoint/client.py +114 -0
- jevify/adapters/endpoint/dialects.py +166 -0
- jevify/adapters/endpoint/probe.py +589 -0
- jevify/adapters/endpoint/readout.py +244 -0
- jevify/adapters/rerank/__init__.py +1 -0
- jevify/adapters/rerank/adapter.py +152 -0
- jevify/api/__init__.py +1 -0
- jevify/api/app.py +228 -0
- jevify/api/schema.py +123 -0
- jevify/api/translate.py +239 -0
- jevify/cli.py +392 -0
- jevify/compose.py +82 -0
- jevify/domain/__init__.py +1 -0
- jevify/domain/distribution.py +103 -0
- jevify/domain/engine.py +152 -0
- jevify/domain/questions.py +121 -0
- jevify/evaluation/__init__.py +1 -0
- jevify/evaluation/calibrate.py +186 -0
- jevify/evaluation/controls.py +78 -0
- jevify/evaluation/metrics.py +91 -0
- jevify/evaluation/scorecard.py +385 -0
- jevify/evaluation/suites.py +166 -0
- jevify/ports/__init__.py +1 -0
- jevify/ports/backend.py +162 -0
- jevify/recipes/__init__.py +21 -0
- jevify/recipes/probe_case.yaml +72 -0
- jevify/recipes/render.py +198 -0
- jevify/recipes/schema.py +204 -0
- jevify/recipes/store.py +81 -0
- jevify-0.1.0.dist-info/METADATA +243 -0
- jevify-0.1.0.dist-info/RECORD +45 -0
- jevify-0.1.0.dist-info/WHEEL +4 -0
- jevify-0.1.0.dist-info/entry_points.txt +2 -0
- jevify-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""EndpointBackend: the backend port over an OpenAI-compatible server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable, Sequence
|
|
11
|
+
|
|
12
|
+
from jevify.adapters.endpoint.client import OpenAICompatibleClient
|
|
13
|
+
from jevify.adapters.endpoint.dialects import base_request, parse_response
|
|
14
|
+
from jevify.adapters.endpoint.readout import RUNGS, MissingLabelsError
|
|
15
|
+
from jevify.domain.distribution import Distribution
|
|
16
|
+
from jevify.domain.questions import Question, State
|
|
17
|
+
from jevify.ports.backend import (
|
|
18
|
+
RUNG_RANK,
|
|
19
|
+
BackendError,
|
|
20
|
+
Capabilities,
|
|
21
|
+
Dialect,
|
|
22
|
+
RawAnswer,
|
|
23
|
+
Rung,
|
|
24
|
+
StateHandle,
|
|
25
|
+
)
|
|
26
|
+
from jevify.recipes.render import render_prefix, render_question
|
|
27
|
+
from jevify.recipes.schema import Recipe
|
|
28
|
+
|
|
29
|
+
Clock = Callable[[], float]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EndpointBackend:
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
client: OpenAICompatibleClient,
|
|
36
|
+
recipe: Recipe,
|
|
37
|
+
*,
|
|
38
|
+
clock: Clock = time.perf_counter,
|
|
39
|
+
concurrency: int = 1,
|
|
40
|
+
) -> None:
|
|
41
|
+
if recipe.endpoint is None:
|
|
42
|
+
raise BackendError("an endpoint backend needs a recipe with an endpoint section")
|
|
43
|
+
self.client = client
|
|
44
|
+
self.recipe = recipe
|
|
45
|
+
self.clock = clock
|
|
46
|
+
self.dialect = (
|
|
47
|
+
Dialect(recipe.endpoint.dialect)
|
|
48
|
+
if recipe.endpoint.dialect != "auto"
|
|
49
|
+
else Dialect.GENERIC
|
|
50
|
+
)
|
|
51
|
+
self.capabilities = Capabilities.from_dict(recipe.probe) if recipe.probe else None
|
|
52
|
+
probed = (
|
|
53
|
+
self.capabilities.fanout.concurrency
|
|
54
|
+
if self.capabilities and self.capabilities.fanout
|
|
55
|
+
else 1
|
|
56
|
+
)
|
|
57
|
+
self.concurrency = max(1, concurrency, probed)
|
|
58
|
+
self.slots = self.capabilities.slots if self.capabilities else None
|
|
59
|
+
|
|
60
|
+
async def probe(self) -> Capabilities:
|
|
61
|
+
from jevify.adapters.endpoint.probe import Prober
|
|
62
|
+
|
|
63
|
+
return await Prober(self.client, self.recipe, self.clock).run()
|
|
64
|
+
|
|
65
|
+
async def warm(self, state: State) -> StateHandle:
|
|
66
|
+
"""Send the state-only prefix once (one token, nothing read) so the server's cache
|
|
67
|
+
holds it; on llama.cpp once per slot the fan-out will use."""
|
|
68
|
+
wanted = state.modalities - {"text"}
|
|
69
|
+
if wanted and self.capabilities and not wanted <= self.capabilities.modalities:
|
|
70
|
+
missing = sorted(wanted - self.capabilities.modalities)
|
|
71
|
+
raise BackendError(
|
|
72
|
+
f"this backend was probed without {missing} support; the state carries "
|
|
73
|
+
f"{missing} parts and jevify never drops them silently (INV-9)"
|
|
74
|
+
)
|
|
75
|
+
prefix = render_prefix(self.recipe, state)
|
|
76
|
+
identity = json.dumps(
|
|
77
|
+
[prefix.mode, prefix.system, prefix.text, prefix.kwargs, len(prefix.images)],
|
|
78
|
+
sort_keys=True,
|
|
79
|
+
ensure_ascii=False,
|
|
80
|
+
)
|
|
81
|
+
state_id = hashlib.sha256(identity.encode("utf-8")).hexdigest()
|
|
82
|
+
slots = tuple(range(min(self.concurrency, self.slots))) if self.slots else ()
|
|
83
|
+
started = self.clock()
|
|
84
|
+
cached: int | None = None
|
|
85
|
+
for slot in slots or (None,):
|
|
86
|
+
path, body = base_request(self.recipe.model.name, prefix, "", self.dialect)
|
|
87
|
+
if slot is not None:
|
|
88
|
+
body["id_slot"] = slot
|
|
89
|
+
post = self.client.post_root if path == "completion" else self.client.post_v1
|
|
90
|
+
parsed = parse_response(path, await post(path, body), self.dialect)
|
|
91
|
+
cached = parsed.cached_tokens
|
|
92
|
+
warm_ms = (self.clock() - started) * 1000.0
|
|
93
|
+
return StateHandle(
|
|
94
|
+
state_id=state_id, prefix=prefix, warm_ms=warm_ms, cached_tokens=cached, slots=slots
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
async def evaluate(self, handle: StateHandle, questions: Sequence[Question]) -> list[RawAnswer]:
|
|
98
|
+
ladder = self._ladder()
|
|
99
|
+
gate = asyncio.Semaphore(self.concurrency)
|
|
100
|
+
|
|
101
|
+
async def one(index: int, question: Question) -> RawAnswer:
|
|
102
|
+
slot = handle.slots[index % len(handle.slots)] if handle.slots else None
|
|
103
|
+
async with gate:
|
|
104
|
+
for step, rung in enumerate(ladder):
|
|
105
|
+
try:
|
|
106
|
+
return await self._answer(handle, question, rung, slot)
|
|
107
|
+
except MissingLabelsError:
|
|
108
|
+
if step == len(ladder) - 1:
|
|
109
|
+
raise
|
|
110
|
+
raise AssertionError("unreachable: the ladder is never empty")
|
|
111
|
+
|
|
112
|
+
return list(await asyncio.gather(*(one(i, q) for i, q in enumerate(questions))))
|
|
113
|
+
|
|
114
|
+
def _ladder(self) -> list[Rung]:
|
|
115
|
+
"""The rungs a question may be read on, strongest first. A pinned rung stands alone
|
|
116
|
+
and fails loudly; auto walks every proven rung down to the floor."""
|
|
117
|
+
strongest = self._resolve_rung()
|
|
118
|
+
if self.recipe.readout.rung != "auto":
|
|
119
|
+
return [strongest]
|
|
120
|
+
proven = Capabilities.from_dict(self.recipe.probe).rungs if self.recipe.probe else set()
|
|
121
|
+
return [r for r in RUNG_RANK if r in proven and r in RUNGS]
|
|
122
|
+
|
|
123
|
+
def _resolve_rung(self) -> Rung:
|
|
124
|
+
pinned = self.recipe.readout.rung
|
|
125
|
+
proven = (
|
|
126
|
+
Capabilities.from_dict(self.recipe.probe).rungs if self.recipe.probe else frozenset()
|
|
127
|
+
)
|
|
128
|
+
if pinned == "auto":
|
|
129
|
+
if not self.recipe.probe:
|
|
130
|
+
raise BackendError(
|
|
131
|
+
"readout.rung is auto but the recipe has no probe section; "
|
|
132
|
+
"run `jevify probe <recipe>` or pin a rung"
|
|
133
|
+
)
|
|
134
|
+
for rung in RUNG_RANK:
|
|
135
|
+
if rung in proven and rung in RUNGS:
|
|
136
|
+
return rung
|
|
137
|
+
raise BackendError("the probe proved no rung this build implements")
|
|
138
|
+
rung = Rung(pinned)
|
|
139
|
+
if rung not in RUNGS:
|
|
140
|
+
raise BackendError(f"rung {rung} is not implemented yet")
|
|
141
|
+
if self.recipe.probe and rung not in proven:
|
|
142
|
+
raise BackendError(
|
|
143
|
+
f"readout.rung {rung} is pinned but the probe did not prove it; "
|
|
144
|
+
f"proven: {[str(r) for r in RUNG_RANK if r in proven]}"
|
|
145
|
+
)
|
|
146
|
+
return rung
|
|
147
|
+
|
|
148
|
+
def _orders(self, question: Question, rendered) -> list[tuple[int, ...]] | None:
|
|
149
|
+
"""Cyclic option orders when the recipe asks for permutation on an identifier-based
|
|
150
|
+
choice or score question; None means a single order."""
|
|
151
|
+
policy = self.recipe.readout.permutation
|
|
152
|
+
n = len(question.labels)
|
|
153
|
+
if policy == "none" or rendered.composition != "single" or n < 2:
|
|
154
|
+
return None
|
|
155
|
+
if policy == "full":
|
|
156
|
+
import itertools
|
|
157
|
+
|
|
158
|
+
return list(itertools.permutations(range(n)))
|
|
159
|
+
k = min(self.recipe.readout.permutation_calls, n)
|
|
160
|
+
return [tuple((i + shift) % n for i in range(n)) for shift in range(k)]
|
|
161
|
+
|
|
162
|
+
async def _permuted(
|
|
163
|
+
self,
|
|
164
|
+
handle: StateHandle,
|
|
165
|
+
question: Question,
|
|
166
|
+
rung: Rung,
|
|
167
|
+
slot: int | None,
|
|
168
|
+
orders: list[tuple[int, ...]],
|
|
169
|
+
) -> RawAnswer:
|
|
170
|
+
"""Ask once per order and average the normalized distributions, so position bias
|
|
171
|
+
cancels; the record says how many calls it took."""
|
|
172
|
+
impl = RUNGS[rung]
|
|
173
|
+
sums: dict[str, float] = dict.fromkeys(question.labels, 0.0)
|
|
174
|
+
latency_ms = 0.0
|
|
175
|
+
prompt_tokens = 0
|
|
176
|
+
cached_tokens: int | None = None
|
|
177
|
+
degraded = False
|
|
178
|
+
off_menu: list[float] = []
|
|
179
|
+
raw_parts = []
|
|
180
|
+
for order in orders:
|
|
181
|
+
part = render_question(self.recipe, question, order=order).parts[0]
|
|
182
|
+
path, body = base_request(
|
|
183
|
+
self.recipe.model.name, handle.prefix, part.text, self.dialect
|
|
184
|
+
)
|
|
185
|
+
impl.shape(body, part, self.recipe.readout.top_k, self.dialect)
|
|
186
|
+
if slot is not None:
|
|
187
|
+
body["id_slot"] = slot
|
|
188
|
+
started = self.clock()
|
|
189
|
+
post = self.client.post_root if path == "completion" else self.client.post_v1
|
|
190
|
+
response = await post(path, body)
|
|
191
|
+
latency_ms += (self.clock() - started) * 1000.0
|
|
192
|
+
parsed = parse_response(path, response, self.dialect)
|
|
193
|
+
readout = impl.read(parsed, part)
|
|
194
|
+
distribution = Distribution.from_logprobs(readout.logprobs)
|
|
195
|
+
for label, probability in distribution.as_mapping().items():
|
|
196
|
+
sums[label] += probability
|
|
197
|
+
degraded = degraded or readout.degraded
|
|
198
|
+
if readout.off_menu_mass is not None:
|
|
199
|
+
off_menu.append(readout.off_menu_mass)
|
|
200
|
+
prompt_tokens += parsed.prompt_tokens or 0
|
|
201
|
+
cached_tokens = parsed.cached_tokens
|
|
202
|
+
raw_parts.append(
|
|
203
|
+
{
|
|
204
|
+
"path": path,
|
|
205
|
+
"order": list(order),
|
|
206
|
+
"entries": [e.__dict__ for e in parsed.entries],
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
k = len(orders)
|
|
210
|
+
logprobs = {label: math.log(max(total / k, 1e-300)) for label, total in sums.items()}
|
|
211
|
+
return RawAnswer(
|
|
212
|
+
question_id=question.id,
|
|
213
|
+
logprobs=logprobs,
|
|
214
|
+
semantics="readout",
|
|
215
|
+
rung=rung,
|
|
216
|
+
degraded=degraded,
|
|
217
|
+
off_menu_mass=(sum(off_menu) / len(off_menu)) if off_menu else None,
|
|
218
|
+
latency_ms=latency_ms,
|
|
219
|
+
prompt_tokens=prompt_tokens,
|
|
220
|
+
cached_tokens=cached_tokens,
|
|
221
|
+
calls=k,
|
|
222
|
+
composition="permuted",
|
|
223
|
+
raw={"parts": raw_parts},
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
async def _answer(
|
|
227
|
+
self, handle: StateHandle, question: Question, rung: Rung, slot: int | None = None
|
|
228
|
+
) -> RawAnswer:
|
|
229
|
+
"""One request per part; a single-part question is the readout itself, a per-option
|
|
230
|
+
question composes each option's yes-minus-no logit into one distribution."""
|
|
231
|
+
rendered = render_question(self.recipe, question)
|
|
232
|
+
orders = self._orders(question, rendered)
|
|
233
|
+
if orders is not None:
|
|
234
|
+
return await self._permuted(handle, question, rung, slot, orders)
|
|
235
|
+
impl = RUNGS[rung]
|
|
236
|
+
readouts = []
|
|
237
|
+
latency_ms = 0.0
|
|
238
|
+
prompt_tokens = 0
|
|
239
|
+
cached_tokens: int | None = None
|
|
240
|
+
raw_parts = []
|
|
241
|
+
for part in rendered.parts:
|
|
242
|
+
path, body = base_request(
|
|
243
|
+
self.recipe.model.name, handle.prefix, part.text, self.dialect
|
|
244
|
+
)
|
|
245
|
+
impl.shape(body, part, self.recipe.readout.top_k, self.dialect)
|
|
246
|
+
if slot is not None:
|
|
247
|
+
body["id_slot"] = slot
|
|
248
|
+
started = self.clock()
|
|
249
|
+
post = self.client.post_root if path == "completion" else self.client.post_v1
|
|
250
|
+
response = await post(path, body)
|
|
251
|
+
latency_ms += (self.clock() - started) * 1000.0
|
|
252
|
+
parsed = parse_response(path, response, self.dialect)
|
|
253
|
+
readouts.append(impl.read(parsed, part))
|
|
254
|
+
prompt_tokens += parsed.prompt_tokens or 0
|
|
255
|
+
cached_tokens = parsed.cached_tokens
|
|
256
|
+
raw_parts.append({"path": path, "entries": [e.__dict__ for e in parsed.entries]})
|
|
257
|
+
if rendered.composition == "single":
|
|
258
|
+
readout = readouts[0]
|
|
259
|
+
logprobs, off_menu, missing = readout.logprobs, readout.off_menu_mass, readout.missing
|
|
260
|
+
else:
|
|
261
|
+
logprobs = {
|
|
262
|
+
part.label: r.logprobs["true"] - r.logprobs["false"]
|
|
263
|
+
for part, r in zip(rendered.parts, readouts, strict=True)
|
|
264
|
+
if part.label is not None
|
|
265
|
+
}
|
|
266
|
+
off_menu, missing = None, ()
|
|
267
|
+
return RawAnswer(
|
|
268
|
+
question_id=question.id,
|
|
269
|
+
logprobs=logprobs,
|
|
270
|
+
semantics="readout",
|
|
271
|
+
rung=rung,
|
|
272
|
+
degraded=any(r.degraded for r in readouts),
|
|
273
|
+
off_menu_mass=off_menu,
|
|
274
|
+
latency_ms=latency_ms,
|
|
275
|
+
prompt_tokens=prompt_tokens,
|
|
276
|
+
cached_tokens=cached_tokens,
|
|
277
|
+
missing=missing,
|
|
278
|
+
calls=len(rendered.parts),
|
|
279
|
+
composition=rendered.composition,
|
|
280
|
+
raw={"parts": raw_parts},
|
|
281
|
+
)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""The only module that imports httpx. Timeouts, bounded retries, error wrapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import random
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from jevify.ports.backend import BackendError, ContextLimitError
|
|
13
|
+
|
|
14
|
+
_RETRY_STATUSES = {429, 500, 502, 503, 504}
|
|
15
|
+
_CONTEXT_LIMIT = re.compile(
|
|
16
|
+
r"(maximum context length|context length|too many tokens|exceeds)", re.I
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OpenAICompatibleClient:
|
|
21
|
+
"""HTTP access to one server. `v1/*` for the OpenAI surface, root for server extras."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
base_url: str,
|
|
26
|
+
*,
|
|
27
|
+
api_key: str | None = None,
|
|
28
|
+
timeout_s: float = 120.0,
|
|
29
|
+
http: httpx.AsyncClient | None = None,
|
|
30
|
+
max_retries: int = 3,
|
|
31
|
+
) -> None:
|
|
32
|
+
stripped = base_url.rstrip("/")
|
|
33
|
+
self.root = stripped[: -len("/v1")] if stripped.endswith("/v1") else stripped
|
|
34
|
+
self.v1 = self.root + "/v1"
|
|
35
|
+
self.api_key = api_key
|
|
36
|
+
self.timeout_s = timeout_s
|
|
37
|
+
self.max_retries = max_retries
|
|
38
|
+
self._http = http or httpx.AsyncClient(timeout=httpx.Timeout(timeout_s))
|
|
39
|
+
|
|
40
|
+
def _headers(self) -> dict[str, str]:
|
|
41
|
+
headers = {"Content-Type": "application/json"}
|
|
42
|
+
if self.api_key:
|
|
43
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
44
|
+
return headers
|
|
45
|
+
|
|
46
|
+
async def _request(self, method: str, url: str, body: dict[str, Any] | None = None) -> Any:
|
|
47
|
+
attempt = 0
|
|
48
|
+
while True:
|
|
49
|
+
try:
|
|
50
|
+
response = await self._http.request(
|
|
51
|
+
method, url, json=body, headers=self._headers(), timeout=self.timeout_s
|
|
52
|
+
)
|
|
53
|
+
except httpx.HTTPError as exc:
|
|
54
|
+
raise BackendError(f"{method} {url} failed: {exc}") from exc
|
|
55
|
+
if response.status_code in _RETRY_STATUSES and attempt < self.max_retries:
|
|
56
|
+
attempt += 1
|
|
57
|
+
await asyncio.sleep(_retry_delay(response, attempt))
|
|
58
|
+
continue
|
|
59
|
+
if response.status_code >= 400:
|
|
60
|
+
raise _http_error(method, url, response)
|
|
61
|
+
return response.json()
|
|
62
|
+
|
|
63
|
+
async def post_v1(self, path: str, body: dict[str, Any]) -> Any:
|
|
64
|
+
return await self._request("POST", f"{self.v1}/{path.lstrip('/')}", body)
|
|
65
|
+
|
|
66
|
+
async def get_v1(self, path: str) -> Any:
|
|
67
|
+
return await self._request("GET", f"{self.v1}/{path.lstrip('/')}")
|
|
68
|
+
|
|
69
|
+
async def post_root(self, path: str, body: dict[str, Any]) -> Any:
|
|
70
|
+
return await self._request("POST", f"{self.root}/{path.lstrip('/')}", body)
|
|
71
|
+
|
|
72
|
+
async def get_root(self, path: str) -> Any:
|
|
73
|
+
return await self._request("GET", f"{self.root}/{path.lstrip('/')}")
|
|
74
|
+
|
|
75
|
+
async def aclose(self) -> None:
|
|
76
|
+
await self._http.aclose()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _retry_delay(response: httpx.Response, attempt: int) -> float:
|
|
80
|
+
header = response.headers.get("retry-after-ms") or response.headers.get("retry-after")
|
|
81
|
+
if header:
|
|
82
|
+
try:
|
|
83
|
+
value = float(header)
|
|
84
|
+
return (
|
|
85
|
+
value / 1000.0 if "ms" in (response.headers.get("retry-after-ms") or "") else value
|
|
86
|
+
)
|
|
87
|
+
except ValueError:
|
|
88
|
+
pass
|
|
89
|
+
return min(2.0, 0.05 * (2**attempt)) + random.uniform(0.0, 0.05) # noqa: S311 - jitter only
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _http_error(method: str, url: str, response: httpx.Response) -> BackendError:
|
|
93
|
+
message = _error_message(response)
|
|
94
|
+
text = f"{method} {url} returned {response.status_code}: {message}"
|
|
95
|
+
if response.status_code in (400, 413, 422) and _CONTEXT_LIMIT.search(message):
|
|
96
|
+
return ContextLimitError(text, status=response.status_code)
|
|
97
|
+
return BackendError(text, status=response.status_code)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _error_message(response: httpx.Response) -> str:
|
|
101
|
+
try:
|
|
102
|
+
body = response.json()
|
|
103
|
+
except ValueError:
|
|
104
|
+
return response.text[:500]
|
|
105
|
+
if isinstance(body, dict):
|
|
106
|
+
error = body.get("error")
|
|
107
|
+
if isinstance(error, dict) and "message" in error:
|
|
108
|
+
return str(error["message"])
|
|
109
|
+
if isinstance(error, str):
|
|
110
|
+
return error
|
|
111
|
+
for key in ("message", "detail"):
|
|
112
|
+
if key in body:
|
|
113
|
+
return str(body[key])
|
|
114
|
+
return str(body)[:500]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""The dialect registry: how each server spells the OpenAI surface and its extras.
|
|
2
|
+
|
|
3
|
+
The only file where the dialect names appear as behaviour. The adapter asks a
|
|
4
|
+
dialect to shape a request and to parse a response; it never branches on the
|
|
5
|
+
name itself.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from jevify.ports.backend import Dialect
|
|
14
|
+
from jevify.recipes.render import RenderedPrefix
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Entry:
|
|
19
|
+
"""One token at the answer position: decoded text, id when the server gives one, logprob."""
|
|
20
|
+
|
|
21
|
+
text: str
|
|
22
|
+
logprob: float
|
|
23
|
+
id: int | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Parsed:
|
|
28
|
+
entries: tuple[Entry, ...]
|
|
29
|
+
prompt_tokens: int | None
|
|
30
|
+
cached_tokens: int | None
|
|
31
|
+
completion_tokens: int | None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def base_request(
|
|
35
|
+
model: str, prefix: RenderedPrefix, suffix: str, dialect: Dialect
|
|
36
|
+
) -> tuple[str, dict[str, Any]]:
|
|
37
|
+
"""The readout request every rung starts from: one token, no sampling, logprobs on.
|
|
38
|
+
|
|
39
|
+
Returns the route key and the body: "chat/completions" and "completions" live under
|
|
40
|
+
/v1; "completion" is llama.cpp's native root route, used for raw multimodal input.
|
|
41
|
+
"""
|
|
42
|
+
if prefix.mode == "raw":
|
|
43
|
+
if prefix.images:
|
|
44
|
+
if dialect is not Dialect.LLAMACPP:
|
|
45
|
+
raise ValueError("raw-mode images need the llama.cpp native completion route")
|
|
46
|
+
# llama.cpp's documented shape: a prompt object with the text (one media marker
|
|
47
|
+
# per image, the marker the server reports in /props) and the base64 payloads.
|
|
48
|
+
# A top-level multimodal_data key is accepted and silently ignored: the image
|
|
49
|
+
# never reaches the model. The probe checks that an image adds prompt tokens.
|
|
50
|
+
return "completion", {
|
|
51
|
+
"prompt": {
|
|
52
|
+
"prompt_string": prefix.text + suffix,
|
|
53
|
+
"multimodal_data": [_base64_payload(i.data_uri) for i in prefix.images],
|
|
54
|
+
},
|
|
55
|
+
"n_predict": 1,
|
|
56
|
+
"temperature": 0,
|
|
57
|
+
"stream": False,
|
|
58
|
+
}
|
|
59
|
+
body: dict[str, Any] = {
|
|
60
|
+
"model": model,
|
|
61
|
+
"prompt": prefix.text + suffix,
|
|
62
|
+
"max_tokens": 1,
|
|
63
|
+
"temperature": 0,
|
|
64
|
+
"stream": False,
|
|
65
|
+
}
|
|
66
|
+
return "completions", body
|
|
67
|
+
content: str | list[dict[str, Any]]
|
|
68
|
+
if prefix.images:
|
|
69
|
+
content = [{"type": "text", "text": prefix.text}]
|
|
70
|
+
content.extend(
|
|
71
|
+
{"type": "image_url", "image_url": {"url": image.data_uri}} for image in prefix.images
|
|
72
|
+
)
|
|
73
|
+
content.append({"type": "text", "text": suffix})
|
|
74
|
+
else:
|
|
75
|
+
content = prefix.text + suffix
|
|
76
|
+
messages: list[dict[str, Any]] = []
|
|
77
|
+
if prefix.system:
|
|
78
|
+
messages.append({"role": "system", "content": prefix.system})
|
|
79
|
+
messages.append({"role": "user", "content": content})
|
|
80
|
+
body = {
|
|
81
|
+
"model": model,
|
|
82
|
+
"messages": messages,
|
|
83
|
+
"max_tokens": 1,
|
|
84
|
+
"temperature": 0,
|
|
85
|
+
"stream": False,
|
|
86
|
+
"logprobs": True,
|
|
87
|
+
}
|
|
88
|
+
if prefix.prefill:
|
|
89
|
+
messages.append({"role": "assistant", "content": prefix.prefill})
|
|
90
|
+
body["continue_final_message"] = True
|
|
91
|
+
body["add_generation_prompt"] = False
|
|
92
|
+
if prefix.kwargs and dialect is not Dialect.GENERIC:
|
|
93
|
+
body["chat_template_kwargs"] = dict(prefix.kwargs)
|
|
94
|
+
return "chat/completions", body
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _base64_payload(data_uri: str) -> str:
|
|
98
|
+
return data_uri.split(",", 1)[1] if data_uri.startswith("data:") else data_uri
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def neutral_samplers(body: dict[str, Any]) -> None:
|
|
102
|
+
"""llama.cpp only: post-sampling probabilities are read after the sampler chain, so the
|
|
103
|
+
chain must not truncate or reshape the distribution (the server's defaults do)."""
|
|
104
|
+
body.update({"temperature": 1.0, "top_k": 0, "top_p": 1.0, "min_p": 0.0})
|
|
105
|
+
body["post_sampling_probs"] = True
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def grammar_for(texts: list[str]) -> str:
|
|
109
|
+
"""A GBNF root that admits exactly the answer spellings."""
|
|
110
|
+
escaped = " | ".join('"' + t.replace("\\", "\\\\").replace('"', '\\"') + '"' for t in texts)
|
|
111
|
+
return f"root ::= {escaped}"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def parse_response(path: str, response: dict[str, Any], dialect: Dialect) -> Parsed:
|
|
115
|
+
entries: list[Entry] = []
|
|
116
|
+
if path == "completion":
|
|
117
|
+
# llama.cpp native route: completion_probabilities plus top-level counters.
|
|
118
|
+
items = response.get("completion_probabilities") or []
|
|
119
|
+
if items:
|
|
120
|
+
for item in items[0].get("top_logprobs") or items[0].get("top_probs") or []:
|
|
121
|
+
entries.append(_entry_from(item, dialect))
|
|
122
|
+
return Parsed(
|
|
123
|
+
entries=tuple(entries),
|
|
124
|
+
prompt_tokens=response.get("tokens_evaluated"),
|
|
125
|
+
cached_tokens=response.get("tokens_cached"),
|
|
126
|
+
completion_tokens=response.get("tokens_predicted"),
|
|
127
|
+
)
|
|
128
|
+
choice = response["choices"][0]
|
|
129
|
+
logprobs = choice.get("logprobs") or {}
|
|
130
|
+
# Shape-driven, not dialect-driven: llama.cpp answers the completions route with the
|
|
131
|
+
# chat-style `content` list (ids included); OpenAI and vLLM use the dict form there.
|
|
132
|
+
if "content" in logprobs:
|
|
133
|
+
content = logprobs.get("content") or []
|
|
134
|
+
if content:
|
|
135
|
+
for item in content[0].get("top_logprobs") or content[0].get("top_probs") or []:
|
|
136
|
+
entries.append(_entry_from(item, dialect))
|
|
137
|
+
else:
|
|
138
|
+
top = (logprobs.get("top_logprobs") or [{}])[0] or {}
|
|
139
|
+
entries = [Entry(text=text, logprob=float(lp)) for text, lp in top.items()]
|
|
140
|
+
usage = response.get("usage") or {}
|
|
141
|
+
details = usage.get("prompt_tokens_details") or {}
|
|
142
|
+
cached = details.get("cached_tokens")
|
|
143
|
+
if cached is None and "tokens_cached" in response:
|
|
144
|
+
cached = response.get("tokens_cached")
|
|
145
|
+
return Parsed(
|
|
146
|
+
entries=tuple(entries),
|
|
147
|
+
prompt_tokens=usage.get("prompt_tokens"),
|
|
148
|
+
cached_tokens=cached,
|
|
149
|
+
completion_tokens=usage.get("completion_tokens"),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _entry_from(item: dict[str, Any], dialect: Dialect) -> Entry:
|
|
154
|
+
text = str(item.get("token", ""))
|
|
155
|
+
ident = item.get("id")
|
|
156
|
+
if ident is None and text.startswith("token_id:"):
|
|
157
|
+
try:
|
|
158
|
+
ident = int(text[len("token_id:") :])
|
|
159
|
+
except ValueError:
|
|
160
|
+
ident = None
|
|
161
|
+
value = item.get("logprob")
|
|
162
|
+
if value is None and "prob" in item:
|
|
163
|
+
import math
|
|
164
|
+
|
|
165
|
+
value = math.log(item["prob"]) if item["prob"] > 0 else -1e4
|
|
166
|
+
return Entry(text=text, logprob=float(value), id=ident)
|