every-other-token 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.
@@ -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
+ )
@@ -0,0 +1,236 @@
1
+ """Render parsed tokens as an HTML confidence heatmap or ANSI terminal text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import math
7
+ from dataclasses import dataclass
8
+ from typing import Any, List, Optional, Sequence
9
+
10
+ from ._parse import Token, parse
11
+
12
+ __all__ = ["Heatmap", "Summary", "band", "render", "render_text", "summarize"]
13
+
14
+ # Same thresholds and colours as the Rust CLI (render::ConfidenceThresholds)
15
+ # and the web UI (.conf-high / .conf-mid / .conf-low).
16
+ DEFAULT_HIGH = 0.7
17
+ DEFAULT_MID = 0.4
18
+ _RGB = {"high": (63, 185, 80), "mid": (227, 179, 65), "low": (248, 81, 73)}
19
+
20
+
21
+ def band(confidence: Optional[float], high: float = DEFAULT_HIGH, mid: float = DEFAULT_MID) -> str:
22
+ """Classify a confidence as ``"high"``, ``"mid"``, ``"low"`` or ``"none"``."""
23
+ if confidence is None:
24
+ return "none"
25
+ if confidence >= high:
26
+ return "high"
27
+ if confidence >= mid:
28
+ return "mid"
29
+ return "low"
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Summary:
34
+ """Whole-answer numbers.
35
+
36
+ ``perplexity`` is the sequence perplexity ``exp(-mean(logprob))`` over the
37
+ tokens that have a logprob; ``mean_confidence`` is the mean of
38
+ ``exp(logprob)``. ``low``/``mid``/``high`` count tokens per band.
39
+ """
40
+
41
+ tokens: int
42
+ scored: int
43
+ mean_confidence: Optional[float]
44
+ perplexity: Optional[float]
45
+ high: int
46
+ mid: int
47
+ low: int
48
+ least_confident: List[Token]
49
+
50
+
51
+ def summarize(tokens: Sequence[Token], high: float = DEFAULT_HIGH, mid: float = DEFAULT_MID, worst: int = 5) -> Summary:
52
+ scored = [t for t in tokens if t.logprob is not None]
53
+ counts = {"high": 0, "mid": 0, "low": 0}
54
+ for t in scored:
55
+ counts[band(t.confidence, high, mid)] += 1
56
+ if scored:
57
+ mean_lp = sum(t.logprob for t in scored) / len(scored) # type: ignore[misc]
58
+ mean_conf = sum(t.confidence for t in scored) / len(scored) # type: ignore[misc]
59
+ try:
60
+ ppl: Optional[float] = math.exp(-mean_lp)
61
+ except OverflowError:
62
+ ppl = math.inf
63
+ else:
64
+ mean_conf = None
65
+ ppl = None
66
+ least = sorted(scored, key=lambda t: t.logprob)[:worst] # type: ignore[arg-type,return-value]
67
+ return Summary(len(tokens), len(scored), mean_conf, ppl, counts["high"], counts["mid"], counts["low"], least)
68
+
69
+
70
+ class Heatmap(str):
71
+ """The rendered HTML (a ``str``), shown as a heatmap by Jupyter.
72
+
73
+ Also carries ``tokens`` and ``summary`` for programmatic use. ``str(h)``
74
+ is the HTML fragment; :meth:`page` wraps it in a full HTML document.
75
+ """
76
+
77
+ tokens: List[Token]
78
+ summary: Summary
79
+
80
+ def __new__(cls, markup: str, tokens: List[Token], summary: Summary) -> "Heatmap":
81
+ obj = super().__new__(cls, markup)
82
+ obj.tokens = tokens
83
+ obj.summary = summary
84
+ return obj
85
+
86
+ def _repr_html_(self) -> str:
87
+ return str(self)
88
+
89
+ def __repr__(self) -> str:
90
+ s = self.summary
91
+ if not s.scored:
92
+ return f"<Heatmap {s.tokens} tokens, no logprobs>"
93
+ return (
94
+ f"<Heatmap {s.tokens} tokens, mean confidence {s.mean_confidence * 100:.1f}%, " # type: ignore[operator]
95
+ f"perplexity {s.perplexity:.2f}>"
96
+ )
97
+
98
+ def page(self, title: str = "Token confidence") -> str:
99
+ """A standalone HTML document, ready to write to a ``.html`` file."""
100
+ return (
101
+ "<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\">"
102
+ "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
103
+ f"<title>{html.escape(title)}</title></head>"
104
+ "<body style=\"margin:0;padding:24px 16px;font-family:system-ui,sans-serif;"
105
+ "background:#fff;color:#1f2328\">"
106
+ f"{self}</body></html>\n"
107
+ )
108
+
109
+
110
+ def _fmt_pct(c: Optional[float]) -> str:
111
+ return "n/a" if c is None else f"{c * 100:.1f}%"
112
+
113
+
114
+ def _tooltip(t: Token, max_alts: int) -> str:
115
+ if t.logprob is None:
116
+ return "no logprob for this token"
117
+ lines = [
118
+ f"confidence {_fmt_pct(t.confidence)}",
119
+ f"logprob {t.logprob:.4f}",
120
+ f"perplexity {t.perplexity:.2f}",
121
+ ]
122
+ if t.alternatives:
123
+ lines.append("top alternatives:")
124
+ for a in t.alternatives[:max_alts]:
125
+ lines.append(f" {a.text!r} {_fmt_pct(a.probability)}")
126
+ return "\n".join(lines)
127
+
128
+
129
+ def _style(t: Token, high: float, mid: float) -> str:
130
+ b = band(t.confidence, high, mid)
131
+ if b == "none":
132
+ return "border-bottom:1px dashed #8b949e"
133
+ r, g, bl = _RGB[b]
134
+ c = t.confidence or 0.0
135
+ # Stronger tint the less sure the model was, so trouble spots stand out.
136
+ alpha = 0.12 + 0.5 * (1.0 - c)
137
+ return f"background:rgba({r},{g},{bl},{alpha:.2f});border-radius:2px"
138
+
139
+
140
+ def render_tokens(
141
+ tokens: Sequence[Token],
142
+ *,
143
+ high: float = DEFAULT_HIGH,
144
+ mid: float = DEFAULT_MID,
145
+ max_alternatives: int = 5,
146
+ show_summary: bool = True,
147
+ ) -> Heatmap:
148
+ """Render already-parsed tokens. See :func:`render`."""
149
+ tokens = list(tokens)
150
+ s = summarize(tokens, high, mid)
151
+ spans = []
152
+ for t in tokens:
153
+ title = html.escape(_tooltip(t, max_alternatives), quote=True)
154
+ conf = "" if t.confidence is None else f" data-confidence=\"{t.confidence:.4f}\""
155
+ spans.append(
156
+ f"<span class=\"eot-tok eot-{band(t.confidence, high, mid)}\"{conf} "
157
+ f"title=\"{title}\" style=\"{_style(t, high, mid)}\">{html.escape(t.text)}</span>"
158
+ )
159
+ parts = [
160
+ "<div class=\"eot-heatmap\" style=\"font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;"
161
+ "font-size:14px;line-height:1.7\">"
162
+ ]
163
+ if show_summary:
164
+ parts.append(_summary_html(s, high, mid))
165
+ parts.append("<div class=\"eot-text\" style=\"white-space:pre-wrap;word-wrap:break-word\">")
166
+ parts.append("".join(spans))
167
+ parts.append("</div></div>")
168
+ return Heatmap("".join(parts), tokens, s)
169
+
170
+
171
+ def _swatch(name: str, label: str) -> str:
172
+ r, g, b = _RGB[name]
173
+ return (
174
+ f"<span style=\"background:rgba({r},{g},{b},0.45);border-radius:2px;padding:0 4px;"
175
+ f"margin-right:6px\">{html.escape(label)}</span>"
176
+ )
177
+
178
+
179
+ def _summary_html(s: Summary, high: float, mid: float) -> str:
180
+ if s.scored:
181
+ stats = (
182
+ f"{s.tokens} tokens &middot; mean confidence {_fmt_pct(s.mean_confidence)} &middot; "
183
+ f"perplexity {s.perplexity:.2f} &middot; {s.low} low / {s.mid} mid / {s.high} high"
184
+ )
185
+ else:
186
+ stats = f"{s.tokens} tokens &middot; no logprobs"
187
+ legend = (
188
+ _swatch("high", f">= {high:.0%}")
189
+ + _swatch("mid", f"{mid:.0%}-{high:.0%}")
190
+ + _swatch("low", f"< {mid:.0%}")
191
+ )
192
+ return (
193
+ "<div class=\"eot-summary\" style=\"font-family:system-ui,sans-serif;font-size:12px;"
194
+ f"opacity:.8;margin-bottom:6px\">{stats}<br>{legend}"
195
+ "<span>hover a token for its alternatives</span></div>"
196
+ )
197
+
198
+
199
+ def render(
200
+ resp: Any,
201
+ *,
202
+ choice: int = 0,
203
+ high: float = DEFAULT_HIGH,
204
+ mid: float = DEFAULT_MID,
205
+ max_alternatives: int = 5,
206
+ show_summary: bool = True,
207
+ ) -> Heatmap:
208
+ """Render a response that has logprobs as an HTML confidence heatmap.
209
+
210
+ ``resp`` is anything :func:`every_other_token.parse` accepts: an OpenAI
211
+ ``ChatCompletion`` or ``Completion`` (SDK object or dict) created with
212
+ logprobs, a list of streamed chunks, a JSON string, or a vLLM
213
+ ``RequestOutput``. In Jupyter the result displays as the heatmap; it is
214
+ also a plain ``str`` of HTML.
215
+
216
+ Each token is tinted green (confidence >= ``high``), amber (>= ``mid``)
217
+ or red (below), more strongly the less confident the model was. Hovering
218
+ shows confidence, logprob, perplexity and the top alternatives.
219
+ """
220
+ return render_tokens(
221
+ parse(resp, choice=choice),
222
+ high=high,
223
+ mid=mid,
224
+ max_alternatives=max_alternatives,
225
+ show_summary=show_summary,
226
+ )
227
+
228
+
229
+ _ANSI = {"high": "\x1b[32m", "mid": "\x1b[33m", "low": "\x1b[31m", "none": "\x1b[2m"}
230
+
231
+
232
+ def render_text(tokens: Sequence[Token], *, color: bool = True, high: float = DEFAULT_HIGH, mid: float = DEFAULT_MID) -> str:
233
+ """The answer with each token coloured by band (ANSI), like the Rust CLI."""
234
+ if not color:
235
+ return "".join(t.text for t in tokens)
236
+ return "".join(f"{_ANSI[band(t.confidence, high, mid)]}{t.text}\x1b[0m" for t in tokens)
@@ -0,0 +1,130 @@
1
+ """``eot-py``: render a saved LLM response with logprobs.
2
+
3
+ Examples::
4
+
5
+ eot-py response.json -o heatmap.html # standalone HTML page
6
+ eot-py response.json --format text # coloured terminal output
7
+ cat response.json | eot-py - --format json
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+ from typing import List, Optional
16
+
17
+ from . import __version__
18
+ from ._parse import ResponseShapeError, parse
19
+ from ._render import DEFAULT_HIGH, DEFAULT_MID, band, render_text, render_tokens, summarize
20
+
21
+
22
+ def _build_parser() -> argparse.ArgumentParser:
23
+ p = argparse.ArgumentParser(
24
+ prog="eot-py",
25
+ description=(
26
+ "Show how confident the model was about each token of a saved response "
27
+ "(OpenAI Chat Completion or Completion with logprobs, a JSON list of stream "
28
+ "chunks, or a vLLM RequestOutput dumped to JSON)."
29
+ ),
30
+ )
31
+ p.add_argument("input", help="path to the JSON response, or - for stdin")
32
+ p.add_argument("-o", "--output", help="write here instead of stdout")
33
+ p.add_argument(
34
+ "-f",
35
+ "--format",
36
+ choices=["html", "text", "json"],
37
+ default=None,
38
+ help="html (default with -o, a full page), text (default on a terminal) or json",
39
+ )
40
+ p.add_argument("--choice", type=int, default=0, help="which choice to render (default 0)")
41
+ p.add_argument("--high", type=float, default=DEFAULT_HIGH, help="confidence for green (default 0.7)")
42
+ p.add_argument("--mid", type=float, default=DEFAULT_MID, help="confidence for amber (default 0.4)")
43
+ p.add_argument("--no-color", action="store_true", help="plain text output")
44
+ p.add_argument("--title", default="Token confidence", help="HTML page title")
45
+ p.add_argument("--version", action="version", version=f"eot-py {__version__}")
46
+ return p
47
+
48
+
49
+ def _read(path: str) -> str:
50
+ if path == "-":
51
+ return sys.stdin.read()
52
+ with open(path, encoding="utf-8") as fh:
53
+ return fh.read()
54
+
55
+
56
+ def main(argv: Optional[List[str]] = None) -> int:
57
+ args = _build_parser().parse_args(argv)
58
+ if not 0.0 <= args.mid <= args.high <= 1.0:
59
+ print("eot-py: need 0 <= --mid <= --high <= 1", file=sys.stderr)
60
+ return 2
61
+ try:
62
+ data = json.loads(_read(args.input))
63
+ except (OSError, json.JSONDecodeError) as exc:
64
+ print(f"eot-py: cannot read {args.input}: {exc}", file=sys.stderr)
65
+ return 2
66
+ try:
67
+ tokens = parse(data, choice=args.choice)
68
+ except ResponseShapeError as exc:
69
+ print(f"eot-py: {exc}", file=sys.stderr)
70
+ return 1
71
+
72
+ fmt = args.format or ("html" if args.output or not sys.stdout.isatty() else "text")
73
+ if fmt == "html":
74
+ out = render_tokens(tokens, high=args.high, mid=args.mid).page(args.title)
75
+ elif fmt == "json":
76
+ s = summarize(tokens, args.high, args.mid)
77
+ out = json.dumps(
78
+ {
79
+ "summary": {
80
+ "tokens": s.tokens,
81
+ "scored": s.scored,
82
+ "mean_confidence": s.mean_confidence,
83
+ "perplexity": s.perplexity,
84
+ "high": s.high,
85
+ "mid": s.mid,
86
+ "low": s.low,
87
+ },
88
+ "tokens": [
89
+ {
90
+ "text": t.text,
91
+ "logprob": t.logprob,
92
+ "confidence": t.confidence,
93
+ "perplexity": t.perplexity,
94
+ "band": band(t.confidence, args.high, args.mid),
95
+ "alternatives": [{"text": a.text, "logprob": a.logprob} for a in t.alternatives],
96
+ }
97
+ for t in tokens
98
+ ],
99
+ },
100
+ indent=2,
101
+ ensure_ascii=False,
102
+ ) + "\n"
103
+ else:
104
+ color = not args.no_color and not args.output and sys.stdout.isatty()
105
+ s = summarize(tokens, args.high, args.mid)
106
+ text = render_text(tokens, color=color, high=args.high, mid=args.mid)
107
+ if s.scored:
108
+ footer = (
109
+ f"\n\n{s.tokens} tokens, mean confidence {s.mean_confidence * 100:.1f}%, " # type: ignore[operator]
110
+ f"perplexity {s.perplexity:.2f}, {s.low} low / {s.mid} mid / {s.high} high\n"
111
+ )
112
+ else:
113
+ footer = f"\n\n{s.tokens} tokens, no logprobs\n"
114
+ out = text + footer
115
+
116
+ if args.output:
117
+ with open(args.output, "w", encoding="utf-8", newline="\n") as fh:
118
+ fh.write(out)
119
+ print(f"wrote {args.output} ({len(tokens)} tokens)", file=sys.stderr)
120
+ else:
121
+ stream = sys.stdout
122
+ try:
123
+ stream.write(out)
124
+ except UnicodeEncodeError:
125
+ stream.buffer.write(out.encode("utf-8")) # type: ignore[attr-defined]
126
+ return 0
127
+
128
+
129
+ if __name__ == "__main__": # pragma: no cover
130
+ sys.exit(main())
File without changes
@@ -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,11 @@
1
+ every_other_token/__init__.py,sha256=1BZQtEb4jQ1_LeDiIyyLhN_WA9x1Z9Dtyx4p9YnUMys,1037
2
+ every_other_token/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
3
+ every_other_token/_parse.py,sha256=-jvfGneC10pyhGVcRHAHSRkoGS4Z8JSb2uIYWVj7Iu4,13382
4
+ every_other_token/_render.py,sha256=I-bOICdgiztnFRk4XEWPc9p1trHs85nkZoAOe8bYLvg,8356
5
+ every_other_token/cli.py,sha256=2CzNrfWIIx5d_DPsaHqzP0T5TVtzB21uq1xgiFeba2c,4898
6
+ every_other_token/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ every_other_token-0.1.0.dist-info/METADATA,sha256=JabmBYfhk4jTNB_xXQItyO-xnm8RBR--hMxxUDrRi4k,2450
8
+ every_other_token-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
9
+ every_other_token-0.1.0.dist-info/entry_points.txt,sha256=XWGR8fo60Cpj2xhAlOn4UMvEAWf0bTV91oJbSBrPmSw,54
10
+ every_other_token-0.1.0.dist-info/licenses/LICENSE,sha256=gHKCBtTMrskKy3zjTmhZG4z13TKr0b-f-XPY9AIooJo,1066
11
+ every_other_token-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ eot-py = every_other_token.cli:main
@@ -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.