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/__init__.py +3 -0
- nabla/cli.py +121 -0
- nabla/contract.py +132 -0
- nabla/induce.py +176 -0
- nabla/profile.py +138 -0
- nabla/prompt_recon.py +542 -0
- nabla/recorder.py +609 -0
- nabla/scanner.py +453 -0
- nabla/schema_resolver.py +309 -0
- nabla/schemas/site_profile.schema.json +136 -0
- nabla_cli-0.1.0.dist-info/METADATA +82 -0
- nabla_cli-0.1.0.dist-info/RECORD +15 -0
- nabla_cli-0.1.0.dist-info/WHEEL +5 -0
- nabla_cli-0.1.0.dist-info/entry_points.txt +2 -0
- nabla_cli-0.1.0.dist-info/top_level.txt +1 -0
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
|