Files
2026-07-13 12:28:55 +08:00

403 lines
12 KiB
TypeScript

#!/usr/bin/env bun
import path from "node:path";
import { cp, mkdir, rm, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { mergeDeep } from "remeda";
import { z } from "zod";
import { generate } from "../src/generate.js";
import { AuthoredModel, AuthoredModelShape, Model, Provider } from "../src/schema.js";
const root = path.join(import.meta.dirname, "..", "..", "..");
const providersPath = path.join(root, "providers");
const modelsPath = path.join(root, "models");
const LegacyExtendsModel = AuthoredModelShape
.partial()
.extend({
extends: z
.object({
from: z.string(),
omit: z.array(z.string()).optional(),
})
.strict(),
})
.strict();
const diffOutput = await Bun.$`git diff --name-only HEAD -- providers`.cwd(root).text();
const changedProviderPaths = diffOutput
.split("\n")
.filter(Boolean)
.filter((filePath) => /^providers\/[^/]+\/models\/.+\.toml$/.test(filePath));
if (changedProviderPaths.length === 0) {
process.exit(0);
}
const baselineRoot = path.join(tmpdir(), `models-dev-compare-${Date.now()}`);
await mkdir(baselineRoot, { recursive: true });
try {
const baselineProvidersPath = path.join(baselineRoot, "providers");
await cp(providersPath, baselineProvidersPath, { recursive: true });
const baselineModelsPath = path.join(baselineRoot, "models");
await cp(modelsPath, baselineModelsPath, { recursive: true });
await installModelNamespaceAliases(baselineModelsPath);
for (const filePath of changedProviderPaths) {
const tempFilePath = path.join(baselineRoot, filePath);
const show = Bun.spawn(["git", "show", `HEAD:${filePath}`], {
cwd: root,
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await show.exited;
if (exitCode !== 0) {
await rm(tempFilePath, { force: true });
continue;
}
const contents = await new Response(show.stdout).text();
await mkdir(path.dirname(tempFilePath), { recursive: true });
await writeFile(tempFilePath, contents);
}
const before = await generateForComparison(baselineProvidersPath);
const after = await generate(providersPath);
for (const filePath of changedProviderPaths) {
const match = /^providers\/([^/]+)\/models\/(.+)\.toml$/.exec(filePath);
if (!match) continue;
const [, providerID, modelID] = match;
if (providerID === undefined || modelID === undefined) continue;
const beforeModel = before[providerID]?.models[modelID];
const afterModel = after[providerID]?.models[modelID];
const beforeJson = sortedJson(beforeModel);
const afterJson = sortedJson(afterModel);
if (beforeJson === afterJson) {
continue;
}
const beforeFilePath = path.join(baselineRoot, "before.json");
const afterFilePath = path.join(baselineRoot, "after.json");
await writeFile(beforeFilePath, `${beforeJson}\n`);
await writeFile(afterFilePath, `${afterJson}\n`);
const diff = Bun.spawn(
[
"diff",
"-u",
"-L",
`${filePath} (before)`,
"-L",
`${filePath} (after)`,
beforeFilePath,
afterFilePath,
],
{
stdout: "pipe",
stderr: "pipe",
},
);
const output = await new Response(diff.stdout).text();
process.stdout.write(output);
}
} finally {
await rm(baselineRoot, { recursive: true, force: true });
}
async function installModelNamespaceAliases(directory: string) {
await copyModelAlias(
directory,
"deepseek/deepseek-r1",
"amazon-bedrock/deepseek.r1-v1:0",
);
await copyModelAlias(
directory,
"meta/llama-4-maverick-17b-instruct",
"amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0",
);
await copyModelAlias(
directory,
"meta/llama-4-scout-17b-instruct",
"amazon-bedrock/meta.llama4-scout-17b-instruct-v1:0",
);
await copyModelAlias(
directory,
"meta/llama-3.3-70b-instruct",
"llama/llama-3.3-70b-instruct",
);
await copyModelAliasWithReplacements(
directory,
"openai/gpt-5.5-pro",
"opencode/gpt-5.5-pro",
[
[/release_date = "2026-04-23"/, 'release_date = "2026-04-24"'],
[/last_updated = "2026-04-23"/, 'last_updated = "2026-04-24"'],
[/structured_output = true/, "structured_output = false"],
],
);
await copyModelAlias(
directory,
"tencent/hy3-preview",
"tencent-tokenhub/hy3-preview",
);
}
async function copyModelAlias(directory: string, from: string, to: string) {
return copyModelAliasWithReplacements(directory, from, to, []);
}
async function copyModelAliasWithReplacements(
directory: string,
from: string,
to: string,
replacements: Array<[RegExp, string]>,
) {
const source = path.join(directory, `${from}.toml`);
const target = path.join(directory, `${to}.toml`);
if (!existsSync(source) || existsSync(target)) return;
await mkdir(path.dirname(target), { recursive: true });
if (replacements.length === 0) {
await cp(source, target);
return;
}
let text = await Bun.file(source).text();
for (const [pattern, replacement] of replacements) {
text = text.replace(pattern, replacement);
}
await writeFile(target, text);
}
async function generateForComparison(directory: string) {
for await (const file of new Bun.Glob("**/*.toml").scan({ cwd: directory })) {
const text = await Bun.file(path.join(directory, file)).text();
if (/^\[extends\]/m.test(text)) {
return generateLegacyExtends(directory);
}
}
return generate(directory);
}
async function generateLegacyExtends(directory: string) {
const result: Record<string, Provider> = {};
const pendingModels: Array<{
providerID: string;
modelID: string;
modelPath: string;
model: z.infer<typeof LegacyExtendsModel>;
}> = [];
for await (const providerPath of new Bun.Glob("*/provider.toml").scan({
cwd: directory,
absolute: true,
})) {
const providerID = path.basename(path.dirname(providerPath));
const toml = await import(providerPath, { with: { type: "toml" } }).then(
(mod) => mod.default,
);
toml.id = providerID;
toml.models = {};
const provider = Provider.safeParse(toml);
if (!provider.success) {
provider.error.cause = { providerPath, toml };
throw provider.error;
}
const modelsPath = path.join(directory, providerID, "models");
for await (const modelPath of new Bun.Glob("**/*.toml").scan({
cwd: modelsPath,
absolute: true,
followSymlinks: true,
})) {
const modelID = path.relative(modelsPath, modelPath).slice(0, -5);
const toml = await import(modelPath, { with: { type: "toml" } }).then(
(mod) => mod.default,
);
toml.id = modelID;
if (toml.extends !== undefined) {
const model = LegacyExtendsModel.safeParse(toml);
if (!model.success) {
model.error.cause = { modelPath, toml };
throw model.error;
}
pendingModels.push({
providerID,
modelID,
modelPath,
model: model.data,
});
continue;
}
const model = AuthoredModel.safeParse(toml);
if (!model.success) {
model.error.cause = { modelPath, toml };
throw model.error;
}
provider.data.models[modelID] = normalizeModelCost(model.data);
}
result[providerID] = provider.data;
}
const nameToProviderID = new Map<string, string>();
for (const provider of Object.values(result)) {
const nameKey = provider.name.toLowerCase();
const existingID = nameToProviderID.get(nameKey);
if (existingID !== undefined) {
throw new Error(
`Duplicate provider name "${provider.name}" used by both "${existingID}" and "${provider.id}". Provider names must be unique.`,
);
}
nameToProviderID.set(nameKey, provider.id);
}
for (const pendingModel of pendingModels) {
const [providerID, ...modelParts] = pendingModel.model.extends.from.split("/");
const modelID = modelParts.join("/");
if (providerID === undefined) {
throw new Error(`Invalid legacy extends.from: ${pendingModel.model.extends.from}`);
}
const baseModel = result[providerID]?.models[modelID];
if (baseModel === undefined) {
throw new Error(`Unable to resolve legacy extends.from: ${pendingModel.model.extends.from}`, {
cause: { modelPath: pendingModel.modelPath, toml: pendingModel.model },
});
}
const { extends: extendsConfig, ...overrides } = pendingModel.model;
const { reasoning_options: _reasoningOptions, ...inherited } = baseModel;
const merged: Record<string, unknown> = structuredClone(
mergeDeep(inherited, overrides),
);
applyOmit(merged, extendsConfig.omit ?? []);
const model = Model.safeParse(normalizeCost(merged));
if (!model.success) {
model.error.cause = { modelPath: pendingModel.modelPath, toml: merged };
throw model.error;
}
result[pendingModel.providerID]!.models[pendingModel.modelID] = model.data;
}
return result;
}
function normalizeModelCost(model: z.infer<typeof AuthoredModel>): Model {
return normalizeCost(model) as Model;
}
function normalizeCost(model: Record<string, unknown>) {
const cost = model.cost;
if (cost === undefined || cost === null || typeof cost !== "object" || Array.isArray(cost)) {
return model;
}
const tiers = (cost as { tiers?: unknown }).tiers;
if (!Array.isArray(tiers) || tiers.length !== 1) {
return model;
}
const contextOver200k = tiers.find((tier) => {
if (tier === null || typeof tier !== "object" || Array.isArray(tier)) return false;
const tierConfig = (tier as { tier?: unknown }).tier;
if (tierConfig === null || typeof tierConfig !== "object" || Array.isArray(tierConfig)) return false;
const type = (tierConfig as { type?: unknown }).type;
const size = (tierConfig as { size?: unknown }).size;
return (
(type === undefined || type === "context") &&
typeof size === "number" &&
size >= 200_000
);
});
if (contextOver200k === undefined) {
return model;
}
const { tier: _tier, ...legacyCost } = contextOver200k as Record<string, unknown>;
return {
...model,
cost: {
...(cost as Record<string, unknown>),
context_over_200k: legacyCost,
},
};
}
function applyOmit(target: Record<string, unknown>, paths: string[]) {
omitLoop: for (const omit of paths) {
const parts = omit.split(".");
const parents: Array<{
value: Record<string, unknown>;
key: string;
}> = [];
let current = target;
for (const part of parts.slice(0, -1)) {
const next = current[part];
if (
next === undefined ||
next === null ||
typeof next !== "object" ||
Array.isArray(next)
) {
continue omitLoop;
}
parents.push({ value: current, key: part });
current = next as Record<string, unknown>;
}
const lastPart = parts.at(-1);
if (lastPart === undefined || !(lastPart in current)) {
continue;
}
delete current[lastPart];
for (let index = parents.length - 1; index >= 0; index--) {
const parent = parents[index];
if (parent === undefined) continue;
const value = parent.value[parent.key];
if (
value === null ||
value === undefined ||
typeof value !== "object" ||
Array.isArray(value) ||
Object.keys(value).length > 0
) {
break;
}
delete parent.value[parent.key];
}
}
}
function sortedJson(value: unknown) {
return JSON.stringify(sortJson(value), null, 2);
}
function sortJson(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(sortJson);
}
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, item]) => [key, sortJson(item)]),
);
}
return value;
}