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.
- maf_cachebench/__init__.py +236 -0
- maf_cachebench/__main__.py +8 -0
- maf_cachebench/_advise_cli.py +303 -0
- maf_cachebench/_advisor.py +461 -0
- maf_cachebench/_cli.py +456 -0
- maf_cachebench/_cli_selection.py +194 -0
- maf_cachebench/_fill.py +454 -0
- maf_cachebench/_live.py +2319 -0
- maf_cachebench/_live_cli.py +4371 -0
- maf_cachebench/_metrics.py +176 -0
- maf_cachebench/_providers.py +424 -0
- maf_cachebench/_recall.py +767 -0
- maf_cachebench/_recall_cli.py +238 -0
- maf_cachebench/_records.py +1771 -0
- maf_cachebench/_report.py +162 -0
- maf_cachebench/_run_identity.py +9 -0
- maf_cachebench/_runner.py +529 -0
- maf_cachebench/_strategies.py +745 -0
- maf_cachebench/_summary.py +335 -0
- maf_cachebench/_summary_cli.py +341 -0
- maf_cachebench/_tokenizers.py +200 -0
- maf_cachebench/_transcripts.py +387 -0
- maf_cachebench/_types.py +200 -0
- maf_cachebench/py.typed +0 -0
- maf_cachebench-0.1.0.dist-info/METADATA +125 -0
- maf_cachebench-0.1.0.dist-info/RECORD +29 -0
- maf_cachebench-0.1.0.dist-info/WHEEL +4 -0
- maf_cachebench-0.1.0.dist-info/entry_points.txt +7 -0
- maf_cachebench-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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)))
|