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
13 changes: 7 additions & 6 deletions containers/api-proxy/guards/retired-model-guard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 };
}
Expand Down
9 changes: 9 additions & 0 deletions containers/api-proxy/guards/retired-model-guard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
59 changes: 56 additions & 3 deletions docs/adr/0004-model-information-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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. |
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
36 changes: 29 additions & 7 deletions scripts/model-information/compile.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand All @@ -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) {
Expand Down Expand Up @@ -131,13 +132,22 @@ 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 (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)) {
throw new Error('target.json: invalid target metadata or corrections');
}
Comment thread
lpcox marked this conversation as resolved.
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) => {
Expand Down Expand Up @@ -287,8 +297,19 @@ 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 allowedSuggestions = new Set(['auto', ...supported]);
const suggestionOrder = compatibility.suggestionOrder;
if (!Array.isArray(suggestionOrder) ||
suggestionOrder.length !== supported.length + 1 ||
new Set(suggestionOrder).size !== suggestionOrder.length ||
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');
}
Comment thread
lpcox marked this conversation as resolved.
const pricing = Object.fromEntries(sortedModels.flatMap(model => {
if (model.identity.provider !== 'copilot') return [];
const tariffs = model.tariffs || [];
Expand Down Expand Up @@ -331,12 +352,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({
Expand Down
Loading
Loading