From 890a98cbc7f20b4e0fdfb2fc9749642e11ed4dca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:43:19 +0000 Subject: [PATCH 1/3] Initial plan From 87b31985cb8b87c7a7fe845b2377a971be80b7e5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:51:50 +0000 Subject: [PATCH 2/3] refactor: migrate lifecycle readers to generated projections Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- .../api-proxy/guards/retired-model-guard.js | 13 +-- .../guards/retired-model-guard.test.js | 9 ++ docs/adr/0004-model-information-management.md | 59 ++++++++++- scripts/model-information/compile.cjs | 31 ++++-- scripts/model-information/compile.test.ts | 97 ++++++++++++++++++- .../baseline-to-target.generated.json | 2 +- .../model-information/catalog.generated.json | 2 +- .../model-information/manifest.generated.json | 5 +- .../sources/lifecycle-compatibility.json | 47 +++++++++ .../model-information/target.generated.json | 2 +- src/copilot-model.test.ts | 25 +++++ src/copilot-model.ts | 67 ++----------- src/model-information.compat.generated.json | 39 +++++++- 13 files changed, 315 insertions(+), 83 deletions(-) create mode 100644 shared/model-information/sources/lifecycle-compatibility.json diff --git a/containers/api-proxy/guards/retired-model-guard.js b/containers/api-proxy/guards/retired-model-guard.js index b29a53b48..e3a4b9ba7 100644 --- a/containers/api-proxy/guards/retired-model-guard.js +++ b/containers/api-proxy/guards/retired-model-guard.js @@ -12,12 +12,13 @@ const { sanitizeForLog } = require('../logging'); * or 403) rather than a clear "model unavailable" message, which misleads * operators into thinking their API keys are invalid. * - * Keep this list in sync with RETIRED_COPILOT_MODEL_ALIASES in - * src/copilot-model.ts (the TypeScript CLI equivalent). + * Generated from shared/model-information/sources by models:generate. + * Keep lowercase-only matching here: host separator equivalence is a separate + * compatibility policy, not an implicit proxy normalization correction. */ -const RETIRED_COPILOT_MODELS = { - 'gpt-5-codex': 'gpt-5.3-codex', -}; +const RETIRED_COPILOT_MODELS = new Map( + Object.entries(require('../model-information.retired.generated.json')), +); /** * Returns a block-state object when the given model name is a known-retired @@ -29,7 +30,7 @@ const RETIRED_COPILOT_MODELS = { function getRetiredModelBlockState(model) { if (!model) return null; const key = model.toLowerCase(); - const suggestion = RETIRED_COPILOT_MODELS[key]; + const suggestion = RETIRED_COPILOT_MODELS.get(key); if (!suggestion) return null; return { model: sanitizeForLog(model), suggestion }; } diff --git a/containers/api-proxy/guards/retired-model-guard.test.js b/containers/api-proxy/guards/retired-model-guard.test.js index fc5e36c2f..b6d486052 100644 --- a/containers/api-proxy/guards/retired-model-guard.test.js +++ b/containers/api-proxy/guards/retired-model-guard.test.js @@ -33,6 +33,15 @@ describe('retired-model-guard', () => { expect(getRetiredModelBlockState('Gpt-5-Codex')).not.toBeNull(); }); + it.each([ + 'GPT_5_CODEX', 'gpt.5.codex', ' gpt-5-codex ', + 'copilot/gpt-5-codex', 'openai/gpt-5-codex', + 'gpt-5-codex-2026-10-01', 'gpt-5-codex?deployment=prod', + 'constructor', '__proto__', 'toString', + ])('preserves lowercase-only exact retirement matching for %s', model => { + expect(getRetiredModelBlockState(model)).toBeNull(); + }); + it('preserves the original casing in the returned model field', () => { const state = getRetiredModelBlockState('GPT-5-CODEX'); expect(state.model).toBe('GPT-5-CODEX'); diff --git a/docs/adr/0004-model-information-management.md b/docs/adr/0004-model-information-management.md index 6780fc57c..03d2a8c30 100644 --- a/docs/adr/0004-model-information-management.md +++ b/docs/adr/0004-model-information-management.md @@ -1116,7 +1116,7 @@ Run `npm run models:generate` after editing source records; run `npm run models:check` to validate complete-set identity and verify every generated byte. Both commands are offline and never update capture times. Only the explicit pinned-snapshot import operation may introduce a new import -capture. The compiler version is 1; the schema version is 1; the deterministic +capture. The compiler version is 3; the schema version is 1; the deterministic revision and source list are in `shared/model-information/manifest.generated.json`. Static `generatedAt` is the newest committed source capture/mapping review time, **not** a build time. @@ -1135,7 +1135,7 @@ Automatic upstream refresh or publication is reserved for Phase 8. | Source of facts | Consumer and compiled projection | Activation | | --- | --- | --- | -| `shared/model-information/sources/copilot-{main,claude,gemini,other}.json` (Copilot-qualified exact records grouped by ID family, **not** asserted billing provider; lifecycle, CLI support and curated tiers) | `src/model-information.compat.generated.json`, `containers/api-proxy/model-information.{pricing,retired}.generated.json` | Staged compatibility; existing host and proxy readers remain authoritative until Gates 4/5. | +| `shared/model-information/sources/copilot-{main,claude,gemini,other}.json` (Copilot-qualified exact records grouped by ID family, **not** asserted billing provider; lifecycle, CLI support and curated tiers) | `src/model-information.compat.generated.json`, `containers/api-proxy/model-information.{pricing,retired}.generated.json` | Host lifecycle and proxy retirement projections active under the partial Gate 4 migration below; pricing remains staged until Gate 5. | | `shared/model-information/sources/pricing-reference.json` (reviewed reference coverage, provider documentation and dates; rates derive from canonical tiers, with explicitly recorded reasoning-equals-output baseline) | `containers/api-proxy/ai-credits-pricing-reference.json` | Active test oracle only, not a runtime configured catalogue or feed. | | `shared/model-information/sources/mapping.json` (pinned endpoint family/ordering, patterns, routing, provenance) | `docs/model-api-mapping.json`, `containers/api-proxy/model-api-mapping.json` | Active byte-identical compatibility format, no routing behavior change. | | `shared/model-information/sources/target.json` (#10029/#10030 evidence and exact corrections) | `shared/model-information/target.generated.json`, `baseline-to-target.generated.json` | Staged only; named changes require explicit Gates 4–6 activation/review. | @@ -1160,7 +1160,7 @@ translation support cannot be inferred from mapping families or imported entries. The reference file remains a test oracle, not a second price authoring path. -The legacy host allowlist, host suggestion algorithm, proxy retirement guard +The captured host allowlist, host suggestion algorithm, proxy retirement guard and curated pricing reader are **compatibility baselines**, not additional places to author new facts. The generated asset copies live in their consumer contexts; the proxy Dockerfile explicitly copies its generated JSON assets @@ -1200,6 +1200,59 @@ through the gh-aw compiler and AWF config reader as run-scoped overlays. Enforce harness requirements against the actual execution environment for explicit models and routing/fallback candidates. +#### Partial Phase 4 handoff: compatibility readers only + +Prerequisites are [Phase 1 #10301](https://github.com/github/gh-aw-firewall/pull/10301), +[Phase 2 #10310](https://github.com/github/gh-aw-firewall/pull/10310), and +[Phase 3 #10317](https://github.com/github/gh-aw-firewall/pull/10317). +This bounded migration **does not complete or close #10294**. + +- `src/copilot-model.ts` now reads the generated host lifecycle projection; + `containers/api-proxy/guards/retired-model-guard.js` reads the generated + retirement projection already copied by the proxy Dockerfile. Neither reader + owns a maintained availability or retirement table. +- Only exact Copilot records with unscoped-deployment, supported `copilot-cli` + availability enter host support. Native-API, Copilot-API, deployment-specific, + retired and removed records cannot establish general CLI support. Only + Copilot retirement records enter the legacy retirement projections. These + are compatibility projections, **not** a general eligibility resolver. +- Preserve host trim/case/separator equivalence and proxy lowercase-only exact + retirement lookup deliberately. Sharing facts does not activate the proposed + separator normalization correction. Both readers use maps for retirement + lookup so object-prototype names cannot masquerade as model records. +- `sources/lifecycle-compatibility.json` captures legacy suggestion tie order, + not availability. The compiler requires an exact permutation of generated + supported CLI IDs plus the legacy `auto` selector; duplicates, missing IDs + and unavailable additions fail. The supported projection remains sorted; + suggestions use the separately generated order. For example, `gpt-54-mini` + still suggests `gpt-5.4-mini`, not the alphabetically earlier `gpt-5-mini`; + `claude-sonnet-45` still suggests `claude-sonnet-5`. +- No #10029 additions are activated: the six named CLI additions remain staged + with their existing estimated issue evidence. Pricing, reasoning, context, + mapping, translation, policies, routing and fallback behavior are untouched. + Neither #10029 nor #10030 is completed by this migration. +- `npm run models:generate` and `npm run models:check` own all changed generated + outputs, including suggestion order and the revision/manifest. The source + metadata is part of the deterministic revision and timestamp calculation. + `scripts/model-information/compile.test.ts` proves the compiled and bundled + host reader and isolated proxy retirement reader load outside the source + tree. Named host/proxy regressions retain their different matching boundaries, + suggestion ties, staged exclusions, and errors. + +**Still open:** there is no verified linked `github/gh-aw` agreement or companion +implementation for this exact payload. No workflow/config keys or schema have +been introduced, and no compiler-to-AWF round-trip coverage is claimed. The +cross-repository blocker above remains binding. Run-local overlays, conflict +handling, sanitized workflow provenance, feed transport/offline configuration, +actual execution-environment harness probing, version/prerelease/excluded-range +semantics, and explicit/selector/routing/fallback harness enforcement remain +unimplemented by this partial change. `auto` remains a legacy selector; accepting +it here does not verify its eventual candidates. Phase 5 refresh re-evaluation +and the reusable Phase 6 structured eligibility API are also still required; +neither should infer verified harness support from this compatibility reader or +from missing constraints. Leave the phase issue open until those requirements +and the companion round-trip evidence land. + ### Gate 5: Pricing resolver Migrate static pricing, then operator and runtime pricing, preserving current diff --git a/scripts/model-information/compile.cjs b/scripts/model-information/compile.cjs index 6f737fe23..8f6cb1245 100644 --- a/scripts/model-information/compile.cjs +++ b/scripts/model-information/compile.cjs @@ -17,7 +17,7 @@ if (sourceArgument && !process.argv.includes('--check')) { const sources = sourceArgument ? path.resolve(sourceArgument.slice('--source-dir='.length)) : path.join(root, 'shared/model-information/sources'); -const compilerVersion = 2; +const compilerVersion = 3; const read = file => JSON.parse(fs.readFileSync(path.join(root, file), 'utf8')); const sha256 = value => crypto.createHash('sha256').update(value).digest('hex'); const json = value => `${JSON.stringify(value, null, 2)}\n`; @@ -41,7 +41,8 @@ function compile() { } const importErrors = validate('importedSnapshot', imported); if (importErrors.length) throw new Error(`${importedFile}: ${importErrors.join('; ')}`); - const records = files.filter(file => !['mapping.json', 'target.json', 'pricing-reference.json'].includes(file)) + const records = files.filter(file => !['mapping.json', 'target.json', 'pricing-reference.json', + 'lifecycle-compatibility.json'].includes(file)) .map(file => [file, JSON.parse(fs.readFileSync(path.join(sources, file), 'utf8'))]); const errors = []; for (const [file, data] of records) { @@ -131,13 +132,15 @@ function compile() { const sortedModels = [...models, ...imported.models].sort((a, b) => a.identity.key < b.identity.key ? -1 : a.identity.key > b.identity.key ? 1 : 0); const target = JSON.parse(fs.readFileSync(path.join(sources, 'target.json'), 'utf8')); + const compatibility = JSON.parse(fs.readFileSync(path.join(sources, 'lifecycle-compatibility.json'), 'utf8')); if (target.schemaVersion !== 1 || target.provenance?.authority !== 'maintained' || !target.provenance.at || !Array.isArray(target.availability) || !Array.isArray(target.routing)) { throw new Error('target.json: invalid target metadata or corrections'); } const generatedAt = latestTimestamp([{ at: imported.retrievedAt }, mapping.provenance, - reference.provenance, ...records.map(([, data]) => data.provenance), target.provenance]); + reference.provenance, ...records.map(([, data]) => data.provenance), target.provenance, + compatibility.provenance]); const targetModels = new Map([...models, ...imported.models] .map(model => [model.identity.key, structuredClone(model)])); const targetModel = (provider, rawId) => { @@ -287,8 +290,21 @@ function compile() { return JSON.stringify(baseline) === JSON.stringify(target) ? [] : [{ key, baseline, target }]; }); - const supported = sortedModels.filter(model => model.availability?.some(item => - item.scope.channel === 'copilot-cli' && item.fact.state === 'supported')).map(model => model.identity.rawId); + const supported = sortedModels.filter(model => model.identity.provider === 'copilot' && + !['retired', 'removed'].includes(model.lifecycle.status) && model.availability?.some(item => + item.scope.provider === 'copilot' && item.scope.channel === 'copilot-cli' && + item.scope.deployment === undefined && item.fact.state === 'supported')) + .map(model => model.identity.rawId); + const suggestionOrder = compatibility.suggestionOrder; + if (compatibility.schemaVersion !== 1 || compatibility.provenance?.authority !== 'maintained' || + !compatibility.provenance.source || !compatibility.provenance.revision || + !compatibility.provenance.at || !compatibility.provenance.confidence || + !Array.isArray(suggestionOrder) || + suggestionOrder.length !== supported.length + 1 || + new Set(suggestionOrder).size !== suggestionOrder.length || + suggestionOrder.some(id => typeof id !== 'string' || !['auto', ...supported].includes(id))) { + throw new Error('lifecycle-compatibility.json: expected a permutation of canonical CLI IDs plus auto; ordering cannot author availability'); + } const pricing = Object.fromEntries(sortedModels.flatMap(model => { if (model.identity.provider !== 'copilot') return []; const tariffs = model.tariffs || []; @@ -331,12 +347,13 @@ function compile() { })), }; } - const retired = Object.fromEntries(sortedModels.filter(model => model.lifecycle.status === 'retired' && + const retired = Object.fromEntries(sortedModels.filter(model => model.identity.provider === 'copilot' && + model.lifecycle.status === 'retired' && model.lifecycle.replacement).map(model => [ model.identity.rawId, model.lifecycle.replacement.split('/').slice(1).join('/'), ])); const output = { - 'src/model-information.compat.generated.json': json({ supported: ['auto', ...supported], retired }), + 'src/model-information.compat.generated.json': json({ supported: ['auto', ...supported], retired, suggestionOrder }), 'containers/api-proxy/model-information.pricing.generated.json': json(pricing), 'containers/api-proxy/model-information.retired.generated.json': json(retired), 'containers/api-proxy/ai-credits-pricing-reference.json': json({ diff --git a/scripts/model-information/compile.test.ts b/scripts/model-information/compile.test.ts index adbf77361..653bf9825 100644 --- a/scripts/model-information/compile.test.ts +++ b/scripts/model-information/compile.test.ts @@ -2,6 +2,8 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { execFileSync, spawnSync } from 'node:child_process'; +import { buildSync } from 'esbuild'; +import proxyRetired from '../../containers/api-proxy/model-information.retired.generated.json'; const root = path.resolve(__dirname, '../..'); const compiler = path.join(__dirname, 'compile.cjs'); @@ -23,10 +25,13 @@ describe('maintained model compilation', () => { }); it('keeps legacy decisions in isolated compatibility projections', () => { - const { supported, retired } = require('../../src/model-information.compat.generated.json'); + const { supported, retired, suggestionOrder } = require('../../src/model-information.compat.generated.json'); const { testHelpers } = require('../../src/copilot-model'); expect(new Set(supported)).toEqual(testHelpers.supportedCopilotModels); + expect([...supported].sort()).toEqual([...suggestionOrder].sort()); + expect(new Set(suggestionOrder).size).toBe(suggestionOrder.length); expect(retired).toEqual({ 'gpt-5-codex': 'gpt-5.3-codex' }); + expect(retired).toEqual(proxyRetired); const pricing = require('../../containers/api-proxy/model-information.pricing.generated.json'); const legacy = require('../../containers/api-proxy/ai-credits-pricing'); expect(pricing).toEqual(JSON.parse(JSON.stringify(legacy))); @@ -38,7 +43,7 @@ describe('maintained model compilation', () => { const manifest = require('../../shared/model-information/manifest.generated.json'); const catalogue = require('../../shared/model-information/catalog.generated.json'); expect(manifest.catalogRevision).toBe(catalogue.catalogRevision); - expect(manifest.compilerVersion).toBe(2); + expect(manifest.compilerVersion).toBe(3); expect(manifest.schemaHash).toMatch(/^[0-9a-f]{64}$/); expect(catalogue.generatedAt).toBe(manifest.generatedAt); }); @@ -115,6 +120,65 @@ describe('maintained model compilation', () => { } }); + it.each(['duplicate', 'missing', 'unavailable'])('rejects %s suggestion-order metadata', kind => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-model-order-')); + try { + fs.cpSync(path.join(root, 'shared/model-information/sources'), temp, { recursive: true }); + const file = path.join(temp, 'lifecycle-compatibility.json'); + const source = JSON.parse(fs.readFileSync(file, 'utf8')); + if (kind === 'duplicate') source.suggestionOrder[1] = source.suggestionOrder[0]; + if (kind === 'missing') source.suggestionOrder.pop(); + if (kind === 'unavailable') source.suggestionOrder[1] = 'grok-4.7'; + fs.writeFileSync(file, JSON.stringify(source)); + expect(() => run('--check', `--source-dir=${temp}`)) + .toThrow(/ordering cannot author availability/); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } + }); + + it.each(['native-api', 'copilot-api', 'deployment'])('does not broaden %s availability into CLI support', scope => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-model-scope-')); + try { + fs.cpSync(path.join(root, 'shared/model-information/sources'), temp, { recursive: true }); + const file = path.join(temp, 'copilot-main.json'); + const source = JSON.parse(fs.readFileSync(file, 'utf8')); + const availability = source.models[0].availability[0]; + if (scope === 'deployment') availability.scope.deployment = 'private'; + else availability.scope.channel = scope; + fs.writeFileSync(file, JSON.stringify(source)); + expect(() => run('--check', `--source-dir=${temp}`)) + .toThrow(/ordering cannot author availability/); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } + }); + + it('does not project another provider retirement into Copilot guards', () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-model-retirement-')); + try { + fs.cpSync(path.join(root, 'shared/model-information/sources'), temp, { recursive: true }); + const file = path.join(temp, 'copilot-main.json'); + const source = JSON.parse(fs.readFileSync(file, 'utf8')); + const retired = structuredClone(source.models.find( + (model: { lifecycle: { status: string } }) => model.lifecycle.status === 'retired', + )); + retired.identity.provider = 'other'; + retired.identity.key = `other/${retired.identity.rawId}`; + source.models.push(retired); + fs.writeFileSync(file, JSON.stringify(source)); + const result = spawnSync(process.execPath, [compiler, '--check', `--source-dir=${temp}`], { + cwd: root, encoding: 'utf8', + }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('catalog.generated.json: stale'); + expect(result.stderr).not.toContain('model-information.compat.generated.json: stale'); + expect(result.stderr).not.toContain('model-information.retired.generated.json: stale'); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } + }); + it.each([ ['normalized collision', (models: any[]) => { const clone = structuredClone(models[0]); @@ -197,16 +261,41 @@ describe('maintained model compilation', () => { expect(dockerfile).toMatch(/model-information\.pricing\.generated\.json model-information\.retired\.generated\.json/); const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-model-assets-')); try { + fs.mkdirSync(path.join(temp, 'guards')); for (const file of ['model-information.pricing.generated.json', - 'model-information.retired.generated.json', 'model-api-mapping.json', 'model-api-mapping.js']) { + 'model-information.retired.generated.json', 'model-api-mapping.json', 'model-api-mapping.js', + 'guards/retired-model-guard.js', 'logging.js']) { fs.copyFileSync(path.join(root, 'containers/api-proxy', file), path.join(temp, file)); } const output = execFileSync(process.execPath, ['-e', - "const m=require('./model-api-mapping');const fs=require('fs');for(const file of fs.readdirSync('.'))require('./'+file);if(!m.lookupModelEndpoints('gpt-5.5','openai'))process.exit(1);"], + "const assert=require('node:assert/strict');const m=require('./model-api-mapping');assert(m.lookupModelEndpoints('gpt-5.5','openai'));const g=require('./guards/retired-model-guard');assert.equal(g.getRetiredModelBlockState('GPT-5-CODEX').suggestion,'gpt-5.3-codex');assert.equal(g.getRetiredModelBlockState('GPT_5_CODEX'),null);"], { cwd: temp, encoding: 'utf8' }); expect(output).toBe(''); } finally { fs.rmSync(temp, { recursive: true, force: true }); } }); + + it('loads the compiled and bundled host lifecycle reader outside the source tree', () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-host-lifecycle-')); + try { + const compiled = path.join(temp, 'compiled'); + execFileSync(process.execPath, [require.resolve('typescript/bin/tsc'), + '--target', 'es2020', '--module', 'commonjs', '--resolveJsonModule', + '--esModuleInterop', '--skipLibCheck', '--outDir', compiled, + path.join(root, 'src/copilot-model.ts')], { cwd: root, encoding: 'utf8' }); + const verify = (entry: string) => execFileSync(process.execPath, ['-e', + `const assert=require('node:assert/strict');const {validateCopilotModel:v}=require(${JSON.stringify(entry)});assert.deepEqual(v(' GPT_5_3_CODEX '),{valid:true,resolvedModel:'gpt-5.3-codex'});assert.equal(v('GPT_5_CODEX').reason,'retired');assert(v('gpt-54-mini').message.includes("'gpt-5.4-mini'"));assert.equal(v('grok-4.7').valid,false);`], + { cwd: temp, encoding: 'utf8' }); + expect(verify('./compiled/copilot-model.js')).toBe(''); + buildSync({ + entryPoints: [path.join(compiled, 'copilot-model.js')], + bundle: true, platform: 'node', format: 'cjs', outfile: path.join(temp, 'bundle.js'), + }); + fs.rmSync(compiled, { recursive: true, force: true }); + expect(verify('./bundle.js')).toBe(''); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } + }); }); diff --git a/shared/model-information/baseline-to-target.generated.json b/shared/model-information/baseline-to-target.generated.json index c6b2cb241..67e894348 100644 --- a/shared/model-information/baseline-to-target.generated.json +++ b/shared/model-information/baseline-to-target.generated.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "catalogRevision": "020a979e78b3a17b4964956cb5d461844e8102653311bc973f69c25ff6739019", + "catalogRevision": "750009c04d5431042e8601a9d6a23beca46b40ad4eae6a3a93cff9e8c6e0251d", "activation": "staged: phases 4, 5 and 6", "differences": [ { diff --git a/shared/model-information/catalog.generated.json b/shared/model-information/catalog.generated.json index 1353c0cd3..847e4a365 100644 --- a/shared/model-information/catalog.generated.json +++ b/shared/model-information/catalog.generated.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "catalogRevision": "020a979e78b3a17b4964956cb5d461844e8102653311bc973f69c25ff6739019", + "catalogRevision": "750009c04d5431042e8601a9d6a23beca46b40ad4eae6a3a93cff9e8c6e0251d", "generatedAt": "2026-10-20T00:22:04.000Z", "models": [ { diff --git a/shared/model-information/manifest.generated.json b/shared/model-information/manifest.generated.json index 85513924d..66657b7b4 100644 --- a/shared/model-information/manifest.generated.json +++ b/shared/model-information/manifest.generated.json @@ -1,14 +1,15 @@ { "schemaVersion": 1, - "compilerVersion": 2, + "compilerVersion": 3, "schemaHash": "df9780a1fd9a1418ec8d99cb4aaf27d49bda9c8a08d157036c6893798c64f479", - "catalogRevision": "020a979e78b3a17b4964956cb5d461844e8102653311bc973f69c25ff6739019", + "catalogRevision": "750009c04d5431042e8601a9d6a23beca46b40ad4eae6a3a93cff9e8c6e0251d", "generatedAt": "2026-10-20T00:22:04.000Z", "sourceFiles": [ "shared/model-information/sources/copilot-claude.json", "shared/model-information/sources/copilot-gemini.json", "shared/model-information/sources/copilot-main.json", "shared/model-information/sources/copilot-other.json", + "shared/model-information/sources/lifecycle-compatibility.json", "shared/model-information/sources/mapping.json", "shared/model-information/sources/pricing-reference.json", "shared/model-information/sources/target.json", diff --git a/shared/model-information/sources/lifecycle-compatibility.json b/shared/model-information/sources/lifecycle-compatibility.json new file mode 100644 index 000000000..cbb673e4d --- /dev/null +++ b/shared/model-information/sources/lifecycle-compatibility.json @@ -0,0 +1,47 @@ +{ + "schemaVersion": 1, + "provenance": { + "authority": "maintained", + "source": "pinned legacy host suggestion insertion order; ordering only, not availability", + "revision": "bc0dd2262f448c89a70a186a2732dc526acecd9d31f77c620e200b67a7e583b4", + "at": "2026-10-20T00:22:04.000Z", + "confidence": "estimated" + }, + "suggestionOrder": [ + "auto", + "gpt-4", + "gpt-4.1", + "gpt-4.5", + "gpt-4o", + "gpt-4o-mini", + "gpt-5.1", + "gpt-5.2", + "gpt-5.2-codex", + "gpt-5.3-codex", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.5", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-6-astra", + "gpt-6-luna", + "gpt-6-sol", + "gpt-6.1-sol", + "gpt-5-mini", + "o3", + "o3-mini", + "claude-fable-5", + "claude-haiku-4.5", + "claude-mythos-5", + "claude-haiku-5.5", + "claude-opus-4.8", + "claude-opus-5", + "claude-sonnet-5", + "claude-sonnet-4.5", + "claude-sonnet-4.6", + "gemini-3.1-pro-preview", + "gemini-3.5-flash", + "mai-code-1-flash" + ] +} diff --git a/shared/model-information/target.generated.json b/shared/model-information/target.generated.json index 3460953b9..2a7c07100 100644 --- a/shared/model-information/target.generated.json +++ b/shared/model-information/target.generated.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "catalogRevision": "020a979e78b3a17b4964956cb5d461844e8102653311bc973f69c25ff6739019", + "catalogRevision": "750009c04d5431042e8601a9d6a23beca46b40ad4eae6a3a93cff9e8c6e0251d", "generatedAt": "2026-10-20T00:22:04.000Z", "models": [ { diff --git a/src/copilot-model.test.ts b/src/copilot-model.test.ts index e37e0cc4c..ec042db8d 100644 --- a/src/copilot-model.test.ts +++ b/src/copilot-model.test.ts @@ -110,6 +110,31 @@ describe('validateCopilotModel', () => { expect(result.message).toContain("Did you mean 'gpt-5.3-codex'?"); }); + it.each([ + ['gpt-54-mini', 'gpt-5.4-mini'], + ['gpt-5.-mini', 'gpt-5.4-mini'], + ['claude-sonnet-.5', 'claude-sonnet-5'], + ['claude-sonnet-45', 'claude-sonnet-5'], + ['gemini-3.5', 'gpt-4.5'], + ])('preserves legacy insertion-order suggestion ties for %s', (model, suggestion) => { + expect(validateCopilotModel(model)).toEqual({ + valid: false, + reason: 'unsupported', + message: `Error: model '${model}' is unsupported or unrecognized by this AWF version. Did you mean '${suggestion}'?`, + }); + }); + + it.each([ + 'claude-opus-5.5', 'claude-sonnet-5.5', 'gemini-3.8-flash', + 'grok-4.5', 'grok-4.6', 'grok-4.7', + ])('does not activate staged CLI availability for %s', model => { + expect(validateCopilotModel(model)).toMatchObject({ valid: false, reason: 'unsupported' }); + }); + + it.each(['constructor', '__proto__', 'toString'])('does not treat object properties as retired IDs (%s)', model => { + expect(validateCopilotModel(model)).toMatchObject({ valid: false, reason: 'unsupported' }); + }); + it('rejects unsupported models without suggestion when no close match exists', () => { const result = validateCopilotModel('this-model-does-not-exist-anywhere-12345'); expect(result.valid).toBe(false); diff --git a/src/copilot-model.ts b/src/copilot-model.ts index 884555bcb..e13478978 100644 --- a/src/copilot-model.ts +++ b/src/copilot-model.ts @@ -1,3 +1,5 @@ +import lifecycle from './model-information.compat.generated.json'; + interface CopilotModelValidationSuccess { valid: true; resolvedModel: string; @@ -21,63 +23,14 @@ function normalizeSeparators(s: string): string { return s.replace(/[._]/g, '-'); } -const RETIRED_COPILOT_MODEL_ALIASES: Record = { - 'gpt-5-codex': 'gpt-5.3-codex', -}; +const RETIRED_COPILOT_MODEL_ALIASES = new Map(Object.entries(lifecycle.retired)); /** - * Single source of truth for all Copilot CLI completion models that AWF accepts - * as a `COPILOT_MODEL` value. The host-side validator rejects any value not in - * this set (or its separator-normalised form) before containers start. - * - * When **adding** a new Copilot CLI model here, also update: - * - `containers/api-proxy/ai-credits-pricing.js` (AI-credits billing) - * - `docs/model-api-mapping.json` (endpoint routing, OpenAI/Anthropic only) - * - * When **retiring** a model, move it to `RETIRED_COPILOT_MODEL_ALIASES` (below) - * and mirror the change in `containers/api-proxy/guards/retired-model-guard.js`. - * - * A CI guard in `src/copilot-model-catalog-sync.test.ts` fails if any model - * present in `ai-credits-pricing.js` is missing from this set (and is not - * explicitly listed as a non-CLI model in the test's exclusion set). + * Compatibility projection of canonical Copilot CLI availability, plus the + * legacy `auto` selector. This does not assert native-API or Copilot-API support. + * Author facts in shared/model-information/sources and run models:generate. */ -const SUPPORTED_COPILOT_MODELS = new Set([ - 'auto', - 'gpt-4', - 'gpt-4.1', - 'gpt-4.5', - 'gpt-4o', - 'gpt-4o-mini', - 'gpt-5.1', - 'gpt-5.2', - 'gpt-5.2-codex', - 'gpt-5.3-codex', - 'gpt-5.4', - 'gpt-5.4-mini', - 'gpt-5.5', - 'gpt-5.6-luna', - 'gpt-5.6-sol', - 'gpt-5.6-terra', - 'gpt-6-astra', - 'gpt-6-luna', - 'gpt-6-sol', - 'gpt-6.1-sol', - 'gpt-5-mini', - 'o3', - 'o3-mini', - 'claude-fable-5', - 'claude-haiku-4.5', - 'claude-mythos-5', - 'claude-haiku-5.5', - 'claude-opus-4.8', - 'claude-opus-5', - 'claude-sonnet-5', - 'claude-sonnet-4.5', - 'claude-sonnet-4.6', - 'gemini-3.1-pro-preview', - 'gemini-3.5-flash', - 'mai-code-1-flash', -]); +const SUPPORTED_COPILOT_MODELS = new Set(lifecycle.supported); /** Maps separator-normalized names (all `-`) → canonical model name. */ const NORMALIZED_TO_CANONICAL = new Map( @@ -86,12 +39,12 @@ const NORMALIZED_TO_CANONICAL = new Map( /** Maps separator-normalized retired alias keys → canonical replacement. */ const NORMALIZED_RETIRED_ALIASES = new Map( - Object.entries(RETIRED_COPILOT_MODEL_ALIASES).map(([k, v]) => [normalizeSeparators(k), v]), + [...RETIRED_COPILOT_MODEL_ALIASES].map(([k, v]) => [normalizeSeparators(k), v]), ); function suggestionFor(model: string): string | undefined { let best: { candidate: string; distance: number } | undefined; - for (const candidate of SUPPORTED_COPILOT_MODELS) { + for (const candidate of lifecycle.suggestionOrder) { const distance = levenshtein(model, candidate); if (!best || distance < best.distance) { best = { candidate, distance }; @@ -133,7 +86,7 @@ export function validateCopilotModel(rawModel: string): CopilotModelValidationRe // Check retired aliases (exact, then separator-normalized) const retiredReplacement = - RETIRED_COPILOT_MODEL_ALIASES[normalized] ?? NORMALIZED_RETIRED_ALIASES.get(separatorNormalized); + RETIRED_COPILOT_MODEL_ALIASES.get(normalized) ?? NORMALIZED_RETIRED_ALIASES.get(separatorNormalized); if (retiredReplacement) { return { valid: false, diff --git a/src/model-information.compat.generated.json b/src/model-information.compat.generated.json index 38a7bd380..cc8b6a77d 100644 --- a/src/model-information.compat.generated.json +++ b/src/model-information.compat.generated.json @@ -38,5 +38,42 @@ ], "retired": { "gpt-5-codex": "gpt-5.3-codex" - } + }, + "suggestionOrder": [ + "auto", + "gpt-4", + "gpt-4.1", + "gpt-4.5", + "gpt-4o", + "gpt-4o-mini", + "gpt-5.1", + "gpt-5.2", + "gpt-5.2-codex", + "gpt-5.3-codex", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.5", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-6-astra", + "gpt-6-luna", + "gpt-6-sol", + "gpt-6.1-sol", + "gpt-5-mini", + "o3", + "o3-mini", + "claude-fable-5", + "claude-haiku-4.5", + "claude-mythos-5", + "claude-haiku-5.5", + "claude-opus-4.8", + "claude-opus-5", + "claude-sonnet-5", + "claude-sonnet-4.5", + "claude-sonnet-4.6", + "gemini-3.1-pro-preview", + "gemini-3.5-flash", + "mai-code-1-flash" + ] } From 1128e2a0c4917ccde8922d87df58960c0c9d1171 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Tue, 20 Oct 2026 16:32:06 -0700 Subject: [PATCH 3/3] fix: validate lifecycle provenance before deriving timestamps Validate lifecycle compatibility metadata before calculating catalogue timestamps and reuse a Set for suggestion membership. Cover missing, invalid, and non-string timestamp metadata. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 16e60324-9d5b-4e04-927c-d0487339e7b2 --- scripts/model-information/compile.cjs | 15 +++++++++----- scripts/model-information/compile.test.ts | 25 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/scripts/model-information/compile.cjs b/scripts/model-information/compile.cjs index 8f6cb1245..be78e6430 100644 --- a/scripts/model-information/compile.cjs +++ b/scripts/model-information/compile.cjs @@ -133,6 +133,13 @@ function compile() { a.identity.key < b.identity.key ? -1 : a.identity.key > b.identity.key ? 1 : 0); const target = JSON.parse(fs.readFileSync(path.join(sources, 'target.json'), 'utf8')); const compatibility = JSON.parse(fs.readFileSync(path.join(sources, 'lifecycle-compatibility.json'), 'utf8')); + if (compatibility?.schemaVersion !== 1 || compatibility.provenance?.authority !== 'maintained' || + !compatibility.provenance.source || !compatibility.provenance.revision || + typeof compatibility.provenance.at !== 'string' || + Number.isNaN(Date.parse(compatibility.provenance.at)) || + !compatibility.provenance.confidence) { + throw new Error('lifecycle-compatibility.json: expected reviewed provenance with a valid at timestamp'); + } if (target.schemaVersion !== 1 || target.provenance?.authority !== 'maintained' || !target.provenance.at || !Array.isArray(target.availability) || !Array.isArray(target.routing)) { @@ -295,14 +302,12 @@ function compile() { item.scope.provider === 'copilot' && item.scope.channel === 'copilot-cli' && item.scope.deployment === undefined && item.fact.state === 'supported')) .map(model => model.identity.rawId); + const allowedSuggestions = new Set(['auto', ...supported]); const suggestionOrder = compatibility.suggestionOrder; - if (compatibility.schemaVersion !== 1 || compatibility.provenance?.authority !== 'maintained' || - !compatibility.provenance.source || !compatibility.provenance.revision || - !compatibility.provenance.at || !compatibility.provenance.confidence || - !Array.isArray(suggestionOrder) || + if (!Array.isArray(suggestionOrder) || suggestionOrder.length !== supported.length + 1 || new Set(suggestionOrder).size !== suggestionOrder.length || - suggestionOrder.some(id => typeof id !== 'string' || !['auto', ...supported].includes(id))) { + suggestionOrder.some(id => typeof id !== 'string' || !allowedSuggestions.has(id))) { throw new Error('lifecycle-compatibility.json: expected a permutation of canonical CLI IDs plus auto; ordering cannot author availability'); } const pricing = Object.fromEntries(sortedModels.flatMap(model => { diff --git a/scripts/model-information/compile.test.ts b/scripts/model-information/compile.test.ts index 653bf9825..ae5b6d1f8 100644 --- a/scripts/model-information/compile.test.ts +++ b/scripts/model-information/compile.test.ts @@ -120,6 +120,31 @@ describe('maintained model compilation', () => { } }); + it.each(['missing provenance', 'missing timestamp', 'invalid timestamp', 'non-string timestamp'])( + 'rejects %s in lifecycle compatibility provenance before deriving timestamps', + kind => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-model-provenance-')); + try { + fs.cpSync(path.join(root, 'shared/model-information/sources'), temp, { recursive: true }); + const file = path.join(temp, 'lifecycle-compatibility.json'); + const source = JSON.parse(fs.readFileSync(file, 'utf8')); + if (kind === 'missing provenance') delete source.provenance; + if (kind === 'missing timestamp') delete source.provenance.at; + if (kind === 'invalid timestamp') source.provenance.at = 'not-a-timestamp'; + if (kind === 'non-string timestamp') source.provenance.at = 0; + fs.writeFileSync(file, JSON.stringify(source)); + const result = spawnSync(process.execPath, [compiler, '--check', `--source-dir=${temp}`], { + cwd: root, encoding: 'utf8', + }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('lifecycle-compatibility.json: expected reviewed provenance with a valid at timestamp'); + expect(result.stderr).not.toMatch(/RangeError|TypeError|stale; run/); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } + }, + ); + it.each(['duplicate', 'missing', 'unavailable'])('rejects %s suggestion-order metadata', kind => { const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-model-order-')); try {