@aidriftline/driftline 0.1.15 → 0.1.17

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.15",
3
+ "version": "0.1.17",
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",
@@ -13,6 +13,7 @@
13
13
  "retires": "2027-02-10",
14
14
  "qualifier": "exact",
15
15
  "replacement": "gpt-image-2.6-sunburst",
16
+ "replacement_alt": "gpt-image-2.6-flare",
16
17
  "kind": "image",
17
18
  "announced": "2026-11-10"
18
19
  },
@@ -22,6 +23,7 @@
22
23
  "retires": "2027-02-10",
23
24
  "qualifier": "exact",
24
25
  "replacement": "gpt-image-2.6-sunburst",
26
+ "replacement_alt": "gpt-image-2.6-flare",
25
27
  "kind": "image",
26
28
  "announced": "2026-11-10"
27
29
  },
@@ -1072,6 +1074,7 @@
1072
1074
  "retires": "2027-02-26",
1073
1075
  "qualifier": "exact",
1074
1076
  "replacement": "gpt-live-transcribe",
1077
+ "replacement_alt": "gpt-transcribe",
1075
1078
  "kind": "audio"
1076
1079
  },
1077
1080
  {
@@ -1080,6 +1083,7 @@
1080
1083
  "retires": "2027-02-26",
1081
1084
  "qualifier": "exact",
1082
1085
  "replacement": "gpt-live-transcribe",
1086
+ "replacement_alt": "gpt-transcribe",
1083
1087
  "kind": "audio"
1084
1088
  },
1085
1089
  {
@@ -1088,6 +1092,7 @@
1088
1092
  "retires": "2027-02-26",
1089
1093
  "qualifier": "exact",
1090
1094
  "replacement": "gpt-live-transcribe",
1095
+ "replacement_alt": "gpt-transcribe",
1091
1096
  "kind": "audio"
1092
1097
  },
1093
1098
  {
@@ -1096,6 +1101,7 @@
1096
1101
  "retires": "2027-02-26",
1097
1102
  "qualifier": "exact",
1098
1103
  "replacement": "gpt-live-transcribe",
1104
+ "replacement_alt": "gpt-transcribe",
1099
1105
  "kind": "audio"
1100
1106
  },
1101
1107
  {
@@ -1104,6 +1110,7 @@
1104
1110
  "retires": "2027-02-26",
1105
1111
  "qualifier": "exact",
1106
1112
  "replacement": "gpt-live-transcribe",
1113
+ "replacement_alt": "gpt-transcribe",
1107
1114
  "kind": "audio"
1108
1115
  },
1109
1116
  {
@@ -1312,6 +1319,7 @@
1312
1319
  "retires": "2026-10-23",
1313
1320
  "qualifier": "exact",
1314
1321
  "replacement": "gpt-image-2.5-sunburst",
1322
+ "replacement_alt": "gpt-image-2.5-flare",
1315
1323
  "kind": "image"
1316
1324
  },
1317
1325
  {
@@ -1320,6 +1328,7 @@
1320
1328
  "retires": "2026-12-01",
1321
1329
  "qualifier": "exact",
1322
1330
  "replacement": "gpt-image-2.6-sunburst",
1331
+ "replacement_alt": "gpt-image-2.6-flare",
1323
1332
  "kind": "image"
1324
1333
  },
1325
1334
  {
@@ -1328,6 +1337,7 @@
1328
1337
  "retires": "2026-12-01",
1329
1338
  "qualifier": "exact",
1330
1339
  "replacement": "gpt-image-2.6-sunburst",
1340
+ "replacement_alt": "gpt-image-2.6-flare",
1331
1341
  "kind": "image"
1332
1342
  },
1333
1343
  {
@@ -1336,6 +1346,7 @@
1336
1346
  "retires": "2026-12-01",
1337
1347
  "qualifier": "exact",
1338
1348
  "replacement": "gpt-image-2.6-sunburst",
1349
+ "replacement_alt": "gpt-image-2.6-flare",
1339
1350
  "kind": "image"
1340
1351
  },
1341
1352
  {
@@ -1344,6 +1355,7 @@
1344
1355
  "retires": "2026-12-01",
1345
1356
  "qualifier": "exact",
1346
1357
  "replacement": "gpt-image-2.6-sunburst",
1358
+ "replacement_alt": "gpt-image-2.6-flare",
1347
1359
  "kind": "image"
1348
1360
  },
1349
1361
  {
package/src/cli.js CHANGED
@@ -8,6 +8,7 @@
8
8
  // driftline mcp MCP server on stdio for coding agents (Claude Code, Cursor, ...)
9
9
  // driftline --help | --version
10
10
  import { loadBestRegistry } from "./registry.js";
11
+ import { migrationFor } from "./migration.js";
11
12
  import { resolve } from "node:path";
12
13
  import { readFileSync } from "node:fs";
13
14
  import { execFileSync } from "node:child_process";
@@ -80,7 +81,7 @@ if (cmd === "-h" || cmd === "--help" || cmd === "help") {
80
81
  const pick = (f) => ({
81
82
  id: f.id, file: f.file, line: f.line, status: f.status, docOnly: !!f.doc,
82
83
  provider: f.entry?.provider, retires: f.entry?.retires, qualifier: f.entry?.qualifier,
83
- replacement: f.entry?.replacement, routes: !!f.entry?.routes, aliasOf: f.entry?.aliasOf,
84
+ replacement: f.entry?.replacement, replacement_alt: f.entry?.replacement_alt, migration: migrationFor(f.entry) || undefined, routes: !!f.entry?.routes, aliasOf: f.entry?.aliasOf,
84
85
  });
85
86
  console.log(JSON.stringify({ findings: result.findings.map(pick), drift: result.drift || [] }, null, 2));
86
87
  } else console.log(result.markdown);
package/src/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // GitHub Action entry point.
3
3
  import { replacementText, replacementChange, loadBestRegistry } from "./registry.js";
4
+ import { migrationFor } from "./migration.js";
4
5
  import { resolve } from "node:path";
5
6
  import { statSync } from "node:fs";
6
7
  import { randomUUID } from "node:crypto";
@@ -53,6 +54,8 @@ async function main() {
53
54
  for (const f of toAnnotate.slice(0, MAX_ANNOTATIONS)) {
54
55
  const level = f.status === "deprecated" || f.doc ? "notice" : "warning";
55
56
  const chg = replacementChange(f.entry);
57
+ const mig = f.entry.routes ? null : migrationFor(f.entry);
58
+ const more = mig && !mig.drop_in && mig.change !== "settings" ? ` Not just a rename: ${mig.note}` : mig?.change === "choose" ? ` ${mig.note}` : "";
56
59
  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}.)` : ""}` : "";
57
60
  const text = f.entry.routes && f.entry.replacement
58
61
  ? (f.days <= 0
@@ -61,7 +64,8 @@ async function main() {
61
64
  : f.entry.qualifier === "earliest" && f.days <= 0
62
65
  ? `${f.id} is past its earliest shutdown date (${f.entry.retires}) and may stop working at any time.`
63
66
  : `${f.id} ${STATUS_TEXT[f.status]} (${f.entry.retires}).`;
64
- annotate(level, text + repl, f.file, f.line);
67
+ const routedMore = f.entry.routes && migrationFor(f.entry)?.change === "params" ? ` Before then, apply the request changes the provider lists: ${migrationFor(f.entry).note}` : "";
68
+ annotate(level, text + repl + more + routedMore, f.file, f.line);
65
69
  }
66
70
 
67
71
  // The report contains model answers; stop GitHub from reading any "::command::" lines inside it.
package/src/mcp.js CHANGED
@@ -6,7 +6,8 @@
6
6
  import { readFileSync, statSync } from "node:fs";
7
7
  import { resolve } from "node:path";
8
8
  import { createInterface } from "node:readline";
9
- import { loadBestRegistry, classify, normalizeId, replacementChange } from "./registry.js";
9
+ import { loadBestRegistry, classify, normalizeId, replacementChange, replacementText } from "./registry.js";
10
+ import { migrationFor } from "./migration.js";
10
11
  import { scanDirectory } from "./scan.js";
11
12
 
12
13
  const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
@@ -21,15 +22,17 @@ function advice(c, today = new Date()) {
21
22
  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).";
22
23
  const who = WHO[m.provider] || m.provider;
23
24
  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}.` : ""}` : "";
25
+ const mig = migrationFor(m);
26
+ const migText = mig && !mig.drop_in && mig.change !== "settings" ? ` More than a rename: ${mig.note}` : mig?.change === "choose" ? ` ${mig.note}` : "";
27
+ const repl = m.replacement ? ` Replacement: ${replacementText(m)}.${chg ? ` ${who} changed this recommendation on ${chg.on}; before it was ${chg.was}.` : ""}` : "";
25
28
  if (m.qualifier === "not-before") return `Active. ${who} says it will not be retired before ${m.retires}.`;
26
29
  if (m.routes) return c.days <= 0
27
- ? `Since ${m.retires}, ${who} answers requests for it with ${m.replacement}. No error, but answers come from a different model.`
28
- : `From ${m.retires} (in ${c.days} days), ${who} will answer requests for it with ${m.replacement}.`;
30
+ ? `Since ${m.retires}, ${who} answers requests for it with ${m.replacement}. No error, but answers come from a different model.${migText}`
31
+ : `From ${m.retires} (in ${c.days} days), ${who} will answer requests for it with ${m.replacement}.${migText}`;
29
32
  if (m.qualifier === "earliest") return c.days <= 0
30
- ? `${who} listed ${m.retires} as the earliest shutdown date; it can stop working at any time now.${repl}`
31
- : `${who} may shut it down from ${m.retires} (in ${c.days} days).${repl}`;
32
- 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}`;
33
+ ? `${who} listed ${m.retires} as the earliest shutdown date; it can stop working at any time now.${repl}${migText}`
34
+ : `${who} may shut it down from ${m.retires} (in ${c.days} days).${repl}${migText}`;
35
+ return c.days <= 0 ? `Retired on ${m.retires}. Calls to it fail.${repl}${migText}` : `Retires on ${m.retires} (in ${c.days} days). Calls fail after that.${repl}${migText}`;
33
36
  }
34
37
 
35
38
  function describe(rawId, reg, today) {
@@ -40,9 +43,10 @@ function describe(rawId, reg, today) {
40
43
  id: rawId, ...(id !== rawId ? { normalized: id } : {}),
41
44
  status: c.status, ...(m ? {
42
45
  provider: m.provider, retires: m.retires, qualifier: m.qualifier, days_left: c.days,
43
- replacement: m.replacement || null, ...(m.replacement_note ? { replacement_note: m.replacement_note } : {}),
46
+ replacement: m.replacement || null, ...(m.replacement_alt ? { replacement_alt: m.replacement_alt } : {}), ...(m.replacement_note ? { replacement_note: m.replacement_note } : {}),
44
47
  ...(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),
45
48
  } : {}),
49
+ ...(m && migrationFor(m) ? { migration: migrationFor(m) } : {}),
46
50
  advice: advice(c, today),
47
51
  };
48
52
  }
@@ -58,8 +62,10 @@ const MODEL_RESULT = {
58
62
  qualifier: { type: "string", enum: ["exact", "earliest", "not-before"], description: "exact date, earliest possible shutdown (Google), or not before this date (Anthropic)" },
59
63
  days_left: { type: "number", description: "Days until the date; 0 or negative when it has passed" },
60
64
  replacement: { type: ["string", "null"] },
65
+ replacement_alt: { type: "string", description: "A second replacement the provider names (\"X or Y\"); which one fits depends on how the model is used" },
61
66
  previous_replacement: { type: "string", description: "The replacement the provider recommended before it changed its advice" },
62
67
  replacement_changed: { type: "string", description: "YYYY-MM-DD: when the change was seen on the provider's page" },
68
+ migration: { type: "object", description: "Whether moving to the replacement is just a new model name (drop_in true) or needs more: change = rename | settings | params | api | re-embed | choose, with a note and the provider's guide", properties: { change: { type: "string" }, drop_in: { type: "boolean" }, note: { type: "string" }, guide: { type: ["string", "null"] } } },
63
69
  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" },
64
70
  routes: { type: "boolean", description: "true: after the date the provider answers with the replacement model instead of failing" },
65
71
  alias_of: { type: "string" }, page: { type: "string", description: "aidriftline.com page with the source for this date" },
@@ -0,0 +1,101 @@
1
+ // Is the replacement a drop-in, or does moving to it need more than a new model name?
2
+ // Rules per model family, written from the providers' own migration docs (checked 13 Nov 2026).
3
+ // Anything no rule matches is a plain rename, which is how Driftline has always presented it.
4
+ //
5
+ // migrationFor(entry) → { change, drop_in, note, guide }
6
+ // change: "rename" the model name is the only change
7
+ // "settings" also set a parameter the provider names (e.g. reasoning.mode: pro)
8
+ // "params" some request parameters are removed or renamed
9
+ // "api" a different API, method or endpoint
10
+ // "re-embed" stored embeddings have to be recomputed
11
+ // "choose" the provider names more than one replacement; which one depends on how you use it
12
+
13
+ const OPENAI = "https://developers.openai.com/api/docs/guides/";
14
+ const GOOGLE = "https://ai.google.dev/gemini-api/docs/";
15
+
16
+ // Claude family version from an ID: claude-sonnet-4-5-20250929 → 4.5, claude-3-haiku-20240307 → 3, claude-opus-4-8 → 4.8
17
+ function claudeVersion(id) {
18
+ const m = String(id || "").match(/^claude-(?:(?:opus|sonnet|haiku|fable|mythos)-)?(\d+)(?:[-.](\d))?(?:-|$)/);
19
+ return m ? Number(m[1]) + (m[2] ? Number(m[2]) / 10 : 0) : null;
20
+ }
21
+ const geminiMajor = (id) => { const m = String(id || "").match(/^gemini-(\d+)/); return m ? Number(m[1]) : null; };
22
+
23
+ const RULES = [
24
+ {
25
+ when: (e) => e.provider === "openai" && (/^(davinci-002|babbage-002|gpt-3\.5-turbo-instruct)/.test(e.id) || /-completions$/.test(e.id)),
26
+ change: "api",
27
+ note: "The old Completions endpoint (a plain prompt) doesn't serve the replacement: move the call to the Responses API or Chat Completions (messages).",
28
+ guide: OPENAI + "migrate-to-responses",
29
+ },
30
+ {
31
+ when: (e) => e.provider === "openai" && /^(tts-1|gpt-4o-mini-tts)/.test(e.id),
32
+ change: "api",
33
+ note: "OpenAI calls this a change of API workflow: the replacement isn't a model for /v1/audio/speech and runs through the Realtime API.",
34
+ guide: OPENAI + "text-to-speech",
35
+ },
36
+ {
37
+ when: (e) => e.provider === "openai" && /realtime-preview/.test(e.id),
38
+ change: "api",
39
+ note: "The preview models ran on the Realtime beta (header OpenAI-Beta: realtime=v1), which OpenAI shut down; the replacement needs the current Realtime API.",
40
+ guide: OPENAI + "realtime",
41
+ },
42
+ {
43
+ when: (e) => e.provider === "openai" && /^(whisper-1|gpt-4o(-mini)?-transcribe)/.test(e.id),
44
+ change: "choose",
45
+ note: "OpenAI names two replacements: gpt-transcribe for recorded files, gpt-live-transcribe for live audio. Word timestamps, subtitles and translation work differently, so check those if you use them.",
46
+ guide: OPENAI + "transcription",
47
+ },
48
+ {
49
+ when: (e) => e.provider === "openai" && e.kind === "image" && /-sunburst$/.test(e.replacement || ""),
50
+ change: "rename",
51
+ note: "Same Images API. OpenAI offers two: …-sunburst for editing precision, …-flare for fast everyday generation.",
52
+ guide: OPENAI + "image-generation",
53
+ },
54
+ {
55
+ when: (e) => e.kind === "embedding",
56
+ change: "re-embed",
57
+ note: "Embeddings from different models can't be compared: re-embed your stored data with the new model, and query with that same model. The vector size may differ too.",
58
+ guide: (e) => e.provider === "google" ? GOOGLE + "embeddings" : OPENAI + "embeddings",
59
+ },
60
+ {
61
+ when: (e) => e.provider === "google" && /^imagen-/.test(e.id),
62
+ change: "api",
63
+ note: "Use generate_content instead of generate_images; images come back as content parts, not an image response object.",
64
+ guide: GOOGLE + "imagen",
65
+ },
66
+ {
67
+ when: (e) => e.provider === "google" && /^veo-/.test(e.id),
68
+ change: "api",
69
+ note: "Gemini Omni Flash uses the Interactions API (interactions.create), not Veo's video-generation call. Google also offers Veo itself on Gemini Enterprise Agent Platform.",
70
+ guide: GOOGLE + "video",
71
+ },
72
+ {
73
+ when: (e) => e.provider === "google" && (!e.kind || e.kind === "text") && geminiMajor(e.id) === 2 && geminiMajor(e.replacement) >= 3,
74
+ change: "params",
75
+ note: "Gemini 3 changes the request: remove temperature, top_p, top_k, candidate_count and prefilled model turns, replace thinking_budget with thinking_level, and check function calling.",
76
+ guide: (e) => GOOGLE + (/^gemini-3\.8/.test(e.replacement) ? "whats-new-gemini-3.8" : "whats-new-gemini-3.6"),
77
+ },
78
+ {
79
+ when: (e) => e.provider === "anthropic" && claudeVersion(e.id) !== null && claudeVersion(e.id) < 4.7 && claudeVersion(e.replacement) >= 4.7,
80
+ change: "params",
81
+ note: "Claude 4.7 and later accept only the default temperature, top_p and top_k: if your code sets them, remove them.",
82
+ guide: "https://platform.claude.com/docs/en/api/migration-2026-10-21",
83
+ },
84
+ ];
85
+
86
+ export function migrationFor(entry) {
87
+ if (!entry?.replacement) return null;
88
+ // A provider's own instruction next to the replacement (registry replacement_note) comes first.
89
+ const setting = entry.replacement_note
90
+ ? { change: "settings", drop_in: false, note: `Also set ${entry.replacement_note}.`, guide: /reasoning\.mode/.test(entry.replacement_note) ? OPENAI + "reasoning" : null }
91
+ : null;
92
+ const rule = RULES.find((r) => r.when(entry));
93
+ if (!rule) return setting || { change: "rename", drop_in: true, note: "", guide: null };
94
+ const found = { change: rule.change, drop_in: rule.change === "rename", note: rule.note, guide: typeof rule.guide === "function" ? rule.guide(entry) : rule.guide };
95
+ return setting ? { ...found, change: found.change === "rename" ? "settings" : found.change, drop_in: false, note: `${setting.note} ${found.note}` } : found;
96
+ }
97
+
98
+ // Short label for tables: "Rename" or what else is needed.
99
+ export const MIGRATION_LABEL = {
100
+ rename: "Rename", settings: "Rename + setting", params: "Parameter changes", api: "API change", "re-embed": "Re-embed data", choose: "Pick one of two",
101
+ };
package/src/providers.js CHANGED
@@ -63,7 +63,7 @@ async function postOnce(fetchImpl, url, headers, body) {
63
63
  return json;
64
64
  }
65
65
 
66
- export async function complete({ model, prompt, system, env = process.env, fetchImpl = fetch }) {
66
+ export async function complete({ model, prompt, system, reasoningMode = null, env = process.env, fetchImpl = fetch }) {
67
67
  const provider = providerOf(model);
68
68
  const key = keyFor(provider, env);
69
69
  if (!key) throw new Error(missingKeyMessage(provider));
@@ -71,7 +71,7 @@ export async function complete({ model, prompt, system, env = process.env, fetch
71
71
  if (provider === "openai") {
72
72
  const data = await postJson(fetchImpl, "https://api.openai.com/v1/responses",
73
73
  { authorization: `Bearer ${key}` },
74
- { model, input: prompt, ...(system ? { instructions: system } : {}) });
74
+ { model, input: prompt, ...(system ? { instructions: system } : {}), ...(reasoningMode ? { reasoning: { mode: reasoningMode } } : {}) });
75
75
  const text = typeof data.output_text === "string" ? data.output_text : (data.output || [])
76
76
  .flatMap((o) => o.content || [])
77
77
  .filter((c) => c.type === "output_text" || typeof c.text === "string")
package/src/registry.js CHANGED
@@ -40,12 +40,18 @@ export function buildRegistry(data) {
40
40
  if (!m.replacement) continue;
41
41
  const seen = new Set([m.id]);
42
42
  let next = m.replacement;
43
+ let hop = m; // the entry whose replacement is `next`: its second option and note belong to that target
43
44
  seen.add(next);
44
45
  while (byId.get(next)?.replacement && byId.get(next).qualifier !== "not-before" && !seen.has(byId.get(next).replacement)) {
45
- next = byId.get(next).replacement;
46
+ hop = byId.get(next);
47
+ next = hop.replacement;
46
48
  seen.add(next);
47
49
  }
48
- if (next !== m.replacement && next !== m.id) { m.via = m.replacement; m.replacement = next; }
50
+ if (next !== m.replacement && next !== m.id) {
51
+ m.via = m.replacement; m.replacement = next;
52
+ if (hop.replacement_alt) m.replacement_alt = hop.replacement_alt; else delete m.replacement_alt;
53
+ if (hop.replacement_note) m.replacement_note = hop.replacement_note; else delete m.replacement_note;
54
+ }
49
55
  }
50
56
  // Anthropic short names point at a dated snapshot: "claude-sonnet-4-5" and "-latest" -> claude-sonnet-4-5-20250929,
51
57
  // "claude-sonnet-4-0" -> claude-sonnet-4-20250514. The deprecation page lists only snapshots, so a short name
@@ -95,7 +101,7 @@ export const isTextModel = (entry) => !entry?.kind || entry.kind === "text";
95
101
  // The replacement as people should read it: the model, plus the provider's extra instruction when there is one,
96
102
  // e.g. "gpt-6.1-sol (reasoning.mode: pro, in the Responses API)". Without the note, a rename alone is not the move.
97
103
  export const replacementText = (entry) => entry?.replacement
98
- ? entry.replacement + (entry.replacement_note ? ` (${entry.replacement_note})` : "") : "";
104
+ ? entry.replacement + (entry.replacement_alt ? ` or ${entry.replacement_alt}` : "") + (entry.replacement_note ? ` (${entry.replacement_note})` : "") : "";
99
105
 
100
106
  // Providers sometimes change the recommended replacement after announcing a retirement. The registry keeps
101
107
  // the old one and the date we saw the change, so reports can say so instead of silently switching targets.
package/src/report.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { replacementChange } from "./registry.js";
2
+ import { migrationFor, MIGRATION_LABEL } from "./migration.js";
2
3
 
3
4
  const STATUS_LABEL = {
4
5
  retired: "🔴 Retired",
@@ -65,16 +66,30 @@ export function renderReport({ findings, drift, registry, notices = [], answers
65
66
  lines.push(n
66
67
  ? `**${n} reference${n === 1 ? "" : "s"} to retired or retiring models** across ${byModel.size} model ID${byModel.size === 1 ? "" : "s"} found.`
67
68
  : `Found ${byModel.size} model ID${byModel.size === 1 ? "" : "s"}. None are scheduled for retirement.`);
68
- lines.push("", "| Model | Status | Retirement | Replacement | Where |", "|---|---|---|---|---|");
69
+ lines.push("", "| Model | Status | Retirement | Replacement | Change needed | Where |", "|---|---|---|---|---|---|");
70
+ const migNotes = new Map(); // note → { n, ids, guide } for the list under the table
69
71
  const order = ["retired", "at-risk", "deprecated", "active", "unknown"];
70
72
  const all = [...byModel.values()].sort((a, b) => order.indexOf(a.status) - order.indexOf(b.status));
71
73
  const rows = all.slice(0, MAX_ROWS); // flagged models sort first, so only unknown/active rows get cut
72
74
  for (const r of rows) {
75
+ const mig = migrationFor(r.entry);
76
+ let changeCell = "";
77
+ if (mig) {
78
+ if (mig.note && !(mig.change === "settings" && r.entry.replacement_note && mig.note === `Also set ${r.entry.replacement_note}.`)) {
79
+ if (!migNotes.has(mig.note)) migNotes.set(mig.note, { n: migNotes.size + 1, ids: [], guide: mig.guide });
80
+ migNotes.get(mig.note).ids.push(r.id);
81
+ changeCell = `${mig.drop_in ? "✅" : "⚠️"} ${MIGRATION_LABEL[mig.change]} [${migNotes.get(mig.note).n}]`;
82
+ } else changeCell = `${mig.drop_in ? "✅" : "⚠️"} ${MIGRATION_LABEL[mig.change]}`;
83
+ }
73
84
  const places = r.places.slice(0, 3).join(", ") + (r.places.length > 3 ? ` +${r.places.length - 3} more` : "");
74
85
  const docsOnly = r.places.length && byModel.get(r.id).docs === r.places.length ? " · docs only" : "";
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} |`);
86
+ 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_alt ? " or " + code(r.entry.replacement_alt) : "") + (r.entry.replacement_note ? " (" + esc(r.entry.replacement_note) + ")" : "") + changed(r.entry, today) : ""} | ${changeCell} | ${places} |`);
76
87
  }
77
88
  if (all.length > rows.length) lines.push("", `_${all.length - rows.length} more model ID(s) with no retirement listed are not shown._`);
89
+ if (migNotes.size) {
90
+ lines.push("", "**More than a rename:**", "");
91
+ for (const [note, m] of migNotes) lines.push(`${m.n}. ${m.ids.map(code).join(", ")}: ${esc(note)}${m.guide ? ` ([provider guide](${m.guide}))` : ""}`);
92
+ }
78
93
  }
79
94
 
80
95
  if (drift?.length) {
package/src/run.js CHANGED
@@ -7,6 +7,7 @@ import { complete, providerOf, keyFor, missingKeyMessage } from "./providers.js"
7
7
  import { compare, extractJson } from "./compare.js";
8
8
  import { checkLicense } from "./license.js";
9
9
  import { renderReport } from "./report.js";
10
+ import { migrationFor } from "./migration.js";
10
11
 
11
12
  const MAX_PROMPTS = 20;
12
13
  const oneLine = (s) => String(s).replace(/[\r\n]+/g, " ");
@@ -95,9 +96,14 @@ async function runDrift({ prompts, findings, registry, env, fetchImpl, today, wa
95
96
  // The provider's replacement comes with an extra instruction (e.g. "reasoning.mode: pro") that this test
96
97
  // doesn't apply: say so on every row, so nobody reads a plain-mode answer as the replacement's best.
97
98
  const chg = !p.compare_to && replacementChange(status.entry, today);
99
+ // The provider says the replacement runs in a mode (e.g. reasoning.mode: pro): test it that way, as the customer would run it.
100
+ const proMode = !p.compare_to && /reasoning\.mode: pro/.test(status.entry?.replacement_note || "") && providerOf(to) === "openai" ? "pro" : null;
101
+ const mig = !p.compare_to ? migrationFor(status.entry) : null;
98
102
  const caveat = [
99
- !p.compare_to && status.entry?.replacement_note
103
+ proMode ? "Replacement tested with reasoning.mode: pro, as the provider recommends." : "",
104
+ !p.compare_to && !proMode && status.entry?.replacement_note
100
105
  ? `Tested without "${status.entry.replacement_note}", which the provider says the replacement needs; answers after a correct switch may differ.` : "",
106
+ mig && !mig.drop_in && mig.change !== "settings" ? `Beyond the model name: ${mig.note}` : "",
101
107
  chg ? `The provider changed this replacement on ${chg.on} (before: ${chg.was}); drift results from earlier runs compared against ${chg.was}.` : "",
102
108
  ].filter(Boolean).join(" ");
103
109
  const push = (row) => out.push(caveat ? { ...row, note: [row.note, caveat].filter(Boolean).join(" ") } : row);
@@ -112,7 +118,7 @@ async function runDrift({ prompts, findings, registry, env, fetchImpl, today, wa
112
118
  }
113
119
  log(oneLine(`Drift test ${p.id}: ${from} → ${to}`));
114
120
  try {
115
- const after = await complete({ model: to, prompt: p.prompt, system: p.system, env, fetchImpl });
121
+ const after = await complete({ model: to, prompt: p.prompt, system: p.system, reasoningMode: proMode, env, fetchImpl });
116
122
  if (status.status === "retired") {
117
123
  if (!after.trim()) {
118
124
  push({ id: p.id, from, to, after, before: "(current model already retired)", result: "broken", note: "Replacement returned an empty answer" });