llm-cascade 0.1.0__py3-none-any.whl
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_cascade/__init__.py +26 -0
- llm_cascade/__main__.py +7 -0
- llm_cascade/cli.py +72 -0
- llm_cascade/config.py +123 -0
- llm_cascade/pricing.py +27 -0
- llm_cascade/providers/__init__.py +6 -0
- llm_cascade/providers/anthropic_provider.py +123 -0
- llm_cascade/providers/local_provider.py +104 -0
- llm_cascade/providers/openrouter_provider.py +93 -0
- llm_cascade/py.typed +0 -0
- llm_cascade/router.py +277 -0
- llm_cascade-0.1.0.dist-info/METADATA +196 -0
- llm_cascade-0.1.0.dist-info/RECORD +16 -0
- llm_cascade-0.1.0.dist-info/WHEEL +4 -0
- llm_cascade-0.1.0.dist-info/entry_points.txt +2 -0
- llm_cascade-0.1.0.dist-info/licenses/LICENSE +21 -0
llm_cascade/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""llm-cascade: free-first LLM routing for Python.
|
|
2
|
+
|
|
3
|
+
Routes each task through a cost ladder -- local Ollama ($0) -> OpenRouter free
|
|
4
|
+
models ($0) -> paid Anthropic API -- with automatic fallback and a savings
|
|
5
|
+
ledger. Zero required dependencies; install the `anthropic` extra to unlock
|
|
6
|
+
the paid tiers.
|
|
7
|
+
|
|
8
|
+
from llm_cascade import route
|
|
9
|
+
result = route("summarize", "Long text here...")
|
|
10
|
+
print(result.text, result.tier, result.model, result.cost_usd)
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from .router import Completion, complete, complete_json, pick, route, stats
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"route",
|
|
20
|
+
"complete",
|
|
21
|
+
"complete_json",
|
|
22
|
+
"pick",
|
|
23
|
+
"stats",
|
|
24
|
+
"Completion",
|
|
25
|
+
"__version__",
|
|
26
|
+
]
|
llm_cascade/__main__.py
ADDED
llm_cascade/cli.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Command-line interface for llm-cascade.
|
|
2
|
+
|
|
3
|
+
llm-cascade "prompt here" --task summarize
|
|
4
|
+
llm-cascade --routes
|
|
5
|
+
llm-cascade --stats
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from . import config as _config
|
|
14
|
+
from . import router
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
prog="llm-cascade",
|
|
20
|
+
description="Free-first LLM routing: local Ollama -> OpenRouter free -> paid Anthropic.",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument("prompt", nargs="*", help="Prompt to complete")
|
|
23
|
+
parser.add_argument("--task", default="light",
|
|
24
|
+
help="Task name or tier (trivial/local/cheap/light/heavy)")
|
|
25
|
+
parser.add_argument("--max-tokens", type=int, default=None)
|
|
26
|
+
parser.add_argument("--effort", default=None,
|
|
27
|
+
choices=[None, "low", "medium", "high", "xhigh", "max"])
|
|
28
|
+
parser.add_argument("--routes", action="store_true",
|
|
29
|
+
help="Print the tier ladder and task routing table")
|
|
30
|
+
parser.add_argument("--stats", action="store_true",
|
|
31
|
+
help="Print cumulative cost and savings")
|
|
32
|
+
parser.add_argument("--json", action="store_true",
|
|
33
|
+
help="Print machine-readable JSON instead of plain text")
|
|
34
|
+
return parser
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main(argv: list[str] | None = None) -> int:
|
|
38
|
+
args = build_parser().parse_args(argv)
|
|
39
|
+
|
|
40
|
+
if args.routes:
|
|
41
|
+
if args.json:
|
|
42
|
+
print(json.dumps(_config.load_config()["task_routes"], indent=2, ensure_ascii=False))
|
|
43
|
+
else:
|
|
44
|
+
print(router.describe_routes())
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
if args.stats:
|
|
48
|
+
print(json.dumps(router.stats(), indent=2, ensure_ascii=False))
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
prompt = " ".join(args.prompt)
|
|
52
|
+
if not prompt:
|
|
53
|
+
print("error: no prompt given (pass a prompt, or use --routes / --stats)",
|
|
54
|
+
file=sys.stderr)
|
|
55
|
+
return 2
|
|
56
|
+
|
|
57
|
+
result = router.route(args.task, prompt, max_tokens=args.max_tokens, effort=args.effort)
|
|
58
|
+
if args.json:
|
|
59
|
+
print(json.dumps({
|
|
60
|
+
"text": result.text,
|
|
61
|
+
"tier": result.tier,
|
|
62
|
+
"model": result.model,
|
|
63
|
+
"cost_usd": result.cost_usd,
|
|
64
|
+
}, indent=2, ensure_ascii=False))
|
|
65
|
+
else:
|
|
66
|
+
print(f"[task={args.task} -> tier={result.tier} -> {result.model}]\n")
|
|
67
|
+
print(result.text)
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
raise SystemExit(main())
|
llm_cascade/config.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Configuration loading for llm-cascade.
|
|
2
|
+
|
|
3
|
+
Precedence (lowest to highest):
|
|
4
|
+
|
|
5
|
+
1. Built-in defaults (this module).
|
|
6
|
+
2. An optional TOML file -- ``~/.llm_cascade/routes.toml`` by default, or the
|
|
7
|
+
path given in the ``LLM_CASCADE_CONFIG`` environment variable.
|
|
8
|
+
3. Environment variables for the handful of settings that make sense as
|
|
9
|
+
env vars (API keys, HTTP headers, the log path, the free-cloud opt-in and
|
|
10
|
+
kill-switch). These are read directly by the modules that use them --
|
|
11
|
+
see ``router.py`` and ``providers/openrouter_provider.py`` -- rather than
|
|
12
|
+
being folded into the dict returned here.
|
|
13
|
+
|
|
14
|
+
Call :func:`load_config` to get the merged config. It re-reads the file (if
|
|
15
|
+
any) on every call, which keeps tests simple (no caching to reset) at a
|
|
16
|
+
negligible cost for a CLI/library that isn't called thousands of times a
|
|
17
|
+
second.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
import tomllib # Python >= 3.11
|
|
27
|
+
except ModuleNotFoundError: # pragma: no cover - exercised only on Python 3.10
|
|
28
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
29
|
+
|
|
30
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".llm_cascade" / "routes.toml"
|
|
31
|
+
DEFAULT_LOG_PATH = Path.home() / ".llm_cascade" / "log.jsonl"
|
|
32
|
+
|
|
33
|
+
# The routing policy: which tier handles each task name. Anything not listed
|
|
34
|
+
# here falls back to `default_task_tier`. This is the one table most users
|
|
35
|
+
# will want to edit -- add task names via the TOML file's [task_routes].
|
|
36
|
+
DEFAULT_TASK_ROUTES: dict[str, str] = {
|
|
37
|
+
"classify": "local",
|
|
38
|
+
"extract": "local",
|
|
39
|
+
"summarize": "local",
|
|
40
|
+
"translate": "local",
|
|
41
|
+
"rewrite": "light",
|
|
42
|
+
"code": "heavy",
|
|
43
|
+
"review": "heavy",
|
|
44
|
+
"plan": "heavy",
|
|
45
|
+
"creative": "heavy",
|
|
46
|
+
"chat": "local",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
DEFAULTS: dict[str, Any] = {
|
|
50
|
+
"models": {
|
|
51
|
+
"opus": "claude-opus-4-8",
|
|
52
|
+
"sonnet": "claude-sonnet-5",
|
|
53
|
+
"haiku": "claude-haiku-4-5",
|
|
54
|
+
},
|
|
55
|
+
# USD per 1M tokens: [input, output]. Used only for the numbers shown by
|
|
56
|
+
# `llm-cascade --stats`; it never affects what a provider actually bills.
|
|
57
|
+
"pricing": {
|
|
58
|
+
"claude-opus-4-8": [5.0, 25.0],
|
|
59
|
+
"claude-sonnet-5": [2.0, 10.0],
|
|
60
|
+
"claude-haiku-4-5": [1.0, 5.0],
|
|
61
|
+
},
|
|
62
|
+
"task_routes": dict(DEFAULT_TASK_ROUTES),
|
|
63
|
+
"default_task_tier": "light",
|
|
64
|
+
# OpenRouter ":free" models tried in order. Different providers per entry
|
|
65
|
+
# so rate limits are uncorrelated -- if one is saturated, the next usually
|
|
66
|
+
# isn't. Override via [openrouter_models] in the TOML file, or the
|
|
67
|
+
# LLM_CASCADE_OPENROUTER_MODEL(S) environment variables (env wins).
|
|
68
|
+
"openrouter_models": [
|
|
69
|
+
"meta-llama/llama-3.3-70b-instruct:free",
|
|
70
|
+
"qwen/qwen3-next-80b-a3b-instruct:free",
|
|
71
|
+
"google/gemma-4-31b-it:free",
|
|
72
|
+
"nvidia/nemotron-3-super-120b-a12b:free",
|
|
73
|
+
"nousresearch/hermes-3-llama-3.1-405b:free",
|
|
74
|
+
],
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _config_file_path() -> Path:
|
|
79
|
+
"""Where to look for the optional TOML config file. Doesn't need to exist."""
|
|
80
|
+
env_path = os.environ.get("LLM_CASCADE_CONFIG")
|
|
81
|
+
return Path(env_path).expanduser() if env_path else DEFAULT_CONFIG_PATH
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _load_toml_file(path: Path) -> dict:
|
|
85
|
+
if not path.exists():
|
|
86
|
+
return {}
|
|
87
|
+
try:
|
|
88
|
+
with open(path, "rb") as f:
|
|
89
|
+
return tomllib.load(f)
|
|
90
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
91
|
+
return {}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
95
|
+
out = dict(base)
|
|
96
|
+
for key, value in override.items():
|
|
97
|
+
if isinstance(value, dict) and isinstance(out.get(key), dict):
|
|
98
|
+
out[key] = _deep_merge(out[key], value)
|
|
99
|
+
else:
|
|
100
|
+
out[key] = value
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def log_path() -> Path:
|
|
105
|
+
"""Where the savings ledger (JSONL) is written and read from.
|
|
106
|
+
|
|
107
|
+
Override with the ``LLM_CASCADE_LOG_PATH`` environment variable; defaults
|
|
108
|
+
to ``~/.llm_cascade/log.jsonl``.
|
|
109
|
+
"""
|
|
110
|
+
override = os.environ.get("LLM_CASCADE_LOG_PATH")
|
|
111
|
+
return Path(override).expanduser() if override else DEFAULT_LOG_PATH
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def load_config() -> dict:
|
|
115
|
+
"""Merge built-in defaults with an optional TOML file and return the result.
|
|
116
|
+
|
|
117
|
+
Never raises: a missing or unparsable config file is silently treated as
|
|
118
|
+
"no overrides", since the built-in defaults are a complete, working
|
|
119
|
+
configuration on their own.
|
|
120
|
+
"""
|
|
121
|
+
cfg = _deep_merge({}, DEFAULTS)
|
|
122
|
+
file_cfg = _load_toml_file(_config_file_path())
|
|
123
|
+
return _deep_merge(cfg, file_cfg)
|
llm_cascade/pricing.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Per-model USD pricing, overridable via the config file (see `config.py`).
|
|
2
|
+
|
|
3
|
+
This module only affects the numbers reported by `stats()` / `llm-cascade
|
|
4
|
+
--stats` -- it has no bearing on what a provider actually bills you.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from . import config as _config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def price_table(cfg: dict | None = None) -> dict[str, tuple[float, float]]:
|
|
12
|
+
"""Model id -> (usd_per_1m_input_tokens, usd_per_1m_output_tokens)."""
|
|
13
|
+
cfg = cfg if cfg is not None else _config.load_config()
|
|
14
|
+
return {model: tuple(prices) for model, prices in cfg["pricing"].items()}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def model_ids(cfg: dict | None = None) -> dict[str, str]:
|
|
18
|
+
"""Tier name ('opus'/'sonnet'/'haiku') -> concrete Anthropic model id."""
|
|
19
|
+
cfg = cfg if cfg is not None else _config.load_config()
|
|
20
|
+
return dict(cfg["models"])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def cost_usd(model_id: str, input_tokens: int, output_tokens: int,
|
|
24
|
+
cfg: dict | None = None) -> float:
|
|
25
|
+
"""Estimated USD cost for one call. Unknown model ids price at $0."""
|
|
26
|
+
price_in, price_out = price_table(cfg).get(model_id, (0.0, 0.0))
|
|
27
|
+
return (input_tokens / 1e6) * price_in + (output_tokens / 1e6) * price_out
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Provider backends for the llm-cascade routing engine.
|
|
2
|
+
|
|
3
|
+
Each provider module exposes a small, mockable surface (no provider-specific
|
|
4
|
+
types leak out): plain strings and dicts in, plain dicts/strings out. See
|
|
5
|
+
`local_provider.py`, `openrouter_provider.py`, and `anthropic_provider.py`.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Anthropic provider: the paid, top-of-cascade tier.
|
|
2
|
+
|
|
3
|
+
The ``anthropic`` SDK is imported lazily (only when this tier is actually
|
|
4
|
+
used) so the library has zero required dependencies. Install it with
|
|
5
|
+
``pip install llm-cascade[anthropic]``.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
# Model families that accept adaptive-thinking + effort params. Anything else
|
|
12
|
+
# (e.g. Haiku) gets a plain call with no extra body.
|
|
13
|
+
_ADAPTIVE_MODELS = {"claude-opus-4-8", "claude-sonnet-5"}
|
|
14
|
+
|
|
15
|
+
_OVERLOAD_MARKERS = (
|
|
16
|
+
"rate_limit", "overloaded", "quota", "529", "503", "capacity",
|
|
17
|
+
"too many requests", "model_not_available",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_client = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _api_key() -> str | None:
|
|
24
|
+
key = os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("CLAUDE_API_KEY")
|
|
25
|
+
return key.strip() if key else None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_client():
|
|
29
|
+
"""Lazily construct (and cache) the Anthropic client.
|
|
30
|
+
|
|
31
|
+
Raises a clear, actionable `RuntimeError` if the SDK isn't installed or no
|
|
32
|
+
API key is configured, instead of letting an SDK-internal error surface.
|
|
33
|
+
"""
|
|
34
|
+
global _client
|
|
35
|
+
if _client is None:
|
|
36
|
+
try:
|
|
37
|
+
from anthropic import Anthropic
|
|
38
|
+
except ImportError as e:
|
|
39
|
+
raise RuntimeError(
|
|
40
|
+
"The 'anthropic' package is required for paid completions. "
|
|
41
|
+
"Install it with: pip install llm-cascade[anthropic]"
|
|
42
|
+
) from e
|
|
43
|
+
key = _api_key()
|
|
44
|
+
if not key:
|
|
45
|
+
raise RuntimeError(
|
|
46
|
+
"Missing ANTHROPIC_API_KEY (set the environment variable, or "
|
|
47
|
+
"CLAUDE_API_KEY as an alias)."
|
|
48
|
+
)
|
|
49
|
+
_client = Anthropic(api_key=key)
|
|
50
|
+
return _client
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_overload(error: Exception) -> bool:
|
|
54
|
+
"""Heuristic: does this exception look like a transient overload (as
|
|
55
|
+
opposed to a real bug), i.e. worth retrying against the fallback model?"""
|
|
56
|
+
message = str(error).lower()
|
|
57
|
+
return any(marker in message for marker in _OVERLOAD_MARKERS)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _extra_body(model_id: str, thinking: str, effort: str | None) -> dict:
|
|
61
|
+
"""Adaptive-thinking / effort params, sent via `extra_body` so this works
|
|
62
|
+
across SDK versions. Only the adaptive-thinking model family accepts them."""
|
|
63
|
+
if model_id not in _ADAPTIVE_MODELS:
|
|
64
|
+
return {}
|
|
65
|
+
body: dict = {}
|
|
66
|
+
if thinking == "adaptive":
|
|
67
|
+
body["thinking"] = {"type": "adaptive"}
|
|
68
|
+
elif thinking == "off":
|
|
69
|
+
body["thinking"] = {"type": "disabled"}
|
|
70
|
+
if effort:
|
|
71
|
+
body["output_config"] = {"effort": effort}
|
|
72
|
+
return body
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _text(message) -> str:
|
|
76
|
+
"""Concatenate only the text content blocks (skip thinking/tool blocks)."""
|
|
77
|
+
return "".join(
|
|
78
|
+
getattr(block, "text", "") for block in message.content
|
|
79
|
+
if getattr(block, "type", None) == "text"
|
|
80
|
+
).strip()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def complete(prompt: str, system: str | None, model_id: str, thinking: str,
|
|
84
|
+
effort: str | None, max_tokens: int,
|
|
85
|
+
fallback_model_id: str | None = None) -> dict:
|
|
86
|
+
"""Call the given model; on overload, retry once against `fallback_model_id`.
|
|
87
|
+
|
|
88
|
+
Returns ``{"text", "model", "input_tokens", "output_tokens"}``. Non-overload
|
|
89
|
+
errors, and overload errors with no fallback configured, propagate to the
|
|
90
|
+
caller (the router decides whether to degrade to a free tier).
|
|
91
|
+
"""
|
|
92
|
+
client = get_client()
|
|
93
|
+
base = {"model": model_id, "max_tokens": max_tokens,
|
|
94
|
+
"messages": [{"role": "user", "content": prompt}]}
|
|
95
|
+
if system:
|
|
96
|
+
base["system"] = system
|
|
97
|
+
|
|
98
|
+
def _call(mid: str):
|
|
99
|
+
kwargs = {**base, "model": mid}
|
|
100
|
+
extra_body = _extra_body(mid, thinking, effort)
|
|
101
|
+
# Streaming for large outputs avoids client-side HTTP timeouts.
|
|
102
|
+
if max_tokens >= 8000:
|
|
103
|
+
with client.messages.stream(**kwargs, extra_body=extra_body) as stream:
|
|
104
|
+
message = stream.get_final_message()
|
|
105
|
+
else:
|
|
106
|
+
message = client.messages.create(**kwargs, extra_body=extra_body)
|
|
107
|
+
return mid, message
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
used_model, message = _call(model_id)
|
|
111
|
+
except Exception as e:
|
|
112
|
+
if fallback_model_id and is_overload(e):
|
|
113
|
+
used_model, message = _call(fallback_model_id)
|
|
114
|
+
else:
|
|
115
|
+
raise
|
|
116
|
+
|
|
117
|
+
usage = getattr(message, "usage", None)
|
|
118
|
+
return {
|
|
119
|
+
"text": _text(message),
|
|
120
|
+
"model": used_model,
|
|
121
|
+
"input_tokens": getattr(usage, "input_tokens", 0) if usage else 0,
|
|
122
|
+
"output_tokens": getattr(usage, "output_tokens", 0) if usage else 0,
|
|
123
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Local provider: cost-free text generation via a local Ollama server.
|
|
2
|
+
|
|
3
|
+
This is the bottom of the cascade -- $0, no network egress beyond localhost
|
|
4
|
+
(or wherever ``LLM_CASCADE_OLLAMA_BASE_URL`` points). If Ollama isn't running,
|
|
5
|
+
`is_up()` returns False and the router moves on to the next tier.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import urllib.request
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "http://localhost:11434"
|
|
14
|
+
|
|
15
|
+
# Lane name -> Ollama model tag. A lane is a "quality knob" independent of the
|
|
16
|
+
# exact model pulled; pass an exact Ollama tag directly as `model=` to bypass
|
|
17
|
+
# lanes entirely.
|
|
18
|
+
DEFAULT_LANES: dict[str, str] = {
|
|
19
|
+
"fast": "qwen2.5:3b",
|
|
20
|
+
"quality": "qwen2.5:7b",
|
|
21
|
+
"creative": "qwen2.5:14b",
|
|
22
|
+
}
|
|
23
|
+
DEFAULT_LANE = "fast"
|
|
24
|
+
|
|
25
|
+
DEFAULT_OPTIONS: dict[str, float | int] = {"temperature": 0.7, "num_predict": 512}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _base_url() -> str:
|
|
29
|
+
url = os.environ.get("LLM_CASCADE_OLLAMA_BASE_URL", DEFAULT_BASE_URL)
|
|
30
|
+
return url.rstrip("/").removesuffix("/v1")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _post(path: str, payload: dict, timeout: int = 240) -> dict:
|
|
34
|
+
req = urllib.request.Request(
|
|
35
|
+
f"{_base_url()}{path}",
|
|
36
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
37
|
+
headers={"Content-Type": "application/json"},
|
|
38
|
+
)
|
|
39
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
40
|
+
return json.loads(r.read().decode("utf-8"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_up() -> bool:
|
|
44
|
+
"""Quick reachability check -- call this before `generate()`/`chat()` so a
|
|
45
|
+
down Ollama fails fast instead of waiting out a long timeout."""
|
|
46
|
+
try:
|
|
47
|
+
urllib.request.urlopen(f"{_base_url()}/api/tags", timeout=4)
|
|
48
|
+
return True
|
|
49
|
+
except Exception:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def installed_models() -> list[str]:
|
|
54
|
+
"""Tags currently pulled in the local Ollama instance (empty list if down)."""
|
|
55
|
+
try:
|
|
56
|
+
with urllib.request.urlopen(f"{_base_url()}/api/tags", timeout=5) as r:
|
|
57
|
+
return [m["name"] for m in json.loads(r.read())["models"]]
|
|
58
|
+
except Exception:
|
|
59
|
+
return []
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _resolve(model: str | None, lanes: dict[str, str] | None = None) -> str:
|
|
63
|
+
"""Map a lane name ('fast'/'quality'/...) or exact tag to an installed model.
|
|
64
|
+
Falls back to any installed qwen model, then to whatever is installed, if the
|
|
65
|
+
requested tag isn't pulled yet."""
|
|
66
|
+
lane_map = lanes or DEFAULT_LANES
|
|
67
|
+
want = lane_map.get(model or DEFAULT_LANE, model or lane_map[DEFAULT_LANE])
|
|
68
|
+
have = installed_models()
|
|
69
|
+
if want in have:
|
|
70
|
+
return want
|
|
71
|
+
for m in have:
|
|
72
|
+
if "qwen" in m:
|
|
73
|
+
return m
|
|
74
|
+
return have[0] if have else want
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def generate(prompt: str, model: str | None = None, system: str | None = None,
|
|
78
|
+
options: dict | None = None, timeout: int = 180,
|
|
79
|
+
lanes: dict[str, str] | None = None) -> str:
|
|
80
|
+
"""Single-shot completion. `model` is a lane name or an exact Ollama tag."""
|
|
81
|
+
payload = {
|
|
82
|
+
"model": _resolve(model, lanes),
|
|
83
|
+
"prompt": prompt,
|
|
84
|
+
"stream": False,
|
|
85
|
+
"options": {**DEFAULT_OPTIONS, **(options or {})},
|
|
86
|
+
}
|
|
87
|
+
if system:
|
|
88
|
+
payload["system"] = system
|
|
89
|
+
return _post("/api/generate", payload, timeout).get("response", "").strip()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def chat(prompt: str, model: str | None = None, system: str | None = None,
|
|
93
|
+
options: dict | None = None, timeout: int = 180,
|
|
94
|
+
lanes: dict[str, str] | None = None) -> str:
|
|
95
|
+
"""Chat-style completion (same return shape as `generate()`)."""
|
|
96
|
+
msgs = ([{"role": "system", "content": system}] if system else []) + \
|
|
97
|
+
[{"role": "user", "content": prompt}]
|
|
98
|
+
payload = {
|
|
99
|
+
"model": _resolve(model, lanes),
|
|
100
|
+
"messages": msgs,
|
|
101
|
+
"stream": False,
|
|
102
|
+
"options": {**DEFAULT_OPTIONS, **(options or {})},
|
|
103
|
+
}
|
|
104
|
+
return _post("/api/chat", payload, timeout).get("message", {}).get("content", "").strip()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""OpenRouter provider: free-tier (``:free`` suffix) models.
|
|
2
|
+
|
|
3
|
+
Free models are rate-limited upstream (expect occasional 429s), so this tries
|
|
4
|
+
each configured model in order and moves to the next on any error. Returns
|
|
5
|
+
``None`` only if every model fails or no API key is configured -- callers
|
|
6
|
+
treat that as "this tier had nothing, try the next one."
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import urllib.request
|
|
13
|
+
|
|
14
|
+
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
|
|
15
|
+
|
|
16
|
+
# Tried in order. Different providers per entry so rate limits are
|
|
17
|
+
# uncorrelated -- if one is saturated, the next usually isn't.
|
|
18
|
+
DEFAULT_FREE_MODELS: tuple[str, ...] = (
|
|
19
|
+
"meta-llama/llama-3.3-70b-instruct:free",
|
|
20
|
+
"qwen/qwen3-next-80b-a3b-instruct:free",
|
|
21
|
+
"google/gemma-4-31b-it:free",
|
|
22
|
+
"nvidia/nemotron-3-super-120b-a12b:free",
|
|
23
|
+
"nousresearch/hermes-3-llama-3.1-405b:free",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def free_models() -> list[str]:
|
|
28
|
+
"""The list of ``:free`` models to try, in order.
|
|
29
|
+
|
|
30
|
+
``LLM_CASCADE_OPENROUTER_MODEL`` pins a single model; the plural
|
|
31
|
+
``LLM_CASCADE_OPENROUTER_MODELS`` sets a comma-separated list. Either env
|
|
32
|
+
var overrides the built-in default.
|
|
33
|
+
"""
|
|
34
|
+
single = os.environ.get("LLM_CASCADE_OPENROUTER_MODEL")
|
|
35
|
+
if single:
|
|
36
|
+
return [single.strip()]
|
|
37
|
+
raw = os.environ.get("LLM_CASCADE_OPENROUTER_MODELS")
|
|
38
|
+
if raw:
|
|
39
|
+
return [m.strip() for m in raw.split(",") if m.strip()]
|
|
40
|
+
return list(DEFAULT_FREE_MODELS)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _api_key() -> str | None:
|
|
44
|
+
key = os.environ.get("OPENROUTER_API_KEY")
|
|
45
|
+
return key.strip() if key else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _headers(key: str) -> dict:
|
|
49
|
+
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {key}"}
|
|
50
|
+
referer = os.environ.get("LLM_CASCADE_HTTP_REFERER")
|
|
51
|
+
title = os.environ.get("LLM_CASCADE_APP_TITLE")
|
|
52
|
+
if referer:
|
|
53
|
+
headers["HTTP-Referer"] = referer
|
|
54
|
+
if title:
|
|
55
|
+
headers["X-Title"] = title
|
|
56
|
+
return headers
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def complete(prompt: str, system: str | None = None, max_tokens: int | None = None,
|
|
60
|
+
models: list[str] | None = None) -> dict | None:
|
|
61
|
+
"""Try each free model in order; return the first success.
|
|
62
|
+
|
|
63
|
+
Returns ``{"text", "model", "input_tokens", "output_tokens"}``, or ``None``
|
|
64
|
+
if there's no API key or every model in the list failed.
|
|
65
|
+
"""
|
|
66
|
+
key = _api_key()
|
|
67
|
+
if not key:
|
|
68
|
+
return None
|
|
69
|
+
msgs = ([{"role": "system", "content": system}] if system else []) + \
|
|
70
|
+
[{"role": "user", "content": prompt}]
|
|
71
|
+
headers = _headers(key)
|
|
72
|
+
for model in (models if models is not None else free_models()):
|
|
73
|
+
body = {"model": model, "messages": msgs, "max_tokens": max_tokens or 1500}
|
|
74
|
+
req = urllib.request.Request(
|
|
75
|
+
OPENROUTER_URL, data=json.dumps(body).encode("utf-8"), headers=headers
|
|
76
|
+
)
|
|
77
|
+
try:
|
|
78
|
+
with urllib.request.urlopen(req, timeout=90) as r:
|
|
79
|
+
data = json.loads(r.read().decode("utf-8"))
|
|
80
|
+
choice = (data.get("choices") or [{}])[0]
|
|
81
|
+
text = (choice.get("message", {}).get("content") or "").strip()
|
|
82
|
+
if not text:
|
|
83
|
+
continue
|
|
84
|
+
usage = data.get("usage") or {}
|
|
85
|
+
return {
|
|
86
|
+
"text": text,
|
|
87
|
+
"model": model,
|
|
88
|
+
"input_tokens": int(usage.get("prompt_tokens", 0) or 0),
|
|
89
|
+
"output_tokens": int(usage.get("completion_tokens", 0) or 0),
|
|
90
|
+
}
|
|
91
|
+
except Exception:
|
|
92
|
+
continue # 429 / invalid slug / timeout -> try the next free model
|
|
93
|
+
return None
|
llm_cascade/py.typed
ADDED
|
File without changes
|
llm_cascade/router.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Core routing engine: decides which tier handles a task, then runs the
|
|
2
|
+
free -> paid cascade with automatic fallback.
|
|
3
|
+
|
|
4
|
+
Tier ladder (cheapest to strongest):
|
|
5
|
+
|
|
6
|
+
trivial -> local Ollama only ($0), no cloud fallback at all
|
|
7
|
+
local -> local Ollama ($0) -> OpenRouter free model ($0) -> paid Anthropic
|
|
8
|
+
(safety net) -- mechanical NLP that a small local model handles fine
|
|
9
|
+
cheap -> OpenRouter free model ($0) -> paid Anthropic (manual-only tier;
|
|
10
|
+
no task routes here by default, invoke with task="cheap")
|
|
11
|
+
light -> paid Anthropic (Sonnet); OpenRouter free is tried first only if
|
|
12
|
+
LLM_CASCADE_LIGHT_FREE=1 (opt-in, since this tier is usually
|
|
13
|
+
customer-facing copy where quality matters more than $0)
|
|
14
|
+
heavy -> paid Anthropic (Opus), adaptive thinking, high effort -- real
|
|
15
|
+
reasoning, long-form, code, review
|
|
16
|
+
|
|
17
|
+
Edit `task_routes` in a TOML config file (see `config.py`) to calibrate which
|
|
18
|
+
task maps to which tier -- that's the one knob most users need to touch.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
|
|
27
|
+
from . import config as _config
|
|
28
|
+
from . import pricing as _pricing
|
|
29
|
+
from .providers import anthropic_provider, local_provider, openrouter_provider
|
|
30
|
+
|
|
31
|
+
# Tier mechanics:
|
|
32
|
+
# local=True -> pure local_provider ($0), no cloud at all.
|
|
33
|
+
# prefer_local=True -> try local_provider ($0) first; cloud only if it fails.
|
|
34
|
+
# cheap_cloud=True -> try OpenRouter free ($0) before paid cloud.
|
|
35
|
+
# cheap_cloud="env" -> same, but opt-in via LLM_CASCADE_LIGHT_FREE=1.
|
|
36
|
+
# lane -> which local_provider lane to use (fast/quality/creative).
|
|
37
|
+
# thinking -> "adaptive" | "off".
|
|
38
|
+
# effort -> low|medium|high|xhigh|max.
|
|
39
|
+
# fallback -> alternate Anthropic model tier name if the primary overloads.
|
|
40
|
+
TIERS: dict[str, dict] = {
|
|
41
|
+
"trivial": {"local": True, "lane": "quality"},
|
|
42
|
+
"local": {"prefer_local": True, "lane": "quality", "cheap_cloud": True,
|
|
43
|
+
"model": "sonnet", "thinking": "off", "effort": "low",
|
|
44
|
+
"max_tokens": 2000, "fallback": "haiku"},
|
|
45
|
+
"cheap": {"cheap_cloud": True,
|
|
46
|
+
"model": "sonnet", "thinking": "off", "effort": "low",
|
|
47
|
+
"max_tokens": 3000, "fallback": "haiku"},
|
|
48
|
+
"light": {"cheap_cloud": "env",
|
|
49
|
+
"model": "sonnet", "thinking": "off", "effort": "low",
|
|
50
|
+
"max_tokens": 3000, "fallback": "haiku"},
|
|
51
|
+
"heavy": {"model": "opus", "thinking": "adaptive", "effort": "high",
|
|
52
|
+
"max_tokens": 16000, "fallback": "sonnet"},
|
|
53
|
+
}
|
|
54
|
+
TIER_ORDER = ("trivial", "local", "cheap", "light", "heavy")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Completion:
|
|
59
|
+
"""Result of a routed completion."""
|
|
60
|
+
|
|
61
|
+
text: str
|
|
62
|
+
tier: str
|
|
63
|
+
model: str
|
|
64
|
+
cost_usd: float
|
|
65
|
+
cached: bool = False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def pick(task_or_tier: str, cfg: dict | None = None) -> dict:
|
|
69
|
+
"""Resolve a TASK name or TIER name to its tier config (+ `model_id`).
|
|
70
|
+
|
|
71
|
+
`task_or_tier` may be an exact tier name ('trivial'/'local'/'cheap'/
|
|
72
|
+
'light'/'heavy') or any task name from the `task_routes` config; unknown
|
|
73
|
+
task names fall back to `default_task_tier`.
|
|
74
|
+
"""
|
|
75
|
+
cfg = cfg if cfg is not None else _config.load_config()
|
|
76
|
+
default_tier = cfg["default_task_tier"]
|
|
77
|
+
key = (task_or_tier or default_tier).strip().lower()
|
|
78
|
+
tier = key if key in TIERS else cfg["task_routes"].get(key, default_tier)
|
|
79
|
+
tier_cfg = dict(TIERS[tier])
|
|
80
|
+
tier_cfg["tier"] = tier
|
|
81
|
+
if not tier_cfg.get("local"):
|
|
82
|
+
tier_cfg["model_id"] = _pricing.model_ids(cfg)[tier_cfg["model"]]
|
|
83
|
+
return tier_cfg
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _cheap_cloud_on(tier_cfg: dict) -> bool:
|
|
87
|
+
if os.environ.get("LLM_CASCADE_NO_FREE_CLOUD"): # global kill-switch
|
|
88
|
+
return False
|
|
89
|
+
cheap_cloud = tier_cfg.get("cheap_cloud")
|
|
90
|
+
if cheap_cloud is True:
|
|
91
|
+
return True
|
|
92
|
+
if cheap_cloud == "env":
|
|
93
|
+
return bool(os.environ.get("LLM_CASCADE_LIGHT_FREE"))
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _log_call(model: str, input_tokens: int, output_tokens: int, cost: float) -> None:
|
|
98
|
+
"""One line per call, paid or free (cost=0.0). Feeds `stats()`."""
|
|
99
|
+
path = _config.log_path()
|
|
100
|
+
try:
|
|
101
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
103
|
+
f.write(json.dumps({
|
|
104
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
105
|
+
"model": model,
|
|
106
|
+
"in": input_tokens,
|
|
107
|
+
"out": output_tokens,
|
|
108
|
+
"cost_usd": round(cost, 6),
|
|
109
|
+
}) + "\n")
|
|
110
|
+
except OSError:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _try_local(prompt: str, lane: str | None, system: str | None) -> str | None:
|
|
115
|
+
"""Try the local tier; None if Ollama is down or returns empty output."""
|
|
116
|
+
if not local_provider.is_up():
|
|
117
|
+
return None
|
|
118
|
+
try:
|
|
119
|
+
out = local_provider.generate(prompt, model=lane, system=system)
|
|
120
|
+
return out if (out and out.strip()) else None
|
|
121
|
+
except Exception:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _try_openrouter(prompt: str, system: str | None, max_tokens: int | None) -> dict | None:
|
|
126
|
+
result = openrouter_provider.complete(prompt, system=system, max_tokens=max_tokens)
|
|
127
|
+
if result:
|
|
128
|
+
_log_call("openrouter:" + result["model"],
|
|
129
|
+
result["input_tokens"], result["output_tokens"], 0.0)
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _call_claude(prompt: str, system: str | None, model_id: str, thinking: str,
|
|
134
|
+
effort: str | None, max_tokens: int, fallback_tier_model: str | None,
|
|
135
|
+
cfg: dict) -> dict:
|
|
136
|
+
fallback_id = _pricing.model_ids(cfg).get(fallback_tier_model) if fallback_tier_model else None
|
|
137
|
+
result = anthropic_provider.complete(prompt, system, model_id, thinking, effort,
|
|
138
|
+
max_tokens, fallback_id)
|
|
139
|
+
cost = _pricing.cost_usd(result["model"], result["input_tokens"], result["output_tokens"], cfg)
|
|
140
|
+
_log_call(result["model"], result["input_tokens"], result["output_tokens"], cost)
|
|
141
|
+
result["cost_usd"] = cost
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def route(task: str, prompt: str, system: str | None = None,
|
|
146
|
+
max_tokens: int | None = None, effort: str | None = None) -> Completion:
|
|
147
|
+
"""Route `task` to the right tier and run the free -> paid cascade.
|
|
148
|
+
|
|
149
|
+
`task` may be a task name (see the `task_routes` config) or a tier name
|
|
150
|
+
directly ('trivial'/'local'/'cheap'/'light'/'heavy'). `max_tokens`/`effort`
|
|
151
|
+
override the tier's defaults. Returns a `Completion` with the text, the
|
|
152
|
+
tier and model that actually served it, and the USD cost (0.0 for local
|
|
153
|
+
or free-cloud tiers).
|
|
154
|
+
"""
|
|
155
|
+
cfg = _config.load_config()
|
|
156
|
+
tier_cfg = pick(task, cfg)
|
|
157
|
+
tier = tier_cfg["tier"]
|
|
158
|
+
|
|
159
|
+
if tier_cfg.get("local"): # pure local ($0), no cloud at all
|
|
160
|
+
text = local_provider.generate(prompt, model=tier_cfg.get("lane"), system=system)
|
|
161
|
+
return Completion(text=text, tier=tier, model="local:ollama", cost_usd=0.0)
|
|
162
|
+
|
|
163
|
+
if tier_cfg.get("prefer_local"): # 1) local $0
|
|
164
|
+
text = _try_local(prompt, tier_cfg.get("lane"), system)
|
|
165
|
+
if text:
|
|
166
|
+
return Completion(text=text, tier=tier, model="local:ollama", cost_usd=0.0)
|
|
167
|
+
|
|
168
|
+
if _cheap_cloud_on(tier_cfg): # 2) free cloud $0 (OpenRouter :free)
|
|
169
|
+
result = _try_openrouter(prompt, system, max_tokens or tier_cfg.get("max_tokens"))
|
|
170
|
+
if result:
|
|
171
|
+
return Completion(text=result["text"], tier=tier,
|
|
172
|
+
model="openrouter:" + result["model"], cost_usd=0.0)
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
result = _call_claude( # 3) paid cloud (quality / safety net)
|
|
176
|
+
prompt, system, tier_cfg["model_id"], tier_cfg["thinking"],
|
|
177
|
+
effort or tier_cfg["effort"], max_tokens or tier_cfg["max_tokens"],
|
|
178
|
+
tier_cfg.get("fallback"), cfg,
|
|
179
|
+
)
|
|
180
|
+
return Completion(text=result["text"], tier=tier, model=result["model"],
|
|
181
|
+
cost_usd=result["cost_usd"])
|
|
182
|
+
except Exception as e:
|
|
183
|
+
# Final safety net: the paid API is unreachable for ANY reason (bad or
|
|
184
|
+
# revoked key, no credits, network down, provider outage -- not just
|
|
185
|
+
# overload) -- degrade to the free tiers instead of raising, so
|
|
186
|
+
# automated/scheduled callers never break outright.
|
|
187
|
+
_log_call("degraded:" + type(e).__name__, 0, 0, 0.0)
|
|
188
|
+
if not _cheap_cloud_on(tier_cfg): # didn't try free cloud yet this call
|
|
189
|
+
result = _try_openrouter(prompt, system, max_tokens or tier_cfg.get("max_tokens"))
|
|
190
|
+
if result:
|
|
191
|
+
return Completion(text=result["text"], tier=tier,
|
|
192
|
+
model="openrouter:" + result["model"], cost_usd=0.0)
|
|
193
|
+
text = _try_local(prompt, tier_cfg.get("lane"), system)
|
|
194
|
+
if text:
|
|
195
|
+
return Completion(text=text, tier=tier, model="local:ollama", cost_usd=0.0)
|
|
196
|
+
raise
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def complete(prompt: str, task: str = "light", system: str | None = None,
|
|
200
|
+
max_tokens: int | None = None, effort: str | None = None) -> str:
|
|
201
|
+
"""Same cascade as `route()`, returning just the completion text."""
|
|
202
|
+
return route(task, prompt, system=system, max_tokens=max_tokens, effort=effort).text
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def complete_json(prompt: str, task: str = "extract", system: str | None = None, **kw) -> dict:
|
|
206
|
+
"""Like `complete()`, but instructs the model to reply with one JSON object
|
|
207
|
+
and parses it."""
|
|
208
|
+
sysj = (system or "") + ("\n\nReply with ONE valid JSON object only. "
|
|
209
|
+
"No markdown, no ```json fences, no extra text.")
|
|
210
|
+
raw = complete(prompt, task=task, system=sysj, **kw).strip()
|
|
211
|
+
if raw.startswith("```"):
|
|
212
|
+
raw = raw.strip("`")
|
|
213
|
+
raw = raw.split("\n", 1)[1] if "\n" in raw else raw
|
|
214
|
+
start, end = raw.find("{"), raw.rfind("}")
|
|
215
|
+
if start >= 0 and end > start:
|
|
216
|
+
raw = raw[start:end + 1]
|
|
217
|
+
return json.loads(raw)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def stats() -> dict:
|
|
221
|
+
"""Cumulative cost + free (local/OpenRouter) vs paid (Anthropic) call counts,
|
|
222
|
+
read from the savings ledger (see `config.log_path()`)."""
|
|
223
|
+
path = _config.log_path()
|
|
224
|
+
paid = free = 0
|
|
225
|
+
cost = 0.0
|
|
226
|
+
by_model: dict[str, int] = {}
|
|
227
|
+
if path.exists():
|
|
228
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
229
|
+
try:
|
|
230
|
+
entry = json.loads(line)
|
|
231
|
+
except Exception:
|
|
232
|
+
continue
|
|
233
|
+
call_cost = float(entry.get("cost_usd", 0) or 0)
|
|
234
|
+
cost += call_cost
|
|
235
|
+
if call_cost > 0:
|
|
236
|
+
paid += 1
|
|
237
|
+
else:
|
|
238
|
+
free += 1
|
|
239
|
+
model = entry.get("model", "unknown")
|
|
240
|
+
by_model[model] = by_model.get(model, 0) + 1
|
|
241
|
+
return {
|
|
242
|
+
"paid_calls": paid,
|
|
243
|
+
"free_calls": free,
|
|
244
|
+
"total_cost_usd": round(cost, 4),
|
|
245
|
+
"by_model": by_model,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _tier_destination(tier: str, cfg: dict) -> str:
|
|
250
|
+
"""Human-readable description of a tier's provider chain, for `--routes`."""
|
|
251
|
+
tier_cfg = pick(tier, cfg)
|
|
252
|
+
if tier_cfg.get("local"):
|
|
253
|
+
return "local (Ollama, $0)"
|
|
254
|
+
chain = []
|
|
255
|
+
if tier_cfg.get("prefer_local"):
|
|
256
|
+
chain.append("local (Ollama, $0)")
|
|
257
|
+
cheap_cloud = tier_cfg.get("cheap_cloud")
|
|
258
|
+
if cheap_cloud is True:
|
|
259
|
+
chain.append("OpenRouter free ($0)")
|
|
260
|
+
elif cheap_cloud == "env":
|
|
261
|
+
chain.append("OpenRouter free ($0, if LLM_CASCADE_LIGHT_FREE=1)")
|
|
262
|
+
chain.append(tier_cfg["model_id"] + (" (safety net)" if chain else ""))
|
|
263
|
+
return " -> ".join(chain)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def describe_routes(cfg: dict | None = None) -> str:
|
|
267
|
+
"""Render the tier ladder + task -> tier table (used by `llm-cascade --routes`)."""
|
|
268
|
+
cfg = cfg if cfg is not None else _config.load_config()
|
|
269
|
+
lines = []
|
|
270
|
+
for tier in TIER_ORDER:
|
|
271
|
+
tasks = sorted(t for t, v in cfg["task_routes"].items() if v == tier)
|
|
272
|
+
label = tier + (" (manual)" if tier == "cheap" else "")
|
|
273
|
+
line = f"[{label:14}] -> {_tier_destination(tier, cfg)}"
|
|
274
|
+
if tasks:
|
|
275
|
+
line += f"\n {', '.join(tasks)}"
|
|
276
|
+
lines.append(line)
|
|
277
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: llm-cascade
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Free-first LLM routing for Python: local Ollama -> OpenRouter free models -> paid Anthropic API, with automatic fallback and a savings ledger.
|
|
5
|
+
Project-URL: Homepage, https://github.com/luandv92/llm-cascade
|
|
6
|
+
Project-URL: Repository, https://github.com/luandv92/llm-cascade
|
|
7
|
+
Project-URL: Issues, https://github.com/luandv92/llm-cascade/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/luandv92/llm-cascade/blob/main/CHANGELOG.md
|
|
9
|
+
Author: luandv92
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai,anthropic,claude,cost-optimization,llm,ollama,openrouter,router
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: tomli>=2.0.1; python_version < '3.11'
|
|
27
|
+
Provides-Extra: anthropic
|
|
28
|
+
Requires-Dist: anthropic>=0.40.0; extra == 'anthropic'
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# llm-cascade
|
|
35
|
+
|
|
36
|
+
[](https://github.com/luandv92/llm-cascade/actions/workflows/ci.yml)
|
|
37
|
+
[](https://pypi.org/project/llm-cascade/)
|
|
38
|
+
[](LICENSE)
|
|
39
|
+
[](https://pypi.org/project/llm-cascade/)
|
|
40
|
+
|
|
41
|
+
Free-first LLM routing for Python: local Ollama ($0) -> OpenRouter free models
|
|
42
|
+
($0) -> paid Anthropic API, with automatic fallback and a savings ledger.
|
|
43
|
+
Zero required dependencies.
|
|
44
|
+
|
|
45
|
+
## Why
|
|
46
|
+
|
|
47
|
+
Most teams route every prompt to the same frontier model regardless of how
|
|
48
|
+
hard the task actually is, and the API bill grows with usage instead of with
|
|
49
|
+
value delivered. llm-cascade flips that: it tries the cheapest tier that can
|
|
50
|
+
plausibly do the job first, and only escalates when that tier is unreachable
|
|
51
|
+
or genuinely too weak for the task. In production, that means classification,
|
|
52
|
+
extraction, and summarization run for $0 on a local model or a free cloud
|
|
53
|
+
model, while code review, planning, and long-form writing still get a
|
|
54
|
+
frontier model -- and if the paid API is ever down or out of credits, your
|
|
55
|
+
scheduled jobs degrade to a free tier instead of crashing.
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install llm-cascade
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The base package has **zero required third-party dependencies**. To use the
|
|
64
|
+
paid Anthropic tier, install the extra:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install "llm-cascade[anthropic]"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Without it, the local (Ollama) and free-cloud (OpenRouter) tiers still work
|
|
71
|
+
end to end -- you'll only see an error if a task actually routes to the paid
|
|
72
|
+
tier and the SDK isn't installed.
|
|
73
|
+
|
|
74
|
+
## Quickstart
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from llm_cascade import route
|
|
78
|
+
|
|
79
|
+
result = route("summarize", "Paste a long document here...")
|
|
80
|
+
print(result.text) # the completion
|
|
81
|
+
print(result.tier) # which tier served it: trivial/local/cheap/light/heavy
|
|
82
|
+
print(result.model) # e.g. "local:ollama", "openrouter:qwen/...", "claude-sonnet-5"
|
|
83
|
+
print(result.cost_usd) # 0.0 for local/free-cloud tiers
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Or from the command line:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
llm-cascade "Summarize this text: ..." --task summarize
|
|
90
|
+
llm-cascade --routes # show the tier ladder and task -> tier table
|
|
91
|
+
llm-cascade --stats # show cumulative cost and free-vs-paid call counts
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## How routing works
|
|
95
|
+
|
|
96
|
+
Each call resolves a **task name** (or a tier name directly) to a **tier**,
|
|
97
|
+
then runs that tier's provider chain in order until one succeeds:
|
|
98
|
+
|
|
99
|
+
| Tier | Chain | Use for |
|
|
100
|
+
|-----------|---------------------------------------------------|-------------------------------------------|
|
|
101
|
+
| `trivial` | local Ollama only ($0) | high-volume micro-tasks, never worth the network |
|
|
102
|
+
| `local` | local Ollama ($0) -> OpenRouter free ($0) -> paid | mechanical NLP: classify, extract, summarize, translate |
|
|
103
|
+
| `cheap` | OpenRouter free ($0) -> paid | manual opt-in tier, invoke directly with `task="cheap"` |
|
|
104
|
+
| `light` | paid (Sonnet); free-cloud only if `LLM_CASCADE_LIGHT_FREE=1` | customer-facing copy that needs reliable polish |
|
|
105
|
+
| `heavy` | paid (Opus), adaptive thinking, high effort | real reasoning: code, review, planning, long-form |
|
|
106
|
+
|
|
107
|
+
If the paid API call fails for *any* reason -- bad or revoked key, no
|
|
108
|
+
credits, a provider outage, a network blip -- llm-cascade degrades to the
|
|
109
|
+
free tiers instead of raising, so scheduled or automated callers don't break
|
|
110
|
+
outright. It only raises once every tier has been tried and failed.
|
|
111
|
+
|
|
112
|
+
The default task -> tier table:
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
classify, extract, summarize, translate, chat -> local
|
|
116
|
+
rewrite -> light
|
|
117
|
+
code, review, plan, creative -> heavy
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Anything not in that table falls back to `default_task_tier` (`light` by
|
|
121
|
+
default). See `config/routes.example.toml` for the full, commented schema.
|
|
122
|
+
|
|
123
|
+
## Configuration
|
|
124
|
+
|
|
125
|
+
Configuration is resolved with this precedence (lowest to highest):
|
|
126
|
+
|
|
127
|
+
1. Built-in defaults.
|
|
128
|
+
2. An optional TOML file: `~/.llm_cascade/routes.toml`, or the path given in
|
|
129
|
+
`LLM_CASCADE_CONFIG`.
|
|
130
|
+
3. Environment variables, for the handful of settings that make sense as env
|
|
131
|
+
vars.
|
|
132
|
+
|
|
133
|
+
Copy [`config/routes.example.toml`](config/routes.example.toml) to
|
|
134
|
+
`~/.llm_cascade/routes.toml` to add your own task names, retune which tier
|
|
135
|
+
handles a task, or override model ids and pricing.
|
|
136
|
+
|
|
137
|
+
Environment variables:
|
|
138
|
+
|
|
139
|
+
| Variable | Purpose |
|
|
140
|
+
|---|---|
|
|
141
|
+
| `ANTHROPIC_API_KEY` | Enables the paid tier (`light`/`heavy`, and the final safety net). |
|
|
142
|
+
| `OPENROUTER_API_KEY` | Enables the free-cloud tier (OpenRouter `:free` models). |
|
|
143
|
+
| `LLM_CASCADE_CONFIG` | Path to a TOML config file, instead of `~/.llm_cascade/routes.toml`. |
|
|
144
|
+
| `LLM_CASCADE_LOG_PATH` | Path to the savings ledger, instead of `~/.llm_cascade/log.jsonl`. |
|
|
145
|
+
| `LLM_CASCADE_LIGHT_FREE` | Set to `1` to try free-cloud first on the `light` tier too. |
|
|
146
|
+
| `LLM_CASCADE_NO_FREE_CLOUD` | Set to any value to disable the free-cloud tier globally. |
|
|
147
|
+
| `LLM_CASCADE_HTTP_REFERER` / `LLM_CASCADE_APP_TITLE` | Sent as `HTTP-Referer` / `X-Title` on OpenRouter requests (optional; omitted if unset). |
|
|
148
|
+
| `LLM_CASCADE_OPENROUTER_MODEL` / `LLM_CASCADE_OPENROUTER_MODELS` | Override the free model (singular) or ordered list (comma-separated) tried on OpenRouter. |
|
|
149
|
+
| `LLM_CASCADE_OLLAMA_BASE_URL` | Ollama server URL (default `http://localhost:11434`). |
|
|
150
|
+
|
|
151
|
+
See [`.env.example`](.env.example) for a copy-pasteable starting point.
|
|
152
|
+
|
|
153
|
+
## Savings ledger
|
|
154
|
+
|
|
155
|
+
Every call -- free or paid -- appends one line to a JSONL ledger
|
|
156
|
+
(`~/.llm_cascade/log.jsonl` by default). `stats()` / `llm-cascade --stats`
|
|
157
|
+
summarize it:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
$ llm-cascade --stats
|
|
161
|
+
{
|
|
162
|
+
"paid_calls": 3,
|
|
163
|
+
"free_calls": 41,
|
|
164
|
+
"total_cost_usd": 0.0842,
|
|
165
|
+
"by_model": {
|
|
166
|
+
"local:ollama": 30,
|
|
167
|
+
"openrouter:qwen/qwen3-next-80b-a3b-instruct:free": 11,
|
|
168
|
+
"claude-sonnet-5": 3
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Comparison
|
|
174
|
+
|
|
175
|
+
Unlike heavier multi-provider routers, llm-cascade ships with zero required
|
|
176
|
+
dependencies and a single opinionated cascade -- it's a routing policy you can
|
|
177
|
+
read in one file, not a platform.
|
|
178
|
+
|
|
179
|
+
## Contributing
|
|
180
|
+
|
|
181
|
+
Issues and pull requests are welcome at
|
|
182
|
+
[github.com/luandv92/llm-cascade](https://github.com/luandv92/llm-cascade).
|
|
183
|
+
Before opening a PR:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
pip install -e ".[dev,anthropic]"
|
|
187
|
+
ruff check .
|
|
188
|
+
pytest tests/ -v
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Tests never touch real networks -- Ollama, OpenRouter, and the Anthropic SDK
|
|
192
|
+
are all mocked (see `tests/conftest.py`).
|
|
193
|
+
|
|
194
|
+
## License
|
|
195
|
+
|
|
196
|
+
MIT -- see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
llm_cascade/__init__.py,sha256=I2h8gApJoagXoGf2ma8rksIGPXu1hdecWgEcAcZb2A4,719
|
|
2
|
+
llm_cascade/__main__.py,sha256=t8oO-Xt48El7YXnfdGBKc4uhW9CAHoizIJir09kT8XQ,203
|
|
3
|
+
llm_cascade/cli.py,sha256=sWFRRsn9N2CJtY5NDSY1FfHq8wrZ_cYfpl2AyJsHQGo,2424
|
|
4
|
+
llm_cascade/config.py,sha256=Z1OLgxW7YHC1D9YP3ooDpKebYHfeLLvfcPZGSArT82Q,4399
|
|
5
|
+
llm_cascade/pricing.py,sha256=oVZ5vP2FBWLiUrr7AEXHt5Tg0kcoAGSDAdKwLSxxWh8,1152
|
|
6
|
+
llm_cascade/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
llm_cascade/router.py,sha256=dbojoCmn1FnIL57s4Bw36L-b-deKselfWXfKmdP1qN4,11966
|
|
8
|
+
llm_cascade/providers/__init__.py,sha256=4RYZkRdu8Kj67TlLjRrgDMWj6mLpywWYmfMVaEDIPJQ,289
|
|
9
|
+
llm_cascade/providers/anthropic_provider.py,sha256=frvij5gtFC45oS22KxQVPauG-_nlU3nkEvhOaQALu4k,4427
|
|
10
|
+
llm_cascade/providers/local_provider.py,sha256=XnuQETyZoXZ4DSeO5yyT0xtM68_t6aAHqjuAY2RAwAQ,3769
|
|
11
|
+
llm_cascade/providers/openrouter_provider.py,sha256=pTafYSvS-qpsnfF_vaCUn82SQ47cCIt2OFLL0YwF67Y,3508
|
|
12
|
+
llm_cascade-0.1.0.dist-info/METADATA,sha256=8S3Bl3sKZWKqWl-WSH4D7cJm2vulp8CdKVL_tIyob4U,8124
|
|
13
|
+
llm_cascade-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
14
|
+
llm_cascade-0.1.0.dist-info/entry_points.txt,sha256=4VrpGvSjuzKOD5Am33zWgYy9sxwUyAKhm2Tzth-BpoY,53
|
|
15
|
+
llm_cascade-0.1.0.dist-info/licenses/LICENSE,sha256=Kh3ppltVP61oAGqAk9UZUXcy8AsIdkfjZ5msTSsNWfI,1065
|
|
16
|
+
llm_cascade-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 luandv92
|
|
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.
|