maf-cachebench 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.
@@ -0,0 +1,236 @@
1
+ """Compaction and prompt-cache interaction benchmark for Agent Framework.
2
+
3
+ Provider prompt caches match on exact prefixes, and every compaction strategy works by
4
+ excluding or rewriting messages inside an existing history. Compaction therefore breaks
5
+ the cached prefix by construction, and the question this package answers empirically is
6
+ what that costs: how much of each prompt stays reusable under a given strategy, how much
7
+ of that reusable prefix a given provider actually serves from cache, and whether
8
+ compacting earlier saves more prompt tokens than it loses in cache reads.
9
+
10
+ Measurements replay deterministic scripted content and structure across providers and
11
+ strategies, with fixed-width per-cell salts isolating their cache prefixes.
12
+ """
13
+
14
+ import importlib.metadata
15
+
16
+ from ._advise_cli import main as advise_main
17
+ from ._advisor import ModelPricing, StrategyCost, Verdict, advise, cost_of, fetch_openrouter_pricing
18
+ from ._cli import build_parser, main, run_benchmark
19
+ from ._fill import FillPlan, plan_fill
20
+ from ._live import (
21
+ AGENT_KINDS,
22
+ DEFAULT_COMBINED_REPEATS,
23
+ DEFAULT_PROBE_REPEATS,
24
+ IdentifiedHistoryProvider,
25
+ LiveOutcome,
26
+ MeteredClient,
27
+ ModelCall,
28
+ ProbeOutcome,
29
+ SummarizerUsage,
30
+ UsageRecorder,
31
+ build_live_agent,
32
+ build_live_scenario,
33
+ find_nested_strategy,
34
+ make_lookup_tool,
35
+ probe_count,
36
+ restore_state,
37
+ run_live,
38
+ score_combined_samples,
39
+ score_samples,
40
+ serialize_history,
41
+ snapshot_state,
42
+ unretrieved_facts,
43
+ wants_client_side_history,
44
+ )
45
+ from ._live_cli import main as live_main
46
+ from ._metrics import (
47
+ common_message_prefix,
48
+ percentile,
49
+ serialize_message,
50
+ summarize_cell,
51
+ token_counts,
52
+ )
53
+ from ._providers import (
54
+ PROVIDER_SPECS,
55
+ ProviderRuntime,
56
+ ProviderSpec,
57
+ build_provider,
58
+ parse_provider_selector,
59
+ prompt_cache_key,
60
+ prompt_cache_key_options,
61
+ provider_names,
62
+ )
63
+ from ._recall import (
64
+ Contradiction,
65
+ FactOutcome,
66
+ PlantedFact,
67
+ RecallScenario,
68
+ RecallScore,
69
+ build_recall_scenario,
70
+ score_answer,
71
+ )
72
+ from ._recall_cli import main as recall_main
73
+ from ._records import (
74
+ SCHEMA_VERSION,
75
+ CellParams,
76
+ SeedRecord,
77
+ StrategySettings,
78
+ WorkloadSettings,
79
+ append_seed_record,
80
+ group_by_cell,
81
+ read_seed_records,
82
+ )
83
+ from ._report import render_summary_table, write_records_jsonl, write_summary_csv
84
+ from ._runner import CallOutcome, ProviderCaller, TurnCaller, run_cell, unsupported_option
85
+ from ._strategies import (
86
+ STRATEGIES_FORCING_RECORDS,
87
+ STRATEGIES_NEEDING_SUMMARIZER,
88
+ STRATEGY_BUILDERS,
89
+ StrategyOptions,
90
+ build_strategy,
91
+ forces_records,
92
+ resolve_context_window,
93
+ strategy_names,
94
+ )
95
+ from ._summary import JointOutcome, JointVerdict, recommend, relative_correctness
96
+ from ._summary_cli import main as summary_main
97
+ from ._tokenizers import TOKENIZER_NAMES, build_tokenizer
98
+ from ._transcripts import (
99
+ DEFAULT_SYSTEM_TOKENS,
100
+ TRANSCRIPT_PRESETS,
101
+ TranscriptPreset,
102
+ build_preset,
103
+ build_transcript,
104
+ )
105
+ from ._types import CellKey, CellSummary, Transcript, TranscriptTurn, TurnRecord
106
+
107
+ try:
108
+ __version__ = importlib.metadata.version(__name__)
109
+ except importlib.metadata.PackageNotFoundError:
110
+ __version__ = "0.1.0" # Fallback for development mode
111
+
112
+ __all__ = [
113
+ "AGENT_KINDS",
114
+ "DEFAULT_COMBINED_REPEATS",
115
+ "DEFAULT_PROBE_REPEATS",
116
+ "DEFAULT_SYSTEM_TOKENS",
117
+ "PROVIDER_SPECS",
118
+ "SCHEMA_VERSION",
119
+ "STRATEGIES_FORCING_RECORDS",
120
+ "STRATEGIES_NEEDING_SUMMARIZER",
121
+ "STRATEGY_BUILDERS",
122
+ "TOKENIZER_NAMES",
123
+ "TRANSCRIPT_PRESETS",
124
+ "CallOutcome",
125
+ "CellKey",
126
+ "CellParams",
127
+ "CellSummary",
128
+ "Contradiction",
129
+ "FactOutcome",
130
+ "FillPlan",
131
+ "IdentifiedHistoryProvider",
132
+ "JointOutcome",
133
+ "JointVerdict",
134
+ "LiveOutcome",
135
+ "MeteredClient",
136
+ "ModelCall",
137
+ "SummarizerUsage",
138
+ "ModelPricing",
139
+ "PlantedFact",
140
+ "ProbeOutcome",
141
+ "ProviderCaller",
142
+ "ProviderRuntime",
143
+ "ProviderSpec",
144
+ "RecallScenario",
145
+ "RecallScore",
146
+ "SeedRecord",
147
+ "StrategyCost",
148
+ "StrategyOptions",
149
+ "StrategySettings",
150
+ "Transcript",
151
+ "TranscriptPreset",
152
+ "TranscriptTurn",
153
+ "TurnCaller",
154
+ "TurnRecord",
155
+ "UsageRecorder",
156
+ "Verdict",
157
+ "WorkloadSettings",
158
+ "MafCachebenchExperimentalWarning",
159
+ "__version__",
160
+ "advise",
161
+ "advise_main",
162
+ "append_seed_record",
163
+ "build_live_agent",
164
+ "build_live_scenario",
165
+ "build_parser",
166
+ "build_preset",
167
+ "build_provider",
168
+ "build_recall_scenario",
169
+ "build_strategy",
170
+ "build_tokenizer",
171
+ "build_transcript",
172
+ "common_message_prefix",
173
+ "cost_of",
174
+ "fetch_openrouter_pricing",
175
+ "find_nested_strategy",
176
+ "forces_records",
177
+ "group_by_cell",
178
+ "live_main",
179
+ "main",
180
+ "make_lookup_tool",
181
+ "parse_provider_selector",
182
+ "percentile",
183
+ "plan_fill",
184
+ "probe_count",
185
+ "prompt_cache_key",
186
+ "prompt_cache_key_options",
187
+ "provider_names",
188
+ "read_seed_records",
189
+ "recall_main",
190
+ "recommend",
191
+ "relative_correctness",
192
+ "render_summary_table",
193
+ "resolve_context_window",
194
+ "restore_state",
195
+ "run_benchmark",
196
+ "run_cell",
197
+ "run_live",
198
+ "score_answer",
199
+ "score_combined_samples",
200
+ "score_samples",
201
+ "serialize_history",
202
+ "serialize_message",
203
+ "snapshot_state",
204
+ "strategy_names",
205
+ "summarize_cell",
206
+ "summary_main",
207
+ "token_counts",
208
+ "unretrieved_facts",
209
+ "unsupported_option",
210
+ "wants_client_side_history",
211
+ "write_records_jsonl",
212
+ "write_summary_csv",
213
+ ]
214
+
215
+ # Experimental package: importing it emits a UserWarning rather than a FutureWarning, so a host
216
+ # running under `python -W error` can still import it.
217
+ import warnings as _warnings
218
+
219
+
220
+ class MafCachebenchExperimentalWarning(UserWarning):
221
+ """Warning category for maf-cachebench's experimental-package notice."""
222
+
223
+
224
+ def _warn_experimental() -> None:
225
+ message = (
226
+ "maf_cachebench is experimental and may change or be removed in future versions "
227
+ "without notice."
228
+ )
229
+ try:
230
+ _warnings.warn(message, category=MafCachebenchExperimentalWarning, stacklevel=2)
231
+ except MafCachebenchExperimentalWarning:
232
+ # Deliberate: under `-W error` an informational notice must not fail the import.
233
+ pass
234
+
235
+
236
+ _warn_experimental()
@@ -0,0 +1,8 @@
1
+ """Enable ``python -m maf_cachebench``."""
2
+
3
+ import sys
4
+
5
+ from ._cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1,303 @@
1
+ """Command line entry point for the per-model strategy recommendation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from ._advisor import ModelPricing, Verdict, advise, fetch_openrouter_pricing
12
+ from ._cli_selection import (
13
+ preflight_replay,
14
+ require_baseline,
15
+ select_standalone_strategies,
16
+ standalone_strategy_names,
17
+ validate_generation_caps,
18
+ validate_pricing_options,
19
+ validate_timing_options,
20
+ )
21
+ from ._metrics import summarize_cell
22
+ from ._providers import (
23
+ ProviderRuntime,
24
+ build_provider,
25
+ parse_provider_selector,
26
+ prompt_cache_key_options,
27
+ provider_names,
28
+ )
29
+ from ._run_identity import new_run_id
30
+ from ._runner import ProviderCaller, run_cell
31
+ from ._strategies import StrategyOptions, build_strategy, resolve_context_window
32
+ from ._tokenizers import TOKENIZER_NAMES, build_tokenizer
33
+ from ._transcripts import TRANSCRIPT_PRESETS, build_preset
34
+ from ._types import CellKey, CellSummary
35
+
36
+ __all__ = ["build_parser", "main", "run_advice"]
37
+
38
+ _DEFAULT_STRATEGIES = "none,truncation,context_window,context_window_aggressive"
39
+
40
+
41
+ def build_parser() -> argparse.ArgumentParser:
42
+ """Build the argument parser.
43
+
44
+ Returns:
45
+ A parser for a single-model strategy recommendation.
46
+ """
47
+ parser = argparse.ArgumentParser(
48
+ prog="cachebench-advise",
49
+ description=(
50
+ "Measure one model against several compaction strategies and recommend the "
51
+ "cheapest, or report that the model is too erratic to rank them."
52
+ ),
53
+ )
54
+ parser.add_argument(
55
+ "provider", help="Provider or provider:model, e.g. openrouter:openai/gpt-5.6-luna"
56
+ )
57
+ parser.add_argument(
58
+ "--strategies",
59
+ default=_DEFAULT_STRATEGIES,
60
+ help=f"Available: {','.join(standalone_strategy_names())}",
61
+ )
62
+ parser.add_argument(
63
+ "--size", default="mid", choices=list(TRANSCRIPT_PRESETS), help="Transcript preset."
64
+ )
65
+ parser.add_argument(
66
+ "--repeats",
67
+ type=int,
68
+ default=3,
69
+ help=(
70
+ "Replays per strategy. Two or more are needed to tell a real gap from provider noise. "
71
+ "Default 3."
72
+ ),
73
+ )
74
+ parser.add_argument(
75
+ "--price-input",
76
+ type=float,
77
+ default=None,
78
+ help="Input price per million tokens. Auto-fetched for OpenRouter models when omitted.",
79
+ )
80
+ parser.add_argument(
81
+ "--price-cached",
82
+ type=float,
83
+ default=None,
84
+ help=(
85
+ "Cached-read price per million tokens. Auto-fetched for OpenRouter models when omitted."
86
+ ),
87
+ )
88
+ parser.add_argument(
89
+ "--context-window", type=int, default=None, help="Simulated context window."
90
+ )
91
+ parser.add_argument(
92
+ "--max-output-tokens", type=int, default=512, help="Output reservation for budget math."
93
+ )
94
+ parser.add_argument(
95
+ "--tokenizer", default="estimator", choices=list(TOKENIZER_NAMES), help="Token counter."
96
+ )
97
+ parser.add_argument(
98
+ "--response-max-tokens", type=int, default=16, help="Cap on generated tokens."
99
+ )
100
+ parser.add_argument(
101
+ "--no-temperature", action="store_true", help="Omit temperature for models that reject it."
102
+ )
103
+ parser.add_argument(
104
+ "--request-timeout", type=float, default=300.0, help="Per-call timeout in seconds."
105
+ )
106
+ parser.add_argument("--turn-delay", type=float, default=0.0, help="Seconds between turns.")
107
+ parser.add_argument(
108
+ "--out", type=Path, default=None, help="Optional directory for the per-turn JSONL."
109
+ )
110
+ return parser
111
+
112
+
113
+ def _resolve_pricing(args: argparse.Namespace, provider: str, model: str) -> ModelPricing:
114
+ """Resolve pricing from the command line, or from OpenRouter's catalogue.
115
+
116
+ Raises:
117
+ SystemExit: If prices are neither supplied nor discoverable.
118
+ """
119
+ validate_pricing_options(args)
120
+ if args.price_input is not None:
121
+ return ModelPricing(
122
+ args.price_input,
123
+ args.price_cached if args.price_cached is not None else args.price_input,
124
+ )
125
+ if provider == "openrouter":
126
+ try:
127
+ return fetch_openrouter_pricing(model)
128
+ except (KeyError, OSError, ValueError) as error:
129
+ raise SystemExit(
130
+ f"Could not fetch pricing for {model!r}: {error}. Pass --price-input."
131
+ ) from error
132
+ raise SystemExit(
133
+ f"--price-input is required for provider {provider!r} (only OpenRouter pricing is "
134
+ f"auto-fetched)."
135
+ )
136
+
137
+
138
+ async def _measure(
139
+ args: argparse.Namespace, provider: str, runtime: ProviderRuntime
140
+ ) -> list[CellSummary]:
141
+ """Replay every selected strategy against the model and summarize each cell."""
142
+ tokenizer = build_tokenizer(args.tokenizer)
143
+ run_id = new_run_id()
144
+ summaries: list[CellSummary] = []
145
+ stream: Any = None
146
+ if args.out is not None:
147
+ args.out.mkdir(parents=True, exist_ok=True)
148
+ stream = (args.out / f"advise-{run_id}-records.jsonl").open("w", encoding="utf-8")
149
+
150
+ try:
151
+ for strategy_name in [
152
+ entry.strip() for entry in args.strategies.split(",") if entry.strip()
153
+ ]:
154
+ for repeat in range(1, args.repeats + 1):
155
+ cell = CellKey(
156
+ provider=provider,
157
+ model=runtime.model,
158
+ transcript=args.size,
159
+ strategy=strategy_name,
160
+ repeat=repeat,
161
+ )
162
+ salt = f"{run_id}-{cell.label}"
163
+ transcript = build_preset(args.size, salt=salt, tokenizer=tokenizer)
164
+ options = StrategyOptions(
165
+ tokenizer=tokenizer,
166
+ max_context_window_tokens=resolve_context_window(
167
+ transcript.approx_final_prompt_tokens,
168
+ override=args.context_window,
169
+ max_output_tokens=args.max_output_tokens,
170
+ ),
171
+ max_output_tokens=args.max_output_tokens,
172
+ )
173
+ print(f"-> {cell.label}", flush=True)
174
+ records = await run_cell(
175
+ cell=cell,
176
+ transcript=transcript,
177
+ strategy=build_strategy(strategy_name, options),
178
+ tokenizer=tokenizer,
179
+ caller=ProviderCaller(
180
+ runtime,
181
+ extra_options=prompt_cache_key_options(provider, salt),
182
+ request_timeout=args.request_timeout or None,
183
+ ),
184
+ on_record=(lambda record: _write(stream, record)) if stream else None,
185
+ turn_delay=args.turn_delay,
186
+ )
187
+ summaries.append(
188
+ summarize_cell(
189
+ records,
190
+ cell=cell,
191
+ reports_cache_tokens=bool(records)
192
+ and all(record.cached_tokens is not None for record in records),
193
+ )
194
+ )
195
+ finally:
196
+ if stream is not None:
197
+ stream.close()
198
+ return summaries
199
+
200
+
201
+ def _write(stream: Any, record: Any) -> None:
202
+ """Append one record to the open JSONL stream."""
203
+ import json
204
+
205
+ stream.write(json.dumps(record.to_dict(), ensure_ascii=False))
206
+ stream.write("\n")
207
+ stream.flush()
208
+
209
+
210
+ def _render(verdict: Verdict, pricing: ModelPricing, model: str) -> str:
211
+ """Render the verdict as the report a human reads."""
212
+ lines = [
213
+ "",
214
+ f"Model: {model}",
215
+ (
216
+ f"Pricing: ${pricing.input_per_million:.2f}/M input, "
217
+ f"${pricing.cached_read_per_million:.3f}/M cached ({pricing.cache_discount:.0%} off)"
218
+ ),
219
+ "",
220
+ f"{'strategy':<28}{'cost/conversation':>19}{'spread':>9}{'cache hit':>11}",
221
+ f"{'-' * 28}{'-' * 19:>19}{'-' * 9:>9}{'-' * 11:>11}",
222
+ ]
223
+ for entry in verdict.ranked:
224
+ hit = "n/a" if entry.hit_rate is None else f"{entry.hit_rate:.0%}"
225
+ marker = " <- baseline" if entry.strategy == verdict.baseline.strategy else ""
226
+ lines.append(
227
+ f"{entry.strategy:<28}{'$' + format(entry.median, '.4f'):>19}"
228
+ f"{entry.spread:>8.0%}{hit:>11}{marker}"
229
+ )
230
+
231
+ saving = verdict.saving_fraction
232
+ lines += ["", f"VERDICT: {verdict.recommended}", f"Confidence: {verdict.confidence}", ""]
233
+ if verdict.confidence == "inconclusive":
234
+ lines.append("Cost cannot separate these strategies on this model.")
235
+ elif verdict.recommended == verdict.baseline.strategy:
236
+ lines.append(
237
+ "Compaction does not pay for itself here — keep it off except as an overflow guard."
238
+ )
239
+ else:
240
+ lines.append(
241
+ f"Compacting with {verdict.recommended!r} saves {saving:.0%} against not compacting."
242
+ )
243
+ lines += [
244
+ verdict.rationale,
245
+ "",
246
+ "Cost only. Compaction's real purpose is preventing context overflow, and this says",
247
+ "nothing about whether the agent still behaves correctly once history is discarded.",
248
+ ]
249
+ return "\n".join(lines)
250
+
251
+
252
+ async def run_advice(args: argparse.Namespace) -> int:
253
+ """Measure the model and print a recommendation.
254
+
255
+ Args:
256
+ args: Parsed command line arguments.
257
+
258
+ Returns:
259
+ A process exit code.
260
+ """
261
+ strategies = select_standalone_strategies(args.strategies)
262
+ require_baseline(strategies)
263
+ validate_pricing_options(args)
264
+ validate_generation_caps(args)
265
+ validate_timing_options(args)
266
+ provider, model_override = parse_provider_selector(args.provider)
267
+ if provider not in provider_names():
268
+ raise SystemExit(f"Unknown provider {provider!r}. Available: {', '.join(provider_names())}")
269
+ if args.repeats <= 0:
270
+ raise SystemExit("--repeats must be greater than 0.")
271
+
272
+ preflight_replay(args, strategies, [args.size], build_tokenizer(args.tokenizer))
273
+ pricing = None
274
+ if provider != "openrouter" or args.price_input is not None:
275
+ pricing = _resolve_pricing(args, provider, model_override or provider)
276
+ runtime = build_provider(
277
+ provider,
278
+ temperature=None if args.no_temperature else 0.0,
279
+ response_max_tokens=args.response_max_tokens,
280
+ model=model_override,
281
+ )
282
+ model = runtime.model
283
+ if pricing is None:
284
+ pricing = _resolve_pricing(args, provider, model)
285
+ summaries = await _measure(args, provider, runtime)
286
+ try:
287
+ verdict = advise(summaries, pricing)
288
+ except ValueError as error:
289
+ raise SystemExit(f"Cannot advise: {error}") from error
290
+ print(_render(verdict, pricing, model))
291
+ return 0
292
+
293
+
294
+ def main(argv: Sequence[str] | None = None) -> int:
295
+ """Parse arguments and produce the recommendation.
296
+
297
+ Args:
298
+ argv: Argument vector. Defaults to ``sys.argv[1:]``.
299
+
300
+ Returns:
301
+ A process exit code.
302
+ """
303
+ return asyncio.run(run_advice(build_parser().parse_args(argv)))