prefixwise 0.3.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.
prefixwise/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Prefixwise: model-agnostic ledger of avoidable agent token spend."""
2
+
3
+ __version__ = "0.3.0"
4
+
5
+ from .classifier import MissCause, classify
6
+ from .diagnostics import AgreementScorer
7
+ from .fingerprint import Fingerprinter, fingerprint_blocks
8
+ from .ledger import Ledger
9
+
10
+ __all__ = ["__version__", "MissCause", "classify", "AgreementScorer", "Fingerprinter", "fingerprint_blocks", "Ledger"]
prefixwise/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,283 @@
1
+ """Miss-cause classifier (v2 taxonomy, 19 causes).
2
+
3
+ Exactly one cause per request: the first divergence in prefix order.
4
+ ``UNKNOWN`` when evidence is absent, never a guess.
5
+
6
+ Legitimacy is a separate column from cause: a legitimate change still
7
+ costs tokens, but it is not waste. A policy can later mark a catalog
8
+ change avoidable (masking instead of removing) without renaming the cause.
9
+
10
+ Provider order (verified, not assumed):
11
+ - Anthropic: tools, system, messages.
12
+ - OpenAI: tools are injected before developer instructions, so
13
+ tools, developer/system, messages. (OpenAI Prompt Caching 201.)
14
+
15
+ Rules run in v2 order; the first that fires wins, the rest are recorded
16
+ as secondary. Provider native reasons (Anthropic 6, OpenAI 9) map onto
17
+ this taxonomy in diagnostics.py; they are a partial agreement signal,
18
+ not ground truth: providers report the earliest divergence only, in a
19
+ coarse taxonomy, and neither names TTL expiry, routing, or eviction.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass, field
25
+ from enum import Enum
26
+
27
+ from .fingerprint import BlockFingerprint
28
+
29
+
30
+ class MissCause(str, Enum):
31
+ FIRST_WRITE = "first_write"
32
+ MODEL_SWITCH = "model_switch"
33
+ VOLATILE_SYSTEM = "volatile_system"
34
+ SYSTEM_EDIT = "system_edit"
35
+ TOOL_RESERIALIZE = "tool_reserialize"
36
+ TOOL_CATALOG_CHANGE = "tool_catalog_change"
37
+ COMPACTION_REWRITE = "compaction_rewrite"
38
+ HISTORY_EDIT = "history_edit"
39
+ TOOL_RESULT_MUTATION = "tool_result_mutation"
40
+ FORK_BOUNDARY = "fork_boundary"
41
+ PARAM_RENDERED = "param_rendered"
42
+ BELOW_MIN_PREFIX = "below_min_prefix"
43
+ BREAKPOINT_MISPLACED = "breakpoint_misplaced"
44
+ TTL_EXPIRED = "ttl_expired"
45
+ EVICTED = "evicted"
46
+ ROUTING_MISS = "routing_miss"
47
+ RETRY_REPLAY = "retry_replay"
48
+ NONE = "none"
49
+ UNKNOWN = "unknown"
50
+
51
+
52
+ # Provider minimum cacheable prefix, tokens. Anthropic is model-dependent:
53
+ # 512-4,096 (512 for Claude Fable/Mythos/Opus/Sonnet/Haiku 5.x, 2,048 for
54
+ # Mythos Preview / Opus 4.7, 4,096 for Opus 4.6/4.5 and Haiku 4.5,
55
+ # 1,024 for Opus 4.8 / Sonnet 5 / Sonnet 4.6). Use the model-specific
56
+ # value when known; the dict below is the per-model table, not a range.
57
+ ANTHROPIC_MIN_PREFIX = {
58
+ "512": 512, "2048": 2048, "4096": 4096, "1024": 1024,
59
+ }
60
+ OPENAI_MIN_PREFIX = 1024
61
+ # No provider keeps a cache entry for less than ~5 minutes (Anthropic default
62
+ # 5 min; OpenAI in-memory 5-10 min). A cold cache within this gap of the
63
+ # last turn is not explained by expiry.
64
+ MIN_PROVIDER_TTL_SECONDS = 300
65
+ GEMINI_MIN_PREFIX = 4096 # Gemini 3.x; 2,048 on 2.5. ai.google.dev, Nov 2026.
66
+
67
+
68
+ @dataclass
69
+ class RequestSnapshot:
70
+ model: str
71
+ provider: str = "unknown"
72
+ blocks: list[BlockFingerprint] = field(default_factory=list)
73
+ # Prompt-affecting params that change the rendered request without
74
+ # changing any block: tool_choice, thinking/reasoning, output_format,
75
+ # context_management, beta headers, temperature is NOT one of these.
76
+ params: dict = field(default_factory=dict)
77
+ prompt_tokens: int = 0
78
+ cached_tokens: int = 0
79
+ # Seconds since the previous turn in this session; None = unknown.
80
+ gap_seconds: float | None = None
81
+ ttl_seconds: float | None = None
82
+ compaction_event: bool = False
83
+ fork_parent_session_id: str | None = None
84
+ session_id: str | None = None
85
+ # vLLM KV events for this session between the two turns.
86
+ evicted_block_hashes: list[str] = field(default_factory=list)
87
+ # Anthropic cache_control present anywhere in the request.
88
+ has_breakpoint: bool = False
89
+ breakpoint_after_volatile: bool = False
90
+ min_prefix_tokens: int | None = None
91
+
92
+
93
+ @dataclass
94
+ class Classification:
95
+ cause: MissCause
96
+ legit: bool | None # True = legitimate, False = waste, None = n/a
97
+ evidence: str
98
+ secondary: list[MissCause] = field(default_factory=list)
99
+ native_reason: str | None = None
100
+ classifier_version: str = "2.0"
101
+
102
+
103
+ def _blocks_by_role(blocks: list[BlockFingerprint]) -> dict[str, list[BlockFingerprint]]:
104
+ out: dict[str, list[BlockFingerprint]] = {}
105
+ for b in blocks:
106
+ out.setdefault(b.role, []).append(b)
107
+ return out
108
+
109
+
110
+ def _tool_multiset(blocks: list[BlockFingerprint]) -> list[str]:
111
+ return sorted(b.canonical for b in blocks if b.role == "tools")
112
+
113
+
114
+ def _same(a: BlockFingerprint, b: BlockFingerprint) -> bool:
115
+ return a.raw == b.raw and a.canonical == b.canonical and a.masked == b.masked
116
+
117
+
118
+ # Roles that grow by appending turn over turn. For these, the previous
119
+ # turn's blocks must be an unchanged prefix of the current turn's; new
120
+ # blocks after that prefix are ordinary conversation growth, not a
121
+ # divergence. System and tool blocks must match exactly.
122
+ _APPEND_ROLES = ("messages", "tool_results", "user")
123
+
124
+
125
+ def _role_unchanged(role: str, p: list[BlockFingerprint], c: list[BlockFingerprint]) -> bool:
126
+ if role in _APPEND_ROLES:
127
+ return len(p) <= len(c) and all(_same(a, b) for a, b in zip(p, c))
128
+ return len(p) == len(c) and all(_same(a, b) for a, b in zip(p, c))
129
+
130
+
131
+ def classify(prev: RequestSnapshot | None, curr: RequestSnapshot,
132
+ *, native_reason: str | None = None) -> Classification:
133
+ """Classify one turn against the previous turn in the same session."""
134
+ if prev is None:
135
+ if curr.fork_parent_session_id:
136
+ return Classification(MissCause.FORK_BOUNDARY, True,
137
+ f"first turn of a session forked from {curr.fork_parent_session_id}",
138
+ native_reason=native_reason)
139
+ return Classification(MissCause.FIRST_WRITE, True, "no prior request in session",
140
+ native_reason=native_reason)
141
+ if prev.model != curr.model:
142
+ return Classification(MissCause.MODEL_SWITCH, True,
143
+ f"model {prev.model} -> {curr.model}", native_reason=native_reason)
144
+
145
+ prev_by_role = _blocks_by_role(prev.blocks)
146
+ curr_by_role = _blocks_by_role(curr.blocks)
147
+ secondary: list[MissCause] = []
148
+
149
+ # First divergence in prefix order: system, tools, messages, tool_results, user.
150
+ for role in ("system", "system_dynamic", "tools", "messages", "tool_results", "user"):
151
+ p, c = prev_by_role.get(role, []), curr_by_role.get(role, [])
152
+ if _role_unchanged(role, p, c):
153
+ continue
154
+ if role in ("system", "system_dynamic"):
155
+ if [b.masked for b in p] == [b.masked for b in c]:
156
+ return Classification(MissCause.VOLATILE_SYSTEM, False,
157
+ "system raw hash changed, masked hash equal (dates/ids/numbers only)",
158
+ secondary, native_reason)
159
+ return Classification(MissCause.SYSTEM_EDIT, None,
160
+ "system masked hash changed", secondary, native_reason)
161
+ if role == "tools":
162
+ if _tool_multiset(prev.blocks) == _tool_multiset(curr.blocks):
163
+ return Classification(MissCause.TOOL_RESERIALIZE, False,
164
+ "tool multiset of canonical hashes equal; sequence or raw bytes differ",
165
+ secondary, native_reason)
166
+ return Classification(MissCause.TOOL_CATALOG_CHANGE, True,
167
+ "tool multiset differs", secondary, native_reason)
168
+ if role == "messages":
169
+ cause = MissCause.COMPACTION_REWRITE if curr.compaction_event else MissCause.HISTORY_EDIT
170
+ legit = True if curr.compaction_event else False
171
+ ev = "previous message blocks rewritten (not a prefix of this turn) with a compaction event" \
172
+ if curr.compaction_event else \
173
+ "previous message blocks rewritten (not a prefix of this turn) without a compaction event"
174
+ return Classification(cause, legit, ev, secondary, native_reason)
175
+ if role == "tool_results":
176
+ if curr.compaction_event:
177
+ # Context editing that clears old tool results is a compaction.
178
+ return Classification(MissCause.COMPACTION_REWRITE, True,
179
+ "earlier tool results cleared or rewritten with a compaction event",
180
+ secondary, native_reason)
181
+ return Classification(MissCause.TOOL_RESULT_MUTATION, False,
182
+ "an earlier tool result changed or was removed without a compaction event",
183
+ secondary, native_reason)
184
+ # user role divergence with no other cause: fall through to param/TTL checks.
185
+ secondary.append(MissCause.HISTORY_EDIT)
186
+
187
+ # Blocks equal (or only the user turn changed): check params, prefix, TTL, eviction.
188
+ if prev.params != curr.params:
189
+ changed = sorted(set(prev.params) ^ set(curr.params) |
190
+ {k for k in prev.params.keys() & curr.params.keys() if prev.params[k] != curr.params[k]})
191
+ return Classification(MissCause.PARAM_RENDERED, None,
192
+ f"blocks equal; prompt-affecting params changed: {changed}",
193
+ secondary, native_reason)
194
+
195
+ min_prefix = curr.min_prefix_tokens
196
+ if min_prefix is None:
197
+ min_prefix = {"openai": OPENAI_MIN_PREFIX, "gemini": GEMINI_MIN_PREFIX}.get(curr.provider, None)
198
+ # 0 prompt tokens means usage was absent, not a short prompt: fall
199
+ # through to UNKNOWN rather than claim BELOW_MIN_PREFIX.
200
+ if min_prefix is not None and 0 < curr.prompt_tokens < min_prefix:
201
+ return Classification(MissCause.BELOW_MIN_PREFIX, None,
202
+ f"cacheable prefix {curr.prompt_tokens} below provider minimum {min_prefix}",
203
+ secondary, native_reason)
204
+
205
+ if curr.provider == "anthropic" and not curr.has_breakpoint:
206
+ return Classification(MissCause.BREAKPOINT_MISPLACED, False,
207
+ "Anthropic: no cache_control anywhere, or breakpoint after a volatile block",
208
+ secondary, native_reason)
209
+ if curr.breakpoint_after_volatile:
210
+ return Classification(MissCause.BREAKPOINT_MISPLACED, False,
211
+ "breakpoint sits after a volatile block", secondary, native_reason)
212
+
213
+ # Every rule below reads usage. Usage absent (0 prompt tokens, seen live
214
+ # on OpenAI 2026-11-06) is no evidence of a hit, a retry or a routing
215
+ # miss: say UNKNOWN rather than guess from a zero.
216
+ if curr.prompt_tokens <= 0:
217
+ return Classification(MissCause.UNKNOWN, None,
218
+ "blocks unchanged but the provider returned no usage; cannot tell hit from miss",
219
+ secondary, native_reason)
220
+
221
+ # Append-only turn: compare cache_read to the reusable prior prefix,
222
+ # not to this turn's prompt, which includes the newly appended tail.
223
+ # A real hit wins over every timing rule below: if the cache served
224
+ # the prefix, it did not expire and was not evicted.
225
+ reusable = min(prev.prompt_tokens, curr.prompt_tokens) if prev.prompt_tokens else curr.prompt_tokens
226
+ if curr.prompt_tokens > 0 and curr.cached_tokens >= reusable * 0.5:
227
+ return Classification(MissCause.NONE, None,
228
+ "append-only turn; cache_read at least half of the prior prefix",
229
+ secondary, native_reason)
230
+
231
+ # From here the prefix was reusable but cache_read was low.
232
+ # Engine eviction events are direct evidence; they outrank inference.
233
+ if curr.evicted_block_hashes:
234
+ return Classification(MissCause.EVICTED, None,
235
+ "BlockRemoved events for this session's blocks between the two turns",
236
+ secondary, native_reason)
237
+
238
+ # Identical replay within a short window is a retry, not a cache miss.
239
+ if prev.blocks and curr.blocks and \
240
+ [b.raw for b in prev.blocks] == [b.raw for b in curr.blocks] and \
241
+ curr.gap_seconds is not None and curr.gap_seconds < 60 and curr.cached_tokens == 0:
242
+ return Classification(MissCause.RETRY_REPLAY, False,
243
+ "identical request within the retry window", secondary, native_reason)
244
+
245
+ gap, ttl = curr.gap_seconds, curr.ttl_seconds
246
+ if ttl is not None and gap is not None and gap > ttl:
247
+ return Classification(MissCause.TTL_EXPIRED, True,
248
+ f"gap {gap:.0f}s since last turn exceeds the cache TTL {ttl:.0f}s",
249
+ secondary, native_reason)
250
+
251
+ if curr.cached_tokens == 0 and curr.prompt_tokens > 0 and gap is not None and \
252
+ (gap <= (ttl if ttl is not None else MIN_PROVIDER_TTL_SECONDS)):
253
+ window = f"TTL {ttl:.0f}s" if ttl is not None else f"shortest provider TTL {MIN_PROVIDER_TTL_SECONDS}s"
254
+ return Classification(MissCause.ROUTING_MISS, None,
255
+ f"blocks and params equal, gap {gap:.0f}s within {window}, "
256
+ "no eviction evidence, nothing read from cache",
257
+ secondary, native_reason)
258
+ if curr.prompt_tokens > 0 and gap is not None and ttl is None:
259
+ return Classification(MissCause.UNKNOWN, None,
260
+ f"prefix reusable but cache_read low after a {gap:.0f}s gap with unknown TTL:"
261
+ " expiry or routing, not distinguishable",
262
+ secondary, native_reason)
263
+ return Classification(MissCause.UNKNOWN, None,
264
+ "prefix reusable but cache_read low; no timing or usage evidence to name a cause",
265
+ secondary, native_reason)
266
+
267
+
268
+ def classify_first_divergence(changed_role: str | None, *, model_changed: bool = False,
269
+ compaction_event: bool = False) -> MissCause:
270
+ """Backwards-compatible helper from v1 (13-cause API)."""
271
+ if model_changed:
272
+ return MissCause.MODEL_SWITCH
273
+ if changed_role is None:
274
+ return MissCause.FIRST_WRITE
275
+ if changed_role in ("system", "system_dynamic"):
276
+ return MissCause.VOLATILE_SYSTEM
277
+ if changed_role == "tools":
278
+ return MissCause.TOOL_RESERIALIZE
279
+ if changed_role == "messages":
280
+ return MissCause.COMPACTION_REWRITE if compaction_event else MissCause.HISTORY_EDIT
281
+ if changed_role == "tool_results":
282
+ return MissCause.TOOL_RESULT_MUTATION
283
+ return MissCause.UNKNOWN
prefixwise/cli.py ADDED
@@ -0,0 +1,338 @@
1
+ """The `prefixwise` command line.
2
+
3
+ prefixwise serve run the proxy (ledger on disk by default)
4
+ prefixwise report where tokens went: totals, causes, sessions, tasks
5
+ prefixwise task record a task outcome (success / failure)
6
+ prefixwise request show one request's verdict by request id
7
+ prefixwise explain offline: why does the cache miss between requests?
8
+ prefixwise fixes which fixes would save the most, from your own ledger
9
+ prefixwise prices the dated price table used for dollar costs
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from . import __version__
21
+
22
+ DEFAULT_LEDGER = "prefixwise.db"
23
+
24
+
25
+ def _ledger_path(args) -> str:
26
+ return args.ledger or os.environ.get("PREFIXWISE_LEDGER_PATH") or DEFAULT_LEDGER
27
+
28
+
29
+ def _open_existing_ledger(args):
30
+ from .ledger import Ledger
31
+
32
+ path = _ledger_path(args)
33
+ if path != ":memory:" and not Path(path).exists():
34
+ sys.exit(f"prefixwise: no ledger at {path} (run `prefixwise serve` first, or pass --ledger)")
35
+ return Ledger(path)
36
+
37
+
38
+ def _fmt_int(n) -> str:
39
+ return "-" if n is None else f"{int(n):,}"
40
+
41
+
42
+ def _fmt_usd(x) -> str:
43
+ if x is None:
44
+ return "-"
45
+ return f"${x:,.2f}" if abs(x) >= 1 else f"${x:,.4f}"
46
+
47
+
48
+ def _fmt_pct(x) -> str:
49
+ return "-" if x is None else f"{x:.1%}"
50
+
51
+
52
+ def _table(rows: list[list[str]], headers: list[str]) -> str:
53
+ widths = [max(len(str(c)) for c in col) for col in zip(headers, *rows)]
54
+ def line(cells):
55
+ return " ".join(str(c).ljust(w) if i == 0 else str(c).rjust(w)
56
+ for i, (c, w) in enumerate(zip(cells, widths)))
57
+ return "\n".join([line(headers), line(["-" * w for w in widths])] + [line(r) for r in rows])
58
+
59
+
60
+ # -- commands ---------------------------------------------------------------------------
61
+
62
+ def cmd_serve(args) -> int:
63
+ from .proxy import serve
64
+
65
+ ledger = args.ledger or os.environ.get("PREFIXWISE_LEDGER_PATH") or DEFAULT_LEDGER
66
+ try:
67
+ serve(host=args.host, port=args.port, ledger_path=ledger,
68
+ upstream_url=args.upstream, mock=True if args.mock else None,
69
+ fixes=args.fix or None, allow_unauthenticated=args.allow_unauthenticated,
70
+ prices=args.prices)
71
+ except ValueError as exc: # unknown --fix name
72
+ sys.exit(f"prefixwise: {exc}")
73
+ return 0
74
+
75
+
76
+ def cmd_fixes(args) -> int:
77
+ from .fixes import FIX_ADVICE, FIX_STATUS
78
+
79
+ led = _open_existing_ledger(args)
80
+ summary = led.summary()
81
+ rows = {r["cause"]: r for r in led.rollup("cause")}
82
+ with led._lock:
83
+ fixed_requests = dict(led.conn.execute(
84
+ "SELECT fixes, COUNT(*) FROM entries WHERE fixes IS NOT NULL GROUP BY fixes").fetchall())
85
+ recs = []
86
+ for cause, advice in FIX_ADVICE.items():
87
+ r = rows.get(cause)
88
+ if not r or not r["avoidable_tokens"]:
89
+ continue
90
+ recs.append({"cause": cause, "requests": r["requests"], "predicted_savings_tokens": r["avoidable_tokens"],
91
+ "predicted_savings_usd": r["avoidable_cost"],
92
+ "unpriced_requests": r["requests"] - r["priced_requests"],
93
+ "share_of_prompt_tokens": (r["avoidable_tokens"] / summary["prompt_tokens"])
94
+ if summary["prompt_tokens"] else None,
95
+ "fix": advice["fix"], "proxy_toggle": advice["toggle"],
96
+ "status": (FIX_STATUS.get(advice["toggle"] or "", {}).get("status")
97
+ or "recommended (predicted; prove it with experiments/e4)"),
98
+ "evidence": FIX_STATUS.get(advice["toggle"] or "", {}).get("evidence")})
99
+ recs.sort(key=lambda x: -x["predicted_savings_tokens"])
100
+ if args.json:
101
+ print(json.dumps({"recommendations": recs, "requests_rewritten_by_fixes": fixed_requests}, indent=2))
102
+ return 0
103
+ if not recs:
104
+ print("No avoidable waste with a known fix in this ledger.")
105
+ for i, r in enumerate(recs, 1):
106
+ toggle = f" proxy can apply it: prefixwise serve --fix {r['proxy_toggle']}" if r["proxy_toggle"] else ""
107
+ print(f"{i}. {r['cause']}: {_fmt_int(r['predicted_savings_tokens'])} avoidable tokens "
108
+ f"({_fmt_pct(r['share_of_prompt_tokens'])} of prompt tokens) over {r['requests']} "
109
+ f"request{'s' if r['requests'] != 1 else ''}")
110
+ if r["predicted_savings_usd"] is not None:
111
+ unpriced = f" ({r['unpriced_requests']} requests unpriced)" if r["unpriced_requests"] else ""
112
+ print(f" worth: {_fmt_usd(r['predicted_savings_usd'])}{unpriced}")
113
+ print(f" fix: {r['fix']}")
114
+ if toggle:
115
+ print(toggle)
116
+ if r["evidence"]:
117
+ print(f" {r['status']}: {r['evidence']}")
118
+ if fixed_requests:
119
+ print("\nAlready active: " + ", ".join(f"{k} rewrote {v} requests" for k, v in fixed_requests.items()))
120
+ return 0
121
+
122
+
123
+ def cmd_report(args) -> int:
124
+ led = _open_existing_ledger(args)
125
+ summary = led.summary()
126
+ rows = led.rollup(args.by)[: args.limit]
127
+ if args.json:
128
+ print(json.dumps({"summary": summary, "by": args.by, "rows": rows}, indent=2))
129
+ return 0
130
+ t = summary["tasks"]
131
+ print(f"Requests {_fmt_int(summary['requests'])}")
132
+ print(f"Prompt tokens {_fmt_int(summary['prompt_tokens'])}"
133
+ f" (cache hit rate {_fmt_pct(summary['cache_hit_rate'])})")
134
+ print(f"Completion tokens {_fmt_int(summary['completion_tokens'])}")
135
+ print(f"Avoidable tokens {_fmt_int(summary['avoidable_tokens'])}"
136
+ f" ({_fmt_pct(summary['avoidable_share'])} of prompt tokens)")
137
+ tpst = summary["tokens_per_successful_task"]
138
+ print(f"Tasks {t['successful']} succeeded / {t['resolved']} resolved")
139
+ print(f"Tokens per successful task {_fmt_int(tpst) if tpst is not None else '- (no task outcomes recorded)'}")
140
+ priced, n = summary["priced_requests"], summary["requests"]
141
+ if priced:
142
+ coverage = "" if priced == n else f" ({priced:,} of {n:,} requests priced; see `prefixwise prices`)"
143
+ print(f"Cost {_fmt_usd(summary['cost'])}{coverage}")
144
+ print(f"Avoidable cost {_fmt_usd(summary['avoidable_cost'])}")
145
+ cpst = summary["cost_per_successful_task"]
146
+ print(f"Cost per successful task {_fmt_usd(cpst) if cpst is not None else '- (needs task outcomes, all requests priced)'}")
147
+ elif n:
148
+ print("Cost - (no request's model is in the price table; see `prefixwise prices`)")
149
+ if not rows:
150
+ return 0
151
+ print()
152
+ print(_table([[r[args.by] if r[args.by] is not None else "(none)", _fmt_int(r["requests"]),
153
+ _fmt_int(r["prompt_tokens"]), _fmt_pct(r["cached_tokens"] / r["prompt_tokens"])
154
+ if r["prompt_tokens"] else "-", _fmt_int(r["avoidable_tokens"]),
155
+ _fmt_int(r["waste_requests"]), _fmt_int(r["tokens_per_successful_task"]),
156
+ _fmt_usd(r["cost"]) if r["priced_requests"] else "-",
157
+ _fmt_usd(r["avoidable_cost"]) if r["priced_requests"] else "-"]
158
+ for r in rows],
159
+ [args.by, "requests", "prompt", "cached", "avoidable", "waste reqs",
160
+ "tokens/success", "cost", "avoidable $"]))
161
+ return 0
162
+
163
+
164
+ def cmd_prices(args) -> int:
165
+ from .pricing import PriceTable
166
+
167
+ try:
168
+ table = PriceTable.load(args.prices)
169
+ except (OSError, ValueError) as exc:
170
+ sys.exit(f"prefixwise: {exc}")
171
+ if args.json:
172
+ print(json.dumps({"as_of": table.as_of, "sources": table.sources, "models": table.models}, indent=2))
173
+ return 0
174
+ print(f"Price table as of {table.as_of} (USD per 1M tokens), from: {' + '.join(table.sources)}")
175
+ rows = [[name, r.get("provider", "-"), _rate(r.get("input")), _rate(r.get("cache_read")),
176
+ _rate(r.get("cache_write_5m", r.get("cache_write"))), _rate(r.get("cache_write_1h")),
177
+ _rate(r.get("output")), "yes" if r.get("long_prompt") else ""]
178
+ for name, r in sorted(table.models.items())]
179
+ print(_table(rows, ["model", "provider", "input", "cache read", "write 5m", "write 1h", "output", "tiered"]))
180
+ print("\nModels not listed are unpriced: their cost shows as '-', never estimated. Add rates with\n"
181
+ "PREFIXWISE_PRICES=/path/prices.json (same format as the built-in table; see `--json`).")
182
+ return 0
183
+
184
+
185
+ def _rate(v) -> str:
186
+ return "-" if v is None else (v if isinstance(v, str) else f"{v:g}")
187
+
188
+
189
+ def cmd_task(args) -> int:
190
+ from .ledger import Ledger
191
+
192
+ Ledger(_ledger_path(args)).set_task_outcome(args.task_id, args.outcome == "success")
193
+ print(f"task {args.task_id}: {args.outcome}")
194
+ return 0
195
+
196
+
197
+ def cmd_request(args) -> int:
198
+ row = _open_existing_ledger(args).get_request(args.request_id)
199
+ if row is None:
200
+ print(f"prefixwise: no request {args.request_id} in the ledger", file=sys.stderr)
201
+ return 1
202
+ if args.json:
203
+ print(json.dumps(row, indent=2))
204
+ else:
205
+ for k, v in row.items():
206
+ print(f"{k:20} {v}")
207
+ return 0
208
+
209
+
210
+ def _print_explanation(label: str, e) -> None:
211
+ legit = {True: "legitimate", False: "WASTE", None: "undetermined"}[e.legit]
212
+ if e.cause == "none":
213
+ legit = "ok, prefix reused"
214
+ print(f"{label}: {e.cause.upper()} [{legit}]")
215
+ if e.divergence:
216
+ d = e.divergence
217
+ print(f" first difference: block {d['index']} ({d['role']}): {d['kind']}")
218
+ print(f" why: {e.evidence}")
219
+ if e.prompt_tokens is not None:
220
+ print(f" usage: {_fmt_int(e.cached_tokens)} of {_fmt_int(e.prompt_tokens)} prompt tokens cached")
221
+
222
+
223
+ def cmd_explain(args) -> int:
224
+ from .explain import explain_pair, explain_session, load_turns
225
+
226
+ provider = None if args.provider == "auto" else args.provider
227
+ try:
228
+ if len(args.files) == 2:
229
+ prev, curr = (load_turns(f) for f in args.files)
230
+ if len(prev) != 1 or len(curr) != 1:
231
+ sys.exit("prefixwise: with two files, each must hold exactly one request")
232
+ results = [explain_pair(prev[0], curr[0], provider)]
233
+ labels = [f"{args.files[0]} -> {args.files[1]}"]
234
+ elif len(args.files) == 1:
235
+ turns = load_turns(args.files[0])
236
+ results = explain_session(turns, provider)
237
+ labels = [f"turn {i + 1} ({t.session_id})" for i, t in enumerate(turns)]
238
+ else:
239
+ sys.exit("prefixwise: give one session file (JSON / JSONL) or two request files")
240
+ except (OSError, ValueError) as exc:
241
+ sys.exit(f"prefixwise: {exc}")
242
+ waste = sum(1 for r in results if r.legit is False)
243
+ fail = 2 if (args.fail_on_waste and waste) else 0
244
+ if args.json:
245
+ print(json.dumps([r.to_dict() for r in results], indent=2))
246
+ return fail
247
+ if results and results[0].offline:
248
+ print("(offline: no usage given, so this explains what in the request breaks reuse;"
249
+ " TTL, eviction and routing need real usage)\n")
250
+ for label, r in zip(labels, results):
251
+ _print_explanation(label, r)
252
+ if len(results) > 1:
253
+ print(f"\n{len(results)} turns, {waste} wasteful")
254
+ return fail
255
+
256
+
257
+ # -- parser -----------------------------------------------------------------------------
258
+
259
+ def build_parser() -> argparse.ArgumentParser:
260
+ ap = argparse.ArgumentParser(prog="prefixwise",
261
+ description="Name why prompt caches miss, and price the miss.")
262
+ ap.add_argument("--version", action="version", version=f"prefixwise {__version__}")
263
+ sub = ap.add_subparsers(dest="command", required=True)
264
+
265
+ def ledger_opt(p):
266
+ p.add_argument("--ledger", help=f"SQLite ledger file (default: $PREFIXWISE_LEDGER_PATH or ./{DEFAULT_LEDGER})")
267
+
268
+ p = sub.add_parser("serve", help="run the proxy")
269
+ p.add_argument("--host", default=os.environ.get("PREFIXWISE_HOST", "127.0.0.1"))
270
+ p.add_argument("--port", type=int, default=int(os.environ.get("PORT", "8080")))
271
+ p.add_argument("--upstream", help="upstream base URL, e.g. https://api.openai.com "
272
+ "(default: $PREFIXWISE_UPSTREAM_URL; none = mock mode). "
273
+ "API key: $PREFIXWISE_API_KEY, or the client's own auth header.")
274
+ p.add_argument("--mock", action="store_true", help="synthetic responses, no upstream calls")
275
+ p.add_argument("--prices", metavar="FILE",
276
+ help="extra or overriding rates (JSON, same format as `prefixwise prices --json`); "
277
+ "default: $PREFIXWISE_PRICES")
278
+ p.add_argument("--allow-unauthenticated", action="store_true",
279
+ help="listen beyond localhost with PREFIXWISE_API_KEY set but no PREFIXWISE_AUTH_TOKEN "
280
+ "(anyone who reaches the port can spend the key)")
281
+ p.add_argument("--fix", action="append", metavar="NAME",
282
+ help="apply a fix to every request before forwarding (repeatable). "
283
+ "Available: canonical_tools. Default: $PREFIXWISE_FIXES or none")
284
+ ledger_opt(p)
285
+ p.set_defaults(func=cmd_serve)
286
+
287
+ p = sub.add_parser("report", help="summarise the ledger")
288
+ p.add_argument("--by", default="cause", choices=["cause", "session_id", "agent_id", "model",
289
+ "provider", "task_id"])
290
+ p.add_argument("--limit", type=int, default=20)
291
+ p.add_argument("--json", action="store_true")
292
+ ledger_opt(p)
293
+ p.set_defaults(func=cmd_report)
294
+
295
+ p = sub.add_parser("prices", help="the dated price table used for dollar costs")
296
+ p.add_argument("--prices", metavar="FILE", help="also load this rates file (default: $PREFIXWISE_PRICES)")
297
+ p.add_argument("--json", action="store_true")
298
+ p.set_defaults(func=cmd_prices)
299
+
300
+ p = sub.add_parser("fixes", help="fixes ranked by the tokens they would save, from your ledger")
301
+ p.add_argument("--json", action="store_true")
302
+ ledger_opt(p)
303
+ p.set_defaults(func=cmd_fixes)
304
+
305
+ p = sub.add_parser("task", help="record a task outcome")
306
+ p.add_argument("task_id")
307
+ g = p.add_mutually_exclusive_group(required=True)
308
+ g.add_argument("--success", dest="outcome", action="store_const", const="success")
309
+ g.add_argument("--failure", dest="outcome", action="store_const", const="failure")
310
+ ledger_opt(p)
311
+ p.set_defaults(func=cmd_task)
312
+
313
+ p = sub.add_parser("request", help="show one request's ledger entry")
314
+ p.add_argument("request_id", help="the X-Prefixwise-Request-Id response header")
315
+ p.add_argument("--json", action="store_true")
316
+ ledger_opt(p)
317
+ p.set_defaults(func=cmd_request)
318
+
319
+ p = sub.add_parser("explain", help="offline: why does the cache miss between requests?",
320
+ description="Explain cache misses offline. Give two request files (JSON), "
321
+ "or one session file: a JSON array or JSONL of request bodies, "
322
+ 'or of {"request": body, "usage": {...}} records.')
323
+ p.add_argument("files", nargs="+")
324
+ p.add_argument("--provider", default="auto", choices=["auto", "openai", "anthropic", "vllm"])
325
+ p.add_argument("--json", action="store_true")
326
+ p.add_argument("--fail-on-waste", action="store_true",
327
+ help="exit 2 if any turn is classified as waste (for CI gates on prompt changes)")
328
+ p.set_defaults(func=cmd_explain)
329
+ return ap
330
+
331
+
332
+ def main(argv: list[str] | None = None) -> int:
333
+ args = build_parser().parse_args(argv)
334
+ return args.func(args)
335
+
336
+
337
+ if __name__ == "__main__":
338
+ raise SystemExit(main())