Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions refresh/providers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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`)
}
Expand Down Expand Up @@ -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)
}

Expand Down
120 changes: 120 additions & 0 deletions refresh/test/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => `<h2>2026-10-01: Family deprecations</h2><table><tr><th>Shutdown date</th><th>Model / system</th><th>Recommended replacement</th></tr>${rows.map(([shutdown, cell, replacement = 'gpt-next']) => `<tr><td>${shutdown}</td><td>${cell}</td><td><code>${replacement}</code></td></tr>`).join('')}</table>`
const familyCell = (...ids) => ids.map(id => `<code>${id}</code>`).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) => `<h2>${heading}</h2><table><tr><th>Shutdown date</th><th>Model / system</th><th>Recommended replacement</th></tr><tr><td>${shutdown}</td><td>${cell}</td><td><code>gpt-next</code></td></tr></table>`
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('<code>gpt-next</code>', '<code>gpt-next</code> or <code>gpt-fam</code>')
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('<table><tr><th>Model</th><th>Release date</th><th>Shutdown date</th><th>Recommended replacement</th></tr><tr><td><code>gemini-3.7-flash</code></td><td>August 13, 2026</td><td>N/A</td><td><code>gemini-3.8-flash</code></td></tr></table>')
strictAssert.equal(record.id, 'gemini-3.7-flash')
Expand Down
Loading