Coverage for orchestrant/benchmark/client.py: 96%

192 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-18 00:48 +0000

1"""The front end the bench tools share: plumbing, so kept out of `tool_sha256` and grading.""" 

2 

3import json 

4import os 

5import re 

6import sys 

7import time 

8import urllib.error 

9import urllib.request 

10from datetime import UTC, datetime 

11 

12from orchestrant.benchmark.gateway import note_reply 

13 

14 

15def _row(entry_label, backend, base_url, model, url): 

16 """One candidate as a dict, before disambiguation.""" 

17 label = entry_label or model or backend or url or "unnamed" 

18 return { 

19 "label": label, 

20 "explicit_label": bool(entry_label), 

21 "backend": backend, 

22 "base_url": url, 

23 "raw_base_url": base_url, 

24 "model": model, 

25 "entry": {}, 

26 } 

27 

28 

29def disambiguate(rows): 

30 """Make every candidate label unique, in place, or refuse loudly: consumers key on it.""" 

31 seen = {} 

32 for row in rows: 

33 seen.setdefault(row["label"], []).append(row) 

34 for label, group in seen.items(): 

35 if len(group) < 2: 

36 continue 

37 explicit = [r for r in group if r["explicit_label"]] 

38 if explicit: 

39 raise SystemExit( 

40 f"two candidates share the label {label!r}. A label names one " 

41 f"measured endpoint; give each an explicit distinct --label / " 

42 f'"label" field.' 

43 ) 

44 for row in group: 

45 suffix = row["backend"] or row["base_url"] 

46 if suffix: 

47 row["label"] = f"{label} ({suffix})" 

48 final = [r["label"] for r in rows] 

49 if len(set(final)) != len(final): 

50 dupes = sorted({lbl for lbl in final if final.count(lbl) > 1}) 

51 raise SystemExit( 

52 f"candidate labels still collide after disambiguation: {dupes}. " 

53 f'Give each candidate a distinct "label".' 

54 ) 

55 return rows 

56 

57 

58def load_candidates(path, resolve_backend, resolve_entry=None): 

59 """Read a candidates/--compare file into rows; a `_comment`-only element is not one.""" 

60 with open(path) as f: 

61 entries = json.load(f) 

62 if not isinstance(entries, list): 

63 raise SystemExit(f"{path}: expected a JSON list of candidates") 

64 rows = [] 

65 for entry in entries: 

66 if isinstance(entry, dict) and set(entry) <= {"_comment"}: 

67 continue 

68 backend, base_url = entry.get("backend"), entry.get("base_url") 

69 url, model, _ = resolve_backend(backend, base_url) 

70 row = _row( 

71 entry.get("label"), backend, base_url, entry.get("model") or model, url 

72 ) 

73 if resolve_entry is not None: 

74 row["entry"] = resolve_entry(backend, base_url) or {} 

75 rows.append(row) 

76 return disambiguate(rows) 

77 

78 

79def candidate_rows(args, resolve_backend, resolve_entry=None): 

80 """The same candidates as resolve_candidates, as whole rows that keep the registry NAME.""" 

81 compare_file = getattr(args, "compare", None) 

82 if compare_file: 

83 return load_candidates(compare_file, resolve_backend, resolve_entry) 

84 backend, base_url = getattr(args, "backend", None), getattr(args, "base_url", None) 

85 url, model, _ = resolve_backend(backend, base_url) 

86 row = _row( 

87 getattr(args, "label", None), 

88 backend, 

89 base_url, 

90 getattr(args, "model", None) or model, 

91 url, 

92 ) 

93 if resolve_entry is not None: 

94 row["entry"] = resolve_entry(backend, base_url) or {} 

95 return disambiguate([row]) 

96 

97 

98def resolve_candidates(args, resolve_backend, resolve_entry=None): 

99 """Return [(label, base_url, model)] from --compare or the single-run flags. 

100 

101 With `resolve_entry`, 4-tuples ending in the backends.json entry. 

102 """ 

103 rows = candidate_rows(args, resolve_backend, resolve_entry) 

104 if resolve_entry is None: 

105 return [(r["label"], r["base_url"], r["model"]) for r in rows] 

106 return [(r["label"], r["base_url"], r["model"], r["entry"]) for r in rows] 

107 

108 

109def run_start(tool_files, base_url=None, seconds=3): 

110 """The start time, source hash and host load a tool records before its first request. 

111 

112 Pass the same `tool_files` to write_report, or the start hash is not compared. 

113 """ 

114 from orchestrant.benchmark import hostload 

115 from orchestrant.benchmark.provenance import tool_fingerprint 

116 

117 record = { 

118 "started_utc": datetime.now(UTC).isoformat(), 

119 "tool_files": list(tool_files), 

120 "tool_sha256": tool_fingerprint(*tool_files) if tool_files else None, 

121 } 

122 try: 

123 record["host_load"] = hostload.load_snapshot(seconds, lane=base_url) 

124 except Exception as e: # a load reading must never cost the run 

125 record["host_load"] = { 

126 "other_cores": None, 

127 "note": f"{type(e).__name__}: {e}"[:160], 

128 } 

129 print(hostload.load_line(record["host_load"]), flush=True) 

130 return record 

131 

132 

133def _served_model(reports): 

134 """The one model id every report row served, or None when they differ.""" 

135 models = {r.get("model") for r in reports or () if isinstance(r, dict)} 

136 return models.pop() if len(models) == 1 else None 

137 

138 

139def write_report( 

140 path, 

141 benchmark, 

142 config, 

143 reports, 

144 base_url, 

145 tool_files, 

146 extra=None, 

147 *, 

148 run_start=None, 

149): 

150 """Write one report in the shared envelope, BEFORE anything that merely prints.""" 

151 from orchestrant.benchmark.provenance import collect 

152 

153 start, same_files = run_start or {}, True 

154 started = {} 

155 if start: 

156 # Hashes of different file sets always differ; comparing them would invent an edit. 

157 same_files = list(start.get("tool_files") or ()) == list(tool_files) 

158 started = { 

159 "tool_sha256_at_start": start.get("tool_sha256") if same_files else None, 

160 "host_load": start.get("host_load"), 

161 "run_started_utc": start.get("started_utc"), 

162 } 

163 provenance = collect(base_url, tool_files, model=_served_model(reports), **started) 

164 if not same_files: 

165 provenance.setdefault("incomplete", []).append("tool_sha256_at_start") 

166 for key, value in (extra or {}).items(): 

167 if key == "incomplete": 

168 provenance["incomplete"] = provenance.get("incomplete", []) + list(value) 

169 else: 

170 provenance[key] = value 

171 payload = { 

172 "benchmark": benchmark, 

173 "provenance": provenance, 

174 "config": config, 

175 "reports": reports, 

176 } 

177 tmp = f"{path}.tmp" 

178 with open(tmp, "w") as f: 

179 json.dump(payload, f, indent=2) 

180 # Atomic: a Ctrl-C mid-write must not leave a truncated JSON. 

181 os.replace(tmp, path) 

182 return payload 

183 

184 

185# One request path, one API-key read: see benchmarks/docs/llm-benchmark-review-2026-09-05.md § R10 

186 

187 

188class Response: 

189 """What post_json returns: `json()` for a body; `lines()`, then `gave_up`, for a stream. 

190 

191 Use it as a context manager; urllib does not close the socket for you. 

192 """ 

193 

194 def __init__(self, raw, started, deadline): 

195 self.raw = raw 

196 self.status = getattr(raw, "status", None) 

197 self.headers = getattr(raw, "headers", {}) 

198 self.started = started 

199 self.gave_up = False 

200 self._deadline = deadline 

201 

202 def json(self): 

203 return json.load(self.raw) 

204 

205 def lines(self): 

206 for chunk in self.raw: 

207 if self._deadline and time.monotonic() - self.started > self._deadline: 

208 self.gave_up = True 

209 return 

210 yield chunk.decode("utf-8", "replace").strip() 

211 

212 def close(self): 

213 self.raw.close() 

214 

215 def __enter__(self): 

216 return self 

217 

218 def __exit__(self, *exc): 

219 self.close() 

220 return False 

221 

222 

223def request_headers(entry): 

224 """Content-Type, the entry's own headers, then Bearer auth read here from api_key_env.""" 

225 entry = entry or {} 

226 headers = {"Content-Type": "application/json"} 

227 for key, value in (entry.get("headers") or {}).items(): 

228 headers[str(key)] = str(value) 

229 var = entry.get("api_key_env") 

230 if var: 

231 if not os.environ.get(var): 

232 raise SystemExit( 

233 f"this backend reads its API key from the environment variable " 

234 f"{var}, which is unset or empty. Export it before benchmarking " 

235 f"(the value is never printed or written to a report)." 

236 ) 

237 headers["Authorization"] = f"Bearer {os.environ[var]}" 

238 return headers 

239 

240 

241def request_extras(entry): 

242 """The entry's request_extra body keys — safe to record in a report.""" 

243 return dict((entry or {}).get("request_extra") or {}) 

244 

245 

246def utf8_stdio(): 

247 """Write stdout/stderr as UTF-8: a piped Windows stdout is cp1252 and raises on '→'.""" 

248 for stream in (sys.stdout, sys.stderr): 

249 reconfigure = getattr(stream, "reconfigure", None) 

250 if reconfigure is not None: 

251 reconfigure(encoding="utf-8", errors="replace") 

252 

253 

254def spacer(base_url, model, entry=None): 

255 """One throwaway request: GenieX answers an identical follow-up along a cache path.""" 

256 body = { 

257 "model": model, 

258 "messages": [{"role": "user", "content": "Reply with the single word: ok"}], 

259 "max_tokens": 1, 

260 "stream": False, 

261 } 

262 try: 

263 with post_json( 

264 f"{base_url}/v1/chat/completions", body, entry=entry, timeout=120 

265 ) as r: 

266 r.json() 

267 except Exception: # nosec B110 -- best effort, see the docstring 

268 pass 

269 

270 

271def entry_config(entry): 

272 """What a committed report may say about a backends.json entry: names, never values.""" 

273 entry = entry or {} 

274 # Cython 3.3's sorted() optimisation crashes on `sorted(x or {})`, so the fallback is bound first. 

275 headers = entry.get("headers") or {} 

276 return { 

277 "request_extra": request_extras(entry), 

278 "headers": sorted(headers), 

279 "api_key_env": entry.get("api_key_env"), 

280 "probe": bool(entry.get("probe", True)), 

281 } 

282 

283 

284REDACTED = "<redacted>" 

285# Whole words only: --api-key loses its value, --max-tokens keeps its own. 

286_SECRET_WORDS = frozenset( 

287 ("key", "apikey", "token", "secret", "password", "passwd", "auth", "bearer") 

288) 

289# Credential-shaped values under any flag; hf_/ghp_ need one alphanumeric run, sparing paths. 

290_SECRET_SHAPES = ( 

291 (re.compile(r"\b(?:sk|pk|rk|glpat)-[\w-]{16,}"), REDACTED), 

292 (re.compile(r"\b(?:hf|gh[pousr])_[A-Za-z0-9]{16,}"), REDACTED), 

293 (re.compile(r"\bgithub_pat_\w{16,}"), REDACTED), 

294 (re.compile(r"(?i)\b(bearer\s+)\S+"), rf"\1{REDACTED}"), 

295 ( 

296 re.compile(r"(?i)([?&](?:api[-_]?key|key|token|access[-_]?token)=)[^&#\s]+"), 

297 rf"\1{REDACTED}", 

298 ), 

299 (re.compile(r"(://[^/:@\s]+:)[^@/\s]+@"), rf"\1{REDACTED}@"), 

300) 

301 

302 

303def _secret_flag(flag): 

304 """Is `flag` (an argument up to any '=') named for a credential?""" 

305 if not flag.startswith("-"): 

306 return False 

307 return bool(_SECRET_WORDS & set(re.split(r"[-_.]", flag.lstrip("-").lower()))) 

308 

309 

310def redact_argv(argv): 

311 """A command line as a report may record it: every credential value replaced.""" 

312 out, hide_next = [], False 

313 for arg in map(str, argv): 

314 if hide_next and not arg.startswith("-"): 

315 out.append(REDACTED) 

316 hide_next = False 

317 continue 

318 flag, has_value, _ = arg.partition("=") 

319 secret = _secret_flag(flag) 

320 hide_next = secret and not has_value 

321 kept = f"{flag}={REDACTED}" if secret and has_value else arg 

322 for pattern, replacement in _SECRET_SHAPES: 

323 kept = pattern.sub(replacement, kept) 

324 out.append(kept) 

325 return out 

326 

327 

328def post_json(url, body, entry=None, stream=False, timeout=300, deadline=None): 

329 """POST one JSON body, with the entry's auth, headers and request_extra. 

330 

331 request_extra goes in BEFORE `body`, so the caller's keys always win; HTTPError propagates. 

332 """ 

333 payload = request_extras(entry) 

334 payload.update(body or {}) 

335 if stream: 

336 payload.setdefault("stream", True) 

337 req = urllib.request.Request( 

338 url, data=json.dumps(payload).encode(), headers=request_headers(entry) 

339 ) 

340 started = time.monotonic() 

341 try: 

342 raw = urllib.request.urlopen(req, timeout=timeout) # nosec B310 -- endpoint URL from config 

343 except urllib.error.HTTPError as e: 

344 note_reply(url, payload, e.headers) 

345 raise 

346 note_reply(url, payload, getattr(raw, "headers", None)) 

347 return Response(raw, started, deadline) 

348 

349 

350def http_error_detail(exc, limit=500): 

351 """(status, body) of the HTTPError post_json let through; None for any other.""" 

352 if not isinstance(exc, urllib.error.HTTPError): 

353 return None 

354 try: 

355 body = exc.read().decode("utf-8", "replace") 

356 except Exception: # an unreadable body still has its status 

357 body = "" 

358 finally: 

359 # An unclosed error holds its connection until GC, and Python 3.14 warns then. 

360 exc.close() 

361 return exc.code, body[:limit]