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.
Files changed (45) hide show
  1. jevify/__init__.py +7 -0
  2. jevify/__main__.py +9 -0
  3. jevify/adapters/__init__.py +1 -0
  4. jevify/adapters/embedding/__init__.py +1 -0
  5. jevify/adapters/embedding/adapter.py +203 -0
  6. jevify/adapters/embedding/local.py +82 -0
  7. jevify/adapters/encoder/__init__.py +1 -0
  8. jevify/adapters/encoder/adapter.py +165 -0
  9. jevify/adapters/encoder/local.py +77 -0
  10. jevify/adapters/endpoint/__init__.py +1 -0
  11. jevify/adapters/endpoint/adapter.py +281 -0
  12. jevify/adapters/endpoint/client.py +114 -0
  13. jevify/adapters/endpoint/dialects.py +166 -0
  14. jevify/adapters/endpoint/probe.py +589 -0
  15. jevify/adapters/endpoint/readout.py +244 -0
  16. jevify/adapters/rerank/__init__.py +1 -0
  17. jevify/adapters/rerank/adapter.py +152 -0
  18. jevify/api/__init__.py +1 -0
  19. jevify/api/app.py +228 -0
  20. jevify/api/schema.py +123 -0
  21. jevify/api/translate.py +239 -0
  22. jevify/cli.py +392 -0
  23. jevify/compose.py +82 -0
  24. jevify/domain/__init__.py +1 -0
  25. jevify/domain/distribution.py +103 -0
  26. jevify/domain/engine.py +152 -0
  27. jevify/domain/questions.py +121 -0
  28. jevify/evaluation/__init__.py +1 -0
  29. jevify/evaluation/calibrate.py +186 -0
  30. jevify/evaluation/controls.py +78 -0
  31. jevify/evaluation/metrics.py +91 -0
  32. jevify/evaluation/scorecard.py +385 -0
  33. jevify/evaluation/suites.py +166 -0
  34. jevify/ports/__init__.py +1 -0
  35. jevify/ports/backend.py +162 -0
  36. jevify/recipes/__init__.py +21 -0
  37. jevify/recipes/probe_case.yaml +72 -0
  38. jevify/recipes/render.py +198 -0
  39. jevify/recipes/schema.py +204 -0
  40. jevify/recipes/store.py +81 -0
  41. jevify-0.1.0.dist-info/METADATA +243 -0
  42. jevify-0.1.0.dist-info/RECORD +45 -0
  43. jevify-0.1.0.dist-info/WHEEL +4 -0
  44. jevify-0.1.0.dist-info/entry_points.txt +2 -0
  45. 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)