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/schema_resolver.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""T3 — output-schema resolution + verifiability classing. See cli-plan.md T3.
|
|
2
|
+
|
|
3
|
+
Re-parses source from raw_site.file/span; never imports scanner or
|
|
4
|
+
prompt_recon. The only dynamic import this module performs is of the
|
|
5
|
+
target repo's own module, and only to resolve a Pydantic model's
|
|
6
|
+
`.model_json_schema()` for the `.parse(response_format=Model)` shape —
|
|
7
|
+
that import is fully guarded and degrades to (None, PYDANTIC_PARSE).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
import importlib
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Callable
|
|
17
|
+
|
|
18
|
+
from .contract import RawSite, RawSiteRef, Verifiability
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def resolve_schema(raw_site: RawSite, repo_path: str) -> tuple[dict[str, Any] | None, Verifiability]:
|
|
22
|
+
"""Normalize the site's output declaration to one JSON Schema.
|
|
23
|
+
|
|
24
|
+
Handles: response_format json_schema (extract + inline $defs/$refs),
|
|
25
|
+
Pydantic model in .parse() (guarded dynamic import -> model_json_schema()),
|
|
26
|
+
tools function schemas, json_object (None, JSON_OBJECT_NO_SCHEMA),
|
|
27
|
+
nothing (None, FREE_TEXT), and wrapper-indirect sites (resolve the
|
|
28
|
+
caller's own schema argument back to a module constant).
|
|
29
|
+
"""
|
|
30
|
+
repo = Path(repo_path)
|
|
31
|
+
|
|
32
|
+
if raw_site.wrapper is not None:
|
|
33
|
+
return _resolve_wrapper_site(raw_site, repo)
|
|
34
|
+
|
|
35
|
+
tree = _parse_file(repo, raw_site.file)
|
|
36
|
+
if tree is None:
|
|
37
|
+
return None, Verifiability.FREE_TEXT
|
|
38
|
+
|
|
39
|
+
call_node = _find_call_by_span(tree, raw_site.span, predicate=_is_openai_call)
|
|
40
|
+
if call_node is None:
|
|
41
|
+
return None, Verifiability.FREE_TEXT
|
|
42
|
+
|
|
43
|
+
module_consts = _module_level_assignments(tree)
|
|
44
|
+
|
|
45
|
+
tools_node = _get_kw(call_node, "tools")
|
|
46
|
+
if tools_node is not None:
|
|
47
|
+
return _resolve_tools(tools_node, module_consts)
|
|
48
|
+
|
|
49
|
+
rf_node = _get_kw(call_node, "response_format")
|
|
50
|
+
if rf_node is not None:
|
|
51
|
+
if isinstance(rf_node, ast.Dict):
|
|
52
|
+
try:
|
|
53
|
+
value = _eval_node(rf_node, module_consts)
|
|
54
|
+
except ValueError:
|
|
55
|
+
value = None
|
|
56
|
+
if isinstance(value, dict):
|
|
57
|
+
rf_type = value.get("type")
|
|
58
|
+
if rf_type == "json_schema":
|
|
59
|
+
schema = (value.get("json_schema") or {}).get("schema")
|
|
60
|
+
if isinstance(schema, dict):
|
|
61
|
+
return _normalize_schema(schema), Verifiability.JSON_SCHEMA
|
|
62
|
+
return None, Verifiability.JSON_SCHEMA
|
|
63
|
+
if rf_type == "json_object":
|
|
64
|
+
return None, Verifiability.JSON_OBJECT_NO_SCHEMA
|
|
65
|
+
else:
|
|
66
|
+
# A Name/Attribute referencing a Pydantic model class, e.g.
|
|
67
|
+
# response_format=Order.
|
|
68
|
+
schema = _resolve_pydantic_model(rf_node, raw_site, repo)
|
|
69
|
+
return schema, Verifiability.PYDANTIC_PARSE
|
|
70
|
+
|
|
71
|
+
return None, Verifiability.FREE_TEXT
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# --------------------------------------------------------------------------
|
|
75
|
+
# Wrapper-indirect sites: schema arrives as an argument to the wrapper call.
|
|
76
|
+
# --------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def _resolve_wrapper_site(raw_site: RawSite, repo: Path) -> tuple[dict[str, Any] | None, Verifiability]:
|
|
79
|
+
tree = _parse_file(repo, raw_site.file)
|
|
80
|
+
if tree is None:
|
|
81
|
+
return None, Verifiability.FREE_TEXT
|
|
82
|
+
|
|
83
|
+
call_node = _find_call_by_span(tree, raw_site.span, predicate=None)
|
|
84
|
+
if call_node is None:
|
|
85
|
+
return None, Verifiability.FREE_TEXT
|
|
86
|
+
|
|
87
|
+
module_consts = _module_level_assignments(tree)
|
|
88
|
+
|
|
89
|
+
schema_node = _get_kw(call_node, "schema")
|
|
90
|
+
if schema_node is None and call_node.args:
|
|
91
|
+
schema_node = call_node.args[-1]
|
|
92
|
+
if schema_node is None:
|
|
93
|
+
return None, Verifiability.JSON_SCHEMA
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
value = _eval_node(schema_node, module_consts)
|
|
97
|
+
except ValueError:
|
|
98
|
+
return None, Verifiability.JSON_SCHEMA
|
|
99
|
+
|
|
100
|
+
if isinstance(value, dict):
|
|
101
|
+
return _normalize_schema(value), Verifiability.JSON_SCHEMA
|
|
102
|
+
return None, Verifiability.JSON_SCHEMA
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# --------------------------------------------------------------------------
|
|
106
|
+
# AST helpers
|
|
107
|
+
# --------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def _parse_file(repo: Path, rel_file: str) -> ast.Module | None:
|
|
110
|
+
path = repo / rel_file
|
|
111
|
+
try:
|
|
112
|
+
source = path.read_text(encoding="utf-8")
|
|
113
|
+
except OSError:
|
|
114
|
+
return None
|
|
115
|
+
try:
|
|
116
|
+
return ast.parse(source, filename=str(path))
|
|
117
|
+
except SyntaxError:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _is_openai_call(node: ast.Call) -> bool:
|
|
122
|
+
return isinstance(node.func, ast.Attribute) and node.func.attr in ("create", "parse")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _find_call_by_span(
|
|
126
|
+
tree: ast.AST, span: tuple[int, int], predicate: Callable[[ast.Call], bool] | None
|
|
127
|
+
) -> ast.Call | None:
|
|
128
|
+
start, end = span
|
|
129
|
+
matches = [
|
|
130
|
+
node
|
|
131
|
+
for node in ast.walk(tree)
|
|
132
|
+
if isinstance(node, ast.Call)
|
|
133
|
+
and node.lineno == start
|
|
134
|
+
and getattr(node, "end_lineno", node.lineno) == end
|
|
135
|
+
]
|
|
136
|
+
if predicate is not None:
|
|
137
|
+
filtered = [n for n in matches if predicate(n)]
|
|
138
|
+
if filtered:
|
|
139
|
+
matches = filtered
|
|
140
|
+
return matches[0] if matches else None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _get_kw(call: ast.Call, name: str) -> ast.AST | None:
|
|
144
|
+
for kw in call.keywords:
|
|
145
|
+
if kw.arg == name:
|
|
146
|
+
return kw.value
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _module_level_assignments(tree: ast.Module) -> dict[str, ast.AST]:
|
|
151
|
+
consts: dict[str, ast.AST] = {}
|
|
152
|
+
for node in tree.body:
|
|
153
|
+
if isinstance(node, ast.Assign):
|
|
154
|
+
for target in node.targets:
|
|
155
|
+
if isinstance(target, ast.Name):
|
|
156
|
+
consts[target.id] = node.value
|
|
157
|
+
elif isinstance(node, ast.AnnAssign) and node.value is not None and isinstance(node.target, ast.Name):
|
|
158
|
+
consts[node.target.id] = node.value
|
|
159
|
+
return consts
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _eval_node(node: ast.AST, consts: dict[str, ast.AST], _stack: frozenset[str] = frozenset()) -> Any:
|
|
163
|
+
"""Restricted static evaluator: literals, dict/list/tuple literals, and
|
|
164
|
+
Name references resolved against module-level constants. Never executes
|
|
165
|
+
arbitrary code."""
|
|
166
|
+
if isinstance(node, ast.Constant):
|
|
167
|
+
return node.value
|
|
168
|
+
if isinstance(node, ast.Dict):
|
|
169
|
+
result: dict[Any, Any] = {}
|
|
170
|
+
for k, v in zip(node.keys, node.values):
|
|
171
|
+
if k is None: # dict unpacking (**x) — unsupported, bail
|
|
172
|
+
raise ValueError("dict unpacking not supported")
|
|
173
|
+
result[_eval_node(k, consts, _stack)] = _eval_node(v, consts, _stack)
|
|
174
|
+
return result
|
|
175
|
+
if isinstance(node, (ast.List, ast.Tuple)):
|
|
176
|
+
return [_eval_node(elt, consts, _stack) for elt in node.elts]
|
|
177
|
+
if isinstance(node, ast.Name):
|
|
178
|
+
if node.id in _stack:
|
|
179
|
+
raise ValueError(f"circular reference resolving {node.id!r}")
|
|
180
|
+
if node.id not in consts:
|
|
181
|
+
raise ValueError(f"unresolved name {node.id!r}")
|
|
182
|
+
return _eval_node(consts[node.id], consts, _stack | {node.id})
|
|
183
|
+
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
|
|
184
|
+
return -_eval_node(node.operand, consts, _stack)
|
|
185
|
+
raise ValueError(f"cannot statically evaluate {ast.dump(node)}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# --------------------------------------------------------------------------
|
|
189
|
+
# tools=[...] resolution
|
|
190
|
+
# --------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def _resolve_tools(
|
|
193
|
+
tools_node: ast.AST, consts: dict[str, ast.AST]
|
|
194
|
+
) -> tuple[dict[str, Any] | None, Verifiability]:
|
|
195
|
+
try:
|
|
196
|
+
tools = _eval_node(tools_node, consts)
|
|
197
|
+
except ValueError:
|
|
198
|
+
return None, Verifiability.TOOL_CALL
|
|
199
|
+
if not isinstance(tools, list) or not tools:
|
|
200
|
+
return None, Verifiability.TOOL_CALL
|
|
201
|
+
|
|
202
|
+
if len(tools) == 1:
|
|
203
|
+
params = _tool_parameters(tools[0])
|
|
204
|
+
if isinstance(params, dict):
|
|
205
|
+
return _normalize_schema(params), Verifiability.TOOL_CALL
|
|
206
|
+
return None, Verifiability.TOOL_CALL
|
|
207
|
+
|
|
208
|
+
schemas: dict[str, Any] = {}
|
|
209
|
+
for tool in tools:
|
|
210
|
+
fn = tool.get("function", {}) if isinstance(tool, dict) else {}
|
|
211
|
+
name = fn.get("name")
|
|
212
|
+
params = fn.get("parameters")
|
|
213
|
+
if name and isinstance(params, dict):
|
|
214
|
+
schemas[name] = _normalize_schema(params)
|
|
215
|
+
return (schemas or None), Verifiability.TOOL_CALL
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _tool_parameters(tool: Any) -> dict[str, Any] | None:
|
|
219
|
+
if not isinstance(tool, dict):
|
|
220
|
+
return None
|
|
221
|
+
fn = tool.get("function", {})
|
|
222
|
+
if not isinstance(fn, dict):
|
|
223
|
+
return None
|
|
224
|
+
params = fn.get("parameters")
|
|
225
|
+
return params if isinstance(params, dict) else None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# --------------------------------------------------------------------------
|
|
229
|
+
# Pydantic .parse(response_format=Model) resolution — guarded dynamic import
|
|
230
|
+
# --------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def _resolve_pydantic_model(node: ast.AST, raw_site: RawSite, repo: Path) -> dict[str, Any] | None:
|
|
233
|
+
class_name: str | None = None
|
|
234
|
+
if isinstance(node, ast.Name):
|
|
235
|
+
class_name = node.id
|
|
236
|
+
elif isinstance(node, ast.Attribute):
|
|
237
|
+
class_name = node.attr
|
|
238
|
+
|
|
239
|
+
if class_name is None:
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
module_dotted = _dotted_module(raw_site.file)
|
|
243
|
+
if module_dotted is None:
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
repo_str = str(repo.resolve())
|
|
247
|
+
added = False
|
|
248
|
+
try:
|
|
249
|
+
if repo_str not in sys.path:
|
|
250
|
+
sys.path.insert(0, repo_str)
|
|
251
|
+
added = True
|
|
252
|
+
try:
|
|
253
|
+
module = importlib.import_module(module_dotted)
|
|
254
|
+
model_cls = getattr(module, class_name)
|
|
255
|
+
schema = model_cls.model_json_schema()
|
|
256
|
+
finally:
|
|
257
|
+
if added:
|
|
258
|
+
try:
|
|
259
|
+
sys.path.remove(repo_str)
|
|
260
|
+
except ValueError:
|
|
261
|
+
pass
|
|
262
|
+
except Exception:
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
if not isinstance(schema, dict):
|
|
266
|
+
return None
|
|
267
|
+
return _normalize_schema(schema)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _dotted_module(rel_file: str) -> str | None:
|
|
271
|
+
p = Path(rel_file)
|
|
272
|
+
if p.suffix != ".py":
|
|
273
|
+
return None
|
|
274
|
+
parts = list(p.with_suffix("").parts)
|
|
275
|
+
if parts and parts[-1] == "__init__":
|
|
276
|
+
parts = parts[:-1]
|
|
277
|
+
return ".".join(parts) if parts else None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# --------------------------------------------------------------------------
|
|
281
|
+
# Schema normalization: inline $defs/$refs so the schema is self-contained.
|
|
282
|
+
# --------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
def _normalize_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
|
285
|
+
defs = schema.get("$defs", {}) if isinstance(schema.get("$defs"), dict) else {}
|
|
286
|
+
|
|
287
|
+
def _inline(node: Any, seen: frozenset[str]) -> Any:
|
|
288
|
+
if isinstance(node, dict):
|
|
289
|
+
if "$ref" in node and isinstance(node["$ref"], str) and node["$ref"].startswith("#/$defs/"):
|
|
290
|
+
ref_name = node["$ref"].split("/")[-1]
|
|
291
|
+
if ref_name in seen or ref_name not in defs:
|
|
292
|
+
# Cycle or dangling ref: leave as-is rather than recurse forever.
|
|
293
|
+
return {k: v for k, v in node.items()}
|
|
294
|
+
resolved = _inline(defs[ref_name], seen | {ref_name})
|
|
295
|
+
merged = dict(resolved) if isinstance(resolved, dict) else {}
|
|
296
|
+
for k, v in node.items():
|
|
297
|
+
if k != "$ref":
|
|
298
|
+
merged[k] = _inline(v, seen)
|
|
299
|
+
return merged
|
|
300
|
+
return {k: _inline(v, seen) for k, v in node.items() if k != "$defs"}
|
|
301
|
+
if isinstance(node, list):
|
|
302
|
+
return [_inline(item, seen) for item in node]
|
|
303
|
+
return node
|
|
304
|
+
|
|
305
|
+
result = _inline(schema, frozenset())
|
|
306
|
+
if isinstance(result, dict):
|
|
307
|
+
result.pop("$defs", None)
|
|
308
|
+
return result
|
|
309
|
+
return schema
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "nabla/site_profile/0.1",
|
|
4
|
+
"title": "Nabla site profile — the Phase 0 handoff artifact",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["profile_version", "site_id", "source", "classification", "prompt",
|
|
8
|
+
"output_schema", "sampling", "examples", "goal", "baseline", "provenance"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"profile_version": { "const": "0.1" },
|
|
11
|
+
"site_id": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
|
|
12
|
+
"source": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"additionalProperties": false,
|
|
15
|
+
"required": ["file", "span", "symbol"],
|
|
16
|
+
"properties": {
|
|
17
|
+
"file": { "type": "string" },
|
|
18
|
+
"span": { "type": "array", "items": { "type": "integer", "minimum": 1 }, "minItems": 2, "maxItems": 2 },
|
|
19
|
+
"symbol": { "type": "string" }
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"classification": {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"additionalProperties": false,
|
|
25
|
+
"required": ["supported", "verifiability", "flags"],
|
|
26
|
+
"properties": {
|
|
27
|
+
"supported": { "type": "boolean" },
|
|
28
|
+
"verifiability": { "enum": ["json_schema", "pydantic_parse", "tool_call", "json_object_no_schema", "free_text"] },
|
|
29
|
+
"flags": {
|
|
30
|
+
"type": "array",
|
|
31
|
+
"items": { "enum": ["streaming", "async", "multi_turn", "vision", "n_gt_1", "wrapper_indirect"] }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"prompt": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"additionalProperties": false,
|
|
38
|
+
"required": ["template", "slots"],
|
|
39
|
+
"properties": {
|
|
40
|
+
"template": { "type": "string" },
|
|
41
|
+
"partial": { "type": "boolean" },
|
|
42
|
+
"slots": {
|
|
43
|
+
"type": "array",
|
|
44
|
+
"items": {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"additionalProperties": false,
|
|
47
|
+
"required": ["name"],
|
|
48
|
+
"properties": {
|
|
49
|
+
"name": { "type": "string" },
|
|
50
|
+
"observed_values": { "type": "array", "items": { "type": "string" } },
|
|
51
|
+
"inferred_type": { "type": "string" }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"system_prompt": { "type": ["string", "null"] },
|
|
56
|
+
"embedded_few_shot": {
|
|
57
|
+
"type": "array",
|
|
58
|
+
"items": { "type": "object" }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"output_schema": { "type": ["object", "null"] },
|
|
63
|
+
"sampling": {
|
|
64
|
+
"type": "object",
|
|
65
|
+
"additionalProperties": false,
|
|
66
|
+
"required": ["model"],
|
|
67
|
+
"properties": {
|
|
68
|
+
"model": { "type": "string" },
|
|
69
|
+
"temperature": { "type": ["number", "null"] },
|
|
70
|
+
"max_tokens": { "type": ["integer", "null"] },
|
|
71
|
+
"top_p": { "type": ["number", "null"] },
|
|
72
|
+
"stop": { "type": ["array", "string", "null"] }
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
"examples": {
|
|
76
|
+
"type": "array",
|
|
77
|
+
"items": {
|
|
78
|
+
"type": "object",
|
|
79
|
+
"additionalProperties": false,
|
|
80
|
+
"required": ["input_slots", "prompt", "completion", "tokens", "latency_ms"],
|
|
81
|
+
"properties": {
|
|
82
|
+
"input_slots": { "type": "object" },
|
|
83
|
+
"prompt": { "type": "string" },
|
|
84
|
+
"completion": { "type": "string" },
|
|
85
|
+
"tokens": {
|
|
86
|
+
"type": "object",
|
|
87
|
+
"additionalProperties": false,
|
|
88
|
+
"required": ["in", "out"],
|
|
89
|
+
"properties": { "in": { "type": "integer" }, "out": { "type": "integer" } }
|
|
90
|
+
},
|
|
91
|
+
"latency_ms": { "type": "integer" }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
"goal": {
|
|
96
|
+
"type": "object",
|
|
97
|
+
"additionalProperties": false,
|
|
98
|
+
"required": ["description", "constraints", "difficulty_dimensions"],
|
|
99
|
+
"properties": {
|
|
100
|
+
"description": { "type": "string", "minLength": 1 },
|
|
101
|
+
"constraints": { "type": "array", "items": { "type": "string" } },
|
|
102
|
+
"difficulty_dimensions": {
|
|
103
|
+
"type": "array",
|
|
104
|
+
"items": {
|
|
105
|
+
"type": "object",
|
|
106
|
+
"additionalProperties": false,
|
|
107
|
+
"required": ["name", "levels", "evidence"],
|
|
108
|
+
"properties": {
|
|
109
|
+
"name": { "type": "string" },
|
|
110
|
+
"levels": { "type": "array", "items": { "type": "string" }, "minItems": 2 },
|
|
111
|
+
"evidence": { "type": "string" }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
"baseline": {
|
|
118
|
+
"type": "object",
|
|
119
|
+
"additionalProperties": false,
|
|
120
|
+
"required": ["cost_per_call_usd", "p50_latency_ms", "calls_observed"],
|
|
121
|
+
"properties": {
|
|
122
|
+
"cost_per_call_usd": { "type": "number", "minimum": 0 },
|
|
123
|
+
"p50_latency_ms": { "type": "integer", "minimum": 0 },
|
|
124
|
+
"calls_observed": { "type": "integer", "minimum": 0 }
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
"provenance": {
|
|
128
|
+
"type": "object",
|
|
129
|
+
"additionalProperties": false,
|
|
130
|
+
"required": ["recorded_from"],
|
|
131
|
+
"properties": {
|
|
132
|
+
"recorded_from": { "enum": ["proxy", "samples", "logs", "none"] }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nabla-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Call-site harvester: scan OpenAI call sites, record real traffic, emit site profiles
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: jsonschema
|
|
9
|
+
Requires-Dist: pydantic>=2
|
|
10
|
+
Requires-Dist: openai>=1.40
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest; extra == "dev"
|
|
13
|
+
|
|
14
|
+
# nabla — OpenAI call-site harvester
|
|
15
|
+
|
|
16
|
+
`nabla` finds the OpenAI call sites in a Python repo, records real
|
|
17
|
+
(prompt, completion) traffic for one of them, and emits a single
|
|
18
|
+
self-contained **site profile** (`site_profile.json`) describing that
|
|
19
|
+
call site: prompt template + slots, resolved output JSON Schema,
|
|
20
|
+
sampling parameters, recorded examples, an inferred goal with difficulty
|
|
21
|
+
dimensions, and a cost/latency baseline.
|
|
22
|
+
|
|
23
|
+
The profile is the handoff artifact for training a small, cheap
|
|
24
|
+
replacement model for that one call site — everything downstream codes
|
|
25
|
+
against the profile, never against your repo.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install nabla-cli
|
|
31
|
+
# or, isolated:
|
|
32
|
+
pipx install nabla-cli
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Requires Python ≥ 3.11.
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# 1. Find call sites (pure static analysis — no network, no API key)
|
|
41
|
+
nabla scan path/to/repo
|
|
42
|
+
|
|
43
|
+
# 2. Record real traffic for one site, driven by sample inputs
|
|
44
|
+
# (JSONL lines of {"input_slots": {...}} matching the site's function kwargs)
|
|
45
|
+
nabla record path/to/repo --site my_function --samples samples.jsonl --out recorded.jsonl
|
|
46
|
+
|
|
47
|
+
# 3. Assemble + validate the profile (one LLM call for goal inference)
|
|
48
|
+
nabla profile path/to/repo --site my_function --examples recorded.jsonl --out site_profile.json
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## What gets detected
|
|
52
|
+
|
|
53
|
+
`scan` classifies every `chat.completions.create` / `beta...parse` call
|
|
54
|
+
by verifiability: `json_schema` and `pydantic_parse` sites are supported
|
|
55
|
+
harvest targets; `tool_call`, `json_object_no_schema`, and `free_text`
|
|
56
|
+
sites are detected and reported but not harvested. Streaming,
|
|
57
|
+
multi-turn, vision, and `n>1` sites are flagged unsupported. Generic
|
|
58
|
+
wrapper functions are expanded to their callers; functions that own
|
|
59
|
+
their prompt template are kept as the site.
|
|
60
|
+
|
|
61
|
+
## Environment variables
|
|
62
|
+
|
|
63
|
+
| Variable | Purpose |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `OPENAI_API_KEY` | Default upstream for `record` (auth passthrough). |
|
|
66
|
+
| `NABLA_UPSTREAM_BASE_URL` | Record through any OpenAI-compatible stand-in oracle instead. |
|
|
67
|
+
| `NABLA_UPSTREAM_API_KEY` | Key for the stand-in oracle. |
|
|
68
|
+
| `NABLA_UPSTREAM_MODEL` | Rewrite the model for the stand-in only (the target repo's own model still lands in the profile). |
|
|
69
|
+
| `NABLA_RECORD_DELAY_MS` | Pacing between samples for rate-limited upstreams (default 0). |
|
|
70
|
+
| `NABLA_SAMPLE_TIMEOUT` | Per-sample subprocess timeout in seconds (default 300). |
|
|
71
|
+
| `GEMINI_API_KEY` | Key for the default goal-inference provider (Gemini free tier). |
|
|
72
|
+
| `NABLA_INDUCE_BASE_URL` / `NABLA_INDUCE_API_KEY_ENV` / `NABLA_INDUCE_MODEL` | Swap the goal-inference provider/model. |
|
|
73
|
+
|
|
74
|
+
## Notes
|
|
75
|
+
|
|
76
|
+
- `record` runs the site's enclosing function in a subprocess per sample,
|
|
77
|
+
with `OPENAI_BASE_URL` pointed at a local recording proxy. If the
|
|
78
|
+
target's tests mock the SDK, the proxy path reports zero traffic
|
|
79
|
+
explicitly instead of writing an empty profile.
|
|
80
|
+
- Recorded prompts/completions are real data from your repo and leave
|
|
81
|
+
the machine only via the goal-inference call. Review before sharing
|
|
82
|
+
profiles.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
nabla/__init__.py,sha256=9PXtjQRdKH4vIYDFe6pVoZDFWt_b826GKWZcZhF463U,78
|
|
2
|
+
nabla/cli.py,sha256=oD-brSXwHKlSGoZaMrzFPYUVxjEC8eI6Rr2jrALyzYY,4829
|
|
3
|
+
nabla/contract.py,sha256=2BV0aF1PRG607uVX93snIkGUjUG_VBPWzObHWV7Gt6Q,4785
|
|
4
|
+
nabla/induce.py,sha256=OHrxNPeMmOjR_FIEu9_f-FT2KSPMSLb4qHkzXAx8GMY,7692
|
|
5
|
+
nabla/profile.py,sha256=QLoRqGUHdLyOTNPMCGOo7X8UtVCW1LPPDwC8VgrQg2U,5379
|
|
6
|
+
nabla/prompt_recon.py,sha256=ikFsUoyImJw3K8OVdX9VkKfs5qRE85pEZ9io8pKgsuw,20442
|
|
7
|
+
nabla/recorder.py,sha256=Y9zy9MnwYRUhghrIl5UdYavITmL8d6BGivwyEurhXAw,24746
|
|
8
|
+
nabla/scanner.py,sha256=kqk-3UePafLxKdMWYB2VeYZMUrog0Vexh-OwHd8Zw-Y,16956
|
|
9
|
+
nabla/schema_resolver.py,sha256=6IBHi1sTm8oa-4pYtEPiAR9PDWYW_muCJqth_Gy7ynI,11509
|
|
10
|
+
nabla/schemas/site_profile.schema.json,sha256=1nhZty8GcJePhFFR45JzzbCX9ZEJ33ZPwBSWt391Q6c,4727
|
|
11
|
+
nabla_cli-0.1.0.dist-info/METADATA,sha256=UX1jYnRBOboWfHtdNj8Z2jQuQRfFmiGos30ve-fEw-8,3396
|
|
12
|
+
nabla_cli-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
13
|
+
nabla_cli-0.1.0.dist-info/entry_points.txt,sha256=I48El-2J50j-3S97L11RWrgdfOLhwRAv_NWSDD1h2IA,41
|
|
14
|
+
nabla_cli-0.1.0.dist-info/top_level.txt,sha256=dhw4W9HA8wn3H0DYFF4Pb9S2DPbnqFF1cS7DdHcIdhs,6
|
|
15
|
+
nabla_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nabla
|