quickstarted 0.2.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.
@@ -0,0 +1,93 @@
1
+ """Optional cost estimation.
2
+
3
+ There are deliberately no built-in prices. Vendor rates change, a stale table
4
+ baked into a benchmarking tool would quietly misreport what a run costs, and a
5
+ wrong number here is worse than no number. Supply rates yourself:
6
+
7
+ QUICKSTARTED_PRICES=/path/to/prices.json
8
+
9
+ {
10
+ "claude-opus-5": {
11
+ "input": 5.0, "output": 25.0, "cache_write": 6.25, "cache_read": 0.5
12
+ }
13
+ }
14
+
15
+ Units are US dollars per million tokens, matching how vendors publish them.
16
+ Token counts are always reported; dollars appear only when you provide rates.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+
26
+ PRICES_ENV = "QUICKSTARTED_PRICES"
27
+ _PER_MILLION = 1_000_000.0
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ModelPrice:
32
+ input: float = 0.0
33
+ output: float = 0.0
34
+ cache_write: float = 0.0
35
+ cache_read: float = 0.0
36
+
37
+ def cost(
38
+ self,
39
+ input_tokens: int,
40
+ output_tokens: int,
41
+ cache_write_tokens: int = 0,
42
+ cache_read_tokens: int = 0,
43
+ ) -> float:
44
+ return (
45
+ input_tokens * self.input
46
+ + output_tokens * self.output
47
+ + cache_write_tokens * self.cache_write
48
+ + cache_read_tokens * self.cache_read
49
+ ) / _PER_MILLION
50
+
51
+
52
+ class PriceBook:
53
+ def __init__(self, prices: dict[str, ModelPrice] | None = None):
54
+ self.prices = prices or {}
55
+
56
+ def __bool__(self) -> bool:
57
+ return bool(self.prices)
58
+
59
+ def for_model(self, model: str) -> ModelPrice | None:
60
+ if model in self.prices:
61
+ return self.prices[model]
62
+ # Adapters report names like "claude:claude-opus-5"; match the tail.
63
+ tail = model.rsplit(":", 1)[-1]
64
+ return self.prices.get(tail)
65
+
66
+ def estimate(self, model: str, outcome) -> float | None:
67
+ price = self.for_model(model)
68
+ if price is None:
69
+ return None
70
+ return price.cost(
71
+ outcome.input_tokens,
72
+ outcome.output_tokens,
73
+ outcome.cache_write_tokens,
74
+ outcome.cache_read_tokens,
75
+ )
76
+
77
+ @classmethod
78
+ def load(cls, path: str | None = None) -> PriceBook:
79
+ path = path or os.environ.get(PRICES_ENV)
80
+ if not path:
81
+ return cls()
82
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
83
+ prices = {}
84
+ for model, fields in data.items():
85
+ if not isinstance(fields, dict):
86
+ raise ValueError(f"price entry for {model!r} must be a mapping")
87
+ prices[model] = ModelPrice(
88
+ input=float(fields.get("input", 0.0)),
89
+ output=float(fields.get("output", 0.0)),
90
+ cache_write=float(fields.get("cache_write", 0.0)),
91
+ cache_read=float(fields.get("cache_read", 0.0)),
92
+ )
93
+ return cls(prices)
quickstarted/report.py ADDED
@@ -0,0 +1,206 @@
1
+ """Human-readable reporting: console summaries and markdown.
2
+
3
+ Reports name the classification, not just PASS/FAIL, because "we could not
4
+ reach PyPI" and "your quickstart is missing a step" require different people
5
+ to do different things.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .pricing import PriceBook
11
+ from .run import DOCS_GAP, PASSED, RunResult
12
+ from .suite import JourneyStats, SuiteResult
13
+
14
+ _LABELS = {
15
+ PASSED: "PASS",
16
+ DOCS_GAP: "FAIL",
17
+ "budget_exhausted": "INCONCLUSIVE",
18
+ "infra_error": "INCONCLUSIVE",
19
+ "harness_error": "INCONCLUSIVE",
20
+ "agent_refusal": "INCONCLUSIVE",
21
+ }
22
+
23
+
24
+ def label(run: RunResult) -> str:
25
+ return _LABELS.get(run.classification, "INCONCLUSIVE")
26
+
27
+
28
+ def _token_line(outcome) -> str:
29
+ """Tokens for one run, cache traffic included so the number reflects cost."""
30
+ parts = []
31
+ if outcome.input_tokens or outcome.output_tokens:
32
+ parts.append(f"{outcome.input_tokens} in / {outcome.output_tokens} out")
33
+ if outcome.cache_write_tokens or outcome.cache_read_tokens:
34
+ parts.append(
35
+ f"cache {outcome.cache_write_tokens} written / "
36
+ f"{outcome.cache_read_tokens} read"
37
+ )
38
+ return ", ".join(parts)
39
+
40
+
41
+ def console_summary(result: RunResult) -> str:
42
+ lines = [
43
+ f"[{label(result)}] {result.journey.name} ({result.agent_name})",
44
+ f" classification: {result.classification}",
45
+ f" stop reason: {result.outcome.stop_reason}"
46
+ + (f" ({result.outcome.detail})" if result.outcome.detail else ""),
47
+ f" turns: {result.outcome.turns}, duration: {result.duration:.1f}s",
48
+ f" backend: {result.backend}"
49
+ + ("" if result.enforced else " [UNENFORCED: policy is advisory here]"),
50
+ ]
51
+ tokens = _token_line(result.outcome)
52
+ if tokens:
53
+ lines.append(f" tokens: {tokens}")
54
+ fetched = result.trace.fetched_urls()
55
+ if fetched:
56
+ lines.append(f" docs pages read: {len(fetched)}")
57
+ if result.bypass_attempts:
58
+ lines.append(
59
+ f" docs fetched outside read_docs: {result.bypass_attempts} blocked attempt(s)"
60
+ )
61
+ if not result.passed:
62
+ if result.score is not None:
63
+ check_line = (result.score.output or "").strip().splitlines()
64
+ lines.append(
65
+ f" success check exit code: {result.score.exit_code}"
66
+ + (f" ({check_line[-1]})" if check_line else "")
67
+ )
68
+ if result.classification == DOCS_GAP and result.suspect_page:
69
+ lines.append(f" last docs page read before failure: {result.suspect_page}")
70
+ return "\n".join(lines)
71
+
72
+
73
+ def suite_summary(suite: SuiteResult, prices: PriceBook | None = None) -> str:
74
+ prices = prices or PriceBook()
75
+ lines = ["", "=" * 62, f"Suite: {len(suite.stats)} journey(s), repeat={suite.repeat}"]
76
+ for stat in suite.stats:
77
+ rate = stat.pass_rate
78
+ shown = "no evidence" if rate is None else f"{rate:.0%}"
79
+ lines.append(
80
+ f" {stat.journey} ({stat.agent}): pass rate {shown} "
81
+ f"[{stat.passes}/{len(stat.evidential)} evidential of {stat.attempts} run(s)]"
82
+ )
83
+ if stat.discarded:
84
+ detail = ", ".join(f"{k}={v}" for k, v in sorted(stat.discarded.items()))
85
+ lines.append(f" discarded: {detail}")
86
+ if len(stat.models_seen) > 1:
87
+ lines.append(
88
+ f" WARNING: more than one model served this journey "
89
+ f"({', '.join(stat.models_seen)}); do not compare these runs"
90
+ )
91
+ for page, count in list(stat.suspect_pages.items())[:3]:
92
+ lines.append(f" failed after: {page} ({count}x)")
93
+ totals = suite.tokens()
94
+ lines.append(
95
+ f" tokens: {totals['input']} in / {totals['output']} out, "
96
+ f"cache {totals['cache_write']} written / {totals['cache_read']} read"
97
+ )
98
+ cost = suite.cost(prices)
99
+ if cost is not None:
100
+ lines.append(f" estimated cost: ${cost:.4f}")
101
+ lines.append(f" wall clock: {suite.duration:.1f}s")
102
+ lines.append("=" * 62)
103
+ return "\n".join(lines)
104
+
105
+
106
+ def markdown_report(result: RunResult) -> str:
107
+ out = [
108
+ f"# quickstarted: {result.journey.name}",
109
+ "",
110
+ f"**Result: {label(result)}** (`{result.classification}`, agent: "
111
+ f"`{result.agent_name}`)",
112
+ "",
113
+ f"- Goal: {result.journey.goal}",
114
+ f"- Stop reason: {result.outcome.stop_reason}"
115
+ + (f" ({result.outcome.detail})" if result.outcome.detail else ""),
116
+ f"- Turns: {result.outcome.turns}",
117
+ f"- Duration: {result.duration:.1f}s",
118
+ f"- Backend: `{result.backend}` "
119
+ + ("(enforced)" if result.enforced else "(**unenforced**)"),
120
+ ]
121
+ if result.model_reported:
122
+ out.append(f"- Model served: `{result.model_reported}`")
123
+ tokens = _token_line(result.outcome)
124
+ if tokens:
125
+ out.append(f"- Tokens: {tokens}")
126
+ out.append("")
127
+ if not result.evidential:
128
+ out += [
129
+ "## This run is not evidence about the documentation",
130
+ "",
131
+ f"It was classified `{result.classification}`, which means the run "
132
+ "failed for a reason unrelated to whether the docs are usable. It "
133
+ "is excluded from pass rates rather than counted as a failure.",
134
+ "",
135
+ ]
136
+ fetched = result.trace.fetched_urls()
137
+ if fetched:
138
+ out += ["## Docs pages the agent read", ""]
139
+ out += [f"1. {url}" for url in fetched]
140
+ out.append("")
141
+ if result.bypass_attempts:
142
+ out += [
143
+ "## Blocked shell access to documentation",
144
+ "",
145
+ f"The agent tried {result.bypass_attempts} time(s) to fetch docs "
146
+ "through the shell instead of the recorded tool. The proxy refused, "
147
+ "so the page list above is complete.",
148
+ "",
149
+ ]
150
+ if result.score is not None:
151
+ out += [
152
+ "## Success check",
153
+ "",
154
+ f"Exit code: {result.score.exit_code}",
155
+ "",
156
+ "```",
157
+ (result.score.output or "").strip(),
158
+ "```",
159
+ "",
160
+ ]
161
+ if result.classification == DOCS_GAP and result.suspect_page:
162
+ out += [
163
+ "## Where to look first",
164
+ "",
165
+ f"The last documentation page the agent read before failing was "
166
+ f"<{result.suspect_page}>. That page (or a gap right after it) is "
167
+ f"the first suspect.",
168
+ "",
169
+ ]
170
+ return "\n".join(out)
171
+
172
+
173
+ def markdown_suite_report(suite: SuiteResult, prices: PriceBook | None = None) -> str:
174
+ prices = prices or PriceBook()
175
+ out = [
176
+ "# quickstarted suite",
177
+ "",
178
+ f"- Journeys: {len(suite.stats)}",
179
+ f"- Attempts per journey: {suite.repeat}",
180
+ f"- Backend: `{suite.backend}`",
181
+ f"- Duration: {suite.duration:.1f}s",
182
+ "",
183
+ "| Journey | Agent | Pass rate | Evidential | Discarded |",
184
+ "| --- | --- | --- | --- | --- |",
185
+ ]
186
+ for stat in suite.stats:
187
+ rate = stat.pass_rate
188
+ shown = "n/a" if rate is None else f"{rate:.0%}"
189
+ discarded = (
190
+ ", ".join(f"{k}={v}" for k, v in sorted(stat.discarded.items())) or "none"
191
+ )
192
+ out.append(
193
+ f"| {stat.journey} | `{stat.agent}` | {shown} | "
194
+ f"{stat.passes}/{len(stat.evidential)} | {discarded} |"
195
+ )
196
+ out.append("")
197
+ cost = suite.cost(prices)
198
+ if cost is not None:
199
+ out.append(f"Estimated cost: ${cost:.4f}")
200
+ out.append("")
201
+ return "\n".join(out)
202
+
203
+
204
+ def stats_line(stat: JourneyStats) -> str:
205
+ rate = stat.pass_rate
206
+ return f"{stat.journey}: {'n/a' if rate is None else f'{rate:.0%}'}"
@@ -0,0 +1,166 @@
1
+ """Machine-readable results: a versioned JSON document and JUnit XML.
2
+
3
+ `SCHEMA_VERSION` is a promise. The hosted product, the benchmark report, and
4
+ anyone's CI dashboard all parse this document, so fields get added but not
5
+ repurposed, and the version goes up when that stops being true.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import platform
12
+ import socket
13
+ import sys
14
+ import time
15
+ from pathlib import Path
16
+ from xml.etree import ElementTree as ET
17
+
18
+ from ._version import __version__
19
+ from .pricing import PriceBook
20
+ from .run import PASSED, RunResult
21
+ from .suite import SuiteResult
22
+
23
+ SCHEMA_VERSION = "1.0"
24
+
25
+
26
+ def _run_document(run: RunResult) -> dict:
27
+ return {
28
+ "journey": run.journey.name,
29
+ "attempt": run.attempt,
30
+ "agent": run.agent_name,
31
+ "model_reported": run.model_reported,
32
+ "classification": run.classification,
33
+ "passed": run.passed,
34
+ "evidential": run.evidential,
35
+ "stop_reason": run.outcome.stop_reason,
36
+ "turns": run.outcome.turns,
37
+ "duration_seconds": round(run.duration, 2),
38
+ "backend": run.backend,
39
+ "enforced": run.enforced,
40
+ "docs_pages_read": run.trace.fetched_urls(),
41
+ "suspect_page": run.suspect_page,
42
+ "docs_bypass_attempts": run.bypass_attempts,
43
+ "success_check": (
44
+ None
45
+ if run.score is None
46
+ else {"exit_code": run.score.exit_code, "output": run.score.output[-2000:]}
47
+ ),
48
+ "tokens": {
49
+ "input": run.outcome.input_tokens,
50
+ "output": run.outcome.output_tokens,
51
+ "cache_write": run.outcome.cache_write_tokens,
52
+ "cache_read": run.outcome.cache_read_tokens,
53
+ },
54
+ }
55
+
56
+
57
+ def suite_document(suite: SuiteResult, prices: PriceBook | None = None) -> dict:
58
+ prices = prices or PriceBook()
59
+ journeys = []
60
+ for stat in suite.stats:
61
+ journeys.append(
62
+ {
63
+ "journey": stat.journey,
64
+ "agent": stat.agent,
65
+ "attempts": stat.attempts,
66
+ "passes": stat.passes,
67
+ "evidential_runs": len(stat.evidential),
68
+ "pass_rate": stat.pass_rate,
69
+ "discarded": stat.discarded,
70
+ "models_reported": stat.models_seen,
71
+ "suspect_pages": stat.suspect_pages,
72
+ "tokens": stat.tokens(),
73
+ "estimated_cost_usd": stat.cost(prices),
74
+ "runs": [_run_document(r) for r in stat.runs],
75
+ }
76
+ )
77
+ return {
78
+ "schema_version": SCHEMA_VERSION,
79
+ "quickstarted_version": __version__,
80
+ "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
81
+ "environment": {
82
+ "python": sys.version.split()[0],
83
+ "platform": platform.platform(),
84
+ "hostname": socket.gethostname(),
85
+ "backend": suite.backend,
86
+ },
87
+ "repeat": suite.repeat,
88
+ "duration_seconds": round(suite.duration, 2),
89
+ "totals": {
90
+ "runs": len(suite.runs),
91
+ "tokens": suite.tokens(),
92
+ "estimated_cost_usd": suite.cost(prices),
93
+ },
94
+ "journeys": journeys,
95
+ }
96
+
97
+
98
+ def write_json(suite: SuiteResult, path: str | Path, prices=None) -> None:
99
+ path = Path(path)
100
+ path.parent.mkdir(parents=True, exist_ok=True)
101
+ path.write_text(
102
+ json.dumps(suite_document(suite, prices), indent=2) + "\n", encoding="utf-8"
103
+ )
104
+
105
+
106
+ def junit_xml(suite: SuiteResult) -> str:
107
+ """JUnit XML so existing CI reporters can render a run with no new tooling."""
108
+ suites = ET.Element("testsuites", name="quickstarted")
109
+ total_failures = total_errors = total_tests = 0
110
+ for stat in suite.stats:
111
+ element = ET.SubElement(
112
+ suites,
113
+ "testsuite",
114
+ name=stat.journey,
115
+ tests=str(stat.attempts),
116
+ )
117
+ failures = errors = 0
118
+ for run in stat.runs:
119
+ case = ET.SubElement(
120
+ element,
121
+ "testcase",
122
+ classname=f"quickstarted.{stat.journey}",
123
+ name=f"{stat.agent} attempt {run.attempt}",
124
+ time=f"{run.duration:.2f}",
125
+ )
126
+ if run.classification == PASSED:
127
+ continue
128
+ if run.evidential:
129
+ failures += 1
130
+ node = ET.SubElement(
131
+ case, "failure", type=run.classification,
132
+ message=(
133
+ "success check exit "
134
+ f"{run.score.exit_code if run.score else 'n/a'}"
135
+ ),
136
+ )
137
+ detail = [f"stop reason: {run.outcome.stop_reason}"]
138
+ if run.suspect_page:
139
+ detail.append(f"last docs page read: {run.suspect_page}")
140
+ if run.score:
141
+ detail.append(run.score.output[-1000:])
142
+ node.text = "\n".join(detail)
143
+ else:
144
+ # Not a docs failure: infrastructure, budget, or our own bug.
145
+ errors += 1
146
+ node = ET.SubElement(
147
+ case, "error", type=run.classification,
148
+ message=run.outcome.detail or run.classification,
149
+ )
150
+ node.text = run.outcome.detail
151
+ element.set("failures", str(failures))
152
+ element.set("errors", str(errors))
153
+ total_failures += failures
154
+ total_errors += errors
155
+ total_tests += stat.attempts
156
+ suites.set("tests", str(total_tests))
157
+ suites.set("failures", str(total_failures))
158
+ suites.set("errors", str(total_errors))
159
+ suites.set("time", f"{suite.duration:.2f}")
160
+ return ET.tostring(suites, encoding="unicode")
161
+
162
+
163
+ def write_junit(suite: SuiteResult, path: str | Path) -> None:
164
+ path = Path(path)
165
+ path.parent.mkdir(parents=True, exist_ok=True)
166
+ path.write_text(junit_xml(suite) + "\n", encoding="utf-8")