tiershift 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tiershift-0.1.0/.gitignore +25 -0
- tiershift-0.1.0/PKG-INFO +86 -0
- tiershift-0.1.0/README.md +62 -0
- tiershift-0.1.0/pyproject.toml +50 -0
- tiershift-0.1.0/tests/conftest.py +47 -0
- tiershift-0.1.0/tests/test_config.py +167 -0
- tiershift-0.1.0/tests/test_conformance.py +50 -0
- tiershift-0.1.0/tests/test_live.py +17 -0
- tiershift-0.1.0/tests/test_log_analytics.py +95 -0
- tiershift-0.1.0/tests/test_policy.py +129 -0
- tiershift-0.1.0/tests/test_providers.py +58 -0
- tiershift-0.1.0/tests/test_router.py +279 -0
- tiershift-0.1.0/tiershift/__init__.py +22 -0
- tiershift-0.1.0/tiershift/analytics.py +178 -0
- tiershift-0.1.0/tiershift/cli.py +216 -0
- tiershift-0.1.0/tiershift/config.py +160 -0
- tiershift-0.1.0/tiershift/data/prices.yaml +125 -0
- tiershift-0.1.0/tiershift/data/tiershift.yaml +99 -0
- tiershift-0.1.0/tiershift/log.py +65 -0
- tiershift-0.1.0/tiershift/policy.py +200 -0
- tiershift-0.1.0/tiershift/providers/__init__.py +29 -0
- tiershift-0.1.0/tiershift/providers/anthropic.py +74 -0
- tiershift-0.1.0/tiershift/providers/base.py +50 -0
- tiershift-0.1.0/tiershift/providers/openai_compatible.py +81 -0
- tiershift-0.1.0/tiershift/router.py +218 -0
- tiershift-0.1.0/tiershift/signals.py +151 -0
- tiershift-0.1.0/tiershift/types.py +188 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# secrets
|
|
2
|
+
.env
|
|
3
|
+
.env.*
|
|
4
|
+
!.env.example
|
|
5
|
+
|
|
6
|
+
# deps and build
|
|
7
|
+
node_modules/
|
|
8
|
+
dist/
|
|
9
|
+
.venv/
|
|
10
|
+
__pycache__/
|
|
11
|
+
|
|
12
|
+
# os
|
|
13
|
+
.DS_Store
|
|
14
|
+
|
|
15
|
+
# runtime
|
|
16
|
+
*.log
|
|
17
|
+
decisions.jsonl
|
|
18
|
+
.tiershift/
|
|
19
|
+
.claude/worktrees/
|
|
20
|
+
|
|
21
|
+
# python
|
|
22
|
+
python/.venv/
|
|
23
|
+
python/dist/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.pytest_cache/
|
tiershift-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tiershift
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shift every LLM call to the cheapest model that can handle it. Routing decided by TypeSafe Jev in ~300 ms. No training data. Policy in plain YAML.
|
|
5
|
+
Project-URL: Homepage, https://github.com/iamvatsalpatel/tiershift
|
|
6
|
+
Project-URL: Repository, https://github.com/iamvatsalpatel/tiershift
|
|
7
|
+
Project-URL: Changelog, https://github.com/iamvatsalpatel/tiershift/blob/main/CHANGELOG.md
|
|
8
|
+
Author: Vatsal Patel
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: agents,cost-optimization,jev,llm,model-routing,router,typesafe
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: httpx>=0.27
|
|
21
|
+
Requires-Dist: pyyaml>=6.0
|
|
22
|
+
Requires-Dist: typesafe-sdk>=0.6.0
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# tiershift (Python)
|
|
26
|
+
|
|
27
|
+
**Shift every LLM call to the cheapest model that can handle it.**
|
|
28
|
+
Routing decided by [TypeSafe Jev](https://docs.typesafe.ai) in about 300 ms for $0.00004 per call. No training data. Policy in plain YAML.
|
|
29
|
+
|
|
30
|
+
This is the Python package. It shares one `tiershift.yaml` format, one `prices.yaml`, and one decision-log format with the [npm package](https://github.com/iamvatsalpatel/tiershift), so `report` and `tune` read logs written by either.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv add tiershift # or: pip install tiershift
|
|
36
|
+
export TYPESAFE_API_KEY=... # get one at typesafe.ai
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Provider keys are optional. A tier skips any model whose key is missing. With only [Ollama](https://ollama.com) running, everything routes to the local model and the decision is flagged `degraded=True`.
|
|
40
|
+
|
|
41
|
+
## Use
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from tiershift import create_router
|
|
45
|
+
|
|
46
|
+
router = create_router() # reads ./tiershift.yaml, else the bundled default
|
|
47
|
+
|
|
48
|
+
d = router.route(messages, tools=tools, step="plan", retries=0)
|
|
49
|
+
d.model # "deepseek/deepseek-flash"
|
|
50
|
+
d.tier # "fast"
|
|
51
|
+
d.fallback # "openai/gpt-5.6-terra" use this if the call fails
|
|
52
|
+
d.signals # Signals(difficulty=0.31, stakes=0.12, needs_reasoning=0.08, ...)
|
|
53
|
+
d.reason # ['rule "difficulty < 0.5" → fast']
|
|
54
|
+
d.est_cost_usd # 0.00006
|
|
55
|
+
|
|
56
|
+
r = router.complete(messages, tools=tools, max_tokens=1024) # decide, call, fall back one tier up on failure
|
|
57
|
+
r.text, r.model, r.fell_back, r.cost_usd, r.attempts
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`route()` calls Jev only. It never calls a provider. `complete()` tries the chosen model, then the fallback one tier up. 4xx validation errors do not trigger a fallback.
|
|
61
|
+
|
|
62
|
+
Messages are plain dicts with `role` and `content`, the same shape the OpenAI and Anthropic SDKs use. Tools are dicts with `name`, `description`, and `parameters`.
|
|
63
|
+
|
|
64
|
+
## CLI
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
tiershift check # which configured models have keys
|
|
68
|
+
tiershift route "your prompt" # decide only; Jev call, no model call
|
|
69
|
+
tiershift ask "your prompt" # decide, call the model, fall back on failure
|
|
70
|
+
tiershift report # tier mix, spend, saving vs always-flagship from the log
|
|
71
|
+
tiershift tune --candidate other.yaml # replay the log against another policy; no API calls
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Add `--json` for the full object and `--config path` for a custom policy.
|
|
75
|
+
|
|
76
|
+
## Configure
|
|
77
|
+
|
|
78
|
+
Copy `tiershift.yaml` from the [repository](https://github.com/iamvatsalpatel/tiershift/blob/main/tiershift.yaml) into your project and edit. Every threshold is a probability or score from Jev. The format, the available signals, and the provider quirks are documented in the main README.
|
|
79
|
+
|
|
80
|
+
## Status
|
|
81
|
+
|
|
82
|
+
v0.1.0. Sync API only. Async `route`/`complete` are planned. The test suite needs no network and includes every shared conformance case from `conformance/`.
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# tiershift (Python)
|
|
2
|
+
|
|
3
|
+
**Shift every LLM call to the cheapest model that can handle it.**
|
|
4
|
+
Routing decided by [TypeSafe Jev](https://docs.typesafe.ai) in about 300 ms for $0.00004 per call. No training data. Policy in plain YAML.
|
|
5
|
+
|
|
6
|
+
This is the Python package. It shares one `tiershift.yaml` format, one `prices.yaml`, and one decision-log format with the [npm package](https://github.com/iamvatsalpatel/tiershift), so `report` and `tune` read logs written by either.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
uv add tiershift # or: pip install tiershift
|
|
12
|
+
export TYPESAFE_API_KEY=... # get one at typesafe.ai
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Provider keys are optional. A tier skips any model whose key is missing. With only [Ollama](https://ollama.com) running, everything routes to the local model and the decision is flagged `degraded=True`.
|
|
16
|
+
|
|
17
|
+
## Use
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from tiershift import create_router
|
|
21
|
+
|
|
22
|
+
router = create_router() # reads ./tiershift.yaml, else the bundled default
|
|
23
|
+
|
|
24
|
+
d = router.route(messages, tools=tools, step="plan", retries=0)
|
|
25
|
+
d.model # "deepseek/deepseek-flash"
|
|
26
|
+
d.tier # "fast"
|
|
27
|
+
d.fallback # "openai/gpt-5.6-terra" use this if the call fails
|
|
28
|
+
d.signals # Signals(difficulty=0.31, stakes=0.12, needs_reasoning=0.08, ...)
|
|
29
|
+
d.reason # ['rule "difficulty < 0.5" → fast']
|
|
30
|
+
d.est_cost_usd # 0.00006
|
|
31
|
+
|
|
32
|
+
r = router.complete(messages, tools=tools, max_tokens=1024) # decide, call, fall back one tier up on failure
|
|
33
|
+
r.text, r.model, r.fell_back, r.cost_usd, r.attempts
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`route()` calls Jev only. It never calls a provider. `complete()` tries the chosen model, then the fallback one tier up. 4xx validation errors do not trigger a fallback.
|
|
37
|
+
|
|
38
|
+
Messages are plain dicts with `role` and `content`, the same shape the OpenAI and Anthropic SDKs use. Tools are dicts with `name`, `description`, and `parameters`.
|
|
39
|
+
|
|
40
|
+
## CLI
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
tiershift check # which configured models have keys
|
|
44
|
+
tiershift route "your prompt" # decide only; Jev call, no model call
|
|
45
|
+
tiershift ask "your prompt" # decide, call the model, fall back on failure
|
|
46
|
+
tiershift report # tier mix, spend, saving vs always-flagship from the log
|
|
47
|
+
tiershift tune --candidate other.yaml # replay the log against another policy; no API calls
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Add `--json` for the full object and `--config path` for a custom policy.
|
|
51
|
+
|
|
52
|
+
## Configure
|
|
53
|
+
|
|
54
|
+
Copy `tiershift.yaml` from the [repository](https://github.com/iamvatsalpatel/tiershift/blob/main/tiershift.yaml) into your project and edit. Every threshold is a probability or score from Jev. The format, the available signals, and the provider quirks are documented in the main README.
|
|
55
|
+
|
|
56
|
+
## Status
|
|
57
|
+
|
|
58
|
+
v0.1.0. Sync API only. Async `route`/`complete` are planned. The test suite needs no network and includes every shared conformance case from `conformance/`.
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
MIT
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tiershift"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Shift every LLM call to the cheapest model that can handle it. Routing decided by TypeSafe Jev in ~300 ms. No training data. Policy in plain YAML."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [{ name = "Vatsal Patel" }]
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
keywords = ["llm", "router", "model-routing", "agents", "typesafe", "jev", "cost-optimization"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.10",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Topic :: Software Development :: Libraries",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"typesafe-sdk>=0.6.0",
|
|
22
|
+
"pyyaml>=6.0",
|
|
23
|
+
"httpx>=0.27",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/iamvatsalpatel/tiershift"
|
|
28
|
+
Repository = "https://github.com/iamvatsalpatel/tiershift"
|
|
29
|
+
Changelog = "https://github.com/iamvatsalpatel/tiershift/blob/main/CHANGELOG.md"
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
tiershift = "tiershift.cli:main"
|
|
33
|
+
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.23"]
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["hatchling"]
|
|
39
|
+
build-backend = "hatchling.build"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["tiershift"]
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.sdist]
|
|
45
|
+
include = ["tiershift", "tests", "README.md", "pyproject.toml"]
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
testpaths = ["tests"]
|
|
49
|
+
markers = ["live: calls the real TypeSafe or Ollama API; runs only with RUN_LIVE=1"]
|
|
50
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
10
|
+
CONFORMANCE = ROOT / "conformance"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture(scope="session")
|
|
14
|
+
def conformance_fixture() -> dict:
|
|
15
|
+
"""The whole cases.json: top-level `policy` plus `cases`, each of which may name its own `policy`."""
|
|
16
|
+
return json.loads((CONFORMANCE / "cases.json").read_text())
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture(scope="session")
|
|
20
|
+
def load_policy():
|
|
21
|
+
cache: dict[str, dict] = {}
|
|
22
|
+
|
|
23
|
+
def _load(name: str) -> dict:
|
|
24
|
+
if name not in cache:
|
|
25
|
+
cache[name] = yaml.safe_load((CONFORMANCE / name).read_text())
|
|
26
|
+
return cache[name]
|
|
27
|
+
return _load
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.fixture(scope="session")
|
|
31
|
+
def conformance_policy(conformance_fixture, load_policy) -> dict:
|
|
32
|
+
return load_policy(conformance_fixture["policy"])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.fixture(scope="session")
|
|
36
|
+
def conformance_cases(conformance_fixture) -> list[dict]:
|
|
37
|
+
return conformance_fixture["cases"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.fixture(scope="session")
|
|
41
|
+
def conformance_log_entry() -> dict:
|
|
42
|
+
return json.loads((CONFORMANCE / "log-entry.json").read_text())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.fixture(scope="session")
|
|
46
|
+
def repo_root() -> Path:
|
|
47
|
+
return ROOT
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
|
|
7
|
+
from tiershift import DEFAULT_JEV_MODEL, available_tiers, load_config, load_prices, merge_model_meta, split_model, validate
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_bundled_default_loads_and_merges_prices():
|
|
11
|
+
cfg = load_config()
|
|
12
|
+
assert "tiers" in cfg and "flagship" in cfg["tiers"]
|
|
13
|
+
# a models: override that only sets params keeps the bundled price
|
|
14
|
+
flash = cfg["models"]["deepseek/deepseek-flash"]
|
|
15
|
+
assert flash.get("params") and "price" in flash
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_load_prices_has_entries():
|
|
19
|
+
prices = load_prices()
|
|
20
|
+
assert "anthropic/claude-fable-5-1" in prices
|
|
21
|
+
assert prices["anthropic/claude-fable-5-1"]["price"]["input"] > 0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_merge_model_meta():
|
|
25
|
+
bundled = {"a/x": {"price": {"input": 1, "output": 2}, "context": 1000}}
|
|
26
|
+
m = merge_model_meta(bundled, {"a/x": {"params": {"k": 1}}, "b/y": {"context": 8}})
|
|
27
|
+
assert m["a/x"] == {"price": {"input": 1, "output": 2}, "context": 1000, "params": {"k": 1}}
|
|
28
|
+
assert m["b/y"] == {"context": 8}
|
|
29
|
+
assert bundled["a/x"] == {"price": {"input": 1, "output": 2}, "context": 1000} # not mutated
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_split_model():
|
|
33
|
+
assert split_model("ollama/qwen2.5:7b") == ("ollama", "qwen2.5:7b")
|
|
34
|
+
assert split_model("openrouter/anthropic/claude-sonnet-5") == ("openrouter", "anthropic/claude-sonnet-5")
|
|
35
|
+
with pytest.raises(ValueError, match="provider/model"):
|
|
36
|
+
split_model("nope")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestValidate:
|
|
40
|
+
BASE = {"providers": {"p": {"type": "openai-compatible"}}, "tiers": {"t": ["p/m"]}, "rules": [{"default": "t"}]}
|
|
41
|
+
|
|
42
|
+
def test_minimal_ok(self):
|
|
43
|
+
validate(self.BASE)
|
|
44
|
+
|
|
45
|
+
def test_unknown_provider(self):
|
|
46
|
+
with pytest.raises(ValueError, match='unknown provider "zzz"'):
|
|
47
|
+
validate({**self.BASE, "tiers": {"t": ["zzz/m"]}})
|
|
48
|
+
|
|
49
|
+
def test_empty_sections(self):
|
|
50
|
+
with pytest.raises(ValueError, match="rules"):
|
|
51
|
+
validate({**self.BASE, "rules": []})
|
|
52
|
+
with pytest.raises(ValueError, match="tiers"):
|
|
53
|
+
validate({**self.BASE, "tiers": {}})
|
|
54
|
+
with pytest.raises(ValueError, match="providers"):
|
|
55
|
+
validate({**self.BASE, "providers": {}})
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_available_tiers():
|
|
59
|
+
cfg = {"providers": {"a": {"type": "anthropic", "api_key_env": "A_KEY"}, "o": {"type": "openai-compatible"}}, "tiers": {"fast": ["a/x", "o/y"], "top": ["a/z"]}, "rules": [{"default": "top"}]}
|
|
60
|
+
assert available_tiers(cfg, {}) == {"fast": ["o/y"], "top": ["a/z"]}
|
|
61
|
+
assert available_tiers(cfg, {"A_KEY": "k"}) == {"fast": ["a/x", "o/y"], "top": ["a/z"]}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_cli_common_flags_in_either_position(tmp_path, capsys):
|
|
65
|
+
from tiershift.cli import main
|
|
66
|
+
cfg = tmp_path / "only.yaml"
|
|
67
|
+
cfg.write_text("providers:\n o: { type: openai-compatible, base_url: http://localhost:1/v1 }\ntiers:\n local: [o/m]\nrules:\n - { default: local }\nlog: { enabled: false }\n")
|
|
68
|
+
assert main(["--config", str(cfg), "check"]) == 0
|
|
69
|
+
before = capsys.readouterr().out
|
|
70
|
+
assert main(["check", "--config", str(cfg)]) == 0
|
|
71
|
+
after = capsys.readouterr().out
|
|
72
|
+
assert before == after and "o/m" in before and "flagship" not in before
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _ok() -> dict:
|
|
76
|
+
return copy.deepcopy({
|
|
77
|
+
"providers": {"p": {"type": "openai-compatible", "base_url": "http://localhost:1/v1"}},
|
|
78
|
+
"tiers": {"local": ["p/l"], "fast": ["p/f"], "mid": ["p/m"], "flagship": ["p/x"]},
|
|
79
|
+
"rules": [{"when": "trivial_ack > 0.8", "tier": "local"}, {"when": "difficulty < 0.5", "tier": "fast"}, {"default": "flagship"}],
|
|
80
|
+
"overrides": [{"when": "stakes > 1.5", "at_least": "flagship"}, {"when": "mid_tier_ok > 0.8", "at_most": "mid"}, {"when": "retries >= 1", "up": 1}],
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class TestLoadTimeValidation:
|
|
85
|
+
def test_accepts_full_policy(self):
|
|
86
|
+
validate(_ok())
|
|
87
|
+
|
|
88
|
+
def test_misspelled_signal_names_location_and_suggests(self):
|
|
89
|
+
c = _ok(); c["overrides"][1] = {"when": "difficlty > 1", "at_most": "mid"}
|
|
90
|
+
with pytest.raises(ValueError) as ei:
|
|
91
|
+
validate(c)
|
|
92
|
+
assert str(ei.value) == 'config: overrides[1].when: unknown signal "difficlty" (did you mean "difficulty"?)'
|
|
93
|
+
|
|
94
|
+
def test_unparsable_condition_quoted(self):
|
|
95
|
+
c = _ok(); c["rules"][0]["when"] = "difficulty <> 0.5"
|
|
96
|
+
with pytest.raises(ValueError, match=r'config: rules\[0\]\.when: cannot parse "difficulty <> 0.5"'):
|
|
97
|
+
validate(c)
|
|
98
|
+
|
|
99
|
+
def test_unknown_tiers_everywhere_with_suggestion(self):
|
|
100
|
+
c = _ok(); c["rules"][1]["tier"] = "fsat"
|
|
101
|
+
with pytest.raises(ValueError) as ei:
|
|
102
|
+
validate(c)
|
|
103
|
+
assert str(ei.value).startswith('config: rules[1].tier: unknown tier "fsat" (did you mean "fast"?)')
|
|
104
|
+
c = _ok(); c["rules"][2] = {"default": "flagshp"}
|
|
105
|
+
with pytest.raises(ValueError, match=r'rules\[2\]\.default: unknown tier "flagshp"'):
|
|
106
|
+
validate(c)
|
|
107
|
+
c = _ok(); c["overrides"][0]["at_least"] = "top"
|
|
108
|
+
with pytest.raises(ValueError, match=r'overrides\[0\]\.at_least: unknown tier "top"'):
|
|
109
|
+
validate(c)
|
|
110
|
+
c = _ok(); c["overrides"][1]["at_most"] = "midd"
|
|
111
|
+
with pytest.raises(ValueError) as ei2:
|
|
112
|
+
validate(c)
|
|
113
|
+
assert str(ei2.value).startswith('config: overrides[1].at_most: unknown tier "midd" (did you mean "mid"?)')
|
|
114
|
+
|
|
115
|
+
def test_bad_up(self):
|
|
116
|
+
for bad in (0, -1, 1.5, True):
|
|
117
|
+
c = _ok(); c["overrides"][2] = {"when": "retries >= 1", "up": bad}
|
|
118
|
+
with pytest.raises(ValueError, match=r"overrides\[2\]\.up: must be a positive integer"):
|
|
119
|
+
validate(c)
|
|
120
|
+
|
|
121
|
+
def test_rule_shape(self):
|
|
122
|
+
c = _ok(); c["rules"][0] = {"when": "difficulty < 0.5"}
|
|
123
|
+
with pytest.raises(ValueError, match=r"rules\[0\]: a rule needs both `when` and `tier`, or a single `default`"):
|
|
124
|
+
validate(c)
|
|
125
|
+
c = _ok(); c["rules"] = [{"default": "flagship"}, {"when": "difficulty < 0.5", "tier": "fast"}]
|
|
126
|
+
with pytest.raises(ValueError, match=r"rules\[0\]: `default` must be the last rule"):
|
|
127
|
+
validate(c)
|
|
128
|
+
c = _ok(); c["rules"][2] = {"default": "flagship", "when": "difficulty < 0.5"}
|
|
129
|
+
with pytest.raises(ValueError, match=r"rules\[2\]: a `default` rule cannot also have `when` or `tier`"):
|
|
130
|
+
validate(c)
|
|
131
|
+
|
|
132
|
+
def test_override_without_action(self):
|
|
133
|
+
c = _ok(); c["overrides"][0] = {"when": "stakes > 1.5"}
|
|
134
|
+
with pytest.raises(ValueError, match=r"overrides\[0\]: needs one of `at_least`, `at_most`, or `up`"):
|
|
135
|
+
validate(c)
|
|
136
|
+
|
|
137
|
+
def test_bad_enums_and_min_output_tokens(self):
|
|
138
|
+
with pytest.raises(ValueError, match=r"budget\.prefer"):
|
|
139
|
+
validate({**_ok(), "budget": {"prefer": "random"}})
|
|
140
|
+
with pytest.raises(ValueError, match=r'fallback: must be "up" or "none"'):
|
|
141
|
+
validate({**_ok(), "fallback": "sideways"})
|
|
142
|
+
with pytest.raises(ValueError, match=r"defaults\.min_output_tokens"):
|
|
143
|
+
validate({**_ok(), "defaults": {"min_output_tokens": -5}})
|
|
144
|
+
|
|
145
|
+
def test_unknown_provider_suggests(self):
|
|
146
|
+
c = _ok(); c["tiers"]["fast"] = ["q/f"]
|
|
147
|
+
with pytest.raises(ValueError) as ei:
|
|
148
|
+
validate(c)
|
|
149
|
+
assert str(ei.value) == 'config: tiers.fast: model "q/f" uses unknown provider "q" (did you mean "p"?)'
|
|
150
|
+
|
|
151
|
+
def test_bad_provider_type(self):
|
|
152
|
+
c = _ok(); c["providers"]["p"]["type"] = "grpc"
|
|
153
|
+
with pytest.raises(ValueError, match=r"providers\.p: `type` must be"):
|
|
154
|
+
validate(c)
|
|
155
|
+
|
|
156
|
+
def test_bundled_default_and_examples_validate(self):
|
|
157
|
+
load_config()
|
|
158
|
+
load_config(str(__import__("pathlib").Path(__file__).resolve().parents[2] / "examples" / "policies" / "bolder.yaml"))
|
|
159
|
+
load_config(str(__import__("pathlib").Path(__file__).resolve().parents[2] / "conformance" / "policy-at-most.yaml"))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_pinned_jev_model_default():
|
|
163
|
+
from tiershift import Router
|
|
164
|
+
r = Router(config={**_ok(), "log": {"enabled": False}}, env={}, log=False)
|
|
165
|
+
assert r._jev_model == DEFAULT_JEV_MODEL == "jev-1.13.0"
|
|
166
|
+
r2 = Router(config={**_ok(), "log": {"enabled": False}, "jev": {"model": "jev-9.9.9"}}, env={}, log=False)
|
|
167
|
+
assert r2._jev_model == "jev-9.9.9"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""The Python side of the cross-language contract in conformance/."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from tiershift import CodeSignals, Signals, apply_policy, validate
|
|
10
|
+
from tiershift.log import LOG_FIELDS
|
|
11
|
+
|
|
12
|
+
pytestmark = pytest.mark.usefixtures("conformance_policy")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_fixture_policies_validate(conformance_fixture, conformance_cases, load_policy):
|
|
16
|
+
for name in {conformance_fixture["policy"], *(c["policy"] for c in conformance_cases if "policy" in c)}:
|
|
17
|
+
validate(load_policy(name))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_all_cases(conformance_fixture, conformance_cases, load_policy):
|
|
21
|
+
failures = []
|
|
22
|
+
for c in conformance_cases:
|
|
23
|
+
policy = load_policy(c.get("policy", conformance_fixture["policy"]))
|
|
24
|
+
r = apply_policy(policy, Signals.from_dict(c["signals"]), CodeSignals.from_dict(c["code_signals"]))
|
|
25
|
+
if r.tier != c["expected_tier"]:
|
|
26
|
+
failures.append(f'{c["name"]}: tier {r.tier!r} != {c["expected_tier"]!r}')
|
|
27
|
+
for frag in c["reason_contains"]:
|
|
28
|
+
if frag not in "\n".join(r.reason):
|
|
29
|
+
failures.append(f'{c["name"]}: reason {r.reason!r} lacks {frag!r}')
|
|
30
|
+
assert not failures, "\n".join(failures)
|
|
31
|
+
assert len(conformance_cases) >= 24
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_log_entry_shape(conformance_log_entry):
|
|
35
|
+
for k in LOG_FIELDS:
|
|
36
|
+
assert k in conformance_log_entry, f"missing {k}"
|
|
37
|
+
assert sorted(conformance_log_entry["signals"]) == sorted(Signals.__dataclass_fields__)
|
|
38
|
+
assert sorted(conformance_log_entry["code_signals"]) == sorted(CodeSignals.__dataclass_fields__)
|
|
39
|
+
# round-trips through our dataclasses without loss
|
|
40
|
+
assert Signals.from_dict(conformance_log_entry["signals"]).to_dict() == conformance_log_entry["signals"]
|
|
41
|
+
assert json.loads(json.dumps(conformance_log_entry)) == conformance_log_entry
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_bundled_yaml_copies_match_repo_root(repo_root):
|
|
45
|
+
"""The package ships copies of the root tiershift.yaml and prices.yaml. Drift fails here."""
|
|
46
|
+
from importlib import resources
|
|
47
|
+
for name in ("tiershift.yaml", "prices.yaml"):
|
|
48
|
+
bundled = resources.files("tiershift").joinpath("data", name).read_bytes()
|
|
49
|
+
root = (repo_root / name).read_bytes()
|
|
50
|
+
assert bundled == root, f"python/tiershift/data/{name} differs from the repo root {name}; copy it again"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Opt-in live checks. Jev only (fraction of a cent) and Ollama. Never OpenAI, DeepSeek, or Anthropic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
live = pytest.mark.skipif(not (os.environ.get("RUN_LIVE") == "1" and os.environ.get("TYPESAFE_API_KEY")), reason="set RUN_LIVE=1 and TYPESAFE_API_KEY")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@live
|
|
13
|
+
def test_route_live_jev_only():
|
|
14
|
+
from tiershift import create_router
|
|
15
|
+
r = create_router(env={"TYPESAFE_API_KEY": os.environ["TYPESAFE_API_KEY"]}, log=False)
|
|
16
|
+
d = r.route([{"role": "user", "content": "ok thanks"}])
|
|
17
|
+
assert d.signals.trivial_ack > 0.5 and d.jev_latency_ms > 0 and d.jev_input_tokens > 0
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from tiershift import CodeSignals, Decision, Signals, build_report, from_decision, log_entry, read_log, tune
|
|
9
|
+
|
|
10
|
+
SIG = dict(difficulty=1.0, difficulty_confidence=0.8, needs_reasoning=0.5, stakes=0.2, stakes_confidence=0.9, domain="code", domain_confidence=0.9, has_code=0.9, ambiguous=0.1, output_length=1.0, creative=0.0, safety_sensitive=0.0, trivial_ack=0.0, mid_tier_ok=0.3)
|
|
11
|
+
CODE = dict(est_input_tokens=1000, has_tools=False, tool_count=0, step=None, retries=0, turn_count=1)
|
|
12
|
+
CFG = {
|
|
13
|
+
"providers": {"p": {"type": "openai-compatible"}},
|
|
14
|
+
"tiers": {"local": ["p/l"], "fast": ["p/f"], "mid": ["p/m"], "flagship": ["p/x"]},
|
|
15
|
+
"rules": [{"when": "trivial_ack > 0.8", "tier": "local"}, {"when": "difficulty < 0.5", "tier": "fast"}, {"when": "difficulty < 1.3", "tier": "mid"}, {"default": "flagship"}],
|
|
16
|
+
"overrides": [{"when": "stakes > 1.5", "at_least": "flagship"}],
|
|
17
|
+
"models": {"p/l": {"price": {"input": 0, "output": 0}}, "p/f": {"price": {"input": 0.2, "output": 1}}, "p/m": {"price": {"input": 2, "output": 10}}, "p/x": {"price": {"input": 10, "output": 50}}},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def decision(**over) -> Decision:
|
|
22
|
+
base = dict(model="p/m", provider="p", tier="mid", tier_index=2, requested_tier="mid", degraded=False, fallback="p/x", signals=Signals(**SIG), code_signals=CodeSignals(**CODE),
|
|
23
|
+
confidence=0.8, reason=['rule "difficulty < 1.3" → mid'], est_cost_usd=0.004, est_output_tokens=400, jev_latency_ms=210, jev_input_tokens=950)
|
|
24
|
+
base.update(over)
|
|
25
|
+
return Decision(**base)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def entry(tier, model, difficulty, kind="route", **over):
|
|
29
|
+
e = from_decision(decision(model=model, tier=tier, requested_tier=tier, signals=Signals(**{**SIG, "difficulty": difficulty})), kind, **over)
|
|
30
|
+
e["ts"] = "2026-09-17T00:00:00.000Z"
|
|
31
|
+
return e
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_log_round_trip(tmp_path: Path):
|
|
35
|
+
path = tmp_path / "nested" / "d.jsonl"
|
|
36
|
+
log_entry(str(path), from_decision(decision(), "route", tag="agent-a"))
|
|
37
|
+
log_entry(str(path), from_decision(decision(), "complete", cost_usd=0.0031, input_tokens=100, output_tokens=50, model_latency_ms=900))
|
|
38
|
+
rows = read_log(str(path))
|
|
39
|
+
assert len(rows) == 2
|
|
40
|
+
assert rows[0]["kind"] == "route" and rows[0]["tag"] == "agent-a" and rows[0]["cost_usd"] is None and rows[0]["est_cost_usd"] == 0.004
|
|
41
|
+
assert rows[1]["kind"] == "complete" and rows[1]["output_tokens"] == 50
|
|
42
|
+
assert "prompt" not in json.dumps(rows[0])
|
|
43
|
+
assert read_log(str(tmp_path / "missing.jsonl")) == []
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_build_report():
|
|
47
|
+
entries = [entry("fast", "p/f", 0.2, est_cost_usd=0.0006), entry("fast", "p/f", 0.3, est_cost_usd=0.0006), entry("flagship", "p/x", 1.9, est_cost_usd=0.03, reason=['override "stakes > 1.5" → at_least flagship'])]
|
|
48
|
+
r = build_report(entries, CFG)
|
|
49
|
+
assert r.n == 3
|
|
50
|
+
assert next(t for t in r.tiers if t.tier == "fast").n == 2
|
|
51
|
+
assert next(t for t in r.tiers if t.tier == "flagship").share == pytest.approx(1 / 3)
|
|
52
|
+
assert r.total_cost == pytest.approx(0.0312)
|
|
53
|
+
assert r.flagship_est_cost == pytest.approx(0.09) # 1000 in * $10 + 400 out * $50 per 1M, times 3
|
|
54
|
+
assert r.saving_vs_flagship == pytest.approx(1 - 0.0312 / 0.09)
|
|
55
|
+
assert r.overrides[0] == {"reason": 'override "stakes > 1.5"', "n": 1}
|
|
56
|
+
assert r.jev["p50_ms"] == 210
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_report_prefers_actual_cost_and_handles_empty():
|
|
60
|
+
r = build_report([entry("mid", "p/m", 1.0, kind="complete", est_cost_usd=0.01, cost_usd=0.02)], CFG)
|
|
61
|
+
assert r.total_cost == pytest.approx(0.02)
|
|
62
|
+
empty = build_report([], CFG)
|
|
63
|
+
assert empty.n == 0 and empty.saving_vs_flagship is None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_report_estimate_check_per_tier():
|
|
67
|
+
r = build_report([entry("mid", "p/m", 1.0, kind="complete", est_cost_usd=0.01, cost_usd=0.02), entry("mid", "p/m", 1.0, est_cost_usd=0.01)], CFG)
|
|
68
|
+
mid = next(t for t in r.tiers if t.tier == "mid")
|
|
69
|
+
assert mid.estimate_check == {"n": 1, "est": 0.01, "actual": 0.02, "ratio": 0.5}
|
|
70
|
+
assert next(t for t in r.tiers if t.tier == "fast").estimate_check is None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_report_uses_supplied_available_tiers_for_baseline():
|
|
74
|
+
r = build_report([entry("mid", "p/m", 1.0)], CFG, tiers={**CFG["tiers"], "flagship": ["p/m"]})
|
|
75
|
+
assert r.flagship_model == "p/m"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_tune_moves_and_saving():
|
|
79
|
+
entries = [entry("mid", "p/m", 0.9), entry("mid", "p/m", 1.1), entry("flagship", "p/x", 1.5), entry("flagship", "p/x", 1.9)]
|
|
80
|
+
bolder = {**CFG, "rules": [{"when": "trivial_ack > 0.8", "tier": "local"}, {"when": "difficulty < 0.5", "tier": "fast"}, {"when": "difficulty < 1.6", "tier": "mid"}, {"default": "flagship"}]}
|
|
81
|
+
t = tune(entries, CFG, bolder)
|
|
82
|
+
assert t.baseline["tiers"] == {"local": 0, "fast": 0, "mid": 2, "flagship": 2}
|
|
83
|
+
assert t.candidate["tiers"] == {"local": 0, "fast": 0, "mid": 3, "flagship": 1}
|
|
84
|
+
assert (t.moved_down, t.moved_up, t.unchanged) == (1, 0, 3)
|
|
85
|
+
assert t.moves[0]["from"] == "flagship" and t.moves[0]["to"] == "mid" and t.moves[0]["difficulty"] == 1.5
|
|
86
|
+
assert t.saving is not None and t.saving > 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_tune_held_by_override():
|
|
90
|
+
e = entry("flagship", "p/x", 1.5)
|
|
91
|
+
e["signals"]["difficulty_confidence"] = 0.3
|
|
92
|
+
with_low = {**CFG, "overrides": [{"when": "difficulty_confidence < 0.5", "up": 1}]}
|
|
93
|
+
bolder = {**with_low, "rules": [{"when": "difficulty < 1.6", "tier": "mid"}, {"default": "flagship"}]}
|
|
94
|
+
t = tune([e], with_low, bolder)
|
|
95
|
+
assert t.unchanged == 1 and t.held_by_override == 1
|