model-sunset 0.0.0-stage → 0.1.0
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.
- package/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +124 -2
- package/data/models.json +1376 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +125 -0
- package/dist/data.d.ts +54 -0
- package/dist/data.js +97 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/report.d.ts +16 -0
- package/dist/report.js +90 -0
- package/dist/scan.d.ts +50 -0
- package/dist/scan.js +197 -0
- package/package.json +67 -4
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { loadData } from "./data.js";
|
|
5
|
+
import { format } from "./report.js";
|
|
6
|
+
import { scanPaths, todayString } from "./scan.js";
|
|
7
|
+
const HELP = `model-sunset - find hardcoded model IDs that are retired or about to be
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
model-sunset [paths...] [options]
|
|
11
|
+
|
|
12
|
+
Scans files and directories (default ".") for model IDs from Anthropic, OpenAI
|
|
13
|
+
and Google that have a shutdown date. Inside a git repository only tracked and
|
|
14
|
+
unignored files are read.
|
|
15
|
+
|
|
16
|
+
Exit code 0 when nothing reaches --fail-on, 1 when something does, 2 on bad usage.
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
-f, --format <fmt> text (default), json, github (annotations) or markdown.
|
|
20
|
+
--fail-on <level> retired, deprecated (default) or none.
|
|
21
|
+
-w, --within <days> Only report deprecated models that shut down within this
|
|
22
|
+
many days. Retired models are always reported.
|
|
23
|
+
-i, --ignore <glob> Skip matching paths. Repeatable, e.g. -i "docs/**".
|
|
24
|
+
--offline Use the model list shipped with this version. By default
|
|
25
|
+
the latest list is downloaded from GitHub (cached 24h).
|
|
26
|
+
--data <file|url> Use your own models.json instead.
|
|
27
|
+
--today <date> Compare against this date (YYYY-MM-DD) instead of today.
|
|
28
|
+
--no-color Don't color the output.
|
|
29
|
+
-h, --help Show this help.
|
|
30
|
+
-v, --version Show the version number.
|
|
31
|
+
|
|
32
|
+
Add "model-sunset-ignore" in a comment on a line to skip it.
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
model-sunset .
|
|
36
|
+
model-sunset src --within 90
|
|
37
|
+
model-sunset --format json > sunset.json
|
|
38
|
+
`;
|
|
39
|
+
const FORMATS = new Set(["text", "json", "github", "markdown"]);
|
|
40
|
+
const FAIL_ON = new Set(["retired", "deprecated", "none"]);
|
|
41
|
+
class UsageError extends Error {
|
|
42
|
+
}
|
|
43
|
+
function version() {
|
|
44
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
45
|
+
return pkg.version;
|
|
46
|
+
}
|
|
47
|
+
async function main(argv) {
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = parseArgs({
|
|
51
|
+
args: argv,
|
|
52
|
+
allowPositionals: true,
|
|
53
|
+
options: {
|
|
54
|
+
format: { type: "string", short: "f", default: "text" },
|
|
55
|
+
"fail-on": { type: "string", default: "deprecated" },
|
|
56
|
+
within: { type: "string", short: "w" },
|
|
57
|
+
ignore: { type: "string", short: "i", multiple: true, default: [] },
|
|
58
|
+
offline: { type: "boolean", default: false },
|
|
59
|
+
data: { type: "string" },
|
|
60
|
+
today: { type: "string" },
|
|
61
|
+
"no-color": { type: "boolean", default: false },
|
|
62
|
+
help: { type: "boolean", short: "h", default: false },
|
|
63
|
+
version: { type: "boolean", short: "v", default: false },
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
throw new UsageError(err.message);
|
|
69
|
+
}
|
|
70
|
+
const { values, positionals } = parsed;
|
|
71
|
+
if (values.help) {
|
|
72
|
+
process.stdout.write(HELP);
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
if (values.version) {
|
|
76
|
+
process.stdout.write(`${version()}\n`);
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
const fmt = values.format;
|
|
80
|
+
if (!FORMATS.has(fmt))
|
|
81
|
+
throw new UsageError(`--format must be text, json, github or markdown, not "${fmt}"`);
|
|
82
|
+
const failOn = values["fail-on"];
|
|
83
|
+
if (!FAIL_ON.has(failOn))
|
|
84
|
+
throw new UsageError(`--fail-on must be retired, deprecated or none, not "${failOn}"`);
|
|
85
|
+
let within;
|
|
86
|
+
if (values.within !== undefined) {
|
|
87
|
+
within = Number(values.within);
|
|
88
|
+
if (!Number.isInteger(within) || within < 0)
|
|
89
|
+
throw new UsageError(`--within must be a whole number of days`);
|
|
90
|
+
}
|
|
91
|
+
const today = values.today ?? process.env.MODEL_SUNSET_TODAY ?? todayString();
|
|
92
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(today) || Number.isNaN(Date.parse(today))) {
|
|
93
|
+
throw new UsageError(`--today must be a date like 2026-12-01`);
|
|
94
|
+
}
|
|
95
|
+
const loaded = await loadData({ offline: values.offline, source: values.data });
|
|
96
|
+
if (loaded.warning)
|
|
97
|
+
process.stderr.write(`model-sunset: ${loaded.warning}\n`);
|
|
98
|
+
const paths = positionals.length ? positionals : ["."];
|
|
99
|
+
let findings;
|
|
100
|
+
try {
|
|
101
|
+
findings = await scanPaths(paths, loaded.data.models, { today, ignore: values.ignore });
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
const e = err;
|
|
105
|
+
if (e.code === "ENOENT")
|
|
106
|
+
throw new UsageError(`no such file or directory: ${e.path ?? paths.join(", ")}`);
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
if (within !== undefined) {
|
|
110
|
+
findings = findings.filter((f) => f.status === "retired" || (f.daysLeft !== undefined && f.daysLeft <= within));
|
|
111
|
+
}
|
|
112
|
+
const color = !values["no-color"] && !process.env.NO_COLOR && process.stdout.isTTY === true;
|
|
113
|
+
process.stdout.write(`${format(findings, fmt, { color, updated: loaded.data.updated })}\n`);
|
|
114
|
+
if (failOn === "none")
|
|
115
|
+
return 0;
|
|
116
|
+
return findings.some((f) => failOn === "deprecated" || f.status === "retired") ? 1 : 0;
|
|
117
|
+
}
|
|
118
|
+
main(process.argv.slice(2)).then((code) => {
|
|
119
|
+
process.exitCode = code;
|
|
120
|
+
}, (err) => {
|
|
121
|
+
process.stderr.write(`model-sunset: ${err.message}\n`);
|
|
122
|
+
if (err instanceof UsageError)
|
|
123
|
+
process.stderr.write("Run with --help for usage.\n");
|
|
124
|
+
process.exitCode = 2;
|
|
125
|
+
});
|
package/dist/data.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export interface ModelEntry {
|
|
2
|
+
/** The model ID as it appears in API calls, e.g. `"claude-3-opus-20240229"`. */
|
|
3
|
+
id: string;
|
|
4
|
+
provider: string;
|
|
5
|
+
/** Date the provider announced the deprecation (YYYY-MM-DD), when known. */
|
|
6
|
+
deprecated?: string;
|
|
7
|
+
/** Date requests start failing (YYYY-MM-DD), or `null` when not announced yet. */
|
|
8
|
+
shutdown: string | null;
|
|
9
|
+
/** Suggested replacement model ID. */
|
|
10
|
+
replacement?: string;
|
|
11
|
+
note?: string;
|
|
12
|
+
/** Other spellings of the same model, e.g. a cloud-platform ID. */
|
|
13
|
+
aliases?: string[];
|
|
14
|
+
/**
|
|
15
|
+
* Only match when the ID is quoted or assigned to a `model` key. Defaults to `true`
|
|
16
|
+
* for IDs of 10 characters or fewer (`o1`, `gpt-4`), which are too short to match anywhere.
|
|
17
|
+
*/
|
|
18
|
+
strict?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface ModelData {
|
|
21
|
+
version: number;
|
|
22
|
+
/** Date the list was last checked against the providers' pages (YYYY-MM-DD). */
|
|
23
|
+
updated: string;
|
|
24
|
+
sources?: Record<string, string>;
|
|
25
|
+
models: ModelEntry[];
|
|
26
|
+
}
|
|
27
|
+
export declare const DATA_URL = "https://raw.githubusercontent.com/acus9345/model-sunset/main/data/models.json";
|
|
28
|
+
export declare function loadBundledData(): Promise<ModelData>;
|
|
29
|
+
/** Parses and validates a models.json document. Throws with a readable message when it's malformed. */
|
|
30
|
+
export declare function parseData(json: string, label?: string): ModelData;
|
|
31
|
+
export interface LoadOptions {
|
|
32
|
+
/** Use only the data shipped with the package. */
|
|
33
|
+
offline?: boolean;
|
|
34
|
+
/** A local file path or URL to load instead of the default list. */
|
|
35
|
+
source?: string;
|
|
36
|
+
/** Directory for the cached download. */
|
|
37
|
+
cacheDir?: string;
|
|
38
|
+
fetch?: typeof globalThis.fetch;
|
|
39
|
+
/** Milliseconds to wait for the download. */
|
|
40
|
+
timeout?: number;
|
|
41
|
+
}
|
|
42
|
+
export interface LoadResult {
|
|
43
|
+
data: ModelData;
|
|
44
|
+
/** Where the data came from: `"bundled"`, `"cache"`, `"remote"` or the `source` you passed. */
|
|
45
|
+
from: string;
|
|
46
|
+
/** Set when the download failed and the bundled or cached list was used instead. */
|
|
47
|
+
warning?: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Loads the deprecation list. By default it downloads the latest list from GitHub (cached for
|
|
51
|
+
* 24 hours), so new shutdown dates reach you without a new release, and falls back to the
|
|
52
|
+
* bundled copy when offline. Whichever list has the newer `updated` date wins.
|
|
53
|
+
*/
|
|
54
|
+
export declare function loadData(options?: LoadOptions): Promise<LoadResult>;
|
package/dist/data.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
export const DATA_URL = "https://raw.githubusercontent.com/acus9345/model-sunset/main/data/models.json";
|
|
5
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
6
|
+
const BUNDLED_URL = new URL("../data/models.json", import.meta.url);
|
|
7
|
+
export async function loadBundledData() {
|
|
8
|
+
return parseData(await readFile(BUNDLED_URL, "utf8"), "bundled data");
|
|
9
|
+
}
|
|
10
|
+
/** Parses and validates a models.json document. Throws with a readable message when it's malformed. */
|
|
11
|
+
export function parseData(json, label = "data") {
|
|
12
|
+
let raw;
|
|
13
|
+
try {
|
|
14
|
+
raw = JSON.parse(json);
|
|
15
|
+
}
|
|
16
|
+
catch (err) {
|
|
17
|
+
throw new Error(`${label} is not valid JSON: ${err.message}`);
|
|
18
|
+
}
|
|
19
|
+
const data = raw;
|
|
20
|
+
if (!data || typeof data !== "object" || !Array.isArray(data.models) || typeof data.updated !== "string") {
|
|
21
|
+
throw new Error(`${label} is missing "updated" or "models"`);
|
|
22
|
+
}
|
|
23
|
+
for (const m of data.models) {
|
|
24
|
+
if (!m || typeof m.id !== "string" || !m.id || typeof m.provider !== "string") {
|
|
25
|
+
throw new Error(`${label} has a model without "id" or "provider"`);
|
|
26
|
+
}
|
|
27
|
+
if (m.shutdown !== null && !isDate(m.shutdown)) {
|
|
28
|
+
throw new Error(`${label}: ${m.id} has an invalid shutdown date ${JSON.stringify(m.shutdown)}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return data;
|
|
32
|
+
}
|
|
33
|
+
function isDate(value) {
|
|
34
|
+
return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(value));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Loads the deprecation list. By default it downloads the latest list from GitHub (cached for
|
|
38
|
+
* 24 hours), so new shutdown dates reach you without a new release, and falls back to the
|
|
39
|
+
* bundled copy when offline. Whichever list has the newer `updated` date wins.
|
|
40
|
+
*/
|
|
41
|
+
export async function loadData(options = {}) {
|
|
42
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
43
|
+
if (options.source) {
|
|
44
|
+
const text = /^https?:\/\//.test(options.source)
|
|
45
|
+
? await download(options.source, doFetch, options.timeout ?? 10_000)
|
|
46
|
+
: await readFile(options.source, "utf8");
|
|
47
|
+
return { data: parseData(text, options.source), from: options.source };
|
|
48
|
+
}
|
|
49
|
+
const bundled = await loadBundledData();
|
|
50
|
+
if (options.offline)
|
|
51
|
+
return { data: bundled, from: "bundled" };
|
|
52
|
+
const cacheFile = join(options.cacheDir ?? defaultCacheDir(), "models.json");
|
|
53
|
+
const cached = await readCache(cacheFile);
|
|
54
|
+
if (cached && Date.now() - cached.mtime < CACHE_TTL_MS) {
|
|
55
|
+
return newer(cached.data, bundled) === cached.data ? { data: cached.data, from: "cache" } : { data: bundled, from: "bundled" };
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const text = await download(DATA_URL, doFetch, options.timeout ?? 3000);
|
|
59
|
+
const remote = parseData(text, DATA_URL);
|
|
60
|
+
await mkdir(join(cacheFile, ".."), { recursive: true })
|
|
61
|
+
.then(() => writeFile(cacheFile, text))
|
|
62
|
+
.catch(() => { });
|
|
63
|
+
return newer(remote, bundled) === remote ? { data: remote, from: "remote" } : { data: bundled, from: "bundled" };
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
const fallback = cached && newer(cached.data, bundled) === cached.data ? cached : undefined;
|
|
67
|
+
return {
|
|
68
|
+
data: fallback?.data ?? bundled,
|
|
69
|
+
from: fallback ? "cache" : "bundled",
|
|
70
|
+
warning: `could not download the latest model list (${err.message}); using the ${fallback ? "cached" : "bundled"} copy from ${(fallback?.data ?? bundled).updated}`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function newer(a, b) {
|
|
75
|
+
return a.updated >= b.updated ? a : b;
|
|
76
|
+
}
|
|
77
|
+
async function download(url, doFetch, timeout) {
|
|
78
|
+
const res = await doFetch(url, { signal: AbortSignal.timeout(timeout) });
|
|
79
|
+
if (!res.ok)
|
|
80
|
+
throw new Error(`HTTP ${res.status}`);
|
|
81
|
+
return res.text();
|
|
82
|
+
}
|
|
83
|
+
async function readCache(file) {
|
|
84
|
+
try {
|
|
85
|
+
const [text, info] = await Promise.all([readFile(file, "utf8"), stat(file)]);
|
|
86
|
+
return { data: parseData(text, file), mtime: info.mtimeMs };
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function defaultCacheDir() {
|
|
93
|
+
const base = process.env.XDG_CACHE_HOME ||
|
|
94
|
+
(process.platform === "win32" ? process.env.LOCALAPPDATA : undefined) ||
|
|
95
|
+
join(homedir(), ".cache");
|
|
96
|
+
return join(base || homedir(), "model-sunset");
|
|
97
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { DATA_URL, loadBundledData, loadData, parseData } from "./data.js";
|
|
2
|
+
export type { LoadOptions, LoadResult, ModelData, ModelEntry } from "./data.js";
|
|
3
|
+
export { createMatcher, daysBetween, globToRegExp, listFiles, scanPaths, scanText, statusOf, todayString } from "./scan.js";
|
|
4
|
+
export type { Finding, ListOptions, Matcher, ScanOptions, Status } from "./scan.js";
|
|
5
|
+
export { describe, format, formatGithub, formatJson, formatMarkdown, formatText, summary } from "./report.js";
|
|
6
|
+
export type { Format, ReportOptions } from "./report.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { DATA_URL, loadBundledData, loadData, parseData } from "./data.js";
|
|
2
|
+
export { createMatcher, daysBetween, globToRegExp, listFiles, scanPaths, scanText, statusOf, todayString } from "./scan.js";
|
|
3
|
+
export { describe, format, formatGithub, formatJson, formatMarkdown, formatText, summary } from "./report.js";
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Finding } from "./scan.js";
|
|
2
|
+
export type Format = "text" | "json" | "github" | "markdown";
|
|
3
|
+
export interface ReportOptions {
|
|
4
|
+
color?: boolean;
|
|
5
|
+
/** Date of the model list, shown in the summary. */
|
|
6
|
+
updated?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function describe(f: Finding): string;
|
|
9
|
+
export declare function summary(findings: Finding[], updated?: string): string;
|
|
10
|
+
export declare function formatText(findings: Finding[], options?: ReportOptions): string;
|
|
11
|
+
export declare function formatJson(findings: Finding[], options?: ReportOptions): string;
|
|
12
|
+
/** GitHub Actions workflow commands, which show up as annotations on the files. */
|
|
13
|
+
export declare function formatGithub(findings: Finding[], options?: ReportOptions): string;
|
|
14
|
+
/** A Markdown table, used for the issue the GitHub Action opens. */
|
|
15
|
+
export declare function formatMarkdown(findings: Finding[], options?: ReportOptions): string;
|
|
16
|
+
export declare function format(findings: Finding[], fmt: Format, options?: ReportOptions): string;
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const paint = (on, code, s) => (on ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
2
|
+
export function describe(f) {
|
|
3
|
+
if (f.status === "retired")
|
|
4
|
+
return "RETIRED";
|
|
5
|
+
if (f.daysLeft === undefined)
|
|
6
|
+
return "DEPRECATED (shutdown date not announced)";
|
|
7
|
+
return `sunset ${f.model.shutdown} (in ${f.daysLeft} day${f.daysLeft === 1 ? "" : "s"})`;
|
|
8
|
+
}
|
|
9
|
+
function suggestion(f) {
|
|
10
|
+
return f.model.replacement ? `suggested: ${f.model.replacement}` : "";
|
|
11
|
+
}
|
|
12
|
+
export function summary(findings, updated) {
|
|
13
|
+
const retired = findings.filter((f) => f.status === "retired").length;
|
|
14
|
+
const deprecated = findings.length - retired;
|
|
15
|
+
const list = updated ? ` (model list from ${updated})` : "";
|
|
16
|
+
if (!findings.length)
|
|
17
|
+
return `No retired or deprecated model IDs found${list}.`;
|
|
18
|
+
return `${findings.length} hardcoded model ID${findings.length === 1 ? "" : "s"}: ${retired} retired, ${deprecated} deprecated${list}.`;
|
|
19
|
+
}
|
|
20
|
+
export function formatText(findings, options = {}) {
|
|
21
|
+
const color = options.color ?? false;
|
|
22
|
+
const rows = findings.map((f) => [`${f.file}:${f.line}`, f.match, describe(f), suggestion(f)]);
|
|
23
|
+
const w0 = Math.max(0, ...rows.map((r) => r[0].length));
|
|
24
|
+
const w1 = Math.max(0, ...rows.map((r) => r[1].length));
|
|
25
|
+
const w2 = Math.max(0, ...rows.map((r) => r[2].length));
|
|
26
|
+
const lines = rows.map(([loc, id, status, next], i) => {
|
|
27
|
+
const f = findings[i];
|
|
28
|
+
const statusCell = paint(color, f.status === "retired" ? "31;1" : "33", status.padEnd(w2));
|
|
29
|
+
const arrow = next ? ` → ${next}` : "";
|
|
30
|
+
return `${paint(color, "2", loc.padEnd(w0))} ${id.padEnd(w1)} ${statusCell}${arrow}`.trimEnd();
|
|
31
|
+
});
|
|
32
|
+
if (lines.length)
|
|
33
|
+
lines.push("");
|
|
34
|
+
lines.push(summary(findings, options.updated));
|
|
35
|
+
return lines.join("\n");
|
|
36
|
+
}
|
|
37
|
+
export function formatJson(findings, options = {}) {
|
|
38
|
+
return JSON.stringify({
|
|
39
|
+
updated: options.updated,
|
|
40
|
+
findings: findings.map((f) => ({
|
|
41
|
+
file: f.file,
|
|
42
|
+
line: f.line,
|
|
43
|
+
column: f.column,
|
|
44
|
+
match: f.match,
|
|
45
|
+
id: f.model.id,
|
|
46
|
+
provider: f.model.provider,
|
|
47
|
+
status: f.status,
|
|
48
|
+
shutdown: f.model.shutdown,
|
|
49
|
+
daysLeft: f.daysLeft ?? null,
|
|
50
|
+
replacement: f.model.replacement ?? null,
|
|
51
|
+
})),
|
|
52
|
+
}, null, 2);
|
|
53
|
+
}
|
|
54
|
+
const ghEscape = (s) => s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
55
|
+
const ghProp = (s) => ghEscape(s).replace(/:/g, "%3A").replace(/,/g, "%2C");
|
|
56
|
+
/** GitHub Actions workflow commands, which show up as annotations on the files. */
|
|
57
|
+
export function formatGithub(findings, options = {}) {
|
|
58
|
+
const lines = findings.map((f) => {
|
|
59
|
+
const level = f.status === "retired" ? "error" : "warning";
|
|
60
|
+
const next = f.model.replacement ? ` Suggested replacement: ${f.model.replacement}.` : "";
|
|
61
|
+
const title = `${f.match} is ${f.status === "retired" ? "retired" : "deprecated"}`;
|
|
62
|
+
return `::${level} file=${ghProp(f.file)},line=${f.line},col=${f.column},title=${ghProp(title)}::${ghEscape(`${f.match}: ${describe(f)}.${next}`)}`;
|
|
63
|
+
});
|
|
64
|
+
lines.push(summary(findings, options.updated));
|
|
65
|
+
return lines.join("\n");
|
|
66
|
+
}
|
|
67
|
+
/** A Markdown table, used for the issue the GitHub Action opens. */
|
|
68
|
+
export function formatMarkdown(findings, options = {}) {
|
|
69
|
+
const out = [summary(findings, options.updated)];
|
|
70
|
+
if (findings.length) {
|
|
71
|
+
out.push("", "| Location | Model | Status | Suggested replacement |", "| --- | --- | --- | --- |");
|
|
72
|
+
for (const f of findings) {
|
|
73
|
+
const status = f.status === "retired" ? "**Retired**" : describe(f).replace(/^sunset/, "Shuts down");
|
|
74
|
+
out.push(`| \`${f.file}:${f.line}\` | \`${f.match}\` | ${status} | ${f.model.replacement ? `\`${f.model.replacement}\`` : ""} |`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out.join("\n");
|
|
78
|
+
}
|
|
79
|
+
export function format(findings, fmt, options = {}) {
|
|
80
|
+
switch (fmt) {
|
|
81
|
+
case "json":
|
|
82
|
+
return formatJson(findings, options);
|
|
83
|
+
case "github":
|
|
84
|
+
return formatGithub(findings, options);
|
|
85
|
+
case "markdown":
|
|
86
|
+
return formatMarkdown(findings, options);
|
|
87
|
+
default:
|
|
88
|
+
return formatText(findings, options);
|
|
89
|
+
}
|
|
90
|
+
}
|
package/dist/scan.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ModelEntry } from "./data.js";
|
|
2
|
+
export type Status = "retired" | "deprecated";
|
|
3
|
+
export interface Finding {
|
|
4
|
+
/** Path relative to the current directory, with forward slashes. */
|
|
5
|
+
file: string;
|
|
6
|
+
line: number;
|
|
7
|
+
column: number;
|
|
8
|
+
/** The text that matched, which can be an alias of `model.id`. */
|
|
9
|
+
match: string;
|
|
10
|
+
model: ModelEntry;
|
|
11
|
+
status: Status;
|
|
12
|
+
/** Days until shutdown; negative once it has passed, `undefined` when no date is announced. */
|
|
13
|
+
daysLeft?: number;
|
|
14
|
+
}
|
|
15
|
+
export interface Matcher {
|
|
16
|
+
/** Finds model IDs in one line of text. */
|
|
17
|
+
matchLine(line: string): {
|
|
18
|
+
match: string;
|
|
19
|
+
column: number;
|
|
20
|
+
model: ModelEntry;
|
|
21
|
+
}[];
|
|
22
|
+
}
|
|
23
|
+
/** Builds a matcher for every model in the list. */
|
|
24
|
+
export declare function createMatcher(models: ModelEntry[]): Matcher;
|
|
25
|
+
/** Whole days from `today` to `date` (both YYYY-MM-DD). */
|
|
26
|
+
export declare function daysBetween(today: string, date: string): number;
|
|
27
|
+
export declare function todayString(now?: Date): string;
|
|
28
|
+
export declare function statusOf(model: ModelEntry, today: string): {
|
|
29
|
+
status: Status;
|
|
30
|
+
daysLeft?: number;
|
|
31
|
+
};
|
|
32
|
+
/** Scans text and returns every model ID it mentions. Lines containing `model-sunset-ignore` are skipped. */
|
|
33
|
+
export declare function scanText(text: string, file: string, matcher: Matcher, today: string): Finding[];
|
|
34
|
+
export interface ListOptions {
|
|
35
|
+
/** Glob patterns to skip, matched against the path relative to the current directory. */
|
|
36
|
+
ignore?: string[];
|
|
37
|
+
}
|
|
38
|
+
/** Lists the text files to scan under each path. Inside a git repository it uses `git ls-files`, so `.gitignore` is respected. */
|
|
39
|
+
export declare function listFiles(paths: string[], options?: ListOptions): Promise<string[]>;
|
|
40
|
+
/** Reads a file as text, or returns `undefined` for binary or very large files. */
|
|
41
|
+
export declare function readText(file: string): Promise<string | undefined>;
|
|
42
|
+
export declare function displayPath(file: string): string;
|
|
43
|
+
/** Converts a glob (`*`, `**`, `?`) to a regular expression. A pattern without `/` matches at any depth. */
|
|
44
|
+
export declare function globToRegExp(glob: string): RegExp;
|
|
45
|
+
export interface ScanOptions extends ListOptions {
|
|
46
|
+
/** Date to compare shutdown dates against (YYYY-MM-DD). Defaults to today (UTC). */
|
|
47
|
+
today?: string;
|
|
48
|
+
}
|
|
49
|
+
/** Scans files and directories for hardcoded model IDs that are retired or deprecated. */
|
|
50
|
+
export declare function scanPaths(paths: string[], models: ModelEntry[], options?: ScanOptions): Promise<Finding[]>;
|
package/dist/scan.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { open, readdir, stat } from "node:fs/promises";
|
|
3
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
4
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
5
|
+
/** Builds a matcher for every model in the list. */
|
|
6
|
+
export function createMatcher(models) {
|
|
7
|
+
const byName = new Map();
|
|
8
|
+
const loose = [];
|
|
9
|
+
const strict = [];
|
|
10
|
+
for (const model of models) {
|
|
11
|
+
const names = [model.id, ...(model.aliases ?? [])];
|
|
12
|
+
// Vertex AI spells dated Claude IDs as `claude-3-opus@20240229`.
|
|
13
|
+
const dated = /^(claude-.+)-(\d{8})$/.exec(model.id);
|
|
14
|
+
if (dated)
|
|
15
|
+
names.push(`${dated[1]}@${dated[2]}`);
|
|
16
|
+
for (const name of names) {
|
|
17
|
+
if (byName.has(name))
|
|
18
|
+
continue;
|
|
19
|
+
byName.set(name, model);
|
|
20
|
+
(model.strict ?? name.length <= 10 ? strict : loose).push(name);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const alternation = (names) => names
|
|
24
|
+
.sort((a, b) => b.length - a.length)
|
|
25
|
+
.map(escape)
|
|
26
|
+
.join("|");
|
|
27
|
+
// A model ID ends at anything that can't continue it. `-v1:0` is allowed after it for
|
|
28
|
+
// Bedrock IDs (`anthropic.claude-3-opus-20240229-v1:0`).
|
|
29
|
+
const end = String.raw `(?=$|-v\d|[^A-Za-z0-9_.\-]|[.\-](?![A-Za-z0-9]))`;
|
|
30
|
+
const patterns = [];
|
|
31
|
+
if (loose.length)
|
|
32
|
+
patterns.push(new RegExp(String.raw `(?<![A-Za-z0-9_\-])(?:${alternation(loose)})${end}`, "g"));
|
|
33
|
+
if (strict.length) {
|
|
34
|
+
const names = alternation(strict);
|
|
35
|
+
patterns.push(new RegExp(String.raw `(?<=["'\x60/])(?:${names})(?=["'\x60])|(?<=[Mm][Oo][Dd][Ee][Ll][A-Za-z_]*["']?\s*[:=]\s*)(?:${names})(?![A-Za-z0-9_.\-])`, "g"));
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
matchLine(line) {
|
|
39
|
+
const hits = [];
|
|
40
|
+
for (const re of patterns) {
|
|
41
|
+
re.lastIndex = 0;
|
|
42
|
+
for (let m = re.exec(line); m; m = re.exec(line)) {
|
|
43
|
+
const model = byName.get(m[0]);
|
|
44
|
+
if (model && !hits.some((h) => h.column === m.index + 1))
|
|
45
|
+
hits.push({ match: m[0], column: m.index + 1, model });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return hits.sort((a, b) => a.column - b.column);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Whole days from `today` to `date` (both YYYY-MM-DD). */
|
|
53
|
+
export function daysBetween(today, date) {
|
|
54
|
+
return Math.round((Date.parse(date) - Date.parse(today)) / 86_400_000);
|
|
55
|
+
}
|
|
56
|
+
export function todayString(now = new Date()) {
|
|
57
|
+
return now.toISOString().slice(0, 10);
|
|
58
|
+
}
|
|
59
|
+
export function statusOf(model, today) {
|
|
60
|
+
if (!model.shutdown)
|
|
61
|
+
return { status: "deprecated" };
|
|
62
|
+
const daysLeft = daysBetween(today, model.shutdown);
|
|
63
|
+
return { status: daysLeft <= 0 ? "retired" : "deprecated", daysLeft };
|
|
64
|
+
}
|
|
65
|
+
const IGNORE_LINE = "model-sunset-ignore";
|
|
66
|
+
/** Scans text and returns every model ID it mentions. Lines containing `model-sunset-ignore` are skipped. */
|
|
67
|
+
export function scanText(text, file, matcher, today) {
|
|
68
|
+
const findings = [];
|
|
69
|
+
const lines = text.split(/\r?\n/);
|
|
70
|
+
for (let i = 0; i < lines.length; i++) {
|
|
71
|
+
const line = lines[i];
|
|
72
|
+
if (line.length > 5000 || line.includes(IGNORE_LINE))
|
|
73
|
+
continue;
|
|
74
|
+
for (const hit of matcher.matchLine(line)) {
|
|
75
|
+
findings.push({ file, line: i + 1, column: hit.column, match: hit.match, model: hit.model, ...statusOf(hit.model, today) });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return findings;
|
|
79
|
+
}
|
|
80
|
+
const SKIP_DIRS = new Set([
|
|
81
|
+
".git", "node_modules", "dist", "build", "out", "coverage", "vendor", ".next", ".nuxt", ".svelte-kit",
|
|
82
|
+
".turbo", ".cache", "target", "venv", ".venv", "__pycache__", ".tox", ".mypy_cache", ".pytest_cache",
|
|
83
|
+
]);
|
|
84
|
+
const SKIP_FILES = new Set([
|
|
85
|
+
"package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb", "bun.lock",
|
|
86
|
+
"poetry.lock", "uv.lock", "Pipfile.lock", "Cargo.lock", "Gemfile.lock", "composer.lock", "go.sum",
|
|
87
|
+
]);
|
|
88
|
+
const BINARY_EXT = /\.(png|jpe?g|gif|webp|ico|bmp|tiff?|pdf|zip|gz|tgz|bz2|xz|7z|rar|jar|war|exe|dll|so|dylib|bin|wasm|woff2?|ttf|otf|eot|mp[34]|mov|avi|webm|wav|ogg|flac|sqlite|db|pyc|class|o|a|lib|node|map)$/i;
|
|
89
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
90
|
+
/** Lists the text files to scan under each path. Inside a git repository it uses `git ls-files`, so `.gitignore` is respected. */
|
|
91
|
+
export async function listFiles(paths, options = {}) {
|
|
92
|
+
const ignore = (options.ignore ?? []).map(globToRegExp);
|
|
93
|
+
const files = new Set();
|
|
94
|
+
for (const p of paths) {
|
|
95
|
+
const info = await stat(p);
|
|
96
|
+
if (info.isFile()) {
|
|
97
|
+
files.add(resolve(p));
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const fromGit = await gitFiles(p);
|
|
101
|
+
for (const f of fromGit ?? (await walk(resolve(p))))
|
|
102
|
+
files.add(f);
|
|
103
|
+
}
|
|
104
|
+
return [...files]
|
|
105
|
+
.filter((f) => {
|
|
106
|
+
const parts = f.split(sep);
|
|
107
|
+
const name = parts[parts.length - 1];
|
|
108
|
+
if (SKIP_FILES.has(name) || BINARY_EXT.test(name) || /\.min\.(js|css)$/.test(name))
|
|
109
|
+
return false;
|
|
110
|
+
if (parts.some((part) => SKIP_DIRS.has(part)))
|
|
111
|
+
return false;
|
|
112
|
+
const rel = displayPath(f);
|
|
113
|
+
return !ignore.some((re) => re.test(rel));
|
|
114
|
+
})
|
|
115
|
+
.sort();
|
|
116
|
+
}
|
|
117
|
+
function gitFiles(dir) {
|
|
118
|
+
return new Promise((done) => {
|
|
119
|
+
execFile("git", ["-C", dir, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { maxBuffer: 256 * 1024 * 1024 }, (err, stdout) => {
|
|
120
|
+
if (err)
|
|
121
|
+
return done(undefined);
|
|
122
|
+
done(stdout.split("\0").filter(Boolean).map((f) => resolve(dir, f)));
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
async function walk(dir, out = []) {
|
|
127
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
128
|
+
if (entry.isDirectory()) {
|
|
129
|
+
if (!SKIP_DIRS.has(entry.name))
|
|
130
|
+
await walk(join(dir, entry.name), out);
|
|
131
|
+
}
|
|
132
|
+
else if (entry.isFile()) {
|
|
133
|
+
out.push(join(dir, entry.name));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
/** Reads a file as text, or returns `undefined` for binary or very large files. */
|
|
139
|
+
export async function readText(file) {
|
|
140
|
+
let handle;
|
|
141
|
+
try {
|
|
142
|
+
handle = await open(file, "r");
|
|
143
|
+
const { size } = await handle.stat();
|
|
144
|
+
if (size > MAX_BYTES)
|
|
145
|
+
return undefined;
|
|
146
|
+
const buf = await handle.readFile();
|
|
147
|
+
if (buf.subarray(0, 8000).includes(0))
|
|
148
|
+
return undefined;
|
|
149
|
+
return buf.toString("utf8");
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
await handle?.close();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
export function displayPath(file) {
|
|
159
|
+
const rel = relative(process.cwd(), file);
|
|
160
|
+
return (rel && !rel.startsWith("..") ? rel : file).split(sep).join("/");
|
|
161
|
+
}
|
|
162
|
+
/** Converts a glob (`*`, `**`, `?`) to a regular expression. A pattern without `/` matches at any depth. */
|
|
163
|
+
export function globToRegExp(glob) {
|
|
164
|
+
let g = glob.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
165
|
+
if (g.endsWith("/"))
|
|
166
|
+
g += "**";
|
|
167
|
+
let re = "";
|
|
168
|
+
for (let i = 0; i < g.length; i++) {
|
|
169
|
+
const c = g[i];
|
|
170
|
+
if (c === "*") {
|
|
171
|
+
if (g[i + 1] === "*") {
|
|
172
|
+
re += g[i + 2] === "/" ? "(?:.*/)?" : ".*";
|
|
173
|
+
i += g[i + 2] === "/" ? 2 : 1;
|
|
174
|
+
}
|
|
175
|
+
else
|
|
176
|
+
re += "[^/]*";
|
|
177
|
+
}
|
|
178
|
+
else if (c === "?")
|
|
179
|
+
re += "[^/]";
|
|
180
|
+
else
|
|
181
|
+
re += escape(c);
|
|
182
|
+
}
|
|
183
|
+
const anchored = g.includes("/") ? `^${re}` : `(?:^|/)${re}`;
|
|
184
|
+
return new RegExp(`${anchored}(?:/.*)?$`);
|
|
185
|
+
}
|
|
186
|
+
/** Scans files and directories for hardcoded model IDs that are retired or deprecated. */
|
|
187
|
+
export async function scanPaths(paths, models, options = {}) {
|
|
188
|
+
const matcher = createMatcher(models);
|
|
189
|
+
const today = options.today ?? todayString();
|
|
190
|
+
const findings = [];
|
|
191
|
+
for (const file of await listFiles(paths, options)) {
|
|
192
|
+
const text = await readText(file);
|
|
193
|
+
if (text !== undefined)
|
|
194
|
+
findings.push(...scanText(text, displayPath(file), matcher, today));
|
|
195
|
+
}
|
|
196
|
+
return findings;
|
|
197
|
+
}
|