From a783278f6d648986aa82e130184c43132a3c7001 Mon Sep 17 00:00:00 2001 From: thossullivan Date: Fri, 23 Oct 2026 20:09:16 -0500 Subject: [PATCH 1/3] Date OpenAI undated ids listed beside their dated snapshot OpenAI rewrote 16 deprecation rows on 2026-10-23 from one id to "alias | snapshot", for example "gpt-5.4-nano | gpt-5.4-nano-2026-03-17". The parser makes the snapshot the record id and the undated name its alias. In 14 rows both ids are already separate committed entries, so the merge refused the row ("conflicts with a canonical model id") and the live refresh failed. That check stops a row from merging one committed model into another. It still applies, except in one narrow case: for OpenAI, when the record id ends in -YYYY-MM-DD and the other id is exactly that id without the date, the row's lifecycle is copied onto both entries and neither is merged. Two rows, or the undated id's own row, giving that entry different lifecycles still throw. Other publishers, month-day snapshots, unrelated aliases, and aliases owned by a third entry still throw. Effect on the next refresh: gpt-5, gpt-5-mini, gpt-5-nano, and gpt-5-pro gain the 2026-12-11 shutdown and gpt-4o-mini-tts 2027-01-06, as the page states, and gpt-image-1.5-2025-12-16 is added as its own entry. Implemented by an Opus subagent; reviewed by Claude. --- refresh/providers.mjs | 29 ++++++++++++++++++ refresh/test/run.mjs | 68 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/refresh/providers.mjs b/refresh/providers.mjs index 42f9264..b330259 100644 --- a/refresh/providers.mjs +++ b/refresh/providers.mjs @@ -1714,15 +1714,33 @@ export function mergeFeed(committed, { deprecations = [], currentIds = null, cur index = identityIndex(models) } + const lifecycleFields = ['announced', 'shutdown', 'date_precision', 'replacement', 'replacement_options', 'replacement_note'] + const lifecycleKey = record => JSON.stringify(lifecycleFields.map(field => record[field] ?? null)) + const lifecycleApplied = new Map() + const noteLifecycle = (model, record, family) => { + const previous = lifecycleApplied.get(model) + if (previous && (previous.family || family) && previous.key !== lifecycleKey(record)) { + throw new Error(`deprecation rows give ${model.id} conflicting lifecycles via ${previous.recordId} and ${record.id}`) + } + lifecycleApplied.set(model, { key: lifecycleKey(record), family, recordId: record.id }) + } + const familyAliasOf = (snapshot, alias) => provider.publisher === 'openai' + && /-(?:19|20)\d{2}-\d{2}-\d{2}$/.test(snapshot) && snapshot.slice(0, -11) === alias + for (const record of deprecations) { let target = locate(record.id) if (!target) target = add({ id: record.id }) const canonicalId = target.id const aliases = new Set(target.aliases ?? []) + const familyAliases = [] for (const alias of [record.id, ...(record.aliases ?? [])]) { if (!alias || alias === canonicalId) continue const owner = locate(alias) + if (owner && owner !== target && owner.id === alias && familyAliasOf(record.id, alias)) { + familyAliases.push(owner) + continue + } if (committedIds.has(alias) || (owner && owner !== target && owner.id === alias)) { throw new Error(`deprecation alias ${alias} for ${canonicalId} conflicts with a canonical model id`) } @@ -1754,8 +1772,19 @@ export function mergeFeed(committed, { deprecations = [], currentIds = null, cur Object.assign(target, clone(record), { id: canonicalId }) if (aliases.size) target.aliases = [...aliases].filter(alias => alias !== canonicalId) else delete target.aliases + noteLifecycle(target, record, false) confirmed.add(target.id) deprecationConfirmed.add(target.id) + // An OpenAI row naming a snapshot and its undated family id dates both entries and joins neither. + for (const family of familyAliases) { + noteLifecycle(family, record, true) + for (const field of lifecycleFields) delete family[field] + for (const [field, value] of Object.entries(clone(record))) { + if (field !== 'id' && field !== 'aliases') family[field] = value + } + confirmed.add(family.id) + deprecationConfirmed.add(family.id) + } index = identityIndex(models) } diff --git a/refresh/test/run.mjs b/refresh/test/run.mjs index 51a5044..eece9ab 100644 --- a/refresh/test/run.mjs +++ b/refresh/test/run.mjs @@ -3559,6 +3559,74 @@ reviewTest('Month-day snapshots: OpenAI picks the snapshot as the canonical ID o strictAssert.deepEqual(openAISnapshots('gpt-4-0613-completions', 'gpt-4-0613'), [['gpt-4-0613', ['gpt-4-0613-completions']]]) strictAssert.deepEqual(openAISnapshots('gpt-4', 'gpt-4-turbo'), [['gpt-4', ['gpt-4-turbo']]]) }) +const familyPage = (...rows) => `

2026-10-01: Family deprecations

${rows.map(([shutdown, cell, replacement = 'gpt-next']) => ``).join('')}
Shutdown dateModel / systemRecommended replacement
${shutdown}${cell}${replacement}
` +const familyCell = (...ids) => ids.map(id => `${id}`).join(' | ') +const familyFeed = models => ({ ...minimalCommitted, models }) +const familyMerge = (models, page, extra = {}) => mergeFeed(familyFeed(models), { deprecations: parseOpenAIDeprecations(page), currentIds: null, generated: '2026-10-23T00:00:00Z', provider: PROVIDERS.openai, notice: () => {}, ...extra }) +const familyLifecycle = model => model && [model.announced, model.shutdown, model.replacement ?? model.replacement_options?.join(), model.source, model.aliases] +const familyDated = ['2026-10-01', '2027-04-01', 'gpt-next', PROVIDERS.openai.deprecationsUrl, undefined] +const familyDistribution = { via: 'azure-ai-foundry', shutdown: '2027-09-21', status: 'active', source: 'https://example.test/azure' } +reviewTest('Family alias: an OpenAI row naming a committed alias and its committed snapshot dates both and joins neither', () => { + const committed = [{ id: 'gpt-fam-2026-03-17', distributions: [familyDistribution] }, { id: 'gpt-fam' }] + const page = familyPage(['Apr 1, 2027', familyCell('gpt-fam', 'gpt-fam-2026-03-17')]) + const before = structuredClone(committed) + const merged = familyMerge(committed, page) + strictAssert.deepEqual(committed, before) + strictAssert.deepEqual(merged.feed.models.map(model => model.id), ['gpt-fam-2026-03-17', 'gpt-fam']) + for (const model of merged.feed.models) strictAssert.deepEqual(familyLifecycle(model), familyDated, model.id) + strictAssert.deepEqual(merged.feed.models[0].distributions, [familyDistribution]) + strictAssert.deepEqual(merged.unconfirmedIds, []) + const reversed = familyMerge(committed, familyPage(['Apr 1, 2027', familyCell('gpt-fam-2026-03-17', 'gpt-fam')])) + strictAssert.deepEqual(reversed.feed, merged.feed) +}) +reviewTest('Family alias: endpoint presence keeps the page lifecycle on both entries', () => { + const merged = familyMerge([{ id: 'gpt-fam-2026-03-17' }, { id: 'gpt-fam' }], familyPage(['Apr 1, 2027', familyCell('gpt-fam', 'gpt-fam-2026-03-17')]), { currentIds: ['gpt-fam', 'gpt-fam-2026-03-17'] }) + for (const model of merged.feed.models) strictAssert.deepEqual(familyLifecycle(model), familyDated, model.id) +}) +reviewTest('Family alias: a committed alias with a new snapshot adds the snapshot as its own dated entry', () => { + const merged = familyMerge([{ id: 'gpt-fam' }], familyPage(['Apr 1, 2027', familyCell('gpt-fam', 'gpt-fam-2026-03-17')])) + strictAssert.deepEqual(merged.feed.models.map(model => [model.id, ...familyLifecycle(model)]), [['gpt-fam', ...familyDated], ['gpt-fam-2026-03-17', ...familyDated]]) +}) +reviewTest('Family alias: an uncommitted alias still binds to its snapshot as before', () => { + const merged = familyMerge([{ id: 'gpt-fam-2026-03-17' }], familyPage(['Apr 1, 2027', familyCell('gpt-fam', 'gpt-fam-2026-03-17')])) + strictAssert.deepEqual(merged.feed.models.map(model => [model.id, model.aliases]), [['gpt-fam-2026-03-17', ['gpt-fam']]]) +}) +reviewTest('Family alias: one alias listed with two snapshots on the same lifecycle dates the alias once', () => { + const page = familyPage(['Apr 1, 2027', familyCell('gpt-fam', 'gpt-fam-2025-03-20')], ['Apr 1, 2027', familyCell('gpt-fam', 'gpt-fam-2025-12-15')]) + const merged = familyMerge([{ id: 'gpt-fam-2025-03-20' }, { id: 'gpt-fam-2025-12-15' }, { id: 'gpt-fam' }], page) + for (const model of merged.feed.models) strictAssert.deepEqual(familyLifecycle(model), familyDated, model.id) +}) +reviewTest('Family alias: fails closed when two snapshot rows give the alias different lifecycles', () => { + const committed = [{ id: 'gpt-fam-2025-03-20' }, { id: 'gpt-fam-2025-12-15' }, { id: 'gpt-fam' }] + for (const rows of [[['Apr 1, 2027', familyCell('gpt-fam', 'gpt-fam-2025-03-20')], ['Jun 1, 2027', familyCell('gpt-fam', 'gpt-fam-2025-12-15')]], + [['Apr 1, 2027', familyCell('gpt-fam', 'gpt-fam-2025-03-20')], ['Apr 1, 2027', familyCell('gpt-fam', 'gpt-fam-2025-12-15'), 'gpt-other-next']]]) { + strictAssert.throws(() => familyMerge(committed, familyPage(...rows)), /gpt-fam conflicting lifecycles/) + } +}) +reviewTest('Family alias: fails closed when the alias has its own row with a different lifecycle, in either order', () => { + const committed = [{ id: 'gpt-fam-2026-03-17' }, { id: 'gpt-fam' }] + const familyRow = ['Apr 1, 2027', familyCell('gpt-fam', 'gpt-fam-2026-03-17')] + const ownRow = ['Jun 1, 2027', familyCell('gpt-fam')] + for (const rows of [[familyRow, ownRow], [ownRow, familyRow]]) { + strictAssert.throws(() => familyMerge(committed, familyPage(...rows)), /gpt-fam conflicting lifecycles/) + } + const merged = familyMerge(committed, familyPage(familyRow, ['Apr 1, 2027', familyCell('gpt-fam')])) + for (const model of merged.feed.models) strictAssert.deepEqual(familyLifecycle(model), familyDated, model.id) +}) +reviewTest('Family alias: a committed canonical alias outside the snapshot family still throws', () => { + for (const [alias, snapshot] of [['gpt-other', 'gpt-fam-2026-03-17'], ['gpt-fam-mini', 'gpt-fam-2026-03-17'], ['gpt-fa', 'gpt-fam-2026-03-17'], ['gpt-fam', 'gpt-fam-mini-2026-03-17'], ['gpt-fam', 'gpt-fam-0613'], ['gpt-fam', 'gpt-fam-2026-03-17-completions']]) { + strictAssert.throws(() => familyMerge([{ id: snapshot }, { id: alias }], familyPage(['Apr 1, 2027', familyCell(alias, snapshot)])), /conflicts with (?:a )?canonical model id/, `${alias} | ${snapshot}`) + } +}) +reviewTest('Family alias: an alias owned by a third committed entry still throws', () => { + strictAssert.throws(() => familyMerge([{ id: 'gpt-fam-2026-03-17' }, { id: 'gpt-third', aliases: ['gpt-fam'] }], familyPage(['Apr 1, 2027', familyCell('gpt-fam', 'gpt-fam-2026-03-17')])), /conflicts with canonical model id gpt-third/) +}) +reviewTest('Family alias: the rule is OpenAI-only; other publishers still refuse a committed canonical alias', () => { + strictAssert.throws(() => mergeFeed({ ...anthropicFeed, models: [{ id: 'claude-x-2026-03-17' }, { id: 'claude-x' }] }, { + deprecations: [{ id: 'claude-x-2026-03-17', aliases: ['claude-x'], announced: '2026-10-01', shutdown: '2027-04-01' }], + currentIds: null, provider: PROVIDERS.anthropic, notice: () => {}, + }), /deprecation alias claude-x for claude-x-2026-03-17 conflicts with a canonical model id/) +}) reviewTest('2026-10-18 shapes: Google reads an N/A shutdown date as no shutdown', () => { const [record] = parseGoogleDeprecations('
ModelRelease dateShutdown dateRecommended replacement
gemini-3.7-flashAugust 13, 2026N/Agemini-3.8-flash
') strictAssert.equal(record.id, 'gemini-3.7-flash') From f646df489479163514c9c75205033f520de93adb Mon Sep 17 00:00:00 2001 From: thossullivan Date: Fri, 23 Oct 2026 20:34:06 -0500 Subject: [PATCH 2/3] Refuse alias rows that would date an id from a row that never names it Review found three ways the family rule could still assign a lifecycle silently. The parser kept the later announcement for a repeated id and carried the earlier row's aliases onto it, so an undated name took a date from a row that never listed it; that now throws when the dropped row names an alias the kept row does not. A row whose replacement is its own family alias means OpenAI is repointing the alias, not retiring it, so the family rule refuses it. A row that reaches its target through an alias is now covered by the same lifecycle conflict check as a direct row, in either order. Implemented by an Opus subagent; reviewed by Claude. --- refresh/providers.mjs | 15 +++++++++++---- refresh/test/run.mjs | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/refresh/providers.mjs b/refresh/providers.mjs index b330259..fe35c9f 100644 --- a/refresh/providers.mjs +++ b/refresh/providers.mjs @@ -680,6 +680,10 @@ export function parseOpenAIDeprecations(html, sourceUrl = PROVIDERS.openai.depre if (!previous.aliases.length) delete previous.aliases continue } + const [kept, dropped] = record.announced > previous.announced ? [record, previous] : [previous, record] + if (record.announced !== previous.announced && (dropped.aliases ?? []).some(alias => !(kept.aliases ?? []).includes(alias))) { + throw new Error(`openai deprecations page has conflicting rows for ${record.id}: an earlier announcement names aliases the later one does not`) + } if (record.announced > previous.announced) { record.aliases = [...new Set([...(record.aliases ?? []), ...(previous.aliases ?? [])])] .filter(alias => alias !== record.id) @@ -1717,12 +1721,12 @@ export function mergeFeed(committed, { deprecations = [], currentIds = null, cur const lifecycleFields = ['announced', 'shutdown', 'date_precision', 'replacement', 'replacement_options', 'replacement_note'] const lifecycleKey = record => JSON.stringify(lifecycleFields.map(field => record[field] ?? null)) const lifecycleApplied = new Map() - const noteLifecycle = (model, record, family) => { + const noteLifecycle = (model, record, indirect) => { const previous = lifecycleApplied.get(model) - if (previous && (previous.family || family) && previous.key !== lifecycleKey(record)) { + if (previous && (previous.indirect || indirect) && previous.key !== lifecycleKey(record)) { throw new Error(`deprecation rows give ${model.id} conflicting lifecycles via ${previous.recordId} and ${record.id}`) } - lifecycleApplied.set(model, { key: lifecycleKey(record), family, recordId: record.id }) + lifecycleApplied.set(model, { key: lifecycleKey(record), indirect: indirect || previous?.indirect, recordId: record.id }) } const familyAliasOf = (snapshot, alias) => provider.publisher === 'openai' && /-(?:19|20)\d{2}-\d{2}-\d{2}$/.test(snapshot) && snapshot.slice(0, -11) === alias @@ -1738,6 +1742,9 @@ export function mergeFeed(committed, { deprecations = [], currentIds = null, cur if (!alias || alias === canonicalId) continue const owner = locate(alias) if (owner && owner !== target && owner.id === alias && familyAliasOf(record.id, alias)) { + if (record.replacement === alias || record.replacement_options?.includes(alias)) { + throw new Error(`deprecation row for ${record.id} names its family alias ${alias} as the replacement`) + } familyAliases.push(owner) continue } @@ -1772,7 +1779,7 @@ export function mergeFeed(committed, { deprecations = [], currentIds = null, cur Object.assign(target, clone(record), { id: canonicalId }) if (aliases.size) target.aliases = [...aliases].filter(alias => alias !== canonicalId) else delete target.aliases - noteLifecycle(target, record, false) + noteLifecycle(target, record, record.id !== target.id) confirmed.add(target.id) deprecationConfirmed.add(target.id) // An OpenAI row naming a snapshot and its undated family id dates both entries and joins neither. diff --git a/refresh/test/run.mjs b/refresh/test/run.mjs index eece9ab..23b5084 100644 --- a/refresh/test/run.mjs +++ b/refresh/test/run.mjs @@ -3627,6 +3627,41 @@ reviewTest('Family alias: the rule is OpenAI-only; other publishers still refuse currentIds: null, provider: PROVIDERS.anthropic, notice: () => {}, }), /deprecation alias claude-x for claude-x-2026-03-17 conflicts with a canonical model id/) }) +const familyTable = (heading, shutdown, cell) => `

${heading}

Shutdown dateModel / systemRecommended replacement
${shutdown}${cell}gpt-next
` +reviewTest('PR 128 review 1: OpenAI refuses a later announcement that drops an alias the earlier row named, in either order', () => { + const earlier = familyTable('2026-06-01: Earlier', 'Dec 1, 2026', familyCell('gpt-fam', 'gpt-fam-2026-03-17')) + const later = familyTable('2026-10-01: Later', 'Jun 1, 2027', familyCell('gpt-fam-2026-03-17')) + for (const page of [earlier + later, later + earlier]) { + strictAssert.throws(() => parseOpenAIDeprecations(page), /conflicting rows for gpt-fam-2026-03-17/) + strictAssert.throws(() => familyMerge([{ id: 'gpt-fam-2026-03-17' }, { id: 'gpt-fam' }], page), /conflicting rows for gpt-fam-2026-03-17/) + } +}) +reviewTest('PR 128 review 1: OpenAI still keeps a later announcement that adds the alias itself', () => { + const earlier = familyTable('2026-06-01: Earlier', 'Dec 1, 2026', familyCell('gpt-fam-2026-03-17')) + const later = familyTable('2026-10-01: Later', 'Jun 1, 2027', familyCell('gpt-fam', 'gpt-fam-2026-03-17')) + for (const page of [earlier + later, later + earlier]) { + strictAssert.deepEqual(parseOpenAIDeprecations(page).map(record => [record.id, record.announced, record.shutdown, record.aliases]), [['gpt-fam-2026-03-17', '2026-10-01', '2027-06-01', ['gpt-fam']]]) + } +}) +reviewTest('PR 128 review 2: the family rule refuses a row whose replacement is the family alias itself', () => { + const committed = [{ id: 'gpt-fam-2026-03-17' }, { id: 'gpt-fam' }] + strictAssert.throws(() => familyMerge(committed, familyPage(['Apr 1, 2027', familyCell('gpt-fam', 'gpt-fam-2026-03-17'), 'gpt-fam'])), /names its family alias gpt-fam as the replacement/) + const options = familyPage(['Apr 1, 2027', familyCell('gpt-fam', 'gpt-fam-2026-03-17')]).replace('gpt-next', 'gpt-next or gpt-fam') + strictAssert.throws(() => familyMerge(committed, options), /names its family alias gpt-fam as the replacement/) +}) +reviewTest('PR 128 review 3: a row reaching its target through an alias cannot silently override an earlier lifecycle, in either order', () => { + const familyRow = ['Apr 1, 2027', familyCell('gpt-fam', 'gpt-fam-2026-03-17')] + const aliasRow = ['Jun 1, 2027', familyCell('gpt-fam')] + for (const rows of [[familyRow, aliasRow], [aliasRow, familyRow]]) { + strictAssert.throws(() => familyMerge([{ id: 'gpt-fam-2026-03-17' }], familyPage(...rows)), /conflicting lifecycles/) + } + const committedAlias = [{ id: 'gpt-fam-2026-03-17', aliases: ['gpt-fam'] }] + for (const rows of [[aliasRow, ['Apr 1, 2027', familyCell('gpt-fam-2026-03-17')]], [['Apr 1, 2027', familyCell('gpt-fam-2026-03-17')], aliasRow]]) { + strictAssert.throws(() => familyMerge(committedAlias, familyPage(...rows)), /conflicting lifecycles/) + } + const agreeing = familyMerge([{ id: 'gpt-fam-2026-03-17' }], familyPage(familyRow, ['Apr 1, 2027', familyCell('gpt-fam')])) + strictAssert.deepEqual(agreeing.feed.models.map(model => [model.id, ...familyLifecycle(model)]), [['gpt-fam-2026-03-17', '2026-10-01', '2027-04-01', 'gpt-next', PROVIDERS.openai.deprecationsUrl, ['gpt-fam']]]) +}) reviewTest('2026-10-18 shapes: Google reads an N/A shutdown date as no shutdown', () => { const [record] = parseGoogleDeprecations('
ModelRelease dateShutdown dateRecommended replacement
gemini-3.7-flashAugust 13, 2026N/Agemini-3.8-flash
') strictAssert.equal(record.id, 'gemini-3.7-flash') From ce3b98f59b41f1202dded8a49bb932b7e53e9b06 Mon Sep 17 00:00:00 2001 From: thossullivan Date: Fri, 23 Oct 2026 20:45:51 -0500 Subject: [PATCH 3/3] Compare alias lifecycles on dates and targets, and catch a repointed alias The alias conflict check compared every lifecycle field, so two rows that agreed on dates and replacement targets but differed in a replacement note or in option order failed the whole refresh. It now compares dates, precision, the replacement, and the replacement options as a set. The family rule refused a row whose replacement is the family alias, but not one whose replacement is the alias's own newer snapshot, which is the same signal that OpenAI is repointing the alias rather than retiring it. --- refresh/providers.mjs | 6 ++++-- refresh/test/run.mjs | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/refresh/providers.mjs b/refresh/providers.mjs index fe35c9f..97327cb 100644 --- a/refresh/providers.mjs +++ b/refresh/providers.mjs @@ -1719,7 +1719,8 @@ export function mergeFeed(committed, { deprecations = [], currentIds = null, cur } const lifecycleFields = ['announced', 'shutdown', 'date_precision', 'replacement', 'replacement_options', 'replacement_note'] - const lifecycleKey = record => JSON.stringify(lifecycleFields.map(field => record[field] ?? null)) + // Notes and option order are presentation; dates, precision, and replacement targets decide a conflict. + const lifecycleKey = record => JSON.stringify([record.announced, record.shutdown, record.date_precision, record.replacement, [...(record.replacement_options ?? [])].sort()].map(value => value ?? null)) const lifecycleApplied = new Map() const noteLifecycle = (model, record, indirect) => { const previous = lifecycleApplied.get(model) @@ -1742,7 +1743,8 @@ export function mergeFeed(committed, { deprecations = [], currentIds = null, cur if (!alias || alias === canonicalId) continue const owner = locate(alias) if (owner && owner !== target && owner.id === alias && familyAliasOf(record.id, alias)) { - if (record.replacement === alias || record.replacement_options?.includes(alias)) { + const repointsAlias = token => token === alias || (token?.startsWith(`${alias}-`) && familyAliasOf(token, alias)) + if ([record.replacement, ...(record.replacement_options ?? [])].some(repointsAlias)) { throw new Error(`deprecation row for ${record.id} names its family alias ${alias} as the replacement`) } familyAliases.push(owner) diff --git a/refresh/test/run.mjs b/refresh/test/run.mjs index 23b5084..bd14fc9 100644 --- a/refresh/test/run.mjs +++ b/refresh/test/run.mjs @@ -3662,6 +3662,23 @@ reviewTest('PR 128 review 3: a row reaching its target through an alias cannot s const agreeing = familyMerge([{ id: 'gpt-fam-2026-03-17' }], familyPage(familyRow, ['Apr 1, 2027', familyCell('gpt-fam')])) strictAssert.deepEqual(agreeing.feed.models.map(model => [model.id, ...familyLifecycle(model)]), [['gpt-fam-2026-03-17', '2026-10-01', '2027-04-01', 'gpt-next', PROVIDERS.openai.deprecationsUrl, ['gpt-fam']]]) }) +reviewTest('PR 128 late review: the family rule also refuses a replacement that is the alias\'s own newer snapshot', () => { + const committed = [{ id: 'gpt-fam-2025-08-07' }, { id: 'gpt-fam' }] + strictAssert.throws(() => familyMerge(committed, familyPage(['Dec 11, 2026', familyCell('gpt-fam', 'gpt-fam-2025-08-07'), 'gpt-fam-2026-03-01'])), /names its family alias gpt-fam as the replacement/) +}) +reviewTest('PR 128 late review: alias rows that differ only in a note or option order do not conflict', () => { + const committed = { ...anthropicFeed, models: [{ id: 'claude-x-20250514', aliases: ['claude-x'] }] } + const base = { announced: '2026-10-01', shutdown: '2027-04-01', replacement_options: ['claude-a', 'claude-b'] } + const merged = mergeFeed(committed, { + deprecations: [{ id: 'claude-x-20250514', ...base }, { id: 'claude-x', ...base, replacement_options: ['claude-b', 'claude-a'], replacement_note: 'Either works.' }], + currentIds: null, provider: PROVIDERS.anthropic, notice: () => {}, + }) + strictAssert.equal(merged.feed.models.find(model => model.id === 'claude-x-20250514').shutdown, '2027-04-01') + strictAssert.throws(() => mergeFeed(committed, { + deprecations: [{ id: 'claude-x-20250514', ...base }, { id: 'claude-x', ...base, shutdown: '2027-06-01' }], + currentIds: null, provider: PROVIDERS.anthropic, notice: () => {}, + }), /conflicting lifecycles/) +}) reviewTest('2026-10-18 shapes: Google reads an N/A shutdown date as no shutdown', () => { const [record] = parseGoogleDeprecations('
ModelRelease dateShutdown dateRecommended replacement
gemini-3.7-flashAugust 13, 2026N/Agemini-3.8-flash
') strictAssert.equal(record.id, 'gemini-3.7-flash')