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
172 changes: 172 additions & 0 deletions scripts/ad-hoc/backfill-servicekinds.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* 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}`
);
82 changes: 81 additions & 1 deletion scripts/check/check-provider-consistency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@
// Catraca: exceções pré-existentes ficam em KNOWN_REGISTRY_ONLY; só NOVOS órfãos falham.
// Stale-enforcement (6A.3): entrada em KNOWN_REGISTRY_ONLY que não suprime nenhum órfão
// real → gate falha com instrução de remoção (evita furo de regressão silencioso).
//
// Reverse walk (#10513): providers.ts → REGISTRY. Um provider canônico cujo
// serviceKinds inclui "llm" DEVE ter entrada no REGISTRY — a não ser que esteja em
// KNOWN_CATALOG_ONLY (providers que roteiam via baseUrl de conexão / executor
// especializado sem entrada de registry). Isso torna provider:remove --dry-run
// verificável: um provider removido do REGISTRY mas esquecido em providers.ts
// aparece como órfão reverso e o gate falha.
import { pathToFileURL } from "node:url";
import { AI_PROVIDERS, getProviderById } from "@/shared/constants/providers.ts";
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
Expand All @@ -16,6 +23,46 @@ import { assertNoStale } from "./lib/allowlist.mjs";
// justificativa. Remover daqui ao registrar o provider em providers.ts.
export const KNOWN_REGISTRY_ONLY: Record<string, string> = {};

/**
* Providers canônicos com serviceKinds llm que LEGITIMAMENTE não têm entrada no
* REGISTRY. Cada um roteia via baseUrl de conexão (providerSpecificData.baseUrl) ou
* executor especializado, então a ausência de registro não é órfão.
*/
export const KNOWN_CATALOG_ONLY: Record<string, string> = {
"amazon-q": "OAuth/IDE provider roteado via KiroExecutor sem entrada de registry.",
zed: "OAuth/IDE provider (Zed) roteado via executor especializado; sem entrada de registry.",
piapi: "Gateway OpenAI-compatible roteado via baseUrl de conexão.",
getgoapi: "Gateway OpenAI-compatible roteado via baseUrl de conexão.",
laozhang: "Gateway OpenAI-compatible roteado via baseUrl de conexão.",
thebai: "Gateway OpenAI-compatible roteado via baseUrl de conexão.",
fenayai: "Gateway OpenAI-compatible roteado via baseUrl de conexão.",
empower: "Gateway OpenAI-compatible roteado via baseUrl de conexão.",
"arcee-ai": "API-key provider roteado via baseUrl de conexão.",
"azure-openai": "Azure OpenAI roteado via AzureOpenAIExecutor + baseUrl de conexão.",
"azure-ai": "Azure AI Foundry roteado via AzureAiExecutor + baseUrl de conexão.",
watsonx: "Enterprise provider roteado via baseUrl de conexão.",
oci: "OCI Generative AI roteado via baseUrl de conexão.",
sap: "SAP AI Core roteado via baseUrl de conexão.",
datarobot: "Enterprise provider roteado via baseUrl de conexão.",
clarifai: "Clarifai PAT roteado via baseUrl de conexão.",
"360ai": "Regional provider roteado via baseUrl de conexão.",
gitlab: "GitLab (non-Duo) roteado via executor especializado + baseUrl de conexão.",
"poe-web": "Web/cookie provider roteado via executor especializado (PoeWebExecutor).",
"venice-web": "Web/cookie provider roteado via executor especializado (VeniceWeb).",
"v0-vercel-web": "Web/cookie provider roteado via executor especializado (V0VercelWeb).",
"gemini-business": "Enterprise Gemini roteado via executor especializado + baseUrl de conexão.",
"ollama-local": "Local provider (Ollama) roteado via baseUrl de conexão; sem registry.",
"lm-studio": "Local provider (LM Studio) roteado via baseUrl de conexão.",
vllm: "Local provider (vLLM) roteado via baseUrl de conexão.",
lemonade: "Local provider roteado via baseUrl de conexão.",
llamafile: "Local provider roteado via baseUrl de conexão.",
"llama-cpp": "Local provider roteado via baseUrl de conexão.",
triton: "Local provider (Triton) roteado via baseUrl de conexão.",
"docker-model-runner": "Local provider roteado via baseUrl de conexão.",
xinference: "Local provider (XInference) roteado via baseUrl de conexão.",
oobabooga: "Local provider (Oobabooga) roteado via baseUrl de conexão.",
};

/** Ids do REGISTRY que não são providers canônicos e não estão na allowlist. */
export function findOrphanRegistryIds(
registryIds: string[],
Expand All @@ -25,6 +72,24 @@ export function findOrphanRegistryIds(
return registryIds.filter((id) => !isKnownProvider(id) && !(id in allowlist));
}

/**
* Providers canônicos com serviceKinds llm sem entrada no REGISTRY e fora da
* allowlist — metade de um provider:remove (registro apagado, catálogo esquecido).
*/
export function findCatalogOnlyLlmProviders(
canonicalProviders: Record<string, { serviceKinds?: string[] }>,
registryIds: string[],
allowlist: Record<string, string>
): string[] {
const registry = new Set(registryIds);
return Object.entries(canonicalProviders)
.filter(([id, p]) => {
if (registry.has(id) || id in allowlist) return false;
return Array.isArray(p.serviceKinds) && p.serviceKinds.includes("llm");
})
.map(([id]) => id);
}

function main(): void {
const canonical = new Set(Object.keys(AI_PROVIDERS));
const isKnown = (id: string) => canonical.has(id) || Boolean(getProviderById(id));
Expand All @@ -42,9 +107,24 @@ function main(): void {
);
process.exitCode = 1;
}
// Reverse walk: llm-kind canonical provider sem REGISTRY = órfão reverso.
const catalogOnlyLlm = findCatalogOnlyLlmProviders(
AI_PROVIDERS as Record<string, { serviceKinds?: string[] }>,
Object.keys(REGISTRY),
KNOWN_CATALOG_ONLY
);
if (catalogOnlyLlm.length) {
console.error(
`[provider-consistency] ${catalogOnlyLlm.length} provider(s) canônico(s) llm sem entrada no REGISTRY:\n` +
catalogOnlyLlm.map((id) => ` ✗ ${id}`).join("\n") +
`\n → registre o provider em open-sse/config/providers/registry/<id>/ ou adicione a KNOWN_CATALOG_ONLY (scripts/check/check-provider-consistency.ts) com justificativa — órfão reverso de um provider:remove incompleto?`
);
process.exitCode = 1;
}

if (!process.exitCode) {
console.log(
`[provider-consistency] OK — ${Object.keys(REGISTRY).length} entradas REGISTRY, ${canonical.size} providers canônicos, ${Object.keys(KNOWN_REGISTRY_ONLY).length} exceção(ões) conhecida(s)`
`[provider-consistency] OK — ${Object.keys(REGISTRY).length} entradas REGISTRY, ${canonical.size} providers canônicos, ${Object.keys(KNOWN_REGISTRY_ONLY).length} exceção(ões) registry-only, ${Object.keys(KNOWN_CATALOG_ONLY).length} catalog-only`
);
}
}
Expand Down
Loading
Loading