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.
- llmledger_mcp-0.1.0/.gitignore +32 -0
- llmledger_mcp-0.1.0/LICENSE +21 -0
- llmledger_mcp-0.1.0/PKG-INFO +78 -0
- llmledger_mcp-0.1.0/README.md +60 -0
- llmledger_mcp-0.1.0/pyproject.toml +37 -0
- llmledger_mcp-0.1.0/scripts/snapshot.py +20 -0
- llmledger_mcp-0.1.0/server.json +21 -0
- llmledger_mcp-0.1.0/src/llmledger_mcp/__init__.py +2 -0
- llmledger_mcp-0.1.0/src/llmledger_mcp/__main__.py +3 -0
- llmledger_mcp-0.1.0/src/llmledger_mcp/data.py +107 -0
- llmledger_mcp-0.1.0/src/llmledger_mcp/logic.py +301 -0
- llmledger_mcp-0.1.0/src/llmledger_mcp/server.py +313 -0
- llmledger_mcp-0.1.0/src/llmledger_mcp/snapshot.json.gz +0 -0
- llmledger_mcp-0.1.0/tests/fixture.json +40 -0
- llmledger_mcp-0.1.0/tests/test_server.py +96 -0
|
@@ -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,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()
|
|
Binary file
|
|
@@ -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
|