chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
type Modality = "text" | "audio" | "image" | "video" | "pdf";
|
||||
|
||||
export interface DescriptionInput {
|
||||
id?: string;
|
||||
providerId?: string;
|
||||
name?: string;
|
||||
family?: string;
|
||||
reasoning?: boolean;
|
||||
tool_call?: boolean;
|
||||
structured_output?: boolean;
|
||||
open_weights?: boolean;
|
||||
status?: "alpha" | "beta" | "deprecated";
|
||||
limit?: {
|
||||
context?: number;
|
||||
input?: number;
|
||||
output?: number;
|
||||
};
|
||||
modalities?: {
|
||||
input?: Modality[];
|
||||
output?: Modality[];
|
||||
};
|
||||
}
|
||||
|
||||
export function describeModel(model: DescriptionInput) {
|
||||
const id = model.id ?? "";
|
||||
const name = model.name ?? humanizeID(id);
|
||||
const lab = labID(id, model.providerId);
|
||||
const target = `${id} ${name} ${model.family ?? ""}`.toLowerCase();
|
||||
const input = model.modalities?.input ?? [];
|
||||
const output = model.modalities?.output ?? [];
|
||||
const multimodal = input.some((value) => value !== "text");
|
||||
const fast = has(target, /\b(flash|lite|mini|nano|small|fast|highspeed|ultraspeed|instant|turbo|micro)\b/);
|
||||
const frontier = has(target, /\b(pro|opus|max|ultra|premier|large|frontier|maverick|behemoth|5\.5|5\.4|5\.3|4\.8|4\.7|4\.6|m3)\b/);
|
||||
const free = has(target, /(^|[^a-z])free([^a-z]|$)|:free\b/);
|
||||
const preview = model.status === "beta" || has(target, /\b(preview|beta|experimental)\b/);
|
||||
|
||||
if (model.status === "deprecated") {
|
||||
return "Legacy model retained for compatibility with older integrations";
|
||||
}
|
||||
|
||||
const special = specialDescription({
|
||||
id,
|
||||
lab,
|
||||
name,
|
||||
target,
|
||||
input,
|
||||
output,
|
||||
multimodal,
|
||||
fast,
|
||||
frontier,
|
||||
free,
|
||||
preview,
|
||||
model,
|
||||
});
|
||||
if (special !== undefined) return special;
|
||||
|
||||
if (free) {
|
||||
return "Free provider route for experiments, demos, and cost-sensitive chat workloads";
|
||||
}
|
||||
|
||||
if (preview) {
|
||||
return "Preview model for early access evaluation, prototyping, and compatibility testing";
|
||||
}
|
||||
|
||||
if (model.reasoning === true) {
|
||||
if (fast) {
|
||||
return "Efficient reasoning model for fast analysis, coding help, and agent workflows";
|
||||
}
|
||||
if (frontier) {
|
||||
return "Flagship reasoning model for complex planning, coding, math, and tool use";
|
||||
}
|
||||
return "Reasoning model for deliberate analysis, multi-step problem solving, and tool use";
|
||||
}
|
||||
|
||||
if (multimodal) {
|
||||
return "Multimodal model for analyzing text, images, documents, and rich media";
|
||||
}
|
||||
|
||||
if (model.open_weights === true) {
|
||||
return "Open-weight instruction model for adaptable chat and self-hosted production workloads";
|
||||
}
|
||||
|
||||
if (fast) {
|
||||
return "Fast chat model for everyday assistance, extraction, and high-volume workloads";
|
||||
}
|
||||
|
||||
if (frontier) {
|
||||
return "Flagship chat model for high-quality writing, analysis, coding, and tools";
|
||||
}
|
||||
|
||||
if (model.tool_call === true) {
|
||||
return "Tool-capable chat model for instruction following and agentic application workflows";
|
||||
}
|
||||
|
||||
return "General-purpose chat model for instruction following, writing, and analysis";
|
||||
}
|
||||
|
||||
interface SpecialDescriptionContext {
|
||||
id: string;
|
||||
lab: string | undefined;
|
||||
name: string;
|
||||
target: string;
|
||||
input: Modality[];
|
||||
output: Modality[];
|
||||
multimodal: boolean;
|
||||
fast: boolean;
|
||||
frontier: boolean;
|
||||
free: boolean;
|
||||
preview: boolean;
|
||||
model: DescriptionInput;
|
||||
}
|
||||
|
||||
function specialDescription(context: SpecialDescriptionContext) {
|
||||
const { lab, target, input, output, multimodal, fast, frontier, model } = context;
|
||||
|
||||
if (has(target, /\b(auto|router|route)\b/) && lab !== "openrouter") {
|
||||
return "Automatic model router for matching prompts to suitable backends and budgets";
|
||||
}
|
||||
|
||||
if (has(target, /\b(embed|embedding|e5)\b/)) {
|
||||
return "Embedding model for semantic search, retrieval, clustering, and ranking pipelines";
|
||||
}
|
||||
if (has(target, /\b(rerank|reranker)\b/)) {
|
||||
return "Reranking model for improving retrieval quality in search and recommendation systems";
|
||||
}
|
||||
if (has(target, /\b(safety|guard|moderation|safeguard)\b/)) {
|
||||
return "Safety model for policy screening, moderation, and risk-aware routing workflows";
|
||||
}
|
||||
if (has(target, /\b(ocr|document-ocr)\b/)) {
|
||||
return "OCR model for extracting structured text from documents and screenshots";
|
||||
}
|
||||
if (has(target, /\b(translate|translation|mt)\b/)) {
|
||||
return "Translation model for multilingual conversion, localization, and cross-language workflows";
|
||||
}
|
||||
if (has(target, /\b(asr|stt|transcribe|transcription|whisper)\b/)) {
|
||||
return "Speech transcription model for accurate audio-to-text and captioning workflows";
|
||||
}
|
||||
if (has(target, /\bomni\b/)) {
|
||||
if (has(target, /\bqwen\b/)) return qwenDescription(context);
|
||||
if (has(target, /\bmimo\b/)) return mimoDescription(context);
|
||||
return "Omni-modal model for text, vision, audio, and multimodal agent tasks";
|
||||
}
|
||||
if (has(target, /\b(tts|speech|voice|voiceclone|voicedesign)\b/) || (input.includes("text") && output.includes("audio"))) {
|
||||
return "Speech generation model for controllable voice, narration, and audio delivery";
|
||||
}
|
||||
if (has(target, /\b(image|imagine|imagen|flux|sdxl|stable-diffusion)\b/) || output.includes("image")) {
|
||||
return "Image model for prompt-driven generation, editing, and visual design workflows";
|
||||
}
|
||||
if (has(target, /\b(video|veo|sora|ray|hailuo|kling)\b/) || output.includes("video")) {
|
||||
return "Video model for prompt-guided generation, editing, and motion workflows";
|
||||
}
|
||||
|
||||
if (lab === "openai" || has(target, /\b(gpt|openai|whisper)\b|(^|[^a-z])o\d/)) {
|
||||
return openAIDescription(context);
|
||||
}
|
||||
if (lab === "anthropic" || has(target, /\bclaude\b/)) return anthropicDescription(context);
|
||||
if (lab === "google" || has(target, /\b(gemini|gemma)\b/)) return googleDescription(context);
|
||||
if (lab === "mistral" || has(target, /\b(mistral|codestral|devstral|magistral|pixtral|ministral)\b/)) {
|
||||
return mistralDescription(context);
|
||||
}
|
||||
if (lab === "alibaba" || has(target, /\b(qwen|qwq)\b/)) return qwenDescription(context);
|
||||
if (lab === "deepseek" || has(target, /\bdeepseek\b/)) return deepSeekDescription(context);
|
||||
if (lab === "xai" || has(target, /\bgrok\b/)) return xaiDescription(context);
|
||||
if (lab === "minimax" || has(target, /\bminimax\b/)) return miniMaxDescription(context);
|
||||
if (lab === "nvidia" || has(target, /\bnemotron\b/)) return nvidiaDescription(context);
|
||||
if (lab === "meta" || has(target, /\bllama\b/)) return metaDescription(context);
|
||||
if (lab === "zhipuai" || lab === "zai" || has(target, /\bglm\b/)) return glmDescription(context);
|
||||
if (lab === "moonshotai" || has(target, /\bkimi\b/)) return kimiDescription(context);
|
||||
if (lab === "xiaomi" || has(target, /\bmimo\b/)) return mimoDescription(context);
|
||||
if (lab === "stepfun" || has(target, /\bstep[-\s]?\d/)) return stepDescription(context);
|
||||
if (lab === "cohere" || has(target, /\b(command|north)\b/)) return cohereDescription(context);
|
||||
if (lab === "perplexity" || has(target, /\bsonar\b/)) return perplexityDescription(context);
|
||||
if (lab === "sarvam" || has(target, /\bsarvam\b/)) return sarvamDescription(context);
|
||||
if (lab === "tencent" || has(target, /\bhy3|hunyuan\b/)) return tencentDescription(context);
|
||||
if (lab === "sakana" || has(target, /\bfugu\b/)) return sakanaDescription(context);
|
||||
if (lab === "deepreinforce" || has(target, /\bornith\b/)) return ornithDescription(context);
|
||||
|
||||
if (has(target, /\b(coder|coding|code|software|dev)\b/)) {
|
||||
return "Coding model for repository understanding, refactors, and agentic engineering tasks";
|
||||
}
|
||||
if (multimodal && model.reasoning === true) {
|
||||
return "Multimodal reasoning model for visual analysis, planning, and tool use";
|
||||
}
|
||||
if (frontier) {
|
||||
return "Flagship model for demanding analysis, coding, and production agent workflows";
|
||||
}
|
||||
if (fast) {
|
||||
return "Efficient model for low-latency assistance, extraction, and routine automation";
|
||||
}
|
||||
}
|
||||
|
||||
function openAIDescription({ id, name, target, fast, frontier }: SpecialDescriptionContext) {
|
||||
const modelName = `${id} ${name}`.toLowerCase();
|
||||
|
||||
if (has(modelName, /\bcodex\b/)) {
|
||||
return "Coding-optimized GPT model for repository edits, reviews, and agentic software work";
|
||||
}
|
||||
if (has(target, /\bdeep[-\s]?research\b/)) {
|
||||
return "Research model for long-horizon investigation, synthesis, and analytical reports";
|
||||
}
|
||||
if (has(target, /(^|[^a-z])o\d|reasoning/)) {
|
||||
return "O-series reasoning model for hard analysis, math, coding, and planning";
|
||||
}
|
||||
if (has(target, /\bgpt-oss\b/)) {
|
||||
return "Open-weight GPT model for self-hosted reasoning and instruction-following workloads";
|
||||
}
|
||||
if (has(target, /\bchat\b/)) {
|
||||
return "Chat-tuned GPT model for conversational assistance, writing, and tool workflows";
|
||||
}
|
||||
if (fast) {
|
||||
return "Compact GPT model for low-latency assistance and high-volume workloads";
|
||||
}
|
||||
if (frontier) {
|
||||
return "Frontier GPT model for professional reasoning, coding, and multimodal work";
|
||||
}
|
||||
return "GPT model for general reasoning, writing, coding, and tool-assisted tasks";
|
||||
}
|
||||
|
||||
function anthropicDescription({ target, fast }: SpecialDescriptionContext) {
|
||||
if (has(target, /\bopus\b/)) {
|
||||
return "Flagship Claude model for deep reasoning, coding, and long-horizon agents";
|
||||
}
|
||||
if (has(target, /\bsonnet\b/)) {
|
||||
return "Balanced Claude model for coding, analysis, agent workflows, and cost control";
|
||||
}
|
||||
if (has(target, /\bhaiku\b/)) {
|
||||
return "Fast Claude model for responsive assistance, classification, and lightweight agents";
|
||||
}
|
||||
if (has(target, /\bfable\b/)) {
|
||||
return "Claude model for creative writing, analysis, and controlled agent workflows";
|
||||
}
|
||||
return fast
|
||||
? "Efficient Claude model for quick analysis, writing, and tool use"
|
||||
: "Claude model for careful reasoning, writing, coding, and tool use";
|
||||
}
|
||||
|
||||
function googleDescription({ target, fast, frontier, multimodal }: SpecialDescriptionContext) {
|
||||
if (has(target, /\bgemma\b/)) {
|
||||
return "Open Gemma instruction model for efficient chat and self-hosted deployments";
|
||||
}
|
||||
if (has(target, /\bflash[-\s]?lite\b/)) {
|
||||
return "Low-latency Gemini model for high-volume multimodal and agent workloads";
|
||||
}
|
||||
if (has(target, /\bflash\b/)) {
|
||||
return "Fast Gemini model balancing multimodal reasoning, tool use, and cost";
|
||||
}
|
||||
if (has(target, /\bpro\b/) || frontier) {
|
||||
return "Advanced Gemini model for complex reasoning, coding, and multimodal analysis";
|
||||
}
|
||||
if (multimodal) {
|
||||
return "Gemini multimodal model for text, image, audio, video, and document tasks";
|
||||
}
|
||||
return fast
|
||||
? "Efficient Gemini model for quick assistance and high-volume automation"
|
||||
: "Gemini model for general assistance, reasoning, and multimodal workflows";
|
||||
}
|
||||
|
||||
function mistralDescription({ target, fast, frontier }: SpecialDescriptionContext) {
|
||||
if (has(target, /\bcodestral\b/)) {
|
||||
return "Mistral coding model for code completion, generation, and developer workflows";
|
||||
}
|
||||
if (has(target, /\bdevstral\b/)) {
|
||||
return "Mistral coding agent model for repository tasks and software engineering workflows";
|
||||
}
|
||||
if (has(target, /\bmagistral\b/)) {
|
||||
return "Mistral reasoning model for transparent analysis, math, and complex decisions";
|
||||
}
|
||||
if (has(target, /\bpixtral\b/)) {
|
||||
return "Mistral vision-language model for image understanding and multimodal chat";
|
||||
}
|
||||
if (has(target, /\bministral\b/)) {
|
||||
return "Compact Mistral model for edge, latency-sensitive, and cost-efficient workloads";
|
||||
}
|
||||
if (frontier || has(target, /\blarge\b/)) {
|
||||
return "Flagship Mistral model for advanced reasoning, coding, and multilingual work";
|
||||
}
|
||||
if (fast || has(target, /\bsmall\b/)) {
|
||||
return "Efficient Mistral model for fast chat, extraction, and production assistants";
|
||||
}
|
||||
return "Mistral model for multilingual chat, reasoning, and tool-assisted workflows";
|
||||
}
|
||||
|
||||
function qwenDescription({ target, fast, frontier, multimodal }: SpecialDescriptionContext) {
|
||||
if (has(target, /\bcoder\b/)) {
|
||||
return "Qwen coding model for software agents, repository edits, and code reasoning";
|
||||
}
|
||||
if (has(target, /\bomni\b/)) {
|
||||
return "Qwen omni model for text, vision, audio, and multimodal agent tasks";
|
||||
}
|
||||
if (has(target, /\bvl\b/) || multimodal) {
|
||||
return "Qwen vision-language model for visual reasoning, documents, and agent tasks";
|
||||
}
|
||||
if (has(target, /\bqwq|thinking\b/)) {
|
||||
return "Qwen reasoning model for deliberate problem solving, math, and coding";
|
||||
}
|
||||
if (frontier || has(target, /\bmax\b/)) {
|
||||
return "Flagship Qwen model for complex reasoning, coding, and agentic workflows";
|
||||
}
|
||||
if (fast || has(target, /\bflash|turbo\b/)) {
|
||||
return "Efficient Qwen model for fast chat, extraction, and high-volume workloads";
|
||||
}
|
||||
return "Qwen instruction model for multilingual chat, reasoning, and tool use";
|
||||
}
|
||||
|
||||
function deepSeekDescription({ target, fast, frontier }: SpecialDescriptionContext) {
|
||||
if (has(target, /\breasoner|r1\b/)) {
|
||||
return "DeepSeek reasoning model for multi-step analysis, math, coding, and tools";
|
||||
}
|
||||
if (fast || has(target, /\bflash\b/)) {
|
||||
return "Fast DeepSeek model for efficient chat, coding help, and agent loops";
|
||||
}
|
||||
if (frontier || has(target, /\bpro|v4\b/)) {
|
||||
return "Flagship DeepSeek model for coding, reasoning, and agentic work";
|
||||
}
|
||||
return "DeepSeek chat model for instruction following, coding, and analysis";
|
||||
}
|
||||
|
||||
function xaiDescription({ target, fast }: SpecialDescriptionContext) {
|
||||
if (has(target, /\bbuild\b/)) {
|
||||
return "Grok coding model for agentic engineering, edits, and codebase workflows";
|
||||
}
|
||||
if (fast) {
|
||||
return "Fast Grok model for responsive chat, reasoning, and tool-assisted work";
|
||||
}
|
||||
return "Grok model for agentic tool use, reasoning, coding, and live assistance";
|
||||
}
|
||||
|
||||
function miniMaxDescription({ target, fast, frontier, multimodal }: SpecialDescriptionContext) {
|
||||
if (has(target, /\bhighspeed|lightning\b/)) {
|
||||
return "High-speed MiniMax model for low-latency coding and agent workflows";
|
||||
}
|
||||
if (multimodal || has(target, /\bm3\b/)) {
|
||||
return "MiniMax multimodal coding model for long-context reasoning and agent tasks";
|
||||
}
|
||||
if (frontier) {
|
||||
return "Frontier MiniMax model for engineering, office tasks, and agentic reasoning";
|
||||
}
|
||||
if (fast) {
|
||||
return "Efficient MiniMax model for quick assistance, coding, and routine automation";
|
||||
}
|
||||
return "MiniMax model for chat, coding, office work, and agentic tasks";
|
||||
}
|
||||
|
||||
function nvidiaDescription({ target, fast, frontier, multimodal }: SpecialDescriptionContext) {
|
||||
if (has(target, /\bvoice\b/)) {
|
||||
return "Nemotron voice model for conversational audio and speech-enabled assistants";
|
||||
}
|
||||
if (has(target, /\bembed\b/)) {
|
||||
return "Nemotron embedding model for multimodal retrieval and semantic search";
|
||||
}
|
||||
if (has(target, /\brerank\b/)) {
|
||||
return "Nemotron reranker for improving retrieval quality across text and vision search";
|
||||
}
|
||||
if (has(target, /\bsafety|guard\b/)) {
|
||||
return "Nemotron safety model for moderation, policy checks, and safe routing";
|
||||
}
|
||||
if (multimodal) {
|
||||
return "Nemotron multimodal model for visual reasoning and agentic AI workflows";
|
||||
}
|
||||
if (frontier || has(target, /\bultra\b/)) {
|
||||
return "Flagship Nemotron model for high-throughput reasoning and complex agents";
|
||||
}
|
||||
if (fast || has(target, /\bnano\b/)) {
|
||||
return "Compact Nemotron model for efficient reasoning and deployable AI agents";
|
||||
}
|
||||
return "Nemotron model for efficient reasoning, coding, and specialized AI agents";
|
||||
}
|
||||
|
||||
function metaDescription({ target, fast, multimodal }: SpecialDescriptionContext) {
|
||||
if (has(target, /\bscout\b/)) {
|
||||
return "Open multimodal Llama model for long-context analysis and efficient agents";
|
||||
}
|
||||
if (has(target, /\bmaverick\b/)) {
|
||||
return "Open multimodal Llama model for strong reasoning and fast responses";
|
||||
}
|
||||
if (multimodal) {
|
||||
return "Open Llama multimodal model for image understanding and text reasoning";
|
||||
}
|
||||
if (fast) {
|
||||
return "Compact Llama instruction model for fast chat and local deployment";
|
||||
}
|
||||
return "Open Llama instruction model for multilingual chat, reasoning, and coding";
|
||||
}
|
||||
|
||||
function glmDescription({ target, fast, multimodal }: SpecialDescriptionContext) {
|
||||
if (multimodal || has(target, /\bv\b/)) {
|
||||
return "GLM vision model for visual reasoning, documents, and multimodal agents";
|
||||
}
|
||||
if (fast || has(target, /\bflash|turbo|air\b/)) {
|
||||
return "Efficient GLM model for fast reasoning, coding, and agent workflows";
|
||||
}
|
||||
return "Flagship GLM model for hybrid reasoning, coding, and agentic engineering";
|
||||
}
|
||||
|
||||
function kimiDescription({ target, fast, multimodal }: SpecialDescriptionContext) {
|
||||
if (has(target, /\bcode\b/)) {
|
||||
return "Kimi coding model for software agents, refactors, and repository reasoning";
|
||||
}
|
||||
if (has(target, /\bthinking\b/)) {
|
||||
return "Kimi reasoning model for long-horizon research, planning, and tool use";
|
||||
}
|
||||
if (multimodal) {
|
||||
return "Kimi multimodal agent model for visual understanding, coding, and planning";
|
||||
}
|
||||
if (fast) {
|
||||
return "Fast Kimi model for responsive chat, coding help, and agent loops";
|
||||
}
|
||||
return "Kimi model for long-context chat, coding, and agentic reasoning";
|
||||
}
|
||||
|
||||
function mimoDescription({ target, fast, multimodal }: SpecialDescriptionContext) {
|
||||
if (has(target, /\bpro\b/)) {
|
||||
return "MiMo pro model for strong multimodal reasoning and agent execution";
|
||||
}
|
||||
if (fast || has(target, /\bflash\b/)) {
|
||||
return "MiMo flash model for fast multimodal assistance and agent workflows";
|
||||
}
|
||||
if (multimodal || has(target, /\bomni\b/)) {
|
||||
return "MiMo omni model for text, image, video, audio, and agents";
|
||||
}
|
||||
return "MiMo model for long-context reasoning, perception, and agentic tasks";
|
||||
}
|
||||
|
||||
function stepDescription(_: SpecialDescriptionContext) {
|
||||
return "StepFun flash model for efficient multimodal reasoning, coding, and tool use";
|
||||
}
|
||||
|
||||
function cohereDescription({ target }: SpecialDescriptionContext) {
|
||||
if (has(target, /\bnorth.*code|code\b/)) {
|
||||
return "Cohere coding model for practical software engineering and agentic edits";
|
||||
}
|
||||
if (has(target, /\bcommand[-\s]?r\b/)) {
|
||||
return "Cohere retrieval model for long-context chat and enterprise RAG workflows";
|
||||
}
|
||||
return "Cohere command model for multilingual enterprise agents, tools, and chat";
|
||||
}
|
||||
|
||||
function perplexityDescription({ target }: SpecialDescriptionContext) {
|
||||
if (has(target, /\breasoning\b/)) {
|
||||
return "Web-grounded reasoning model for multi-step research and cited answers";
|
||||
}
|
||||
if (has(target, /\bpro\b/)) {
|
||||
return "Advanced Sonar search model for deeper research and cited synthesis";
|
||||
}
|
||||
return "Sonar search model for current answers, retrieval, and citation-backed chat";
|
||||
}
|
||||
|
||||
function sarvamDescription({ target }: SpecialDescriptionContext) {
|
||||
if (has(target, /\b105b\b/)) {
|
||||
return "Flagship Indian-language reasoning model for enterprise multilingual applications";
|
||||
}
|
||||
return "Efficient Indian-language reasoning model for chat, coding, and multilingual work";
|
||||
}
|
||||
|
||||
function tencentDescription(_: SpecialDescriptionContext) {
|
||||
return "Tencent Hy reasoning model for coding, instruction following, and agent tasks";
|
||||
}
|
||||
|
||||
function sakanaDescription({ target }: SpecialDescriptionContext) {
|
||||
if (has(target, /\bultra\b/)) {
|
||||
return "Quality-first multi-agent model for hard research, analysis, and competitions";
|
||||
}
|
||||
return "Multi-agent model for routing expert agents across complex analytical tasks";
|
||||
}
|
||||
|
||||
function ornithDescription({ target }: SpecialDescriptionContext) {
|
||||
if (has(target, /\b397b|35b\b/)) {
|
||||
return "Large coding-reasoning model for agentic software tasks and RL search";
|
||||
}
|
||||
return "Open coding-reasoning model for repository tasks and self-improving agents";
|
||||
}
|
||||
|
||||
function has(value: string, pattern: RegExp) {
|
||||
return pattern.test(value);
|
||||
}
|
||||
|
||||
function labID(id: string, providerId?: string) {
|
||||
const [first] = id.split("/");
|
||||
if (first !== undefined && first.length > 0) return normalizeLab(first);
|
||||
if (providerId !== undefined && providerId.length > 0) return normalizeLab(providerId);
|
||||
}
|
||||
|
||||
function normalizeLab(value: string) {
|
||||
const normalized = value.toLowerCase();
|
||||
return {
|
||||
"x-ai": "xai",
|
||||
"z-ai": "zai",
|
||||
"zai-org": "zhipuai",
|
||||
qwen: "alibaba",
|
||||
mistralai: "mistral",
|
||||
"meta-llama": "meta",
|
||||
llama: "meta",
|
||||
"moonshot-ai": "moonshotai",
|
||||
minimaxai: "minimax",
|
||||
"deepseek-ai": "deepseek",
|
||||
xiaomimimo: "xiaomi",
|
||||
"stepfun-ai": "stepfun",
|
||||
}[normalized] ?? normalized;
|
||||
}
|
||||
|
||||
function humanizeID(id: string) {
|
||||
const last = id.split("/").at(-1) ?? id;
|
||||
return last
|
||||
.replace(/[:._-]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ModelFamilyValues = [
|
||||
// Arcee
|
||||
"trinity",
|
||||
"trinity-mini",
|
||||
|
||||
// OpenAI/GPT style
|
||||
"gpt",
|
||||
"gpt-codex",
|
||||
"gpt-codex-spark",
|
||||
"gpt-codex-mini",
|
||||
"gpt-pro",
|
||||
"gpt-mini",
|
||||
"gpt-nano",
|
||||
"gpt-sol",
|
||||
"gpt-terra",
|
||||
"gpt-luna",
|
||||
"gpt-oss",
|
||||
"gpt-image",
|
||||
|
||||
// OpenAI o-series (reasoning models)
|
||||
"o",
|
||||
"o-mini",
|
||||
"o-pro",
|
||||
|
||||
// Anthropic style
|
||||
"claude",
|
||||
"claude-haiku",
|
||||
"claude-sonnet",
|
||||
"claude-opus",
|
||||
"claude-fable",
|
||||
|
||||
// Gemini style
|
||||
"gemini",
|
||||
"gemini-pro",
|
||||
"gemini-flash",
|
||||
"gemini-flash-lite",
|
||||
"gemini-embedding",
|
||||
|
||||
// GLM (zai)
|
||||
"glm",
|
||||
"glmv",
|
||||
"glm-air",
|
||||
"glm-flash",
|
||||
"glm-free",
|
||||
"glm-z",
|
||||
|
||||
// Meta Llama
|
||||
"llama",
|
||||
|
||||
// Meta Muse
|
||||
"muse",
|
||||
|
||||
// Alibaba Qwen
|
||||
"qwen",
|
||||
"qwen3.5",
|
||||
"qwen3.6",
|
||||
"qwen3.7-plus",
|
||||
"qwen3.7-max",
|
||||
"qwen-free",
|
||||
|
||||
// DeepReinforce
|
||||
"ornith",
|
||||
|
||||
// DeepSeek
|
||||
"deepseek",
|
||||
"deepseek-thinking",
|
||||
"deepseek-flash",
|
||||
"deepseek-flash-free",
|
||||
"deepseek-flash-think",
|
||||
|
||||
// Microsoft Phi
|
||||
"phi",
|
||||
|
||||
// Moonshot Kimi
|
||||
"kimi",
|
||||
"kimi-k2",
|
||||
"kimi-free",
|
||||
"kimi-thinking",
|
||||
|
||||
// Poolside Laguna
|
||||
"laguna",
|
||||
|
||||
// Mistral family
|
||||
"mistral",
|
||||
"mistral-large",
|
||||
"mistral-medium",
|
||||
"mistral-small",
|
||||
"mistral-nemo",
|
||||
"ministral",
|
||||
"codestral",
|
||||
"devstral",
|
||||
"pixtral",
|
||||
"mixtral",
|
||||
|
||||
// xAI Grok
|
||||
"grok",
|
||||
"grok-build",
|
||||
"grok-vision",
|
||||
"grok-beta",
|
||||
|
||||
// Google Gemma
|
||||
"gemma",
|
||||
|
||||
// AWS Nova
|
||||
"nova",
|
||||
"nova-pro",
|
||||
"nova-lite",
|
||||
"nova-micro",
|
||||
|
||||
// Cohere Command
|
||||
"command",
|
||||
"command-r",
|
||||
"command-a",
|
||||
"command-light",
|
||||
"north",
|
||||
"north-free",
|
||||
|
||||
// AI21 Jamba
|
||||
"jamba",
|
||||
|
||||
// NVIDIA Nemotron
|
||||
"nemotron",
|
||||
"nemotron-free",
|
||||
|
||||
// AWS Titan
|
||||
"titan",
|
||||
"titan-embed",
|
||||
|
||||
// MiniMax
|
||||
"minimax",
|
||||
"minimax-m2.5",
|
||||
"minimax-m2.7",
|
||||
"minimax-m3",
|
||||
"minimax-m3-free",
|
||||
"minimax-free",
|
||||
|
||||
// Hunyuan
|
||||
"hunyuan",
|
||||
|
||||
// Hy
|
||||
"Hy",
|
||||
|
||||
// Yi
|
||||
"yi",
|
||||
|
||||
// Granite
|
||||
"granite",
|
||||
|
||||
// Reka
|
||||
"reka",
|
||||
|
||||
// Sonar (Perplexity)
|
||||
"sonar",
|
||||
"sonar-pro",
|
||||
"sonar-reasoning",
|
||||
"sonar-deep-research",
|
||||
|
||||
// Solar
|
||||
"solar",
|
||||
"solar-mini",
|
||||
"solar-pro",
|
||||
|
||||
// Step (StepFun)
|
||||
"step",
|
||||
|
||||
// Embedding models
|
||||
"text-embedding",
|
||||
"cohere-embed",
|
||||
"voyage",
|
||||
"mistral-embed",
|
||||
"bge",
|
||||
"plamo",
|
||||
"codestral-embed",
|
||||
|
||||
// Image generation
|
||||
"dall-e",
|
||||
"flux",
|
||||
"imagen",
|
||||
"recraft",
|
||||
"stable-diffusion",
|
||||
"ideogram",
|
||||
"dreamshaper",
|
||||
|
||||
// Video generation
|
||||
"sora",
|
||||
"veo",
|
||||
"runway",
|
||||
"dream-machine",
|
||||
|
||||
// Audio/Speech
|
||||
"whisper",
|
||||
"elevenlabs",
|
||||
"lyria",
|
||||
"melotts",
|
||||
|
||||
// Baidu Ernie
|
||||
"ernie",
|
||||
|
||||
// Hermes
|
||||
"hermes",
|
||||
|
||||
// Zephyr
|
||||
"zephyr",
|
||||
|
||||
// OpenChat
|
||||
"openchat",
|
||||
|
||||
// Starling
|
||||
"starling",
|
||||
|
||||
// Qwen QVQ
|
||||
"qvq",
|
||||
|
||||
// Sherlock
|
||||
"sherlock",
|
||||
|
||||
// Pony
|
||||
"pony",
|
||||
|
||||
// Mercury
|
||||
"mercury",
|
||||
|
||||
// Cogito
|
||||
"cogito",
|
||||
|
||||
// Mimo
|
||||
"mimo",
|
||||
"mimo-pro",
|
||||
"mimo-omni",
|
||||
"mimo-v2-pro",
|
||||
"mimo-v2-omni",
|
||||
"mimo-v2.5-pro",
|
||||
"mimo-v2.5",
|
||||
"mimo-v2.5-free",
|
||||
"mimo-pro-free",
|
||||
"mimo-omni-free",
|
||||
"mimo-flash-free",
|
||||
|
||||
// Clarifai
|
||||
"mm-poly",
|
||||
|
||||
// Longcat
|
||||
"longcat",
|
||||
|
||||
// Magistral
|
||||
"magistral",
|
||||
"magistral-small",
|
||||
"magistral-medium",
|
||||
|
||||
// Phoenix
|
||||
"phoenix",
|
||||
|
||||
// Trinity
|
||||
"trinity",
|
||||
|
||||
// Lucid
|
||||
"lucid",
|
||||
|
||||
// LucidQuery
|
||||
"agi",
|
||||
|
||||
// Intellect
|
||||
"intellect",
|
||||
|
||||
// Aura (Stability AI)
|
||||
"aura",
|
||||
|
||||
// JAIS
|
||||
"jais",
|
||||
|
||||
// Sarvam
|
||||
"sarvam",
|
||||
|
||||
// Falcon
|
||||
"falcon",
|
||||
|
||||
// Baichuan
|
||||
"baichuan",
|
||||
|
||||
// Skywork
|
||||
"skywork",
|
||||
|
||||
// BART
|
||||
"bart",
|
||||
|
||||
// DistilBERT
|
||||
"distilbert",
|
||||
|
||||
// ResNet
|
||||
"resnet",
|
||||
|
||||
// M2M100
|
||||
"m2m",
|
||||
|
||||
// IndicTrans
|
||||
"indictrans",
|
||||
|
||||
// LLaVA
|
||||
"llava",
|
||||
|
||||
// Seed
|
||||
"seed",
|
||||
|
||||
// Ray
|
||||
"ray",
|
||||
|
||||
// T-Stars
|
||||
"tstars",
|
||||
|
||||
// RNJ
|
||||
"rnj",
|
||||
|
||||
// Tecent Hy
|
||||
"hy3",
|
||||
"hy3-free",
|
||||
|
||||
// Ling & Ring (InclusionAI)
|
||||
"ling",
|
||||
"ling-flash-free",
|
||||
"ring",
|
||||
"ring-1t-free",
|
||||
|
||||
// Kat Coder
|
||||
"kat-coder",
|
||||
|
||||
// SQL Coder
|
||||
"sqlcoder",
|
||||
|
||||
// DiscoLM
|
||||
"discolm",
|
||||
|
||||
// Osmosis
|
||||
"osmosis",
|
||||
|
||||
// Parakeet
|
||||
"parakeet",
|
||||
|
||||
// NeMo
|
||||
"nemoretriever",
|
||||
|
||||
// Nano Banana
|
||||
"nano-banana",
|
||||
|
||||
// Una Cybertron
|
||||
"una-cybertron",
|
||||
|
||||
// Morph
|
||||
"morph",
|
||||
|
||||
// Voxtral
|
||||
"voxtral",
|
||||
|
||||
// Venice
|
||||
"venice",
|
||||
|
||||
// Auto router
|
||||
"auto",
|
||||
"model-router",
|
||||
|
||||
// Conductor
|
||||
"fugu",
|
||||
|
||||
// V0
|
||||
"v0",
|
||||
|
||||
// Tako
|
||||
"tako",
|
||||
|
||||
// MAI
|
||||
"mai",
|
||||
|
||||
// RedNote
|
||||
"rednote",
|
||||
|
||||
// Smart Turn
|
||||
"smart-turn",
|
||||
|
||||
// Qwerky
|
||||
"qwerky",
|
||||
|
||||
// Big Pickle
|
||||
"big-pickle",
|
||||
|
||||
// Chutes AI
|
||||
"chutesai",
|
||||
|
||||
// OpenGVLab
|
||||
"opengvlab",
|
||||
|
||||
// TNG Tech
|
||||
"tngtech",
|
||||
|
||||
// TopazLabs
|
||||
"topazlabs",
|
||||
|
||||
// Unsloth
|
||||
"unsloth",
|
||||
|
||||
// Nousresearch
|
||||
"nousresearch",
|
||||
|
||||
// Alpha variants (experimental models)
|
||||
"alpha",
|
||||
|
||||
// OSWE
|
||||
"oswe",
|
||||
|
||||
// Neural Chat
|
||||
"neural-chat",
|
||||
|
||||
// Pangu (Ascend Tribe)
|
||||
"pangu",
|
||||
|
||||
// LiquidAI
|
||||
"liquid",
|
||||
|
||||
// Sourceful
|
||||
"sourceful",
|
||||
|
||||
// AllenAI
|
||||
"allenai",
|
||||
|
||||
// Writer
|
||||
"palmyra",
|
||||
|
||||
// ALLaM
|
||||
"allam",
|
||||
|
||||
// Canopy Labs
|
||||
"canopylabs",
|
||||
|
||||
// Groq
|
||||
"groq",
|
||||
|
||||
// Elephant
|
||||
"elephant",
|
||||
] as const;
|
||||
|
||||
export const ModelFamily = z.enum(ModelFamilyValues);
|
||||
export type ModelFamily = z.infer<typeof ModelFamily>;
|
||||
|
||||
export function inferKimiFamily(...values: string[]): ModelFamily | undefined {
|
||||
const target = values.join(" ").toLowerCase();
|
||||
if (/kimi[^a-z0-9]*k2(?:[^a-z0-9]*\d+)?[^a-z0-9]*thinking/.test(target)) return "kimi-thinking";
|
||||
if (/kimi[\s_-]*k2/.test(target)) return "kimi-k2";
|
||||
return undefined;
|
||||
}
|
||||
Executable
+276
@@ -0,0 +1,276 @@
|
||||
import path from "path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mergeDeep } from "remeda";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
Provider,
|
||||
Model,
|
||||
AuthoredModel,
|
||||
AuthoredModelShape,
|
||||
ModelMetadata,
|
||||
} from "./schema.js";
|
||||
|
||||
const BaseModel = AuthoredModelShape
|
||||
.deepPartial()
|
||||
.extend({
|
||||
id: z.string(),
|
||||
base_model: z.string().min(1, "Base model cannot be empty"),
|
||||
base_model_omit: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export async function generateCatalog(directory: string) {
|
||||
const models = await generateModels(path.join(directory, "models"));
|
||||
const providers = await generateProviders(
|
||||
path.join(directory, "providers"),
|
||||
models,
|
||||
);
|
||||
|
||||
return { models, providers };
|
||||
}
|
||||
|
||||
export async function generateModels(directory: string) {
|
||||
const result: Record<string, ModelMetadata> = {};
|
||||
if (!existsSync(directory)) return result;
|
||||
|
||||
for await (const modelPath of new Bun.Glob("**/*.toml").scan({
|
||||
cwd: directory,
|
||||
absolute: true,
|
||||
followSymlinks: true,
|
||||
})) {
|
||||
const modelID = path.relative(directory, modelPath).split(path.sep).join("/").slice(0, -5);
|
||||
const toml = await import(modelPath, {
|
||||
with: {
|
||||
type: "toml",
|
||||
},
|
||||
}).then((mod) => mod.default);
|
||||
toml.id = modelID;
|
||||
|
||||
const model = ModelMetadata.safeParse(toml);
|
||||
if (!model.success) {
|
||||
model.error.cause = { modelPath, toml };
|
||||
throw model.error;
|
||||
}
|
||||
result[modelID] = model.data;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function generate(directory: string) {
|
||||
const modelsDirectory = path.join(path.dirname(directory), "models");
|
||||
const models = await generateModels(modelsDirectory);
|
||||
|
||||
return generateProviders(directory, models);
|
||||
}
|
||||
|
||||
async function generateProviders(
|
||||
directory: string,
|
||||
models: Record<string, ModelMetadata>,
|
||||
) {
|
||||
const result: Record<string, Provider> = {};
|
||||
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).split(path.sep).join("/").slice(0, -5);
|
||||
const toml = await import(modelPath, {
|
||||
with: {
|
||||
type: "toml",
|
||||
},
|
||||
}).then((mod) => mod.default);
|
||||
toml.id = modelID;
|
||||
if (toml.base_model !== undefined) {
|
||||
const baseModel = BaseModel.safeParse(toml);
|
||||
if (!baseModel.success) {
|
||||
baseModel.error.cause = { modelPath, toml };
|
||||
throw baseModel.error;
|
||||
}
|
||||
|
||||
const merged = mergeBaseModel(baseModel.data, models, modelPath);
|
||||
const model = AuthoredModel.safeParse(merged);
|
||||
if (!model.success) {
|
||||
model.error.cause = { modelPath, toml: merged };
|
||||
throw model.error;
|
||||
}
|
||||
provider.data.models[modelID] = normalizeModelCost(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.`,
|
||||
{ cause: { providerIDs: [existingID, provider.id], name: provider.name } },
|
||||
);
|
||||
}
|
||||
nameToProviderID.set(nameKey, provider.id);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function mergeBaseModel(
|
||||
model: z.infer<typeof BaseModel>,
|
||||
models: Record<string, ModelMetadata>,
|
||||
modelPath: string,
|
||||
) {
|
||||
const base = models[model.base_model];
|
||||
if (base === undefined) {
|
||||
throw new Error(`Unable to resolve base_model: ${model.base_model}`, {
|
||||
cause: { modelPath, toml: model },
|
||||
});
|
||||
}
|
||||
|
||||
const { base_model: _baseModel, base_model_omit: omit, ...overrides } = model;
|
||||
const merged: Record<string, unknown> = structuredClone(
|
||||
mergeDeep(inheritableModelMetadata(base), overrides),
|
||||
);
|
||||
|
||||
applyOmit(merged, omit ?? []);
|
||||
return merged;
|
||||
}
|
||||
|
||||
function inheritableModelMetadata(model: ModelMetadata) {
|
||||
const {
|
||||
id: _id,
|
||||
benchmarks: _benchmarks,
|
||||
license: _license,
|
||||
links: _links,
|
||||
weights: _weights,
|
||||
...metadata
|
||||
} = model;
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(metadata).filter(([, value]) => value !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
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 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)) {
|
||||
return model;
|
||||
}
|
||||
|
||||
if (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;
|
||||
// context_over_200k is a legacy compatibility field. It intentionally
|
||||
// includes higher thresholds; cost.tiers carries the exact threshold.
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./schema.js";
|
||||
export * from "./generate.js";
|
||||
export * from "./describe.js";
|
||||
export * from "./family.js";
|
||||
@@ -0,0 +1,399 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { ModelFamily } from "./family";
|
||||
|
||||
type JsonValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| { [key: string]: JsonValue }
|
||||
| JsonValue[];
|
||||
|
||||
const JsonValue: z.ZodType<JsonValue> = z.lazy(() =>
|
||||
z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.null(),
|
||||
z.array(JsonValue),
|
||||
z.record(JsonValue),
|
||||
]),
|
||||
);
|
||||
|
||||
const ReasoningEffortValue = z.preprocess(
|
||||
(value) => (value === "null" ? null : value),
|
||||
z.union([
|
||||
z.null(),
|
||||
z.enum(["none", "minimal", "low", "medium", "high", "xhigh", "max", "default"]),
|
||||
]),
|
||||
);
|
||||
|
||||
export const ReasoningOption = z
|
||||
.discriminatedUnion("type", [
|
||||
z
|
||||
.object({
|
||||
type: z.literal("toggle"),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("effort"),
|
||||
values: z.array(ReasoningEffortValue),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("budget_tokens"),
|
||||
min: z
|
||||
.number()
|
||||
.min(-1, "Minimum reasoning budget cannot be less than -1")
|
||||
.optional(),
|
||||
max: z
|
||||
.number()
|
||||
.min(0, "Maximum reasoning budget cannot be negative")
|
||||
.optional(),
|
||||
})
|
||||
.strict(),
|
||||
])
|
||||
.refine(
|
||||
(data) =>
|
||||
data.type !== "budget_tokens" ||
|
||||
data.min === undefined ||
|
||||
data.max === undefined ||
|
||||
data.min <= data.max,
|
||||
{
|
||||
message:
|
||||
"Minimum reasoning budget cannot exceed maximum reasoning budget",
|
||||
path: ["min"],
|
||||
},
|
||||
);
|
||||
|
||||
const Cost = z.object({
|
||||
input: z.number().min(0, "Input price cannot be negative"),
|
||||
output: z.number().min(0, "Output price cannot be negative"),
|
||||
reasoning: z.number().min(0, "Reasoning price cannot be negative").optional(),
|
||||
cache_read: z
|
||||
.number()
|
||||
.min(0, "Cache read price cannot be negative")
|
||||
.optional(),
|
||||
cache_write: z
|
||||
.number()
|
||||
.min(0, "Cache write price cannot be negative")
|
||||
.optional(),
|
||||
input_audio: z
|
||||
.number()
|
||||
.min(0, "Audio input price cannot be negative")
|
||||
.optional(),
|
||||
output_audio: z
|
||||
.number()
|
||||
.min(0, "Audio output price cannot be negative")
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const CostTier = Cost.extend({
|
||||
tier: z
|
||||
.object({
|
||||
type: z.literal("context").default("context"),
|
||||
size: z.number().int().min(0, "Context tier size cannot be negative"),
|
||||
})
|
||||
.strict(),
|
||||
}).strict();
|
||||
|
||||
const AuthoredCost = Cost.extend({
|
||||
context_over_200k: z.never().optional(),
|
||||
tiers: z.array(CostTier).optional(),
|
||||
});
|
||||
|
||||
const OutputCost = Cost.extend({
|
||||
context_over_200k: Cost.optional(),
|
||||
tiers: z.array(CostTier).optional(),
|
||||
});
|
||||
|
||||
const DateString = z.string().regex(/^\d{4}-\d{2}(-\d{2})?$/, {
|
||||
message: "Must be in YYYY-MM or YYYY-MM-DD format",
|
||||
});
|
||||
|
||||
const Modality = z.enum(["text", "audio", "image", "video", "pdf"]);
|
||||
|
||||
const Modalities = z
|
||||
.object({
|
||||
input: z.array(Modality),
|
||||
output: z.array(Modality),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const LimitBase = z
|
||||
.object({
|
||||
context: z.number().min(0, "Context window must be positive"),
|
||||
input: z.number().min(0, "Input tokens must be positive").optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ModelLimit = LimitBase.extend({
|
||||
output: z.number().min(0, "Output tokens must be positive").optional(),
|
||||
}).strict();
|
||||
|
||||
const ProviderModelLimit = LimitBase.extend({
|
||||
output: z.number().min(0, "Output tokens must be positive"),
|
||||
}).strict();
|
||||
|
||||
const UrlString = z.string().url("Must be a valid URL");
|
||||
|
||||
export const ModelLink = z
|
||||
.object({
|
||||
label: z.string().min(1, "Link label cannot be empty").optional(),
|
||||
url: UrlString,
|
||||
type: z
|
||||
.enum([
|
||||
"announcement",
|
||||
"blog",
|
||||
"docs",
|
||||
"license",
|
||||
"model_card",
|
||||
"paper",
|
||||
"weights",
|
||||
"other",
|
||||
])
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const ModelWeights = z
|
||||
.object({
|
||||
label: z.string().min(1, "Weights label cannot be empty").optional(),
|
||||
url: UrlString,
|
||||
format: z.string().min(1, "Weights format cannot be empty").optional(),
|
||||
quantization: z
|
||||
.string()
|
||||
.min(1, "Weights quantization cannot be empty")
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const BenchmarkResult = z
|
||||
.object({
|
||||
name: z.string().min(1, "Benchmark name cannot be empty"),
|
||||
score: z.union([z.number(), z.string().min(1)]),
|
||||
metric: z.string().min(1, "Benchmark metric cannot be empty").optional(),
|
||||
harness: z.string().min(1, "Benchmark harness cannot be empty").optional(),
|
||||
variant: z.string().min(1, "Benchmark variant cannot be empty").optional(),
|
||||
dataset: z.string().min(1, "Benchmark dataset cannot be empty").optional(),
|
||||
version: z.string().min(1, "Benchmark version cannot be empty").optional(),
|
||||
source: UrlString.optional(),
|
||||
date: DateString.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ModelMetadataBase = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1, "Model name cannot be empty"),
|
||||
description: z.string().min(1, "Model description cannot be empty"),
|
||||
family: ModelFamily.optional(),
|
||||
attachment: z.boolean().optional(),
|
||||
reasoning: z.boolean().optional(),
|
||||
tool_call: z.boolean().optional(),
|
||||
structured_output: z.boolean().optional(),
|
||||
temperature: z.boolean().optional(),
|
||||
knowledge: DateString.optional(),
|
||||
release_date: DateString.optional(),
|
||||
last_updated: DateString.optional(),
|
||||
modalities: Modalities.optional(),
|
||||
open_weights: z.boolean().optional(),
|
||||
limit: ModelLimit.optional(),
|
||||
license: z.string().min(1, "License cannot be empty").optional(),
|
||||
links: z.array(ModelLink).optional(),
|
||||
weights: z.array(ModelWeights).optional(),
|
||||
benchmarks: z.array(BenchmarkResult).optional(),
|
||||
});
|
||||
|
||||
export const ModelMetadata = ModelMetadataBase.strict();
|
||||
|
||||
export type ModelMetadata = z.infer<typeof ModelMetadata>;
|
||||
|
||||
const ModelBase = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1, "Model name cannot be empty"),
|
||||
description: z.string().min(1, "Model description cannot be empty"),
|
||||
family: ModelFamily.optional(),
|
||||
attachment: z.boolean(),
|
||||
reasoning: z.boolean(),
|
||||
reasoning_options: z.array(ReasoningOption).optional(),
|
||||
tool_call: z.boolean(),
|
||||
interleaved: z
|
||||
.union([
|
||||
z.literal(true),
|
||||
z
|
||||
.object({
|
||||
field: z.enum(["reasoning_content", "reasoning_details"]),
|
||||
})
|
||||
.strict(),
|
||||
])
|
||||
.optional(),
|
||||
structured_output: z.boolean().optional(),
|
||||
temperature: z.boolean().optional(),
|
||||
knowledge: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}(-\d{2})?$/, {
|
||||
message: "Must be in YYYY-MM or YYYY-MM-DD format",
|
||||
})
|
||||
.optional(),
|
||||
release_date: DateString,
|
||||
last_updated: DateString,
|
||||
modalities: Modalities,
|
||||
open_weights: z.boolean(),
|
||||
limit: ProviderModelLimit,
|
||||
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
|
||||
experimental: z
|
||||
.object({
|
||||
modes: z
|
||||
.record(
|
||||
z.object({
|
||||
cost: Cost.optional(),
|
||||
provider: z
|
||||
.object({
|
||||
body: z.record(JsonValue).optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
provider: z
|
||||
.object({
|
||||
npm: z.string().optional(),
|
||||
api: z.string().optional(),
|
||||
shape: z.enum(["responses", "completions"]).optional(),
|
||||
body: z.record(JsonValue).optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
function refineModel<
|
||||
Output extends z.infer<typeof ModelShape> | z.infer<typeof AuthoredModelShape>,
|
||||
Def extends z.ZodTypeDef,
|
||||
Input,
|
||||
>(schema: z.ZodType<Output, Def, Input>) {
|
||||
return schema
|
||||
.refine(
|
||||
(data) => {
|
||||
return data.reasoning !== true || data.reasoning_options !== undefined;
|
||||
},
|
||||
{
|
||||
message: "Must set reasoning_options when reasoning is true",
|
||||
path: ["reasoning_options"],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
return data.reasoning !== false || data.reasoning_options === undefined;
|
||||
},
|
||||
{
|
||||
message: "Cannot set reasoning_options when reasoning is false",
|
||||
path: ["reasoning_options"],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
return !(
|
||||
data.reasoning === false && data.cost?.reasoning !== undefined
|
||||
);
|
||||
},
|
||||
{
|
||||
message: "Cannot set cost.reasoning when reasoning is false",
|
||||
path: ["cost", "reasoning"],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
const tiers = data.cost?.tiers;
|
||||
if (tiers === undefined) return true;
|
||||
|
||||
const sizes = tiers.map(
|
||||
(tier: { tier: { size: number } }) => tier.tier.size,
|
||||
);
|
||||
return new Set(sizes).size === sizes.length;
|
||||
},
|
||||
{
|
||||
message: "Cost context tiers must not have duplicate sizes",
|
||||
path: ["cost", "tiers"],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const ModelShape = z
|
||||
.object({
|
||||
...ModelBase.shape,
|
||||
cost: OutputCost.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const AuthoredModelShape = z
|
||||
.object({
|
||||
...ModelBase.shape,
|
||||
cost: AuthoredCost.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const Model = refineModel(ModelShape);
|
||||
|
||||
export const AuthoredModel = refineModel(AuthoredModelShape);
|
||||
|
||||
export type Model = z.infer<typeof Model>;
|
||||
|
||||
export const Provider = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
env: z.array(z.string()).min(1, "Provider env cannot be empty"),
|
||||
npm: z.string().min(1, "Provider npm module cannot be empty"),
|
||||
api: z.string().optional(),
|
||||
name: z.string().min(1, "Provider name cannot be empty"),
|
||||
doc: z
|
||||
.string()
|
||||
.min(
|
||||
1,
|
||||
"Please provide a link to the provider documentation where models are listed",
|
||||
),
|
||||
models: z.record(Model),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(data) => {
|
||||
const isOpenAI = data.npm === "@ai-sdk/openai";
|
||||
const isOpenAIcompatible = data.npm === "@ai-sdk/openai-compatible";
|
||||
const isOpenrouter = data.npm === "@openrouter/ai-sdk-provider";
|
||||
const isAnthropic = data.npm === "@ai-sdk/anthropic";
|
||||
const isKiro = data.npm === "kiro-acp-ai-provider";
|
||||
const hasApi = data.api !== undefined;
|
||||
|
||||
return (
|
||||
// openai-compatible: must have api
|
||||
(isOpenAIcompatible && hasApi) ||
|
||||
// openrouter: must have api
|
||||
(isOpenrouter && hasApi) ||
|
||||
// anthropic: api optional (always allowed)
|
||||
isAnthropic ||
|
||||
// openai: api optional (always allowed)
|
||||
isOpenAI ||
|
||||
// kiro: api optional (always allowed)
|
||||
isKiro ||
|
||||
// all others: must NOT have api
|
||||
(!isOpenAI &&
|
||||
!isOpenAIcompatible &&
|
||||
!isOpenrouter &&
|
||||
!isAnthropic &&
|
||||
!isKiro &&
|
||||
!hasApi)
|
||||
);
|
||||
},
|
||||
{
|
||||
message:
|
||||
"'api' is required for openai-compatible and openrouter, optional for anthropic, openai, and kiro, forbidden otherwise",
|
||||
path: ["api"],
|
||||
},
|
||||
);
|
||||
|
||||
export type Provider = z.infer<typeof Provider>;
|
||||
@@ -0,0 +1,949 @@
|
||||
import path from "node:path";
|
||||
import { lstat, mkdir, readdir, rm } from "node:fs/promises";
|
||||
import { mergeDeep } from "remeda";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AuthoredModel, AuthoredModelShape, ModelMetadata } from "../schema.js";
|
||||
import { ambient } from "./providers/ambient.js";
|
||||
import { anthropic } from "./providers/anthropic.js";
|
||||
import { baseten } from "./providers/baseten.js";
|
||||
import { chutes } from "./providers/chutes.js";
|
||||
import { cloudflareWorkersAi } from "./providers/cloudflare-workers-ai.js";
|
||||
import { crossmodel } from "./providers/crossmodel.js";
|
||||
import { deepinfra } from "./providers/deepinfra.js";
|
||||
import { digitalocean } from "./providers/digitalocean.js";
|
||||
import { empiriolabs } from "./providers/empiriolabs.js";
|
||||
import { google } from "./providers/google.js";
|
||||
import { huggingface } from "./providers/huggingface.js";
|
||||
import { kilo } from "./providers/kilo.js";
|
||||
import { llmgateway } from "./providers/llmgateway.js";
|
||||
import { openai } from "./providers/openai.js";
|
||||
import { openrouter } from "./providers/openrouter.js";
|
||||
import { ovhcloud } from "./providers/ovhcloud.js";
|
||||
import { pioneer } from "./providers/pioneer.js";
|
||||
import { vercel } from "./providers/vercel.js";
|
||||
import { venice } from "./providers/venice.js";
|
||||
import { wandb } from "./providers/wandb.js";
|
||||
import { xai } from "./providers/xai.js";
|
||||
|
||||
const ExistingModelType = AuthoredModelShape.partial()
|
||||
.extend({
|
||||
base_model: z.string().optional(),
|
||||
base_model_omit: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ExistingModel = AuthoredModelShape.deepPartial()
|
||||
.extend({
|
||||
base_model: z.string().optional(),
|
||||
base_model_omit: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const SyncedBaseModel = AuthoredModelShape.deepPartial()
|
||||
.extend({
|
||||
id: z.string(),
|
||||
base_model: z.string(),
|
||||
base_model_omit: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const SyncedAuthoredModel = z.union([AuthoredModel, SyncedBaseModel]);
|
||||
|
||||
export type ExistingModel = z.infer<typeof ExistingModelType>;
|
||||
export type SyncedFullModel = Omit<z.infer<typeof AuthoredModelShape>, "id">;
|
||||
export type SyncedBaseModel = Omit<z.infer<typeof SyncedBaseModel>, "id">;
|
||||
export type SyncedModel = SyncedFullModel | SyncedBaseModel;
|
||||
export type SyncedMetadata = Omit<z.infer<typeof ModelMetadata>, "id">;
|
||||
|
||||
export interface SyncProvider<SourceModel> {
|
||||
id: string;
|
||||
name: string;
|
||||
modelsDir: string;
|
||||
metadataNamespace?: string;
|
||||
skipCreates?: boolean;
|
||||
deleteMissing?: boolean;
|
||||
preserveSymlinks?: boolean;
|
||||
preserveBaseModels?: boolean;
|
||||
sameModel?(current: ExistingModel, desired: SyncedModel): boolean;
|
||||
missingNotice?(paths: string[]): string[];
|
||||
sourceID?(model: SourceModel): string;
|
||||
skippedNotice?(ids: string[]): string[];
|
||||
fetchModels(): Promise<unknown>;
|
||||
parseModels(raw: unknown): SourceModel[];
|
||||
translateModel(
|
||||
model: SourceModel,
|
||||
context: {
|
||||
existing(id: string): ExistingModel | undefined;
|
||||
authored(id: string): ExistingModel | undefined;
|
||||
},
|
||||
): { id: string; model: SyncedModel; metadata?: { id: string; model: SyncedMetadata } } | undefined;
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "changed" | "unchanged";
|
||||
created: number;
|
||||
updated: number;
|
||||
deleted: number;
|
||||
unchanged: number;
|
||||
notices: string[];
|
||||
files: Array<{ status: "created" | "updated" | "deleted"; path: string }>;
|
||||
}
|
||||
|
||||
export const providers: {
|
||||
ambient: SyncProvider<any>;
|
||||
anthropic: SyncProvider<any>;
|
||||
baseten: SyncProvider<any>;
|
||||
chutes: SyncProvider<any>;
|
||||
"cloudflare-workers-ai": SyncProvider<any>;
|
||||
crossmodel: SyncProvider<any>;
|
||||
deepinfra: SyncProvider<any>;
|
||||
digitalocean: SyncProvider<any>;
|
||||
empiriolabs: SyncProvider<any>;
|
||||
google: SyncProvider<any>;
|
||||
kilo: SyncProvider<any>;
|
||||
huggingface: SyncProvider<any>;
|
||||
llmgateway: SyncProvider<any>;
|
||||
openai: SyncProvider<any>;
|
||||
openrouter: SyncProvider<any>;
|
||||
ovhcloud: SyncProvider<any>;
|
||||
pioneer: SyncProvider<any>;
|
||||
vercel: SyncProvider<any>;
|
||||
venice: SyncProvider<any>;
|
||||
wandb: SyncProvider<any>;
|
||||
xai: SyncProvider<any>;
|
||||
} = {
|
||||
ambient,
|
||||
anthropic,
|
||||
baseten,
|
||||
chutes,
|
||||
"cloudflare-workers-ai": cloudflareWorkersAi,
|
||||
crossmodel,
|
||||
deepinfra,
|
||||
digitalocean,
|
||||
empiriolabs,
|
||||
google,
|
||||
kilo,
|
||||
huggingface,
|
||||
llmgateway,
|
||||
openai,
|
||||
openrouter,
|
||||
ovhcloud,
|
||||
pioneer,
|
||||
vercel,
|
||||
venice,
|
||||
wandb,
|
||||
xai,
|
||||
};
|
||||
|
||||
export const groups = {
|
||||
aggregators: ["crossmodel", "empiriolabs", "huggingface", "kilo", "llmgateway", "openrouter", "vercel"],
|
||||
cloudflare: ["cloudflare-workers-ai"],
|
||||
direct: ["ambient", "anthropic", "baseten", "chutes", "deepinfra", "digitalocean", "google", "openai", "ovhcloud", "pioneer", "venice", "wandb", "xai"],
|
||||
} as const;
|
||||
|
||||
type ProviderID = keyof typeof providers;
|
||||
|
||||
interface SyncOptions {
|
||||
dryRun?: boolean;
|
||||
newOnly?: boolean;
|
||||
}
|
||||
|
||||
export async function syncProviderByID(id: ProviderID, options: SyncOptions = {}) {
|
||||
return syncProvider(providers[id], options);
|
||||
}
|
||||
|
||||
export async function syncProvider<SourceModel>(
|
||||
provider: SyncProvider<SourceModel>,
|
||||
options: SyncOptions = {},
|
||||
): Promise<SyncResult> {
|
||||
console.log(`\nSyncing ${provider.name}...`);
|
||||
|
||||
const existingState = await readExisting(provider.modelsDir);
|
||||
const { models: existing, brokenSymlinks } = existingState;
|
||||
let { modelMetadata } = existingState;
|
||||
const sourceModels = provider.parseModels(await provider.fetchModels());
|
||||
const desired = new Map<string, { model: z.infer<typeof SyncedAuthoredModel>; content: string }>();
|
||||
const desiredMetadata = new Map<string, { model: z.infer<typeof ModelMetadata>; content: string }>();
|
||||
const skippedRemote: string[] = [];
|
||||
|
||||
for (const sourceModel of sourceModels) {
|
||||
const translated = provider.translateModel(sourceModel, {
|
||||
existing(id) {
|
||||
return existing.get(`${id}.toml`)?.toml;
|
||||
},
|
||||
authored(id) {
|
||||
return existing.get(`${id}.toml`)?.authored;
|
||||
},
|
||||
});
|
||||
if (translated === undefined) {
|
||||
if (provider.sourceID !== undefined) skippedRemote.push(provider.sourceID(sourceModel));
|
||||
continue;
|
||||
}
|
||||
|
||||
const relativePath = `${translated.id}.toml`;
|
||||
if (provider.skipCreates && !existing.has(relativePath)) {
|
||||
skippedRemote.push(translated.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (desired.has(relativePath)) {
|
||||
throw new Error(`Duplicate synced model path: ${provider.id}/${relativePath}`);
|
||||
}
|
||||
|
||||
if (translated.metadata !== undefined) {
|
||||
const parsedMetadata = ModelMetadata.safeParse({
|
||||
id: translated.metadata.id,
|
||||
...stripUndefined(translated.metadata.model),
|
||||
});
|
||||
if (!parsedMetadata.success) {
|
||||
parsedMetadata.error.cause = { provider: provider.id, metadata: translated.metadata.id };
|
||||
throw parsedMetadata.error;
|
||||
}
|
||||
const metadataPath = `${translated.metadata.id}.toml`;
|
||||
if (desiredMetadata.has(metadataPath)) throw new Error(`Duplicate synced metadata path: ${metadataPath}`);
|
||||
desiredMetadata.set(metadataPath, {
|
||||
model: parsedMetadata.data,
|
||||
content: formatMetadataToml(parsedMetadata.data),
|
||||
});
|
||||
}
|
||||
|
||||
const translatedModel = provider.preserveBaseModels === false
|
||||
? translated.model
|
||||
: preserveBaseModel(translated.model, existing.get(relativePath)?.authored);
|
||||
const translatedBase = "base_model" in translatedModel ? translatedModel.base_model : undefined;
|
||||
let resolvedReasoning: boolean | undefined;
|
||||
if (translatedBase !== undefined) {
|
||||
if (translated.metadata?.id === translatedBase) {
|
||||
resolvedReasoning = translated.metadata.model.reasoning;
|
||||
} else {
|
||||
modelMetadata ??= await readModelMetadata(provider.modelsDir);
|
||||
const canonicalReasoning = modelMetadata[translatedBase]?.reasoning;
|
||||
resolvedReasoning = typeof canonicalReasoning === "boolean" ? canonicalReasoning : undefined;
|
||||
}
|
||||
} else {
|
||||
resolvedReasoning = existing.get(relativePath)?.toml.reasoning;
|
||||
}
|
||||
const parsed = SyncedAuthoredModel.safeParse(stripUndefined({
|
||||
id: translated.id,
|
||||
...preserveDescription(
|
||||
preserveReasoningOptions(
|
||||
translatedModel,
|
||||
existing.get(relativePath)?.authored,
|
||||
resolvedReasoning,
|
||||
),
|
||||
existing.get(relativePath)?.authored,
|
||||
),
|
||||
}));
|
||||
if (!parsed.success) {
|
||||
parsed.error.cause = { provider: provider.id, path: relativePath };
|
||||
throw parsed.error;
|
||||
}
|
||||
|
||||
desired.set(relativePath, {
|
||||
model: parsed.data,
|
||||
content: (existing.get(relativePath)?.header ?? "") + formatToml(parsed.data),
|
||||
});
|
||||
}
|
||||
|
||||
const files: SyncResult["files"] = [];
|
||||
let unchanged = 0;
|
||||
|
||||
const metadataDir = modelMetadataDir(provider.modelsDir);
|
||||
for (const [relativePath, file] of desiredMetadata) {
|
||||
const filePath = path.join(metadataDir, relativePath);
|
||||
const currentFile = Bun.file(filePath);
|
||||
const currentText = await currentFile.exists() ? await currentFile.text() : undefined;
|
||||
const current = currentText !== undefined
|
||||
? ModelMetadata.safeParse({
|
||||
id: relativePath.slice(0, -5),
|
||||
...Bun.TOML.parse(currentText) as Record<string, unknown>,
|
||||
})
|
||||
: undefined;
|
||||
if (current?.success && stable(current.data) === stable(file.model)) continue;
|
||||
files.push({ status: current === undefined ? "created" : "updated", path: filePath });
|
||||
if (options.dryRun) {
|
||||
console.log(`Would ${current === undefined ? "create" : "update"} metadata ${relativePath}`);
|
||||
} else {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await Bun.write(filePath, (currentText !== undefined ? leadingComments(currentText) : "") + file.content);
|
||||
}
|
||||
}
|
||||
|
||||
if (provider.metadataNamespace !== undefined) {
|
||||
if (!/^[a-z0-9-]+$/.test(provider.metadataNamespace)) {
|
||||
throw new Error(`Invalid metadata namespace: ${provider.metadataNamespace}`);
|
||||
}
|
||||
const namespaceDir = path.join(metadataDir, provider.metadataNamespace);
|
||||
for (const { file } of await tomlFiles(namespaceDir)) {
|
||||
const relativePath = path.join(provider.metadataNamespace, file).split(path.sep).join("/");
|
||||
if (desiredMetadata.has(relativePath) || provider.deleteMissing === false) continue;
|
||||
if (options.newOnly) {
|
||||
console.log(`Skipping metadata removal in new-only mode: ${relativePath}`);
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(metadataDir, relativePath);
|
||||
files.push({ status: "deleted", path: filePath });
|
||||
if (options.dryRun) {
|
||||
console.log(`Would remove metadata ${relativePath}`);
|
||||
} else {
|
||||
await rm(filePath, { force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [relativePath, file] of desired) {
|
||||
const filePath = path.join(provider.modelsDir, relativePath);
|
||||
const current = existing.get(relativePath);
|
||||
|
||||
if (current === undefined) {
|
||||
files.push({ status: "created", path: filePath });
|
||||
if (options.dryRun) {
|
||||
console.log(`Would create ${relativePath}`);
|
||||
} else {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
if (await isSymlink(filePath)) await rm(filePath, { force: true });
|
||||
await Bun.write(filePath, file.content);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current.symlink && provider.preserveSymlinks) {
|
||||
unchanged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(provider.sameModel?.(current.authored, file.model) ?? sameModel(relativePath, current.authored, file.model))) {
|
||||
if (options.newOnly) {
|
||||
unchanged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
files.push({ status: "updated", path: filePath });
|
||||
if (options.dryRun) {
|
||||
console.log(`Would update ${relativePath}`);
|
||||
} else {
|
||||
if (current.symlink) await rm(filePath, { force: true });
|
||||
await Bun.write(filePath, file.content);
|
||||
}
|
||||
} else {
|
||||
unchanged++;
|
||||
}
|
||||
}
|
||||
|
||||
const missingLocal: string[] = [];
|
||||
for (const relativePath of new Set([...existing.keys(), ...brokenSymlinks])) {
|
||||
if (desired.has(relativePath)) continue;
|
||||
if (provider.deleteMissing === false) {
|
||||
missingLocal.push(relativePath);
|
||||
console.log(`Retaining model missing from source: ${relativePath}`);
|
||||
unchanged++;
|
||||
continue;
|
||||
}
|
||||
if (options.newOnly) {
|
||||
console.log(`Skipping removal in new-only mode: ${relativePath}`);
|
||||
unchanged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = path.join(provider.modelsDir, relativePath);
|
||||
files.push({ status: "deleted", path: filePath });
|
||||
if (options.dryRun) {
|
||||
console.log(`Would remove ${relativePath}`);
|
||||
} else {
|
||||
await rm(filePath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const result = summarize(provider, files, unchanged, [
|
||||
...provider.skippedNotice?.(skippedRemote) ?? [],
|
||||
...provider.missingNotice?.(missingLocal) ?? [],
|
||||
]);
|
||||
console.log(
|
||||
`${options.dryRun ? "Dry run: " : ""}${result.created} created, ${result.updated} updated, ${result.deleted} removed, ${result.unchanged} unchanged`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function preserveBaseModel(model: SyncedModel, existing: ExistingModel | undefined): SyncedModel {
|
||||
if (existing?.base_model === undefined) return model;
|
||||
const translatedBase = "base_model" in model ? model.base_model : undefined;
|
||||
if (translatedBase !== undefined) {
|
||||
const translatedOmit = "base_model_omit" in model ? model.base_model_omit : undefined;
|
||||
if (translatedBase !== existing.base_model || translatedOmit !== undefined) return model;
|
||||
return { ...model, base_model_omit: existing.base_model_omit };
|
||||
}
|
||||
return {
|
||||
...model,
|
||||
base_model: existing.base_model,
|
||||
base_model_omit: existing.base_model_omit,
|
||||
};
|
||||
}
|
||||
|
||||
export function preserveDescription(model: SyncedModel, existing: ExistingModel | undefined): SyncedModel {
|
||||
if (model.description !== undefined) return model;
|
||||
if (existing?.description === undefined) return model;
|
||||
return { ...model, description: existing.description } as SyncedModel;
|
||||
}
|
||||
|
||||
export function preserveReasoningOptions(
|
||||
model: SyncedModel,
|
||||
existing: ExistingModel | undefined,
|
||||
resolvedReasoning: boolean | undefined = existing?.reasoning,
|
||||
): SyncedModel {
|
||||
if ((model.reasoning ?? resolvedReasoning) === false) {
|
||||
const { reasoning_options: _reasoningOptions, ...withoutReasoningOptions } = model;
|
||||
return withoutReasoningOptions as SyncedModel;
|
||||
}
|
||||
if (model.reasoning_options !== undefined) return model;
|
||||
if (existing?.reasoning_options === undefined) {
|
||||
return (model.reasoning ?? resolvedReasoning) === true
|
||||
? { ...model, reasoning_options: [] }
|
||||
: model;
|
||||
}
|
||||
return {
|
||||
...model,
|
||||
reasoning_options: existing.reasoning_options,
|
||||
};
|
||||
}
|
||||
|
||||
export async function syncTargets(target: string, options: SyncOptions = {}) {
|
||||
const ids = target in groups
|
||||
? groups[target as keyof typeof groups]
|
||||
: target in providers
|
||||
? [target as ProviderID]
|
||||
: undefined;
|
||||
|
||||
if (ids === undefined) {
|
||||
throw new Error(`Unknown sync target: ${target}`);
|
||||
}
|
||||
|
||||
const results: SyncResult[] = [];
|
||||
for (const id of ids) {
|
||||
results.push(await syncProviderByID(id as ProviderID, options));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export function syncProviderMatrix() {
|
||||
return {
|
||||
include: Object.values(providers).map((provider) => ({
|
||||
provider: provider.id,
|
||||
name: provider.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function readExisting(modelsDir: string) {
|
||||
const existing = new Map<string, {
|
||||
authored: ExistingModel;
|
||||
toml: ExistingModel;
|
||||
header: string;
|
||||
symlink: boolean;
|
||||
}>();
|
||||
const brokenSymlinks = new Set<string>();
|
||||
let modelMetadata: Record<string, Record<string, unknown>> | undefined;
|
||||
|
||||
for (const { file, symlink } of await tomlFiles(modelsDir)) {
|
||||
const filePath = path.join(modelsDir, file);
|
||||
let text: string;
|
||||
try {
|
||||
text = await Bun.file(filePath).text();
|
||||
} catch (error) {
|
||||
if (symlink && error instanceof Error && "code" in error && error.code === "ENOENT") {
|
||||
brokenSymlinks.add(file);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const parsed = ExistingModel.safeParse(Bun.TOML.parse(text));
|
||||
if (!parsed.success) {
|
||||
parsed.error.cause = { path: filePath };
|
||||
throw parsed.error;
|
||||
}
|
||||
|
||||
const authored = parsed.data as ExistingModel;
|
||||
if (authored.base_model !== undefined && modelMetadata === undefined) {
|
||||
modelMetadata = await readModelMetadata(modelsDir);
|
||||
}
|
||||
const toml = authored.base_model === undefined
|
||||
? authored
|
||||
: resolveBaseModel(authored, modelMetadata ?? {}, filePath);
|
||||
|
||||
existing.set(file, { authored, toml, header: leadingComments(text), symlink });
|
||||
}
|
||||
|
||||
return { models: existing, brokenSymlinks, modelMetadata };
|
||||
}
|
||||
|
||||
async function isSymlink(filePath: string) {
|
||||
try {
|
||||
return (await lstat(filePath)).isSymbolicLink();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function readModelMetadata(modelsDir: string) {
|
||||
const metadataDir = modelMetadataDir(modelsDir);
|
||||
const result: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
for await (const modelPath of new Bun.Glob("**/*.toml").scan({
|
||||
cwd: metadataDir,
|
||||
absolute: true,
|
||||
followSymlinks: true,
|
||||
})) {
|
||||
const modelID = path.relative(metadataDir, modelPath).split(path.sep).join("/").slice(0, -5);
|
||||
const toml = Bun.TOML.parse(
|
||||
await Bun.file(modelPath).text(),
|
||||
) as Record<string, unknown>;
|
||||
result[modelID] = inheritableModelMetadata(toml);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function modelMetadataDir(modelsDir: string) {
|
||||
return path.join(path.dirname(path.dirname(path.dirname(modelsDir))), "models");
|
||||
}
|
||||
|
||||
function resolveBaseModel(
|
||||
authored: ExistingModel,
|
||||
modelMetadata: Record<string, Record<string, unknown>>,
|
||||
modelPath: string,
|
||||
) {
|
||||
const baseModelID = authored.base_model;
|
||||
if (baseModelID === undefined) return authored;
|
||||
|
||||
const base = modelMetadata[baseModelID];
|
||||
if (base === undefined) {
|
||||
throw new Error(`Unable to resolve base_model: ${baseModelID}`, {
|
||||
cause: { modelPath, toml: authored },
|
||||
});
|
||||
}
|
||||
|
||||
const merged = structuredClone(
|
||||
mergeDeep(
|
||||
base,
|
||||
Object.fromEntries(
|
||||
Object.entries(authored).filter(([, value]) => value !== undefined),
|
||||
),
|
||||
),
|
||||
) as Record<string, unknown>;
|
||||
applyOmit(merged, authored.base_model_omit ?? []);
|
||||
|
||||
const parsed = ExistingModel.safeParse(merged);
|
||||
if (!parsed.success) {
|
||||
parsed.error.cause = { modelPath, toml: merged };
|
||||
throw parsed.error;
|
||||
}
|
||||
return parsed.data as ExistingModel;
|
||||
}
|
||||
|
||||
function inheritableModelMetadata(model: Record<string, unknown>) {
|
||||
const {
|
||||
id: _id,
|
||||
benchmarks: _benchmarks,
|
||||
license: _license,
|
||||
links: _links,
|
||||
weights: _weights,
|
||||
...metadata
|
||||
} = model;
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(metadata).filter(([, value]) => value !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function tomlFiles(root: string, dir = "") {
|
||||
const result: Array<{ file: string; symlink: boolean }> = [];
|
||||
|
||||
for (const entry of await readdir(path.join(root, dir), { withFileTypes: true })) {
|
||||
const file = path.join(dir, entry.name).split(path.sep).join("/");
|
||||
if (entry.isDirectory()) {
|
||||
result.push(...await tomlFiles(root, file));
|
||||
} else if (entry.name.endsWith(".toml") && (entry.isFile() || entry.isSymbolicLink())) {
|
||||
result.push({ file, symlink: entry.isSymbolicLink() });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function summarize(
|
||||
provider: { id: string; name: string },
|
||||
files: SyncResult["files"],
|
||||
unchanged: number,
|
||||
notices: string[],
|
||||
): SyncResult {
|
||||
return {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
status: files.length > 0 ? "changed" : "unchanged",
|
||||
created: files.filter((file) => file.status === "created").length,
|
||||
updated: files.filter((file) => file.status === "updated").length,
|
||||
deleted: files.filter((file) => file.status === "deleted").length,
|
||||
unchanged,
|
||||
notices,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
function sameModel(
|
||||
relativePath: string,
|
||||
current: ExistingModel,
|
||||
desired: z.infer<typeof SyncedAuthoredModel>,
|
||||
) {
|
||||
const parsed = SyncedAuthoredModel.safeParse({
|
||||
id: relativePath.slice(0, -5),
|
||||
...current,
|
||||
});
|
||||
return parsed.success && stable(parsed.data) === stable(desired);
|
||||
}
|
||||
|
||||
function stable(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.map(stable);
|
||||
const ordered = value.every((item) => item === null || typeof item !== "object")
|
||||
? items.sort()
|
||||
: items;
|
||||
return `[${ordered.join(",")}]`;
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
return `{${Object.entries(value)
|
||||
.filter(([, item]) => item !== undefined)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${stable(item)}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function stripUndefined<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(stripUndefined) as T;
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter(([, item]) => item !== undefined)
|
||||
.map(([key, item]) => [key, stripUndefined(item)]),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function writeReport(target: string, results: SyncResult[]) {
|
||||
await mkdir(".sync", { recursive: true });
|
||||
|
||||
const lines = [
|
||||
`Updates model TOMLs for the \`${target}\` sync target.`,
|
||||
"",
|
||||
"| Provider | Status | Created | Updated | Deleted |",
|
||||
"| --- | --- | ---: | ---: | ---: |",
|
||||
];
|
||||
|
||||
for (const result of results) {
|
||||
lines.push(
|
||||
`| ${result.name} | ${result.status} | ${result.created} | ${result.updated} | ${result.deleted} |`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const result of results.filter((item) => item.files.length > 0)) {
|
||||
lines.push("", `<details><summary>${result.name} changed files</summary>`, "");
|
||||
for (const file of result.files) {
|
||||
lines.push(`- ${file.status}: \`${file.path}\``);
|
||||
}
|
||||
lines.push("", "</details>");
|
||||
}
|
||||
|
||||
const noticeResults = results.filter((item) => item.notices.length > 0);
|
||||
if (noticeResults.length > 0) {
|
||||
lines.push("", "## Notices");
|
||||
for (const result of noticeResults) {
|
||||
lines.push("", `### ${result.name}`);
|
||||
for (const notice of result.notices) {
|
||||
lines.push(`- ${notice}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("", "This PR was created automatically by the daily model sync workflow.");
|
||||
await Bun.write(".sync/model-sync-report.md", `${lines.join("\n")}\n`);
|
||||
}
|
||||
|
||||
function quote(value: string) {
|
||||
return `"${value
|
||||
.replaceAll("\\", "\\\\")
|
||||
.replaceAll('"', '\\"')
|
||||
.replaceAll("\n", "\\n")
|
||||
.replaceAll("\r", "\\r")
|
||||
.replaceAll("\t", "\\t")}"`;
|
||||
}
|
||||
|
||||
// Preserve the leading comment block (header) authored at the top of a TOML file.
|
||||
// `Bun.TOML.parse` discards comments, so the serializer must re-attach them or
|
||||
// every rewrite would silently delete hand-authored documentation.
|
||||
function leadingComments(text: string) {
|
||||
const header: string[] = [];
|
||||
for (const line of text.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === "" || trimmed.startsWith("#")) {
|
||||
header.push(line);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
while (header.length > 0 && header[header.length - 1]?.trim() === "") header.pop();
|
||||
return header.length > 0 ? `${header.join("\n")}\n` : "";
|
||||
}
|
||||
|
||||
function formatInteger(n: number) {
|
||||
return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, "_");
|
||||
}
|
||||
|
||||
function formatNumber(n: number) {
|
||||
return Number.isInteger(n) ? formatInteger(n) : String(n);
|
||||
}
|
||||
|
||||
function formatKey(value: string) {
|
||||
return /^[A-Za-z0-9_-]+$/.test(value) ? value : quote(value);
|
||||
}
|
||||
|
||||
function formatInlineValue(value: unknown): string {
|
||||
if (typeof value === "string") return quote(value);
|
||||
if (typeof value === "number") return formatNumber(value);
|
||||
if (typeof value === "boolean") return String(value);
|
||||
if (Array.isArray(value)) return `[${value.map(formatInlineValue).join(", ")}]`;
|
||||
if (value !== null && typeof value === "object") {
|
||||
const fields = Object.entries(value)
|
||||
.filter(([, item]) => item !== undefined)
|
||||
.map(([key, item]) => `${formatKey(key)} = ${formatInlineValue(item)}`);
|
||||
return `{ ${fields.join(", ")} }`;
|
||||
}
|
||||
throw new Error("Cannot serialize null or undefined as TOML");
|
||||
}
|
||||
|
||||
function formatReasoningValue(value: string | null) {
|
||||
return value === null ? quote("null") : quote(value);
|
||||
}
|
||||
|
||||
const ReasoningEffortOrder = new Map<string | null, number>([
|
||||
["none", 0],
|
||||
["minimal", 1],
|
||||
["low", 2],
|
||||
["medium", 3],
|
||||
["high", 4],
|
||||
["xhigh", 5],
|
||||
["max", 6],
|
||||
["default", 7],
|
||||
[null, 8],
|
||||
]);
|
||||
|
||||
function sortReasoningValues(values: Array<string | null>) {
|
||||
return [...values].sort((a, b) => {
|
||||
const order = (ReasoningEffortOrder.get(a) ?? Number.MAX_SAFE_INTEGER)
|
||||
- (ReasoningEffortOrder.get(b) ?? Number.MAX_SAFE_INTEGER);
|
||||
return order || formatReasoningValue(a).localeCompare(formatReasoningValue(b));
|
||||
});
|
||||
}
|
||||
|
||||
export function formatToml(model: z.infer<typeof SyncedAuthoredModel>) {
|
||||
const lines: string[] = [];
|
||||
|
||||
if ("base_model" in model && model.base_model !== undefined) {
|
||||
lines.push(`base_model = ${quote(model.base_model)}`);
|
||||
}
|
||||
if ("base_model_omit" in model && model.base_model_omit !== undefined) {
|
||||
lines.push(`base_model_omit = [${model.base_model_omit.map(quote).join(", ")}]`);
|
||||
}
|
||||
if (model.name !== undefined) lines.push(`name = ${quote(model.name)}`);
|
||||
if (model.description !== undefined) lines.push(`description = ${quote(model.description)}`);
|
||||
if (model.family !== undefined) lines.push(`family = ${quote(model.family)}`);
|
||||
if (model.release_date !== undefined) lines.push(`release_date = ${quote(model.release_date)}`);
|
||||
if (model.last_updated !== undefined) lines.push(`last_updated = ${quote(model.last_updated)}`);
|
||||
if (model.attachment !== undefined) lines.push(`attachment = ${model.attachment}`);
|
||||
if (model.reasoning !== undefined) lines.push(`reasoning = ${model.reasoning}`);
|
||||
if (model.temperature !== undefined) lines.push(`temperature = ${model.temperature}`);
|
||||
if (model.tool_call !== undefined) lines.push(`tool_call = ${model.tool_call}`);
|
||||
if (model.structured_output !== undefined) {
|
||||
lines.push(`structured_output = ${model.structured_output}`);
|
||||
}
|
||||
if (model.knowledge !== undefined) lines.push(`knowledge = ${quote(model.knowledge)}`);
|
||||
if (model.open_weights !== undefined) lines.push(`open_weights = ${model.open_weights}`);
|
||||
if (model.status !== undefined) lines.push(`status = ${quote(model.status)}`);
|
||||
if (model.reasoning_options?.length === 0) lines.push("reasoning_options = []");
|
||||
|
||||
if (model.interleaved !== undefined) {
|
||||
lines.push("");
|
||||
if (model.interleaved === true) {
|
||||
lines.push("interleaved = true");
|
||||
} else {
|
||||
lines.push("[interleaved]");
|
||||
lines.push(`field = ${quote(model.interleaved.field)}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const option of model.reasoning_options ?? []) {
|
||||
lines.push("", "[[reasoning_options]]");
|
||||
lines.push(`type = ${quote(option.type)}`);
|
||||
if (option.type === "effort") {
|
||||
const values = sortReasoningValues(option.values).map(formatReasoningValue).join(", ");
|
||||
lines.push(`values = [${values}]`);
|
||||
}
|
||||
if (option.type === "budget_tokens") {
|
||||
if (option.min !== undefined) lines.push(`min = ${formatInteger(option.min)}`);
|
||||
if (option.max !== undefined) lines.push(`max = ${formatInteger(option.max)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (model.cost !== undefined) {
|
||||
lines.push("", "[cost]");
|
||||
if (model.cost.input !== undefined) lines.push(`input = ${formatNumber(model.cost.input)}`);
|
||||
if (model.cost.output !== undefined) lines.push(`output = ${formatNumber(model.cost.output)}`);
|
||||
if (model.cost.reasoning !== undefined) {
|
||||
lines.push(`reasoning = ${formatNumber(model.cost.reasoning)}`);
|
||||
}
|
||||
if (model.cost.cache_read !== undefined) {
|
||||
lines.push(`cache_read = ${formatNumber(model.cost.cache_read)}`);
|
||||
}
|
||||
if (model.cost.cache_write !== undefined) {
|
||||
lines.push(`cache_write = ${formatNumber(model.cost.cache_write)}`);
|
||||
}
|
||||
if (model.cost.input_audio !== undefined) {
|
||||
lines.push(`input_audio = ${formatNumber(model.cost.input_audio)}`);
|
||||
}
|
||||
if (model.cost.output_audio !== undefined) {
|
||||
lines.push(`output_audio = ${formatNumber(model.cost.output_audio)}`);
|
||||
}
|
||||
|
||||
for (const tier of model.cost.tiers ?? []) {
|
||||
lines.push("", "[[cost.tiers]]");
|
||||
if (tier.tier?.size !== undefined) {
|
||||
lines.push(`tier = { type = ${quote(tier.tier.type ?? "context")}, size = ${formatInteger(tier.tier.size)} }`);
|
||||
}
|
||||
if (tier.input !== undefined) lines.push(`input = ${formatNumber(tier.input)}`);
|
||||
if (tier.output !== undefined) lines.push(`output = ${formatNumber(tier.output)}`);
|
||||
if (tier.reasoning !== undefined) lines.push(`reasoning = ${formatNumber(tier.reasoning)}`);
|
||||
if (tier.cache_read !== undefined) lines.push(`cache_read = ${formatNumber(tier.cache_read)}`);
|
||||
if (tier.cache_write !== undefined) lines.push(`cache_write = ${formatNumber(tier.cache_write)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (model.limit !== undefined) {
|
||||
lines.push("", "[limit]");
|
||||
if (model.limit.context !== undefined) lines.push(`context = ${formatInteger(model.limit.context)}`);
|
||||
if (model.limit.input !== undefined) lines.push(`input = ${formatInteger(model.limit.input)}`);
|
||||
if (model.limit.output !== undefined) lines.push(`output = ${formatInteger(model.limit.output)}`);
|
||||
}
|
||||
|
||||
if (model.modalities !== undefined) {
|
||||
lines.push("", "[modalities]");
|
||||
if (model.modalities.input !== undefined) {
|
||||
lines.push(`input = [${model.modalities.input.map(quote).join(", ")}]`);
|
||||
}
|
||||
if (model.modalities.output !== undefined) {
|
||||
lines.push(`output = [${model.modalities.output.map(quote).join(", ")}]`);
|
||||
}
|
||||
}
|
||||
|
||||
if (model.provider !== undefined) {
|
||||
lines.push("", "[provider]");
|
||||
if (model.provider.npm !== undefined) lines.push(`npm = ${quote(model.provider.npm)}`);
|
||||
if (model.provider.api !== undefined) lines.push(`api = ${quote(model.provider.api)}`);
|
||||
if (model.provider.shape !== undefined) lines.push(`shape = ${quote(model.provider.shape)}`);
|
||||
if (model.provider.body !== undefined) lines.push(`body = ${formatInlineValue(model.provider.body)}`);
|
||||
if (model.provider.headers !== undefined) lines.push(`headers = ${formatInlineValue(model.provider.headers)}`);
|
||||
}
|
||||
|
||||
for (const [name, mode] of Object.entries(model.experimental?.modes ?? {})) {
|
||||
lines.push("", `[experimental.modes.${formatKey(name)}]`);
|
||||
if (mode.cost !== undefined) lines.push(`cost = ${formatInlineValue(mode.cost)}`);
|
||||
if (mode.provider !== undefined) lines.push(`provider = ${formatInlineValue(mode.provider)}`);
|
||||
}
|
||||
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function formatMetadataToml(model: z.infer<typeof ModelMetadata>) {
|
||||
const content = formatToml(model as unknown as z.infer<typeof SyncedAuthoredModel>).trimEnd();
|
||||
const lines = [content];
|
||||
for (const weight of model.weights ?? []) {
|
||||
lines.push("", "[[weights]]");
|
||||
if (weight.label !== undefined) lines.push(`label = ${quote(weight.label)}`);
|
||||
lines.push(`url = ${quote(weight.url)}`);
|
||||
if (weight.format !== undefined) lines.push(`format = ${quote(weight.format)}`);
|
||||
if (weight.quantization !== undefined) lines.push(`quantization = ${quote(weight.quantization)}`);
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
export async function main(args = process.argv.slice(2)) {
|
||||
if (args.includes("--list-providers")) {
|
||||
console.log(JSON.stringify(syncProviderMatrix()));
|
||||
return;
|
||||
}
|
||||
|
||||
const target = args.find((arg) => !arg.startsWith("-")) ?? "aggregators";
|
||||
const results = await syncTargets(target, {
|
||||
dryRun: args.includes("--dry-run"),
|
||||
newOnly: args.includes("--new-only"),
|
||||
});
|
||||
|
||||
await writeReport(target, results);
|
||||
|
||||
console.log("\nSync summary");
|
||||
for (const result of results) {
|
||||
console.log(
|
||||
`${result.name}: ${result.created} created, ${result.updated} updated, ${result.deleted} deleted`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) await main();
|
||||
@@ -0,0 +1,114 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { SyncProvider } from "../index.js";
|
||||
import { buildOpenRouterModel, type OpenRouterModel } from "./openrouter.js";
|
||||
|
||||
const API_ENDPOINT = "https://api.ambient.xyz/v1/models";
|
||||
|
||||
export const AmbientModel = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
created: z.number(),
|
||||
hugging_face_id: z.string().nullable().optional(),
|
||||
context_length: z.number(),
|
||||
max_output_length: z.number(),
|
||||
input_modalities: z.array(z.string()),
|
||||
output_modalities: z.array(z.string()),
|
||||
pricing: z.object({
|
||||
prompt: z.string(),
|
||||
completion: z.string(),
|
||||
input_cache_read: z.string().optional(),
|
||||
input_cache_write: z.string().optional(),
|
||||
}).passthrough(),
|
||||
supported_features: z.array(z.string()).default([]),
|
||||
supported_sampling_parameters: z.array(z.string()).default([]),
|
||||
openrouter: z.object({ slug: z.string() }).nullable().optional(),
|
||||
is_ready: z.boolean().default(false),
|
||||
}).passthrough();
|
||||
|
||||
export const AmbientResponse = z.object({
|
||||
object: z.literal("list"),
|
||||
data: z.array(AmbientModel),
|
||||
}).passthrough();
|
||||
|
||||
export type AmbientModel = z.infer<typeof AmbientModel>;
|
||||
|
||||
function toOpenRouterShape(model: AmbientModel): OpenRouterModel {
|
||||
return {
|
||||
id: model.openrouter?.slug ?? model.id,
|
||||
name: model.name,
|
||||
created: model.created,
|
||||
hugging_face_id: model.hugging_face_id ?? null,
|
||||
knowledge_cutoff: null,
|
||||
context_length: model.context_length,
|
||||
architecture: {
|
||||
input_modalities: model.input_modalities,
|
||||
output_modalities: model.output_modalities,
|
||||
},
|
||||
pricing: {
|
||||
prompt: model.pricing.prompt,
|
||||
completion: model.pricing.completion,
|
||||
input_cache_read: model.pricing.input_cache_read,
|
||||
input_cache_write: model.pricing.input_cache_write,
|
||||
},
|
||||
top_provider: {
|
||||
context_length: model.context_length,
|
||||
max_completion_tokens: model.max_output_length,
|
||||
},
|
||||
supported_parameters: [...model.supported_features, ...model.supported_sampling_parameters],
|
||||
};
|
||||
}
|
||||
|
||||
export const ambient = {
|
||||
id: "ambient",
|
||||
name: "Ambient",
|
||||
modelsDir: "providers/ambient/models",
|
||||
deleteMissing: false,
|
||||
sourceID(model) {
|
||||
return model.id;
|
||||
},
|
||||
skippedNotice(ids) {
|
||||
if (ids.length === 0) return [];
|
||||
return [
|
||||
`${ids.length} Ambient models were skipped because the catalog reports them as not ready (is_ready=false).`,
|
||||
`Skipped remote IDs: ${ids.map((id) => `\`${id}\``).join(", ")}`,
|
||||
];
|
||||
},
|
||||
missingNotice(paths) {
|
||||
if (paths.length === 0) return [];
|
||||
return [
|
||||
`${paths.length} local Ambient models were absent from the catalog and were retained for manual lifecycle review.`,
|
||||
`Retained local paths: ${paths.map((item) => `\`${item}\``).join(", ")}`,
|
||||
];
|
||||
},
|
||||
async fetchModels() {
|
||||
const response = await fetch(API_ENDPOINT);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ambient request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
parseModels(raw) {
|
||||
return AmbientResponse.parse(raw).data;
|
||||
},
|
||||
translateModel(model, context) {
|
||||
if (!model.is_ready) return undefined;
|
||||
const built = buildOpenRouterModel(toOpenRouterShape(model), context.existing(model.id));
|
||||
const reasoning = model.supported_features.includes("reasoning");
|
||||
const withOptions = reasoning ? { ...built, reasoning_options: [] } : built;
|
||||
const aliasName = ambientAliasName(model.id);
|
||||
return {
|
||||
id: model.id,
|
||||
model: aliasName === undefined ? withOptions : { ...withOptions, name: aliasName },
|
||||
};
|
||||
},
|
||||
} satisfies SyncProvider<AmbientModel>;
|
||||
|
||||
function ambientAliasName(id: string): string | undefined {
|
||||
if (!id.startsWith("ambient/")) return undefined;
|
||||
const label = id.slice("ambient/".length)
|
||||
.split(/[/-]/)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
return `Ambient ${label}`;
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import path from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ExistingModel, SyncProvider, SyncedModel } from "../index.js";
|
||||
|
||||
const API_ENDPOINT = "https://api.anthropic.com/v1/models";
|
||||
const PRICING_ENDPOINT = "https://platform.claude.com/docs/en/about-claude/pricing";
|
||||
const METADATA_DIR = path.join(import.meta.dirname, "..", "..", "..", "..", "..", "models", "anthropic");
|
||||
|
||||
const CapabilitySupport = z.object({ supported: z.boolean() }).passthrough();
|
||||
|
||||
const AnthropicModel = z.object({
|
||||
id: z.string(),
|
||||
canonical_id: z.string().optional(),
|
||||
display_name: z.string(),
|
||||
created_at: z.string(),
|
||||
max_input_tokens: z.number().int().nonnegative(),
|
||||
max_tokens: z.number().int().nonnegative(),
|
||||
capabilities: z.object({
|
||||
effort: z.object({
|
||||
supported: z.boolean(),
|
||||
low: CapabilitySupport.optional(),
|
||||
medium: CapabilitySupport.optional(),
|
||||
high: CapabilitySupport.optional(),
|
||||
xhigh: CapabilitySupport.optional(),
|
||||
max: CapabilitySupport.optional(),
|
||||
}).passthrough().optional(),
|
||||
image_input: CapabilitySupport.optional(),
|
||||
pdf_input: CapabilitySupport.optional(),
|
||||
structured_outputs: CapabilitySupport.optional(),
|
||||
thinking: z.object({
|
||||
supported: z.boolean(),
|
||||
types: z.object({
|
||||
adaptive: CapabilitySupport.optional(),
|
||||
enabled: CapabilitySupport.optional(),
|
||||
}).passthrough().optional(),
|
||||
}).passthrough().optional(),
|
||||
}).passthrough(),
|
||||
}).passthrough();
|
||||
|
||||
const AnthropicPage = z.object({
|
||||
data: z.array(AnthropicModel),
|
||||
has_more: z.boolean(),
|
||||
last_id: z.string().nullable().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const AnthropicResponse = z.object({
|
||||
models: z.array(AnthropicModel),
|
||||
pricing: z.string(),
|
||||
});
|
||||
|
||||
export type AnthropicModel = z.infer<typeof AnthropicModel>;
|
||||
|
||||
export interface AnthropicPricing {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
deprecated: boolean;
|
||||
}
|
||||
|
||||
interface AnthropicSourceModel extends AnthropicModel {
|
||||
pricing?: AnthropicPricing;
|
||||
}
|
||||
|
||||
export const anthropic = {
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
modelsDir: "providers/anthropic/models",
|
||||
sourceID(model) {
|
||||
return model.id;
|
||||
},
|
||||
skippedNotice(ids) {
|
||||
if (ids.length === 0) return [];
|
||||
return [
|
||||
`${ids.length} Anthropic models were not created because no matching canonical models/anthropic metadata entry exists.`,
|
||||
`Skipped remote IDs: ${ids.map((id) => `\`${id}\``).join(", ")}`,
|
||||
];
|
||||
},
|
||||
async fetchModels() {
|
||||
const key = process.env.ANTHROPIC_API_KEY;
|
||||
if (!key) throw new Error("Anthropic sync requires ANTHROPIC_API_KEY");
|
||||
|
||||
const [models, pricing] = await Promise.all([
|
||||
fetchAllModels(key),
|
||||
fetchPricing(),
|
||||
]);
|
||||
return { models: [...models, ...await fetchAliases(key, models)], pricing };
|
||||
},
|
||||
parseModels(raw) {
|
||||
const response = AnthropicResponse.parse(raw);
|
||||
const pricing = parseAnthropicPricing(response.pricing);
|
||||
return response.models.map((model) => ({
|
||||
...model,
|
||||
pricing: pricing.get(normalizeModelName(model.display_name)),
|
||||
}));
|
||||
},
|
||||
translateModel(model, context) {
|
||||
const existing = context.existing(model.id);
|
||||
if (existing !== undefined) {
|
||||
const baseModel = context.authored(model.id)?.base_model;
|
||||
return { id: model.id, model: buildAnthropicModel(model, existing, baseModel) };
|
||||
}
|
||||
|
||||
const baseModel = `anthropic/${model.id}`;
|
||||
if (!existsSync(path.join(METADATA_DIR, `${model.id}.toml`))) return undefined;
|
||||
const canonical = model.canonical_id === undefined ? undefined : context.existing(model.canonical_id);
|
||||
return { id: model.id, model: buildAnthropicModel(model, canonical, baseModel) };
|
||||
},
|
||||
} satisfies SyncProvider<AnthropicSourceModel>;
|
||||
|
||||
async function fetchAllModels(key: string) {
|
||||
const models: AnthropicModel[] = [];
|
||||
let afterID: string | undefined;
|
||||
|
||||
do {
|
||||
const url = new URL(API_ENDPOINT);
|
||||
url.searchParams.set("limit", "1000");
|
||||
if (afterID !== undefined) url.searchParams.set("after_id", afterID);
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"x-api-key": key,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Anthropic models request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const page = AnthropicPage.parse(await response.json());
|
||||
models.push(...page.data);
|
||||
if (page.has_more && page.last_id === undefined) {
|
||||
throw new Error("Anthropic models response has_more without last_id");
|
||||
}
|
||||
afterID = page.has_more ? page.last_id ?? undefined : undefined;
|
||||
} while (afterID !== undefined);
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
async function fetchAliases(key: string, models: AnthropicModel[]) {
|
||||
const canonicalIDs = new Set(models.map((model) => model.id));
|
||||
const candidates = [...new Set(models
|
||||
.map((model) => model.id.replace(/-\d{8}$/, ""))
|
||||
.filter((id) => !canonicalIDs.has(id)))];
|
||||
|
||||
const aliases = await Promise.all(candidates.map(async (id) => {
|
||||
const response = await fetch(`${API_ENDPOINT}/${id}`, {
|
||||
headers: {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"x-api-key": key,
|
||||
},
|
||||
});
|
||||
if (response.status === 404) return undefined;
|
||||
if (!response.ok) {
|
||||
throw new Error(`Anthropic model alias request failed for ${id}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const model = AnthropicModel.parse(await response.json());
|
||||
return { ...model, id, canonical_id: model.id };
|
||||
}));
|
||||
|
||||
return aliases.filter((model): model is AnthropicModel => model !== undefined);
|
||||
}
|
||||
|
||||
async function fetchPricing() {
|
||||
const response = await fetch(PRICING_ENDPOINT, {
|
||||
headers: { Accept: "text/markdown" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Anthropic pricing request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
function markdownText(value: string) {
|
||||
return value
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replaceAll("**", "")
|
||||
.replaceAll("`", "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function effectiveOn(label: string, now: Date) {
|
||||
const through = label.match(/\bthrough ([A-Z][a-z]+ \d{1,2}, \d{4})/i)?.[1];
|
||||
if (through !== undefined && now.getTime() > Date.parse(`${through} 23:59:59 UTC`)) return false;
|
||||
const starting = label.match(/\bstarting ([A-Z][a-z]+ \d{1,2}, \d{4})/i)?.[1];
|
||||
if (starting !== undefined && now.getTime() < Date.parse(`${starting} 00:00:00 UTC`)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function normalizeModelName(value: string) {
|
||||
return markdownText(value)
|
||||
.replace(/\s*\([^)]*(?:deprecated|retired|limited availability)[^)]*\)/gi, "")
|
||||
.replace(/\s+(?:through|starting) [A-Z][a-z]+ \d{1,2}, \d{4}.*$/i, "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function price(value: string) {
|
||||
const match = markdownText(value).match(/\$([\d.]+)\s*\/\s*MTok/i);
|
||||
return match === null ? undefined : Number(match[1]);
|
||||
}
|
||||
|
||||
export function parseAnthropicPricing(markdown: string, now = new Date()) {
|
||||
const section = markdown.split(/^## Model pricing\s*$/m)[1]?.split(/^## /m)[0];
|
||||
if (section === undefined) throw new Error("Anthropic pricing page is missing the Model pricing section");
|
||||
|
||||
const table = section.split("\n").filter((line) => line.trimStart().startsWith("|"));
|
||||
const rows = table.map((line) => line.split("|").slice(1, -1).map((cell) => cell.trim()));
|
||||
const header = rows[0]?.map(markdownText);
|
||||
if (header === undefined) throw new Error("Anthropic pricing page is missing the model pricing table");
|
||||
|
||||
const indexes = {
|
||||
model: header.indexOf("Model"),
|
||||
input: header.indexOf("Base Input Tokens"),
|
||||
cacheWrite: header.indexOf("5m Cache Writes"),
|
||||
cacheRead: header.indexOf("Cache Hits & Refreshes"),
|
||||
output: header.indexOf("Output Tokens"),
|
||||
};
|
||||
if (Object.values(indexes).some((index) => index < 0)) {
|
||||
throw new Error("Anthropic model pricing table has unexpected columns");
|
||||
}
|
||||
|
||||
const result = new Map<string, AnthropicPricing>();
|
||||
for (const row of rows.slice(2)) {
|
||||
const label = markdownText(row[indexes.model] ?? "");
|
||||
if (label === "" || !effectiveOn(label, now)) continue;
|
||||
const input = price(row[indexes.input] ?? "");
|
||||
const output = price(row[indexes.output] ?? "");
|
||||
const cacheRead = price(row[indexes.cacheRead] ?? "");
|
||||
const cacheWrite = price(row[indexes.cacheWrite] ?? "");
|
||||
if (input === undefined || output === undefined || cacheRead === undefined || cacheWrite === undefined) {
|
||||
throw new Error(`Anthropic pricing row has invalid prices: ${label}`);
|
||||
}
|
||||
result.set(normalizeModelName(label), {
|
||||
input,
|
||||
output,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
deprecated: /\b(?:deprecated|retired)\b/i.test(label),
|
||||
});
|
||||
}
|
||||
|
||||
if (result.size < 5) throw new Error(`Anthropic pricing table returned only ${result.size} active models`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function releaseDate(value: string, fallback: string | undefined) {
|
||||
const timestamp = Date.parse(value);
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return fallback;
|
||||
return new Date(timestamp).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function reasoningOptions(model: AnthropicModel, existing: ExistingModel | undefined) {
|
||||
if (model.capabilities.thinking?.supported !== true) return undefined;
|
||||
const enabled = model.capabilities.thinking.types?.enabled?.supported === true;
|
||||
const options = (existing?.reasoning_options ?? []).filter((option) => {
|
||||
if (option.type === "effort") return false;
|
||||
if (option.type === "budget_tokens") return enabled;
|
||||
return true;
|
||||
});
|
||||
if (enabled && !options.some((option) => option.type === "budget_tokens")) {
|
||||
options.push({ type: "budget_tokens" });
|
||||
}
|
||||
const effort = model.capabilities.effort;
|
||||
if (effort?.supported) {
|
||||
const values = (["low", "medium", "high", "xhigh", "max"] as const)
|
||||
.filter((value) => effort[value]?.supported === true);
|
||||
if (values.length > 0) {
|
||||
const budgetIndex = options.findIndex((option) => option.type === "budget_tokens");
|
||||
options.splice(budgetIndex < 0 ? options.length : budgetIndex, 0, { type: "effort", values });
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function syncedCost(model: AnthropicSourceModel, existing: ExistingModel | undefined) {
|
||||
if (model.pricing === undefined) return existing?.cost;
|
||||
return {
|
||||
input: model.pricing.input,
|
||||
output: model.pricing.output,
|
||||
cache_read: model.pricing.cacheRead,
|
||||
cache_write: model.pricing.cacheWrite,
|
||||
reasoning: existing?.cost?.reasoning,
|
||||
input_audio: existing?.cost?.input_audio,
|
||||
output_audio: existing?.cost?.output_audio,
|
||||
tiers: existing?.cost?.tiers,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAnthropicModel(
|
||||
model: AnthropicSourceModel,
|
||||
existing: ExistingModel | undefined,
|
||||
baseModel?: string,
|
||||
): SyncedModel {
|
||||
const name = model.canonical_id !== undefined && !model.display_name.endsWith("(latest)")
|
||||
? `${model.display_name} (latest)`
|
||||
: model.display_name;
|
||||
const reasoning = model.capabilities.thinking?.supported ?? existing?.reasoning ?? false;
|
||||
const input = [
|
||||
"text" as const,
|
||||
...(model.capabilities.image_input?.supported ? ["image" as const] : []),
|
||||
...(model.capabilities.pdf_input?.supported ? ["pdf" as const] : []),
|
||||
];
|
||||
const context = model.max_input_tokens > 0
|
||||
? model.max_input_tokens
|
||||
: existing?.limit?.context;
|
||||
const output = model.max_tokens > 0 ? model.max_tokens : existing?.limit?.output;
|
||||
const cost = syncedCost(model, existing);
|
||||
const options = reasoningOptions(model, existing);
|
||||
|
||||
if (baseModel !== undefined) {
|
||||
return {
|
||||
base_model: baseModel,
|
||||
name: model.canonical_id === undefined ? undefined : name,
|
||||
attachment: input.length > 1,
|
||||
reasoning,
|
||||
reasoning_options: options,
|
||||
structured_output: model.capabilities.structured_outputs?.supported,
|
||||
status: model.pricing?.deprecated ? "deprecated" : undefined,
|
||||
cost,
|
||||
limit: context !== undefined && output !== undefined ? { context, output } : undefined,
|
||||
modalities: { input, output: ["text"] },
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
existing?.description === undefined
|
||||
|| existing.release_date === undefined
|
||||
|| existing.last_updated === undefined
|
||||
|| existing.tool_call === undefined
|
||||
|| existing.open_weights === undefined
|
||||
|| context === undefined
|
||||
|| output === undefined
|
||||
) {
|
||||
throw new Error(`Anthropic model ${model.id} has incomplete local TOML metadata required for sync`);
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
description: existing.description,
|
||||
family: existing.family,
|
||||
release_date: releaseDate(model.created_at, existing.release_date) ?? existing.release_date,
|
||||
last_updated: existing.last_updated,
|
||||
attachment: input.length > 1,
|
||||
reasoning,
|
||||
reasoning_options: options,
|
||||
temperature: existing.temperature,
|
||||
tool_call: existing.tool_call,
|
||||
structured_output: model.capabilities.structured_outputs?.supported ?? existing.structured_output,
|
||||
knowledge: existing.knowledge,
|
||||
open_weights: existing.open_weights,
|
||||
status: model.pricing?.deprecated ? "deprecated" : existing.status,
|
||||
interleaved: existing.interleaved,
|
||||
experimental: existing.experimental,
|
||||
provider: existing.provider,
|
||||
cost,
|
||||
limit: { context, input: existing.limit?.input, output },
|
||||
modalities: { input, output: ["text"] },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { describeModel } from "../../describe.js";
|
||||
import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js";
|
||||
import { factorBaseModel, resolveCanonicalBaseModel } from "./openrouter.js";
|
||||
|
||||
const API_ENDPOINT = "https://inference.baseten.co/v1/models";
|
||||
|
||||
const Price = z.union([z.string(), z.number()]);
|
||||
|
||||
export const BasetenModel = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
context_length: z.number().int().positive(),
|
||||
max_completion_tokens: z.number().int().positive(),
|
||||
input_modalities: z.array(z.string()),
|
||||
output_modalities: z.array(z.string()),
|
||||
pricing: z.object({
|
||||
prompt: Price,
|
||||
completion: Price,
|
||||
}).passthrough(),
|
||||
supported_features: z.array(z.string()),
|
||||
supported_sampling_parameters: z.array(z.string()),
|
||||
}).passthrough();
|
||||
|
||||
export const BasetenResponse = z.object({
|
||||
data: z.array(BasetenModel),
|
||||
}).passthrough();
|
||||
|
||||
export type BasetenModel = z.infer<typeof BasetenModel>;
|
||||
|
||||
export const baseten = {
|
||||
id: "baseten",
|
||||
name: "Baseten",
|
||||
modelsDir: "providers/baseten/models",
|
||||
deleteMissing: false,
|
||||
sourceID(model) {
|
||||
return model.id;
|
||||
},
|
||||
skippedNotice(ids) {
|
||||
if (ids.length === 0) return [];
|
||||
return [
|
||||
`${ids.length} Baseten models were not created because their slugs could not be mapped exactly to provider-agnostic metadata.`,
|
||||
`Skipped remote IDs: ${ids.map((id) => `\`${id}\``).join(", ")}`,
|
||||
];
|
||||
},
|
||||
missingNotice(paths) {
|
||||
if (paths.length === 0) return [];
|
||||
return [
|
||||
`${paths.length} local Baseten models were absent from the catalog and were retained for manual lifecycle review.`,
|
||||
`Retained local paths: ${paths.map((item) => `\`${item}\``).join(", ")}`,
|
||||
];
|
||||
},
|
||||
async fetchModels() {
|
||||
const key = process.env.BASETEN_API_KEY;
|
||||
if (key === undefined) throw new Error("Baseten sync requires BASETEN_API_KEY");
|
||||
return fetchBasetenModels(key);
|
||||
},
|
||||
parseModels(raw) {
|
||||
return BasetenResponse.parse(raw).data;
|
||||
},
|
||||
translateModel(model, context) {
|
||||
const existing = context.existing(model.id);
|
||||
const baseModel = existing === undefined
|
||||
? resolveBasetenBaseModel(model.id)
|
||||
: existing.base_model;
|
||||
if (existing === undefined && baseModel === undefined) return undefined;
|
||||
if (
|
||||
existing === undefined
|
||||
&& (price(model.pricing.prompt) === undefined || price(model.pricing.completion) === undefined)
|
||||
) return undefined;
|
||||
|
||||
return {
|
||||
id: model.id,
|
||||
model: buildBasetenModel(model, existing, baseModel),
|
||||
};
|
||||
},
|
||||
} satisfies SyncProvider<BasetenModel>;
|
||||
|
||||
export async function fetchBasetenModels(
|
||||
key: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
) {
|
||||
const response = await fetcher(API_ENDPOINT, {
|
||||
headers: { Authorization: `Api-Key ${key}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Baseten models request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return BasetenResponse.parse(await response.json());
|
||||
}
|
||||
|
||||
function price(value: string | number | undefined) {
|
||||
if (value === undefined || value === "") return undefined;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number >= 0
|
||||
? Math.round(number * 1_000_000_000_000) / 1_000_000
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function buildBasetenModel(
|
||||
model: BasetenModel,
|
||||
existing: ExistingModel | undefined,
|
||||
baseModel = existing === undefined ? resolveBasetenBaseModel(model.id) : existing.base_model,
|
||||
): SyncedModel {
|
||||
const features = new Set(model.supported_features);
|
||||
const samplingParameters = new Set(model.supported_sampling_parameters);
|
||||
const input = modalities(model.input_modalities, existing?.modalities?.input ?? ["text"]);
|
||||
const output = modalities(model.output_modalities, existing?.modalities?.output ?? ["text"]);
|
||||
const inputCost = price(model.pricing.prompt);
|
||||
const outputCost = price(model.pricing.completion);
|
||||
const cost = inputCost !== undefined && outputCost !== undefined
|
||||
? {
|
||||
input: inputCost,
|
||||
output: outputCost,
|
||||
reasoning: existing?.cost?.reasoning,
|
||||
cache_read: existing?.cost?.cache_read,
|
||||
cache_write: existing?.cost?.cache_write,
|
||||
tiers: existing?.cost?.tiers,
|
||||
}
|
||||
: existing?.cost;
|
||||
const limit = {
|
||||
context: model.context_length,
|
||||
input: existing?.limit?.input,
|
||||
output: model.max_completion_tokens,
|
||||
};
|
||||
const values: Partial<SyncedFullModel> = {
|
||||
name: model.name ?? existing?.name,
|
||||
description: existing?.description ?? describeModel({
|
||||
id: model.id,
|
||||
name: model.name ?? existing?.name,
|
||||
family: existing?.family,
|
||||
reasoning: features.has("reasoning") || existing?.reasoning,
|
||||
tool_call: features.has("tools") || existing?.tool_call,
|
||||
structured_output: features.has("structured_outputs") || existing?.structured_output,
|
||||
open_weights: existing?.open_weights,
|
||||
limit,
|
||||
modalities: { input, output },
|
||||
}),
|
||||
family: existing?.family,
|
||||
release_date: existing?.release_date,
|
||||
last_updated: existing?.last_updated,
|
||||
attachment: input.some((value) => value !== "text"),
|
||||
reasoning: features.has("reasoning") || existing?.reasoning,
|
||||
reasoning_options: existing?.reasoning_options,
|
||||
temperature: samplingParameters.has("temperature"),
|
||||
tool_call: features.has("tools") || existing?.tool_call,
|
||||
structured_output: features.has("structured_outputs") || existing?.structured_output,
|
||||
knowledge: existing?.knowledge,
|
||||
open_weights: existing?.open_weights,
|
||||
status: existing?.status,
|
||||
interleaved: existing?.interleaved,
|
||||
cost,
|
||||
limit,
|
||||
modalities: { input, output },
|
||||
};
|
||||
|
||||
if (baseModel !== undefined) {
|
||||
if (limit.context === undefined || limit.output === undefined) {
|
||||
throw new Error(`Baseten model ${model.id} has incomplete token limits required for sync`);
|
||||
}
|
||||
return factorBaseModel(baseModel, values, limit, existing?.base_model_omit);
|
||||
}
|
||||
|
||||
const required = z.object({
|
||||
name: z.string(),
|
||||
release_date: z.string(),
|
||||
last_updated: z.string(),
|
||||
description: z.string(),
|
||||
open_weights: z.boolean(),
|
||||
cost: z.object({ input: z.number(), output: z.number() }),
|
||||
}).safeParse(values);
|
||||
if (!required.success) {
|
||||
throw new Error(`Baseten model ${model.id} has incomplete local metadata required for sync`);
|
||||
}
|
||||
return values as SyncedFullModel;
|
||||
}
|
||||
|
||||
export function resolveBasetenBaseModel(id: string) {
|
||||
const [prefix, ...parts] = id.split("/");
|
||||
if (prefix === undefined || parts.length === 0) return undefined;
|
||||
const canonicalPrefix = {
|
||||
"deepseek-ai": "deepseek",
|
||||
MiniMaxAI: "minimax",
|
||||
moonshotai: "moonshotai",
|
||||
nvidia: "nvidia",
|
||||
"zai-org": "zai",
|
||||
}[prefix];
|
||||
if (canonicalPrefix === undefined) return resolveCanonicalBaseModel(id);
|
||||
return resolveCanonicalBaseModel(`${canonicalPrefix}/${parts.join("/").toLowerCase()}`);
|
||||
}
|
||||
|
||||
type Modality = "text" | "audio" | "image" | "video" | "pdf";
|
||||
|
||||
function modalities(values: string[], fallback: Modality[]): Modality[] {
|
||||
const allowed = new Set<Modality>(["text", "audio", "image", "video", "pdf"]);
|
||||
const result = values
|
||||
.map((value) => value.toLowerCase())
|
||||
.filter((value): value is Modality => allowed.has(value as Modality));
|
||||
return [...new Set(result.length > 0 ? result : fallback)];
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
|
||||
import { describeModel } from "../../describe.js";
|
||||
import { inferKimiFamily, ModelFamilyValues } from "../../family.js";
|
||||
import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js";
|
||||
import { factorBaseModel } from "./openrouter.js";
|
||||
|
||||
const API_ENDPOINT = "https://llm.chutes.ai/v1/models";
|
||||
const MODELS_DIR = path.join(import.meta.dirname, "..", "..", "..", "..", "..", "models");
|
||||
|
||||
const CHUTES_ORG_TO_MODEL_PROVIDER: Record<string, string | undefined> = {
|
||||
MiniMaxAI: "minimax",
|
||||
Qwen: "alibaba",
|
||||
XiaomiMiMo: "xiaomi",
|
||||
"deepseek-ai": "deepseek",
|
||||
google: "google",
|
||||
moonshotai: "moonshotai",
|
||||
openai: "openai",
|
||||
"zai-org": "zhipuai",
|
||||
};
|
||||
|
||||
const BASE_MODEL_ALIASES: Record<string, string | undefined> = {
|
||||
"google/gemma-4-31B-turbo-TEE": "google/gemma-4-31b-it",
|
||||
// "unsloth" re-hosts models from many providers, so it has no org mapping; alias the
|
||||
// ones whose canonical metadata lives under the original provider's namespace.
|
||||
"unsloth/Mistral-Nemo-Instruct-2407-TEE": "mistral/mistral-nemo",
|
||||
};
|
||||
|
||||
const Pricing = z.object({
|
||||
prompt: z.number().optional(),
|
||||
completion: z.number().optional(),
|
||||
input_cache_read: z.number().optional(),
|
||||
}).passthrough();
|
||||
|
||||
export const ChutesModel = z.object({
|
||||
id: z.string(),
|
||||
created: z.number(),
|
||||
pricing: Pricing.optional(),
|
||||
context_length: z.number().optional(),
|
||||
max_output_length: z.number().optional(),
|
||||
max_model_len: z.number().optional(),
|
||||
input_modalities: z.array(z.string()).optional(),
|
||||
output_modalities: z.array(z.string()).optional(),
|
||||
supported_features: z.array(z.string()).optional(),
|
||||
supported_sampling_parameters: z.array(z.string()).optional(),
|
||||
quantization: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
export const ChutesResponse = z.object({
|
||||
data: z.array(ChutesModel),
|
||||
}).passthrough();
|
||||
|
||||
export type ChutesModel = z.infer<typeof ChutesModel>;
|
||||
|
||||
type Modality = "text" | "audio" | "image" | "video" | "pdf";
|
||||
|
||||
export const chutes = {
|
||||
id: "chutes",
|
||||
name: "Chutes",
|
||||
modelsDir: "providers/chutes/models",
|
||||
preserveBaseModels: false,
|
||||
async fetchModels() {
|
||||
const response = await fetch(API_ENDPOINT);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Chutes models request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
parseModels(raw) {
|
||||
return ChutesResponse.parse(raw).data;
|
||||
},
|
||||
translateModel(model, context) {
|
||||
return {
|
||||
id: model.id,
|
||||
model: buildChutesModel(model, context.existing(model.id)),
|
||||
};
|
||||
},
|
||||
} satisfies SyncProvider<ChutesModel>;
|
||||
|
||||
export function buildChutesModel(
|
||||
model: ChutesModel,
|
||||
existing: ExistingModel | undefined,
|
||||
today = new Date().toISOString().slice(0, 10),
|
||||
): SyncedModel {
|
||||
const features = new Set(model.supported_features ?? []);
|
||||
const samplingParams = new Set(model.supported_sampling_parameters ?? []);
|
||||
const input = normalizeModalities(model.input_modalities ?? ["text"]);
|
||||
const output = normalizeModalities(model.output_modalities ?? ["text"]);
|
||||
|
||||
const attachment = input.some((value) => value !== "text");
|
||||
const reasoning = features.has("reasoning");
|
||||
const toolCall = features.has("tools");
|
||||
const structuredOutput = features.has("structured_outputs");
|
||||
// Absent sampling-parameter info, assume temperature is tunable.
|
||||
const temperature = samplingParams.size > 0 ? samplingParams.has("temperature") : true;
|
||||
|
||||
const name = existing?.name ?? humanizeModelName(model.id);
|
||||
const baseModel = resolveBaseModel(model.id);
|
||||
|
||||
const apiContext = model.context_length ?? model.max_model_len ?? 0;
|
||||
const context = apiContext > 0 ? apiContext : existing?.limit?.context ?? 0;
|
||||
const apiOutput = model.max_output_length ?? 0;
|
||||
const limit = {
|
||||
context,
|
||||
input: existing?.limit?.input,
|
||||
output: apiOutput > 0 ? apiOutput : existing?.limit?.output ?? 0,
|
||||
};
|
||||
|
||||
const cost = model.pricing?.prompt !== undefined && model.pricing?.completion !== undefined
|
||||
? {
|
||||
input: model.pricing.prompt,
|
||||
output: model.pricing.completion,
|
||||
cache_read: model.pricing.input_cache_read,
|
||||
}
|
||||
: existing?.cost;
|
||||
|
||||
const values: SyncedFullModel = {
|
||||
name,
|
||||
description: existing?.description ?? describeModel({
|
||||
id: model.id,
|
||||
name,
|
||||
family: baseModel == null ? (existing?.family ?? inferFamily(model.id, name)) : existing?.family,
|
||||
reasoning,
|
||||
tool_call: toolCall,
|
||||
structured_output: structuredOutput ? true : undefined,
|
||||
open_weights: true,
|
||||
limit,
|
||||
modalities: { input, output },
|
||||
}),
|
||||
family: baseModel == null ? (existing?.family ?? inferFamily(model.id, name)) : existing?.family,
|
||||
release_date: existing?.release_date ?? dateFromTimestamp(model.created),
|
||||
last_updated: existing?.last_updated ?? today,
|
||||
attachment,
|
||||
reasoning,
|
||||
// Chutes' /v1/models advertises `reasoning` as a capability but exposes no parameter
|
||||
// to toggle or set its effort, so there is no provider evidence for a reasoning option.
|
||||
reasoning_options: [],
|
||||
temperature,
|
||||
tool_call: toolCall,
|
||||
structured_output: structuredOutput ? true : undefined,
|
||||
knowledge: existing?.knowledge,
|
||||
open_weights: true,
|
||||
status: existing?.status,
|
||||
interleaved: existing?.interleaved,
|
||||
cost,
|
||||
limit,
|
||||
modalities: { input, output },
|
||||
};
|
||||
|
||||
return baseModel == null
|
||||
? values
|
||||
: factorBaseModel(baseModel, values, limit, existing?.base_model_omit);
|
||||
}
|
||||
|
||||
function resolveBaseModel(modelId: string): string | undefined {
|
||||
return baseModelCandidates(modelId).find(canonicalExists);
|
||||
}
|
||||
|
||||
// existsSync is case-insensitive on Windows/macOS; verify the real on-disk filename case
|
||||
// so the resolved base_model matches the canonical metadata exactly (and CI on Linux).
|
||||
function canonicalExists(candidate: string): boolean {
|
||||
const file = path.join(MODELS_DIR, `${candidate}.toml`);
|
||||
if (!existsSync(file)) return false;
|
||||
try {
|
||||
return readdirSync(path.dirname(file)).includes(path.basename(file));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function baseModelCandidates(modelId: string): string[] {
|
||||
const alias = BASE_MODEL_ALIASES[modelId];
|
||||
const [org, ...modelParts] = modelId.split("/");
|
||||
if (org === undefined || modelParts.length === 0 || modelParts.join("/").endsWith("-TEE") === false) {
|
||||
return alias === undefined ? [] : [alias];
|
||||
}
|
||||
|
||||
const provider = CHUTES_ORG_TO_MODEL_PROVIDER[org];
|
||||
if (provider === undefined) {
|
||||
return alias === undefined ? [] : [alias];
|
||||
}
|
||||
|
||||
const withoutTee = modelParts.join("/").slice(0, -"-TEE".length);
|
||||
const lower = withoutTee.toLowerCase();
|
||||
// Distinct checkpoints (e.g. "-Thinking-2507") keep their own metadata — deliberately
|
||||
// not collapsed onto the generic base, which would inherit the wrong capabilities.
|
||||
const normalized = [
|
||||
withoutTee,
|
||||
lower,
|
||||
lower.replace(/-turbo$/, "-it"),
|
||||
lower.replace(/-turbo$/, ""),
|
||||
];
|
||||
|
||||
return [
|
||||
...new Set([alias, ...normalized.map((candidate) => `${provider}/${candidate}`)]).values(),
|
||||
].filter((candidate): candidate is string => candidate !== undefined);
|
||||
}
|
||||
|
||||
function normalizeModalities(values: string[]): Modality[] {
|
||||
const allowed = new Set<Modality>(["text", "audio", "image", "video", "pdf"]);
|
||||
const result = values
|
||||
.map((value) => value.toLowerCase())
|
||||
.filter((value): value is Modality => allowed.has(value as Modality));
|
||||
if (result.length === 0) return ["text"];
|
||||
return [...new Set(result)];
|
||||
}
|
||||
|
||||
function humanizeModelName(modelId: string): string {
|
||||
const modelPart = modelId.split("/").at(-1) ?? modelId;
|
||||
return modelPart.replace(/-/g, " ");
|
||||
}
|
||||
|
||||
function dateFromTimestamp(timestamp: number): string {
|
||||
return new Date(timestamp * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function inferFamily(id: string, name: string) {
|
||||
const kimiFamily = inferKimiFamily(id, name);
|
||||
if (kimiFamily !== undefined) return kimiFamily;
|
||||
|
||||
const target = `${id} ${name}`.toLowerCase();
|
||||
return [...ModelFamilyValues]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.find((family) => {
|
||||
const value = family.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
if (family === "o") return new RegExp(`(^|[^a-z0-9])${value}(?=\\d|$|[^a-z0-9])`).test(target);
|
||||
return new RegExp(`(^|[^a-z0-9])${value}(?=$|[^a-z0-9])`).test(target);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { z } from "zod";
|
||||
import { readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { ExistingModel, SyncedModel, SyncProvider } from "../index.js";
|
||||
import {
|
||||
buildOpenRouterModel,
|
||||
OpenRouterModel,
|
||||
OpenRouterResponse,
|
||||
} from "./openrouter.js";
|
||||
|
||||
const API_BASE = "https://api.cloudflare.com/client/v4/accounts";
|
||||
const MODELS_DIR = path.join(import.meta.dirname, "..", "..", "..", "..", "..", "models");
|
||||
const metadataFilesByPublisher = new Map<string, string[]>();
|
||||
const METADATA_PUBLISHERS: Record<string, string> = {
|
||||
"deepseek-ai": "deepseek",
|
||||
google: "google",
|
||||
meta: "meta",
|
||||
mistralai: "mistral",
|
||||
moonshotai: "moonshotai",
|
||||
nvidia: "nvidia",
|
||||
openai: "openai",
|
||||
qwen: "alibaba",
|
||||
"zai-org": "zhipuai",
|
||||
};
|
||||
|
||||
const CloudflareOpenRouterResponse = z.object({
|
||||
result: z.union([OpenRouterResponse, z.array(OpenRouterModel)]).optional(),
|
||||
result_info: z.object({
|
||||
page: z.number().optional(),
|
||||
total_pages: z.number().optional(),
|
||||
}).passthrough().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const CloudflareModel = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
created: z.number(),
|
||||
hugging_face_id: z.string().nullable().optional(),
|
||||
context_length: z.number(),
|
||||
max_output_length: z.number().nullable().optional(),
|
||||
input_modalities: z.array(z.string()).optional(),
|
||||
output_modalities: z.array(z.string()).optional(),
|
||||
pricing: z.object({
|
||||
prompt: z.string(),
|
||||
completion: z.string(),
|
||||
internal_reasoning: z.string().optional(),
|
||||
input_cache_read: z.string().optional(),
|
||||
input_cache_write: z.string().optional(),
|
||||
}),
|
||||
supported_features: z.array(z.string()).optional(),
|
||||
supported_sampling_parameters: z.array(z.string()).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const CloudflareResponse = z.object({
|
||||
data: z.array(CloudflareModel),
|
||||
}).passthrough();
|
||||
|
||||
type CloudflareModel = z.infer<typeof CloudflareModel>;
|
||||
|
||||
export const cloudflareWorkersAi = {
|
||||
id: "cloudflare-workers-ai",
|
||||
name: "Cloudflare Workers AI",
|
||||
modelsDir: "providers/cloudflare-workers-ai/models",
|
||||
async fetchModels() {
|
||||
const accountID = process.env.CLOUDFLARE_WORKERS_AI_SYNC_ACCOUNT_ID;
|
||||
const token = process.env.CLOUDFLARE_WORKERS_AI_SYNC_API_TOKEN;
|
||||
if (accountID === undefined || token === undefined) {
|
||||
throw new Error(
|
||||
"Cloudflare Workers AI sync requires CLOUDFLARE_WORKERS_AI_SYNC_ACCOUNT_ID and CLOUDFLARE_WORKERS_AI_SYNC_API_TOKEN",
|
||||
);
|
||||
}
|
||||
|
||||
const first = await fetchPage(accountID, token, 1);
|
||||
const models = parseCloudflareModels(first);
|
||||
const pageInfo = CloudflareOpenRouterResponse.safeParse(first).success
|
||||
? CloudflareOpenRouterResponse.parse(first).result_info
|
||||
: undefined;
|
||||
|
||||
for (let page = 2; page <= (pageInfo?.total_pages ?? 1); page++) {
|
||||
models.push(...parseCloudflareModels(await fetchPage(accountID, token, page)));
|
||||
}
|
||||
|
||||
return { data: models };
|
||||
},
|
||||
parseModels(raw) {
|
||||
return parseCloudflareModels(raw);
|
||||
},
|
||||
translateModel(model, context) {
|
||||
const normalized = normalizeModel(model);
|
||||
const id = normalized.id.replace(/^workers-ai\//, "");
|
||||
return {
|
||||
id,
|
||||
model: buildWorkersAiModel(normalized, context.existing(id)),
|
||||
};
|
||||
},
|
||||
} satisfies SyncProvider<CloudflareModel>;
|
||||
|
||||
export function buildWorkersAiModel(
|
||||
model: z.infer<typeof OpenRouterModel>,
|
||||
existing: ExistingModel | undefined,
|
||||
): SyncedModel {
|
||||
const source = {
|
||||
...model,
|
||||
name: existing?.name ?? model.name,
|
||||
top_provider: {
|
||||
...model.top_provider,
|
||||
max_completion_tokens: existing?.limit?.output ?? model.top_provider.max_completion_tokens,
|
||||
},
|
||||
};
|
||||
const synced = {
|
||||
...buildOpenRouterModel(
|
||||
source,
|
||||
existing,
|
||||
existing?.base_model ?? resolveCloudflareBaseModel(model),
|
||||
),
|
||||
reasoning_options: existing?.reasoning_options,
|
||||
};
|
||||
if ("base_model" in synced) return synced;
|
||||
return {
|
||||
...synced,
|
||||
name: existing?.name ?? synced.name,
|
||||
release_date: existing?.release_date ?? synced.release_date,
|
||||
last_updated: existing?.last_updated ?? synced.last_updated,
|
||||
limit: {
|
||||
...synced.limit,
|
||||
output: existing?.limit?.output ?? synced.limit.output,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCloudflareBaseModel(model: z.infer<typeof OpenRouterModel>) {
|
||||
const [, publisher] = model.id.replace(/^workers-ai\//, "").split("/");
|
||||
if (publisher === undefined) return undefined;
|
||||
|
||||
const metadataPublisher = METADATA_PUBLISHERS[publisher];
|
||||
if (metadataPublisher === undefined) return undefined;
|
||||
|
||||
let files = metadataFilesByPublisher.get(metadataPublisher);
|
||||
if (files === undefined) {
|
||||
try {
|
||||
files = readdirSync(path.join(MODELS_DIR, metadataPublisher))
|
||||
.filter((file) => file.endsWith(".toml"))
|
||||
.map((file) => file.slice(0, -5));
|
||||
} catch {
|
||||
files = [];
|
||||
}
|
||||
metadataFilesByPublisher.set(metadataPublisher, files);
|
||||
}
|
||||
|
||||
const identity = new Set(identityTokens(`${model.id} ${model.name}`));
|
||||
const matches = files.filter((file) => identityTokens(file).every((token) => identity.has(token)));
|
||||
return matches.length === 1 ? `${metadataPublisher}/${matches[0]}` : undefined;
|
||||
}
|
||||
|
||||
function identityTokens(value: string) {
|
||||
return value.toLowerCase().match(/[a-z]+|\d+(?:\.\d+)?/g) ?? [];
|
||||
}
|
||||
|
||||
async function fetchPage(accountID: string, token: string, page: number) {
|
||||
const url = new URL(`${API_BASE}/${accountID}/ai/models/search`);
|
||||
url.searchParams.set("format", "openrouter");
|
||||
url.searchParams.set("per_page", "1000");
|
||||
url.searchParams.set("page", String(page));
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Cloudflare Workers AI models request failed: ${response.status} ${response.statusText}${await responseDetails(response)}`,
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function parseCloudflareModels(raw: unknown): CloudflareModel[] {
|
||||
const cloudflare = CloudflareResponse.safeParse(raw);
|
||||
if (cloudflare.success) return cloudflare.data.data;
|
||||
|
||||
const direct = OpenRouterResponse.safeParse(raw);
|
||||
if (direct.success) return direct.data.data.map((model) => CloudflareModel.parse(model));
|
||||
|
||||
const wrapped = CloudflareOpenRouterResponse.parse(raw);
|
||||
if (wrapped.result === undefined) {
|
||||
throw new Error("Cloudflare Workers AI response did not include model data");
|
||||
}
|
||||
const models = Array.isArray(wrapped.result) ? wrapped.result : wrapped.result.data;
|
||||
return models.map((model) => CloudflareModel.parse(model));
|
||||
}
|
||||
|
||||
function normalizeModel(model: CloudflareModel) {
|
||||
if ("architecture" in model && "top_provider" in model && "supported_parameters" in model) {
|
||||
return OpenRouterModel.parse(model);
|
||||
}
|
||||
|
||||
return OpenRouterModel.parse({
|
||||
id: model.id.startsWith("@cf/") ? model.id : `@cf/${model.id.replace(/^@cf\//, "")}`,
|
||||
name: model.name,
|
||||
created: model.created,
|
||||
hugging_face_id: model.hugging_face_id ?? null,
|
||||
knowledge_cutoff: null,
|
||||
context_length: model.context_length,
|
||||
architecture: {
|
||||
input_modalities: model.input_modalities ?? ["text"],
|
||||
output_modalities: model.output_modalities ?? ["text"],
|
||||
},
|
||||
pricing: model.pricing,
|
||||
top_provider: {
|
||||
context_length: model.context_length,
|
||||
max_completion_tokens: model.max_output_length ?? null,
|
||||
},
|
||||
supported_parameters: [
|
||||
...model.supported_sampling_parameters ?? [],
|
||||
...model.supported_features ?? [],
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function responseDetails(response: Response) {
|
||||
const text = await response.text();
|
||||
if (text.length === 0) return "";
|
||||
|
||||
try {
|
||||
const body = z.object({
|
||||
errors: z.array(z.object({
|
||||
code: z.union([z.string(), z.number()]).optional(),
|
||||
message: z.string().optional(),
|
||||
}).passthrough()).optional(),
|
||||
}).passthrough().parse(JSON.parse(text));
|
||||
const details = body.errors
|
||||
?.map((error) => [error.code, error.message].filter(Boolean).join(": "))
|
||||
.filter((message) => message.length > 0)
|
||||
.join("; ");
|
||||
return details === undefined || details.length === 0 ? "" : ` (${details})`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ExistingModel, SyncProvider, SyncedModel } from "../index.js";
|
||||
import { factorBaseModel } from "./openrouter.js";
|
||||
|
||||
// Repo-level base-model metadata directory (mirrors openrouter.ts MODELS_DIR).
|
||||
const MODELS_DIR = path.join(import.meta.dirname, "..", "..", "..", "..", "..", "models");
|
||||
|
||||
function baseModelExists(modelID: string): boolean {
|
||||
return existsSync(path.join(MODELS_DIR, `${modelID}.toml`));
|
||||
}
|
||||
|
||||
// CrossModel is an OpenAI- and Anthropic-compatible multi-provider gateway. Its
|
||||
// public catalog endpoint carries the volatile, gateway-specific data we sync:
|
||||
// served price (USD micro / 1M tokens, threshold-tiered), modalities, context /
|
||||
// output limits, and a `capabilities.reasoning` object describing the reasoning
|
||||
// controls CrossModel itself exposes (the internal shape behind models.dev's
|
||||
// reasoning_options). https://www.crossmodel.ai/api/models
|
||||
// CROSSMODEL_MODELS_URL overrides the endpoint (e.g. a local backend) for testing.
|
||||
const API_ENDPOINT = process.env.CROSSMODEL_MODELS_URL ?? "https://www.crossmodel.ai/api/models";
|
||||
|
||||
const ReasoningCapability = z
|
||||
.object({
|
||||
toggle: z.boolean().optional(),
|
||||
effort: z.array(z.string()).optional(),
|
||||
budget_tokens: z
|
||||
.object({ min: z.number().optional(), max: z.number().optional() })
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const PriceTier = z
|
||||
.object({
|
||||
threshold: z.number().nullable().optional(),
|
||||
input_micro_per_1m: z.number().nullable().optional(),
|
||||
output_micro_per_1m: z.number().nullable().optional(),
|
||||
cache_read_micro_per_1m: z.number().nullable().optional(),
|
||||
cache_creation_micro_per_1m: z.number().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
type PriceTier = z.infer<typeof PriceTier>;
|
||||
|
||||
export const CrossModelModel = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
vendor_code: z.string(),
|
||||
display_name: z.string().optional(),
|
||||
context_window_tokens: z.number().nullable().optional(),
|
||||
max_output_tokens: z.number().nullable().optional(),
|
||||
modalities: z
|
||||
.object({ input: z.array(z.string()), output: z.array(z.string()) })
|
||||
.optional(),
|
||||
capabilities: z
|
||||
.object({ reasoning: ReasoningCapability.optional() })
|
||||
.passthrough()
|
||||
.nullable()
|
||||
.optional(),
|
||||
status: z.string().optional(),
|
||||
currency: z.string().nullable().optional(),
|
||||
pricing: z.object({ tiers: z.array(PriceTier).nullable() }).nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const CrossModelResponse = z.object({ data: z.array(CrossModelModel) }).passthrough();
|
||||
|
||||
export type CrossModelModel = z.infer<typeof CrossModelModel>;
|
||||
|
||||
// vendor_code -> models.dev base_model author prefix. Used only for brand-new
|
||||
// models without an existing factored TOML; existing rows reuse their base_model.
|
||||
const AUTHOR_BY_VENDOR: Record<string, string> = {
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
gemini: "google",
|
||||
moonshot: "moonshotai",
|
||||
deepseek: "deepseek",
|
||||
qwen: "alibaba",
|
||||
xiaomi: "xiaomi",
|
||||
minimax: "minimax",
|
||||
"z-ai": "zhipuai",
|
||||
tencent: "tencent",
|
||||
};
|
||||
|
||||
export const crossmodel = {
|
||||
id: "crossmodel",
|
||||
name: "CrossModel",
|
||||
modelsDir: "providers/crossmodel/models",
|
||||
async fetchModels() {
|
||||
const headers = process.env.CROSSMODEL_API_KEY
|
||||
? { Authorization: `Bearer ${process.env.CROSSMODEL_API_KEY}` }
|
||||
: undefined;
|
||||
const response = await fetch(API_ENDPOINT, { headers });
|
||||
if (!response.ok) {
|
||||
throw new Error(`CrossModel request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
parseModels(raw) {
|
||||
return CrossModelResponse.parse(raw).data.filter(
|
||||
(model) => model.status !== "hidden",
|
||||
);
|
||||
},
|
||||
translateModel(model, context) {
|
||||
const existing = context.existing(model.id);
|
||||
const built = buildCrossModel(model, existing);
|
||||
if (built === undefined) return undefined;
|
||||
return { id: model.id, model: built };
|
||||
},
|
||||
} satisfies SyncProvider<CrossModelModel>;
|
||||
|
||||
/** Integer USD micro / 1M tokens -> USD / 1M tokens; undefined when absent. */
|
||||
function price(micro: number | null | undefined): number | undefined {
|
||||
if (micro === undefined || micro === null) return undefined;
|
||||
return Math.round(micro) / 1_000_000;
|
||||
}
|
||||
|
||||
function nonZeroPrice(micro: number | null | undefined): number | undefined {
|
||||
const value = price(micro);
|
||||
return value !== undefined && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
type TierCost = { input: number; output: number; cache_read?: number; cache_write?: number };
|
||||
|
||||
// Convert one CrossModel price tier into a models.dev cost block. Cache fields
|
||||
// are emitted only when cache_read is a genuine discount (< input); a cache_read
|
||||
// at or above input means the model offers no caching benefit (e.g. OpenAI
|
||||
// "pro" tiers, which every other provider ships without cache pricing), so both
|
||||
// cache fields are dropped. Returns undefined when the tier lacks input/output.
|
||||
function tierCost(tier: PriceTier | undefined): TierCost | undefined {
|
||||
const input = price(tier?.input_micro_per_1m);
|
||||
const output = price(tier?.output_micro_per_1m);
|
||||
if (input === undefined || output === undefined) return undefined;
|
||||
const cost: TierCost = { input, output };
|
||||
const cacheRead = nonZeroPrice(tier?.cache_read_micro_per_1m);
|
||||
if (cacheRead !== undefined && cacheRead < input) {
|
||||
cost.cache_read = cacheRead;
|
||||
const cacheWrite = nonZeroPrice(tier?.cache_creation_micro_per_1m);
|
||||
if (cacheWrite !== undefined) cost.cache_write = cacheWrite;
|
||||
}
|
||||
return cost;
|
||||
}
|
||||
|
||||
type Modality = "text" | "audio" | "image" | "video" | "pdf";
|
||||
|
||||
function modalities(values: string[] | undefined, fallback: Modality[]): Modality[] {
|
||||
const allowed = new Set<Modality>(["text", "audio", "image", "video", "pdf"]);
|
||||
const result = (values ?? [])
|
||||
.map((value) => value.toLowerCase())
|
||||
.map((value) => (value === "file" ? "pdf" : value))
|
||||
.filter((value): value is Modality => allowed.has(value as Modality));
|
||||
return [...new Set(result.length > 0 ? result : fallback)];
|
||||
}
|
||||
|
||||
// models.dev's reasoning_options effort enum (schema.ts ReasoningEffortValue).
|
||||
// Guarding against it means an unexpected upstream value is dropped instead of
|
||||
// silently producing a TOML that fails `validate`.
|
||||
const REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "default"] as const;
|
||||
type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
|
||||
function isReasoningEffort(value: string): value is ReasoningEffort {
|
||||
return (REASONING_EFFORTS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
// Project CrossModel's capabilities.reasoning onto models.dev reasoning_options.
|
||||
// reasoning absent -> undefined (non-reasoning model; option omitted)
|
||||
// reasoning === {} -> [] (model reasons, no verified user-selectable control)
|
||||
// otherwise -> toggle / effort / budget_tokens entries
|
||||
function reasoningOptions(model: CrossModelModel): SyncedModel["reasoning_options"] {
|
||||
const reasoning = model.capabilities?.reasoning;
|
||||
if (reasoning === undefined) return undefined;
|
||||
const options: NonNullable<SyncedModel["reasoning_options"]> = [];
|
||||
if (reasoning.toggle === true) options.push({ type: "toggle" });
|
||||
if (reasoning.effort !== undefined) {
|
||||
const values = reasoning.effort.filter(isReasoningEffort);
|
||||
if (values.length > 0) options.push({ type: "effort", values });
|
||||
}
|
||||
if (reasoning.budget_tokens !== undefined) {
|
||||
const budget: { type: "budget_tokens"; min?: number; max?: number } = { type: "budget_tokens" };
|
||||
if (reasoning.budget_tokens.min !== undefined) budget.min = reasoning.budget_tokens.min;
|
||||
if (reasoning.budget_tokens.max !== undefined) budget.max = reasoning.budget_tokens.max;
|
||||
options.push(budget);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function buildCrossModel(
|
||||
model: CrossModelModel,
|
||||
existing: ExistingModel | undefined,
|
||||
): SyncedModel | undefined {
|
||||
// CrossModel serves threshold-tiered pricing. The lowest-threshold tier is the
|
||||
// headline [cost]; every higher tier maps to a [[cost.tiers]] context band
|
||||
// (threshold -> tier size), so tier pricing stays fresh on each sync instead of
|
||||
// being frozen at hand-authored values. Fall back to the existing tiers only
|
||||
// when the API reports none.
|
||||
const tiers = [...(model.pricing?.tiers ?? [])].sort(
|
||||
(a, b) => (a.threshold ?? 0) - (b.threshold ?? 0),
|
||||
);
|
||||
const base = tierCost(tiers[0]);
|
||||
const contextTiers = tiers
|
||||
.slice(1)
|
||||
.map((tier) => {
|
||||
const c = tierCost(tier);
|
||||
return c === undefined
|
||||
? undefined
|
||||
: { tier: { type: "context" as const, size: tier.threshold ?? 0 }, ...c };
|
||||
})
|
||||
.filter((entry): entry is NonNullable<typeof entry> => entry !== undefined);
|
||||
const cost =
|
||||
base !== undefined
|
||||
? { ...base, tiers: contextTiers.length > 0 ? contextTiers : existing?.cost?.tiers }
|
||||
: existing?.cost;
|
||||
|
||||
// Every served model reports a context window; without one (and no existing
|
||||
// value to fall back on) there's no valid limit to emit, so skip the model
|
||||
// rather than fabricate a context. The guard also narrows `context` to number.
|
||||
const context = model.context_window_tokens ?? existing?.limit?.context;
|
||||
if (context === undefined) return undefined;
|
||||
const limit = {
|
||||
context,
|
||||
input: existing?.limit?.input,
|
||||
output: model.max_output_tokens ?? existing?.limit?.output ?? context,
|
||||
};
|
||||
|
||||
const modality = {
|
||||
input: modalities(model.modalities?.input, existing?.modalities?.input ?? ["text"]),
|
||||
output: modalities(model.modalities?.output, existing?.modalities?.output ?? ["text"]),
|
||||
};
|
||||
|
||||
const reasoning_options = reasoningOptions(model);
|
||||
|
||||
// Resolve the base_model: prefer the existing factored entry; otherwise derive
|
||||
// from vendor_code. Skip models we can't map or whose base isn't in models.dev
|
||||
// yet — those need their author metadata hand-added first.
|
||||
const baseModel = existing?.base_model ?? deriveBaseModel(model);
|
||||
if (baseModel === undefined || !baseModelExists(baseModel)) return undefined;
|
||||
|
||||
// Curated capability fields stay inherited from the base model (undefined here);
|
||||
// we only drive the volatile cost/limit/modalities plus the gateway-specific
|
||||
// reasoning_options.
|
||||
return factorBaseModel(
|
||||
baseModel,
|
||||
{
|
||||
attachment: existing?.attachment,
|
||||
reasoning: existing?.reasoning,
|
||||
temperature: existing?.temperature,
|
||||
tool_call: existing?.tool_call,
|
||||
structured_output: existing?.structured_output,
|
||||
knowledge: existing?.knowledge,
|
||||
modalities: modality,
|
||||
reasoning_options,
|
||||
limit,
|
||||
cost,
|
||||
},
|
||||
limit,
|
||||
existing?.base_model_omit,
|
||||
);
|
||||
}
|
||||
|
||||
function deriveBaseModel(model: CrossModelModel): string | undefined {
|
||||
const author = AUTHOR_BY_VENDOR[model.vendor_code];
|
||||
if (author === undefined) return undefined;
|
||||
const short = model.id.includes("/") ? model.id.split("/").slice(1).join("/") : model.id;
|
||||
// MiniMax base ids are TitleCased incl. the model letter (e.g. minimax/MiniMax-M3).
|
||||
if (author === "minimax") {
|
||||
return `minimax/${short.replace(/^minimax-m/i, "MiniMax-M")}`;
|
||||
}
|
||||
return `${author}/${short}`;
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js";
|
||||
import { factorBaseModel, resolveCanonicalBaseModel } from "./openrouter.js";
|
||||
|
||||
// Public DeepInfra deploy catalog. Richer than the OpenAI-compatible
|
||||
// `/v1/openai/models` endpoint: it exposes capability tags (tools,
|
||||
// structured-output, multimodal, input-audio/video, reasoning), token pricing,
|
||||
// the served context window, and deprecation state.
|
||||
const API_ENDPOINT = "https://api.deepinfra.com/models/list?type=text-generation";
|
||||
|
||||
export const DeepInfraModel = z.object({
|
||||
model_name: z.string().min(1),
|
||||
type: z.string(),
|
||||
tags: z.array(z.string()).nullish(),
|
||||
pricing: z.object({
|
||||
type: z.string().nullish(),
|
||||
cents_per_input_token: z.number().nullish(),
|
||||
cents_per_output_token: z.number().nullish(),
|
||||
// Cache rates are multipliers applied to the input price, not absolute prices.
|
||||
rate_per_input_token_cached: z.number().nullish(),
|
||||
rate_per_input_token_cache_write: z.number().nullish(),
|
||||
// Free-text breakdown of context-based pricing tiers, when the model has them.
|
||||
full: z.string().nullish(),
|
||||
}).passthrough().nullish(),
|
||||
// DeepInfra's `max_tokens` is the served context window, not a completion cap.
|
||||
max_tokens: z.number().int().positive().nullish(),
|
||||
// null when active; a unix timestamp (possibly in the future) when scheduled.
|
||||
deprecated: z.union([z.number(), z.string(), z.boolean()]).nullish(),
|
||||
private: z.number().nullish(),
|
||||
}).passthrough();
|
||||
|
||||
export const DeepInfraResponse = z.array(DeepInfraModel);
|
||||
|
||||
export type DeepInfraModel = z.infer<typeof DeepInfraModel>;
|
||||
|
||||
// DeepInfra resells some proprietary models via passthrough. We exclude those
|
||||
// closed-weight families from this provider's catalog (open Google `gemma-*`
|
||||
// models are kept — only `gemini-*` is dropped).
|
||||
const EXCLUDED_PATTERNS = [/^anthropic\//, /^google\/gemini/];
|
||||
|
||||
function isExcluded(modelName: string) {
|
||||
return EXCLUDED_PATTERNS.some((pattern) => pattern.test(modelName));
|
||||
}
|
||||
|
||||
export const deepinfra = {
|
||||
id: "deepinfra",
|
||||
name: "Deep Infra",
|
||||
modelsDir: "providers/deepinfra/models",
|
||||
// DeepInfra rotates served models frequently; never delete local TOMLs
|
||||
// automatically — surface them for manual lifecycle review instead.
|
||||
deleteMissing: false,
|
||||
sourceID(model) {
|
||||
return model.model_name;
|
||||
},
|
||||
skippedNotice(ids) {
|
||||
if (ids.length === 0) return [];
|
||||
return [
|
||||
`${ids.length} Deep Infra models were not created because they lacked provider-agnostic metadata to inherit (no \`models/\` entry) and the API does not supply the required curated fields, or because they are already deprecated.`,
|
||||
`Skipped remote IDs: ${ids.map((id) => `\`${id}\``).join(", ")}`,
|
||||
"Add a `models/<provider>/<model>.toml` entry (or a full provider TOML) to include them in the next sync.",
|
||||
];
|
||||
},
|
||||
missingNotice(paths) {
|
||||
if (paths.length === 0) return [];
|
||||
return [
|
||||
`${paths.length} local Deep Infra models were absent from the live API and were retained for manual lifecycle review.`,
|
||||
`Retained local paths: ${paths.map((item) => `\`${item}\``).join(", ")}`,
|
||||
];
|
||||
},
|
||||
async fetchModels() {
|
||||
return fetchDeepInfraModels(process.env.DEEPINFRA_API_KEY);
|
||||
},
|
||||
parseModels(raw) {
|
||||
return DeepInfraResponse.parse(raw).filter((model) =>
|
||||
model.type === "text-generation"
|
||||
&& !model.private
|
||||
&& !isExcluded(model.model_name),
|
||||
);
|
||||
},
|
||||
translateModel(model, context) {
|
||||
const id = model.model_name;
|
||||
const existing = context.existing(id);
|
||||
const baseModel = existing === undefined
|
||||
? resolveDeepInfraBaseModel(id)
|
||||
: existing.base_model;
|
||||
|
||||
const inputCost = perMillion(model.pricing?.cents_per_input_token);
|
||||
const outputCost = perMillion(model.pricing?.cents_per_output_token);
|
||||
|
||||
// A brand-new model we can neither inherit nor price has nothing to author.
|
||||
if (existing === undefined && baseModel === undefined) return undefined;
|
||||
if (
|
||||
existing === undefined
|
||||
&& (inputCost === undefined || outputCost === undefined)
|
||||
) return undefined;
|
||||
// Don't introduce brand-new entries for models that are already deprecated;
|
||||
// existing entries are kept and marked instead.
|
||||
if (existing === undefined && isDeprecated(model)) return undefined;
|
||||
|
||||
return {
|
||||
id,
|
||||
model: buildDeepInfraModel(model, existing, baseModel),
|
||||
};
|
||||
},
|
||||
} satisfies SyncProvider<DeepInfraModel>;
|
||||
|
||||
export async function fetchDeepInfraModels(
|
||||
key: string | undefined,
|
||||
fetcher: typeof fetch = fetch,
|
||||
) {
|
||||
const response = await fetcher(API_ENDPOINT, {
|
||||
headers: key === undefined ? undefined : { Authorization: `Bearer ${key}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Deep Infra models request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return DeepInfraResponse.parse(await response.json());
|
||||
}
|
||||
|
||||
function isDeprecated(model: DeepInfraModel) {
|
||||
const deprecated = model.deprecated;
|
||||
if (deprecated === undefined || deprecated === null || deprecated === false) {
|
||||
return false;
|
||||
}
|
||||
// Numeric values are unix (seconds) timestamps. A future timestamp is a
|
||||
// scheduled deprecation — the model is still served until then.
|
||||
if (typeof deprecated === "number") return deprecated * 1000 <= Date.now();
|
||||
return Boolean(deprecated);
|
||||
}
|
||||
|
||||
// DeepInfra prices in cents per token; the catalog uses USD per million tokens.
|
||||
// cents/token * 1e6 tokens / 100 cents-per-dollar = cents/token * 10_000.
|
||||
function perMillion(centsPerToken: number | null | undefined) {
|
||||
if (centsPerToken === undefined || centsPerToken === null) return undefined;
|
||||
if (!Number.isFinite(centsPerToken) || centsPerToken < 0) return undefined;
|
||||
return round(centsPerToken * 10_000);
|
||||
}
|
||||
|
||||
function round(value: number) {
|
||||
return Math.round(value * 1_000_000) / 1_000_000;
|
||||
}
|
||||
|
||||
// DeepInfra's API exposes cache pricing via `rate_per_input_token_cached`
|
||||
// (a multiplier on the input price). When that rate is null the model has no
|
||||
// cache pricing, so the (possibly stale) curated value is cleared.
|
||||
function cacheCost(inputCost: number, rate: number | null | undefined) {
|
||||
return rate == null ? undefined : round(inputCost * rate);
|
||||
}
|
||||
|
||||
function buildCost(
|
||||
model: DeepInfraModel,
|
||||
existing: ExistingModel | undefined,
|
||||
): SyncedFullModel["cost"] | undefined {
|
||||
const inputCost = perMillion(model.pricing?.cents_per_input_token);
|
||||
const outputCost = perMillion(model.pricing?.cents_per_output_token);
|
||||
// No usable API price — leave the curated cost untouched.
|
||||
if (inputCost === undefined || outputCost === undefined) return existing?.cost;
|
||||
|
||||
const cacheWriteRate = model.pricing?.rate_per_input_token_cache_write;
|
||||
const tiered = parseTieredPricing(model.pricing?.full);
|
||||
|
||||
if (tiered !== undefined) {
|
||||
const base = tiered.base;
|
||||
return {
|
||||
input: round(base.input),
|
||||
output: round(base.output),
|
||||
reasoning: existing?.cost?.reasoning,
|
||||
cache_read: base.cache_read === undefined ? undefined : round(base.cache_read),
|
||||
cache_write: cacheWriteRate == null ? undefined : round(base.input * cacheWriteRate),
|
||||
tiers: tiered.tiers.map((tier) => ({
|
||||
tier: { type: "context" as const, size: tier.size },
|
||||
input: round(tier.input),
|
||||
output: round(tier.output),
|
||||
cache_read: tier.cache_read === undefined ? undefined : round(tier.cache_read),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
input: inputCost,
|
||||
output: outputCost,
|
||||
reasoning: existing?.cost?.reasoning,
|
||||
cache_read: cacheCost(inputCost, model.pricing?.rate_per_input_token_cached),
|
||||
cache_write: cacheCost(inputCost, cacheWriteRate),
|
||||
// API pricing is flat (or its tier string was unparseable): clear any stale
|
||||
// curated tiers rather than leaving obsolete thresholds active.
|
||||
tiers: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
interface ParsedSegment {
|
||||
input: number;
|
||||
output: number;
|
||||
cache_read: number | undefined;
|
||||
bound: number | undefined;
|
||||
}
|
||||
|
||||
// Parses DeepInfra's free-text tiered-pricing string, e.g.
|
||||
// "$1.2 in $6 out $0.24 cached <= 32K, $2.4 in $12 out $0.48 cached <= 128K, $3 in $15 out $0.6 cached > 128K"
|
||||
// into a base cost (cheapest tier) plus context tiers keyed by the lower bound
|
||||
// at which each higher tier starts. Returns undefined for flat pricing or any
|
||||
// string that does not match the expected shape (caller falls back to the flat
|
||||
// per-token price), so a format change degrades gracefully instead of mispricing.
|
||||
function parseTieredPricing(full: string | null | undefined) {
|
||||
if (full == null || !/[\d.]\s*[KM]\b/i.test(full)) return undefined;
|
||||
const segments = full.split(",").map((segment) => segment.trim()).filter(Boolean);
|
||||
if (segments.length < 2) return undefined;
|
||||
|
||||
const parsed: ParsedSegment[] = [];
|
||||
for (const segment of segments) {
|
||||
// The bound (`<= 32K` / `> 128K`) is optional: the final tier is often
|
||||
// unbounded (e.g. ByteDance/Seed-2.0-code "$1 in $6 out $0.20 cached").
|
||||
const match = segment.match(
|
||||
/^\$\s*([\d.]+)\s+in\s+\$\s*([\d.]+)\s+out(?:\s+\$\s*([\d.]+)\s+cached)?(?:\s+(?:<=|>)\s*([\d.]+)\s*([KM]))?\s*$/i,
|
||||
);
|
||||
if (match === null) {
|
||||
console.warn(`Deep Infra: unrecognized tiered pricing, using flat price: ${full}`);
|
||||
return undefined;
|
||||
}
|
||||
const cached = match[3];
|
||||
const size = match[4];
|
||||
parsed.push({
|
||||
input: Number(match[1]),
|
||||
output: Number(match[2]),
|
||||
cache_read: cached === undefined ? undefined : Number(cached),
|
||||
bound: size === undefined
|
||||
? undefined
|
||||
: Math.round(Number(size) * (match[5]!.toUpperCase() === "M" ? 1_000_000 : 1_000)),
|
||||
});
|
||||
}
|
||||
|
||||
// Every segment except the last must carry a bound — the next tier starts at
|
||||
// the previous segment's upper bound, so a missing interior bound is unparseable.
|
||||
if (parsed.slice(0, -1).some((segment) => segment.bound === undefined)) {
|
||||
console.warn(`Deep Infra: tiered pricing missing interior bound, using flat price: ${full}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const tiers = parsed.slice(1).map((segment, index) => ({
|
||||
size: parsed[index]!.bound!,
|
||||
input: segment.input,
|
||||
output: segment.output,
|
||||
cache_read: segment.cache_read,
|
||||
}));
|
||||
for (let index = 1; index < tiers.length; index++) {
|
||||
if (tiers[index]!.size <= tiers[index - 1]!.size) return undefined;
|
||||
}
|
||||
|
||||
return { base: parsed[0]!, tiers };
|
||||
}
|
||||
|
||||
export function buildDeepInfraModel(
|
||||
model: DeepInfraModel,
|
||||
existing: ExistingModel | undefined,
|
||||
baseModel = existing === undefined ? resolveDeepInfraBaseModel(model.model_name) : existing.base_model,
|
||||
): SyncedModel {
|
||||
const tags = new Set(model.tags ?? []);
|
||||
|
||||
// Capabilities are derived from the live tags (authoritative), falling back to
|
||||
// curated values only where no tag expresses the capability.
|
||||
// Capability tags only ever turn a feature ON (DeepInfra's tagging is
|
||||
// incomplete — e.g. reasoning models without a reasoning tag), with the sole
|
||||
// exception of the explicit `non-reasoning` tag. When no tag speaks to a
|
||||
// capability we leave it unset so it inherits the canonical `models/` metadata
|
||||
// (base_model entries) or keeps the curated value (full definitions), rather
|
||||
// than clobbering it with a `false`/default.
|
||||
const reasoning = tags.has("reasoning") || tags.has("can-disable-reasoning")
|
||||
? true
|
||||
: tags.has("non-reasoning")
|
||||
? false
|
||||
: existing?.reasoning;
|
||||
const toolCall = tags.has("tools") ? true : existing?.tool_call;
|
||||
// `structured-output` marks dedicated structured output (JSON schema); the
|
||||
// generic `json` tag only means JSON mode, so it does not count here.
|
||||
const structuredOutput = tags.has("structured-output") || tags.has("structured_output")
|
||||
? true
|
||||
: existing?.structured_output;
|
||||
// `can-disable-reasoning` means a reasoning on/off toggle exists. Surface that
|
||||
// as an explicit option, but never override curated options (e.g. effort scales).
|
||||
const reasoningOptions = existing?.reasoning_options
|
||||
?? (tags.has("can-disable-reasoning") ? [{ type: "toggle" as const }] : undefined);
|
||||
|
||||
// Modalities are model-intrinsic. Merge the tag-derived inputs into existing
|
||||
// values for full definitions (never dropping curated extras like video); for
|
||||
// new base_model entries leave them unset so they inherit from metadata.
|
||||
const derivedModalities: Modality[] = [];
|
||||
if (tags.has("multimodal")) derivedModalities.push("image");
|
||||
if (tags.has("input-audio")) derivedModalities.push("audio");
|
||||
if (tags.has("input-video")) derivedModalities.push("video");
|
||||
const unsupportedModalities = UNSUPPORTED_MODALITIES[model.model_name];
|
||||
const inputModalities = existing?.modalities?.input !== undefined || derivedModalities.length > 0
|
||||
? mergeModalities(existing?.modalities?.input, derivedModalities)
|
||||
.filter((value) => !unsupportedModalities?.has(value))
|
||||
: undefined;
|
||||
const modalities = inputModalities === undefined
|
||||
? undefined
|
||||
: { input: inputModalities, output: existing?.modalities?.output ?? ["text"] };
|
||||
const attachment = inputModalities === undefined
|
||||
? existing?.attachment
|
||||
: inputModalities.some((value) => value !== "text");
|
||||
|
||||
const cost = buildCost(model, existing);
|
||||
|
||||
// Only the context window is sourced from the API; the curated input/output
|
||||
// limits stay authoritative (the API exposes no real completion cap).
|
||||
const limit = {
|
||||
context: model.max_tokens ?? existing?.limit?.context,
|
||||
input: existing?.limit?.input,
|
||||
output: existing?.limit?.output,
|
||||
} as SyncedFullModel["limit"];
|
||||
|
||||
const deprecated = isDeprecated(model);
|
||||
const status = deprecated
|
||||
? "deprecated"
|
||||
: existing?.status === "deprecated"
|
||||
? undefined
|
||||
: existing?.status;
|
||||
|
||||
const values: Partial<SyncedFullModel> = {
|
||||
// For base_model entries the display name is inherited from `models/`;
|
||||
// deriveName is only a fallback for standalone full definitions.
|
||||
name: existing?.name ?? (baseModel !== undefined ? undefined : deriveName(model.model_name)),
|
||||
description: existing?.description,
|
||||
family: existing?.family,
|
||||
release_date: existing?.release_date,
|
||||
last_updated: existing?.last_updated,
|
||||
attachment,
|
||||
reasoning,
|
||||
reasoning_options: reasoningOptions,
|
||||
// No tag expresses temperature support, so always inherit/preserve it.
|
||||
temperature: existing?.temperature,
|
||||
tool_call: toolCall,
|
||||
structured_output: structuredOutput,
|
||||
knowledge: existing?.knowledge,
|
||||
// open_weights is a model-intrinsic fact: always inherit it from `models/`
|
||||
// for base_model entries (so proprietary passthrough models like Claude keep
|
||||
// open_weights=false), and only carry it on standalone full definitions.
|
||||
open_weights: baseModel !== undefined ? undefined : existing?.open_weights,
|
||||
status,
|
||||
interleaved: existing?.interleaved,
|
||||
cost,
|
||||
limit,
|
||||
modalities,
|
||||
};
|
||||
|
||||
if (baseModel !== undefined) {
|
||||
if (limit.context === undefined) {
|
||||
throw new Error(`Deep Infra model ${model.model_name} is missing a context length required for sync`);
|
||||
}
|
||||
// Everything except context / cost / capability flags is inherited from the
|
||||
// `models/` metadata.
|
||||
return factorBaseModel(baseModel, values, limit, existing?.base_model_omit);
|
||||
}
|
||||
|
||||
const required = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
release_date: z.string(),
|
||||
last_updated: z.string(),
|
||||
open_weights: z.boolean(),
|
||||
cost: z.object({ input: z.number(), output: z.number() }),
|
||||
limit: z.object({ context: z.number(), output: z.number() }),
|
||||
}).safeParse(values);
|
||||
if (!required.success) {
|
||||
throw new Error(`Deep Infra model ${model.model_name} has incomplete local metadata required for sync`);
|
||||
}
|
||||
return values as SyncedFullModel;
|
||||
}
|
||||
|
||||
// DeepInfra uses Hugging Face style `org/model` IDs. Map the org prefix to the
|
||||
// catalog's canonical metadata namespace so new models can inherit via
|
||||
// `base_model` whenever a `models/` entry already exists.
|
||||
const DEEPINFRA_PREFIXES: Record<string, string> = {
|
||||
"deepseek-ai": "deepseek",
|
||||
"meta-llama": "meta",
|
||||
google: "google",
|
||||
microsoft: "microsoft",
|
||||
MiniMaxAI: "minimax",
|
||||
mistralai: "mistralai",
|
||||
moonshotai: "moonshotai",
|
||||
nvidia: "nvidia",
|
||||
openai: "openai",
|
||||
Qwen: "qwen",
|
||||
XiaomiMiMo: "xiaomi",
|
||||
"zai-org": "zai",
|
||||
};
|
||||
|
||||
export function resolveDeepInfraBaseModel(id: string) {
|
||||
const [prefix, ...parts] = id.split("/");
|
||||
if (prefix === undefined || parts.length === 0) return undefined;
|
||||
const canonicalPrefix = DEEPINFRA_PREFIXES[prefix];
|
||||
if (canonicalPrefix === undefined) return resolveCanonicalBaseModel(id);
|
||||
return resolveCanonicalBaseModel(`${canonicalPrefix}/${parts.join("/").toLowerCase()}`);
|
||||
}
|
||||
|
||||
function deriveName(id: string) {
|
||||
const modelPart = id.split("/").at(-1) ?? id;
|
||||
return modelPart.replace(/[-_]+/g, " ").trim();
|
||||
}
|
||||
|
||||
type Modality = "text" | "audio" | "image" | "video" | "pdf";
|
||||
|
||||
const ALLOWED_MODALITIES = new Set<Modality>(["text", "audio", "image", "video", "pdf"]);
|
||||
|
||||
// DeepInfra currently applies `input-audio` to the whole Gemma 4 family, but
|
||||
// its model page limits audio input to the E2B and E4B variants.
|
||||
const UNSUPPORTED_MODALITIES: Record<string, Set<Modality>> = {
|
||||
"google/gemma-4-31B-it": new Set(["audio"]),
|
||||
};
|
||||
|
||||
function mergeModalities(existing: string[] | undefined, add: Modality[]): Modality[] {
|
||||
const result = new Set<Modality>(["text"]);
|
||||
for (const value of existing ?? []) {
|
||||
const lowered = value.toLowerCase();
|
||||
if (ALLOWED_MODALITIES.has(lowered as Modality)) result.add(lowered as Modality);
|
||||
}
|
||||
for (const value of add) result.add(value);
|
||||
return [...result];
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { describeModel } from "../../describe.js";
|
||||
import { inferKimiFamily, ModelFamilyValues } from "../../family.js";
|
||||
import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js";
|
||||
import { factorBaseModel, resolveCanonicalBaseModel } from "./openrouter.js";
|
||||
|
||||
const MODELS_API = "https://api.digitalocean.com/v2/gen-ai/models?per_page=200";
|
||||
const CATALOG_API = "https://api.digitalocean.com/v2/gen-ai/models/catalog?limit=200";
|
||||
|
||||
export const DigitalOceanModel = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
lifecycle_status: z.string(),
|
||||
type: z.string().optional(),
|
||||
thinking: z.boolean().optional(),
|
||||
reasoning_efforts: z.array(z.string()).optional(),
|
||||
context_window: z.union([z.number(), z.string()]).optional(),
|
||||
modalities: z.object({
|
||||
input: z.array(z.string()).optional(),
|
||||
output: z.array(z.string()).optional(),
|
||||
}).optional(),
|
||||
settings: z.array(z.object({
|
||||
name: z.string(),
|
||||
max: z.number().optional(),
|
||||
default_value: z.number().optional(),
|
||||
})).optional(),
|
||||
created_at: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const DigitalOceanModelsResponse = z.object({
|
||||
models: z.array(DigitalOceanModel),
|
||||
links: z.object({
|
||||
pages: z.object({
|
||||
next: z.string().nullable().optional(),
|
||||
}).passthrough().optional(),
|
||||
}).passthrough().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const DigitalOceanCatalogPricing = z.object({
|
||||
input_price_per_million: z.number().optional(),
|
||||
output_price_per_million: z.number().optional(),
|
||||
cache_read_input_price_per_million: z.number().optional(),
|
||||
cache_write_5m_input_price_per_million: z.number().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const DigitalOceanCatalogModel = z.object({
|
||||
id: z.string().min(1).optional(),
|
||||
model_id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
context_window: z.union([z.number(), z.string()]).nullish(),
|
||||
max_output_tokens: z.union([z.number(), z.string()]).nullish(),
|
||||
availability: z.array(z.string()).optional(),
|
||||
modalities: z.object({
|
||||
input: z.array(z.string()).optional(),
|
||||
output: z.array(z.string()).optional(),
|
||||
}).nullish(),
|
||||
pricing: DigitalOceanCatalogPricing.nullish(),
|
||||
pricing_detail: z.object({
|
||||
variants: z.array(z.object({
|
||||
tier: z.string().optional(),
|
||||
mode: z.string().optional(),
|
||||
prices: DigitalOceanCatalogPricing.nullish(),
|
||||
}).passthrough()),
|
||||
}).nullish(),
|
||||
}).passthrough();
|
||||
|
||||
const DigitalOceanCatalogResponse = z.object({
|
||||
data: z.array(DigitalOceanCatalogModel),
|
||||
links: z.object({
|
||||
pages: z.object({
|
||||
next: z.string().nullable().optional(),
|
||||
}).passthrough().optional(),
|
||||
}).passthrough().optional(),
|
||||
meta: z.object({
|
||||
page: z.number().int().positive(),
|
||||
pages: z.number().int().nonnegative(),
|
||||
total: z.number().int().nonnegative(),
|
||||
}).passthrough().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const DigitalOceanCatalogDetailResponse = z.object({
|
||||
data: DigitalOceanCatalogModel,
|
||||
}).passthrough();
|
||||
|
||||
const DigitalOceanResponse = z.object({
|
||||
models: z.array(DigitalOceanModel),
|
||||
catalog: z.array(DigitalOceanCatalogModel),
|
||||
});
|
||||
|
||||
export type DigitalOceanModel = z.infer<typeof DigitalOceanModel>;
|
||||
type DigitalOceanCatalogModel = z.infer<typeof DigitalOceanCatalogModel>;
|
||||
|
||||
interface ModelPricing {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
extended?: {
|
||||
context: number;
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
};
|
||||
}
|
||||
|
||||
type ReasoningEffort =
|
||||
| null
|
||||
| "none"
|
||||
| "minimal"
|
||||
| "low"
|
||||
| "medium"
|
||||
| "high"
|
||||
| "xhigh"
|
||||
| "max"
|
||||
| "default";
|
||||
|
||||
export interface DigitalOceanSourceModel extends DigitalOceanModel {
|
||||
max_output_tokens?: string | number | null;
|
||||
availability?: string[];
|
||||
pricing?: ModelPricing;
|
||||
}
|
||||
|
||||
export const digitalocean = {
|
||||
id: "digitalocean",
|
||||
name: "DigitalOcean",
|
||||
modelsDir: "providers/digitalocean/models",
|
||||
deleteMissing: false,
|
||||
sourceID(model) {
|
||||
return model.id;
|
||||
},
|
||||
skippedNotice(ids) {
|
||||
if (ids.length === 0) return [];
|
||||
return [
|
||||
`${ids.length} DigitalOcean text models could not be translated because required metadata was unavailable.`,
|
||||
`Skipped remote IDs: ${ids.map((id) => `\`${id}\``).join(", ")}`,
|
||||
];
|
||||
},
|
||||
missingNotice(paths) {
|
||||
if (paths.length === 0) return [];
|
||||
return [
|
||||
`${paths.length} local DigitalOcean models were outside the managed text-model catalog and were retained for manual lifecycle review.`,
|
||||
`Retained local paths: ${paths.map((item) => `\`${item}\``).join(", ")}`,
|
||||
];
|
||||
},
|
||||
async fetchModels() {
|
||||
const key = process.env.DIGITALOCEAN_API_TOKEN || process.env.DIGITALOCEAN_ACCESS_TOKEN;
|
||||
if (!key) {
|
||||
throw new Error("DigitalOcean sync requires DIGITALOCEAN_API_TOKEN or DIGITALOCEAN_ACCESS_TOKEN");
|
||||
}
|
||||
return fetchDigitalOceanModels(key);
|
||||
},
|
||||
parseModels(raw) {
|
||||
return parseDigitalOceanModels(raw);
|
||||
},
|
||||
translateModel(model, context) {
|
||||
const existing = context.existing(model.id);
|
||||
const contextWindow = number(model.context_window);
|
||||
const outputLimit = number(model.max_output_tokens ?? undefined);
|
||||
if (model.pricing?.input === undefined || model.pricing.output === undefined) return undefined;
|
||||
if (
|
||||
existing === undefined
|
||||
&& (
|
||||
contextWindow === undefined
|
||||
|| contextWindow <= 0
|
||||
|| outputLimit === undefined
|
||||
|| outputLimit <= 0
|
||||
)
|
||||
) return undefined;
|
||||
const baseModel = existing === undefined
|
||||
? resolveDigitalOceanBaseModel(model.id)
|
||||
: existing.base_model;
|
||||
return {
|
||||
id: model.id,
|
||||
model: buildDigitalOceanModel(model, existing, baseModel),
|
||||
};
|
||||
},
|
||||
} satisfies SyncProvider<DigitalOceanSourceModel>;
|
||||
|
||||
export async function fetchDigitalOceanModels(key: string, fetcher: typeof fetch = fetch) {
|
||||
const [models, catalog] = await Promise.all([
|
||||
fetchAllDigitalOceanModels(key, fetcher),
|
||||
fetchAllDigitalOceanCatalog(fetcher),
|
||||
]);
|
||||
return { models, catalog };
|
||||
}
|
||||
|
||||
async function fetchAllDigitalOceanModels(key: string, fetcher: typeof fetch) {
|
||||
const models: DigitalOceanModel[] = [];
|
||||
const visited = new Set<string>();
|
||||
let url: string | undefined = MODELS_API;
|
||||
|
||||
while (url !== undefined) {
|
||||
if (visited.has(url)) throw new Error(`DigitalOcean models pagination repeated URL: ${url}`);
|
||||
visited.add(url);
|
||||
|
||||
const response = await fetcher(url, {
|
||||
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`DigitalOcean models request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const page = DigitalOceanModelsResponse.parse(await response.json());
|
||||
models.push(...page.models);
|
||||
const next = page.links?.pages?.next;
|
||||
url = next ? new URL(next, url).toString() : undefined;
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
async function fetchAllDigitalOceanCatalog(fetcher: typeof fetch) {
|
||||
const catalog: DigitalOceanCatalogModel[] = [];
|
||||
const visited = new Set<string>();
|
||||
let url: string | undefined = CATALOG_API;
|
||||
|
||||
while (url !== undefined) {
|
||||
if (visited.has(url)) throw new Error(`DigitalOcean catalog pagination repeated URL: ${url}`);
|
||||
visited.add(url);
|
||||
|
||||
const response = await fetcher(url, {
|
||||
headers: { "Content-Type": "application/json", "User-Agent": "models.dev/digitalocean-sync" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`DigitalOcean catalog request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const page = DigitalOceanCatalogResponse.parse(await response.json());
|
||||
catalog.push(...page.data);
|
||||
const next = page.links?.pages?.next;
|
||||
if (next) {
|
||||
url = new URL(next, url).toString();
|
||||
} else if (page.meta !== undefined && page.meta.page < page.meta.pages) {
|
||||
const nextPage = new URL(url);
|
||||
nextPage.searchParams.set("page", String(page.meta.page + 1));
|
||||
url = nextPage.toString();
|
||||
} else {
|
||||
url = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.all(catalog.map(async (model) => {
|
||||
if (model.id === undefined || model.availability?.includes("serverless") !== true) return model;
|
||||
const response = await fetcher(`https://api.digitalocean.com/v2/gen-ai/models/catalog/${model.id}`, {
|
||||
headers: { "Content-Type": "application/json", "User-Agent": "models.dev/digitalocean-sync" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`DigitalOcean catalog detail request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const detail = DigitalOceanCatalogDetailResponse.parse(await response.json()).data;
|
||||
return {
|
||||
...model,
|
||||
modalities: detail.modalities ?? model.modalities,
|
||||
pricing_detail: detail.pricing_detail ?? model.pricing_detail,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
export function parseDigitalOceanModels(raw: unknown): DigitalOceanSourceModel[] {
|
||||
const response = DigitalOceanResponse.parse(raw);
|
||||
const catalog = new Map(response.catalog.map((model) => [model.model_id, model]));
|
||||
return response.models
|
||||
.map((model) => mergeCatalogModel(model, catalog.get(model.id)))
|
||||
.filter(isManagedTextModel);
|
||||
}
|
||||
|
||||
function mergeCatalogModel(
|
||||
model: DigitalOceanModel,
|
||||
catalog: DigitalOceanCatalogModel | undefined,
|
||||
): DigitalOceanSourceModel {
|
||||
return {
|
||||
...model,
|
||||
name: catalog?.name ?? model.name,
|
||||
context_window: catalog?.context_window ?? model.context_window,
|
||||
max_output_tokens: catalog?.max_output_tokens,
|
||||
modalities: catalog?.modalities ?? model.modalities,
|
||||
availability: catalog?.availability,
|
||||
pricing: catalogPricing(catalog),
|
||||
};
|
||||
}
|
||||
|
||||
function isManagedTextModel(model: DigitalOceanSourceModel) {
|
||||
const output = normalizeModalities(model.modalities?.output ?? [], []);
|
||||
return model.availability?.includes("serverless") === true
|
||||
&& output.includes("text")
|
||||
&& model.type !== "embedding"
|
||||
&& model.type !== "reranking";
|
||||
}
|
||||
|
||||
function catalogPricing(model: DigitalOceanCatalogModel | undefined): ModelPricing | undefined {
|
||||
if (model?.pricing == null) return undefined;
|
||||
const standard = model.pricing_detail?.variants.find((variant) =>
|
||||
variant.mode === "MODEL_BILLING_MODE_INTERACTIVE"
|
||||
&& variant.tier === "MODEL_PRICING_TIER_STANDARD"
|
||||
)?.prices;
|
||||
const extended = model.pricing_detail?.variants.find((variant) =>
|
||||
variant.mode === "MODEL_BILLING_MODE_INTERACTIVE"
|
||||
&& variant.tier?.startsWith("MODEL_PRICING_TIER_EXTENDED_") === true
|
||||
);
|
||||
const extendedContext = pricingTierContext(extended?.tier);
|
||||
return {
|
||||
input: perMillion(model.pricing.input_price_per_million),
|
||||
output: perMillion(model.pricing.output_price_per_million),
|
||||
cacheRead: perMillion(model.pricing.cache_read_input_price_per_million),
|
||||
cacheWrite: perMillion(standard?.cache_write_5m_input_price_per_million),
|
||||
extended: extendedContext === undefined || extended?.prices == null
|
||||
? undefined
|
||||
: {
|
||||
context: extendedContext,
|
||||
input: perMillion(extended.prices.input_price_per_million),
|
||||
output: perMillion(extended.prices.output_price_per_million),
|
||||
cacheRead: perMillion(extended.prices.cache_read_input_price_per_million),
|
||||
cacheWrite: perMillion(extended.prices.cache_write_5m_input_price_per_million),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pricingTierContext(tier: string | undefined) {
|
||||
// Tier names describe capacity; Anthropic's 1M surcharge starts above 200K.
|
||||
if (tier === "MODEL_PRICING_TIER_EXTENDED_1M") return 200_000;
|
||||
if (tier === "MODEL_PRICING_TIER_EXTENDED_272K") return 272_000;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function perMillion(value: number | undefined) {
|
||||
if (value === undefined) return undefined;
|
||||
// The live catalog currently returns per-token rates despite the field names.
|
||||
const normalized = value < 0.001 ? value * 1_000_000 : value;
|
||||
return Math.round(normalized * 10_000) / 10_000;
|
||||
}
|
||||
|
||||
type Modality = "text" | "audio" | "image" | "video" | "pdf";
|
||||
|
||||
function normalizeModalities(values: string[], fallback: Modality[]): Modality[] {
|
||||
const allowed = new Set<Modality>(["text", "audio", "image", "video", "pdf"]);
|
||||
const normalized = values
|
||||
.map((value) => value.toLowerCase())
|
||||
.map((value) => value === "code" ? "text" : value)
|
||||
.filter((value): value is Modality => allowed.has(value as Modality));
|
||||
return [...new Set(normalized.length > 0 ? normalized : fallback)];
|
||||
}
|
||||
|
||||
function number(value: string | number | undefined) {
|
||||
if (value === undefined) return undefined;
|
||||
const parsed = typeof value === "number" ? value : Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function inferFamily(id: string, name: string) {
|
||||
const kimi = inferKimiFamily(id, name);
|
||||
if (kimi !== undefined) return kimi;
|
||||
const target = `${id} ${name}`.toLowerCase();
|
||||
return [...ModelFamilyValues]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.find((family) => target.includes(family.toLowerCase()));
|
||||
}
|
||||
|
||||
function reasoningOptionsFor(
|
||||
model: DigitalOceanSourceModel,
|
||||
existing: ExistingModel | undefined,
|
||||
): ExistingModel["reasoning_options"] {
|
||||
if (model.reasoning_efforts === undefined) return existing?.reasoning_options;
|
||||
const values = model.reasoning_efforts
|
||||
.map((value) => value === "null" ? null : value)
|
||||
.filter(isReasoningEffort);
|
||||
const preserved = existing?.reasoning_options?.filter((option) => option.type !== "effort") ?? [];
|
||||
return values.length > 0 ? [...preserved, { type: "effort", values }] : preserved;
|
||||
}
|
||||
|
||||
function isReasoningEffort(value: string | null): value is ReasoningEffort {
|
||||
return value === null
|
||||
|| value === "none"
|
||||
|| value === "minimal"
|
||||
|| value === "low"
|
||||
|| value === "medium"
|
||||
|| value === "high"
|
||||
|| value === "xhigh"
|
||||
|| value === "max"
|
||||
|| value === "default";
|
||||
}
|
||||
|
||||
function status(
|
||||
lifecycleStatus: string,
|
||||
existing: ExistingModel["status"],
|
||||
): ExistingModel["status"] {
|
||||
const lifecycle = lifecycleStatus.toLowerCase().replaceAll("_", "-");
|
||||
if (lifecycle === "deprecated" || lifecycle === "end-of-life") return "deprecated";
|
||||
if (lifecycle === "public-preview") return "beta";
|
||||
return existing === "deprecated" || existing === "beta" ? undefined : existing;
|
||||
}
|
||||
|
||||
function cost(model: DigitalOceanSourceModel, existing: ExistingModel | undefined) {
|
||||
const input = model.pricing?.input ?? existing?.cost?.input;
|
||||
const output = model.pricing?.output ?? existing?.cost?.output;
|
||||
if (input === undefined || output === undefined) return existing?.cost;
|
||||
|
||||
const existingTiers = existing?.cost?.tiers ?? [];
|
||||
const longContext = existingTiers.find((tier) =>
|
||||
(tier.tier.type === undefined || tier.tier.type === "context") && tier.tier.size >= 200_000
|
||||
);
|
||||
const extended = model.pricing?.extended;
|
||||
const hasLongContextPricing = extended?.input !== undefined && extended.output !== undefined;
|
||||
const tiers = hasLongContextPricing
|
||||
? [
|
||||
...existingTiers.filter((tier) => tier !== longContext),
|
||||
{
|
||||
tier: { type: "context" as const, size: extended.context },
|
||||
input: extended.input!,
|
||||
output: extended.output!,
|
||||
reasoning: longContext?.reasoning,
|
||||
cache_read: extended.cacheRead ?? longContext?.cache_read,
|
||||
cache_write: extended.cacheWrite ?? longContext?.cache_write,
|
||||
},
|
||||
]
|
||||
: existingTiers;
|
||||
|
||||
return {
|
||||
input,
|
||||
output,
|
||||
reasoning: existing?.cost?.reasoning,
|
||||
cache_read: model.pricing?.cacheRead ?? existing?.cost?.cache_read,
|
||||
cache_write: model.pricing?.cacheWrite ?? existing?.cost?.cache_write,
|
||||
input_audio: existing?.cost?.input_audio,
|
||||
output_audio: existing?.cost?.output_audio,
|
||||
tiers: tiers.length > 0 ? tiers : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDigitalOceanModel(
|
||||
model: DigitalOceanSourceModel,
|
||||
existing: ExistingModel | undefined,
|
||||
baseModel = existing === undefined ? resolveDigitalOceanBaseModel(model.id) : existing.base_model,
|
||||
): SyncedModel {
|
||||
const input = normalizeModalities(
|
||||
model.modalities?.input ?? [],
|
||||
existing?.modalities?.input ?? ["text"],
|
||||
);
|
||||
const output = normalizeModalities(
|
||||
model.modalities?.output ?? [],
|
||||
existing?.modalities?.output ?? ["text"],
|
||||
);
|
||||
const context = number(model.context_window) ?? existing?.limit?.context ?? 0;
|
||||
const maxTokens = number(model.max_output_tokens ?? undefined);
|
||||
const limit = {
|
||||
context,
|
||||
input: existing?.limit?.input,
|
||||
output: maxTokens ?? existing?.limit?.output ?? 0,
|
||||
};
|
||||
const textOutput = output.includes("text") && !output.includes("image") && !output.includes("video");
|
||||
const remoteReasoning = textOutput
|
||||
&& ((model.thinking ?? false) || (model.reasoning_efforts?.length ?? 0) > 0);
|
||||
const providerReasoning = remoteReasoning ? true : existing?.reasoning;
|
||||
const reasoning = providerReasoning ?? false;
|
||||
const reasoningOptions = reasoning ? reasoningOptionsFor(model, existing) : undefined;
|
||||
const modelStatus = status(model.lifecycle_status, existing?.status);
|
||||
const releaseDate = existing?.release_date ?? model.created_at?.slice(0, 10) ?? new Date().toISOString().slice(0, 10);
|
||||
const values: Partial<SyncedFullModel> = {
|
||||
name: model.name,
|
||||
description: existing?.description ?? describeModel({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
family: existing?.family ?? inferFamily(model.id, model.name),
|
||||
reasoning,
|
||||
tool_call: existing?.tool_call ?? textOutput,
|
||||
structured_output: existing?.structured_output,
|
||||
open_weights: existing?.open_weights ?? false,
|
||||
limit,
|
||||
modalities: { input, output },
|
||||
}),
|
||||
family: existing?.family ?? inferFamily(model.id, model.name),
|
||||
release_date: releaseDate,
|
||||
last_updated: existing?.last_updated ?? releaseDate,
|
||||
attachment: existing?.attachment ?? input.some((value) => value !== "text"),
|
||||
reasoning,
|
||||
reasoning_options: reasoningOptions,
|
||||
temperature: existing?.temperature ?? true,
|
||||
tool_call: existing?.tool_call ?? textOutput,
|
||||
structured_output: existing?.structured_output,
|
||||
knowledge: existing?.knowledge,
|
||||
open_weights: existing?.open_weights ?? false,
|
||||
status: modelStatus,
|
||||
interleaved: existing?.interleaved,
|
||||
cost: cost(model, existing),
|
||||
limit,
|
||||
modalities: { input, output },
|
||||
provider: existing?.provider,
|
||||
experimental: existing?.experimental,
|
||||
};
|
||||
|
||||
if (baseModel !== undefined) {
|
||||
return factorBaseModel(baseModel, {
|
||||
name: model.name,
|
||||
description: existing?.description,
|
||||
attachment: input.some((value) => value !== "text"),
|
||||
reasoning: providerReasoning,
|
||||
reasoning_options: reasoningOptions,
|
||||
temperature: existing?.temperature,
|
||||
tool_call: existing?.tool_call,
|
||||
structured_output: existing?.structured_output,
|
||||
status: modelStatus,
|
||||
interleaved: existing?.interleaved,
|
||||
cost: cost(model, existing),
|
||||
limit,
|
||||
modalities: { input, output },
|
||||
provider: existing?.provider,
|
||||
experimental: existing?.experimental,
|
||||
}, limit, existing?.base_model_omit);
|
||||
}
|
||||
|
||||
const required = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
release_date: z.string(),
|
||||
last_updated: z.string(),
|
||||
attachment: z.boolean(),
|
||||
reasoning: z.boolean(),
|
||||
tool_call: z.boolean(),
|
||||
open_weights: z.boolean(),
|
||||
cost: z.object({ input: z.number(), output: z.number() }),
|
||||
limit: z.object({ context: z.number().nonnegative(), output: z.number().nonnegative() }),
|
||||
modalities: z.object({ input: z.array(z.string()).min(1), output: z.array(z.string()).min(1) }),
|
||||
}).safeParse(values);
|
||||
if (!required.success) {
|
||||
throw new Error(`DigitalOcean model ${model.id} has incomplete metadata required for sync`);
|
||||
}
|
||||
return values as SyncedFullModel;
|
||||
}
|
||||
|
||||
export function resolveDigitalOceanBaseModel(id: string) {
|
||||
const candidates: string[] = [];
|
||||
if (id.startsWith("openai-")) candidates.push(`openai/${id.slice("openai-".length)}`);
|
||||
if (id.startsWith("deepseek-")) {
|
||||
candidates.push(`deepseek/${id}`);
|
||||
candidates.push(`deepseek/${id.replace(/^deepseek-4-/, "deepseek-v4-")}`);
|
||||
}
|
||||
if (id.startsWith("glm-")) candidates.push(`zai/${id}`);
|
||||
if (id.startsWith("kimi-")) candidates.push(`moonshotai/${id}`);
|
||||
if (id.startsWith("minimax-")) candidates.push(`minimax/${id}`);
|
||||
if (id.startsWith("nvidia-")) candidates.push(`nvidia/${id.slice("nvidia-".length)}`);
|
||||
if (id.startsWith("alibaba-")) candidates.push(`qwen/${id.slice("alibaba-".length)}`);
|
||||
if (id.startsWith("qwen")) candidates.push(`qwen/${id}`);
|
||||
if (id.startsWith("llama")) candidates.push(`meta/${id}`);
|
||||
if (id.startsWith("mistral") || id.startsWith("ministral")) candidates.push(`mistralai/${id}`);
|
||||
|
||||
const anthropic = id.match(/^anthropic-claude-(\d+(?:\.\d+)?)-(opus|sonnet|haiku)$/);
|
||||
if (anthropic !== null) {
|
||||
candidates.push(`anthropic/claude-${anthropic[2]}-${anthropic[1]}`);
|
||||
}
|
||||
if (id.startsWith("anthropic-")) candidates.push(`anthropic/${id.slice("anthropic-".length)}`);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const resolved = resolveCanonicalBaseModel(candidate);
|
||||
if (resolved !== undefined) return resolved;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js";
|
||||
import { factorBaseModel, resolveCanonicalBaseModel } from "./openrouter.js";
|
||||
|
||||
// EmpirioLabs exposes a public, unauthenticated OpenAI-compatible model
|
||||
// catalog, so no API key is needed or used for this sync.
|
||||
const API_ENDPOINT = "https://api.empiriolabs.ai/v1/models";
|
||||
|
||||
const CANONICAL_BASE_MODELS: Record<string, string> = {
|
||||
"fugu-ultra": "sakana/fugu-ultra",
|
||||
"gemma-4-26b-a4b": "google/gemma-4-26b-a4b-it",
|
||||
"gemma-4-e4b": "google/gemma-4-E4B-it",
|
||||
"mistral-medium-3": "mistral/mistral-medium-2505",
|
||||
"mistral-small-4": "mistral/mistral-small-2603",
|
||||
"muse-spark-1-1": "meta/muse-spark-1.1",
|
||||
"qwen3-5-9b": "alibaba/qwen3.5-9b",
|
||||
"qwen3-7-max": "alibaba/qwen3.7-max",
|
||||
"qwen3-7-plus": "alibaba/qwen3.7-plus",
|
||||
"step-3-5-flash": "stepfun/step-3.5-flash",
|
||||
"step-3-5-flash-2603": "stepfun/step-3.5-flash-2603",
|
||||
"step-3-7-flash": "stepfun/step-3.7-flash",
|
||||
};
|
||||
|
||||
const EmpiriolabsParameter = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
type: z.string().optional(),
|
||||
options: z.array(z.string()).optional(),
|
||||
min: z.number().optional(),
|
||||
max: z.number().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const EmpiriolabsPricingTier = z
|
||||
.object({
|
||||
prompt: z.string().optional(),
|
||||
completion: z.string().optional(),
|
||||
input_cache_read: z.string().optional(),
|
||||
min_context: z.number().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
// Pricing is returned either as a single tier object or as an array of tier
|
||||
// objects (tiered/context-priced models). Accept both shapes.
|
||||
const EmpiriolabsPricing = z.union([
|
||||
z.array(EmpiriolabsPricingTier),
|
||||
EmpiriolabsPricingTier,
|
||||
]);
|
||||
|
||||
const EmpiriolabsModel = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
display_name: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
context_length: z.number().nullable().optional(),
|
||||
context_window: z.number().nullable().optional(),
|
||||
max_output_tokens: z.number().nullable().optional(),
|
||||
model_released_at: z.string().nullable().optional(),
|
||||
pricing: EmpiriolabsPricing.optional(),
|
||||
capabilities: z.record(z.unknown()).optional(),
|
||||
features: z.array(z.string()).optional(),
|
||||
structured_output: z.string().nullable().optional(),
|
||||
input_modalities: z.array(z.string()).optional(),
|
||||
output_modalities: z.array(z.string()).optional(),
|
||||
supported_parameters: z.array(EmpiriolabsParameter).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const EmpiriolabsResponse = z
|
||||
.object({
|
||||
data: z.array(EmpiriolabsModel),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type EmpiriolabsModel = z.infer<typeof EmpiriolabsModel>;
|
||||
|
||||
export const empiriolabs = {
|
||||
id: "empiriolabs",
|
||||
name: "EmpirioLabs AI",
|
||||
modelsDir: "providers/empiriolabs/models",
|
||||
sourceID(model) {
|
||||
return model.id;
|
||||
},
|
||||
skippedNotice(ids) {
|
||||
if (ids.length === 0) return [];
|
||||
return [
|
||||
`${ids.length} EmpirioLabs AI models returned by the API were not created because they could not be mapped exactly to models.dev canonical metadata. `
|
||||
+ "Existing models and canonical matches are still updated from API-authoritative fields.",
|
||||
`Skipped remote IDs: ${ids.map((id) => `\`${id}\``).join(", ")}`,
|
||||
];
|
||||
},
|
||||
async fetchModels() {
|
||||
const response = await fetch(API_ENDPOINT);
|
||||
if (!response.ok) {
|
||||
throw new Error(`EmpirioLabs request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
parseModels(raw) {
|
||||
// Text chat models only. Skip non-text categories (image, video, audio,
|
||||
// 3D, research, tools) and regional/capability variant lanes (id has ":").
|
||||
return EmpiriolabsResponse.parse(raw).data.filter(
|
||||
(model) => (model.category ?? "").toLowerCase() === "text" && !model.id.includes(":"),
|
||||
);
|
||||
},
|
||||
translateModel(model, context) {
|
||||
const existing = context.existing(model.id);
|
||||
const baseModel = existing?.base_model ?? resolveEmpiriolabsBaseModel(model.id);
|
||||
if (existing === undefined && baseModel === undefined) return undefined;
|
||||
const built = buildEmpiriolabsModel(model, existing, baseModel);
|
||||
// A model with no resolvable context window cannot produce a valid TOML
|
||||
// (limit.context is required), so skip it rather than fail the whole sync.
|
||||
if (built === undefined) return undefined;
|
||||
return {
|
||||
id: model.id,
|
||||
model: built,
|
||||
};
|
||||
},
|
||||
} satisfies SyncProvider<EmpiriolabsModel>;
|
||||
|
||||
type Modality = "text" | "audio" | "image" | "video" | "pdf";
|
||||
type EffortValue =
|
||||
| "none"
|
||||
| "minimal"
|
||||
| "low"
|
||||
| "medium"
|
||||
| "high"
|
||||
| "xhigh"
|
||||
| "max"
|
||||
| "default";
|
||||
|
||||
const EFFORT_VALUES: EffortValue[] = [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
"default",
|
||||
];
|
||||
|
||||
function price(value: string | undefined) {
|
||||
if (value === undefined) return undefined;
|
||||
const number = Number(value);
|
||||
// Per-token string converted to a per-1M-token number.
|
||||
return Number.isFinite(number) && number >= 0
|
||||
? Math.round(number * 1_000_000_000_000) / 1_000_000
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function nonZeroPrice(value: string | undefined) {
|
||||
const result = price(value);
|
||||
return result !== undefined && result > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
type TierCost = { input: number; output: number; cache_read?: number };
|
||||
|
||||
function tierCost(tier: z.infer<typeof EmpiriolabsPricingTier> | undefined): TierCost | undefined {
|
||||
const input = price(tier?.prompt);
|
||||
const output = price(tier?.completion);
|
||||
if (input === undefined || output === undefined) return undefined;
|
||||
const cacheRead = nonZeroPrice(tier?.input_cache_read);
|
||||
return { input, output, cache_read: cacheRead };
|
||||
}
|
||||
|
||||
function modalities(values: string[] | undefined, fallback: Modality[]): Modality[] {
|
||||
const allowed = new Set<Modality>(["text", "audio", "image", "video", "pdf"]);
|
||||
const result = (values ?? [])
|
||||
.map((value) => value.toLowerCase())
|
||||
.map((value) => (value === "file" ? "pdf" : value))
|
||||
.filter((value): value is Modality => allowed.has(value as Modality));
|
||||
return [...new Set(result.length > 0 ? result : fallback)];
|
||||
}
|
||||
|
||||
function reasoningOptions(model: EmpiriolabsModel): SyncedModel["reasoning_options"] {
|
||||
const params = model.supported_parameters ?? [];
|
||||
const options: NonNullable<SyncedModel["reasoning_options"]> = [];
|
||||
if (params.some((parameter) => parameter.name === "enable_thinking")) {
|
||||
options.push({ type: "toggle" });
|
||||
}
|
||||
|
||||
const effort = params.find((parameter) => parameter.name === "reasoning_effort");
|
||||
if (effort?.options?.length) {
|
||||
const values = effort.options.filter((value): value is EffortValue =>
|
||||
(EFFORT_VALUES as string[]).includes(value),
|
||||
);
|
||||
if (values.length > 0) options.push({ type: "effort", values });
|
||||
}
|
||||
|
||||
const budget = params.find((parameter) => parameter.name === "thinking_budget");
|
||||
if (budget !== undefined) {
|
||||
const option: { type: "budget_tokens"; min?: number; max?: number } = { type: "budget_tokens" };
|
||||
if (budget.min !== undefined) option.min = budget.min;
|
||||
if (budget.max !== undefined) option.max = budget.max;
|
||||
options.push(option);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function parameterOutputLimit(model: EmpiriolabsModel) {
|
||||
const parameter = (model.supported_parameters ?? []).find(
|
||||
(item) => item.name === "max_tokens" || item.name === "max_completion_tokens",
|
||||
);
|
||||
return parameter?.max !== undefined && parameter.max > 0 ? parameter.max : undefined;
|
||||
}
|
||||
|
||||
export function resolveEmpiriolabsBaseModel(id: string) {
|
||||
const explicit = CANONICAL_BASE_MODELS[id];
|
||||
if (explicit !== undefined) return explicit;
|
||||
return canonicalCandidates(id)
|
||||
.map((candidate) => resolveCanonicalBaseModel(candidate))
|
||||
.find((candidate) => candidate !== undefined);
|
||||
}
|
||||
|
||||
function canonicalCandidates(id: string) {
|
||||
const candidates: string[] = [];
|
||||
|
||||
if (id.startsWith("deepseek-")) {
|
||||
candidates.push(`deepseek/${id}`);
|
||||
candidates.push(`deepseek/${id.replace(/^deepseek-v(\d+)-(\d+)/, "deepseek-v$1.$2")}`);
|
||||
}
|
||||
|
||||
if (id.startsWith("glm-")) {
|
||||
const normalized = id
|
||||
.replace(/^glm-(\d+)-(\d+)/, "glm-$1.$2")
|
||||
.replace(/^glm-(\d+)-(\d+)v/, "glm-$1.$2v");
|
||||
candidates.push(`z-ai/${id}`);
|
||||
candidates.push(`z-ai/${normalized}`);
|
||||
}
|
||||
|
||||
if (id.startsWith("kimi-")) {
|
||||
const normalized = id.replace(/^(kimi-k\d+)-(\d+)/, "$1.$2");
|
||||
candidates.push(`moonshotai/${id}`);
|
||||
candidates.push(`moonshotai/${normalized}`);
|
||||
}
|
||||
|
||||
if (id.startsWith("minimax-")) {
|
||||
const normalized = id.replace(/^minimax-m(\d+)-(\d+)/, "minimax-m$1.$2");
|
||||
candidates.push(`minimax/${id}`);
|
||||
candidates.push(`minimax/${normalized}`);
|
||||
}
|
||||
|
||||
if (id.startsWith("mimo-")) {
|
||||
const normalized = id.replace(/^mimo-v(\d+)-(\d+)/, "mimo-v$1.$2");
|
||||
candidates.push(`xiaomi/${id}`);
|
||||
candidates.push(`xiaomi/${normalized}`);
|
||||
}
|
||||
|
||||
if (id.startsWith("qwen")) {
|
||||
const normalized = id.replace(/^(qwen\d+)-(\d+)/, "$1.$2");
|
||||
candidates.push(`qwen/${id}`);
|
||||
candidates.push(`qwen/${normalized}`);
|
||||
}
|
||||
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
|
||||
export function buildEmpiriolabsModel(
|
||||
model: EmpiriolabsModel,
|
||||
existing: ExistingModel | undefined,
|
||||
baseModel = existing?.base_model ?? resolveEmpiriolabsBaseModel(model.id),
|
||||
): SyncedModel | undefined {
|
||||
const features = new Set(model.features ?? []);
|
||||
const capabilities = (model.capabilities ?? {}) as Record<string, unknown>;
|
||||
const input = modalities(model.input_modalities, ["text"]);
|
||||
const output = modalities(model.output_modalities, ["text"]);
|
||||
const attachment = input.some((value) => value !== "text");
|
||||
const reasoning =
|
||||
capabilities.reasoning === true || features.has("reasoning") || existing?.reasoning === true;
|
||||
const toolCall =
|
||||
features.has("function_calling") || features.has("tools") || existing?.tool_call === true;
|
||||
const structuredOutput = features.has("structured_output") || existing?.structured_output === true;
|
||||
const temperature =
|
||||
(model.supported_parameters ?? []).some((parameter) => parameter.name === "temperature")
|
||||
|| existing?.temperature === true;
|
||||
|
||||
const pricingTiers = model.pricing === undefined
|
||||
? []
|
||||
: Array.isArray(model.pricing)
|
||||
? [...model.pricing].sort((a, b) => (a.min_context ?? 0) - (b.min_context ?? 0))
|
||||
: [model.pricing];
|
||||
const baseCost = tierCost(pricingTiers[0]);
|
||||
const contextTiers = pricingTiers
|
||||
.slice(1)
|
||||
.map((tier) => {
|
||||
const tierPricing = tierCost(tier);
|
||||
return tierPricing === undefined || tier.min_context === undefined || tier.min_context === null
|
||||
? undefined
|
||||
: { tier: { type: "context" as const, size: tier.min_context }, ...tierPricing };
|
||||
})
|
||||
.filter((tier): tier is NonNullable<typeof tier> => tier !== undefined);
|
||||
const cost = baseCost !== undefined
|
||||
? {
|
||||
...baseCost,
|
||||
reasoning: existing?.cost?.reasoning,
|
||||
cache_write: existing?.cost?.cache_write,
|
||||
tiers: contextTiers.length > 0 ? contextTiers : undefined,
|
||||
}
|
||||
: existing?.cost;
|
||||
|
||||
const context =
|
||||
model.context_length ?? model.context_window ?? existing?.limit?.context;
|
||||
// No usable context window: cannot build a valid model TOML, so skip.
|
||||
if (context === undefined || context === null) return undefined;
|
||||
|
||||
const releaseDate = baseModel === undefined
|
||||
? model.model_released_at ?? existing?.release_date
|
||||
: undefined;
|
||||
const lastUpdated = baseModel === undefined
|
||||
? model.model_released_at ?? existing?.last_updated ?? releaseDate
|
||||
: existing?.last_updated ?? releaseDate;
|
||||
const outputTokens = model.max_output_tokens
|
||||
?? parameterOutputLimit(model)
|
||||
?? existing?.limit?.output
|
||||
?? context;
|
||||
const limit = {
|
||||
context,
|
||||
input: existing?.limit?.input,
|
||||
output: outputTokens,
|
||||
};
|
||||
const values: Partial<SyncedFullModel> = {
|
||||
name: model.display_name ?? model.name ?? model.id,
|
||||
description: baseModel === undefined ? existing?.description ?? model.description : existing?.description,
|
||||
family: existing?.family,
|
||||
release_date: releaseDate,
|
||||
last_updated: lastUpdated,
|
||||
attachment,
|
||||
reasoning,
|
||||
reasoning_options: reasoning ? reasoningOptions(model) : undefined,
|
||||
temperature: temperature || undefined,
|
||||
tool_call: toolCall,
|
||||
structured_output:
|
||||
(model.structured_output !== undefined && model.structured_output !== null)
|
||||
|| structuredOutput
|
||||
|| undefined,
|
||||
knowledge: existing?.knowledge,
|
||||
open_weights: existing?.open_weights,
|
||||
status: existing?.status,
|
||||
interleaved: existing?.interleaved,
|
||||
cost,
|
||||
limit,
|
||||
modalities: { input, output },
|
||||
};
|
||||
|
||||
if (baseModel !== undefined) {
|
||||
return factorBaseModel(baseModel, values, limit, existing?.base_model_omit);
|
||||
}
|
||||
|
||||
if (existing === undefined) return undefined;
|
||||
const required = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
release_date: z.string(),
|
||||
last_updated: z.string(),
|
||||
open_weights: z.boolean(),
|
||||
cost: z.object({ input: z.number(), output: z.number() }),
|
||||
}).safeParse(values);
|
||||
if (!required.success) {
|
||||
throw new Error(`EmpirioLabs model ${model.id} has incomplete local metadata required for sync`);
|
||||
}
|
||||
|
||||
return values as SyncedFullModel;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { describeModel } from "../../describe.js";
|
||||
import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js";
|
||||
import { factorBaseModel } from "./openrouter.js";
|
||||
|
||||
const API_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/models";
|
||||
|
||||
const GoogleModel = z.object({
|
||||
name: z.string(),
|
||||
baseModelId: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
displayName: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
inputTokenLimit: z.number().int().nonnegative(),
|
||||
outputTokenLimit: z.number().int().nonnegative(),
|
||||
supportedGenerationMethods: z.array(z.string()).optional(),
|
||||
temperature: z.number().optional(),
|
||||
topP: z.number().optional(),
|
||||
topK: z.number().optional(),
|
||||
maxTemperature: z.number().optional(),
|
||||
thinking: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const GoogleResponse = z.object({
|
||||
models: z.array(GoogleModel).optional(),
|
||||
nextPageToken: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
type GoogleModel = z.infer<typeof GoogleModel>;
|
||||
|
||||
export const google = {
|
||||
id: "google",
|
||||
name: "Google",
|
||||
modelsDir: "providers/google/models",
|
||||
skipCreates: true,
|
||||
sourceID(model) {
|
||||
return model.name.replace(/^models\//, "");
|
||||
},
|
||||
skippedNotice(ids) {
|
||||
if (ids.length === 0) return [];
|
||||
return [
|
||||
`${ids.length} Google models returned by the API were not created because the Models API does not provide authoritative modalities, pricing, knowledge cutoff, release date, tool calling, or structured output metadata. Existing models are still updated from API-authoritative fields.`,
|
||||
`Skipped remote IDs: ${ids.map((id) => `\`${id}\``).join(", ")}`,
|
||||
];
|
||||
},
|
||||
async fetchModels() {
|
||||
const key = process.env.GOOGLE_API_KEY
|
||||
?? process.env.GEMINI_API_KEY
|
||||
?? process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
||||
if (key === undefined) {
|
||||
throw new Error("Google sync requires GOOGLE_API_KEY, GEMINI_API_KEY, or GOOGLE_GENERATIVE_AI_API_KEY");
|
||||
}
|
||||
|
||||
const models: GoogleModel[] = [];
|
||||
let pageToken: string | undefined;
|
||||
|
||||
do {
|
||||
const url = new URL(API_ENDPOINT);
|
||||
url.searchParams.set("key", key);
|
||||
url.searchParams.set("pageSize", "1000");
|
||||
if (pageToken !== undefined) url.searchParams.set("pageToken", pageToken);
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Google models request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const page = GoogleResponse.parse(await response.json());
|
||||
models.push(...page.models ?? []);
|
||||
pageToken = page.nextPageToken;
|
||||
} while (pageToken !== undefined);
|
||||
|
||||
return { models };
|
||||
},
|
||||
parseModels(raw) {
|
||||
return GoogleResponse.parse(raw).models ?? [];
|
||||
},
|
||||
translateModel(model, context) {
|
||||
const id = model.name.replace(/^models\//, "");
|
||||
const existing = context.existing(id);
|
||||
if (existing === undefined) return undefined;
|
||||
|
||||
return {
|
||||
id,
|
||||
model: buildGoogleModel(model, existing),
|
||||
};
|
||||
},
|
||||
} satisfies SyncProvider<GoogleModel>;
|
||||
|
||||
export function buildGoogleModel(model: GoogleModel, existing: ExistingModel): SyncedModel {
|
||||
const name = existing.name;
|
||||
const description = existing.description;
|
||||
const releaseDate = existing.release_date;
|
||||
const lastUpdated = existing.last_updated;
|
||||
const attachment = existing.attachment;
|
||||
const reasoning = existing.reasoning;
|
||||
const toolCall = existing.tool_call;
|
||||
const openWeights = existing.open_weights;
|
||||
const limit = existing.limit;
|
||||
const modalities = existing.modalities;
|
||||
|
||||
if (
|
||||
name === undefined
|
||||
|| releaseDate === undefined
|
||||
|| lastUpdated === undefined
|
||||
|| attachment === undefined
|
||||
|| reasoning === undefined
|
||||
|| toolCall === undefined
|
||||
|| openWeights === undefined
|
||||
|| limit === undefined
|
||||
|| modalities === undefined
|
||||
) {
|
||||
throw new Error(`Google model ${model.name} has incomplete local TOML metadata required for sync`);
|
||||
}
|
||||
|
||||
const synced: SyncedFullModel = {
|
||||
name: model.displayName ?? name,
|
||||
description: description ?? model.description ?? describeModel({
|
||||
id: model.name.replace(/^models\//, ""),
|
||||
name: model.displayName ?? name,
|
||||
family: existing.family,
|
||||
reasoning: model.thinking ?? reasoning,
|
||||
tool_call: toolCall,
|
||||
structured_output: existing.structured_output,
|
||||
open_weights: openWeights,
|
||||
limit: {
|
||||
input: limit.input,
|
||||
context: model.inputTokenLimit,
|
||||
output: model.outputTokenLimit,
|
||||
},
|
||||
modalities,
|
||||
}),
|
||||
family: existing.family,
|
||||
release_date: releaseDate,
|
||||
last_updated: lastUpdated,
|
||||
attachment,
|
||||
reasoning: model.thinking ?? reasoning,
|
||||
temperature: model.temperature !== undefined || model.maxTemperature !== undefined
|
||||
? true
|
||||
: existing.temperature,
|
||||
reasoning_options: existing.reasoning_options,
|
||||
tool_call: toolCall,
|
||||
structured_output: existing.structured_output,
|
||||
knowledge: existing.knowledge,
|
||||
open_weights: openWeights,
|
||||
status: existing.status,
|
||||
interleaved: existing.interleaved,
|
||||
cost: existing.cost,
|
||||
limit: {
|
||||
input: limit.input,
|
||||
context: model.inputTokenLimit,
|
||||
output: model.outputTokenLimit,
|
||||
},
|
||||
modalities,
|
||||
};
|
||||
|
||||
return existing.base_model === undefined
|
||||
? synced
|
||||
: factorBaseModel(existing.base_model, synced, synced.limit, existing.base_model_omit);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { describeModel } from "../../describe.js";
|
||||
import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js";
|
||||
import { factorBaseModel, resolveCanonicalBaseModel } from "./openrouter.js";
|
||||
|
||||
const API_ENDPOINT = "https://router.huggingface.co/v1/models";
|
||||
|
||||
// Hugging Face org prefixes mapped to the canonical metadata prefixes understood
|
||||
// by resolveCanonicalBaseModel. Anything not listed falls back to a direct lookup.
|
||||
const CANONICAL_ORG_PREFIXES: Record<string, string> = {
|
||||
CohereLabs: "cohere",
|
||||
"deepseek-ai": "deepseek",
|
||||
google: "google",
|
||||
"meta-llama": "meta-llama",
|
||||
MiniMaxAI: "minimax",
|
||||
moonshotai: "moonshotai",
|
||||
nvidia: "nvidia",
|
||||
Qwen: "qwen",
|
||||
"stepfun-ai": "stepfun",
|
||||
XiaomiMiMo: "xiaomi",
|
||||
"zai-org": "zai",
|
||||
};
|
||||
|
||||
const HuggingFaceProvider = z.object({
|
||||
provider: z.string(),
|
||||
status: z.string(),
|
||||
context_length: z.number().int().positive().optional(),
|
||||
pricing: z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
}).passthrough().optional(),
|
||||
throughput: z.number().nonnegative().optional(),
|
||||
first_token_latency_ms: z.number().nonnegative().optional(),
|
||||
is_free: z.boolean().optional(),
|
||||
supports_tools: z.boolean().optional(),
|
||||
supports_structured_output: z.boolean().optional(),
|
||||
is_model_author: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
export const HuggingFaceModel = z.object({
|
||||
id: z.string().min(1),
|
||||
created: z.number().optional(),
|
||||
owned_by: z.string().optional(),
|
||||
architecture: z.object({
|
||||
input_modalities: z.array(z.string()),
|
||||
output_modalities: z.array(z.string()),
|
||||
}).passthrough(),
|
||||
providers: z.array(HuggingFaceProvider),
|
||||
}).passthrough();
|
||||
|
||||
export const HuggingFaceResponse = z.object({
|
||||
data: z.array(HuggingFaceModel),
|
||||
}).passthrough();
|
||||
|
||||
export type HuggingFaceModel = z.infer<typeof HuggingFaceModel>;
|
||||
export type HuggingFaceProvider = z.infer<typeof HuggingFaceProvider>;
|
||||
|
||||
export const huggingface = {
|
||||
id: "huggingface",
|
||||
name: "Hugging Face",
|
||||
modelsDir: "providers/huggingface/models",
|
||||
deleteMissing: false,
|
||||
sourceID(model) {
|
||||
return model.id;
|
||||
},
|
||||
skippedNotice(ids) {
|
||||
if (ids.length === 0) return [];
|
||||
return [
|
||||
`${ids.length} Hugging Face Inference Providers models were not created because their IDs could not be mapped to provider-agnostic metadata, had no live provider, or had no priced provider.`,
|
||||
`Skipped remote IDs: ${ids.map((id) => `\`${id}\``).join(", ")}`,
|
||||
];
|
||||
},
|
||||
missingNotice(paths) {
|
||||
if (paths.length === 0) return [];
|
||||
return [
|
||||
`${paths.length} local Hugging Face models were absent from the Inference Providers catalog and were retained for manual lifecycle review.`,
|
||||
`Retained local paths: ${paths.map((item) => `\`${item}\``).join(", ")}`,
|
||||
];
|
||||
},
|
||||
async fetchModels() {
|
||||
const headers = process.env.HF_TOKEN
|
||||
? { Authorization: `Bearer ${process.env.HF_TOKEN}` }
|
||||
: undefined;
|
||||
const response = await fetch(API_ENDPOINT, { headers });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Hugging Face models request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
parseModels(raw) {
|
||||
return HuggingFaceResponse.parse(raw).data;
|
||||
},
|
||||
translateModel(model, context) {
|
||||
if (!model.providers.some((provider) => provider.status === "live")) return undefined;
|
||||
|
||||
const existing = context.existing(model.id);
|
||||
const baseModel = existing === undefined
|
||||
? resolveHuggingFaceBaseModel(model.id)
|
||||
: existing.base_model;
|
||||
if (existing === undefined && baseModel === undefined) return undefined;
|
||||
|
||||
// The router only exposes pricing per inference provider, so a new model with
|
||||
// no priced provider cannot be created with a meaningful cost.
|
||||
const aggregate = aggregateProviders(model);
|
||||
if (existing === undefined && aggregate.cost === undefined) return undefined;
|
||||
|
||||
return {
|
||||
id: model.id,
|
||||
model: buildHuggingFaceModel(model, existing, baseModel, aggregate),
|
||||
};
|
||||
},
|
||||
sameModel() {
|
||||
// For now the sync only creates new models; existing curated TOMLs are left
|
||||
// untouched. Treating every existing model as already in sync skips updates
|
||||
// while still allowing new files to be created.
|
||||
return true;
|
||||
},
|
||||
} satisfies SyncProvider<HuggingFaceModel>;
|
||||
|
||||
interface Aggregate {
|
||||
cost: { input: number; output: number } | undefined;
|
||||
context: number | undefined;
|
||||
tools: boolean;
|
||||
structuredOutput: boolean;
|
||||
}
|
||||
|
||||
function price(value: number) {
|
||||
return Number.isFinite(value) && value >= 0
|
||||
? Math.round(value * 1_000_000) / 1_000_000
|
||||
: undefined;
|
||||
}
|
||||
|
||||
// The router aggregates several inference providers per model and sends traffic to
|
||||
// the fastest one, so this collapses them into the route a request would actually
|
||||
// take: pricing and context from the highest-throughput provider, plus capabilities
|
||||
// advertised by any provider (a caller can always pin a slower provider).
|
||||
function aggregateProviders(model: HuggingFaceModel): Aggregate {
|
||||
const providers = model.providers.filter((provider) => provider.status === "live");
|
||||
|
||||
const byThroughput = (a: HuggingFaceProvider, b: HuggingFaceProvider) =>
|
||||
(b.throughput ?? -Infinity) - (a.throughput ?? -Infinity);
|
||||
// The provider the router routes to (fastest). Take its price when it reports one;
|
||||
// otherwise fall back to the fastest provider that does, so a new model can still
|
||||
// be costed.
|
||||
const routed = [...providers].sort(byThroughput).at(0);
|
||||
const costProvider = routed?.pricing !== undefined
|
||||
? routed
|
||||
: [...providers]
|
||||
.filter((provider): provider is HuggingFaceProvider & { pricing: { input: number; output: number } } =>
|
||||
provider.pricing !== undefined)
|
||||
.sort(byThroughput)
|
||||
.at(0);
|
||||
const input = costProvider?.pricing === undefined ? undefined : price(costProvider.pricing.input);
|
||||
const output = costProvider?.pricing === undefined ? undefined : price(costProvider.pricing.output);
|
||||
|
||||
const contexts = providers
|
||||
.map((provider) => provider.context_length)
|
||||
.filter((value): value is number => value !== undefined);
|
||||
|
||||
return {
|
||||
cost: input !== undefined && output !== undefined ? { input, output } : undefined,
|
||||
context: routed?.context_length ?? (contexts.length > 0 ? Math.max(...contexts) : undefined),
|
||||
tools: providers.some((provider) => provider.supports_tools === true),
|
||||
structuredOutput: providers.some((provider) => provider.supports_structured_output === true),
|
||||
};
|
||||
}
|
||||
|
||||
type Modality = "text" | "audio" | "image" | "video" | "pdf";
|
||||
|
||||
function modalities(values: string[], fallback: Modality[]): Modality[] {
|
||||
const allowed = new Set<Modality>(["text", "audio", "image", "video", "pdf"]);
|
||||
const result = values
|
||||
.map((value) => value.toLowerCase())
|
||||
.filter((value): value is Modality => allowed.has(value as Modality));
|
||||
return [...new Set(result.length > 0 ? result : fallback)];
|
||||
}
|
||||
|
||||
export function buildHuggingFaceModel(
|
||||
model: HuggingFaceModel,
|
||||
existing: ExistingModel | undefined,
|
||||
baseModel = existing === undefined ? resolveHuggingFaceBaseModel(model.id) : existing.base_model,
|
||||
aggregate: Aggregate = aggregateProviders(model),
|
||||
): SyncedModel {
|
||||
const input = modalities(model.architecture.input_modalities, existing?.modalities?.input ?? ["text"]);
|
||||
const output = modalities(model.architecture.output_modalities, existing?.modalities?.output ?? ["text"]);
|
||||
// Pricing is curated: keep what was authored and only fall back to the router
|
||||
// (fastest route) when the local model has no cost yet.
|
||||
const cost = existing?.cost ?? aggregate.cost;
|
||||
// context/output may be unset for a freshly created base_model entry, in which case
|
||||
// factorBaseModel inherits them from the canonical metadata; the standalone-model
|
||||
// path below validates their presence at runtime.
|
||||
const limit = {
|
||||
context: existing?.limit?.context ?? aggregate.context,
|
||||
input: existing?.limit?.input,
|
||||
output: existing?.limit?.output,
|
||||
} as SyncedFullModel["limit"];
|
||||
const values: Partial<SyncedFullModel> = {
|
||||
name: existing?.name,
|
||||
description: existing?.description ?? describeModel({
|
||||
id: model.id,
|
||||
name: existing?.name ?? model.id,
|
||||
family: existing?.family,
|
||||
reasoning: existing?.reasoning,
|
||||
tool_call: aggregate.tools || existing?.tool_call || undefined,
|
||||
structured_output: aggregate.structuredOutput || existing?.structured_output || undefined,
|
||||
open_weights: existing?.open_weights ?? true,
|
||||
limit,
|
||||
modalities: { input, output },
|
||||
}),
|
||||
family: existing?.family,
|
||||
release_date: existing?.release_date,
|
||||
last_updated: existing?.last_updated,
|
||||
attachment: input.some((value) => value !== "text"),
|
||||
reasoning: existing?.reasoning,
|
||||
reasoning_options: existing?.reasoning_options,
|
||||
temperature: existing?.temperature,
|
||||
tool_call: aggregate.tools || existing?.tool_call || undefined,
|
||||
structured_output: aggregate.structuredOutput || existing?.structured_output || undefined,
|
||||
knowledge: existing?.knowledge,
|
||||
open_weights: existing?.open_weights ?? true,
|
||||
status: existing?.status,
|
||||
interleaved: existing?.interleaved,
|
||||
cost,
|
||||
limit,
|
||||
modalities: { input, output },
|
||||
};
|
||||
|
||||
if (baseModel !== undefined) {
|
||||
return factorBaseModel(baseModel, values, limit, existing?.base_model_omit);
|
||||
}
|
||||
|
||||
// Standalone (non base_model) models require concrete booleans the router does
|
||||
// not always report; default the capability flags it leaves out.
|
||||
const full = { ...values, tool_call: values.tool_call ?? false };
|
||||
const required = z.object({
|
||||
name: z.string(),
|
||||
release_date: z.string(),
|
||||
last_updated: z.string(),
|
||||
description: z.string(),
|
||||
reasoning: z.boolean(),
|
||||
open_weights: z.boolean(),
|
||||
cost: z.object({ input: z.number(), output: z.number() }),
|
||||
limit: z.object({ context: z.number(), output: z.number() }),
|
||||
}).safeParse(full);
|
||||
if (!required.success) {
|
||||
throw new Error(`Hugging Face model ${model.id} has incomplete local metadata required for sync`);
|
||||
}
|
||||
return full as SyncedFullModel;
|
||||
}
|
||||
|
||||
export function resolveHuggingFaceBaseModel(id: string) {
|
||||
const [prefix, ...parts] = id.split("/");
|
||||
if (prefix === undefined || parts.length === 0) return undefined;
|
||||
const canonicalPrefix = CANONICAL_ORG_PREFIXES[prefix];
|
||||
if (canonicalPrefix === undefined) return resolveCanonicalBaseModel(id);
|
||||
return resolveCanonicalBaseModel(`${canonicalPrefix}/${parts.join("/").toLowerCase()}`);
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
import { z } from "zod";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { describeModel } from "../../describe.js";
|
||||
import { inferKimiFamily, ModelFamilyValues } from "../../family.js";
|
||||
import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js";
|
||||
import { factorBaseModel, resolveCanonicalBaseModel } from "./openrouter.js";
|
||||
|
||||
const API_ENDPOINT = "https://api.kilo.ai/api/gateway/models";
|
||||
const MODELS_DIR = path.join(import.meta.dirname, "..", "..", "..", "..", "..", "models");
|
||||
const modelMetadataByID = new Map<string, Record<string, unknown>>();
|
||||
const modelMetadataFilesByProvider = new Map<string, Set<string>>();
|
||||
|
||||
|
||||
export const KiloModel = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
created: z.number(),
|
||||
description: z.string().optional(),
|
||||
hugging_face_id: z.string().nullable().optional(),
|
||||
knowledge_cutoff: z.string().nullable().optional(),
|
||||
context_length: z.number(),
|
||||
architecture: z.object({
|
||||
modality: z.string().optional(),
|
||||
input_modalities: z.array(z.string()),
|
||||
output_modalities: z.array(z.string()),
|
||||
tokenizer: z.string().optional(),
|
||||
}),
|
||||
pricing: z.object({
|
||||
prompt: z.string(),
|
||||
completion: z.string(),
|
||||
internal_reasoning: z.string().optional(),
|
||||
input_cache_read: z.string().optional(),
|
||||
input_cache_write: z.string().optional(),
|
||||
}),
|
||||
top_provider: z.object({
|
||||
context_length: z.number().nullable(),
|
||||
max_completion_tokens: z.number().nullable(),
|
||||
is_moderated: z.boolean().optional(),
|
||||
}),
|
||||
supported_parameters: z.array(z.string()),
|
||||
opencode: z
|
||||
.object({
|
||||
variants: z
|
||||
.record(
|
||||
z.object({
|
||||
reasoning: z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
effort: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const KiloResponse = z.object({
|
||||
data: z.array(KiloModel),
|
||||
}).passthrough();
|
||||
|
||||
export type KiloModel = z.infer<typeof KiloModel>;
|
||||
|
||||
export const kilo = {
|
||||
id: "kilo",
|
||||
name: "Kilo",
|
||||
modelsDir: "providers/kilo/models",
|
||||
async fetchModels() {
|
||||
const headers = process.env.KILO_API_KEY
|
||||
? { Authorization: `Bearer ${process.env.KILO_API_KEY}` }
|
||||
: undefined;
|
||||
const response = await fetch(API_ENDPOINT, { headers });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Kilo request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
parseModels(raw) {
|
||||
return KiloResponse.parse(raw).data;
|
||||
},
|
||||
translateModel(model, context) {
|
||||
// Kilo serves deprecated/unavailable routes as degraded stubs:
|
||||
// negative pricing (`"-1"`) and an empty `supported_parameters` array. Syncing
|
||||
// those would wrongly flip `reasoning`/`tool_call`/`structured_output` to false
|
||||
// and strip `reasoning_options`. Leave the authored file untouched instead, and
|
||||
// skip the model entirely when we have nothing to preserve.
|
||||
if (isUnavailable(model)) {
|
||||
const authored = context.authored(model.id);
|
||||
return authored === undefined ? undefined : { id: model.id, model: authored as SyncedModel };
|
||||
}
|
||||
return {
|
||||
id: model.id,
|
||||
model: buildKiloModel(model, context.existing(model.id)),
|
||||
};
|
||||
},
|
||||
} satisfies SyncProvider<KiloModel>;
|
||||
|
||||
function isUnavailable(model: KiloModel) {
|
||||
return (
|
||||
model.supported_parameters.length === 0 ||
|
||||
Number(model.pricing.prompt) < 0 ||
|
||||
Number(model.pricing.completion) < 0
|
||||
);
|
||||
}
|
||||
|
||||
function dateFromTimestamp(timestamp: number) {
|
||||
return new Date(timestamp * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function price(value: string | undefined) {
|
||||
if (value === undefined) return undefined;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number >= 0
|
||||
? Math.round(number * 1_000_000_000_000) / 1_000_000
|
||||
: undefined;
|
||||
}
|
||||
|
||||
type Modality = "text" | "audio" | "image" | "video" | "pdf";
|
||||
|
||||
function modalities(values: string[], fallback: Modality[]): Modality[] {
|
||||
const allowed = new Set<Modality>(["text", "audio", "image", "video", "pdf"]);
|
||||
const result = values
|
||||
.map((value) => value.toLowerCase())
|
||||
.map((value) => value === "file" ? "pdf" : value)
|
||||
.filter((value): value is Modality => allowed.has(value as Modality));
|
||||
return [...new Set(result.length > 0 ? result : fallback)];
|
||||
}
|
||||
|
||||
function inferFamily(model: KiloModel, name: string) {
|
||||
const kimiFamily = inferKimiFamily(model.id, name);
|
||||
if (kimiFamily !== undefined) return kimiFamily;
|
||||
|
||||
const target = `${model.id} ${name}`.toLowerCase();
|
||||
return [...ModelFamilyValues]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.find((family) => {
|
||||
const value = family.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
if (family === "o") {
|
||||
return new RegExp(`(^|[^a-z0-9])${value}(?=\\d|$|[^a-z0-9])`).test(target);
|
||||
}
|
||||
return new RegExp(`(^|[^a-z0-9])${value}(?=$|[^a-z0-9])`).test(target);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildKiloModel(
|
||||
model: KiloModel,
|
||||
existing: ExistingModel | undefined,
|
||||
baseModel?: string,
|
||||
): SyncedModel {
|
||||
const params = new Set(model.supported_parameters);
|
||||
const name = model.name;
|
||||
const apiDescription = model.description?.replaceAll(/\s+/g, " ").trim();
|
||||
const input = modalities(model.architecture.input_modalities, ["text"]);
|
||||
const output = modalities(model.architecture.output_modalities, ["text"]);
|
||||
const prompt = price(model.pricing.prompt);
|
||||
const completion = price(model.pricing.completion);
|
||||
const reasoning = params.has("reasoning") || params.has("include_reasoning");
|
||||
const reasoning_options = existing?.reasoning_options?.length
|
||||
? existing.reasoning_options
|
||||
: KiloReasoningOptions(model.opencode) ?? existing?.reasoning_options;
|
||||
const context = model.top_provider.context_length ?? model.context_length;
|
||||
const family = inferFamily(model, name);
|
||||
const releaseDate = dateFromTimestamp(model.created);
|
||||
const familyValue = existing?.family === "o" && family !== "o"
|
||||
? family
|
||||
: (existing?.family ?? family);
|
||||
const attachment = input.some((value) => value !== "text");
|
||||
const toolCall = params.has("tools") || params.has("tool_choice");
|
||||
const structuredOutput = params.has("structured_outputs");
|
||||
const knowledge = model.knowledge_cutoff?.slice(0, 10) ?? existing?.knowledge;
|
||||
const openWeights = Boolean(model.hugging_face_id);
|
||||
const cost = prompt !== undefined && completion !== undefined
|
||||
? {
|
||||
input: prompt,
|
||||
output: completion,
|
||||
reasoning: reasoning ? price(model.pricing.internal_reasoning) : undefined,
|
||||
cache_read: price(model.pricing.input_cache_read),
|
||||
cache_write: price(model.pricing.input_cache_write),
|
||||
tiers: existing?.cost?.tiers,
|
||||
}
|
||||
: existing?.cost;
|
||||
const limit = {
|
||||
context,
|
||||
input: existing?.limit?.input,
|
||||
output: model.top_provider.max_completion_tokens ?? existing?.limit?.output ?? context,
|
||||
};
|
||||
const canonical = existing?.base_model ?? baseModel ?? resolveCanonicalBaseModel(model.id);
|
||||
|
||||
if (canonical !== undefined) {
|
||||
return factorBaseModel(
|
||||
canonical,
|
||||
{
|
||||
name: baseModel !== undefined || model.id.endsWith(":free") ? name : undefined,
|
||||
description: existing?.description ?? apiDescription ?? describeModel({
|
||||
id: model.id,
|
||||
name,
|
||||
family: familyValue,
|
||||
reasoning,
|
||||
tool_call: toolCall,
|
||||
structured_output: structuredOutput,
|
||||
open_weights: openWeights,
|
||||
limit,
|
||||
modalities: { input, output },
|
||||
}),
|
||||
attachment,
|
||||
reasoning,
|
||||
reasoning_options,
|
||||
temperature: params.has("temperature"),
|
||||
tool_call: toolCall,
|
||||
structured_output: structuredOutput,
|
||||
status: existing?.status,
|
||||
interleaved: existing?.interleaved,
|
||||
limit,
|
||||
modalities: { input, output },
|
||||
cost,
|
||||
},
|
||||
limit,
|
||||
existing?.base_model === canonical ? existing.base_model_omit : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
description: existing?.description ?? apiDescription ?? describeModel({
|
||||
id: model.id,
|
||||
name,
|
||||
family: familyValue,
|
||||
reasoning,
|
||||
tool_call: toolCall,
|
||||
structured_output: structuredOutput,
|
||||
open_weights: openWeights,
|
||||
limit,
|
||||
modalities: { input, output },
|
||||
}),
|
||||
family: familyValue,
|
||||
release_date: releaseDate,
|
||||
last_updated: releaseDate,
|
||||
attachment,
|
||||
reasoning,
|
||||
reasoning_options,
|
||||
temperature: params.has("temperature"),
|
||||
tool_call: toolCall,
|
||||
structured_output: structuredOutput,
|
||||
knowledge,
|
||||
open_weights: openWeights,
|
||||
status: existing?.status,
|
||||
interleaved: existing?.interleaved,
|
||||
cost,
|
||||
limit,
|
||||
modalities: { input, output },
|
||||
} satisfies SyncedFullModel;
|
||||
}
|
||||
|
||||
function KiloReasoningOptions(opencode: KiloModel["opencode"]): SyncedFullModel["reasoning_options"] {
|
||||
if (opencode?.variants === undefined) return undefined;
|
||||
|
||||
const options: NonNullable<SyncedFullModel["reasoning_options"]> = [];
|
||||
const variants = Object.entries(opencode.variants);
|
||||
|
||||
if (variants.length === 0) return undefined;
|
||||
|
||||
const reasoningEffortOrder = new Map<string, number>([
|
||||
["none", 0],
|
||||
["minimal", 1],
|
||||
["low", 2],
|
||||
["medium", 3],
|
||||
["high", 4],
|
||||
["xhigh", 5],
|
||||
["max", 6],
|
||||
]);
|
||||
|
||||
const efforts = variants
|
||||
.filter(([, variant]) => variant.reasoning?.enabled === true)
|
||||
.map(([, variant]) => variant.reasoning?.effort)
|
||||
.filter((effort): effort is string => effort !== undefined);
|
||||
const hasNone = variants.some(([, variant]) => variant.reasoning?.enabled === false);
|
||||
const allEfforts = hasNone ? [...efforts, "none"] : [...efforts];
|
||||
|
||||
if (allEfforts.length > 0) {
|
||||
const orderedEfforts = allEfforts.sort((a, b) => {
|
||||
const order = (reasoningEffortOrder.get(a) ?? Number.MAX_SAFE_INTEGER)
|
||||
- (reasoningEffortOrder.get(b) ?? Number.MAX_SAFE_INTEGER);
|
||||
return order;
|
||||
});
|
||||
options.push({
|
||||
type: "effort",
|
||||
values: orderedEfforts as Array<string | null>,
|
||||
});
|
||||
}
|
||||
|
||||
return options.length > 0 ? options : undefined;
|
||||
}
|
||||
|
||||
function modelMetadataExists(provider: string, modelID: string) {
|
||||
let files = modelMetadataFilesByProvider.get(provider);
|
||||
if (files === undefined) {
|
||||
try {
|
||||
files = new Set(readdirSync(path.join(MODELS_DIR, provider)));
|
||||
} catch {
|
||||
files = new Set();
|
||||
}
|
||||
modelMetadataFilesByProvider.set(provider, files);
|
||||
}
|
||||
return files.has(`${modelID}.toml`);
|
||||
}
|
||||
|
||||
function baseModelOmit(
|
||||
modelID: string,
|
||||
limit: SyncedFullModel["limit"],
|
||||
) {
|
||||
const metadata = modelMetadata(modelID);
|
||||
const omit: string[] = [];
|
||||
const baseLimit = metadata.limit;
|
||||
if (
|
||||
isPlainObject(baseLimit) &&
|
||||
baseLimit.input !== undefined &&
|
||||
limit.input === undefined &&
|
||||
baseLimit.context !== limit.context
|
||||
) {
|
||||
omit.push("limit.input");
|
||||
}
|
||||
|
||||
return omit.length > 0 ? omit : undefined;
|
||||
}
|
||||
|
||||
function baseModelOverrides(
|
||||
modelID: string,
|
||||
values: Partial<SyncedFullModel>,
|
||||
) {
|
||||
const metadata = modelMetadata(modelID);
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
const override = inheritedOverride(value, metadata[key]);
|
||||
if (override !== undefined) result[key] = override;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function inheritedOverride(value: unknown, inherited: unknown): unknown {
|
||||
if (value === undefined) return undefined;
|
||||
if (sameInheritedValue(value, inherited)) return undefined;
|
||||
if (isPlainObject(value) && isPlainObject(inherited)) {
|
||||
const overrides = Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.map(([key, item]) => [key, inheritedOverride(item, inherited[key])])
|
||||
.filter(([, item]) => item !== undefined),
|
||||
);
|
||||
return Object.keys(overrides).length > 0 ? overrides : undefined;
|
||||
}
|
||||
return stripUndefined(value);
|
||||
}
|
||||
|
||||
function stripUndefined(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(stripUndefined);
|
||||
if (isPlainObject(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter(([, item]) => item !== undefined)
|
||||
.map(([key, item]) => [key, stripUndefined(item)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sameInheritedValue(value: unknown, inherited: unknown) {
|
||||
return stableInheritedValue(value) === stableInheritedValue(inherited);
|
||||
}
|
||||
|
||||
function stableInheritedValue(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.map(stableInheritedValue);
|
||||
const ordered = value.every((item) => item === null || typeof item !== "object")
|
||||
? items.sort()
|
||||
: items;
|
||||
return `[${ordered.join(",")}]`;
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
return `{${Object.entries(value)
|
||||
.filter(([, item]) => item !== undefined)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${stableInheritedValue(item)}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function modelMetadata(modelID: string) {
|
||||
let metadata = modelMetadataByID.get(modelID);
|
||||
if (metadata === undefined) {
|
||||
const filePath = path.join(MODELS_DIR, `${modelID}.toml`);
|
||||
metadata = Bun.TOML.parse(readFileSync(filePath, "utf8")) as Record<string, unknown>;
|
||||
modelMetadataByID.set(modelID, metadata);
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function canonicalCandidates(provider: string, modelID: string) {
|
||||
const candidates = [modelID];
|
||||
|
||||
if (provider === "anthropic") {
|
||||
candidates.push(modelID.replace(/(claude-(?:opus|sonnet|haiku)-\d+)\.(\d+)/, "$1-$2"));
|
||||
candidates.push(modelID.replace(/^claude-3\.5-/, "claude-3-5-"));
|
||||
}
|
||||
|
||||
if (provider === "llama") {
|
||||
candidates.push(modelID.replace(/^llama-(\d+)-(\d+)/, "llama-$1.$2"));
|
||||
candidates.push(modelID.replace(/^llama-(4)-(maverick|scout)$/, "llama-$1-$2-17b"));
|
||||
}
|
||||
|
||||
if (provider === "mistral") {
|
||||
candidates.push(modelID.replace(/-latest$/, ""));
|
||||
}
|
||||
|
||||
if (provider === "minimax") {
|
||||
candidates.push(modelID.replace(/^minimax-m/, "MiniMax-M"));
|
||||
}
|
||||
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { describeModel } from "../../describe.js";
|
||||
import { inferKimiFamily, ModelFamilyValues } from "../../family.js";
|
||||
import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js";
|
||||
import { factorBaseModel, resolveCanonicalBaseModel } from "./openrouter.js";
|
||||
|
||||
const API_ENDPOINT = "https://api.llmgateway.io/v1/models";
|
||||
|
||||
// LLM Gateway names the originating lab in `family`; most already match the
|
||||
// canonical prefixes understood by resolveCanonicalBaseModel. Alias the few that
|
||||
// spell the lab differently. (Mirrors huggingface's CANONICAL_ORG_PREFIXES.)
|
||||
const CANONICAL_FAMILY_ALIASES: Record<string, string> = {
|
||||
mistral: "mistralai",
|
||||
moonshot: "moonshotai",
|
||||
};
|
||||
|
||||
const BASE_MODEL_ALIASES: Record<string, string> = {
|
||||
"glm-5-2": "zhipuai/glm-5.2",
|
||||
};
|
||||
|
||||
const Pricing = z.object({
|
||||
prompt: z.string().optional(),
|
||||
completion: z.string().optional(),
|
||||
internal_reasoning: z.string().optional(),
|
||||
input_cache_read: z.string().optional(),
|
||||
input_cache_write: z.string().optional(),
|
||||
});
|
||||
|
||||
export const LLMGatewayModel = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
created: z.number(),
|
||||
family: z.string().optional(),
|
||||
architecture: z.object({
|
||||
input_modalities: z.array(z.string()),
|
||||
output_modalities: z.array(z.string()),
|
||||
}),
|
||||
pricing: Pricing,
|
||||
context_length: z.number(),
|
||||
supported_parameters: z.array(z.string()),
|
||||
structured_outputs: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
export const LLMGatewayResponse = z.object({
|
||||
data: z.array(LLMGatewayModel),
|
||||
}).passthrough();
|
||||
|
||||
export type LLMGatewayModel = z.infer<typeof LLMGatewayModel>;
|
||||
|
||||
export const llmgateway = {
|
||||
id: "llmgateway",
|
||||
name: "LLM Gateway",
|
||||
modelsDir: "providers/llmgateway/models",
|
||||
async fetchModels() {
|
||||
const headers = process.env.LLMGATEWAY_API_KEY
|
||||
? { Authorization: `Bearer ${process.env.LLMGATEWAY_API_KEY}` }
|
||||
: undefined;
|
||||
const response = await fetch(API_ENDPOINT, { headers });
|
||||
if (!response.ok) {
|
||||
throw new Error(`LLM Gateway request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
parseModels(raw) {
|
||||
return LLMGatewayResponse.parse(raw).data.filter((model) => {
|
||||
const output = model.architecture.output_modalities;
|
||||
return output.length === 1 && output[0] === "text";
|
||||
});
|
||||
},
|
||||
translateModel(model, context) {
|
||||
return {
|
||||
id: model.id,
|
||||
model: buildLLMGatewayModel(model, context.existing(model.id)),
|
||||
};
|
||||
},
|
||||
} satisfies SyncProvider<LLMGatewayModel>;
|
||||
|
||||
function dateFromTimestamp(timestamp: number) {
|
||||
return new Date(timestamp * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function price(value: string | undefined) {
|
||||
if (value === undefined) return undefined;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number >= 0
|
||||
? Math.round(number * 1_000_000_000_000) / 1_000_000
|
||||
: undefined;
|
||||
}
|
||||
|
||||
// Cache/reasoning prices are reported as "0" when the gateway has no data; treat
|
||||
// those as unknown so we never downgrade a hand-authored value to zero.
|
||||
function nonZeroPrice(value: string | undefined) {
|
||||
const result = price(value);
|
||||
return result !== undefined && result > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
type Modality = "text" | "audio" | "image" | "video" | "pdf";
|
||||
|
||||
function modalities(values: string[], fallback: Modality[]): Modality[] {
|
||||
const allowed = new Set<Modality>(["text", "audio", "image", "video", "pdf"]);
|
||||
const result = values
|
||||
.map((value) => value.toLowerCase())
|
||||
.map((value) => (value === "file" ? "pdf" : value))
|
||||
.filter((value): value is Modality => allowed.has(value as Modality));
|
||||
return [...new Set(result.length > 0 ? result : fallback)];
|
||||
}
|
||||
|
||||
function resolveLLMGatewayBaseModel(model: LLMGatewayModel) {
|
||||
const alias = BASE_MODEL_ALIASES[model.id];
|
||||
if (alias !== undefined) return alias;
|
||||
if (model.family === undefined) return undefined;
|
||||
const prefix = CANONICAL_FAMILY_ALIASES[model.family] ?? model.family;
|
||||
return resolveCanonicalBaseModel(`${prefix}/${model.id}`);
|
||||
}
|
||||
|
||||
function inferFamily(model: LLMGatewayModel, name: string) {
|
||||
const kimiFamily = inferKimiFamily(model.id, name);
|
||||
if (kimiFamily !== undefined) return kimiFamily;
|
||||
|
||||
const target = `${model.id} ${name}`.toLowerCase();
|
||||
return [...ModelFamilyValues]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.find((family) => {
|
||||
const value = family.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
if (family === "o") {
|
||||
return new RegExp(`(^|[^a-z0-9])${value}(?=\\d|$|[^a-z0-9])`).test(target);
|
||||
}
|
||||
return new RegExp(`(^|[^a-z0-9])${value}(?=$|[^a-z0-9])`).test(target);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildLLMGatewayModel(
|
||||
model: LLMGatewayModel,
|
||||
existing: ExistingModel | undefined,
|
||||
): SyncedModel {
|
||||
const prompt = price(model.pricing.prompt);
|
||||
const completion = price(model.pricing.completion);
|
||||
const reasoning = model.supported_parameters.includes("reasoning")
|
||||
|| model.supported_parameters.includes("include_reasoning");
|
||||
const context = model.context_length > 0
|
||||
? model.context_length
|
||||
: existing?.limit?.context ?? model.context_length;
|
||||
|
||||
// The gateway is authoritative for the volatile, gateway-specific data — cost
|
||||
// and served limits. Its supported_parameters / modalities are too noisy to
|
||||
// drive capability fields (it omits "tools" for flagship models yet lists
|
||||
// "temperature" for ones the catalog deliberately marks temperature=false),
|
||||
// so those stay curated: preserved from the existing entry (which, for a
|
||||
// factored model, inherits its base when the field is absent).
|
||||
const cost = prompt !== undefined && completion !== undefined
|
||||
? {
|
||||
input: prompt,
|
||||
output: completion,
|
||||
reasoning: reasoning ? nonZeroPrice(model.pricing.internal_reasoning) ?? existing?.cost?.reasoning : existing?.cost?.reasoning,
|
||||
cache_read: nonZeroPrice(model.pricing.input_cache_read) ?? existing?.cost?.cache_read,
|
||||
cache_write: nonZeroPrice(model.pricing.input_cache_write) ?? existing?.cost?.cache_write,
|
||||
tiers: existing?.cost?.tiers,
|
||||
}
|
||||
: existing?.cost;
|
||||
const limit = {
|
||||
context,
|
||||
input: existing?.limit?.input,
|
||||
output: existing?.limit?.output ?? context,
|
||||
};
|
||||
|
||||
// Existing factored model: refresh cost + limit, keep every authored override
|
||||
// as-is (undefined fields keep inheriting the base model).
|
||||
if (existing?.base_model !== undefined) {
|
||||
return factorBaseModel(
|
||||
existing.base_model,
|
||||
{
|
||||
attachment: existing.attachment,
|
||||
description: existing.description ?? describeModel({
|
||||
id: model.id,
|
||||
name: existing.name ?? model.name,
|
||||
family: existing.family,
|
||||
reasoning: existing.reasoning,
|
||||
tool_call: existing.tool_call,
|
||||
structured_output: existing.structured_output,
|
||||
open_weights: existing.open_weights,
|
||||
limit,
|
||||
modalities: existing.modalities,
|
||||
}),
|
||||
reasoning: existing.reasoning,
|
||||
temperature: existing.temperature,
|
||||
tool_call: existing.tool_call,
|
||||
structured_output: existing.structured_output,
|
||||
status: existing.status,
|
||||
interleaved: existing.interleaved,
|
||||
knowledge: existing.knowledge,
|
||||
modalities: existing.modalities,
|
||||
limit,
|
||||
cost,
|
||||
},
|
||||
limit,
|
||||
existing.base_model_omit,
|
||||
);
|
||||
}
|
||||
|
||||
// Existing full model: refresh cost + limit, preserve curated metadata.
|
||||
if (existing !== undefined) {
|
||||
return {
|
||||
name: existing.name ?? model.name,
|
||||
description: existing.description ?? describeModel({
|
||||
id: model.id,
|
||||
name: existing.name ?? model.name,
|
||||
family: existing.family,
|
||||
reasoning: existing.reasoning,
|
||||
tool_call: existing.tool_call,
|
||||
structured_output: existing.structured_output,
|
||||
open_weights: existing.open_weights,
|
||||
limit,
|
||||
modalities: existing.modalities ?? defaultModalities(model),
|
||||
}),
|
||||
family: existing.family,
|
||||
release_date: existing.release_date ?? dateFromTimestamp(model.created),
|
||||
last_updated: existing.last_updated ?? dateFromTimestamp(model.created),
|
||||
attachment: existing.attachment ?? false,
|
||||
reasoning: existing.reasoning ?? false,
|
||||
temperature: existing.temperature ?? false,
|
||||
tool_call: existing.tool_call ?? false,
|
||||
structured_output: existing.structured_output,
|
||||
knowledge: existing.knowledge,
|
||||
open_weights: existing.open_weights ?? false,
|
||||
status: existing.status,
|
||||
interleaved: existing.interleaved,
|
||||
cost,
|
||||
limit,
|
||||
modalities: existing.modalities ?? defaultModalities(model),
|
||||
} satisfies SyncedFullModel;
|
||||
}
|
||||
|
||||
// Brand-new model with a reviewed metadata entry: factor it against the
|
||||
// canonical base so capability, modality, and description facts inherit from
|
||||
// the curated `models/` file. The gateway serves bare IDs and names the lab in
|
||||
// `family`, so glue them into the prefixed form the shared resolver expects.
|
||||
// Only the gateway-authoritative cost and served context are overridden; the
|
||||
// gateway's capability/modality data is too noisy to author standalone.
|
||||
const canonical = resolveLLMGatewayBaseModel(model);
|
||||
if (canonical !== undefined) {
|
||||
const factoredLimit = { context, input: undefined, output: undefined };
|
||||
return factorBaseModel(canonical, { limit: factoredLimit, cost }, factoredLimit);
|
||||
}
|
||||
|
||||
// Brand-new model: best-effort translation from the gateway. Capability and
|
||||
// modality data are unreliable here and should be hand-reviewed.
|
||||
const { input, output } = defaultModalities(model);
|
||||
return {
|
||||
name: model.name,
|
||||
description: describeModel({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
family: inferFamily(model, model.name),
|
||||
reasoning,
|
||||
tool_call: model.supported_parameters.includes("tools")
|
||||
|| model.supported_parameters.includes("tool_choice"),
|
||||
structured_output: model.structured_outputs ?? false,
|
||||
open_weights: false,
|
||||
limit,
|
||||
modalities: { input, output },
|
||||
}),
|
||||
family: inferFamily(model, model.name),
|
||||
release_date: dateFromTimestamp(model.created),
|
||||
last_updated: dateFromTimestamp(model.created),
|
||||
attachment: input.some((value) => value !== "text"),
|
||||
reasoning,
|
||||
temperature: model.supported_parameters.includes("temperature"),
|
||||
tool_call: model.supported_parameters.includes("tools")
|
||||
|| model.supported_parameters.includes("tool_choice"),
|
||||
structured_output: model.structured_outputs ?? false,
|
||||
open_weights: false,
|
||||
cost,
|
||||
limit,
|
||||
modalities: { input, output },
|
||||
} satisfies SyncedFullModel;
|
||||
}
|
||||
|
||||
function defaultModalities(model: LLMGatewayModel) {
|
||||
return {
|
||||
input: modalities(model.architecture.input_modalities, ["text"]),
|
||||
output: modalities(model.architecture.output_modalities, ["text"]),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { AuthoredModel } from "../../schema.js";
|
||||
import type { ExistingModel, SyncProvider, SyncedBaseModel, SyncedModel } from "../index.js";
|
||||
|
||||
const API_ENDPOINT = "https://api.openai.com/v1/models";
|
||||
|
||||
export const OpenAIModel = z.object({
|
||||
id: z.string().min(1),
|
||||
object: z.literal("model"),
|
||||
created: z.number().int().nonnegative(),
|
||||
owned_by: z.string(),
|
||||
}).passthrough();
|
||||
|
||||
const OpenAIResponse = z.object({
|
||||
object: z.literal("list"),
|
||||
data: z.array(OpenAIModel),
|
||||
}).passthrough();
|
||||
|
||||
export type OpenAIModel = z.infer<typeof OpenAIModel>;
|
||||
|
||||
function isFirstPartyModel(model: OpenAIModel) {
|
||||
return !model.id.startsWith("ft:")
|
||||
&& (model.owned_by === "system" || model.owned_by.startsWith("openai"));
|
||||
}
|
||||
|
||||
export function parseOpenAIModels(raw: unknown) {
|
||||
return OpenAIResponse.parse(raw).data.filter(isFirstPartyModel);
|
||||
}
|
||||
|
||||
function preserveAuthoredModel(id: string, authored: ExistingModel): SyncedModel {
|
||||
if (authored.base_model !== undefined) return authored as SyncedBaseModel;
|
||||
|
||||
const parsed = AuthoredModel.safeParse({ id, ...authored });
|
||||
if (!parsed.success) {
|
||||
parsed.error.cause = { provider: "openai", model: id };
|
||||
throw parsed.error;
|
||||
}
|
||||
const { id: _id, ...model } = parsed.data;
|
||||
return model;
|
||||
}
|
||||
|
||||
export async function fetchOpenAIModels(key: string, fetcher: typeof fetch = fetch) {
|
||||
const response = await fetcher(API_ENDPOINT, {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAI models request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export const openai = {
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
modelsDir: "providers/openai/models",
|
||||
skipCreates: true,
|
||||
deleteMissing: false,
|
||||
sourceID(model) {
|
||||
return model.id;
|
||||
},
|
||||
skippedNotice(ids) {
|
||||
if (ids.length === 0) return [];
|
||||
return [
|
||||
`${ids.length} first-party OpenAI models returned by the API are missing from the local catalog and require hand-authored metadata.`,
|
||||
`Missing remote IDs: ${ids.map((id) => `\`${id}\``).join(", ")}`,
|
||||
];
|
||||
},
|
||||
async fetchModels() {
|
||||
const key = process.env.OPENAI_API_KEY;
|
||||
if (key === undefined) throw new Error("OpenAI sync requires OPENAI_API_KEY");
|
||||
return fetchOpenAIModels(key);
|
||||
},
|
||||
parseModels: parseOpenAIModels,
|
||||
translateModel(model, context) {
|
||||
const authored = context.authored(model.id);
|
||||
if (authored === undefined) return undefined;
|
||||
return { id: model.id, model: preserveAuthoredModel(model.id, authored) };
|
||||
},
|
||||
} satisfies SyncProvider<OpenAIModel>;
|
||||
@@ -0,0 +1,469 @@
|
||||
import { z } from "zod";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { describeModel } from "../../describe.js";
|
||||
import { inferKimiFamily, ModelFamilyValues } from "../../family.js";
|
||||
import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js";
|
||||
|
||||
const API_ENDPOINT = "https://openrouter.ai/api/v1/models";
|
||||
const MODELS_DIR = path.join(import.meta.dirname, "..", "..", "..", "..", "..", "models");
|
||||
const modelMetadataByID = new Map<string, Record<string, unknown>>();
|
||||
const modelMetadataFilesByProvider = new Map<string, Set<string>>();
|
||||
|
||||
const CANONICAL_BASE_MODEL_OVERRIDES = {
|
||||
"openai/gpt-5.6-luna-pro": "openai/gpt-5.6-luna",
|
||||
"openai/gpt-5.6-sol-pro": "openai/gpt-5.6-sol",
|
||||
"openai/gpt-5.6-terra-pro": "openai/gpt-5.6-terra",
|
||||
} as const;
|
||||
|
||||
const CANONICAL_PROVIDER_PREFIXES = {
|
||||
alibaba: { provider: "alibaba", metadata: "alibaba" },
|
||||
anthropic: { provider: "anthropic", metadata: "anthropic" },
|
||||
cohere: { provider: "cohere", metadata: "cohere" },
|
||||
deepseek: { provider: "deepseek", metadata: "deepseek" },
|
||||
google: { provider: "google", metadata: "google" },
|
||||
meta: { provider: "llama", metadata: "meta" },
|
||||
"meta-llama": { provider: "llama", metadata: "meta" },
|
||||
minimax: { provider: "minimax", metadata: "minimax" },
|
||||
mistralai: { provider: "mistral", metadata: "mistral" },
|
||||
moonshotai: { provider: "moonshotai", metadata: "moonshotai" },
|
||||
openai: { provider: "openai", metadata: "openai" },
|
||||
nvidia: { provider: "nvidia", metadata: "nvidia" },
|
||||
qwen: { provider: "alibaba", metadata: "alibaba" },
|
||||
stepfun: { provider: "stepfun", metadata: "stepfun" },
|
||||
tencent: { provider: "tencent", metadata: "tencent" },
|
||||
"x-ai": { provider: "xai", metadata: "xai" },
|
||||
xai: { provider: "xai", metadata: "xai" },
|
||||
xiaomi: { provider: "xiaomi", metadata: "xiaomi" },
|
||||
zai: { provider: "zai", metadata: "zhipuai" },
|
||||
"z-ai": { provider: "zai", metadata: "zhipuai" },
|
||||
} as const;
|
||||
|
||||
export const OpenRouterModel = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
created: z.number(),
|
||||
hugging_face_id: z.string().nullable(),
|
||||
knowledge_cutoff: z.string().nullable(),
|
||||
context_length: z.number(),
|
||||
architecture: z.object({
|
||||
input_modalities: z.array(z.string()),
|
||||
output_modalities: z.array(z.string()),
|
||||
}),
|
||||
pricing: z.object({
|
||||
prompt: z.string(),
|
||||
completion: z.string(),
|
||||
internal_reasoning: z.string().optional(),
|
||||
input_cache_read: z.string().optional(),
|
||||
input_cache_write: z.string().optional(),
|
||||
}),
|
||||
top_provider: z.object({
|
||||
context_length: z.number().nullable(),
|
||||
max_completion_tokens: z.number().nullable(),
|
||||
}),
|
||||
supported_parameters: z.array(z.string()),
|
||||
reasoning: z
|
||||
.object({
|
||||
mandatory: z.boolean(),
|
||||
supported_efforts: z
|
||||
.array(z.enum(["max", "xhigh", "high", "medium", "low", "minimal", "none"]))
|
||||
.nullable()
|
||||
.optional(),
|
||||
supports_max_tokens: z.boolean().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const OpenRouterResponse = z.object({
|
||||
data: z.array(OpenRouterModel),
|
||||
}).passthrough();
|
||||
|
||||
export type OpenRouterModel = z.infer<typeof OpenRouterModel>;
|
||||
|
||||
export const openrouter = {
|
||||
id: "openrouter",
|
||||
name: "OpenRouter",
|
||||
modelsDir: "providers/openrouter/models",
|
||||
async fetchModels() {
|
||||
const headers = process.env.OPENROUTER_API_KEY
|
||||
? { Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}` }
|
||||
: undefined;
|
||||
const response = await fetch(API_ENDPOINT, { headers });
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenRouter request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
parseModels(raw) {
|
||||
return OpenRouterResponse.parse(raw).data;
|
||||
},
|
||||
translateModel(model, context) {
|
||||
// OpenRouter serves deprecated/unavailable routes as degraded stubs:
|
||||
// negative pricing (`"-1"`) and an empty `supported_parameters` array. Syncing
|
||||
// those would wrongly flip `reasoning`/`tool_call`/`structured_output` to false
|
||||
// and strip `reasoning_options`. Leave the authored file untouched instead, and
|
||||
// skip the model entirely when we have nothing to preserve.
|
||||
if (isUnavailable(model)) {
|
||||
const authored = context.authored(model.id);
|
||||
return authored === undefined ? undefined : { id: model.id, model: authored as SyncedModel };
|
||||
}
|
||||
return {
|
||||
id: model.id,
|
||||
model: buildOpenRouterModel(model, context.existing(model.id)),
|
||||
};
|
||||
},
|
||||
} satisfies SyncProvider<OpenRouterModel>;
|
||||
|
||||
function isUnavailable(model: OpenRouterModel) {
|
||||
return (
|
||||
model.supported_parameters.length === 0 ||
|
||||
Number(model.pricing.prompt) < 0 ||
|
||||
Number(model.pricing.completion) < 0
|
||||
);
|
||||
}
|
||||
|
||||
function dateFromTimestamp(timestamp: number) {
|
||||
return new Date(timestamp * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function price(value: string | undefined) {
|
||||
if (value === undefined) return undefined;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number >= 0
|
||||
? Math.round(number * 1_000_000_000_000) / 1_000_000
|
||||
: undefined;
|
||||
}
|
||||
|
||||
type Modality = "text" | "audio" | "image" | "video" | "pdf";
|
||||
|
||||
function modalities(values: string[], fallback: Modality[]): Modality[] {
|
||||
const allowed = new Set<Modality>(["text", "audio", "image", "video", "pdf"]);
|
||||
const result = values
|
||||
.map((value) => value.toLowerCase())
|
||||
.map((value) => value === "file" ? "pdf" : value)
|
||||
.filter((value): value is Modality => allowed.has(value as Modality));
|
||||
return [...new Set(result.length > 0 ? result : fallback)];
|
||||
}
|
||||
|
||||
function inferFamily(model: OpenRouterModel, name: string) {
|
||||
const kimiFamily = inferKimiFamily(model.id, name);
|
||||
if (kimiFamily !== undefined) return kimiFamily;
|
||||
|
||||
const target = `${model.id} ${name}`.toLowerCase();
|
||||
return [...ModelFamilyValues]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.find((family) => {
|
||||
const value = family.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
if (family === "o") {
|
||||
return new RegExp(`(^|[^a-z0-9])${value}(?=\\d|$|[^a-z0-9])`).test(target);
|
||||
}
|
||||
return new RegExp(`(^|[^a-z0-9])${value}(?=$|[^a-z0-9])`).test(target);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildOpenRouterModel(
|
||||
model: OpenRouterModel,
|
||||
existing: ExistingModel | undefined,
|
||||
baseModel?: string,
|
||||
): SyncedModel {
|
||||
const params = new Set(model.supported_parameters);
|
||||
const name = model.name.replace(/^[^:]+:\s+/, "");
|
||||
const input = modalities(model.architecture.input_modalities, ["text"]);
|
||||
const output = modalities(model.architecture.output_modalities, ["text"]);
|
||||
const prompt = price(model.pricing.prompt);
|
||||
const completion = price(model.pricing.completion);
|
||||
const reasoning = params.has("reasoning") || params.has("include_reasoning");
|
||||
const reasoning_options = existing?.reasoning_options?.length
|
||||
? existing.reasoning_options
|
||||
: openRouterReasoningOptions(model.reasoning) ?? existing?.reasoning_options;
|
||||
const context = model.context_length;
|
||||
const family = inferFamily(model, name);
|
||||
const releaseDate = dateFromTimestamp(model.created);
|
||||
const familyValue = existing?.family === "o" && family !== "o"
|
||||
? family
|
||||
: (existing?.family ?? family);
|
||||
const attachment = input.some((value) => value !== "text");
|
||||
const toolCall = params.has("tools") || params.has("tool_choice");
|
||||
const structuredOutput = params.has("structured_outputs");
|
||||
const knowledge = model.knowledge_cutoff?.slice(0, 10) ?? existing?.knowledge;
|
||||
const openWeights = Boolean(model.hugging_face_id);
|
||||
const cost = prompt !== undefined && completion !== undefined
|
||||
? {
|
||||
input: prompt,
|
||||
output: completion,
|
||||
reasoning: reasoning ? price(model.pricing.internal_reasoning) : undefined,
|
||||
cache_read: price(model.pricing.input_cache_read),
|
||||
cache_write: price(model.pricing.input_cache_write),
|
||||
tiers: existing?.cost?.tiers,
|
||||
}
|
||||
: existing?.cost;
|
||||
const limit = {
|
||||
context,
|
||||
input: existing?.limit?.input,
|
||||
output: model.top_provider.max_completion_tokens ?? existing?.limit?.output ?? context,
|
||||
};
|
||||
const canonical = existing?.base_model ?? baseModel ?? resolveCanonicalBaseModel(model.id);
|
||||
|
||||
if (canonical !== undefined) {
|
||||
const canonicalOverride = canonicalBaseModelOverride(model.id);
|
||||
return factorBaseModel(
|
||||
canonical,
|
||||
{
|
||||
name: baseModel !== undefined || model.id.endsWith(":free") || canonicalOverride === canonical
|
||||
? name
|
||||
: undefined,
|
||||
description: existing?.description ?? describeModel({
|
||||
id: model.id,
|
||||
name,
|
||||
family: familyValue,
|
||||
reasoning,
|
||||
tool_call: toolCall,
|
||||
structured_output: structuredOutput,
|
||||
open_weights: openWeights,
|
||||
limit,
|
||||
modalities: { input, output },
|
||||
}),
|
||||
attachment,
|
||||
reasoning,
|
||||
reasoning_options,
|
||||
temperature: params.has("temperature"),
|
||||
tool_call: toolCall,
|
||||
structured_output: structuredOutput,
|
||||
status: existing?.status,
|
||||
interleaved: existing?.interleaved,
|
||||
limit,
|
||||
modalities: { input, output },
|
||||
cost,
|
||||
},
|
||||
limit,
|
||||
existing?.base_model === canonical ? existing.base_model_omit : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
description: existing?.description ?? describeModel({
|
||||
id: model.id,
|
||||
name,
|
||||
family: familyValue,
|
||||
reasoning,
|
||||
tool_call: toolCall,
|
||||
structured_output: structuredOutput,
|
||||
open_weights: openWeights,
|
||||
limit,
|
||||
modalities: { input, output },
|
||||
}),
|
||||
family: familyValue,
|
||||
release_date: releaseDate,
|
||||
last_updated: releaseDate,
|
||||
attachment,
|
||||
reasoning,
|
||||
reasoning_options,
|
||||
temperature: params.has("temperature"),
|
||||
tool_call: toolCall,
|
||||
structured_output: structuredOutput,
|
||||
knowledge,
|
||||
open_weights: openWeights,
|
||||
status: existing?.status,
|
||||
interleaved: existing?.interleaved,
|
||||
cost,
|
||||
limit,
|
||||
modalities: { input, output },
|
||||
} satisfies SyncedFullModel;
|
||||
}
|
||||
|
||||
function openRouterReasoningOptions(reasoning: OpenRouterModel["reasoning"]): SyncedFullModel["reasoning_options"] {
|
||||
if (reasoning === undefined) return undefined;
|
||||
|
||||
const options: NonNullable<SyncedFullModel["reasoning_options"]> = [];
|
||||
const efforts = reasoning.supported_efforts === null
|
||||
? ["max", "xhigh", "high", "medium", "low", "minimal", "none"] as const
|
||||
: reasoning.supported_efforts;
|
||||
|
||||
if (efforts !== undefined) {
|
||||
options.push({
|
||||
type: "effort",
|
||||
values: reasoning.mandatory ? efforts.filter((value) => value !== "none") : [...efforts],
|
||||
});
|
||||
}
|
||||
|
||||
if (reasoning.supports_max_tokens === true) {
|
||||
options.push({ type: "budget_tokens" });
|
||||
}
|
||||
|
||||
return options.length > 0 ? options : undefined;
|
||||
}
|
||||
|
||||
export function resolveCanonicalBaseModel(openrouterID: string) {
|
||||
const override = canonicalBaseModelOverride(openrouterID);
|
||||
if (override !== undefined) return override;
|
||||
|
||||
const [prefix, ...modelParts] = openrouterID.split("/");
|
||||
if (prefix === undefined || modelParts.length === 0) return undefined;
|
||||
if (openrouterID.startsWith("~/") || prefix.startsWith("~")) return undefined;
|
||||
|
||||
const canonical = CANONICAL_PROVIDER_PREFIXES[prefix as keyof typeof CANONICAL_PROVIDER_PREFIXES];
|
||||
if (canonical === undefined) return undefined;
|
||||
|
||||
const modelID = modelParts.join("/").replace(/:free$/, "");
|
||||
const candidates = canonicalCandidates(canonical.provider, modelID);
|
||||
const match = candidates.find((candidate) => {
|
||||
return modelMetadataExists(canonical.metadata, candidate);
|
||||
});
|
||||
|
||||
return match === undefined ? undefined : `${canonical.metadata}/${match}`;
|
||||
}
|
||||
|
||||
function modelMetadataExists(provider: string, modelID: string) {
|
||||
let files = modelMetadataFilesByProvider.get(provider);
|
||||
if (files === undefined) {
|
||||
try {
|
||||
files = new Set(readdirSync(path.join(MODELS_DIR, provider)));
|
||||
} catch {
|
||||
files = new Set();
|
||||
}
|
||||
modelMetadataFilesByProvider.set(provider, files);
|
||||
}
|
||||
return files.has(`${modelID}.toml`);
|
||||
}
|
||||
|
||||
function canonicalBaseModelOverride(openrouterID: string) {
|
||||
return CANONICAL_BASE_MODEL_OVERRIDES[
|
||||
openrouterID as keyof typeof CANONICAL_BASE_MODEL_OVERRIDES
|
||||
];
|
||||
}
|
||||
|
||||
export function factorBaseModel(
|
||||
modelID: string,
|
||||
values: Partial<SyncedFullModel>,
|
||||
limit: SyncedFullModel["limit"],
|
||||
existingOmit?: string[],
|
||||
): SyncedModel {
|
||||
return {
|
||||
base_model: modelID,
|
||||
base_model_omit: existingOmit ?? baseModelOmit(modelID, limit),
|
||||
...baseModelOverrides(modelID, values),
|
||||
};
|
||||
}
|
||||
|
||||
function baseModelOmit(
|
||||
modelID: string,
|
||||
limit: SyncedFullModel["limit"],
|
||||
) {
|
||||
const metadata = modelMetadata(modelID);
|
||||
const omit: string[] = [];
|
||||
const baseLimit = metadata.limit;
|
||||
if (
|
||||
isPlainObject(baseLimit) &&
|
||||
baseLimit.input !== undefined &&
|
||||
limit.input === undefined &&
|
||||
baseLimit.context !== limit.context
|
||||
) {
|
||||
omit.push("limit.input");
|
||||
}
|
||||
|
||||
return omit.length > 0 ? omit : undefined;
|
||||
}
|
||||
|
||||
function baseModelOverrides(
|
||||
modelID: string,
|
||||
values: Partial<SyncedFullModel>,
|
||||
) {
|
||||
const metadata = modelMetadata(modelID);
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
const override = inheritedOverride(value, metadata[key]);
|
||||
if (override !== undefined) result[key] = override;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function inheritedOverride(value: unknown, inherited: unknown): unknown {
|
||||
if (value === undefined) return undefined;
|
||||
if (sameInheritedValue(value, inherited)) return undefined;
|
||||
if (isPlainObject(value) && isPlainObject(inherited)) {
|
||||
const overrides = Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.map(([key, item]) => [key, inheritedOverride(item, inherited[key])])
|
||||
.filter(([, item]) => item !== undefined),
|
||||
);
|
||||
return Object.keys(overrides).length > 0 ? overrides : undefined;
|
||||
}
|
||||
return stripUndefined(value);
|
||||
}
|
||||
|
||||
function stripUndefined(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(stripUndefined);
|
||||
if (isPlainObject(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter(([, item]) => item !== undefined)
|
||||
.map(([key, item]) => [key, stripUndefined(item)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sameInheritedValue(value: unknown, inherited: unknown) {
|
||||
return stableInheritedValue(value) === stableInheritedValue(inherited);
|
||||
}
|
||||
|
||||
function stableInheritedValue(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.map(stableInheritedValue);
|
||||
const ordered = value.every((item) => item === null || typeof item !== "object")
|
||||
? items.sort()
|
||||
: items;
|
||||
return `[${ordered.join(",")}]`;
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
return `{${Object.entries(value)
|
||||
.filter(([, item]) => item !== undefined)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${stableInheritedValue(item)}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function modelMetadata(modelID: string) {
|
||||
let metadata = modelMetadataByID.get(modelID);
|
||||
if (metadata === undefined) {
|
||||
const filePath = path.join(MODELS_DIR, `${modelID}.toml`);
|
||||
metadata = Bun.TOML.parse(readFileSync(filePath, "utf8")) as Record<string, unknown>;
|
||||
modelMetadataByID.set(modelID, metadata);
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function canonicalCandidates(provider: string, modelID: string) {
|
||||
const candidates = [modelID];
|
||||
|
||||
if (provider === "anthropic") {
|
||||
candidates.push(modelID.replace(/(claude-(?:opus|sonnet|haiku)-\d+)\.(\d+)/, "$1-$2"));
|
||||
candidates.push(modelID.replace(/^claude-3\.5-/, "claude-3-5-"));
|
||||
}
|
||||
|
||||
if (provider === "llama") {
|
||||
candidates.push(modelID.replace(/^llama-(\d+)-(\d+)/, "llama-$1.$2"));
|
||||
candidates.push(modelID.replace(/^llama-(4)-(maverick|scout)$/, "llama-$1-$2-17b"));
|
||||
}
|
||||
|
||||
if (provider === "mistral") {
|
||||
candidates.push(modelID.replace(/-latest$/, ""));
|
||||
}
|
||||
|
||||
if (provider === "minimax") {
|
||||
candidates.push(modelID.replace(/^minimax-m/, "MiniMax-M"));
|
||||
}
|
||||
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { describeModel } from "../../describe.js";
|
||||
import type { ExistingModel, SyncProvider, SyncedModel } from "../index.js";
|
||||
|
||||
const API_ENDPOINT = "https://catalog.endpoints.ai.ovh.net/rest/v2/openrouter";
|
||||
|
||||
export const OvhcloudModel = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
created: z.number(),
|
||||
hugging_face_id: z.string().nullable().optional(),
|
||||
context_length: z.number(),
|
||||
max_output_length: z.number().optional(),
|
||||
input_modalities: z.array(z.string()).optional(),
|
||||
output_modalities: z.array(z.string()).optional(),
|
||||
pricing: z
|
||||
.object({
|
||||
prompt: z.string().optional(),
|
||||
completion: z.string().optional(),
|
||||
input_cache_reads: z.string().optional(),
|
||||
input_cache_writes: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
supported_features: z.array(z.string()).optional(),
|
||||
supported_sampling_parameters: z.array(z.string()).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const OvhcloudResponse = z
|
||||
.object({
|
||||
data: z.array(OvhcloudModel),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type OvhcloudModel = z.infer<typeof OvhcloudModel>;
|
||||
|
||||
export const ovhcloud = {
|
||||
id: "ovhcloud",
|
||||
name: "OVHcloud AI Endpoints",
|
||||
modelsDir: "providers/ovhcloud/models",
|
||||
async fetchModels() {
|
||||
const response = await fetch(API_ENDPOINT);
|
||||
if (!response.ok) {
|
||||
throw new Error(`OVHcloud request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
parseModels(raw) {
|
||||
return OvhcloudResponse.parse(raw).data;
|
||||
},
|
||||
translateModel(model, context) {
|
||||
return {
|
||||
id: model.id.toLowerCase(),
|
||||
model: buildOvhcloudModel(model, context.existing(model.id.toLowerCase())),
|
||||
};
|
||||
},
|
||||
} satisfies SyncProvider<OvhcloudModel>;
|
||||
|
||||
function dateFromTimestamp(timestamp: number) {
|
||||
return new Date(timestamp * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function price(value: string | undefined) {
|
||||
if (value === undefined) return undefined;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number >= 0
|
||||
? Math.round(number * 1_000_000_000_000) / 1_000_000
|
||||
: undefined;
|
||||
}
|
||||
|
||||
type Modality = "text" | "audio" | "image" | "video" | "pdf";
|
||||
|
||||
function modalities(values: string[], fallback: Modality[]): Modality[] {
|
||||
const allowed = new Set<Modality>(["text", "audio", "image", "video", "pdf"]);
|
||||
const result = values
|
||||
.map((value) => value.toLowerCase())
|
||||
.map((value) => (value === "file" ? "pdf" : value))
|
||||
.filter((value): value is Modality => allowed.has(value as Modality));
|
||||
return [...new Set(result.length > 0 ? result : fallback)];
|
||||
}
|
||||
|
||||
export function buildOvhcloudModel(
|
||||
model: OvhcloudModel,
|
||||
existing: ExistingModel | undefined,
|
||||
): SyncedModel {
|
||||
const features = new Set(model.supported_features ?? []);
|
||||
const samplingParameters = new Set(model.supported_sampling_parameters ?? []);
|
||||
const input = modalities(model.input_modalities ?? ["text"], ["text"]);
|
||||
const output = modalities(model.output_modalities ?? ["text"], ["text"]);
|
||||
const attachment = input.some((value) => value !== "text");
|
||||
const reasoning = features.has("reasoning");
|
||||
const toolCall = features.has("tools");
|
||||
const structuredOutput = features.has("structured_outputs");
|
||||
const temperature = samplingParameters.has("temperature");
|
||||
const openWeights = Boolean(model.hugging_face_id);
|
||||
const releaseDate = existing?.release_date ?? dateFromTimestamp(model.created);
|
||||
const lastUpdated = existing?.last_updated ?? releaseDate;
|
||||
|
||||
const inputCost = price(model.pricing?.prompt);
|
||||
const outputCost = price(model.pricing?.completion);
|
||||
const cacheRead = price(model.pricing?.input_cache_reads);
|
||||
const cacheWrite = price(model.pricing?.input_cache_writes);
|
||||
const cost =
|
||||
(inputCost ?? 0) > 0 || (outputCost ?? 0) > 0
|
||||
? {
|
||||
input: inputCost ?? 0,
|
||||
output: outputCost ?? 0,
|
||||
cache_read: cacheRead !== undefined && cacheRead > 0 ? cacheRead : undefined,
|
||||
cache_write: cacheWrite !== undefined && cacheWrite > 0 ? cacheWrite : undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
base_model: existing?.base_model,
|
||||
base_model_omit: existing?.base_model_omit,
|
||||
name: model.name,
|
||||
description: existing?.description ?? describeModel({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
family: existing?.family,
|
||||
reasoning,
|
||||
tool_call: toolCall,
|
||||
structured_output: structuredOutput || undefined,
|
||||
open_weights: openWeights,
|
||||
limit: {
|
||||
context: model.context_length,
|
||||
input: existing?.limit?.input,
|
||||
output: model.max_output_length ?? existing?.limit?.output ?? model.context_length,
|
||||
},
|
||||
modalities: { input, output },
|
||||
}),
|
||||
family: existing?.family,
|
||||
release_date: releaseDate,
|
||||
last_updated: lastUpdated,
|
||||
attachment,
|
||||
reasoning,
|
||||
reasoning_options: reasoning ? existing?.reasoning_options : undefined,
|
||||
temperature: temperature || undefined,
|
||||
tool_call: toolCall,
|
||||
structured_output: structuredOutput || undefined,
|
||||
knowledge: existing?.knowledge,
|
||||
open_weights: openWeights,
|
||||
status: existing?.status,
|
||||
interleaved: existing?.interleaved,
|
||||
cost,
|
||||
limit: {
|
||||
context: model.context_length,
|
||||
input: existing?.limit?.input,
|
||||
output: model.max_output_length ?? existing?.limit?.output ?? model.context_length,
|
||||
},
|
||||
modalities: { input, output },
|
||||
} satisfies SyncedModel;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { describeModel } from "../../describe.js";
|
||||
import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js";
|
||||
import { factorBaseModel } from "./openrouter.js";
|
||||
|
||||
const API_ENDPOINT = "https://api.pioneer.ai/v1/models";
|
||||
|
||||
const BaseModels: Record<string, string> = {
|
||||
"Qwen/Qwen3.5-9B": "alibaba/qwen3.5-9b",
|
||||
"google/gemma-4-E2B-it": "google/gemma-4-E2B-it",
|
||||
"google/gemma-4-E4B-it": "google/gemma-4-E4B-it",
|
||||
"mistral-medium-3.5": "mistral/mistral-medium-2604",
|
||||
"moonshotai/Kimi-K2.7-Code": "moonshotai/kimi-k2.7-code",
|
||||
"openai/gpt-oss-120b": "openai/gpt-oss-120b",
|
||||
"openai/gpt-oss-20b": "openai/gpt-oss-20b",
|
||||
"sakana/fugu-ultra": "sakana/fugu-ultra",
|
||||
"zai-org/GLM-5.2": "zhipuai/glm-5.2",
|
||||
};
|
||||
|
||||
const Capability = z
|
||||
.object({
|
||||
supported: z.boolean(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const ReasoningEffortValues = [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
"default",
|
||||
] as const;
|
||||
|
||||
type ReasoningEffort = typeof ReasoningEffortValues[number];
|
||||
|
||||
const ReasoningEfforts = new Set<string>(ReasoningEffortValues);
|
||||
|
||||
const PioneerReasoningLevel = z
|
||||
.object({
|
||||
effort: z.string(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const PioneerMetadataModel = z
|
||||
.object({
|
||||
slug: z.string(),
|
||||
default_reasoning_level: z.string().nullish(),
|
||||
supported_reasoning_levels: z.array(PioneerReasoningLevel).nullish(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const PioneerServedModel = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
display_name: z.string(),
|
||||
created: z.number().optional(),
|
||||
created_at: z.string().optional(),
|
||||
max_input_tokens: z.number().int().nonnegative(),
|
||||
max_tokens: z.number().int().nonnegative(),
|
||||
deprecated: z.boolean().optional(),
|
||||
capabilities: z
|
||||
.object({
|
||||
image_input: Capability.optional(),
|
||||
pdf_input: Capability.optional(),
|
||||
structured_outputs: Capability.optional(),
|
||||
thinking: Capability.optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const PioneerModel = PioneerServedModel.extend({
|
||||
metadata: PioneerMetadataModel.optional(),
|
||||
});
|
||||
|
||||
export const PioneerResponse = z
|
||||
.object({
|
||||
data: z.array(PioneerServedModel),
|
||||
models: z.array(PioneerMetadataModel).optional().default([]),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type PioneerModel = z.infer<typeof PioneerModel>;
|
||||
|
||||
export const pioneer = {
|
||||
id: "pioneer",
|
||||
name: "Pioneer",
|
||||
modelsDir: "providers/pioneer/models",
|
||||
skipCreates: true,
|
||||
deleteMissing: false,
|
||||
async fetchModels() {
|
||||
const response = await fetch(API_ENDPOINT);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Pioneer request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
parseModels(raw) {
|
||||
const parsed = PioneerResponse.parse(raw);
|
||||
const metadata = new Map(parsed.models.map((model) => [model.slug, model]));
|
||||
return parsed.data.map((model) => ({
|
||||
...model,
|
||||
metadata: metadata.get(model.id),
|
||||
}));
|
||||
},
|
||||
translateModel(model, context) {
|
||||
return {
|
||||
id: model.id,
|
||||
model: buildPioneerModel(model, context.existing(model.id)),
|
||||
};
|
||||
},
|
||||
missingNotice(paths) {
|
||||
if (paths.length === 0) return [];
|
||||
return [
|
||||
`${paths.length} local model(s) are not present in Pioneer /v1/models and were retained: ${paths.join(", ")}`,
|
||||
];
|
||||
},
|
||||
skippedNotice(ids) {
|
||||
if (ids.length === 0) return [];
|
||||
return [
|
||||
`${ids.length} remote model(s) are present in Pioneer /v1/models but were not created because Pioneer sync is update-only for new models: ${ids.join(", ")}`,
|
||||
];
|
||||
},
|
||||
} satisfies SyncProvider<PioneerModel>;
|
||||
|
||||
function dateFromModel(model: PioneerModel) {
|
||||
if (model.created !== undefined) return new Date(model.created * 1000).toISOString().slice(0, 10);
|
||||
if (model.created_at !== undefined) return model.created_at.slice(0, 10);
|
||||
return "2024-01-01";
|
||||
}
|
||||
|
||||
function supported(model: PioneerModel, capability: keyof PioneerModel["capabilities"]) {
|
||||
return model.capabilities[capability]?.supported === true;
|
||||
}
|
||||
|
||||
function isReasoningEffort(value: string): value is ReasoningEffort {
|
||||
return ReasoningEfforts.has(value);
|
||||
}
|
||||
|
||||
function pioneerReasoningOptions(model: PioneerModel): SyncedFullModel["reasoning_options"] {
|
||||
const levels = model.metadata?.supported_reasoning_levels ?? [];
|
||||
if (levels.length === 0) return undefined;
|
||||
|
||||
const unsupported = levels
|
||||
.map((level) => level.effort)
|
||||
.filter((effort) => !isReasoningEffort(effort));
|
||||
if (unsupported.length > 0) {
|
||||
throw new Error(
|
||||
`Unsupported Pioneer reasoning effort(s) for ${model.id}: ${[...new Set(unsupported)].join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const values = [...new Set(levels.map((level) => level.effort).filter(isReasoningEffort))];
|
||||
return values.length > 0 ? [{ type: "effort", values }] : undefined;
|
||||
}
|
||||
|
||||
function buildPioneerModel(
|
||||
model: PioneerModel,
|
||||
existing: ExistingModel | undefined,
|
||||
): SyncedModel {
|
||||
const status = model.deprecated === true ? "deprecated" : existing?.status;
|
||||
const baseModel = existing?.base_model ?? BaseModels[model.id];
|
||||
const apiReasoningOptions = pioneerReasoningOptions(model);
|
||||
const reasoning = apiReasoningOptions !== undefined || supported(model, "thinking") || existing?.reasoning === true;
|
||||
const reasoningOptions = apiReasoningOptions ?? (reasoning ? existing?.reasoning_options : undefined);
|
||||
const interleaved = reasoning ? (existing?.interleaved ?? { field: "reasoning_content" as const }) : undefined;
|
||||
|
||||
if (baseModel !== undefined) {
|
||||
const limit = {
|
||||
context: model.max_input_tokens,
|
||||
input: existing?.limit?.input,
|
||||
output: model.max_tokens,
|
||||
};
|
||||
return factorBaseModel(baseModel, {
|
||||
cost: existing?.cost,
|
||||
reasoning: apiReasoningOptions !== undefined ? true : undefined,
|
||||
reasoning_options: reasoningOptions,
|
||||
status,
|
||||
interleaved,
|
||||
limit,
|
||||
}, limit, existing?.base_model_omit);
|
||||
}
|
||||
|
||||
const input = [
|
||||
"text",
|
||||
supported(model, "image_input") ? "image" : undefined,
|
||||
supported(model, "pdf_input") ? "pdf" : undefined,
|
||||
].filter((value): value is "text" | "image" | "pdf" => value !== undefined);
|
||||
|
||||
return {
|
||||
name: existing?.name ?? model.display_name,
|
||||
description: existing?.description ?? describeModel({
|
||||
id: model.id,
|
||||
providerId: "pioneer",
|
||||
name: model.display_name,
|
||||
family: existing?.family,
|
||||
reasoning,
|
||||
tool_call: existing?.tool_call ?? true,
|
||||
structured_output: supported(model, "structured_outputs") || undefined,
|
||||
open_weights: existing?.open_weights ?? false,
|
||||
modalities: { input, output: ["text"] },
|
||||
}),
|
||||
family: existing?.family,
|
||||
release_date: existing?.release_date ?? dateFromModel(model),
|
||||
last_updated: existing?.last_updated ?? dateFromModel(model),
|
||||
attachment: input.some((value) => value !== "text"),
|
||||
reasoning,
|
||||
reasoning_options: reasoningOptions,
|
||||
temperature: existing?.temperature ?? true,
|
||||
tool_call: existing?.tool_call ?? true,
|
||||
structured_output: supported(model, "structured_outputs") || undefined,
|
||||
knowledge: existing?.knowledge,
|
||||
open_weights: existing?.open_weights ?? false,
|
||||
status,
|
||||
interleaved,
|
||||
cost: existing?.cost,
|
||||
limit: {
|
||||
context: model.max_input_tokens,
|
||||
input: existing?.limit?.input,
|
||||
output: model.max_tokens,
|
||||
},
|
||||
modalities: { input, output: ["text"] },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
|
||||
import { describeModel } from "../../describe.js";
|
||||
import { inferKimiFamily, ModelFamilyValues } from "../../family.js";
|
||||
import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js";
|
||||
import { factorBaseModel } from "./openrouter.js";
|
||||
|
||||
const API_ENDPOINT = "https://api.venice.ai/api/v1/models?type=text";
|
||||
const MODELS_DIR = path.join(import.meta.dirname, "..", "..", "..", "..", "..", "models");
|
||||
|
||||
const Capabilities = z.object({
|
||||
supportsAudioInput: z.boolean().optional(),
|
||||
supportsE2EE: z.boolean().optional(),
|
||||
supportsFunctionCalling: z.boolean().optional(),
|
||||
supportsReasoning: z.boolean().optional(),
|
||||
supportsReasoningEffort: z.boolean().optional(),
|
||||
reasoningEffortOptions: z.array(z.string()).optional(),
|
||||
supportsResponseSchema: z.boolean().optional(),
|
||||
supportsVideoInput: z.boolean().optional(),
|
||||
supportsVision: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const PricingTier = z.object({
|
||||
usd: z.number().nonnegative(),
|
||||
}).passthrough();
|
||||
|
||||
const ExtendedPricing = z.object({
|
||||
context_token_threshold: z.number().int().nonnegative(),
|
||||
input: PricingTier,
|
||||
output: PricingTier,
|
||||
cache_input: PricingTier.optional(),
|
||||
cache_write: PricingTier.optional(),
|
||||
}).passthrough();
|
||||
|
||||
const Pricing = z.object({
|
||||
input: PricingTier,
|
||||
output: PricingTier,
|
||||
cache_input: PricingTier.optional(),
|
||||
cache_write: PricingTier.optional(),
|
||||
extended: ExtendedPricing.optional(),
|
||||
}).passthrough();
|
||||
|
||||
const ModelSpec = z.object({
|
||||
pricing: Pricing.optional(),
|
||||
availableContextTokens: z.number().int().nonnegative(),
|
||||
maxCompletionTokens: z.number().int().nonnegative().optional(),
|
||||
capabilities: Capabilities,
|
||||
name: z.string().min(1),
|
||||
modelSource: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
export const VeniceModel = z.object({
|
||||
created: z.number(),
|
||||
id: z.string().min(1),
|
||||
model_spec: ModelSpec,
|
||||
}).passthrough();
|
||||
|
||||
export const VeniceResponse = z.object({
|
||||
data: z.array(VeniceModel),
|
||||
}).passthrough();
|
||||
|
||||
export type VeniceModel = z.infer<typeof VeniceModel>;
|
||||
|
||||
type ReasoningEffort = "default" | "max" | "low" | "high" | "none" | "medium" | "minimal" | "xhigh";
|
||||
|
||||
interface MetadataEntry {
|
||||
id: string;
|
||||
filename: string;
|
||||
normalizedFull: string;
|
||||
normalizedFilename: string;
|
||||
}
|
||||
|
||||
let metadataEntries: MetadataEntry[] | undefined;
|
||||
|
||||
const BASE_MODEL_ALIASES: Record<string, string> = {
|
||||
"claude-opus-4-6-fast": "anthropic/claude-opus-4-6",
|
||||
"claude-opus-4-7-fast": "anthropic/claude-opus-4-7",
|
||||
"claude-opus-4-8-fast": "anthropic/claude-opus-4-8",
|
||||
"openai-gpt-56-luna-pro": "openai/gpt-5.6-luna",
|
||||
"openai-gpt-56-sol-pro": "openai/gpt-5.6-sol",
|
||||
"openai-gpt-56-terra-pro": "openai/gpt-5.6-terra",
|
||||
};
|
||||
|
||||
export const venice = {
|
||||
id: "venice",
|
||||
name: "Venice",
|
||||
modelsDir: "providers/venice/models",
|
||||
preserveBaseModels: false,
|
||||
async fetchModels() {
|
||||
const headers = process.env.VENICE_API_KEY
|
||||
? { Authorization: `Bearer ${process.env.VENICE_API_KEY}` }
|
||||
: undefined;
|
||||
const response = await fetch(API_ENDPOINT, { headers });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Venice models request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
parseModels(raw) {
|
||||
return VeniceResponse.parse(raw).data;
|
||||
},
|
||||
translateModel(model, context) {
|
||||
if (model.model_spec.capabilities.supportsE2EE === true) return undefined;
|
||||
const id = model.id.replaceAll("/", "-");
|
||||
const existing = context.existing(id);
|
||||
const existingBase = existing?.base_model?.startsWith("venice/") === false ? existing.base_model : undefined;
|
||||
const resolvedBase = existingBase ?? resolveVeniceBaseModel(model.id, model.model_spec.name);
|
||||
return {
|
||||
id,
|
||||
model: buildVeniceModel(model, existing, resolvedBase ?? null),
|
||||
};
|
||||
},
|
||||
} satisfies SyncProvider<VeniceModel>;
|
||||
|
||||
export function buildVeniceModel(
|
||||
model: VeniceModel,
|
||||
existing: ExistingModel | undefined,
|
||||
baseModel: string | null | undefined = existing?.base_model ?? resolveVeniceBaseModel(model.id, model.model_spec.name),
|
||||
today = new Date().toISOString().slice(0, 10),
|
||||
): SyncedModel {
|
||||
const spec = model.model_spec;
|
||||
const capabilities = spec.capabilities;
|
||||
const input = [
|
||||
"text" as const,
|
||||
...(capabilities.supportsVision ? ["image" as const] : []),
|
||||
...(capabilities.supportsAudioInput ? ["audio" as const] : []),
|
||||
...(capabilities.supportsVideoInput ? ["video" as const] : []),
|
||||
...(existing?.modalities?.input.includes("pdf") ? ["pdf" as const] : []),
|
||||
];
|
||||
const limit = {
|
||||
context: spec.availableContextTokens,
|
||||
input: existing?.limit?.input,
|
||||
output: spec.maxCompletionTokens ?? Math.floor(spec.availableContextTokens / 4),
|
||||
};
|
||||
const reasoningEfforts = capabilities.reasoningEffortOptions?.filter(isReasoningEffort);
|
||||
const reasoningOptions = reasoningEfforts?.length
|
||||
? [{ type: "effort" as const, values: reasoningEfforts }]
|
||||
: [];
|
||||
const cost = spec.pricing === undefined
|
||||
? existing?.cost
|
||||
: {
|
||||
input: spec.pricing.input.usd,
|
||||
output: spec.pricing.output.usd,
|
||||
reasoning: existing?.cost?.reasoning,
|
||||
cache_read: spec.pricing.cache_input?.usd,
|
||||
cache_write: spec.pricing.cache_write?.usd,
|
||||
input_audio: existing?.cost?.input_audio,
|
||||
output_audio: existing?.cost?.output_audio,
|
||||
tiers: spec.pricing.extended === undefined
|
||||
? existing?.cost?.tiers
|
||||
: [{
|
||||
tier: { type: "context" as const, size: spec.pricing.extended.context_token_threshold },
|
||||
input: spec.pricing.extended.input.usd,
|
||||
output: spec.pricing.extended.output.usd,
|
||||
cache_read: spec.pricing.extended.cache_input?.usd,
|
||||
cache_write: spec.pricing.extended.cache_write?.usd,
|
||||
}],
|
||||
};
|
||||
const authoritative = {
|
||||
name: spec.name,
|
||||
attachment: input.some((value) => value !== "text"),
|
||||
reasoning: capabilities.supportsReasoning === true,
|
||||
reasoning_options: reasoningOptions,
|
||||
tool_call: capabilities.supportsFunctionCalling === true,
|
||||
structured_output: capabilities.supportsResponseSchema === true ? true : undefined,
|
||||
temperature: undefined,
|
||||
cost,
|
||||
limit,
|
||||
modalities: { input: [...new Set(input)], output: ["text" as const] },
|
||||
};
|
||||
const releaseDate = new Date(model.created * 1000).toISOString().slice(0, 10);
|
||||
const values: SyncedFullModel = {
|
||||
...authoritative,
|
||||
description: existing?.description ?? describeModel({
|
||||
id: model.id,
|
||||
name: spec.name,
|
||||
family: baseModel == null ? inferFamily(model.id, spec.name) ?? existing?.family : existing?.family,
|
||||
reasoning: capabilities.supportsReasoning === true,
|
||||
tool_call: capabilities.supportsFunctionCalling === true,
|
||||
structured_output: capabilities.supportsResponseSchema === true ? true : undefined,
|
||||
open_weights: spec.modelSource?.toLowerCase().includes("huggingface")
|
||||
?? existing?.open_weights
|
||||
?? false,
|
||||
limit,
|
||||
modalities: authoritative.modalities,
|
||||
}),
|
||||
family: baseModel == null ? inferFamily(model.id, spec.name) ?? existing?.family : existing?.family,
|
||||
release_date: releaseDate,
|
||||
last_updated: existing?.last_updated ?? today,
|
||||
knowledge: existing?.knowledge,
|
||||
open_weights: spec.modelSource?.toLowerCase().includes("huggingface")
|
||||
?? existing?.open_weights
|
||||
?? false,
|
||||
status: existing?.status,
|
||||
interleaved: existing?.interleaved,
|
||||
};
|
||||
|
||||
return baseModel == null
|
||||
? values
|
||||
: factorBaseModel(baseModel, values, limit, existing?.base_model_omit);
|
||||
}
|
||||
|
||||
export function resolveVeniceBaseModel(id: string, name: string) {
|
||||
const alias = BASE_MODEL_ALIASES[id];
|
||||
if (alias !== undefined) return alias;
|
||||
const entries = getMetadataEntries();
|
||||
const normalizedID = normalize(id);
|
||||
const normalizedName = normalize(name);
|
||||
const ranked = [
|
||||
entries.filter((entry) => entry.normalizedFull === normalizedID),
|
||||
entries.filter((entry) => entry.normalizedFilename === normalizedID),
|
||||
entries.filter((entry) => entry.normalizedFilename === normalizedName),
|
||||
];
|
||||
return ranked.find((matches) => matches.length === 1)?.[0]?.id;
|
||||
}
|
||||
|
||||
function getMetadataEntries() {
|
||||
if (metadataEntries !== undefined) return metadataEntries;
|
||||
metadataEntries = [];
|
||||
for (const provider of readdirSync(MODELS_DIR, { withFileTypes: true })) {
|
||||
if (!provider.isDirectory()) continue;
|
||||
for (const file of readdirSync(path.join(MODELS_DIR, provider.name), { withFileTypes: true })) {
|
||||
if (!file.isFile() || !file.name.endsWith(".toml")) continue;
|
||||
const filename = file.name.slice(0, -5);
|
||||
metadataEntries.push({
|
||||
id: `${provider.name}/${filename}`,
|
||||
filename,
|
||||
normalizedFull: normalize(`${provider.name}/${filename}`),
|
||||
normalizedFilename: normalize(filename),
|
||||
});
|
||||
}
|
||||
}
|
||||
return metadataEntries;
|
||||
}
|
||||
|
||||
function normalize(value: string) {
|
||||
return value.toLowerCase().replaceAll(/[^a-z0-9]/g, "");
|
||||
}
|
||||
|
||||
function isReasoningEffort(value: string): value is ReasoningEffort {
|
||||
return ["default", "max", "low", "high", "none", "medium", "minimal", "xhigh"].includes(value);
|
||||
}
|
||||
|
||||
function inferFamily(id: string, name: string) {
|
||||
const kimiFamily = inferKimiFamily(id, name);
|
||||
if (kimiFamily !== undefined) return kimiFamily;
|
||||
|
||||
const target = `${id} ${name}`.toLowerCase();
|
||||
return [...ModelFamilyValues]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.find((family) => {
|
||||
const value = family.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
if (family === "o") return new RegExp(`(^|[^a-z0-9])${value}(?=\\d|$|[^a-z0-9])`).test(target);
|
||||
return new RegExp(`(^|[^a-z0-9])${value}(?=$|[^a-z0-9])`).test(target);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { describeModel } from "../../describe.js";
|
||||
import { inferKimiFamily, ModelFamilyValues } from "../../family.js";
|
||||
import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js";
|
||||
import { factorBaseModel, resolveCanonicalBaseModel } from "./openrouter.js";
|
||||
|
||||
const API_ENDPOINT = "https://ai-gateway.vercel.sh/v1/models";
|
||||
|
||||
const ModelType = z.enum([
|
||||
"language",
|
||||
"embedding",
|
||||
"image",
|
||||
"video",
|
||||
"reranking",
|
||||
"transcription",
|
||||
"speech",
|
||||
"realtime",
|
||||
]);
|
||||
|
||||
const PricingTier = z.object({
|
||||
cost: z.string(),
|
||||
min: z.number().optional(),
|
||||
max: z.number().optional(),
|
||||
});
|
||||
|
||||
const Pricing = z.object({
|
||||
input: z.string().optional(),
|
||||
output: z.string().optional(),
|
||||
input_cache_read: z.string().optional(),
|
||||
input_cache_write: z.string().optional(),
|
||||
input_tiers: z.array(PricingTier).optional(),
|
||||
output_tiers: z.array(PricingTier).optional(),
|
||||
input_cache_read_tiers: z.array(PricingTier).optional(),
|
||||
input_cache_write_tiers: z.array(PricingTier).optional(),
|
||||
}).passthrough();
|
||||
|
||||
export const VercelModel = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
created: z.number(),
|
||||
released: z.number().optional(),
|
||||
context_window: z.number().optional().default(0),
|
||||
max_tokens: z.number().optional().default(0),
|
||||
type: ModelType,
|
||||
tags: z.array(z.string()).optional().default([]),
|
||||
pricing: Pricing.optional(),
|
||||
}).passthrough();
|
||||
|
||||
const VercelResponse = z.object({
|
||||
data: z.array(VercelModel),
|
||||
}).passthrough();
|
||||
|
||||
export type VercelModel = z.infer<typeof VercelModel>;
|
||||
|
||||
export const vercel = {
|
||||
id: "vercel",
|
||||
name: "Vercel AI Gateway",
|
||||
modelsDir: "providers/vercel/models",
|
||||
preserveSymlinks: true,
|
||||
async fetchModels() {
|
||||
const response = await fetch(API_ENDPOINT);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Vercel AI Gateway request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
parseModels(raw) {
|
||||
return VercelResponse.parse(raw).data;
|
||||
},
|
||||
translateModel(model, context) {
|
||||
return {
|
||||
id: model.id,
|
||||
model: buildVercelModel(model, context.existing(model.id)),
|
||||
};
|
||||
},
|
||||
sameModel(current, desired) {
|
||||
return sameVercelModel(current, desired);
|
||||
},
|
||||
} satisfies SyncProvider<VercelModel>;
|
||||
|
||||
export function buildVercelModel(model: VercelModel, existing: ExistingModel | undefined): SyncedModel {
|
||||
const tags = new Set(model.tags);
|
||||
const releaseDate = model.released
|
||||
? dateFromTimestamp(model.released)
|
||||
: existing?.release_date ?? new Date().toISOString().slice(0, 10);
|
||||
const context = model.context_window > 0
|
||||
? model.context_window
|
||||
: existing?.limit?.context ?? 0;
|
||||
const output = model.max_tokens > 0
|
||||
? model.max_tokens
|
||||
: existing?.limit?.output ?? 0;
|
||||
const input = model.id.startsWith("openai/") && context > output
|
||||
? context - output
|
||||
: undefined;
|
||||
const cost = buildCost(model.pricing, existing?.cost);
|
||||
|
||||
const synced: SyncedFullModel = {
|
||||
name: existing?.name ?? model.name,
|
||||
description: existing?.description ?? describeModel({
|
||||
id: model.id,
|
||||
name: existing?.name ?? model.name,
|
||||
family: existing?.family ?? inferFamily(model.id, model.name),
|
||||
reasoning: existing?.reasoning ?? tags.has("reasoning"),
|
||||
tool_call: model.type === "language"
|
||||
? existing?.tool_call ?? tags.has("tool-use")
|
||||
: tags.has("tool-use"),
|
||||
structured_output: existing?.structured_output,
|
||||
open_weights: existing?.open_weights ?? false,
|
||||
limit: { context, input, output },
|
||||
modalities: {
|
||||
input: model.type === "transcription"
|
||||
? ["audio"]
|
||||
: model.type === "realtime"
|
||||
? ["text", "audio"]
|
||||
: ["text", tags.has("vision") ? "image" : undefined, tags.has("file-input") ? "pdf" : undefined]
|
||||
.filter((value): value is "text" | "image" | "pdf" => value !== undefined),
|
||||
output: model.type === "speech"
|
||||
? ["audio"]
|
||||
: model.type === "realtime"
|
||||
? ["text", "audio"]
|
||||
: model.type === "image"
|
||||
? ["image"]
|
||||
: model.type === "video"
|
||||
? ["video"]
|
||||
: tags.has("image-generation")
|
||||
? ["text", "image"]
|
||||
: ["text"],
|
||||
},
|
||||
}),
|
||||
family: existing?.family ?? inferFamily(model.id, model.name),
|
||||
release_date: releaseDate,
|
||||
last_updated: existing?.last_updated ?? releaseDate,
|
||||
attachment: existing?.attachment ?? (tags.has("vision") || tags.has("file-input")),
|
||||
reasoning: existing?.reasoning ?? tags.has("reasoning"),
|
||||
reasoning_options: existing?.reasoning_options,
|
||||
temperature: true,
|
||||
tool_call: model.type === "language"
|
||||
? existing?.tool_call ?? tags.has("tool-use")
|
||||
: tags.has("tool-use"),
|
||||
structured_output: existing?.structured_output,
|
||||
knowledge: existing?.knowledge,
|
||||
open_weights: existing?.open_weights ?? false,
|
||||
status: existing?.status,
|
||||
interleaved: existing?.interleaved,
|
||||
experimental: existing?.experimental,
|
||||
provider: existing?.provider,
|
||||
cost,
|
||||
limit: { context, input, output },
|
||||
modalities: {
|
||||
input: model.type === "transcription"
|
||||
? ["audio"]
|
||||
: model.type === "realtime"
|
||||
? ["text", "audio"]
|
||||
: ["text", tags.has("vision") ? "image" : undefined, tags.has("file-input") ? "pdf" : undefined]
|
||||
.filter((value): value is "text" | "image" | "pdf" => value !== undefined),
|
||||
output: model.type === "speech"
|
||||
? ["audio"]
|
||||
: model.type === "realtime"
|
||||
? ["text", "audio"]
|
||||
: model.type === "image"
|
||||
? ["image"]
|
||||
: model.type === "video"
|
||||
? ["video"]
|
||||
: tags.has("image-generation")
|
||||
? ["text", "image"]
|
||||
: ["text"],
|
||||
},
|
||||
};
|
||||
|
||||
const baseModel = existing?.base_model ?? resolveCanonicalBaseModel(model.id);
|
||||
if (baseModel === undefined) return synced;
|
||||
|
||||
const { last_updated: _lastUpdated, ...overrides } = synced;
|
||||
return factorBaseModel(baseModel, overrides, synced.limit, existing?.base_model_omit);
|
||||
}
|
||||
|
||||
function dateFromTimestamp(timestamp: number) {
|
||||
return new Date(timestamp * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function price(value: string | undefined) {
|
||||
if (value === undefined) return undefined;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number >= 0
|
||||
? Math.round(number * 1_000_000_000_000) / 1_000_000
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function buildCost(pricing: VercelModel["pricing"], existing?: ExistingModel["cost"]) {
|
||||
const input = price(pricing?.input_tiers?.[0]?.cost ?? pricing?.input);
|
||||
const output = price(pricing?.output_tiers?.[0]?.cost ?? pricing?.output);
|
||||
if (input === undefined || output === undefined) return undefined;
|
||||
return {
|
||||
input,
|
||||
output,
|
||||
reasoning: existing?.reasoning,
|
||||
cache_read: price(pricing?.input_cache_read_tiers?.[0]?.cost ?? pricing?.input_cache_read),
|
||||
cache_write: price(pricing?.input_cache_write_tiers?.[0]?.cost ?? pricing?.input_cache_write),
|
||||
tiers: existing?.tiers,
|
||||
};
|
||||
}
|
||||
|
||||
function inferFamily(modelID: string, name: string) {
|
||||
const kimiFamily = inferKimiFamily(modelID, name);
|
||||
if (kimiFamily !== undefined) return kimiFamily;
|
||||
|
||||
const targets = [modelID, name].map((value) => value.toLowerCase());
|
||||
const families = [...ModelFamilyValues].sort((a, b) => b.length - a.length);
|
||||
return families.find((family) => targets.some((target) => target.includes(family.toLowerCase())))
|
||||
?? families.find((family) => targets.some((target) => isSubsequence(target, family.toLowerCase())));
|
||||
}
|
||||
|
||||
function isSubsequence(target: string, value: string) {
|
||||
let index = 0;
|
||||
for (const character of target) {
|
||||
if (character === value[index]) index++;
|
||||
}
|
||||
return index === value.length;
|
||||
}
|
||||
|
||||
function sameVercelModel(current: ExistingModel, desired: SyncedModel) {
|
||||
const desiredModel = desired as ExistingModel;
|
||||
const fields: Array<[unknown, unknown, boolean?]> = [
|
||||
[current.base_model, desiredModel.base_model],
|
||||
[current.base_model_omit, desiredModel.base_model_omit],
|
||||
[current.name, desiredModel.name],
|
||||
[current.description, desiredModel.description],
|
||||
[current.family, desiredModel.family],
|
||||
[current.attachment, desiredModel.attachment],
|
||||
[current.reasoning, desiredModel.reasoning],
|
||||
[current.reasoning_options, desiredModel.reasoning_options],
|
||||
[current.tool_call, desiredModel.tool_call],
|
||||
[current.structured_output, desiredModel.structured_output],
|
||||
[current.open_weights, desiredModel.open_weights],
|
||||
[current.release_date, desiredModel.release_date],
|
||||
[current.cost?.input, desiredModel.cost?.input, true],
|
||||
[current.cost?.output, desiredModel.cost?.output, true],
|
||||
[current.cost?.cache_read, desiredModel.cost?.cache_read, true],
|
||||
[current.cost?.cache_write, desiredModel.cost?.cache_write, true],
|
||||
[current.limit?.context, desiredModel.limit?.context],
|
||||
[current.limit?.input, desiredModel.limit?.input],
|
||||
[current.limit?.output, desiredModel.limit?.output],
|
||||
[current.modalities?.input, desiredModel.modalities?.input],
|
||||
];
|
||||
|
||||
return fields.every(([currentValue, desiredValue, cost]) => {
|
||||
if (cost && currentValue === 0 && desiredValue === undefined) return true;
|
||||
if (cost && typeof currentValue === "number" && typeof desiredValue === "number") {
|
||||
return Math.abs(currentValue - desiredValue) <= 0.001;
|
||||
}
|
||||
if (
|
||||
(currentValue === 0 || desiredValue === 0)
|
||||
&& (typeof currentValue === "number" || typeof desiredValue === "number")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return JSON.stringify(currentValue) === JSON.stringify(desiredValue);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import path from "node:path";
|
||||
import { readdirSync } from "node:fs";
|
||||
import { z } from "zod";
|
||||
|
||||
import { inferKimiFamily, ModelFamily, ModelFamilyValues } from "../../family.js";
|
||||
import { ReasoningOption } from "../../schema.js";
|
||||
import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js";
|
||||
import { factorBaseModel } from "./openrouter.js";
|
||||
|
||||
const API_ENDPOINT = "https://trace.wandb.ai/inference/modelsdev/models";
|
||||
const MODELS_DIR = path.join(import.meta.dirname, "..", "..", "..", "..", "..", "models");
|
||||
|
||||
const WandbCost = z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
reasoning: z.number().optional(),
|
||||
cache_read: z.number().optional(),
|
||||
cache_write: z.number().optional(),
|
||||
input_audio: z.number().optional(),
|
||||
output_audio: z.number().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const WandbLimit = z.object({
|
||||
context: z.number(),
|
||||
input: z.number().optional(),
|
||||
output: z.number(),
|
||||
}).passthrough();
|
||||
|
||||
const WandbModalities = z.object({
|
||||
input: z.array(z.string()),
|
||||
output: z.array(z.string()),
|
||||
}).passthrough();
|
||||
|
||||
export const WandbModel = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
attachment: z.boolean(),
|
||||
reasoning: z.boolean(),
|
||||
reasoning_options: z.array(ReasoningOption).optional(),
|
||||
tool_call: z.boolean(),
|
||||
structured_output: z.boolean().optional(),
|
||||
temperature: z.boolean().optional(),
|
||||
knowledge: z.string().optional(),
|
||||
release_date: z.string(),
|
||||
last_updated: z.string(),
|
||||
open_weights: z.boolean(),
|
||||
status: z.string().optional(),
|
||||
interleaved: z.union([z.boolean(), z.object({ field: z.string() }).passthrough()]).optional(),
|
||||
cost: WandbCost.optional(),
|
||||
limit: WandbLimit.optional(),
|
||||
modalities: WandbModalities.optional(),
|
||||
}).passthrough();
|
||||
|
||||
const WandbProvider = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
npm: z.string(),
|
||||
env: z.array(z.string()),
|
||||
doc: z.string(),
|
||||
api: z.string().optional(),
|
||||
models: z.record(z.string(), WandbModel),
|
||||
}).passthrough();
|
||||
|
||||
const WandbResponse = z.record(z.string(), WandbProvider);
|
||||
|
||||
export type WandbModel = z.infer<typeof WandbModel>;
|
||||
|
||||
type SupportedModality = "text" | "audio" | "image" | "video" | "pdf";
|
||||
type InterleavedObject = Exclude<SyncedFullModel["interleaved"], true | undefined>;
|
||||
|
||||
interface MetadataEntry {
|
||||
id: string;
|
||||
filename: string;
|
||||
normalizedFull: string;
|
||||
normalizedFilename: string;
|
||||
}
|
||||
|
||||
const CANONICAL_PREFIXES: Record<string, string> = {
|
||||
"deepseek-ai": "deepseek",
|
||||
google: "google",
|
||||
"meta-llama": "meta",
|
||||
MiniMaxAI: "minimax",
|
||||
moonshotai: "moonshotai",
|
||||
nvidia: "nvidia",
|
||||
openai: "openai",
|
||||
Qwen: "alibaba",
|
||||
"zai-org": "zhipuai",
|
||||
};
|
||||
|
||||
let metadataEntries: MetadataEntry[] | undefined;
|
||||
|
||||
const modalityMap: Record<string, SupportedModality | undefined> = {
|
||||
text: "text",
|
||||
image: "image",
|
||||
audio: "audio",
|
||||
video: "video",
|
||||
pdf: "pdf",
|
||||
file: "pdf",
|
||||
files: "pdf",
|
||||
};
|
||||
|
||||
export const wandb = {
|
||||
id: "wandb",
|
||||
name: "Weights & Biases",
|
||||
modelsDir: "providers/wandb/models",
|
||||
deleteMissing: true,
|
||||
sourceID(model) {
|
||||
return model.id;
|
||||
},
|
||||
async fetchModels() {
|
||||
const response = await fetch(API_ENDPOINT);
|
||||
if (!response.ok) {
|
||||
throw new Error(`W&B Inference request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
parseModels(raw) {
|
||||
return Object.values(WandbResponse.parse(raw)).flatMap((provider) => Object.values(provider.models));
|
||||
},
|
||||
translateModel(model, context) {
|
||||
const existing = context.existing(model.id);
|
||||
const baseModel = existing?.base_model ?? resolveWandbBaseModel(model.id);
|
||||
return {
|
||||
id: model.id,
|
||||
model: buildWandbModel(model, existing, baseModel),
|
||||
};
|
||||
},
|
||||
} satisfies SyncProvider<WandbModel>;
|
||||
|
||||
export function buildWandbModel(
|
||||
model: WandbModel,
|
||||
existing: ExistingModel | undefined,
|
||||
baseModel = existing?.base_model ?? resolveWandbBaseModel(model.id),
|
||||
): SyncedModel {
|
||||
const inputModalities = normalizeModalities(model.modalities?.input ?? []);
|
||||
const outputModalities = normalizeModalities(model.modalities?.output ?? []);
|
||||
const limit = {
|
||||
context: model.limit?.context ?? existing?.limit?.context ?? 0,
|
||||
output: model.limit?.output ?? existing?.limit?.output ?? 0,
|
||||
};
|
||||
const synced: SyncedFullModel = {
|
||||
name: normalizeName(model),
|
||||
description: model.description ?? existing?.description,
|
||||
family: resolveFamily(model),
|
||||
attachment: model.attachment,
|
||||
reasoning: model.reasoning,
|
||||
// The endpoint is authoritative for reasoning controls: an explicit list
|
||||
// (e.g. a toggle) means the capability is exposed, while reasoning without
|
||||
// any options means reasoning is always on and cannot be disabled.
|
||||
reasoning_options: model.reasoning ? model.reasoning_options ?? [] : undefined,
|
||||
temperature: model.temperature ?? true,
|
||||
tool_call: model.tool_call,
|
||||
structured_output: model.structured_output === true,
|
||||
knowledge: model.knowledge ?? existing?.knowledge,
|
||||
release_date: existing?.release_date ?? model.release_date,
|
||||
last_updated: existing?.last_updated ?? model.last_updated,
|
||||
open_weights: model.open_weights,
|
||||
status: resolveStatus(existing, model.status),
|
||||
interleaved: model.reasoning
|
||||
? normalizeInterleaved(model.interleaved) ?? existing?.interleaved
|
||||
: undefined,
|
||||
cost: buildCost(model.cost, existing?.cost),
|
||||
limit,
|
||||
modalities: {
|
||||
input: inputModalities.length > 0
|
||||
? inputModalities
|
||||
: existing?.modalities?.input ?? ["text"],
|
||||
output: outputModalities.length > 0
|
||||
? outputModalities
|
||||
: existing?.modalities?.output ?? ["text"],
|
||||
},
|
||||
};
|
||||
|
||||
if (baseModel === undefined) return synced;
|
||||
return factorBaseModel(baseModel, synced, limit, existing?.base_model_omit);
|
||||
}
|
||||
|
||||
function buildCost(
|
||||
cost: WandbModel["cost"],
|
||||
existing: ExistingModel["cost"] | undefined,
|
||||
): SyncedFullModel["cost"] | undefined {
|
||||
if (cost !== undefined) {
|
||||
return {
|
||||
input: cost.input,
|
||||
output: cost.output,
|
||||
reasoning: cost.reasoning,
|
||||
cache_read: cost.cache_read !== undefined && cost.cache_read > 0
|
||||
? cost.cache_read
|
||||
: undefined,
|
||||
cache_write: cost.cache_write !== undefined && cost.cache_write > 0
|
||||
? cost.cache_write
|
||||
: undefined,
|
||||
input_audio: cost.input_audio,
|
||||
output_audio: cost.output_audio,
|
||||
};
|
||||
}
|
||||
|
||||
if (existing?.input === undefined || existing.output === undefined) return undefined;
|
||||
return {
|
||||
input: existing.input,
|
||||
output: existing.output,
|
||||
reasoning: existing.reasoning,
|
||||
cache_read: existing.cache_read,
|
||||
cache_write: existing.cache_write,
|
||||
input_audio: existing.input_audio,
|
||||
output_audio: existing.output_audio,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeName(model: WandbModel): string {
|
||||
const stripped = model.name.replace(/^[^:]+:\s*/, "").trim();
|
||||
return stripped || path.basename(model.id);
|
||||
}
|
||||
|
||||
function normalizeModalities(values: string[]): SupportedModality[] {
|
||||
const normalized = values
|
||||
.map((value) => modalityMap[value.toLowerCase()])
|
||||
.filter((value): value is SupportedModality => value !== undefined);
|
||||
return [...new Set(normalized)];
|
||||
}
|
||||
|
||||
function normalizeInterleaved(
|
||||
value: WandbModel["interleaved"],
|
||||
): SyncedFullModel["interleaved"] | undefined {
|
||||
if (value === true) return true;
|
||||
if (value !== undefined && value !== false) {
|
||||
return { field: value.field as InterleavedObject["field"] };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveStatus(
|
||||
existing: ExistingModel | undefined,
|
||||
status: string | undefined,
|
||||
): SyncedFullModel["status"] | undefined {
|
||||
return existing?.status ?? (status as SyncedFullModel["status"] | undefined);
|
||||
}
|
||||
|
||||
function resolveFamily(model: WandbModel): SyncedFullModel["family"] | undefined {
|
||||
const inferred = inferFamily(model.id, model.name);
|
||||
return isValidFamily(inferred) ? inferred : undefined;
|
||||
}
|
||||
|
||||
function isValidFamily(family: string | undefined): family is ModelFamily {
|
||||
return family !== undefined && ModelFamily.safeParse(family).success;
|
||||
}
|
||||
|
||||
function inferFamily(modelID: string, modelName: string): string | undefined {
|
||||
const kimiFamily = inferKimiFamily(modelID, modelName);
|
||||
if (kimiFamily !== undefined) return kimiFamily;
|
||||
|
||||
const sortedFamilies = [...ModelFamilyValues].sort((a, b) => b.length - a.length);
|
||||
|
||||
for (const family of sortedFamilies) {
|
||||
if (includesIgnoreCase(modelID, family) || includesIgnoreCase(modelName, family)) {
|
||||
return family;
|
||||
}
|
||||
}
|
||||
|
||||
// Deliberately no fuzzy/subsequence fallback: matching a family by scattered
|
||||
// letters produces false positives (e.g. "Mellum2-12B-A2.5B" -> "jamba"). If
|
||||
// no family name is a substring of the id or name, omit the family instead.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function includesIgnoreCase(target: string, value: string) {
|
||||
return target.toLowerCase().includes(value.toLowerCase());
|
||||
}
|
||||
|
||||
function resolveWandbBaseModel(id: string) {
|
||||
const [prefix, ...modelParts] = id.split("/");
|
||||
if (prefix === undefined || modelParts.length === 0) return undefined;
|
||||
|
||||
const namespace = CANONICAL_PREFIXES[prefix];
|
||||
if (namespace === undefined) return undefined;
|
||||
|
||||
const modelID = modelParts.join("/");
|
||||
const candidates = canonicalCandidates(namespace, modelID);
|
||||
for (const candidate of candidates) {
|
||||
const match = metadataMatch(namespace, candidate);
|
||||
if (match !== undefined) return match.id;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function canonicalCandidates(namespace: string, modelID: string) {
|
||||
const lower = modelID.toLowerCase();
|
||||
const candidates = [
|
||||
modelID,
|
||||
lower,
|
||||
lower.replace(/^nvidia-/, ""),
|
||||
lower.replace(/^nvidia-/, "").replace(/-fp8$/, ""),
|
||||
lower.replace(/-(?:instruct|thinking)-2507$/, ""),
|
||||
];
|
||||
|
||||
if (namespace === "alibaba") {
|
||||
candidates.push(lower.replace(/-a22b-(?:instruct|thinking)-2507$/, "-a22b"));
|
||||
}
|
||||
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
|
||||
function metadataMatch(namespace: string, candidate: string) {
|
||||
const normalizedCandidate = normalize(candidate);
|
||||
const normalizedFull = normalize(`${namespace}/${candidate}`);
|
||||
const matches = getMetadataEntries(namespace).filter((entry) =>
|
||||
entry.filename === candidate ||
|
||||
entry.normalizedFilename === normalizedCandidate ||
|
||||
entry.normalizedFull === normalizedFull
|
||||
);
|
||||
return matches.length === 1 ? matches[0] : undefined;
|
||||
}
|
||||
|
||||
function getMetadataEntries(namespace: string) {
|
||||
metadataEntries ??= readMetadataEntries();
|
||||
return metadataEntries.filter((entry) => entry.id.startsWith(`${namespace}/`));
|
||||
}
|
||||
|
||||
function readMetadataEntries() {
|
||||
const entries: MetadataEntry[] = [];
|
||||
for (const provider of readdirSync(MODELS_DIR, { withFileTypes: true })) {
|
||||
if (!provider.isDirectory()) continue;
|
||||
for (const file of readdirSync(path.join(MODELS_DIR, provider.name), { withFileTypes: true })) {
|
||||
if (!file.isFile() || !file.name.endsWith(".toml")) continue;
|
||||
const filename = file.name.slice(0, -5);
|
||||
const id = `${provider.name}/${filename}`;
|
||||
entries.push({
|
||||
id,
|
||||
filename,
|
||||
normalizedFull: normalize(id),
|
||||
normalizedFilename: normalize(filename),
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function normalize(value: string) {
|
||||
return value.toLowerCase().replaceAll(/[^a-z0-9]/g, "");
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { describeModel } from "../../describe.js";
|
||||
import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js";
|
||||
import { factorBaseModel } from "./openrouter.js";
|
||||
|
||||
const API_BASE = "https://api.x.ai/v1";
|
||||
|
||||
const XAIModel = z.object({
|
||||
id: z.string(),
|
||||
canonical_id: z.string().optional(),
|
||||
created: z.number().int().nonnegative(),
|
||||
aliases: z.array(z.string()).optional(),
|
||||
input_modalities: z.array(z.string()).optional(),
|
||||
output_modalities: z.array(z.string()).optional(),
|
||||
prompt_text_token_price: z.number().int().nonnegative().optional(),
|
||||
cached_prompt_text_token_price: z.number().int().nonnegative().optional(),
|
||||
completion_text_token_price: z.number().int().nonnegative().optional(),
|
||||
max_prompt_length: z.number().int().nonnegative().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const XAIModelList = z.object({
|
||||
models: z.array(XAIModel),
|
||||
}).passthrough();
|
||||
|
||||
const XAIResponse = z.object({
|
||||
models: z.array(XAIModel),
|
||||
});
|
||||
|
||||
const XAIAPIKey = z.object({
|
||||
acls: z.array(z.string()),
|
||||
}).passthrough();
|
||||
|
||||
export type XAIModel = z.infer<typeof XAIModel>;
|
||||
|
||||
export const xai = {
|
||||
id: "xai",
|
||||
name: "xAI",
|
||||
modelsDir: "providers/xai/models",
|
||||
skipCreates: true,
|
||||
sourceID(model) {
|
||||
return model.id;
|
||||
},
|
||||
skippedNotice(ids) {
|
||||
if (ids.length === 0) return [];
|
||||
return [
|
||||
`${ids.length} xAI models returned by the API were not created because the Models API does not provide enough authoritative metadata for the catalog, especially output token limits and some feature/capability flags. Existing models are still updated from API-authoritative fields.`,
|
||||
`Skipped remote IDs: ${ids.map((id) => `\`${id}\``).join(", ")}`,
|
||||
];
|
||||
},
|
||||
async fetchModels() {
|
||||
const key = process.env.XAI_API_KEY;
|
||||
if (key === undefined) throw new Error("xAI sync requires XAI_API_KEY");
|
||||
await assertFullModelAccess(key);
|
||||
|
||||
const models = await Promise.all([
|
||||
fetchTypedModels(key, "language-models"),
|
||||
fetchTypedModels(key, "image-generation-models"),
|
||||
fetchTypedModels(key, "video-generation-models"),
|
||||
]);
|
||||
|
||||
return { models: models.flat() };
|
||||
},
|
||||
parseModels(raw) {
|
||||
const models = XAIResponse.parse(raw).models;
|
||||
const seen = new Set<string>();
|
||||
const expanded: XAIModel[] = [];
|
||||
|
||||
for (const model of models) {
|
||||
if (!seen.has(model.id)) {
|
||||
seen.add(model.id);
|
||||
expanded.push(model);
|
||||
}
|
||||
}
|
||||
|
||||
for (const model of models) {
|
||||
for (const alias of model.aliases ?? []) {
|
||||
if (seen.has(alias)) continue;
|
||||
seen.add(alias);
|
||||
expanded.push({ ...model, id: alias, canonical_id: model.id });
|
||||
}
|
||||
}
|
||||
|
||||
return expanded;
|
||||
},
|
||||
translateModel(model, context) {
|
||||
const existing = context.existing(model.id);
|
||||
if (existing === undefined) return undefined;
|
||||
|
||||
return {
|
||||
id: model.id,
|
||||
model: buildXAIModel(model, existing),
|
||||
};
|
||||
},
|
||||
} satisfies SyncProvider<XAIModel>;
|
||||
|
||||
async function assertFullModelAccess(key: string) {
|
||||
const response = await fetch(`${API_BASE}/api-key`, {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`xAI API key metadata request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const apiKey = XAIAPIKey.parse(await response.json());
|
||||
if (!apiKey.acls.includes("api-key:model:*")) {
|
||||
throw new Error("xAI sync requires XAI_API_KEY to include api-key:model:* so the model list is not ACL-filtered");
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTypedModels(key: string, endpoint: string) {
|
||||
const response = await fetch(`${API_BASE}/${endpoint}`, {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`xAI ${endpoint} request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return XAIModelList.parse(await response.json()).models;
|
||||
}
|
||||
|
||||
type Modality = "text" | "audio" | "image" | "video" | "pdf";
|
||||
|
||||
function modalities(values: string[] | undefined, fallback: Modality[]) {
|
||||
const allowed = new Set<Modality>(["text", "audio", "image", "video", "pdf"]);
|
||||
const result = (values ?? [])
|
||||
.map((value) => value.toLowerCase())
|
||||
.filter((value): value is Modality => allowed.has(value as Modality));
|
||||
if (result.includes("image")) result.push("pdf");
|
||||
return [...new Set(result.length > 0 ? result : fallback)];
|
||||
}
|
||||
|
||||
function tokenPrice(value: number | undefined) {
|
||||
if (value === undefined) return undefined;
|
||||
return value / 10_000;
|
||||
}
|
||||
|
||||
function preservedCostTiers(existing: ExistingModel) {
|
||||
// The xAI models API exposes base pricing only; long-context tiers are curated from xAI docs/console.
|
||||
return existing.cost?.tiers;
|
||||
}
|
||||
|
||||
function cost(model: XAIModel, existing: ExistingModel) {
|
||||
const input = tokenPrice(model.prompt_text_token_price);
|
||||
const output = tokenPrice(model.completion_text_token_price);
|
||||
if (input === undefined || output === undefined) return existing.cost;
|
||||
|
||||
return {
|
||||
input,
|
||||
output,
|
||||
reasoning: existing.cost?.reasoning,
|
||||
cache_read: tokenPrice(model.cached_prompt_text_token_price),
|
||||
cache_write: existing.cost?.cache_write,
|
||||
input_audio: existing.cost?.input_audio,
|
||||
output_audio: existing.cost?.output_audio,
|
||||
tiers: preservedCostTiers(existing),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildXAIModel(model: XAIModel, existing: ExistingModel): SyncedModel {
|
||||
const name = existing.name;
|
||||
const description = existing.description;
|
||||
const attachment = existing.attachment;
|
||||
const reasoning = existing.reasoning;
|
||||
const toolCall = existing.tool_call;
|
||||
const openWeights = existing.open_weights;
|
||||
const limit = existing.limit;
|
||||
const releaseDate = existing.release_date;
|
||||
const lastUpdated = existing.last_updated;
|
||||
|
||||
if (
|
||||
name === undefined
|
||||
|| attachment === undefined
|
||||
|| reasoning === undefined
|
||||
|| toolCall === undefined
|
||||
|| openWeights === undefined
|
||||
|| limit === undefined
|
||||
|| releaseDate === undefined
|
||||
|| lastUpdated === undefined
|
||||
) {
|
||||
throw new Error(`xAI model ${model.id} has incomplete local TOML metadata required for sync`);
|
||||
}
|
||||
|
||||
const input = modalities(model.input_modalities, existing.modalities?.input ?? ["text"]);
|
||||
const output = modalities(model.output_modalities, existing.modalities?.output ?? ["text"]);
|
||||
|
||||
const values = {
|
||||
name,
|
||||
description: description ?? describeModel({
|
||||
id: model.id,
|
||||
name,
|
||||
family: existing.family,
|
||||
reasoning,
|
||||
tool_call: toolCall,
|
||||
structured_output: existing.structured_output,
|
||||
open_weights: openWeights,
|
||||
limit: {
|
||||
input: limit.input,
|
||||
context: model.max_prompt_length ?? limit.context,
|
||||
output: limit.output,
|
||||
},
|
||||
modalities: { input, output },
|
||||
}),
|
||||
family: existing.family,
|
||||
release_date: releaseDate,
|
||||
last_updated: lastUpdated,
|
||||
attachment: input.some((value) => value !== "text"),
|
||||
reasoning,
|
||||
reasoning_options: existing.reasoning_options,
|
||||
temperature: existing.temperature,
|
||||
tool_call: toolCall,
|
||||
structured_output: existing.structured_output,
|
||||
knowledge: existing.knowledge,
|
||||
open_weights: openWeights,
|
||||
status: existing.status,
|
||||
interleaved: existing.interleaved,
|
||||
cost: cost(model, existing),
|
||||
limit: {
|
||||
input: limit.input,
|
||||
context: model.max_prompt_length ?? limit.context,
|
||||
output: limit.output,
|
||||
},
|
||||
modalities: { input, output },
|
||||
} satisfies SyncedFullModel;
|
||||
|
||||
return existing.base_model === undefined
|
||||
? values
|
||||
: factorBaseModel(existing.base_model, values, values.limit, existing.base_model_omit);
|
||||
}
|
||||
Reference in New Issue
Block a user