cachemaxxing 0.1.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ashhad Ahsan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: cachemaxxing
3
+ Version: 0.1.0
4
+ Summary: Stop paying full price for the same tokens: measure, diagnose and fix prompt-cache misses on Anthropic, OpenAI, Gemini and LiteLLM.
5
+ Keywords: llm,prompt-caching,anthropic,openai,gemini,litellm,cost
6
+ Author: Ashhad Ahsan
7
+ Author-email: Ashhad Ahsan <ashhadahsan@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Programming Language :: Python :: 3.15
18
+ Classifier: Typing :: Typed
19
+ Requires-Dist: anthropic>=2.3 ; extra == 'anthropic'
20
+ Requires-Dist: google-genai>=2.34 ; extra == 'gemini'
21
+ Requires-Dist: litellm>=1.106 ; extra == 'litellm'
22
+ Requires-Dist: openai>=3.41 ; extra == 'openai'
23
+ Requires-Python: >=3.11
24
+ Project-URL: Homepage, https://ashhadahsan.github.io/cachemaxxing/
25
+ Project-URL: Source, https://github.com/ashhadahsan/cachemaxxing
26
+ Project-URL: Changelog, https://github.com/ashhadahsan/cachemaxxing/blob/main/CHANGELOG.md
27
+ Provides-Extra: anthropic
28
+ Provides-Extra: gemini
29
+ Provides-Extra: litellm
30
+ Provides-Extra: openai
31
+ Description-Content-Type: text/markdown
32
+
33
+ # cachemaxxing
34
+
35
+ [![PyPI](https://img.shields.io/pypi/v/cachemaxxing)](https://pypi.org/project/cachemaxxing/)
36
+ [![Python](https://img.shields.io/pypi/pyversions/cachemaxxing)](https://pypi.org/project/cachemaxxing/)
37
+ [![CI](https://github.com/ashhadahsan/cachemaxxing/actions/workflows/ci.yml/badge.svg)](https://github.com/ashhadahsan/cachemaxxing/actions/workflows/ci.yml)
38
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/ashhadahsan/cachemaxxing/blob/main/LICENSE)
39
+
40
+ **Stop paying full price for the same tokens.**
41
+
42
+ Anthropic, OpenAI and Gemini bill a repeated prompt prefix at a fraction of the input price, but only when the bytes really repeat. cachemaxxing finds where they don't, tells you what it costs, and lays your prompts out so they do.
43
+
44
+ - **Measure.** `usage()` normalizes every provider's usage, and `cachemaxxing report` gives hit rate and $ per call site.
45
+ - **Diagnose.** `cachemaxxing diff` shows the first byte where two requests diverge, and why.
46
+ - **Fix.** `Prompt` orders segments from most to least stable and places each provider's cache markers.
47
+
48
+ ```python
49
+ from cachemaxxing import Prompt, usage
50
+
51
+ p = Prompt(call_site="support.answer")
52
+ p.tools(TOOLS).system(SYSTEM).context(DOCS).history(messages).user(question)
53
+
54
+ resp = client.messages.create(max_tokens=1024, **p.anthropic("claude-haiku-5-5"))
55
+
56
+ u = usage(resp)
57
+ print(f"{u.hit_rate:.0%} of input from cache, ~${u.est_cost or 0:.4f}")
58
+ ```
59
+
60
+ A full multi-turn RAG chat lives in [`examples/rag_chat.py`](examples/rag_chat.py).
61
+ The API works the same with `p.openai(...)`, `p.openai_chat(...)`, `p.gemini(...)` and `p.litellm(...)`. It is a thin library, not a proxy, and has zero runtime dependencies.
62
+
63
+ ## Benchmark
64
+
65
+ ![Cached input tokens per 8-turn chat, naive vs cachemaxxing](https://raw.githubusercontent.com/ashhadahsan/cachemaxxing/main/docs/assets/benchmark.svg)
66
+
67
+ | Provider | Model | Cached input | $ per conversation | Saving |
68
+ |---|---|---|---|---|
69
+ | Anthropic | `claude-haiku-5-5` | 0.0% → **94.8%** | $0.0092 → $0.0017 | **82%** |
70
+ | OpenAI | `gpt-6-luna` | 0.0% → **93.1%** | $0.0070 → $0.0012 | **83%** |
71
+ | Gemini | `gemini-3.8-flash` | 0.0% → **97.5%** | $0.0484 → $0.0094 | **80%** |
72
+
73
+ The same 8-turn conversation, 3 customers per arm, starting from a cold cache, with costs estimated at list price. The naive arm has four common habits: a timestamp at the top of the prompt, tool order that varies, JSON key order that varies, and history re-rendered every turn. The Gemini row uses an explicit cache, creation and storage cost included; with implicit caching alone it reached 40.0%. Data: [`benchmarks/results/`](benchmarks/results/), 2026-10-30.
74
+
75
+ ## Install
76
+
77
+ ```console
78
+ pip install cachemaxxing # or: uv add cachemaxxing
79
+ pip install "cachemaxxing[anthropic]" # extras: anthropic, openai, gemini, litellm
80
+ ```
81
+
82
+ **Docs:** https://ashhadahsan.github.io/cachemaxxing/ covers the quickstart, how caching works per provider, diagnosing a low hit rate, the API reference, and how it compares.
83
+
84
+ MIT licensed.
@@ -0,0 +1,52 @@
1
+ # cachemaxxing
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/cachemaxxing)](https://pypi.org/project/cachemaxxing/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/cachemaxxing)](https://pypi.org/project/cachemaxxing/)
5
+ [![CI](https://github.com/ashhadahsan/cachemaxxing/actions/workflows/ci.yml/badge.svg)](https://github.com/ashhadahsan/cachemaxxing/actions/workflows/ci.yml)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/ashhadahsan/cachemaxxing/blob/main/LICENSE)
7
+
8
+ **Stop paying full price for the same tokens.**
9
+
10
+ Anthropic, OpenAI and Gemini bill a repeated prompt prefix at a fraction of the input price, but only when the bytes really repeat. cachemaxxing finds where they don't, tells you what it costs, and lays your prompts out so they do.
11
+
12
+ - **Measure.** `usage()` normalizes every provider's usage, and `cachemaxxing report` gives hit rate and $ per call site.
13
+ - **Diagnose.** `cachemaxxing diff` shows the first byte where two requests diverge, and why.
14
+ - **Fix.** `Prompt` orders segments from most to least stable and places each provider's cache markers.
15
+
16
+ ```python
17
+ from cachemaxxing import Prompt, usage
18
+
19
+ p = Prompt(call_site="support.answer")
20
+ p.tools(TOOLS).system(SYSTEM).context(DOCS).history(messages).user(question)
21
+
22
+ resp = client.messages.create(max_tokens=1024, **p.anthropic("claude-haiku-5-5"))
23
+
24
+ u = usage(resp)
25
+ print(f"{u.hit_rate:.0%} of input from cache, ~${u.est_cost or 0:.4f}")
26
+ ```
27
+
28
+ A full multi-turn RAG chat lives in [`examples/rag_chat.py`](examples/rag_chat.py).
29
+ The API works the same with `p.openai(...)`, `p.openai_chat(...)`, `p.gemini(...)` and `p.litellm(...)`. It is a thin library, not a proxy, and has zero runtime dependencies.
30
+
31
+ ## Benchmark
32
+
33
+ ![Cached input tokens per 8-turn chat, naive vs cachemaxxing](https://raw.githubusercontent.com/ashhadahsan/cachemaxxing/main/docs/assets/benchmark.svg)
34
+
35
+ | Provider | Model | Cached input | $ per conversation | Saving |
36
+ |---|---|---|---|---|
37
+ | Anthropic | `claude-haiku-5-5` | 0.0% → **94.8%** | $0.0092 → $0.0017 | **82%** |
38
+ | OpenAI | `gpt-6-luna` | 0.0% → **93.1%** | $0.0070 → $0.0012 | **83%** |
39
+ | Gemini | `gemini-3.8-flash` | 0.0% → **97.5%** | $0.0484 → $0.0094 | **80%** |
40
+
41
+ The same 8-turn conversation, 3 customers per arm, starting from a cold cache, with costs estimated at list price. The naive arm has four common habits: a timestamp at the top of the prompt, tool order that varies, JSON key order that varies, and history re-rendered every turn. The Gemini row uses an explicit cache, creation and storage cost included; with implicit caching alone it reached 40.0%. Data: [`benchmarks/results/`](benchmarks/results/), 2026-10-30.
42
+
43
+ ## Install
44
+
45
+ ```console
46
+ pip install cachemaxxing # or: uv add cachemaxxing
47
+ pip install "cachemaxxing[anthropic]" # extras: anthropic, openai, gemini, litellm
48
+ ```
49
+
50
+ **Docs:** https://ashhadahsan.github.io/cachemaxxing/ covers the quickstart, how caching works per provider, diagnosing a low hit rate, the API reference, and how it compares.
51
+
52
+ MIT licensed.
@@ -0,0 +1,91 @@
1
+ [project]
2
+ name = "cachemaxxing"
3
+ version = "0.1.0"
4
+ description = "Stop paying full price for the same tokens: measure, diagnose and fix prompt-cache misses on Anthropic, OpenAI, Gemini and LiteLLM."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.11"
9
+ dependencies = []
10
+ keywords = [
11
+ "llm",
12
+ "prompt-caching",
13
+ "anthropic",
14
+ "openai",
15
+ "gemini",
16
+ "litellm",
17
+ "cost",
18
+ ]
19
+ classifiers = [
20
+ "Development Status :: 3 - Alpha",
21
+ "Intended Audience :: Developers",
22
+ "Programming Language :: Python :: 3 :: Only",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Programming Language :: Python :: 3.14",
27
+ "Programming Language :: Python :: 3.15",
28
+ "Typing :: Typed",
29
+ ]
30
+
31
+ [[project.authors]]
32
+ name = "Ashhad Ahsan"
33
+ email = "ashhadahsan@gmail.com"
34
+
35
+ [project.optional-dependencies]
36
+ anthropic = ["anthropic>=2.3"]
37
+ openai = ["openai>=3.41"]
38
+ gemini = ["google-genai>=2.34"]
39
+ litellm = ["litellm>=1.106"]
40
+
41
+ [project.urls]
42
+ Homepage = "https://ashhadahsan.github.io/cachemaxxing/"
43
+ Source = "https://github.com/ashhadahsan/cachemaxxing"
44
+ Changelog = "https://github.com/ashhadahsan/cachemaxxing/blob/main/CHANGELOG.md"
45
+
46
+ [project.scripts]
47
+ cachemaxxing = "cachemaxxing.cli:main"
48
+
49
+ [build-system]
50
+ requires = ["uv_build>=0.13.6,<0.14"]
51
+ build-backend = "uv_build"
52
+
53
+ [dependency-groups]
54
+ dev = [
55
+ "pytest>=9.2",
56
+ "ruff>=0.17.3",
57
+ "mypy>=2.4.1",
58
+ "anthropic>=2.3",
59
+ "openai>=3.41",
60
+ "google-genai>=2.34",
61
+ "httpx>=0.28",
62
+ ]
63
+ docs = [
64
+ "mkdocstrings-python>=2.0.10",
65
+ "zensical==0.0.73",
66
+ ]
67
+
68
+ [tool.ruff]
69
+ line-length = 120
70
+
71
+ [tool.ruff.format]
72
+ exclude = ["docs/*.md"]
73
+
74
+ [tool.ruff.lint]
75
+ select = [
76
+ "E",
77
+ "F",
78
+ "I",
79
+ "UP",
80
+ "B",
81
+ ]
82
+
83
+ [tool.ruff.lint.per-file-ignores]
84
+ "benchmarks/chart.py" = ["E501"]
85
+
86
+ [tool.mypy]
87
+ strict = true
88
+ files = ["src"]
89
+
90
+ [tool.pytest.ini_options]
91
+ testpaths = ["tests"]
@@ -0,0 +1,66 @@
1
+ [project]
2
+ name = "cachemaxxing"
3
+ version = "0.1.0"
4
+ description = "Stop paying full price for the same tokens: measure, diagnose and fix prompt-cache misses on Anthropic, OpenAI, Gemini and LiteLLM."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Ashhad Ahsan", email = "ashhadahsan@gmail.com" }]
9
+ requires-python = ">=3.11"
10
+ dependencies = []
11
+ keywords = ["llm", "prompt-caching", "anthropic", "openai", "gemini", "litellm", "cost"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Intended Audience :: Developers",
15
+ "Programming Language :: Python :: 3 :: Only",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Programming Language :: Python :: 3.14",
20
+ "Programming Language :: Python :: 3.15",
21
+ "Typing :: Typed",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ anthropic = ["anthropic>=2.3"]
26
+ openai = ["openai>=3.41"]
27
+ gemini = ["google-genai>=2.34"]
28
+ litellm = ["litellm>=1.106"] # never 1.82.7/1.82.8 (compromised 2026-03-24)
29
+
30
+ [project.urls]
31
+ Homepage = "https://ashhadahsan.github.io/cachemaxxing/"
32
+ Source = "https://github.com/ashhadahsan/cachemaxxing"
33
+ Changelog = "https://github.com/ashhadahsan/cachemaxxing/blob/main/CHANGELOG.md"
34
+
35
+ [project.scripts]
36
+ cachemaxxing = "cachemaxxing.cli:main"
37
+
38
+ [build-system]
39
+ requires = ["uv_build>=0.13.6,<0.14"]
40
+ build-backend = "uv_build"
41
+
42
+ [dependency-groups]
43
+ dev = ["pytest>=9.2", "ruff>=0.17.3", "mypy>=2.4.1", "anthropic>=2.3", "openai>=3.41", "google-genai>=2.34", "httpx>=0.28"]
44
+ docs = [
45
+ "mkdocstrings-python>=2.0.10",
46
+ "zensical==0.0.73",
47
+ ]
48
+
49
+ [tool.ruff]
50
+ line-length = 120
51
+
52
+ [tool.ruff.format]
53
+ exclude = ["docs/*.md"] # hand-aligned examples and --8<-- snippet includes
54
+
55
+ [tool.ruff.lint]
56
+ select = ["E", "F", "I", "UP", "B"]
57
+
58
+ [tool.ruff.lint.per-file-ignores]
59
+ "benchmarks/chart.py" = ["E501"] # SVG markup lines
60
+
61
+ [tool.mypy]
62
+ strict = true
63
+ files = ["src"]
64
+
65
+ [tool.pytest.ini_options]
66
+ testpaths = ["tests"]
@@ -0,0 +1,23 @@
1
+ """Stop paying full price for the same tokens."""
2
+
3
+ from .canon import canonical_json
4
+ from .diff import Divergence, diff
5
+ from .economics import Decision, should_cache
6
+ from .gemini import gemini_cache, gemini_cache_async
7
+ from .prompt import Prompt
8
+ from .usage import Stats, Usage, record, usage
9
+
10
+ __all__ = [
11
+ "Decision",
12
+ "Divergence",
13
+ "Prompt",
14
+ "Stats",
15
+ "Usage",
16
+ "canonical_json",
17
+ "diff",
18
+ "gemini_cache",
19
+ "gemini_cache_async",
20
+ "record",
21
+ "should_cache",
22
+ "usage",
23
+ ]
@@ -0,0 +1,42 @@
1
+ """Deterministic serialization helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+
9
+ def canonical_json(obj: Any) -> str:
10
+ """Same data, same bytes: sorted keys, fixed separators, non-ASCII kept."""
11
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
12
+
13
+
14
+ def normalize_ws(text: str) -> str:
15
+ """LF line endings, no trailing whitespace per line, no blank lines at either end."""
16
+ lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
17
+ return "\n".join(line.rstrip() for line in lines).strip("\n")
18
+
19
+
20
+ def plain(obj: Any) -> Any:
21
+ """An SDK (pydantic) object as a plain dict; anything else unchanged."""
22
+ dump = getattr(obj, "model_dump", None)
23
+ return dump(mode="json", exclude_none=True) if callable(dump) else obj
24
+
25
+
26
+ def jsonable(obj: Any) -> Any:
27
+ """`plain`, applied recursively; tuples become lists."""
28
+ obj = plain(obj)
29
+ if isinstance(obj, dict):
30
+ return {k: jsonable(v) for k, v in obj.items()}
31
+ if isinstance(obj, list | tuple):
32
+ return [jsonable(v) for v in obj]
33
+ return obj
34
+
35
+
36
+ def tool_name(tool: Any) -> str:
37
+ """Name of an Anthropic, OpenAI (Responses or Chat) or Gemini function tool; '' if none."""
38
+ t = plain(tool)
39
+ if not isinstance(t, dict):
40
+ return ""
41
+ fn = t.get("function")
42
+ return str(fn.get("name", "") if isinstance(fn, dict) else t.get("name", ""))
@@ -0,0 +1,73 @@
1
+ """cachemaxxing diff A.json B.json | cachemaxxing report LOG.jsonl [--min-hit-rate X]"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import re
8
+ import sys
9
+ from collections import Counter
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .diff import diff
14
+ from .usage import Stats, usage
15
+
16
+ NO_READ = "prefix ok, no cache read (TTL expired, below minimum, or routing)"
17
+
18
+
19
+ def _load(path: str) -> Any:
20
+ return json.loads(Path(path).read_text(encoding="utf-8"))
21
+
22
+
23
+ def _report(log: str, min_hit_rate: float | None) -> int:
24
+ stats = Stats()
25
+ last: dict[str, Any] = {}
26
+ drift: dict[str, Counter[str]] = {}
27
+ for line in Path(log).read_text(encoding="utf-8").splitlines():
28
+ if not line.strip():
29
+ continue
30
+ rec = json.loads(line)
31
+ site, u = rec.get("call_site", "default"), usage(rec["response"])
32
+ stats.add(site, u)
33
+ if site in last:
34
+ div = diff(last[site], rec["request"])
35
+ if (div is None or div.hint == "last turn") and u.cache_read == 0:
36
+ drift.setdefault(site, Counter())[NO_READ] += 1
37
+ elif div is not None and div.hint != "last turn":
38
+ path = re.sub(r"\[\d+\]", "[]", div.path) # group growing conversations together
39
+ drift.setdefault(site, Counter())[f"{path} ({div.hint or '?'})"] += 1
40
+ last[site] = rec["request"]
41
+ if not stats.totals:
42
+ raise ValueError(f"no records in {log}")
43
+ notes = {site: f"{key} x{n}" for site, c in drift.items() for key, n in c.most_common(1)}
44
+ print(stats.table(notes))
45
+ low = sorted(s for s, u in stats.totals.items() if min_hit_rate is not None and u.hit_rate < min_hit_rate)
46
+ for s in low:
47
+ print(f"{s}: hit rate {stats.totals[s].hit_rate:.1%} is below --min-hit-rate {min_hit_rate}", file=sys.stderr)
48
+ return 1 if low else 0
49
+
50
+
51
+ def main(argv: list[str] | None = None) -> int:
52
+ ap = argparse.ArgumentParser(prog="cachemaxxing", description="Find and price prompt-cache misses.")
53
+ sub = ap.add_subparsers(dest="cmd", required=True)
54
+ d = sub.add_parser("diff", help="first point where request B stops repeating request A")
55
+ d.add_argument("a")
56
+ d.add_argument("b")
57
+ r = sub.add_parser("report", help="hit rate, $ and top drift per call site from a JSONL log")
58
+ r.add_argument("log")
59
+ r.add_argument("--min-hit-rate", type=float, help="exit 1 if any call site is below this (0-1)")
60
+ args = ap.parse_args(argv)
61
+ try:
62
+ if args.cmd == "diff":
63
+ div = diff(_load(args.a), _load(args.b))
64
+ print(div if div else "no divergence: B repeats all of A")
65
+ return 1 if div else 0
66
+ return _report(args.log, args.min_hit_rate)
67
+ except (OSError, ValueError, KeyError) as e:
68
+ print(f"cachemaxxing: {e}", file=sys.stderr)
69
+ return 2
70
+
71
+
72
+ if __name__ == "__main__":
73
+ raise SystemExit(main())
@@ -0,0 +1,127 @@
1
+ """Find where request b stops repeating request a's cacheable prefix."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ from collections.abc import Iterator
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ from .canon import jsonable
13
+
14
+ _ORDER = ("model", "tools", "system", "instructions", "config", "messages", "input", "contents")
15
+ _GEMINI_PREFIX = ("cached_content", "system_instruction", "tools", "tool_config")
16
+ _LISTS = ("messages", "input", "contents")
17
+ _MARKERS = {"cache_control", "prompt_cache_breakpoint"}
18
+ _TEXT_TYPES = {"text", "input_text", "output_text"}
19
+ _TOOL_NAME = re.compile(r"(?:config\.)?tools\[\d+\](?:\.function|\.function_declarations\[\d+\])?\.name")
20
+ _HINTS = (
21
+ ("uuid", re.compile(r"[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}")),
22
+ ("timestamp", re.compile(r"\d{4}-\d\d-\d\d[T ]\d\d:\d\d|\b\d{1,2}:\d\d:\d\d\b|\b1\d{9}(?:\d{3})?\b")),
23
+ ("hex id", re.compile(r"\b[0-9a-f]{16,}\b")),
24
+ )
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Divergence:
29
+ path: str # where the two requests differ, e.g. "system[0].text"
30
+ offset: int # characters into that value
31
+ a: str # text around the divergence in request a
32
+ b: str # ... and in request b
33
+ hint: str | None # model, tool order, key order, removed, last turn, whitespace, uuid, timestamp, hex id
34
+ prefix_chars: int # identical characters before it (about 4 per token)
35
+
36
+ def __str__(self) -> str:
37
+ why = f" ({self.hint})" if self.hint else ""
38
+ return (
39
+ f"diverges at {self.path} char {self.offset}{why}, after ~{self.prefix_chars // 4:,} matching tokens\n"
40
+ f" a: {self.a!r}\n b: {self.b!r}"
41
+ )
42
+
43
+
44
+ def _leaves(x: Any, path: str) -> Iterator[tuple[str, str]]:
45
+ if isinstance(x, dict):
46
+ if isinstance(x.get("content"), str): # "hi" renders like [{"type": "text", "text": "hi"}]
47
+ x = {**x, "content": [{"type": "text", "text": x["content"]}]}
48
+ for k, v in x.items():
49
+ if k not in _MARKERS and not (k == "type" and v in _TEXT_TYPES):
50
+ yield from _leaves(v, f"{path}.{k}")
51
+ elif isinstance(x, list):
52
+ for i, v in enumerate(x):
53
+ yield from _leaves(v, f"{path}[{i}]")
54
+ else:
55
+ yield path, x if isinstance(x, str) else json.dumps(x)
56
+
57
+
58
+ def _snake(key: str) -> str:
59
+ return re.sub(r"(?<!^)([A-Z])", r"_\1", key).lower()
60
+
61
+
62
+ def _request(r: Any) -> dict[str, Any]:
63
+ r = jsonable(r)
64
+ if not isinstance(r, dict):
65
+ raise ValueError("expected a request object (a JSON dict) or a record() line")
66
+ inner = r.get("request")
67
+ return inner if isinstance(inner, dict) else r
68
+
69
+
70
+ def _flat(req: dict[str, Any]) -> list[tuple[str, str]]:
71
+ out: list[tuple[str, str]] = []
72
+ for k in _ORDER:
73
+ if k in req:
74
+ v = req[k]
75
+ if k == "config" and isinstance(v, dict): # google-genai accepts camelCase keys too
76
+ snake = {_snake(key): x for key, x in v.items()}
77
+ v = {c: snake[c] for c in _GEMINI_PREFIX if c in snake}
78
+ if isinstance(v.get("system_instruction"), str):
79
+ v["system_instruction"] = {"parts": [{"text": v["system_instruction"]}]}
80
+ elif k == "system" and isinstance(v, str): # "S" renders like [{"type": "text", "text": "S"}]
81
+ v = [{"type": "text", "text": v}]
82
+ out += _leaves(v, k)
83
+ return out
84
+
85
+
86
+ def diff(a: Any, b: Any) -> Divergence | None:
87
+ """First point where b stops repeating a's prompt; None when b repeats all of a.
88
+ Accepts request kwargs, SDK objects, or record() lines."""
89
+ ra, rb = _request(a), _request(b)
90
+ fa, fb = _flat(ra), _flat(rb)
91
+ seen = 0
92
+ for i, (pa, va) in enumerate(fa):
93
+ if i == len(fb):
94
+ return Divergence(pa, 0, va[:60], "", "removed", seen)
95
+ pb, vb = fb[i]
96
+ if pa != pb:
97
+ parent = pa.rsplit(".", 1)[0]
98
+ same = pb.rsplit(".", 1)[0] == parent and {x for x in fa if x[0].startswith(parent)} == {
99
+ x for x in fb if x[0].startswith(parent)
100
+ }
101
+ return Divergence(f"{pa} / {pb}", 0, va[:60], vb[:60], "key order" if same else None, seen)
102
+ if va != vb:
103
+ off = len(os.path.commonprefix([va, vb]))
104
+ lo = max(0, off - 30)
105
+ hint = _hint(pa, va, vb, off, fa, fb, ra)
106
+ return Divergence(pa, off, va[lo : off + 30], vb[lo : off + 30], hint, seen + off)
107
+ seen += len(va)
108
+ return None
109
+
110
+
111
+ def _hint(
112
+ path: str, va: str, vb: str, off: int, fa: list[tuple[str, str]], fb: list[tuple[str, str]], ra: dict[str, Any]
113
+ ) -> str | None:
114
+ if path == "model":
115
+ return "model"
116
+ names_a = [v for p, v in fa if _TOOL_NAME.fullmatch(p)]
117
+ names_b = [v for p, v in fb if _TOOL_NAME.fullmatch(p)]
118
+ if "tools" in path.split("[")[0] and names_a != names_b and sorted(names_a) == sorted(names_b):
119
+ return "tool order"
120
+ for k in _LISTS:
121
+ items = ra.get(k)
122
+ if isinstance(items, list) and items and path.startswith(f"{k}[{len(items) - 1}]"):
123
+ return "last turn"
124
+ if va.split() == vb.split():
125
+ return "whitespace"
126
+ wa, wb = va[max(0, off - 40) : off + 40], vb[max(0, off - 40) : off + 40]
127
+ return next((name for name, rx in _HINTS if rx.search(wa) and rx.search(wb)), None)
@@ -0,0 +1,51 @@
1
+ """Does explicit caching pay? Pure arithmetic over facts.MODELS."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass
7
+
8
+ from . import facts
9
+
10
+ TTL_SECONDS = {"5m": 300, "1h": 3600, "30m": 1800, "12h": 43200}
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Decision:
15
+ cache: bool
16
+ ttl: str | None # "5m"/"1h" (Anthropic), "30m"/"12h" (OpenAI), "<n>s" (Gemini explicit cache)
17
+ saving: float # expected USD saved versus not caching; negative means caching loses money
18
+ reason: str
19
+
20
+
21
+ def should_cache(
22
+ prefix_tokens: int, expected_reuses: int, provider: str, model: str, *, interval_s: float = 60.0
23
+ ) -> Decision:
24
+ """Whether caching a prefix of `prefix_tokens` pays when it is reused `expected_reuses` times,
25
+ `interval_s` seconds apart (start to start)."""
26
+ m = facts.find(model)
27
+ if m is None or m.provider != provider:
28
+ raise ValueError(f"unknown {provider} model {model!r}; add it to cachemaxxing.facts.MODELS")
29
+ if m.long_context and prefix_tokens > m.long_context[0]:
30
+ m = m.long_context[1]
31
+ if prefix_tokens < m.min_tokens:
32
+ return Decision(False, None, 0.0, f"{prefix_tokens} tokens is below the {m.min_tokens}-token minimum")
33
+ t, n = prefix_tokens / 1e6, expected_reuses
34
+ ttl: str | None
35
+ if provider == "gemini":
36
+ if m.storage_hour is None:
37
+ return Decision(False, None, 0.0, f"{model} has no explicit caching; implicit caching is automatic")
38
+ seconds = max(60, math.ceil(interval_s * n))
39
+ ttl, cost = f"{seconds}s", t * m.input + n * t * m.cache_read + t * m.storage_hour * seconds / 3600
40
+ elif not m.cache_write: # automatic caching without a write premium
41
+ ttl, cost = None, t * m.input + n * t * m.cache_read
42
+ else:
43
+ fits = [k for k in m.cache_write if TTL_SECONDS[k] >= interval_s]
44
+ if not fits:
45
+ return Decision(False, None, 0.0, f"reuses {interval_s:g}s apart outlive the longest TTL")
46
+ ttl = min(fits, key=TTL_SECONDS.__getitem__)
47
+ cost = t * m.cache_write[ttl] + n * t * m.cache_read
48
+ saving = (1 + n) * t * m.input - cost
49
+ if saving <= 0:
50
+ return Decision(False, None, saving, f"caching costs ${-saving:.6f} more than not caching")
51
+ return Decision(True, ttl, saving, f"saves ${saving:.6f} over {n} reuses")
@@ -0,0 +1,114 @@
1
+ """Provider prompt-caching facts. Prices are USD per 1M tokens; every row links to its source.
2
+
3
+ Checked on VERIFIED. Add or correct models by editing MODELS (it is a plain dict)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+
9
+ VERIFIED = "2026-10-30"
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Provider:
14
+ max_breakpoints: int | None # explicit markers per request; None = none (implicit caching)
15
+ ttls: tuple[str, ...] # shortest first
16
+ docs: str
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Model:
21
+ provider: str
22
+ input: float
23
+ output: float
24
+ cache_read: float
25
+ cache_write: dict[str, float] = field(default_factory=dict) # TTL -> price, shortest first
26
+ min_tokens: int = 1024
27
+ storage_hour: float | None = None # Gemini explicit caches, per 1M tokens per hour
28
+ long_context: tuple[int, Model] | None = None # (prompt tokens above which, these prices)
29
+ source: str = ""
30
+
31
+
32
+ PROVIDERS = {
33
+ "anthropic": Provider(4, ("5m", "1h"), "https://platform.claude.com/docs/en/build-with-claude/prompt-caching"),
34
+ "openai": Provider(4, ("30m", "12h"), "https://developers.openai.com/api/docs/guides/prompt-caching"),
35
+ "gemini": Provider(None, (), "https://ai.google.dev/gemini-api/docs/caching"),
36
+ }
37
+
38
+ _A = "https://platform.claude.com/docs/en/about-claude/pricing"
39
+ _O = "https://developers.openai.com/api/docs/pricing"
40
+ _G = "https://ai.google.dev/gemini-api/docs/pricing"
41
+
42
+ MODELS: dict[str, Model] = {
43
+ "claude-haiku-5-5": Model(
44
+ "anthropic",
45
+ 0.10,
46
+ 0.50,
47
+ 0.01,
48
+ {"5m": 0.125, "1h": 0.20},
49
+ 512,
50
+ source=_A,
51
+ long_context=(100_000, Model("anthropic", 0.50, 2.50, 0.05, {"5m": 0.625, "1h": 1.00}, 512, source=_A)),
52
+ ),
53
+ "claude-sonnet-5-5": Model("anthropic", 2.00, 10.00, 0.10, {"5m": 2.50, "1h": 4.00}, 512, source=_A),
54
+ "claude-opus-5-5": Model("anthropic", 4.00, 20.00, 0.20, {"5m": 5.00, "1h": 8.00}, 512, source=_A),
55
+ "claude-fable-5-1": Model("anthropic", 10.00, 50.00, 0.25, {"5m": 12.50, "1h": 20.00}, 512, source=_A),
56
+ "gpt-6-luna": Model("openai", 0.10, 0.50, 0.01, {"30m": 0.125, "12h": 0.25}, 1024, source=_O),
57
+ "gpt-6.1-sol": Model("openai", 2.00, 10.00, 0.10, {"30m": 2.50, "12h": 5.00}, 1024, source=_O),
58
+ "gpt-6-astra": Model("openai", 10.00, 50.00, 1.00, {"30m": 12.50, "12h": 25.00}, 1024, source=_O),
59
+ # Gemini caches implicitly; storage_hour marks explicit-cache support.
60
+ # gemini-3.8-flash prices double on 2027-01-01, gemini-4-argon's on 2026-12-01: patch releases then.
61
+ "gemini-3.8-flash": Model("gemini", 0.75, 3.75, 0.075, min_tokens=4096, storage_hour=0.50, source=_G),
62
+ "gemini-3.1-flash-lite": Model("gemini", 0.25, 1.50, 0.025, min_tokens=4096, storage_hour=1.00, source=_G),
63
+ "gemini-4-argon": Model("gemini", 2.00, 10.00, 0.10, min_tokens=4096, source=_G),
64
+ }
65
+
66
+
67
+ def find(model: str) -> Model | None:
68
+ """Longest-prefix match on the id, ignoring 'provider/' and 'models/' prefixes and dated suffixes."""
69
+ name = model.rsplit("/", 1)[-1]
70
+ best = max((k for k in MODELS if name.startswith(k)), key=len, default=None)
71
+ return MODELS[best] if best else None
72
+
73
+
74
+ def provider_of(model: str) -> str:
75
+ """'anthropic', 'openai', 'gemini' or 'other' for a model id, LiteLLM 'route/model' ids included."""
76
+ if (m := find(model)) is not None:
77
+ return m.provider
78
+ prefix, _, rest = model.partition("/")
79
+ if rest and prefix in PROVIDERS:
80
+ return prefix
81
+ name = rest or prefix
82
+ if "claude" in name:
83
+ return "anthropic"
84
+ if name.startswith("gemini"):
85
+ return "gemini"
86
+ if not rest and name.startswith(("gpt-", "o1", "o3", "o4")):
87
+ return "openai"
88
+ return "other"
89
+
90
+
91
+ def markdown() -> str:
92
+ """The facts as Markdown; docs/_facts.md is this output."""
93
+ out = ["| Provider | Max breakpoints | TTLs | Docs |", "|---|---|---|---|"]
94
+ for name, p in PROVIDERS.items():
95
+ ttls = ", ".join(p.ttls) or "explicit caches take any TTL"
96
+ out.append(f"| {name} | {p.max_breakpoints or 'implicit only'} | {ttls} | <{p.docs}> |")
97
+ out += [
98
+ "",
99
+ "| Model | Input | Cache read | Cache write | Output | Min tokens | Storage/h | Source |",
100
+ "|---|--:|--:|---|--:|--:|--:|---|",
101
+ ]
102
+ for name, m in MODELS.items():
103
+ write = ", ".join(f"{ttl}: {price:g}" for ttl, price in m.cache_write.items()) or "—"
104
+ storage = "—" if m.storage_hour is None else f"{m.storage_hour:g}"
105
+ out.append(
106
+ f"| `{name}` | {m.input:g} | {m.cache_read:g} | {write} | {m.output:g} "
107
+ f"| {m.min_tokens:,} | {storage} | [pricing]({m.source}) |"
108
+ )
109
+ out += ["", f"USD per 1M tokens. Verified {VERIFIED}."]
110
+ return "\n".join(out) + "\n"
111
+
112
+
113
+ if __name__ == "__main__":
114
+ print(markdown(), end="")
@@ -0,0 +1,84 @@
1
+ """Gemini explicit caches through your own google-genai client: the library's only I/O."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+
8
+ from .economics import should_cache
9
+ from .prompt import Prompt
10
+
11
+ _LIVE: dict[str, tuple[str, float]] = {} # ponytail: per-process memo; other processes match on display_name
12
+ _MARGIN = 30.0 # seconds a cache must still live to be reused
13
+
14
+
15
+ def _plan(
16
+ p: Prompt, model: str, reuses: int, interval_s: float, tokens: int | None
17
+ ) -> tuple[str, dict[str, Any]] | None:
18
+ if any(callable(t) for t in p._tools): # python-function tools can't live in an explicit cache
19
+ return None
20
+ d = should_cache(tokens or p.est_prefix_tokens(), reuses, "gemini", model, interval_s=interval_s)
21
+ if not d.cache or d.ttl is None:
22
+ return None
23
+ key = f"cmx-{p.call_site}-{model.rsplit('/', 1)[-1]}-{p.prefix_hash()}"
24
+ return key, {"display_name": key, "ttl": d.ttl, **p.gemini_prefix()}
25
+
26
+
27
+ def _alive(c: Any, key: str) -> bool:
28
+ exp = getattr(c, "expire_time", None)
29
+ return getattr(c, "display_name", None) == key and exp is not None and exp.timestamp() - time.time() > _MARGIN
30
+
31
+
32
+ def _remember(key: str, c: Any) -> str:
33
+ _LIVE[key] = (str(c.name), c.expire_time.timestamp())
34
+ return str(c.name)
35
+
36
+
37
+ def _memo(key: str) -> str | None:
38
+ hit = _LIVE.get(key)
39
+ return hit[0] if hit and hit[1] - time.time() > _MARGIN else None
40
+
41
+
42
+ def gemini_cache(
43
+ client: Any,
44
+ p: Prompt,
45
+ model: str,
46
+ *,
47
+ expected_reuses: int,
48
+ interval_s: float = 60.0,
49
+ prefix_tokens: int | None = None,
50
+ ) -> str | None:
51
+ """Name of a live explicit cache holding p's static prefix, created only if should_cache() says it pays.
52
+ Use it as p.gemini(model, cached_content=name). None means stay on implicit caching."""
53
+ plan = _plan(p, model, expected_reuses, interval_s, prefix_tokens)
54
+ if plan is None:
55
+ return None
56
+ key, config = plan
57
+ if name := _memo(key):
58
+ return name
59
+ for c in client.caches.list():
60
+ if _alive(c, key):
61
+ return _remember(key, c)
62
+ return _remember(key, client.caches.create(model=model, config=config))
63
+
64
+
65
+ async def gemini_cache_async(
66
+ client: Any,
67
+ p: Prompt,
68
+ model: str,
69
+ *,
70
+ expected_reuses: int,
71
+ interval_s: float = 60.0,
72
+ prefix_tokens: int | None = None,
73
+ ) -> str | None:
74
+ """Async twin of gemini_cache, using client.aio."""
75
+ plan = _plan(p, model, expected_reuses, interval_s, prefix_tokens)
76
+ if plan is None:
77
+ return None
78
+ key, config = plan
79
+ if name := _memo(key):
80
+ return name
81
+ async for c in await client.aio.caches.list():
82
+ if _alive(c, key):
83
+ return _remember(key, c)
84
+ return _remember(key, await client.aio.caches.create(model=model, config=config))
@@ -0,0 +1,295 @@
1
+ """Prompt: segments ordered from most to least stable, rendered as request kwargs per provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ from collections.abc import Mapping, Sequence
8
+ from typing import Any, Self
9
+
10
+ from . import facts
11
+ from .canon import canonical_json, normalize_ws, plain, tool_name
12
+
13
+ Json = dict[str, Any]
14
+ Parts = str | Mapping[str, Any] | Sequence[str | Mapping[str, Any]]
15
+
16
+
17
+ def _parts(x: Parts) -> list[str]:
18
+ """Text parts: strings verbatim, anything else as canonical JSON."""
19
+ items: Sequence[Any] = [x] if isinstance(x, str | Mapping) else x
20
+ return [p if isinstance(p, str) else canonical_json(p) for p in items]
21
+
22
+
23
+ def _sort_tool(t: Any) -> Any:
24
+ t = plain(t)
25
+ if isinstance(t, dict):
26
+ for key in ("function_declarations", "functionDeclarations"):
27
+ if isinstance(t.get(key), list):
28
+ return {**t, key: sorted(t[key], key=tool_name)}
29
+ return t
30
+
31
+
32
+ _NOT_MARKABLE = {"thinking", "redacted_thinking"}
33
+ _LITELLM_ROUTES = { # LiteLLM route prefixes that honour each provider's cache fields
34
+ "anthropic": {"", "anthropic", "bedrock", "vertex_ai"},
35
+ "openai": {"", "openai"},
36
+ "gemini": {"", "gemini", "vertex_ai"},
37
+ }
38
+
39
+
40
+ def _cc(ttl: str) -> Json:
41
+ return {"type": "ephemeral"} if ttl == "5m" else {"type": "ephemeral", "ttl": ttl}
42
+
43
+
44
+ def _drop(t: Any, key: str) -> Any:
45
+ d = plain(t)
46
+ return {k: v for k, v in d.items() if k != key} if isinstance(d, dict) and key in d else t
47
+
48
+
49
+ def _drop_fn(t: Any) -> Any:
50
+ """A LiteLLM tool without a stale cache_control inside its function object."""
51
+ d = plain(t)
52
+ if isinstance(d, dict) and isinstance(d.get("function"), dict) and "cache_control" in d["function"]:
53
+ return {**d, "function": _drop(d["function"], "cache_control")}
54
+ return t
55
+
56
+
57
+ def _check_ttl(provider: str, ttl: str) -> str:
58
+ if ttl not in facts.PROVIDERS[provider].ttls:
59
+ raise ValueError(f"{provider} cache TTL must be one of {facts.PROVIDERS[provider].ttls}, not {ttl!r}")
60
+ return ttl
61
+
62
+
63
+ def _unmark(msg: Any, key: str) -> Any:
64
+ """Copy of msg without `key` markers on its blocks. Markers aren't content; stale ones would pile up."""
65
+ m = plain(msg)
66
+ if not (isinstance(m, dict) and isinstance(m.get("content"), list)):
67
+ return msg
68
+ blocks = [plain(b) for b in m["content"]]
69
+ if not any(isinstance(b, dict) and key in b for b in blocks):
70
+ return msg
71
+ return {**m, "content": [{k: v for k, v in b.items() if k != key} if isinstance(b, dict) else b for b in blocks]}
72
+
73
+
74
+ def openai_explicit(model: str) -> bool:
75
+ """GPT-5.6 and later take explicit cache breakpoints; older models cache implicitly."""
76
+ m = re.search(r"gpt-(\d+)(?:\.(\d+))?", model)
77
+ return m is not None and (int(m[1]), int(m[2] or 0)) >= (5, 6)
78
+
79
+
80
+ def _mark(msg: Any, key: str, marker: Json, text_type: str, only_text: bool) -> Any:
81
+ """Copy of msg with `marker` on its last markable block, or None if it has none. Text never changes."""
82
+ m = dict(plain(msg))
83
+ content = m.get("content")
84
+ if isinstance(content, str):
85
+ if not content.strip(): # providers reject markers on empty text
86
+ return None
87
+ m["content"] = [{"type": text_type, "text": content, key: marker}]
88
+ return m
89
+ if isinstance(content, list):
90
+ blocks = [plain(b) for b in content]
91
+ for i in range(len(blocks) - 1, -1, -1):
92
+ kind = blocks[i].get("type") if isinstance(blocks[i], dict) else None
93
+ ok = kind == text_type if only_text else kind is not None and kind not in _NOT_MARKABLE
94
+ if ok and not (kind in ("text", "input_text") and not str(blocks[i].get("text", "")).strip()):
95
+ blocks[i] = {**blocks[i], key: marker}
96
+ m["content"] = blocks
97
+ return m
98
+ return None
99
+
100
+
101
+ class Prompt:
102
+ """Collect segments in any order; render with an adapter. Output order is fixed:
103
+ tools, system, context, examples, history, retrieved, user."""
104
+
105
+ def __init__(self, call_site: str = "default") -> None:
106
+ self.call_site = call_site
107
+ self._tools: list[Any] = []
108
+ self._system: list[str] = []
109
+ self._context: list[str] = []
110
+ self._examples: list[str] = []
111
+ self._history: list[Any] = []
112
+ self._retrieved: list[str] = []
113
+ self._user: list[str] = []
114
+
115
+ def tools(self, tools: Sequence[Any], *, sort: bool = True) -> Self:
116
+ """Tool definitions in your provider's format; sorted by name unless sort=False."""
117
+ self._tools = sorted(map(_sort_tool, tools), key=tool_name) if sort else list(tools)
118
+ return self
119
+
120
+ def system(self, text: Parts) -> Self:
121
+ self._system = [t for t in map(normalize_ws, _parts(text)) if t]
122
+ return self
123
+
124
+ def context(self, docs: Parts, *, reorderable: bool = False) -> Self:
125
+ """Static reference material. reorderable=True sorts the parts so their order can't drift."""
126
+ parts = [t for t in map(normalize_ws, _parts(docs)) if t]
127
+ self._context = sorted(parts) if reorderable else parts
128
+ return self
129
+
130
+ def examples(self, examples: Parts, *, reorderable: bool = False) -> Self:
131
+ parts = [t for t in map(normalize_ws, _parts(examples)) if t]
132
+ self._examples = sorted(parts) if reorderable else parts
133
+ return self
134
+
135
+ def history(self, messages: Sequence[Any]) -> Self:
136
+ """Earlier turns in your provider's message format. Append-only; never modified."""
137
+ self._history = list(messages)
138
+ return self
139
+
140
+ def retrieved(self, chunks: Parts) -> Self:
141
+ """Per-request context (RAG chunks, time, user profile). Placed after history, verbatim."""
142
+ self._retrieved = [t for t in _parts(chunks) if t.strip()]
143
+ return self
144
+
145
+ def user(self, text: str) -> Self:
146
+ self._user = [text] if text.strip() else []
147
+ return self
148
+
149
+ @property
150
+ def static(self) -> list[str]:
151
+ return self._system + self._context + self._examples
152
+
153
+ def _prefix_blob(self) -> str:
154
+ tools = [t.__qualname__ if callable(t) else plain(t) for t in self._tools] # python-function tools: by name
155
+ return canonical_json([tools, self.static])
156
+
157
+ def prefix_hash(self) -> str:
158
+ """16 hex chars that change exactly when the tools or the static text change."""
159
+ return hashlib.sha256(self._prefix_blob().encode()).hexdigest()[:16]
160
+
161
+ def est_prefix_tokens(self) -> int:
162
+ # ponytail: ~4 chars/token estimate; pass exact counts to should_cache() when you have them
163
+ return len(self._prefix_blob()) // 4
164
+
165
+ def _tail(
166
+ self,
167
+ marker: Json | None,
168
+ key: str,
169
+ text_type: str,
170
+ roles: tuple[str, ...],
171
+ *,
172
+ only_text: bool,
173
+ count: int = 1,
174
+ ) -> list[Any]:
175
+ """History, then the final user turn, with `count` markers on the last markable positions: the final
176
+ user block (unless a per-request retrieved block follows it), then eligible history messages.
177
+ Stale markers in history are stripped first so they never pile up. Anthropic needs one tail marker
178
+ (its 20-block lookback finds earlier entries); OpenAI only looks up at breakpoints present in the
179
+ request, so count=2 also re-marks the position the previous request wrote."""
180
+ msgs = [_unmark(m, key) for m in self._history]
181
+ final: list[Json] = [{"type": text_type, "text": t} for t in self._retrieved + self._user]
182
+ left = count if marker is not None else 0
183
+ if left and final and not self._retrieved:
184
+ final[-1][key] = marker
185
+ left -= 1
186
+ for i in range(len(msgs) - 1, -1, -1):
187
+ if not left:
188
+ break
189
+ m = plain(msgs[i])
190
+ if isinstance(m, dict) and m.get("role") in roles and marker is not None:
191
+ marked = _mark(m, key, marker, text_type, only_text)
192
+ if marked is not None:
193
+ msgs[i] = marked
194
+ left -= 1
195
+ if final:
196
+ msgs.append({"role": "user", "content": final})
197
+ return msgs
198
+
199
+ def anthropic(self, model: str, *, ttl: str = "5m") -> Json:
200
+ """kwargs for anthropic `client.messages.create(max_tokens=..., **kwargs)`."""
201
+ cc = _cc(_check_ttl("anthropic", ttl))
202
+ kw: Json = {"model": model}
203
+ if self._tools:
204
+ tools = [_drop(t, "cache_control") for t in self._tools]
205
+ kw["tools"] = [*tools[:-1], {**plain(tools[-1]), "cache_control": cc}]
206
+ if self.static:
207
+ kw["system"] = [{"type": "text", "text": t} for t in self.static]
208
+ kw["system"][-1]["cache_control"] = cc
209
+ kw["messages"] = self._tail(cc, "cache_control", "text", ("user", "assistant"), only_text=False)
210
+ return kw
211
+
212
+ def openai(self, model: str, *, ttl: str = "30m", cache_key: str | None = None) -> Json:
213
+ """kwargs for openai `client.responses.create(**kwargs)`."""
214
+ return self._openai(model, ttl, cache_key, field="input", role="developer", text_type="input_text")
215
+
216
+ def openai_chat(self, model: str, *, ttl: str = "30m", cache_key: str | None = None) -> Json:
217
+ """kwargs for openai `client.chat.completions.create(**kwargs)` and OpenAI-compatible servers."""
218
+ if ttl == "12h":
219
+ raise ValueError("the 12h cache TTL is Responses-only; use Prompt.openai()")
220
+ return self._openai(model, ttl, cache_key, field="messages", role="system", text_type="text")
221
+
222
+ def _openai(self, model: str, ttl: str, cache_key: str | None, *, field: str, role: str, text_type: str) -> Json:
223
+ explicit = openai_explicit(model)
224
+ if explicit:
225
+ _check_ttl("openai", ttl)
226
+ bp: Json | None = {"mode": "explicit"} if explicit else None
227
+ kw: Json = {"model": model}
228
+ if self._tools:
229
+ kw["tools"] = list(self._tools)
230
+ msgs: list[Any] = []
231
+ if self.static:
232
+ parts: list[Json] = [{"type": text_type, "text": t} for t in self.static]
233
+ if bp is not None:
234
+ parts[-1]["prompt_cache_breakpoint"] = bp
235
+ msgs.append({"role": role, "content": parts})
236
+ roles = ("user", "developer", "system")
237
+ msgs += self._tail(bp, "prompt_cache_breakpoint", text_type, roles, only_text=True, count=2)
238
+ kw[field] = msgs
239
+ if explicit:
240
+ kw["prompt_cache_options"] = {"mode": "explicit", "ttl": ttl}
241
+ if cache_key or not explicit:
242
+ kw["prompt_cache_key"] = cache_key or f"{self.call_site}:{self.prefix_hash()}"
243
+ return kw
244
+
245
+ def gemini(self, model: str, *, cached_content: str | None = None) -> Json:
246
+ """kwargs for google-genai `client.models.generate_content(**kwargs)`. Implicit caching unless
247
+ cached_content (from gemini_cache) is given; then the cached fields are left out."""
248
+ config: Json = {"cached_content": cached_content} if cached_content else self.gemini_prefix()
249
+ contents = list(self._history)
250
+ final = [{"text": t} for t in self._retrieved + self._user]
251
+ if final:
252
+ contents.append({"role": "user", "parts": final})
253
+ return {"model": model, "contents": contents, "config": config}
254
+
255
+ def gemini_prefix(self) -> Json:
256
+ """The cacheable part of a Gemini request: what an explicit cache holds."""
257
+ prefix: Json = {}
258
+ if self.static:
259
+ prefix["system_instruction"] = {"parts": [{"text": t} for t in self.static]}
260
+ if self._tools:
261
+ prefix["tools"] = list(self._tools)
262
+ return prefix
263
+
264
+ def litellm(self, model: str, *, ttl: str | None = None, explicit: bool = False) -> Json:
265
+ """kwargs for `litellm.completion(**kwargs)` (or the LiteLLM Proxy via the OpenAI SDK), model included.
266
+ Cache fields follow the route: Claude gets cache_control, openai/ gets openai_chat(), gemini/ stays
267
+ implicit unless explicit=True (LiteLLM then creates an explicit cache for the static prefix)."""
268
+ route = model.split("/", 1)[0] if "/" in model else ""
269
+ provider = facts.provider_of(model)
270
+ if route not in _LITELLM_ROUTES.get(provider, set()):
271
+ provider = "other"
272
+ if provider == "openai":
273
+ return self.openai_chat(model, ttl=ttl or "30m")
274
+ if provider == "gemini" and explicit and (fm := facts.find(model)) is not None and fm.storage_hour is None:
275
+ raise ValueError(f"{model} has no explicit caching; drop explicit=True")
276
+ mark = provider == "anthropic" or (provider == "gemini" and explicit)
277
+ marker = _cc(_check_ttl("anthropic", ttl or "5m")) if mark else None
278
+ kw: Json = {"model": model}
279
+ if self._tools:
280
+ tools = [_drop_fn(t) for t in self._tools]
281
+ last = plain(tools[-1])
282
+ if provider == "anthropic" and isinstance(last, dict) and isinstance(last.get("function"), dict):
283
+ tools[-1] = {**last, "function": {**last["function"], "cache_control": marker}}
284
+ kw["tools"] = tools
285
+ msgs: list[Any] = []
286
+ if self.static:
287
+ parts: list[Json] = [{"type": "text", "text": t} for t in self.static]
288
+ if marker is not None:
289
+ parts[-1]["cache_control"] = marker
290
+ msgs.append({"role": "system", "content": parts})
291
+ # Gemini explicit caches cover the static prefix only; a history marker would create a cache per turn.
292
+ tail_marker = marker if provider == "anthropic" else None
293
+ msgs += self._tail(tail_marker, "cache_control", "text", ("user", "assistant"), only_text=True)
294
+ kw["messages"] = msgs
295
+ return kw
File without changes
@@ -0,0 +1,139 @@
1
+ """Normalize provider usage into one shape, price it, aggregate it per call site."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, replace
6
+ from typing import Any
7
+
8
+ from . import facts
9
+ from .canon import jsonable, plain
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Usage:
14
+ provider: str
15
+ model: str
16
+ input: int = 0 # every prompt token: uncached + cache read + cache write
17
+ cache_read: int = 0
18
+ cache_write: int = 0
19
+ output: int = 0 # includes thinking / reasoning tokens
20
+ est_cost: float | None = None # USD at list price as of facts.VERIFIED; None for unknown models
21
+
22
+ @property
23
+ def hit_rate(self) -> float:
24
+ return self.cache_read / self.input if self.input else 0.0
25
+
26
+ def __add__(self, other: Usage) -> Usage:
27
+ cost = None if self.est_cost is None or other.est_cost is None else self.est_cost + other.est_cost
28
+ return replace(
29
+ self,
30
+ input=self.input + other.input,
31
+ cache_read=self.cache_read + other.cache_read,
32
+ cache_write=self.cache_write + other.cache_write,
33
+ output=self.output + other.output,
34
+ est_cost=cost,
35
+ )
36
+
37
+
38
+ def _camel(key: str) -> str:
39
+ head, *rest = key.split("_")
40
+ return head + "".join(w.title() for w in rest)
41
+
42
+
43
+ def _n(d: Any, *keys: str) -> int:
44
+ """First non-zero count among keys (snake_case or camelCase) of dict d; 0 if none."""
45
+ if isinstance(d, dict):
46
+ for k in keys:
47
+ for key in (k, _camel(k)):
48
+ if d.get(key):
49
+ return int(d[key])
50
+ return 0
51
+
52
+
53
+ def usage(response: Any, model: str | None = None) -> Usage:
54
+ """Normalize an Anthropic, OpenAI (Responses or Chat), Gemini or LiteLLM response (object or dict)."""
55
+ d = plain(response)
56
+ if not isinstance(d, dict):
57
+ raise ValueError("unrecognised response: expected an SDK response object or dict")
58
+ name = str(model or d.get("model") or d.get("model_version") or d.get("modelVersion") or "")
59
+ meta = d.get("usage_metadata") or d.get("usageMetadata")
60
+ if meta is not None or "candidates" in d: # Gemini: prompt_token_count includes cached tokens
61
+ total = _n(meta, "prompt_token_count") + _n(meta, "tool_use_prompt_token_count")
62
+ out = _n(meta, "candidates_token_count") + _n(meta, "thoughts_token_count")
63
+ return _priced("gemini", name, total, _n(meta, "cached_content_token_count"), 0, out)
64
+ u = d.get("usage") or {}
65
+ if d.get("type") == "message": # Anthropic: input_tokens EXCLUDES cache tokens
66
+ read, write = _n(u, "cache_read_input_tokens"), _n(u, "cache_creation_input_tokens")
67
+ long_write = _n(u.get("cache_creation"), "ephemeral_1h_input_tokens")
68
+ total = _n(u, "input_tokens") + read + write
69
+ return _priced("anthropic", name, total, read, write, _n(u, "output_tokens"), long_write)
70
+ if "prompt_tokens" in u: # OpenAI Chat and LiteLLM: prompt_tokens includes cache tokens
71
+ det = u.get("prompt_tokens_details") or {}
72
+ write = _n(det, "cache_write_tokens", "cache_creation_tokens") or _n(u, "cache_creation_input_tokens")
73
+ long_write = _n(det.get("cache_write_tokens_details"), "ttl_12h_tokens")
74
+ read, out = _n(det, "cached_tokens"), _n(u, "completion_tokens")
75
+ return _priced(facts.provider_of(name), name, _n(u, "prompt_tokens"), read, write, out, long_write)
76
+ if "input_tokens" in u: # OpenAI Responses: input_tokens includes cache tokens
77
+ det = u.get("input_tokens_details") or {}
78
+ long_write = _n(det.get("cache_write_tokens_details"), "ttl_12h_tokens")
79
+ read, write = _n(det, "cached_tokens"), _n(det, "cache_write_tokens")
80
+ return _priced("openai", name, _n(u, "input_tokens"), read, write, _n(u, "output_tokens"), long_write)
81
+ raise ValueError("unrecognised response: expected Anthropic, OpenAI, Gemini or LiteLLM")
82
+
83
+
84
+ def _priced(provider: str, model: str, total: int, read: int, write: int, out: int, long_write: int = 0) -> Usage:
85
+ m = facts.find(model)
86
+ cost = None
87
+ if m is not None:
88
+ if m.long_context and total > m.long_context[0]:
89
+ m = m.long_context[1]
90
+ writes = list(m.cache_write.values()) or [m.input] # [short TTL, long TTL]
91
+ uncached = total - read - write
92
+ cost = (
93
+ uncached * m.input
94
+ + read * m.cache_read
95
+ + (write - long_write) * writes[0]
96
+ + long_write * writes[-1]
97
+ + out * m.output
98
+ ) / 1e6
99
+ return Usage(provider, model, total, read, write, out, cost)
100
+
101
+
102
+ _HEADER = ("call site", "calls", "hit rate", "input", "cache read", "cache write", "output", "est $", "top drift")
103
+
104
+
105
+ class Stats:
106
+ """Usage totals per call site, in memory."""
107
+
108
+ def __init__(self) -> None:
109
+ self.calls: dict[str, int] = {}
110
+ self.totals: dict[str, Usage] = {}
111
+
112
+ def add(self, call_site: str, u: Usage) -> None:
113
+ self.calls[call_site] = self.calls.get(call_site, 0) + 1
114
+ self.totals[call_site] = self.totals[call_site] + u if call_site in self.totals else u
115
+
116
+ def table(self, notes: dict[str, str] | None = None) -> str:
117
+ rows = [list(_HEADER)]
118
+ for site, u in sorted(self.totals.items()):
119
+ cost = "n/a" if u.est_cost is None else f"{u.est_cost:.4f}"
120
+ rows.append(
121
+ [
122
+ site,
123
+ str(self.calls[site]),
124
+ f"{u.hit_rate:.1%}",
125
+ f"{u.input:,}",
126
+ f"{u.cache_read:,}",
127
+ f"{u.cache_write:,}",
128
+ f"{u.output:,}",
129
+ cost,
130
+ (notes or {}).get(site, ""),
131
+ ]
132
+ )
133
+ widths = [max(len(r[i]) for r in rows) for i in range(len(rows[0]))]
134
+ return "\n".join(" ".join(c.ljust(w) for c, w in zip(r, widths, strict=True)).rstrip() for r in rows)
135
+
136
+
137
+ def record(call_site: str, request: Any, response: Any) -> dict[str, Any]:
138
+ """One JSONL line for `cachemaxxing report`. The library never writes files; you do."""
139
+ return {"call_site": call_site, "request": jsonable(request), "response": jsonable(response)}