llm-route-audit 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.
Files changed (66) hide show
  1. llm_route_audit-0.1.0/.gitignore +31 -0
  2. llm_route_audit-0.1.0/CHANGELOG.md +26 -0
  3. llm_route_audit-0.1.0/LICENSE +21 -0
  4. llm_route_audit-0.1.0/PKG-INFO +259 -0
  5. llm_route_audit-0.1.0/README.md +232 -0
  6. llm_route_audit-0.1.0/examples/candidates-openrouter.yaml +9 -0
  7. llm_route_audit-0.1.0/examples/candidates.yaml +9 -0
  8. llm_route_audit-0.1.0/examples/demo/grades.jsonl +15 -0
  9. llm_route_audit-0.1.0/examples/demo/replay.jsonl +10 -0
  10. llm_route_audit-0.1.0/examples/grading.yaml +36 -0
  11. llm_route_audit-0.1.0/examples/sample_logs.jsonl +200 -0
  12. llm_route_audit-0.1.0/pyproject.toml +73 -0
  13. llm_route_audit-0.1.0/scripts/make_demo_chart.py +40 -0
  14. llm_route_audit-0.1.0/scripts/make_sample_logs.py +677 -0
  15. llm_route_audit-0.1.0/src/routeaudit/__init__.py +3 -0
  16. llm_route_audit-0.1.0/src/routeaudit/analyze.py +207 -0
  17. llm_route_audit-0.1.0/src/routeaudit/cache.py +93 -0
  18. llm_route_audit-0.1.0/src/routeaudit/candidates.py +68 -0
  19. llm_route_audit-0.1.0/src/routeaudit/checkmodel.py +130 -0
  20. llm_route_audit-0.1.0/src/routeaudit/cli.py +891 -0
  21. llm_route_audit-0.1.0/src/routeaudit/costs.py +67 -0
  22. llm_route_audit-0.1.0/src/routeaudit/data/prices.yaml +10 -0
  23. llm_route_audit-0.1.0/src/routeaudit/display.py +301 -0
  24. llm_route_audit-0.1.0/src/routeaudit/grading/__init__.py +1 -0
  25. llm_route_audit-0.1.0/src/routeaudit/grading/checks.py +212 -0
  26. llm_route_audit-0.1.0/src/routeaudit/grading/grade.py +292 -0
  27. llm_route_audit-0.1.0/src/routeaudit/grading/judge.py +71 -0
  28. llm_route_audit-0.1.0/src/routeaudit/ingest/__init__.py +1 -0
  29. llm_route_audit-0.1.0/src/routeaudit/ingest/common.py +144 -0
  30. llm_route_audit-0.1.0/src/routeaudit/ingest/jsonl.py +64 -0
  31. llm_route_audit-0.1.0/src/routeaudit/ingest/langfuse.py +139 -0
  32. llm_route_audit-0.1.0/src/routeaudit/ingest/litellm.py +107 -0
  33. llm_route_audit-0.1.0/src/routeaudit/ingest/otel.py +167 -0
  34. llm_route_audit-0.1.0/src/routeaudit/monitor.py +236 -0
  35. llm_route_audit-0.1.0/src/routeaudit/policy.py +41 -0
  36. llm_route_audit-0.1.0/src/routeaudit/providers/__init__.py +23 -0
  37. llm_route_audit-0.1.0/src/routeaudit/providers/anthropic.py +82 -0
  38. llm_route_audit-0.1.0/src/routeaudit/providers/base.py +36 -0
  39. llm_route_audit-0.1.0/src/routeaudit/providers/ollama.py +71 -0
  40. llm_route_audit-0.1.0/src/routeaudit/providers/openrouter.py +143 -0
  41. llm_route_audit-0.1.0/src/routeaudit/records.py +59 -0
  42. llm_route_audit-0.1.0/src/routeaudit/replay.py +249 -0
  43. llm_route_audit-0.1.0/src/routeaudit/report.py +403 -0
  44. llm_route_audit-0.1.0/src/routeaudit/report_view.py +394 -0
  45. llm_route_audit-0.1.0/src/routeaudit/runner.py +176 -0
  46. llm_route_audit-0.1.0/src/routeaudit/sampling.py +48 -0
  47. llm_route_audit-0.1.0/tests/test_analyze.py +98 -0
  48. llm_route_audit-0.1.0/tests/test_candidates.py +44 -0
  49. llm_route_audit-0.1.0/tests/test_checkmodel.py +156 -0
  50. llm_route_audit-0.1.0/tests/test_checks.py +88 -0
  51. llm_route_audit-0.1.0/tests/test_cli.py +271 -0
  52. llm_route_audit-0.1.0/tests/test_costs.py +33 -0
  53. llm_route_audit-0.1.0/tests/test_grade.py +183 -0
  54. llm_route_audit-0.1.0/tests/test_import_langfuse_otel.py +234 -0
  55. llm_route_audit-0.1.0/tests/test_jsonl.py +40 -0
  56. llm_route_audit-0.1.0/tests/test_judge.py +38 -0
  57. llm_route_audit-0.1.0/tests/test_litellm.py +116 -0
  58. llm_route_audit-0.1.0/tests/test_monitor.py +199 -0
  59. llm_route_audit-0.1.0/tests/test_openrouter.py +156 -0
  60. llm_route_audit-0.1.0/tests/test_providers.py +117 -0
  61. llm_route_audit-0.1.0/tests/test_records.py +39 -0
  62. llm_route_audit-0.1.0/tests/test_replay.py +137 -0
  63. llm_route_audit-0.1.0/tests/test_report.py +191 -0
  64. llm_route_audit-0.1.0/tests/test_sample_logs.py +31 -0
  65. llm_route_audit-0.1.0/tests/test_sampling.py +47 -0
  66. llm_route_audit-0.1.0/tests/test_spend_guard.py +134 -0
@@ -0,0 +1,31 @@
1
+ # Secrets: never commit real keys
2
+ .env
3
+ .env.*
4
+ !.env.example
5
+
6
+ # Python
7
+ __pycache__/
8
+ *.py[cod]
9
+ *.egg-info/
10
+ .venv/
11
+ build/
12
+ dist/
13
+
14
+ # Tool caches
15
+ .pytest_cache/
16
+ .ruff_cache/
17
+ .mypy_cache/
18
+ .coverage
19
+ htmlcov/
20
+
21
+ # routeaudit outputs (replay cache, generated reports)
22
+ .routeaudit/
23
+ *.sqlite
24
+ *.sqlite3
25
+ reports/
26
+
27
+ # Editors and OS
28
+ .vscode/
29
+ .idea/
30
+ .DS_Store
31
+ Thumbs.db
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - `routeaudit import --format langfuse`: import Langfuse GENERATION observations from a UI export or the API, with task types from trace tags or generation names.
6
+ - `routeaudit import --format otel`: import OpenTelemetry GenAI spans from OTLP JSON, using the `gen_ai.*` message and usage attributes, with task types from a span attribute.
7
+ - `routeaudit check-model`: test a new model (and effort) on the same sample as the last audit. It adds the results to the audit files and shows which tasks it would take over and how savings change.
8
+ - `routeaudit monitor`: shadow checks after you adopt a policy. It samples production requests on each switched route and replays them on the reference model. It grades the production answer against the reference and reports OK, WAIT, WARN or ALERT for each task. Exits with code 2 on an alert.
9
+ - `routeaudit export` now writes each switched route's reference model and audited pass rate (`reference`, `expected_pass_rate`), which `monitor` checks against.
10
+
11
+ ## 0.1.0 (2026-10-07)
12
+
13
+ First public release. It runs the full audit end to end on single-request workloads.
14
+
15
+ - `routeaudit import`: convert LiteLLM logs (StandardLoggingPayload JSON/JSONL). Task types come from `task:` request tags. Failed calls, cache hits, images and tool calls are skipped and counted.
16
+ - `routeaudit validate`: check a log file, with errors reported by line number.
17
+ - `routeaudit analyze`: cost by task type and model, a monthly estimate and latency. Accepts `--prices` and `--json`.
18
+ - `routeaudit replay`: re-run a sample of logged requests, spread across task types, on candidate (model, effort) pairs.
19
+ - Providers: Anthropic, OpenRouter and Ollama.
20
+ - Spending: cost estimate with confirmation, `--dry-run`, `--budget`, and a hard `--max-spend` limit.
21
+ - Every answer is cached, so the same request is never paid for twice.
22
+ - `routeaudit grade`: compare each answer with the original.
23
+ - Exact checks run first: JSON, matching fields, exact match, contains, regex, length.
24
+ - An AI judge then reads each pair in both orders, and its consistency is reported.
25
+ - `routeaudit report`: per-task recommendation (the cheapest option within 95% of the original's pass rate, with at least 10 graded answers), a comparison of whole-workload strategies, 95% ranges, and an offline HTML report.
26
+ - `routeaudit export`: the routing policy as YAML or as a LiteLLM proxy config with one alias per task type.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Muhammad Idnan
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,259 @@
1
+ Metadata-Version: 2.5
2
+ Name: llm-route-audit
3
+ Version: 0.1.0
4
+ Summary: Find out whether LLM model routing saves money without hurting quality, on your own traffic.
5
+ Project-URL: Homepage, https://github.com/good2idnan/llm-route-audit
6
+ Project-URL: Repository, https://github.com/good2idnan/llm-route-audit
7
+ Project-URL: Issues, https://github.com/good2idnan/llm-route-audit/issues
8
+ Project-URL: Changelog, https://github.com/good2idnan/llm-route-audit/blob/main/CHANGELOG.md
9
+ Author: Muhammad Idnan
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: cost-optimization,llm,llm-evaluation,llm-router,model-routing
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: anthropic>=1.11.0
22
+ Requires-Dist: pydantic>=2.7
23
+ Requires-Dist: python-dotenv>=1.2.4
24
+ Requires-Dist: pyyaml>=6.0
25
+ Requires-Dist: typer>=0.12
26
+ Description-Content-Type: text/markdown
27
+
28
+ # llm-route-audit
29
+
30
+ **Find out whether LLM model routing saves money without hurting quality, on your own traffic.**
31
+
32
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
33
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](pyproject.toml)
34
+ [![Status: alpha](https://img.shields.io/badge/status-alpha-orange.svg)](CHANGELOG.md)
35
+
36
+ Model routers send easy prompts to cheap models and hard prompts to strong ones. Whether that works for *your* workload is usually a guess. llm-route-audit replays a sample of your real requests on cheaper models, grades every answer against the original, and tells you, task by task, where a cheaper model is good enough. You get a report and a routing policy for the gateway you already run.
37
+
38
+ ![Per-task routing compared with using one cheaper model everywhere](https://raw.githubusercontent.com/good2idnan/llm-route-audit/main/docs/images/demo-chart.svg)
39
+
40
+ <sub>Demo run on 5 requests: using Haiku everywhere cut cost to 19% but only 58% of answers passed. Per-task routing kept every answer passing at 26% of the cost. The sample is far too small to trust, so run it on your own logs.</sub>
41
+
42
+ ## Try it in one minute
43
+
44
+ No API key needed. The repository includes the results of a real run.
45
+
46
+ ```bash
47
+ git clone https://github.com/good2idnan/llm-route-audit.git
48
+ cd llm-route-audit
49
+ uv sync
50
+ uv run routeaudit report examples/sample_logs.jsonl \
51
+ --replay examples/demo/replay.jsonl --grades examples/demo/grades.jsonl --min-samples 1
52
+ ```
53
+
54
+ Requires Python 3.11+ and [uv](https://docs.astral.sh/uv/). The command-line tool is called `routeaudit`.
55
+
56
+ ## Why
57
+
58
+ - **Routers often don't beat one good model.** In independent benchmarks, most learned routers fail to beat simply using the best single model.
59
+ - **Cheaper per call isn't cheaper per task.** A cheap model that breaks your JSON format or gets the facts wrong costs more than it saves.
60
+ - **Your workload is not a benchmark.** The only evidence that counts is how candidate models do on your own requests.
61
+
62
+ llm-route-audit is not a router. It measures whether routing pays off, and can tell you not to route.
63
+
64
+ ## Features
65
+
66
+ - **Your own data.** Replays your logged requests, not public benchmarks.
67
+ - **Per task type.** Recommends a model for each kind of request, since one cheaper model rarely fits all of them.
68
+ - **Honest grading.** Exact checks first (JSON, fields, required text), then an AI judge that reads each pair in both orders, with its consistency reported.
69
+ - **Model and effort.** Tests (model, reasoning effort) pairs, not just models.
70
+ - **Spending you control.** A cost estimate and confirmation before any paid call, plus a hard `--max-spend` limit that can't be exceeded. Answers are cached, so you never pay twice.
71
+ - **Readable output.** An offline HTML report, a YAML policy, or a ready-to-use LiteLLM config.
72
+ - **Local first.** Logs, results and reports stay on your machine.
73
+
74
+ ## How it works
75
+
76
+ ```
77
+ your logs ─▶ analyze ─▶ replay a sample ─▶ grade answers ─▶ report ─▶ export policy ─▶ monitor
78
+ ```
79
+
80
+ | Command | What it does |
81
+ |---|---|
82
+ | `routeaudit import` | Convert LiteLLM, Langfuse or OpenTelemetry logs into routeaudit's log format |
83
+ | `routeaudit validate` | Check a log file |
84
+ | `routeaudit analyze` | Show what your traffic costs today, by task type and model |
85
+ | `routeaudit replay` | Re-run a sample of requests on candidate models (Anthropic, OpenRouter, Ollama) |
86
+ | `routeaudit grade` | Compare every answer with the original: exact checks, then an AI judge |
87
+ | `routeaudit report` | Recommend a model per task type, with cost and quality for each strategy |
88
+ | `routeaudit export` | Write the policy as YAML or as a LiteLLM proxy config |
89
+ | `routeaudit monitor` | After you switch, check that routed traffic still meets the audited quality |
90
+ | `routeaudit check-model` | Test a newly released model on your last audit's sample and see what it would change |
91
+
92
+ ## Audit your own traffic
93
+
94
+ The commands below use the bundled synthetic sample, [`examples/sample_logs.jsonl`](examples/sample_logs.jsonl). Swap in your own log file.
95
+
96
+ **1. See what you spend today**
97
+
98
+ ```bash
99
+ uv run routeaudit analyze examples/sample_logs.jsonl
100
+ ```
101
+
102
+ ```
103
+ By task type (most expensive first)
104
+ Task Requests Share Avg in Avg out Cost Cost share Per 1K p50
105
+ review_contract 30 15% 209 1,126 $0.7005 55% $23.35 24.1s
106
+ summarize_call 40 20% 199 235 $0.2197 17% $5.49 6.3s
107
+ classify_ticket 45 22% 121 31 $0.0499 4% $1.11 1.6s
108
+ ```
109
+
110
+ **2. Replay a sample on cheaper models**
111
+
112
+ List the candidates in a YAML file (see [`examples/candidates.yaml`](examples/candidates.yaml)), check the cost first, then run:
113
+
114
+ ```bash
115
+ uv run routeaudit replay examples/sample_logs.jsonl -c examples/candidates.yaml --dry-run
116
+ uv run routeaudit replay examples/sample_logs.jsonl -c examples/candidates.yaml --max-spend 1.00
117
+ ```
118
+
119
+ **3. Grade the answers**
120
+
121
+ ```bash
122
+ uv run routeaudit grade examples/sample_logs.jsonl --config examples/grading.yaml --max-spend 1.00
123
+ ```
124
+
125
+ Rules are set per task type in [`examples/grading.yaml`](examples/grading.yaml). Exact checks run first and cost nothing. Answers that pass them go to the AI judge, which compares each one with the original twice, swapping the order to cancel position bias. Disagreements count as ties. Pick the judge with `--judge-model`.
126
+
127
+ **4. Get the report**
128
+
129
+ ```bash
130
+ uv run routeaudit report examples/sample_logs.jsonl
131
+ ```
132
+
133
+ For each task type, the report recommends the **cheapest option that keeps at least 95% of the original's pass rate**, and only once that option has **at least 10 graded answers**. Until then it keeps your current model and says why. The same report is saved as `.routeaudit/report.html`. Tune the rules with `--target` and `--min-samples`.
134
+
135
+ **5. Export the policy**
136
+
137
+ ```bash
138
+ uv run routeaudit export examples/sample_logs.jsonl --out routing-policy.yaml
139
+ uv run routeaudit export examples/sample_logs.jsonl --format litellm --out litellm-config.yaml
140
+ ```
141
+
142
+ The LiteLLM config gives each task type its own model alias, such as `route/draft_reply`. Your app sends each request to its task's alias, and LiteLLM forwards it to the chosen model with the right effort setting.
143
+
144
+ **6. Keep watching after you switch**
145
+
146
+ Models change and traffic drifts, so a cheaper route that passed the audit can get worse later. Once the policy is live, point `monitor` at fresh production logs:
147
+
148
+ ```bash
149
+ uv run routeaudit monitor production-logs.jsonl --policy routing-policy.yaml --config examples/grading.yaml
150
+ ```
151
+
152
+ For each task that switched to a cheaper model, it samples recent requests (`--per-task 20`) and replays them on the model the route replaced. It then grades the production answer against that reference answer and compares the pass rate with what the audit measured:
153
+
154
+ | Status | Meaning |
155
+ |---|---|
156
+ | `OK` | At or above the audited pass rate, minus a small tolerance (`--tolerance 0.05`) |
157
+ | `WAIT` | No problem so far, but too few checks to be sure (`--min-checks 10`) |
158
+ | `WARN` | Below the line, but the sample can't rule out bad luck yet |
159
+ | `ALERT` | 95% sure quality dropped. The command exits with code 2, so cron or CI can flag it. |
160
+
161
+ Monitoring uses the same cost estimate, confirmation, `--max-spend` and cache as replay.
162
+
163
+ **7. Test a new model in one command**
164
+
165
+ When a new model comes out, test it on the same requests as your last audit:
166
+
167
+ ```bash
168
+ uv run routeaudit check-model examples/sample_logs.jsonl -m claude-sonnet-5-5 --effort low --config examples/grading.yaml
169
+ ```
170
+
171
+ It replays and grades only the new model, adds its results to your audit files, and shows which tasks it would take over and how projected savings change. To test the "one strong model at lower effort" alternative, check your current model at `--effort low`.
172
+
173
+ ## Providers and spending
174
+
175
+ Name each candidate (and the judge) after where it runs, and put the key in a `.env` file:
176
+
177
+ | Provider | Model name | Key in `.env` |
178
+ |---|---|---|
179
+ | Anthropic | `claude-haiku-4-5` | `ANTHROPIC_API_KEY` |
180
+ | [OpenRouter](https://openrouter.ai) | `openrouter/anthropic/claude-haiku-4.5` | `OPENROUTER_API_KEY` |
181
+ | [Ollama](https://ollama.com) (local, free) | `ollama/llama3.2` | none |
182
+
183
+ Effort levels (`low` to `max`) go next to the model in the candidates file. OpenRouter prices are looked up automatically, and the real cost of each call is recorded.
184
+
185
+ Three spending controls work on both `replay` and `grade`:
186
+
187
+ - **Confirmation.** routeaudit shows the estimate and asks before spending (`--yes` skips the question).
188
+ - **`--budget 1.00`** refuses to start if the *estimate* is above $1.00.
189
+ - **`--max-spend 1.00`** is a hard limit on *actual* spend. Each call reserves its worst-case cost first, so even parallel calls can't push the total over. Calls that don't fit are held back and reported.
190
+
191
+ ## Logs
192
+
193
+ **Already logging somewhere?** Import from the tool you use. Point `import` at one file or a folder:
194
+
195
+ | Source | Command | Task type comes from |
196
+ |---|---|---|
197
+ | [LiteLLM](https://github.com/BerriAI/litellm) logging callbacks (JSON/JSONL) | `routeaudit import litellm-logs/ --format litellm` | request tag `task:<name>` |
198
+ | [Langfuse](https://langfuse.com) observations (UI export or `/api/public/v2/observations`) | `routeaudit import observations.json --format langfuse` | trace tag `task:<name>`, or `--task-from-name` |
199
+ | [OpenTelemetry](https://opentelemetry.io) GenAI spans (OTLP JSON, e.g. the Collector's file exporter) | `routeaudit import traces.jsonl --format otel` | span attribute `task_type` (`--task-attribute`) |
200
+
201
+ Only real model calls are imported. Requests that can't be replayed faithfully yet are skipped and counted: failed calls, cache hits, images and tool calls. For OpenTelemetry, turn on GenAI message capture so the spans include the prompts and answers. API keys and other metadata are not copied.
202
+
203
+ **Writing your own logs?** Use one JSON object per line:
204
+
205
+ ```json
206
+ {"id": "req_0001", "timestamp": "2026-09-28T09:14:03Z", "model": "claude-opus-5-5",
207
+ "task_type": "classify_ticket",
208
+ "messages": [{"role": "system", "content": "..."}, {"role": "user", "content": "..."}],
209
+ "response": "{\"category\": \"billing\", \"priority\": \"high\"}",
210
+ "input_tokens": 180, "output_tokens": 32, "latency_ms": 1610}
211
+ ```
212
+
213
+ | Field | Required | Notes |
214
+ |---|---|---|
215
+ | `id`, `timestamp`, `model`, `response` | Yes | `id` must be unique |
216
+ | `messages` or `prompt` | Yes | Use either one |
217
+ | `input_tokens`, `output_tokens` | No | `input_tokens` counts uncached input only; estimated from text if missing |
218
+ | `cache_read_tokens`, `cache_write_tokens` | No | For cache-aware pricing |
219
+ | `task_type` | No | Strongly recommended: recommendations are per task type |
220
+ | `latency_ms`, `outcome`, `metadata` | No | Unknown fields are ignored |
221
+
222
+ **Prices.** A starter table for Claude models is built in. Pass your own with `--prices my-prices.yaml` (USD per 1M tokens: `input`, `output`, `cache_read`, `cache_write`). Requests whose model has no price are reported, not silently dropped.
223
+
224
+ ## FAQ
225
+
226
+ **When should I not route?**
227
+ When traffic is low, when every request is equally hard, or in long agent sessions where switching models throws away the prompt cache. Also whenever cheaper models fail your checks. "Keep your current model" is a normal result.
228
+
229
+ **How much does an audit cost?**
230
+ Mostly the replay and judge calls. The demo in this repo (5 requests, 2 candidates, a Sonnet judge) cost $0.11. Costs grow roughly in line with requests × candidates. `--dry-run` shows the estimate, `--max-spend` caps it, and local Ollama models are free.
231
+
232
+ **Does my data leave my machine?**
233
+ Only the requests you choose to replay, sent to the providers you configure. Logs, answers, grades and reports stay in your project folder, and the HTML report loads nothing from the internet.
234
+
235
+ **Can I trust an AI judge?**
236
+ Treat it as one signal. Exact checks come first and settle formats and facts for free. The judge sees each pair in both orders, and the report shows how often its two verdicts agree. Use a strong judge model, and spot-check `.routeaudit/grades.jsonl`.
237
+
238
+ **How many requests do I need?**
239
+ At least 10 graded answers per task type before anything is recommended, and 30 or more for confident numbers. The report shows a 95% range for every pass rate.
240
+
241
+ **Does it handle agents and tool calls?**
242
+ Not yet. Version 0.1 covers single requests, such as chat, extraction, classification and drafting. Agent turns are skipped on import.
243
+
244
+ **Is this a router?**
245
+ No. It audits routing and produces a policy. Put the policy in the gateway you already use, for example with the LiteLLM export.
246
+
247
+ ## Development
248
+
249
+ ```bash
250
+ uv sync
251
+ uv run pytest
252
+ uv run ruff check . && uv run ruff format --check .
253
+ ```
254
+
255
+ Issues and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
256
+
257
+ ## License
258
+
259
+ [MIT](LICENSE) © 2026 Muhammad Idnan
@@ -0,0 +1,232 @@
1
+ # llm-route-audit
2
+
3
+ **Find out whether LLM model routing saves money without hurting quality, on your own traffic.**
4
+
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](pyproject.toml)
7
+ [![Status: alpha](https://img.shields.io/badge/status-alpha-orange.svg)](CHANGELOG.md)
8
+
9
+ Model routers send easy prompts to cheap models and hard prompts to strong ones. Whether that works for *your* workload is usually a guess. llm-route-audit replays a sample of your real requests on cheaper models, grades every answer against the original, and tells you, task by task, where a cheaper model is good enough. You get a report and a routing policy for the gateway you already run.
10
+
11
+ ![Per-task routing compared with using one cheaper model everywhere](https://raw.githubusercontent.com/good2idnan/llm-route-audit/main/docs/images/demo-chart.svg)
12
+
13
+ <sub>Demo run on 5 requests: using Haiku everywhere cut cost to 19% but only 58% of answers passed. Per-task routing kept every answer passing at 26% of the cost. The sample is far too small to trust, so run it on your own logs.</sub>
14
+
15
+ ## Try it in one minute
16
+
17
+ No API key needed. The repository includes the results of a real run.
18
+
19
+ ```bash
20
+ git clone https://github.com/good2idnan/llm-route-audit.git
21
+ cd llm-route-audit
22
+ uv sync
23
+ uv run routeaudit report examples/sample_logs.jsonl \
24
+ --replay examples/demo/replay.jsonl --grades examples/demo/grades.jsonl --min-samples 1
25
+ ```
26
+
27
+ Requires Python 3.11+ and [uv](https://docs.astral.sh/uv/). The command-line tool is called `routeaudit`.
28
+
29
+ ## Why
30
+
31
+ - **Routers often don't beat one good model.** In independent benchmarks, most learned routers fail to beat simply using the best single model.
32
+ - **Cheaper per call isn't cheaper per task.** A cheap model that breaks your JSON format or gets the facts wrong costs more than it saves.
33
+ - **Your workload is not a benchmark.** The only evidence that counts is how candidate models do on your own requests.
34
+
35
+ llm-route-audit is not a router. It measures whether routing pays off, and can tell you not to route.
36
+
37
+ ## Features
38
+
39
+ - **Your own data.** Replays your logged requests, not public benchmarks.
40
+ - **Per task type.** Recommends a model for each kind of request, since one cheaper model rarely fits all of them.
41
+ - **Honest grading.** Exact checks first (JSON, fields, required text), then an AI judge that reads each pair in both orders, with its consistency reported.
42
+ - **Model and effort.** Tests (model, reasoning effort) pairs, not just models.
43
+ - **Spending you control.** A cost estimate and confirmation before any paid call, plus a hard `--max-spend` limit that can't be exceeded. Answers are cached, so you never pay twice.
44
+ - **Readable output.** An offline HTML report, a YAML policy, or a ready-to-use LiteLLM config.
45
+ - **Local first.** Logs, results and reports stay on your machine.
46
+
47
+ ## How it works
48
+
49
+ ```
50
+ your logs ─▶ analyze ─▶ replay a sample ─▶ grade answers ─▶ report ─▶ export policy ─▶ monitor
51
+ ```
52
+
53
+ | Command | What it does |
54
+ |---|---|
55
+ | `routeaudit import` | Convert LiteLLM, Langfuse or OpenTelemetry logs into routeaudit's log format |
56
+ | `routeaudit validate` | Check a log file |
57
+ | `routeaudit analyze` | Show what your traffic costs today, by task type and model |
58
+ | `routeaudit replay` | Re-run a sample of requests on candidate models (Anthropic, OpenRouter, Ollama) |
59
+ | `routeaudit grade` | Compare every answer with the original: exact checks, then an AI judge |
60
+ | `routeaudit report` | Recommend a model per task type, with cost and quality for each strategy |
61
+ | `routeaudit export` | Write the policy as YAML or as a LiteLLM proxy config |
62
+ | `routeaudit monitor` | After you switch, check that routed traffic still meets the audited quality |
63
+ | `routeaudit check-model` | Test a newly released model on your last audit's sample and see what it would change |
64
+
65
+ ## Audit your own traffic
66
+
67
+ The commands below use the bundled synthetic sample, [`examples/sample_logs.jsonl`](examples/sample_logs.jsonl). Swap in your own log file.
68
+
69
+ **1. See what you spend today**
70
+
71
+ ```bash
72
+ uv run routeaudit analyze examples/sample_logs.jsonl
73
+ ```
74
+
75
+ ```
76
+ By task type (most expensive first)
77
+ Task Requests Share Avg in Avg out Cost Cost share Per 1K p50
78
+ review_contract 30 15% 209 1,126 $0.7005 55% $23.35 24.1s
79
+ summarize_call 40 20% 199 235 $0.2197 17% $5.49 6.3s
80
+ classify_ticket 45 22% 121 31 $0.0499 4% $1.11 1.6s
81
+ ```
82
+
83
+ **2. Replay a sample on cheaper models**
84
+
85
+ List the candidates in a YAML file (see [`examples/candidates.yaml`](examples/candidates.yaml)), check the cost first, then run:
86
+
87
+ ```bash
88
+ uv run routeaudit replay examples/sample_logs.jsonl -c examples/candidates.yaml --dry-run
89
+ uv run routeaudit replay examples/sample_logs.jsonl -c examples/candidates.yaml --max-spend 1.00
90
+ ```
91
+
92
+ **3. Grade the answers**
93
+
94
+ ```bash
95
+ uv run routeaudit grade examples/sample_logs.jsonl --config examples/grading.yaml --max-spend 1.00
96
+ ```
97
+
98
+ Rules are set per task type in [`examples/grading.yaml`](examples/grading.yaml). Exact checks run first and cost nothing. Answers that pass them go to the AI judge, which compares each one with the original twice, swapping the order to cancel position bias. Disagreements count as ties. Pick the judge with `--judge-model`.
99
+
100
+ **4. Get the report**
101
+
102
+ ```bash
103
+ uv run routeaudit report examples/sample_logs.jsonl
104
+ ```
105
+
106
+ For each task type, the report recommends the **cheapest option that keeps at least 95% of the original's pass rate**, and only once that option has **at least 10 graded answers**. Until then it keeps your current model and says why. The same report is saved as `.routeaudit/report.html`. Tune the rules with `--target` and `--min-samples`.
107
+
108
+ **5. Export the policy**
109
+
110
+ ```bash
111
+ uv run routeaudit export examples/sample_logs.jsonl --out routing-policy.yaml
112
+ uv run routeaudit export examples/sample_logs.jsonl --format litellm --out litellm-config.yaml
113
+ ```
114
+
115
+ The LiteLLM config gives each task type its own model alias, such as `route/draft_reply`. Your app sends each request to its task's alias, and LiteLLM forwards it to the chosen model with the right effort setting.
116
+
117
+ **6. Keep watching after you switch**
118
+
119
+ Models change and traffic drifts, so a cheaper route that passed the audit can get worse later. Once the policy is live, point `monitor` at fresh production logs:
120
+
121
+ ```bash
122
+ uv run routeaudit monitor production-logs.jsonl --policy routing-policy.yaml --config examples/grading.yaml
123
+ ```
124
+
125
+ For each task that switched to a cheaper model, it samples recent requests (`--per-task 20`) and replays them on the model the route replaced. It then grades the production answer against that reference answer and compares the pass rate with what the audit measured:
126
+
127
+ | Status | Meaning |
128
+ |---|---|
129
+ | `OK` | At or above the audited pass rate, minus a small tolerance (`--tolerance 0.05`) |
130
+ | `WAIT` | No problem so far, but too few checks to be sure (`--min-checks 10`) |
131
+ | `WARN` | Below the line, but the sample can't rule out bad luck yet |
132
+ | `ALERT` | 95% sure quality dropped. The command exits with code 2, so cron or CI can flag it. |
133
+
134
+ Monitoring uses the same cost estimate, confirmation, `--max-spend` and cache as replay.
135
+
136
+ **7. Test a new model in one command**
137
+
138
+ When a new model comes out, test it on the same requests as your last audit:
139
+
140
+ ```bash
141
+ uv run routeaudit check-model examples/sample_logs.jsonl -m claude-sonnet-5-5 --effort low --config examples/grading.yaml
142
+ ```
143
+
144
+ It replays and grades only the new model, adds its results to your audit files, and shows which tasks it would take over and how projected savings change. To test the "one strong model at lower effort" alternative, check your current model at `--effort low`.
145
+
146
+ ## Providers and spending
147
+
148
+ Name each candidate (and the judge) after where it runs, and put the key in a `.env` file:
149
+
150
+ | Provider | Model name | Key in `.env` |
151
+ |---|---|---|
152
+ | Anthropic | `claude-haiku-4-5` | `ANTHROPIC_API_KEY` |
153
+ | [OpenRouter](https://openrouter.ai) | `openrouter/anthropic/claude-haiku-4.5` | `OPENROUTER_API_KEY` |
154
+ | [Ollama](https://ollama.com) (local, free) | `ollama/llama3.2` | none |
155
+
156
+ Effort levels (`low` to `max`) go next to the model in the candidates file. OpenRouter prices are looked up automatically, and the real cost of each call is recorded.
157
+
158
+ Three spending controls work on both `replay` and `grade`:
159
+
160
+ - **Confirmation.** routeaudit shows the estimate and asks before spending (`--yes` skips the question).
161
+ - **`--budget 1.00`** refuses to start if the *estimate* is above $1.00.
162
+ - **`--max-spend 1.00`** is a hard limit on *actual* spend. Each call reserves its worst-case cost first, so even parallel calls can't push the total over. Calls that don't fit are held back and reported.
163
+
164
+ ## Logs
165
+
166
+ **Already logging somewhere?** Import from the tool you use. Point `import` at one file or a folder:
167
+
168
+ | Source | Command | Task type comes from |
169
+ |---|---|---|
170
+ | [LiteLLM](https://github.com/BerriAI/litellm) logging callbacks (JSON/JSONL) | `routeaudit import litellm-logs/ --format litellm` | request tag `task:<name>` |
171
+ | [Langfuse](https://langfuse.com) observations (UI export or `/api/public/v2/observations`) | `routeaudit import observations.json --format langfuse` | trace tag `task:<name>`, or `--task-from-name` |
172
+ | [OpenTelemetry](https://opentelemetry.io) GenAI spans (OTLP JSON, e.g. the Collector's file exporter) | `routeaudit import traces.jsonl --format otel` | span attribute `task_type` (`--task-attribute`) |
173
+
174
+ Only real model calls are imported. Requests that can't be replayed faithfully yet are skipped and counted: failed calls, cache hits, images and tool calls. For OpenTelemetry, turn on GenAI message capture so the spans include the prompts and answers. API keys and other metadata are not copied.
175
+
176
+ **Writing your own logs?** Use one JSON object per line:
177
+
178
+ ```json
179
+ {"id": "req_0001", "timestamp": "2026-09-28T09:14:03Z", "model": "claude-opus-5-5",
180
+ "task_type": "classify_ticket",
181
+ "messages": [{"role": "system", "content": "..."}, {"role": "user", "content": "..."}],
182
+ "response": "{\"category\": \"billing\", \"priority\": \"high\"}",
183
+ "input_tokens": 180, "output_tokens": 32, "latency_ms": 1610}
184
+ ```
185
+
186
+ | Field | Required | Notes |
187
+ |---|---|---|
188
+ | `id`, `timestamp`, `model`, `response` | Yes | `id` must be unique |
189
+ | `messages` or `prompt` | Yes | Use either one |
190
+ | `input_tokens`, `output_tokens` | No | `input_tokens` counts uncached input only; estimated from text if missing |
191
+ | `cache_read_tokens`, `cache_write_tokens` | No | For cache-aware pricing |
192
+ | `task_type` | No | Strongly recommended: recommendations are per task type |
193
+ | `latency_ms`, `outcome`, `metadata` | No | Unknown fields are ignored |
194
+
195
+ **Prices.** A starter table for Claude models is built in. Pass your own with `--prices my-prices.yaml` (USD per 1M tokens: `input`, `output`, `cache_read`, `cache_write`). Requests whose model has no price are reported, not silently dropped.
196
+
197
+ ## FAQ
198
+
199
+ **When should I not route?**
200
+ When traffic is low, when every request is equally hard, or in long agent sessions where switching models throws away the prompt cache. Also whenever cheaper models fail your checks. "Keep your current model" is a normal result.
201
+
202
+ **How much does an audit cost?**
203
+ Mostly the replay and judge calls. The demo in this repo (5 requests, 2 candidates, a Sonnet judge) cost $0.11. Costs grow roughly in line with requests × candidates. `--dry-run` shows the estimate, `--max-spend` caps it, and local Ollama models are free.
204
+
205
+ **Does my data leave my machine?**
206
+ Only the requests you choose to replay, sent to the providers you configure. Logs, answers, grades and reports stay in your project folder, and the HTML report loads nothing from the internet.
207
+
208
+ **Can I trust an AI judge?**
209
+ Treat it as one signal. Exact checks come first and settle formats and facts for free. The judge sees each pair in both orders, and the report shows how often its two verdicts agree. Use a strong judge model, and spot-check `.routeaudit/grades.jsonl`.
210
+
211
+ **How many requests do I need?**
212
+ At least 10 graded answers per task type before anything is recommended, and 30 or more for confident numbers. The report shows a 95% range for every pass rate.
213
+
214
+ **Does it handle agents and tool calls?**
215
+ Not yet. Version 0.1 covers single requests, such as chat, extraction, classification and drafting. Agent turns are skipped on import.
216
+
217
+ **Is this a router?**
218
+ No. It audits routing and produces a policy. Put the policy in the gateway you already use, for example with the LiteLLM export.
219
+
220
+ ## Development
221
+
222
+ ```bash
223
+ uv sync
224
+ uv run pytest
225
+ uv run ruff check . && uv run ruff format --check .
226
+ ```
227
+
228
+ Issues and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
229
+
230
+ ## License
231
+
232
+ [MIT](LICENSE) © 2026 Muhammad Idnan
@@ -0,0 +1,9 @@
1
+ # Candidates served through OpenRouter (one key for many providers).
2
+ # Set OPENROUTER_API_KEY in .env. Prices are looked up from OpenRouter automatically.
3
+ # max_tokens caps each answer, which also caps the worst-case cost used by --max-spend.
4
+ candidates:
5
+ - model: openrouter/anthropic/claude-haiku-4.5
6
+ max_tokens: 1500
7
+ - model: openrouter/anthropic/claude-sonnet-5.5
8
+ effort: low
9
+ max_tokens: 2000
@@ -0,0 +1,9 @@
1
+ # Models to test against your logged traffic.
2
+ # effort (optional): low | medium | high | xhigh | max, on models that support it.
3
+ # Local Ollama models: use "ollama/<name>", e.g. "ollama/llama3.2". They run free.
4
+ candidates:
5
+ - model: claude-haiku-4-5
6
+ - model: claude-sonnet-5-5
7
+ effort: low
8
+ - model: claude-opus-5-5
9
+ effort: low
@@ -0,0 +1,15 @@
1
+ {"record_id": "req_0012", "task_type": "extract_invoice", "candidate": "original (as logged)", "outcome": "pass", "checks": [{"name": "json", "passed": true, "detail": ""}, {"name": "match_reference(vendor, invoice_number, invoice_date, due_date, currency, total)", "passed": true, "detail": ""}], "judge": null, "judge_votes": [], "reason": null}
2
+ {"record_id": "req_0042", "task_type": "review_contract", "candidate": "original (as logged)", "outcome": "pass", "checks": [{"name": "regex((?i)overall risk( rating)?:?\\s*\\**\\s*(low|medium|high))", "passed": true, "detail": ""}], "judge": null, "judge_votes": [], "reason": null}
3
+ {"record_id": "req_0111", "task_type": "classify_ticket", "candidate": "original (as logged)", "outcome": "pass", "checks": [{"name": "json", "passed": true, "detail": ""}, {"name": "match_reference(category, priority)", "passed": true, "detail": ""}], "judge": null, "judge_votes": [], "reason": null}
4
+ {"record_id": "req_0137", "task_type": "draft_reply", "candidate": "original (as logged)", "outcome": "pass", "checks": [{"name": "contains(Brightpath Support)", "passed": true, "detail": ""}], "judge": null, "judge_votes": [], "reason": null}
5
+ {"record_id": "req_0178", "task_type": "summarize_call", "candidate": "original (as logged)", "outcome": "pass", "checks": [{"name": "regex((?i)action items)", "passed": true, "detail": ""}], "judge": null, "judge_votes": [], "reason": null}
6
+ {"record_id": "req_0012", "task_type": "extract_invoice", "candidate": "openrouter/anthropic/claude-haiku-4.5", "outcome": "fail", "checks": [{"name": "json", "passed": false, "detail": "JSON is wrapped in a code fence"}, {"name": "match_reference(vendor, invoice_number, invoice_date, due_date, currency, total)", "passed": true, "detail": ""}], "judge": null, "judge_votes": [], "reason": "failed checks"}
7
+ {"record_id": "req_0042", "task_type": "review_contract", "candidate": "openrouter/anthropic/claude-haiku-4.5", "outcome": "pass", "checks": [{"name": "regex((?i)overall risk( rating)?:?\\s*\\**\\s*(low|medium|high))", "passed": true, "detail": ""}], "judge": "win", "judge_votes": ["win", "win"], "reason": "judge: win"}
8
+ {"record_id": "req_0111", "task_type": "classify_ticket", "candidate": "openrouter/anthropic/claude-haiku-4.5", "outcome": "fail", "checks": [{"name": "json", "passed": false, "detail": "JSON is wrapped in a code fence"}, {"name": "match_reference(category, priority)", "passed": true, "detail": ""}], "judge": null, "judge_votes": [], "reason": "failed checks"}
9
+ {"record_id": "req_0137", "task_type": "draft_reply", "candidate": "openrouter/anthropic/claude-haiku-4.5", "outcome": "pass", "checks": [{"name": "contains(Brightpath Support)", "passed": true, "detail": ""}], "judge": "win", "judge_votes": ["win", "win"], "reason": "judge: win"}
10
+ {"record_id": "req_0178", "task_type": "summarize_call", "candidate": "openrouter/anthropic/claude-haiku-4.5", "outcome": "pass", "checks": [{"name": "regex((?i)action items)", "passed": true, "detail": ""}], "judge": "win", "judge_votes": ["win", "win"], "reason": "judge: win"}
11
+ {"record_id": "req_0012", "task_type": "extract_invoice", "candidate": "openrouter/anthropic/claude-sonnet-5.5 @ low", "outcome": "pass", "checks": [{"name": "json", "passed": true, "detail": ""}, {"name": "match_reference(vendor, invoice_number, invoice_date, due_date, currency, total)", "passed": true, "detail": ""}], "judge": null, "judge_votes": [], "reason": null}
12
+ {"record_id": "req_0042", "task_type": "review_contract", "candidate": "openrouter/anthropic/claude-sonnet-5.5 @ low", "outcome": "pass", "checks": [{"name": "regex((?i)overall risk( rating)?:?\\s*\\**\\s*(low|medium|high))", "passed": true, "detail": ""}], "judge": "win", "judge_votes": ["win", "win"], "reason": "judge: win"}
13
+ {"record_id": "req_0111", "task_type": "classify_ticket", "candidate": "openrouter/anthropic/claude-sonnet-5.5 @ low", "outcome": "fail", "checks": [{"name": "json", "passed": true, "detail": ""}, {"name": "match_reference(category, priority)", "passed": false, "detail": "priority: \"high\" vs \"urgent\""}], "judge": null, "judge_votes": [], "reason": "failed checks"}
14
+ {"record_id": "req_0137", "task_type": "draft_reply", "candidate": "openrouter/anthropic/claude-sonnet-5.5 @ low", "outcome": "pass", "checks": [{"name": "contains(Brightpath Support)", "passed": true, "detail": ""}], "judge": "win", "judge_votes": ["win", "win"], "reason": "judge: win"}
15
+ {"record_id": "req_0178", "task_type": "summarize_call", "candidate": "openrouter/anthropic/claude-sonnet-5.5 @ low", "outcome": "pass", "checks": [{"name": "regex((?i)action items)", "passed": true, "detail": ""}], "judge": "win", "judge_votes": ["win", "win"], "reason": "judge: win"}