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.
@@ -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/
@@ -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