From e09cb5a76885d9198faf8ed34f6c757c4cc428c7 Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:42:16 +0200 Subject: [PATCH] chore(lifecycle): gate DEFAULT_DEGRADATION_MAP against retired ids (#12535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third hand-maintained table naming model ids and the only one outside the retired-model gate — extending `check-model-lifecycle.mjs` to cover it is the durable fix, and the three retired rows it flushed out were already dead code behind the 410 `model_shutdown` answer. --- Validated in one consolidated worktree cut from `release/v3.8.51`, boarded with the rest of this batch — zero conflicts between the 19 PRs. - `typecheck:core` clean; `check:dashboard-typecheck` OK (206 pre-existing, all within the frozen baseline); `check:changelog-integrity` OK - complexity 2802 / baseline 3218 and cognitive-complexity 1267 / baseline 1437 — both under baseline - 226 of 228 focused assertions green across the batch's 23 test files. The 2 remaining belong to #12551, which is held separately. Two batch-owned defects were found and fixed in flight, both pure base drift: `173_xp_action_counts.sql` collided with `173_call_logs_video_content_removed.sql` (renumbered to 176 on #12651 — it aborted every DB open, which is what 53 of the first run's failures were), and the feature-flag catalog was missing the `SERVER_OWNED_TOOL_LOOP_ENABLED` row the base gained after #12552 was written. ⚠️ base-red inherited: #12732 — `Docs Gates`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` reproduce on the pure tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098, which this batch does not touch). Thanks @pacocartones — the `file:line` citations and the explicit out-of-scope notes on every one of these made a 19-PR batch reviewable in one pass. --- .../12535-lifecycle-gate-degradation-map.md | 7 +++ docs/architecture/QUALITY_GATES.md | 56 +++++++++---------- open-sse/services/backgroundTaskDetector.ts | 8 ++- scripts/check/check-model-lifecycle.mjs | 56 +++++++++++++++---- tests/unit/check-model-lifecycle-gate.test.ts | 32 ++++++++++- .../model-lifecycle-degradation-map.test.ts | 56 +++++++++++++++++++ 6 files changed, 173 insertions(+), 42 deletions(-) create mode 100644 changelog.d/maintenance/12535-lifecycle-gate-degradation-map.md create mode 100644 tests/unit/model-lifecycle-degradation-map.test.ts diff --git a/changelog.d/maintenance/12535-lifecycle-gate-degradation-map.md b/changelog.d/maintenance/12535-lifecycle-gate-degradation-map.md new file mode 100644 index 0000000000..f667999ac9 --- /dev/null +++ b/changelog.d/maintenance/12535-lifecycle-gate-degradation-map.md @@ -0,0 +1,7 @@ +- **chore(lifecycle):** `check:model-lifecycle` now also diffs `DEFAULT_DEGRADATION_MAP` + (the background-task redirect table) against the vendor lifecycle snapshot, refusing a + retired id as source or target, with a table-driven unit test beside it. Three rows + whose source the vendor had retired — `claude-sonnet-4-20250514`, `gemini-3-pro-preview` + and `gpt-5.1-codex` (whose target `gpt-5.1-codex-mini` is retired too) — were dead code, + since `checkLifecycle` answers 410 before the redirect runs; they are dropped + (#12535 — thanks @pacocartones) diff --git a/docs/architecture/QUALITY_GATES.md b/docs/architecture/QUALITY_GATES.md index 4b4315cc4d..feb11fad25 100644 --- a/docs/architecture/QUALITY_GATES.md +++ b/docs/architecture/QUALITY_GATES.md @@ -57,34 +57,34 @@ assertion weakening and other masking remain owned by the independently blocking Runs on every PR to `main`. Blocks merge on failure. -| Script (`npm run ...`) | Validates | Blocking | -| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | -| `check:node-runtime` | Node.js version is within the supported range | Yes | -| `check:cycles` | Circular imports — all `src/` + `open-sse/` modules | Yes | -| `check:route-validation:t06` | Zod schemas present on all routes (Tier 6 policy) | Yes | -| `check:any-budget:t11` | `@ts-expect-error // any` count does not exceed budget (Tier 11 catraca) | Yes | -| `check:provider-consistency` | Every provider in `providers.ts` has a matching entry in `providerRegistry.ts` (and vice-versa, within the allowlist) | Yes | -| `check:model-lifecycle` | The two hand-maintained routing tables do not point at retired models (#11503): `FITNESS_TABLE` (`taskFitness.ts`) scores no routable retired id, every `BUILT_IN_ALIASES` target is a live catalog model, and every retired id the catalog still routes is either forwarded or listed in `allowedRetiredInCatalog`. Offline — compares against the vendor snapshot `config/quality/model-lifecycle.json`, refreshed by hand with `npm run quality:refresh-model-lifecycle` (network; not wired into CI). `allowedRetiredInCatalog` is a burn-down ratchet: add an entry only with a tracking issue. | Yes | -| `check:fetch-targets` | Every `fetch("/api/...")` in client-side `src/` resolves to a real `route.ts` | Yes | -| `check:deps` | All `npm install`-able deps across every `package.json` in the repo are in `dependency-allowlist.json`; new unpinned or slopsquatted packages flagged | Yes | -| `audit:deps` | `npm audit` (root + electron) — no high/critical advisories (overlaps osv `check:vuln-ratchet`; see Rationalization Backlog) | Yes | -| `check:lockfile` | `package-lock.json` integrity — https registry, integrity hashes, no host overrides | Yes | -| `check:licenses` | SPDX license allowlist for production dependencies | Yes | -| `check:tracked-artifacts` | No build artifacts / committed `node_modules` symlinks (also runs in husky pre-commit; pre-push is intentionally light — #6716) | Yes | -| `check:file-size` | No source file exceeds the per-extension cap (ratchet: frozen large files in `frozen` list) | Yes | -| `check:error-helper` | Error responses in executors/handlers use `buildErrorBody()` / `sanitizeErrorMessage()` (Hard Rule #12) | Yes | -| `check:migration-numbering` | Migration SQL files are sequentially numbered, no gaps or duplicates | Yes | -| `check:public-creds` | No literal OAuth `client_id`/`client_secret` or Firebase Web keys outside `publicCreds.ts` (Hard Rule #11) | Yes | -| `check:db-rules` | No raw SQL outside `src/lib/db/` modules; no barrel-imports from `localDb.ts` (Hard Rules #2/#5) | Yes | -| `check:known-symbols` | Provider executors, routing strategies, and translators registered in their dispatch tables match the files on disk — no orphaned or undeclared symbols | Yes | -| `check:route-guard-membership` | Every route that spawns a child process is classified by `isLocalOnlyPath()` (Hard Rules #15/#17) | Yes | -| `check:test-discovery` | Every `*.test.ts` / `*.spec.ts` file in the repo is collected by at least one test runner (ratchet: orphan list in `test-discovery-baseline.json` can only shrink) | Yes | -| `check:agent-skills-sync` | Generated agent-skills artifacts match their source catalog (no drift) | -| `check:provider-asset-provenance` | Provider logos/assets carry a recorded provenance entry | -| `lint:json` | JSON config files parse and satisfy the repo lint rules | -| `typecheck:core` | TypeScript compilation without errors (advisory warnings only) | Yes | -| `typecheck:noimplicit:core` | Strict `noImplicitAny` — forward-looking; many pre-existing call sites still need annotations | **Advisory** (`continue-on-error: true`) | -| `check:dashboard-typecheck` | `tsc` scoped to `src/app/(dashboard)/**` (#7033) — `typecheck:core`'s curated 27-file allowlist does not include any dashboard TSX, and `next build` never type-checks it either (`next.config.mjs` sets `ignoreBuildErrors: true`), so orphaned-identifier regressions there (#6625/#6909) were invisible to CI. Diffs against a frozen per-file/per-TS-code count baseline (`config/quality/dashboard-typecheck-baseline.json`, same stale-enforcement pattern as `check:known-symbols`) — only NEW errors beyond the baselined count fail the gate; ratchet down with `--update` when a pre-existing error is fixed. | Yes | +| Script (`npm run ...`) | Validates | Blocking | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | +| `check:node-runtime` | Node.js version is within the supported range | Yes | +| `check:cycles` | Circular imports — all `src/` + `open-sse/` modules | Yes | +| `check:route-validation:t06` | Zod schemas present on all routes (Tier 6 policy) | Yes | +| `check:any-budget:t11` | `@ts-expect-error // any` count does not exceed budget (Tier 11 catraca) | Yes | +| `check:provider-consistency` | Every provider in `providers.ts` has a matching entry in `providerRegistry.ts` (and vice-versa, within the allowlist) | Yes | +| `check:model-lifecycle` | The three hand-maintained routing tables stay consistent with the checked-in lifecycle snapshot (#11503): `FITNESS_TABLE` (`taskFitness.ts`) scores no retired id that `REGISTRY` can route; every `BUILT_IN_ALIASES` target is present in `REGISTRY` and absent from the retired-id snapshot; every retired id still in `REGISTRY` is forwarded or listed in `allowedRetiredInCatalog`; and no `DEFAULT_DEGRADATION_MAP` source or target appears retired in that snapshot. This does not prove that a model is currently served by a live upstream. Offline — compares against `config/quality/model-lifecycle.json`, refreshed by hand with `npm run quality:refresh-model-lifecycle` (network; not wired into CI). `allowedRetiredInCatalog` is a burn-down ratchet: add an entry only with a tracking issue. | Yes | +| `check:fetch-targets` | Every `fetch("/api/...")` in client-side `src/` resolves to a real `route.ts` | Yes | +| `check:deps` | All `npm install`-able deps across every `package.json` in the repo are in `dependency-allowlist.json`; new unpinned or slopsquatted packages flagged | Yes | +| `audit:deps` | `npm audit` (root + electron) — no high/critical advisories (overlaps osv `check:vuln-ratchet`; see Rationalization Backlog) | Yes | +| `check:lockfile` | `package-lock.json` integrity — https registry, integrity hashes, no host overrides | Yes | +| `check:licenses` | SPDX license allowlist for production dependencies | Yes | +| `check:tracked-artifacts` | No build artifacts / committed `node_modules` symlinks (also runs in husky pre-commit; pre-push is intentionally light — #6716) | Yes | +| `check:file-size` | No source file exceeds the per-extension cap (ratchet: frozen large files in `frozen` list) | Yes | +| `check:error-helper` | Error responses in executors/handlers use `buildErrorBody()` / `sanitizeErrorMessage()` (Hard Rule #12) | Yes | +| `check:migration-numbering` | Migration SQL files are sequentially numbered, no gaps or duplicates | Yes | +| `check:public-creds` | No literal OAuth `client_id`/`client_secret` or Firebase Web keys outside `publicCreds.ts` (Hard Rule #11) | Yes | +| `check:db-rules` | No raw SQL outside `src/lib/db/` modules; no barrel-imports from `localDb.ts` (Hard Rules #2/#5) | Yes | +| `check:known-symbols` | Provider executors, routing strategies, and translators registered in their dispatch tables match the files on disk — no orphaned or undeclared symbols | Yes | +| `check:route-guard-membership` | Every route that spawns a child process is classified by `isLocalOnlyPath()` (Hard Rules #15/#17) | Yes | +| `check:test-discovery` | Every `*.test.ts` / `*.spec.ts` file in the repo is collected by at least one test runner (ratchet: orphan list in `test-discovery-baseline.json` can only shrink) | Yes | +| `check:agent-skills-sync` | Generated agent-skills artifacts match their source catalog (no drift) | +| `check:provider-asset-provenance` | Provider logos/assets carry a recorded provenance entry | +| `lint:json` | JSON config files parse and satisfy the repo lint rules | +| `typecheck:core` | TypeScript compilation without errors (advisory warnings only) | Yes | +| `typecheck:noimplicit:core` | Strict `noImplicitAny` — forward-looking; many pre-existing call sites still need annotations | **Advisory** (`continue-on-error: true`) | +| `check:dashboard-typecheck` | `tsc` scoped to `src/app/(dashboard)/**` (#7033) — `typecheck:core`'s curated 27-file allowlist does not include any dashboard TSX, and `next build` never type-checks it either (`next.config.mjs` sets `ignoreBuildErrors: true`), so orphaned-identifier regressions there (#6625/#6909) were invisible to CI. Diffs against a frozen per-file/per-TS-code count baseline (`config/quality/dashboard-typecheck-baseline.json`, same stale-enforcement pattern as `check:known-symbols`) — only NEW errors beyond the baselined count fail the gate; ratchet down with `--update` when a pre-existing error is fixed. | Yes | ### Job: `quality-gate` diff --git a/open-sse/services/backgroundTaskDetector.ts b/open-sse/services/backgroundTaskDetector.ts index 8cbcbd3e9e..258500fbd9 100644 --- a/open-sse/services/backgroundTaskDetector.ts +++ b/open-sse/services/backgroundTaskDetector.ts @@ -45,22 +45,24 @@ const DEFAULT_DETECTION_PATTERNS = [ "label this", ]; +// Every source and target must be absent from the retired-id snapshot: a retired source is +// a dead row (checkLifecycle answers 410 before the redirect runs), while a retired target +// is normally rejected with 410 when lifecycle validation runs again after the redirect +// (unless alias resolution maps it to an accepted id). `npm run check:model-lifecycle` +// diffs this map against config/quality/model-lifecycle.json. const DEFAULT_DEGRADATION_MAP: Record = { // Premium → Cheap alternatives "claude-opus-4-6": "gemini-3-flash", "claude-opus-4-6-thinking": "gemini-3-flash", "claude-opus-4-5-20251101": "gemini-3-flash", "claude-sonnet-4-5-20250929": "gemini-3-flash", - "claude-sonnet-4-20250514": "gemini-3-flash", "claude-sonnet-4": "gemini-3-flash", "gemini-3.1-pro": "gemini-3-flash", "gemini-3.1-pro-high": "gemini-3-flash", - "gemini-3-pro-preview": "gemini-3-flash-preview", "gemini-2.5-pro": "gemini-3-flash", "gpt-4o": "gpt-4o-mini", "gpt-5": "gpt-5-mini", "gpt-5.1": "gpt-5-mini", - "gpt-5.1-codex": "gpt-5.1-codex-mini", }; // ── State ─────────────────────────────────────────────────────────────────── diff --git a/scripts/check/check-model-lifecycle.mjs b/scripts/check/check-model-lifecycle.mjs index 7bad03ec60..2e9c961ccd 100644 --- a/scripts/check/check-model-lifecycle.mjs +++ b/scripts/check/check-model-lifecycle.mjs @@ -1,19 +1,25 @@ #!/usr/bin/env node // scripts/check/check-model-lifecycle.mjs -// Gate anti-drift (#11503): as duas tabelas mantidas à mão que decidem roteamento — +// Gate anti-drift (#11503): as três tabelas mantidas à mão que decidem roteamento — // FITNESS_TABLE (open-sse/services/autoCombo/taskFitness.ts, camada 4 do task fitness) e // BUILT_IN_ALIASES (open-sse/services/modelDeprecation.ts, reescreve `body.model` em toda -// request) — apodrecem em silêncio quando o fornecedor aposenta um modelo. Em +// request), além de DEFAULT_DEGRADATION_MAP (backgroundTaskDetector.ts) — apodrecem em +// silêncio quando o fornecedor aposenta um modelo. Em // release/v3.8.51 o resultado foi uma inversão de ranking (modelo morto 0.98 vs flagship -// vivo 0.50) e aliases que garantiam 404. Este gate compara as duas contra o snapshot de +// vivo 0.50) e aliases apontando para ids obsoletos. Este gate compara as três contra o snapshot de // ciclo de vida em config/quality/model-lifecycle.json (sem rede; regenerar com // `npm run quality:refresh-model-lifecycle`). // -// Três checagens, todas somadas antes do exit — nenhuma aborta as outras: +// Quatro checagens, todas somadas antes do exit — nenhuma aborta as outras: // (a) nenhum padrão do FITNESS_TABLE pontua um id aposentado que o catálogo roteia; // (b) nenhum alvo de BUILT_IN_ALIASES está aposentado ou ausente do catálogo; // (c) todo id aposentado ainda presente no REGISTRY tem encaminhamento em -// BUILT_IN_ALIASES ou consta em `allowedRetiredInCatalog` (a catraca a queimar). +// BUILT_IN_ALIASES ou consta em `allowedRetiredInCatalog` (a catraca a queimar); +// (d) nenhuma linha de DEFAULT_DEGRADATION_MAP (open-sse/services/backgroundTaskDetector.ts) +// tem origem ou destino aposentado. A origem aposentada é linha morta: checkLifecycle +// devolve 410 antes de resolveBackgroundTaskRedirect rodar. O destino aposentado é o +// normalmente rejeitado com 410 quando o ciclo de vida é validado novamente após o +// redirecionamento; a resolução de alias ainda pode convertê-lo em um id aceito. // // (a) é deliberadamente restrita aos ids ROTEÁVEIS: linhas versionadas legítimas como // `gpt-4o` também casam com ids aposentados que o catálogo nunca serviu @@ -88,6 +94,22 @@ export function findUnforwardedRetiredIds(routableRetiredIds, aliases, allowlist .map((id) => `${id} is retired but still routable with no BUILT_IN_ALIASES forward`); } +/** (d) Linhas de DEFAULT_DEGRADATION_MAP com origem ou destino aposentado. */ +export function findRetiredDegradationRows(degradationMap, retiredIds) { + const violations = []; + for (const [source, target] of Object.entries(degradationMap ?? {})) { + if (isRetiredId(source, retiredIds)) { + violations.push( + `${source} → ${target} (the vendor has retired the source id; checkLifecycle rejects it before the redirect runs)` + ); + } + if (isRetiredId(target, retiredIds)) { + violations.push(`${source} → ${target} (the vendor has retired the target id)`); + } + } + return violations; +} + export function readSnapshot(snapshotPath = SNAPSHOT_PATH) { const snapshot = JSON.parse(fs.readFileSync(snapshotPath, "utf8")); const retiredIds = new Set( @@ -102,12 +124,18 @@ async function loadProductionTables() { // Nenhum gate pode migrar o banco do operador: taskFitness.ts importa src/lib/db/core.ts, // então DATA_DIR aponta para um diretório descartável ANTES do import dinâmico. process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-lifecycle-gate-")); - const [{ REGISTRY }, { getStaticFitnessTableScore }, { getBuiltInAliases }] = await Promise.all([ + const [ + { REGISTRY }, + { getStaticFitnessTableScore }, + { getBuiltInAliases }, + { getDefaultDegradationMap }, + ] = await Promise.all([ import(pathToFileURL(path.join(ROOT, "open-sse/config/providers/index.ts")).href), import(pathToFileURL(path.join(ROOT, "open-sse/services/autoCombo/taskFitness.ts")).href), import(pathToFileURL(path.join(ROOT, "open-sse/services/modelDeprecation.ts")).href), + import(pathToFileURL(path.join(ROOT, "open-sse/services/backgroundTaskDetector.ts")).href), ]); - return { REGISTRY, getStaticFitnessTableScore, getBuiltInAliases }; + return { REGISTRY, getStaticFitnessTableScore, getBuiltInAliases, getDefaultDegradationMap }; } function report(label, violations, hint) { @@ -125,11 +153,13 @@ function report(label, violations, hint) { async function main() { const { snapshot, retiredIds } = readSnapshot(); - const { REGISTRY, getStaticFitnessTableScore, getBuiltInAliases } = await loadProductionTables(); + const { REGISTRY, getStaticFitnessTableScore, getBuiltInAliases, getDefaultDegradationMap } = + await loadProductionTables(); const catalogIds = collectCatalogIds(REGISTRY); const routableRetired = catalogIds.filter((id) => isRetiredId(id, retiredIds)).sort(); const aliases = getBuiltInAliases(); + const degradationMap = getDefaultDegradationMap(); let failures = 0; failures += report( @@ -138,7 +168,7 @@ async function main() { "drop the row from FITNESS_TABLE in open-sse/services/autoCombo/taskFitness.ts, or replace it with the versioned id of the live successor." ); failures += report( - `all ${Object.keys(aliases).length} BUILT_IN_ALIASES targets are live catalog models`, + `all ${Object.keys(aliases).length} BUILT_IN_ALIASES targets are present in REGISTRY and absent from the retired-id snapshot`, findBadAliasTargets(aliases, catalogIds, retiredIds), "point the alias at the replacement the vendor publishes (see `sources` in config/quality/model-lifecycle.json). Never invent a target." ); @@ -148,8 +178,14 @@ async function main() { "add a BUILT_IN_ALIASES forward to the vendor's replacement, remove the model from the provider catalog, or (last resort) add the id to `allowedRetiredInCatalog` in config/quality/model-lifecycle.json with a tracking issue." ); + failures += report( + `none of the ${Object.keys(degradationMap).length} DEFAULT_DEGRADATION_MAP rows names a retired id`, + findRetiredDegradationRows(degradationMap, retiredIds), + "drop the row from DEFAULT_DEGRADATION_MAP in open-sse/services/backgroundTaskDetector.ts (a retired source can never reach the redirect), or point a retired target at the replacement the vendor publishes (see `sources` in config/quality/model-lifecycle.json)." + ); + if (failures) { - console.error(`[model-lifecycle] FAIL — ${failures} violation(s) across 3 check(s).`); + console.error(`[model-lifecycle] FAIL — ${failures} violation(s) across 4 check(s).`); process.exit(1); } console.log( diff --git a/tests/unit/check-model-lifecycle-gate.test.ts b/tests/unit/check-model-lifecycle-gate.test.ts index d717ffabe4..3a9d649f7f 100644 --- a/tests/unit/check-model-lifecycle-gate.test.ts +++ b/tests/unit/check-model-lifecycle-gate.test.ts @@ -2,7 +2,7 @@ * Unit coverage for the #11503 drift gate (`scripts/check/check-model-lifecycle.mjs`). * * The gate's value is that it goes red when a hand-maintained routing table starts - * pointing at a model the vendor retired, so each of its three checks is exercised here + * pointing at a model the vendor retired, so each of its four checks is exercised here * against small fixtures rather than against the live catalog (which would make the test * a duplicate of the gate run itself, and red for reasons unrelated to the logic). */ @@ -14,6 +14,7 @@ import { findRetiredFitnessRows, findBadAliasTargets, findUnforwardedRetiredIds, + findRetiredDegradationRows, } from "../../scripts/check/check-model-lifecycle.mjs"; const RETIRED = new Set(["dead-model-1", "dead-model-2", "gpt-5.2-codex"]); @@ -93,3 +94,32 @@ describe("check-model-lifecycle: (c) routable retired ids", () => { ); }); }); + +describe("check-model-lifecycle: (d) DEFAULT_DEGRADATION_MAP rows", () => { + it("flags a retired source id as a dead row", () => { + const violations = findRetiredDegradationRows({ "dead-model-1": "live-1" }, RETIRED); + assert.equal(violations.length, 1); + assert.match(violations[0], /retired the source id; checkLifecycle rejects it/); + }); + + it("flags a retired target id", () => { + const violations = findRetiredDegradationRows({ "live-1": "dead-model-1" }, RETIRED); + assert.equal(violations.length, 1); + assert.match(violations[0], /retired the target id/); + }); + + it("reports both ends when source and target are retired", () => { + const violations = findRetiredDegradationRows({ "dead-model-1": "dead-model-2" }, RETIRED); + assert.equal(violations.length, 2); + }); + + it("treats a vendor-prefixed source as retired when its bare form is", () => { + const violations = findRetiredDegradationRows({ "openai/gpt-5.2-codex": "live-1" }, RETIRED); + assert.equal(violations.length, 1); + }); + + it("passes for a map of live ids", () => { + assert.deepEqual(findRetiredDegradationRows({ "live-1": "live-2" }, RETIRED), []); + assert.deepEqual(findRetiredDegradationRows({}, RETIRED), []); + }); +}); diff --git a/tests/unit/model-lifecycle-degradation-map.test.ts b/tests/unit/model-lifecycle-degradation-map.test.ts new file mode 100644 index 0000000000..0b59ab732a --- /dev/null +++ b/tests/unit/model-lifecycle-degradation-map.test.ts @@ -0,0 +1,56 @@ +/** + * Follow-up to #11503 / #11507: `DEFAULT_DEGRADATION_MAP` (backgroundTaskDetector.ts) is the + * third hand-maintained routing table that names model ids, and it was outside the + * retired-model gate. A retired *source* is a dead row — `checkLifecycle` answers 410 + * `model_shutdown` before `resolveBackgroundTaskRedirect` runs — and a retired *target* + * is normally rejected with 410 when lifecycle validation runs again after the redirect, + * unless alias resolution maps it to an accepted id. + * + * Table-driven over the production default map and the checked-in lifecycle snapshot, mirroring + * `model-deprecation-aliases-11503.test.ts`, so a new dead row fails by name. + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { getDefaultDegradationMap } from "../../open-sse/services/backgroundTaskDetector.ts"; +import { isVendorRetiredId } from "../../open-sse/services/modelLifecycle.ts"; + +const lifecycle = JSON.parse( + readFileSync( + fileURLToPath(new URL("../../config/quality/model-lifecycle.json", import.meta.url)), + "utf8" + ) +) as { retired: Record }; + +const retiredIds = new Set( + Object.entries(lifecycle.retired) + .filter(([, entry]) => entry.status === "retired") + .map(([id]) => id.toLowerCase()) +); + +describe("DEFAULT_DEGRADATION_MAP names no retired model id", () => { + const rows = Object.entries(getDefaultDegradationMap()); + + it("has rows to check", () => { + assert.ok(rows.length > 0); + }); + + for (const [source, target] of rows) { + it(`degrades from ${source}, an id the vendor has not retired`, () => { + assert.ok( + !retiredIds.has(source.toLowerCase()), + `"${source}" → "${target}" is dead: the vendor has retired "${source}", so checkLifecycle rejects the request before the background redirect runs` + ); + assert.equal(isVendorRetiredId(source), false); + }); + + it(`degrades ${source} to ${target}, an id the vendor has not retired`, () => { + assert.ok( + !retiredIds.has(target.toLowerCase()), + `"${source}" → "${target}" forwards background tasks to "${target}", which the vendor has retired` + ); + assert.equal(isVendorRetiredId(target), false); + }); + } +});