@aidriftline/driftline 0.0.0-stage → 0.1.10
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/LICENSE +95 -0
- package/README.md +112 -2
- package/package.json +36 -4
- package/registry/models.json +1941 -0
- package/src/cli.js +85 -0
- package/src/compare.js +92 -0
- package/src/github.js +57 -0
- package/src/index.js +97 -0
- package/src/license.js +133 -0
- package/src/mcp.js +150 -0
- package/src/prompts.js +98 -0
- package/src/providers.js +100 -0
- package/src/registry.js +90 -0
- package/src/report.js +102 -0
- package/src/run.js +133 -0
- package/src/scan.js +82 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Local use:
|
|
3
|
+
// driftline [directory] scan and print the report (drift tests run when
|
|
4
|
+
// .driftline/prompts.yml, DRIFTLINE_LICENSE_KEY and provider keys exist)
|
|
5
|
+
// driftline activate <license-key> register this GitHub account; prints the code to save as DRIFTLINE_LICENSE_KEY
|
|
6
|
+
// driftline deactivate <code> free that account's slot (a key works on up to 3 accounts)
|
|
7
|
+
// driftline [directory] --json same scan, machine-readable (for scripts and coding agents)
|
|
8
|
+
// driftline mcp MCP server on stdio for coding agents (Claude Code, Cursor, ...)
|
|
9
|
+
// driftline --help | --version
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { run } from "./run.js";
|
|
14
|
+
import { activate, deactivate } from "./license.js";
|
|
15
|
+
|
|
16
|
+
const JSON_OUT = process.argv.includes("--json");
|
|
17
|
+
const [cmd, arg] = process.argv.slice(2).filter((a) => a !== "--json");
|
|
18
|
+
const flag = (name) => { const i = process.argv.indexOf(`--${name}`); return i > 0 ? process.argv[i + 1] : ""; };
|
|
19
|
+
|
|
20
|
+
// "git@github.com:acme/app.git" or "https://github.com/acme/app" -> "acme"
|
|
21
|
+
function accountFromGit() {
|
|
22
|
+
try {
|
|
23
|
+
const url = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
24
|
+
return url.match(/github\.com[:/]([^/]+)\//i)?.[1] || "";
|
|
25
|
+
} catch { return ""; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const HELP = `Driftline: finds AI model IDs in your code and warns before they are retired.
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
npx github:AI-Driftline/driftline [directory] scan and print the report
|
|
32
|
+
npx github:AI-Driftline/driftline activate <license-key> register this GitHub account for Pro
|
|
33
|
+
npx github:AI-Driftline/driftline deactivate <code> free that account's slot
|
|
34
|
+
npx github:AI-Driftline/driftline mcp MCP server for coding agents (stdio)
|
|
35
|
+
|
|
36
|
+
Options:
|
|
37
|
+
--json print the scan as JSON (for scripts and coding agents)
|
|
38
|
+
--account <name> GitHub account to activate for (default: origin remote owner)
|
|
39
|
+
-h, --help show this help
|
|
40
|
+
-v, --version show the version
|
|
41
|
+
|
|
42
|
+
Exit code: 1 if a retired model is used in code, 0 otherwise.
|
|
43
|
+
Retirement dates for every model: https://aidriftline.com/models/
|
|
44
|
+
Docs: https://github.com/AI-Driftline/driftline`;
|
|
45
|
+
|
|
46
|
+
if (cmd === "-h" || cmd === "--help" || cmd === "help") {
|
|
47
|
+
console.log(HELP);
|
|
48
|
+
} else if (cmd === "-v" || cmd === "--version") {
|
|
49
|
+
console.log(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
|
|
50
|
+
} else if (cmd?.startsWith("-")) {
|
|
51
|
+
console.error(`Unknown option ${cmd}\n\n${HELP}`);
|
|
52
|
+
process.exitCode = 2;
|
|
53
|
+
} else if (cmd === "mcp") {
|
|
54
|
+
const { startServer } = await import("./mcp.js");
|
|
55
|
+
startServer();
|
|
56
|
+
} else if (cmd === "activate") {
|
|
57
|
+
const account = flag("account") || accountFromGit();
|
|
58
|
+
const r = await activate(arg, account);
|
|
59
|
+
if (!r.ok) { console.error(r.reason); process.exitCode = 1; }
|
|
60
|
+
else {
|
|
61
|
+
console.log(`Activated for GitHub account "${account}"${r.limit ? ` (${r.used} of ${r.limit} used)` : ""}.\n`);
|
|
62
|
+
console.log("Save this as a repository or organization secret named DRIFTLINE_LICENSE_KEY:\n");
|
|
63
|
+
console.log(` ${r.code}\n`);
|
|
64
|
+
}
|
|
65
|
+
} else if (cmd === "deactivate") {
|
|
66
|
+
const r = await deactivate(arg);
|
|
67
|
+
if (!r.ok) { console.error(r.reason); process.exitCode = 1; }
|
|
68
|
+
else console.log("Deactivated. That slot is free for another GitHub account.");
|
|
69
|
+
} else {
|
|
70
|
+
const result = await run({
|
|
71
|
+
root: resolve(cmd || "."),
|
|
72
|
+
licenseKey: process.env.DRIFTLINE_LICENSE_KEY || "",
|
|
73
|
+
account: process.env.GITHUB_REPOSITORY_OWNER || accountFromGit(),
|
|
74
|
+
log: (m) => console.error(m),
|
|
75
|
+
});
|
|
76
|
+
if (JSON_OUT) {
|
|
77
|
+
const pick = (f) => ({
|
|
78
|
+
id: f.id, file: f.file, line: f.line, status: f.status, docOnly: !!f.doc,
|
|
79
|
+
provider: f.entry?.provider, retires: f.entry?.retires, qualifier: f.entry?.qualifier,
|
|
80
|
+
replacement: f.entry?.replacement, routes: !!f.entry?.routes, aliasOf: f.entry?.aliasOf,
|
|
81
|
+
});
|
|
82
|
+
console.log(JSON.stringify({ findings: result.findings.map(pick), drift: result.drift || [] }, null, 2));
|
|
83
|
+
} else console.log(result.markdown);
|
|
84
|
+
process.exitCode = result.findings.some((f) => f.status === "retired" && !f.doc) ? 1 : 0;
|
|
85
|
+
}
|
package/src/compare.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Compares the current model's answer with the replacement model's answer.
|
|
2
|
+
// Scores run from 0 (nothing in common) to 1 (identical in substance).
|
|
3
|
+
|
|
4
|
+
const SAME = 0.9;
|
|
5
|
+
const CHANGED = 0.6;
|
|
6
|
+
|
|
7
|
+
// Words, except in scripts written without spaces (Chinese, Japanese, Thai), where each character
|
|
8
|
+
// is a token; the word-pair pass below then compares them as character pairs.
|
|
9
|
+
const NO_SPACES = "\\p{sc=Han}\\p{sc=Hiragana}\\p{sc=Katakana}\\p{sc=Thai}";
|
|
10
|
+
const TOKEN = new RegExp(`[${NO_SPACES}]|(?:(?![${NO_SPACES}])[\\p{L}\\p{N}])+`, "gu");
|
|
11
|
+
|
|
12
|
+
function tokens(text) {
|
|
13
|
+
return text.toLowerCase().match(TOKEN) || [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function vector(text) {
|
|
17
|
+
const t = tokens(text);
|
|
18
|
+
const v = new Map();
|
|
19
|
+
const add = (k) => v.set(k, (v.get(k) || 0) + 1);
|
|
20
|
+
t.forEach((w, i) => { add(w); if (i > 0) add(t[i - 1] + " " + w); });
|
|
21
|
+
return v;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function textSimilarity(a, b) {
|
|
25
|
+
if (a.trim() === b.trim()) return 1;
|
|
26
|
+
const va = vector(a), vb = vector(b);
|
|
27
|
+
let dot = 0, na = 0, nb = 0;
|
|
28
|
+
for (const [k, x] of va) { na += x * x; if (vb.has(k)) dot += x * vb.get(k); }
|
|
29
|
+
for (const [, y] of vb) nb += y * y;
|
|
30
|
+
return na && nb ? dot / Math.sqrt(na * nb) : 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Models often wrap JSON in ```json fences or a sentence; pull out the JSON part.
|
|
34
|
+
export function extractJson(text) {
|
|
35
|
+
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
36
|
+
const candidate = (fenced ? fenced[1] : text).trim();
|
|
37
|
+
try { return { ok: true, value: JSON.parse(candidate) }; } catch {}
|
|
38
|
+
// JSON after a sentence, which may itself contain braces: try each opening bracket in turn.
|
|
39
|
+
const end = Math.max(candidate.lastIndexOf("}"), candidate.lastIndexOf("]"));
|
|
40
|
+
let tries = 0;
|
|
41
|
+
for (let start = candidate.search(/[[{]/); start >= 0 && start < end && tries < 50; tries++) {
|
|
42
|
+
try { return { ok: true, value: JSON.parse(candidate.slice(start, end + 1)) }; } catch {}
|
|
43
|
+
const next = candidate.slice(start + 1).search(/[[{]/);
|
|
44
|
+
start = next < 0 ? -1 : start + 1 + next;
|
|
45
|
+
}
|
|
46
|
+
return { ok: false };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function paths(value, prefix = "", out = new Map()) {
|
|
50
|
+
if (value && typeof value === "object") {
|
|
51
|
+
const entries = Array.isArray(value) ? value.slice(0, 20).map((v, i) => [String(i), v]) : Object.entries(value);
|
|
52
|
+
if (!entries.length) out.set(prefix || "$", JSON.stringify(value));
|
|
53
|
+
for (const [k, v] of entries) paths(v, Array.isArray(value) ? `${prefix}[]` : `${prefix}.${k}`, out);
|
|
54
|
+
} else {
|
|
55
|
+
out.set(prefix || "$", String(value).trim().toLowerCase());
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function jsonSimilarity(a, b) {
|
|
61
|
+
const pa = paths(a), pb = paths(b);
|
|
62
|
+
const keys = new Set([...pa.keys(), ...pb.keys()]);
|
|
63
|
+
if (!keys.size) return 1;
|
|
64
|
+
let score = 0;
|
|
65
|
+
for (const k of keys) {
|
|
66
|
+
if (pa.has(k) && pb.has(k)) score += pa.get(k) === pb.get(k) ? 1 : 0.5; // same field, different value
|
|
67
|
+
}
|
|
68
|
+
return score / keys.size;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Returns { score, result, note } where result is: same | changed | broken
|
|
73
|
+
*/
|
|
74
|
+
export function compare(before, after, expect = "text") {
|
|
75
|
+
const emptyBefore = !String(before ?? "").trim(), emptyAfter = !String(after ?? "").trim();
|
|
76
|
+
if (emptyBefore && emptyAfter) return { score: 0, result: "error", note: "Both models returned an empty answer" };
|
|
77
|
+
if (emptyAfter) return { score: 0, result: "broken", note: "Replacement returned an empty answer" };
|
|
78
|
+
if (emptyBefore) return { score: 0, result: "changed", note: "Current model returned an empty answer; review the replacement's" };
|
|
79
|
+
if (expect === "json") {
|
|
80
|
+
const a = extractJson(before), b = extractJson(after);
|
|
81
|
+
if (!b.ok) return { score: 0, result: "broken", note: "Replacement did not return valid JSON" };
|
|
82
|
+
if (!a.ok) return { score: 0.5, result: "changed", note: "Current model did not return valid JSON; replacement did" };
|
|
83
|
+
const score = jsonSimilarity(a.value, b.value);
|
|
84
|
+
return { score, result: label(score), note: score < 1 ? "JSON fields or values differ" : "" };
|
|
85
|
+
}
|
|
86
|
+
const score = textSimilarity(before, after);
|
|
87
|
+
return { score, result: label(score), note: "" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function label(score) {
|
|
91
|
+
return score >= SAME ? "same" : score >= CHANGED ? "changed" : "broken";
|
|
92
|
+
}
|
package/src/github.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export function input(name, fallback = "") {
|
|
4
|
+
const v = process.env[`INPUT_${name.replace(/ /g, "_").toUpperCase()}`];
|
|
5
|
+
return v === undefined || v === "" ? fallback : v.trim();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function setOutput(name, value) {
|
|
9
|
+
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function writeSummary(markdown) {
|
|
13
|
+
if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdown + "\n");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// GitHub workflow commands for annotations on the changed lines.
|
|
17
|
+
const escProp = (v) => String(v).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A").replace(/:/g, "%3A").replace(/,/g, "%2C");
|
|
18
|
+
|
|
19
|
+
export function annotate(level, message, file, line) {
|
|
20
|
+
const loc = file ? ` file=${escProp(file)},line=${line}` : "";
|
|
21
|
+
console.log(`::${level}${loc}::${message.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A")}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const TITLE = "Driftline: AI model retirement report";
|
|
25
|
+
|
|
26
|
+
async function failure(res, what) {
|
|
27
|
+
let detail = "";
|
|
28
|
+
try { detail = ((await res.json())?.message || "").slice(0, 200); } catch {}
|
|
29
|
+
const hint = res.status === 403 || res.status === 404 ? ' Does the workflow have "issues: write" permission?' : "";
|
|
30
|
+
return new Error(`Could not ${what}: HTTP ${res.status}${detail ? ` (${detail})` : ""}.${hint}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Opens one issue labelled "driftline", or updates it if it is already open.
|
|
34
|
+
// With close: true, an open report issue is updated with the all-clear and closed; none is opened.
|
|
35
|
+
export async function upsertIssue(body, { token, repo = process.env.GITHUB_REPOSITORY, fetchImpl = fetch, close = false } = {}) {
|
|
36
|
+
if (!token || !repo) return { skipped: "No GitHub token or repository." };
|
|
37
|
+
const api = process.env.GITHUB_API_URL || "https://api.github.com";
|
|
38
|
+
const headers = {
|
|
39
|
+
authorization: `Bearer ${token}`,
|
|
40
|
+
accept: "application/vnd.github+json",
|
|
41
|
+
"x-github-api-version": "2022-11-28",
|
|
42
|
+
"content-type": "application/json",
|
|
43
|
+
};
|
|
44
|
+
const list = await fetchImpl(`${api}/repos/${repo}/issues?labels=driftline&state=open&per_page=100`, { headers });
|
|
45
|
+
if (!list.ok) throw await failure(list, "list issues");
|
|
46
|
+
const existing = (await list.json()).find((i) => i.title === TITLE && !i.pull_request);
|
|
47
|
+
if (close && !existing) return { skipped: "Nothing flagged and no open Driftline issue." };
|
|
48
|
+
const url = existing ? `${api}/repos/${repo}/issues/${existing.number}` : `${api}/repos/${repo}/issues`;
|
|
49
|
+
const res = await fetchImpl(url, {
|
|
50
|
+
method: existing ? "PATCH" : "POST",
|
|
51
|
+
headers,
|
|
52
|
+
body: JSON.stringify(existing ? { body, ...(close ? { state: "closed" } : {}) } : { title: TITLE, body, labels: ["driftline"] }),
|
|
53
|
+
});
|
|
54
|
+
if (!res.ok) throw await failure(res, `${existing ? "update" : "open"} issue`);
|
|
55
|
+
const issue = await res.json();
|
|
56
|
+
return { number: issue.number, url: issue.html_url, updated: Boolean(existing), closed: close };
|
|
57
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// GitHub Action entry point.
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { statSync } from "node:fs";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { run } from "./run.js";
|
|
7
|
+
import { input, setOutput, writeSummary, annotate, upsertIssue } from "./github.js";
|
|
8
|
+
|
|
9
|
+
const STATUS_TEXT = {
|
|
10
|
+
retired: "is retired",
|
|
11
|
+
"at-risk": "retires soon",
|
|
12
|
+
deprecated: "is deprecated",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
async function main() {
|
|
16
|
+
const workspace = process.env.GITHUB_WORKSPACE || process.cwd();
|
|
17
|
+
const root = resolve(workspace, input("path", "."));
|
|
18
|
+
let isDir = false;
|
|
19
|
+
try { isDir = statSync(root).isDirectory(); } catch {}
|
|
20
|
+
if (!isDir) throw new Error(`path "${input("path", ".")}" is not a folder in this repository`);
|
|
21
|
+
const wd = Number(input("warn-days", "90"));
|
|
22
|
+
const warnDays = Number.isFinite(wd) && wd >= 0 ? wd : 90;
|
|
23
|
+
let failOn = input("fail-on", "retired").toLowerCase();
|
|
24
|
+
if (!["retired", "at-risk", "never"].includes(failOn)) {
|
|
25
|
+
annotate("warning", `Unknown fail-on value "${failOn}". Use retired, at-risk or never. Using "retired".`);
|
|
26
|
+
failOn = "retired";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const result = await run({
|
|
30
|
+
root,
|
|
31
|
+
repoRoot: workspace,
|
|
32
|
+
warnDays,
|
|
33
|
+
promptsFile: input("prompts-file", ".driftline/prompts.yml"),
|
|
34
|
+
licenseKey: input("license-key"),
|
|
35
|
+
account: process.env.GITHUB_REPOSITORY_OWNER || "",
|
|
36
|
+
log: (m) => console.log(m),
|
|
37
|
+
answers: input("show-answers", "true") !== "false",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// GitHub shows only a handful of annotations per step; send the most urgent, point to the report for the rest.
|
|
41
|
+
const MAX_ANNOTATIONS = 50;
|
|
42
|
+
const rank = { retired: 0, "at-risk": 1, deprecated: 2 };
|
|
43
|
+
const toAnnotate = result.findings
|
|
44
|
+
.filter((f) => STATUS_TEXT[f.status])
|
|
45
|
+
.sort((a, b) => (a.doc - b.doc) || (rank[a.status] - rank[b.status]));
|
|
46
|
+
if (toAnnotate.length > MAX_ANNOTATIONS) {
|
|
47
|
+
annotate("notice", `${toAnnotate.length - MAX_ANNOTATIONS} more model references are listed in the job summary.`);
|
|
48
|
+
}
|
|
49
|
+
for (const f of toAnnotate.slice(0, MAX_ANNOTATIONS)) {
|
|
50
|
+
const level = f.status === "deprecated" || f.doc ? "notice" : "warning";
|
|
51
|
+
const repl = f.entry?.replacement && !f.entry.routes ? ` Replacement: ${f.entry.replacement}.` : "";
|
|
52
|
+
const text = f.entry.routes && f.entry.replacement
|
|
53
|
+
? (f.days <= 0
|
|
54
|
+
? `Since ${f.entry.retires}, requests to ${f.id} are answered by ${f.entry.replacement}. The call still works, but the model behind it changed.`
|
|
55
|
+
: `From ${f.entry.retires}, requests to ${f.id} will be answered by ${f.entry.replacement}. The call keeps working, but the model behind it changes.`)
|
|
56
|
+
: f.entry.qualifier === "earliest" && f.days <= 0
|
|
57
|
+
? `${f.id} is past its earliest shutdown date (${f.entry.retires}) and may stop working at any time.`
|
|
58
|
+
: `${f.id} ${STATUS_TEXT[f.status]} (${f.entry.retires}).`;
|
|
59
|
+
annotate(level, text + repl, f.file, f.line);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The report contains model answers; stop GitHub from reading any "::command::" lines inside it.
|
|
63
|
+
const token = `driftline-${randomUUID()}`;
|
|
64
|
+
console.log(`::stop-commands::${token}`);
|
|
65
|
+
console.log("\n" + result.markdown);
|
|
66
|
+
console.log(`::${token}::`);
|
|
67
|
+
writeSummary(result.markdown);
|
|
68
|
+
setOutput("findings", result.findings.length);
|
|
69
|
+
setOutput("at-risk", result.atRisk);
|
|
70
|
+
|
|
71
|
+
if (input("create-issue", "false") === "true") {
|
|
72
|
+
// Something flagged in code: open or update the issue. Nothing flagged: close it with the all-clear.
|
|
73
|
+
const flagged = result.findings.some((f) => STATUS_TEXT[f.status] && !f.doc);
|
|
74
|
+
const body = flagged ? result.issueMarkdown : `${result.issueMarkdown}\n\n✅ No retired or retiring models are left in the code. Closing this issue.`;
|
|
75
|
+
try {
|
|
76
|
+
const issue = await upsertIssue(body, { token: input("github-token"), close: !flagged });
|
|
77
|
+
if (issue.url) console.log(`${issue.closed ? "Closed" : issue.updated ? "Updated" : "Opened"} issue: ${issue.url}`);
|
|
78
|
+
else console.log(issue.skipped);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
annotate("warning", `Driftline issue: ${e.message}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Mentions in Markdown and text files are reported but never fail the build.
|
|
85
|
+
const inCode = result.findings.filter((f) => !f.doc);
|
|
86
|
+
const retired = inCode.some((f) => f.status === "retired");
|
|
87
|
+
const atRisk = inCode.some((f) => f.status === "at-risk");
|
|
88
|
+
if ((failOn === "retired" && retired) || (failOn === "at-risk" && (retired || atRisk))) {
|
|
89
|
+
console.log(`::error::Driftline found ${failOn === "retired" ? "retired" : "retired or soon-to-retire"} models (fail-on: ${failOn}).`);
|
|
90
|
+
process.exitCode = 1;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
main().catch((e) => {
|
|
95
|
+
console.log(`::error::Driftline failed: ${e.message}`);
|
|
96
|
+
process.exitCode = 1;
|
|
97
|
+
});
|
package/src/license.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Pro features (drift tests) need a Lemon Squeezy license, activated once per GitHub account.
|
|
2
|
+
// API: https://api.lemonsqueezy.com/v1/licenses/{activate,validate,deactivate} (form fields)
|
|
3
|
+
//
|
|
4
|
+
// A key works on up to 3 GitHub accounts or organizations (the activation limit set in Lemon Squeezy).
|
|
5
|
+
// `driftline activate <key>` registers one account and prints a license code: "<key>:<instance id>".
|
|
6
|
+
// The Action checks that code on every run and that it was activated for the account it runs in.
|
|
7
|
+
|
|
8
|
+
// Only keys from Driftline's own store and product unlock Pro. A key bought in another store,
|
|
9
|
+
// or for another product in this store, is rejected. (null / [] = no check; tests use that.)
|
|
10
|
+
// Live product only. Never add the test-mode product (1428709): free test keys would unlock Pro.
|
|
11
|
+
export const LICENSE = { storeId: 494524, productIds: [1432580] };
|
|
12
|
+
|
|
13
|
+
const API = "https://api.lemonsqueezy.com/v1/licenses";
|
|
14
|
+
const ACTIVATE_HELP = "Run `npx github:AI-Driftline/driftline activate <your-license-key>` once for this GitHub account and save the code it prints as DRIFTLINE_LICENSE_KEY.";
|
|
15
|
+
|
|
16
|
+
// "expired": the subscription ended. "disabled": refunded or blocked.
|
|
17
|
+
const ENDED = ["expired", "disabled"];
|
|
18
|
+
const endedReason = (status) => `Driftline Pro has ended for this license key (${status}: the subscription ran out or was refunded). The scan still runs; drift tests are off. Renew at https://aidriftline.com.`;
|
|
19
|
+
|
|
20
|
+
export const instanceName = (account) => `github:${String(account).trim().toLowerCase()}`;
|
|
21
|
+
|
|
22
|
+
export function parseCode(code) {
|
|
23
|
+
const s = String(code || "").trim();
|
|
24
|
+
const i = s.lastIndexOf(":");
|
|
25
|
+
return i > 0 ? { key: s.slice(0, i), instanceId: s.slice(i + 1) } : { key: s, instanceId: "" };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Lemon Squeezy license keys and instance ids are UUIDs (per its API docs).
|
|
29
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
30
|
+
const looksReal = (key, instanceId) => UUID.test(key) && UUID.test(instanceId);
|
|
31
|
+
|
|
32
|
+
async function call(fetchImpl, action, fields) {
|
|
33
|
+
return fetchImpl(`${API}/${action}`, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
|
|
36
|
+
body: new URLSearchParams(fields).toString(),
|
|
37
|
+
signal: AbortSignal.timeout(15_000),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const productOk = (data) =>
|
|
42
|
+
(LICENSE.storeId == null || Number(data.meta?.store_id) === Number(LICENSE.storeId)) &&
|
|
43
|
+
(!LICENSE.productIds.length || LICENSE.productIds.map(Number).includes(Number(data.meta?.product_id)));
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Checks a license code on each run. `account` is the GitHub owner the code runs in
|
|
47
|
+
* (GITHUB_REPOSITORY_OWNER in CI, else the origin remote); Pro needs it, or one key would work everywhere.
|
|
48
|
+
*/
|
|
49
|
+
export async function checkLicense(code, { fetchImpl = fetch, account = "" } = {}) {
|
|
50
|
+
if (!code) return { ok: false, reason: "No license key. The scan ran; drift tests need Driftline Pro." };
|
|
51
|
+
const { key, instanceId } = parseCode(code);
|
|
52
|
+
if (!instanceId) return { ok: false, reason: `This license key is not activated yet. ${ACTIVATE_HELP}` };
|
|
53
|
+
if (!String(account).trim()) return { ok: false, reason: "Could not tell which GitHub account this runs in, so drift tests were skipped. Run inside a repository whose origin is on GitHub." };
|
|
54
|
+
|
|
55
|
+
// Do not punish paying users for a network blip: allow the run and say so. Only for codes shaped
|
|
56
|
+
// like a real one, so blocking api.lemonsqueezy.com with a made-up code like "a:b" unlocks nothing.
|
|
57
|
+
const outage = (why) => looksReal(key, instanceId)
|
|
58
|
+
? { ok: true, reason: `License server ${why}; drift tests ran anyway.` }
|
|
59
|
+
: { ok: false, reason: `License server ${why}, and this is not a Driftline license code. ${ACTIVATE_HELP}` };
|
|
60
|
+
let res;
|
|
61
|
+
try {
|
|
62
|
+
res = await call(fetchImpl, "validate", { license_key: key, instance_id: instanceId });
|
|
63
|
+
} catch (e) {
|
|
64
|
+
return outage(`unreachable (${e.message})`);
|
|
65
|
+
}
|
|
66
|
+
if (res.status === 429 || res.status >= 500) return outage(`unavailable (HTTP ${res.status})`);
|
|
67
|
+
let data = null;
|
|
68
|
+
try { data = await res.json(); } catch {}
|
|
69
|
+
if (!data || typeof data !== "object") return { ok: false, reason: `License key could not be checked (HTTP ${res.status}).` };
|
|
70
|
+
if (!data.valid) {
|
|
71
|
+
const ended = await keyStatus(fetchImpl, key);
|
|
72
|
+
if (ENDED.includes(ended)) return { ok: false, reason: endedReason(ended) };
|
|
73
|
+
const why = data.error ? `: ${String(data.error).replace(/\.+$/, "")}` : "";
|
|
74
|
+
return { ok: false, reason: `License not valid${why}. ${ACTIVATE_HELP}` };
|
|
75
|
+
}
|
|
76
|
+
const status = data.license_key?.status;
|
|
77
|
+
if (ENDED.includes(status)) return { ok: false, reason: endedReason(status) };
|
|
78
|
+
if (status && !["active", "inactive"].includes(status)) return { ok: false, reason: `License key is ${status}.` };
|
|
79
|
+
if (!productOk(data)) return { ok: false, reason: "License key belongs to a different product." };
|
|
80
|
+
if (!data.instance) return { ok: false, reason: `This activation was removed. ${ACTIVATE_HELP}` };
|
|
81
|
+
if (data.instance.name !== instanceName(account)) {
|
|
82
|
+
const where = String(data.instance.name || "").replace(/^github:/, "");
|
|
83
|
+
return { ok: false, reason: `This license code was activated for "${where}", not "${account}". A key works on up to 3 GitHub accounts or organizations. ${ACTIVATE_HELP}` };
|
|
84
|
+
}
|
|
85
|
+
return { ok: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// When a key expires or is refunded, Lemon Squeezy deletes its activations, so a code check only says
|
|
89
|
+
// "instance_id not found". Asking about the key alone tells that apart from a slot freed with `deactivate`.
|
|
90
|
+
async function keyStatus(fetchImpl, key) {
|
|
91
|
+
try {
|
|
92
|
+
const data = await (await call(fetchImpl, "validate", { license_key: key })).json();
|
|
93
|
+
return data?.license_key?.status;
|
|
94
|
+
} catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Registers one GitHub account. Returns { ok, code } or { ok: false, reason }. */
|
|
100
|
+
export async function activate(key, account, { fetchImpl = fetch } = {}) {
|
|
101
|
+
if (!key) return { ok: false, reason: "Give your license key: driftline activate <license-key>" };
|
|
102
|
+
if (!account) return { ok: false, reason: "Could not tell which GitHub account this is for. Add --account <name>." };
|
|
103
|
+
let res, data = null;
|
|
104
|
+
try {
|
|
105
|
+
res = await call(fetchImpl, "activate", { license_key: key.trim(), instance_name: instanceName(account) });
|
|
106
|
+
data = await res.json();
|
|
107
|
+
} catch (e) {
|
|
108
|
+
return { ok: false, reason: `Could not reach Lemon Squeezy: ${e.message}` };
|
|
109
|
+
}
|
|
110
|
+
if (!data?.activated) {
|
|
111
|
+
const status = data?.license_key?.status;
|
|
112
|
+
if (ENDED.includes(status)) return { ok: false, reason: endedReason(status) };
|
|
113
|
+
const limit = /activation limit/i.test(data?.error || "")
|
|
114
|
+
? " This key is already used on 3 GitHub accounts. Deactivate one with `npx github:AI-Driftline/driftline deactivate <code>`, or write to hello@aidriftline.com."
|
|
115
|
+
: "";
|
|
116
|
+
return { ok: false, reason: `Not activated: ${String(data?.error || `HTTP ${res?.status}`).replace(/\.+$/, "")}.${limit}` };
|
|
117
|
+
}
|
|
118
|
+
if (!productOk(data)) return { ok: false, reason: "This key belongs to a different product." };
|
|
119
|
+
return { ok: true, code: `${key.trim()}:${data.instance.id}`, used: data.license_key?.activation_usage, limit: data.license_key?.activation_limit };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Frees the slot used by a license code. */
|
|
123
|
+
export async function deactivate(code, { fetchImpl = fetch } = {}) {
|
|
124
|
+
const { key, instanceId } = parseCode(code);
|
|
125
|
+
if (!instanceId) return { ok: false, reason: "Give the license code that `driftline activate` printed (it contains a colon)." };
|
|
126
|
+
let data = null;
|
|
127
|
+
try {
|
|
128
|
+
data = await (await call(fetchImpl, "deactivate", { license_key: key, instance_id: instanceId })).json();
|
|
129
|
+
} catch (e) {
|
|
130
|
+
return { ok: false, reason: `Could not reach Lemon Squeezy: ${e.message}` };
|
|
131
|
+
}
|
|
132
|
+
return data?.deactivated ? { ok: true } : { ok: false, reason: `Not deactivated: ${data?.error || "unknown error"}` };
|
|
133
|
+
}
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// MCP server (stdio): lets coding agents ask Driftline about model retirements.
|
|
2
|
+
// npx -y github:AI-Driftline/driftline mcp
|
|
3
|
+
// Speaks JSON-RPC 2.0, one message per line on stdin/stdout (the MCP stdio transport). No dependencies.
|
|
4
|
+
// Data: the live registry at aidriftline.com/models.json (updated daily), or the copy bundled with this
|
|
5
|
+
// version when the site can't be reached or is older. Set DRIFTLINE_OFFLINE=1 to never fetch.
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
import { createInterface } from "node:readline";
|
|
9
|
+
import { REGISTRY_PATH, buildRegistry, classify, normalizeId } from "./registry.js";
|
|
10
|
+
import { scanDirectory } from "./scan.js";
|
|
11
|
+
|
|
12
|
+
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
13
|
+
const LIVE_URL = "https://aidriftline.com/models.json";
|
|
14
|
+
const page = (id) => `https://aidriftline.com/models/${id}/`;
|
|
15
|
+
const WHO = { openai: "OpenAI", anthropic: "Anthropic", google: "Google" };
|
|
16
|
+
|
|
17
|
+
export async function loadBestRegistry({ fetchImpl = fetch, offline = process.env.DRIFTLINE_OFFLINE === "1" } = {}) {
|
|
18
|
+
const bundled = JSON.parse(readFileSync(REGISTRY_PATH, "utf8"));
|
|
19
|
+
if (offline) return { reg: buildRegistry(bundled), source: "bundled" };
|
|
20
|
+
try {
|
|
21
|
+
const res = await fetchImpl(LIVE_URL, { signal: AbortSignal.timeout(4000) });
|
|
22
|
+
const live = res.ok ? await res.json() : null;
|
|
23
|
+
if (live?.models?.length && live.checked >= bundled.checked) return { reg: buildRegistry(live), source: "live" };
|
|
24
|
+
} catch { /* offline or slow: use the bundled copy */ }
|
|
25
|
+
return { reg: buildRegistry(bundled), source: "bundled" };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// One plain sentence an agent can pass on to the user.
|
|
29
|
+
function advice(c) {
|
|
30
|
+
const m = c.entry;
|
|
31
|
+
if (!m) return "Not in Driftline's registry: no retirement is announced for it on the OpenAI, Anthropic or Google deprecation pages (or it is not a model ID).";
|
|
32
|
+
const who = WHO[m.provider] || m.provider;
|
|
33
|
+
const repl = m.replacement ? ` Replacement: ${m.replacement}.` : "";
|
|
34
|
+
if (m.qualifier === "not-before") return `Active. ${who} says it will not be retired before ${m.retires}.`;
|
|
35
|
+
if (m.routes) return c.days <= 0
|
|
36
|
+
? `Since ${m.retires}, ${who} answers requests for it with ${m.replacement}. No error, but answers come from a different model.`
|
|
37
|
+
: `From ${m.retires} (in ${c.days} days), ${who} will answer requests for it with ${m.replacement}.`;
|
|
38
|
+
if (m.qualifier === "earliest") return c.days <= 0
|
|
39
|
+
? `${who} listed ${m.retires} as the earliest shutdown date; it can stop working at any time now.${repl}`
|
|
40
|
+
: `${who} may shut it down from ${m.retires} (in ${c.days} days).${repl}`;
|
|
41
|
+
return c.days <= 0 ? `Retired on ${m.retires}. Calls to it fail.${repl}` : `Retires on ${m.retires} (in ${c.days} days). Calls fail after that.${repl}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function describe(rawId, reg, today) {
|
|
45
|
+
const id = normalizeId(rawId);
|
|
46
|
+
const c = classify(id, reg, { today });
|
|
47
|
+
const m = c.entry;
|
|
48
|
+
return {
|
|
49
|
+
id: rawId, ...(id !== rawId ? { normalized: id } : {}),
|
|
50
|
+
status: c.status, ...(m ? {
|
|
51
|
+
provider: m.provider, retires: m.retires, qualifier: m.qualifier, days_left: c.days,
|
|
52
|
+
replacement: m.replacement || null, routes: !!m.routes, ...(m.aliasOf ? { alias_of: m.aliasOf } : {}), page: page(m.aliasOf || m.id),
|
|
53
|
+
} : {}),
|
|
54
|
+
advice: advice(c),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const TOOLS = [
|
|
59
|
+
{
|
|
60
|
+
name: "check_models",
|
|
61
|
+
description: "Check AI model IDs (OpenAI, Anthropic, Google) for retirement: status, retirement date, replacement. Use before writing or keeping a model ID in code. Accepts provider spellings such as models/gemini-2.5-pro, openai/gpt-4o or Bedrock IDs.",
|
|
62
|
+
inputSchema: { type: "object", properties: { ids: { type: "array", items: { type: "string" }, minItems: 1, description: "Model IDs, e.g. [\"gpt-4o\", \"claude-sonnet-4-5\"]" } }, required: ["ids"] },
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "scan_project",
|
|
66
|
+
description: "Scan a project folder for AI model IDs in code and config and report the ones that are retired, retiring or being re-routed, with file and line. Pass the absolute path of the project if unsure what the current directory is.",
|
|
67
|
+
inputSchema: { type: "object", properties: {
|
|
68
|
+
path: { type: "string", description: "Folder to scan (default: the server's current directory)" },
|
|
69
|
+
include_ok: { type: "boolean", description: "Also list models with no retirement announced (default false)" },
|
|
70
|
+
} },
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "upcoming_retirements",
|
|
74
|
+
description: "List AI models retiring or being re-routed in the next N days, soonest first. Optionally filter by provider.",
|
|
75
|
+
inputSchema: { type: "object", properties: {
|
|
76
|
+
days: { type: "number", description: "Look-ahead window in days (default 90)" },
|
|
77
|
+
provider: { type: "string", enum: ["openai", "anthropic", "google"] },
|
|
78
|
+
} },
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
export async function callTool(name, args = {}, { reg, source, today = new Date() }) {
|
|
83
|
+
const meta = { registry_checked: reg.checked, registry_source: source, all_dates: "https://aidriftline.com/models/" };
|
|
84
|
+
if (name === "check_models") {
|
|
85
|
+
const ids = Array.isArray(args.ids) ? args.ids.filter((x) => typeof x === "string").slice(0, 100) : [];
|
|
86
|
+
if (!ids.length) throw new Error("ids must be a non-empty array of model ID strings");
|
|
87
|
+
return { models: ids.map((id) => describe(id, reg, today)), ...meta };
|
|
88
|
+
}
|
|
89
|
+
if (name === "scan_project") {
|
|
90
|
+
const root = resolve(args.path || ".");
|
|
91
|
+
const hits = scanDirectory(root, { known: reg.byId });
|
|
92
|
+
const rows = hits.map((h) => ({ file: h.file.split("\\").join("/"), line: h.line, in_docs: h.doc, ...describe(h.id, reg, today) }))
|
|
93
|
+
.filter((r) => args.include_ok || !["active", "unknown"].includes(r.status));
|
|
94
|
+
const order = { retired: 0, "at-risk": 1, deprecated: 2, active: 3, unknown: 4 };
|
|
95
|
+
rows.sort((a, b) => order[a.status] - order[b.status] || (a.days_left ?? 1e9) - (b.days_left ?? 1e9));
|
|
96
|
+
return { scanned: root, model_references: hits.length, flagged: rows.length, findings: rows.slice(0, 200),
|
|
97
|
+
...(rows.length > 200 ? { truncated: true } : {}), ...meta };
|
|
98
|
+
}
|
|
99
|
+
if (name === "upcoming_retirements") {
|
|
100
|
+
const days = Number.isFinite(args.days) && args.days > 0 ? Math.min(args.days, 3650) : 90;
|
|
101
|
+
const list = reg.models
|
|
102
|
+
.filter((m) => m.qualifier !== "not-before" && (!args.provider || m.provider === args.provider))
|
|
103
|
+
.map((m) => describe(m.id, reg, today))
|
|
104
|
+
.filter((r) => r.days_left > 0 && r.days_left <= days)
|
|
105
|
+
.sort((a, b) => a.days_left - b.days_left || a.id.localeCompare(b.id));
|
|
106
|
+
return { days, count: list.length, models: list, ...meta };
|
|
107
|
+
}
|
|
108
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function startServer({ input = process.stdin, output = process.stdout, load = loadBestRegistry } = {}) {
|
|
112
|
+
let registry = null;
|
|
113
|
+
const getRegistry = () => (registry ||= load());
|
|
114
|
+
const send = (msg) => output.write(JSON.stringify({ jsonrpc: "2.0", ...msg }) + "\n");
|
|
115
|
+
|
|
116
|
+
async function handle(msg) {
|
|
117
|
+
const { id, method, params } = msg;
|
|
118
|
+
switch (method) {
|
|
119
|
+
case "initialize":
|
|
120
|
+
getRegistry(); // start the download early
|
|
121
|
+
return { protocolVersion: params?.protocolVersion || "2025-06-18", capabilities: { tools: {} },
|
|
122
|
+
serverInfo: { name: "driftline", version: VERSION },
|
|
123
|
+
instructions: "Driftline knows when OpenAI, Anthropic and Google retire their models (from their official deprecation pages, checked daily). Call check_models before adding or keeping a model ID in code, and scan_project to audit a codebase." };
|
|
124
|
+
case "ping": return {};
|
|
125
|
+
case "tools/list": return { tools: TOOLS };
|
|
126
|
+
case "tools/call": {
|
|
127
|
+
try {
|
|
128
|
+
const result = await callTool(params?.name, params?.arguments || {}, await getRegistry());
|
|
129
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], structuredContent: result };
|
|
130
|
+
} catch (e) {
|
|
131
|
+
return { content: [{ type: "text", text: e.message }], isError: true };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
default:
|
|
135
|
+
if (id === undefined) return undefined; // notifications need no answer
|
|
136
|
+
throw Object.assign(new Error(`Method not found: ${method}`), { code: -32601 });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const rl = createInterface({ input });
|
|
141
|
+
rl.on("line", async (line) => {
|
|
142
|
+
if (!line.trim()) return;
|
|
143
|
+
let msg;
|
|
144
|
+
try { msg = JSON.parse(line); } catch { return send({ id: null, error: { code: -32700, message: "Parse error" } }); }
|
|
145
|
+
if (msg.id === undefined) { handle(msg).catch(() => {}); return; }
|
|
146
|
+
try { send({ id: msg.id, result: await handle(msg) }); }
|
|
147
|
+
catch (e) { send({ id: msg.id, error: { code: e.code || -32603, message: e.message } }); }
|
|
148
|
+
});
|
|
149
|
+
return rl;
|
|
150
|
+
}
|