diff --git a/refresh/providers.mjs b/refresh/providers.mjs
index 42f9264..97327cb 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)
@@ -1714,15 +1718,38 @@ export function mergeFeed(committed, { deprecations = [], currentIds = null, cur
index = identityIndex(models)
}
+ const lifecycleFields = ['announced', 'shutdown', 'date_precision', 'replacement', 'replacement_options', 'replacement_note']
+ // 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)
+ 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), 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
+
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)) {
+ 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)
+ 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 +1781,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, 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.
+ 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..bd14fc9 100644
--- a/refresh/test/run.mjs
+++ b/refresh/test/run.mjs
@@ -3559,6 +3559,126 @@ 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
| Shutdown date | Model / system | Recommended replacement |
${rows.map(([shutdown, cell, replacement = 'gpt-next']) => `| ${shutdown} | ${cell} | ${replacement} |
`).join('')}
`
+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/)
+})
+const familyTable = (heading, shutdown, cell) => `${heading}
| Shutdown date | Model / system | Recommended 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('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('| Model | Release date | Shutdown date | Recommended replacement |
|---|
gemini-3.7-flash | August 13, 2026 | N/A | gemini-3.8-flash |
')
strictAssert.equal(record.id, 'gemini-3.7-flash')