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

${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/) +}) +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('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')