@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/src/prompts.js ADDED
@@ -0,0 +1,98 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { normalizeId } from "./registry.js";
3
+
4
+ /**
5
+ * Load the prompt file. JSON is parsed as-is. YAML supports the documented subset:
6
+ *
7
+ * prompts:
8
+ * - id: invoice-json
9
+ * model: gpt-4o-2024-05-13 # optional
10
+ * compare_to: gpt-5.6-sol # optional; defaults to the registry replacement
11
+ * expect: json # optional: json | text
12
+ * system: You extract invoice fields.
13
+ * prompt: |
14
+ * Extract the fields from this invoice as JSON:
15
+ * ...
16
+ *
17
+ * Block values can be literal (|) or folded (>), with optional chomping (+ or -).
18
+ */
19
+ export function loadPrompts(path) {
20
+ if (!existsSync(path)) return null;
21
+ const text = readFileSync(path, "utf8");
22
+ const data = path.toLowerCase().endsWith(".json") ? JSON.parse(text) : parseYamlSubset(text);
23
+ const list = Array.isArray(data) ? data : data?.prompts;
24
+ if (!Array.isArray(list)) throw new Error(`${path}: expected a "prompts:" list`);
25
+ return list.map((p, i) => {
26
+ if (!p.prompt) throw new Error(`${path}: prompt #${i + 1} has no "prompt" field`);
27
+ return {
28
+ ...p,
29
+ id: String(p.id || `prompt-${i + 1}`).replace(/[\r\n]+/g, " "),
30
+ expect: String(p.expect || "text").toLowerCase(),
31
+ ...(p.model ? { model: normalizeId(String(p.model).replace(/\s+/g, "")) } : {}),
32
+ ...(p.compare_to ? { compare_to: normalizeId(String(p.compare_to).replace(/\s+/g, "")) } : {}),
33
+ };
34
+ });
35
+ }
36
+
37
+ export function parseYamlSubset(text) {
38
+ const lines = text.replace(/\t/g, " ").split(/\r?\n/);
39
+ const out = { prompts: [] };
40
+ let item = null;
41
+ let block = null; // { key, indent, lines }
42
+
43
+ const flushBlock = () => {
44
+ if (block && item) {
45
+ const lines = block.lines.join("\n").replace(/\n+$/, "").split("\n");
46
+ // Folded (>): lines join with spaces; a blank line starts a new paragraph.
47
+ item[block.key] = block.folded
48
+ ? lines.join("\n").split(/\n{2,}/).map((para) => para.split("\n").join(" ")).join("\n")
49
+ : lines.join("\n");
50
+ }
51
+ block = null;
52
+ };
53
+ const scalar = (v) => {
54
+ v = v.trim();
55
+ let q = v.match(/^"((?:[^"\\]|\\.)*)"\s*(?:#.*)?$/);
56
+ if (q) return JSON.parse(`"${q[1]}"`);
57
+ q = v.match(/^'((?:[^']|'')*)'\s*(?:#.*)?$/);
58
+ if (q) return q[1].replace(/''/g, "'");
59
+ return v.replace(/\s+#.*$/, "").trim();
60
+ };
61
+
62
+ for (const raw of lines) {
63
+ const indent = raw.match(/^ */)[0].length;
64
+ const line = raw.trim();
65
+ if (block) {
66
+ if (block.indent === null && line !== "") {
67
+ if (indent > block.keyCol) block.indent = indent;
68
+ }
69
+ if (line === "" || (block.indent !== null && indent >= block.indent)) {
70
+ block.lines.push(line === "" ? "" : raw.slice(block.indent));
71
+ continue;
72
+ }
73
+ flushBlock();
74
+ }
75
+ if (line === "" || line.startsWith("#")) continue;
76
+ if (indent === 0 && /^(---|\.\.\.)(\s.*)?$/.test(line)) continue; // YAML document markers
77
+ if (/^prompts:\s*(\[\s*\])?\s*(#.*)?$/.test(line) && indent === 0) continue;
78
+
79
+ let m = line.match(/^-\s+([\w-]+):\s*(.*)$/);
80
+ if (m) {
81
+ item = {};
82
+ out.prompts.push(item);
83
+ } else {
84
+ m = line.match(/^([\w-]+):\s*(.*)$/);
85
+ if (!m || !item) throw new Error(`Cannot read prompt file line: "${raw}"`);
86
+ }
87
+ const [, key, value] = m;
88
+ const blockStart = value.replace(/\s+#.*$/, "").match(/^([|>])[+-]?$/);
89
+ if (blockStart) {
90
+ const keyCol = indent + (raw.trimStart().startsWith("-") ? 2 : 0);
91
+ block = { key, keyCol, indent: null, lines: [], folded: blockStart[1] === ">" };
92
+ } else {
93
+ item[key] = scalar(value);
94
+ }
95
+ }
96
+ flushBlock();
97
+ return out;
98
+ }
@@ -0,0 +1,100 @@
1
+ // Calls each provider with the customer's own API key. Keys never leave their CI runner.
2
+
3
+ export function providerOf(model) {
4
+ if (/^claude-/.test(model)) return "anthropic";
5
+ if (/^(gemini-|text-embedding-0)/.test(model)) return "google"; // OpenAI's are text-embedding-3-* / -ada-*
6
+ return "openai";
7
+ }
8
+
9
+ const KEY_ENV = { openai: "OPENAI_API_KEY", anthropic: "ANTHROPIC_API_KEY", google: "GEMINI_API_KEY" };
10
+
11
+ export function keyFor(provider, env = process.env) {
12
+ return env[KEY_ENV[provider]] || (provider === "google" ? env.GOOGLE_API_KEY : undefined);
13
+ }
14
+
15
+ export function missingKeyMessage(provider) {
16
+ return `No API key for ${provider}. Add ${KEY_ENV[provider]} as a repository secret and pass it in "env".`;
17
+ }
18
+
19
+ const TIMEOUT_MS = 120_000;
20
+ const RETRY_STATUS = new Set([429, 500, 502, 503, 504, 529]); // busy or overloaded: worth one more try
21
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
22
+ export const retry = { delayMs: 5000 }; // tests set this to 0
23
+
24
+ async function postJson(fetchImpl, url, headers, body) {
25
+ try {
26
+ return await postOnce(fetchImpl, url, headers, body);
27
+ } catch (e) {
28
+ if (!RETRY_STATUS.has(e.status)) throw e;
29
+ await wait(Math.min(e.retryAfterMs ?? retry.delayMs, retry.delayMs ? 30_000 : 0));
30
+ return postOnce(fetchImpl, url, headers, body);
31
+ }
32
+ }
33
+
34
+ // An empty answer is not a "changed" answer: say why the model gave nothing.
35
+ function nothing(reason) {
36
+ throw new Error(`Empty answer${reason ? ` (${reason})` : ""}`);
37
+ }
38
+
39
+ async function postOnce(fetchImpl, url, headers, body) {
40
+ let res;
41
+ try {
42
+ res = await fetchImpl(url, {
43
+ method: "POST",
44
+ headers: { "content-type": "application/json", ...headers },
45
+ body: JSON.stringify(body),
46
+ signal: AbortSignal.timeout(TIMEOUT_MS), // a hung request must not hold the job for hours
47
+ });
48
+ } catch (e) {
49
+ if (e.name === "TimeoutError") throw new Error(`No answer after ${TIMEOUT_MS / 1000} seconds`);
50
+ throw e;
51
+ }
52
+ const text = await res.text();
53
+ let json;
54
+ try { json = JSON.parse(text); } catch { json = null; }
55
+ if (!res.ok) {
56
+ const msg = json?.error?.message || text.slice(0, 300);
57
+ const err = new Error(`HTTP ${res.status}: ${String(msg).replace(/\s+/g, " ").slice(0, 300)}`);
58
+ err.status = res.status;
59
+ const ra = Number(res.headers?.get?.("retry-after"));
60
+ if (ra > 0) err.retryAfterMs = ra * 1000;
61
+ throw err;
62
+ }
63
+ return json;
64
+ }
65
+
66
+ export async function complete({ model, prompt, system, env = process.env, fetchImpl = fetch }) {
67
+ const provider = providerOf(model);
68
+ const key = keyFor(provider, env);
69
+ if (!key) throw new Error(missingKeyMessage(provider));
70
+
71
+ if (provider === "openai") {
72
+ const data = await postJson(fetchImpl, "https://api.openai.com/v1/responses",
73
+ { authorization: `Bearer ${key}` },
74
+ { model, input: prompt, ...(system ? { instructions: system } : {}) });
75
+ const text = typeof data.output_text === "string" ? data.output_text : (data.output || [])
76
+ .flatMap((o) => o.content || [])
77
+ .filter((c) => c.type === "output_text" || typeof c.text === "string")
78
+ .map((c) => c.text)
79
+ .join("");
80
+ const refusal = (data.output || []).flatMap((o) => o.content || []).find((c) => c.type === "refusal");
81
+ return text.trim() ? text : nothing(refusal ? "refused" : data.incomplete_details?.reason || data.status);
82
+ }
83
+
84
+ if (provider === "anthropic") {
85
+ const data = await postJson(fetchImpl, "https://api.anthropic.com/v1/messages",
86
+ { "x-api-key": key, "anthropic-version": "2023-06-01" },
87
+ { model, max_tokens: 4096, ...(system ? { system } : {}), messages: [{ role: "user", content: prompt }] });
88
+ const text = (data.content || []).filter((c) => c.type === "text").map((c) => c.text).join("");
89
+ if (data.stop_reason === "max_tokens") throw new Error("Answer cut off at 4,096 tokens; shorten the prompt or ask for less");
90
+ return text.trim() ? text : nothing(data.stop_reason);
91
+ }
92
+
93
+ const data = await postJson(fetchImpl,
94
+ `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`,
95
+ { "x-goog-api-key": key },
96
+ { contents: [{ role: "user", parts: [{ text: prompt }] }], ...(system ? { systemInstruction: { parts: [{ text: system }] } } : {}) });
97
+ const c = data.candidates?.[0];
98
+ const text = (c?.content?.parts || []).map((p) => p.text || "").join("");
99
+ return text.trim() ? text : nothing(c?.finishReason || data.promptFeedback?.blockReason);
100
+ }
@@ -0,0 +1,90 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { dirname, join } from "node:path";
4
+
5
+ const here = dirname(fileURLToPath(import.meta.url));
6
+ const DAY = 24 * 60 * 60 * 1000;
7
+
8
+ export const REGISTRY_PATH = join(here, "..", "registry", "models.json");
9
+
10
+ export function loadRegistry(path = REGISTRY_PATH) {
11
+ return buildRegistry(JSON.parse(readFileSync(path, "utf8")));
12
+ }
13
+
14
+ // Same as loadRegistry, from already-parsed JSON (e.g. the live copy at aidriftline.com/models.json).
15
+ export function buildRegistry(data) {
16
+ const byId = new Map(data.models.map((m) => [m.id, { ...m }]));
17
+ // Providers sometimes name a replacement that is itself being retired (o1-mini -> o4-mini -> ...).
18
+ // Follow the chain so customers are pointed at a model that stays.
19
+ for (const m of byId.values()) {
20
+ if (!m.replacement) continue;
21
+ const seen = new Set([m.id]);
22
+ let next = m.replacement;
23
+ seen.add(next);
24
+ while (byId.get(next)?.replacement && byId.get(next).qualifier !== "not-before" && !seen.has(byId.get(next).replacement)) {
25
+ next = byId.get(next).replacement;
26
+ seen.add(next);
27
+ }
28
+ if (next !== m.replacement && next !== m.id) { m.via = m.replacement; m.replacement = next; }
29
+ }
30
+ // Anthropic short names point at a dated snapshot: "claude-sonnet-4-5" and "-latest" -> claude-sonnet-4-5-20250929,
31
+ // "claude-sonnet-4-0" -> claude-sonnet-4-20250514. The deprecation page lists only snapshots, so a short name
32
+ // is added only when every snapshot behind it shares one date. Aliases go in the lookup, not in `models`.
33
+ const groups = new Map();
34
+ for (const m of byId.values()) {
35
+ const base = m.provider === "anthropic" && m.id.match(/^(claude-.+)-\d{8}$/)?.[1];
36
+ if (base) groups.set(base, [...(groups.get(base) || []), m]);
37
+ }
38
+ const lookup = new Map(byId);
39
+ for (const [base, snaps] of groups) {
40
+ if (snaps.some((s) => s.retires !== snaps[0].retires || s.qualifier !== snaps[0].qualifier)) continue;
41
+ const latest = snaps.reduce((a, b) => (a.id > b.id ? a : b));
42
+ const names = [base, `${base}-latest`, ...(/-[a-z]+-\d$/.test(base) ? [`${base}-0`] : [])];
43
+ for (const n of names) if (!lookup.has(n)) lookup.set(n, { ...latest, aliasOf: latest.id });
44
+ }
45
+ return { ...data, models: [...byId.values()], byId: lookup };
46
+ }
47
+
48
+ // Strip prefixes people put in front of model IDs, e.g. "models/gemini-2.0-flash" or "openai/gpt-4",
49
+ // and map cloud-platform spellings to the provider's ID:
50
+ // Amazon Bedrock "us.anthropic.claude-3-5-sonnet-20240620-v1:0" -> "claude-3-5-sonnet-20240620"
51
+ // Google Vertex "claude-3-5-sonnet@20240620" -> "claude-3-5-sonnet-20240620"
52
+ // Bedrock ARN "arn:aws:bedrock:...:foundation-model/anthropic.claude-..." -> "claude-..."
53
+ // Router prefixes "openrouter/openai/gpt-4", "vertex_ai/gemini-2.0-flash" -> plain ID (not azure/: Azure retires on its own dates)
54
+ export function normalizeId(raw) {
55
+ let id = raw.trim().toLowerCase();
56
+ if (id.startsWith("arn:aws:bedrock")) id = id.slice(id.lastIndexOf("/") + 1); // foundation-model/… or inference-profile/…
57
+ let prev;
58
+ do { prev = id; id = id.replace(/^(models|openai|anthropic|google|gemini|vertex_ai|bedrock|openrouter)\//, ""); } while (id !== prev);
59
+ const bedrock = /^(?:(?:us|eu|apac|global)\.)?anthropic\./;
60
+ if (bedrock.test(id)) id = id.replace(bedrock, "").replace(/-v\d+(?::\d+)?$/, "");
61
+ id = id.replace(/(?:-v\d+)?@(\d{8})$/, "-$1"); // Vertex: claude-3-5-sonnet-v2@20241022
62
+ return id.startsWith("claude-") ? id.replace(/(\d)\.(\d)/g, "$1-$2") : id; // OpenRouter: claude-sonnet-4.5
63
+ }
64
+
65
+ // Is this string shaped like a model ID from one of the three providers?
66
+ // IDs listed in the registry (e.g. "whisper-1", "dall-e-3", "imagen-4.0-generate-001") always count.
67
+ export function looksLikeModelId(id, known) {
68
+ if (known && known.has(id)) return true;
69
+ return /^(gpt-\d|gpt-[a-z]|chatgpt-|o[1-9](-|$)|claude-|gemini-|text-embedding-|davinci-|babbage-)/.test(id);
70
+ }
71
+
72
+ // Only text models can be drift-tested; audio, image, video, embedding and the rest are scan-only.
73
+ export const isTextModel = (entry) => !entry?.kind || entry.kind === "text";
74
+
75
+ /**
76
+ * Classify one model ID against the registry on a given date.
77
+ * status: retired | at-risk | deprecated | active | unknown
78
+ */
79
+ export function classify(id, registry, { today = new Date(), warnDays = 90 } = {}) {
80
+ const entry = registry.byId.get(id);
81
+ if (!entry) return { id, status: "unknown" };
82
+ const retires = new Date(entry.retires + "T00:00:00Z");
83
+ const days = Math.ceil((retires - today) / DAY);
84
+ let status;
85
+ if (entry.qualifier === "not-before") status = "active";
86
+ else if (days <= 0) status = entry.qualifier === "earliest" ? "at-risk" : "retired"; // Google only gives earliest dates
87
+ else if (days <= warnDays) status = "at-risk";
88
+ else status = "deprecated";
89
+ return { id, status, days, entry };
90
+ }
package/src/report.js ADDED
@@ -0,0 +1,102 @@
1
+ const STATUS_LABEL = {
2
+ retired: "🔴 Retired",
3
+ "at-risk": "🟠 Retires soon",
4
+ deprecated: "🟡 Deprecated",
5
+ active: "🟢 Active",
6
+ unknown: "⚪ No retirement listed",
7
+ };
8
+ const RESULT_LABEL = { same: "✅ Same", changed: "🟡 Changed", broken: "🔴 Broken", error: "⚠️ Error", skipped: "⏭️ Skipped" };
9
+
10
+ // Google gives only earliest dates; once one has passed the model may be gone already, so "retires soon" would be wrong.
11
+ // Routed models keep answering after the date, but from the replacement model ("retired" would be wrong too).
12
+ const label = (c) => c.status === "at-risk" && c.entry?.qualifier === "earliest" && c.days <= 0
13
+ ? "🟠 May already be off"
14
+ : c.status === "retired" && c.entry?.routes ? "🔴 Now answered by the replacement"
15
+ : c.status === "at-risk" && c.entry?.routes ? "🟠 Switches model soon"
16
+ : STATUS_LABEL[c.status];
17
+
18
+ function when(c) {
19
+ if (!c.entry) return "";
20
+ const q = c.entry.routes ? " (routing starts)" : c.entry.qualifier === "earliest" ? " (earliest)" : c.entry.qualifier === "not-before" ? " (not before)" : "";
21
+ const rel = c.days <= 0
22
+ ? (c.entry.qualifier === "earliest" ? `earliest shutdown passed ${-c.days} days ago` : `${-c.days} days ago`)
23
+ : `in ${c.days} days`;
24
+ return `${c.entry.retires}${q} · ${rel}`;
25
+ }
26
+
27
+ // Table cells: no pipes, no line breaks, no raw HTML (provider error pages, odd file names).
28
+ const esc = (s) => String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
29
+ .replace(/\|/g, "\\|").replace(/[\r\n]+/g, " ");
30
+ // Inside a code span HTML is shown as text, so only pipes, line breaks and backticks need handling.
31
+ const code = (s) => `\`${String(s ?? "").replace(/[\r\n]+/g, " ").replace(/`/g, "'").replace(/\|/g, "\\|")}\``;
32
+
33
+ // A code fence longer than any run of backticks inside the text, so a model's own ```json fence can't close it.
34
+ function fenced(text) {
35
+ const longest = Math.max(0, ...(String(text).match(/`+/g) || []).map((r) => r.length));
36
+ const fence = "`".repeat(Math.max(3, longest + 1));
37
+ return [fence, text, fence];
38
+ }
39
+
40
+ // GitHub issues hold at most 65,536 characters; keep the report well under that.
41
+ const MAX_REPORT = 60000;
42
+ const MAX_ANSWER = 1500;
43
+ const MAX_ROWS = 150;
44
+ const clip = (s) => { s = String(s || ""); return s.length > MAX_ANSWER ? s.slice(0, MAX_ANSWER) + "\n[…cut]" : s; };
45
+
46
+ export function renderReport({ findings, drift, registry, notices = [], answers = true }) {
47
+ const lines = ["## Driftline report", ""];
48
+ const flagged = findings.filter((f) => ["retired", "at-risk", "deprecated"].includes(f.status));
49
+ const byModel = new Map();
50
+ for (const f of findings) {
51
+ if (!byModel.has(f.id)) byModel.set(f.id, { ...f, places: [], docs: 0 });
52
+ byModel.get(f.id).places.push(code(`${f.file}:${f.line}`));
53
+ if (f.doc) byModel.get(f.id).docs++;
54
+ }
55
+
56
+ if (!findings.length) {
57
+ lines.push("No AI model IDs found in this repository.");
58
+ } else {
59
+ const n = flagged.length;
60
+ lines.push(n
61
+ ? `**${n} reference${n === 1 ? "" : "s"} to retired or retiring models** across ${byModel.size} model ID${byModel.size === 1 ? "" : "s"} found.`
62
+ : `Found ${byModel.size} model ID${byModel.size === 1 ? "" : "s"}. None are scheduled for retirement.`);
63
+ lines.push("", "| Model | Status | Retirement | Replacement | Where |", "|---|---|---|---|---|");
64
+ const order = ["retired", "at-risk", "deprecated", "active", "unknown"];
65
+ const all = [...byModel.values()].sort((a, b) => order.indexOf(a.status) - order.indexOf(b.status));
66
+ const rows = all.slice(0, MAX_ROWS); // flagged models sort first, so only unknown/active rows get cut
67
+ for (const r of rows) {
68
+ const places = r.places.slice(0, 3).join(", ") + (r.places.length > 3 ? ` +${r.places.length - 3} more` : "");
69
+ const docsOnly = r.places.length && byModel.get(r.id).docs === r.places.length ? " · docs only" : "";
70
+ lines.push(`| ${code(r.id)}${r.entry?.aliasOf ? " · alias of " + code(r.entry.aliasOf) : ""}${r.entry?.kind && r.entry.kind !== "text" ? " · " + r.entry.kind : ""} | ${label(r)}${docsOnly} | ${esc(when(r))} | ${r.entry?.replacement ? code(r.entry.replacement) : ""} | ${places} |`);
71
+ }
72
+ if (all.length > rows.length) lines.push("", `_${all.length - rows.length} more model ID(s) with no retirement listed are not shown._`);
73
+ }
74
+
75
+ if (drift?.length) {
76
+ lines.push("", "### Drift test", "", "| Prompt | Current → replacement | Similarity | Result | Note |", "|---|---|---|---|---|");
77
+ for (const d of drift) {
78
+ const score = typeof d.score === "number" ? d.score.toFixed(2) : "";
79
+ lines.push(`| ${esc(d.id)} | ${code(d.from)} → ${code(d.to)} | ${score} | ${RESULT_LABEL[d.result] || d.result} | ${esc(d.note)} |`);
80
+ }
81
+ const details = drift.filter((d) => d.before !== undefined);
82
+ if (details.length && !answers) {
83
+ lines.push("", "_Answers are not included here. They are in the job summary of the workflow run._");
84
+ } else if (details.length) {
85
+ lines.push("", "<details><summary>Answers</summary>", "");
86
+ let shown = 0;
87
+ for (const d of details) {
88
+ const block = [`#### ${esc(d.id)}`, "", code(d.from), "", ...fenced(clip(d.before)), "", code(d.to), "", ...fenced(clip(d.after)), ""];
89
+ if (lines.join("\n").length + block.join("\n").length > MAX_REPORT - 2000) break;
90
+ lines.push(...block);
91
+ shown++;
92
+ }
93
+ if (shown < details.length) lines.push(`_${details.length - shown} more answer(s) left out to stay within GitHub's size limit._`, "");
94
+ lines.push("</details>");
95
+ }
96
+ }
97
+
98
+ if (notices.length) lines.push("", ...notices.map((n) => `> ${n}`));
99
+ lines.push("", `<sub>Retirement dates checked against official provider pages on ${registry.checked}. ` +
100
+ `Sources: [OpenAI](${registry.sources.openai}) · [Anthropic](${registry.sources.anthropic}) · [Google](${registry.sources.google}).</sub>`);
101
+ return lines.join("\n");
102
+ }
package/src/run.js ADDED
@@ -0,0 +1,133 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join, relative, sep } from "node:path";
3
+ import { loadRegistry, classify, isTextModel } from "./registry.js";
4
+ import { scanDirectory } from "./scan.js";
5
+ import { loadPrompts } from "./prompts.js";
6
+ import { complete, providerOf, keyFor, missingKeyMessage } from "./providers.js";
7
+ import { compare, extractJson } from "./compare.js";
8
+ import { checkLicense } from "./license.js";
9
+ import { renderReport } from "./report.js";
10
+
11
+ const MAX_PROMPTS = 20;
12
+ const oneLine = (s) => String(s).replace(/[\r\n]+/g, " ");
13
+ const FLAGGED = new Set(["retired", "at-risk", "deprecated"]);
14
+
15
+ export async function run({
16
+ root = ".",
17
+ repoRoot = root, // the repository root: prompt file and report paths are relative to it
18
+ warnDays = 90,
19
+ promptsFile = ".driftline/prompts.yml",
20
+ licenseKey = "",
21
+ account = "", // GitHub owner the workflow runs in; the license must be activated for it
22
+ env = process.env,
23
+ fetchImpl = fetch,
24
+ today = new Date(),
25
+ registry = loadRegistry(),
26
+ log = () => {},
27
+ answers = true, // include model answers in the job summary
28
+ } = {}) {
29
+ const notices = [];
30
+ const prefix = relative(repoRoot, root).split(sep).join("/");
31
+ const raw = scanDirectory(root, { ignore: [".driftline/"], known: registry.byId });
32
+ const findings = raw.map((h) => ({
33
+ ...h,
34
+ file: prefix && !prefix.startsWith("..") ? `${prefix}/${h.file.split(sep).join("/")}` : h.file.split(sep).join("/"),
35
+ ...classify(h.id, registry, { today, warnDays }),
36
+ }));
37
+ log(`Scanned ${root}: ${findings.length} model reference(s).`);
38
+
39
+ let drift = [];
40
+ let prompts = null;
41
+ try {
42
+ // Repo root first; a monorepo may keep the file next to the scanned folder instead.
43
+ const file = [join(repoRoot, promptsFile), join(root, promptsFile)].find((f) => existsSync(f));
44
+ prompts = file ? loadPrompts(file) : null;
45
+ } catch (e) {
46
+ notices.push(`Could not read ${promptsFile}: ${e.message}. The scan still ran; drift tests were skipped.`);
47
+ }
48
+ if (prompts && !prompts.length) {
49
+ notices.push(`${promptsFile} has no prompts in it, so drift tests were skipped.`);
50
+ } else if (prompts?.length) {
51
+ try {
52
+ const lic = await checkLicense(licenseKey, { fetchImpl, account });
53
+ if (lic.reason) notices.push(lic.reason);
54
+ if (lic.ok) drift = await runDrift({ prompts, findings, registry, env, fetchImpl, today, warnDays, notices, log });
55
+ } catch (e) {
56
+ // Drift tests are extra; the free scan must still produce its report.
57
+ notices.push(`Drift tests stopped: ${e.message}. The scan results above are complete.`);
58
+ }
59
+ } else if (findings.some((f) => FLAGGED.has(f.status))) {
60
+ notices.push(`Add ${promptsFile} to test your real prompts on the replacement model before you switch.`);
61
+ }
62
+
63
+ const atRisk = findings.filter((f) => !f.doc && (f.status === "retired" || f.status === "at-risk")).length;
64
+ const markdown = renderReport({ findings, drift, registry, notices, answers });
65
+ // The issue never carries model answers: in a public repository anyone can read it.
66
+ const issueMarkdown = renderReport({ findings, drift, registry, notices, answers: false });
67
+ return { findings, drift, notices, atRisk, markdown, issueMarkdown };
68
+ }
69
+
70
+ async function runDrift({ prompts, findings, registry, env, fetchImpl, today, warnDays, notices, log }) {
71
+ if (prompts.length > MAX_PROMPTS) {
72
+ notices.push(`Only the first ${MAX_PROMPTS} prompts were tested.`);
73
+ prompts = prompts.slice(0, MAX_PROMPTS);
74
+ }
75
+ // Models in code (not docs) that are going away and have a known replacement, soonest first.
76
+ const flaggedModels = [...new Set(findings
77
+ .filter((f) => !f.doc && FLAGGED.has(f.status) && f.entry?.replacement && isTextModel(f.entry))
78
+ .sort((a, b) => a.days - b.days)
79
+ .map((f) => f.id))];
80
+ // A prompt without "model" is tested once, on the model that retires first: two API calls per prompt.
81
+ if (flaggedModels.length > 1 && prompts.some((p) => !p.model)) {
82
+ notices.push(`Prompts without "model" were tested on ${flaggedModels[0]}, the model that retires first. Set "model" on a prompt to test it on another one.`);
83
+ }
84
+ const out = [];
85
+
86
+ for (const p of prompts) {
87
+ const froms = p.model ? [p.model] : flaggedModels.slice(0, 1);
88
+ if (!froms.length) {
89
+ out.push({ id: p.id, from: "—", to: "—", result: "skipped", note: "No retiring model found and no \"model\" set for this prompt" });
90
+ continue;
91
+ }
92
+ for (const from of froms) {
93
+ const status = classify(from, registry, { today, warnDays });
94
+ const to = p.compare_to || status.entry?.replacement;
95
+ if (!to) {
96
+ out.push({ id: p.id, from, to: "—", result: "skipped", note: "No known replacement; set compare_to" });
97
+ continue;
98
+ }
99
+ const missing = [from, to].map(providerOf).find((pr) => !keyFor(pr, env));
100
+ if (missing) {
101
+ out.push({ id: p.id, from, to, result: "error", note: missingKeyMessage(missing) });
102
+ continue;
103
+ }
104
+ log(oneLine(`Drift test ${p.id}: ${from} → ${to}`));
105
+ try {
106
+ const after = await complete({ model: to, prompt: p.prompt, system: p.system, env, fetchImpl });
107
+ if (status.status === "retired") {
108
+ if (!after.trim()) {
109
+ out.push({ id: p.id, from, to, after, before: "(current model already retired)", result: "broken", note: "Replacement returned an empty answer" });
110
+ continue;
111
+ }
112
+ const jsonOk = p.expect !== "json" || extractJson(after).ok;
113
+ out.push({ id: p.id, from, to, after, before: "(current model already retired)", result: jsonOk ? "changed" : "broken",
114
+ note: jsonOk ? "Current model is retired; review the replacement's answer" : "Replacement did not return valid JSON" });
115
+ continue;
116
+ }
117
+ let before;
118
+ try {
119
+ before = await complete({ model: from, prompt: p.prompt, system: p.system, env, fetchImpl });
120
+ } catch (e) {
121
+ if (!/^(Empty answer|Answer cut off)/.test(e.message)) throw e;
122
+ // The current model gave nothing usable; the replacement's answer is still worth showing.
123
+ out.push({ id: p.id, from, to, after, before: `(${e.message})`, result: "changed", note: `Current model: ${e.message}. Review the replacement's answer` });
124
+ continue;
125
+ }
126
+ out.push({ id: p.id, from, to, before, after, ...compare(before, after, p.expect) });
127
+ } catch (e) {
128
+ out.push({ id: p.id, from, to, result: "error", note: e.message.slice(0, 200) });
129
+ }
130
+ }
131
+ }
132
+ return out;
133
+ }
package/src/scan.js ADDED
@@ -0,0 +1,82 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join, relative, extname, sep } from "node:path";
3
+ import { normalizeId, looksLikeModelId } from "./registry.js";
4
+
5
+ const SKIP_DIRS = new Set([
6
+ ".git", "node_modules", "vendor", "dist", "build", "out", ".next", ".nuxt",
7
+ "coverage", "__pycache__", ".venv", "venv", "target", ".turbo", ".cache",
8
+ ]);
9
+ const EXTENSIONS = new Set([
10
+ ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".py", ".rb", ".go", ".rs", ".java",
11
+ ".kt", ".php", ".cs", ".swift", ".json", ".yaml", ".yml", ".toml", ".env", ".ini",
12
+ ".cfg", ".md", ".txt", ".sh", ".tf", ".vue", ".svelte", ".astro", ".mts", ".cts", ".html",
13
+ ".jsonc", ".ipynb",
14
+ ]);
15
+ const MAX_BYTES = 1024 * 1024;
16
+ // Mentions in docs are reported but never fail the build (changelogs name old models on purpose).
17
+ const DOC_EXTENSIONS = new Set([".md", ".txt"]);
18
+ export const isDocFile = (file) => DOC_EXTENSIONS.has(extname(file).toLowerCase());
19
+
20
+ // Quoted strings: "gpt-4o", 'claude-3-haiku-20240307', `gemini-2.0-flash`
21
+ const QUOTED = /(["'`])([A-Za-z0-9][\w.\-:/@]{1,160})\1/g;
22
+ // Unquoted config values: MODEL=gpt-4o or model: claude-sonnet-4-5-20250929
23
+ // Bounded repeats keep this linear on long lines (minified files, generated data).
24
+ const ASSIGNED = /(?:^|[\s,{])[\w.\-]{0,40}model[\w.\-]{0,40}\s*[=:]\s*([A-Za-z0-9][\w.\-/]{1,80})\s*(?:#.*)?$/i;
25
+ const MAX_ASSIGNED_LINE = 2000;
26
+ // "o1", "o3": too short to trust without some sign the line is about a model.
27
+ const BARE_SHORT = /^o\d$/;
28
+ const MODEL_CONTEXT = /model|engine|llm|openai|reasoning/i;
29
+
30
+ export function* walk(root, dir = root) {
31
+ let entries;
32
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
33
+ for (const e of entries) {
34
+ const full = join(dir, e.name);
35
+ if (e.isDirectory()) {
36
+ if (!SKIP_DIRS.has(e.name)) yield* walk(root, full);
37
+ } else if (e.isFile()) {
38
+ const ext = extname(e.name).toLowerCase();
39
+ const isEnv = e.name.startsWith(".env");
40
+ const isDocker = /^dockerfile/i.test(e.name) || ext === ".dockerfile";
41
+ if (!EXTENSIONS.has(ext) && !isEnv && !isDocker) continue;
42
+ if (e.name === "package-lock.json" || e.name.endsWith(".lock")) continue;
43
+ try { if (statSync(full).size > MAX_BYTES) continue; } catch { continue; }
44
+ yield { full, rel: relative(root, full) };
45
+ }
46
+ }
47
+ }
48
+
49
+ export function scanText(text, file, known) {
50
+ const hits = [];
51
+ const doc = isDocFile(file);
52
+ const lines = text.split(/\r?\n/);
53
+ lines.forEach((raw, i) => {
54
+ // Escaped quotes (\"gpt-4\") appear in notebooks and JSON-in-JSON; read them as plain quotes.
55
+ const line = raw.replace(/\\(["'`])/g, "$1");
56
+ const seen = new Set();
57
+ for (const m of line.matchAll(QUOTED)) seen.add(m[2]);
58
+ if (line.length <= MAX_ASSIGNED_LINE && /model/i.test(line)) {
59
+ const a = line.match(ASSIGNED);
60
+ if (a) seen.add(a[1]);
61
+ }
62
+ const ids = [...seen].map(normalizeId).filter((id) => looksLikeModelId(id, known));
63
+ const context = MODEL_CONTEXT.test(line) || ids.some((id) => !BARE_SHORT.test(id)); // e.g. ["o1", "gpt-4o"]
64
+ for (const id of ids) {
65
+ if (BARE_SHORT.test(id) && !context) continue;
66
+ hits.push({ id, file, line: i + 1, doc, text: line.trim().slice(0, 160) });
67
+ }
68
+ });
69
+ return hits;
70
+ }
71
+
72
+ export function scanDirectory(root, { ignore = [], known } = {}) {
73
+ const hits = [];
74
+ for (const { full, rel } of walk(root)) {
75
+ if (ignore.some((p) => rel.split(sep).join("/").startsWith(p))) continue;
76
+ let text;
77
+ try { text = readFileSync(full, "utf8"); } catch { continue; }
78
+ if (text.includes("\u0000")) continue; // binary
79
+ hits.push(...scanText(text, rel, known));
80
+ }
81
+ return hits;
82
+ }