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.
- every_other_token-0.1.0/.gitignore +6 -0
- every_other_token-0.1.0/LICENSE +21 -0
- every_other_token-0.1.0/PKG-INFO +64 -0
- every_other_token-0.1.0/PYPI.md +39 -0
- every_other_token-0.1.0/pyproject.toml +46 -0
- every_other_token-0.1.0/src/every_other_token/__init__.py +32 -0
- every_other_token-0.1.0/src/every_other_token/__main__.py +5 -0
- every_other_token-0.1.0/src/every_other_token/_parse.py +376 -0
- every_other_token-0.1.0/src/every_other_token/_render.py +236 -0
- every_other_token-0.1.0/src/every_other_token/cli.py +130 -0
- every_other_token-0.1.0/src/every_other_token/py.typed +0 -0
- every_other_token-0.1.0/tests/fixtures/chat_completion.json +41 -0
- every_other_token-0.1.0/tests/fixtures/chat_stream_chunks.json +6 -0
- every_other_token-0.1.0/tests/fixtures/completion.json +20 -0
- every_other_token-0.1.0/tests/fixtures/vllm_request_output.json +20 -0
- every_other_token-0.1.0/tests/test_render.py +307 -0
|
@@ -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,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
|
+
)
|