benchkit-cli 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.
- benchkit/__init__.py +1 -0
- benchkit/_pi_proxy.py +219 -0
- benchkit/answer_key_guard.ts +49 -0
- benchkit/artifacts.py +87 -0
- benchkit/benchmarks/__init__.py +105 -0
- benchkit/benchmarks/aider_polyglot.py +405 -0
- benchkit/benchmarks/arc.py +52 -0
- benchkit/benchmarks/banking77.py +149 -0
- benchkit/benchmarks/base.py +19 -0
- benchkit/benchmarks/boolq.py +49 -0
- benchkit/benchmarks/chartqa.py +133 -0
- benchkit/benchmarks/code_verifier.py +77 -0
- benchkit/benchmarks/downloads.py +166 -0
- benchkit/benchmarks/evalplus.py +288 -0
- benchkit/benchmarks/git_surgery.py +485 -0
- benchkit/benchmarks/gpqa.py +56 -0
- benchkit/benchmarks/gsm8k.py +97 -0
- benchkit/benchmarks/hellaswag.py +49 -0
- benchkit/benchmarks/humaneval.py +103 -0
- benchkit/benchmarks/ifeval.py +56 -0
- benchkit/benchmarks/ifeval_instructions.py +477 -0
- benchkit/benchmarks/mbpp.py +60 -0
- benchkit/benchmarks/mc_arena.py +479 -0
- benchkit/benchmarks/mcq.py +94 -0
- benchkit/benchmarks/medfailbench.py +537 -0
- benchkit/benchmarks/mmlu.py +54 -0
- benchkit/benchmarks/mmlu_pro.py +62 -0
- benchkit/benchmarks/mmvp.py +115 -0
- benchkit/benchmarks/ocrbench.py +96 -0
- benchkit/benchmarks/openbookqa.py +52 -0
- benchkit/benchmarks/patcheval.py +929 -0
- benchkit/benchmarks/piqa.py +52 -0
- benchkit/benchmarks/ruler.py +172 -0
- benchkit/benchmarks/ruler_generator.py +535 -0
- benchkit/benchmarks/sanity.py +174 -0
- benchkit/benchmarks/treejs_arena.py +268 -0
- benchkit/benchmarks/truthfulqa.py +52 -0
- benchkit/benchmarks/utils.py +25 -0
- benchkit/benchmarks/vision.py +230 -0
- benchkit/benchmarks/vstar.py +108 -0
- benchkit/benchmarks/winogrande.py +52 -0
- benchkit/benchmarks/xstest.py +116 -0
- benchkit/browser.py +429 -0
- benchkit/catalog.py +121 -0
- benchkit/cli.py +1000 -0
- benchkit/client.py +1413 -0
- benchkit/controls.py +87 -0
- benchkit/datasets/MMLU_LICENSE.txt +21 -0
- benchkit/datasets/README.md +142 -0
- benchkit/datasets/arc_challenge.jsonl +1172 -0
- benchkit/datasets/banking77.jsonl +3080 -0
- benchkit/datasets/banking77_intents.json +79 -0
- benchkit/datasets/boolq.jsonl +3270 -0
- benchkit/datasets/code_verifier.jsonl +500 -0
- benchkit/datasets/gpqa_diamond.jsonl +198 -0
- benchkit/datasets/gsm8k.jsonl +1319 -0
- benchkit/datasets/hellaswag.jsonl +1000 -0
- benchkit/datasets/humaneval.jsonl +164 -0
- benchkit/datasets/ifeval.jsonl +541 -0
- benchkit/datasets/mbpp.jsonl +500 -0
- benchkit/datasets/mc_blocks_1_20_1.jsonl +1003 -0
- benchkit/datasets/medfailbench_hard30.jsonl +30 -0
- benchkit/datasets/medfailbench_hard30.md +13 -0
- benchkit/datasets/openbookqa.jsonl +500 -0
- benchkit/datasets/piqa.jsonl +1838 -0
- benchkit/datasets/sanity_code.jsonl +6 -0
- benchkit/datasets/truthfulqa.jsonl +817 -0
- benchkit/datasets/winogrande.jsonl +1267 -0
- benchkit/datasets/xstest.csv +451 -0
- benchkit/decision.py +194 -0
- benchkit/demo.py +451 -0
- benchkit/dockerfiles.py +247 -0
- benchkit/engine.py +1270 -0
- benchkit/evaluation.py +195 -0
- benchkit/events.py +141 -0
- benchkit/executor.py +96 -0
- benchkit/git_surgery/backport-release-stack/setup.sh +328 -0
- benchkit/git_surgery/backport-release-stack/verify.sh +119 -0
- benchkit/git_surgery/bisect-the-regression/setup.sh +49 -0
- benchkit/git_surgery/bisect-the-regression/verify.sh +34 -0
- benchkit/git_surgery/rebase-conflict-chain/setup.sh +65 -0
- benchkit/git_surgery/rebase-conflict-chain/verify.sh +43 -0
- benchkit/git_surgery/recover-complex-stash/setup.sh +152 -0
- benchkit/git_surgery/recover-complex-stash/verify.sh +95 -0
- benchkit/git_surgery/recover-lost-work/setup.sh +40 -0
- benchkit/git_surgery/recover-lost-work/verify.sh +36 -0
- benchkit/git_surgery/repair-force-pushed-remote/setup.sh +171 -0
- benchkit/git_surgery/repair-force-pushed-remote/verify.sh +83 -0
- benchkit/git_surgery/revert-merge-with-followups/setup.sh +198 -0
- benchkit/git_surgery/revert-merge-with-followups/verify.sh +98 -0
- benchkit/git_surgery/secret-in-history/README.md +27 -0
- benchkit/git_surgery/secret-in-history/setup.sh +107 -0
- benchkit/git_surgery/secret-in-history/verify.sh +106 -0
- benchkit/git_surgery/split-the-mega-commit/setup.sh +50 -0
- benchkit/git_surgery/split-the-mega-commit/verify.sh +48 -0
- benchkit/git_surgery/untangle-nested-submodules/setup.sh +193 -0
- benchkit/git_surgery/untangle-nested-submodules/verify.sh +90 -0
- benchkit/history.py +474 -0
- benchkit/jobs.py +332 -0
- benchkit/leakage.py +51 -0
- benchkit/looping.py +873 -0
- benchkit/mc_render.py +274 -0
- benchkit/mc_viewer/dist/atlas.png +0 -0
- benchkit/mc_viewer/dist/blockStates.json.gz +0 -0
- benchkit/mc_viewer/dist/version.json +4 -0
- benchkit/mc_viewer/dist/viewer.html +23 -0
- benchkit/mc_viewer/dist/viewer.js +2 -0
- benchkit/mc_viewer/dist/viewer.js.LICENSE.txt +25 -0
- benchkit/mc_viewer/dist/worker.js +2 -0
- benchkit/mc_viewer/dist/worker.js.LICENSE.txt +19 -0
- benchkit/metrics.py +100 -0
- benchkit/ollama.py +60 -0
- benchkit/perf.py +353 -0
- benchkit/perf_report.py +207 -0
- benchkit/perturbations.py +208 -0
- benchkit/pi_agent.py +476 -0
- benchkit/pi_package/package-lock.json +1827 -0
- benchkit/pi_package/package.json +8 -0
- benchkit/report.py +142 -0
- benchkit/report_markdown.py +645 -0
- benchkit/results.py +688 -0
- benchkit/retry.py +163 -0
- benchkit/runner.py +769 -0
- benchkit/sandbox.py +1167 -0
- benchkit/template_check.py +291 -0
- benchkit/templates/arena.html +504 -0
- benchkit/templates/history.html +58 -0
- benchkit/templates/perf.html +509 -0
- benchkit/templates/report.html +769 -0
- benchkit/tui/__init__.py +5 -0
- benchkit/tui/app.py +87 -0
- benchkit/tui/app.tcss +512 -0
- benchkit/tui/formatting.py +84 -0
- benchkit/tui/screens/__init__.py +1 -0
- benchkit/tui/screens/connect.py +191 -0
- benchkit/tui/screens/detail.py +288 -0
- benchkit/tui/screens/help.py +84 -0
- benchkit/tui/screens/modals.py +544 -0
- benchkit/tui/screens/results.py +361 -0
- benchkit/tui/screens/run.py +988 -0
- benchkit/tui/screens/setup.py +733 -0
- benchkit/tui/theme.py +135 -0
- benchkit/tui/widgets.py +120 -0
- benchkit_cli-0.1.0.dist-info/METADATA +294 -0
- benchkit_cli-0.1.0.dist-info/RECORD +149 -0
- benchkit_cli-0.1.0.dist-info/WHEEL +5 -0
- benchkit_cli-0.1.0.dist-info/entry_points.txt +3 -0
- benchkit_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- benchkit_cli-0.1.0.dist-info/top_level.txt +1 -0
benchkit/client.py
ADDED
|
@@ -0,0 +1,1413 @@
|
|
|
1
|
+
"""Inference clients for OpenAI-compatible servers and Ollama."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import contextlib
|
|
7
|
+
import json
|
|
8
|
+
import mimetypes
|
|
9
|
+
import os
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Literal
|
|
16
|
+
from urllib.parse import quote
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from benchkit.looping import InlineThinkingParser
|
|
21
|
+
from benchkit.retry import RetryPolicy, is_retryable, run_with_retries
|
|
22
|
+
|
|
23
|
+
Provider = Literal["auto", "openai", "ollama"]
|
|
24
|
+
ProgressCallback = Callable[["GenerationUpdate"], None]
|
|
25
|
+
|
|
26
|
+
DEFAULT_HOST = "http://localhost:11434"
|
|
27
|
+
MAX_DISCOVERED_CONCURRENCY = 256
|
|
28
|
+
|
|
29
|
+
_PARALLELISM_KEYS = {
|
|
30
|
+
"concurrency",
|
|
31
|
+
"concurrencylimit",
|
|
32
|
+
"maxconcurrency",
|
|
33
|
+
"maxparallelrequests",
|
|
34
|
+
"nparallel",
|
|
35
|
+
"parallel",
|
|
36
|
+
"parallelism",
|
|
37
|
+
}
|
|
38
|
+
_PARALLELISM_CONTAINERS = {"limits", "llamaswap", "meta", "metadata"}
|
|
39
|
+
_CONTEXT_LENGTH_KEYS = {
|
|
40
|
+
"contextlength",
|
|
41
|
+
"contextsize",
|
|
42
|
+
"contextwindow",
|
|
43
|
+
"maxcontextlength",
|
|
44
|
+
"maxpositionembeddings",
|
|
45
|
+
"nctx",
|
|
46
|
+
"numctx",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _without_v1(url: str) -> str:
|
|
51
|
+
url = url.rstrip("/")
|
|
52
|
+
return url[:-3] if url.endswith("/v1") else url
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _openai_base(url: str) -> str:
|
|
56
|
+
url = url.rstrip("/")
|
|
57
|
+
return url if url.endswith("/v1") else f"{url}/v1"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_IMAGE_SIGNATURES = (
|
|
61
|
+
(b"\x89PNG\r\n\x1a\n", "image/png"),
|
|
62
|
+
(b"\xff\xd8", "image/jpeg"),
|
|
63
|
+
(b"GIF87a", "image/gif"),
|
|
64
|
+
(b"GIF89a", "image/gif"),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def image_media_type(data: bytes) -> str | None:
|
|
69
|
+
"""Return an image's media type from its leading bytes, or None."""
|
|
70
|
+
for signature, mime in _IMAGE_SIGNATURES:
|
|
71
|
+
if data.startswith(signature):
|
|
72
|
+
return mime
|
|
73
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
74
|
+
return "image/webp"
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _encode_image(path: str) -> tuple[str, str]:
|
|
79
|
+
"""Return the media type and base64 payload of a local image file.
|
|
80
|
+
|
|
81
|
+
The extension decides when it names an image type; otherwise the leading
|
|
82
|
+
bytes do. A file that is neither is refused rather than mislabelled,
|
|
83
|
+
since servers may reject a data URI whose bytes contradict its type.
|
|
84
|
+
"""
|
|
85
|
+
data = Path(path).read_bytes()
|
|
86
|
+
mime = mimetypes.guess_type(path)[0]
|
|
87
|
+
if not (mime and mime.startswith("image/")):
|
|
88
|
+
mime = image_media_type(data)
|
|
89
|
+
if mime is None:
|
|
90
|
+
raise ValueError(f"{path} is not a PNG, JPEG, GIF or WebP image")
|
|
91
|
+
return mime, base64.b64encode(data).decode("ascii")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def image_data_uri(path: str) -> str:
|
|
95
|
+
"""Return a local image file as a base64 data URI."""
|
|
96
|
+
mime, data = _encode_image(path)
|
|
97
|
+
return f"data:{mime};base64,{data}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _positive_int(value: object) -> int | None:
|
|
101
|
+
"""Return a positive integer without accepting booleans or fractions."""
|
|
102
|
+
if isinstance(value, bool):
|
|
103
|
+
return None
|
|
104
|
+
if isinstance(value, int):
|
|
105
|
+
return value if value > 0 else None
|
|
106
|
+
if isinstance(value, float):
|
|
107
|
+
return int(value) if value > 0 and value.is_integer() else None
|
|
108
|
+
if isinstance(value, str):
|
|
109
|
+
try:
|
|
110
|
+
parsed = int(value.strip())
|
|
111
|
+
except ValueError:
|
|
112
|
+
return None
|
|
113
|
+
return parsed if parsed > 0 else None
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _parallelism_hint(value: object) -> int | None:
|
|
118
|
+
"""Read explicit concurrency hints from common model metadata shapes."""
|
|
119
|
+
if not isinstance(value, dict):
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
hints: list[int] = []
|
|
123
|
+
for key, candidate in value.items():
|
|
124
|
+
normalized = str(key).lower().replace("_", "").replace("-", "")
|
|
125
|
+
if normalized in _PARALLELISM_KEYS:
|
|
126
|
+
parsed = _positive_int(candidate)
|
|
127
|
+
if parsed is not None:
|
|
128
|
+
hints.append(parsed)
|
|
129
|
+
elif normalized in _PARALLELISM_CONTAINERS:
|
|
130
|
+
nested = _parallelism_hint(candidate)
|
|
131
|
+
if nested is not None:
|
|
132
|
+
hints.append(nested)
|
|
133
|
+
return min(hints) if hints else None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _context_length_hint(value: object) -> int | None:
|
|
137
|
+
"""Read context-window hints from model, props, slot or Ollama payloads."""
|
|
138
|
+
hints: list[int] = []
|
|
139
|
+
if isinstance(value, list):
|
|
140
|
+
for item in value:
|
|
141
|
+
hint = _context_length_hint(item)
|
|
142
|
+
if hint is not None:
|
|
143
|
+
hints.append(hint)
|
|
144
|
+
elif isinstance(value, dict):
|
|
145
|
+
for key, candidate in value.items():
|
|
146
|
+
key_text = str(key).lower()
|
|
147
|
+
normalized = "".join(
|
|
148
|
+
character for character in key_text if character.isalnum()
|
|
149
|
+
)
|
|
150
|
+
leaf = key_text.rsplit(".", 1)[-1]
|
|
151
|
+
leaf_normalized = "".join(
|
|
152
|
+
character for character in leaf if character.isalnum()
|
|
153
|
+
)
|
|
154
|
+
if (
|
|
155
|
+
normalized in _CONTEXT_LENGTH_KEYS
|
|
156
|
+
or leaf_normalized in _CONTEXT_LENGTH_KEYS
|
|
157
|
+
):
|
|
158
|
+
parsed = _positive_int(candidate)
|
|
159
|
+
if parsed is not None:
|
|
160
|
+
hints.append(parsed)
|
|
161
|
+
if isinstance(candidate, (dict, list)):
|
|
162
|
+
nested = _context_length_hint(candidate)
|
|
163
|
+
if nested is not None:
|
|
164
|
+
hints.append(nested)
|
|
165
|
+
return min(hints) if hints else None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _capacity_from_payload(payload: object) -> int | None:
|
|
169
|
+
"""Normalize llama.cpp slot and health responses into a worker count."""
|
|
170
|
+
if isinstance(payload, list):
|
|
171
|
+
return len(payload) or None
|
|
172
|
+
if not isinstance(payload, dict):
|
|
173
|
+
return None
|
|
174
|
+
slots = payload.get("slots")
|
|
175
|
+
if isinstance(slots, list) and slots:
|
|
176
|
+
return len(slots)
|
|
177
|
+
idle = _positive_int(payload.get("slots_idle")) or 0
|
|
178
|
+
processing = _positive_int(payload.get("slots_processing")) or 0
|
|
179
|
+
total = idle + processing
|
|
180
|
+
return total or None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _is_decision_model(model: dict) -> bool:
|
|
184
|
+
"""Read the native decision-model marker from a /v1/models entry.
|
|
185
|
+
|
|
186
|
+
llama-server reports ``decisions`` in ``architecture.output_modalities``.
|
|
187
|
+
llama-swap builds its own listing and cannot advertise that modality, so a
|
|
188
|
+
model there is marked by hand with ``metadata: {decision: true}`` in its
|
|
189
|
+
config, which llama-swap returns under ``meta.llamaswap``.
|
|
190
|
+
"""
|
|
191
|
+
architecture = model.get("architecture")
|
|
192
|
+
modalities = (
|
|
193
|
+
architecture.get("output_modalities")
|
|
194
|
+
if isinstance(architecture, dict)
|
|
195
|
+
else None
|
|
196
|
+
)
|
|
197
|
+
if isinstance(modalities, list) and "decisions" in modalities:
|
|
198
|
+
return True
|
|
199
|
+
meta = model.get("meta")
|
|
200
|
+
swap = meta.get("llamaswap") if isinstance(meta, dict) else None
|
|
201
|
+
return isinstance(swap, dict) and swap.get("decision") is True
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _describe_http_error(exc: Exception) -> str:
|
|
205
|
+
"""Human message for an unload failure, reading error bodies when present."""
|
|
206
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
207
|
+
response = exc.response
|
|
208
|
+
try:
|
|
209
|
+
body = response.json()
|
|
210
|
+
except (ValueError, TypeError):
|
|
211
|
+
return f"HTTP {response.status_code}"
|
|
212
|
+
if isinstance(body, dict):
|
|
213
|
+
error = body.get("error")
|
|
214
|
+
if isinstance(error, dict) and isinstance(error.get("message"), str):
|
|
215
|
+
return f"HTTP {response.status_code}: {error['message']}"
|
|
216
|
+
if isinstance(error, str):
|
|
217
|
+
return f"HTTP {response.status_code}: {error}"
|
|
218
|
+
return f"HTTP {response.status_code}"
|
|
219
|
+
return str(exc)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _content_text(content: object) -> str:
|
|
223
|
+
"""Normalize text from OpenAI-compatible string or content-part fields."""
|
|
224
|
+
if isinstance(content, str):
|
|
225
|
+
return content
|
|
226
|
+
if isinstance(content, dict):
|
|
227
|
+
for key in ("text", "content"):
|
|
228
|
+
if isinstance(content.get(key), str):
|
|
229
|
+
return content[key]
|
|
230
|
+
if isinstance(content, list):
|
|
231
|
+
parts = []
|
|
232
|
+
for item in content:
|
|
233
|
+
if isinstance(item, dict) and isinstance(item.get("text"), str):
|
|
234
|
+
parts.append(item["text"])
|
|
235
|
+
return "".join(parts)
|
|
236
|
+
return ""
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _reasoning_text(message: dict) -> tuple[str, bool]:
|
|
240
|
+
"""Return reasoning text plus whether a known reasoning field was present."""
|
|
241
|
+
for key in ("reasoning_content", "reasoning", "thinking"):
|
|
242
|
+
if key in message:
|
|
243
|
+
return _content_text(message.get(key)), True
|
|
244
|
+
return "", False
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _trace_status(thinking: str, channel_seen: bool) -> str:
|
|
248
|
+
if thinking:
|
|
249
|
+
return "observed"
|
|
250
|
+
return "available_empty" if channel_seen else "unavailable"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _close_response(response: httpx.Response) -> None:
|
|
254
|
+
"""Best-effort close for deadline and user-cancellation watchers."""
|
|
255
|
+
# The stream may have completed at the same instant as a watcher.
|
|
256
|
+
with contextlib.suppress(Exception):
|
|
257
|
+
response.close()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _start_deadline(
|
|
261
|
+
response: httpx.Response,
|
|
262
|
+
seconds: float,
|
|
263
|
+
) -> tuple[threading.Event, threading.Timer]:
|
|
264
|
+
"""Close a live response when its per-task generation deadline expires."""
|
|
265
|
+
expired = threading.Event()
|
|
266
|
+
|
|
267
|
+
def expire() -> None:
|
|
268
|
+
expired.set()
|
|
269
|
+
_close_response(response)
|
|
270
|
+
|
|
271
|
+
timer = threading.Timer(max(0.0, seconds), expire)
|
|
272
|
+
timer.daemon = True
|
|
273
|
+
timer.start()
|
|
274
|
+
return expired, timer
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _start_cancellation_watch(
|
|
278
|
+
response: httpx.Response,
|
|
279
|
+
cancel_event: threading.Event | None,
|
|
280
|
+
) -> tuple[threading.Event, threading.Event]:
|
|
281
|
+
"""Close a live response promptly when the user stops the run."""
|
|
282
|
+
cancelled = threading.Event()
|
|
283
|
+
finished = threading.Event()
|
|
284
|
+
if cancel_event is None:
|
|
285
|
+
return cancelled, finished
|
|
286
|
+
|
|
287
|
+
def watch() -> None:
|
|
288
|
+
while not finished.is_set():
|
|
289
|
+
if cancel_event.wait(0.05):
|
|
290
|
+
if not finished.is_set():
|
|
291
|
+
cancelled.set()
|
|
292
|
+
_close_response(response)
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
thread = threading.Thread(target=watch, daemon=True)
|
|
296
|
+
thread.start()
|
|
297
|
+
return cancelled, finished
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _openai_metrics(data: dict, elapsed_s: float) -> dict:
|
|
301
|
+
"""Read token metrics from standard usage and llama.cpp timings payloads."""
|
|
302
|
+
usage = data.get("usage") or {}
|
|
303
|
+
timings = data.get("timings") or {}
|
|
304
|
+
|
|
305
|
+
eval_count = int(
|
|
306
|
+
usage.get("completion_tokens")
|
|
307
|
+
or timings.get("predicted_n")
|
|
308
|
+
or timings.get("tokens_predicted")
|
|
309
|
+
or 0
|
|
310
|
+
)
|
|
311
|
+
input_tokens = int(
|
|
312
|
+
usage.get("prompt_tokens")
|
|
313
|
+
or usage.get("input_tokens")
|
|
314
|
+
or timings.get("prompt_n")
|
|
315
|
+
or timings.get("tokens_evaluated")
|
|
316
|
+
or 0
|
|
317
|
+
)
|
|
318
|
+
predicted_ms = float(
|
|
319
|
+
timings.get("predicted_ms") or timings.get("generation_ms") or 0.0
|
|
320
|
+
)
|
|
321
|
+
tok_s = float(
|
|
322
|
+
timings.get("predicted_per_second")
|
|
323
|
+
or timings.get("tokens_predicted_per_second")
|
|
324
|
+
or 0.0
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
if not tok_s and predicted_ms > 0 and eval_count:
|
|
328
|
+
tok_s = eval_count / (predicted_ms / 1000)
|
|
329
|
+
if not tok_s and elapsed_s > 0 and eval_count:
|
|
330
|
+
tok_s = eval_count / elapsed_s
|
|
331
|
+
|
|
332
|
+
eval_duration_ns = int(predicted_ms * 1_000_000)
|
|
333
|
+
if not eval_duration_ns and tok_s > 0 and eval_count:
|
|
334
|
+
eval_duration_ns = int(eval_count / tok_s * 1_000_000_000)
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
"tok_s": tok_s,
|
|
338
|
+
"input_tokens": input_tokens,
|
|
339
|
+
"eval_count": eval_count,
|
|
340
|
+
"eval_duration_ns": eval_duration_ns,
|
|
341
|
+
"response_time_s": elapsed_s,
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@dataclass
|
|
346
|
+
class _StreamState:
|
|
347
|
+
"""Tracks whether an attempt already handed output to the caller."""
|
|
348
|
+
|
|
349
|
+
received: bool = False
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@dataclass(frozen=True)
|
|
353
|
+
class GenerationUpdate:
|
|
354
|
+
"""A normalized piece of a live model generation."""
|
|
355
|
+
|
|
356
|
+
thinking: str = ""
|
|
357
|
+
response: str = ""
|
|
358
|
+
elapsed_s: float = 0.0
|
|
359
|
+
reasoning_channel_seen: bool = False
|
|
360
|
+
done: bool = False
|
|
361
|
+
attempt: int = 0
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@dataclass
|
|
365
|
+
class InferenceClient:
|
|
366
|
+
"""Small synchronous client with provider auto-detection."""
|
|
367
|
+
|
|
368
|
+
host: str
|
|
369
|
+
requested_provider: Provider = "auto"
|
|
370
|
+
api_key: str | None = None
|
|
371
|
+
timeout: float = 300.0
|
|
372
|
+
retries: RetryPolicy = field(default_factory=RetryPolicy)
|
|
373
|
+
provider: Literal["openai", "ollama"] | None = field(default=None, init=False)
|
|
374
|
+
_models_by_name: dict[str, dict] = field(
|
|
375
|
+
default_factory=dict, init=False, repr=False
|
|
376
|
+
)
|
|
377
|
+
_parallelism: dict[str, int] = field(default_factory=dict, init=False, repr=False)
|
|
378
|
+
_context_lengths: dict[str, int | None] = field(
|
|
379
|
+
default_factory=dict, init=False, repr=False
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
@classmethod
|
|
383
|
+
def from_env(cls) -> InferenceClient:
|
|
384
|
+
provider = os.environ.get("BENCHKIT_PROVIDER", "auto").lower()
|
|
385
|
+
if provider not in {"auto", "openai", "ollama"}:
|
|
386
|
+
raise ValueError("BENCHKIT_PROVIDER must be one of: auto, openai, ollama")
|
|
387
|
+
|
|
388
|
+
host = (
|
|
389
|
+
os.environ.get("BENCHKIT_HOST")
|
|
390
|
+
or os.environ.get("OPENAI_BASE_URL")
|
|
391
|
+
or os.environ.get("OLLAMA_HOST")
|
|
392
|
+
or DEFAULT_HOST
|
|
393
|
+
)
|
|
394
|
+
api_key = os.environ.get("BENCHKIT_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
|
395
|
+
timeout = float(os.environ.get("BENCHKIT_TIMEOUT", "300"))
|
|
396
|
+
return cls(host.rstrip("/"), provider, api_key, timeout, RetryPolicy.from_env())
|
|
397
|
+
|
|
398
|
+
@property
|
|
399
|
+
def label(self) -> str:
|
|
400
|
+
if self.provider == "openai":
|
|
401
|
+
return "OpenAI-compatible"
|
|
402
|
+
if self.provider == "ollama":
|
|
403
|
+
return "Ollama"
|
|
404
|
+
return "Auto-detect"
|
|
405
|
+
|
|
406
|
+
def _headers(self) -> dict[str, str]:
|
|
407
|
+
if not self.api_key:
|
|
408
|
+
return {}
|
|
409
|
+
return {"Authorization": f"Bearer {self.api_key}"}
|
|
410
|
+
|
|
411
|
+
def _request(
|
|
412
|
+
self,
|
|
413
|
+
method: str,
|
|
414
|
+
url: str,
|
|
415
|
+
*,
|
|
416
|
+
cancel_event: threading.Event | None = None,
|
|
417
|
+
**kwargs: object,
|
|
418
|
+
) -> httpx.Response:
|
|
419
|
+
"""Issue a non-streaming request, retrying transient server failures."""
|
|
420
|
+
|
|
421
|
+
def attempt() -> httpx.Response:
|
|
422
|
+
response = httpx.request(method, url, **kwargs)
|
|
423
|
+
response.raise_for_status()
|
|
424
|
+
return response
|
|
425
|
+
|
|
426
|
+
return run_with_retries(self.retries, attempt, cancel_event=cancel_event)
|
|
427
|
+
|
|
428
|
+
def _retry_stream(
|
|
429
|
+
self,
|
|
430
|
+
stream: Callable[[_StreamState], dict],
|
|
431
|
+
cancel_event: threading.Event | None,
|
|
432
|
+
) -> dict:
|
|
433
|
+
"""Retry a generation only while nothing has been streamed to the caller.
|
|
434
|
+
|
|
435
|
+
Once tokens have reached ``on_progress`` a replay would duplicate output,
|
|
436
|
+
so a mid-stream failure is surfaced instead of retried.
|
|
437
|
+
"""
|
|
438
|
+
state = _StreamState()
|
|
439
|
+
|
|
440
|
+
def attempt() -> dict:
|
|
441
|
+
state.received = False
|
|
442
|
+
return stream(state)
|
|
443
|
+
|
|
444
|
+
return run_with_retries(
|
|
445
|
+
self.retries,
|
|
446
|
+
attempt,
|
|
447
|
+
cancel_event=cancel_event,
|
|
448
|
+
retryable=lambda exc: not state.received and is_retryable(exc),
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
def list_models(self) -> list[dict]:
|
|
452
|
+
"""Discover models and lock in the detected provider."""
|
|
453
|
+
providers: list[Literal["openai", "ollama"]]
|
|
454
|
+
if self.requested_provider == "auto":
|
|
455
|
+
providers = ["openai", "ollama"]
|
|
456
|
+
else:
|
|
457
|
+
providers = [self.requested_provider]
|
|
458
|
+
|
|
459
|
+
errors = []
|
|
460
|
+
for provider in providers:
|
|
461
|
+
try:
|
|
462
|
+
if provider == "openai":
|
|
463
|
+
models = self._list_openai_models()
|
|
464
|
+
else:
|
|
465
|
+
models = self._list_ollama_models()
|
|
466
|
+
self.provider = provider
|
|
467
|
+
self._models_by_name = {model["name"]: model for model in models}
|
|
468
|
+
self._parallelism.clear()
|
|
469
|
+
self._context_lengths.clear()
|
|
470
|
+
return models
|
|
471
|
+
except (httpx.HTTPError, ValueError, TypeError) as exc:
|
|
472
|
+
errors.append(f"{provider}: {exc}")
|
|
473
|
+
|
|
474
|
+
raise RuntimeError(
|
|
475
|
+
"Could not discover a compatible API (" + "; ".join(errors) + ")"
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
def _list_openai_models(self) -> list[dict]:
|
|
479
|
+
response = self._request(
|
|
480
|
+
"GET",
|
|
481
|
+
f"{_openai_base(self.host)}/models",
|
|
482
|
+
headers=self._headers(),
|
|
483
|
+
timeout=10,
|
|
484
|
+
)
|
|
485
|
+
models = response.json().get("data", [])
|
|
486
|
+
normalized = []
|
|
487
|
+
for model in models:
|
|
488
|
+
model_id = model.get("id")
|
|
489
|
+
if not model_id:
|
|
490
|
+
continue
|
|
491
|
+
raw_status = model.get("status")
|
|
492
|
+
status = (
|
|
493
|
+
raw_status.get("value", "")
|
|
494
|
+
if isinstance(raw_status, dict)
|
|
495
|
+
else str(raw_status or "")
|
|
496
|
+
)
|
|
497
|
+
normalized_status = status.strip().lower()
|
|
498
|
+
normalized.append(
|
|
499
|
+
{
|
|
500
|
+
"name": model_id,
|
|
501
|
+
"size": model.get("size"),
|
|
502
|
+
"owned_by": model.get("owned_by", ""),
|
|
503
|
+
"status": status,
|
|
504
|
+
"loaded": (
|
|
505
|
+
True
|
|
506
|
+
if normalized_status in {"loaded", "running"}
|
|
507
|
+
else False
|
|
508
|
+
if normalized_status == "unloaded"
|
|
509
|
+
else None
|
|
510
|
+
),
|
|
511
|
+
"meta": model.get("meta") or {},
|
|
512
|
+
"parallelism": _parallelism_hint(model),
|
|
513
|
+
"context_length": _context_length_hint(model),
|
|
514
|
+
"decision": _is_decision_model(model),
|
|
515
|
+
}
|
|
516
|
+
)
|
|
517
|
+
return sorted(normalized, key=lambda model: model["name"].lower())
|
|
518
|
+
|
|
519
|
+
def _list_ollama_models(self) -> list[dict]:
|
|
520
|
+
response = self._request(
|
|
521
|
+
"GET",
|
|
522
|
+
f"{_without_v1(self.host)}/api/tags",
|
|
523
|
+
timeout=10,
|
|
524
|
+
)
|
|
525
|
+
models = response.json().get("models", [])
|
|
526
|
+
running: set[str] | None = None
|
|
527
|
+
running_models: list[dict] | None = None
|
|
528
|
+
try:
|
|
529
|
+
response = self._request(
|
|
530
|
+
"GET",
|
|
531
|
+
f"{_without_v1(self.host)}/api/ps",
|
|
532
|
+
timeout=10,
|
|
533
|
+
)
|
|
534
|
+
running_models = response.json().get("models", [])
|
|
535
|
+
running = {
|
|
536
|
+
str(value)
|
|
537
|
+
for model in running_models
|
|
538
|
+
for value in (
|
|
539
|
+
model.get("name"),
|
|
540
|
+
model.get("model"),
|
|
541
|
+
model.get("digest"),
|
|
542
|
+
)
|
|
543
|
+
if value
|
|
544
|
+
}
|
|
545
|
+
except (httpx.HTTPError, ValueError, TypeError):
|
|
546
|
+
# Older or non-standard Ollama-compatible servers may not expose
|
|
547
|
+
# the optional running-model endpoint. Discovery should still work.
|
|
548
|
+
pass
|
|
549
|
+
|
|
550
|
+
if running is not None:
|
|
551
|
+
for model in models:
|
|
552
|
+
loaded = any(
|
|
553
|
+
str(value) in running
|
|
554
|
+
for value in (
|
|
555
|
+
model.get("name"),
|
|
556
|
+
model.get("model"),
|
|
557
|
+
model.get("digest"),
|
|
558
|
+
)
|
|
559
|
+
if value
|
|
560
|
+
)
|
|
561
|
+
model["loaded"] = loaded
|
|
562
|
+
model["status"] = "loaded" if loaded else "unloaded"
|
|
563
|
+
matches = [
|
|
564
|
+
entry
|
|
565
|
+
for entry in running_models or []
|
|
566
|
+
if any(
|
|
567
|
+
str(value)
|
|
568
|
+
in {
|
|
569
|
+
str(model.get("name") or ""),
|
|
570
|
+
str(model.get("model") or ""),
|
|
571
|
+
str(model.get("digest") or ""),
|
|
572
|
+
}
|
|
573
|
+
for value in (
|
|
574
|
+
entry.get("name"),
|
|
575
|
+
entry.get("model"),
|
|
576
|
+
entry.get("digest"),
|
|
577
|
+
)
|
|
578
|
+
if value
|
|
579
|
+
)
|
|
580
|
+
]
|
|
581
|
+
context_length = _context_length_hint(matches)
|
|
582
|
+
if context_length is not None:
|
|
583
|
+
model["context_length"] = context_length
|
|
584
|
+
return sorted(models, key=lambda model: model.get("size", 0))
|
|
585
|
+
|
|
586
|
+
def max_parallel_requests(self, model: str) -> int:
|
|
587
|
+
"""Return the model's automatically discovered request capacity.
|
|
588
|
+
|
|
589
|
+
llama.cpp exposes one object per server slot from ``GET /slots``. Its
|
|
590
|
+
router mode accepts the model as a query parameter, while llama-swap
|
|
591
|
+
exposes the same endpoint below ``/upstream/<model>/``. Other server
|
|
592
|
+
types stay at one request unless their model metadata contains an
|
|
593
|
+
explicit positive concurrency value. ``BENCHKIT_CONCURRENCY`` overrides
|
|
594
|
+
discovery, for hosted APIs that expose no slot endpoint.
|
|
595
|
+
"""
|
|
596
|
+
if model in self._parallelism:
|
|
597
|
+
return self._parallelism[model]
|
|
598
|
+
forced = _positive_int(os.environ.get("BENCHKIT_CONCURRENCY"))
|
|
599
|
+
if forced is not None:
|
|
600
|
+
self._parallelism[model] = min(MAX_DISCOVERED_CONCURRENCY, forced)
|
|
601
|
+
return self._parallelism[model]
|
|
602
|
+
|
|
603
|
+
info = self._models_by_name.get(model, {})
|
|
604
|
+
hint = _positive_int(info.get("parallelism"))
|
|
605
|
+
discovered = self._discover_slot_capacity(model, info)
|
|
606
|
+
candidates = [value for value in (hint, discovered) if value is not None]
|
|
607
|
+
capacity = min(candidates) if candidates else 1
|
|
608
|
+
capacity = min(MAX_DISCOVERED_CONCURRENCY, max(1, capacity))
|
|
609
|
+
self._parallelism[model] = capacity
|
|
610
|
+
return capacity
|
|
611
|
+
|
|
612
|
+
def context_length(self, model: str) -> int | None:
|
|
613
|
+
"""Return the best available served-context limit for a model."""
|
|
614
|
+
if model in self._context_lengths:
|
|
615
|
+
return self._context_lengths[model]
|
|
616
|
+
|
|
617
|
+
known = _context_length_hint(self._models_by_name.get(model, {}))
|
|
618
|
+
if known is None:
|
|
619
|
+
known = self._discover_context_length(model)
|
|
620
|
+
self._context_lengths[model] = known
|
|
621
|
+
return known
|
|
622
|
+
|
|
623
|
+
def _is_llama_swap(self, model: str) -> bool:
|
|
624
|
+
"""Return True when the served model is routed by llama-swap."""
|
|
625
|
+
info = self._models_by_name.get(model, {})
|
|
626
|
+
owner = str(info.get("owned_by") or "").strip().lower()
|
|
627
|
+
meta = info.get("meta") or {}
|
|
628
|
+
return owner in {"llama-swap", "llamaswap"} or (
|
|
629
|
+
isinstance(meta, dict) and "llamaswap" in meta
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
def _discover_context_length(self, model: str) -> int | None:
|
|
633
|
+
if self.provider == "ollama":
|
|
634
|
+
root = _without_v1(self.host)
|
|
635
|
+
try:
|
|
636
|
+
response = self._request("GET", f"{root}/api/ps", timeout=10)
|
|
637
|
+
running = response.json().get("models", [])
|
|
638
|
+
matches = [
|
|
639
|
+
entry
|
|
640
|
+
for entry in running
|
|
641
|
+
if model
|
|
642
|
+
in {
|
|
643
|
+
str(entry.get("name") or ""),
|
|
644
|
+
str(entry.get("model") or ""),
|
|
645
|
+
str(entry.get("digest") or ""),
|
|
646
|
+
}
|
|
647
|
+
]
|
|
648
|
+
hint = _context_length_hint(matches)
|
|
649
|
+
if hint is not None:
|
|
650
|
+
return hint
|
|
651
|
+
except (httpx.HTTPError, RuntimeError, ValueError, TypeError):
|
|
652
|
+
pass
|
|
653
|
+
try:
|
|
654
|
+
response = self._request(
|
|
655
|
+
"POST",
|
|
656
|
+
f"{root}/api/show",
|
|
657
|
+
json={"model": model},
|
|
658
|
+
timeout=10,
|
|
659
|
+
)
|
|
660
|
+
return _context_length_hint(response.json())
|
|
661
|
+
except (httpx.HTTPError, RuntimeError, ValueError, TypeError):
|
|
662
|
+
return None
|
|
663
|
+
|
|
664
|
+
if self.provider != "openai":
|
|
665
|
+
return None
|
|
666
|
+
|
|
667
|
+
root = _without_v1(self.host)
|
|
668
|
+
attempts: list[tuple[str, dict[str, str] | None]] = [
|
|
669
|
+
(f"{root}/props", {"model": model}),
|
|
670
|
+
(f"{root}/slots", {"model": model}),
|
|
671
|
+
]
|
|
672
|
+
if self._is_llama_swap(model):
|
|
673
|
+
upstream = f"{root}/upstream/{quote(model, safe='/')}"
|
|
674
|
+
attempts.extend([(f"{upstream}/props", None), (f"{upstream}/slots", None)])
|
|
675
|
+
|
|
676
|
+
for url, params in attempts:
|
|
677
|
+
try:
|
|
678
|
+
response = self._request(
|
|
679
|
+
"GET",
|
|
680
|
+
url,
|
|
681
|
+
headers=self._headers(),
|
|
682
|
+
params=params,
|
|
683
|
+
timeout=min(self.timeout, 30),
|
|
684
|
+
)
|
|
685
|
+
hint = _context_length_hint(response.json())
|
|
686
|
+
if hint is not None:
|
|
687
|
+
return hint
|
|
688
|
+
except (httpx.HTTPError, RuntimeError, ValueError, TypeError):
|
|
689
|
+
continue
|
|
690
|
+
return None
|
|
691
|
+
|
|
692
|
+
def _discover_slot_capacity(self, model: str, info: dict) -> int | None:
|
|
693
|
+
"""Probe optional llama.cpp monitoring routes without breaking peers."""
|
|
694
|
+
if self.provider != "openai":
|
|
695
|
+
return None
|
|
696
|
+
|
|
697
|
+
root = _without_v1(self.host)
|
|
698
|
+
attempts: list[tuple[str, dict[str, str] | None]] = [
|
|
699
|
+
(f"{root}/slots", {"model": model}),
|
|
700
|
+
(f"{root}/health", {"model": model}),
|
|
701
|
+
]
|
|
702
|
+
|
|
703
|
+
if self._is_llama_swap(model):
|
|
704
|
+
upstream = f"{root}/upstream/{quote(model, safe='/')}"
|
|
705
|
+
attempts.extend(
|
|
706
|
+
[
|
|
707
|
+
(f"{upstream}/slots", None),
|
|
708
|
+
(f"{upstream}/health", None),
|
|
709
|
+
]
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
for url, params in attempts:
|
|
713
|
+
try:
|
|
714
|
+
response = self._request(
|
|
715
|
+
"GET",
|
|
716
|
+
url,
|
|
717
|
+
headers=self._headers(),
|
|
718
|
+
params=params,
|
|
719
|
+
timeout=self.timeout,
|
|
720
|
+
)
|
|
721
|
+
capacity = _capacity_from_payload(response.json())
|
|
722
|
+
if capacity is not None:
|
|
723
|
+
return capacity
|
|
724
|
+
except (httpx.HTTPError, RuntimeError, ValueError, TypeError):
|
|
725
|
+
continue
|
|
726
|
+
return None
|
|
727
|
+
|
|
728
|
+
def generate(
|
|
729
|
+
self,
|
|
730
|
+
model: str,
|
|
731
|
+
prompt: str,
|
|
732
|
+
on_progress: ProgressCallback | None = None,
|
|
733
|
+
cancel_event: threading.Event | None = None,
|
|
734
|
+
images: list[str] | None = None,
|
|
735
|
+
) -> dict:
|
|
736
|
+
"""Stream one completion. ``images`` are local files sent inline."""
|
|
737
|
+
if self.provider is None:
|
|
738
|
+
raise RuntimeError("Call list_models() before generate()")
|
|
739
|
+
encoded = [_encode_image(path) for path in images or ()]
|
|
740
|
+
if self.provider == "openai":
|
|
741
|
+
return self._generate_openai(
|
|
742
|
+
model, prompt, on_progress, cancel_event, encoded
|
|
743
|
+
)
|
|
744
|
+
return self._generate_ollama(model, prompt, on_progress, cancel_event, encoded)
|
|
745
|
+
|
|
746
|
+
def is_decision_model(self, model: str) -> bool:
|
|
747
|
+
"""Return whether a discovered model answers through /v1/systemone."""
|
|
748
|
+
return bool(self._models_by_name.get(model, {}).get("decision"))
|
|
749
|
+
|
|
750
|
+
def decide(
|
|
751
|
+
self,
|
|
752
|
+
model: str,
|
|
753
|
+
request: dict,
|
|
754
|
+
cancel_event: threading.Event | None = None,
|
|
755
|
+
) -> dict:
|
|
756
|
+
"""Ask a native decision model typed questions (llama.cpp /v1/systemone)."""
|
|
757
|
+
response = self._request(
|
|
758
|
+
"POST",
|
|
759
|
+
f"{_openai_base(self.host)}/systemone",
|
|
760
|
+
cancel_event=cancel_event,
|
|
761
|
+
headers=self._headers(),
|
|
762
|
+
json={"model": model, **request},
|
|
763
|
+
timeout=self._generation_timeout(),
|
|
764
|
+
)
|
|
765
|
+
return response.json()
|
|
766
|
+
|
|
767
|
+
def _generation_timeout(self) -> httpx.Timeout:
|
|
768
|
+
"""Apply the configured timeout to every transport operation."""
|
|
769
|
+
return httpx.Timeout(self.timeout)
|
|
770
|
+
|
|
771
|
+
def _generate_openai(
|
|
772
|
+
self,
|
|
773
|
+
model: str,
|
|
774
|
+
prompt: str,
|
|
775
|
+
on_progress: ProgressCallback | None,
|
|
776
|
+
cancel_event: threading.Event | None,
|
|
777
|
+
images: list[tuple[str, str]] | None = None,
|
|
778
|
+
) -> dict:
|
|
779
|
+
content: str | list[dict] = prompt
|
|
780
|
+
if images:
|
|
781
|
+
# Data URIs, not URLs: llama-swap and many local servers cannot
|
|
782
|
+
# fetch remote images, and inline bytes keep runs reproducible.
|
|
783
|
+
content = [
|
|
784
|
+
*(
|
|
785
|
+
{
|
|
786
|
+
"type": "image_url",
|
|
787
|
+
"image_url": {"url": f"data:{mime};base64,{data}"},
|
|
788
|
+
}
|
|
789
|
+
for mime, data in images
|
|
790
|
+
),
|
|
791
|
+
{"type": "text", "text": prompt},
|
|
792
|
+
]
|
|
793
|
+
body = {
|
|
794
|
+
"model": model,
|
|
795
|
+
"messages": [{"role": "user", "content": content}],
|
|
796
|
+
"stream": True,
|
|
797
|
+
"temperature": 0.0,
|
|
798
|
+
"stream_options": {"include_usage": True},
|
|
799
|
+
}
|
|
800
|
+
try:
|
|
801
|
+
return self._retry_stream(
|
|
802
|
+
lambda state: self._stream_openai(
|
|
803
|
+
body, on_progress, cancel_event, state
|
|
804
|
+
),
|
|
805
|
+
cancel_event,
|
|
806
|
+
)
|
|
807
|
+
except httpx.HTTPStatusError as exc:
|
|
808
|
+
if exc.response.status_code not in {400, 404, 422}:
|
|
809
|
+
raise
|
|
810
|
+
# Some older compatible servers reject the optional usage request but
|
|
811
|
+
# still support standard streamed chat completions.
|
|
812
|
+
body.pop("stream_options")
|
|
813
|
+
return self._retry_stream(
|
|
814
|
+
lambda state: self._stream_openai(body, on_progress, cancel_event, state),
|
|
815
|
+
cancel_event,
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
def _stream_openai(
|
|
819
|
+
self,
|
|
820
|
+
body: dict,
|
|
821
|
+
on_progress: ProgressCallback | None,
|
|
822
|
+
cancel_event: threading.Event | None,
|
|
823
|
+
state: _StreamState | None = None,
|
|
824
|
+
) -> dict:
|
|
825
|
+
state = state if state is not None else _StreamState()
|
|
826
|
+
started = time.perf_counter()
|
|
827
|
+
thinking_parts: list[str] = []
|
|
828
|
+
response_parts: list[str] = []
|
|
829
|
+
usage: dict = {}
|
|
830
|
+
timings: dict = {}
|
|
831
|
+
done_reason = ""
|
|
832
|
+
timed_out = False
|
|
833
|
+
generation_cancelled = False
|
|
834
|
+
explicit_reasoning_channel = False
|
|
835
|
+
inline = InlineThinkingParser()
|
|
836
|
+
|
|
837
|
+
try:
|
|
838
|
+
with httpx.stream(
|
|
839
|
+
"POST",
|
|
840
|
+
f"{_openai_base(self.host)}/chat/completions",
|
|
841
|
+
headers=self._headers(),
|
|
842
|
+
json=body,
|
|
843
|
+
timeout=self._generation_timeout(),
|
|
844
|
+
) as response:
|
|
845
|
+
response.raise_for_status()
|
|
846
|
+
expired, watchdog = _start_deadline(
|
|
847
|
+
response,
|
|
848
|
+
self.timeout - (time.perf_counter() - started),
|
|
849
|
+
)
|
|
850
|
+
cancelled, cancellation_finished = _start_cancellation_watch(
|
|
851
|
+
response, cancel_event
|
|
852
|
+
)
|
|
853
|
+
try:
|
|
854
|
+
for line in response.iter_lines():
|
|
855
|
+
if cancelled.is_set() or (
|
|
856
|
+
cancel_event is not None and cancel_event.is_set()
|
|
857
|
+
):
|
|
858
|
+
generation_cancelled = True
|
|
859
|
+
break
|
|
860
|
+
if (
|
|
861
|
+
expired.is_set()
|
|
862
|
+
or time.perf_counter() - started >= self.timeout
|
|
863
|
+
):
|
|
864
|
+
timed_out = True
|
|
865
|
+
break
|
|
866
|
+
if not line.startswith("data:"):
|
|
867
|
+
continue
|
|
868
|
+
payload = line[5:].strip()
|
|
869
|
+
if not payload or payload == "[DONE]":
|
|
870
|
+
continue
|
|
871
|
+
data = json.loads(payload)
|
|
872
|
+
if data.get("error"):
|
|
873
|
+
raise RuntimeError(f"generation failed: {data['error']}")
|
|
874
|
+
if isinstance(data.get("usage"), dict):
|
|
875
|
+
usage = data["usage"]
|
|
876
|
+
if isinstance(data.get("timings"), dict):
|
|
877
|
+
timings = data["timings"]
|
|
878
|
+
|
|
879
|
+
choices = data.get("choices") or []
|
|
880
|
+
choice = choices[0] if choices else {}
|
|
881
|
+
done_reason = choice.get("finish_reason") or done_reason
|
|
882
|
+
delta = choice.get("delta") or choice.get("message") or {}
|
|
883
|
+
thinking_piece, reasoning_field = _reasoning_text(delta)
|
|
884
|
+
explicit_reasoning_channel = (
|
|
885
|
+
explicit_reasoning_channel or reasoning_field
|
|
886
|
+
)
|
|
887
|
+
content_piece = _content_text(delta.get("content"))
|
|
888
|
+
|
|
889
|
+
if content_piece and not explicit_reasoning_channel:
|
|
890
|
+
inline_thinking, answer_piece = inline.feed(content_piece)
|
|
891
|
+
thinking_piece += inline_thinking
|
|
892
|
+
else:
|
|
893
|
+
answer_piece = content_piece
|
|
894
|
+
|
|
895
|
+
reasoning_channel_seen = (
|
|
896
|
+
explicit_reasoning_channel or inline.saw_marker
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
thinking_parts.append(thinking_piece)
|
|
900
|
+
response_parts.append(answer_piece)
|
|
901
|
+
if thinking_piece or answer_piece:
|
|
902
|
+
state.received = True
|
|
903
|
+
if on_progress is not None and (
|
|
904
|
+
thinking_piece or answer_piece or reasoning_field
|
|
905
|
+
):
|
|
906
|
+
on_progress(
|
|
907
|
+
GenerationUpdate(
|
|
908
|
+
thinking=thinking_piece,
|
|
909
|
+
response=answer_piece,
|
|
910
|
+
elapsed_s=time.perf_counter() - started,
|
|
911
|
+
reasoning_channel_seen=reasoning_channel_seen,
|
|
912
|
+
)
|
|
913
|
+
)
|
|
914
|
+
except (httpx.HTTPError, httpx.StreamError):
|
|
915
|
+
if cancelled.is_set() or (
|
|
916
|
+
cancel_event is not None and cancel_event.is_set()
|
|
917
|
+
):
|
|
918
|
+
generation_cancelled = True
|
|
919
|
+
elif expired.is_set():
|
|
920
|
+
timed_out = True
|
|
921
|
+
else:
|
|
922
|
+
raise
|
|
923
|
+
finally:
|
|
924
|
+
cancellation_finished.set()
|
|
925
|
+
watchdog.cancel()
|
|
926
|
+
generation_cancelled = generation_cancelled or cancelled.is_set()
|
|
927
|
+
timed_out = not generation_cancelled and (timed_out or expired.is_set())
|
|
928
|
+
except httpx.TimeoutException:
|
|
929
|
+
generation_cancelled = bool(
|
|
930
|
+
cancel_event is not None and cancel_event.is_set()
|
|
931
|
+
)
|
|
932
|
+
timed_out = not generation_cancelled
|
|
933
|
+
|
|
934
|
+
inline_thinking, inline_answer = inline.finish()
|
|
935
|
+
if inline_thinking or inline_answer:
|
|
936
|
+
thinking_parts.append(inline_thinking)
|
|
937
|
+
response_parts.append(inline_answer)
|
|
938
|
+
if on_progress is not None and not generation_cancelled:
|
|
939
|
+
on_progress(
|
|
940
|
+
GenerationUpdate(
|
|
941
|
+
thinking=inline_thinking,
|
|
942
|
+
response=inline_answer,
|
|
943
|
+
elapsed_s=time.perf_counter() - started,
|
|
944
|
+
reasoning_channel_seen=(
|
|
945
|
+
explicit_reasoning_channel or inline.saw_marker
|
|
946
|
+
),
|
|
947
|
+
)
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
elapsed_s = time.perf_counter() - started
|
|
951
|
+
if on_progress is not None and not generation_cancelled:
|
|
952
|
+
on_progress(
|
|
953
|
+
GenerationUpdate(
|
|
954
|
+
elapsed_s=elapsed_s,
|
|
955
|
+
reasoning_channel_seen=(
|
|
956
|
+
explicit_reasoning_channel or inline.saw_marker
|
|
957
|
+
),
|
|
958
|
+
done=True,
|
|
959
|
+
)
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
return {
|
|
963
|
+
"thinking": "".join(thinking_parts),
|
|
964
|
+
"response": "".join(response_parts),
|
|
965
|
+
"trace_status": _trace_status(
|
|
966
|
+
"".join(thinking_parts),
|
|
967
|
+
explicit_reasoning_channel or inline.saw_marker,
|
|
968
|
+
),
|
|
969
|
+
"done_reason": (
|
|
970
|
+
"cancelled"
|
|
971
|
+
if generation_cancelled
|
|
972
|
+
else "timeout"
|
|
973
|
+
if timed_out
|
|
974
|
+
else done_reason
|
|
975
|
+
),
|
|
976
|
+
"timed_out": timed_out,
|
|
977
|
+
"cancelled": generation_cancelled,
|
|
978
|
+
**_openai_metrics(
|
|
979
|
+
{"usage": usage, "timings": timings},
|
|
980
|
+
elapsed_s,
|
|
981
|
+
),
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
def _generate_ollama(
|
|
985
|
+
self,
|
|
986
|
+
model: str,
|
|
987
|
+
prompt: str,
|
|
988
|
+
on_progress: ProgressCallback | None,
|
|
989
|
+
cancel_event: threading.Event | None,
|
|
990
|
+
images: list[tuple[str, str]] | None = None,
|
|
991
|
+
) -> dict:
|
|
992
|
+
return self._retry_stream(
|
|
993
|
+
lambda state: self._stream_ollama(
|
|
994
|
+
model,
|
|
995
|
+
prompt,
|
|
996
|
+
on_progress,
|
|
997
|
+
cancel_event,
|
|
998
|
+
state,
|
|
999
|
+
[data for _, data in images or ()],
|
|
1000
|
+
),
|
|
1001
|
+
cancel_event,
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
def _stream_ollama(
|
|
1005
|
+
self,
|
|
1006
|
+
model: str,
|
|
1007
|
+
prompt: str,
|
|
1008
|
+
on_progress: ProgressCallback | None,
|
|
1009
|
+
cancel_event: threading.Event | None,
|
|
1010
|
+
state: _StreamState | None = None,
|
|
1011
|
+
images: list[str] | None = None,
|
|
1012
|
+
) -> dict:
|
|
1013
|
+
state = state if state is not None else _StreamState()
|
|
1014
|
+
started = time.perf_counter()
|
|
1015
|
+
thinking_parts: list[str] = []
|
|
1016
|
+
response_parts: list[str] = []
|
|
1017
|
+
final: dict = {}
|
|
1018
|
+
timed_out = False
|
|
1019
|
+
generation_cancelled = False
|
|
1020
|
+
explicit_reasoning_channel = False
|
|
1021
|
+
inline = InlineThinkingParser()
|
|
1022
|
+
|
|
1023
|
+
try:
|
|
1024
|
+
with httpx.stream(
|
|
1025
|
+
"POST",
|
|
1026
|
+
f"{_without_v1(self.host)}/api/generate",
|
|
1027
|
+
json={
|
|
1028
|
+
"model": model,
|
|
1029
|
+
"prompt": prompt,
|
|
1030
|
+
"stream": True,
|
|
1031
|
+
"options": {"temperature": 0.0},
|
|
1032
|
+
**({"images": images} if images else {}),
|
|
1033
|
+
},
|
|
1034
|
+
timeout=self._generation_timeout(),
|
|
1035
|
+
) as response:
|
|
1036
|
+
response.raise_for_status()
|
|
1037
|
+
expired, watchdog = _start_deadline(
|
|
1038
|
+
response,
|
|
1039
|
+
self.timeout - (time.perf_counter() - started),
|
|
1040
|
+
)
|
|
1041
|
+
cancelled, cancellation_finished = _start_cancellation_watch(
|
|
1042
|
+
response, cancel_event
|
|
1043
|
+
)
|
|
1044
|
+
try:
|
|
1045
|
+
for line in response.iter_lines():
|
|
1046
|
+
if cancelled.is_set() or (
|
|
1047
|
+
cancel_event is not None and cancel_event.is_set()
|
|
1048
|
+
):
|
|
1049
|
+
generation_cancelled = True
|
|
1050
|
+
break
|
|
1051
|
+
if (
|
|
1052
|
+
expired.is_set()
|
|
1053
|
+
or time.perf_counter() - started >= self.timeout
|
|
1054
|
+
):
|
|
1055
|
+
timed_out = True
|
|
1056
|
+
break
|
|
1057
|
+
if not line.strip():
|
|
1058
|
+
continue
|
|
1059
|
+
data = json.loads(line)
|
|
1060
|
+
if data.get("error"):
|
|
1061
|
+
raise RuntimeError(f"generation failed: {data['error']}")
|
|
1062
|
+
final = data
|
|
1063
|
+
reasoning_field = "thinking" in data
|
|
1064
|
+
explicit_reasoning_channel = (
|
|
1065
|
+
explicit_reasoning_channel or reasoning_field
|
|
1066
|
+
)
|
|
1067
|
+
thinking_piece = _content_text(data.get("thinking"))
|
|
1068
|
+
content_piece = _content_text(data.get("response"))
|
|
1069
|
+
|
|
1070
|
+
if content_piece and not explicit_reasoning_channel:
|
|
1071
|
+
inline_thinking, answer_piece = inline.feed(content_piece)
|
|
1072
|
+
thinking_piece += inline_thinking
|
|
1073
|
+
else:
|
|
1074
|
+
answer_piece = content_piece
|
|
1075
|
+
|
|
1076
|
+
reasoning_channel_seen = (
|
|
1077
|
+
explicit_reasoning_channel or inline.saw_marker
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
thinking_parts.append(thinking_piece)
|
|
1081
|
+
response_parts.append(answer_piece)
|
|
1082
|
+
if thinking_piece or answer_piece:
|
|
1083
|
+
state.received = True
|
|
1084
|
+
if on_progress is not None and (
|
|
1085
|
+
thinking_piece or answer_piece or reasoning_field
|
|
1086
|
+
):
|
|
1087
|
+
on_progress(
|
|
1088
|
+
GenerationUpdate(
|
|
1089
|
+
thinking=thinking_piece,
|
|
1090
|
+
response=answer_piece,
|
|
1091
|
+
elapsed_s=time.perf_counter() - started,
|
|
1092
|
+
reasoning_channel_seen=reasoning_channel_seen,
|
|
1093
|
+
)
|
|
1094
|
+
)
|
|
1095
|
+
except (httpx.HTTPError, httpx.StreamError):
|
|
1096
|
+
if cancelled.is_set() or (
|
|
1097
|
+
cancel_event is not None and cancel_event.is_set()
|
|
1098
|
+
):
|
|
1099
|
+
generation_cancelled = True
|
|
1100
|
+
elif expired.is_set():
|
|
1101
|
+
timed_out = True
|
|
1102
|
+
else:
|
|
1103
|
+
raise
|
|
1104
|
+
finally:
|
|
1105
|
+
cancellation_finished.set()
|
|
1106
|
+
watchdog.cancel()
|
|
1107
|
+
generation_cancelled = generation_cancelled or cancelled.is_set()
|
|
1108
|
+
timed_out = not generation_cancelled and (timed_out or expired.is_set())
|
|
1109
|
+
except httpx.TimeoutException:
|
|
1110
|
+
generation_cancelled = bool(
|
|
1111
|
+
cancel_event is not None and cancel_event.is_set()
|
|
1112
|
+
)
|
|
1113
|
+
timed_out = not generation_cancelled
|
|
1114
|
+
|
|
1115
|
+
inline_thinking, inline_answer = inline.finish()
|
|
1116
|
+
thinking_parts.append(inline_thinking)
|
|
1117
|
+
response_parts.append(inline_answer)
|
|
1118
|
+
elapsed_s = time.perf_counter() - started
|
|
1119
|
+
reasoning_channel_seen = explicit_reasoning_channel or inline.saw_marker
|
|
1120
|
+
if on_progress is not None and not generation_cancelled:
|
|
1121
|
+
if inline_thinking or inline_answer:
|
|
1122
|
+
on_progress(
|
|
1123
|
+
GenerationUpdate(
|
|
1124
|
+
thinking=inline_thinking,
|
|
1125
|
+
response=inline_answer,
|
|
1126
|
+
elapsed_s=elapsed_s,
|
|
1127
|
+
reasoning_channel_seen=reasoning_channel_seen,
|
|
1128
|
+
)
|
|
1129
|
+
)
|
|
1130
|
+
on_progress(
|
|
1131
|
+
GenerationUpdate(
|
|
1132
|
+
elapsed_s=elapsed_s,
|
|
1133
|
+
reasoning_channel_seen=reasoning_channel_seen,
|
|
1134
|
+
done=True,
|
|
1135
|
+
)
|
|
1136
|
+
)
|
|
1137
|
+
|
|
1138
|
+
eval_count = final.get("eval_count", 0)
|
|
1139
|
+
eval_duration_ns = final.get("eval_duration", 0)
|
|
1140
|
+
total_duration_ns = final.get("total_duration", 0)
|
|
1141
|
+
tok_s = eval_count / (eval_duration_ns / 1e9) if eval_duration_ns > 0 else 0.0
|
|
1142
|
+
|
|
1143
|
+
thinking = "".join(thinking_parts)
|
|
1144
|
+
|
|
1145
|
+
return {
|
|
1146
|
+
"thinking": thinking,
|
|
1147
|
+
"response": "".join(response_parts),
|
|
1148
|
+
"trace_status": _trace_status(thinking, reasoning_channel_seen),
|
|
1149
|
+
"tok_s": tok_s,
|
|
1150
|
+
"input_tokens": int(final.get("prompt_eval_count") or 0),
|
|
1151
|
+
"eval_count": eval_count,
|
|
1152
|
+
"eval_duration_ns": eval_duration_ns,
|
|
1153
|
+
"response_time_s": (
|
|
1154
|
+
total_duration_ns / 1e9 if total_duration_ns else elapsed_s
|
|
1155
|
+
),
|
|
1156
|
+
"done_reason": (
|
|
1157
|
+
"cancelled"
|
|
1158
|
+
if generation_cancelled
|
|
1159
|
+
else "timeout"
|
|
1160
|
+
if timed_out
|
|
1161
|
+
else final.get("done_reason", "")
|
|
1162
|
+
),
|
|
1163
|
+
"timed_out": timed_out,
|
|
1164
|
+
"cancelled": generation_cancelled,
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
def tokenize(
|
|
1168
|
+
self,
|
|
1169
|
+
model: str,
|
|
1170
|
+
content: str,
|
|
1171
|
+
*,
|
|
1172
|
+
add_special: bool = True,
|
|
1173
|
+
) -> list[int] | None:
|
|
1174
|
+
"""Tokenize text through a llama.cpp-compatible native endpoint.
|
|
1175
|
+
|
|
1176
|
+
The endpoint is optional. Returning ``None`` lets performance profiling
|
|
1177
|
+
fall back to a deterministic text prompt on generic OpenAI servers.
|
|
1178
|
+
"""
|
|
1179
|
+
if self.provider != "openai":
|
|
1180
|
+
return None
|
|
1181
|
+
root = _without_v1(self.host)
|
|
1182
|
+
attempts = [f"{root}/tokenize"]
|
|
1183
|
+
if self._is_llama_swap(model):
|
|
1184
|
+
upstream = f"{root}/upstream/{quote(model, safe='/')}"
|
|
1185
|
+
attempts.append(f"{upstream}/tokenize")
|
|
1186
|
+
|
|
1187
|
+
body = {
|
|
1188
|
+
"model": model,
|
|
1189
|
+
"content": content,
|
|
1190
|
+
"add_special": add_special,
|
|
1191
|
+
}
|
|
1192
|
+
for url in attempts:
|
|
1193
|
+
try:
|
|
1194
|
+
response = self._request(
|
|
1195
|
+
"POST",
|
|
1196
|
+
url,
|
|
1197
|
+
headers=self._headers(),
|
|
1198
|
+
json=body,
|
|
1199
|
+
timeout=min(self.timeout, 60),
|
|
1200
|
+
)
|
|
1201
|
+
payload = response.json()
|
|
1202
|
+
tokens = payload.get("tokens") if isinstance(payload, dict) else None
|
|
1203
|
+
if isinstance(tokens, list) and all(
|
|
1204
|
+
isinstance(token, int) for token in tokens
|
|
1205
|
+
):
|
|
1206
|
+
return tokens
|
|
1207
|
+
except (httpx.HTTPError, ValueError, TypeError):
|
|
1208
|
+
continue
|
|
1209
|
+
return None
|
|
1210
|
+
|
|
1211
|
+
def profile_completion(
|
|
1212
|
+
self,
|
|
1213
|
+
model: str,
|
|
1214
|
+
prompt: str | list[int],
|
|
1215
|
+
max_tokens: int,
|
|
1216
|
+
) -> dict:
|
|
1217
|
+
"""Stream a controlled completion and return normalized perf timings."""
|
|
1218
|
+
if self.provider != "openai":
|
|
1219
|
+
raise RuntimeError(
|
|
1220
|
+
"Performance profiling currently targets OpenAI-compatible "
|
|
1221
|
+
"llama.cpp/llama-swap endpoints"
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1224
|
+
advanced = {
|
|
1225
|
+
"model": model,
|
|
1226
|
+
"prompt": prompt,
|
|
1227
|
+
"max_tokens": max_tokens,
|
|
1228
|
+
"stream": True,
|
|
1229
|
+
"temperature": 0.0,
|
|
1230
|
+
"seed": 42,
|
|
1231
|
+
"ignore_eos": True,
|
|
1232
|
+
"cache_prompt": False,
|
|
1233
|
+
"timings_per_token": True,
|
|
1234
|
+
"stream_options": {"include_usage": True},
|
|
1235
|
+
}
|
|
1236
|
+
try:
|
|
1237
|
+
return run_with_retries(
|
|
1238
|
+
self.retries,
|
|
1239
|
+
lambda: self._stream_profile(advanced, advanced_options=True),
|
|
1240
|
+
)
|
|
1241
|
+
except httpx.HTTPStatusError as exc:
|
|
1242
|
+
if exc.response.status_code not in {400, 404, 422}:
|
|
1243
|
+
raise
|
|
1244
|
+
|
|
1245
|
+
portable = {
|
|
1246
|
+
"model": model,
|
|
1247
|
+
"prompt": prompt,
|
|
1248
|
+
"max_tokens": max_tokens,
|
|
1249
|
+
"stream": True,
|
|
1250
|
+
"temperature": 0.0,
|
|
1251
|
+
"seed": 42,
|
|
1252
|
+
"stream_options": {"include_usage": True},
|
|
1253
|
+
}
|
|
1254
|
+
return run_with_retries(
|
|
1255
|
+
self.retries,
|
|
1256
|
+
lambda: self._stream_profile(portable, advanced_options=False),
|
|
1257
|
+
)
|
|
1258
|
+
|
|
1259
|
+
def _stream_profile(self, body: dict, *, advanced_options: bool) -> dict:
|
|
1260
|
+
started = time.perf_counter()
|
|
1261
|
+
first_token_at: float | None = None
|
|
1262
|
+
finished_at = started
|
|
1263
|
+
timings: dict = {}
|
|
1264
|
+
usage: dict = {}
|
|
1265
|
+
text_parts: list[str] = []
|
|
1266
|
+
|
|
1267
|
+
with httpx.stream(
|
|
1268
|
+
"POST",
|
|
1269
|
+
f"{_openai_base(self.host)}/completions",
|
|
1270
|
+
headers=self._headers(),
|
|
1271
|
+
json=body,
|
|
1272
|
+
timeout=self.timeout,
|
|
1273
|
+
) as response:
|
|
1274
|
+
response.raise_for_status()
|
|
1275
|
+
for line in response.iter_lines():
|
|
1276
|
+
if not line.startswith("data:"):
|
|
1277
|
+
continue
|
|
1278
|
+
payload = line[5:].strip()
|
|
1279
|
+
if not payload or payload == "[DONE]":
|
|
1280
|
+
continue
|
|
1281
|
+
data = json.loads(payload)
|
|
1282
|
+
if isinstance(data.get("timings"), dict):
|
|
1283
|
+
timings = data["timings"]
|
|
1284
|
+
if isinstance(data.get("usage"), dict):
|
|
1285
|
+
usage = data["usage"]
|
|
1286
|
+
choices = data.get("choices") or []
|
|
1287
|
+
choice = choices[0] if choices else {}
|
|
1288
|
+
piece = choice.get("text")
|
|
1289
|
+
if isinstance(piece, str) and piece:
|
|
1290
|
+
if first_token_at is None:
|
|
1291
|
+
first_token_at = time.perf_counter()
|
|
1292
|
+
text_parts.append(piece)
|
|
1293
|
+
finished_at = time.perf_counter()
|
|
1294
|
+
|
|
1295
|
+
prompt_tokens = int(timings.get("prompt_n") or usage.get("prompt_tokens") or 0)
|
|
1296
|
+
cached_tokens = int(timings.get("cache_n") or 0)
|
|
1297
|
+
output_tokens = int(
|
|
1298
|
+
timings.get("predicted_n") or usage.get("completion_tokens") or 0
|
|
1299
|
+
)
|
|
1300
|
+
prompt_ms = float(timings.get("prompt_ms") or 0.0)
|
|
1301
|
+
predicted_ms = float(timings.get("predicted_ms") or 0.0)
|
|
1302
|
+
ttft_s = (
|
|
1303
|
+
first_token_at - started
|
|
1304
|
+
if first_token_at is not None
|
|
1305
|
+
else finished_at - started
|
|
1306
|
+
)
|
|
1307
|
+
wall_time_s = finished_at - started
|
|
1308
|
+
wall_decode_s = (
|
|
1309
|
+
finished_at - first_token_at if first_token_at is not None else 0.0
|
|
1310
|
+
)
|
|
1311
|
+
server_time_s = (prompt_ms + predicted_ms) / 1000
|
|
1312
|
+
|
|
1313
|
+
return {
|
|
1314
|
+
"response": "".join(text_parts),
|
|
1315
|
+
"input_tokens": prompt_tokens + cached_tokens,
|
|
1316
|
+
"processed_input_tokens": prompt_tokens,
|
|
1317
|
+
"cached_input_tokens": cached_tokens,
|
|
1318
|
+
"output_tokens": output_tokens,
|
|
1319
|
+
"pp_tps": (
|
|
1320
|
+
float(timings.get("prompt_per_second") or 0.0)
|
|
1321
|
+
or (
|
|
1322
|
+
prompt_tokens / (prompt_ms / 1000)
|
|
1323
|
+
if prompt_tokens and prompt_ms > 0
|
|
1324
|
+
else 0.0
|
|
1325
|
+
)
|
|
1326
|
+
),
|
|
1327
|
+
"tg_tps": (
|
|
1328
|
+
float(timings.get("predicted_per_second") or 0.0)
|
|
1329
|
+
or (
|
|
1330
|
+
output_tokens / (predicted_ms / 1000)
|
|
1331
|
+
if output_tokens and predicted_ms > 0
|
|
1332
|
+
else 0.0
|
|
1333
|
+
)
|
|
1334
|
+
),
|
|
1335
|
+
"ttft_s": ttft_s,
|
|
1336
|
+
"wall_time_s": wall_time_s,
|
|
1337
|
+
"wall_tg_tps": (
|
|
1338
|
+
(output_tokens - 1) / wall_decode_s
|
|
1339
|
+
if output_tokens > 1 and wall_decode_s > 0
|
|
1340
|
+
else 0.0
|
|
1341
|
+
),
|
|
1342
|
+
"server_time_s": server_time_s,
|
|
1343
|
+
"overhead_s": (
|
|
1344
|
+
max(0.0, wall_time_s - server_time_s) if server_time_s > 0 else 0.0
|
|
1345
|
+
),
|
|
1346
|
+
"advanced_options": advanced_options,
|
|
1347
|
+
"server_timings": bool(timings),
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
def unload_model(self, model: str) -> None:
|
|
1351
|
+
"""Evict only when Ollama exposes a model-scoped unload operation.
|
|
1352
|
+
|
|
1353
|
+
OpenAI-compatible APIs have no standard model unload call. In particular,
|
|
1354
|
+
llama-swap handles model switching itself, so BenchKit never calls its
|
|
1355
|
+
global ``/unload`` endpoint.
|
|
1356
|
+
"""
|
|
1357
|
+
if self.provider != "ollama":
|
|
1358
|
+
return
|
|
1359
|
+
self._request(
|
|
1360
|
+
"POST",
|
|
1361
|
+
f"{_without_v1(self.host)}/api/generate",
|
|
1362
|
+
json={"model": model, "keep_alive": 0},
|
|
1363
|
+
timeout=30,
|
|
1364
|
+
)
|
|
1365
|
+
|
|
1366
|
+
def force_unload_model(self, model: str) -> None:
|
|
1367
|
+
"""Force a model out of VRAM before the next model loads.
|
|
1368
|
+
|
|
1369
|
+
Ollama keeps the model-scoped ``keep_alive=0`` unload. Both llama.cpp's
|
|
1370
|
+
router mode and llama-swap unload a named model through the synchronous
|
|
1371
|
+
``POST /models/unload`` endpoint, whose success response means the
|
|
1372
|
+
model has already been evicted. Older single-model llama-server builds
|
|
1373
|
+
only know the bare ``POST /unload`` endpoint, used as a fallback for
|
|
1374
|
+
non-swap servers. Raises ``RuntimeError`` with the server's message when
|
|
1375
|
+
no unload could be issued.
|
|
1376
|
+
"""
|
|
1377
|
+
if self.provider == "ollama":
|
|
1378
|
+
self.unload_model(model)
|
|
1379
|
+
return
|
|
1380
|
+
if self.provider != "openai":
|
|
1381
|
+
raise RuntimeError(f"{self.label} does not support model unload")
|
|
1382
|
+
|
|
1383
|
+
root = _without_v1(self.host)
|
|
1384
|
+
try:
|
|
1385
|
+
self._request(
|
|
1386
|
+
"POST",
|
|
1387
|
+
f"{root}/models/unload",
|
|
1388
|
+
headers=self._headers(),
|
|
1389
|
+
json={"model": model},
|
|
1390
|
+
timeout=min(self.timeout, 60),
|
|
1391
|
+
)
|
|
1392
|
+
return
|
|
1393
|
+
except httpx.HTTPStatusError as exc:
|
|
1394
|
+
if exc.response.status_code in {404, 405} and not self._is_llama_swap(
|
|
1395
|
+
model
|
|
1396
|
+
):
|
|
1397
|
+
try:
|
|
1398
|
+
self._request(
|
|
1399
|
+
"POST",
|
|
1400
|
+
f"{root}/unload",
|
|
1401
|
+
headers=self._headers(),
|
|
1402
|
+
timeout=min(self.timeout, 60),
|
|
1403
|
+
)
|
|
1404
|
+
return
|
|
1405
|
+
except (httpx.HTTPError, RuntimeError, ValueError, TypeError) as fb:
|
|
1406
|
+
raise RuntimeError(
|
|
1407
|
+
f"POST /unload: {_describe_http_error(fb)}"
|
|
1408
|
+
) from fb
|
|
1409
|
+
raise RuntimeError(
|
|
1410
|
+
f"POST /models/unload: {_describe_http_error(exc)}"
|
|
1411
|
+
) from exc
|
|
1412
|
+
except (httpx.HTTPError, RuntimeError, ValueError, TypeError) as exc:
|
|
1413
|
+
raise RuntimeError(f"POST /models/unload: {exc}") from exc
|