quantgap 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.
quantgap/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """quantgap: how much did this quant lose?"""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,60 @@
1
+ """Backend adapters. Each one is imported only when it is asked for, so the default install
2
+ (no torch, no llama.cpp) stays light."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import platform
7
+
8
+ from quantgap.backends.base import (
9
+ Backend,
10
+ BackendSettings,
11
+ PromptLogprobs,
12
+ ResolvedSource,
13
+ gather_from_logits,
14
+ )
15
+
16
+ BACKEND_NAMES = ("llamacpp", "mlx", "openai", "hf")
17
+
18
+ __all__ = [
19
+ "BACKEND_NAMES",
20
+ "Backend",
21
+ "BackendSettings",
22
+ "PromptLogprobs",
23
+ "ResolvedSource",
24
+ "gather_from_logits",
25
+ "get_backend",
26
+ "pick_backend_name",
27
+ ]
28
+
29
+
30
+ def get_backend(name: str) -> Backend:
31
+ if name == "llamacpp":
32
+ from quantgap.backends.llamacpp import LlamaCppBackend
33
+
34
+ return LlamaCppBackend()
35
+ if name == "mlx":
36
+ if not (platform.system() == "Darwin" and platform.machine() == "arm64"):
37
+ raise RuntimeError("The mlx backend only works on Apple silicon Macs.")
38
+ from quantgap.backends.mlx import MLXBackend
39
+
40
+ return MLXBackend()
41
+ if name == "openai":
42
+ from quantgap.backends.openai_compat import OpenAIBackend
43
+
44
+ return OpenAIBackend()
45
+ if name == "hf":
46
+ from quantgap.backends.hf import HFBackend
47
+
48
+ return HFBackend()
49
+ raise ValueError(f"Unknown backend {name!r}. Choose one of: {', '.join(BACKEND_NAMES)}.")
50
+
51
+
52
+ def pick_backend_name(src: ResolvedSource, base_url: str | None) -> str:
53
+ """Guess a backend from what the quant is."""
54
+ if src.format == "openai" or base_url:
55
+ return "openai"
56
+ if src.format == "gguf":
57
+ return "llamacpp"
58
+ if src.format == "mlx":
59
+ return "mlx"
60
+ return "hf"
@@ -0,0 +1,118 @@
1
+ """The small interface every backend adapter has to provide (section 6.3 of the spec)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any, Protocol
8
+
9
+ import numpy as np
10
+
11
+ from quantgap.score.kld import log_softmax
12
+ from quantgap.tokcheck import TokFingerprint
13
+
14
+ LOGSOFTMAX_CHUNK = 64 # positions per float64 log-softmax, keeps memory flat for big vocabularies
15
+
16
+
17
+ @dataclass
18
+ class ResolvedSource:
19
+ """A quant that has been located on disk (or on a server)."""
20
+
21
+ source: str # what the user typed, or the canonical hf:// URL
22
+ label: str # short name, for example "UD-Q4_K_XL"
23
+ path: Path | None = None # local file or folder; None for a server
24
+ size_bytes: int = 0
25
+ format: str = "gguf"
26
+ publisher: str | None = None
27
+ base_model: str | None = None
28
+ sha256: str | None = None
29
+ extra: dict[str, Any] = field(default_factory=dict)
30
+
31
+
32
+ @dataclass
33
+ class BackendSettings:
34
+ ctx: int = 2048
35
+ n_gpu_layers: int = -1
36
+ n_threads: int | None = None
37
+ base_url: str | None = None
38
+ model_name: str | None = None
39
+ max_prompt_logprobs: int = 20
40
+ api_key: str | None = None
41
+ extra: dict[str, Any] = field(default_factory=dict)
42
+
43
+ def public(self) -> dict[str, Any]:
44
+ """Settings that go into the result file (never the API key)."""
45
+ d = {"ctx": self.ctx}
46
+ if self.n_gpu_layers != -1:
47
+ d["n_gpu_layers"] = self.n_gpu_layers
48
+ else:
49
+ d["n_gpu_layers"] = -1
50
+ if self.n_threads:
51
+ d["n_threads"] = self.n_threads
52
+ if self.base_url:
53
+ d["base_url"] = self.base_url
54
+ if self.model_name:
55
+ d["model_name"] = self.model_name
56
+ d.update(self.extra)
57
+ return d
58
+
59
+
60
+ @dataclass
61
+ class PromptLogprobs:
62
+ """What a backend returns for one sequence, at the requested positions only."""
63
+
64
+ gathered: np.ndarray # (n, k) log prob of each requested id; NaN = could not tell
65
+ target: np.ndarray # (n,) log prob of the actual next token
66
+ top1: np.ndarray # (n,) the model's own best next token
67
+ approximate: bool = False # True for servers that only return their own top-N
68
+
69
+
70
+ class Backend(Protocol):
71
+ name: str
72
+
73
+ def load(self, source: ResolvedSource, settings: BackendSettings) -> None: ...
74
+
75
+ def tokenizer_fingerprint(self) -> TokFingerprint: ...
76
+
77
+ def prompt_logprobs(
78
+ self, token_ids: list[int], positions: np.ndarray, gather_ids: np.ndarray
79
+ ) -> PromptLogprobs:
80
+ """Teacher forcing. Feed `token_ids`, and at each position p in `positions` return the
81
+ model's log probability for every id in gather_ids[row], plus for token p + 1."""
82
+ ...
83
+
84
+ def generate_greedy(self, token_ids: list[int], max_new_tokens: int) -> list[int]: ...
85
+
86
+ def decode(self, ids: list[int]) -> str: ...
87
+
88
+ def version(self) -> str: ...
89
+
90
+ def close(self) -> None: ...
91
+
92
+
93
+ def gather_from_logits(
94
+ logits: np.ndarray, target_ids: np.ndarray, gather_ids: np.ndarray
95
+ ) -> PromptLogprobs:
96
+ """Shared maths for backends that hand back full logits.
97
+
98
+ logits: (n, V) raw scores, one row per scored position.
99
+ target_ids: (n,) the actual next token at each row.
100
+ gather_ids: (n, k) the reference's top-k ids at each row.
101
+ Uses a stable float64 log-softmax, in chunks so big vocabularies stay cheap.
102
+ """
103
+ n, k = gather_ids.shape
104
+ gathered = np.empty((n, k), dtype=np.float64)
105
+ target = np.empty(n, dtype=np.float64)
106
+ top1 = np.empty(n, dtype=np.int64)
107
+ vocab = logits.shape[1]
108
+ for a in range(0, n, LOGSOFTMAX_CHUNK):
109
+ b = min(n, a + LOGSOFTMAX_CHUNK)
110
+ lp = log_softmax(logits[a:b])
111
+ ids = gather_ids[a:b]
112
+ oob = ids >= vocab # reference ids beyond this model's vocabulary
113
+ g = np.take_along_axis(lp, np.where(oob, 0, ids), axis=1)
114
+ gathered[a:b] = np.where(oob, -np.inf, g)
115
+ tid = target_ids[a:b]
116
+ target[a:b] = lp[np.arange(b - a), np.minimum(tid, vocab - 1)]
117
+ top1[a:b] = lp.argmax(axis=1)
118
+ return PromptLogprobs(gathered, target, top1, approximate=False)
@@ -0,0 +1,77 @@
1
+ """Transformers backend (needs `quantgap[hf]`). Also the reference for self-tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+
9
+ from quantgap.backends.base import (
10
+ BackendSettings,
11
+ PromptLogprobs,
12
+ ResolvedSource,
13
+ gather_from_logits,
14
+ )
15
+ from quantgap.tokcheck import CHECK_STRING, TokFingerprint
16
+
17
+
18
+ class HFBackend:
19
+ name = "hf"
20
+
21
+ def __init__(self) -> None:
22
+ self.model: Any = None
23
+ self.tok: Any = None
24
+ self.device: Any = None
25
+
26
+ def load(self, source: ResolvedSource, settings: BackendSettings) -> None:
27
+ import torch
28
+ from transformers import AutoModelForCausalLM, AutoTokenizer
29
+
30
+ where = str(source.path) if source.path else source.source.removeprefix("hf://")
31
+ self.tok = AutoTokenizer.from_pretrained(where)
32
+ kwargs: dict[str, Any] = {}
33
+ if torch.cuda.is_available():
34
+ kwargs["device_map"] = "auto"
35
+ self.model = AutoModelForCausalLM.from_pretrained(where, **kwargs)
36
+ self.model.eval()
37
+ self.device = next(self.model.parameters()).device
38
+
39
+ def tokenizer_fingerprint(self) -> TokFingerprint:
40
+ return TokFingerprint(
41
+ vocab_size=int(self.model.get_output_embeddings().weight.shape[0])
42
+ if self.model.get_output_embeddings() is not None
43
+ else len(self.tok),
44
+ bos_id=self.tok.bos_token_id,
45
+ eos_id=self.tok.eos_token_id,
46
+ check_ids=list(self.tok(CHECK_STRING, add_special_tokens=False)["input_ids"]),
47
+ )
48
+
49
+ def prompt_logprobs(
50
+ self, token_ids: list[int], positions: np.ndarray, gather_ids: np.ndarray
51
+ ) -> PromptLogprobs:
52
+ import torch
53
+
54
+ ids = torch.tensor([token_ids], device=self.device)
55
+ with torch.no_grad():
56
+ logits = self.model(input_ids=ids).logits[0]
57
+ rows = logits[torch.from_numpy(np.asarray(positions)).to(logits.device)]
58
+ rows = rows.float().cpu().numpy()
59
+ tgt = np.asarray(token_ids, dtype=np.int64)[np.asarray(positions) + 1]
60
+ return gather_from_logits(rows, tgt, gather_ids)
61
+
62
+ def generate_greedy(self, token_ids: list[int], max_new_tokens: int) -> list[int]:
63
+ from quantgap.pack.runner import greedy_generate
64
+
65
+ return greedy_generate(self.model, self.tok, token_ids, max_new_tokens, self.device)
66
+
67
+ def decode(self, ids: list[int]) -> str:
68
+ return self.tok.decode(ids, skip_special_tokens=True)
69
+
70
+ def version(self) -> str:
71
+ import transformers
72
+
73
+ return f"transformers {transformers.__version__}"
74
+
75
+ def close(self) -> None:
76
+ self.model = None
77
+ self.tok = None
@@ -0,0 +1,99 @@
1
+ """GGUF backend through llama-cpp-python (needs `quantgap[llamacpp]`).
2
+
3
+ Verified fact (docs/DECISIONS.md, M0): `Llama(..., logits_all=True)` followed by `eval(tokens)`
4
+ fills `llm.scores`, an array of shape (n_ctx, n_vocab). Row i holds the logits that come out
5
+ after reading token i, so it predicts token i + 1.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+
14
+ from quantgap.backends.base import (
15
+ BackendSettings,
16
+ PromptLogprobs,
17
+ ResolvedSource,
18
+ gather_from_logits,
19
+ )
20
+ from quantgap.tokcheck import CHECK_STRING, TokFingerprint
21
+
22
+
23
+ class LlamaCppBackend:
24
+ name = "llamacpp"
25
+
26
+ def __init__(self) -> None:
27
+ self.llm: Any = None
28
+ self.settings: BackendSettings | None = None
29
+
30
+ def load(self, source: ResolvedSource, settings: BackendSettings) -> None:
31
+ try:
32
+ from llama_cpp import Llama
33
+ except ImportError as e: # pragma: no cover
34
+ raise RuntimeError(
35
+ 'llama-cpp-python is not installed. Run: pip install "quantgap[llamacpp]"'
36
+ ) from e
37
+ if source.path is None or not source.path.is_file():
38
+ raise FileNotFoundError(f"GGUF file not found: {source.path}")
39
+ self.settings = settings
40
+ kwargs: dict[str, Any] = {
41
+ "model_path": str(source.path),
42
+ "n_ctx": settings.ctx,
43
+ "n_gpu_layers": settings.n_gpu_layers,
44
+ "logits_all": True,
45
+ "verbose": False,
46
+ }
47
+ if settings.n_threads:
48
+ kwargs["n_threads"] = settings.n_threads
49
+ self.llm = Llama(**kwargs)
50
+
51
+ def tokenizer_fingerprint(self) -> TokFingerprint:
52
+ llm = self.llm
53
+ ids = llm.tokenize(CHECK_STRING.encode("utf-8"), add_bos=False, special=False)
54
+ return TokFingerprint(
55
+ vocab_size=int(llm.n_vocab()),
56
+ bos_id=int(llm.token_bos()) if llm.token_bos() >= 0 else None,
57
+ eos_id=int(llm.token_eos()) if llm.token_eos() >= 0 else None,
58
+ check_ids=[int(i) for i in ids],
59
+ )
60
+
61
+ def prompt_logprobs(
62
+ self, token_ids: list[int], positions: np.ndarray, gather_ids: np.ndarray
63
+ ) -> PromptLogprobs:
64
+ llm = self.llm
65
+ llm.reset()
66
+ llm.eval(token_ids)
67
+ positions = np.asarray(positions)
68
+ rows = np.asarray(llm.scores[positions], dtype=np.float32)
69
+ tgt = np.asarray(token_ids, dtype=np.int64)[positions + 1]
70
+ return gather_from_logits(rows, tgt, gather_ids)
71
+
72
+ def generate_greedy(self, token_ids: list[int], max_new_tokens: int) -> list[int]:
73
+ """Plain greedy decoding: no repetition penalty, no sampling. Stops on any end-of-
74
+ generation token (for Qwen that is both <|im_end|> and <|endoftext|>)."""
75
+ llm = self.llm
76
+ llm.reset()
77
+ is_eog = getattr(llm._model, "token_is_eog", None)
78
+ eos = llm.token_eos()
79
+ out: list[int] = []
80
+ for tok in llm.generate(
81
+ token_ids, top_k=1, top_p=1.0, temp=0.0, repeat_penalty=1.0, reset=True
82
+ ):
83
+ if tok == eos or (is_eog is not None and is_eog(tok)) or len(out) >= max_new_tokens:
84
+ break
85
+ out.append(int(tok))
86
+ return out
87
+
88
+ def decode(self, ids: list[int]) -> str:
89
+ return self.llm.detokenize(ids).decode("utf-8", errors="replace")
90
+
91
+ def version(self) -> str:
92
+ import llama_cpp
93
+
94
+ return f"llama-cpp-python {llama_cpp.__version__}"
95
+
96
+ def close(self) -> None:
97
+ if self.llm is not None:
98
+ self.llm.close()
99
+ self.llm = None
@@ -0,0 +1,86 @@
1
+ """Apple silicon backend through mlx-lm (needs `quantgap[mlx]`).
2
+
3
+ Written from the mlx-lm API (`mlx_lm.load` returns `(model, tokenizer)`; calling the model on an
4
+ array of token ids with shape (1, T) returns logits of shape (1, T, V)). It has NOT been run on a
5
+ Mac by the author yet: see docs/DECISIONS.md. Run `quantgap doctor` on your Mac and open an issue
6
+ if anything fails.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ from typing import Any
13
+
14
+ import numpy as np
15
+
16
+ from quantgap.backends.base import (
17
+ BackendSettings,
18
+ PromptLogprobs,
19
+ ResolvedSource,
20
+ gather_from_logits,
21
+ )
22
+ from quantgap.tokcheck import CHECK_STRING, TokFingerprint
23
+
24
+
25
+ class MLXBackend:
26
+ name = "mlx"
27
+
28
+ def __init__(self) -> None:
29
+ self.model: Any = None
30
+ self.tok: Any = None
31
+
32
+ def load(self, source: ResolvedSource, settings: BackendSettings) -> None:
33
+ try:
34
+ from mlx_lm import load
35
+ except ImportError as e: # pragma: no cover
36
+ raise RuntimeError('mlx-lm is not installed. Run: pip install "quantgap[mlx]"') from e
37
+ where = str(source.path) if source.path else source.source.removeprefix("hf://")
38
+ self.model, self.tok = load(where)
39
+
40
+ def _hf_tok(self) -> Any:
41
+ return getattr(self.tok, "_tokenizer", self.tok)
42
+
43
+ def tokenizer_fingerprint(self) -> TokFingerprint:
44
+ t = self._hf_tok()
45
+ ids = list(t(CHECK_STRING, add_special_tokens=False)["input_ids"])
46
+ vocab = len(t)
47
+ with contextlib.suppress(Exception): # the real embedding size is what matters for padding
48
+ vocab = int(self.model.model.embed_tokens.weight.shape[0])
49
+ return TokFingerprint(vocab, t.bos_token_id, t.eos_token_id, ids)
50
+
51
+ def prompt_logprobs(
52
+ self, token_ids: list[int], positions: np.ndarray, gather_ids: np.ndarray
53
+ ) -> PromptLogprobs:
54
+ import mlx.core as mx
55
+
56
+ logits = self.model(mx.array([token_ids]))[0]
57
+ rows = np.array(logits.astype(mx.float32))[np.asarray(positions)]
58
+ tgt = np.asarray(token_ids, dtype=np.int64)[np.asarray(positions) + 1]
59
+ return gather_from_logits(rows, tgt, gather_ids)
60
+
61
+ def generate_greedy(self, token_ids: list[int], max_new_tokens: int) -> list[int]:
62
+ import mlx.core as mx
63
+
64
+ ids = list(token_ids)
65
+ out: list[int] = []
66
+ eos = self.tok.eos_token_id
67
+ for _ in range(max_new_tokens): # no KV cache: slow but simple and exact
68
+ logits = self.model(mx.array([ids]))[0, -1]
69
+ nxt = int(mx.argmax(logits).item())
70
+ if nxt == eos:
71
+ break
72
+ out.append(nxt)
73
+ ids.append(nxt)
74
+ return out
75
+
76
+ def decode(self, ids: list[int]) -> str:
77
+ return self.tok.decode(ids)
78
+
79
+ def version(self) -> str:
80
+ import mlx_lm
81
+
82
+ return f"mlx-lm {getattr(mlx_lm, '__version__', 'unknown')}"
83
+
84
+ def close(self) -> None:
85
+ self.model = None
86
+ self.tok = None
@@ -0,0 +1,164 @@
1
+ """Any OpenAI-compatible server that returns prompt logprobs (vLLM: `prompt_logprobs`).
2
+
3
+ This covers NVFP4, AWQ, GPTQ and FP8 quants served by vLLM.
4
+
5
+ Server facts this adapter relies on (see docs/DECISIONS.md, M0, where they are marked as taken
6
+ from the vLLM docs and checked against a fake server, not against a live GPU):
7
+ - POST {base}/completions accepts `prompt` as a list of token ids and `prompt_logprobs: N`.
8
+ - The reply has `choices[0].prompt_logprobs`: a list with one entry per prompt token. Entry 0 is
9
+ null. Entry i is a dict {"<token id>": {"logprob": float, "rank": int, ...}} holding the actual
10
+ token i plus the server's top-N for that position.
11
+ - N is capped by the server (`--max-logprobs`, default 20). We ask for the configured value and
12
+ step down if the server refuses.
13
+
14
+ A server only tells us its own top-N, not the probability of every reference id. Ids that are
15
+ missing come back as NaN and the scorer moves them into the tail bucket (spec section 7.2, E4).
16
+ Results from this backend are always marked approximate when that happens.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ from typing import Any
23
+
24
+ import httpx
25
+ import numpy as np
26
+
27
+ from quantgap.backends.base import BackendSettings, PromptLogprobs, ResolvedSource
28
+ from quantgap.tokcheck import CHECK_STRING, TokFingerprint
29
+
30
+ _FALLBACK_N = (20, 10, 5, 1)
31
+
32
+
33
+ class OpenAIBackend:
34
+ name = "openai"
35
+
36
+ def __init__(self, client: httpx.Client | None = None) -> None:
37
+ self._client = client
38
+ self.base_url = ""
39
+ self.model_name = ""
40
+ self.n = 20
41
+ self._headers: dict[str, str] = {}
42
+
43
+ # ------------------------------------------------------------------ setup
44
+ def load(self, source: ResolvedSource, settings: BackendSettings) -> None:
45
+ if not settings.base_url:
46
+ raise ValueError(
47
+ "The openai backend needs --base-url (for example http://localhost:8000/v1)."
48
+ )
49
+ self.base_url = settings.base_url.rstrip("/")
50
+ self.model_name = settings.model_name or source.extra.get("model_name") or ""
51
+ if not self.model_name:
52
+ raise ValueError("The openai backend needs --model-name (the name the server serves).")
53
+ self.n = settings.max_prompt_logprobs
54
+ key = settings.api_key or os.environ.get("OPENAI_API_KEY")
55
+ self._headers = {"Authorization": f"Bearer {key}"} if key else {}
56
+ if self._client is None:
57
+ self._client = httpx.Client(timeout=600)
58
+
59
+ @property
60
+ def _root(self) -> str:
61
+ return self.base_url[: -len("/v1")] if self.base_url.endswith("/v1") else self.base_url
62
+
63
+ def _post(self, url: str, payload: dict[str, Any]) -> httpx.Response:
64
+ assert self._client is not None
65
+ return self._client.post(url, json=payload, headers=self._headers)
66
+
67
+ # ------------------------------------------------------------------ interface
68
+ def tokenizer_fingerprint(self) -> TokFingerprint:
69
+ ids: list[int] = []
70
+ try:
71
+ r = self._post(
72
+ f"{self._root}/tokenize",
73
+ {"model": self.model_name, "prompt": CHECK_STRING, "add_special_tokens": False},
74
+ )
75
+ if r.status_code == 200:
76
+ ids = [int(t) for t in r.json()["tokens"]]
77
+ except (httpx.HTTPError, KeyError, ValueError):
78
+ pass
79
+ # A server does not tell us its vocabulary size, so -1 means "unknown, skip that check".
80
+ return TokFingerprint(vocab_size=-1, bos_id=None, eos_id=None, check_ids=ids)
81
+
82
+ def prompt_logprobs(
83
+ self, token_ids: list[int], positions: np.ndarray, gather_ids: np.ndarray
84
+ ) -> PromptLogprobs:
85
+ entries = self._request_prompt_logprobs(token_ids)
86
+ positions = np.asarray(positions)
87
+ n, k = gather_ids.shape
88
+ gathered = np.full((n, k), np.nan)
89
+ target = np.full(n, np.nan)
90
+ top1 = np.zeros(n, dtype=np.int64)
91
+ for row, p in enumerate(positions):
92
+ entry = entries[p + 1]
93
+ if entry is None:
94
+ raise RuntimeError(f"Server returned no logprobs for token {p + 1}.")
95
+ tid = str(token_ids[p + 1])
96
+ if tid in entry:
97
+ target[row] = entry[tid]["logprob"]
98
+ best = max(entry.items(), key=lambda kv: kv[1]["logprob"])
99
+ top1[row] = int(best[0])
100
+ for j in range(k):
101
+ v = entry.get(str(int(gather_ids[row, j])))
102
+ if v is not None:
103
+ gathered[row, j] = v["logprob"]
104
+ if np.isnan(target).any(): # defensive: server must always include the actual token
105
+ raise RuntimeError(
106
+ "Server did not return the log probability of the actual next token."
107
+ )
108
+ return PromptLogprobs(gathered, target, top1, approximate=bool(np.isnan(gathered).any()))
109
+
110
+ def _request_prompt_logprobs(self, token_ids: list[int]) -> list[dict | None]:
111
+ last = ""
112
+ for n in dict.fromkeys([self.n, *[x for x in _FALLBACK_N if x < self.n]]):
113
+ r = self._post(
114
+ f"{self.base_url}/completions",
115
+ {
116
+ "model": self.model_name,
117
+ "prompt": token_ids,
118
+ "max_tokens": 1,
119
+ "temperature": 0,
120
+ "prompt_logprobs": n,
121
+ },
122
+ )
123
+ if r.status_code == 200:
124
+ self.n = n
125
+ data = r.json()["choices"][0].get("prompt_logprobs")
126
+ if data is None:
127
+ raise RuntimeError(
128
+ "The server did not return prompt_logprobs. This backend needs vLLM "
129
+ "or a server with the same extension."
130
+ )
131
+ return data
132
+ last = r.text[:300]
133
+ if r.status_code != 400:
134
+ break
135
+ raise RuntimeError(f"Server refused the prompt_logprobs request: {last}")
136
+
137
+ def generate_greedy(self, token_ids: list[int], max_new_tokens: int) -> list[int]:
138
+ r = self._post(
139
+ f"{self.base_url}/completions",
140
+ {
141
+ "model": self.model_name,
142
+ "prompt": token_ids,
143
+ "max_tokens": max_new_tokens,
144
+ "temperature": 0,
145
+ "logprobs": 1,
146
+ "return_tokens_as_token_ids": True,
147
+ },
148
+ )
149
+ r.raise_for_status()
150
+ toks = r.json()["choices"][0]["logprobs"]["tokens"]
151
+ return [int(t.split(":")[1]) for t in toks] # entries look like "token_id:123"
152
+
153
+ def decode(self, ids: list[int]) -> str:
154
+ r = self._post(f"{self._root}/detokenize", {"model": self.model_name, "tokens": ids})
155
+ r.raise_for_status()
156
+ return r.json()["prompt"]
157
+
158
+ def version(self) -> str:
159
+ return f"openai-compatible server at {self.base_url}"
160
+
161
+ def close(self) -> None:
162
+ if self._client is not None:
163
+ self._client.close()
164
+ self._client = None