Skip to content
Open
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
28 changes: 17 additions & 11 deletions packages/core/test/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(callback: (root: string) => Promise<T>) {
const root = await mkdtemp(path.join(tmpdir(), "models-dev-test-"));
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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",
);
Expand Down Expand Up @@ -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 () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,10 @@ export type ModelFamily =
| "kimi"
| "kimi-free"
| "kimi-k2"
| "kimi-k3"
| "kimi-thinking"
| "laguna"
| "laguna-s"
| "ling"
| "ling-flash-free"
| "liquid"
Expand Down
7 changes: 7 additions & 0 deletions packages/sdk/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
31 changes: 7 additions & 24 deletions packages/web/src/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();
const LabLogoSvgs = new Map<string, string>();
Expand Down Expand Up @@ -146,31 +146,14 @@ export function renderDocument(template: string, page: RenderedPage) {
.replace("<!--static-->", page.html);
}

async function loadProviderBaseModelRefs(root: string) {
function collectBaseModelRefs(providers: Record<string, Provider>) {
const refs = new Map<string, string>();
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);
}
}
}

Expand Down
Loading