@aidriftline/driftline 0.1.13 → 0.1.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aidriftline/driftline",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Finds OpenAI, Anthropic and Google model IDs in your code and warns before they are retired. CLI, GitHub Action and MCP server.",
5
5
  "mcpName": "io.github.AI-Driftline/driftline",
6
6
  "type": "module",
@@ -1,5 +1,5 @@
1
1
  {
2
- "checked": "2026-11-11",
2
+ "checked": "2026-11-13",
3
3
  "note": "Hand-maintained from the official deprecation pages. Re-check before each release. Entries with a 'kind' other than text are scanned and reported but never drift-tested. gpt-4-1106-preview is listed on OpenAI's page under both 2026-03-26 and 2026-10-23; the later date is used so builds are not failed on a conflicting record. Entries with routes: true keep answering after the date, but the provider sends the requests to the replacement model (Google calls this automatic routing); the 'retires' date is the day routing starts. gemini-3.5-flash and gemini-3.7-flash already route to newer models with no date given, so they are left out.",
4
4
  "sources": {
5
5
  "openai": "https://developers.openai.com/api/docs/deprecations",
@@ -151,7 +151,9 @@
151
151
  "provider": "openai",
152
152
  "retires": "2026-12-11",
153
153
  "qualifier": "exact",
154
- "replacement": "gpt-5.6-sol",
154
+ "replacement": "gpt-6.1-sol",
155
+ "previous_replacement": "gpt-5.6-sol",
156
+ "replacement_changed": "2026-11-13",
155
157
  "announced": "2026-06-11"
156
158
  },
157
159
  {
@@ -159,7 +161,9 @@
159
161
  "provider": "openai",
160
162
  "retires": "2026-12-11",
161
163
  "qualifier": "exact",
162
- "replacement": "gpt-5.6-sol",
164
+ "replacement": "gpt-6.1-sol",
165
+ "previous_replacement": "gpt-5.6-sol",
166
+ "replacement_changed": "2026-11-13",
163
167
  "announced": "2026-06-11"
164
168
  },
165
169
  {
@@ -167,7 +171,10 @@
167
171
  "provider": "openai",
168
172
  "retires": "2026-12-11",
169
173
  "qualifier": "exact",
170
- "replacement": "gpt-5.6-sol",
174
+ "replacement": "gpt-6.1-sol",
175
+ "replacement_note": "reasoning.mode: pro, in the Responses API",
176
+ "previous_replacement": "gpt-5.6-sol",
177
+ "replacement_changed": "2026-11-13",
171
178
  "announced": "2026-06-11"
172
179
  },
173
180
  {
@@ -175,7 +182,10 @@
175
182
  "provider": "openai",
176
183
  "retires": "2026-12-11",
177
184
  "qualifier": "exact",
178
- "replacement": "gpt-5.6-sol",
185
+ "replacement": "gpt-6.1-sol",
186
+ "replacement_note": "reasoning.mode: pro, in the Responses API",
187
+ "previous_replacement": "gpt-5.6-sol",
188
+ "replacement_changed": "2026-11-13",
179
189
  "announced": "2026-06-11"
180
190
  },
181
191
  {
@@ -183,7 +193,9 @@
183
193
  "provider": "openai",
184
194
  "retires": "2026-12-11",
185
195
  "qualifier": "exact",
186
- "replacement": "gpt-5.6-sol",
196
+ "replacement": "gpt-6.1-sol",
197
+ "previous_replacement": "gpt-5.6-sol",
198
+ "replacement_changed": "2026-11-13",
187
199
  "announced": "2026-06-11"
188
200
  },
189
201
  {
@@ -191,7 +203,9 @@
191
203
  "provider": "openai",
192
204
  "retires": "2026-12-11",
193
205
  "qualifier": "exact",
194
- "replacement": "gpt-5.6-sol",
206
+ "replacement": "gpt-6.1-sol",
207
+ "previous_replacement": "gpt-5.6-sol",
208
+ "replacement_changed": "2026-11-13",
195
209
  "announced": "2026-06-11"
196
210
  },
197
211
  {
@@ -199,7 +213,10 @@
199
213
  "provider": "openai",
200
214
  "retires": "2026-12-11",
201
215
  "qualifier": "exact",
202
- "replacement": "gpt-5.6-sol",
216
+ "replacement": "gpt-6.1-sol",
217
+ "replacement_note": "reasoning.mode: pro, in the Responses API",
218
+ "previous_replacement": "gpt-5.6-sol",
219
+ "replacement_changed": "2026-11-13",
203
220
  "announced": "2026-06-11"
204
221
  },
205
222
  {
@@ -207,7 +224,10 @@
207
224
  "provider": "openai",
208
225
  "retires": "2026-12-11",
209
226
  "qualifier": "exact",
210
- "replacement": "gpt-5.6-sol",
227
+ "replacement": "gpt-6.1-sol",
228
+ "replacement_note": "reasoning.mode: pro, in the Responses API",
229
+ "previous_replacement": "gpt-5.6-sol",
230
+ "replacement_changed": "2026-11-13",
211
231
  "announced": "2026-06-11"
212
232
  },
213
233
  {
@@ -215,7 +235,9 @@
215
235
  "provider": "openai",
216
236
  "retires": "2026-12-11",
217
237
  "qualifier": "exact",
218
- "replacement": "gpt-5.6-terra",
238
+ "replacement": "gpt-6.1-terra",
239
+ "previous_replacement": "gpt-5.6-terra",
240
+ "replacement_changed": "2026-11-13",
219
241
  "announced": "2026-06-11"
220
242
  },
221
243
  {
@@ -223,7 +245,9 @@
223
245
  "provider": "openai",
224
246
  "retires": "2026-12-11",
225
247
  "qualifier": "exact",
226
- "replacement": "gpt-5.6-terra",
248
+ "replacement": "gpt-6.1-terra",
249
+ "previous_replacement": "gpt-5.6-terra",
250
+ "replacement_changed": "2026-11-13",
227
251
  "announced": "2026-06-11"
228
252
  },
229
253
  {
@@ -231,7 +255,9 @@
231
255
  "provider": "openai",
232
256
  "retires": "2026-12-11",
233
257
  "qualifier": "exact",
234
- "replacement": "gpt-5.6-luna",
258
+ "replacement": "gpt-6-luna",
259
+ "previous_replacement": "gpt-5.6-luna",
260
+ "replacement_changed": "2026-11-13",
235
261
  "announced": "2026-06-11"
236
262
  },
237
263
  {
@@ -239,7 +265,9 @@
239
265
  "provider": "openai",
240
266
  "retires": "2026-12-11",
241
267
  "qualifier": "exact",
242
- "replacement": "gpt-5.6-luna",
268
+ "replacement": "gpt-6-luna",
269
+ "previous_replacement": "gpt-5.6-luna",
270
+ "replacement_changed": "2026-11-13",
243
271
  "announced": "2026-06-11"
244
272
  },
245
273
  {
@@ -274,6 +302,14 @@
274
302
  "replacement": "gpt-5.6-terra",
275
303
  "announced": "2026-04-22"
276
304
  },
305
+ {
306
+ "id": "ft-o4-mini-2025-04-16",
307
+ "provider": "openai",
308
+ "retires": "2026-10-23",
309
+ "qualifier": "exact",
310
+ "replacement": "gpt-5.6-terra",
311
+ "announced": "2026-04-22"
312
+ },
277
313
  {
278
314
  "id": "gpt-4",
279
315
  "provider": "openai",
@@ -336,6 +372,7 @@
336
372
  "retires": "2026-10-23",
337
373
  "qualifier": "exact",
338
374
  "replacement": "gpt-5.6-sol",
375
+ "replacement_note": "reasoning.mode: pro",
339
376
  "announced": "2026-04-22"
340
377
  },
341
378
  {
@@ -344,6 +381,7 @@
344
381
  "retires": "2026-10-23",
345
382
  "qualifier": "exact",
346
383
  "replacement": "gpt-5.6-sol",
384
+ "replacement_note": "reasoning.mode: pro",
347
385
  "announced": "2026-04-22"
348
386
  },
349
387
  {
@@ -1936,6 +1974,46 @@
1936
1974
  "qualifier": "earliest",
1937
1975
  "replacement": "antigravity-preview-09-2026",
1938
1976
  "kind": "agent"
1977
+ },
1978
+ {
1979
+ "id": "gpt-3.5-turbo-completions",
1980
+ "provider": "openai",
1981
+ "retires": "2026-10-23",
1982
+ "qualifier": "exact",
1983
+ "replacement": "gpt-5.6-terra",
1984
+ "announced": "2026-04-22"
1985
+ },
1986
+ {
1987
+ "id": "gpt-4-completions",
1988
+ "provider": "openai",
1989
+ "retires": "2026-10-23",
1990
+ "qualifier": "exact",
1991
+ "replacement": "gpt-5.6-sol",
1992
+ "announced": "2026-04-22"
1993
+ },
1994
+ {
1995
+ "id": "gpt-4-0613-completions",
1996
+ "provider": "openai",
1997
+ "retires": "2026-10-23",
1998
+ "qualifier": "exact",
1999
+ "replacement": "gpt-5.6-sol",
2000
+ "announced": "2026-04-22"
2001
+ },
2002
+ {
2003
+ "id": "gpt-4-turbo-completions",
2004
+ "provider": "openai",
2005
+ "retires": "2026-10-23",
2006
+ "qualifier": "exact",
2007
+ "replacement": "gpt-5.6-sol",
2008
+ "announced": "2026-04-22"
2009
+ },
2010
+ {
2011
+ "id": "gpt-4-turbo-preview-completions",
2012
+ "provider": "openai",
2013
+ "retires": "2026-03-26",
2014
+ "qualifier": "exact",
2015
+ "replacement": "gpt-5",
2016
+ "announced": "2025-09-26"
1939
2017
  }
1940
2018
  ]
1941
2019
  }
package/src/cli.js CHANGED
@@ -7,6 +7,7 @@
7
7
  // driftline [directory] --json same scan, machine-readable (for scripts and coding agents)
8
8
  // driftline mcp MCP server on stdio for coding agents (Claude Code, Cursor, ...)
9
9
  // driftline --help | --version
10
+ import { loadBestRegistry } from "./registry.js";
10
11
  import { resolve } from "node:path";
11
12
  import { readFileSync } from "node:fs";
12
13
  import { execFileSync } from "node:child_process";
@@ -67,8 +68,10 @@ if (cmd === "-h" || cmd === "--help" || cmd === "help") {
67
68
  if (!r.ok) { console.error(r.reason); process.exitCode = 1; }
68
69
  else console.log("Deactivated. That slot is free for another GitHub account.");
69
70
  } else {
71
+ const { reg } = await loadBestRegistry();
70
72
  const result = await run({
71
73
  root: resolve(cmd || "."),
74
+ registry: reg,
72
75
  licenseKey: process.env.DRIFTLINE_LICENSE_KEY || "",
73
76
  account: process.env.GITHUB_REPOSITORY_OWNER || accountFromGit(),
74
77
  log: (m) => console.error(m),
package/src/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // GitHub Action entry point.
3
+ import { replacementText, replacementChange, loadBestRegistry } from "./registry.js";
3
4
  import { resolve } from "node:path";
4
5
  import { statSync } from "node:fs";
5
6
  import { randomUUID } from "node:crypto";
@@ -26,9 +27,12 @@ async function main() {
26
27
  failOn = "retired";
27
28
  }
28
29
 
30
+ const { reg, source } = await loadBestRegistry({ offline: input("registry", "live").toLowerCase() === "bundled" });
31
+ console.log(`Registry: ${source === "live" ? "live copy from aidriftline.com" : "copy bundled with this version"}, checked ${reg.checked}.`);
29
32
  const result = await run({
30
33
  root,
31
34
  repoRoot: workspace,
35
+ registry: reg,
32
36
  warnDays,
33
37
  promptsFile: input("prompts-file", ".driftline/prompts.yml"),
34
38
  licenseKey: input("license-key"),
@@ -48,7 +52,8 @@ async function main() {
48
52
  }
49
53
  for (const f of toAnnotate.slice(0, MAX_ANNOTATIONS)) {
50
54
  const level = f.status === "deprecated" || f.doc ? "notice" : "warning";
51
- const repl = f.entry?.replacement && !f.entry.routes ? ` Replacement: ${f.entry.replacement}.` : "";
55
+ const chg = replacementChange(f.entry);
56
+ const repl = f.entry?.replacement && !f.entry.routes ? ` Replacement: ${replacementText(f.entry)}.${chg ? ` (Changed by the provider on ${chg.on}; before it was ${chg.was}.)` : ""}` : "";
52
57
  const text = f.entry.routes && f.entry.replacement
53
58
  ? (f.days <= 0
54
59
  ? `Since ${f.entry.retires}, requests to ${f.id} are answered by ${f.entry.replacement}. The call still works, but the model behind it changed.`
package/src/mcp.js CHANGED
@@ -6,31 +6,22 @@
6
6
  import { readFileSync, statSync } from "node:fs";
7
7
  import { resolve } from "node:path";
8
8
  import { createInterface } from "node:readline";
9
- import { REGISTRY_PATH, buildRegistry, classify, normalizeId } from "./registry.js";
9
+ import { loadBestRegistry, classify, normalizeId, replacementChange } from "./registry.js";
10
10
  import { scanDirectory } from "./scan.js";
11
11
 
12
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
13
  const page = (id) => `https://aidriftline.com/models/${id}/`;
15
14
  const WHO = { openai: "OpenAI", anthropic: "Anthropic", google: "Google" };
16
15
 
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
- }
16
+ export { loadBestRegistry }; // shared with the Action and CLI (src/registry.js)
27
17
 
28
18
  // One plain sentence an agent can pass on to the user.
29
- function advice(c) {
19
+ function advice(c, today = new Date()) {
30
20
  const m = c.entry;
31
21
  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
22
  const who = WHO[m.provider] || m.provider;
33
- const repl = m.replacement ? ` Replacement: ${m.replacement}.` : "";
23
+ const chg = replacementChange(m, today);
24
+ const repl = m.replacement ? ` Replacement: ${m.replacement}${m.replacement_note ? ` (${m.replacement_note})` : ""}.${chg ? ` ${who} changed this recommendation on ${chg.on}; before it was ${chg.was}.` : ""}` : "";
34
25
  if (m.qualifier === "not-before") return `Active. ${who} says it will not be retired before ${m.retires}.`;
35
26
  if (m.routes) return c.days <= 0
36
27
  ? `Since ${m.retires}, ${who} answers requests for it with ${m.replacement}. No error, but answers come from a different model.`
@@ -49,9 +40,10 @@ function describe(rawId, reg, today) {
49
40
  id: rawId, ...(id !== rawId ? { normalized: id } : {}),
50
41
  status: c.status, ...(m ? {
51
42
  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),
43
+ replacement: m.replacement || null, ...(m.replacement_note ? { replacement_note: m.replacement_note } : {}),
44
+ ...(m.previous_replacement ? { previous_replacement: m.previous_replacement, replacement_changed: m.replacement_changed } : {}), routes: !!m.routes, ...(m.aliasOf ? { alias_of: m.aliasOf } : {}), page: page(m.aliasOf || m.id),
53
45
  } : {}),
54
- advice: advice(c),
46
+ advice: advice(c, today),
55
47
  };
56
48
  }
57
49
 
@@ -66,6 +58,9 @@ const MODEL_RESULT = {
66
58
  qualifier: { type: "string", enum: ["exact", "earliest", "not-before"], description: "exact date, earliest possible shutdown (Google), or not before this date (Anthropic)" },
67
59
  days_left: { type: "number", description: "Days until the date; 0 or negative when it has passed" },
68
60
  replacement: { type: ["string", "null"] },
61
+ previous_replacement: { type: "string", description: "The replacement the provider recommended before it changed its advice" },
62
+ replacement_changed: { type: "string", description: "YYYY-MM-DD: when the change was seen on the provider's page" },
63
+ replacement_note: { type: "string", description: "Extra instruction the provider gives with the replacement (e.g. a setting or API to use). Without it, switching the model name alone is not enough" },
69
64
  routes: { type: "boolean", description: "true: after the date the provider answers with the replacement model instead of failing" },
70
65
  alias_of: { type: "string" }, page: { type: "string", description: "aidriftline.com page with the source for this date" },
71
66
  advice: { type: "string", description: "One plain sentence to pass on to the user" },
package/src/registry.js CHANGED
@@ -11,6 +11,26 @@ export function loadRegistry(path = REGISTRY_PATH) {
11
11
  return buildRegistry(JSON.parse(readFileSync(path, "utf8")));
12
12
  }
13
13
 
14
+ // The live registry is updated as soon as a provider changes a date or replacement; the copy bundled with a
15
+ // release can be days or weeks old. Everything (Action, CLI, MCP) uses the live copy when it can be read, looks
16
+ // like ours and is at least as new as the bundled one; otherwise the bundled copy. reg.source says which.
17
+ export const LIVE_URL = "https://aidriftline.com/models.json";
18
+ export function validRegistry(data) {
19
+ return Array.isArray(data?.models) && data.models.length >= 50 && /^\d{4}-\d{2}-\d{2}$/.test(data.checked || "") &&
20
+ data.models.every((m) => typeof m?.id === "string" && ["openai", "anthropic", "google"].includes(m.provider) && /^\d{4}-\d{2}-\d{2}$/.test(m.retires || ""));
21
+ }
22
+ export async function loadBestRegistry({ fetchImpl = fetch, offline = process.env.DRIFTLINE_OFFLINE === "1", timeoutMs = 4000 } = {}) {
23
+ const bundled = JSON.parse(readFileSync(REGISTRY_PATH, "utf8"));
24
+ const use = (data, source, why = "") => { const reg = buildRegistry(data); reg.source = source; reg.sourceWhy = why; return { reg, source, why }; };
25
+ if (offline) return use(bundled, "bundled", "offline");
26
+ try {
27
+ const res = await fetchImpl(LIVE_URL, { signal: AbortSignal.timeout(timeoutMs) });
28
+ const live = res.ok ? await res.json() : null;
29
+ if (validRegistry(live)) return live.checked >= bundled.checked ? use(live, "live") : use(bundled, "bundled", "newer");
30
+ } catch { /* offline, slow or malformed: use the bundled copy */ }
31
+ return use(bundled, "bundled", "unreachable");
32
+ }
33
+
14
34
  // Same as loadRegistry, from already-parsed JSON (e.g. the live copy at aidriftline.com/models.json).
15
35
  export function buildRegistry(data) {
16
36
  const byId = new Map(data.models.map((m) => [m.id, { ...m }]));
@@ -72,6 +92,20 @@ export function looksLikeModelId(id, known) {
72
92
  // Only text models can be drift-tested; audio, image, video, embedding and the rest are scan-only.
73
93
  export const isTextModel = (entry) => !entry?.kind || entry.kind === "text";
74
94
 
95
+ // The replacement as people should read it: the model, plus the provider's extra instruction when there is one,
96
+ // e.g. "gpt-6.1-sol (reasoning.mode: pro, in the Responses API)". Without the note, a rename alone is not the move.
97
+ export const replacementText = (entry) => entry?.replacement
98
+ ? entry.replacement + (entry.replacement_note ? ` (${entry.replacement_note})` : "") : "";
99
+
100
+ // Providers sometimes change the recommended replacement after announcing a retirement. The registry keeps
101
+ // the old one and the date we saw the change, so reports can say so instead of silently switching targets.
102
+ export const RECENT_CHANGE_DAYS = 120;
103
+ export function replacementChange(entry, today = new Date()) {
104
+ if (!entry?.replacement_changed || !entry.previous_replacement) return null;
105
+ const days = Math.floor((today - new Date(entry.replacement_changed + "T00:00:00Z")) / DAY);
106
+ return days >= 0 && days <= RECENT_CHANGE_DAYS ? { on: entry.replacement_changed, was: entry.previous_replacement, days } : null;
107
+ }
108
+
75
109
  /**
76
110
  * Classify one model ID against the registry on a given date.
77
111
  * status: retired | at-risk | deprecated | active | unknown
package/src/report.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { replacementChange } from "./registry.js";
2
+
1
3
  const STATUS_LABEL = {
2
4
  retired: "🔴 Retired",
3
5
  "at-risk": "🟠 Retires soon",
@@ -7,6 +9,9 @@ const STATUS_LABEL = {
7
9
  };
8
10
  const RESULT_LABEL = { same: "✅ Same", changed: "🟡 Changed", broken: "🔴 Broken", error: "⚠️ Error", skipped: "⏭️ Skipped" };
9
11
 
12
+ // "<br>changed 2026-11-13, was `gpt-5.6-sol`" when the provider changed its advice recently.
13
+ const changed = (entry, today) => { const c = replacementChange(entry, today); return c ? `<br>_changed ${c.on}, was ${code(c.was)}_` : ""; };
14
+
10
15
  // Google gives only earliest dates; once one has passed the model may be gone already, so "retires soon" would be wrong.
11
16
  // Routed models keep answering after the date, but from the replacement model ("retired" would be wrong too).
12
17
  const label = (c) => c.status === "at-risk" && c.entry?.qualifier === "earliest" && c.days <= 0
@@ -43,7 +48,7 @@ const MAX_ANSWER = 1500;
43
48
  const MAX_ROWS = 150;
44
49
  const clip = (s) => { s = String(s || ""); return s.length > MAX_ANSWER ? s.slice(0, MAX_ANSWER) + "\n[…cut]" : s; };
45
50
 
46
- export function renderReport({ findings, drift, registry, notices = [], answers = true }) {
51
+ export function renderReport({ findings, drift, registry, notices = [], answers = true, today = new Date() }) {
47
52
  const lines = ["## Driftline report", ""];
48
53
  const flagged = findings.filter((f) => ["retired", "at-risk", "deprecated"].includes(f.status));
49
54
  const byModel = new Map();
@@ -67,7 +72,7 @@ export function renderReport({ findings, drift, registry, notices = [], answers
67
72
  for (const r of rows) {
68
73
  const places = r.places.slice(0, 3).join(", ") + (r.places.length > 3 ? ` +${r.places.length - 3} more` : "");
69
74
  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} |`);
75
+ 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) + (r.entry.replacement_note ? " (" + esc(r.entry.replacement_note) + ")" : "") + changed(r.entry, today) : ""} | ${places} |`);
71
76
  }
72
77
  if (all.length > rows.length) lines.push("", `_${all.length - rows.length} more model ID(s) with no retirement listed are not shown._`);
73
78
  }
@@ -96,7 +101,7 @@ export function renderReport({ findings, drift, registry, notices = [], answers
96
101
  }
97
102
 
98
103
  if (notices.length) lines.push("", ...notices.map((n) => `> ${n}`));
99
- lines.push("", `<sub>Retirement dates checked against official provider pages on ${registry.checked}. ` +
104
+ lines.push("", `<sub>Retirement dates checked against official provider pages on ${registry.checked}${registry.source === "live" ? " (live list at aidriftline.com)" : ({ newer: " (list bundled with this version, newer than the live list)", unreachable: " (list bundled with this version; the live list could not be read)", offline: " (list bundled with this version)" })[registry.sourceWhy] || ""}. ` +
100
105
  `Sources: [OpenAI](${registry.sources.openai}) · [Anthropic](${registry.sources.anthropic}) · [Google](${registry.sources.google}).</sub>`);
101
106
  return lines.join("\n");
102
107
  }
package/src/run.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join, relative, sep } from "node:path";
3
- import { loadRegistry, classify, isTextModel } from "./registry.js";
3
+ import { loadRegistry, classify, isTextModel, replacementChange } from "./registry.js";
4
4
  import { scanDirectory } from "./scan.js";
5
5
  import { loadPrompts } from "./prompts.js";
6
6
  import { complete, providerOf, keyFor, missingKeyMessage } from "./providers.js";
@@ -61,9 +61,9 @@ export async function run({
61
61
  }
62
62
 
63
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 });
64
+ const markdown = renderReport({ findings, drift, registry, notices, answers, today });
65
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 });
66
+ const issueMarkdown = renderReport({ findings, drift, registry, notices, answers: false, today });
67
67
  return { findings, drift, notices, atRisk, markdown, issueMarkdown };
68
68
  }
69
69
 
@@ -92,13 +92,22 @@ async function runDrift({ prompts, findings, registry, env, fetchImpl, today, wa
92
92
  for (const from of froms) {
93
93
  const status = classify(from, registry, { today, warnDays });
94
94
  const to = p.compare_to || status.entry?.replacement;
95
+ // The provider's replacement comes with an extra instruction (e.g. "reasoning.mode: pro") that this test
96
+ // doesn't apply: say so on every row, so nobody reads a plain-mode answer as the replacement's best.
97
+ const chg = !p.compare_to && replacementChange(status.entry, today);
98
+ const caveat = [
99
+ !p.compare_to && status.entry?.replacement_note
100
+ ? `Tested without "${status.entry.replacement_note}", which the provider says the replacement needs; answers after a correct switch may differ.` : "",
101
+ chg ? `The provider changed this replacement on ${chg.on} (before: ${chg.was}); drift results from earlier runs compared against ${chg.was}.` : "",
102
+ ].filter(Boolean).join(" ");
103
+ const push = (row) => out.push(caveat ? { ...row, note: [row.note, caveat].filter(Boolean).join(" ") } : row);
95
104
  if (!to) {
96
- out.push({ id: p.id, from, to: "—", result: "skipped", note: "No known replacement; set compare_to" });
105
+ push({ id: p.id, from, to: "—", result: "skipped", note: "No known replacement; set compare_to" });
97
106
  continue;
98
107
  }
99
108
  const missing = [from, to].map(providerOf).find((pr) => !keyFor(pr, env));
100
109
  if (missing) {
101
- out.push({ id: p.id, from, to, result: "error", note: missingKeyMessage(missing) });
110
+ push({ id: p.id, from, to, result: "error", note: missingKeyMessage(missing) });
102
111
  continue;
103
112
  }
104
113
  log(oneLine(`Drift test ${p.id}: ${from} → ${to}`));
@@ -106,11 +115,11 @@ async function runDrift({ prompts, findings, registry, env, fetchImpl, today, wa
106
115
  const after = await complete({ model: to, prompt: p.prompt, system: p.system, env, fetchImpl });
107
116
  if (status.status === "retired") {
108
117
  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" });
118
+ push({ id: p.id, from, to, after, before: "(current model already retired)", result: "broken", note: "Replacement returned an empty answer" });
110
119
  continue;
111
120
  }
112
121
  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",
122
+ push({ id: p.id, from, to, after, before: "(current model already retired)", result: jsonOk ? "changed" : "broken",
114
123
  note: jsonOk ? "Current model is retired; review the replacement's answer" : "Replacement did not return valid JSON" });
115
124
  continue;
116
125
  }
@@ -120,12 +129,12 @@ async function runDrift({ prompts, findings, registry, env, fetchImpl, today, wa
120
129
  } catch (e) {
121
130
  if (!/^(Empty answer|Answer cut off)/.test(e.message)) throw e;
122
131
  // 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` });
132
+ push({ id: p.id, from, to, after, before: `(${e.message})`, result: "changed", note: `Current model: ${e.message}. Review the replacement's answer` });
124
133
  continue;
125
134
  }
126
- out.push({ id: p.id, from, to, before, after, ...compare(before, after, p.expect) });
135
+ push({ id: p.id, from, to, before, after, ...compare(before, after, p.expect) });
127
136
  } catch (e) {
128
- out.push({ id: p.id, from, to, result: "error", note: e.message.slice(0, 200) });
137
+ push({ id: p.id, from, to, result: "error", note: e.message.slice(0, 200) });
129
138
  }
130
139
  }
131
140
  }