mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 17:12:27 +03:00
Validated in a combined 4-PR batch worktree off release/v3.8.51 tip. This PR's diff overlapped taskFitness.ts and autoCombo.test.ts with the already-merged #11492/#11506 — git's merge auto-resolved both hunks cleanly (non-overlapping layers: #11492/#11506 touch layer 2 arena lookup, this PR touches layer 4 static-table hygiene); verified no conflict markers remained and re-ran the full suite after boarding. - npm run check:model-lifecycle — PASS, 68 retired ids, 1327 catalog ids, 0 violations (re-ran with the correct `node --import tsx/esm` loader after an initial bare-node invocation mistakenly failed on path-alias resolution — that was my invocation error, not the gate) - Focused tests: fitness-table-hygiene-11503.test.ts, taskFitness-pattern-order-8603.test.ts, model-deprecation-aliases-11503.test.ts, check-model-lifecycle-gate.test.ts, model-deprecation.test.ts, autoCombo.test.ts — part of batch's 126/126 vitest + 246/246 node:test runs - typecheck:core, file-size, changelog-integrity, complexity, cognitive-complexity, check:cycles — all OK - Full-repo lint: 228 problems remaining, all pre-existing dashboard react-hooks/* findings unrelated to this diff (zero errors in any file this PR touches) Thanks for this — genuinely thorough methodology (segment-boundary matching, provider-scoped alias guard, offline lifecycle gate with a documented burn-down list for the 6 remaining catalog offenders).
201 lines
7.0 KiB
JavaScript
201 lines
7.0 KiB
JavaScript
#!/usr/bin/env node
|
|
// scripts/quality/refresh-model-lifecycle.mjs
|
|
// Regenera config/quality/model-lifecycle.json a partir das páginas de depreciação de
|
|
// primeira mão listadas em `sources`. NÃO roda em CI (o gate check:model-lifecycle é
|
|
// offline, por design): é uma ferramenta de manutenção, executada à mão quando um
|
|
// fornecedor publica uma nova rodada de aposentadorias.
|
|
//
|
|
// Regra dura: o script NUNCA descarta uma entrada em silêncio. Fornecedor cuja página
|
|
// não pôde ser parseada mantém as entradas atuais do snapshot e imprime
|
|
// "manual review needed" — assim uma mudança de layout upstream vira um aviso, não uma
|
|
// perda de dados que abriria o gate.
|
|
//
|
|
// Uso:
|
|
// npm run quality:refresh-model-lifecycle # escreve o snapshot
|
|
// npm run quality:refresh-model-lifecycle -- --dry-run
|
|
import fs from "node:fs";
|
|
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 DRY_RUN = process.argv.includes("--dry-run");
|
|
|
|
/** Fornecedor de cada source, na ordem em que aparecem no snapshot. */
|
|
const VENDOR_BY_HOST = {
|
|
"platform.claude.com": "anthropic",
|
|
"developers.openai.com": "openai",
|
|
"ai.google.dev": "google",
|
|
"docs.x.ai": "xai",
|
|
"console.groq.com": "groq",
|
|
};
|
|
|
|
/**
|
|
* Parser da página da Anthropic: uma tabela markdown
|
|
* `| API model name | Current state | Deprecated | Tentative retirement date |`.
|
|
* Só linhas cujo estado contém "retired"/"deprecated" viram entradas.
|
|
*
|
|
* @returns {Record<string, {vendor: string, status: string, retiredOn: string|null, replacement: string|null}>}
|
|
*/
|
|
export function parseAnthropicTable(text) {
|
|
const out = {};
|
|
const rows = text.split("\n").filter((line) => line.trim().startsWith("|"));
|
|
for (const row of rows) {
|
|
const cells = row
|
|
.split("|")
|
|
.slice(1, -1)
|
|
.map((cell) => cell.trim());
|
|
if (cells.length < 3) continue;
|
|
const [model, state, deprecatedOn, retirementDate] = cells;
|
|
if (!/^[a-z0-9][a-z0-9.\-]*$/i.test(model)) continue; // pula cabeçalho e separadores
|
|
const lowerState = String(state).toLowerCase();
|
|
const status = lowerState.includes("retired")
|
|
? "retired"
|
|
: lowerState.includes("deprecated")
|
|
? "retiring"
|
|
: null;
|
|
if (!status) continue;
|
|
out[model] = {
|
|
vendor: "anthropic",
|
|
status,
|
|
retiredOn: normalizeDate(retirementDate) ?? normalizeDate(deprecatedOn),
|
|
// A página lista o substituto em prosa, fora da tabela. Preencher aqui inventaria
|
|
// dado: o valor vigente do snapshot é preservado pelo merge.
|
|
replacement: null,
|
|
};
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** "2026-06-15" ou "June 15, 2026" → "2026-06-15"; qualquer outra coisa → null. */
|
|
export function normalizeDate(value) {
|
|
if (!value) return null;
|
|
const iso = String(value).match(/\d{4}-\d{2}-\d{2}/);
|
|
if (iso) return iso[0];
|
|
const parsed = Date.parse(String(value));
|
|
return Number.isNaN(parsed) ? null : new Date(parsed).toISOString().slice(0, 10);
|
|
}
|
|
|
|
/**
|
|
* Funde entradas recém-parseadas sobre as atuais SEM perder nada:
|
|
* - entrada nova → adicionada;
|
|
* - entrada existente → status/retiredOn atualizados, `replacement` preservado quando o
|
|
* parser não trouxe um (o fornecedor nem sempre publica substituto);
|
|
* - entrada que sumiu do parse → mantida (uma página pode paginar ou mudar de layout).
|
|
*
|
|
* @returns {{merged: Object, added: string[], updated: string[], kept: string[]}}
|
|
*/
|
|
export function mergeVendorEntries(current, parsed, vendor) {
|
|
const merged = { ...current };
|
|
const added = [];
|
|
const updated = [];
|
|
const kept = [];
|
|
|
|
for (const [id, entry] of Object.entries(parsed)) {
|
|
const existing = current[id];
|
|
if (!existing) {
|
|
merged[id] = entry;
|
|
added.push(id);
|
|
continue;
|
|
}
|
|
const next = {
|
|
...existing,
|
|
status: entry.status,
|
|
retiredOn: entry.retiredOn ?? existing.retiredOn,
|
|
replacement: entry.replacement ?? existing.replacement,
|
|
};
|
|
merged[id] = next;
|
|
if (JSON.stringify(next) !== JSON.stringify(existing)) updated.push(id);
|
|
}
|
|
|
|
for (const [id, entry] of Object.entries(current)) {
|
|
if (entry?.vendor === vendor && !(id in parsed)) kept.push(id);
|
|
}
|
|
return { merged, added, updated, kept };
|
|
}
|
|
|
|
async function fetchText(url) {
|
|
const res = await fetch(url, { headers: { "user-agent": "omniroute-lifecycle-refresh" } });
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
return res.text();
|
|
}
|
|
|
|
async function main() {
|
|
const snapshot = JSON.parse(fs.readFileSync(SNAPSHOT_PATH, "utf8"));
|
|
let retired = { ...snapshot.retired };
|
|
const warnings = [];
|
|
|
|
for (const url of snapshot.sources) {
|
|
const host = new URL(url).host;
|
|
const vendor = VENDOR_BY_HOST[host] ?? host;
|
|
let text;
|
|
try {
|
|
text = await fetchText(url);
|
|
} catch (err) {
|
|
warnings.push(
|
|
`${vendor}: fetch failed (${err.message}) — existing entries kept, manual review needed`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (vendor !== "anthropic") {
|
|
// Só a Anthropic publica a lista como tabela estável. Os demais são HTML/JS que
|
|
// mudam de forma; parsear por heurística arriscaria apagar dados corretos.
|
|
warnings.push(
|
|
`${vendor}: no reliable parser — existing entries kept, manual review needed (${url})`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const parsed = parseAnthropicTable(text);
|
|
if (!Object.keys(parsed).length) {
|
|
warnings.push(
|
|
`${vendor}: table parsed to 0 rows (layout change?) — existing entries kept, manual review needed`
|
|
);
|
|
continue;
|
|
}
|
|
const { merged, added, updated, kept } = mergeVendorEntries(retired, parsed, vendor);
|
|
retired = merged;
|
|
console.log(
|
|
`[refresh-model-lifecycle] ${vendor}: ${added.length} added, ${updated.length} updated, ${kept.length} kept (not in this parse)`
|
|
);
|
|
for (const id of added) console.log(` + ${id}`);
|
|
for (const id of updated) console.log(` ~ ${id}`);
|
|
}
|
|
|
|
const next = {
|
|
...snapshot,
|
|
generatedAt: new Date().toISOString().slice(0, 10),
|
|
retired,
|
|
};
|
|
|
|
for (const warning of warnings) console.warn(`[refresh-model-lifecycle] WARN ${warning}`);
|
|
|
|
const before = Object.keys(snapshot.retired).length;
|
|
const after = Object.keys(retired).length;
|
|
if (after < before) {
|
|
console.error(
|
|
`[refresh-model-lifecycle] ABORT — entry count dropped ${before} → ${after}. The script must never lose an entry; investigate before writing.`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (DRY_RUN) {
|
|
console.log(
|
|
`[refresh-model-lifecycle] dry run — ${before} → ${after} entries, nothing written.`
|
|
);
|
|
return;
|
|
}
|
|
fs.writeFileSync(SNAPSHOT_PATH, JSON.stringify(next, null, 2) + "\n");
|
|
console.log(
|
|
`[refresh-model-lifecycle] wrote ${path.relative(ROOT, SNAPSHOT_PATH)} — ${after} entries. Review the diff, then run \`npm run check:model-lifecycle\`.`
|
|
);
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
|
|
main().catch((err) => {
|
|
console.error(`[refresh-model-lifecycle] ERROR — ${err?.message ?? err}`);
|
|
process.exit(1);
|
|
});
|
|
}
|