Files
OmniRoute/scripts/check/check-model-lifecycle.mjs
Paco Cartones e09cb5a768 chore(lifecycle): gate DEFAULT_DEGRADATION_MAP against retired ids (#12535)
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.
2026-09-11 17:42:16 -03:00

202 lines
9.2 KiB
JavaScript

#!/usr/bin/env node
// scripts/check/check-model-lifecycle.mjs
// 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), 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 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`).
//
// 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);
// (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
// (`gpt-4o-audio-preview`), e esses não podem inverter decisão de roteamento nenhuma.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const SNAPSHOT_PATH = path.join(ROOT, "config/quality/model-lifecycle.json");
const TASK_TYPES = ["coding", "review", "planning", "analysis", "debugging", "documentation"];
/** Ids de modelo que o catálogo consegue rotear (id + aliases de cada modelo). */
export function collectCatalogIds(registry) {
const ids = new Set();
for (const entry of Object.values(registry ?? {})) {
for (const model of entry?.models ?? []) {
if (typeof model?.id === "string") ids.add(model.id);
for (const alias of model?.aliases ?? []) {
if (typeof alias === "string") ids.add(alias);
}
}
}
return [...ids];
}
/** Um id do catálogo está aposentado quando sua forma nua (sem prefixo `vendor/`) está. */
export function isRetiredId(id, retiredIds) {
const lower = String(id).toLowerCase();
if (retiredIds.has(lower)) return true;
const slash = lower.lastIndexOf("/");
return slash !== -1 && retiredIds.has(lower.slice(slash + 1));
}
/** (a) Linhas do FITNESS_TABLE que ainda pontuam um modelo aposentado e roteável. */
export function findRetiredFitnessRows(routableRetiredIds, scoreFor, taskTypes = TASK_TYPES) {
const violations = [];
for (const id of routableRetiredIds) {
for (const task of taskTypes) {
const score = scoreFor(id, task);
if (score !== null && score !== undefined) {
violations.push(
`${id} scores ${score} for "${task}" via FITNESS_TABLE (vendor retired it)`
);
}
}
}
return violations;
}
/** (b) Alvos de alias aposentados ou fora do catálogo. */
export function findBadAliasTargets(aliases, catalogIds, retiredIds) {
const catalog = new Set([...catalogIds].map((id) => id.toLowerCase()));
const violations = [];
for (const [source, target] of Object.entries(aliases)) {
const lower = String(target).toLowerCase();
if (!catalog.has(lower)) {
violations.push(`${source}${target} (no provider in REGISTRY serves this id)`);
} else if (retiredIds.has(lower)) {
violations.push(`${source}${target} (the vendor has retired this id)`);
}
}
return violations;
}
/** (c) Ids aposentados que o catálogo ainda roteia sem encaminhamento nem allowlist. */
export function findUnforwardedRetiredIds(routableRetiredIds, aliases, allowlist) {
const allowed = new Set((allowlist ?? []).map((id) => String(id).toLowerCase()));
return routableRetiredIds
.filter((id) => !(id in aliases) && !allowed.has(String(id).toLowerCase()))
.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(
Object.entries(snapshot.retired ?? {})
.filter(([, entry]) => entry?.status === "retired")
.map(([id]) => id.toLowerCase())
);
return { snapshot, retiredIds };
}
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 },
{ 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, getDefaultDegradationMap };
}
function report(label, violations, hint) {
if (!violations.length) {
console.log(`[model-lifecycle] OK — ${label}`);
return 0;
}
console.error(
`[model-lifecycle] ${violations.length} violation(s) — ${label}:\n` +
violations.map((v) => " ✗ " + v).join("\n") +
`\n${hint}`
);
return violations.length;
}
async function main() {
const { snapshot, retiredIds } = readSnapshot();
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(
`FITNESS_TABLE scores none of the ${routableRetired.length} routable retired id(s)`,
findRetiredFitnessRows(routableRetired, getStaticFitnessTableScore),
"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 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."
);
failures += report(
"every routable retired id is forwarded or allowlisted",
findUnforwardedRetiredIds(routableRetired, aliases, snapshot.allowedRetiredInCatalog),
"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 4 check(s).`);
process.exit(1);
}
console.log(
`[model-lifecycle] PASS — snapshot ${snapshot.generatedAt}, ${retiredIds.size} retired id(s), ${catalogIds.length} catalog id(s).`
);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
main().catch((err) => {
console.error(`[model-lifecycle] ERROR — ${err?.message ?? err}`);
process.exit(1);
});
}