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.
- llm_sunset-0.1.0/LICENSE +21 -0
- llm_sunset-0.1.0/PKG-INFO +124 -0
- llm_sunset-0.1.0/README.md +104 -0
- llm_sunset-0.1.0/pyproject.toml +38 -0
- llm_sunset-0.1.0/setup.cfg +4 -0
- llm_sunset-0.1.0/src/llm_sunset/__init__.py +3 -0
- llm_sunset-0.1.0/src/llm_sunset/__main__.py +5 -0
- llm_sunset-0.1.0/src/llm_sunset/cli.py +171 -0
- llm_sunset-0.1.0/src/llm_sunset/data.py +139 -0
- llm_sunset-0.1.0/src/llm_sunset/report.py +191 -0
- llm_sunset-0.1.0/src/llm_sunset/scanner.py +173 -0
- llm_sunset-0.1.0/src/llm_sunset/snapshot.json +4497 -0
- llm_sunset-0.1.0/src/llm_sunset.egg-info/PKG-INFO +124 -0
- llm_sunset-0.1.0/src/llm_sunset.egg-info/SOURCES.txt +16 -0
- llm_sunset-0.1.0/src/llm_sunset.egg-info/dependency_links.txt +1 -0
- llm_sunset-0.1.0/src/llm_sunset.egg-info/entry_points.txt +2 -0
- llm_sunset-0.1.0/src/llm_sunset.egg-info/top_level.txt +1 -0
- llm_sunset-0.1.0/tests/test_llm_sunset.py +152 -0
llm_sunset-0.1.0/LICENSE
ADDED
|
@@ -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,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
|