350 lines
13 KiB
JavaScript
350 lines
13 KiB
JavaScript
// Multi-model router core implementation
|
|
import { readFileSync, existsSync } from 'fs';
|
|
import { homedir } from 'os';
|
|
import { join } from 'path';
|
|
import { OpenRouterProvider } from './providers/openrouter.js';
|
|
import { AnthropicProvider } from './providers/anthropic.js';
|
|
import { ONNXLocalProvider } from './providers/onnx-local.js';
|
|
import { GeminiProvider } from './providers/gemini.js';
|
|
export class ModelRouter {
|
|
config;
|
|
providers = new Map();
|
|
metrics;
|
|
constructor(configPath) {
|
|
this.config = this.loadConfig(configPath);
|
|
this.initializeProviders();
|
|
this.metrics = this.initializeMetrics();
|
|
}
|
|
loadConfig(configPath) {
|
|
const paths = [
|
|
configPath,
|
|
process.env.AGENTIC_FLOW_ROUTER_CONFIG,
|
|
join(homedir(), '.agentic-flow', 'router.config.json'),
|
|
join(process.cwd(), 'router.config.json'),
|
|
join(process.cwd(), 'config', 'router.config.json'),
|
|
join(process.cwd(), 'router.config.example.json')
|
|
].filter(Boolean);
|
|
for (const path of paths) {
|
|
if (existsSync(path)) {
|
|
const content = readFileSync(path, 'utf-8');
|
|
const config = JSON.parse(content);
|
|
// Substitute environment variables
|
|
return this.substituteEnvVars(config);
|
|
}
|
|
}
|
|
// If no config file found, create config from environment variables
|
|
return this.createConfigFromEnv();
|
|
}
|
|
createConfigFromEnv() {
|
|
// Create minimal config from environment variables
|
|
const config = {
|
|
version: '1.0',
|
|
defaultProvider: process.env.PROVIDER || 'anthropic',
|
|
routing: { mode: 'manual' },
|
|
providers: {}
|
|
};
|
|
// Add Anthropic if API key exists
|
|
if (process.env.ANTHROPIC_API_KEY) {
|
|
config.providers.anthropic = {
|
|
apiKey: process.env.ANTHROPIC_API_KEY,
|
|
baseUrl: process.env.ANTHROPIC_BASE_URL
|
|
};
|
|
}
|
|
// Add OpenRouter if API key exists
|
|
if (process.env.OPENROUTER_API_KEY) {
|
|
config.providers.openrouter = {
|
|
apiKey: process.env.OPENROUTER_API_KEY,
|
|
baseUrl: process.env.OPENROUTER_BASE_URL
|
|
};
|
|
}
|
|
// Add Gemini if API key exists
|
|
if (process.env.GOOGLE_GEMINI_API_KEY) {
|
|
config.providers.gemini = {
|
|
apiKey: process.env.GOOGLE_GEMINI_API_KEY
|
|
};
|
|
}
|
|
// ONNX is always available (no API key needed)
|
|
config.providers.onnx = {
|
|
modelPath: process.env.ONNX_MODEL_PATH,
|
|
executionProviders: ['cpu']
|
|
};
|
|
return config;
|
|
}
|
|
substituteEnvVars(obj) {
|
|
if (typeof obj === 'string') {
|
|
// Replace ${VAR_NAME} with environment variable value
|
|
return obj.replace(/\$\{([^}]+)\}/g, (_, key) => {
|
|
const [varName, defaultValue] = key.split(':-');
|
|
return process.env[varName] || defaultValue || '';
|
|
});
|
|
}
|
|
if (Array.isArray(obj)) {
|
|
return obj.map(item => this.substituteEnvVars(item));
|
|
}
|
|
if (obj && typeof obj === 'object') {
|
|
const result = {};
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
result[key] = this.substituteEnvVars(value);
|
|
}
|
|
return result;
|
|
}
|
|
return obj;
|
|
}
|
|
initializeProviders() {
|
|
const verbose = process.env.ROUTER_VERBOSE === 'true';
|
|
// Initialize Anthropic
|
|
if (this.config.providers.anthropic) {
|
|
try {
|
|
const provider = new AnthropicProvider(this.config.providers.anthropic);
|
|
this.providers.set('anthropic', provider);
|
|
if (verbose)
|
|
console.log('✅ Anthropic provider initialized');
|
|
}
|
|
catch (error) {
|
|
if (verbose)
|
|
console.error('❌ Failed to initialize Anthropic:', error);
|
|
}
|
|
}
|
|
// Initialize OpenRouter
|
|
if (this.config.providers.openrouter) {
|
|
try {
|
|
const provider = new OpenRouterProvider(this.config.providers.openrouter);
|
|
this.providers.set('openrouter', provider);
|
|
if (verbose)
|
|
console.log('✅ OpenRouter provider initialized');
|
|
}
|
|
catch (error) {
|
|
if (verbose)
|
|
console.error('❌ Failed to initialize OpenRouter:', error);
|
|
}
|
|
}
|
|
// Initialize ONNX Local
|
|
if (this.config.providers.onnx) {
|
|
try {
|
|
const provider = new ONNXLocalProvider({
|
|
modelPath: this.config.providers.onnx.modelPath || './models/phi-4/cpu_and_mobile/cpu-int4-rtn-block-32-acc-level-4/model.onnx',
|
|
executionProviders: this.config.providers.onnx.executionProviders || ['cpu'],
|
|
maxTokens: this.config.providers.onnx.maxTokens || 100,
|
|
temperature: this.config.providers.onnx.temperature || 0.7
|
|
});
|
|
this.providers.set('onnx', provider);
|
|
if (verbose)
|
|
console.log('✅ ONNX Local provider initialized');
|
|
}
|
|
catch (error) {
|
|
if (verbose)
|
|
console.error('❌ Failed to initialize ONNX:', error);
|
|
}
|
|
}
|
|
// Initialize Gemini
|
|
if (this.config.providers.gemini) {
|
|
try {
|
|
const provider = new GeminiProvider(this.config.providers.gemini);
|
|
this.providers.set('gemini', provider);
|
|
if (verbose)
|
|
console.log('✅ Gemini provider initialized');
|
|
}
|
|
catch (error) {
|
|
if (verbose)
|
|
console.error('❌ Failed to initialize Gemini:', error);
|
|
}
|
|
}
|
|
// TODO: Initialize other providers (OpenAI, Ollama, LiteLLM)
|
|
// Will be implemented in Phase 1
|
|
}
|
|
initializeMetrics() {
|
|
return {
|
|
totalRequests: 0,
|
|
totalCost: 0,
|
|
totalTokens: { input: 0, output: 0 },
|
|
providerBreakdown: {},
|
|
agentBreakdown: {}
|
|
};
|
|
}
|
|
async chat(params, agentType) {
|
|
const startTime = Date.now();
|
|
const provider = await this.selectProvider(params, agentType);
|
|
try {
|
|
const response = await provider.chat(params);
|
|
// Update metrics
|
|
this.updateMetrics(provider.name, response, Date.now() - startTime, agentType);
|
|
// Add metadata
|
|
response.metadata = {
|
|
...response.metadata,
|
|
provider: provider.name,
|
|
latency: Date.now() - startTime
|
|
};
|
|
return response;
|
|
}
|
|
catch (error) {
|
|
return this.handleProviderError(error, params, agentType);
|
|
}
|
|
}
|
|
async *stream(params, agentType) {
|
|
const provider = await this.selectProvider(params, agentType);
|
|
if (!provider.stream) {
|
|
throw new Error(`Provider ${provider.name} does not support streaming`);
|
|
}
|
|
try {
|
|
const iterator = provider.stream(params);
|
|
for await (const chunk of iterator) {
|
|
yield chunk;
|
|
}
|
|
}
|
|
catch (error) {
|
|
throw error;
|
|
}
|
|
}
|
|
async selectProvider(params, agentType) {
|
|
// If provider is explicitly specified in params, use it
|
|
if (params.provider) {
|
|
const forcedProvider = this.providers.get(params.provider);
|
|
if (forcedProvider) {
|
|
return forcedProvider;
|
|
}
|
|
console.warn(`⚠️ Requested provider '${params.provider}' not available, falling back to routing logic`);
|
|
}
|
|
const routingMode = this.config.routing?.mode || 'manual';
|
|
switch (routingMode) {
|
|
case 'manual':
|
|
return this.getDefaultProvider();
|
|
case 'rule-based':
|
|
return this.selectByRules(params, agentType);
|
|
case 'cost-optimized':
|
|
return this.selectByCost(params);
|
|
case 'performance-optimized':
|
|
return this.selectByPerformance(params);
|
|
default:
|
|
return this.getDefaultProvider();
|
|
}
|
|
}
|
|
getDefaultProvider() {
|
|
const provider = this.providers.get(this.config.defaultProvider);
|
|
if (!provider) {
|
|
throw new Error(`Default provider ${this.config.defaultProvider} not initialized`);
|
|
}
|
|
return provider;
|
|
}
|
|
selectByRules(params, agentType) {
|
|
const rules = this.config.routing?.rules || [];
|
|
for (const rule of rules) {
|
|
if (this.matchesRule(rule.condition, params, agentType)) {
|
|
const provider = this.providers.get(rule.action.provider);
|
|
if (provider) {
|
|
console.log(`🎯 Routing via rule: ${rule.reason || 'matched condition'}`);
|
|
return provider;
|
|
}
|
|
}
|
|
}
|
|
return this.getDefaultProvider();
|
|
}
|
|
matchesRule(condition, params, agentType) {
|
|
if (condition.agentType && agentType) {
|
|
if (!condition.agentType.includes(agentType)) {
|
|
return false;
|
|
}
|
|
}
|
|
if (condition.requiresTools !== undefined) {
|
|
if (condition.requiresTools && (!params.tools || params.tools.length === 0)) {
|
|
return false;
|
|
}
|
|
}
|
|
// TODO: Add more condition matching logic
|
|
return true;
|
|
}
|
|
selectByCost(params) {
|
|
// For now, prefer cheaper providers
|
|
// TODO: Implement actual cost calculation
|
|
const providerOrder = ['openrouter', 'anthropic', 'openai'];
|
|
for (const providerType of providerOrder) {
|
|
const provider = this.providers.get(providerType);
|
|
if (provider) {
|
|
console.log(`💰 Cost-optimized routing: selected ${provider.name}`);
|
|
return provider;
|
|
}
|
|
}
|
|
return this.getDefaultProvider();
|
|
}
|
|
selectByPerformance(params) {
|
|
// For now, use metrics to select fastest provider
|
|
let fastestProvider = null;
|
|
let lowestLatency = Infinity;
|
|
for (const [providerType, provider] of this.providers) {
|
|
const breakdown = this.metrics.providerBreakdown[providerType];
|
|
if (breakdown && breakdown.avgLatency < lowestLatency) {
|
|
lowestLatency = breakdown.avgLatency;
|
|
fastestProvider = provider;
|
|
}
|
|
}
|
|
if (fastestProvider) {
|
|
console.log(`⚡ Performance-optimized routing: selected ${fastestProvider.name}`);
|
|
return fastestProvider;
|
|
}
|
|
return this.getDefaultProvider();
|
|
}
|
|
async handleProviderError(error, params, agentType) {
|
|
console.error(`❌ Provider error from ${error.provider}:`, error.message);
|
|
// Try fallback chain
|
|
const fallbackChain = this.config.fallbackChain || [];
|
|
for (const providerType of fallbackChain) {
|
|
if (providerType === error.provider)
|
|
continue; // Skip failed provider
|
|
const provider = this.providers.get(providerType);
|
|
if (provider) {
|
|
console.log(`🔄 Falling back to ${provider.name}`);
|
|
try {
|
|
return await provider.chat(params);
|
|
}
|
|
catch (fallbackError) {
|
|
console.error(`❌ Fallback provider ${provider.name} also failed`);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
throw error; // No fallback succeeded
|
|
}
|
|
updateMetrics(providerName, response, latency, agentType) {
|
|
this.metrics.totalRequests++;
|
|
if (response.usage) {
|
|
this.metrics.totalTokens.input += response.usage.inputTokens;
|
|
this.metrics.totalTokens.output += response.usage.outputTokens;
|
|
}
|
|
if (response.metadata?.cost) {
|
|
this.metrics.totalCost += response.metadata.cost;
|
|
}
|
|
// Provider breakdown
|
|
if (!this.metrics.providerBreakdown[providerName]) {
|
|
this.metrics.providerBreakdown[providerName] = {
|
|
requests: 0,
|
|
cost: 0,
|
|
avgLatency: 0,
|
|
errors: 0
|
|
};
|
|
}
|
|
const breakdown = this.metrics.providerBreakdown[providerName];
|
|
breakdown.requests++;
|
|
breakdown.cost += response.metadata?.cost || 0;
|
|
breakdown.avgLatency = (breakdown.avgLatency * (breakdown.requests - 1) + latency) / breakdown.requests;
|
|
// Agent breakdown
|
|
if (agentType) {
|
|
if (!this.metrics.agentBreakdown) {
|
|
this.metrics.agentBreakdown = {};
|
|
}
|
|
if (!this.metrics.agentBreakdown[agentType]) {
|
|
this.metrics.agentBreakdown[agentType] = { requests: 0, cost: 0 };
|
|
}
|
|
this.metrics.agentBreakdown[agentType].requests++;
|
|
this.metrics.agentBreakdown[agentType].cost += response.metadata?.cost || 0;
|
|
}
|
|
}
|
|
getMetrics() {
|
|
return { ...this.metrics };
|
|
}
|
|
getConfig() {
|
|
return { ...this.config };
|
|
}
|
|
getProviders() {
|
|
return new Map(this.providers);
|
|
}
|
|
}
|
|
//# sourceMappingURL=router.js.map
|