llm-sunset 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 llm-sunset contributors
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.
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: llm-sunset
3
+ Version: 0.1.0
4
+ Summary: Find AI model IDs in your code that are deprecated or about to be shut down (OpenAI, Anthropic, Gemini, Azure, Groq, Cohere, xAI).
5
+ Author: llm-sunset contributors
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Ashveil1/llm-sunset
8
+ Project-URL: Issues, https://github.com/Ashveil1/llm-sunset/issues
9
+ Keywords: llm,openai,anthropic,gemini,deprecation,linter,ci,ai,model-retirement
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Quality Assurance
16
+ Requires-Python: >=3.8
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Dynamic: license-file
20
+
21
+ # 🌅 llm-sunset
22
+
23
+ **Your app is about to break because an AI provider is shutting down the model it uses. `llm-sunset` tells you before it happens.**
24
+
25
+ OpenAI, Anthropic, Google, Groq, Cohere and xAI retire models constantly, often with only a few months' notice. The model ID sits hard-coded in some config file and nobody notices until production starts returning `404 model_not_found`.
26
+
27
+ `llm-sunset` scans your code, finds every AI model ID, and checks it against a daily-updated list of official deprecation notices.
28
+
29
+ ```console
30
+ $ llm-sunset
31
+ app/llm.py:12:23 error OpenAI model 'gpt-4o-2024-05-13' retires 2026-10-23 (16 days left); replace with: gpt-5.6-sol
32
+ app/summarize.py:8:11 error OpenAI model 'gpt-3.5-turbo' retires 2026-10-23 (16 days left); replace with: gpt-5.6-terra
33
+ config/models.yaml:3:8 error Anthropic model 'claude-2.0' RETIRED on 2025-07-21 (443 days ago); replace with: claude-opus-4-8
34
+ worker/.env:2:7 warning Anthropic model 'claude-sonnet-4-5-20250929' retires 2026-11-30 (54 days left)
35
+
36
+ 4 finding(s): 3 error(s), 1 warning(s)
37
+ ```
38
+
39
+ - **Zero dependencies.** Pure Python standard library, Python 3.8+.
40
+ - **Works offline.** Ships with a bundled snapshot; uses live data when it can reach it.
41
+ - **No API keys, and your code never leaves your machine.** It only downloads a public JSON file.
42
+ - **CI-ready.** Comes with a GitHub Action, a pre-commit hook, SARIF output for GitHub code scanning, JSON and Markdown.
43
+ - **Low noise.** It matches exact model IDs (so `gpt-4o-mini` is not reported as `gpt-4o`, and `gpt-4.1` is not reported as `gpt-4`) and skips prose docs, lockfiles and `node_modules`.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pipx install llm-sunset # or: pip install llm-sunset / uvx llm-sunset
49
+ ```
50
+
51
+ ## Usage
52
+
53
+ ```bash
54
+ llm-sunset # scan current directory
55
+ llm-sunset src/ config/ # scan specific paths
56
+ llm-sunset --provider openai # only OpenAI notices (repeatable)
57
+ llm-sunset --fail-within 30 # only fail on models retiring within 30 days
58
+ llm-sunset --format json # also: markdown, sarif, github
59
+ llm-sunset info gpt-4o-2024-05-13 # look up a model
60
+ llm-sunset upcoming --days 90 # every shutdown in the next 90 days
61
+ ```
62
+
63
+ **Exit code** is `1` if any model is already retired or retires within `--fail-within` days (default 90). Use `--no-fail` to only report.
64
+
65
+ **Ignoring things:** put `llm-sunset: ignore` in a comment on a line, or `llm-sunset: ignore-file` anywhere in a file. Use `--exclude 'tests/*'` for paths. Markdown/RST/TXT files are skipped unless you pass `--include-docs`.
66
+
67
+ **Azure, Vertex AI and Bedrock** publish their own retirement dates for models they resell, which often differ from the original provider's dates. To avoid false alarms these are off by default. Turn them on with `--provider azure` or `--provider all`.
68
+
69
+ ## GitHub Action
70
+
71
+ ```yaml
72
+ # .github/workflows/llm-sunset.yml
73
+ name: llm-sunset
74
+ on:
75
+ push:
76
+ pull_request:
77
+ schedule:
78
+ - cron: "0 8 * * 1" # also re-check weekly: deprecations are announced while your code sits still
79
+ jobs:
80
+ check:
81
+ runs-on: ubuntu-latest
82
+ steps:
83
+ - uses: actions/checkout@v4
84
+ - uses: Ashveil1/llm-sunset@v0
85
+ with:
86
+ fail-within: 60 # optional
87
+ # provider: "openai anthropic"
88
+ # exclude: "tests/*"
89
+ ```
90
+
91
+ Findings appear as inline annotations on the PR, along with a summary table on the run page.
92
+
93
+ ## pre-commit
94
+
95
+ ```yaml
96
+ repos:
97
+ - repo: https://github.com/Ashveil1/llm-sunset
98
+ rev: v0.1.0
99
+ hooks:
100
+ - id: llm-sunset
101
+ ```
102
+
103
+ ## GitHub code scanning (SARIF)
104
+
105
+ ```yaml
106
+ - run: pipx run llm-sunset --format sarif --no-fail > llm-sunset.sarif
107
+ - uses: github/codeql-action/upload-sarif@v3
108
+ with:
109
+ sarif_file: llm-sunset.sarif
110
+ ```
111
+
112
+ ## Where the data comes from
113
+
114
+ Deprecation data comes from [deprecations.info](https://deprecations.info) ([source](https://github.com/deprecations/deprecations-rss), MIT). It scrapes the official deprecation pages of OpenAI, Anthropic, Google Gemini and Vertex AI, AWS Bedrock, Azure AI Foundry, Cohere, Groq and xAI every day. `llm-sunset` caches it for 24 hours in `~/.cache/llm-sunset/` and falls back to a bundled snapshot that is refreshed weekly.
115
+
116
+ If you find a missing or wrong entry, please report it upstream at deprecations-rss. For false positives or negatives in matching, open an issue here.
117
+
118
+ ## Support the project
119
+
120
+ If `llm-sunset` saved you from a production outage, please consider [sponsoring](https://github.com/sponsors/Ashveil1) ❤️
121
+
122
+ ## License
123
+
124
+ MIT
@@ -0,0 +1,104 @@
1
+ # 🌅 llm-sunset
2
+
3
+ **Your app is about to break because an AI provider is shutting down the model it uses. `llm-sunset` tells you before it happens.**
4
+
5
+ OpenAI, Anthropic, Google, Groq, Cohere and xAI retire models constantly, often with only a few months' notice. The model ID sits hard-coded in some config file and nobody notices until production starts returning `404 model_not_found`.
6
+
7
+ `llm-sunset` scans your code, finds every AI model ID, and checks it against a daily-updated list of official deprecation notices.
8
+
9
+ ```console
10
+ $ llm-sunset
11
+ app/llm.py:12:23 error OpenAI model 'gpt-4o-2024-05-13' retires 2026-10-23 (16 days left); replace with: gpt-5.6-sol
12
+ app/summarize.py:8:11 error OpenAI model 'gpt-3.5-turbo' retires 2026-10-23 (16 days left); replace with: gpt-5.6-terra
13
+ config/models.yaml:3:8 error Anthropic model 'claude-2.0' RETIRED on 2025-07-21 (443 days ago); replace with: claude-opus-4-8
14
+ worker/.env:2:7 warning Anthropic model 'claude-sonnet-4-5-20250929' retires 2026-11-30 (54 days left)
15
+
16
+ 4 finding(s): 3 error(s), 1 warning(s)
17
+ ```
18
+
19
+ - **Zero dependencies.** Pure Python standard library, Python 3.8+.
20
+ - **Works offline.** Ships with a bundled snapshot; uses live data when it can reach it.
21
+ - **No API keys, and your code never leaves your machine.** It only downloads a public JSON file.
22
+ - **CI-ready.** Comes with a GitHub Action, a pre-commit hook, SARIF output for GitHub code scanning, JSON and Markdown.
23
+ - **Low noise.** It matches exact model IDs (so `gpt-4o-mini` is not reported as `gpt-4o`, and `gpt-4.1` is not reported as `gpt-4`) and skips prose docs, lockfiles and `node_modules`.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pipx install llm-sunset # or: pip install llm-sunset / uvx llm-sunset
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ```bash
34
+ llm-sunset # scan current directory
35
+ llm-sunset src/ config/ # scan specific paths
36
+ llm-sunset --provider openai # only OpenAI notices (repeatable)
37
+ llm-sunset --fail-within 30 # only fail on models retiring within 30 days
38
+ llm-sunset --format json # also: markdown, sarif, github
39
+ llm-sunset info gpt-4o-2024-05-13 # look up a model
40
+ llm-sunset upcoming --days 90 # every shutdown in the next 90 days
41
+ ```
42
+
43
+ **Exit code** is `1` if any model is already retired or retires within `--fail-within` days (default 90). Use `--no-fail` to only report.
44
+
45
+ **Ignoring things:** put `llm-sunset: ignore` in a comment on a line, or `llm-sunset: ignore-file` anywhere in a file. Use `--exclude 'tests/*'` for paths. Markdown/RST/TXT files are skipped unless you pass `--include-docs`.
46
+
47
+ **Azure, Vertex AI and Bedrock** publish their own retirement dates for models they resell, which often differ from the original provider's dates. To avoid false alarms these are off by default. Turn them on with `--provider azure` or `--provider all`.
48
+
49
+ ## GitHub Action
50
+
51
+ ```yaml
52
+ # .github/workflows/llm-sunset.yml
53
+ name: llm-sunset
54
+ on:
55
+ push:
56
+ pull_request:
57
+ schedule:
58
+ - cron: "0 8 * * 1" # also re-check weekly: deprecations are announced while your code sits still
59
+ jobs:
60
+ check:
61
+ runs-on: ubuntu-latest
62
+ steps:
63
+ - uses: actions/checkout@v4
64
+ - uses: Ashveil1/llm-sunset@v0
65
+ with:
66
+ fail-within: 60 # optional
67
+ # provider: "openai anthropic"
68
+ # exclude: "tests/*"
69
+ ```
70
+
71
+ Findings appear as inline annotations on the PR, along with a summary table on the run page.
72
+
73
+ ## pre-commit
74
+
75
+ ```yaml
76
+ repos:
77
+ - repo: https://github.com/Ashveil1/llm-sunset
78
+ rev: v0.1.0
79
+ hooks:
80
+ - id: llm-sunset
81
+ ```
82
+
83
+ ## GitHub code scanning (SARIF)
84
+
85
+ ```yaml
86
+ - run: pipx run llm-sunset --format sarif --no-fail > llm-sunset.sarif
87
+ - uses: github/codeql-action/upload-sarif@v3
88
+ with:
89
+ sarif_file: llm-sunset.sarif
90
+ ```
91
+
92
+ ## Where the data comes from
93
+
94
+ Deprecation data comes from [deprecations.info](https://deprecations.info) ([source](https://github.com/deprecations/deprecations-rss), MIT). It scrapes the official deprecation pages of OpenAI, Anthropic, Google Gemini and Vertex AI, AWS Bedrock, Azure AI Foundry, Cohere, Groq and xAI every day. `llm-sunset` caches it for 24 hours in `~/.cache/llm-sunset/` and falls back to a bundled snapshot that is refreshed weekly.
95
+
96
+ If you find a missing or wrong entry, please report it upstream at deprecations-rss. For false positives or negatives in matching, open an issue here.
97
+
98
+ ## Support the project
99
+
100
+ If `llm-sunset` saved you from a production outage, please consider [sponsoring](https://github.com/sponsors/Ashveil1) ❤️
101
+
102
+ ## License
103
+
104
+ MIT
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "llm-sunset"
7
+ dynamic = ["version"]
8
+ description = "Find AI model IDs in your code that are deprecated or about to be shut down (OpenAI, Anthropic, Gemini, Azure, Groq, Cohere, xAI)."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.8"
12
+ authors = [{ name = "llm-sunset contributors" }]
13
+ keywords = ["llm", "openai", "anthropic", "gemini", "deprecation", "linter", "ci", "ai", "model-retirement"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Software Development :: Quality Assurance",
21
+ ]
22
+ dependencies = []
23
+
24
+ [project.scripts]
25
+ llm-sunset = "llm_sunset.cli:main"
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/Ashveil1/llm-sunset"
29
+ Issues = "https://github.com/Ashveil1/llm-sunset/issues"
30
+
31
+ [tool.setuptools.dynamic]
32
+ version = { attr = "llm_sunset.__version__" }
33
+
34
+ [tool.setuptools.packages.find]
35
+ where = ["src"]
36
+
37
+ [tool.setuptools.package-data]
38
+ llm_sunset = ["snapshot.json"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """llm-sunset: find deprecated and soon-to-be-retired AI model IDs in your code."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,171 @@
1
+ """Command line interface for llm-sunset."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from datetime import date
9
+ from typing import List, Optional
10
+
11
+ from . import __version__, report
12
+ from .data import load
13
+ from .scanner import Matcher, scan
14
+
15
+ EPILOG = """examples:
16
+ llm-sunset scan the current directory
17
+ llm-sunset src/ config/ scan specific paths
18
+ llm-sunset --provider openai only check OpenAI deprecations
19
+ llm-sunset --format sarif > out.sarif
20
+ llm-sunset info gpt-4o-2024-05-13 look up a single model
21
+ llm-sunset upcoming --days 90 list every shutdown in the next 90 days
22
+
23
+ Ignore a line with a comment containing: llm-sunset: ignore
24
+ Ignore a whole file with: llm-sunset: ignore-file
25
+ """
26
+
27
+
28
+ def _today(value: Optional[str]) -> date:
29
+ return date.fromisoformat(value) if value else date.today()
30
+
31
+
32
+ def _common(p: argparse.ArgumentParser) -> None:
33
+ p.add_argument("--provider", action="append", metavar="NAME",
34
+ help="only consider this provider (repeatable; e.g. openai, anthropic, google, azure, groq). "
35
+ "Default: all direct providers; Azure/Vertex/Bedrock need an explicit "
36
+ "--provider (or --provider all)")
37
+ p.add_argument("--offline", action="store_true", help="don't fetch live data; use cache or bundled snapshot")
38
+ p.add_argument("--data-file", metavar="PATH", help="use a local deprecations JSON file")
39
+ p.add_argument("--today", metavar="YYYY-MM-DD", help=argparse.SUPPRESS)
40
+
41
+
42
+ def build_parser() -> argparse.ArgumentParser:
43
+ p = argparse.ArgumentParser(
44
+ prog="llm-sunset",
45
+ description="Find AI model IDs in your code that are deprecated or about to be shut down.",
46
+ epilog=EPILOG,
47
+ formatter_class=argparse.RawDescriptionHelpFormatter,
48
+ )
49
+ p.add_argument("--version", action="version", version=f"llm-sunset {__version__}")
50
+ sub = p.add_subparsers(dest="command")
51
+
52
+ s = sub.add_parser("scan", help="scan files (default command)")
53
+ s.add_argument("paths", nargs="*", default=["."])
54
+ s.add_argument("--format", choices=["text", "json", "markdown", "sarif", "github"], default="text")
55
+ s.add_argument("--fail-within", type=int, default=90, metavar="DAYS",
56
+ help="treat models retiring within DAYS as errors (default: 90)")
57
+ s.add_argument("--warn-within", type=int, default=365, metavar="DAYS",
58
+ help="hide models retiring more than DAYS from now (default: 365, -1 = show all)")
59
+ s.add_argument("--no-fail", action="store_true", help="always exit 0")
60
+ s.add_argument("--exclude", action="append", default=[], metavar="GLOB", help="exclude paths (repeatable)")
61
+ s.add_argument("--include-docs", action="store_true", help="also scan .md/.rst/.txt files")
62
+ _common(s)
63
+
64
+ i = sub.add_parser("info", help="show deprecation info for model IDs")
65
+ i.add_argument("models", nargs="+")
66
+ _common(i)
67
+
68
+ u = sub.add_parser("upcoming", help="list upcoming shutdowns")
69
+ u.add_argument("--days", type=int, default=180, help="look ahead this many days (default: 180)")
70
+ _common(u)
71
+ return p
72
+
73
+
74
+ def cmd_scan(a: argparse.Namespace) -> int:
75
+ today = _today(a.today)
76
+ deps, source = load(offline=a.offline, data_file=a.data_file)
77
+ findings = scan(a.paths, Matcher(deps, a.provider), exclude=a.exclude, include_docs=a.include_docs)
78
+ if a.warn_within >= 0:
79
+ findings = [
80
+ f for f in findings
81
+ if f.deprecation.days_left(today) is None or f.deprecation.days_left(today) <= a.warn_within
82
+ ]
83
+
84
+ if a.format == "json":
85
+ print(report.render_json(findings, today, a.fail_within, source))
86
+ elif a.format == "markdown":
87
+ print(report.render_markdown(findings, today, a.fail_within, source))
88
+ elif a.format == "sarif":
89
+ print(report.render_sarif(findings, today, a.fail_within))
90
+ elif a.format == "github":
91
+ out = report.render_github(findings, today, a.fail_within)
92
+ if out:
93
+ print(out)
94
+ print(report.render_text(findings, today, a.fail_within, source))
95
+ summary = os.environ.get("GITHUB_STEP_SUMMARY")
96
+ if summary:
97
+ with open(summary, "a", encoding="utf-8") as fh:
98
+ fh.write(report.render_markdown(findings, today, a.fail_within, source) + "\n")
99
+ else:
100
+ print(report.render_text(findings, today, a.fail_within, source))
101
+
102
+ if a.no_fail:
103
+ return 0
104
+ return 1 if any(report.severity(f, today, a.fail_within) == "error" for f in findings) else 0
105
+
106
+
107
+ def cmd_info(a: argparse.Namespace) -> int:
108
+ today = _today(a.today)
109
+ deps, source = load(offline=a.offline, data_file=a.data_file)
110
+ matcher = Matcher(deps, a.provider)
111
+ status = 0
112
+ for model in a.models:
113
+ hits = matcher.by_id.get(model, [])
114
+ if not hits:
115
+ print(f"{model}: no deprecation notice found")
116
+ continue
117
+ status = 1
118
+ for d in hits:
119
+ left = d.days_left(today)
120
+ if left is None:
121
+ when = "deprecated, no shutdown date announced"
122
+ elif left <= 0:
123
+ when = f"RETIRED on {d.shutdown_date}"
124
+ else:
125
+ when = f"retires {d.shutdown_date} ({left} days left)"
126
+ print(f"{model} [{d.provider}]: {when}")
127
+ if d.replacements:
128
+ print(f" replace with: {', '.join(d.replacements)}")
129
+ if d.url:
130
+ print(f" source: {d.url}")
131
+ print(f"(data: {source})", file=sys.stderr)
132
+ return status
133
+
134
+
135
+ def cmd_upcoming(a: argparse.Namespace) -> int:
136
+ today = _today(a.today)
137
+ deps, source = load(offline=a.offline, data_file=a.data_file)
138
+ matcher = Matcher(deps, a.provider)
139
+ rows = []
140
+ for items in matcher.by_id.values():
141
+ for d in items:
142
+ left = d.days_left(today)
143
+ if left is not None and 0 < left <= a.days:
144
+ rows.append((d.shutdown_date, d))
145
+ rows.sort(key=lambda r: (r[0], r[1].provider, r[1].model_id))
146
+ if not rows:
147
+ print(f"No shutdowns in the next {a.days} days.")
148
+ for when, d in rows:
149
+ repl = f" -> {', '.join(d.replacements)}" if d.replacements else ""
150
+ print(f"{when} {d.provider:<14} {d.model_id}{repl}")
151
+ print(f"(data: {source})", file=sys.stderr)
152
+ return 0
153
+
154
+
155
+ def main(argv: Optional[List[str]] = None) -> int:
156
+ argv = list(sys.argv[1:] if argv is None else argv)
157
+ known = {"scan", "info", "upcoming", "-h", "--help", "--version"}
158
+ if not argv or argv[0] not in known:
159
+ argv = ["scan"] + argv
160
+ a = build_parser().parse_args(argv)
161
+ handler = {"scan": cmd_scan, "info": cmd_info, "upcoming": cmd_upcoming}[a.command]
162
+ try:
163
+ return handler(a)
164
+ except BrokenPipeError:
165
+ return 0
166
+ except KeyboardInterrupt:
167
+ return 130
168
+
169
+
170
+ if __name__ == "__main__":
171
+ sys.exit(main())
@@ -0,0 +1,139 @@
1
+ """Load the AI model deprecation dataset.
2
+
3
+ Data comes from https://deprecations.info (MIT licensed, refreshed daily by
4
+ https://github.com/deprecations/deprecations-rss). We try, in order:
5
+
6
+ 1. a fresh local cache (< 24h old)
7
+ 2. the live feed
8
+ 3. a stale local cache
9
+ 4. the snapshot bundled with this package (works fully offline)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import time
17
+ import urllib.request
18
+ from dataclasses import dataclass, field
19
+ from datetime import date
20
+ from pathlib import Path
21
+ from typing import Dict, Iterable, List, Optional, Tuple
22
+
23
+ FEED_URLS = (
24
+ "https://deprecations.info/v1/deprecations.json",
25
+ "https://raw.githubusercontent.com/deprecations/deprecations-rss/main/data.json",
26
+ )
27
+ CACHE_TTL_SECONDS = 24 * 60 * 60
28
+ SNAPSHOT_PATH = Path(__file__).with_name("snapshot.json")
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Deprecation:
33
+ provider: str
34
+ model_id: str
35
+ shutdown_date: Optional[date]
36
+ deprecation_date: Optional[date]
37
+ replacements: Tuple[str, ...] = field(default_factory=tuple)
38
+ url: str = ""
39
+
40
+ def days_left(self, today: date) -> Optional[int]:
41
+ if self.shutdown_date is None:
42
+ return None
43
+ return (self.shutdown_date - today).days
44
+
45
+
46
+ def _parse_date(value: object) -> Optional[date]:
47
+ if not value or not isinstance(value, str):
48
+ return None
49
+ try:
50
+ return date.fromisoformat(value[:10])
51
+ except ValueError:
52
+ return None
53
+
54
+
55
+ def cache_path() -> Path:
56
+ base = os.environ.get("XDG_CACHE_HOME") or os.path.join(Path.home(), ".cache")
57
+ return Path(base) / "llm-sunset" / "deprecations.json"
58
+
59
+
60
+ def _download(timeout: float) -> Optional[list]:
61
+ for url in FEED_URLS:
62
+ try:
63
+ req = urllib.request.Request(url, headers={"User-Agent": "llm-sunset"})
64
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
65
+ data = json.loads(resp.read().decode("utf-8"))
66
+ if isinstance(data, list) and data:
67
+ return data
68
+ except Exception:
69
+ continue
70
+ return None
71
+
72
+
73
+ def _read_json(path: Path) -> Optional[list]:
74
+ try:
75
+ data = json.loads(path.read_text(encoding="utf-8"))
76
+ return data if isinstance(data, list) else None
77
+ except Exception:
78
+ return None
79
+
80
+
81
+ def load_raw(offline: bool = False, data_file: Optional[str] = None, timeout: float = 10.0) -> Tuple[list, str]:
82
+ """Return (records, source_description)."""
83
+ if data_file:
84
+ data = _read_json(Path(data_file))
85
+ if data is None:
86
+ raise SystemExit(f"llm-sunset: could not read data file {data_file}")
87
+ return data, data_file
88
+
89
+ cache = cache_path()
90
+ if not offline:
91
+ fresh = cache.exists() and time.time() - cache.stat().st_mtime < CACHE_TTL_SECONDS
92
+ if fresh:
93
+ data = _read_json(cache)
94
+ if data:
95
+ return data, "cache"
96
+ data = _download(timeout)
97
+ if data:
98
+ try:
99
+ cache.parent.mkdir(parents=True, exist_ok=True)
100
+ cache.write_text(json.dumps(data), encoding="utf-8")
101
+ except OSError:
102
+ pass
103
+ return data, "live"
104
+ data = _read_json(cache)
105
+ if data:
106
+ return data, "cache (stale)"
107
+
108
+ data = _read_json(SNAPSHOT_PATH)
109
+ if not data:
110
+ raise SystemExit("llm-sunset: bundled snapshot missing and feed unreachable")
111
+ return data, "bundled snapshot"
112
+
113
+
114
+ def normalize(records: Iterable[dict]) -> List[Deprecation]:
115
+ """Turn raw records into Deprecation objects, one per (provider, model_id)."""
116
+ best: Dict[Tuple[str, str], Tuple[str, Deprecation]] = {}
117
+ for r in records:
118
+ model_id = (r.get("model_id") or "").strip()
119
+ provider = (r.get("provider") or "Unknown").strip()
120
+ if not model_id:
121
+ continue
122
+ dep = Deprecation(
123
+ provider=provider,
124
+ model_id=model_id,
125
+ shutdown_date=_parse_date(r.get("shutdown_date")),
126
+ deprecation_date=_parse_date(r.get("deprecation_date")),
127
+ replacements=tuple(x for x in (r.get("replacement_models") or []) if isinstance(x, str)),
128
+ url=r.get("url") or "",
129
+ )
130
+ key = (provider, model_id)
131
+ seen = r.get("last_observed") or r.get("announcement_date") or ""
132
+ if key not in best or seen >= best[key][0]:
133
+ best[key] = (seen, dep)
134
+ return [d for _, d in best.values()]
135
+
136
+
137
+ def load(offline: bool = False, data_file: Optional[str] = None) -> Tuple[List[Deprecation], str]:
138
+ raw, source = load_raw(offline=offline, data_file=data_file)
139
+ return normalize(raw), source