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.
@@ -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
+ ]
@@ -0,0 +1,7 @@
1
+ """Enables `python -m llm_cascade` as an alias for the `llm-cascade` console script."""
2
+ from __future__ import annotations
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
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
+ [![CI](https://github.com/luandv92/llm-cascade/actions/workflows/ci.yml/badge.svg)](https://github.com/luandv92/llm-cascade/actions/workflows/ci.yml)
37
+ [![PyPI](https://img.shields.io/pypi/v/llm-cascade.svg)](https://pypi.org/project/llm-cascade/)
38
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
39
+ [![Python versions](https://img.shields.io/pypi/pyversions/llm-cascade.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ llm-cascade = llm_cascade.cli:main
@@ -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.