llmledger-mcp 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,32 @@
1
+ # ── private, never public ──────────────────────────────────────────────
2
+ # Study notes, career context, working notes. First rule in the file
3
+ # because it is the one that must never fail.
4
+ notes/
5
+
6
+ # ── data: ALLOW-LIST, never a deny-list ───────────────────────────────
7
+ # A deny-list of extensions fails on the next file type you invent.
8
+ # Everything under data/ is ignored except the one marker that keeps
9
+ # the directory in git. The upstream litellm clone also lands here
10
+ # (data/upstream/), so it is covered by this rule and needs no other.
11
+ data/*
12
+ !data/.gitkeep
13
+
14
+ # ── secrets ───────────────────────────────────────────────────────────
15
+ .env
16
+ .env.*
17
+ !.env.example
18
+ secrets/
19
+
20
+ # ── Python ────────────────────────────────────────────────────────────
21
+ __pycache__/
22
+ *.py[cod]
23
+ .venv/
24
+ venv/
25
+ *.egg-info/
26
+ build/
27
+ dist/
28
+ .pytest_cache/
29
+
30
+ # ── macOS ─────────────────────────────────────────────────────────────
31
+ .DS_Store
32
+ site/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pouriaetab
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,78 @@
1
+ Metadata-Version: 2.5
2
+ Name: llmledger-mcp
3
+ Version: 0.1.0
4
+ Summary: Which AI model, and what it costs: LiteLLM prices, Arena ratings and price history, inside Claude and other AI apps.
5
+ Project-URL: Homepage, https://pouriaetab.github.io/llmledger/
6
+ Project-URL: Source, https://github.com/pouriaetab/llmledger/tree/main/mcp
7
+ Project-URL: Issues, https://github.com/pouriaetab/llmledger/issues
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: fastmcp,litellm,llm,mcp,model selection,pricing
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: fastmcp==4.1.0
17
+ Description-Content-Type: text/markdown
18
+
19
+ # llmledger-mcp
20
+
21
+ <!-- mcp-name: io.github.pouriaetab/llmledger -->
22
+
23
+ Ask Claude (or Cursor, ChatGPT desktop, any app that speaks MCP) which AI model to use and what it
24
+ costs, in the middle of your work. The answers come from [llmledger](https://pouriaetab.github.io/llmledger/):
25
+ LiteLLM's public price catalogue, Arena's ratings by task, and every price change since 2023 with how
26
+ likely a price is to change next. Rebuilt every day.
27
+
28
+ ## Install
29
+
30
+ Needs [uv](https://docs.astral.sh/uv/). Nothing to host and no keys: the plug-in runs on your computer
31
+ and reads the day's data from the llmledger page, cached for a day, with a bundled copy for offline use.
32
+
33
+ Claude Code:
34
+
35
+ ```bash
36
+ claude mcp add llmledger -- uvx llmledger-mcp
37
+ ```
38
+
39
+ Claude Desktop (Settings, Developer, Edit config) or Cursor (`.cursor/mcp.json`):
40
+
41
+ ```json
42
+ {
43
+ "mcpServers": {
44
+ "llmledger": { "command": "uvx", "args": ["llmledger-mcp"] }
45
+ }
46
+ }
47
+ ```
48
+
49
+ A LiteLLM proxy can offer it to a whole team through its MCP gateway, as a stdio server with the same
50
+ command.
51
+
52
+ ## What you can ask
53
+
54
+ | Tool | For example |
55
+ |---|---|
56
+ | `suggest_for_project` | "Which model and effort for this project?" with the starting prompt; `pay` can be `claude` or `chatgpt` to compare within a plan |
57
+ | `compare_models` | "Opus 5.5 or Fable 5.1 for coding, and when is Fable cheaper?" |
58
+ | `estimate_cost` | "What do 2,000 calls a day of 10K tokens in and 1K out cost on these three?" |
59
+ | `find_models` | "The best rated coding models under $10 per 1M answer tokens that read images" |
60
+ | `price_history` | "Has Claude Opus 5.5's price changed, and how likely is it to change soon?" |
61
+
62
+ Plus a resource, `llmledger://snapshot` (data dates and sources), and a prompt, `pick_a_model`.
63
+
64
+ ## Good to know
65
+
66
+ - Costs and ratings are estimates. Arena ratings say which answers people preferred, not what will work
67
+ best for you. Plan use (Claude, ChatGPT) assumes a plan runs down in step with API prices, because
68
+ plans do not publish per-model limits. Not financial or procurement advice.
69
+ - Effort levels come from LiteLLM; the suggested level is a rule of thumb. No open source measures
70
+ tokens per task at each effort level yet.
71
+ - Set `LLMLEDGER_DATA` to a local data.json or another address to use your own copy.
72
+
73
+ ## Data and licences
74
+
75
+ Prices: LiteLLM's `model_prices_and_context_window.json` (MIT). Ratings: the Arena leaderboard
76
+ (CC BY 4.0). Price history and outlook: [llmledger](https://github.com/pouriaetab/llmledger) (MIT).
77
+ Built with [FastMCP](https://github.com/PrefectHQ/fastmcp) (Apache-2.0). Independent; not affiliated
78
+ with BerriAI, LiteLLM or Arena.
@@ -0,0 +1,60 @@
1
+ # llmledger-mcp
2
+
3
+ <!-- mcp-name: io.github.pouriaetab/llmledger -->
4
+
5
+ Ask Claude (or Cursor, ChatGPT desktop, any app that speaks MCP) which AI model to use and what it
6
+ costs, in the middle of your work. The answers come from [llmledger](https://pouriaetab.github.io/llmledger/):
7
+ LiteLLM's public price catalogue, Arena's ratings by task, and every price change since 2023 with how
8
+ likely a price is to change next. Rebuilt every day.
9
+
10
+ ## Install
11
+
12
+ Needs [uv](https://docs.astral.sh/uv/). Nothing to host and no keys: the plug-in runs on your computer
13
+ and reads the day's data from the llmledger page, cached for a day, with a bundled copy for offline use.
14
+
15
+ Claude Code:
16
+
17
+ ```bash
18
+ claude mcp add llmledger -- uvx llmledger-mcp
19
+ ```
20
+
21
+ Claude Desktop (Settings, Developer, Edit config) or Cursor (`.cursor/mcp.json`):
22
+
23
+ ```json
24
+ {
25
+ "mcpServers": {
26
+ "llmledger": { "command": "uvx", "args": ["llmledger-mcp"] }
27
+ }
28
+ }
29
+ ```
30
+
31
+ A LiteLLM proxy can offer it to a whole team through its MCP gateway, as a stdio server with the same
32
+ command.
33
+
34
+ ## What you can ask
35
+
36
+ | Tool | For example |
37
+ |---|---|
38
+ | `suggest_for_project` | "Which model and effort for this project?" with the starting prompt; `pay` can be `claude` or `chatgpt` to compare within a plan |
39
+ | `compare_models` | "Opus 5.5 or Fable 5.1 for coding, and when is Fable cheaper?" |
40
+ | `estimate_cost` | "What do 2,000 calls a day of 10K tokens in and 1K out cost on these three?" |
41
+ | `find_models` | "The best rated coding models under $10 per 1M answer tokens that read images" |
42
+ | `price_history` | "Has Claude Opus 5.5's price changed, and how likely is it to change soon?" |
43
+
44
+ Plus a resource, `llmledger://snapshot` (data dates and sources), and a prompt, `pick_a_model`.
45
+
46
+ ## Good to know
47
+
48
+ - Costs and ratings are estimates. Arena ratings say which answers people preferred, not what will work
49
+ best for you. Plan use (Claude, ChatGPT) assumes a plan runs down in step with API prices, because
50
+ plans do not publish per-model limits. Not financial or procurement advice.
51
+ - Effort levels come from LiteLLM; the suggested level is a rule of thumb. No open source measures
52
+ tokens per task at each effort level yet.
53
+ - Set `LLMLEDGER_DATA` to a local data.json or another address to use your own copy.
54
+
55
+ ## Data and licences
56
+
57
+ Prices: LiteLLM's `model_prices_and_context_window.json` (MIT). Ratings: the Arena leaderboard
58
+ (CC BY 4.0). Price history and outlook: [llmledger](https://github.com/pouriaetab/llmledger) (MIT).
59
+ Built with [FastMCP](https://github.com/PrefectHQ/fastmcp) (Apache-2.0). Independent; not affiliated
60
+ with BerriAI, LiteLLM or Arena.
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling==1.32.4"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "llmledger-mcp"
7
+ version = "0.1.0"
8
+ description = "Which AI model, and what it costs: LiteLLM prices, Arena ratings and price history, inside Claude and other AI apps."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ keywords = ["mcp", "llm", "pricing", "litellm", "model selection", "fastmcp"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Operating System :: OS Independent",
17
+ "Intended Audience :: Developers",
18
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
19
+ ]
20
+ dependencies = ["fastmcp==4.1.0"]
21
+
22
+ [project.scripts]
23
+ llmledger-mcp = "llmledger_mcp.server:main"
24
+
25
+ [project.urls]
26
+ Homepage = "https://pouriaetab.github.io/llmledger/"
27
+ Source = "https://github.com/pouriaetab/llmledger/tree/main/mcp"
28
+ Issues = "https://github.com/pouriaetab/llmledger/issues"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/llmledger_mcp"]
32
+
33
+ [tool.hatch.build.targets.sdist]
34
+ include = ["src/llmledger_mcp", "tests", "scripts", "README.md", "LICENSE", "server.json"]
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
@@ -0,0 +1,20 @@
1
+ """Refresh the data snapshot bundled in the package (used only before the first download).
2
+ Run before each build: python scripts/snapshot.py [path or address of data.json]"""
3
+ import gzip
4
+ import json
5
+ import sys
6
+ import urllib.request
7
+ from pathlib import Path
8
+
9
+ SOURCE = sys.argv[1] if len(sys.argv) > 1 else "https://pouriaetab.github.io/llmledger/data.json"
10
+ DEST = Path(__file__).parent.parent / "src" / "llmledger_mcp" / "snapshot.json.gz"
11
+
12
+ if SOURCE.startswith("http"):
13
+ with urllib.request.urlopen(urllib.request.Request(SOURCE, headers={"User-Agent": "llmledger-mcp"}), timeout=60) as r:
14
+ body = r.read()
15
+ else:
16
+ body = Path(SOURCE).read_bytes()
17
+ data = json.loads(body)
18
+ assert data.get("schema") == 1 and len(data["models"]) > 100, "not a llmledger data.json"
19
+ DEST.write_bytes(gzip.compress(json.dumps(data, separators=(",", ":")).encode(), 9, mtime=0))
20
+ print(f"{DEST.name}: {len(data['models']):,} models, built {data['built']}, {DEST.stat().st_size // 1024} KB")
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.pouriaetab/llmledger",
4
+ "description": "Which AI model, and what it costs: LiteLLM prices, Arena ratings and price history.",
5
+ "repository": {
6
+ "url": "https://github.com/pouriaetab/llmledger",
7
+ "source": "github",
8
+ "subfolder": "mcp"
9
+ },
10
+ "version": "0.1.0",
11
+ "packages": [
12
+ {
13
+ "registryType": "pypi",
14
+ "identifier": "llmledger-mcp",
15
+ "version": "0.1.0",
16
+ "transport": {
17
+ "type": "stdio"
18
+ }
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,2 @@
1
+ """llmledger-mcp: which AI model, and what it costs, inside Claude and other AI apps."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from llmledger_mcp.server import main
2
+
3
+ main()
@@ -0,0 +1,107 @@
1
+ """The day's data: downloaded once a day from the llmledger page into a cache folder, so the
2
+ plug-in works offline from the last copy, with a snapshot bundled in the package for the
3
+ very first run. Standard library only."""
4
+ import gzip
5
+ import json
6
+ import os
7
+ import re
8
+ import tempfile
9
+ import time
10
+ import urllib.request
11
+ from importlib import resources
12
+ from pathlib import Path
13
+
14
+ URL = "https://pouriaetab.github.io/llmledger/data.json"
15
+ MAX_AGE = 24 * 3600
16
+
17
+
18
+ def cache_file():
19
+ base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
20
+ return Path(base) / "llmledger-mcp" / "data.json"
21
+
22
+
23
+ def _download(url, dest):
24
+ request = urllib.request.Request(url, headers={"User-Agent": "llmledger-mcp"})
25
+ with urllib.request.urlopen(request, timeout=20) as response:
26
+ body = response.read()
27
+ json.loads(body) # a broken download never replaces a good copy
28
+ dest.parent.mkdir(parents=True, exist_ok=True)
29
+ with tempfile.NamedTemporaryFile(dir=dest.parent, delete=False) as f:
30
+ f.write(body)
31
+ os.replace(f.name, dest)
32
+
33
+
34
+ def _bundled():
35
+ return json.loads(gzip.decompress(resources.files("llmledger_mcp").joinpath("snapshot.json.gz").read_bytes()))
36
+
37
+
38
+ def load(now=None):
39
+ """(data, where it came from). LLMLEDGER_DATA may name a local file or another address."""
40
+ override = os.environ.get("LLMLEDGER_DATA", "")
41
+ if override and not re.match(r"https?://", override):
42
+ return json.loads(Path(override).read_text()), "file " + override
43
+ url, dest = override or URL, cache_file()
44
+ now = now or time.time()
45
+ fresh = dest.exists() and now - dest.stat().st_mtime < MAX_AGE
46
+ if not fresh:
47
+ try:
48
+ _download(url, dest)
49
+ fresh = True
50
+ except Exception: # offline or the page is down: use what we have
51
+ pass
52
+ if dest.exists():
53
+ try:
54
+ return json.loads(dest.read_text()), "downloaded" if fresh else "the last download (could not refresh it)"
55
+ except ValueError:
56
+ pass
57
+ return _bundled(), "the copy bundled with this version"
58
+
59
+
60
+ class Shelf:
61
+ """The data, indexed: models by listing and by grouped name, ratings by task."""
62
+
63
+ def __init__(self, data, where=""):
64
+ self.data, self.where, self.loaded = data, where, time.time()
65
+ self.models = [m for m in data["models"]]
66
+ self.by_key = {m["key"]: m for m in self.models}
67
+ self.groups = {}
68
+ for m in self.models:
69
+ self.groups.setdefault(m["maker"] + " / " + m["name"], []).append(m)
70
+ ratings = data.get("ratings") or {}
71
+ self.labels = dict(ratings.get("labels") or [])
72
+ self.ratings = ratings.get("tasks") or {}
73
+ self.rating_date = ratings.get("date", "")
74
+
75
+ @staticmethod
76
+ def norm(text):
77
+ text = str(text).lower()
78
+ text = re.sub(r"(\d)[-_p](?=\d(?![\d.bkm]))", r"\1.", text) # 4-5 and 4p5 are versions: 4.5
79
+ return re.sub(r"[-_:/@\s]+", " ", re.sub(r"(?<!\d)\.|\.(?!\d)", " ", text)).strip()
80
+
81
+ def resolve(self, name):
82
+ """The grouped name ("maker / name") for what a person typed, or a list of close ones."""
83
+ if name in self.groups:
84
+ return name, []
85
+ if name in self.by_key:
86
+ m = self.by_key[name]
87
+ return m["maker"] + " / " + m["name"], []
88
+ want = self.norm(name)
89
+ exact = [g for g in self.groups if self.norm(g) == want or self.norm(g.split(" / ", 1)[1]) == want]
90
+ if exact:
91
+ return exact[0], []
92
+ words = want.split()
93
+ close = [g for g in self.groups
94
+ if all(any(t.startswith(w) for t in self.norm(g + " " + " ".join(m["key"] for m in self.groups[g])).split())
95
+ for w in words)]
96
+ if len(close) == 1:
97
+ return close[0], []
98
+ close.sort(key=lambda g: (-self.rating_of("overall", g)[2] if self.rating_of("overall", g) else 0, len(g)))
99
+ return None, close[:8]
100
+
101
+ def rating_of(self, task, group):
102
+ return next((r for r in self.ratings.get(task, []) if r[0] == group), None)
103
+
104
+ def rank_of(self, task, group, priced):
105
+ rows = [r for r in self.ratings.get(task, []) if r[0] in priced]
106
+ ids = [r[0] for r in rows]
107
+ return (ids.index(group) + 1 if group in ids else None), len(ids)
@@ -0,0 +1,301 @@
1
+ """The page's own logic, in Python, for the plug-in: the task guess (route.js), prompt cost
2
+ (cost.js) and the side-by-side comparison (pick.js). Pure functions; no network, no FastMCP.
3
+
4
+ The task rules are copied from route.js; tests/test_mcp_logic.py in the repo checks that both
5
+ give the same answers. Python reads word boundaries next to accented letters a little more
6
+ generously than JavaScript does, so a rare prompt in Spanish, French or German can differ.
7
+ """
8
+ import math
9
+ import re
10
+
11
+ # ---------- task guess (route.js) ----------
12
+ # task: (strong pattern, its flags, weak pattern, its flags); a strong match counts 2, a weak one 1.
13
+ RULES = {
14
+ "webdev": ("\\b(html|css|tailwind|react|next\\.js|nextjs|vue|svelte|landing page|web ?page|website|web app|frontend|front-end)\\b", "i",
15
+ "\\b(navbar|button|layout|responsive|dom|jsx|tsx)\\b", "i"),
16
+ "coding": ("```|\\b(python|javascript|typescript|java|c\\+\\+|c#|golang|rust|kotlin|swift|flutter|php|ruby|bash|powershell|sql|regex|numpy|pandas|pytorch|tensorflow|django|flask|fastapi|node\\.?js|npm|docker\\w*|kubernetes|git|github|api|json|yaml|code|coding|script|function|debug|refactor|compile\\w*|stack ?trace|traceback|exception|bug|algorithm|leetcode|unit test|programm\\w*|linux|ubuntu|terminal|excel formula|vba)\\b|(\\bdef |\\bimport |console\\.log|#include|public static|=>|!=|==|\\w+\\(\\)\\s*[{;:]|[{};]\\s*$|\\bSELECT\\b[\\s\\S]*\\bFROM\\b)|(код|программ|функци|скрипт|代码|程序|函数|编程|コード|プログラム|関数|코드|프로그램|함수|programowa\\w*|\\bkod\\w*|funkcj\\w*|código|programa\\w*|fonction|Programmier\\w*|پایتون|کد)", "im",
17
+ "\\b(variable|loop|array|class|method|library|install\\w*|error|server|database|query|plugin|app|framework|sdk|cpu|gpu|windows|version|command|config\\w*|network|router)\\b", "i"),
18
+ "math": ("\\b(solve|equation|integral|derivative|prove|proof|theorem|lemma|probability|calculus|algebra|geometry|matrix|matrices|eigen\\w*|polynomial|logarithm|factorial|combinatorics|arithmetic)\\b|\\\\(frac|int|sum|sqrt)|\\d\\s*[\\^×÷]\\s*\\d|\\b\\d+\\s*[+\\-*\\/]\\s*\\d+\\s*=", "i",
19
+ "\\b(calculate|compute|how many|how long|percent\\w*|average|mean|median|standard deviation|variance|sum of|formula|area of|volume of|limit|next number|x\\s*=|f\\(x\\))\\b|\\d+\\s*%|(\\d+\\s*,\\s*){3,}|(数学|方程|计算|概率|確率|計算|수학|방정식|matemat\\w*|oblicz\\w*|równani\\w*|вычисл\\w*|уравнени\\w*|задач\\w*)", "i"),
20
+ "creative_writing": ("\\b(story|stories|poem|poetry|lyrics|song|novel|fiction|fanfic\\w*|screenplay|script for a|haiku|limerick|sonnet|fairy tale|roleplay|role-play|role play|role playing)\\b|(小说|故事|诗|歌词|рассказ|стих\\w*|песн\\w*|сказк\\w*|opowiada\\w*|wiersz\\w*|piosenk\\w*|histoire|poème|chanson|Geschichte|Gedicht|cuento|poema|canción|小説|物語|이야기|소설)", "i",
21
+ "\\b(character|plot|chapter|scene|scenario|imagine|creative|narrat\\w*|villain|hero|speech|toast|slogan)\\b", "i"),
22
+ "legal": ("\\b(lawsuit|attorney|lawyer|contract|legal|statute|court|plaintiff|defendant|gdpr|liabilit\\w*|clause)\\b", "i",
23
+ "\\b(law|rights|regulation|compliance|tenant|landlord)\\b", "i"),
24
+ "medicine": ("\\b(symptom\\w*|diagnos\\w*|medication|dosage|clinical|patient|disease|treatment|doctor|surgery|pregnan\\w*|vaccine)\\b", "i",
25
+ "\\b(health|pain|medical|drug|therapy|blood|nutrition)\\b", "i"),
26
+ "business": ("\\b(marketing|revenue|profit|startup|business plan|investor|pricing strategy|sales funnel|kpi|roi|invoice|accounting|financial statement|stock market|valuation)\\b", "i",
27
+ "\\b(business|customer|budget|finance|company|market|sales|brand|product)\\b", "i"),
28
+ "science": ("\\b(physics|chemistry|biology|molecule|quantum|thermodynamic\\w*|photosynthesis|dna|genome|neuron|astronom\\w*|hypothesis|experiment)\\b", "i",
29
+ "\\b(atom|energy|cell|evolution|climate|species|planet|element)\\b", "i"),
30
+ "writing": ("\\b(essay|proofread|rewrite|rephrase|paraphrase|summari[sz]e|grammar|cover letter|resume|email to|draft an? (email|letter))\\b", "i",
31
+ "\\b(email|letter|article|blog|tone|wording|sentence|paragraph)\\b", "i"),
32
+ "media": ("\\b(movie|film|tv show|series|anime|manga|album|football|soccer|basketball|nba|nfl|olympic\\w*|video game|celebrity)\\b", "i",
33
+ "\\b(music|sport\\w*|game|actor|singer|team|season|episode)\\b", "i")
34
+ }
35
+ LABELS = {
36
+ "overall": "Everything (overall)",
37
+ "coding": "Coding",
38
+ "math": "Math",
39
+ "creative_writing": "Creative writing",
40
+ "instruction_following": "Following detailed instructions",
41
+ "hard_prompts": "Hard prompts",
42
+ "longer_query": "Long prompts",
43
+ "multi_turn": "Long conversations",
44
+ "expert": "Expert questions",
45
+ "business": "Business and finance",
46
+ "legal": "Legal and government",
47
+ "medicine": "Medicine and health",
48
+ "science": "Science",
49
+ "software": "Software and IT",
50
+ "writing": "Writing and literature",
51
+ "media": "Entertainment, sports and media",
52
+ "chinese": "In Chinese",
53
+ "french": "In French",
54
+ "german": "In German",
55
+ "japanese": "In Japanese",
56
+ "korean": "In Korean",
57
+ "russian": "In Russian",
58
+ "spanish": "In Spanish",
59
+ "vision": "Understanding images",
60
+ "document": "Understanding documents",
61
+ "webdev": "Building websites and apps"
62
+ }
63
+ ORDER = ["webdev", "coding", "math", "creative_writing", "legal", "medicine", "science", "business", "writing", "media"]
64
+ TASKS = list(LABELS)
65
+ LANGUAGES = [
66
+ ("japanese", r"[\u3040-\u30ff]"),
67
+ ("korean", r"[\uac00-\ud7af]"),
68
+ ("chinese", r"[\u4e00-\u9fff]"),
69
+ ("russian", r"[\u0400-\u04ff]"),
70
+ ("spanish", r"[ñ¿¡]|\b(el|los|las|que|por|para|una|con|del|es|está|cómo|qué|puedes|necesito)\b"),
71
+ ("french", r"[çœ]|\b(les|des|est|une|pour|dans|avec|qui|pas|je|vous|c'est|n'est|qu'il)\b"),
72
+ ("german", r"ß|\b(der|die|das|und|ist|nicht|mit|ein|eine|ich|sie|auf|für|über|wie|bitte)\b"),
73
+ ]
74
+ SCRIPTS = ("japanese", "korean", "chinese", "russian")
75
+ ENGLISH = re.compile(r"\b(the|and|is|are|to|of|you|what|how|with|for|this|that|in)\b", re.I)
76
+
77
+
78
+ def _flags(text):
79
+ return (re.I if "i" in text else 0) | (re.M if "m" in text else 0)
80
+
81
+
82
+ _COMPILED = {task: (re.compile(s, _flags(sf)), re.compile(w, _flags(wf))) for task, (s, sf, w, wf) in RULES.items()}
83
+ _LANG = [(name, re.compile(p, 0 if name in SCRIPTS else re.I)) for name, p in LANGUAGES]
84
+
85
+
86
+ def language(text):
87
+ """A language with its own Arena task, or None. Writing systems decide Japanese, Korean,
88
+ Chinese and Russian; Spanish, French and German need 3 or more of their common words,
89
+ and more than English ones."""
90
+ english = len(ENGLISH.findall(text))
91
+ best = None
92
+ for name, pattern in _LANG:
93
+ hits = sum(1 for _ in pattern.finditer(text))
94
+ if name in SCRIPTS:
95
+ if hits >= 5:
96
+ return name
97
+ continue
98
+ if hits >= 3 and hits > english and (best is None or hits > best[1]):
99
+ best = (name, hits)
100
+ return best[0] if best else None
101
+
102
+
103
+ def _score(text, task):
104
+ found, points = [], 0
105
+ for pattern, weight in zip(_COMPILED[task], (2, 1)):
106
+ for m in pattern.finditer(text):
107
+ points += weight
108
+ word = m.group(0).strip().lower()
109
+ if len(found) < 4 and word not in found:
110
+ found.append(word)
111
+ return points, found
112
+
113
+
114
+ def guess_task(text, images=0, pages=0):
115
+ """The Arena task a prompt most likely belongs to: {task, why, language}."""
116
+ text = str(text or "")
117
+ if pages > 0:
118
+ return {"task": "document", "why": ["a PDF is attached"], "language": language(text)}
119
+ if images > 0:
120
+ return {"task": "vision", "why": ["an image is attached"], "language": language(text)}
121
+ best = None
122
+ for task in ORDER:
123
+ points, found = _score(text, task)
124
+ if points >= 2 and (best is None or points > best[1]):
125
+ best = (task, points, found)
126
+ lang = language(text)
127
+ if best:
128
+ return {"task": best[0], "why": best[2], "language": lang}
129
+ if lang:
130
+ return {"task": lang, "why": ["written in " + lang.capitalize()], "language": lang}
131
+ return {"task": "overall", "why": ["no clear task"], "language": lang}
132
+
133
+
134
+ # ---------- prompt cost (cost.js) ----------
135
+ CHARS_PER_TOKEN = 4
136
+ TEXT_MODES = ("chat", "responses", "completion")
137
+ NOT_TEXT = re.compile(r"embed|rerank|flux|diffusion|sdxl|controlnet|dall-e|imagen|whisper|tts")
138
+ ANSWERS = {"short": 250, "medium": 800, "long": 2000, "very_long": 6000}
139
+
140
+
141
+ def estimate_tokens(text):
142
+ return math.ceil(len(str(text or "")) / CHARS_PER_TOKEN)
143
+
144
+
145
+ def is_text_model(model):
146
+ return model.get("mode") in TEXT_MODES and not NOT_TEXT.search(str(model.get("key", "")).lower())
147
+
148
+
149
+ def cost_of(model, input_tokens, output_tokens, cached_tokens=0):
150
+ """Dollars for one call: prompt tokens at the input price (the cached part at the cache
151
+ read price when the model lists one), answer tokens at the output price. None if unpriced."""
152
+ if model.get("input") is None or model.get("output") is None:
153
+ return None
154
+ cached = min(max(cached_tokens, 0), input_tokens)
155
+ cache_price = model.get("cache_read")
156
+ if cache_price is None:
157
+ cache_price, cached = model["input"], cached
158
+ return ((input_tokens - cached) * model["input"] + cached * cache_price + output_tokens * model["output"]) / 1e6
159
+
160
+
161
+ # ---------- compare side by side (pick.js) ----------
162
+ LEVELS = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
163
+ HARD_TASKS = ("coding", "webdev", "math", "hard_prompts", "expert", "software", "science")
164
+ DEEP = re.compile(r"\b(architect\w*|design\w*|from scratch|refactor\w*|debug\w*|root cause|prove|proof|optimi[sz]\w*|"
165
+ r"migrat\w*|security|concurren\w*|race condition|trade-?offs?|specification|multi-?step|end to end|production)\b", re.I)
166
+ LIGHT = re.compile(r"\b(summari[sz]e|translate|rewrite|rephrase|proofread|typos?|reformat|format|short|quick(ly)?|one line|"
167
+ r"tl;?dr|classify|extract)\b", re.I)
168
+ SCORE_LEVEL = ["low", "medium", "high", "high", "xhigh", "max"]
169
+
170
+
171
+ def clamp(level, levels):
172
+ """The closest level a model takes: the nearest one below, else the lowest one above."""
173
+ if not levels:
174
+ return None
175
+ if level in levels:
176
+ return level
177
+ at = LEVELS.index(level)
178
+ for i in range(at, -1, -1):
179
+ if LEVELS[i] in levels:
180
+ return LEVELS[i]
181
+ return next((x for x in levels if LEVELS.index(x) > at), levels[0])
182
+
183
+
184
+ def suggest_effort(text, task, levels, prompt_tokens, fallback=""):
185
+ """A suggested effort level: a rule of thumb from the task and the prompt's words and length."""
186
+ if not levels:
187
+ return {"level": None, "why": []}
188
+ text = str(text or "")
189
+ if not text.strip():
190
+ return {"level": fallback if fallback in levels else None, "why": []}
191
+ why, score = [], 1
192
+ if task in HARD_TASKS:
193
+ score += 1
194
+ why.append("a task where thinking helps")
195
+ deep = DEEP.search(text)
196
+ light = None if deep else LIGHT.search(text)
197
+ if deep:
198
+ score += 2
199
+ why.append('it asks for "' + deep.group(0).lower() + '"')
200
+ if light:
201
+ score -= 1
202
+ why.append('it asks for "' + light.group(0).lower() + '"')
203
+ tokens = prompt_tokens or 0
204
+ if tokens > 8000:
205
+ score += 2
206
+ why.append("a very long prompt")
207
+ elif tokens > 2000:
208
+ score += 1
209
+ why.append("a long prompt")
210
+ elif tokens < 40 and not deep:
211
+ score -= 1
212
+ why.append("a short prompt")
213
+ level = SCORE_LEVEL[max(0, min(len(SCORE_LEVEL) - 1, score))]
214
+ return {"level": clamp(level, levels), "why": why}
215
+
216
+
217
+ def plan_multiples(costs):
218
+ """How fast each choice uses a plan compared with the one that uses it least (assumes a
219
+ plan runs down in step with API prices)."""
220
+ known = [c for c in costs if c is not None and c > 0]
221
+ if not known:
222
+ return [None for _ in costs]
223
+ least = min(known)
224
+ return [None if c is None or c <= 0 else c / least for c in costs]
225
+
226
+
227
+ def tied(a, b):
228
+ """The votes cannot tell two ratings apart: their 95% ranges overlap."""
229
+ return a["high"] >= b["low"] and b["high"] >= a["low"]
230
+
231
+
232
+ def best_pick(rows):
233
+ """Among the choices tied with the highest rated, the cheapest; without ratings, the cheapest."""
234
+ priced = [r for r in rows if r.get("cost") is not None]
235
+ if not priced:
236
+ return None
237
+ rated = [r for r in priced if r.get("rating") is not None]
238
+ if not rated:
239
+ return min(priced, key=lambda r: r["cost"])
240
+ top = max(rated, key=lambda r: r["rating"])
241
+ return min((r for r in rated if tied(r, top)), key=lambda r: r["cost"])
242
+
243
+
244
+ def _times(x):
245
+ return (f"{x:.1f}" if x < 10 else f"{x:.0f}") + "×"
246
+
247
+
248
+ def _percent(x):
249
+ return f"{round(100 * x)}%"
250
+
251
+
252
+ def headline(rows, plan=False):
253
+ """One sentence on which to pick, and why (as the page says it)."""
254
+ pick = best_pick(rows)
255
+ if pick is None:
256
+ return ""
257
+ priced = [r for r in rows if r.get("cost") is not None]
258
+ if len(priced) < 2:
259
+ return "Add a model to compare with " + pick["name"] + "."
260
+ rated = [r for r in priced if r.get("rating") is not None]
261
+ top = max(rated, key=lambda r: r["rating"]) if rated else None
262
+ least = min(priced, key=lambda r: r["cost"])
263
+ light = "lightest on your plan" if plan else "cheapest"
264
+ if top is None:
265
+ return pick["name"] + " is the " + light + " here; none of these is rated for this task."
266
+ if pick is top and pick is least:
267
+ return pick["name"] + " is the pick: the highest rated here, and the " + light + "."
268
+ if pick is top:
269
+ return (pick["name"] + " is the pick: the highest rated here. The ones that "
270
+ + ("use less of the plan" if plan else "cost less") + " are rated lower.")
271
+ return (pick["name"] + " is the pick: the votes cannot tell it apart from the highest rated, " + top["name"]
272
+ + ", and it " + ("uses less of the plan" if plan else "costs less") + ".")
273
+
274
+
275
+ def versus(r, pick, plan=False):
276
+ """A short note on one choice against the pick, with the break-even share of answer tokens."""
277
+ if pick is None or r is pick or r.get("cost") is None or not (pick.get("cost") or 0) > 0:
278
+ return ""
279
+ ratio = r["cost"] / pick["cost"]
280
+ name = pick["name"]
281
+ if plan:
282
+ if ratio >= 1.05:
283
+ line = "Uses the plan " + _times(ratio) + " as fast as " + name
284
+ elif ratio <= 0.95:
285
+ line = "Uses " + ("1/" + str(round(1 / ratio)) + " as much" if ratio <= 0.1 else _percent(1 - ratio) + " less") + " of the plan than " + name
286
+ else:
287
+ line = "Uses the plan at about the same rate as " + name
288
+ else:
289
+ if ratio >= 1.05:
290
+ line = "Costs " + _times(ratio) + " as much as " + name
291
+ elif ratio <= 0.95:
292
+ line = "Costs " + ("1/" + str(round(1 / ratio)) + " as much as " if ratio <= 0.1 else _percent(1 - ratio) + " less than ") + name
293
+ else:
294
+ line = "Costs about the same as " + name
295
+ k = r["out"] / pick["out"] if (r.get("out") or 0) > 0 and (pick.get("out") or 0) > 0 else None
296
+ if k is not None and 0.1 < k < 10 and abs(k - 1) >= 0.05:
297
+ if k > 1:
298
+ line += "; it only " + ("uses less" if plan else "costs less") + " if it needs under " + _percent(1 / k) + " of the answer tokens"
299
+ else:
300
+ line += "; it stays " + ("lighter" if plan else "cheaper") + " unless it needs over " + _times(1 / k) + " the answer tokens"
301
+ return line + "."
@@ -0,0 +1,313 @@
1
+ """llmledger as a plug-in (an MCP server) for Claude and other AI apps.
2
+
3
+ Five tools answer "which model, and what will it cost?" from the same data as the llmledger
4
+ page: LiteLLM's price catalogue (MIT), Arena's ratings (CC BY 4.0), and every price change
5
+ since 2023 with how likely a price is to change next. Runs on the user's own computer over
6
+ stdio; reads the day's data.json, cached for a day.
7
+ """
8
+ import time
9
+ from typing import Annotated, Literal
10
+
11
+ from fastmcp import FastMCP
12
+ from fastmcp.exceptions import ToolError
13
+ from mcp.types import ToolAnnotations
14
+ from pydantic import Field
15
+
16
+ from llmledger_mcp import __version__, data, logic
17
+
18
+ Task = Literal[tuple(logic.TASKS)]
19
+ Pay = Literal["api", "claude", "chatgpt"]
20
+ Need = Literal["vision", "pdf", "audio", "video", "tools", "reasoning", "web", "schema", "cache", "computer"]
21
+ PLANS = {"claude": ("anthropic", "anthropic", "Claude"), "chatgpt": ("openai", "openai", "ChatGPT")}
22
+ READ_ONLY = ToolAnnotations(read_only_hint=True, idempotent_hint=True, open_world_hint=False)
23
+ PLAN_NOTE = ("Plan use is an estimate: plans do not publish per-model limits, so it assumes a plan runs down "
24
+ "in step with each model's API price on the maker's own host.")
25
+ EFFORT_NOTE = ("The effort level is a rule of thumb from the task and the prompt. Higher effort writes more hidden "
26
+ "answer tokens, and no open source measures how many yet.")
27
+
28
+ mcp = FastMCP(
29
+ "llmledger",
30
+ instructions=(
31
+ "Which AI model to use, and what it costs. Prices come from LiteLLM's public catalogue, quality from "
32
+ "Arena's votes, and each price's full history from llmledger. Use suggest_for_project for 'which model "
33
+ "for this', compare_models to put 2 to 6 models side by side, estimate_cost for a budget, find_models to "
34
+ "browse, and price_history for one model. Costs and ratings are estimates; pass on the credits line."),
35
+ version=__version__,
36
+ )
37
+
38
+ _SHELF = None
39
+
40
+
41
+ def shelf():
42
+ """The data, loaded once and refreshed after a day."""
43
+ global _SHELF
44
+ if _SHELF is None or time.time() - _SHELF.loaded > data.MAX_AGE:
45
+ _SHELF = data.Shelf(*data.load())
46
+ return _SHELF
47
+
48
+
49
+ def footer(s):
50
+ d = s.data
51
+ return {"data": {"built": d.get("built"), "litellm_changed": (d.get("litellm") or {}).get("changed"),
52
+ "ratings_published": s.rating_date, "copy": s.where},
53
+ "credits": d.get("credits")}
54
+
55
+
56
+ def listing(s, group, plan=None, input_tokens=0, output_tokens=0, needs=(), min_context=None):
57
+ """The listing a grouped model is priced at: its cheapest text listing, or on a plan the plan's own host."""
58
+ best = None
59
+ for m in s.groups.get(group, []):
60
+ if not logic.is_text_model(m) or (plan and m["host"] != PLANS[plan][1]):
61
+ continue
62
+ if any(n not in (m.get("skills") or []) for n in needs):
63
+ continue
64
+ if min_context and not (m.get("context") or 0) >= min_context:
65
+ continue
66
+ cost = logic.cost_of(m, input_tokens, output_tokens)
67
+ if cost is not None and (best is None or cost < best[1]):
68
+ best = (m, cost)
69
+ return best
70
+
71
+
72
+ def prices(m):
73
+ return {"input": m["input"], "output": m["output"], "cache_read": m.get("cache_read"), "unit": "dollars per 1M tokens"}
74
+
75
+
76
+ def money(x):
77
+ return None if x is None else float(f"{x:.6g}")
78
+
79
+
80
+ def resolve_all(s, names):
81
+ groups, missing = [], []
82
+ for name in names:
83
+ group, close = s.resolve(name)
84
+ if group is None:
85
+ missing.append(f"{name!r}" + (" (did you mean " + ", ".join(close[:5]) + "?)" if close else ""))
86
+ elif group not in groups:
87
+ groups.append(group)
88
+ if missing:
89
+ raise ToolError("Not found: " + "; ".join(missing) + ". Use find_models to see the names.")
90
+ return groups
91
+
92
+
93
+ def task_label(task):
94
+ return logic.LABELS.get(task, task)
95
+
96
+
97
+ @mcp.tool(annotations=READ_ONLY)
98
+ def find_models(
99
+ task: Annotated[Task, Field(description="The kind of work, as Arena rates it")] = "overall",
100
+ max_output_price: Annotated[float | None, Field(description="Highest answer price, dollars per 1M tokens", ge=0)] = None,
101
+ min_context: Annotated[int | None, Field(description="Smallest prompt size the model must take, in tokens", ge=0)] = None,
102
+ needs: Annotated[list[Need], Field(description="What the model must be able to do")] = [],
103
+ maker: Annotated[str | None, Field(description="Only this maker, for example anthropic or openai")] = None,
104
+ limit: Annotated[int, Field(ge=1, le=20)] = 5,
105
+ ) -> dict:
106
+ """The highest rated models for a kind of work that meet the limits, each at its cheapest listing,
107
+ with prices per 1M tokens, Arena rating and prompt size. Unrated models follow, cheapest first."""
108
+ s = shelf()
109
+ found = {}
110
+ for group in s.groups:
111
+ if maker and not group.lower().startswith(maker.lower().strip() + " / "):
112
+ continue
113
+ hit = listing(s, group, needs=needs, min_context=min_context)
114
+ if hit and (max_output_price is None or hit[0]["output"] <= max_output_price):
115
+ found[group] = hit[0]
116
+ rated = [r for r in s.ratings.get(task, []) if r[0] in found]
117
+ rows = [{"model": r[0], "listing": found[r[0]]["key"], "host": found[r[0]]["host"],
118
+ "rating": {"score": r[2], "low": r[3], "high": r[4], "rank": i + 1, "of": len(rated)},
119
+ "price_per_1m": prices(found[r[0]]), "prompt_size": found[r[0]].get("context"),
120
+ "effort_levels": found[r[0]].get("effort")} for i, r in enumerate(rated[:limit])]
121
+ if len(rows) < limit:
122
+ rest = sorted((g for g in found if g not in {r[0] for r in rated}), key=lambda g: found[g]["output"])
123
+ rows += [{"model": g, "listing": found[g]["key"], "host": found[g]["host"], "rating": None,
124
+ "price_per_1m": prices(found[g]), "prompt_size": found[g].get("context"),
125
+ "effort_levels": found[g].get("effort")} for g in rest[:limit - len(rows)]]
126
+ return {"task": task_label(task), "matching_models": len(found), "rated_for_task": len(rated), "models": rows,
127
+ **footer(s)}
128
+
129
+
130
+ @mcp.tool(annotations=READ_ONLY)
131
+ def compare_models(
132
+ models: Annotated[list[str], Field(description="2 to 6 model names, for example claude-opus-5.5, gpt-6.1-sol", min_length=2, max_length=6)],
133
+ task: Annotated[Task, Field(description="The kind of work, as Arena rates it")] = "overall",
134
+ input_tokens: Annotated[int, Field(description="Prompt tokens per task", ge=0)] = 1000,
135
+ output_tokens: Annotated[int, Field(description="Answer tokens per task, including hidden thinking", ge=0)] = 800,
136
+ answer_tokens: Annotated[dict[str, int] | None, Field(description="Answer tokens per model when they differ, by the names given")] = None,
137
+ pay: Annotated[Pay, Field(description="api: dollars; claude or chatgpt: how fast each uses that plan")] = "api",
138
+ ) -> dict:
139
+ """Two to six models side by side for a kind of work: rating, prices, cost per task (or plan use), and
140
+ which to pick, with the break-even share of answer tokens for each against the pick."""
141
+ s = shelf()
142
+ groups = resolve_all(s, models)
143
+ own = {}
144
+ for name, n in (answer_tokens or {}).items():
145
+ group, _ = s.resolve(name)
146
+ if group:
147
+ own[group] = n
148
+ plan = pay if pay in PLANS else None
149
+ rows = []
150
+ for group in groups:
151
+ hit = listing(s, group, plan)
152
+ m = hit[0] if hit else None
153
+ out = own.get(group, output_tokens)
154
+ cost = logic.cost_of(m, input_tokens, out) if m else None
155
+ r = s.rating_of(task, group)
156
+ rows.append({"name": group.split(" / ", 1)[1], "model": group, "listing": m["key"] if m else None,
157
+ "host": m["host"] if m else None, "rating": r[2] if r else None, "low": r[3] if r else None,
158
+ "high": r[4] if r else None, "cost": cost, "out": m["output"] if m else None,
159
+ "answer_tokens": out, "m": m})
160
+ pick = logic.best_pick(rows)
161
+ use = logic.plan_multiples([r["cost"] for r in rows])
162
+ table = []
163
+ for r, u in zip(rows, use):
164
+ m = r["m"]
165
+ row = {"model": r["model"], "listing": r["listing"], "host": r["host"],
166
+ "rating": None if r["rating"] is None else {"score": r["rating"], "low": r["low"], "high": r["high"]},
167
+ "price_per_1m": prices(m) if m else None, "answer_tokens": r["answer_tokens"],
168
+ "cost_per_task": money(r["cost"]), "effort_levels": m.get("effort") if m else None,
169
+ "versus_pick": logic.versus(r, pick, bool(plan)) or None}
170
+ if plan:
171
+ row["plan_use"] = None if u is None else round(u, 2)
172
+ if not m:
173
+ row["note"] = "Not listed on " + PLANS[plan][1]
174
+ table.append(row)
175
+ return {"task": task_label(task), "pick": pick["model"] if pick else None,
176
+ "headline": logic.headline(rows, bool(plan)), "models": table,
177
+ "notes": [PLAN_NOTE] if plan else [], **footer(s)}
178
+
179
+
180
+ @mcp.tool(annotations=READ_ONLY)
181
+ def estimate_cost(
182
+ models: Annotated[list[str], Field(description="1 to 10 model names", min_length=1, max_length=10)],
183
+ input_tokens: Annotated[int, Field(description="Prompt tokens per call", ge=0)],
184
+ output_tokens: Annotated[int, Field(description="Answer tokens per call, including hidden thinking", ge=0)],
185
+ cached_tokens: Annotated[int, Field(description="How many of the prompt tokens are read from the cache", ge=0)] = 0,
186
+ calls_per_day: Annotated[float, Field(description="Calls per day", ge=0)] = 1,
187
+ ) -> dict:
188
+ """What a workload costs on each model at its cheapest listing: per call, per day and per 30 days."""
189
+ s = shelf()
190
+ rows = []
191
+ for group in resolve_all(s, models):
192
+ hit = listing(s, group, input_tokens=input_tokens, output_tokens=output_tokens)
193
+ if not hit:
194
+ rows.append({"model": group, "note": "no text price listed"})
195
+ continue
196
+ m = hit[0]
197
+ call = logic.cost_of(m, input_tokens, output_tokens, cached_tokens)
198
+ rows.append({"model": group, "listing": m["key"], "host": m["host"], "price_per_1m": prices(m),
199
+ "per_call": money(call), "per_day": money(call * calls_per_day),
200
+ "per_30_days": money(call * calls_per_day * 30),
201
+ "cache_priced": cached_tokens > 0 and m.get("cache_read") is not None})
202
+ rows.sort(key=lambda r: r.get("per_call") if r.get("per_call") is not None else float("inf"))
203
+ return {"models": rows, **footer(s)}
204
+
205
+
206
+ def _value(field, v):
207
+ if isinstance(v, (int, float)) and not isinstance(v, bool) and ("per_token" in field or "token_cost" in field):
208
+ return money(v * 1e6)
209
+ return v
210
+
211
+
212
+ @mcp.tool(annotations=READ_ONLY)
213
+ def price_history(
214
+ model: Annotated[str, Field(description="A model name, for example claude-opus-5.5, or one listing such as bedrock/anthropic.claude-opus-5-5")],
215
+ ) -> dict:
216
+ """Every change to a model's listings since 2023 (prices, retirement dates), and for listings that have not
217
+ changed yet, the chance of a price change, a retirement date or removal in the next 90 days and year."""
218
+ s = shelf()
219
+ if model in s.by_key:
220
+ keys = [model]
221
+ group = s.by_key[model]["maker"] + " / " + s.by_key[model]["name"]
222
+ else:
223
+ group = resolve_all(s, [model])[0]
224
+ keys = [m["key"] for m in sorted(s.groups[group], key=lambda m: m.get("last_change") or "", reverse=True)][:10]
225
+ out = []
226
+ for key in keys:
227
+ m = s.by_key[key]
228
+ events = [{"day": e[0], "change": e[1], "field": e[2] or None, "before": _value(e[2], e[3]),
229
+ "after": _value(e[2], e[4]), "flag": e[5] or None} for e in s.data["events"].get(key, [])]
230
+ out.append({"listing": key, "host": m["host"], "added": m.get("added") or "before Sept 2023",
231
+ "retirement_date": m.get("retire") or None, "price_per_1m": prices(m),
232
+ "price_changes": m.get("price_changes"), "changes": events, "outlook": m.get("outlook")})
233
+ return {"model": group, "listings": out, "listings_in_total": len(s.groups[group]),
234
+ "notes": ["Prices per 1M tokens. A flag marks a bulk clean up, a unit fix or a change that was undone.",
235
+ "The outlook covers listings that have not changed since they were added: Aalen-Johansen estimates "
236
+ "with 95% intervals, among listings of the same age. What usually happens in the catalogue, not a "
237
+ "forecast for one model."], **footer(s)}
238
+
239
+
240
+ @mcp.tool(annotations=READ_ONLY)
241
+ def suggest_for_project(
242
+ prompt: Annotated[str, Field(description="The project's starting prompt, or a description of the work")],
243
+ pay: Annotated[Pay, Field(description="api: dollars; claude or chatgpt: compare within that plan")] = "api",
244
+ answer: Annotated[Literal["short", "medium", "long", "very_long"], Field(description="Typical answer length")] = "medium",
245
+ ) -> dict:
246
+ """Reads a prompt, guesses its kind of work, and suggests a model and effort level: the best value (the
247
+ cheapest whose rating the votes cannot tell apart from the top), the top rated and the cheapest rated."""
248
+ s = shelf()
249
+ guess = logic.guess_task(prompt)
250
+ task = guess["task"] if s.ratings.get(guess["task"]) else "overall"
251
+ plan = pay if pay in PLANS else None
252
+ tokens_in, tokens_out = logic.estimate_tokens(prompt), logic.ANSWERS[answer]
253
+ rows = []
254
+ for r in s.ratings.get(task, []):
255
+ if plan and not r[0].startswith(PLANS[plan][0] + " / "):
256
+ continue
257
+ hit = listing(s, r[0], plan, tokens_in, tokens_out)
258
+ if hit:
259
+ rows.append({"name": r[0].split(" / ", 1)[1], "model": r[0], "m": hit[0], "cost": hit[1],
260
+ "rating": r[2], "low": r[3], "high": r[4], "out": hit[0]["output"]})
261
+ if not rows:
262
+ raise ToolError("No rated model matches; try pay='api'.")
263
+ best = logic.best_pick(rows)
264
+ top = max(rows, key=lambda r: r["rating"])
265
+ low = min(rows, key=lambda r: r["cost"])
266
+ picks = [("best value", best), ("top rated", top), ("cheapest rated", low)]
267
+ shown, seen = [], set()
268
+ use = logic.plan_multiples([r["cost"] for _, r in picks])
269
+ for (role, r), u in zip(picks, use):
270
+ if r["model"] in seen:
271
+ continue
272
+ seen.add(r["model"])
273
+ m = r["m"]
274
+ effort = logic.suggest_effort(prompt, task, m.get("effort"), tokens_in, m.get("effort_default") or "")
275
+ item = {"role": role, "model": r["model"], "listing": m["key"], "host": m["host"],
276
+ "rating": {"score": r["rating"], "low": r["low"], "high": r["high"],
277
+ "rank": rows.index(r) + 1, "of": len(rows)},
278
+ "cost_per_task": money(r["cost"]), "price_per_1m": prices(m),
279
+ "effort": effort["level"], "effort_why": effort["why"] or None,
280
+ "versus_pick": logic.versus(r, best, bool(plan)) or None}
281
+ if plan:
282
+ item["plan_use"] = None if u is None else round(u, 2)
283
+ shown.append(item)
284
+ notes = [EFFORT_NOTE] + ([PLAN_NOTE] if plan else [])
285
+ return {"task": task_label(task), "why": guess["why"], "prompt_tokens": tokens_in, "answer_tokens": tokens_out,
286
+ "headline": logic.headline([r for _, r in picks], bool(plan)),
287
+ "picks": shown, "rated_models_considered": len(rows), "notes": notes, **footer(s)}
288
+
289
+
290
+ @mcp.resource("llmledger://snapshot", mime_type="application/json")
291
+ def snapshot() -> dict:
292
+ """What the plug-in knows today: data dates, how many models and rated tasks, and the sources."""
293
+ s = shelf()
294
+ return {"models_listed": len(s.models), "grouped_models": len(s.groups),
295
+ "rated_tasks": {k: len(v) for k, v in s.ratings.items()}, "tasks": logic.LABELS, **footer(s)}
296
+
297
+
298
+ @mcp.prompt
299
+ def pick_a_model(work: str) -> str:
300
+ """Pick a model for a piece of work, with its cost and effort."""
301
+ return ("Use the llmledger tools to pick an AI model for this work:\n\n" + work + "\n\n"
302
+ "1. Call suggest_for_project with the work as the prompt.\n"
303
+ "2. Call compare_models with its three picks and the task it returned.\n"
304
+ "3. Answer in five lines or fewer: the pick, its cost per task, the effort level, the runner up and why, "
305
+ "and the credits line.")
306
+
307
+
308
+ def main():
309
+ mcp.run(show_banner=False)
310
+
311
+
312
+ if __name__ == "__main__":
313
+ main()
@@ -0,0 +1,40 @@
1
+ {"schema": 1, "built": "2026-10-16T12:00:00+00:00",
2
+ "litellm": {"commit": "abc", "changed": "2026-10-15T00:00:00+00:00"},
3
+ "credits": {"prices": "LiteLLM (MIT)", "ratings": "Arena (CC BY 4.0)", "note": "Not financial or procurement advice."},
4
+ "models": [
5
+ {"key": "claude-opus-5-5", "maker": "anthropic", "name": "claude-opus-5.5", "host": "anthropic", "mode": "chat",
6
+ "input": 4.0, "output": 20.0, "cache_read": 0.2, "context": 1000000, "skills": ["vision", "tools", "reasoning"],
7
+ "effort": ["low", "medium", "high", "xhigh", "max"], "effort_default": "", "added": "2026-08-01", "retire": "",
8
+ "price_changes": 0, "last_change": "2026-08-01",
9
+ "outlook": {"group": "all", "age_days": 76, "90": {"followed": 900, "price": [0.05, 0.04, 0.06], "retirement": [0.02, 0.01, 0.03], "removed": [0.01, 0.0, 0.02], "none": [0.92, 0.9, 0.94]}}},
10
+ {"key": "bedrock/claude-opus-5-5", "maker": "anthropic", "name": "claude-opus-5.5", "host": "bedrock", "mode": "chat",
11
+ "input": 4.4, "output": 22.0, "cache_read": null, "context": 1000000, "skills": ["tools"], "effort": null,
12
+ "effort_default": "", "added": "2026-08-02", "retire": "", "price_changes": 1, "last_change": "2026-09-01", "outlook": null},
13
+ {"key": "claude-fable-5-1", "maker": "anthropic", "name": "claude-fable-5.1", "host": "anthropic", "mode": "chat",
14
+ "input": 10.0, "output": 50.0, "cache_read": 0.25, "context": 1000000, "skills": ["vision", "tools", "reasoning"],
15
+ "effort": ["low", "medium", "high", "xhigh", "max"], "effort_default": "", "added": "2026-09-01", "retire": "",
16
+ "price_changes": 0, "last_change": "2026-09-01", "outlook": null},
17
+ {"key": "claude-sonnet-5-5", "maker": "anthropic", "name": "claude-sonnet-5.5", "host": "anthropic", "mode": "chat",
18
+ "input": 2.0, "output": 10.0, "cache_read": 0.1, "context": 1000000, "skills": ["tools", "reasoning"],
19
+ "effort": ["low", "medium", "high"], "effort_default": "medium", "added": "2026-09-01", "retire": "",
20
+ "price_changes": 0, "last_change": "2026-09-01", "outlook": null},
21
+ {"key": "gpt-6.1-sol", "maker": "openai", "name": "gpt-6.1-sol", "host": "openai", "mode": "chat",
22
+ "input": 2.0, "output": 10.0, "cache_read": 0.1, "context": 922000, "skills": ["vision", "tools"],
23
+ "effort": ["low", "medium", "high"], "effort_default": "", "added": "2026-09-10", "retire": "",
24
+ "price_changes": 0, "last_change": "2026-09-10", "outlook": null},
25
+ {"key": "tiny-embed", "maker": "other", "name": "tiny-embed", "host": "x", "mode": "embedding",
26
+ "input": 0.01, "output": 0.0, "cache_read": null, "context": 8000, "skills": [], "effort": null,
27
+ "effort_default": "", "added": "2026-01-01", "retire": "", "price_changes": 0, "last_change": "2026-01-01", "outlook": null}
28
+ ],
29
+ "events": {"claude-opus-5-5": [["2026-08-01", "added", "", null, null, ""]],
30
+ "bedrock/claude-opus-5-5": [["2026-08-02", "added", "", null, null, ""],
31
+ ["2026-09-01", "price", "input_cost_per_token", 5e-06, 4.4e-06, ""]],
32
+ "claude-fable-5-1": [], "claude-sonnet-5-5": [], "gpt-6.1-sol": [], "tiny-embed": []},
33
+ "ratings": {"date": "2026-10-15", "source": "s", "labels": [["overall", "Everything (overall)"], ["coding", "Coding"]],
34
+ "tasks": {"overall": [["anthropic / claude-opus-5.5", "claude-opus-5-5", 1548.0, 1534.0, 1561.0, 9000, 1],
35
+ ["anthropic / claude-fable-5.1", "claude-fable-5-1", 1537.0, 1526.0, 1549.0, 7000, 2],
36
+ ["openai / gpt-6.1-sol", "gpt-6.1-sol", 1530.0, 1520.0, 1540.0, 8000, 3],
37
+ ["anthropic / claude-sonnet-5.5", "claude-sonnet-5-5", 1510.0, 1500.0, 1520.0, 6000, 4]],
38
+ "coding": [["anthropic / claude-fable-5.1", "claude-fable-5-1", 1550.0, 1545.0, 1556.0, 5000, 1],
39
+ ["anthropic / claude-opus-5.5", "claude-opus-5-5", 1548.0, 1534.0, 1561.0, 6000, 2],
40
+ ["anthropic / claude-sonnet-5.5", "claude-sonnet-5-5", 1520.0, 1510.0, 1530.0, 4000, 3]]}}}
@@ -0,0 +1,96 @@
1
+ """Known-answer tests for each tool, through FastMCP's in-memory client (no network)."""
2
+ import asyncio
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ os.environ["LLMLEDGER_DATA"] = str(Path(__file__).with_name("fixture.json"))
10
+
11
+ from fastmcp import Client # noqa: E402
12
+ from fastmcp.exceptions import ToolError # noqa: E402
13
+
14
+ from llmledger_mcp.server import mcp # noqa: E402
15
+
16
+
17
+ def call(tool, **args):
18
+ async def go():
19
+ async with Client(mcp) as client:
20
+ return (await client.call_tool(tool, args)).data
21
+ return asyncio.run(go())
22
+
23
+
24
+ def test_the_five_tools_a_resource_and_a_prompt_are_listed():
25
+ async def go():
26
+ async with Client(mcp) as client:
27
+ tools = {t.name: t for t in await client.list_tools()}
28
+ resources = [str(r.uri) for r in await client.list_resources()]
29
+ prompts = [p.name for p in await client.list_prompts()]
30
+ snap = json.loads((await client.read_resource("llmledger://snapshot"))[0].text)
31
+ return tools, resources, prompts, snap
32
+ tools, resources, prompts, snap = asyncio.run(go())
33
+ assert sorted(tools) == ["compare_models", "estimate_cost", "find_models", "price_history", "suggest_for_project"]
34
+ assert all(t.annotations.read_only_hint for t in tools.values())
35
+ assert resources == ["llmledger://snapshot"] and prompts == ["pick_a_model"]
36
+ assert snap["models_listed"] == 6 and snap["rated_tasks"] == {"overall": 4, "coding": 3}
37
+ assert "MIT" in snap["credits"]["prices"]
38
+
39
+
40
+ def test_find_models_ranks_by_rating_and_applies_limits():
41
+ out = call("find_models", task="coding", limit=5)
42
+ assert [m["model"] for m in out["models"]] == [
43
+ "anthropic / claude-fable-5.1", "anthropic / claude-opus-5.5", "anthropic / claude-sonnet-5.5", "openai / gpt-6.1-sol"]
44
+ assert out["models"][1]["listing"] == "claude-opus-5-5" # the cheapest listing, not bedrock's
45
+ assert out["models"][3]["rating"] is None and out["rated_for_task"] == 3
46
+ cheap = call("find_models", task="overall", max_output_price=15, needs=["vision"])
47
+ assert [m["model"] for m in cheap["models"]] == ["openai / gpt-6.1-sol"]
48
+
49
+
50
+ def test_compare_models_picks_and_explains():
51
+ out = call("compare_models", models=["opus 5.5", "claude-fable-5-1", "claude-sonnet-5.5"], task="coding",
52
+ input_tokens=1000, output_tokens=800)
53
+ assert out["pick"] == "anthropic / claude-opus-5.5"
54
+ assert out["headline"] == ("claude-opus-5.5 is the pick: the votes cannot tell it apart from the highest rated, "
55
+ "claude-fable-5.1, and it costs less.")
56
+ opus, fable, sonnet = out["models"]
57
+ assert opus["cost_per_task"] == pytest.approx((1000 * 4 + 800 * 20) / 1e6)
58
+ assert fable["versus_pick"] == ("Costs 2.5× as much as claude-opus-5.5; it only costs less if it needs under 40% "
59
+ "of the answer tokens.")
60
+ plan = call("compare_models", models=["claude-opus-5.5", "claude-fable-5.1"], task="coding", pay="claude",
61
+ answer_tokens={"claude-fable-5.1": 300})
62
+ # opus: 1,000 in at $4 and 800 out at $20 = $0.020; fable with 300 answer tokens: $0.010 + $0.015 = $0.025
63
+ assert [m["plan_use"] for m in plan["models"]] == [1.0, 1.25]
64
+ assert plan["notes"] and "estimate" in plan["notes"][0]
65
+
66
+
67
+ def test_unknown_names_say_what_is_close():
68
+ with pytest.raises(ToolError, match="Not found: 'claude' \\(did you mean"):
69
+ call("compare_models", models=["claude", "gpt-6.1-sol"])
70
+
71
+
72
+ def test_estimate_cost_per_call_day_and_month_with_cache():
73
+ out = call("estimate_cost", models=["claude-opus-5.5"], input_tokens=10000, output_tokens=1000,
74
+ cached_tokens=8000, calls_per_day=100)
75
+ m = out["models"][0]
76
+ per_call = (2000 * 4 + 8000 * 0.2 + 1000 * 20) / 1e6
77
+ assert m["per_call"] == pytest.approx(per_call) and m["per_30_days"] == pytest.approx(per_call * 3000)
78
+ assert m["cache_priced"] is True
79
+
80
+
81
+ def test_price_history_lists_changes_per_1m_and_the_outlook():
82
+ out = call("price_history", model="claude-opus-5.5")
83
+ bedrock = next(x for x in out["listings"] if x["listing"] == "bedrock/claude-opus-5-5")
84
+ assert bedrock["changes"][1] == {"day": "2026-09-01", "change": "price", "field": "input_cost_per_token",
85
+ "before": 5.0, "after": 4.4, "flag": None}
86
+ first = next(x for x in out["listings"] if x["listing"] == "claude-opus-5-5")
87
+ assert first["outlook"]["90"]["price"] == [0.05, 0.04, 0.06]
88
+
89
+
90
+ def test_suggest_for_project_reads_the_prompt():
91
+ out = call("suggest_for_project", prompt="Debug this Python service and refactor the slow parts")
92
+ assert out["task"] == "Coding" and out["why"][:2] == ["debug", "python"]
93
+ best = out["picks"][0]
94
+ assert best["role"] == "best value" and best["model"] == "anthropic / claude-opus-5.5" and best["effort"] == "xhigh"
95
+ plan = call("suggest_for_project", prompt="Write a short poem", pay="chatgpt")
96
+ assert [p["model"] for p in plan["picks"]] == ["openai / gpt-6.1-sol"] and plan["picks"][0]["plan_use"] == 1.0