llm-router-lite 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.
- llm_router_lite-0.1.0/.gitignore +30 -0
- llm_router_lite-0.1.0/LICENSE +21 -0
- llm_router_lite-0.1.0/PKG-INFO +162 -0
- llm_router_lite-0.1.0/README.md +140 -0
- llm_router_lite-0.1.0/pyproject.toml +49 -0
- llm_router_lite-0.1.0/src/llm_router_lite/__init__.py +87 -0
- llm_router_lite-0.1.0/src/llm_router_lite/cli.py +383 -0
- llm_router_lite-0.1.0/src/llm_router_lite/complexity.py +640 -0
- llm_router_lite-0.1.0/src/llm_router_lite/models.py +290 -0
- llm_router_lite-0.1.0/src/llm_router_lite/results.py +322 -0
- llm_router_lite-0.1.0/src/llm_router_lite/router.py +685 -0
- llm_router_lite-0.1.0/tests/test_cli.py +407 -0
- llm_router_lite-0.1.0/tests/test_complete.py +225 -0
- llm_router_lite-0.1.0/tests/test_complexity.py +305 -0
- llm_router_lite-0.1.0/tests/test_models.py +201 -0
- llm_router_lite-0.1.0/tests/test_no_network.py +121 -0
- llm_router_lite-0.1.0/tests/test_router.py +459 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Virtual environments and scratch space used while building. Never committed:
|
|
2
|
+
# they are large, machine-specific, and rebuilt from pyproject.toml anyway.
|
|
3
|
+
_venvs/
|
|
4
|
+
_proof/
|
|
5
|
+
.venv*/
|
|
6
|
+
|
|
7
|
+
# Build output. Wheels are built by the release workflow from this source, so a
|
|
8
|
+
# wheel in git could silently differ from the code beside it.
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
*.egg-info/
|
|
12
|
+
src/*.egg-info/
|
|
13
|
+
|
|
14
|
+
# Python noise
|
|
15
|
+
__pycache__/
|
|
16
|
+
*.py[cod]
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
|
|
21
|
+
# Local verification state, not source
|
|
22
|
+
verify_results.json
|
|
23
|
+
publish-log.txt
|
|
24
|
+
published.json
|
|
25
|
+
|
|
26
|
+
# Credentials. None of these belong here, and this line is the backstop.
|
|
27
|
+
.pypirc
|
|
28
|
+
.env
|
|
29
|
+
*.pem
|
|
30
|
+
*.key
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pranay Mahendrakar
|
|
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,162 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: llm-router-lite
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Send each prompt to the cheapest model that can handle it, and fall back when one fails
|
|
5
|
+
Project-URL: Homepage, https://pypi.org/project/llm-router-lite/
|
|
6
|
+
Project-URL: Author, https://pypi.org/user/pranaymahendrakar/
|
|
7
|
+
Author: Pranay Mahendrakar
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: complexity,cost,fallback,llm,offline,openai,router,routing
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# llm-router-lite
|
|
24
|
+
|
|
25
|
+
Send each prompt to the cheapest model that can handle it, and fall back to the next one when a call fails.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install llm-router-lite
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
No dependencies. Standard library only, and no model is ever downloaded.
|
|
34
|
+
|
|
35
|
+
**This package is not an API client.** It never talks to OpenAI, Anthropic, Ollama or anything else, and it holds no keys. You give it a handler per model - a plain function that takes a prompt and returns a string - and it decides which of *your* functions to call. There is no network code in this package at all.
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from llm_router_lite import Router
|
|
41
|
+
|
|
42
|
+
router = Router()
|
|
43
|
+
router.add("small", lambda p, **kw: "small says: " + p, cost=0.0002, quality=0.4)
|
|
44
|
+
router.add("large", lambda p, **kw: "large says: " + p, cost=0.01, quality=0.95)
|
|
45
|
+
|
|
46
|
+
hard = "Explain step by step why this design scales, compare it with a queue-based approach, and then recommend one."
|
|
47
|
+
print(router.route("What is 2 + 2?").reason)
|
|
48
|
+
print(router.complete(hard).summary())
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
'small' for a simple prompt (complexity 0.11); balanced routing leans on cost for a simple prompt; it is the cheapest of the 2 that qualify; fallbacks, in order: large.
|
|
53
|
+
large answered in 0.0 ms for about 0.00283
|
|
54
|
+
large: ok in 0.0 ms
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The easy prompt goes to the cheap model, the hard one to the good model, and nothing in the line-up had to be described twice.
|
|
58
|
+
|
|
59
|
+
Swap the two lambdas for your real calls and nothing else changes.
|
|
60
|
+
|
|
61
|
+
## What it does
|
|
62
|
+
|
|
63
|
+
- **Scores the prompt first.** `complexity(prompt)` gives a 0-1 score and a band of `simple`, `moderate` or `hard`, from five documented signals: length, question depth, reasoning words, code markers and the number of distinct instructions. Same prompt, same score, every time, in any script it can read - and it says so in `warnings` when it cannot read one.
|
|
64
|
+
- **Routes on cost, speed and quality.** `prefer="balanced"` (the default) reads the band: an easy prompt leans on cost, a hard one leans on quality. `"cheap"`, `"fast"` and `"quality"` say it plainly instead. Cost and latency are scored as ratios against the best in the field - the cheapest scores 1.0, one costing ten times more scores 0.1 - so registering a third, pricier model cannot change which of the other two wins, and `prefer="cheap"` picks the cheapest thing that qualifies however many models are registered.
|
|
65
|
+
- **Keeps a prompt off a model that cannot handle it.** Under the default `prefer="balanced"`, a hard prompt is not handed to a model rated below 0.60 quality while a better one is registered, and a moderate one wants 0.35. The floor demotes rather than discards: the weak model still stands behind as a fallback, listed in `route.demoted`, because a weak answer beats no answer when the good model is down. If nothing clears the floor at all, the best available model is used anyway and the route says so - it never returns nothing.
|
|
66
|
+
- **Respects hard constraints.** `require=("local",)` keeps a prompt off the network. `max_cost=0.002` refuses to route above a price. A model with `max_tokens` set is skipped when the prompt would not fit.
|
|
67
|
+
- **Falls back in order.** `complete()` tries the pick, then each alternative. A handler that raises is caught and recorded, never propagated raw. If every model fails you get one `AllModelsFailed` naming the last error, with every attempt attached.
|
|
68
|
+
- **Explains itself.** `route.reason` is a sentence, not a score dump: which model, for what kind of prompt, why it won, what was set aside and what stands behind it.
|
|
69
|
+
- **Counts what happened.** `router.stats()` gives calls, failures, total cost and mean latency per model.
|
|
70
|
+
|
|
71
|
+
## How the score is built
|
|
72
|
+
|
|
73
|
+
| signal | weight | what it measures |
|
|
74
|
+
| --- | --- | --- |
|
|
75
|
+
| length | 0.15 | words, reaching 1.0 at 60 words |
|
|
76
|
+
| questions | 0.15 | question marks plus question words, 1.0 at 3 |
|
|
77
|
+
| reasoning | 0.35 | words such as "explain", "compare", "trade-off", 1.0 at 3 |
|
|
78
|
+
| code | 0.15 | markers such as a fenced block, `def `, a traceback, 1.0 at 2 |
|
|
79
|
+
| instructions | 0.20 | distinct instructions - list items, imperative sentences, "and then" - 1.0 at 4 |
|
|
80
|
+
|
|
81
|
+
Reasoning carries the most weight on purpose: a short prompt asking for a derivation is harder than a long one pasting a log, so length is the smallest term.
|
|
82
|
+
|
|
83
|
+
Score below 0.22 is `simple`, below 0.45 is `moderate`, and 0.45 or above is `hard`. An empty prompt scores 0.0 and routes as simple.
|
|
84
|
+
|
|
85
|
+
### Which languages the score reads
|
|
86
|
+
|
|
87
|
+
Words are counted in every script. A script that separates words with spaces is counted word by word; Chinese, Japanese and Korean are written without spaces, so a run of them counts one word per two characters instead of collapsing to one.
|
|
88
|
+
|
|
89
|
+
The question, reasoning and "and then" markers are word lists, so each one only fires in a language it is written for. They cover **English, Chinese, Japanese, Korean, Russian, Arabic, Hindi, Greek and Hebrew**: the same hard request scores within a few hundredths of a point and bands `hard` in all of them.
|
|
90
|
+
|
|
91
|
+
A prompt with real substance written mostly in some other non-Latin script is still scored on its length and structure, and the result then carries a plain sentence in `complexity(prompt).warnings` - repeated in `route.reason` and in the log - saying the language markers could not be read and the score is lower than the prompt may deserve. The scorer never quietly returns 0.0 for text it could not read.
|
|
92
|
+
|
|
93
|
+
Cost is estimated, not measured: tokens are counted at 4 characters each, an answer of up to 256 tokens is assumed, and `estimated_cost = cost * tokens / 1000`. `cost` is whatever unit you registered - dollars, cents, credits. The result is rounded to 12 significant digits, not to a fixed number of decimal places, so the float noise of the multiplication is tidied off while prices as small as `1e-12` keep their order.
|
|
94
|
+
|
|
95
|
+
## API
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from llm_router_lite import Router, route, complexity
|
|
99
|
+
|
|
100
|
+
Router(models=None)
|
|
101
|
+
Router.add(name, handler, *, cost=0.0, latency_ms=None, quality=0.5, max_tokens=None, tags=())
|
|
102
|
+
Router.route(prompt, *, prefer="balanced", require=None, max_cost=None) -> Route
|
|
103
|
+
Router.complete(prompt, **kw) -> Completion
|
|
104
|
+
Router.stats() -> Stats
|
|
105
|
+
route(prompt, models, **kw) -> Route
|
|
106
|
+
complexity(prompt) -> Complexity
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**`Router(models=None)`** - `models` may be a mapping of `name -> handler`, a mapping of `name -> options dict`, or a sequence of `Model` objects, option dicts or `(name, handler)` pairs. `Router()` starts empty.
|
|
110
|
+
|
|
111
|
+
**`.add(name, handler, *, cost=0.0, latency_ms=None, quality=0.5, max_tokens=None, tags=())`** - register one model and return the router, so `.add(...).add(...)` chains.
|
|
112
|
+
|
|
113
|
+
- `handler` - `callable(prompt, **kw) -> str`. Nothing is called until you call `complete()`; `route()` only compares numbers.
|
|
114
|
+
- `cost` - price of 1000 tokens, prompt plus answer, in any unit.
|
|
115
|
+
- `latency_ms` - typical round trip. Left out, a model is assumed to be as fast as the median of the models that do declare one.
|
|
116
|
+
- `quality` - your own 0-1 opinion of the answers.
|
|
117
|
+
- `max_tokens` - the largest prompt-plus-answer it can hold. A prompt that does not fit skips the model.
|
|
118
|
+
- `tags` - free labels such as `"local"` or `"vision"`, matched case-insensitively by `require`.
|
|
119
|
+
|
|
120
|
+
**`.route(prompt, *, prefer="balanced", require=None, max_cost=None) -> Route`** - decide, call nothing.
|
|
121
|
+
|
|
122
|
+
**`Route`** - `.model` (the name), `.reason` (plain language), `.estimated_cost`, `.alternatives` (fallbacks in order), `.complexity`, `.prefer`, `.estimated_tokens`, `.considered` (every candidate with its score), `.skipped` (name -> why it is out of the running), `.demoted` (name -> why it ranks behind but is still a fallback), `.cost_of(name)`, `.summary()`, `.to_dict()`.
|
|
123
|
+
|
|
124
|
+
**`.complete(prompt, **kw) -> Completion`** - route, call, fall back. `prefer`, `require`, `max_cost` and `route` are reserved keywords; every other keyword is passed to the handler.
|
|
125
|
+
|
|
126
|
+
**`Completion`** - `.text`, `.model`, `.attempts` (a list of `Attempt(model, ok, error, ms)`), `.cost`, `.route`, `.ms`, `.fallbacks_used`, `.summary()`, `.to_dict()`. `str(completion)` is the text.
|
|
127
|
+
|
|
128
|
+
**`.stats() -> Stats`** - `stats["small"]` gives a `ModelStats(name, calls, failures, total_cost, mean_latency_ms)` with `.successes` and `.failure_rate`; `stats.calls`, `.failures` and `.total_cost` are the totals. `total_cost` counts the calls that succeeded; `mean_latency_ms` covers every call, failures included. `.reset_stats()` zeroes them.
|
|
129
|
+
|
|
130
|
+
**`complexity(prompt) -> Complexity`** - `.score`, `.band`, `.words`, `.characters`, `.estimated_tokens`, `.signals` (the five sub-scores), `.reasons`, `.warnings` (empty unless the scorer could not read the prompt's script, or nothing matched at all), `.summary()`, `.to_dict()`.
|
|
131
|
+
|
|
132
|
+
**`route(prompt, models, **kw)`** - build a router and route in one line.
|
|
133
|
+
|
|
134
|
+
Errors are plain built-ins wherever one fits:
|
|
135
|
+
|
|
136
|
+
- `ValueError` when no models are registered, when `require` matches nothing, when the prompt fits in no model, and when nothing meets `max_cost` - that last message names the cheapest model and what it would cost.
|
|
137
|
+
- `TypeError` when a prompt is not a string or a handler is not callable.
|
|
138
|
+
- `AllModelsFailed` (a `RuntimeError`) when every model in the chain fails. It carries `.attempts`, `.route`, `.last_model` and `.last_error`, chains the original exception as `__cause__`, and has `.summary()` and `.to_dict()`.
|
|
139
|
+
|
|
140
|
+
## CLI
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
llm-router-lite "Why is the sky blue?" # how hard is this prompt
|
|
144
|
+
llm-router-lite complexity - < prompt.txt # ... from a file or a pipe
|
|
145
|
+
llm-router-lite route "Explain why this fails" --prefer cheap
|
|
146
|
+
llm-router-lite route "Summarise this" --require local --max-cost 0.002
|
|
147
|
+
llm-router-lite route "..." --model tiny:0:0.3:80:local --model big:0.01:0.95:900:cloud
|
|
148
|
+
llm-router-lite route "..." --models lineup.json --json --output route.json
|
|
149
|
+
llm-router-lite models # the line-up being used
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Without `--models` or `--model` an example line-up (`local-small`, `cloud-mid`, `cloud-large`) stands in, so the command does something useful immediately. A `--model` spec is `NAME[:COST[:QUALITY[:LATENCY_MS[:TAG,TAG]]]]`; `--models` takes a JSON list of option objects, or an object of `name -> options`. `--json` prints `to_dict()`, `--output` also writes it as UTF-8.
|
|
153
|
+
|
|
154
|
+
`route` prints the pick, every candidate with its score, the models **ranked behind** it (demoted but still fallbacks) and the ones **set aside** (out of the running) - the same two blocks `--json` carries as `demoted` and `skipped`.
|
|
155
|
+
|
|
156
|
+
One wrinkle worth knowing: a command word wins over a bare prompt, so `llm-router-lite models` lists the line-up rather than scoring the word "models". `complexity`, `route` and `models` are therefore the only three prompts the shorthand cannot express - write `llm-router-lite complexity "models"` to score one of them. Every other prompt, one word or many, goes to `complexity`.
|
|
157
|
+
|
|
158
|
+
The CLI never calls a model - it routes and reports. Output is UTF-8 whatever the console encoding is, so piping a prompt full of Japanese or emoji through another program is safe.
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
MIT
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# llm-router-lite
|
|
2
|
+
|
|
3
|
+
Send each prompt to the cheapest model that can handle it, and fall back to the next one when a call fails.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install llm-router-lite
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
No dependencies. Standard library only, and no model is ever downloaded.
|
|
12
|
+
|
|
13
|
+
**This package is not an API client.** It never talks to OpenAI, Anthropic, Ollama or anything else, and it holds no keys. You give it a handler per model - a plain function that takes a prompt and returns a string - and it decides which of *your* functions to call. There is no network code in this package at all.
|
|
14
|
+
|
|
15
|
+
## Quickstart
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from llm_router_lite import Router
|
|
19
|
+
|
|
20
|
+
router = Router()
|
|
21
|
+
router.add("small", lambda p, **kw: "small says: " + p, cost=0.0002, quality=0.4)
|
|
22
|
+
router.add("large", lambda p, **kw: "large says: " + p, cost=0.01, quality=0.95)
|
|
23
|
+
|
|
24
|
+
hard = "Explain step by step why this design scales, compare it with a queue-based approach, and then recommend one."
|
|
25
|
+
print(router.route("What is 2 + 2?").reason)
|
|
26
|
+
print(router.complete(hard).summary())
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
'small' for a simple prompt (complexity 0.11); balanced routing leans on cost for a simple prompt; it is the cheapest of the 2 that qualify; fallbacks, in order: large.
|
|
31
|
+
large answered in 0.0 ms for about 0.00283
|
|
32
|
+
large: ok in 0.0 ms
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The easy prompt goes to the cheap model, the hard one to the good model, and nothing in the line-up had to be described twice.
|
|
36
|
+
|
|
37
|
+
Swap the two lambdas for your real calls and nothing else changes.
|
|
38
|
+
|
|
39
|
+
## What it does
|
|
40
|
+
|
|
41
|
+
- **Scores the prompt first.** `complexity(prompt)` gives a 0-1 score and a band of `simple`, `moderate` or `hard`, from five documented signals: length, question depth, reasoning words, code markers and the number of distinct instructions. Same prompt, same score, every time, in any script it can read - and it says so in `warnings` when it cannot read one.
|
|
42
|
+
- **Routes on cost, speed and quality.** `prefer="balanced"` (the default) reads the band: an easy prompt leans on cost, a hard one leans on quality. `"cheap"`, `"fast"` and `"quality"` say it plainly instead. Cost and latency are scored as ratios against the best in the field - the cheapest scores 1.0, one costing ten times more scores 0.1 - so registering a third, pricier model cannot change which of the other two wins, and `prefer="cheap"` picks the cheapest thing that qualifies however many models are registered.
|
|
43
|
+
- **Keeps a prompt off a model that cannot handle it.** Under the default `prefer="balanced"`, a hard prompt is not handed to a model rated below 0.60 quality while a better one is registered, and a moderate one wants 0.35. The floor demotes rather than discards: the weak model still stands behind as a fallback, listed in `route.demoted`, because a weak answer beats no answer when the good model is down. If nothing clears the floor at all, the best available model is used anyway and the route says so - it never returns nothing.
|
|
44
|
+
- **Respects hard constraints.** `require=("local",)` keeps a prompt off the network. `max_cost=0.002` refuses to route above a price. A model with `max_tokens` set is skipped when the prompt would not fit.
|
|
45
|
+
- **Falls back in order.** `complete()` tries the pick, then each alternative. A handler that raises is caught and recorded, never propagated raw. If every model fails you get one `AllModelsFailed` naming the last error, with every attempt attached.
|
|
46
|
+
- **Explains itself.** `route.reason` is a sentence, not a score dump: which model, for what kind of prompt, why it won, what was set aside and what stands behind it.
|
|
47
|
+
- **Counts what happened.** `router.stats()` gives calls, failures, total cost and mean latency per model.
|
|
48
|
+
|
|
49
|
+
## How the score is built
|
|
50
|
+
|
|
51
|
+
| signal | weight | what it measures |
|
|
52
|
+
| --- | --- | --- |
|
|
53
|
+
| length | 0.15 | words, reaching 1.0 at 60 words |
|
|
54
|
+
| questions | 0.15 | question marks plus question words, 1.0 at 3 |
|
|
55
|
+
| reasoning | 0.35 | words such as "explain", "compare", "trade-off", 1.0 at 3 |
|
|
56
|
+
| code | 0.15 | markers such as a fenced block, `def `, a traceback, 1.0 at 2 |
|
|
57
|
+
| instructions | 0.20 | distinct instructions - list items, imperative sentences, "and then" - 1.0 at 4 |
|
|
58
|
+
|
|
59
|
+
Reasoning carries the most weight on purpose: a short prompt asking for a derivation is harder than a long one pasting a log, so length is the smallest term.
|
|
60
|
+
|
|
61
|
+
Score below 0.22 is `simple`, below 0.45 is `moderate`, and 0.45 or above is `hard`. An empty prompt scores 0.0 and routes as simple.
|
|
62
|
+
|
|
63
|
+
### Which languages the score reads
|
|
64
|
+
|
|
65
|
+
Words are counted in every script. A script that separates words with spaces is counted word by word; Chinese, Japanese and Korean are written without spaces, so a run of them counts one word per two characters instead of collapsing to one.
|
|
66
|
+
|
|
67
|
+
The question, reasoning and "and then" markers are word lists, so each one only fires in a language it is written for. They cover **English, Chinese, Japanese, Korean, Russian, Arabic, Hindi, Greek and Hebrew**: the same hard request scores within a few hundredths of a point and bands `hard` in all of them.
|
|
68
|
+
|
|
69
|
+
A prompt with real substance written mostly in some other non-Latin script is still scored on its length and structure, and the result then carries a plain sentence in `complexity(prompt).warnings` - repeated in `route.reason` and in the log - saying the language markers could not be read and the score is lower than the prompt may deserve. The scorer never quietly returns 0.0 for text it could not read.
|
|
70
|
+
|
|
71
|
+
Cost is estimated, not measured: tokens are counted at 4 characters each, an answer of up to 256 tokens is assumed, and `estimated_cost = cost * tokens / 1000`. `cost` is whatever unit you registered - dollars, cents, credits. The result is rounded to 12 significant digits, not to a fixed number of decimal places, so the float noise of the multiplication is tidied off while prices as small as `1e-12` keep their order.
|
|
72
|
+
|
|
73
|
+
## API
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from llm_router_lite import Router, route, complexity
|
|
77
|
+
|
|
78
|
+
Router(models=None)
|
|
79
|
+
Router.add(name, handler, *, cost=0.0, latency_ms=None, quality=0.5, max_tokens=None, tags=())
|
|
80
|
+
Router.route(prompt, *, prefer="balanced", require=None, max_cost=None) -> Route
|
|
81
|
+
Router.complete(prompt, **kw) -> Completion
|
|
82
|
+
Router.stats() -> Stats
|
|
83
|
+
route(prompt, models, **kw) -> Route
|
|
84
|
+
complexity(prompt) -> Complexity
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**`Router(models=None)`** - `models` may be a mapping of `name -> handler`, a mapping of `name -> options dict`, or a sequence of `Model` objects, option dicts or `(name, handler)` pairs. `Router()` starts empty.
|
|
88
|
+
|
|
89
|
+
**`.add(name, handler, *, cost=0.0, latency_ms=None, quality=0.5, max_tokens=None, tags=())`** - register one model and return the router, so `.add(...).add(...)` chains.
|
|
90
|
+
|
|
91
|
+
- `handler` - `callable(prompt, **kw) -> str`. Nothing is called until you call `complete()`; `route()` only compares numbers.
|
|
92
|
+
- `cost` - price of 1000 tokens, prompt plus answer, in any unit.
|
|
93
|
+
- `latency_ms` - typical round trip. Left out, a model is assumed to be as fast as the median of the models that do declare one.
|
|
94
|
+
- `quality` - your own 0-1 opinion of the answers.
|
|
95
|
+
- `max_tokens` - the largest prompt-plus-answer it can hold. A prompt that does not fit skips the model.
|
|
96
|
+
- `tags` - free labels such as `"local"` or `"vision"`, matched case-insensitively by `require`.
|
|
97
|
+
|
|
98
|
+
**`.route(prompt, *, prefer="balanced", require=None, max_cost=None) -> Route`** - decide, call nothing.
|
|
99
|
+
|
|
100
|
+
**`Route`** - `.model` (the name), `.reason` (plain language), `.estimated_cost`, `.alternatives` (fallbacks in order), `.complexity`, `.prefer`, `.estimated_tokens`, `.considered` (every candidate with its score), `.skipped` (name -> why it is out of the running), `.demoted` (name -> why it ranks behind but is still a fallback), `.cost_of(name)`, `.summary()`, `.to_dict()`.
|
|
101
|
+
|
|
102
|
+
**`.complete(prompt, **kw) -> Completion`** - route, call, fall back. `prefer`, `require`, `max_cost` and `route` are reserved keywords; every other keyword is passed to the handler.
|
|
103
|
+
|
|
104
|
+
**`Completion`** - `.text`, `.model`, `.attempts` (a list of `Attempt(model, ok, error, ms)`), `.cost`, `.route`, `.ms`, `.fallbacks_used`, `.summary()`, `.to_dict()`. `str(completion)` is the text.
|
|
105
|
+
|
|
106
|
+
**`.stats() -> Stats`** - `stats["small"]` gives a `ModelStats(name, calls, failures, total_cost, mean_latency_ms)` with `.successes` and `.failure_rate`; `stats.calls`, `.failures` and `.total_cost` are the totals. `total_cost` counts the calls that succeeded; `mean_latency_ms` covers every call, failures included. `.reset_stats()` zeroes them.
|
|
107
|
+
|
|
108
|
+
**`complexity(prompt) -> Complexity`** - `.score`, `.band`, `.words`, `.characters`, `.estimated_tokens`, `.signals` (the five sub-scores), `.reasons`, `.warnings` (empty unless the scorer could not read the prompt's script, or nothing matched at all), `.summary()`, `.to_dict()`.
|
|
109
|
+
|
|
110
|
+
**`route(prompt, models, **kw)`** - build a router and route in one line.
|
|
111
|
+
|
|
112
|
+
Errors are plain built-ins wherever one fits:
|
|
113
|
+
|
|
114
|
+
- `ValueError` when no models are registered, when `require` matches nothing, when the prompt fits in no model, and when nothing meets `max_cost` - that last message names the cheapest model and what it would cost.
|
|
115
|
+
- `TypeError` when a prompt is not a string or a handler is not callable.
|
|
116
|
+
- `AllModelsFailed` (a `RuntimeError`) when every model in the chain fails. It carries `.attempts`, `.route`, `.last_model` and `.last_error`, chains the original exception as `__cause__`, and has `.summary()` and `.to_dict()`.
|
|
117
|
+
|
|
118
|
+
## CLI
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
llm-router-lite "Why is the sky blue?" # how hard is this prompt
|
|
122
|
+
llm-router-lite complexity - < prompt.txt # ... from a file or a pipe
|
|
123
|
+
llm-router-lite route "Explain why this fails" --prefer cheap
|
|
124
|
+
llm-router-lite route "Summarise this" --require local --max-cost 0.002
|
|
125
|
+
llm-router-lite route "..." --model tiny:0:0.3:80:local --model big:0.01:0.95:900:cloud
|
|
126
|
+
llm-router-lite route "..." --models lineup.json --json --output route.json
|
|
127
|
+
llm-router-lite models # the line-up being used
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Without `--models` or `--model` an example line-up (`local-small`, `cloud-mid`, `cloud-large`) stands in, so the command does something useful immediately. A `--model` spec is `NAME[:COST[:QUALITY[:LATENCY_MS[:TAG,TAG]]]]`; `--models` takes a JSON list of option objects, or an object of `name -> options`. `--json` prints `to_dict()`, `--output` also writes it as UTF-8.
|
|
131
|
+
|
|
132
|
+
`route` prints the pick, every candidate with its score, the models **ranked behind** it (demoted but still fallbacks) and the ones **set aside** (out of the running) - the same two blocks `--json` carries as `demoted` and `skipped`.
|
|
133
|
+
|
|
134
|
+
One wrinkle worth knowing: a command word wins over a bare prompt, so `llm-router-lite models` lists the line-up rather than scoring the word "models". `complexity`, `route` and `models` are therefore the only three prompts the shorthand cannot express - write `llm-router-lite complexity "models"` to score one of them. Every other prompt, one word or many, goes to `complexity`.
|
|
135
|
+
|
|
136
|
+
The CLI never calls a model - it routes and reports. Output is UTF-8 whatever the console encoding is, so piping a prompt full of Japanese or emoji through another program is safe.
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "llm-router-lite"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Send each prompt to the cheapest model that can handle it, and fall back when one fails"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Pranay Mahendrakar" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"llm",
|
|
16
|
+
"router",
|
|
17
|
+
"routing",
|
|
18
|
+
"fallback",
|
|
19
|
+
"cost",
|
|
20
|
+
"complexity",
|
|
21
|
+
"openai",
|
|
22
|
+
"offline",
|
|
23
|
+
]
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 4 - Beta",
|
|
26
|
+
"Intended Audience :: Developers",
|
|
27
|
+
"Intended Audience :: Science/Research",
|
|
28
|
+
"Programming Language :: Python :: 3",
|
|
29
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
30
|
+
"Operating System :: OS Independent",
|
|
31
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
32
|
+
]
|
|
33
|
+
dependencies = []
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
dev = ["pytest>=7"]
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
llm-router-lite = "llm_router_lite.cli:main"
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://pypi.org/project/llm-router-lite/"
|
|
43
|
+
Author = "https://pypi.org/user/pranaymahendrakar/"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["src/llm_router_lite"]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""llm-router-lite: send each prompt to the cheapest model that can handle it.
|
|
2
|
+
|
|
3
|
+
Three lines get you the whole thing::
|
|
4
|
+
|
|
5
|
+
from llm_router_lite import Router
|
|
6
|
+
|
|
7
|
+
router = Router()
|
|
8
|
+
router.add("small", small_model, cost=0.0002, quality=0.4)
|
|
9
|
+
router.add("large", large_model, cost=0.01, quality=0.95)
|
|
10
|
+
|
|
11
|
+
answer = router.complete("What is 2 + 2?")
|
|
12
|
+
|
|
13
|
+
Easy prompts go to the cheap model, hard prompts to the good one, and if a
|
|
14
|
+
model raises, the next one in line is tried. ``answer.route.reason`` says in
|
|
15
|
+
plain words why that model was picked.
|
|
16
|
+
|
|
17
|
+
This package never talks to a provider. It has no API keys, no HTTP client and
|
|
18
|
+
no network code at all: *you* supply the handler for each model, and the
|
|
19
|
+
router decides which of your handlers to call.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from .complexity import (
|
|
23
|
+
CHARS_PER_TOKEN,
|
|
24
|
+
MODERATE_MAX,
|
|
25
|
+
SIGNAL_WEIGHTS,
|
|
26
|
+
SIMPLE_MAX,
|
|
27
|
+
Complexity,
|
|
28
|
+
band_for,
|
|
29
|
+
complexity,
|
|
30
|
+
estimate_tokens,
|
|
31
|
+
)
|
|
32
|
+
from .models import (
|
|
33
|
+
DEFAULT_COMPLETION_TOKENS,
|
|
34
|
+
DEFAULT_LATENCY_MS,
|
|
35
|
+
Model,
|
|
36
|
+
make_model,
|
|
37
|
+
normalize_models,
|
|
38
|
+
)
|
|
39
|
+
from .results import (
|
|
40
|
+
AllModelsFailed,
|
|
41
|
+
Attempt,
|
|
42
|
+
Candidate,
|
|
43
|
+
Completion,
|
|
44
|
+
ModelStats,
|
|
45
|
+
Route,
|
|
46
|
+
Stats,
|
|
47
|
+
)
|
|
48
|
+
from .router import (
|
|
49
|
+
BALANCED_WEIGHTS,
|
|
50
|
+
PREFERENCE_WEIGHTS,
|
|
51
|
+
PREFERENCES,
|
|
52
|
+
QUALITY_FLOOR,
|
|
53
|
+
Router,
|
|
54
|
+
route,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
__version__ = "0.1.0"
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"AllModelsFailed",
|
|
61
|
+
"Attempt",
|
|
62
|
+
"BALANCED_WEIGHTS",
|
|
63
|
+
"CHARS_PER_TOKEN",
|
|
64
|
+
"Candidate",
|
|
65
|
+
"Complexity",
|
|
66
|
+
"Completion",
|
|
67
|
+
"DEFAULT_COMPLETION_TOKENS",
|
|
68
|
+
"DEFAULT_LATENCY_MS",
|
|
69
|
+
"MODERATE_MAX",
|
|
70
|
+
"Model",
|
|
71
|
+
"ModelStats",
|
|
72
|
+
"PREFERENCES",
|
|
73
|
+
"PREFERENCE_WEIGHTS",
|
|
74
|
+
"QUALITY_FLOOR",
|
|
75
|
+
"Route",
|
|
76
|
+
"Router",
|
|
77
|
+
"SIGNAL_WEIGHTS",
|
|
78
|
+
"SIMPLE_MAX",
|
|
79
|
+
"Stats",
|
|
80
|
+
"__version__",
|
|
81
|
+
"band_for",
|
|
82
|
+
"complexity",
|
|
83
|
+
"estimate_tokens",
|
|
84
|
+
"make_model",
|
|
85
|
+
"normalize_models",
|
|
86
|
+
"route",
|
|
87
|
+
]
|