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

501 lines
13 KiB
TypeScript

#!/usr/bin/env bun
import { z } from "zod";
import path from "node:path";
import { readdir, mkdir, unlink, rmdir } from "node:fs/promises";
import { ModelFamilyValues } from "../../../packages/core/src/family.js";
// Jiekou.AI API endpoint
const API_ENDPOINT = "https://api.jiekou.ai/openai/models";
const SKIP_MODELS = [
"google/gemma-*",
"meta-llama/*",
"mistralai/*",
"openai/gpt-oss-*",
"qwen/qwen-2.5-*",
"qwen/qwen2.5-*",
"qwen/qwen-mt-plus",
"sao10k/*",
"Sao10K/*",
"claude-3-*",
"deepseek/deepseek-ocr-*",
"doubao-*",
"gemini-2.0*",
"gpt-4*",
"gpt-5.1-chat-latest",
"gpt-5.2-chat-latest",
"gpt-5",
"grok-3-mini",
"grok-3",
"gryphe/*",
"nova-2-Lite",
"o1-mini",
"o1",
"zai-org/glm-ocr"
]
// Zod schemas for API response validation
const JiekouModel = z
.object({
id: z.string(),
created: z.number(),
object: z.string(),
owned_by: z.string(),
title: z.string().optional(),
display_name: z.string().optional(),
description: z.string().optional(),
input_token_price_per_m: z.number(),
output_token_price_per_m: z.number(),
context_size: z.number(),
max_output_tokens: z.number(),
features: z.array(z.string()).optional(),
input_modalities: z.array(z.string()).optional(),
output_modalities: z.array(z.string()).optional(),
})
.passthrough();
const JiekouResponse = z
.object({
data: z.array(JiekouModel),
})
.passthrough();
// Check if model ID should be skipped based on SKIP_MODELS patterns
function shouldSkipModel(modelId: string): boolean {
const lowerModelId = modelId.toLowerCase();
for (const pattern of SKIP_MODELS) {
const lowerPattern = pattern.toLowerCase();
if (lowerPattern.endsWith("/*")) {
// Prefix match: "gemma/*" matches "google/gemma-3-12b-it"
const prefix = lowerPattern.slice(0, -2);
if (lowerModelId.includes(prefix + "/") || lowerModelId.startsWith(prefix + "/")) {
return true;
}
} else if (lowerPattern.endsWith("*")) {
// Wildcard suffix: "gpt-4*" matches "gpt-4o", "gpt-4.1"
const prefix = lowerPattern.slice(0, -1);
if (lowerModelId.startsWith(prefix) || lowerModelId.includes("/" + prefix)) {
return true;
}
} else {
// Exact match
if (lowerModelId === lowerPattern || lowerModelId.endsWith("/" + lowerPattern)) {
return true;
}
}
}
return false;
}
// Open-source model patterns
const OPEN_WEIGHTS_PATTERNS = [
"deepseek",
"qwen",
"llama",
"gemma",
"mistral",
"phi",
"yi",
"baichuan",
"glm",
"ernie",
"minimax",
];
function isOpenWeights(modelId: string): boolean {
const lowerModelId = modelId.toLowerCase();
return OPEN_WEIGHTS_PATTERNS.some((pattern) =>
lowerModelId.includes(pattern)
);
}
function matchesFamily(target: string, family: string): boolean {
const targetLower = target.toLowerCase();
const familyLower = family.toLowerCase();
let familyIdx = 0;
for (let i = 0; i < targetLower.length && familyIdx < familyLower.length; i++) {
if (targetLower[i] === familyLower[familyIdx]) {
familyIdx++;
}
}
return familyIdx === familyLower.length;
}
function inferFamily(modelId: string): string | undefined {
const sortedFamilies = [...ModelFamilyValues].sort(
(a, b) => b.length - a.length
);
// Remove prefix like "deepseek/", "qwen/", etc.
const baseName = modelId.includes("/")
? modelId.split("/").pop()!
: modelId;
for (const family of sortedFamilies) {
if (matchesFamily(baseName, family)) {
return family;
}
}
return undefined;
}
function formatNumber(n: number): string {
if (n >= 1000) {
return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, "_");
}
return n.toString();
}
function getYearMonth(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
}
interface ProcessedModel {
name: string;
family?: string;
release_date: string;
last_updated: string;
attachment: boolean;
reasoning: boolean;
temperature: boolean;
tool_call: boolean;
structured_output: boolean;
open_weights: boolean;
cost: {
input: number;
output: number;
};
limit: {
context: number;
output: number;
};
modalities: {
input: string[];
output: string[];
};
}
function processModel(apiModel: z.infer<typeof JiekouModel>): ProcessedModel {
const features = apiModel.features ?? [];
const inputModalities = apiModel.input_modalities ?? ["text"];
const outputModalities = apiModel.output_modalities ?? ["text"];
// Convert price: divide by 10000 (from 0.0001 USD to USD)
const inputCost = apiModel.input_token_price_per_m / 10000;
const outputCost = apiModel.output_token_price_per_m / 10000;
// Features mapping (ignore "serverless")
const hasToolCall = features.includes("function-calling");
const hasStructuredOutput = features.includes("structured-outputs");
const hasReasoning = features.includes("reasoning");
// Attachment: true if image/video/audio in input modalities
const hasAttachment =
inputModalities.includes("image") ||
inputModalities.includes("video") ||
inputModalities.includes("audio");
const yearMonth = getYearMonth();
return {
name: apiModel.id,
family: inferFamily(apiModel.id),
release_date: yearMonth,
last_updated: yearMonth,
attachment: hasAttachment,
reasoning: hasReasoning,
temperature: true,
tool_call: hasToolCall,
structured_output: hasStructuredOutput,
open_weights: isOpenWeights(apiModel.id),
cost: {
input: inputCost,
output: outputCost,
},
limit: {
context: apiModel.context_size,
output: apiModel.max_output_tokens,
},
modalities: {
input: inputModalities,
output: outputModalities,
},
};
}
function formatToml(model: ProcessedModel): string {
const lines: string[] = [];
// Basic fields
lines.push(`name = "${model.name.replace(/"/g, '\\"')}"`);
if (model.family) {
lines.push(`family = "${model.family}"`);
}
lines.push(`release_date = "${model.release_date}"`);
lines.push(`last_updated = "${model.last_updated}"`);
lines.push(`attachment = ${model.attachment}`);
lines.push(`reasoning = ${model.reasoning}`);
lines.push(`temperature = ${model.temperature}`);
lines.push(`tool_call = ${model.tool_call}`);
lines.push(`structured_output = ${model.structured_output}`);
lines.push(`open_weights = ${model.open_weights}`);
// Cost section
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";
}
function getFilePath(
modelsDir: string,
modelId: string
): { filePath: string; dirPath: string } {
if (modelId.includes("/")) {
// e.g., "deepseek/deepseek-r1-0528" -> models/deepseek/deepseek-r1-0528.toml
const parts = modelId.split("/");
const fileName = `${parts[parts.length - 1]}.toml`;
const subDir = parts.slice(0, -1).join("/");
const dirPath = path.join(modelsDir, subDir);
const filePath = path.join(dirPath, fileName);
return { filePath, dirPath };
} else {
// e.g., "claude-opus-4-6" -> models/claude-opus-4-6.toml
return {
filePath: path.join(modelsDir, `${modelId}.toml`),
dirPath: modelsDir,
};
}
}
async function ensureDir(dirPath: string): Promise<void> {
try {
await mkdir(dirPath, { recursive: true });
} catch {
// Directory already exists
}
}
async function getAllExistingFiles(
modelsDir: string
): Promise<Set<string>> {
const files = new Set<string>();
async function scanDir(dir: string, prefix: string = ""): Promise<void> {
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
await scanDir(
path.join(dir, entry.name),
prefix ? `${prefix}/${entry.name}` : entry.name
);
} else if (entry.name.endsWith(".toml")) {
const relativePath = prefix
? `${prefix}/${entry.name}`
: entry.name;
files.add(relativePath);
}
}
} catch {
// Directory might not exist
}
}
await scanDir(modelsDir);
return files;
}
// Extract model name from TOML file content
function extractModelName(content: string): string | null {
const match = content.match(/^name\s*=\s*"([^"]+)"/m);
return match ? match[1] ?? null : null;
}
// Delete existing TOML files that match SKIP_MODELS
async function cleanupSkippedModels(
modelsDir: string,
existingFiles: Set<string>,
dryRun: boolean
): Promise<number> {
let deleted = 0;
for (const relativePath of existingFiles) {
const filePath = path.join(modelsDir, relativePath);
try {
const file = Bun.file(filePath);
const content = await file.text();
const modelName = extractModelName(content);
if (modelName && shouldSkipModel(modelName)) {
deleted++;
if (dryRun) {
console.log(`[DRY RUN] Would delete (matches SKIP_MODELS): ${relativePath}`);
} else {
await unlink(filePath);
console.log(`Deleted (matches SKIP_MODELS): ${relativePath}`);
// Try to remove parent directory if empty
const dirPath = path.dirname(filePath);
if (dirPath !== modelsDir) {
try {
await rmdir(dirPath);
} catch {
// Directory not empty, ignore
}
}
}
}
} catch {
// File read error, skip
}
}
return deleted;
}
async function main() {
const args = process.argv.slice(2);
const dryRun = args.includes("--dry-run");
const modelsDir = path.join(import.meta.dirname, "..", "models");
if (dryRun) {
console.log("[DRY RUN] Fetching Jiekou.AI models from API...");
} else {
console.log("Fetching Jiekou.AI 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 = JiekouResponse.safeParse(json);
if (!parsed.success) {
console.error("Invalid API response:", parsed.error.errors);
process.exit(1);
}
const apiModels = parsed.data.data;
// Get existing files
const existingFiles = await getAllExistingFiles(modelsDir);
console.log(
`Found ${apiModels.length} models in API, ${existingFiles.size} existing files\n`
);
// First, clean up existing files that match SKIP_MODELS
const deleted = await cleanupSkippedModels(modelsDir, existingFiles, dryRun);
if (deleted > 0) {
console.log("");
}
// Refresh existing files after cleanup
const remainingFiles = await getAllExistingFiles(modelsDir);
// Track API model IDs for orphan detection
const apiModelPaths = new Set<string>();
let created = 0;
let skipped = 0;
let unchanged = 0;
for (const apiModel of apiModels) {
// Check if model should be skipped based on SKIP_MODELS patterns
if (shouldSkipModel(apiModel.id)) {
skipped++;
if (dryRun) {
console.log(`[DRY RUN] Skipped (matches SKIP_MODELS): ${apiModel.id}`);
}
continue;
}
const processed = processModel(apiModel);
const { filePath, dirPath } = getFilePath(modelsDir, apiModel.id);
// Build relative path for tracking
const relativePath = apiModel.id.includes("/")
? `${apiModel.id.split("/").slice(0, -1).join("/")}/${apiModel.id.split("/").pop()}.toml`
: `${apiModel.id}.toml`;
apiModelPaths.add(relativePath);
// Check if file exists - if so, skip it (don't overwrite)
const fileExists = remainingFiles.has(relativePath);
if (fileExists) {
unchanged++;
continue;
}
// Create new file
const tomlContent = formatToml(processed);
created++;
if (dryRun) {
console.log(`[DRY RUN] Would create: ${relativePath}`);
console.log(` name = "${processed.name}"`);
if (processed.family) {
console.log(` family = "${processed.family}" (inferred)`);
}
console.log("");
} else {
await ensureDir(dirPath);
await Bun.write(filePath, tomlContent);
console.log(`Created: ${relativePath}`);
}
}
// Check for orphaned files
const orphaned: string[] = [];
for (const file of remainingFiles) {
if (!apiModelPaths.has(file)) {
orphaned.push(file);
console.log(`Warning: Orphaned file (not in API): ${file}`);
}
}
// Summary
console.log("");
if (dryRun) {
console.log(
`Summary: ${deleted} would be deleted, ${created} would be created, ${unchanged} unchanged, ${skipped} skipped, ${orphaned.length} orphaned`
);
} else {
console.log(
`Summary: ${deleted} deleted, ${created} created, ${unchanged} unchanged, ${skipped} skipped, ${orphaned.length} orphaned`
);
}
}
await main();