@aidriftline/driftline 0.1.15 → 0.1.16

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.16",
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",
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, 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
@@ -7,6 +7,7 @@ import { readFileSync, statSync } from "node:fs";
7
7
  import { resolve } from "node:path";
8
8
  import { createInterface } from "node:readline";
9
9
  import { loadBestRegistry, classify, normalizeId, replacementChange } 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);
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}` : "";
24
27
  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
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) {
@@ -43,6 +46,7 @@ function describe(rawId, reg, today) {
43
46
  replacement: m.replacement || null, ...(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
  }
@@ -60,6 +64,7 @@ const MODEL_RESULT = {
60
64
  replacement: { type: ["string", "null"] },
61
65
  previous_replacement: { type: "string", description: "The replacement the provider recommended before it changed its advice" },
62
66
  replacement_changed: { type: "string", description: "YYYY-MM-DD: when the change was seen on the provider's page" },
67
+ 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
68
  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
69
  routes: { type: "boolean", description: "true: after the date the provider answers with the replacement model instead of failing" },
65
70
  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/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_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" });