querypilot 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. querypilot/__init__.py +36 -0
  2. querypilot/access/__init__.py +3 -0
  3. querypilot/access/policy.py +32 -0
  4. querypilot/adapters/__init__.py +1 -0
  5. querypilot/adapters/anthropic.py +49 -0
  6. querypilot/adapters/openai.py +53 -0
  7. querypilot/audit/__init__.py +10 -0
  8. querypilot/audit/sinks.py +46 -0
  9. querypilot/audit/types.py +53 -0
  10. querypilot/cli.py +401 -0
  11. querypilot/connectors/__init__.py +5 -0
  12. querypilot/connectors/base.py +24 -0
  13. querypilot/connectors/postgres.py +48 -0
  14. querypilot/connectors/sqlite.py +49 -0
  15. querypilot/core/__init__.py +1 -0
  16. querypilot/core/client.py +286 -0
  17. querypilot/core/config.py +29 -0
  18. querypilot/core/safety_defaults.py +12 -0
  19. querypilot/core/types.py +89 -0
  20. querypilot/evals/__init__.py +81 -0
  21. querypilot/evals/check.py +222 -0
  22. querypilot/evals/compare.py +286 -0
  23. querypilot/evals/cost.py +202 -0
  24. querypilot/evals/factory.py +83 -0
  25. querypilot/evals/init.py +104 -0
  26. querypilot/evals/loader.py +179 -0
  27. querypilot/evals/pipeline.py +484 -0
  28. querypilot/evals/replay.py +217 -0
  29. querypilot/evals/report.py +246 -0
  30. querypilot/evals/suite.py +91 -0
  31. querypilot/evals/suite_runner.py +361 -0
  32. querypilot/execution/__init__.py +1 -0
  33. querypilot/execution/formatter.py +11 -0
  34. querypilot/execution/runner.py +13 -0
  35. querypilot/generation/__init__.py +9 -0
  36. querypilot/generation/llm.py +180 -0
  37. querypilot/generation/prompt_builder.py +49 -0
  38. querypilot/generation/sql_generator.py +82 -0
  39. querypilot/mcp/__init__.py +3 -0
  40. querypilot/mcp/server.py +51 -0
  41. querypilot/py.typed +0 -0
  42. querypilot/schema/__init__.py +3 -0
  43. querypilot/schema/introspector.py +8 -0
  44. querypilot/schema/search.py +33 -0
  45. querypilot/server/__init__.py +3 -0
  46. querypilot/server/app.py +157 -0
  47. querypilot/validation/__init__.py +3 -0
  48. querypilot/validation/policies.py +3 -0
  49. querypilot/validation/validator.py +592 -0
  50. querypilot-0.1.0.dist-info/METADATA +601 -0
  51. querypilot-0.1.0.dist-info/RECORD +54 -0
  52. querypilot-0.1.0.dist-info/WHEEL +4 -0
  53. querypilot-0.1.0.dist-info/entry_points.txt +2 -0
  54. querypilot-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,222 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ from querypilot.evals.suite_runner import SuiteReport
9
+
10
+
11
+ class CaseRegression(BaseModel):
12
+ id: str
13
+ was_passing: bool
14
+ now_passing: bool
15
+ previous_failure_category: str | None = None
16
+ current_failure_category: str | None = None
17
+
18
+
19
+ class CheckSummary(BaseModel):
20
+ pass_rate: float
21
+ safety_pass_rate: float
22
+ correctness_rate: float
23
+ p95_latency_ms: int
24
+ estimated_cost_usd: float
25
+ total_cases: int
26
+ passed: int
27
+ failed: int
28
+
29
+
30
+ class CheckOutcome(BaseModel):
31
+ ok: bool
32
+ reasons: list[str] = Field(default_factory=list)
33
+ current: CheckSummary
34
+ baseline: CheckSummary | None = None
35
+ regressed_cases: list[CaseRegression] = Field(default_factory=list)
36
+ latency_p95_delta_ms: int | None = None
37
+
38
+
39
+ def check_report(
40
+ report: SuiteReport,
41
+ *,
42
+ baseline: SuiteReport | None = None,
43
+ threshold: float | None = None,
44
+ max_p95_ms: int | None = None,
45
+ require_safety_pass_rate: float | None = None,
46
+ require_correctness_rate: float | None = None,
47
+ ) -> CheckOutcome:
48
+ reasons: list[str] = []
49
+
50
+ if threshold is not None and report.pass_rate < threshold:
51
+ reasons.append(
52
+ f"pass_rate {report.pass_rate:.3f} below threshold {threshold:.3f}"
53
+ )
54
+
55
+ if (
56
+ require_safety_pass_rate is not None
57
+ and report.safety_pass_rate < require_safety_pass_rate
58
+ ):
59
+ reasons.append(
60
+ f"safety_pass_rate {report.safety_pass_rate:.3f} below required "
61
+ f"{require_safety_pass_rate:.3f}"
62
+ )
63
+
64
+ if (
65
+ require_correctness_rate is not None
66
+ and report.correctness_rate < require_correctness_rate
67
+ ):
68
+ reasons.append(
69
+ f"correctness_rate {report.correctness_rate:.3f} below required "
70
+ f"{require_correctness_rate:.3f}"
71
+ )
72
+
73
+ if max_p95_ms is not None and report.p95_latency_ms > max_p95_ms:
74
+ reasons.append(
75
+ f"p95_latency_ms {report.p95_latency_ms} exceeds {max_p95_ms}"
76
+ )
77
+
78
+ for violation in report.threshold_violations:
79
+ if violation not in reasons:
80
+ reasons.append(violation)
81
+
82
+ regressed: list[CaseRegression] = []
83
+ latency_delta: int | None = None
84
+ baseline_summary: CheckSummary | None = None
85
+
86
+ if baseline is not None:
87
+ baseline_summary = _summary(baseline)
88
+ latency_delta = report.p95_latency_ms - baseline.p95_latency_ms
89
+
90
+ if report.pass_rate < baseline.pass_rate:
91
+ reasons.append(
92
+ f"pass_rate dropped from baseline {baseline.pass_rate:.3f} to "
93
+ f"{report.pass_rate:.3f}"
94
+ )
95
+
96
+ if report.safety_pass_rate < baseline.safety_pass_rate:
97
+ reasons.append(
98
+ f"safety_pass_rate dropped from baseline "
99
+ f"{baseline.safety_pass_rate:.3f} to {report.safety_pass_rate:.3f}"
100
+ )
101
+
102
+ if report.correctness_rate < baseline.correctness_rate:
103
+ reasons.append(
104
+ f"correctness_rate dropped from baseline "
105
+ f"{baseline.correctness_rate:.3f} to {report.correctness_rate:.3f}"
106
+ )
107
+
108
+ regressed = _diff_cases(baseline=baseline, current=report)
109
+
110
+ return CheckOutcome(
111
+ ok=not reasons and not regressed,
112
+ reasons=reasons,
113
+ current=_summary(report),
114
+ baseline=baseline_summary,
115
+ regressed_cases=regressed,
116
+ latency_p95_delta_ms=latency_delta,
117
+ )
118
+
119
+
120
+ def load_report(path: str | Path) -> SuiteReport:
121
+ target = Path(path)
122
+ if not target.is_file():
123
+ raise FileNotFoundError(f"Report file not found: {target}")
124
+ return SuiteReport.model_validate_json(target.read_text(encoding="utf-8"))
125
+
126
+
127
+ def format_outcome(outcome: CheckOutcome) -> str:
128
+ if outcome.ok:
129
+ return f"OK\n pass_rate {outcome.current.pass_rate:.3f}, p95 {outcome.current.p95_latency_ms} ms"
130
+
131
+ lines = ["Regression detected.", ""]
132
+ if outcome.baseline is not None:
133
+ lines.extend(
134
+ [
135
+ "Pass rate:",
136
+ f" baseline: {outcome.baseline.pass_rate * 100:.0f}%",
137
+ f" current: {outcome.current.pass_rate * 100:.0f}%",
138
+ "",
139
+ ]
140
+ )
141
+
142
+ if outcome.regressed_cases:
143
+ lines.append("Failed cases (regression vs. baseline):")
144
+ for case in outcome.regressed_cases:
145
+ verb = "was passing" if case.was_passing else "was failing"
146
+ now = "now passing" if case.now_passing else (
147
+ f"now {case.current_failure_category}" if case.current_failure_category
148
+ else "now failing"
149
+ )
150
+ lines.append(f" - {case.id} ({verb} -> {now})")
151
+ lines.append("")
152
+
153
+ if outcome.latency_p95_delta_ms is not None and outcome.baseline is not None:
154
+ delta = outcome.latency_p95_delta_ms
155
+ lines.extend(
156
+ [
157
+ "Latency:",
158
+ f" baseline p95: {outcome.baseline.p95_latency_ms} ms",
159
+ f" current p95: {outcome.current.p95_latency_ms} ms ({delta:+d} ms)",
160
+ "",
161
+ ]
162
+ )
163
+
164
+ if outcome.reasons:
165
+ lines.append("Reasons:")
166
+ for r in outcome.reasons:
167
+ lines.append(f" - {r}")
168
+
169
+ return "\n".join(lines).rstrip() + "\n"
170
+
171
+
172
+ def write_outcome(outcome: CheckOutcome, path: str | Path) -> Path:
173
+ target = Path(path)
174
+ target.parent.mkdir(parents=True, exist_ok=True)
175
+ target.write_text(
176
+ json.dumps(outcome.model_dump(mode="json"), indent=2) + "\n",
177
+ encoding="utf-8",
178
+ )
179
+ return target
180
+
181
+
182
+ def _summary(report: SuiteReport) -> CheckSummary:
183
+ return CheckSummary(
184
+ pass_rate=report.pass_rate,
185
+ safety_pass_rate=report.safety_pass_rate,
186
+ correctness_rate=report.correctness_rate,
187
+ p95_latency_ms=report.p95_latency_ms,
188
+ estimated_cost_usd=report.estimated_cost_usd,
189
+ total_cases=report.total_cases,
190
+ passed=report.passed,
191
+ failed=report.failed,
192
+ )
193
+
194
+
195
+ def _diff_cases(*, baseline: SuiteReport, current: SuiteReport) -> list[CaseRegression]:
196
+ baseline_by_id = {c.id: c for c in baseline.case_results}
197
+ current_by_id = {c.id: c for c in current.case_results}
198
+
199
+ out: list[CaseRegression] = []
200
+ for case_id, current_case in current_by_id.items():
201
+ baseline_case = baseline_by_id.get(case_id)
202
+ if baseline_case is None:
203
+ continue
204
+ if baseline_case.passed and not current_case.passed:
205
+ out.append(
206
+ CaseRegression(
207
+ id=case_id,
208
+ was_passing=True,
209
+ now_passing=False,
210
+ previous_failure_category=(
211
+ baseline_case.failure_category.value
212
+ if baseline_case.failure_category
213
+ else None
214
+ ),
215
+ current_failure_category=(
216
+ current_case.failure_category.value
217
+ if current_case.failure_category
218
+ else None
219
+ ),
220
+ )
221
+ )
222
+ return out
@@ -0,0 +1,286 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from collections import Counter
5
+ from datetime import date, datetime, time
6
+ from decimal import Decimal
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, Field
10
+ from sqlglot import parse_one
11
+ from sqlglot.errors import ParseError
12
+
13
+ from querypilot.evals.suite import ComparisonConfig
14
+
15
+
16
+ class _NaNSentinel:
17
+ _instance = None
18
+
19
+ def __new__(cls) -> "_NaNSentinel":
20
+ if cls._instance is None:
21
+ cls._instance = super().__new__(cls)
22
+ return cls._instance
23
+
24
+ def __repr__(self) -> str:
25
+ return "<NaN>"
26
+
27
+ def __reduce__(self):
28
+ return (_NaNSentinel, ())
29
+
30
+
31
+ NAN = _NaNSentinel()
32
+
33
+
34
+ class ValueMismatch(BaseModel):
35
+ row_index: int
36
+ column: str
37
+ gold: Any
38
+ candidate: Any
39
+
40
+
41
+ class RowsetMatch(BaseModel):
42
+ matched: bool
43
+ order_sensitive: bool
44
+ column_mismatch: list[str] = Field(default_factory=list)
45
+ missing_rows: list[dict] = Field(default_factory=list)
46
+ extra_rows: list[dict] = Field(default_factory=list)
47
+ mismatched_values: list[ValueMismatch] = Field(default_factory=list)
48
+ normalized_gold_rows: list[dict] = Field(default_factory=list)
49
+ normalized_candidate_rows: list[dict] = Field(default_factory=list)
50
+
51
+
52
+ def has_order_by(sql: str) -> bool:
53
+ try:
54
+ tree = parse_one(sql)
55
+ except ParseError:
56
+ return False
57
+ if tree is None:
58
+ return False
59
+ return tree.args.get("order") is not None
60
+
61
+
62
+ def compare_rows(
63
+ gold_rows: list[dict],
64
+ candidate_rows: list[dict],
65
+ gold_sql: str,
66
+ config: ComparisonConfig | None = None,
67
+ ) -> RowsetMatch:
68
+ config = config or ComparisonConfig()
69
+
70
+ order_sensitive = has_order_by(gold_sql) or not config.ignore_row_order
71
+
72
+ column_mismatch = _column_mismatch(gold_rows, candidate_rows, config)
73
+ if column_mismatch:
74
+ return RowsetMatch(
75
+ matched=False,
76
+ order_sensitive=order_sensitive,
77
+ column_mismatch=column_mismatch,
78
+ normalized_gold_rows=gold_rows,
79
+ normalized_candidate_rows=candidate_rows,
80
+ )
81
+
82
+ norm_gold = [_normalize_row(row, config) for row in gold_rows]
83
+ norm_candidate = [_normalize_row(row, config) for row in candidate_rows]
84
+
85
+ if order_sensitive:
86
+ return _compare_ordered(norm_gold, norm_candidate, tolerance=config.float_tolerance)
87
+ return _compare_bag(norm_gold, norm_candidate, tolerance=config.float_tolerance)
88
+
89
+
90
+ def _column_mismatch(
91
+ gold_rows: list[dict], candidate_rows: list[dict], config: ComparisonConfig
92
+ ) -> list[str]:
93
+ if not gold_rows or not candidate_rows:
94
+ return []
95
+ gold_cols = list(gold_rows[0].keys())
96
+ candidate_cols = list(candidate_rows[0].keys())
97
+
98
+ if config.ignore_column_order:
99
+ gold_set = set(gold_cols)
100
+ candidate_set = set(candidate_cols)
101
+ if gold_set == candidate_set:
102
+ return []
103
+ diffs: list[str] = []
104
+ for col in sorted(gold_set - candidate_set):
105
+ diffs.append(f"missing in candidate: {col}")
106
+ for col in sorted(candidate_set - gold_set):
107
+ diffs.append(f"unexpected in candidate: {col}")
108
+ return diffs
109
+
110
+ if gold_cols == candidate_cols:
111
+ return []
112
+ return [f"column order mismatch: gold={gold_cols} candidate={candidate_cols}"]
113
+
114
+
115
+ def _normalize_row(row: dict, config: ComparisonConfig) -> dict:
116
+ return {key: _normalize_value(value, config) for key, value in row.items()}
117
+
118
+
119
+ def _normalize_value(value: Any, config: ComparisonConfig) -> Any:
120
+ if value is None:
121
+ return None
122
+ if isinstance(value, bool):
123
+ return value
124
+ if isinstance(value, Decimal):
125
+ value = float(value)
126
+ if isinstance(value, int) and not isinstance(value, bool):
127
+ return value
128
+ if isinstance(value, float):
129
+ if math.isnan(value):
130
+ return NAN
131
+ return value
132
+ if isinstance(value, datetime):
133
+ return value.isoformat() if config.normalize_datetimes else value
134
+ if isinstance(value, date):
135
+ return value.isoformat() if config.normalize_datetimes else value
136
+ if isinstance(value, time):
137
+ return value.isoformat() if config.normalize_datetimes else value
138
+ if isinstance(value, str):
139
+ if config.case_insensitive_strings:
140
+ return value.casefold()
141
+ return value
142
+ return value
143
+
144
+
145
+ def _compare_ordered(
146
+ gold: list[dict], candidate: list[dict], *, tolerance: float
147
+ ) -> RowsetMatch:
148
+ mismatched: list[ValueMismatch] = []
149
+ missing: list[dict] = []
150
+ extra: list[dict] = []
151
+
152
+ common = min(len(gold), len(candidate))
153
+ for index in range(common):
154
+ for column, gold_value in gold[index].items():
155
+ candidate_value = candidate[index].get(column)
156
+ if not _values_equal(gold_value, candidate_value, tolerance):
157
+ mismatched.append(
158
+ ValueMismatch(
159
+ row_index=index,
160
+ column=column,
161
+ gold=gold_value,
162
+ candidate=candidate_value,
163
+ )
164
+ )
165
+
166
+ if len(gold) > len(candidate):
167
+ missing = gold[common:]
168
+ elif len(candidate) > len(gold):
169
+ extra = candidate[common:]
170
+
171
+ matched = not mismatched and not missing and not extra
172
+ return RowsetMatch(
173
+ matched=matched,
174
+ order_sensitive=True,
175
+ missing_rows=missing,
176
+ extra_rows=extra,
177
+ mismatched_values=mismatched,
178
+ normalized_gold_rows=gold,
179
+ normalized_candidate_rows=candidate,
180
+ )
181
+
182
+
183
+ def _compare_bag(
184
+ gold: list[dict], candidate: list[dict], *, tolerance: float
185
+ ) -> RowsetMatch:
186
+ if tolerance > 0:
187
+ missing_rows, extra_rows = _greedy_match(gold, candidate, tolerance)
188
+ else:
189
+ missing_rows, extra_rows = _counter_diff(gold, candidate)
190
+
191
+ matched = not missing_rows and not extra_rows
192
+ return RowsetMatch(
193
+ matched=matched,
194
+ order_sensitive=False,
195
+ missing_rows=missing_rows,
196
+ extra_rows=extra_rows,
197
+ normalized_gold_rows=gold,
198
+ normalized_candidate_rows=candidate,
199
+ )
200
+
201
+
202
+ def _counter_diff(
203
+ gold: list[dict], candidate: list[dict]
204
+ ) -> tuple[list[dict], list[dict]]:
205
+ gold_counter = Counter(_row_key(row) for row in gold)
206
+ candidate_counter = Counter(_row_key(row) for row in candidate)
207
+
208
+ missing_keys = gold_counter - candidate_counter
209
+ extra_keys = candidate_counter - gold_counter
210
+
211
+ return _rows_for_keys(gold, missing_keys), _rows_for_keys(candidate, extra_keys)
212
+
213
+
214
+ def _greedy_match(
215
+ gold: list[dict], candidate: list[dict], tolerance: float
216
+ ) -> tuple[list[dict], list[dict]]:
217
+ available = list(range(len(candidate)))
218
+ missing: list[dict] = []
219
+ matched_candidate: set[int] = set()
220
+
221
+ for gold_row in gold:
222
+ match_index = None
223
+ for i in available:
224
+ if _rows_equal(gold_row, candidate[i], tolerance):
225
+ match_index = i
226
+ break
227
+ if match_index is None:
228
+ missing.append(gold_row)
229
+ else:
230
+ available.remove(match_index)
231
+ matched_candidate.add(match_index)
232
+
233
+ extra = [candidate[i] for i in range(len(candidate)) if i not in matched_candidate]
234
+ return missing, extra
235
+
236
+
237
+ def _rows_equal(gold: dict, candidate: dict, tolerance: float) -> bool:
238
+ if set(gold.keys()) != set(candidate.keys()):
239
+ return False
240
+ for column, gold_value in gold.items():
241
+ if not _values_equal(gold_value, candidate.get(column), tolerance):
242
+ return False
243
+ return True
244
+
245
+
246
+ def _row_key(row: dict) -> tuple:
247
+ return tuple(sorted((key, _hashable(value)) for key, value in row.items()))
248
+
249
+
250
+ def _hashable(value: Any) -> Any:
251
+ if isinstance(value, (list, tuple)):
252
+ return tuple(_hashable(item) for item in value)
253
+ if isinstance(value, dict):
254
+ return tuple(sorted((k, _hashable(v)) for k, v in value.items()))
255
+ return value
256
+
257
+
258
+ def _rows_for_keys(rows: list[dict], wanted: Counter) -> list[dict]:
259
+ remaining = Counter(wanted)
260
+ out: list[dict] = []
261
+ for row in rows:
262
+ key = _row_key(row)
263
+ if remaining.get(key, 0) > 0:
264
+ out.append(row)
265
+ remaining[key] -= 1
266
+ return out
267
+
268
+
269
+ def _values_equal(a: Any, b: Any, tolerance: float = 0.0) -> bool:
270
+ if isinstance(a, _NaNSentinel) and isinstance(b, _NaNSentinel):
271
+ return True
272
+ if isinstance(a, _NaNSentinel) or isinstance(b, _NaNSentinel):
273
+ return False
274
+ if a is None and b is None:
275
+ return True
276
+ if a is None or b is None:
277
+ return False
278
+ if (
279
+ isinstance(a, float)
280
+ and isinstance(b, float)
281
+ and not isinstance(a, bool)
282
+ and not isinstance(b, bool)
283
+ and tolerance > 0
284
+ ):
285
+ return abs(a - b) <= tolerance
286
+ return a == b
@@ -0,0 +1,202 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Protocol
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ # (input_per_1k_usd, output_per_1k_usd). Missing models leave estimated_usd unset.
9
+ MODEL_PRICING: dict[str, tuple[float, float]] = {
10
+ # OpenAI Responses API (illustrative; refresh as pricing changes)
11
+ "gpt-5.1": (0.01, 0.03),
12
+ "gpt-4o": (0.005, 0.015),
13
+ "gpt-4o-mini": (0.00015, 0.0006),
14
+ # Anthropic Messages API
15
+ "claude-opus-4-7": (0.015, 0.075),
16
+ "claude-sonnet-4-6": (0.003, 0.015),
17
+ "claude-sonnet-4-20250514": (0.003, 0.015),
18
+ "claude-haiku-4-5-20251001": (0.00025, 0.00125),
19
+ }
20
+
21
+
22
+ class TokenUsage(BaseModel):
23
+ prompt_tokens: int
24
+ completion_tokens: int
25
+ total_tokens: int
26
+ model: str | None = None
27
+ estimated_usd: float | None = None
28
+
29
+
30
+ class CostTracker(Protocol):
31
+ def wrap(self, generator: Any) -> Any:
32
+ ...
33
+
34
+ def restore(self) -> None:
35
+ ...
36
+
37
+ def last_usage(self) -> TokenUsage | None:
38
+ ...
39
+
40
+ def reset(self) -> None:
41
+ ...
42
+
43
+
44
+ class NullCostTracker:
45
+ def wrap(self, generator: Any) -> Any:
46
+ return generator
47
+
48
+ def restore(self) -> None:
49
+ pass
50
+
51
+ def last_usage(self) -> TokenUsage | None:
52
+ return None
53
+
54
+ def reset(self) -> None:
55
+ pass
56
+
57
+
58
+ class _ResponseCapture:
59
+ def __init__(self, inner: Any, sink: list) -> None:
60
+ self._inner = inner
61
+ self._sink = sink
62
+
63
+ @property
64
+ def __querypilot_inner__(self) -> Any:
65
+ return self._inner
66
+
67
+ def __getattr__(self, name: str) -> Any:
68
+ return getattr(self._inner, name)
69
+
70
+ def create(self, *args: Any, **kwargs: Any) -> Any:
71
+ response = self._inner.create(*args, **kwargs)
72
+ self._sink.append(response)
73
+ return response
74
+
75
+
76
+ def _unwrap(namespace: Any) -> Any:
77
+ while isinstance(namespace, _ResponseCapture):
78
+ namespace = namespace.__querypilot_inner__
79
+ return namespace
80
+
81
+
82
+ class OpenAICostTracker:
83
+ def __init__(self) -> None:
84
+ self._captured: list[Any] = []
85
+ self._model: str | None = None
86
+ self._wrapped: tuple[Any, Any] | None = None # (client, original_responses)
87
+
88
+ def wrap(self, generator: Any) -> Any:
89
+ self._model = getattr(generator, "model", None)
90
+ client = getattr(generator, "client", None)
91
+ if client is None or not hasattr(client, "responses"):
92
+ return generator
93
+ original = _unwrap(client.responses)
94
+ if isinstance(client.responses, _ResponseCapture):
95
+ return generator # already wrapped
96
+ client.responses = _ResponseCapture(original, self._captured)
97
+ self._wrapped = (client, original)
98
+ return generator
99
+
100
+ def restore(self) -> None:
101
+ if self._wrapped is None:
102
+ return
103
+ client, original = self._wrapped
104
+ client.responses = original
105
+ self._wrapped = None
106
+
107
+ def last_usage(self) -> TokenUsage | None:
108
+ if not self._captured:
109
+ return None
110
+ response = self._captured[-1]
111
+ usage = getattr(response, "usage", None)
112
+ if usage is None:
113
+ return None
114
+ prompt = _coerce_int(getattr(usage, "input_tokens", None))
115
+ completion = _coerce_int(getattr(usage, "output_tokens", None))
116
+ if prompt is None and completion is None:
117
+ return None
118
+ prompt = prompt or 0
119
+ completion = completion or 0
120
+ return TokenUsage(
121
+ prompt_tokens=prompt,
122
+ completion_tokens=completion,
123
+ total_tokens=prompt + completion,
124
+ model=self._model,
125
+ estimated_usd=estimate_usd(self._model, prompt, completion),
126
+ )
127
+
128
+ def reset(self) -> None:
129
+ self._captured.clear()
130
+
131
+
132
+ class AnthropicCostTracker:
133
+ def __init__(self) -> None:
134
+ self._captured: list[Any] = []
135
+ self._model: str | None = None
136
+ self._wrapped: tuple[Any, Any] | None = None # (client, original_messages)
137
+
138
+ def wrap(self, generator: Any) -> Any:
139
+ self._model = getattr(generator, "model", None)
140
+ client = getattr(generator, "client", None)
141
+ if client is None or not hasattr(client, "messages"):
142
+ return generator
143
+ original = _unwrap(client.messages)
144
+ if isinstance(client.messages, _ResponseCapture):
145
+ return generator # already wrapped
146
+ client.messages = _ResponseCapture(original, self._captured)
147
+ self._wrapped = (client, original)
148
+ return generator
149
+
150
+ def restore(self) -> None:
151
+ if self._wrapped is None:
152
+ return
153
+ client, original = self._wrapped
154
+ client.messages = original
155
+ self._wrapped = None
156
+
157
+ def last_usage(self) -> TokenUsage | None:
158
+ if not self._captured:
159
+ return None
160
+ message = self._captured[-1]
161
+ usage = getattr(message, "usage", None)
162
+ if usage is None:
163
+ return None
164
+ prompt = _coerce_int(getattr(usage, "input_tokens", None))
165
+ completion = _coerce_int(getattr(usage, "output_tokens", None))
166
+ if prompt is None and completion is None:
167
+ return None
168
+ prompt = prompt or 0
169
+ completion = completion or 0
170
+ return TokenUsage(
171
+ prompt_tokens=prompt,
172
+ completion_tokens=completion,
173
+ total_tokens=prompt + completion,
174
+ model=self._model,
175
+ estimated_usd=estimate_usd(self._model, prompt, completion),
176
+ )
177
+
178
+ def reset(self) -> None:
179
+ self._captured.clear()
180
+
181
+
182
+ def estimate_usd(model: str | None, prompt_tokens: int, completion_tokens: int) -> float | None:
183
+ if model is None:
184
+ return None
185
+ pricing = MODEL_PRICING.get(model)
186
+ if pricing is None:
187
+ return None
188
+ input_per_1k, output_per_1k = pricing
189
+ return round(
190
+ (prompt_tokens / 1000.0) * input_per_1k
191
+ + (completion_tokens / 1000.0) * output_per_1k,
192
+ 6,
193
+ )
194
+
195
+
196
+ def _coerce_int(value: Any) -> int | None:
197
+ if value is None:
198
+ return None
199
+ try:
200
+ return int(value)
201
+ except (TypeError, ValueError):
202
+ return None