model-eol 0.6.2 → 0.8.0

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/README.md CHANGED
@@ -36,7 +36,7 @@ format instead of each scraping the same pages.
36
36
  replacement, `distributions` for per-channel lifecycles, and publisher `policy`
37
37
  floors. It is small enough that a provider could serve it at
38
38
  `/.well-known/model-eol.json` in an afternoon.
39
- - **`feeds/`** - <!-- feeds-status -->Amazon (4 entries), Anthropic (30 entries), Google (93 entries) and OpenAI (195 entries), generated from the providers' live deprecation pages plus the AWS Bedrock and Google Vertex AI lifecycle pages, feed data generated 2026-09-07<!-- /feeds-status -->. Every
39
+ - **`feeds/`** - <!-- feeds-status -->Amazon (4 entries), Anthropic (32 entries), Cohere (12 entries), Google (93 entries), Mistral (40 entries) and OpenAI (195 entries), generated from the providers' live deprecation pages plus the AWS Bedrock, Google Vertex AI, and Azure Foundry lifecycle pages, feed data generated 2026-09-09<!-- /feeds-status -->. Every
40
40
  dated entry carries a source URL. Anthropic's "not sooner than" dates for
41
41
  active models are included as `tentative` planning floors.
42
42
  - **`check.mjs`** - zero-dependency CLI: CI gate, PR diff gate, inventory, CycloneDX
@@ -115,7 +115,8 @@ files. The checker, PR gate, schedule, SBOM export, and badge run with no
115
115
  credentials at all. The bot needs a GitHub token, and a fine-grained PAT if you
116
116
  want its PRs to trigger checks. Provider API keys are optional in exactly two
117
117
  places: your own eval command, and the models-endpoint coverage in the feed
118
- refresh.
118
+ refresh. Refresh accepts `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`
119
+ (or `GOOGLE_API_KEY`), `MISTRAL_API_KEY`, and `COHERE_API_KEY`.
119
120
 
120
121
  Both command-line binaries ship in one zero-dependency npm package. The test
121
122
  suite packs that package, installs it into an empty directory with the network
@@ -306,10 +307,25 @@ until January 8, 2027.
306
307
 
307
308
  `distributions` in the spec carries these per-channel dates. `--via <distributor>`
308
309
  judges your repo by the channel you call. The distributor refresh keeps the
309
- Bedrock and Vertex dates current from their lifecycle pages. A distributor's
310
- later date gives you more time for the same migration. It is not a reason to
310
+ Bedrock, Vertex, and Azure Foundry dates current from their lifecycle pages.
311
+ Bedrock combines its [legacy table](https://docs.aws.amazon.com/bedrock/latest/userguide/model-lifecycle-legacy.html)
312
+ with [model cards](https://docs.aws.amazon.com/bedrock/latest/userguide/model-cards.html).
313
+ Card floors become tentative distribution dates without announcements.
314
+ Under `--via aws-bedrock`, these floors remain `scheduled` and display "not sooner than".
315
+ A distributor's later date gives you more time for the same migration. It is not a reason to
311
316
  skip it.
312
317
 
318
+ The later date is also the last day you can still call the old model, when the
319
+ feed gives it as an exact date. A tentative or earliest floor is "at least until",
320
+ and the old model may answer past it. When a retired or retiring model still
321
+ answers on another channel, `check` appends
322
+ `[still answers via aws-bedrock until 2026-10-14]` to the line, and the bot adds
323
+ a `## Capture window` section to its PRs and issues. If your eval compares the
324
+ replacement's outputs against the old model's, capture that baseline before the
325
+ window closes. model-eol reports the window from the feeds and never runs the
326
+ capture; see [docs/DESIGN_EVAL_BOUNDARY.md](docs/DESIGN_EVAL_BOUNDARY.md) for
327
+ where that line sits and why.
328
+
313
329
  ## Policy floors
314
330
 
315
331
  A feed entry with no `announced` and no `shutdown` means "nothing scheduled as
@@ -440,6 +456,8 @@ receives:
440
456
  - `MODEL_EOL_OLD_ID` and `MODEL_EOL_NEW_ID` for the current migration.
441
457
  - `MODEL_EOL_PLAN`, a one-model plan containing the exact changed references.
442
458
  - `MODEL_EOL_REPORT`, the required path for a bounded, publishable Markdown receipt.
459
+ - `MODEL_EOL_VIA`, the distributor channel the plan was built for, or empty for the publisher's direct API. Use it to map the publisher's replacement ID to your client's ID on that channel.
460
+ - `MODEL_EOL_EVAL_MODE`, always `evaluate` today. The value `capture` is reserved for a future run on the unpatched checkout against the old ID. The bot does not run it, and the hook refuses any other value.
443
461
 
444
462
  Exit zero plus a regular report file means pass. Any other exit, a timeout, a
445
463
  missing report, a tracked workspace change, or checkout drift fails that
@@ -505,12 +523,28 @@ read-only evaluation job, and write tokens live only in the reconciliation job.
505
523
 
506
524
  ```sh
507
525
  node refresh/refresh.mjs --check # semantic diff vs live pages; exit 3 = PR-worthy
508
- node refresh/refresh.mjs --distributor aws-bedrock,vertex-ai # distributor lifecycle clocks
526
+ node refresh/refresh.mjs --distributor aws-bedrock,vertex-ai,azure-ai-foundry # distributor lifecycle clocks
509
527
  node scripts/feed-changelog.mjs # local rendering of the hosted Atom feed
510
528
  ```
511
529
 
512
530
  A parse failure stops the run. The refresh never writes a guessed feed.
513
531
 
532
+ Anthropic reads model IDs and aliases from overview pages listed in `https://platform.claude.com/docs/llms.txt`.
533
+ It reads at most 100 unique pages with 30-second deadlines and 8 MiB response limits.
534
+ Only the deprecations page supplies Anthropic dates.
535
+ Offline checks use `anthropic-llms.txt` and `anthropic-model-pages/<slug>.md`; missing pages stop refresh.
536
+ Alias attachments and moves emit notices and appear in semantic diffs.
537
+
538
+ AWS changed its Bedrock lifecycle policy on 2026-09-07.
539
+ The legacy table covers models launched before that date; newer models publish lifecycle dates on individual cards.
540
+ The Bedrock refresh reads both sources and keeps legacy-table records when IDs overlap.
541
+ Conflicting exact dates appear under "Source conflicts"; the legacy table wins.
542
+ When two card floors differ, the later day-precision date applies.
543
+ Cards without lifecycle fields or day-precision dates appear under "Model cards without lifecycle fields" and emit notices.
544
+ The crawl reads at most 400 unique cards sequentially, with 30-second deadlines and 8 MiB response limits.
545
+ Offline checks use `bedrock-lifecycle.html`, `bedrock-model-cards.html`, and `bedrock-model-cards/<basename>` fixtures.
546
+ Missing card fixtures stop the refresh.
547
+
514
548
  This runs automatically. A weekly workflow
515
549
  (`.github/workflows/feed-refresh.yml`, Mondays 05:23 UTC) checks the live
516
550
  sources and opens a PR with the semantic diff, plus an updated README freshness
@@ -566,9 +600,9 @@ is the most useful thing any of us can do here.
566
600
  - The checker matches known IDs only. It does not discover models that are
567
601
  absent from the feeds. This is deliberate: a CI gate needs precision more than
568
602
  discovery.
569
- - Fetchers: OpenAI, Anthropic, Google, aws-bedrock, and vertex-ai are live.
570
- Azure dates are carried where OpenAI's own page publishes them. A standalone
571
- Azure lifecycle fetcher is not built yet.
603
+ - Fetchers: OpenAI, Anthropic, Google, Mistral, Cohere, aws-bedrock, vertex-ai,
604
+ and azure-ai-foundry are live. xAI publishes no lifecycle index and its models
605
+ API carries no deprecation fields, so xAI is not ingested.
572
606
  - On npm as [`model-eol`](https://www.npmjs.com/package/model-eol). Material
573
607
  feed changes republish automatically as patch versions, with trusted
574
608
  publishing and provenance. Code releases require an explicit stable version.
package/SPEC.md CHANGED
@@ -69,6 +69,11 @@ Private arrangements after EOL are not represented by this status. `retired`
69
69
  means the distributor explicitly reports the model as no longer generally
70
70
  available even when it does not publish an exact shutdown date.
71
71
 
72
+ Bedrock model-card floors are tentative distribution dates (`date_precision: "tentative"`) without `announced`.
73
+
74
+ Azure maps GA/Preview/Legacy to `active`, Deprecated to `legacy`, and Retired to `retired`; Azure Legacy permits new deployments.
75
+ Azure OpenAI uses a resolving bare identity for single-row models only when a dated snapshot candidate is absent.
76
+
72
77
  A model may contain at most one distribution for each `via`. Duplicate channel
73
78
  records are ambiguous and fail runtime semantic validation. Draft-07 can enforce
74
79
  whole-object uniqueness, but cannot express uniqueness keyed by the `via` field,
package/bot/bot.mjs CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  stableJson,
26
26
  } from './lib/common.mjs'
27
27
  import { assertValidPlanDocument } from '../lib/validate-document.mjs'
28
+ import { captureWindowFor, lifecycleFor } from '../lib/feeds.mjs'
28
29
  import { parseCliArgs } from '../lib/cli.mjs'
29
30
  import { loadConfig } from './lib/config.mjs'
30
31
  import { downloadFeeds } from './lib/feeds.mjs'
@@ -363,11 +364,21 @@ const buildModelGroups = (plan, config, root, records) => {
363
364
  }
364
365
  for (const group of groups.values()) {
365
366
  group.items.sort((a, b) => `${a.file}:${a.line}:${a.occurrence}`.localeCompare(`${b.file}:${b.line}:${b.occurrence}`))
366
- group.feedDigest = itemDigest(group.items)
367
+ group.feedDigest = groupDigest(group.items, group.context?.entry)
367
368
  }
368
369
  return [...groups.values()].sort((a, b) => `${a.publisher}/${a.id}`.localeCompare(`${b.publisher}/${b.id}`))
369
370
  }
370
371
 
372
+ // the capture section reads clocks outside the plan items, so they join the digest that gates body updates
373
+ const clockRows = entry => entry
374
+ ? {
375
+ shutdown: entry.shutdown ?? null,
376
+ date_precision: entry.date_precision ?? null,
377
+ distributions: itemDigest((entry.distributions ?? []).map(row => ({ via: row.via, shutdown: row.shutdown ?? null, status: row.status ?? null, date_precision: row.date_precision ?? null }))),
378
+ }
379
+ : null
380
+ const groupDigest = (items, entry) => sha256(stableJson({ items: itemDigest(items), clocks: clockRows(entry) }))
381
+
371
382
  const buildIssueGroups = (plan, config, root, records) => {
372
383
  const ignoredIds = ignoredModelIds(plan, config)
373
384
  const groups = new Map()
@@ -398,7 +409,7 @@ const buildIssueGroups = (plan, config, root, records) => {
398
409
  }
399
410
  for (const group of groups.values()) {
400
411
  group.issues.sort((a, b) => `${a.file}:${a.line}:${a.reason}`.localeCompare(`${b.file}:${b.line}:${b.reason}`))
401
- group.feedDigest = itemDigest(group.issues)
412
+ group.feedDigest = groupDigest(group.issues, group.context?.entry)
402
413
  }
403
414
  return [...groups.values()].sort((a, b) => `${a.publisher}/${a.subject}/${a.shutdown ?? ''}`.localeCompare(`${b.publisher}/${b.subject}/${b.shutdown ?? ''}`))
404
415
  }
@@ -484,6 +495,43 @@ const replacementSection = (item, now) => [
484
495
  : []),
485
496
  ]
486
497
 
498
+ const captureWindow = (group, now) => {
499
+ const entry = group.context?.entry
500
+ if (!entry) return null
501
+ const lifecycle = lifecycleFor(entry, {
502
+ days: group.items?.[0]?.threshold_days ?? group.issues?.[0]?.threshold_days ?? 0,
503
+ via: group.via ?? null,
504
+ today: now,
505
+ })
506
+ return captureWindowFor(entry, lifecycle, { today: now })
507
+ }
508
+
509
+ // the first date on which the capture section changes, so a body can be refreshed when the digest cannot see it
510
+ const captureExpiry = capture => capture
511
+ ? [capture.until, ...capture.alternatives.map(alternative => alternative.until)].filter(Boolean).sort()[0] ?? null
512
+ : null
513
+
514
+ const captureExpired = (metadata, now) => typeof metadata.capture_expires === 'string' && metadata.capture_expires <= now.toISOString().slice(0, 10)
515
+
516
+ const captureSection = (group, now) => {
517
+ const capture = captureWindow(group, now)
518
+ if (!capture) return ''
519
+ const clock = capture.via === 'publisher' || capture.via === 'publisher-fallback'
520
+ ? markdownText('publisher')
521
+ : markdownCode(capture.via)
522
+ const untilText = window => `${['tentative', 'earliest'].includes(window.date_precision) ? 'at least until' : 'until'} ${markdownText(window.until)}`
523
+ return [
524
+ '## Capture window',
525
+ capture.until !== null
526
+ ? `- The old model answers on the ${clock} clock ${untilText(capture)}.`
527
+ : `- The old model no longer answers on the ${clock} clock. No baseline can be captured from it there.`,
528
+ ...capture.alternatives.map(alternative => `- It still answers via ${markdownCode(alternative.via)} ${untilText(alternative)}.`),
529
+ capture.until === null && capture.alternatives.length === 0
530
+ ? '- The feed lists no dated clock on which the old model still answers, so model-eol cannot state a capture window. model-eol does not run captures.'
531
+ : '- Capture a baseline from the old model before the window closes if your eval compares outputs. model-eol does not run captures.',
532
+ ].join('\n')
533
+ }
534
+
487
535
  export const buildPullBody = ({ group, headSha, baseSha = null, now = new Date(), tokenKind = null, evalResult = null, evalConfigHash = null }) => {
488
536
  const item = group.items[0]
489
537
  const announced = markdownText(group.context?.announced ?? 'not specified')
@@ -500,6 +548,7 @@ export const buildPullBody = ({ group, headSha, baseSha = null, now = new Date()
500
548
  base_sha: baseSha,
501
549
  head_sha: headSha,
502
550
  feed_digest: group.feedDigest,
551
+ capture_expires: captureExpiry(captureWindow(group, now)),
503
552
  ...(evalConfigHash ? { eval_config_digest: evalConfigHash } : {}),
504
553
  }
505
554
  const sections = [
@@ -522,6 +571,8 @@ export const buildPullBody = ({ group, headSha, baseSha = null, now = new Date()
522
571
  if (group.via) {
523
572
  sections.push('', '## Distributor clock', `This migration uses the ${markdownCode(group.via)} distributor clock; the shutdown date above is the date for that channel.`)
524
573
  }
574
+ const capture = captureSection(group, now)
575
+ if (capture) sections.push('', capture)
525
576
  const evaluation = evalSection(evalResult)
526
577
  if (evaluation) sections.push('', evaluation)
527
578
  sections.push(
@@ -547,6 +598,7 @@ export const buildIssueBody = ({ group, now = new Date() }) => {
547
598
  replacement_note: issue.replacement_note,
548
599
  head_sha: null,
549
600
  feed_digest: group.feedDigest,
601
+ capture_expires: captureExpiry(captureWindow(group, now)),
550
602
  ...(group.channel ? { channel: group.channel } : {}),
551
603
  }
552
604
  const evidence = group.issues
@@ -580,6 +632,8 @@ export const buildIssueBody = ({ group, now = new Date() }) => {
580
632
  '## Feed notes',
581
633
  notesSection(group),
582
634
  ]
635
+ const capture = captureSection(group, now)
636
+ if (capture) sections.push('', capture)
583
637
  const evaluation = evalSection(group.evalResult)
584
638
  if (evaluation) sections.push('', evaluation)
585
639
  return sections.join('\n')
@@ -841,6 +895,8 @@ export const evaluatePlan = async ({
841
895
  cwd: clone,
842
896
  oldId: group.id,
843
897
  newId: group.items[0].replacement,
898
+ via: group.via ?? null,
899
+ mode: 'evaluate',
844
900
  planPath: selectedPlanPath,
845
901
  reportPath,
846
902
  })
@@ -1090,7 +1146,7 @@ const processModel = async ({ api, pulls, issueRecords, group, source, base, bas
1090
1146
  if (externalEval && externalEval.status !== 'pass') {
1091
1147
  return processEvalFailure({ api, issueRecords, group, evalResult: externalEval, root, now, issuesEnabled: config.issues.enabled, open })
1092
1148
  }
1093
- if (open.metadata.feed_digest === currentDigest && open.metadata.base_sha === baseHead && (!evalConfigHash || open.metadata.eval_config_digest === evalConfigHash)) {
1149
+ if (open.metadata.feed_digest === currentDigest && open.metadata.base_sha === baseHead && (!evalConfigHash || open.metadata.eval_config_digest === evalConfigHash) && !captureExpired(open.metadata, now)) {
1094
1150
  return decision(group, 'skip-unchanged', { number: open.item.number })
1095
1151
  }
1096
1152
  let patch
@@ -1250,7 +1306,7 @@ const processIssue = async ({ api, issues, group, now }) => {
1250
1306
  const open = matches.find(record => isOpen(record.item))
1251
1307
  const body = buildIssueBody({ group, now })
1252
1308
  if (open) {
1253
- if (open.metadata.feed_digest === group.feedDigest) return decision(group, 'skip-unchanged', { number: open.item.number, body })
1309
+ if (open.metadata.feed_digest === group.feedDigest && !captureExpired(open.metadata, now)) return decision(group, 'skip-unchanged', { number: open.item.number, body })
1254
1310
  await api.updateIssue(open.item.number, { title: issueTitle(group), body })
1255
1311
  return decision(group, 'update', { number: open.item.number, body })
1256
1312
  }
@@ -16,7 +16,10 @@ const stableValue = value => {
16
16
  export const stableJson = value => JSON.stringify(stableValue(value))
17
17
 
18
18
  export const sortedJsonDigest = values => {
19
- const sorted = values.slice().sort((a, b) => stableJson(a).localeCompare(stableJson(b)))
19
+ const sorted = values.slice().sort((a, b) => {
20
+ const [left, right] = [stableJson(a), stableJson(b)]
21
+ return left < right ? -1 : left > right ? 1 : 0
22
+ })
20
23
  return sha256(stableJson(sorted))
21
24
  }
22
25
 
@@ -67,6 +70,7 @@ export const parseMetadata = body => {
67
70
  if (metadata.replacement !== undefined && metadata.replacement !== null && typeof metadata.replacement !== 'string') return null
68
71
  if (metadata.replacement_options !== undefined && (!Array.isArray(metadata.replacement_options) || metadata.replacement_options.some(option => typeof option !== 'string'))) return null
69
72
  if (metadata.replacement_note !== undefined && metadata.replacement_note !== null && typeof metadata.replacement_note !== 'string') return null
73
+ if (metadata.capture_expires !== undefined && metadata.capture_expires !== null && typeof metadata.capture_expires !== 'string') return null
70
74
  if (metadata.base_sha !== undefined && metadata.base_sha !== null && (typeof metadata.base_sha !== 'string' || !/^[0-9a-f]{40,64}$/.test(metadata.base_sha))) return null
71
75
  if (metadata.stale_closed !== undefined && metadata.stale_closed !== true) return null
72
76
  if (typeof metadata.feed_digest !== 'string' || !metadata.feed_digest) return null
@@ -111,7 +115,11 @@ export const markdownText = value => String(value ?? '')
111
115
  .map(line => line.replace(/^(\s*)([-+>]\s|\d+\.\s)/, '$1&#45; '))
112
116
  .join('\n')
113
117
 
114
- export const markdownCode = value => `\`${markdownText(value)}\``
118
+ // code spans show entities literally, so drop backticks and line breaks instead of escaping
119
+ export const markdownCode = value => {
120
+ const text = String(value ?? '').replace(/[\r\n]+/g, ' ').replaceAll('`', '')
121
+ return `\`${text === '' ? ' ' : text}\``
122
+ }
115
123
 
116
124
  export const markdownLink = (label, value) => {
117
125
  const text = String(value ?? '')
package/bot/lib/eval.mjs CHANGED
@@ -50,7 +50,8 @@ export const readReportCapped = (file, limit) => {
50
50
  }
51
51
  }
52
52
 
53
- export const runEvalHook = ({ command, timeoutMs, maxReportBytes, passEnv = [], cwd, oldId, newId, planPath, reportPath }) => {
53
+ export const runEvalHook = ({ command, timeoutMs, maxReportBytes, passEnv = [], cwd, oldId, newId, planPath, reportPath, via = null, mode = 'evaluate' }) => {
54
+ if (mode !== 'evaluate') throw new Error(`unsupported eval mode: ${mode}`)
54
55
  if (fs.existsSync(reportPath)) fs.rmSync(reportPath, { force: true })
55
56
  const env = {}
56
57
  for (const key of ['PATH', 'HOME', 'TMPDIR', 'LANG', 'USER', 'SHELL']) {
@@ -63,6 +64,8 @@ export const runEvalHook = ({ command, timeoutMs, maxReportBytes, passEnv = [],
63
64
  env.MODEL_EOL_NEW_ID = newId
64
65
  env.MODEL_EOL_PLAN = planPath
65
66
  env.MODEL_EOL_REPORT = reportPath
67
+ env.MODEL_EOL_VIA = via ?? ''
68
+ env.MODEL_EOL_EVAL_MODE = mode
66
69
 
67
70
  const result = spawnSync(command, {
68
71
  cwd,
package/check.mjs CHANGED
@@ -406,7 +406,8 @@ if (command === 'plan') {
406
406
 
407
407
  if (command === 'check') {
408
408
  if (AS_JSON) {
409
- console.log(JSON.stringify({ schema: 'model-eol/check@0.1', threshold_days: DAYS, distributor: VIA, scope: SCOPE, scan_notes: scan.notes, findings: changedFindings }, null, 2))
409
+ // capture stays out of the 0.1 document until the contract grows; the human line and the bot carry it
410
+ console.log(JSON.stringify({ schema: 'model-eol/check@0.1', threshold_days: DAYS, distributor: VIA, scope: SCOPE, scan_notes: scan.notes, findings: changedFindings.map(({ capture, ...finding }) => finding) }, null, 2))
410
411
  } else {
411
412
  console.log(formatCheck({ findings: changedFindings, bad, scannedFiles: scan.files.length, days: DAYS, scope: SCOPE }))
412
413
  }
package/feeds/amazon.json CHANGED
@@ -15,7 +15,7 @@
15
15
  "announced": "2026-03-30",
16
16
  "shutdown": "2026-09-30",
17
17
  "status": "legacy",
18
- "source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-lifecycle.html"
18
+ "source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-lifecycle-legacy.html"
19
19
  }
20
20
  ]
21
21
  },
@@ -30,7 +30,7 @@
30
30
  "announced": "2026-03-30",
31
31
  "shutdown": "2026-09-30",
32
32
  "status": "legacy",
33
- "source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-lifecycle.html"
33
+ "source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-lifecycle-legacy.html"
34
34
  }
35
35
  ]
36
36
  },
@@ -45,7 +45,7 @@
45
45
  "announced": "2026-03-13",
46
46
  "shutdown": "2026-09-14",
47
47
  "status": "legacy",
48
- "source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-lifecycle.html"
48
+ "source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-lifecycle-legacy.html"
49
49
  }
50
50
  ]
51
51
  },
@@ -60,7 +60,7 @@
60
60
  "announced": "2026-03-13",
61
61
  "shutdown": "2026-09-14",
62
62
  "status": "legacy",
63
- "source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-lifecycle.html"
63
+ "source": "https://docs.aws.amazon.com/bedrock/latest/userguide/model-lifecycle-legacy.html"
64
64
  }
65
65
  ]
66
66
  }