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.
Files changed (149) hide show
  1. benchkit/__init__.py +1 -0
  2. benchkit/_pi_proxy.py +219 -0
  3. benchkit/answer_key_guard.ts +49 -0
  4. benchkit/artifacts.py +87 -0
  5. benchkit/benchmarks/__init__.py +105 -0
  6. benchkit/benchmarks/aider_polyglot.py +405 -0
  7. benchkit/benchmarks/arc.py +52 -0
  8. benchkit/benchmarks/banking77.py +149 -0
  9. benchkit/benchmarks/base.py +19 -0
  10. benchkit/benchmarks/boolq.py +49 -0
  11. benchkit/benchmarks/chartqa.py +133 -0
  12. benchkit/benchmarks/code_verifier.py +77 -0
  13. benchkit/benchmarks/downloads.py +166 -0
  14. benchkit/benchmarks/evalplus.py +288 -0
  15. benchkit/benchmarks/git_surgery.py +485 -0
  16. benchkit/benchmarks/gpqa.py +56 -0
  17. benchkit/benchmarks/gsm8k.py +97 -0
  18. benchkit/benchmarks/hellaswag.py +49 -0
  19. benchkit/benchmarks/humaneval.py +103 -0
  20. benchkit/benchmarks/ifeval.py +56 -0
  21. benchkit/benchmarks/ifeval_instructions.py +477 -0
  22. benchkit/benchmarks/mbpp.py +60 -0
  23. benchkit/benchmarks/mc_arena.py +479 -0
  24. benchkit/benchmarks/mcq.py +94 -0
  25. benchkit/benchmarks/medfailbench.py +537 -0
  26. benchkit/benchmarks/mmlu.py +54 -0
  27. benchkit/benchmarks/mmlu_pro.py +62 -0
  28. benchkit/benchmarks/mmvp.py +115 -0
  29. benchkit/benchmarks/ocrbench.py +96 -0
  30. benchkit/benchmarks/openbookqa.py +52 -0
  31. benchkit/benchmarks/patcheval.py +929 -0
  32. benchkit/benchmarks/piqa.py +52 -0
  33. benchkit/benchmarks/ruler.py +172 -0
  34. benchkit/benchmarks/ruler_generator.py +535 -0
  35. benchkit/benchmarks/sanity.py +174 -0
  36. benchkit/benchmarks/treejs_arena.py +268 -0
  37. benchkit/benchmarks/truthfulqa.py +52 -0
  38. benchkit/benchmarks/utils.py +25 -0
  39. benchkit/benchmarks/vision.py +230 -0
  40. benchkit/benchmarks/vstar.py +108 -0
  41. benchkit/benchmarks/winogrande.py +52 -0
  42. benchkit/benchmarks/xstest.py +116 -0
  43. benchkit/browser.py +429 -0
  44. benchkit/catalog.py +121 -0
  45. benchkit/cli.py +1000 -0
  46. benchkit/client.py +1413 -0
  47. benchkit/controls.py +87 -0
  48. benchkit/datasets/MMLU_LICENSE.txt +21 -0
  49. benchkit/datasets/README.md +142 -0
  50. benchkit/datasets/arc_challenge.jsonl +1172 -0
  51. benchkit/datasets/banking77.jsonl +3080 -0
  52. benchkit/datasets/banking77_intents.json +79 -0
  53. benchkit/datasets/boolq.jsonl +3270 -0
  54. benchkit/datasets/code_verifier.jsonl +500 -0
  55. benchkit/datasets/gpqa_diamond.jsonl +198 -0
  56. benchkit/datasets/gsm8k.jsonl +1319 -0
  57. benchkit/datasets/hellaswag.jsonl +1000 -0
  58. benchkit/datasets/humaneval.jsonl +164 -0
  59. benchkit/datasets/ifeval.jsonl +541 -0
  60. benchkit/datasets/mbpp.jsonl +500 -0
  61. benchkit/datasets/mc_blocks_1_20_1.jsonl +1003 -0
  62. benchkit/datasets/medfailbench_hard30.jsonl +30 -0
  63. benchkit/datasets/medfailbench_hard30.md +13 -0
  64. benchkit/datasets/openbookqa.jsonl +500 -0
  65. benchkit/datasets/piqa.jsonl +1838 -0
  66. benchkit/datasets/sanity_code.jsonl +6 -0
  67. benchkit/datasets/truthfulqa.jsonl +817 -0
  68. benchkit/datasets/winogrande.jsonl +1267 -0
  69. benchkit/datasets/xstest.csv +451 -0
  70. benchkit/decision.py +194 -0
  71. benchkit/demo.py +451 -0
  72. benchkit/dockerfiles.py +247 -0
  73. benchkit/engine.py +1270 -0
  74. benchkit/evaluation.py +195 -0
  75. benchkit/events.py +141 -0
  76. benchkit/executor.py +96 -0
  77. benchkit/git_surgery/backport-release-stack/setup.sh +328 -0
  78. benchkit/git_surgery/backport-release-stack/verify.sh +119 -0
  79. benchkit/git_surgery/bisect-the-regression/setup.sh +49 -0
  80. benchkit/git_surgery/bisect-the-regression/verify.sh +34 -0
  81. benchkit/git_surgery/rebase-conflict-chain/setup.sh +65 -0
  82. benchkit/git_surgery/rebase-conflict-chain/verify.sh +43 -0
  83. benchkit/git_surgery/recover-complex-stash/setup.sh +152 -0
  84. benchkit/git_surgery/recover-complex-stash/verify.sh +95 -0
  85. benchkit/git_surgery/recover-lost-work/setup.sh +40 -0
  86. benchkit/git_surgery/recover-lost-work/verify.sh +36 -0
  87. benchkit/git_surgery/repair-force-pushed-remote/setup.sh +171 -0
  88. benchkit/git_surgery/repair-force-pushed-remote/verify.sh +83 -0
  89. benchkit/git_surgery/revert-merge-with-followups/setup.sh +198 -0
  90. benchkit/git_surgery/revert-merge-with-followups/verify.sh +98 -0
  91. benchkit/git_surgery/secret-in-history/README.md +27 -0
  92. benchkit/git_surgery/secret-in-history/setup.sh +107 -0
  93. benchkit/git_surgery/secret-in-history/verify.sh +106 -0
  94. benchkit/git_surgery/split-the-mega-commit/setup.sh +50 -0
  95. benchkit/git_surgery/split-the-mega-commit/verify.sh +48 -0
  96. benchkit/git_surgery/untangle-nested-submodules/setup.sh +193 -0
  97. benchkit/git_surgery/untangle-nested-submodules/verify.sh +90 -0
  98. benchkit/history.py +474 -0
  99. benchkit/jobs.py +332 -0
  100. benchkit/leakage.py +51 -0
  101. benchkit/looping.py +873 -0
  102. benchkit/mc_render.py +274 -0
  103. benchkit/mc_viewer/dist/atlas.png +0 -0
  104. benchkit/mc_viewer/dist/blockStates.json.gz +0 -0
  105. benchkit/mc_viewer/dist/version.json +4 -0
  106. benchkit/mc_viewer/dist/viewer.html +23 -0
  107. benchkit/mc_viewer/dist/viewer.js +2 -0
  108. benchkit/mc_viewer/dist/viewer.js.LICENSE.txt +25 -0
  109. benchkit/mc_viewer/dist/worker.js +2 -0
  110. benchkit/mc_viewer/dist/worker.js.LICENSE.txt +19 -0
  111. benchkit/metrics.py +100 -0
  112. benchkit/ollama.py +60 -0
  113. benchkit/perf.py +353 -0
  114. benchkit/perf_report.py +207 -0
  115. benchkit/perturbations.py +208 -0
  116. benchkit/pi_agent.py +476 -0
  117. benchkit/pi_package/package-lock.json +1827 -0
  118. benchkit/pi_package/package.json +8 -0
  119. benchkit/report.py +142 -0
  120. benchkit/report_markdown.py +645 -0
  121. benchkit/results.py +688 -0
  122. benchkit/retry.py +163 -0
  123. benchkit/runner.py +769 -0
  124. benchkit/sandbox.py +1167 -0
  125. benchkit/template_check.py +291 -0
  126. benchkit/templates/arena.html +504 -0
  127. benchkit/templates/history.html +58 -0
  128. benchkit/templates/perf.html +509 -0
  129. benchkit/templates/report.html +769 -0
  130. benchkit/tui/__init__.py +5 -0
  131. benchkit/tui/app.py +87 -0
  132. benchkit/tui/app.tcss +512 -0
  133. benchkit/tui/formatting.py +84 -0
  134. benchkit/tui/screens/__init__.py +1 -0
  135. benchkit/tui/screens/connect.py +191 -0
  136. benchkit/tui/screens/detail.py +288 -0
  137. benchkit/tui/screens/help.py +84 -0
  138. benchkit/tui/screens/modals.py +544 -0
  139. benchkit/tui/screens/results.py +361 -0
  140. benchkit/tui/screens/run.py +988 -0
  141. benchkit/tui/screens/setup.py +733 -0
  142. benchkit/tui/theme.py +135 -0
  143. benchkit/tui/widgets.py +120 -0
  144. benchkit_cli-0.1.0.dist-info/METADATA +294 -0
  145. benchkit_cli-0.1.0.dist-info/RECORD +149 -0
  146. benchkit_cli-0.1.0.dist-info/WHEEL +5 -0
  147. benchkit_cli-0.1.0.dist-info/entry_points.txt +3 -0
  148. benchkit_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  149. 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