Provider JSON Catalog Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Collapse Tier-2 provider onboarding from ~16 hand-edited files to one schema-validated JSON file plus machine-generated code — zero hand-written src edits, zero test edits.
Architecture: One src/lib/providers/catalog/<id>.json per provider is the single source of truth. A codegen script (tools/codegen-catalog.ts) validates every JSON against a zod schema and machine-writes all compile-time artifacts (AIProviderName members, <Name>Models enums, NeurolinkCredentials keys, aggregation index, type unions) into marked regions / generated files. A runtime loader converts JSON entries into the existing OpenAICompatCatalogEntry shape, so registration is untouched. Every per-concern data table (descriptors, setup configs, context windows, pricing, vision, model choices, model manifests, validator) derives its catalog-provider entries from the loader; test suites iterate the catalog so the five hand-bumped count pins become derived assertions.
Tech Stack: TypeScript strict, zod (already a dependency), vite build (JSON imports already supported: resolveJsonModule: true, three src/ files import JSON today).
Spec: docs/superpowers/plans/2026-08-28-provider-json-catalog-spec.md — read it first; the four approved rulings and the schema there are binding.
Global Constraints
- Repo rule 5 (backward compat): every currently exported enum member name AND string value survives byte-identical. Task 3's public-surface snapshot is the net; it must be written from the PRE-migration dist and never regenerated afterward.
- Repo rule 7:
typeonly, neverinterface. Rule 9: new type names use theProviderCatalog*/Catalog*prefix and must be globally unique. Rule 10/12/13: types live insrc/lib/types/, barrel usesexport *only, runtime files never re-export types, internal type imports go through the barrel. - Repo rule 1: registration keeps using the existing catalog loop in
providerRegistry.ts— this plan changes what feedsOPENAI_COMPAT_CATALOG, never the loop. - Repo rule 15: suites are end-to-end over
dist/; no unit tests of the loader/codegen internals. Codegen correctness is proven by (a) the freshness check, (b) the snapshot test, (c) existing suites passing unchanged. - Generated files are committed.
pnpm run codegen:catalogmust be idempotent (second run = byte-identical). Pre-commit and CI run codegen +git diff --exit-code. - Assertion messages never quote payloads (defineSuite SKIP hazard — see CLAUDE.md).
- Final delivery is ONE commit on a
feat/branch (repo single-commit-per-PR policy): commit per task locally, thengit reset --softto a single conventional commit (refactor(providers): drive the tier-2 catalog from per-provider JSON) before opening the PR.docs:apiregeneration is the last pre-commit step. - Do not touch
src/cli/factories/setupCommandFactory.ts— its choices are already enum-derived (#1583), so codegen'd enum members flow through automatically.
Task 1: Catalog types + zod schema + editor schema
Files:
- Create:
src/lib/types/providerCatalog.ts - Create:
src/lib/providers/catalog/schema.ts - Create:
src/lib/providers/catalog/provider-catalog.schema.json - Modify:
src/lib/types/index.ts(oneexport *line)
Interfaces:
-
Produces:
ProviderCatalogJson(and sub-types) consumed by every later task;parseProviderCatalogJson(raw: unknown, sourcePath: string): ProviderCatalogJsonthrowing on invalid input. -
Step 1: Write the types in
src/lib/types/providerCatalog.ts:
/**
* Single-JSON provider catalog — the authoring format for Tier-2
* (zero-quirk OpenAI-compatible) providers. One
* src/lib/providers/catalog/<id>.json file per provider is the single
* source of truth; see docs/superpowers/plans/2026-08-28-provider-json-catalog-spec.md.
*/
export type CatalogPricingPerMTok = {
input: number;
output: number;
cachedInput?: number;
};
export type CatalogModelStatus = "production" | "preview" | "retired";
export type CatalogModelSpec = {
contextWindow?: number;
maxOutputTokens?: number;
pricingPerMTok?: CatalogPricingPerMTok;
vision: boolean;
status: CatalogModelStatus;
description: string;
/**
* Enum member name override. Default is the derived constant-case of the
* model id; REQUIRED where the derived name differs from a pre-existing
* exported member (public-surface compatibility).
*/
enumMember?: string;
};
export type CatalogWire = {
baseURL?: string;
baseURLTemplate?: string;
extraCredentials?: string[];
missingCredentialMessage?: string;
envOverrides?: { apiKey?: string; baseURL?: string; model?: string };
};
export type CatalogErrorRuleClass =
| "authentication"
| "rate-limit"
| "invalid-model"
| "network"
| "provider";
export type CatalogErrorRuleJson = {
status?: number;
pattern?: string;
class: CatalogErrorRuleClass;
message: string;
};
export type CatalogQuirks = {
timeoutErrorClass?: "provider";
registryDefaultIgnoresModelEnvVar?: boolean;
};
export type CatalogBillingPolicy =
| "free-tier"
| "free-with-card"
| "no-free-tier";
export type CatalogSetup = {
url: string;
apiKeyFormat: string | null;
billingPolicy: CatalogBillingPolicy;
instructions: string[];
};
export type CatalogProbeEvidence = {
date: string;
status?: number;
code?: string;
method?: string;
};
export type CatalogEvidence = {
rosterVerified: CatalogProbeEvidence;
authProbe?: CatalogProbeEvidence;
billingProbe?: CatalogProbeEvidence;
liveMatrix: { date: string; result: string } | null;
addedInPR: string;
};
export type CatalogCapabilities = {
text: boolean;
streaming: boolean;
tools: boolean;
toolsWithStreaming: boolean;
structuredOutput: boolean;
structuredOutputWithTools: boolean;
embeddings: boolean;
thinking: boolean;
};
export type ProviderCatalogJson = {
/** Editor-only pointer to provider-catalog.schema.json — accepted and ignored. */
$schema?: string;
id: string;
displayName: string;
/**
* Exported <Name>Models enum name override. Default: PascalCase(id) +
* "Models". REQUIRED where the derived name differs from a pre-existing
* export ("together-ai" derives "TogetherAiModels"; the legacy export is
* "TogetherAIModels").
*/
enumTypeName?: string;
aliases: string[];
tier: 2;
wire: CatalogWire;
models: {
default: string;
fallbacks: string[];
/** Default: fallbacks[1] ?? fallbacks[0]. Set explicitly where the
* legacy entry differs (behavior preservation — e.g. Groq). */
fallbackModelName?: string;
/** Default: models.default. Set explicitly where the legacy entry's
* registry-level default differs (behavior preservation — Mistral's
* registryDefaultModel is MISTRAL_LARGE_LATEST while its defaultModel
* is not). Must be a models.catalog key (validated). */
registryDefaultModel?: string;
defaultContextWindow: number;
defaultMaxOutputTokens: number;
catalog: Record<string, CatalogModelSpec>;
};
capabilities: CatalogCapabilities;
errorRules: CatalogErrorRuleJson[];
quirks?: CatalogQuirks;
setup: CatalogSetup;
evidence: CatalogEvidence;
};
-
Step 2: Add the barrel line to
src/lib/types/index.ts:export * from "./providerCatalog.js";(alphabetical position with the other lines). -
Step 3: Write the zod validator in
src/lib/providers/catalog/schema.ts. Mirror every field above 1:1 withz.strictObject(unknown keys are authoring mistakes and must fail). Cross-field refinements — each is a.superRefinewith a message naming the offending model/field but never quoting file content:models.fallbackshas at least one entry (the loader'sfallbackModelNamedefault depends on it).models.default, everymodels.fallbacks[]entry, andmodels.fallbackModelName(when set), andmodels.registryDefaultModel(when set) must be keys ofmodels.catalog.wire.extraCredentials, when present, has EXACTLY one entry whose value matches/^[A-Za-z_$][A-Za-z0-9_$]*$/(it is embedded verbatim in generated credential typing) (deliberately narrow — mirrors the runtimecomputedBaseURLtype; widen both together if a second computed-URL provider ever needs it).models.catalog[*].enumMember(when set) matches/^[A-Za-z_$][A-Za-z0-9_$]*$/.enumTypeName(when set) matches the same identifier pattern.wire.baseURLTemplate(when set) must contain the literal placeholder{<extraCredentials[0]>}— a template without its credential placeholder emits a broken URL at runtime.wire: exactly one ofbaseURL/baseURLTemplate;extraCredentialsandmissingCredentialMessageonly withbaseURLTemplate.idmatches/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.- every
errorRules[]entry hasstatusorpattern(or both). patternvalues must compile:new RegExp(pattern, "i")inside a try/catch, failing validation on throw.evidence.rosterVerified.dateand otherdatefields match/^\d{4}-\d{2}-\d{2}$/.
Export exactly one function:
export function parseProviderCatalogJson(
raw: unknown,
sourcePath: string,
): ProviderCatalogJson {
const result = providerCatalogJsonSchema.safeParse(raw);
if (!result.success) {
throw new Error(
`Invalid provider catalog file ${sourcePath}: ${result.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ")}`,
);
}
return result.data;
}
Import ProviderCatalogJson from ../../types/index.js (rule 13).
-
Step 4: Write
provider-catalog.schema.json— a plain JSON Schema (draft-07) mirroring the same shape for editor validation via each file's$schemafield. It is documentation-grade (the zod schema is authoritative); keep the two in sync by hand and say so in a"$comment"at the top. -
Step 5:
pnpm run checkandpnpm run lintpass. Commit (git add -A && git commit -m "task 1: catalog types + zod schema").
Task 2: First two catalog JSON files (sambanova, cerebras)
Files:
- Create:
src/lib/providers/catalog/sambanova.json - Create:
src/lib/providers/catalog/cerebras.json
Interfaces:
-
Produces: the first two data files every later task consumes. Nothing imports them yet — this task is data-entry plus schema validation via a one-off
npx tsxcheck. -
Step 1: Write
sambanova.json. The spec's example shows the SHAPE (it elides six models for brevity); populate ALL SEVEN models inmodels.catalogusing Task 6's extraction rules against the shipped sambanova data (enums.tsSambanovaModels for ids+member names,contextWindows.ts,pricing.ts,providerImageAdapter.tsvision list,providerMatrix.tscapabilities,openaiCompatCatalog.tsentry,docs/provider-integration/manifests/sambanova.jsonevidence). Set"addedInPR": "https://github.com/juspay/neurolink/pull/1586".models.defaultand every fallback must exist in the populated catalog or the Step 3 validation fails. -
Step 2: Write
cerebras.jsonfrom the live values shipped in PRs #1561/#1564/#1583 — sources:openaiCompatCatalog.tscerebras entry (baseURL, defaultgpt-oss-120b, fallbackgemma-4-31b, 401 rule patternwrong_api_key|Wrong API Key|invalid_api_key, message),contextWindows.ts(65_536 floor — keep it, with the free/paid rationale moved to the guide),pricing.ts(gpt-oss-120b 0.35/0.75, gemma-4-31b 0.99/1.49),providerConfig.tscreateCerebrasConfig(setup url/instructions),docs/provider-integration/manifests/cerebras.json(evidence: dates, PR, live-verified status →"liveMatrix": { "date": "2026-08-27", "result": "4/4" }), capabilities fromtest/helpers/providerMatrix.tscerebras row.billingPolicy: "free-with-card". -
Step 3: Validate both files:
npx tsx -e 'import { readFileSync } from "node:fs"; const { parseProviderCatalogJson } = await import("./src/lib/providers/catalog/schema.ts"); for (const f of ["sambanova","cerebras"]) { parseProviderCatalogJson(JSON.parse(readFileSync(`src/lib/providers/catalog/${f}.json`,"utf8")), f); console.log(f, "valid"); }'
Expected: both print valid. Break one field on purpose (e.g. rename default to defaultModel), confirm it throws naming the path, restore.
- Step 4: Commit.
Task 3: Public-surface snapshot test (the compat net — BEFORE anything moves)
Files:
- Modify:
test/continuous-test-suite-provider-wiring.ts(new test block at the end, beforerunSuite())
Interfaces:
-
Produces: a frozen literal of every catalog-provider enum's member→value map, captured from the CURRENT dist. Later tasks may not touch this block.
-
Step 1: Capture the current surface. Run
pnpm run build, then:
node -e 'import("./dist/constants/enums.js").then(m => { for (const n of ["GroqModels","XaiModels","TogetherAIModels","FireworksModels","PerplexityModels","MistralModels","CloudflareModels","CerebrasModels","SambanovaModels"]) console.log(n, JSON.stringify(m[n])); })'
- Step 2: Write the test — paste each captured object as a frozen literal:
await test("catalog-provider enum surfaces are byte-identical to the pre-JSON-migration snapshot", async () => {
const enums = await import("../dist/constants/enums.js");
// Captured from dist on 2026-08-28, BEFORE the JSON-catalog migration.
// If this test fails, generated enums drifted from the frozen public
// surface — fix the codegen or the enumMember overrides, never this
// literal.
const frozen: Record<string, Record<string, string>> = {
GroqModels: {
/* paste captured */
},
XaiModels: {
/* paste captured */
},
TogetherAIModels: {
/* paste captured */
},
FireworksModels: {
/* paste captured */
},
PerplexityModels: {
/* paste captured */
},
MistralModels: {
/* paste captured */
},
CloudflareModels: {
/* paste captured */
},
CerebrasModels: {
/* paste captured */
},
SambanovaModels: {
/* paste captured */
},
};
for (const [enumName, members] of Object.entries(frozen)) {
const actual = (enums as Record<string, unknown>)[enumName] as Record<
string,
string
>;
assert(actual !== undefined, `enum missing from dist: ${enumName}`);
for (const [member, value] of Object.entries(members)) {
assert(
actual[member] === value,
`enum member drifted: ${enumName}.${member}`,
);
}
}
const providerNames = Object.values(
(enums as { AIProviderName: Record<string, string> }).AIProviderName,
);
for (const id of [
"groq",
"xai",
"together-ai",
"fireworks",
"perplexity",
"mistral",
"cloudflare",
"cerebras",
"sambanova",
]) {
assert(
providerNames.includes(id),
`AIProviderName missing catalog id: ${id}`,
);
}
});
(The paste replaces the /* paste captured */ comments with the real captured objects — the committed test contains only literals.)
- Step 3: Run it (
pnpm run test:provider-wiringafterpnpm run build): passes against the unmodified codebase. Break one literal value, confirm ✗ + exit 1, restore. Commit.
Task 4: Codegen script + generated outputs + freshness enforcement
Files:
- Create:
tools/codegen-catalog.ts - Create (generated):
src/lib/providers/catalog/index.generated.ts - Create (generated):
src/lib/types/providerCatalog.generated.ts - Modify:
src/lib/constants/enums.ts(insert marked region — content generated) - Modify:
src/lib/types/providers.ts(insert marked region — content generated) - Modify:
src/lib/types/index.ts(addexport * from "./providerCatalog.generated.js";) - Modify:
package.json("codegen:catalog": "tsx tools/codegen-catalog.ts", and appendpnpm run codegen:catalog --checkto thevalidate/pre-commit chain used by.husky/pre-commit) - Modify:
.github/workflows/ci.yml(in thetestjob, after checkout+install:pnpm run codegen:catalog && git diff --exit-code -- src/)
Interfaces:
-
Consumes:
parseProviderCatalogJson(Task 1),catalog/*.json(Task 2). -
Produces:
CATALOG_JSON_ENTRIES: ProviderCatalogJson[]andCATALOG_PROVIDER_IDS: readonly string[]fromsrc/lib/providers/catalog/index.generated.ts;CatalogProviderNameandCatalogCredentialKeyunion types from the types barrel;AIProviderNamecatalog members +<Name>Modelsenums inside the enums.ts marked region; credentials keys inside the providers.ts marked region. -
Step 1: Write
tools/codegen-catalog.ts. Complete implementation:
#!/usr/bin/env tsx
/**
* Provider-catalog codegen. Reads src/lib/providers/catalog/*.json,
* validates each against the zod schema, and machine-writes every
* compile-time artifact the catalog needs:
* 1. src/lib/providers/catalog/index.generated.ts (aggregation index)
* 2. src/lib/types/providerCatalog.generated.ts (type unions)
* 3. marked region in src/lib/constants/enums.ts (AIProviderName members + <Name>Models enums)
* 4. marked region in src/lib/types/providers.ts (NeurolinkCredentials keys)
*
* Idempotent: a second run produces byte-identical output. `--check`
* exits 1 (writing nothing) if any output is stale — used by pre-commit
* and CI so generated code can never drift from the JSON.
*/
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { parseProviderCatalogJson } from "../src/lib/providers/catalog/schema.js";
import type { ProviderCatalogJson } from "../src/lib/types/index.js";
const CATALOG_DIR = "src/lib/providers/catalog";
const checkMode = process.argv.includes("--check");
let stale = false;
function toConstantCase(id: string): string {
return id.toUpperCase().replace(/-/g, "_");
}
function toPascalCase(id: string): string {
return id
.split("-")
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
.join("");
}
function toCamelCase(id: string): string {
return id.replace(/-([a-z])/g, (_m, c: string) => c.toUpperCase());
}
function toModelConstant(modelId: string): string {
const n = modelId
.toUpperCase()
.replace(/[^A-Z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
if (n === "") return "MODEL";
return /^[0-9]/.test(n) ? `M_${n}` : n;
}
function loadEntries(): ProviderCatalogJson[] {
const files = readdirSync(CATALOG_DIR)
.filter((f) => f.endsWith(".json") && !f.endsWith(".schema.json"))
.sort();
return files.map((f) => {
const raw: unknown = JSON.parse(readFileSync(join(CATALOG_DIR, f), "utf8"));
const entry = parseProviderCatalogJson(raw, f);
if (`${entry.id}.json` !== f) {
throw new Error(
`catalog file name must match its id: ${f} vs ${entry.id}`,
);
}
return entry;
});
}
function emit(path: string, content: string): void {
const current = ((): string | null => {
try {
return readFileSync(path, "utf8");
} catch {
return null;
}
})();
if (current === content) return;
if (checkMode) {
console.error(`stale generated output: ${path}`);
stale = true;
return;
}
writeFileSync(path, content);
console.log(`wrote ${path}`);
}
function replaceRegionWith(path: string, tag: string, generated: string): void {
const begin = `// ── BEGIN GENERATED(${tag}): provider catalog (pnpm run codegen:catalog) ──`;
const end = `// ── END GENERATED(${tag}) ──`;
const current = readFileSync(path, "utf8");
const beginIdx = current.indexOf(begin);
const endIdx = current.indexOf(end);
if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) {
throw new Error(`marked region "${tag}" not found in ${path}`);
}
const next =
current.slice(0, beginIdx + begin.length) +
"\n" +
generated +
"\n" +
current.slice(endIdx);
if (next === current) return;
if (checkMode) {
console.error(`stale generated region "${tag}" in ${path}`);
stale = true;
return;
}
writeFileSync(path, next);
console.log(`updated region "${tag}" in ${path}`);
}
const entries = loadEntries();
// Collision + identifier checks — fail loudly BEFORE writing any TypeScript.
{
const seen = new Map<string, string>();
for (const e of entries) {
const camel = toCamelCase(e.id);
const clash = seen.get(camel);
if (clash) {
throw new Error(
`credential-key collision: ids "${clash}" and "${e.id}" both derive "${camel}"`,
);
}
seen.set(camel, e.id);
const memberSeen = new Set<string>();
for (const [modelId, spec] of Object.entries(e.models.catalog)) {
const member = spec.enumMember ?? toModelConstant(modelId);
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(member)) {
throw new Error(
`invalid enum member "${member}" for ${e.id}/${modelId}`,
);
}
if (memberSeen.has(member)) {
throw new Error(
`enum-member collision in ${e.id}: two models derive "${member}" — set enumMember on one`,
);
}
memberSeen.add(member);
}
}
const enumNames = new Set<string>();
for (const e of entries) {
const enumName = e.enumTypeName ?? `${toPascalCase(e.id)}Models`;
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(enumName)) {
throw new Error(
`invalid enum type name "${enumName}" for provider ${e.id}`,
);
}
if (enumNames.has(enumName)) {
throw new Error(
`enum type name collision: "${enumName}" derived twice — set enumTypeName on one provider`,
);
}
enumNames.add(enumName);
}
}
// 1. Aggregation index
const indexLines = [
"// GENERATED FILE — do not edit. Regenerate with `pnpm run codegen:catalog`.",
"// Source of truth: the per-provider JSON files in this directory.",
...entries.map(
(e) => `import ${toCamelCase(e.id)}Json from "./${e.id}.json";`,
),
'import type { ProviderCatalogJson } from "../../types/index.js";',
"",
"export const CATALOG_JSON_ENTRIES: ProviderCatalogJson[] = [",
...entries.map((e) => ` ${toCamelCase(e.id)}Json as ProviderCatalogJson,`),
"];",
"",
"export const CATALOG_PROVIDER_IDS = [",
...entries.map((e) => ` "${e.id}",`),
"] as const;",
"",
];
emit(join(CATALOG_DIR, "index.generated.ts"), indexLines.join("\n"));
// 2. Type unions
const typeLines = [
"// GENERATED FILE — do not edit. Regenerate with `pnpm run codegen:catalog`.",
`export type CatalogProviderName = ${entries.map((e) => `"${e.id}"`).join(" | ")};`,
`export type CatalogCredentialKey = ${entries.map((e) => `"${toCamelCase(e.id)}"`).join(" | ")};`,
"",
];
emit("src/lib/types/providerCatalog.generated.ts", typeLines.join("\n"));
// 3. enums.ts gets TWO tagged regions: "provider-members" (inside the
// AIProviderName enum body) and "models-enums" (top level, end of file).
const providerMemberLines = entries
.map((e) => ` ${toConstantCase(e.id)} = ${JSON.stringify(e.id)},`)
.join("\n");
const modelsEnumBlocks = entries
.map((e) => {
const members = Object.entries(e.models.catalog)
.map(
([modelId, spec]) =>
` ${spec.enumMember ?? toModelConstant(modelId)} = ${JSON.stringify(modelId)},`,
)
.join("\n");
const enumName = e.enumTypeName ?? `${toPascalCase(e.id)}Models`;
return `export enum ${enumName} {\n${members}\n}`;
})
.join("\n\n");
replaceRegionWith(
"src/lib/constants/enums.ts",
"provider-members",
providerMemberLines,
);
replaceRegionWith(
"src/lib/constants/enums.ts",
"models-enums",
modelsEnumBlocks,
);
// 4. credentials keys
const credentialLines = entries
.map((e) => {
const extras = (e.wire.extraCredentials ?? [])
.map((c) => ` ${c}?: string;`)
.join("");
return ` ${toCamelCase(e.id)}?: { apiKey?: string; baseURL?: string;${extras} };`;
})
.join("\n");
replaceRegionWith("src/lib/types/providers.ts", "credentials", credentialLines);
if (checkMode && stale) {
console.error(
"Generated catalog output is stale. Run: pnpm run codegen:catalog",
);
process.exit(1);
}
console.log(
`codegen:catalog ${checkMode ? "check passed" : "complete"} — ${entries.length} providers`,
);
-
Step 2: Insert the empty marked regions by hand (one time). In
src/lib/constants/enums.ts: theprovider-membersregion goes INSIDEAIProviderNameimmediately beforeAUTO = "auto",— then delete the hand-writtenCEREBRASandSAMBANOVAmembers (they regenerate inside the region; the other 7 legacy members are deleted in Task 6, not now). Themodels-enumsregion goes at the end of the file — then delete the hand-writtenCerebrasModelsandSambanovaModelsenums. Insrc/lib/types/providers.ts: thecredentialsregion replaces the hand-writtencerebras?:andsambanova?:lines. -
Step 3: Run
pnpm run codegen:catalog— regions fill with cerebras + sambanova content. Run it again — output byte-identical (verify withgit status). Runpnpm run codegen:catalog --check— exits 0. Editsambanova.json(add a model), run--check— exits 1 with the stale-path message; revert; regenerate. -
Step 4:
pnpm run check && pnpm run build && pnpm run test:provider-wiring— the Task-3 snapshot test proves CerebrasModels/SambanovaModels regenerated identically. Wire the pre-commit + CI freshness checks per the Files list. Commit (generated files included).
Task 5: Runtime loader (JSON → OpenAICompatCatalogEntry)
Files:
- Create:
src/lib/providers/catalog/loader.ts
Interfaces:
-
Consumes:
CATALOG_JSON_ENTRIES(Task 4),DEFAULT_ERROR_RULES+ error classes. -
Produces:
buildCatalogEntries(): OpenAICompatCatalogEntry[]andgetCatalogJsonEntries(): ProviderCatalogJson[]— the two functions everything else consumes. AlsocatalogEnvVar(id, kind)helper. -
Step 1: Write the loader:
/**
* Converts the JSON authoring format into the runtime
* OpenAICompatCatalogEntry shape the registry loop consumes. All
* knowledge about deriving env vars, template interpolation and error
* rule matching lives HERE — the JSON stays declarative.
*/
import { CATALOG_JSON_ENTRIES } from "./index.generated.js";
import { DEFAULT_ERROR_RULES } from "../../utils/errorClassifier.js";
import {
AuthenticationError,
RateLimitError,
InvalidModelError,
NetworkError,
ProviderError,
} from "../../types/index.js";
import type {
OpenAICompatCatalogEntry,
ProviderCatalogJson,
ProviderErrorRule,
ProviderConfigOptions,
CatalogErrorRuleClass,
} from "../../types/index.js";
import type { AIProviderName } from "../../constants/enums.js";
const ERROR_CLASS_MAP: Record<
CatalogErrorRuleClass,
new (message: string, provider?: string) => Error
> = {
authentication: AuthenticationError,
"rate-limit": RateLimitError,
"invalid-model": InvalidModelError,
network: NetworkError,
provider: ProviderError,
};
export function catalogEnvVar(
entry: ProviderCatalogJson,
kind: "apiKey" | "baseURL" | "model",
): string {
const override = entry.wire.envOverrides?.[kind];
if (override) {
return override;
}
const base = entry.id.toUpperCase().replace(/-/g, "_");
return kind === "apiKey"
? `${base}_API_KEY`
: kind === "baseURL"
? `${base}_BASE_URL`
: `${base}_MODEL`;
}
function interpolate(
template: string,
entry: ProviderCatalogJson,
modelName?: string,
): string {
return template
.replace(/\{apiKeyEnvVar\}/g, catalogEnvVar(entry, "apiKey"))
.replace(/\{setupUrl\}/g, entry.setup.url)
.replace(/\{model\}/g, modelName ?? "");
}
export function buildCatalogConfigOptions(
entry: ProviderCatalogJson,
): ProviderConfigOptions {
return {
providerName: entry.displayName,
envVarName: catalogEnvVar(entry, "apiKey"),
setupUrl: entry.setup.url,
description: "API key",
instructions: entry.setup.instructions.map((line) =>
interpolate(line, entry),
),
};
}
function buildErrorRules(entry: ProviderCatalogJson): ProviderErrorRule[] {
const bespoke: ProviderErrorRule[] = entry.errorRules.map((rule) => {
const regex = rule.pattern ? new RegExp(rule.pattern, "i") : undefined;
return {
match: (ctx) =>
(rule.status !== undefined && ctx.statusCode === rule.status) ||
(regex !== undefined && regex.test(ctx.message)),
errorClass: ERROR_CLASS_MAP[rule.class],
message: rule.message.includes("{model}")
? (ctx) => interpolate(rule.message, entry, ctx.modelName)
: interpolate(rule.message, entry),
};
});
return [...bespoke, ...DEFAULT_ERROR_RULES];
}
export function getCatalogJsonEntries(): ProviderCatalogJson[] {
return CATALOG_JSON_ENTRIES;
}
export function buildCatalogEntries(): OpenAICompatCatalogEntry[] {
return CATALOG_JSON_ENTRIES.map((entry) => {
const base: OpenAICompatCatalogEntry = {
providerName: entry.id as AIProviderName,
aliases: [entry.id, ...entry.aliases],
apiKeyEnvVar: catalogEnvVar(entry, "apiKey"),
configOptions: buildCatalogConfigOptions(entry),
modelEnvVar: catalogEnvVar(entry, "model"),
defaultModel: entry.models.default,
registryDefaultModel:
entry.models.registryDefaultModel ?? entry.models.default,
registryDefaultModelChecksEnvVar:
entry.quirks?.registryDefaultIgnoresModelEnvVar !== true,
fallbackModelName:
entry.models.fallbackModelName ??
entry.models.fallbacks[1] ??
entry.models.fallbacks[0],
fallbackModels: [...entry.models.fallbacks],
errorRules: buildErrorRules(entry),
};
if (entry.wire.baseURLTemplate) {
// Schema constrains extraCredentials to exactly one entry (Task 1) —
// [0] is the whole list by construction, mirroring the deliberately
// accountId-shaped runtime computedBaseURL type.
const extra = entry.wire.extraCredentials?.[0] ?? "accountId";
base.computedBaseURL = {
envVar: `${entry.id.toUpperCase().replace(/-/g, "_")}_${extra.replace(/([A-Z])/g, "_$1").toUpperCase()}`,
missingValueMessage:
entry.wire.missingCredentialMessage ??
`Missing ${extra} for ${entry.displayName}`,
build: (value: string) =>
entry.wire.baseURLTemplate!.replace(`{${extra}}`, value),
};
} else {
base.baseURLEnvVar = catalogEnvVar(entry, "baseURL");
base.defaultBaseURL = entry.wire.baseURL;
}
if (entry.quirks?.timeoutErrorClass === "provider") {
base.timeoutErrorClass = ProviderError;
}
return base;
});
}
Adjust the computedBaseURL.envVar derivation against Cloudflare's real current value (CLOUDFLARE_ACCOUNT_ID) when migrating it in Task 6 — the current entry in openaiCompatCatalog.ts is the authority; if the generic derivation doesn't produce it exactly, add wire.envOverrides-style explicit field wire.extraCredentialEnvVar to the schema instead of guessing.
- Step 2:
pnpm run checkpasses (nothing consumes the loader yet). Sanity-run:
npx tsx -e 'const { buildCatalogEntries } = await import("./src/lib/providers/catalog/loader.ts"); console.log(buildCatalogEntries().map(e => e.providerName));'
Expected: [ "cerebras", "sambanova" ]. Commit.
Task 6: Migrate the 7 legacy catalog providers to JSON
Files:
- Create:
src/lib/providers/catalog/{groq,xai,together-ai,fireworks,perplexity,mistral,cloudflare}.json - Modify (generated regions only, via codegen):
enums.ts,types/providers.ts - Modify:
src/lib/constants/enums.ts— delete the 7 hand-writtenAIProviderNamemembers and the 7 hand-written<Name>Modelsenums (they regenerate inside the marked regions) - Modify:
src/lib/types/providers.ts— delete the 7 hand-written credential slices
Interfaces:
-
Consumes: the existing hand-written data — extraction sources per provider are: its
openaiCompatCatalog.tsentry (wire, models default/fallbacks, error rules, quirks), its<Name>Modelsenum inenums.ts(full model roster + member names),contextWindows.ts,pricing.ts,providerConfig.tsfactory (setup),providerImageAdapter.ts(vision),test/helpers/providerMatrix.ts(capabilities),docs/provider-integration/manifests/*.jsonwhere one exists (evidence). -
Produces: 9 total JSON files; the generated enums must satisfy the Task-3 snapshot.
-
Step 1: For each of the 7 providers, transcribe every field. Rules that make this mechanical, not judgment:
- Every member of the existing
<Name>Modelsenum becomes amodels.catalogkey (the STRING VALUE is the key; the MEMBER NAME goes intoenumMemberwhenever the derived constant differs — run both throughtoModelConstantand compare; e.g. GroqGEMMA_2_9B_IT = "gemma2-9b-it"derives toGEMMA2_9B_IT, so"enumMember": "GEMMA_2_9B_IT"is required). contextWindow/pricingPerMTokcopy fromcontextWindows.ts/pricing.tswhere a per-model entry exists; omit the optional field otherwise (providerdefaultContextWindow= the_default). NEVER invent a number a shipped file doesn't state.vision: trueexactly for the models listed inVISION_CAPABILITIES.errorRules: each existingmatchfunction decomposes intostatus(thectx.statusCode === Nclause) +pattern(the regex source). Groq's decommissioned rule keeps its dynamic message via the{model}template. Groq gets"quirks": { "timeoutErrorClass": "provider" }; Mistral gets"quirks": { "registryDefaultIgnoresModelEnvVar": true }.- Compare each provider's derived enum type name (
PascalCase(id) + "Models") against the existing export; where it differs, setenumTypeName— among the 9, only together-ai needs it ("enumTypeName": "TogetherAIModels"). - Compare the legacy entry's
fallbackModelNameagainstfallbacks[1] ?? fallbacks[0]; where it differs, setmodels.fallbackModelNameexplicitly (behavior preservation). - Compare the legacy entry's
registryDefaultModelagainst itsdefaultModel; where it differs (Mistral: MISTRAL_LARGE_LATEST), setmodels.registryDefaultModelexplicitly. - Cloudflare uses
baseURLTemplate+extraCredentials: ["accountId"]+ the existingmissingValueMessage. status:"production"unless the current description/comment says preview/retired.evidence: legacy providers get{"rosterVerified": {"date": "2026-08-28", "method": "transcribed from pre-migration TS catalog"}, "liveMatrix": null, "addedInPR": "<this plan's PR>"}— honest provenance, upgradable later.capabilitiescopy from the provider'sproviderMatrix.tsrow.
- Every member of the existing
-
Step 2:
pnpm run codegen:catalog, then delete the 7 hand-written members/enums/slices listed under Files. -
Step 3:
pnpm run check && pnpm run build && pnpm run test:provider-wiring— the Task-3 snapshot test is the merge gate for this task: any member-name or value drift fails there. Fix viaenumMemberoverrides only. -
Step 4: Commit.
Task 7: Switch the catalog + derive per-concern src consumers
Files:
- Modify:
src/lib/providers/openaiCompatCatalog.ts— becomesexport const OPENAI_COMPAT_CATALOG: readonly OpenAICompatCatalogEntry[] = buildCatalogEntries();plus the header comment; delete the 9 hand entries and now-unused imports. - Modify:
src/lib/utils/providerConfig.ts— the 9create<Name>Config()bodies become one-line delegations (keep the exported names for compat):export function createGroqConfig(): ProviderConfigOptions { return catalogConfigOptions("groq"); }wherecatalogConfigOptions(id)looks up the JSON entry and callsbuildCatalogConfigOptions. - Modify:
src/lib/factories/providerDescriptors.ts— delete the 9 catalog descriptors;export const PROVIDER_DESCRIPTORS = [...HAND_DESCRIPTORS, ...buildCatalogDescriptors()]where the builder maps JSON → descriptor (name: id,aliases,credentialsKey: camel(id),envVarsfromcatalogEnvVar,defaultModel: models.default,toolSupport=capabilities.tools ? "native" : <the ProviderDescriptor toolSupport union's unsupported member — read the union in src/lib/types, do not invent a value>(all 9 current providers have tools:true, so runtime-identical today),localRuntime: false,healthCheck: "env-only",setupUrl,apiKeyFormatPatternfromsetup.apiKeyFormatwhen non-null). - Modify:
src/cli/commands/setup.ts— delete the 9 entries fromEXTRA_PROVIDER_CONFIGS; spread...Object.fromEntries(getCatalogJsonEntries().map((e) => [e.id, buildCatalogConfigOptions(e)])). - Modify:
src/lib/constants/contextWindows.ts— delete the 9 catalog blocks; spread derived blocks built frommodels.catalog[*].contextWindow+defaultContextWindow. - Modify:
src/lib/utils/pricing.ts— same pattern forpricingPerMTok(+ the alias map entries derive from ids). - Modify:
src/lib/adapters/providerImageAdapter.ts— delete catalog vision entries; derive{[id]: modelsWithVision}for entries with ≥1 vision model. - Modify:
src/lib/utils/modelChoices.ts— delete the 9 catalog blocks from both tables. Table types becomeRecord<Exclude<AIProviderName, CatalogProviderName>, …>for the hand part (full compile-time exhaustiveness preserved for non-catalog providers), with catalog entries derived frommodels.catalog[*].descriptionand merged in the accessor functions. - Modify:
src/lib/models/manifestRegistry.ts+ deletesrc/lib/models/manifests/{cerebras,sambanova}.ts— catalog manifests derive frommodels.catalog(contextWindow/maxOutputTokens/vision) withfunctionCalling: entry.capabilities.tools— never hardcoded. - Modify:
tools/testing/providerValidator.ts— roster/keyMappings/builtin-set/connectivity cases derive fromCATALOG_PROVIDER_IDS.
Interfaces:
-
Consumes: Task 5 loader, Task 4
CatalogProviderName. -
Produces: identical runtime behavior — proven by the existing suites, not new ones.
-
Step 1 Apply the edits above, smallest file first, running
pnpm run checkafter each. -
Step 2
pnpm run build, then the full existing gate set — all must pass UNCHANGED (that is the point):
npx tsx test/continuous-test-suite-providers-mocked.ts # 73/73
npx tsx test/continuous-test-suite-provider-wiring.ts # incl. snapshot
npx tsx test/continuous-test-suite-provider-descriptors.ts
pnpm run test:provider-structure
pnpm run test:error-classifier-contract
pnpm run verify:provider-onboarding
node dist/cli/index.js generate "hi" --provider cerebras # live smoke (key in .env)
- Step 3: Commit.
Task 8: Data-driven tests (zero test edits per future provider)
Files:
-
Modify:
test/continuous-test-suite-providers-mocked.ts—OPENAI_COMPAT_PROVIDERSderives: for each dist catalog JSON entry build{provider: id, envVar, urlMatch: new URL(baseURL or template-with-dummy).host + "/…/chat/completions" path, authPrefix: "Bearer ", model: models.default, authErrorMatch, rateLimitErrorMatch}.authErrorMatch=new RegExp(id + "|401|unauthor|api key", "i")(the invariant the suite actually asserts); import the entries from../dist/providers/catalog/index.generated.js(all-dist, rule 15). Cloudflare keeps its existing bespoke handling if its computed URL doesn't fit the generic builder — preserve current coverage, never reduce it. -
Modify:
test/helpers/providerMatrix.ts— delete the 9 catalog rows; spread derived rows: capabilities from JSONcapabilities+visionderived +defaultModel+envVars: [apiKeyEnvVar]— computed-URL providers additionally include theircomputedBaseURL.envVar(Cloudflare: CLOUDFLARE_ACCOUNT_ID) so the matrix skips rather than runs an unconstructible provider. Rows import from dist index (test helper — dist graph). -
Modify:
test/continuous-test-suite-provider-wiring.ts—KNOWN_CREDENTIAL_KEYSkeeps hand keys for non-catalog providers typed assatisfies Record<Exclude<keyof NeurolinkCredentials, CatalogCredentialKey>, undefined>; the runtime set unionsCATALOG_PROVIDER_IDScamelized. The wizard-count and provider-count assertions compute expected values fromCATALOG_PROVIDER_IDS.length+ named literals for the non-catalog roster (which changes rarely and intentionally). -
Modify:
test/continuous-test-suite-provider-descriptors.ts—getAllDescriptorsexpected length = non-catalog literal +CATALOG_PROVIDER_IDS.length;apiKeyFormatPattern-absent expectation derives from the JSONsetup.apiKeyFormatnull-count. -
Modify:
tools/verify-provider-onboarding.ts— the gate for a newAIProviderNamemember becomes: acatalog/<id>.jsonexists, parses against the schema, andevidence.rosterVerified+evidence.addedInPRare present. Delete the docs-manifest requirement; deletedocs/provider-integration/manifests/(its two files' content now lives inevidence). -
Step 1 Apply, run every touched suite, expected: same totals as Task 7.
-
Step 2: Break-one-assertion ritual — delete
sambanova.json'sevidence.rosterVerified, runpnpm run verify:provider-onboarding→ non-zero; restore. Set one derived matrix capability wrong via a temporary JSON edit, thenpnpm run codegen:catalog && pnpm run build(the suites import the CATALOG FROM DIST — running them against a stale build silently tests the old data), and run the MATRIX runner for a provider with keys in .env → the corresponding test must ✗ non-zero (not ⊘); the mocked-suite half of the ritual instead breaks a derived spec field (e.g. the urlMatch host) and confirms ✗. Restore, regenerate, rebuild. Note the independence boundary: derived expectations prove wiring, not data — the DATA's truth is anchored by the evidence fields (live probes), which verify:provider-onboarding requires. -
Step 3: Commit.
Task 9: Tooling + docs alignment
Files:
-
Modify:
tools/scaffold-provider.ts— Tier 2 now emits exactly TWO artifacts:<id>.json(pre-filled from flags, with TODO evidence fields) andMANUAL-CHECKLIST.mdreduced to: live probes (roster/auth/billing), fill the JSON,pnpm run codegen:catalog, run gates, live matrix. Delete the now-dead tier-2 snippet generators (catalog-entry/descriptor/provider-config/models-enum/mocked-section snippets); Tier 3/4 paths keep theirs. -
Modify:
docs/provider-integration/tiers/tier-2-catalog-entry.md— rewrite: files-touched table becomes ONE row (catalog/<id>.json) + "generated automatically" note; Count-pins section becomes "derived — nothing to bump"; keep the Live-verification section unchanged. -
Modify:
docs/provider-integration/openai-compat-catalog.md— describe the JSON format, link the spec. -
Modify:
CLAUDE.md— "Adding a New Provider" how-to gains the Tier-2 fast path (one JSON + codegen), and the Key Files table addssrc/lib/providers/catalog/. -
Step 1 Apply; dummy-run the scaffold for tier 2 and tier 3, verify outputs.
-
Step 2: Commit.
Task 10: Final gates + single-commit packaging
- Step 1: Full sweep:
pnpm run check && pnpm run check:tools-tests && pnpm run lint && pnpm run build && pnpm testplus every suite from Task 7 Step 2, pluspnpm run codegen:catalog --check. - Step 2: Live smokes with the keys in
.env: cerebras generate + stream via CLI; sambanova expected-402 friendly error via CLI (or live matrix if credits exist by then). - Step 3:
pnpm run docs:api+pnpm run format(last pre-commit step). - Step 4: Squash to one commit:
git reset --soft $(git merge-base HEAD origin/release) && git commit -m "refactor(providers): drive the tier-2 catalog from per-provider JSON"with a body summarizing spec rulings + the 16→1 file collapse. Push, open PR, merge under the full condition with the hard thread gate.