modelrot 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.
- modelrot/__init__.py +5 -0
- modelrot/__main__.py +3 -0
- modelrot/catalog.py +75 -0
- modelrot/checks.py +170 -0
- modelrot/cli.py +79 -0
- modelrot/data/models.json +2110 -0
- modelrot/data/sources.json +60 -0
- modelrot/report.py +92 -0
- modelrot/scan.py +180 -0
- modelrot-0.1.0.dist-info/METADATA +165 -0
- modelrot-0.1.0.dist-info/RECORD +14 -0
- modelrot-0.1.0.dist-info/WHEEL +4 -0
- modelrot-0.1.0.dist-info/entry_points.txt +2 -0
- modelrot-0.1.0.dist-info/licenses/LICENSE +21 -0
modelrot/__init__.py
ADDED
modelrot/__main__.py
ADDED
modelrot/catalog.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""The snapshot of what each provider says about its own models.
|
|
2
|
+
|
|
3
|
+
The catalog is data, not opinion: every entry comes from a published
|
|
4
|
+
deprecation page, and every finding modelrot reports carries the URL it came
|
|
5
|
+
from. It is also *dated*. A model retired after ``captured_on`` is not in here,
|
|
6
|
+
and the report says so rather than implying the silence means "fine".
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from datetime import date
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Dict, Iterable, List, Optional
|
|
15
|
+
|
|
16
|
+
DATA = Path(__file__).parent / "data"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _load(name: str) -> dict:
|
|
20
|
+
return json.loads((DATA / name).read_text(encoding="utf-8"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Catalog:
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self._models: Dict[str, dict] = {m["id"]: m for m in _load("models.json")["models"]}
|
|
26
|
+
self.sources: dict = _load("sources.json")
|
|
27
|
+
self.captured_on: date = date.fromisoformat(self.sources["captured_on"])
|
|
28
|
+
|
|
29
|
+
def __len__(self) -> int:
|
|
30
|
+
return len(self._models)
|
|
31
|
+
|
|
32
|
+
def get(self, model_id: str) -> Optional[dict]:
|
|
33
|
+
return self._models.get(model_id)
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def state_of(entry: dict, today: Optional[date] = None) -> str:
|
|
37
|
+
"""What state this model is in, and how that was decided.
|
|
38
|
+
|
|
39
|
+
Anthropic publishes a state column, and a published state is taken as
|
|
40
|
+
given — they are the authority on their own models. OpenAI publishes
|
|
41
|
+
none, only a shutdown date, so the state is derived from that date at
|
|
42
|
+
read time. Deriving rather than freezing means a catalog built in March
|
|
43
|
+
is still correct in October, when the date has passed.
|
|
44
|
+
"""
|
|
45
|
+
published = entry.get("published_state")
|
|
46
|
+
if published:
|
|
47
|
+
return published
|
|
48
|
+
retirement = entry.get("retirement_date")
|
|
49
|
+
if not retirement:
|
|
50
|
+
return "deprecated"
|
|
51
|
+
return "retired" if date.fromisoformat(retirement) <= (today or date.today()) else "deprecated"
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def state_is_published(entry: dict) -> bool:
|
|
55
|
+
return bool(entry.get("published_state"))
|
|
56
|
+
|
|
57
|
+
def ids(self) -> Iterable[str]:
|
|
58
|
+
return self._models.keys()
|
|
59
|
+
|
|
60
|
+
def source_for(self, provider: str) -> dict:
|
|
61
|
+
for s in self.sources["sources"]:
|
|
62
|
+
if s["provider"] == provider:
|
|
63
|
+
return s
|
|
64
|
+
return {"url": "", "covers": ""}
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def parameters(self) -> List[dict]:
|
|
68
|
+
return self.sources.get("parameters", [])
|
|
69
|
+
|
|
70
|
+
def age_days(self, today: Optional[date] = None) -> int:
|
|
71
|
+
return ((today or date.today()) - self.captured_on).days
|
|
72
|
+
|
|
73
|
+
def sorted_ids_by_length(self) -> List[str]:
|
|
74
|
+
"""Longest first, so ``gpt-4-turbo-2024-04-09`` wins over ``gpt-4``."""
|
|
75
|
+
return sorted(self._models, key=len, reverse=True)
|
modelrot/checks.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Turning what was found into findings, each one carrying its source.
|
|
2
|
+
|
|
3
|
+
Severity here means consequence, not confidence:
|
|
4
|
+
|
|
5
|
+
* ``high`` this is already broken, or will break on a known date soon
|
|
6
|
+
* ``medium`` this has an announced end, far enough away to plan for
|
|
7
|
+
* ``low`` worth knowing, nothing is failing
|
|
8
|
+
* ``note`` inventory
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import date
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import List, Optional
|
|
17
|
+
|
|
18
|
+
from .catalog import Catalog
|
|
19
|
+
from .scan import CallSite, Hit, ScanResult
|
|
20
|
+
|
|
21
|
+
SOON_DAYS = 90
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Finding:
|
|
26
|
+
severity: str
|
|
27
|
+
title: str
|
|
28
|
+
where: str
|
|
29
|
+
detail: str
|
|
30
|
+
url: str
|
|
31
|
+
quote: str = ""
|
|
32
|
+
inferred: bool = False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _rel(path: Path, root: Path) -> str:
|
|
36
|
+
try:
|
|
37
|
+
return str(path.relative_to(root))
|
|
38
|
+
except ValueError:
|
|
39
|
+
return str(path)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _days_until(day: Optional[str], today: date) -> Optional[int]:
|
|
43
|
+
if not day:
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
return (date.fromisoformat(day) - today).days
|
|
47
|
+
except ValueError:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def check_models(result: ScanResult, catalog: Catalog, root: Path, today: date) -> List[Finding]:
|
|
52
|
+
findings: List[Finding] = []
|
|
53
|
+
grouped = {}
|
|
54
|
+
for hit in result.hits:
|
|
55
|
+
grouped.setdefault(hit.model, []).append(hit)
|
|
56
|
+
|
|
57
|
+
for model, hits in sorted(grouped.items()):
|
|
58
|
+
entry = catalog.get(model)
|
|
59
|
+
if entry is None:
|
|
60
|
+
continue
|
|
61
|
+
state = catalog.state_of(entry, today)
|
|
62
|
+
if state in ("active", "legacy"):
|
|
63
|
+
continue
|
|
64
|
+
# Per-row provenance: the URL and document hash this row was read from,
|
|
65
|
+
# not a provider-level guess at where it probably came from.
|
|
66
|
+
url = entry.get("source_url") or catalog.source_for(entry["provider"])["url"]
|
|
67
|
+
derived = "" if catalog.state_is_published(entry) else (
|
|
68
|
+
f' {entry["provider"]} publishes the shutdown date rather than a state; this is derived from it.')
|
|
69
|
+
where = ", ".join(f"{_rel(h.path, root)}:{h.line}" for h in hits[:6])
|
|
70
|
+
if len(hits) > 6:
|
|
71
|
+
where += f" (+{len(hits) - 6} more)"
|
|
72
|
+
|
|
73
|
+
replacement = entry.get("replacement")
|
|
74
|
+
swap = f" The provider recommends {replacement}." if replacement else ""
|
|
75
|
+
left = _days_until(entry.get("retirement_date"), today)
|
|
76
|
+
|
|
77
|
+
if state == "retired":
|
|
78
|
+
findings.append(Finding(
|
|
79
|
+
severity="high",
|
|
80
|
+
title=f'"{model}" was retired on {entry["retirement_date"]}',
|
|
81
|
+
where=where,
|
|
82
|
+
detail=f"Calls naming this model do not work any more.{swap}{derived}",
|
|
83
|
+
url=url,
|
|
84
|
+
quote=f'{entry["provider"]} lists {model} as retired.',
|
|
85
|
+
))
|
|
86
|
+
elif left is not None and left <= SOON_DAYS:
|
|
87
|
+
findings.append(Finding(
|
|
88
|
+
severity="high",
|
|
89
|
+
title=f'"{model}" is switched off in {left} days, on {entry["retirement_date"]}',
|
|
90
|
+
where=where,
|
|
91
|
+
detail=f"It still works today. On that date it stops, with no further notice.{swap}{derived}",
|
|
92
|
+
url=url,
|
|
93
|
+
quote=f'{entry["provider"]} lists {model} as deprecated, shutting down {entry["retirement_date"]}.',
|
|
94
|
+
))
|
|
95
|
+
else:
|
|
96
|
+
when = entry.get("retirement_date") or "a date yet to be announced"
|
|
97
|
+
findings.append(Finding(
|
|
98
|
+
severity="medium",
|
|
99
|
+
title=f'"{model}" is deprecated, ending {when}',
|
|
100
|
+
where=where,
|
|
101
|
+
detail=f"Nothing is failing yet. This is the window to move.{swap}{derived}",
|
|
102
|
+
url=url,
|
|
103
|
+
quote=f'{entry["provider"]} lists {model} as deprecated.',
|
|
104
|
+
))
|
|
105
|
+
return findings
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def check_parameters(result: ScanResult, catalog: Catalog, root: Path) -> List[Finding]:
|
|
109
|
+
"""Parameters passed to a model that does not accept them.
|
|
110
|
+
|
|
111
|
+
Both rules here are the providers' own, and both live in data rather than
|
|
112
|
+
in code: which models a rule applies to is a matcher name plus a value, so
|
|
113
|
+
adding a provider is an edit to sources.json.
|
|
114
|
+
"""
|
|
115
|
+
findings: List[Finding] = []
|
|
116
|
+
for rule in catalog.parameters:
|
|
117
|
+
names = set(rule["names"])
|
|
118
|
+
for call in result.calls:
|
|
119
|
+
offending = sorted(names & set(call.params))
|
|
120
|
+
if not offending or not _applies(rule["applies_to"], call.model):
|
|
121
|
+
continue
|
|
122
|
+
listed = ", ".join(offending)
|
|
123
|
+
findings.append(Finding(
|
|
124
|
+
severity="high",
|
|
125
|
+
title=f"{listed} passed to {call.model}, which rejects it",
|
|
126
|
+
where=f"{_rel(call.path, root)}:{min(call.params[n] for n in offending)}",
|
|
127
|
+
detail=f"{rule['consequence']} {rule['replacement']}",
|
|
128
|
+
url=rule["url"],
|
|
129
|
+
quote=rule["quote"],
|
|
130
|
+
))
|
|
131
|
+
return findings
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _applies(matcher: dict, model: str) -> bool:
|
|
135
|
+
kind = matcher.get("kind")
|
|
136
|
+
if kind == "exact":
|
|
137
|
+
return model in matcher["value"]
|
|
138
|
+
if kind == "claude_min_version":
|
|
139
|
+
return _is_affected_claude(model, tuple(matcher["value"]))
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _is_affected_claude(model: str, minimum: tuple = (4, 7)) -> bool:
|
|
144
|
+
"""Claude at or past a given version, plus Mythos. Conservative by design."""
|
|
145
|
+
if model.startswith("claude-mythos"):
|
|
146
|
+
return True
|
|
147
|
+
if not model.startswith("claude-"):
|
|
148
|
+
return False
|
|
149
|
+
parts = model.split("-")
|
|
150
|
+
for i, part in enumerate(parts):
|
|
151
|
+
if part.isdigit() and i + 1 < len(parts) and parts[i + 1].isdigit():
|
|
152
|
+
major, minor = int(part), int(parts[i + 1])
|
|
153
|
+
return (major, minor) >= minimum
|
|
154
|
+
if part.isdigit() and int(part) >= minimum[0] + 1:
|
|
155
|
+
return True
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def inventory(result: ScanResult, catalog: Catalog, root: Path) -> List[dict]:
|
|
160
|
+
seen = {}
|
|
161
|
+
for hit in result.hits:
|
|
162
|
+
entry = catalog.get(hit.model)
|
|
163
|
+
row = seen.setdefault(hit.model, {
|
|
164
|
+
"model": hit.model,
|
|
165
|
+
"state": catalog.state_of(entry) if entry else "unknown",
|
|
166
|
+
"provider": entry["provider"] if entry else "unknown",
|
|
167
|
+
"places": [],
|
|
168
|
+
})
|
|
169
|
+
row["places"].append(f"{_rel(hit.path, root)}:{hit.line}")
|
|
170
|
+
return sorted(seen.values(), key=lambda r: (r["state"] != "retired", r["model"]))
|
modelrot/cli.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""modelrot — the model names in your code that the provider already retired."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import date
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .catalog import Catalog
|
|
12
|
+
from .checks import check_models, check_parameters, inventory
|
|
13
|
+
from .report import print_catalog_note, print_coverage, print_findings, print_inventory, bold, dim, green
|
|
14
|
+
from .scan import scan
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
prog="modelrot",
|
|
22
|
+
description="Find the model names in your code that the provider has already retired, "
|
|
23
|
+
"and the parameters that now return a 400.",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument("path", nargs="?", default=".", help="directory to scan (default: the current one)")
|
|
26
|
+
parser.add_argument("--json", action="store_true", help="machine-readable output")
|
|
27
|
+
parser.add_argument("--inventory", action="store_true", help="also list every model found, including the healthy ones")
|
|
28
|
+
parser.add_argument("--prose", action="store_true", help="also search markdown, rst and txt files, where most mentions are discussion rather than use")
|
|
29
|
+
parser.add_argument("--no-sources", action="store_true", help="omit the quoted provider documentation")
|
|
30
|
+
parser.add_argument("--fail-on", choices=["high", "medium", "none"], default="high",
|
|
31
|
+
help="exit non-zero at this severity or worse (default: high)")
|
|
32
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
33
|
+
return parser
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main(argv=None) -> int:
|
|
37
|
+
args = build_parser().parse_args(argv)
|
|
38
|
+
root = Path(args.path).resolve()
|
|
39
|
+
if not root.is_dir():
|
|
40
|
+
print(f"not a directory: {root}", file=sys.stderr)
|
|
41
|
+
return 2
|
|
42
|
+
|
|
43
|
+
catalog = Catalog()
|
|
44
|
+
today = date.today()
|
|
45
|
+
result = scan(root, set(catalog.ids()), catalog.sorted_ids_by_length(), prose=args.prose)
|
|
46
|
+
|
|
47
|
+
findings = check_models(result, catalog, root, today) + check_parameters(result, catalog, root)
|
|
48
|
+
rows = inventory(result, catalog, root)
|
|
49
|
+
|
|
50
|
+
if args.json:
|
|
51
|
+
print(json.dumps({
|
|
52
|
+
"root": str(root),
|
|
53
|
+
"catalog": {"captured_on": str(catalog.captured_on), "models": len(catalog), "age_days": catalog.age_days(today)},
|
|
54
|
+
"findings": [f.__dict__ for f in findings],
|
|
55
|
+
"inventory": rows,
|
|
56
|
+
}, indent=2))
|
|
57
|
+
else:
|
|
58
|
+
print()
|
|
59
|
+
print_catalog_note(catalog, today)
|
|
60
|
+
if findings:
|
|
61
|
+
print_findings(findings, show_sources=not args.no_sources)
|
|
62
|
+
else:
|
|
63
|
+
print(f"{green('No findings.')} {dim('That is not the same as being up to date — here is what was looked at.')}\n")
|
|
64
|
+
if args.inventory or not findings:
|
|
65
|
+
print_inventory(rows)
|
|
66
|
+
print_coverage()
|
|
67
|
+
counts = {}
|
|
68
|
+
for f in findings:
|
|
69
|
+
counts[f.severity] = counts.get(f.severity, 0) + 1
|
|
70
|
+
summary = ", ".join(f"{counts[s]} {s}" for s in ("high", "medium", "low", "note") if s in counts)
|
|
71
|
+
if summary:
|
|
72
|
+
print(f"{bold(summary)}\n")
|
|
73
|
+
|
|
74
|
+
threshold = {"high": ["high"], "medium": ["high", "medium"], "none": []}[args.fail_on]
|
|
75
|
+
return 1 if any(f.severity in threshold for f in findings) else 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
raise SystemExit(main())
|