diff --git a/AGENTS.md b/AGENTS.md index cd4f815d3f..39254de44e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,7 +100,7 @@ items are **hard blockers**; the last two are **strongly recommended** but not b base_model_omit = ["limit.input"] # optional, dot-path strings ``` Example: `base_model = "anthropic/claude-opus-4-6"` -- Resolved at parse time in `generate()`; the final provider JSON output contains **no** `base_model` or `base_model_omit` fields +- Resolved at parse time in `generate()`; the resolved `base_model` ref is **retained** on the emitted provider model (served in `api.json`/`catalog.json` so consumers can group offerings by underlying model), while `base_model_omit` is authoring-only and never appears in output - Merge semantics: - Plain objects from metadata and provider TOML (`[limit]`, `[modalities]`, …) are **deep-merged** - Arrays (e.g. `modalities.input`) and primitives are **replaced** wholesale by the child diff --git a/packages/core/src/generate.ts b/packages/core/src/generate.ts index d0764b6469..020886a3bf 100755 --- a/packages/core/src/generate.ts +++ b/packages/core/src/generate.ts @@ -114,7 +114,10 @@ async function generateProviders( model.error.cause = { modelPath, toml: merged }; throw model.error; } - provider.data.models[modelID] = normalizeModelCost(model.data); + provider.data.models[modelID] = { + ...normalizeModelCost(model.data), + base_model: baseModel.data.base_model, + }; continue; } const model = AuthoredModel.safeParse(toml); diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 442d322a58..0330cc8ca5 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -355,6 +355,11 @@ export const ModelShape = z .object({ ...ModelBase.shape, cost: OutputCost.optional(), + // Canonical models/ registry id this offering inherits from, retained from + // the provider TOML's base_model ref so API consumers can group offerings + // by underlying model. Output-only: authored TOMLs declare base_model via + // the BaseModel wrapper in generate.ts, not through this shape. + base_model: z.string().optional(), }) .strict(); diff --git a/packages/core/test/generate.test.ts b/packages/core/test/generate.test.ts index 7fee116f43..e74295a1d6 100644 --- a/packages/core/test/generate.test.ts +++ b/packages/core/test/generate.test.ts @@ -4,7 +4,7 @@ import { existsSync } from "node:fs"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { generate, generateCatalog } from "../src/index.js"; +import { generate, generateCatalog, generateModels } from "../src/index.js"; async function withFixture(callback: (root: string) => Promise) { const root = await mkdtemp(path.join(tmpdir(), "models-dev-test-")); @@ -35,7 +35,7 @@ function stable(value: unknown): string { } describe("catalog generation", () => { - test("base_model can factor metadata without changing provider JSON", async () => { + test("base_model factors metadata and retains the ref on provider JSON", async () => { await withFixture(async (root) => { await write(root, "providers/direct/provider.toml", providerToml("Direct")); await write(root, "providers/factored/provider.toml", providerToml("Factored")); @@ -86,12 +86,10 @@ cache_read = 0.125 }, ]); - expect(catalog.providers.factored?.models.model).toEqual( - catalog.providers.direct?.models.model, - ); - expect(catalog.providers.factored?.models.model).not.toHaveProperty( - "base_model", - ); + expect(catalog.providers.factored?.models.model).toEqual({ + ...catalog.providers.direct?.models.model, + base_model: "lab/model", + }); expect(catalog.providers.factored?.models.model).not.toHaveProperty( "benchmarks", ); @@ -183,21 +181,29 @@ input = ["text"] expect(matches).toEqual([]); }); - test("repository provider JSON strips authored metadata pointers", async () => { + test("repository provider JSON strips base_model_omit and serves resolvable base_model refs", async () => { const root = path.join(import.meta.dirname, "..", "..", ".."); + const models = await generateModels(path.join(root, "models")); const providers = await generate(path.join(root, "providers")); const leaked: string[] = []; + const unresolvable: string[] = []; for (const [providerID, provider] of Object.entries(providers)) { for (const [modelID, model] of Object.entries(provider.models)) { - const encoded = stable(model); - if (encoded.includes("base_model") || encoded.includes("base_model_omit")) { + // base_model is deliberately retained on provider JSON so consumers + // can group offerings by underlying model; base_model_omit is an + // authoring-only directive and must never be served. + if (stable(model).includes("base_model_omit")) { leaked.push(`${providerID}/${modelID}`); } + if (model.base_model !== undefined && models[model.base_model] === undefined) { + unresolvable.push(`${providerID}/${modelID} -> ${model.base_model}`); + } } } expect(leaked).toEqual([]); + expect(unresolvable).toEqual([]); }); test("repository provider JSON excludes model-only metadata", async () => { diff --git a/packages/sdk/src/generated.ts b/packages/sdk/src/generated.ts index 9847259d1a..56917482ad 100644 --- a/packages/sdk/src/generated.ts +++ b/packages/sdk/src/generated.ts @@ -89,8 +89,10 @@ export type ModelFamily = | "kimi" | "kimi-free" | "kimi-k2" + | "kimi-k3" | "kimi-thinking" | "laguna" + | "laguna-s" | "ling" | "ling-flash-free" | "liquid" diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 0e81fa652a..649b699088 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -208,6 +208,13 @@ export interface Model { name: string description: string family?: ModelFamily + /** + * Canonical `models/` registry ID this offering inherits from (its + * provider TOML's `base_model` ref), e.g. "anthropic/claude-opus-4-6". + * Group offerings by this ID to identify the same underlying model + * across providers. Absent when the offering has no registry link. + */ + base_model?: string /** Supports file attachments. */ attachment: boolean /** Is a reasoning model. */ diff --git a/packages/web/src/render.tsx b/packages/web/src/render.tsx index 93c73c6e66..bb93905edc 100644 --- a/packages/web/src/render.tsx +++ b/packages/web/src/render.tsx @@ -27,7 +27,7 @@ const Catalog = await generateCatalog(root); export const Models = Catalog.models; export const Providers = Catalog.providers; -const BaseModelRefs = await loadProviderBaseModelRefs(root); +const BaseModelRefs = collectBaseModelRefs(Providers); const LabMetadata = loadLabMetadata(root); const ProviderLogoSvgs = new Map(); const LabLogoSvgs = new Map(); @@ -146,31 +146,14 @@ export function renderDocument(template: string, page: RenderedPage) { .replace("", page.html); } -async function loadProviderBaseModelRefs(root: string) { +function collectBaseModelRefs(providers: Record) { const refs = new Map(); - const providersDirectory = path.join(root, "providers"); - if (!existsSync(providersDirectory)) return refs; - - for await (const modelPath of new Bun.Glob("*/models/**/*.toml").scan({ - cwd: providersDirectory, - absolute: true, - followSymlinks: true, - })) { - const parts = path.relative(providersDirectory, modelPath).split(path.sep); - const [providerId, modelsSegment, ...modelParts] = parts; - if (!providerId || modelsSegment !== "models" || modelParts.length === 0) { - continue; - } - - const modelId = modelParts.join("/").slice(0, -5); - const toml = await import(modelPath, { - with: { - type: "toml", - }, - }).then((mod) => mod.default as { base_model?: unknown }); - if (typeof toml.base_model === "string") { - refs.set(`${providerId}/${modelId}`, toml.base_model); + for (const [providerId, provider] of Object.entries(providers)) { + for (const [modelId, model] of Object.entries(provider.models)) { + if (model.base_model !== undefined) { + refs.set(`${providerId}/${modelId}`, model.base_model); + } } }