Files
OmniRoute/scripts/ad-hoc/backfill-servicekinds.mjs
Tushar Agarwal a298dc6b73 feat(check): make serviceKinds required and add the reverse-walk provider consistency gate (#11392)
serviceKinds now drops .optional() in providerSchema.ts, and check-provider-consistency gains the reverse walk: a canonical provider whose serviceKinds include "llm" must have a REGISTRY entry unless it is in the new KNOWN_CATALOG_ONLY allowlist (providers routed through a connection baseUrl or a specialised executor). That turns "catalog entry outlived its registry entry" — the half-finished provider:remove — into a checkable invariant instead of something a reviewer has to notice.

Reconciled on merge, and worth reading before comparing diffs. The branch's 18 files had landed at the repository ROOT: git diff --name-status showed A gateways.ts, A providerSchema.ts, A check-provider-consistency.test.ts, A backfill-servicekinds.mjs with no directory component. The real provider files, schema, gate and test were never touched, so the +5093/-0 diff was root files AGENTS.md forbids (a test outside tests/, a script outside scripts/) and a no-op for the feature. The content was also 227 commits stale — the root gateways.ts was missing oneminai, among 267 divergent lines.

So each file's actual delta was reapplied onto the current tip rather than copied: the schema one-liner; the gate's KNOWN_CATALOG_ONLY, findCatalogOnlyLlmProviders(), the main() check and the summary line (the branch's copy also repeated the file header and imports at the end — 12 lines of residue from the same accident, dropped); the test's import block and five reverse-walk cases; and backfill-servicekinds.mjs placed at scripts/ad-hoc/, the path its own docstring names, then run against the current catalog: 315 insertions, 352/352 entries declaring serviceKinds, idempotent on a second run.

Two entries the mechanical pass could not get right, both surfaced by doing it against the live tree:

- github in oauth.ts is a single-line object, so the script's id:-per-line regex skipped it — the one failure it reported. Declared ["llm"] by hand, which is what the script's own rule computes.
- magnific came out as ["llm"] but is an image provider (icon: "image", registered in imageRegistry.ts). It is freepik renamed by migration 160, and freepik is in the script's NO_LLM set, so the rename left that set no longer matching. Your reverse walk caught it on its first run — a fair demonstration of why the gate is worth having. Corrected to [], with magnific added to NO_LLM and a note so a re-run cannot reintroduce it.

Verified: check:provider-consistency OK (268 REGISTRY entries, 352 canonical providers, 0 registry-only exceptions, 32 catalog-only), typecheck:core clean, 137/137 across the provider/schema/serviceKinds suites, check-file-size and check:cycles green.

Thanks @Tushar49 — the design is sound and the backfill script did the heavy lifting; only its placement and freshness needed fixing.
2026-09-02 00:32:53 -03:00

173 lines
7.9 KiB
JavaScript

/**
* scripts/ad-hoc/backfill-servicekinds.mjs
*
* PR A (gate hardening, #10513): make `serviceKinds` REQUIRED on every provider
* in the catalog, backfilling the ~320 entries that never declared it.
*
* Design (pacocartones #10267): serviceKinds distinguishes a canonical provider
* that legitimately has no REGISTRY entry (search/audio/media/local/cloud-agent)
* from a half-removed provider whose catalog entry outlived its registry entry.
* Making the field mandatory turns "canonical provider with no REGISTRY entry"
* into a checkable invariant for `provider:remove --dry-run`.
*
* Rule:
* - LLM chat providers -> ["llm"]
* - Search providers -> ["webSearch"] (+["webFetch"] where known)
* - Pure-media providers -> [] (kinds derived from media registries)
* - Cloud agents / system / proxy-> [] (no direct chat registry path)
*
* Media kinds are NOT declared here — open-sse/config/mediaServiceKinds.ts
* derives them from the audio/video/music/image/embedding/ocr registries, so
* declaring them would duplicate (and drift from) that source of truth.
*
* USAGE: node --import tsx/esm scripts/ad-hoc/backfill-servicekinds.mjs
* Idempotent: only inserts where serviceKinds is absent.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
// ── Section membership from the REAL catalog modules ────────────────────────
import { SEARCH_PROVIDERS } from "../../src/shared/constants/providers/search.ts";
import { AUDIO_ONLY_PROVIDERS } from "../../src/shared/constants/providers/audio.ts";
import { CLOUD_AGENT_PROVIDERS } from "../../src/shared/constants/providers/cloud-agent.ts";
import { SYSTEM_PROVIDERS } from "../../src/shared/constants/providers/system.ts";
import { UPSTREAM_PROXY_PROVIDERS } from "../../src/shared/constants/providers/upstream-proxy.ts";
import { OAUTH_PROVIDERS } from "../../src/shared/constants/providers/oauth.ts";
import { WEB_COOKIE_PROVIDERS } from "../../src/shared/constants/providers/web-cookie.ts";
import { LOCAL_PROVIDERS } from "../../src/shared/constants/providers/local.ts";
import { APIKEY_PROVIDERS_GATEWAYS } from "../../src/shared/constants/providers/apikey/gateways.ts";
import { APIKEY_PROVIDERS_FRONTIER } from "../../src/shared/constants/providers/apikey/frontier-labs.ts";
import { APIKEY_PROVIDERS_INFERENCE } from "../../src/shared/constants/providers/apikey/inference-hosts.ts";
import { APIKEY_PROVIDERS_ENTERPRISE } from "../../src/shared/constants/providers/apikey/enterprise-cloud.ts";
import { APIKEY_PROVIDERS_REGIONAL } from "../../src/shared/constants/providers/apikey/regional.ts";
import { APIKEY_PROVIDERS_SPECIALTY } from "../../src/shared/constants/providers/apikey/specialty-media.ts";
const SEARCH_IDS = new Set(Object.keys(SEARCH_PROVIDERS));
const AUDIO_IDS = new Set(Object.keys(AUDIO_ONLY_PROVIDERS));
const CLOUD_AGENT_IDS = new Set(Object.keys(CLOUD_AGENT_PROVIDERS));
const SYSTEM_IDS = new Set(Object.keys(SYSTEM_PROVIDERS));
const UPSTREAM_PROXY_IDS = new Set(Object.keys(UPSTREAM_PROXY_PROVIDERS));
// Search providers that ALSO fetch pages (declared webFetch today).
const SEARCH_WEBFETCH = new Set(["exa-search", "tavily-search", "firecrawl"]);
// Pure-media / no-direct-chat providers -> [] (kinds come from registries).
// web-cookie image/video generators + local image runtimes + specialty-media
// image/embedding/music/video set members that have no chat facade.
const NO_LLM = new Set([
// web-cookie image/video generators
"microsoft-designer-web",
"adobe-firefly",
// local image runtimes
"sdwebui",
"comfyui",
// specialty-media pure media (image/embedding/music/video, no chat facade)
"runwayml",
"ideogram",
"freepik",
// freepik foi renomeado para magnific na migration 160 — ambos os ids
// permanecem aqui para que uma re-execução não volte a marcá-lo como llm.
"magnific",
"suno",
"udio",
"voyage-ai",
"jina-ai",
"fal-ai",
"stability-ai",
"black-forest-labs",
"recraft",
"topaz",
"segmind",
"nomic",
"mixedbread",
"leonardo",
"haiper",
"kie",
"deepai",
]);
/** Compute declared serviceKinds for a provider id (media kinds NOT included). */
export function computeDeclaredServiceKinds(providerId) {
if (SEARCH_IDS.has(providerId)) {
return SEARCH_WEBFETCH.has(providerId) ? ["webSearch", "webFetch"] : ["webSearch"];
}
if (NO_LLM.has(providerId)) return [];
if (AUDIO_IDS.has(providerId)) return [];
if (CLOUD_AGENT_IDS.has(providerId)) return [];
if (SYSTEM_IDS.has(providerId)) return [];
if (UPSTREAM_PROXY_IDS.has(providerId)) return [];
return ["llm"];
}
/** Insert `serviceKinds` after the `id:` line of a provider entry, if absent. */
function insertIntoFile(filePath, providerId, kinds) {
const abs = path.join(ROOT, filePath);
const src = readFileSync(abs, "utf8");
const escaped = providerId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Multi-line entry ` \"provider-id\": {\n ... },` — full block capture. The
// whole-block capture makes the idempotency check see serviceKinds wherever it
// sits (before OR after the id line) without a file-global `includes` that
// would short-circuit every later entry after the first insert.
const entryRe = new RegExp(`^( {2})"?${escaped}"?(: \\{)([\\s\\S]*?)^( {2})},$`, "m");
const match = entryRe.exec(src);
if (!match) {
console.error(` ✗ could not locate entry for ${providerId} in ${filePath}`);
return false;
}
// Per-entry idempotency: refuse when THIS entry already declares serviceKinds.
const block = match[0];
if (/serviceKinds\s*:/.test(block)) return null;
// Insert after the `id: \"provider-id\",` line (4-space indent inside the block).
const idLineRe = new RegExp(`( {4}id: \"${escaped}\",)`);
const idMatch = idLineRe.exec(block);
if (!idMatch) {
console.error(` ✗ entry for ${providerId} in ${filePath} has no id line`);
return false;
}
const idLineEnd = match.index + idMatch.index + idMatch[1].length;
const insert = `\n serviceKinds: ${JSON.stringify(kinds)},`;
writeFileSync(abs, src.slice(0, idLineEnd) + insert + src.slice(idLineEnd));
return true;
}
// ── Files to process, derived from the section modules themselves ───────────
const FILES = [
["src/shared/constants/providers/oauth.ts", OAUTH_PROVIDERS],
["src/shared/constants/providers/web-cookie.ts", WEB_COOKIE_PROVIDERS],
["src/shared/constants/providers/local.ts", LOCAL_PROVIDERS],
["src/shared/constants/providers/search.ts", SEARCH_PROVIDERS],
["src/shared/constants/providers/audio.ts", AUDIO_ONLY_PROVIDERS],
["src/shared/constants/providers/upstream-proxy.ts", UPSTREAM_PROXY_PROVIDERS],
["src/shared/constants/providers/cloud-agent.ts", CLOUD_AGENT_PROVIDERS],
["src/shared/constants/providers/system.ts", SYSTEM_PROVIDERS],
["src/shared/constants/providers/apikey/gateways.ts", APIKEY_PROVIDERS_GATEWAYS],
["src/shared/constants/providers/apikey/frontier-labs.ts", APIKEY_PROVIDERS_FRONTIER],
["src/shared/constants/providers/apikey/inference-hosts.ts", APIKEY_PROVIDERS_INFERENCE],
["src/shared/constants/providers/apikey/enterprise-cloud.ts", APIKEY_PROVIDERS_ENTERPRISE],
["src/shared/constants/providers/apikey/regional.ts", APIKEY_PROVIDERS_REGIONAL],
["src/shared/constants/providers/apikey/specialty-media.ts", APIKEY_PROVIDERS_SPECIALTY],
];
let inserted = 0;
let skipped = 0;
let failed = 0;
for (const [file, sectionMap] of FILES) {
for (const id of Object.keys(sectionMap)) {
if (sectionMap[id]?.serviceKinds !== undefined) {
skipped += 1;
continue;
}
const kinds = computeDeclaredServiceKinds(id);
const result = insertIntoFile(file, id, kinds);
if (result === true) inserted += 1;
else if (result === false) failed += 1;
}
}
console.log(
`[backfill] inserted=${inserted} skipped(already-declared)=${skipped} failed=${failed}`
);