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
27 changes: 27 additions & 0 deletions integrations/providers/usai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,33 @@ comparing it would fail every offline run regardless of real drift — and a
detector that always fires is one everybody learns to ignore, leaving genuine
drift indistinguishable from the noise.

## Role defaults (`recommendations`)

`recommendations` maps a role (`primary`, `small`, `compaction`, `summary`,
`title`) to a model id. A person chooses these; the feeds do not. So a
regeneration carries the existing values forward from the catalog at `--out`
rather than recomputing them.

- Every value must name a model in `models[]`. A role name outside the schema,
or an id that is not in `models[]`, fails validation.
- If a recommended model is no longer in the regenerated list, the build **fails**
and names each missing role and id. Nothing is written and nothing is
re-picked automatically. Choose a replacement, edit `recommendations` in
`catalog.json`, and re-run.
- The file at `--out` must be absent or a real catalog (a JSON object with
`schemaVersion` `usai-model-catalog/v1` and a `models` array). Anything else,
including invalid JSON, `[]`, `null`, a number, `{}`, or a catalog with no
`models`, fails the build and leaves the file untouched, instead of
overwriting it with a catalog that has no roles.
- A real catalog that has no `recommendations` yet is accepted: there are no
roles to lose, so the new catalog is written without any. Roles are only ever
added by a person editing `recommendations`; the build never invents them.
The shipped catalog always carries them, so deleting the block from it is a
reviewable diff, not a silent build outcome.
- `--check` applies the same rules: a recommended model that left the feed makes
it fail with the message above, and `describeDrift` also names any role whose
value differs between the two catalogs it compares.

## Files

- `catalog.schema.json` — JSON Schema (draft 2020-12), id
Expand Down
110 changes: 109 additions & 1 deletion integrations/providers/usai/scripts/build-catalog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -991,6 +991,27 @@ function validateCatalog(catalog, schema) {

assert(catalog.schemaVersion === SCHEMA_VERSION, `schemaVersion must be "${SCHEMA_VERSION}"`, errors)

// recommendations (optional): role names come from the schema, and every
// value must name a model that is actually in models[].
if ("recommendations" in catalog) {
const recs = catalog.recommendations
if (!recs || typeof recs !== "object" || Array.isArray(recs)) {
errors.push("recommendations must be an object")
} else {
const roles = new Set(Object.keys(schema.properties.recommendations.properties))
const ids = new Set(Array.isArray(catalog.models) ? catalog.models.map((m) => m?.id) : [])
for (const [role, modelId] of Object.entries(recs)) {
if (!roles.has(role)) {
errors.push(`recommendations: disallowed role "${role}" (allowed: ${[...roles].join(", ")})`)
} else if (typeof modelId !== "string" || modelId.length === 0) {
errors.push(`recommendations.${role} must be a non-empty string`)
} else if (!ids.has(modelId)) {
errors.push(`recommendations.${role} references "${modelId}", which is not in models[]`)
}
}
}
}

// gateway
const gw = catalog.gateway
if (gw && typeof gw === "object") {
Expand Down Expand Up @@ -1064,7 +1085,8 @@ function validateCatalog(catalog, schema) {
// newline. Order is stable so the committed catalog.json is reproducible.
// -----------------------------------------------------------------------------
const KEY_ORDER = {
root: ["schemaVersion", "generatedBy", "sources", "gateway", "vendors", "models"],
root: ["schemaVersion", "generatedBy", "sources", "recommendations", "gateway", "vendors", "models"],
recommendations: ["primary", "small", "compaction", "summary", "title"],
gateway: ["host", "baseUrl", "api", "apiKeyEnv"],
vendor: ["key", "label", "order", "usaiBackend"],
model: ["id", "vendor", "name", "contextWindow", "maxOutputTokens", "cost", "costAbove200kContext", "reasoning", "inputModalities"],
Expand All @@ -1081,6 +1103,9 @@ function orderObject(obj, order) {

function orderCatalog(catalog) {
const out = orderObject(catalog, KEY_ORDER.root)
if (out.recommendations && typeof out.recommendations === "object" && !Array.isArray(out.recommendations)) {
out.recommendations = orderObject(out.recommendations, KEY_ORDER.recommendations)
}
if (out.gateway) out.gateway = orderObject(out.gateway, KEY_ORDER.gateway)
if (Array.isArray(out.vendors)) out.vendors = out.vendors.map((v) => orderObject(v, KEY_ORDER.vendor))
if (Array.isArray(out.models)) {
Expand Down Expand Up @@ -1131,6 +1156,72 @@ function parseArgs(argv) {
return args
}

/**
* Role defaults are curated by a human, not derived from the feeds, so a
* regeneration must carry them forward. Returns the existing catalog's
* `recommendations` object, or undefined when there is nothing to carry:
* - the file does not exist (first generation), or
* - the file is a catalog (right schemaVersion, models[] array) that has no
* `recommendations` yet; there are no roles to lose.
* Anything else that exists at the output path is an error. In particular a
* file that parses as JSON but is not a catalog (`[]`, `null`, `123`, `{}`, a
* catalog with no models[]) must never be silently overwritten with a catalog
* that has no roles, because that is the failure this guards against.
*/
async function readExistingRecommendations(catalogPath) {
let raw
try {
raw = await readFile(catalogPath, "utf8")
} catch (error) {
if (error?.code === "ENOENT") return undefined
throw error
}
const refuse = (why) =>
new Error(
`cannot carry recommendations forward: ${catalogPath} ${why}.\n` +
` Repair or remove it, then re-run. The catalog was NOT written.`,
)
let existing
try {
existing = JSON.parse(raw)
} catch {
throw refuse("exists but is not valid JSON")
}
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
throw refuse("is valid JSON but not a catalog object")
}
if (existing.schemaVersion !== SCHEMA_VERSION) {
throw refuse(`is not a ${SCHEMA_VERSION} catalog (schemaVersion is ${JSON.stringify(existing.schemaVersion)})`)
}
if (!Array.isArray(existing.models)) {
throw refuse("is a catalog without a models[] array")
}
return existing.recommendations
}

/**
* Keep the existing role defaults. If any recommended model is no longer in
* the regenerated model list, fail closed: a human must pick the replacement.
* No role is ever re-chosen automatically.
*/
function carryForwardRecommendations(recommendations, models, where = "catalog.json") {
if (recommendations === undefined) return undefined
if (!recommendations || typeof recommendations !== "object" || Array.isArray(recommendations)) {
throw new Error(`recommendations in ${where} must be an object. The catalog was NOT written.`)
}
const ids = new Set(models.map((m) => m.id))
const gone = Object.entries(recommendations).filter(([, id]) => !ids.has(id))
if (gone.length) {
throw new Error(
`recommended model(s) are no longer in the model list:\n` +
gone.map(([role, id]) => ` - ${role}: ${id}`).join("\n") +
`\n Choose a replacement for each role, set it under "recommendations" in ${where}, ` +
`and re-run. The catalog was NOT written.`,
)
}
return { ...recommendations }
}

async function buildCatalog(args) {
const schemaPath = args.opts["--schema"] || DEFAULT_SCHEMA
const schema = JSON.parse(await readFile(schemaPath, "utf8"))
Expand Down Expand Up @@ -1191,6 +1282,13 @@ async function buildCatalog(args) {
}
}

const carried = carryForwardRecommendations(
await readExistingRecommendations(args.opts["--out"] || DEFAULT_OUT),
catalog.models,
args.opts["--out"] || DEFAULT_OUT,
)
if (carried !== undefined) catalog.recommendations = carried

const errors = validateCatalog(catalog, schema)
if (errors.length) {
throw new Error(`catalog failed schema validation:\n - ${errors.join("\n - ")}`)
Expand Down Expand Up @@ -1220,6 +1318,15 @@ function describeDrift(existing, fresh) {
lines.push(`${key}: ${JSON.stringify(existing?.[key])} -> ${JSON.stringify(fresh?.[key])}`)
}
}
const rolesBefore = existing?.recommendations ?? {}
const rolesAfter = fresh?.recommendations ?? {}
for (const role of [...new Set([...Object.keys(rolesBefore), ...Object.keys(rolesAfter)])].sort()) {
if (rolesBefore[role] !== rolesAfter[role]) {
lines.push(
`recommendations.${role}: ${JSON.stringify(rolesBefore[role] ?? "(none)")} -> ${JSON.stringify(rolesAfter[role] ?? "(none)")}`,
)
}
}
if (JSON.stringify(existing?.gateway) !== JSON.stringify(fresh?.gateway)) {
lines.push("gateway block changed")
}
Expand Down Expand Up @@ -1284,6 +1391,7 @@ export {
serialize,
substantive,
describeDrift,
carryForwardRecommendations,
parseBootstrapModels,
bootstrapModelsFromBlock,
shapeFromFeeds,
Expand Down
Loading
Loading