nabla-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.
nabla/recorder.py ADDED
@@ -0,0 +1,609 @@
1
+ """T4 — traffic recorder. See cli-plan.md T4.
2
+
3
+ Three capture paths behind one interface (`record`):
4
+
5
+ 1. "samples" — run the site's enclosing function in a fresh subprocess per
6
+ sample-input line, with OPENAI_BASE_URL pointed at a local recording
7
+ proxy. Subprocess because target modules bind an OpenAI client at
8
+ import time (see fixtures/demo-repo/app/*.py), so env vars must be set
9
+ *before* the module is imported.
10
+ 2. "proxy" — run an arbitrary command (typically the target's pytest) as
11
+ a subprocess under the same proxy and harvest whatever traffic flows.
12
+ The fixture repo's own tests mock the SDK client directly, so this path
13
+ must detect "zero traffic" and say so explicitly (ZeroTrafficError)
14
+ instead of silently returning an empty profile.
15
+ 3. "logs" — parse user-supplied JSONL of past request/response bodies.
16
+
17
+ The recording proxy is a tiny threaded HTTP server. Its upstream behavior is
18
+ injectable (a `responder` callable, or NABLA_FAKE_UPSTREAM=1 for a canned
19
+ response) so tests never touch the network or need a real API key. Default
20
+ behavior with no override forwards to https://api.openai.com with auth
21
+ passthrough.
22
+
23
+ Site attribution (matching a captured request back to `site`) uses a
24
+ lightweight static heuristic: string literals reachable from the site's
25
+ enclosing function (including module-level constants it references, and up
26
+ to two hops into same-module helper functions it calls) are compared against
27
+ the captured request's message content. This is deliberately conservative —
28
+ a request that doesn't share enough of the site's static text is not
29
+ attributed to it.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import ast
35
+ import http.server
36
+ import json
37
+ import os
38
+ import statistics
39
+ import subprocess
40
+ import sys
41
+ import threading
42
+ import time
43
+ import urllib.error
44
+ import urllib.request
45
+ from dataclasses import dataclass
46
+ from pathlib import Path
47
+ from typing import Callable
48
+
49
+ from .contract import Example, RawSite
50
+
51
+
52
+ @dataclass
53
+ class RecordSource:
54
+ """Where examples come from: exactly one of the three paths."""
55
+ kind: str # "samples" | "proxy" | "logs"
56
+ samples_file: str | None = None # JSONL of {"input_slots": {...}} or {"prompt": "..."}
57
+ logs_file: str | None = None # JSONL of past request/response pairs
58
+ proxy_command: list[str] | None = None # command to run under the proxy (e.g. pytest)
59
+
60
+
61
+ class ZeroTrafficError(RuntimeError):
62
+ """Raised when the proxy path observed no OpenAI traffic — e.g. the
63
+ target's tests mock the SDK. Message must say this explicitly and point
64
+ at the samples/logs paths."""
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Recording proxy
69
+ # ---------------------------------------------------------------------------
70
+
71
+ @dataclass
72
+ class _Capture:
73
+ request: dict
74
+ response: dict
75
+ latency_ms: int
76
+
77
+
78
+ Responder = Callable[[dict, dict], dict]
79
+
80
+
81
+ def _fake_upstream_responder(body: dict, headers: dict) -> dict:
82
+ """Canned upstream used under NABLA_FAKE_UPSTREAM=1 (or when a caller
83
+ passes no responder in a test context). Never touches the network, never
84
+ needs a real API key, always returns a valid chat-completions body with
85
+ usage numbers."""
86
+ messages = body.get("messages", [])
87
+ user_text = ""
88
+ for m in reversed(messages):
89
+ if m.get("role") == "user" and isinstance(m.get("content"), str):
90
+ user_text = m["content"]
91
+ break
92
+ completion_text = json.dumps({"fake": True, "echo_chars": len(user_text)})
93
+ prompt_tokens = max(1, len(user_text) // 4)
94
+ completion_tokens = max(1, len(completion_text) // 4)
95
+ return {
96
+ "id": "chatcmpl-fake0000000000000000000",
97
+ "object": "chat.completion",
98
+ "created": int(time.time()),
99
+ "model": body.get("model", "gpt-4o-mini"),
100
+ "choices": [
101
+ {
102
+ "index": 0,
103
+ "message": {"role": "assistant", "content": completion_text},
104
+ "finish_reason": "stop",
105
+ }
106
+ ],
107
+ "usage": {
108
+ "prompt_tokens": prompt_tokens,
109
+ "completion_tokens": completion_tokens,
110
+ "total_tokens": prompt_tokens + completion_tokens,
111
+ },
112
+ }
113
+
114
+
115
+ def _forward_to_openai_responder(body: dict, headers: dict) -> dict:
116
+ """Default upstream: forward to the real OpenAI API with auth
117
+ passthrough.
118
+
119
+ Overridable via env for recording without an OpenAI key:
120
+ NABLA_UPSTREAM_BASE_URL — any OpenAI-compatible endpoint (e.g. Gemini's
121
+ compat layer) used as a stand-in oracle;
122
+ NABLA_UPSTREAM_API_KEY — replaces the passthrough Authorization;
123
+ NABLA_UPSTREAM_MODEL — rewrites the request's model for the upstream
124
+ only. The target repo's own model kwarg is what lands in the
125
+ profile's sampling/baseline blocks, so the "before" cost story is
126
+ unaffected by the stand-in.
127
+ """
128
+ base_url = (os.environ.get("NABLA_UPSTREAM_BASE_URL") or "https://api.openai.com/v1").rstrip("/")
129
+ api_key = os.environ.get("NABLA_UPSTREAM_API_KEY")
130
+ auth = f"Bearer {api_key}" if api_key else headers.get("Authorization", "")
131
+ model_override = os.environ.get("NABLA_UPSTREAM_MODEL")
132
+ if model_override:
133
+ body = {**body, "model": model_override}
134
+ req = urllib.request.Request(
135
+ f"{base_url}/chat/completions",
136
+ data=json.dumps(body).encode("utf-8"),
137
+ method="POST",
138
+ headers={
139
+ "Content-Type": "application/json",
140
+ "Authorization": auth,
141
+ # Some providers' CDNs (e.g. Cloudflare in front of Groq) reject
142
+ # urllib's default Python-urllib User-Agent with a 403.
143
+ "User-Agent": "nabla-recorder/0.1",
144
+ },
145
+ )
146
+ # Rate limits (429) and transient upstream errors are expected during a
147
+ # long record run, especially on free-tier stand-in oracles: back off and
148
+ # retry, honoring Retry-After when the upstream sends one.
149
+ delays = [5, 10, 20, 30, 45]
150
+ for attempt, delay in enumerate([*delays, None]):
151
+ try:
152
+ with urllib.request.urlopen(req, timeout=60) as resp:
153
+ return json.loads(resp.read().decode("utf-8"))
154
+ except urllib.error.HTTPError as exc:
155
+ if delay is None or exc.code not in (429, 500, 502, 503):
156
+ # Surface the upstream's error body — "HTTP 400" alone is
157
+ # undiagnosable (e.g. Groq's json_validate_failed).
158
+ try:
159
+ detail = exc.read().decode("utf-8", "replace")[:500]
160
+ except Exception: # noqa: BLE001 - body read is best-effort
161
+ detail = ""
162
+ raise RuntimeError(f"upstream HTTP {exc.code}: {detail or exc.reason}") from exc
163
+ retry_after = exc.headers.get("Retry-After") if exc.headers else None
164
+ try:
165
+ wait = max(float(retry_after), delay) if retry_after else delay
166
+ except ValueError:
167
+ wait = delay
168
+ time.sleep(min(wait, 60))
169
+ raise RuntimeError("unreachable")
170
+
171
+
172
+ def _default_responder() -> Responder:
173
+ if os.environ.get("NABLA_FAKE_UPSTREAM") == "1":
174
+ return _fake_upstream_responder
175
+ return _forward_to_openai_responder
176
+
177
+
178
+ class RecordingProxy:
179
+ """Local threaded HTTP server that records chat-completions traffic.
180
+
181
+ Binds 127.0.0.1 on an OS-assigned free port (port 0). Upstream behavior
182
+ is injectable via `responder`; if omitted it's resolved from
183
+ NABLA_FAKE_UPSTREAM at start() time.
184
+ """
185
+
186
+ def __init__(self, responder: Responder | None = None):
187
+ self.captured: list[_Capture] = []
188
+ self._lock = threading.Lock()
189
+ self._responder = responder or _default_responder()
190
+ self._server: http.server.ThreadingHTTPServer | None = None
191
+ self._thread: threading.Thread | None = None
192
+
193
+ @property
194
+ def port(self) -> int:
195
+ assert self._server is not None, "proxy not started"
196
+ return self._server.server_address[1]
197
+
198
+ @property
199
+ def base_url(self) -> str:
200
+ return f"http://127.0.0.1:{self.port}/v1"
201
+
202
+ def start(self) -> None:
203
+ proxy = self
204
+
205
+ class Handler(http.server.BaseHTTPRequestHandler):
206
+ def log_message(self, fmt, *args): # noqa: A002 - stdlib signature
207
+ pass # silence per-request logging to stderr
208
+
209
+ def do_POST(self):
210
+ length = int(self.headers.get("Content-Length") or 0)
211
+ raw = self.rfile.read(length) if length else b"{}"
212
+ try:
213
+ body = json.loads(raw or b"{}")
214
+ except json.JSONDecodeError:
215
+ body = {}
216
+
217
+ if not self.path.rstrip("/").endswith("chat/completions"):
218
+ self.send_response(404)
219
+ self.end_headers()
220
+ return
221
+
222
+ t0 = time.perf_counter()
223
+ try:
224
+ resp_body = proxy._responder(body, dict(self.headers))
225
+ except Exception as exc: # noqa: BLE001 - report upstream failure to caller
226
+ payload = json.dumps({"error": {"message": str(exc)}}).encode("utf-8")
227
+ self.send_response(502)
228
+ self.send_header("Content-Type", "application/json")
229
+ self.send_header("Content-Length", str(len(payload)))
230
+ self.end_headers()
231
+ self.wfile.write(payload)
232
+ return
233
+ latency_ms = max(1, round((time.perf_counter() - t0) * 1000))
234
+
235
+ with proxy._lock:
236
+ proxy.captured.append(_Capture(request=body, response=resp_body, latency_ms=latency_ms))
237
+
238
+ payload = json.dumps(resp_body).encode("utf-8")
239
+ self.send_response(200)
240
+ self.send_header("Content-Type", "application/json")
241
+ self.send_header("Content-Length", str(len(payload)))
242
+ self.end_headers()
243
+ self.wfile.write(payload)
244
+
245
+ self._server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
246
+ self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
247
+ self._thread.start()
248
+
249
+ def stop(self) -> None:
250
+ if self._server is not None:
251
+ self._server.shutdown()
252
+ self._server.server_close()
253
+ if self._thread is not None:
254
+ self._thread.join(timeout=5)
255
+
256
+
257
+ # ---------------------------------------------------------------------------
258
+ # Attribution: static-literal matching between a site and a captured request
259
+ # ---------------------------------------------------------------------------
260
+
261
+ def _callee_name(func_node: ast.AST) -> str | None:
262
+ if isinstance(func_node, ast.Name):
263
+ return func_node.id
264
+ if isinstance(func_node, ast.Attribute):
265
+ return func_node.attr
266
+ return None
267
+
268
+
269
+ def _extract_site_literals(file_path: str, symbol: str, max_hops: int = 2) -> set[str]:
270
+ """Static string literals reachable from `symbol`'s function body in
271
+ `file_path`: literals written directly in the function, module-level
272
+ string constants it references by name, and (up to `max_hops`) literals
273
+ from same-module helper functions it calls. Best-effort — used only to
274
+ build an attribution heuristic, never to fail hard."""
275
+ try:
276
+ source = Path(file_path).read_text(encoding="utf-8")
277
+ tree = ast.parse(source)
278
+ except (OSError, SyntaxError):
279
+ return set()
280
+
281
+ module_consts: dict[str, str] = {}
282
+ module_funcs: dict[str, ast.AST] = {}
283
+ for node in tree.body:
284
+ if (
285
+ isinstance(node, ast.Assign)
286
+ and len(node.targets) == 1
287
+ and isinstance(node.targets[0], ast.Name)
288
+ and isinstance(node.value, ast.Constant)
289
+ and isinstance(node.value.value, str)
290
+ ):
291
+ module_consts[node.targets[0].id] = node.value.value
292
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
293
+ module_funcs[node.name] = node
294
+
295
+ func_name = symbol.rstrip("()").split(".")[-1]
296
+ target = module_funcs.get(func_name)
297
+ if target is None:
298
+ for node in ast.walk(tree):
299
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == func_name:
300
+ target = node
301
+ break
302
+ if target is None:
303
+ return set()
304
+
305
+ literals: set[str] = set()
306
+ visited: set[str] = set()
307
+
308
+ def visit(fn_node: ast.AST, hops_left: int) -> None:
309
+ for node in ast.walk(fn_node):
310
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
311
+ if len(node.value.strip()) >= 6:
312
+ literals.add(node.value)
313
+ elif isinstance(node, ast.Name) and node.id in module_consts:
314
+ literals.add(module_consts[node.id])
315
+ elif isinstance(node, ast.Call) and hops_left > 0:
316
+ callee = _callee_name(node.func)
317
+ if callee and callee in module_funcs and callee not in visited:
318
+ visited.add(callee)
319
+ visit(module_funcs[callee], hops_left - 1)
320
+
321
+ visit(target, max_hops)
322
+ return literals
323
+
324
+
325
+ def _matches_site(request_body: dict, literals: set[str]) -> bool:
326
+ if not literals:
327
+ return False
328
+ messages = request_body.get("messages", [])
329
+ haystack = " ".join(
330
+ m.get("content", "") for m in messages if isinstance(m.get("content"), str)
331
+ )
332
+ haystack_n = " ".join(haystack.split())
333
+ considered = [" ".join(lit.split()) for lit in literals if len(lit.strip()) >= 6]
334
+ considered = [lit for lit in considered if lit]
335
+ if not considered:
336
+ return False
337
+ hits = sum(1 for lit in considered if lit in haystack_n)
338
+ return (hits / len(considered)) >= 0.34
339
+
340
+
341
+ # ---------------------------------------------------------------------------
342
+ # Example construction
343
+ # ---------------------------------------------------------------------------
344
+
345
+ def _capture_to_example(capture: _Capture, input_slots: dict[str, str]) -> Example:
346
+ request, response = capture.request, capture.response
347
+ messages = request.get("messages", []) or []
348
+ user_msg = ""
349
+ for m in reversed(messages):
350
+ if m.get("role") == "user" and isinstance(m.get("content"), str):
351
+ user_msg = m["content"]
352
+ break
353
+
354
+ completion = ""
355
+ choices = response.get("choices") or []
356
+ if choices:
357
+ msg = choices[0].get("message") or {}
358
+ completion = msg.get("content") or ""
359
+
360
+ usage = response.get("usage") or {}
361
+ if usage:
362
+ tokens_in = int(usage.get("prompt_tokens", 0) or 0)
363
+ tokens_out = int(usage.get("completion_tokens", 0) or 0)
364
+ else:
365
+ tokens_in = len(user_msg) // 4
366
+ tokens_out = len(completion) // 4
367
+
368
+ return Example(
369
+ input_slots=dict(input_slots),
370
+ prompt=user_msg,
371
+ completion=completion,
372
+ tokens_in=tokens_in,
373
+ tokens_out=tokens_out,
374
+ latency_ms=capture.latency_ms,
375
+ )
376
+
377
+
378
+ def _read_jsonl(path: str) -> list[dict]:
379
+ lines: list[dict] = []
380
+ with open(path, "r", encoding="utf-8") as f:
381
+ for raw_line in f:
382
+ raw_line = raw_line.strip()
383
+ if not raw_line:
384
+ continue
385
+ lines.append(json.loads(raw_line))
386
+ return lines
387
+
388
+
389
+ # ---------------------------------------------------------------------------
390
+ # Samples path
391
+ # ---------------------------------------------------------------------------
392
+
393
+ _RUNNER_SRC = """
394
+ import sys, json, importlib
395
+
396
+ repo_path, rel_file, symbol, kwargs_json = sys.argv[1:5]
397
+ sys.path.insert(0, repo_path)
398
+ module_path = rel_file[:-3] if rel_file.endswith(".py") else rel_file
399
+ module_name = module_path.replace("/", ".")
400
+ mod = importlib.import_module(module_name)
401
+ func_name = symbol.rstrip("()").split(".")[-1]
402
+ func = getattr(mod, func_name)
403
+ kwargs = json.loads(kwargs_json)
404
+ func(**kwargs)
405
+ sys.stdout.write("OK")
406
+ """
407
+
408
+
409
+ def _run_site_in_subprocess(site: RawSite, repo_path: str, input_slots: dict, env: dict) -> None:
410
+ rel_file = site.file.replace("\\", "/")
411
+ args = [sys.executable, "-c", _RUNNER_SRC, repo_path, rel_file, site.symbol, json.dumps(input_slots)]
412
+ # Generous default: the recording proxy may spend minutes in upstream
413
+ # backoff-retry (rate-limited stand-in oracles), and the subprocess must
414
+ # outlive that. Override via NABLA_SAMPLE_TIMEOUT (seconds).
415
+ timeout = int(os.environ.get("NABLA_SAMPLE_TIMEOUT", "300"))
416
+ proc = subprocess.run(args, cwd=repo_path, env=env, capture_output=True, text=True, timeout=timeout)
417
+ if proc.returncode != 0:
418
+ raise RuntimeError(
419
+ f"Sample invocation of {site.symbol!r} in {site.file!r} failed "
420
+ f"(exit {proc.returncode}): {proc.stderr.strip() or proc.stdout.strip()}"
421
+ )
422
+
423
+
424
+ def _record_samples(site: RawSite, source: RecordSource, repo_path: str) -> list[Example]:
425
+ if not source.samples_file:
426
+ raise ValueError("RecordSource.kind='samples' requires samples_file")
427
+ sample_lines = _read_jsonl(source.samples_file)
428
+ examples: list[Example] = []
429
+ if not sample_lines:
430
+ return examples
431
+
432
+ proxy = RecordingProxy()
433
+ proxy.start()
434
+ try:
435
+ env = os.environ.copy()
436
+ env["OPENAI_BASE_URL"] = proxy.base_url
437
+ env["OPENAI_API_KEY"] = env.get("OPENAI_API_KEY") or "sk-nabla-recorder-test"
438
+
439
+ # Optional pacing between samples (milliseconds) so rate-limited
440
+ # upstreams aren't hammered into 429 backoff on every call.
441
+ delay_ms = int(os.environ.get("NABLA_RECORD_DELAY_MS", "0"))
442
+ failures: list[tuple[int, str]] = []
443
+ total = len(sample_lines)
444
+
445
+ for i, line in enumerate(sample_lines):
446
+ input_slots = line.get("input_slots")
447
+ if input_slots is None:
448
+ raise ValueError(
449
+ f"samples_file line missing 'input_slots': {line!r} "
450
+ "(the samples path invokes the site's function with input_slots as kwargs)"
451
+ )
452
+ if delay_ms and i:
453
+ time.sleep(delay_ms / 1000)
454
+ with proxy._lock:
455
+ before = len(proxy.captured)
456
+ # One bad sample must not kill the harvest: record the failure,
457
+ # keep going, and fail hard only if every sample failed.
458
+ try:
459
+ _run_site_in_subprocess(site, repo_path, input_slots, env)
460
+ except (RuntimeError, subprocess.TimeoutExpired) as exc:
461
+ failures.append((i, str(exc)))
462
+ print(f"nabla record: sample {i + 1}/{total} FAILED: {str(exc)[:300]}",
463
+ file=sys.stderr)
464
+ continue
465
+ with proxy._lock:
466
+ new_captures = list(proxy.captured[before:])
467
+ for capture in new_captures:
468
+ examples.append(_capture_to_example(capture, input_slots))
469
+ print(f"nabla record: sample {i + 1}/{total} -> {len(new_captures)} capture(s)",
470
+ file=sys.stderr)
471
+ finally:
472
+ proxy.stop()
473
+
474
+ if failures and not examples:
475
+ raise RuntimeError(
476
+ f"all {len(failures)} sample invocations failed; first error: {failures[0][1]}"
477
+ )
478
+ if failures:
479
+ print(f"nabla record: {len(failures)}/{total} samples failed and were skipped",
480
+ file=sys.stderr)
481
+ return examples
482
+
483
+
484
+ # ---------------------------------------------------------------------------
485
+ # Proxy path
486
+ # ---------------------------------------------------------------------------
487
+
488
+ def _record_proxy(site: RawSite, source: RecordSource, repo_path: str) -> list[Example]:
489
+ if not source.proxy_command:
490
+ raise ValueError("RecordSource.kind='proxy' requires proxy_command")
491
+
492
+ proxy = RecordingProxy()
493
+ proxy.start()
494
+ try:
495
+ env = os.environ.copy()
496
+ env["OPENAI_BASE_URL"] = proxy.base_url
497
+ env["OPENAI_API_KEY"] = env.get("OPENAI_API_KEY") or "sk-nabla-recorder-test"
498
+ subprocess.run(source.proxy_command, cwd=repo_path, env=env, capture_output=True, text=True, timeout=300)
499
+ finally:
500
+ proxy.stop()
501
+
502
+ command_str = " ".join(source.proxy_command)
503
+
504
+ if not proxy.captured:
505
+ raise ZeroTrafficError(
506
+ f"Recording proxy observed zero OpenAI requests while running `{command_str}`. "
507
+ "The target's tests likely mock the OpenAI SDK client directly (e.g. via "
508
+ "unittest.mock.patch on client.chat.completions.create), so no HTTP traffic ever "
509
+ "reaches OPENAI_BASE_URL. Use the 'samples' or 'logs' RecordSource path instead."
510
+ )
511
+
512
+ literals = _extract_site_literals(str(Path(repo_path) / site.file), site.symbol)
513
+ matched = [c for c in proxy.captured if _matches_site(c.request, literals)]
514
+
515
+ if not matched:
516
+ raise ZeroTrafficError(
517
+ f"Recording proxy observed {len(proxy.captured)} OpenAI request(s) while running "
518
+ f"`{command_str}`, but none were attributable to site {site.symbol!r} in {site.file!r}. "
519
+ "The target's tests likely mock the OpenAI SDK for this site, so no attributable "
520
+ "traffic reaches OPENAI_BASE_URL. Use the 'samples' or 'logs' RecordSource path instead."
521
+ )
522
+
523
+ return [_capture_to_example(c, input_slots={}) for c in matched]
524
+
525
+
526
+ # ---------------------------------------------------------------------------
527
+ # Logs path
528
+ # ---------------------------------------------------------------------------
529
+
530
+ def _record_logs(site: RawSite, source: RecordSource, repo_path: str) -> list[Example]:
531
+ if not source.logs_file:
532
+ raise ValueError("RecordSource.kind='logs' requires logs_file")
533
+ examples: list[Example] = []
534
+ for line in _read_jsonl(source.logs_file):
535
+ request = line.get("request", {}) or {}
536
+ response = line.get("response", {}) or {}
537
+ latency_ms = int(line.get("latency_ms", 0) or 0)
538
+ capture = _Capture(request=request, response=response, latency_ms=latency_ms)
539
+ examples.append(_capture_to_example(capture, input_slots={}))
540
+ return examples
541
+
542
+
543
+ # ---------------------------------------------------------------------------
544
+ # Public interface
545
+ # ---------------------------------------------------------------------------
546
+
547
+ def record(site: RawSite, source: RecordSource, repo_path: str) -> list[Example]:
548
+ """Capture real (prompt, completion, tokens, latency) pairs for site.
549
+
550
+ Must: attribute captured requests back to the site (template match),
551
+ populate token counts and latency, raise ZeroTrafficError on an empty
552
+ proxy run instead of returning [].
553
+ """
554
+ if source.kind == "samples":
555
+ return _record_samples(site, source, repo_path)
556
+ if source.kind == "proxy":
557
+ return _record_proxy(site, source, repo_path)
558
+ if source.kind == "logs":
559
+ return _record_logs(site, source, repo_path)
560
+ raise ValueError(f"unknown RecordSource.kind: {source.kind!r}")
561
+
562
+
563
+ # ---------------------------------------------------------------------------
564
+ # Baseline stats
565
+ # ---------------------------------------------------------------------------
566
+
567
+ # Pricing as of 2026-07-18, USD per 1,000,000 tokens (input, output).
568
+ # Approximate — confirm against platform.openai.com/pricing before relying
569
+ # on this for real billing decisions; models not listed fall back to
570
+ # cost 0.0 with pricing_unknown=True rather than a guess.
571
+ _PRICING_PER_1M_USD: dict[str, tuple[float, float]] = {
572
+ "gpt-4o": (2.50, 10.00),
573
+ "gpt-4o-2024-08-06": (2.50, 10.00),
574
+ "gpt-4o-mini": (0.15, 0.60),
575
+ "gpt-4o-mini-2024-07-18": (0.15, 0.60),
576
+ "gpt-4.1": (2.00, 8.00),
577
+ "gpt-4.1-mini": (0.40, 1.60),
578
+ "gpt-4.1-nano": (0.10, 0.40),
579
+ "gpt-3.5-turbo": (0.50, 1.50),
580
+ "o1": (15.00, 60.00),
581
+ "o1-mini": (1.10, 4.40),
582
+ "o3-mini": (1.10, 4.40),
583
+ }
584
+
585
+
586
+ def baseline_stats(examples: list[Example], model: str) -> dict:
587
+ """Cost/latency baseline from observed traffic and the pinned pricing
588
+ table (hardcode current OpenAI prices with a date stamp)."""
589
+ calls_observed = len(examples)
590
+ if calls_observed == 0:
591
+ return {"cost_per_call_usd": 0.0, "p50_latency_ms": 0, "calls_observed": 0}
592
+
593
+ pricing = _PRICING_PER_1M_USD.get(model)
594
+ result: dict = {}
595
+ if pricing is None:
596
+ avg_cost = 0.0
597
+ result["pricing_unknown"] = True
598
+ else:
599
+ in_price, out_price = pricing
600
+ costs = [
601
+ (e.tokens_in / 1_000_000) * in_price + (e.tokens_out / 1_000_000) * out_price
602
+ for e in examples
603
+ ]
604
+ avg_cost = sum(costs) / len(costs)
605
+
606
+ result["cost_per_call_usd"] = round(avg_cost, 6)
607
+ result["p50_latency_ms"] = int(round(statistics.median(e.latency_ms for e in examples)))
608
+ result["calls_observed"] = calls_observed
609
+ return result