every-other-token 0.1.0__tar.gz

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,6 @@
1
+ .venv/
2
+ dist/
3
+ build/
4
+ *.egg-info/
5
+ __pycache__/
6
+ .pytest_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Mattbusel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.5
2
+ Name: every-other-token
3
+ Version: 0.1.0
4
+ Summary: Render per-token LLM confidence from logprobs as an HTML heatmap (Jupyter, CLI). OpenAI, vLLM and OpenAI-compatible servers.
5
+ Project-URL: Homepage, https://every-other-token.vercel.app/
6
+ Project-URL: Documentation, https://github.com/Mattbusel/Every-Other-Token/blob/main/docs/python.md
7
+ Project-URL: Source, https://github.com/Mattbusel/Every-Other-Token/tree/main/python
8
+ Project-URL: Changelog, https://github.com/Mattbusel/Every-Other-Token/blob/main/CHANGELOG.md
9
+ Author: Matthew Busel
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: confidence,heatmap,interpretability,jupyter,llm,logprobs,openai,vllm
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Framework :: Jupyter
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Intended Audience :: Science/Research
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.9
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest>=7; extra == 'test'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # every-other-token (Python)
27
+
28
+ See how sure an LLM was about every token of its answer. Ask for logprobs,
29
+ pass the response to `render()`, and get an HTML heatmap: green where the
30
+ model was confident, amber where it hesitated, red where it was guessing.
31
+ Hover a token for its probability, logprob, perplexity and the model's top
32
+ alternatives.
33
+
34
+ ```python
35
+ from openai import OpenAI
36
+ from every_other_token import render
37
+
38
+ resp = OpenAI().chat.completions.create(
39
+ model="gpt-4o-mini",
40
+ messages=[{"role": "user", "content": "Name three moons of Jupiter."}],
41
+ logprobs=True,
42
+ top_logprobs=5,
43
+ )
44
+ render(resp) # in Jupyter this displays the heatmap
45
+ ```
46
+
47
+ Works with OpenAI Chat Completions and legacy Completions (SDK objects or
48
+ dicts), lists of streamed chunks, vLLM / llama.cpp / LM Studio
49
+ (OpenAI-compatible servers) and vLLM's offline `RequestOutput`. Pure Python,
50
+ no dependencies.
51
+
52
+ From the shell:
53
+
54
+ ```
55
+ eot-py response.json -o heatmap.html
56
+ eot-py response.json --format text
57
+ ```
58
+
59
+ Full guide: https://github.com/Mattbusel/Every-Other-Token/blob/main/docs/python.md
60
+
61
+ This is the Python companion to the
62
+ [every-other-token](https://crates.io/crates/every-other-token) Rust CLI,
63
+ which streams live from OpenAI, Anthropic, Gemini, OpenRouter and local
64
+ models. MIT licensed.
@@ -0,0 +1,39 @@
1
+ # every-other-token (Python)
2
+
3
+ See how sure an LLM was about every token of its answer. Ask for logprobs,
4
+ pass the response to `render()`, and get an HTML heatmap: green where the
5
+ model was confident, amber where it hesitated, red where it was guessing.
6
+ Hover a token for its probability, logprob, perplexity and the model's top
7
+ alternatives.
8
+
9
+ ```python
10
+ from openai import OpenAI
11
+ from every_other_token import render
12
+
13
+ resp = OpenAI().chat.completions.create(
14
+ model="gpt-4o-mini",
15
+ messages=[{"role": "user", "content": "Name three moons of Jupiter."}],
16
+ logprobs=True,
17
+ top_logprobs=5,
18
+ )
19
+ render(resp) # in Jupyter this displays the heatmap
20
+ ```
21
+
22
+ Works with OpenAI Chat Completions and legacy Completions (SDK objects or
23
+ dicts), lists of streamed chunks, vLLM / llama.cpp / LM Studio
24
+ (OpenAI-compatible servers) and vLLM's offline `RequestOutput`. Pure Python,
25
+ no dependencies.
26
+
27
+ From the shell:
28
+
29
+ ```
30
+ eot-py response.json -o heatmap.html
31
+ eot-py response.json --format text
32
+ ```
33
+
34
+ Full guide: https://github.com/Mattbusel/Every-Other-Token/blob/main/docs/python.md
35
+
36
+ This is the Python companion to the
37
+ [every-other-token](https://crates.io/crates/every-other-token) Rust CLI,
38
+ which streams live from OpenAI, Anthropic, Gemini, OpenRouter and local
39
+ models. MIT licensed.
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "every-other-token"
7
+ version = "0.1.0"
8
+ description = "Render per-token LLM confidence from logprobs as an HTML heatmap (Jupyter, CLI). OpenAI, vLLM and OpenAI-compatible servers."
9
+ readme = "PYPI.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Matthew Busel" }]
14
+ keywords = ["llm", "logprobs", "openai", "vllm", "jupyter", "interpretability", "confidence", "heatmap"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Framework :: Jupyter",
18
+ "Intended Audience :: Developers",
19
+ "Intended Audience :: Science/Research",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = []
26
+
27
+ [project.optional-dependencies]
28
+ test = ["pytest>=7"]
29
+
30
+ [project.urls]
31
+ Homepage = "https://every-other-token.vercel.app/"
32
+ Documentation = "https://github.com/Mattbusel/Every-Other-Token/blob/main/docs/python.md"
33
+ Source = "https://github.com/Mattbusel/Every-Other-Token/tree/main/python"
34
+ Changelog = "https://github.com/Mattbusel/Every-Other-Token/blob/main/CHANGELOG.md"
35
+
36
+ [project.scripts]
37
+ eot-py = "every_other_token.cli:main"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/every_other_token"]
41
+
42
+ [tool.hatch.build.targets.sdist]
43
+ include = ["src/", "tests/", "PYPI.md", "LICENSE", "pyproject.toml"]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
@@ -0,0 +1,32 @@
1
+ """See how sure an LLM was about every token of its answer.
2
+
3
+ >>> from every_other_token import render
4
+ >>> resp = client.chat.completions.create(model="gpt-4o-mini", messages=msgs,
5
+ ... logprobs=True, top_logprobs=5) # doctest: +SKIP
6
+ >>> render(resp) # in Jupyter: a confidence heatmap # doctest: +SKIP
7
+
8
+ The Python companion to the ``every-other-token`` Rust CLI. Pure Python, no
9
+ dependencies. Accepts OpenAI Chat Completions and legacy Completions (SDK
10
+ objects or dicts), lists of streamed chunks, any OpenAI-compatible server
11
+ (vLLM, llama.cpp, LM Studio) and vLLM's offline ``RequestOutput``.
12
+ """
13
+
14
+ from ._parse import Alternative, ResponseShapeError, Token, parse
15
+ from ._render import Heatmap, Summary, band, render, render_text, render_tokens, summarize
16
+
17
+ __version__ = "0.1.0"
18
+
19
+ __all__ = [
20
+ "Alternative",
21
+ "Heatmap",
22
+ "ResponseShapeError",
23
+ "Summary",
24
+ "Token",
25
+ "band",
26
+ "parse",
27
+ "render",
28
+ "render_text",
29
+ "render_tokens",
30
+ "summarize",
31
+ "__version__",
32
+ ]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,376 @@
1
+ """Turn LLM responses that carry log-probabilities into a flat list of tokens.
2
+
3
+ Supported shapes (objects from the ``openai`` SDK, plain dicts, or JSON):
4
+
5
+ * OpenAI **Chat Completions** with ``logprobs=True``:
6
+ ``choices[i].logprobs.content[j] = {token, logprob, bytes, top_logprobs}``.
7
+ vLLM, llama.cpp, LM Studio and other OpenAI-compatible servers return the
8
+ same shape.
9
+ * A list of **streamed chat chunks** (``stream=True``), each carrying its own
10
+ ``choices[i].logprobs.content`` slice.
11
+ * OpenAI legacy **Completions** with ``logprobs=N``:
12
+ ``choices[i].logprobs = {tokens, token_logprobs, top_logprobs}``.
13
+ * vLLM **offline** ``RequestOutput`` (``LLM.generate`` with
14
+ ``SamplingParams(logprobs=N)``): ``outputs[i].token_ids`` plus
15
+ ``outputs[i].logprobs[j] = {token_id: Logprob(logprob, rank, decoded_token)}``.
16
+
17
+ The arithmetic matches the Rust crate: ``confidence = clamp(exp(logprob), 0, 1)``
18
+ and per-token ``perplexity = exp(-logprob)``.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import math
24
+ from dataclasses import dataclass, field
25
+ from typing import Any, Iterable, List, Mapping, Optional, Sequence, Tuple
26
+
27
+ __all__ = ["Alternative", "Token", "parse", "ResponseShapeError"]
28
+
29
+
30
+ class ResponseShapeError(ValueError):
31
+ """The response has no per-token log-probabilities in a shape we know."""
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Alternative:
36
+ """One of the model's top candidates at a position."""
37
+
38
+ text: str
39
+ logprob: float
40
+
41
+ @property
42
+ def probability(self) -> float:
43
+ return _confidence(self.logprob)
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Token:
48
+ """A generated token and how sure the model was about it.
49
+
50
+ ``logprob`` is ``None`` when the provider sent no number for this token
51
+ (for example the first prompt token of an ``echo=True`` completion).
52
+ """
53
+
54
+ text: str
55
+ logprob: Optional[float]
56
+ alternatives: Tuple[Alternative, ...] = field(default_factory=tuple)
57
+
58
+ @property
59
+ def confidence(self) -> Optional[float]:
60
+ """``exp(logprob)`` clamped to ``[0, 1]``, or ``None``."""
61
+ return None if self.logprob is None else _confidence(self.logprob)
62
+
63
+ @property
64
+ def perplexity(self) -> Optional[float]:
65
+ """Per-token perplexity ``exp(-logprob)``, or ``None``."""
66
+ if self.logprob is None:
67
+ return None
68
+ try:
69
+ return math.exp(-self.logprob)
70
+ except OverflowError:
71
+ return math.inf
72
+
73
+
74
+ def _confidence(logprob: float) -> float:
75
+ if math.isnan(logprob):
76
+ return 0.0
77
+ if logprob >= 0:
78
+ return 1.0
79
+ return min(1.0, max(0.0, math.exp(logprob)))
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Generic accessors: work on dicts, pydantic models and plain objects alike.
84
+ # ---------------------------------------------------------------------------
85
+
86
+ _MISSING = object()
87
+
88
+
89
+ def _get(obj: Any, key: str, default: Any = None) -> Any:
90
+ if obj is None:
91
+ return default
92
+ if isinstance(obj, Mapping):
93
+ return obj.get(key, default)
94
+ value = getattr(obj, key, _MISSING)
95
+ return default if value is _MISSING else value
96
+
97
+
98
+ def _to_plain(resp: Any) -> Any:
99
+ """Convert SDK objects to dicts when they offer a lossless dump."""
100
+ if isinstance(resp, (Mapping, list, tuple)):
101
+ return resp
102
+ for method in ("model_dump", "to_dict", "dict"):
103
+ fn = getattr(resp, method, None)
104
+ if callable(fn):
105
+ try:
106
+ out = fn()
107
+ except TypeError:
108
+ continue
109
+ if isinstance(out, Mapping):
110
+ return out
111
+ return resp
112
+
113
+
114
+ def _num(value: Any) -> Optional[float]:
115
+ if value is None:
116
+ return None
117
+ try:
118
+ return float(value)
119
+ except (TypeError, ValueError):
120
+ return None
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Shape handlers
125
+ # ---------------------------------------------------------------------------
126
+
127
+
128
+ # (text, logprob, raw UTF-8 bytes or None, alternatives) before byte merging.
129
+ _Entry = Tuple[str, Optional[float], Optional[bytes], Tuple[Alternative, ...]]
130
+
131
+
132
+ def _merge_byte_fragments(entries: List[_Entry]) -> List[Token]:
133
+ """Join tokens that each hold part of one UTF-8 character.
134
+
135
+ OpenAI splits some characters (emoji, CJK) across tokens and sends each
136
+ fragment as ``"bytes:\\xe2\\x80"`` with the raw ``bytes``. We join the
137
+ fragments until they decode; the joined token's logprob is the sum (the
138
+ probability of the whole run), and its alternatives are the first
139
+ fragment's.
140
+ """
141
+ out: List[Token] = []
142
+ pending: List[_Entry] = []
143
+
144
+ def flush_raw() -> None:
145
+ for text, lp, _b, alts in pending:
146
+ out.append(Token(text, lp, alts))
147
+ pending.clear()
148
+
149
+ for entry in entries:
150
+ text, lp, raw, alts = entry
151
+ if raw is None and not pending:
152
+ out.append(Token(text, lp, alts))
153
+ continue
154
+ if raw is None:
155
+ flush_raw()
156
+ out.append(Token(text, lp, alts))
157
+ continue
158
+ pending.append(entry)
159
+ buf = b"".join(p[2] or b"" for p in pending)
160
+ try:
161
+ decoded = buf.decode("utf-8")
162
+ except UnicodeDecodeError:
163
+ if len(pending) >= 4:
164
+ flush_raw()
165
+ continue
166
+ if len(pending) == 1:
167
+ out.append(Token(text if not text.startswith("bytes:") else decoded, lp, alts))
168
+ else:
169
+ lps = [p[1] for p in pending]
170
+ total = None if any(v is None for v in lps) else sum(lps) # type: ignore[arg-type]
171
+ out.append(Token(decoded, total, pending[0][3]))
172
+ pending.clear()
173
+ flush_raw()
174
+ return out
175
+
176
+
177
+ def _chat_entries(content: Iterable[Any]) -> List[_Entry]:
178
+ entries = []
179
+ for item in content or []:
180
+ text = _get(item, "token", "") or ""
181
+ lp = _num(_get(item, "logprob"))
182
+ raw_bytes = _get(item, "bytes")
183
+ raw = bytes(raw_bytes) if isinstance(raw_bytes, (list, tuple, bytes, bytearray)) else None
184
+ alts = tuple(
185
+ Alternative(_get(a, "token", "") or "", float(_get(a, "logprob")))
186
+ for a in (_get(item, "top_logprobs") or [])
187
+ if _num(_get(a, "logprob")) is not None
188
+ )
189
+ entries.append((text, lp, raw, alts))
190
+ return entries
191
+
192
+
193
+ def _from_completion_logprobs(logprobs: Any) -> Optional[List[Token]]:
194
+ tokens = _get(logprobs, "tokens")
195
+ if tokens is None:
196
+ return None
197
+ lps = _get(logprobs, "token_logprobs") or []
198
+ tops = _get(logprobs, "top_logprobs") or []
199
+ out = []
200
+ for i, text in enumerate(tokens):
201
+ lp = _num(lps[i]) if i < len(lps) else None
202
+ top = tops[i] if i < len(tops) else None
203
+ alts: Tuple[Alternative, ...] = ()
204
+ if isinstance(top, Mapping):
205
+ alts = tuple(
206
+ Alternative(str(k), float(v))
207
+ for k, v in sorted(top.items(), key=lambda kv: _desc(_num(kv[1])))
208
+ if _num(v) is not None
209
+ )
210
+ out.append(Token(str(text), lp, alts))
211
+ return out
212
+
213
+
214
+ def _desc(value: Optional[float]) -> float:
215
+ """Sort key: highest value first, missing values last. 0.0 is a real value."""
216
+ return math.inf if value is None else -value
217
+
218
+
219
+ def _has(obj: Any, key: str) -> bool:
220
+ if isinstance(obj, Mapping):
221
+ return key in obj
222
+ return hasattr(obj, key)
223
+
224
+
225
+ def _logprobs_entries(logprobs: Any) -> Optional[List[_Entry]]:
226
+ """Raw entries of one ``choices[i].logprobs`` object, before byte merging.
227
+
228
+ Returns ``[]`` for a chat logprobs object with nothing in it (an empty
229
+ answer, ``max_tokens=0``, or ``content: null``), and ``None`` only when the
230
+ object is not a logprobs shape we know.
231
+ """
232
+ content = _get(logprobs, "content")
233
+ if isinstance(content, (list, tuple)):
234
+ return _chat_entries(content)
235
+ tokens = _get(logprobs, "tokens")
236
+ if isinstance(tokens, (list, tuple)):
237
+ return [(t.text, t.logprob, None, t.alternatives) for t in _from_completion_logprobs(logprobs) or []]
238
+ # A refusal carries its own token logprobs; those are the generated tokens.
239
+ refusal = _get(logprobs, "refusal")
240
+ if isinstance(refusal, (list, tuple)):
241
+ return _chat_entries(refusal)
242
+ if content is None and (_has(logprobs, "content") or _has(logprobs, "refusal")):
243
+ return []
244
+ return None
245
+
246
+
247
+ def _from_vllm_output(output: Any) -> Optional[List[Token]]:
248
+ logprobs = _get(output, "logprobs")
249
+ token_ids = _get(output, "token_ids")
250
+ if logprobs is None or token_ids is None:
251
+ return None
252
+ out = []
253
+ for i, tid in enumerate(token_ids):
254
+ pos = logprobs[i] if i < len(logprobs) else None
255
+ if not pos:
256
+ out.append(Token(str(tid), None))
257
+ continue
258
+ # Keys may be ints (Python objects) or strings (after a JSON round trip).
259
+ chosen = pos.get(tid, pos.get(str(tid))) if isinstance(pos, Mapping) else None
260
+ by_rank = sorted(pos.items(), key=lambda kv: _rank(kv[1]))
261
+ alts = tuple(
262
+ Alternative(_decoded(lp, key), float(_get(lp, "logprob")))
263
+ for key, lp in by_rank
264
+ if _num(_get(lp, "logprob")) is not None
265
+ )
266
+ if chosen is None:
267
+ out.append(Token(str(tid), None, alts))
268
+ else:
269
+ out.append(Token(_decoded(chosen, tid), _num(_get(chosen, "logprob")), alts))
270
+ return out
271
+
272
+
273
+ def _rank(lp: Any) -> Tuple[int, float]:
274
+ """Sort key: vLLM's rank when present, then most probable first."""
275
+ rank = _num(_get(lp, "rank"))
276
+ if rank is not None:
277
+ return (0, rank)
278
+ lpv = _num(_get(lp, "logprob"))
279
+ return (1, math.inf if lpv is None else -lpv)
280
+
281
+
282
+ def _decoded(lp: Any, key: Any) -> str:
283
+ text = _get(lp, "decoded_token")
284
+ return str(key) if text is None else str(text)
285
+
286
+
287
+ def _choice_entries(choice: Any) -> Optional[List[_Entry]]:
288
+ logprobs = _get(choice, "logprobs")
289
+ if logprobs is None:
290
+ return None
291
+ return _logprobs_entries(logprobs)
292
+
293
+
294
+ def _pick(seq: Sequence[Any], index: int, what: str) -> Any:
295
+ for item in seq:
296
+ if _get(item, "index", None) == index:
297
+ return item
298
+ if 0 <= index < len(seq):
299
+ return seq[index]
300
+ raise ResponseShapeError(f"response has no {what} with index {index} (it has {len(seq)})")
301
+
302
+
303
+ def parse(resp: Any, choice: int = 0) -> List[Token]:
304
+ """Extract the tokens of one choice, with their log-probabilities.
305
+
306
+ ``resp`` may be an ``openai`` SDK object, a dict, a JSON string, a list of
307
+ streamed chunks, or a vLLM ``RequestOutput`` (object or dict). Raises
308
+ :class:`ResponseShapeError` when no logprobs are present, with a hint on
309
+ how to ask for them.
310
+ """
311
+ if isinstance(resp, (str, bytes)):
312
+ import json
313
+
314
+ resp = json.loads(resp)
315
+ resp = _to_plain(resp)
316
+
317
+ # A list of streamed chunks (or the list of RequestOutputs LLM.generate returns).
318
+ if isinstance(resp, (list, tuple)):
319
+ n_outputs = sum(1 for item in resp if _get(item, "outputs") is not None)
320
+ if n_outputs == 1 and len(resp) == 1:
321
+ return parse(resp[0], choice)
322
+ if n_outputs:
323
+ raise ResponseShapeError(
324
+ f"got a list of {len(resp)} vLLM RequestOutputs (one per prompt); "
325
+ "pass one of them, for example render(outputs[0])"
326
+ )
327
+ # Collect every chunk's raw entries first and merge once: a character
328
+ # split into byte fragments usually arrives across several chunks.
329
+ entries: List[_Entry] = []
330
+ saw_logprobs = False
331
+ for chunk in resp:
332
+ chunk = _to_plain(chunk)
333
+ for c in _get(chunk, "choices") or []:
334
+ if (_get(c, "index", 0) or 0) != choice:
335
+ continue
336
+ part = _choice_entries(c)
337
+ if part is not None:
338
+ saw_logprobs = True
339
+ entries.extend(part)
340
+ if not saw_logprobs:
341
+ raise ResponseShapeError(_NO_LOGPROBS)
342
+ return _merge_byte_fragments(entries)
343
+
344
+ choices = _get(resp, "choices")
345
+ if choices is not None:
346
+ picked = _pick(choices, choice, "choice")
347
+ picked_entries = _choice_entries(picked)
348
+ if picked_entries is None:
349
+ raise ResponseShapeError(_NO_LOGPROBS)
350
+ return _merge_byte_fragments(picked_entries)
351
+
352
+ outputs = _get(resp, "outputs")
353
+ if outputs is not None:
354
+ out = _from_vllm_output(_pick(outputs, choice, "output"))
355
+ if out is None:
356
+ raise ResponseShapeError(
357
+ "vLLM output has no logprobs: pass SamplingParams(logprobs=5) to LLM.generate"
358
+ )
359
+ return out
360
+
361
+ # A bare logprobs object (choices[i].logprobs) is accepted too.
362
+ # Only list-valued content/tokens/refusal count, so a chat message dict
363
+ # ({"role": ..., "content": "text"}) is not mistaken for one.
364
+ if any(isinstance(_get(resp, k), (list, tuple)) for k in ("content", "tokens", "refusal")):
365
+ bare = _logprobs_entries(resp)
366
+ if bare is not None:
367
+ return _merge_byte_fragments(bare)
368
+ raise ResponseShapeError(
369
+ "not a Chat Completion, Completion, list of stream chunks or vLLM RequestOutput"
370
+ )
371
+
372
+
373
+ _NO_LOGPROBS = (
374
+ "response has no logprobs: call the API with logprobs=True (and top_logprobs=5 "
375
+ "for alternatives) for chat completions, or logprobs=5 for legacy completions"
376
+ )