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 ADDED
@@ -0,0 +1,5 @@
1
+ """modelrot — a static audit of the LLM surface of a codebase."""
2
+
3
+ from .cli import __version__
4
+
5
+ __all__ = ["__version__"]
modelrot/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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())