chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
#!/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;
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Generates Databricks model TOML files from the Foundation Model API endpoint.
|
||||
*
|
||||
* Each Databricks endpoint exposes a model from another provider (Anthropic,
|
||||
* OpenAI, Google, etc.), so the generated TOML uses base_model to inherit
|
||||
* provider-agnostic metadata from models.dev.
|
||||
*
|
||||
* Usage:
|
||||
* DATABRICKS_HOST=<host> DATABRICKS_TOKEN=<pat> bun run databricks:generate
|
||||
* bun run databricks:generate --workspace <host> --token <pat>
|
||||
*
|
||||
* Flags:
|
||||
* --dry-run: Preview changes without writing files
|
||||
* --new-only: Only create new models, skip updating existing ones
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import path from "node:path";
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name: string) => {
|
||||
const i = args.indexOf(`--${name}`);
|
||||
return i !== -1 ? args[i + 1] : undefined;
|
||||
};
|
||||
const dryRun = args.includes("--dry-run");
|
||||
const newOnly = args.includes("--new-only");
|
||||
|
||||
const host = flag("workspace") ?? process.env.DATABRICKS_HOST;
|
||||
const token = flag("token") ?? process.env.DATABRICKS_TOKEN;
|
||||
|
||||
if (!host || !token) {
|
||||
console.error(
|
||||
"Usage: DATABRICKS_HOST=<host> DATABRICKS_TOKEN=<pat> bun run databricks:generate",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const workspace = host.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
||||
const PROVIDERS_DIR = path.join(import.meta.dirname, "..", "..", "..", "providers");
|
||||
const MODEL_METADATA_DIR = path.join(import.meta.dirname, "..", "..", "..", "models");
|
||||
const MODELS_DIR = path.join(PROVIDERS_DIR, "databricks", "models");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FoundationModel = z
|
||||
.object({
|
||||
ai_gateway_v2_supported: z.boolean().optional(),
|
||||
api_types: z.array(z.string()).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const ServedEntity = z
|
||||
.object({
|
||||
foundation_model: FoundationModel.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const Endpoint = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
config: z
|
||||
.object({
|
||||
served_entities: z.array(ServedEntity).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const FoundationModelsResponse = z
|
||||
.object({
|
||||
endpoints: z.array(Endpoint),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Canonical resolution: map a Databricks endpoint name to a models.dev entry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PREFIX_TO_PROVIDER: [string, string][] = [
|
||||
["claude-", "anthropic"],
|
||||
["gpt-", "openai"],
|
||||
["gemini-", "google"],
|
||||
["mistral-", "mistral"],
|
||||
["mixtral-", "mistral"],
|
||||
];
|
||||
|
||||
type Resolution =
|
||||
| { type: "base_model"; from: string }
|
||||
| { type: "inline"; content: string }
|
||||
| null;
|
||||
|
||||
async function resolveCanonical(endpointName: string): Promise<Resolution> {
|
||||
const bare = endpointName.replace(/^databricks-/, "");
|
||||
|
||||
// Models in provider subdirectories may not have provider-agnostic metadata
|
||||
// yet, so inline when no model-only entry exists.
|
||||
if (bare.startsWith("gpt-oss-")) {
|
||||
const p = path.join(PROVIDERS_DIR, "openrouter", "models", "openai", `${bare}.toml`);
|
||||
if (existsSync(p)) {
|
||||
return { type: "inline", content: await readFile(p, "utf8") };
|
||||
}
|
||||
}
|
||||
|
||||
// Meta Llama: "meta-llama-3-3-70b-instruct" → "llama-3.3-70b-instruct"
|
||||
if (bare.startsWith("meta-llama-") || bare.startsWith("llama-")) {
|
||||
const llamaId = bare
|
||||
.replace(/^meta-llama-/, "llama-")
|
||||
.replace(/^(llama-\d+)-(\d+)-/, "$1.$2-");
|
||||
const p = path.join(PROVIDERS_DIR, "llama", "models", `${llamaId}.toml`);
|
||||
const metadata = path.join(MODEL_METADATA_DIR, "meta", `${llamaId}.toml`);
|
||||
if (existsSync(p) && existsSync(metadata)) {
|
||||
return { type: "base_model", from: `meta/${llamaId}` };
|
||||
}
|
||||
}
|
||||
|
||||
for (const [prefix, provider] of PREFIX_TO_PROVIDER) {
|
||||
if (!bare.startsWith(prefix)) continue;
|
||||
|
||||
const exact = path.join(PROVIDERS_DIR, provider, "models", `${bare}.toml`);
|
||||
if (existsSync(exact)) return { type: "base_model", from: `${provider}/${bare}` };
|
||||
|
||||
// Try with hyphens-as-dots in version (e.g. gpt-5-4 → gpt-5.4)
|
||||
const dotted = bare.replace(/^((?:[a-z]+-)+\d+)-(\d)/, "$1.$2");
|
||||
if (dotted !== bare) {
|
||||
const dottedExact = path.join(PROVIDERS_DIR, provider, "models", `${dotted}.toml`);
|
||||
if (existsSync(dottedExact)) return { type: "base_model", from: `${provider}/${dotted}` };
|
||||
}
|
||||
|
||||
// Fuzzy: longest filename that shares a prefix with bare or its dotted form
|
||||
const candidates = [bare, ...(dotted !== bare ? [dotted] : [])];
|
||||
const files: string[] = [];
|
||||
try {
|
||||
for await (const f of new Bun.Glob("*.toml").scan({
|
||||
cwd: path.join(PROVIDERS_DIR, provider, "models"),
|
||||
})) {
|
||||
files.push(f);
|
||||
}
|
||||
} catch {
|
||||
// provider directory may not exist
|
||||
}
|
||||
const match = files
|
||||
.map((f) => f.replace(/\.toml$/, ""))
|
||||
.filter((id) => candidates.some((c) => id.startsWith(c) || c.startsWith(id)))
|
||||
.sort((a, b) => b.length - a.length)[0];
|
||||
if (match) return { type: "base_model", from: `${provider}/${match}` };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatToml(resolution: Resolution, endpointName: string): string {
|
||||
if (resolution?.type === "base_model") {
|
||||
return `base_model = "${resolution.from}"\n`;
|
||||
}
|
||||
if (resolution?.type === "inline") {
|
||||
return resolution.content;
|
||||
}
|
||||
return `# TODO: fill in details for ${endpointName}\nname = "${endpointName}"\n`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const IGNORE_PREFIXES = [
|
||||
"databricks-llama-",
|
||||
"databricks-meta-llama-",
|
||||
"databricks-qwen",
|
||||
"databricks-gemma-",
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log(
|
||||
`${dryRun ? "[DRY RUN] " : ""}${newOnly ? "[NEW ONLY] " : ""}Fetching Databricks foundation-models...`,
|
||||
);
|
||||
|
||||
const url = `https://${workspace}/api/2.0/serving-endpoints:foundation-models`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`Failed to fetch API: ${res.status} ${res.statusText}`);
|
||||
console.error(await res.text().catch(() => ""));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const parsed = FoundationModelsResponse.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
console.error("Invalid API response:", parsed.error.errors);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const endpoints = parsed.data.endpoints.filter(
|
||||
(e) =>
|
||||
!IGNORE_PREFIXES.some((p) => e.name.startsWith(p)) &&
|
||||
e.config?.served_entities?.some(
|
||||
(se) =>
|
||||
se.foundation_model?.ai_gateway_v2_supported === true &&
|
||||
se.foundation_model?.api_types?.includes("mlflow/v1/chat/completions"),
|
||||
),
|
||||
);
|
||||
|
||||
const existingFiles = new Set<string>();
|
||||
try {
|
||||
for await (const f of new Bun.Glob("*.toml").scan({ cwd: MODELS_DIR })) {
|
||||
existingFiles.add(f);
|
||||
}
|
||||
} catch {
|
||||
// directory may not exist yet
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Found ${endpoints.length} models in API, ${existingFiles.size} existing files\n`,
|
||||
);
|
||||
|
||||
const apiModelIds = new Set<string>();
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let unchanged = 0;
|
||||
|
||||
for (const ep of endpoints) {
|
||||
const filename = `${ep.name}.toml`;
|
||||
apiModelIds.add(filename);
|
||||
const filePath = path.join(MODELS_DIR, filename);
|
||||
|
||||
const resolution = await resolveCanonical(ep.name);
|
||||
const newContent = formatToml(resolution, ep.name);
|
||||
const tag = resolution?.type === "base_model" ? `base_model ${resolution.from}` : resolution?.type ?? "stub";
|
||||
|
||||
const existed = existsSync(filePath);
|
||||
if (!existed) {
|
||||
created++;
|
||||
if (dryRun) {
|
||||
console.log(`[DRY RUN] Would create: ${filename} → ${tag}`);
|
||||
} else {
|
||||
await mkdir(MODELS_DIR, { recursive: true });
|
||||
await Bun.write(filePath, newContent);
|
||||
console.log(`Created: ${filename} → ${tag}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (newOnly) {
|
||||
unchanged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingContent = await readFile(filePath, "utf8");
|
||||
if (existingContent === newContent) {
|
||||
unchanged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
updated++;
|
||||
if (dryRun) {
|
||||
console.log(`[DRY RUN] Would update: ${filename} → ${tag}`);
|
||||
} else {
|
||||
await Bun.write(filePath, newContent);
|
||||
console.log(`Updated: ${filename} → ${tag}`);
|
||||
}
|
||||
}
|
||||
|
||||
const orphaned: string[] = [];
|
||||
for (const file of existingFiles) {
|
||||
if (!apiModelIds.has(file)) {
|
||||
orphaned.push(file);
|
||||
console.log(`Warning: Orphaned file (not in API): ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
if (dryRun) {
|
||||
console.log(
|
||||
`Summary: ${created} would be created, ${updated} would be updated, ${unchanged} unchanged, ${orphaned.length} orphaned`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`Summary: ${created} created, ${updated} updated, ${unchanged} unchanged, ${orphaned.length} orphaned`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,505 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
|
||||
import { inferKimiFamily } from "../src/family.js";
|
||||
|
||||
// Friendli API endpoint
|
||||
const API_ENDPOINT = "https://api.friendli.ai/serverless/v1/models";
|
||||
|
||||
// Zod schemas for API response validation
|
||||
const Functionality = z.object({
|
||||
tool_call: z.boolean(),
|
||||
parallel_tool_call: z.boolean(),
|
||||
structured_output: z.boolean(),
|
||||
});
|
||||
|
||||
const Pricing = z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
response_time: z.number(),
|
||||
unit_type: z.enum(["TOKEN", "SECOND"]),
|
||||
});
|
||||
|
||||
const FriendliModel = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
max_completion_tokens: z.number(),
|
||||
context_length: z.number(),
|
||||
functionality: Functionality,
|
||||
pricing: Pricing,
|
||||
hugging_face_url: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
license: z.string().optional(),
|
||||
policy: z.string().optional().nullable(),
|
||||
created: z.number(), // Unix timestamp
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const FriendliResponse = z.object({
|
||||
data: z.array(FriendliModel),
|
||||
});
|
||||
|
||||
// Family inference patterns
|
||||
const familyPatterns: [RegExp, string][] = [
|
||||
[/qwen3/i, "qwen3"],
|
||||
[/deepseek-r1/i, "deepseek-r1"],
|
||||
[/glm-4/i, "glm-4"],
|
||||
[/glm-5/i, "glm"],
|
||||
];
|
||||
|
||||
function inferFamily(modelId: string, modelName: string): string | undefined {
|
||||
const kimiFamily = inferKimiFamily(modelId, modelName);
|
||||
if (kimiFamily !== undefined) return kimiFamily;
|
||||
|
||||
for (const [pattern, family] of familyPatterns) {
|
||||
if (pattern.test(modelId) || pattern.test(modelName)) {
|
||||
return family;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractModelName(fullName: string): string {
|
||||
// "meta-llama/Llama-3.3-70B-Instruct" -> "Llama 3.3 70B Instruct"
|
||||
const parts = fullName.split("/");
|
||||
const modelName = parts.at(-1) ?? fullName;
|
||||
return modelName
|
||||
.replace(/-/g, " ")
|
||||
.replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
}
|
||||
|
||||
// TODO: Replace with functionality.parse_reasoning from API when available
|
||||
function isReasoningModel(modelId: string): boolean {
|
||||
const nonReasoningPatterns = [
|
||||
/qwen3.*instruct/i,
|
||||
];
|
||||
|
||||
for (const pattern of nonReasoningPatterns) {
|
||||
if (pattern.test(modelId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Everything else is reasoning or hybrid reasoning
|
||||
return true;
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
if (n >= 1000) {
|
||||
// Format with underscores for readability (e.g., 131_072)
|
||||
return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, "_");
|
||||
}
|
||||
return n.toString();
|
||||
}
|
||||
|
||||
function timestampToDate(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function getTodayDate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
interface ExistingModel {
|
||||
name?: string;
|
||||
family?: string;
|
||||
attachment?: boolean;
|
||||
reasoning?: boolean;
|
||||
tool_call?: boolean;
|
||||
structured_output?: boolean;
|
||||
temperature?: boolean;
|
||||
knowledge?: string;
|
||||
release_date?: string;
|
||||
last_updated?: string;
|
||||
open_weights?: boolean;
|
||||
interleaved?: boolean | { field: string };
|
||||
status?: string;
|
||||
cost?: {
|
||||
input?: number;
|
||||
output?: number;
|
||||
reasoning?: number;
|
||||
cache_read?: number;
|
||||
cache_write?: number;
|
||||
};
|
||||
limit?: {
|
||||
context?: number;
|
||||
input?: number;
|
||||
output?: number;
|
||||
};
|
||||
modalities?: {
|
||||
input?: string[];
|
||||
output?: string[];
|
||||
};
|
||||
provider?: {
|
||||
npm?: string;
|
||||
api?: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function loadExistingModel(
|
||||
filePath: string,
|
||||
): Promise<ExistingModel | null> {
|
||||
try {
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) {
|
||||
return null;
|
||||
}
|
||||
const toml = await import(filePath, { with: { type: "toml" } }).then(
|
||||
(mod) => mod.default,
|
||||
);
|
||||
return toml as ExistingModel;
|
||||
} catch (e) {
|
||||
console.warn(`Warning: Failed to parse existing file ${filePath}:`, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface MergedModel {
|
||||
name: string;
|
||||
family?: string;
|
||||
attachment: boolean;
|
||||
reasoning: boolean;
|
||||
tool_call: boolean;
|
||||
structured_output?: boolean;
|
||||
temperature: boolean;
|
||||
knowledge?: string;
|
||||
release_date: string;
|
||||
last_updated: string;
|
||||
open_weights: boolean;
|
||||
interleaved?: boolean | { field: string };
|
||||
status?: string;
|
||||
cost?: {
|
||||
input: number;
|
||||
output: number;
|
||||
};
|
||||
limit: {
|
||||
context: number;
|
||||
output: number;
|
||||
};
|
||||
modalities: {
|
||||
input: string[];
|
||||
output: string[];
|
||||
};
|
||||
}
|
||||
|
||||
function mergeModel(
|
||||
apiModel: z.infer<typeof FriendliModel>,
|
||||
existing: ExistingModel | null,
|
||||
): MergedModel {
|
||||
const contextTokens = apiModel.context_length;
|
||||
const outputTokens = apiModel.max_completion_tokens;
|
||||
|
||||
const openWeights = Boolean(apiModel.hugging_face_url);
|
||||
|
||||
const merged: MergedModel = {
|
||||
// Always from API
|
||||
name: extractModelName(apiModel.name),
|
||||
attachment: false, // All Friendli models are text-only currently
|
||||
reasoning: isReasoningModel(apiModel.id),
|
||||
tool_call: apiModel.functionality.tool_call,
|
||||
temperature: true,
|
||||
release_date: timestampToDate(apiModel.created),
|
||||
last_updated: getTodayDate(),
|
||||
open_weights: openWeights,
|
||||
limit: {
|
||||
context: contextTokens,
|
||||
output: outputTokens,
|
||||
},
|
||||
modalities: {
|
||||
input: ["text"],
|
||||
output: ["text"],
|
||||
},
|
||||
};
|
||||
|
||||
// structured_output only if true
|
||||
if (apiModel.functionality.structured_output === true) {
|
||||
merged.structured_output = true;
|
||||
}
|
||||
|
||||
// Cost from API - ONLY include if unit_type is TOKEN
|
||||
if (apiModel.pricing.unit_type === "TOKEN") {
|
||||
merged.cost = {
|
||||
input: apiModel.pricing.input,
|
||||
output: apiModel.pricing.output,
|
||||
};
|
||||
} else {
|
||||
console.log(
|
||||
` Note: ${apiModel.id} uses ${apiModel.pricing.unit_type} pricing - cost section omitted`,
|
||||
);
|
||||
}
|
||||
|
||||
// Preserve from existing OR infer
|
||||
if (existing?.family) {
|
||||
merged.family = existing.family;
|
||||
} else {
|
||||
const inferred = inferFamily(apiModel.id, apiModel.name);
|
||||
if (inferred) {
|
||||
merged.family = inferred;
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve manual fields from existing
|
||||
if (existing?.knowledge) {
|
||||
merged.knowledge = existing.knowledge;
|
||||
}
|
||||
if (existing?.interleaved !== undefined) {
|
||||
merged.interleaved = existing.interleaved;
|
||||
}
|
||||
if (existing?.status !== undefined) {
|
||||
merged.status = existing.status;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function formatToml(model: MergedModel): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Basic fields
|
||||
lines.push(`name = "${model.name.replace(/"/g, '\\"')}"`);
|
||||
if (model.family) {
|
||||
lines.push(`family = "${model.family}"`);
|
||||
}
|
||||
lines.push(`attachment = ${model.attachment}`);
|
||||
lines.push(`reasoning = ${model.reasoning}`);
|
||||
lines.push(`tool_call = ${model.tool_call}`);
|
||||
if (model.structured_output !== undefined) {
|
||||
lines.push(`structured_output = ${model.structured_output}`);
|
||||
}
|
||||
lines.push(`temperature = ${model.temperature}`);
|
||||
if (model.knowledge) {
|
||||
lines.push(`knowledge = "${model.knowledge}"`);
|
||||
}
|
||||
lines.push(`release_date = "${model.release_date}"`);
|
||||
lines.push(`last_updated = "${model.last_updated}"`);
|
||||
lines.push(`open_weights = ${model.open_weights}`);
|
||||
if (model.status) {
|
||||
lines.push(`status = "${model.status}"`);
|
||||
}
|
||||
|
||||
// Interleaved section (if present)
|
||||
if (model.interleaved !== undefined) {
|
||||
lines.push("");
|
||||
if (model.interleaved === true) {
|
||||
lines.push(`interleaved = true`);
|
||||
} else if (typeof model.interleaved === "object") {
|
||||
lines.push(`[interleaved]`);
|
||||
lines.push(`field = "${model.interleaved.field}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Cost section (only if present)
|
||||
if (model.cost) {
|
||||
lines.push("");
|
||||
lines.push(`[cost]`);
|
||||
lines.push(`input = ${model.cost.input}`);
|
||||
lines.push(`output = ${model.cost.output}`);
|
||||
}
|
||||
|
||||
// Limit section
|
||||
lines.push("");
|
||||
lines.push(`[limit]`);
|
||||
lines.push(`context = ${formatNumber(model.limit.context)}`);
|
||||
lines.push(`output = ${formatNumber(model.limit.output)}`);
|
||||
|
||||
// Modalities section
|
||||
lines.push("");
|
||||
lines.push(`[modalities]`);
|
||||
lines.push(
|
||||
`input = [${model.modalities.input.map((m) => `"${m}"`).join(", ")}]`,
|
||||
);
|
||||
lines.push(
|
||||
`output = [${model.modalities.output.map((m) => `"${m}"`).join(", ")}]`,
|
||||
);
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
interface Changes {
|
||||
field: string;
|
||||
oldValue: string;
|
||||
newValue: string;
|
||||
}
|
||||
|
||||
function detectChanges(
|
||||
existing: ExistingModel | null,
|
||||
merged: MergedModel,
|
||||
): Changes[] {
|
||||
if (!existing) return [];
|
||||
|
||||
const changes: Changes[] = [];
|
||||
|
||||
const compare = (field: string, oldVal: unknown, newVal: unknown) => {
|
||||
const oldStr = JSON.stringify(oldVal);
|
||||
const newStr = JSON.stringify(newVal);
|
||||
if (oldStr !== newStr) {
|
||||
changes.push({
|
||||
field,
|
||||
oldValue: formatValue(oldVal),
|
||||
newValue: formatValue(newVal),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const formatValue = (val: unknown): string => {
|
||||
if (typeof val === "number") return formatNumber(val);
|
||||
if (Array.isArray(val)) return `[${val.join(", ")}]`;
|
||||
if (val === undefined) return "(none)";
|
||||
return String(val);
|
||||
};
|
||||
|
||||
compare("name", existing.name, merged.name);
|
||||
compare("family", existing.family, merged.family);
|
||||
compare("attachment", existing.attachment, merged.attachment);
|
||||
compare("reasoning", existing.reasoning, merged.reasoning);
|
||||
compare("tool_call", existing.tool_call, merged.tool_call);
|
||||
compare(
|
||||
"structured_output",
|
||||
existing.structured_output,
|
||||
merged.structured_output,
|
||||
);
|
||||
compare("open_weights", existing.open_weights, merged.open_weights);
|
||||
compare("release_date", existing.release_date, merged.release_date);
|
||||
compare("cost.input", existing.cost?.input, merged.cost?.input);
|
||||
compare("cost.output", existing.cost?.output, merged.cost?.output);
|
||||
compare("limit.context", existing.limit?.context, merged.limit.context);
|
||||
compare("limit.output", existing.limit?.output, merged.limit.output);
|
||||
compare("modalities.input", existing.modalities?.input, merged.modalities.input);
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes("--dry-run");
|
||||
|
||||
const modelsDir = path.join(
|
||||
import.meta.dirname,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"providers",
|
||||
"friendli",
|
||||
"models",
|
||||
);
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`[DRY RUN] Fetching Friendli models from API...`);
|
||||
} else {
|
||||
console.log(`Fetching Friendli models from API...`);
|
||||
}
|
||||
|
||||
// Fetch API data
|
||||
const res = await fetch(API_ENDPOINT);
|
||||
if (!res.ok) {
|
||||
console.error(`Failed to fetch API: ${res.status} ${res.statusText}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const parsed = FriendliResponse.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
console.error("Invalid API response:", parsed.error.errors);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const apiModels = parsed.data.data;
|
||||
|
||||
// Get existing files (recursively)
|
||||
const existingFiles = new Set<string>();
|
||||
try {
|
||||
for await (const file of new Bun.Glob("**/*.toml").scan({
|
||||
cwd: modelsDir,
|
||||
absolute: false,
|
||||
})) {
|
||||
existingFiles.add(file);
|
||||
}
|
||||
} catch {
|
||||
// Directory might not exist yet
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Found ${apiModels.length} models in API, ${existingFiles.size} existing files\n`,
|
||||
);
|
||||
|
||||
// Track API model IDs for orphan detection
|
||||
const apiModelIds = new Set<string>();
|
||||
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let unchanged = 0;
|
||||
|
||||
for (const apiModel of apiModels) {
|
||||
const relativePath = `${apiModel.id}.toml`;
|
||||
const filePath = path.join(modelsDir, relativePath);
|
||||
const dirPath = path.dirname(filePath);
|
||||
|
||||
apiModelIds.add(relativePath);
|
||||
|
||||
const existing = await loadExistingModel(filePath);
|
||||
const merged = mergeModel(apiModel, existing);
|
||||
const tomlContent = formatToml(merged);
|
||||
|
||||
if (existing === null) {
|
||||
created++;
|
||||
if (dryRun) {
|
||||
console.log(`[DRY RUN] Would create: ${relativePath}`);
|
||||
console.log(` name = "${merged.name}"`);
|
||||
if (merged.family) {
|
||||
console.log(` family = "${merged.family}" (inferred)`);
|
||||
}
|
||||
console.log("");
|
||||
} else {
|
||||
await mkdir(dirPath, { recursive: true });
|
||||
await Bun.write(filePath, tomlContent);
|
||||
console.log(`Created: ${relativePath}`);
|
||||
}
|
||||
} else {
|
||||
const changes = detectChanges(existing, merged);
|
||||
|
||||
if (changes.length > 0) {
|
||||
updated++;
|
||||
if (dryRun) {
|
||||
console.log(`[DRY RUN] Would update: ${relativePath}`);
|
||||
} else {
|
||||
await Bun.write(filePath, tomlContent);
|
||||
console.log(`Updated: ${relativePath}`);
|
||||
}
|
||||
for (const change of changes) {
|
||||
console.log(` ${change.field}: ${change.oldValue} → ${change.newValue}`);
|
||||
}
|
||||
console.log("");
|
||||
} else {
|
||||
unchanged++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for orphaned files
|
||||
const orphaned: string[] = [];
|
||||
for (const file of existingFiles) {
|
||||
if (!apiModelIds.has(file)) {
|
||||
orphaned.push(file);
|
||||
console.log(`Warning: Orphaned file (not in API): ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log("");
|
||||
if (dryRun) {
|
||||
console.log(
|
||||
`Summary: ${created} would be created, ${updated} would be updated, ${unchanged} unchanged, ${orphaned.length} orphaned`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`Summary: ${created} created, ${updated} updated, ${unchanged} unchanged, ${orphaned.length} orphaned`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { z } from "zod";
|
||||
import path from "node:path";
|
||||
import { mkdir, rm, readdir, stat } from "node:fs/promises";
|
||||
|
||||
// Helicone public model registry endpoint
|
||||
const DEFAULT_ENDPOINT =
|
||||
"https://jawn.helicone.ai/v1/public/model-registry/models";
|
||||
|
||||
// Zod schemas to validate the Helicone response
|
||||
const Pricing = z
|
||||
.object({
|
||||
prompt: z.number().optional(),
|
||||
completion: z.number().optional(),
|
||||
cacheRead: z.number().optional(),
|
||||
cacheWrite: z.number().optional(),
|
||||
reasoning: z.number().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const Endpoint = z
|
||||
.object({
|
||||
provider: z.string(),
|
||||
providerSlug: z.string().optional(),
|
||||
supportsPtb: z.boolean().optional(),
|
||||
pricing: Pricing.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const ModelItem = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
author: z.string().optional(),
|
||||
contextLength: z.number().optional(),
|
||||
maxOutput: z.number().optional(),
|
||||
trainingDate: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
inputModalities: z.array(z.string()).optional(),
|
||||
outputModalities: z.array(z.string()).optional(),
|
||||
supportedParameters: z.array(z.string()).optional(),
|
||||
endpoints: z.array(Endpoint).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const HeliconeResponse = z
|
||||
.object({
|
||||
data: z.object({
|
||||
models: z.array(ModelItem),
|
||||
total: z.number().optional(),
|
||||
filters: z.any().optional(),
|
||||
}),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
interface ExistingModel {
|
||||
base_model?: string;
|
||||
base_model_omit?: string[];
|
||||
}
|
||||
|
||||
async function loadExistingModel(filePath: string): Promise<ExistingModel | undefined> {
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) return undefined;
|
||||
return await import(filePath, { with: { type: "toml" } }).then(
|
||||
(mod) => mod.default as ExistingModel,
|
||||
);
|
||||
}
|
||||
|
||||
function pickEndpoint(m: z.infer<typeof ModelItem>) {
|
||||
if (!m.endpoints || m.endpoints.length === 0) return undefined;
|
||||
// Prefer endpoint that matches author if available
|
||||
if (m.author) {
|
||||
const match = m.endpoints.find((e) => e.provider === m.author);
|
||||
if (match) return match;
|
||||
}
|
||||
return m.endpoints[0];
|
||||
}
|
||||
|
||||
function boolFromParams(params: string[] | undefined, keys: string[]): boolean {
|
||||
if (!params) return false;
|
||||
const set = new Set(params.map((p) => p.toLowerCase()));
|
||||
return keys.some((k) => set.has(k.toLowerCase()));
|
||||
}
|
||||
|
||||
function sanitizeModalities(values: string[] | undefined): string[] {
|
||||
if (!values) return ["text"]; // default to text
|
||||
const allowed = new Set(["text", "audio", "image", "video", "pdf"]);
|
||||
const out = values.map((v) => v.toLowerCase()).filter((v) => allowed.has(v));
|
||||
return out.length > 0 ? out : ["text"];
|
||||
}
|
||||
|
||||
function formatToml(model: z.infer<typeof ModelItem>, existing: ExistingModel | undefined) {
|
||||
const ep = pickEndpoint(model);
|
||||
const pricing = ep?.pricing;
|
||||
|
||||
const supported = model.supportedParameters ?? [];
|
||||
|
||||
const nowISO = new Date().toISOString().slice(0, 10);
|
||||
const rdRaw = model.trainingDate ? String(model.trainingDate) : nowISO;
|
||||
const releaseDate = rdRaw.slice(0, 10);
|
||||
const lastUpdated = releaseDate;
|
||||
const knowledge = model.trainingDate
|
||||
? String(model.trainingDate).slice(0, 7)
|
||||
: undefined;
|
||||
|
||||
const attachment = false; // Not exposed by Helicone registry
|
||||
const temperature = boolFromParams(supported, ["temperature"]);
|
||||
const toolCall = boolFromParams(supported, ["tools", "tool_choice"]);
|
||||
const reasoning = boolFromParams(supported, [
|
||||
"reasoning",
|
||||
"include_reasoning",
|
||||
]);
|
||||
|
||||
const inputMods = sanitizeModalities(model.inputModalities);
|
||||
const outputMods = sanitizeModalities(model.outputModalities);
|
||||
|
||||
const lines: string[] = [];
|
||||
if (existing?.base_model !== undefined) {
|
||||
lines.push(`base_model = "${existing.base_model}"`);
|
||||
}
|
||||
if (existing?.base_model_omit !== undefined) {
|
||||
lines.push(
|
||||
`base_model_omit = [${existing.base_model_omit.map((item) => `"${item}"`).join(", ")}]`,
|
||||
);
|
||||
}
|
||||
lines.push(`name = "${model.name.replaceAll('"', '\\"')}"`);
|
||||
lines.push(`release_date = "${releaseDate}"`);
|
||||
lines.push(`last_updated = "${lastUpdated}"`);
|
||||
lines.push(`attachment = ${attachment}`);
|
||||
lines.push(`reasoning = ${reasoning}`);
|
||||
lines.push(`temperature = ${temperature}`);
|
||||
lines.push(`tool_call = ${toolCall}`);
|
||||
if (knowledge) lines.push(`knowledge = "${knowledge}"`);
|
||||
lines.push(`open_weights = false`);
|
||||
lines.push("");
|
||||
|
||||
if (
|
||||
pricing &&
|
||||
(pricing.prompt ??
|
||||
pricing.completion ??
|
||||
pricing.cacheRead ??
|
||||
pricing.cacheWrite ??
|
||||
(reasoning && pricing.reasoning)) !== undefined
|
||||
) {
|
||||
lines.push(`[cost]`);
|
||||
if (pricing.prompt !== undefined) lines.push(`input = ${pricing.prompt}`);
|
||||
if (pricing.completion !== undefined)
|
||||
lines.push(`output = ${pricing.completion}`);
|
||||
if (reasoning && pricing.reasoning !== undefined)
|
||||
lines.push(`reasoning = ${pricing.reasoning}`);
|
||||
if (pricing.cacheRead !== undefined)
|
||||
lines.push(`cache_read = ${pricing.cacheRead}`);
|
||||
if (pricing.cacheWrite !== undefined)
|
||||
lines.push(`cache_write = ${pricing.cacheWrite}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
const context = model.contextLength ?? 0;
|
||||
const output = model.maxOutput ?? 4096;
|
||||
lines.push(`[limit]`);
|
||||
lines.push(`context = ${context}`);
|
||||
lines.push(`output = ${output}`);
|
||||
lines.push("");
|
||||
|
||||
lines.push(`[modalities]`);
|
||||
lines.push(`input = [${inputMods.map((m) => `"${m}"`).join(", ")}]`);
|
||||
lines.push(`output = [${outputMods.map((m) => `"${m}"`).join(", ")}]`);
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const endpoint = DEFAULT_ENDPOINT;
|
||||
|
||||
const outDir = path.join(
|
||||
import.meta.dirname,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"providers",
|
||||
"helicone",
|
||||
"models",
|
||||
);
|
||||
|
||||
const res = await fetch(endpoint);
|
||||
if (!res.ok) {
|
||||
console.error(`Failed to fetch registry: ${res.status} ${res.statusText}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const json = await res.json();
|
||||
|
||||
const parsed = HeliconeResponse.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
parsed.error.cause = json;
|
||||
console.error("Invalid Helicone response:", parsed.error.errors);
|
||||
console.error("When parsing:", parsed.error.cause);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const models = parsed.data.data.models;
|
||||
const existing = new Map<string, ExistingModel>();
|
||||
await mkdir(outDir, { recursive: true });
|
||||
for await (const file of new Bun.Glob("**/*.toml").scan({ cwd: outDir })) {
|
||||
const filePath = path.join(outDir, file);
|
||||
const model = await loadExistingModel(filePath);
|
||||
if (model !== undefined) existing.set(file, model);
|
||||
}
|
||||
|
||||
// Clean output directory: remove subfolders and existing TOML files
|
||||
for (const entry of await readdir(outDir)) {
|
||||
const p = path.join(outDir, entry);
|
||||
const st = await stat(p);
|
||||
if (st.isDirectory()) {
|
||||
await rm(p, { recursive: true, force: true });
|
||||
} else if (st.isFile() && entry.endsWith(".toml")) {
|
||||
await rm(p, { force: true });
|
||||
}
|
||||
}
|
||||
let created = 0;
|
||||
|
||||
for (const m of models) {
|
||||
const fileSafeId = m.id.replaceAll("/", "-");
|
||||
const filePath = path.join(outDir, `${fileSafeId}.toml`);
|
||||
const toml = formatToml(m, existing.get(`${fileSafeId}.toml`));
|
||||
await Bun.write(filePath, toml);
|
||||
created++;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Generated ${created} model file(s) under providers/helicone/models/*.toml`,
|
||||
);
|
||||
}
|
||||
|
||||
await main();
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Generates model files from the data in Ollama Cloud's API.
|
||||
*
|
||||
* Ollama Cloud does not provide some data fields, such as release date or
|
||||
* knowledge cutoff. The `family` field provided by Ollama Cloud may not match
|
||||
* the values in family.ts. We expect that when TOML validaton fails, the
|
||||
* maintainer will manually source those data points (such as from other
|
||||
* provider TOML files, or from the internet at large). This script preserves
|
||||
* those fields when overwriting Ollama Cloud's TOML files.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import path from "node:path";
|
||||
|
||||
import type { Model } from "../src/schema";
|
||||
import type { ModelFamily } from "../src/family";
|
||||
|
||||
const modelsDir = path.join(
|
||||
import.meta.dirname,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"providers",
|
||||
"ollama-cloud",
|
||||
"models"
|
||||
);
|
||||
|
||||
function modelFileName(modelName: string): string {
|
||||
return modelName + ".toml";
|
||||
}
|
||||
|
||||
type OllamaModel = Omit<Model, "id" | "description" | "release_date" | "limit"> & {
|
||||
description?: Model["description"];
|
||||
release_date?: Model["release_date"];
|
||||
limit: Omit<Model["limit"], "output"> & { output?: number };
|
||||
};
|
||||
|
||||
type ComparableModel = Pick<Model,
|
||||
| "name"
|
||||
| "attachment"
|
||||
| "reasoning"
|
||||
| "tool_call"
|
||||
| "knowledge"
|
||||
| "open_weights"
|
||||
| "modalities"
|
||||
> & {
|
||||
limit: Pick<Model["limit"], "context">;
|
||||
};
|
||||
|
||||
function normalizeForComparison(model: OllamaModel | Omit<Model, "id">): ComparableModel {
|
||||
return {
|
||||
name: model.name,
|
||||
attachment: model.attachment,
|
||||
reasoning: model.reasoning,
|
||||
tool_call: model.tool_call,
|
||||
knowledge: model.knowledge,
|
||||
open_weights: model.open_weights,
|
||||
limit: { context: model.limit.context },
|
||||
modalities: model.modalities,
|
||||
};
|
||||
}
|
||||
|
||||
const OllamaTagsResponse = z.object({
|
||||
models: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
type OllamaTagsResponse = z.infer<typeof OllamaTagsResponse>;
|
||||
|
||||
const OllamaModelDetails = z.object({
|
||||
modified_at: z.string(),
|
||||
details: z.object({
|
||||
parent_model: z.string(),
|
||||
format: z.string(),
|
||||
family: z.string(),
|
||||
families: z.array(z.string()).nullable(),
|
||||
parameter_size: z.string().transform(Number),
|
||||
quantization_level: z.string(),
|
||||
}),
|
||||
model_info: z.record(z.union([z.string(), z.number()])),
|
||||
capabilities: z.array(z.enum(["thinking", "completion", "tools", "vision"])),
|
||||
});
|
||||
|
||||
type OllamaModelDetails = z.infer<typeof OllamaModelDetails>;
|
||||
|
||||
function generateToml(modelName: string, model: OllamaModel): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`name = "${modelName}"`);
|
||||
lines.push(`family = "${model.family}"`);
|
||||
lines.push(`attachment = ${model.attachment}`);
|
||||
lines.push(`reasoning = ${model.reasoning}`);
|
||||
lines.push(`tool_call = ${model.tool_call}`);
|
||||
if (model.release_date) {
|
||||
lines.push(`release_date = "${model.release_date}"`);
|
||||
}
|
||||
if (model.knowledge) {
|
||||
lines.push(`knowledge = "${model.knowledge}"`);
|
||||
}
|
||||
lines.push(`last_updated = "${model.last_updated}"`);
|
||||
lines.push(`open_weights = ${model.open_weights}`);
|
||||
lines.push("");
|
||||
lines.push("[limit]");
|
||||
lines.push(`context = ${model.limit.context}`);
|
||||
if (model.limit.output !== undefined) {
|
||||
lines.push(`output = ${model.limit.output}`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("[modalities]");
|
||||
lines.push(`input = ${JSON.stringify(model.modalities.input)}`);
|
||||
lines.push(`output = ${JSON.stringify(model.modalities.output)}`);
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
const tagsResponse = await fetch("https://ollama.com/api/tags");
|
||||
if (!tagsResponse.ok) {
|
||||
console.error(
|
||||
`Failed to fetch tags: ${tagsResponse.status} ${tagsResponse.statusText}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const tagsJson = await tagsResponse.json();
|
||||
const tagsParsed = OllamaTagsResponse.safeParse(tagsJson);
|
||||
if (!tagsParsed.success) {
|
||||
console.error("Invalid tags response:", tagsParsed.error.errors);
|
||||
process.exit(1);
|
||||
}
|
||||
const tagsData: OllamaTagsResponse = tagsParsed.data;
|
||||
const modelNames = tagsData.models.map((m) => m.name);
|
||||
|
||||
console.log(`Fetching details for ${modelNames.length} models...`);
|
||||
|
||||
const modelsData: Array<{ name: string; data: OllamaModelDetails }> = [];
|
||||
for (const modelName of modelNames) {
|
||||
const showResponse = await fetch("https://ollama.com/api/show", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: modelName }),
|
||||
});
|
||||
|
||||
if (!showResponse.ok) {
|
||||
console.error(
|
||||
`Failed to fetch details for ${modelName}: ${showResponse.status} ${showResponse.statusText}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const showJson = await showResponse.json();
|
||||
const showParsed = OllamaModelDetails.safeParse(showJson);
|
||||
if (!showParsed.success) {
|
||||
console.error(
|
||||
`Invalid response for ${modelName}:`,
|
||||
showParsed.error.errors
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
modelsData.push({ name: modelName, data: showParsed.data });
|
||||
}
|
||||
|
||||
console.log(`Fetched all models. Syncing files...`);
|
||||
|
||||
const existingFiles = Array.from(new Bun.Glob("*.toml").scanSync(modelsDir));
|
||||
const existingModelNames = new Set(existingFiles.map((f) => f.replace(/\.toml$/, "")));
|
||||
const apiModelNames = new Set(modelNames);
|
||||
|
||||
let deleted = 0;
|
||||
for (const existingName of existingModelNames) {
|
||||
if (!apiModelNames.has(existingName)) {
|
||||
const filePath = path.join(modelsDir, modelFileName(existingName));
|
||||
await Bun.file(filePath).delete();
|
||||
console.log(`Deleted: ${modelFileName(existingName)}`);
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
for (const { name, data } of modelsData) {
|
||||
const fileName = modelFileName(name);
|
||||
const filePath = path.join(modelsDir, fileName);
|
||||
|
||||
let existingData: Omit<Model, "id"> | null = null;
|
||||
try {
|
||||
const existingToml = await Bun.file(filePath).text();
|
||||
existingData = Bun.TOML.parse(existingToml) as Omit<Model, "id">;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
|
||||
const family = existingData?.family ?? (data.details.family as ModelFamily);
|
||||
const contextLength =
|
||||
(data.model_info[`${data.details.family}.context_length`] as number) ?? 0;
|
||||
|
||||
const ollamaModel: OllamaModel = {
|
||||
name,
|
||||
family,
|
||||
attachment: data.capabilities.includes("vision"),
|
||||
reasoning: data.capabilities.includes("thinking"),
|
||||
tool_call: data.capabilities.includes("tools"),
|
||||
release_date: existingData?.release_date,
|
||||
knowledge: existingData?.knowledge,
|
||||
last_updated: new Date().toISOString().slice(0, 10),
|
||||
open_weights: true,
|
||||
modalities: {
|
||||
input: data.capabilities.includes("vision")
|
||||
? ["text", "image"]
|
||||
: ["text"],
|
||||
output: ["text"],
|
||||
},
|
||||
limit: {
|
||||
context: contextLength,
|
||||
output: existingData?.limit.output,
|
||||
},
|
||||
};
|
||||
|
||||
if (existingData) {
|
||||
const normalizedExisting = normalizeForComparison(existingData);
|
||||
const normalizedIncoming = normalizeForComparison(ollamaModel);
|
||||
|
||||
if (Bun.deepEquals(normalizedExisting, normalizedIncoming)) {
|
||||
console.log(`Skipped (no changes): ${fileName}`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await Bun.write(filePath, generateToml(name, ollamaModel));
|
||||
console.log(`Created: ${fileName}`);
|
||||
created++;
|
||||
}
|
||||
|
||||
console.log(`\nDone. Created: ${created}, Skipped: ${skipped}, Deleted: ${deleted}`);
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { main } from "../src/sync/index.js";
|
||||
|
||||
await main(["wandb", ...process.argv.slice(2)]);
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { main } from "../src/sync/index.js";
|
||||
|
||||
await main();
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { generate } from "../src/generate";
|
||||
import path from "path";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
try {
|
||||
const result = await generate(
|
||||
path.join(import.meta.dirname, "..", "..", "..", "providers"),
|
||||
);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (e: any) {
|
||||
if (e instanceof ZodError) {
|
||||
console.error("Validation error:", e.errors);
|
||||
console.error("When parsing:", e.cause);
|
||||
process.exit(1);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
Reference in New Issue
Block a user