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.
- every_other_token/__init__.py +32 -0
- every_other_token/__main__.py +5 -0
- every_other_token/_parse.py +376 -0
- every_other_token/_render.py +236 -0
- every_other_token/cli.py +130 -0
- every_other_token/py.typed +0 -0
- every_other_token-0.1.0.dist-info/METADATA +64 -0
- every_other_token-0.1.0.dist-info/RECORD +11 -0
- every_other_token-0.1.0.dist-info/WHEEL +4 -0
- every_other_token-0.1.0.dist-info/entry_points.txt +2 -0
- every_other_token-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
)
|
|
@@ -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 · mean confidence {_fmt_pct(s.mean_confidence)} · "
|
|
183
|
+
f"perplexity {s.perplexity:.2f} · {s.low} low / {s.mid} mid / {s.high} high"
|
|
184
|
+
)
|
|
185
|
+
else:
|
|
186
|
+
stats = f"{s.tokens} tokens · 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)
|
every_other_token/cli.py
ADDED
|
@@ -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,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.
|