Skip to content

Commit 1f3ba4b

Browse files
authored
Merge pull request #1483 from anomalyco/add-extends-support
feat: add extends support
2 parents f93c1a8 + 305bdb6 commit 1f3ba4b

31 files changed

Lines changed: 284 additions & 589 deletions

AGENTS.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,27 @@
3131
## Model Configuration
3232

3333
- Model `id` is **auto-injected** from filename (minus `.toml`) — never put `id` in TOML files
34-
- Same model is duplicated across provider directories with no cross-referencing
34+
- Models may reuse another model's definition via `extends` (see below); otherwise the full definition must be present in the file
3535
- Schema uses `.strict()` — extra fields cause validation errors
3636

37+
### `[extends]` (inheritance between models)
38+
- Syntax — a table at the top of the TOML:
39+
```toml
40+
[extends]
41+
from = "<provider-id>/<model-id>" # required
42+
omit = ["experimental.modes.fast"] # optional, dot-path strings
43+
```
44+
Example: `from = "anthropic/claude-opus-4-6"`
45+
- Resolved at parse time in `generate()`; the final JSON output contains **no** `extends` field — it exists only to cut duplication in the TOMLs
46+
- Merge semantics:
47+
- Plain objects (`[cost]`, `[limit]`, `[modalities]`, `[provider]`, `[experimental]`, …) are **deep-merged**
48+
- Arrays (e.g. `modalities.input`) and primitives are **replaced** wholesale by the child
49+
- Any field the child omits is inherited verbatim from the base
50+
- `omit` runs **after** the merge and deletes each dot-path from the result (used when the child needs to *remove* something the base defines, e.g. a provider-specific experimental mode). Every listed path must exist in the merged model, else an error is thrown. Ancestor tables that become empty as a result are also pruned, so `omit = ["experimental.modes.fast"]` yields no `experimental` key in the final JSON when `fast` was the only mode.
51+
- Chains are allowed (A extends B extends C); cycles throw
52+
- The base model must exist; `[extends.from]` pointing at a missing provider/model is an error
53+
- The `extends` table is stripped before schema validation, so the merged result must still satisfy the strict `Model` schema
54+
3755
### Bedrock Naming Patterns
3856
- Dated models: `-v1:0` suffix (`anthropic.claude-3-5-sonnet-20241022-v1:0.toml`)
3957
- Latest/undated models: bare `-v1` (`anthropic.claude-opus-4-6-v1.toml`)

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
},
1717
"scripts": {
1818
"validate": "bun ./packages/core/script/validate.ts",
19+
"compare:migrations": "bun ./packages/core/script/compare-model-migrations.ts",
1920
"helicone:generate": "bun ./packages/core/script/generate-helicone.ts",
2021
"venice:generate": "bun ./packages/core/script/generate-venice.ts",
2122
"vercel:generate": "bun ./packages/core/script/generate-vercel.ts",

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"$schema": "https://json.schemastore.org/package.json",
55
"type": "module",
66
"dependencies": {
7+
"remeda": "^2.33.7",
78
"zod": "catalog:"
89
},
910
"main": "./src/index.ts",
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
#!/usr/bin/env bun
2+
3+
import path from "node:path";
4+
import { cp, mkdir, rm, writeFile } from "node:fs/promises";
5+
import { tmpdir } from "node:os";
6+
import { generate } from "../src/generate.js";
7+
8+
const root = path.join(import.meta.dirname, "..", "..", "..");
9+
const providersPath = path.join(root, "providers");
10+
11+
const diffOutput = await Bun.$`git diff --name-only HEAD -- providers`.cwd(root).text();
12+
const changedProviderPaths = diffOutput
13+
.split("\n")
14+
.filter(Boolean)
15+
.filter((filePath) => /^providers\/[^/]+\/models\/.+\.toml$/.test(filePath));
16+
17+
if (changedProviderPaths.length === 0) {
18+
process.exit(0);
19+
}
20+
21+
const baselineRoot = path.join(tmpdir(), `models-dev-compare-${Date.now()}`);
22+
await mkdir(baselineRoot, { recursive: true });
23+
24+
try {
25+
const baselineProvidersPath = path.join(baselineRoot, "providers");
26+
await cp(providersPath, baselineProvidersPath, { recursive: true });
27+
28+
for (const filePath of changedProviderPaths) {
29+
const tempFilePath = path.join(baselineRoot, filePath);
30+
const show = Bun.spawn(["git", "show", `HEAD:${filePath}`], {
31+
cwd: root,
32+
stdout: "pipe",
33+
stderr: "pipe",
34+
});
35+
const exitCode = await show.exited;
36+
if (exitCode !== 0) {
37+
await rm(tempFilePath, { force: true });
38+
continue;
39+
}
40+
41+
const contents = await new Response(show.stdout).text();
42+
await mkdir(path.dirname(tempFilePath), { recursive: true });
43+
await writeFile(tempFilePath, contents);
44+
}
45+
46+
const before = await generate(baselineProvidersPath);
47+
const after = await generate(providersPath);
48+
49+
for (const filePath of changedProviderPaths) {
50+
const match = /^providers\/([^/]+)\/models\/(.+)\.toml$/.exec(filePath);
51+
if (!match) continue;
52+
53+
const [, providerID, modelID] = match;
54+
const beforeModel = before[providerID]?.models[modelID];
55+
const afterModel = after[providerID]?.models[modelID];
56+
const beforeJson = JSON.stringify(beforeModel, null, 2);
57+
const afterJson = JSON.stringify(afterModel, null, 2);
58+
59+
if (beforeJson === afterJson) {
60+
continue;
61+
}
62+
63+
const beforeFilePath = path.join(baselineRoot, "before.json");
64+
const afterFilePath = path.join(baselineRoot, "after.json");
65+
await writeFile(beforeFilePath, `${beforeJson}\n`);
66+
await writeFile(afterFilePath, `${afterJson}\n`);
67+
68+
const diff = Bun.spawn(
69+
[
70+
"diff",
71+
"-u",
72+
"-L",
73+
`${filePath} (before)`,
74+
"-L",
75+
`${filePath} (after)`,
76+
beforeFilePath,
77+
afterFilePath,
78+
],
79+
{
80+
stdout: "pipe",
81+
stderr: "pipe",
82+
},
83+
);
84+
const output = await new Response(diff.stdout).text();
85+
process.stdout.write(output);
86+
}
87+
} finally {
88+
await rm(baselineRoot, { recursive: true, force: true });
89+
}

packages/core/src/generate.ts

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,31 @@
11
import path from "path";
2+
import { mergeDeep } from "remeda";
3+
import { z } from "zod";
24

35
import { Provider, Model } from "./schema.js";
46

7+
const ExtendsModel = Model.sourceType()
8+
.partial()
9+
.extend({
10+
extends: z
11+
.object({
12+
from: z
13+
.string()
14+
.regex(/^[^/]+\/[^/]+$/, "Must be in provider/model format"),
15+
omit: z.array(z.string()).optional(),
16+
})
17+
.strict(),
18+
})
19+
.strict();
20+
521
export async function generate(directory: string) {
6-
const result = {} as Record<string, Provider>;
22+
const result: Record<string, Provider> = {};
23+
const extendsModels: Array<{
24+
providerID: string;
25+
modelID: string;
26+
modelPath: string;
27+
model: z.infer<typeof ExtendsModel>;
28+
}> = [];
729
for await (const providerPath of new Bun.Glob("*/provider.toml").scan({
830
cwd: directory,
931
absolute: true,
@@ -35,6 +57,20 @@ export async function generate(directory: string) {
3557
},
3658
}).then((mod) => mod.default);
3759
toml.id = modelID;
60+
if (toml.extends !== undefined) {
61+
const model = ExtendsModel.safeParse(toml);
62+
if (!model.success) {
63+
model.error.cause = { modelPath, toml };
64+
throw model.error;
65+
}
66+
extendsModels.push({
67+
providerID,
68+
modelID,
69+
modelPath,
70+
model: model.data,
71+
});
72+
continue;
73+
}
3874
const model = Model.safeParse(toml);
3975
if (!model.success) {
4076
model.error.cause = { modelPath, toml };
@@ -45,5 +81,77 @@ export async function generate(directory: string) {
4581
result[providerID] = provider.data;
4682
}
4783

84+
for (const pendingModel of extendsModels) {
85+
const [providerID, modelID] = pendingModel.model.extends.from.split("/");
86+
const baseModel = result[providerID]?.models[modelID];
87+
if (baseModel === undefined) {
88+
throw new Error(`Unable to resolve extends.from: ${pendingModel.model.extends.from}`, {
89+
cause: { modelPath: pendingModel.modelPath, toml: pendingModel.model },
90+
});
91+
}
92+
93+
const { extends: extendsConfig, ...overrides } = pendingModel.model;
94+
const merged: Record<string, unknown> = structuredClone(
95+
mergeDeep(baseModel, overrides),
96+
);
97+
98+
for (const omit of extendsConfig.omit ?? []) {
99+
const parts = omit.split(".");
100+
const parents: Array<{
101+
value: Record<string, unknown>;
102+
key: string;
103+
}> = [];
104+
let current = merged;
105+
106+
for (const part of parts.slice(0, -1)) {
107+
const next = current[part];
108+
if (
109+
next === undefined ||
110+
next === null ||
111+
typeof next !== "object" ||
112+
Array.isArray(next)
113+
) {
114+
throw new Error(`Unable to omit missing path: ${omit}`, {
115+
cause: { modelPath: pendingModel.modelPath, toml: pendingModel.model },
116+
});
117+
}
118+
parents.push({ value: current, key: part });
119+
current = next as Record<string, unknown>;
120+
}
121+
122+
const lastPart = parts.at(-1);
123+
if (lastPart === undefined || !(lastPart in current)) {
124+
throw new Error(`Unable to omit missing path: ${omit}`, {
125+
cause: { modelPath: pendingModel.modelPath, toml: pendingModel.model },
126+
});
127+
}
128+
129+
delete current[lastPart];
130+
131+
for (let index = parents.length - 1; index >= 0; index--) {
132+
const parent = parents[index];
133+
const value = parent?.value[parent.key];
134+
if (
135+
value === null ||
136+
value === undefined ||
137+
typeof value !== "object" ||
138+
Array.isArray(value) ||
139+
Object.keys(value).length > 0
140+
) {
141+
break;
142+
}
143+
delete parent.value[parent.key];
144+
}
145+
}
146+
147+
const model = Model.safeParse(merged);
148+
if (!model.success) {
149+
model.error.cause = { modelPath: pendingModel.modelPath, toml: merged };
150+
throw model.error;
151+
}
152+
153+
result[pendingModel.providerID]!.models[pendingModel.modelID] = model.data;
154+
}
155+
48156
return result;
49157
}
Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,2 @@
1-
name = "Claude Haiku 3.5"
2-
family = "claude-haiku"
3-
release_date = "2024-10-22"
4-
last_updated = "2024-10-22"
5-
attachment = true
6-
reasoning = false
7-
temperature = true
8-
knowledge = "2024-07"
9-
tool_call = true
10-
open_weights = false
11-
12-
[cost]
13-
input = 0.80
14-
output = 4.00
15-
cache_read = 0.08
16-
cache_write = 1.00
17-
18-
[limit]
19-
context = 200_000
20-
output = 8_192
21-
22-
[modalities]
23-
input = ["text", "image", "pdf"]
24-
output = ["text"]
1+
[extends]
2+
from = "anthropic/claude-3-5-haiku-20241022"
Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,5 @@
1-
name = "Claude Opus 4.6"
2-
family = "claude-opus"
3-
release_date = "2026-02-05"
4-
last_updated = "2026-03-18"
5-
attachment = true
6-
reasoning = true
7-
temperature = true
8-
tool_call = true
91
structured_output = true
10-
knowledge = "2025-05-31"
11-
open_weights = false
122

13-
[cost]
14-
input = 5.00
15-
output = 25.00
16-
cache_read = 0.50
17-
cache_write = 6.25
18-
19-
[limit]
20-
context = 1_000_000
21-
output = 128_000
22-
23-
[modalities]
24-
input = ["text", "image", "pdf"]
25-
output = ["text"]
3+
[extends]
4+
from = "anthropic/claude-opus-4-6"
5+
omit = ["experimental.modes.fast"]
Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,6 @@
11
name = "Claude Opus 4.6 (EU)"
2-
family = "claude-opus"
3-
release_date = "2026-02-05"
4-
last_updated = "2026-03-18"
5-
attachment = true
6-
reasoning = true
7-
temperature = true
8-
tool_call = true
92
structured_output = true
10-
knowledge = "2025-05-31"
11-
open_weights = false
123

13-
[cost]
14-
input = 5.00
15-
output = 25.00
16-
cache_read = 0.50
17-
cache_write = 6.25
18-
19-
[limit]
20-
context = 1_000_000
21-
output = 128_000
22-
23-
[modalities]
24-
input = ["text", "image", "pdf"]
25-
output = ["text"]
4+
[extends]
5+
from = "anthropic/claude-opus-4-6"
6+
omit = ["experimental.modes.fast"]
Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,6 @@
11
name = "Claude Opus 4.6 (Global)"
2-
family = "claude-opus"
3-
release_date = "2026-02-05"
4-
last_updated = "2026-03-18"
5-
attachment = true
6-
reasoning = true
7-
temperature = true
8-
tool_call = true
92
structured_output = true
10-
knowledge = "2025-05-31"
11-
open_weights = false
123

13-
[cost]
14-
input = 5.00
15-
output = 25.00
16-
cache_read = 0.50
17-
cache_write = 6.25
18-
19-
[limit]
20-
context = 1_000_000
21-
output = 128_000
22-
23-
[modalities]
24-
input = ["text", "image", "pdf"]
25-
output = ["text"]
4+
[extends]
5+
from = "anthropic/claude-opus-4-6"
6+
omit = ["experimental.modes.fast"]

0 commit comments

Comments
 (0)