modelmix
Version:
🧬 ModelMix - Unified API for Diverse AI LLM.
722 lines (617 loc) • 23.8 kB
JavaScript
const axios = require('axios');
const fs = require('fs');
const mime = require('mime-types');
const log = require('lemonlog')('ModelMix');
const Bottleneck = require('bottleneck');
const path = require('path');
class ModelMix {
constructor(args = { options: {}, config: {} }) {
this.models = {};
this.defaultOptions = {
max_tokens: 2000,
temperature: 1,
top_p: 1,
...args.options
};
// Standard Bottleneck configuration
const defaultBottleneckConfig = {
maxConcurrent: 8, // Maximum number of concurrent requests
minTime: 500, // Minimum time between requests (in ms)
};
this.config = {
system: 'You are an assistant.',
max_history: 1, // Default max history
debug: false,
bottleneck: defaultBottleneckConfig,
...args.config
}
this.limiter = new Bottleneck(this.config.bottleneck);
}
replace(keyValues) {
this.config.replace = keyValues;
return this;
}
attach(...modelInstances) {
for (const modelInstance of modelInstances) {
const key = modelInstance.config.prefix.join("_");
this.models[key] = modelInstance;
}
return this;
}
create(modelKeys, args = { config: {}, options: {} }) {
// If modelKeys is a string, convert it to an array for backwards compatibility
const modelArray = Array.isArray(modelKeys) ? modelKeys : [modelKeys];
if (modelArray.length === 0) {
throw new Error('No model keys provided');
}
// Verificar que todos los modelos estén disponibles
const unavailableModels = modelArray.filter(modelKey => {
return !Object.values(this.models).some(entry =>
entry.config.prefix.some(p => modelKey.startsWith(p))
);
});
if (unavailableModels.length > 0) {
throw new Error(`The following models are not available: ${unavailableModels.join(', ')}`);
}
// Una vez verificado que todos están disponibles, obtener el primer modelo
const modelKey = modelArray[0];
const modelEntry = Object.values(this.models).find(entry =>
entry.config.prefix.some(p => modelKey.startsWith(p))
);
const options = {
...this.defaultOptions,
...modelEntry.options,
...args.options,
model: modelKey
};
const config = {
...this.config,
...modelEntry.config,
...args.config
};
// Pass remaining models array for fallback
return new MessageHandler(this, modelEntry, options, config, modelArray.slice(1));
}
setSystem(text) {
this.config.system = text;
return this;
}
setSystemFromFile(filePath) {
const content = this.readFile(filePath);
this.setSystem(content);
return this;
}
readFile(filePath, options = { encoding: 'utf8' }) {
try {
const absolutePath = path.resolve(filePath);
return fs.readFileSync(absolutePath, options);
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error(`File not found: ${filePath}`);
} else if (error.code === 'EACCES') {
throw new Error(`Permission denied: ${filePath}`);
} else {
throw new Error(`Error reading file ${filePath}: ${error.message}`);
}
}
}
}
class MessageHandler {
constructor(mix, modelEntry, options, config, fallbackModels = []) {
this.mix = mix;
this.modelEntry = modelEntry;
this.options = options;
this.config = config;
this.messages = [];
this.fallbackModels = fallbackModels;
this.imagesToProcess = [];
}
new() {
this.messages = [];
return this;
}
addText(text, config = { role: "user" }) {
const content = [{
type: "text",
text
}];
this.messages.push({ ...config, content });
return this;
}
addTextFromFile(filePath, config = { role: "user" }) {
const content = this.mix.readFile(filePath);
this.addText(content, config);
return this;
}
setSystem(text) {
this.config.system = text;
return this;
}
setSystemFromFile(filePath) {
const content = this.mix.readFile(filePath);
this.setSystem(content);
return this;
}
addImage(filePath, config = { role: "user" }) {
const imageBuffer = this.mix.readFile(filePath, { encoding: null });
const mimeType = mime.lookup(filePath);
if (!mimeType || !mimeType.startsWith('image/')) {
throw new Error('Invalid image file type');
}
const data = imageBuffer.toString('base64');
const imageMessage = {
...config,
content: [
{
type: "image",
"source": {
type: "base64",
media_type: mimeType,
data
}
}
]
};
this.messages.push(imageMessage);
return this;
}
addImageFromUrl(url, config = { role: "user" }) {
this.imagesToProcess.push({ url, config });
return this;
}
async processImageUrls() {
const imageContents = await Promise.all(
this.imagesToProcess.map(async (image) => {
try {
const response = await axios.get(image.url, { responseType: 'arraybuffer' });
const base64 = Buffer.from(response.data, 'binary').toString('base64');
const mimeType = response.headers['content-type'];
return { base64, mimeType, config: image.config };
} catch (error) {
console.error(`Error descargando imagen desde ${image.url}:`, error);
return null;
}
})
);
imageContents.forEach((image) => {
if (image) {
const imageMessage = {
...image.config,
content: [
{
type: "image",
"source": {
type: "base64",
media_type: image.mimeType,
data: image.base64
}
}
]
};
this.messages.push(imageMessage);
}
});
}
async message() {
this.options.stream = false;
const response = await this.execute();
return response.message;
}
async raw() {
this.options.stream = false;
return this.execute();
}
async stream(callback) {
this.options.stream = true;
this.modelEntry.streamCallback = callback;
return this.execute();
}
replace(keyValues) {
this.config.replace = { ...this.config.replace, ...keyValues };
return this;
}
replaceKeyFromFile(key, filePath) {
const content = this.mix.readFile(filePath);
this.replace({ [key]: this.template(content, this.config.replace) });
return this;
}
template(input, replace) {
for (const k in replace) {
input = input.split(/([¿?¡!,"';:\(\)\.\s])/).map(x => x === k ? replace[k] : x).join("");
}
return input;
}
groupByRoles(messages) {
return messages.reduce((acc, currentMessage, index) => {
if (index === 0 || currentMessage.role !== messages[index - 1].role) {
acc.push({
role: currentMessage.role,
content: currentMessage.content
});
} else {
acc[acc.length - 1].content = acc[acc.length - 1].content.concat(currentMessage.content);
}
return acc;
}, []);
}
applyTemplate() {
if (!this.config.replace) return;
this.config.system = this.template(this.config.system, this.config.replace)
this.messages = this.messages.map(message => {
if (message.content instanceof Array) {
message.content = message.content.map(content => {
if (content.type === 'text') {
content.text = this.template(content.text, this.config.replace)
}
return content;
});
}
return message;
});
}
async execute() {
return this.mix.limiter.schedule(async () => {
try {
await this.processImageUrls();
this.applyTemplate();
this.messages = this.messages.slice(-this.config.max_history);
this.messages = this.groupByRoles(this.messages);
if (this.messages.length === 0) {
throw new Error("No user messages have been added. Use addText(prompt), addTextFromFile(filePath), addImage(filePath), or addImageFromUrl(url) to add a prompt.");
}
this.options.messages = this.messages;
try {
const result = await this.modelEntry.create({ options: this.options, config: this.config });
this.messages.push({ role: "assistant", content: result.message });
return result;
} catch (error) {
// If there are fallback models available, try the next one
if (this.fallbackModels.length > 0) {
const nextModelKey = this.fallbackModels[0];
log.warn(`Model ${this.options.model} failed, trying fallback model ${nextModelKey}...`);
// Create a completely new handler with the fallback model
const nextHandler = this.mix.create(
[nextModelKey, ...this.fallbackModels.slice(1)],
{
options: {
// Keep only generic options, not model-specific ones
max_tokens: this.options.max_tokens,
temperature: this.options.temperature,
top_p: this.options.top_p,
stream: this.options.stream
}
}
);
// Asignar directamente todos los mensajes
nextHandler.messages = [...this.messages];
// Mantener el mismo sistema y reemplazos
nextHandler.setSystem(this.config.system);
if (this.config.replace) {
nextHandler.replace(this.config.replace);
}
// Try with next model
return nextHandler.execute();
}
throw error;
}
} catch (error) {
throw error;
}
});
}
}
class MixCustom {
constructor(args = { config: {}, options: {}, headers: {} }) {
this.config = this.getDefaultConfig(args.config);
this.options = this.getDefaultOptions(args.options);
this.headers = this.getDefaultHeaders(args.headers);
this.streamCallback = null; // Definimos streamCallback aquÃ
}
getDefaultOptions(customOptions) {
return {
...customOptions
};
}
getDefaultConfig(customConfig) {
return {
url: '',
apiKey: '',
prefix: [],
...customConfig
};
}
getDefaultHeaders(customHeaders) {
return {
'accept': 'application/json',
'content-type': 'application/json',
'authorization': `Bearer ${this.config.apiKey}`,
...customHeaders
};
}
async create(args = { config: {}, options: {} }) {
try {
if (args.config.debug) {
log.debug("config");
log.info(args.config);
log.debug("options");
log.inspect(args.options);
}
if (args.options.stream) {
return this.processStream(await axios.post(this.config.url, args.options, {
headers: this.headers,
responseType: 'stream'
}));
} else {
return this.processResponse(await axios.post(this.config.url, args.options, {
headers: this.headers
}));
}
} catch (error) {
throw this.handleError(error, args);
}
}
handleError(error, args) {
let errorMessage = 'An error occurred in MixCustom';
let statusCode = null;
let errorDetails = null;
if (error.isAxiosError) {
statusCode = error.response ? error.response.status : null;
errorMessage = `Request to ${this.config.url} failed with status code ${statusCode}`;
errorDetails = error.response ? error.response.data : null;
}
const formattedError = {
message: errorMessage,
statusCode,
details: errorDetails,
stack: error.stack,
config: args.config,
options: args.options
};
return formattedError;
}
processStream(response) {
return new Promise((resolve, reject) => {
let raw = [];
let message = '';
let buffer = '';
response.data.on('data', chunk => {
buffer += chunk.toString();
let boundary;
while ((boundary = buffer.indexOf('\n')) !== -1) {
const dataStr = buffer.slice(0, boundary).trim();
buffer = buffer.slice(boundary + 1);
const firstBraceIndex = dataStr.indexOf('{');
if (dataStr === '[DONE]' || firstBraceIndex === -1) continue;
const jsonStr = dataStr.slice(firstBraceIndex);
try {
const data = JSON.parse(jsonStr);
if (this.streamCallback) {
const delta = this.extractDelta(data);
message += delta;
this.streamCallback({ response: data, message, delta });
raw.push(data);
}
} catch (error) {
console.error('Error parsing JSON:', error);
}
}
});
response.data.on('end', () => resolve({ response: raw, message: message.trim() }));
response.data.on('error', reject);
});
}
extractDelta(data) {
if (data.choices && data.choices[0].delta.content) return data.choices[0].delta.content;
return '';
}
processResponse(response) {
return { response: response.data, message: response.data.choices[0].message.content };
}
}
class MixOpenAI extends MixCustom {
getDefaultConfig(customConfig) {
return super.getDefaultConfig({
url: 'https://api.openai.com/v1/chat/completions',
prefix: ['gpt', 'ft:', 'o3', 'o1'],
apiKey: process.env.OPENAI_API_KEY,
...customConfig
});
}
create(args = { config: {}, options: {} }) {
if (!this.config.apiKey) {
throw new Error('OpenAI API key not found. Please provide it in config or set OPENAI_API_KEY environment variable.');
}
// Remove max_tokens and temperature for o1/o3 models
if (args.options.model?.startsWith('o')) {
delete args.options.max_tokens;
delete args.options.temperature;
}
args.options.messages = [{ role: 'system', content: args.config.system }, ...args.options.messages || []];
args.options.messages = MixOpenAI.convertMessages(args.options.messages);
return super.create(args);
}
static convertMessages(messages) {
return messages.map(message => {
if (message.role === 'user' && message.content instanceof Array) {
message.content = message.content.map(content => {
if (content.type === 'image') {
const { type, media_type, data } = content.source;
return {
type: 'image_url',
image_url: {
url: `data:${media_type};${type},${data}`
}
};
}
return content;
});
}
return message;
});
}
}
class MixAnthropic extends MixCustom {
getDefaultConfig(customConfig) {
return super.getDefaultConfig({
url: 'https://api.anthropic.com/v1/messages',
prefix: ['claude'],
apiKey: process.env.ANTHROPIC_API_KEY,
...customConfig
});
}
create(args = { config: {}, options: {} }) {
if (!this.config.apiKey) {
throw new Error('Anthropic API key not found. Please provide it in config or set ANTHROPIC_API_KEY environment variable.');
}
args.options.system = args.config.system;
return super.create(args);
}
getDefaultHeaders(customHeaders) {
return super.getDefaultHeaders({
'x-api-key': this.config.apiKey,
'anthropic-version': '2023-06-01',
...customHeaders
});
}
extractDelta(data) {
if (data.delta && data.delta.text) return data.delta.text;
return '';
}
processResponse(response) {
return { response: response.data, message: response.data.content[0].text };
}
}
class MixPerplexity extends MixCustom {
getDefaultConfig(customConfig) {
return super.getDefaultConfig({
url: 'https://api.perplexity.ai/chat/completions',
prefix: ['llama-3', 'mixtral'],
apiKey: process.env.PPLX_API_KEY,
...customConfig
});
}
create(args = { config: {}, options: {} }) {
if (!this.config.apiKey) {
throw new Error('Perplexity API key not found. Please provide it in config or set PPLX_API_KEY environment variable.');
}
args.options.messages = [{ role: 'system', content: args.config.system }, ...args.options.messages || []];
return super.create(args);
}
}
class MixOllama extends MixCustom {
getDefaultConfig(customConfig) {
return super.getDefaultConfig({
url: 'http://localhost:11434/api/chat',
...customConfig
});
}
getDefaultOptions(customOptions) {
return {
options: customOptions,
};
}
extractDelta(data) {
if (data.message && data.message.content) return data.message.content;
return '';
}
create(args = { config: {}, options: {} }) {
args.options.messages = MixOllama.convertMessages(args.options.messages);
args.options.messages = [{ role: 'system', content: args.config.system }, ...args.options.messages || []];
return super.create(args);
}
processResponse(response) {
return { response: response.data, message: response.data.message.content.trim() };
}
static convertMessages(messages) {
return messages.map(entry => {
let content = '';
let images = [];
entry.content.forEach(item => {
if (item.type === 'text') {
content += item.text + ' ';
} else if (item.type === 'image') {
images.push(item.source.data);
}
});
return {
role: entry.role,
content: content.trim(),
images: images
};
});
}
}
class MixGrok extends MixOpenAI {
getDefaultConfig(customConfig) {
return super.getDefaultConfig({
url: 'https://api.x.ai/v1/chat/completions',
prefix: ['grok'],
apiKey: process.env.XAI_API_KEY,
...customConfig
});
}
}
class MixLMStudio extends MixCustom {
getDefaultConfig(customConfig) {
return super.getDefaultConfig({
url: 'http://localhost:1234/v1/chat/completions',
...customConfig
});
}
create(args = { config: {}, options: {} }) {
args.options.messages = [{ role: 'system', content: args.config.system }, ...args.options.messages || []];
args.options.messages = MixOpenAI.convertMessages(args.options.messages);
return super.create(args);
}
}
class MixGroq extends MixCustom {
getDefaultConfig(customConfig) {
return super.getDefaultConfig({
url: 'https://api.groq.com/openai/v1/chat/completions',
prefix: ["llama", "mixtral", "gemma", "deepseek-r1-distill"],
apiKey: process.env.GROQ_API_KEY,
...customConfig
});
}
create(args = { config: {}, options: {} }) {
if (!this.config.apiKey) {
throw new Error('Groq API key not found. Please provide it in config or set GROQ_API_KEY environment variable.');
}
args.options.messages = [{ role: 'system', content: args.config.system }, ...args.options.messages || []];
args.options.messages = MixOpenAI.convertMessages(args.options.messages);
return super.create(args);
}
}
class MixTogether extends MixCustom {
getDefaultConfig(customConfig) {
return super.getDefaultConfig({
url: 'https://api.together.xyz/v1/chat/completions',
prefix: ["meta-llama", "google", "NousResearch", "deepseek-ai"],
apiKey: process.env.TOGETHER_API_KEY,
...customConfig
});
}
getDefaultOptions(customOptions) {
return {
stop: ["<|eot_id|>", "<|eom_id|>"],
...customOptions
};
}
convertMessages(messages) {
return messages.map(message => {
if (message.content instanceof Array) {
message.content = message.content.map(content => content.text).join("\n\n");
}
return message;
});
}
create(args = { config: {}, options: {} }) {
if (!this.config.apiKey) {
throw new Error('Together API key not found. Please provide it in config or set TOGETHER_API_KEY environment variable.');
}
args.options.messages = [{ role: 'system', content: args.config.system }, ...args.options.messages || []];
args.options.messages = this.convertMessages(args.options.messages);
return super.create(args);
}
}
module.exports = { MixCustom, ModelMix, MixAnthropic, MixOpenAI, MixPerplexity, MixOllama, MixLMStudio, MixGroq, MixTogether, MixGrok };