diff --git a/src/cli/src/commands/config-setup.ts b/src/cli/src/commands/config-setup.ts index 127d77d..5c299ac 100644 --- a/src/cli/src/commands/config-setup.ts +++ b/src/cli/src/commands/config-setup.ts @@ -4,7 +4,7 @@ import https from 'node:https'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { loadConfig, saveConfig } from '../config/index.js'; -import type { ConfigLoaderDeps, McpctlConfig, LlmConfig, LlmProviderName } from '../config/index.js'; +import type { ConfigLoaderDeps, McpctlConfig, LlmConfig, LlmProviderName, LlmProviderEntry, LlmTier } from '../config/index.js'; import type { SecretStore } from '@mcpctl/shared'; import { createSecretStore } from '@mcpctl/shared'; @@ -32,13 +32,28 @@ interface ProviderChoice { description: string; } -const PROVIDER_CHOICES: ProviderChoice[] = [ - { name: 'Gemini CLI', value: 'gemini-cli', description: 'Google Gemini via local CLI (free, no API key)' }, - { name: 'Ollama', value: 'ollama', description: 'Local models via Ollama' }, - { name: 'Anthropic (Claude)', value: 'anthropic', description: 'Claude API (requires API key)' }, +/** Provider config fields returned by per-provider setup functions. */ +interface ProviderFields { + model?: string; + url?: string; + binaryPath?: string; +} + +const FAST_PROVIDER_CHOICES: ProviderChoice[] = [ { name: 'vLLM', value: 'vllm', description: 'Self-hosted vLLM (OpenAI-compatible)' }, + { name: 'Ollama', value: 'ollama', description: 'Local models via Ollama' }, +]; + +const HEAVY_PROVIDER_CHOICES: ProviderChoice[] = [ + { name: 'Gemini CLI', value: 'gemini-cli', description: 'Google Gemini via local CLI (free, no API key)' }, + { name: 'Anthropic (Claude)', value: 'anthropic', description: 'Claude API (requires API key)' }, { name: 'OpenAI', value: 'openai', description: 'OpenAI API (requires API key)' }, { name: 'DeepSeek', value: 'deepseek', description: 'DeepSeek API (requires API key)' }, +]; + +const ALL_PROVIDER_CHOICES: ProviderChoice[] = [ + ...FAST_PROVIDER_CHOICES, + ...HEAVY_PROVIDER_CHOICES, { name: 'None (disable)', value: 'none', description: 'Disable LLM features' }, ]; @@ -145,6 +160,283 @@ async function defaultWhichBinary(name: string): Promise { } } +// --- Per-provider setup functions (return ProviderFields for reuse in both modes) --- + +async function setupGeminiCliFields( + prompt: ConfigSetupPrompt, + log: (...args: string[]) => void, + whichBinary: (name: string) => Promise, + currentModel?: string, +): Promise { + const model = await prompt.select('Select model:', [ + ...GEMINI_MODELS.map((m) => ({ + name: m === currentModel ? `${m} (current)` : m, + value: m, + })), + { name: 'Custom...', value: '__custom__' }, + ]); + + const finalModel = model === '__custom__' + ? await prompt.input('Model name:', currentModel) + : model; + + let binaryPath: string | undefined; + const detected = await whichBinary('gemini'); + if (detected) { + log(`Found gemini at: ${detected}`); + binaryPath = detected; + } else { + log('Warning: gemini binary not found in PATH'); + const manualPath = await prompt.input('Binary path (or install with: npm i -g @google/gemini-cli):'); + if (manualPath) binaryPath = manualPath; + } + + const result: ProviderFields = { model: finalModel }; + if (binaryPath) result.binaryPath = binaryPath; + return result; +} + +async function setupOllamaFields( + prompt: ConfigSetupPrompt, + fetchModels: ConfigSetupDeps['fetchModels'], + currentUrl?: string, + currentModel?: string, +): Promise { + const url = await prompt.input('Ollama URL:', currentUrl ?? 'http://localhost:11434'); + const models = await fetchModels(url, '/api/tags'); + let model: string; + + if (models.length > 0) { + const choices = models.map((m) => ({ + name: m === currentModel ? `${m} (current)` : m, + value: m, + })); + choices.push({ name: 'Custom...', value: '__custom__' }); + model = await prompt.select('Select model:', choices); + if (model === '__custom__') { + model = await prompt.input('Model name:', currentModel); + } + } else { + model = await prompt.input('Model name (could not fetch models):', currentModel ?? 'llama3.2'); + } + + const result: ProviderFields = { model }; + if (url) result.url = url; + return result; +} + +async function setupVllmFields( + prompt: ConfigSetupPrompt, + fetchModels: ConfigSetupDeps['fetchModels'], + currentUrl?: string, + currentModel?: string, +): Promise { + const url = await prompt.input('vLLM URL:', currentUrl ?? 'http://localhost:8000'); + const models = await fetchModels(url, '/v1/models'); + let model: string; + + if (models.length > 0) { + const choices = models.map((m) => ({ + name: m === currentModel ? `${m} (current)` : m, + value: m, + })); + choices.push({ name: 'Custom...', value: '__custom__' }); + model = await prompt.select('Select model:', choices); + if (model === '__custom__') { + model = await prompt.input('Model name:', currentModel); + } + } else { + model = await prompt.input('Model name (could not fetch models):', currentModel ?? 'default'); + } + + const result: ProviderFields = { model }; + if (url) result.url = url; + return result; +} + +async function setupApiKeyFields( + prompt: ConfigSetupPrompt, + secretStore: SecretStore, + provider: LlmProviderName, + secretKey: string, + hardcodedModels: string[], + currentModel?: string, + currentUrl?: string, +): Promise { + const existingKey = await secretStore.get(secretKey); + let apiKey: string; + + if (existingKey) { + const masked = `****${existingKey.slice(-4)}`; + const changeKey = await prompt.confirm(`API key stored (${masked}). Change it?`, false); + apiKey = changeKey ? await prompt.password('API key:') : existingKey; + } else { + apiKey = await prompt.password('API key:'); + } + + if (apiKey !== existingKey) { + await secretStore.set(secretKey, apiKey); + } + + let model: string; + if (hardcodedModels.length > 0) { + const choices = hardcodedModels.map((m) => ({ + name: m === currentModel ? `${m} (current)` : m, + value: m, + })); + choices.push({ name: 'Custom...', value: '__custom__' }); + model = await prompt.select('Select model:', choices); + if (model === '__custom__') { + model = await prompt.input('Model name:', currentModel); + } + } else { + model = await prompt.input('Model name:', currentModel ?? 'gpt-4o'); + } + + let url: string | undefined; + if (provider === 'openai') { + const customUrl = await prompt.confirm('Use custom API endpoint?', false); + if (customUrl) { + url = await prompt.input('API URL:', currentUrl ?? 'https://api.openai.com'); + } + } + + const result: ProviderFields = { model }; + if (url) result.url = url; + return result; +} + +/** Configure a single provider type and return its fields. */ +async function setupProviderFields( + providerType: LlmProviderName, + prompt: ConfigSetupPrompt, + log: (...args: string[]) => void, + fetchModels: ConfigSetupDeps['fetchModels'], + whichBinary: (name: string) => Promise, + secretStore: SecretStore, +): Promise { + switch (providerType) { + case 'gemini-cli': + return setupGeminiCliFields(prompt, log, whichBinary); + case 'ollama': + return setupOllamaFields(prompt, fetchModels); + case 'vllm': + return setupVllmFields(prompt, fetchModels); + case 'anthropic': + return setupApiKeyFields(prompt, secretStore, 'anthropic', 'anthropic-api-key', ANTHROPIC_MODELS); + case 'openai': + return setupApiKeyFields(prompt, secretStore, 'openai', 'openai-api-key', []); + case 'deepseek': + return setupApiKeyFields(prompt, secretStore, 'deepseek', 'deepseek-api-key', DEEPSEEK_MODELS); + default: + return {}; + } +} + +/** Build a LlmProviderEntry from type, name, and fields. */ +function buildEntry(providerType: LlmProviderName, name: string, fields: ProviderFields, tier?: LlmTier): LlmProviderEntry { + const entry: LlmProviderEntry = { name, type: providerType }; + if (fields.model) entry.model = fields.model; + if (fields.url) entry.url = fields.url; + if (fields.binaryPath) entry.binaryPath = fields.binaryPath; + if (tier) entry.tier = tier; + return entry; +} + +/** Simple mode: single provider (legacy format). */ +async function simpleSetup( + config: McpctlConfig, + configDeps: Partial, + prompt: ConfigSetupPrompt, + log: (...args: string[]) => void, + fetchModels: ConfigSetupDeps['fetchModels'], + whichBinary: (name: string) => Promise, + secretStore: SecretStore, +): Promise { + const currentLlm = config.llm && 'provider' in config.llm ? config.llm : undefined; + + const choices = ALL_PROVIDER_CHOICES.map((c) => { + if (currentLlm?.provider === c.value) { + return { ...c, name: `${c.name} (current)` }; + } + return c; + }); + + const provider = await prompt.select('Select LLM provider:', choices); + + if (provider === 'none') { + const updated: McpctlConfig = { ...config, llm: { provider: 'none' } }; + saveConfig(updated, configDeps); + log('LLM disabled. Restart mcplocal: systemctl --user restart mcplocal'); + return; + } + + const fields = await setupProviderFields(provider, prompt, log, fetchModels, whichBinary, secretStore); + const llmConfig: LlmConfig = { provider, ...fields }; + const updated: McpctlConfig = { ...config, llm: llmConfig }; + saveConfig(updated, configDeps); + log(`\nLLM configured: ${llmConfig.provider}${llmConfig.model ? ` / ${llmConfig.model}` : ''}`); + log('Restart mcplocal: systemctl --user restart mcplocal'); +} + +/** Advanced mode: multiple providers with tier assignments. */ +async function advancedSetup( + config: McpctlConfig, + configDeps: Partial, + prompt: ConfigSetupPrompt, + log: (...args: string[]) => void, + fetchModels: ConfigSetupDeps['fetchModels'], + whichBinary: (name: string) => Promise, + secretStore: SecretStore, +): Promise { + const entries: LlmProviderEntry[] = []; + + // Fast providers + const addFast = await prompt.confirm('Add a FAST provider? (vLLM, Ollama — local, cheap, fast)', true); + if (addFast) { + let addMore = true; + while (addMore) { + const providerType = await prompt.select('Fast provider type:', FAST_PROVIDER_CHOICES); + const defaultName = providerType === 'vllm' ? 'vllm-local' : providerType; + const name = await prompt.input('Provider name:', defaultName); + const fields = await setupProviderFields(providerType, prompt, log, fetchModels, whichBinary, secretStore); + entries.push(buildEntry(providerType, name, fields, 'fast')); + log(` Added: ${name} (${providerType}) → fast tier`); + addMore = await prompt.confirm('Add another fast provider?', false); + } + } + + // Heavy providers + const addHeavy = await prompt.confirm('Add a HEAVY provider? (Gemini, Anthropic, OpenAI — cloud, smart)', true); + if (addHeavy) { + let addMore = true; + while (addMore) { + const providerType = await prompt.select('Heavy provider type:', HEAVY_PROVIDER_CHOICES); + const defaultName = providerType; + const name = await prompt.input('Provider name:', defaultName); + const fields = await setupProviderFields(providerType, prompt, log, fetchModels, whichBinary, secretStore); + entries.push(buildEntry(providerType, name, fields, 'heavy')); + log(` Added: ${name} (${providerType}) → heavy tier`); + addMore = await prompt.confirm('Add another heavy provider?', false); + } + } + + if (entries.length === 0) { + log('No providers configured.'); + return; + } + + // Summary + log('\nProvider configuration:'); + for (const e of entries) { + log(` ${e.tier ?? 'unassigned'}: ${e.name} (${e.type})${e.model ? ` / ${e.model}` : ''}`); + } + + const updated: McpctlConfig = { ...config, llm: { providers: entries } }; + saveConfig(updated, configDeps); + log('\nRestart mcplocal: systemctl --user restart mcplocal'); +} + export function createConfigSetupCommand(deps?: Partial): Command { return new Command('setup') .description('Interactive LLM provider setup wizard') @@ -157,191 +449,16 @@ export function createConfigSetupCommand(deps?: Partial): Comma const secretStore = deps?.secretStore ?? await createSecretStore(); const config = loadConfig(configDeps); - const currentLlm = config.llm; - // Annotate current provider in choices - const choices = PROVIDER_CHOICES.map((c) => { - if (currentLlm?.provider === c.value) { - return { ...c, name: `${c.name} (current)` }; - } - return c; - }); + const mode = await prompt.select<'simple' | 'advanced'>('Setup mode:', [ + { name: 'Simple', value: 'simple', description: 'One provider for everything' }, + { name: 'Advanced', value: 'advanced', description: 'Multiple providers with fast/heavy tiers' }, + ]); - const provider = await prompt.select('Select LLM provider:', choices); - - if (provider === 'none') { - const updated: McpctlConfig = { ...config, llm: { provider: 'none' } }; - saveConfig(updated, configDeps); - log('LLM disabled. Restart mcplocal: systemctl --user restart mcplocal'); - return; + if (mode === 'simple') { + await simpleSetup(config, configDeps, prompt, log, fetchModels, whichBinary, secretStore); + } else { + await advancedSetup(config, configDeps, prompt, log, fetchModels, whichBinary, secretStore); } - - let llmConfig: LlmConfig; - - switch (provider) { - case 'gemini-cli': - llmConfig = await setupGeminiCli(prompt, log, whichBinary, currentLlm); - break; - case 'ollama': - llmConfig = await setupOllama(prompt, fetchModels, currentLlm); - break; - case 'anthropic': - llmConfig = await setupApiKeyProvider(prompt, secretStore, 'anthropic', 'anthropic-api-key', ANTHROPIC_MODELS, currentLlm); - break; - case 'vllm': - llmConfig = await setupVllm(prompt, fetchModels, currentLlm); - break; - case 'openai': - llmConfig = await setupApiKeyProvider(prompt, secretStore, 'openai', 'openai-api-key', [], currentLlm); - break; - case 'deepseek': - llmConfig = await setupApiKeyProvider(prompt, secretStore, 'deepseek', 'deepseek-api-key', DEEPSEEK_MODELS, currentLlm); - break; - default: - return; - } - - const updated: McpctlConfig = { ...config, llm: llmConfig }; - saveConfig(updated, configDeps); - log(`\nLLM configured: ${llmConfig.provider}${llmConfig.model ? ` / ${llmConfig.model}` : ''}`); - log('Restart mcplocal: systemctl --user restart mcplocal'); }); } - -async function setupGeminiCli( - prompt: ConfigSetupPrompt, - log: (...args: string[]) => void, - whichBinary: (name: string) => Promise, - current?: LlmConfig, -): Promise { - const model = await prompt.select('Select model:', [ - ...GEMINI_MODELS.map((m) => ({ - name: m === current?.model ? `${m} (current)` : m, - value: m, - })), - { name: 'Custom...', value: '__custom__' }, - ]); - - const finalModel = model === '__custom__' - ? await prompt.input('Model name:', current?.model) - : model; - - // Auto-detect gemini binary path - let binaryPath: string | undefined; - const detected = await whichBinary('gemini'); - if (detected) { - log(`Found gemini at: ${detected}`); - binaryPath = detected; - } else { - log('Warning: gemini binary not found in PATH'); - const manualPath = await prompt.input('Binary path (or install with: npm i -g @google/gemini-cli):'); - if (manualPath) binaryPath = manualPath; - } - - return { provider: 'gemini-cli', model: finalModel, binaryPath }; -} - -async function setupOllama(prompt: ConfigSetupPrompt, fetchModels: ConfigSetupDeps['fetchModels'], current?: LlmConfig): Promise { - const url = await prompt.input('Ollama URL:', current?.url ?? 'http://localhost:11434'); - - // Try to fetch models from Ollama - const models = await fetchModels(url, '/api/tags'); - let model: string; - - if (models.length > 0) { - const choices = models.map((m) => ({ - name: m === current?.model ? `${m} (current)` : m, - value: m, - })); - choices.push({ name: 'Custom...', value: '__custom__' }); - model = await prompt.select('Select model:', choices); - if (model === '__custom__') { - model = await prompt.input('Model name:', current?.model); - } - } else { - model = await prompt.input('Model name (could not fetch models):', current?.model ?? 'llama3.2'); - } - - return { provider: 'ollama', model, url }; -} - -async function setupVllm(prompt: ConfigSetupPrompt, fetchModels: ConfigSetupDeps['fetchModels'], current?: LlmConfig): Promise { - const url = await prompt.input('vLLM URL:', current?.url ?? 'http://localhost:8000'); - - // Try to fetch models from vLLM (OpenAI-compatible) - const models = await fetchModels(url, '/v1/models'); - let model: string; - - if (models.length > 0) { - const choices = models.map((m) => ({ - name: m === current?.model ? `${m} (current)` : m, - value: m, - })); - choices.push({ name: 'Custom...', value: '__custom__' }); - model = await prompt.select('Select model:', choices); - if (model === '__custom__') { - model = await prompt.input('Model name:', current?.model); - } - } else { - model = await prompt.input('Model name (could not fetch models):', current?.model ?? 'default'); - } - - return { provider: 'vllm', model, url }; -} - -async function setupApiKeyProvider( - prompt: ConfigSetupPrompt, - secretStore: SecretStore, - provider: LlmProviderName, - secretKey: string, - hardcodedModels: string[], - current?: LlmConfig, -): Promise { - // Check for existing API key - const existingKey = await secretStore.get(secretKey); - let apiKey: string; - - if (existingKey) { - const masked = `****${existingKey.slice(-4)}`; - const changeKey = await prompt.confirm(`API key stored (${masked}). Change it?`, false); - if (changeKey) { - apiKey = await prompt.password('API key:'); - } else { - apiKey = existingKey; - } - } else { - apiKey = await prompt.password('API key:'); - } - - // Store API key - if (apiKey !== existingKey) { - await secretStore.set(secretKey, apiKey); - } - - // Model selection - let model: string; - if (hardcodedModels.length > 0) { - const choices = hardcodedModels.map((m) => ({ - name: m === current?.model ? `${m} (current)` : m, - value: m, - })); - choices.push({ name: 'Custom...', value: '__custom__' }); - model = await prompt.select('Select model:', choices); - if (model === '__custom__') { - model = await prompt.input('Model name:', current?.model); - } - } else { - model = await prompt.input('Model name:', current?.model ?? 'gpt-4o'); - } - - // Optional custom URL for openai - let url: string | undefined; - if (provider === 'openai') { - const customUrl = await prompt.confirm('Use custom API endpoint?', false); - if (customUrl) { - url = await prompt.input('API URL:', current?.url ?? 'https://api.openai.com'); - } - } - - return { provider, model, url }; -} diff --git a/src/cli/src/commands/status.ts b/src/cli/src/commands/status.ts index b3ba569..2981786 100644 --- a/src/cli/src/commands/status.ts +++ b/src/cli/src/commands/status.ts @@ -14,6 +14,11 @@ const DIM = '\x1b[2m'; const RESET = '\x1b[0m'; const CLEAR_LINE = '\x1b[2K\r'; +interface ProvidersInfo { + providers: string[]; + tiers: { fast: string[]; heavy: string[] }; +} + export interface StatusCommandDeps { configDeps: Partial; credentialsDeps: Partial; @@ -24,6 +29,8 @@ export interface StatusCommandDeps { checkLlm: (mcplocalUrl: string) => Promise; /** Fetch available models from mcplocal's /llm/models endpoint */ fetchModels: (mcplocalUrl: string) => Promise; + /** Fetch provider tier info from mcplocal's /llm/providers endpoint */ + fetchProviders: (mcplocalUrl: string) => Promise; isTTY: boolean; } @@ -91,6 +98,25 @@ function defaultFetchModels(mcplocalUrl: string): Promise { }); } +function defaultFetchProviders(mcplocalUrl: string): Promise { + return new Promise((resolve) => { + const req = http.get(`${mcplocalUrl}/llm/providers`, { timeout: 5000 }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + try { + const body = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as ProvidersInfo; + resolve(body); + } catch { + resolve(null); + } + }); + }); + req.on('error', () => resolve(null)); + req.on('timeout', () => { req.destroy(); resolve(null); }); + }); +} + const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; const defaultDeps: StatusCommandDeps = { @@ -101,11 +127,35 @@ const defaultDeps: StatusCommandDeps = { checkHealth: defaultCheckHealth, checkLlm: defaultCheckLlm, fetchModels: defaultFetchModels, + fetchProviders: defaultFetchProviders, isTTY: process.stdout.isTTY ?? false, }; +/** Determine LLM label from config (handles both legacy and multi-provider formats). */ +function getLlmLabel(llm: unknown): string | null { + if (!llm || typeof llm !== 'object') return null; + // Legacy format: { provider, model } + if ('provider' in llm) { + const legacy = llm as { provider: string; model?: string }; + if (legacy.provider === 'none') return null; + return `${legacy.provider}${legacy.model ? ` / ${legacy.model}` : ''}`; + } + // Multi-provider format: { providers: [...] } + if ('providers' in llm) { + const multi = llm as { providers: Array<{ name: string; type: string; tier?: string }> }; + if (multi.providers.length === 0) return null; + return multi.providers.map((p) => `${p.name}${p.tier ? ` (${p.tier})` : ''}`).join(', '); + } + return null; +} + +/** Check if config uses multi-provider format. */ +function isMultiProvider(llm: unknown): boolean { + return !!llm && typeof llm === 'object' && 'providers' in llm; +} + export function createStatusCommand(deps?: Partial): Command { - const { configDeps, credentialsDeps, log, write, checkHealth, checkLlm, fetchModels, isTTY } = { ...defaultDeps, ...deps }; + const { configDeps, credentialsDeps, log, write, checkHealth, checkLlm, fetchModels, fetchProviders, isTTY } = { ...defaultDeps, ...deps }; return new Command('status') .description('Show mcpctl status and connectivity') @@ -114,16 +164,16 @@ export function createStatusCommand(deps?: Partial): Command const config = loadConfig(configDeps); const creds = loadCredentials(credentialsDeps); - const llmLabel = config.llm && config.llm.provider !== 'none' - ? `${config.llm.provider}${config.llm.model ? ` / ${config.llm.model}` : ''}` - : null; + const llmLabel = getLlmLabel(config.llm); + const multiProvider = isMultiProvider(config.llm); if (opts.output !== 'table') { // JSON/YAML: run everything in parallel, wait, output at once - const [mcplocalReachable, mcpdReachable, llmStatus] = await Promise.all([ + const [mcplocalReachable, mcpdReachable, llmStatus, providersInfo] = await Promise.all([ checkHealth(config.mcplocalUrl), checkHealth(config.mcpdUrl), llmLabel ? checkLlm(config.mcplocalUrl) : Promise.resolve(null), + multiProvider ? fetchProviders(config.mcplocalUrl) : Promise.resolve(null), ]); const llm = llmLabel @@ -141,6 +191,7 @@ export function createStatusCommand(deps?: Partial): Command outputFormat: config.outputFormat, llm, llmStatus, + ...(providersInfo ? { providers: providersInfo } : {}), }; log(opts.output === 'json' ? formatJson(status) : formatYaml(status)); @@ -167,35 +218,58 @@ export function createStatusCommand(deps?: Partial): Command return; } - // LLM check + models fetch in parallel — queries mcplocal endpoints + // LLM check + models + providers fetch in parallel const llmPromise = checkLlm(config.mcplocalUrl); const modelsPromise = fetchModels(config.mcplocalUrl); + const providersPromise = multiProvider ? fetchProviders(config.mcplocalUrl) : Promise.resolve(null); if (isTTY) { let frame = 0; const interval = setInterval(() => { - write(`${CLEAR_LINE}LLM: ${llmLabel} ${DIM}${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} checking...${RESET}`); + write(`${CLEAR_LINE}LLM: ${DIM}${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} checking...${RESET}`); frame++; }, 80); - const [llmStatus, models] = await Promise.all([llmPromise, modelsPromise]); + const [llmStatus, models, providersInfo] = await Promise.all([llmPromise, modelsPromise, providersPromise]); clearInterval(interval); - if (llmStatus === 'ok' || llmStatus === 'ok (key stored)') { - write(`${CLEAR_LINE}LLM: ${llmLabel} ${GREEN}✓ ${llmStatus}${RESET}\n`); + if (providersInfo && (providersInfo.tiers.fast.length > 0 || providersInfo.tiers.heavy.length > 0)) { + // Tiered display + write(`${CLEAR_LINE}`); + if (providersInfo.tiers.fast.length > 0) { + log(`LLM (fast): ${providersInfo.tiers.fast.join(', ')} ${GREEN}✓${RESET}`); + } + if (providersInfo.tiers.heavy.length > 0) { + log(`LLM (heavy): ${providersInfo.tiers.heavy.join(', ')} ${GREEN}✓${RESET}`); + } } else { - write(`${CLEAR_LINE}LLM: ${llmLabel} ${RED}✗ ${llmStatus}${RESET}\n`); + // Legacy single provider display + if (llmStatus === 'ok' || llmStatus === 'ok (key stored)') { + write(`${CLEAR_LINE}LLM: ${llmLabel} ${GREEN}✓ ${llmStatus}${RESET}\n`); + } else { + write(`${CLEAR_LINE}LLM: ${llmLabel} ${RED}✗ ${llmStatus}${RESET}\n`); + } } if (models.length > 0) { log(`${DIM} Available: ${models.join(', ')}${RESET}`); } } else { // Non-TTY: no spinner, just wait and print - const [llmStatus, models] = await Promise.all([llmPromise, modelsPromise]); - if (llmStatus === 'ok' || llmStatus === 'ok (key stored)') { - log(`LLM: ${llmLabel} ✓ ${llmStatus}`); + const [llmStatus, models, providersInfo] = await Promise.all([llmPromise, modelsPromise, providersPromise]); + + if (providersInfo && (providersInfo.tiers.fast.length > 0 || providersInfo.tiers.heavy.length > 0)) { + if (providersInfo.tiers.fast.length > 0) { + log(`LLM (fast): ${providersInfo.tiers.fast.join(', ')}`); + } + if (providersInfo.tiers.heavy.length > 0) { + log(`LLM (heavy): ${providersInfo.tiers.heavy.join(', ')}`); + } } else { - log(`LLM: ${llmLabel} ✗ ${llmStatus}`); + if (llmStatus === 'ok' || llmStatus === 'ok (key stored)') { + log(`LLM: ${llmLabel} ✓ ${llmStatus}`); + } else { + log(`LLM: ${llmLabel} ✗ ${llmStatus}`); + } } if (models.length > 0) { log(`${DIM} Available: ${models.join(', ')}${RESET}`); diff --git a/src/cli/src/config/index.ts b/src/cli/src/config/index.ts index b7d856a..def1aaf 100644 --- a/src/cli/src/config/index.ts +++ b/src/cli/src/config/index.ts @@ -1,4 +1,4 @@ -export { McpctlConfigSchema, LlmConfigSchema, LLM_PROVIDERS, DEFAULT_CONFIG } from './schema.js'; -export type { McpctlConfig, LlmConfig, LlmProviderName } from './schema.js'; +export { McpctlConfigSchema, LlmConfigSchema, LlmProviderEntrySchema, LlmMultiConfigSchema, LLM_PROVIDERS, LLM_TIERS, DEFAULT_CONFIG } from './schema.js'; +export type { McpctlConfig, LlmConfig, LlmProviderEntry, LlmMultiConfig, LlmProviderName, LlmTier } from './schema.js'; export { loadConfig, saveConfig, mergeConfig, getConfigPath } from './loader.js'; export type { ConfigLoaderDeps } from './loader.js'; diff --git a/src/cli/src/config/schema.ts b/src/cli/src/config/schema.ts index e923d8f..48e818c 100644 --- a/src/cli/src/config/schema.ts +++ b/src/cli/src/config/schema.ts @@ -3,6 +3,10 @@ import { z } from 'zod'; export const LLM_PROVIDERS = ['gemini-cli', 'ollama', 'anthropic', 'openai', 'deepseek', 'vllm', 'none'] as const; export type LlmProviderName = typeof LLM_PROVIDERS[number]; +export const LLM_TIERS = ['fast', 'heavy'] as const; +export type LlmTier = typeof LLM_TIERS[number]; + +/** Legacy single-provider format. */ export const LlmConfigSchema = z.object({ /** LLM provider name */ provider: z.enum(LLM_PROVIDERS), @@ -16,6 +20,31 @@ export const LlmConfigSchema = z.object({ export type LlmConfig = z.infer; +/** Multi-provider entry (advanced mode). */ +export const LlmProviderEntrySchema = z.object({ + /** User-chosen name for this provider instance (e.g. "vllm-local") */ + name: z.string(), + /** Provider type */ + type: z.enum(LLM_PROVIDERS), + /** Model name */ + model: z.string().optional(), + /** Provider URL (for ollama, vllm, openai with custom endpoint) */ + url: z.string().optional(), + /** Binary path override (for gemini-cli) */ + binaryPath: z.string().optional(), + /** Tier assignment */ + tier: z.enum(LLM_TIERS).optional(), +}).strict(); + +export type LlmProviderEntry = z.infer; + +/** Multi-provider format with providers array. */ +export const LlmMultiConfigSchema = z.object({ + providers: z.array(LlmProviderEntrySchema).min(1), +}).strict(); + +export type LlmMultiConfig = z.infer; + export const McpctlConfigSchema = z.object({ /** mcplocal daemon endpoint (local LLM pre-processing proxy) */ mcplocalUrl: z.string().default('http://localhost:3200'), @@ -35,8 +64,8 @@ export const McpctlConfigSchema = z.object({ outputFormat: z.enum(['table', 'json', 'yaml']).default('table'), /** Smithery API key */ smitheryApiKey: z.string().optional(), - /** LLM provider configuration for smart features (pagination summaries, etc.) */ - llm: LlmConfigSchema.optional(), + /** LLM provider configuration — accepts legacy single-provider or multi-provider format */ + llm: z.union([LlmConfigSchema, LlmMultiConfigSchema]).optional(), }).transform((cfg) => { // Backward compatibility: if old daemonUrl is set but mcplocalUrl wasn't explicitly changed, // use daemonUrl as mcplocalUrl diff --git a/src/cli/tests/commands/config-setup.test.ts b/src/cli/tests/commands/config-setup.test.ts index d4860fd..c1e5115 100644 --- a/src/cli/tests/commands/config-setup.test.ts +++ b/src/cli/tests/commands/config-setup.test.ts @@ -67,7 +67,7 @@ async function runSetup(deps: ConfigSetupDeps): Promise { describe('config setup wizard', () => { describe('provider: none', () => { it('disables LLM and saves config', async () => { - const deps = buildDeps({ answers: ['none'] }); + const deps = buildDeps({ answers: ['simple', 'none'] }); await runSetup(deps); const config = readConfig(); @@ -81,7 +81,7 @@ describe('config setup wizard', () => { it('auto-detects binary path and saves config', async () => { // Answers: select provider, select model (no binary prompt — auto-detected) const deps = buildDeps({ - answers: ['gemini-cli', 'gemini-2.5-flash'], + answers: ['simple', 'gemini-cli', 'gemini-2.5-flash'], whichBinary: vi.fn(async () => '/home/user/.npm-global/bin/gemini'), }); await runSetup(deps); @@ -98,7 +98,7 @@ describe('config setup wizard', () => { it('prompts for manual path when binary not found', async () => { // Answers: select provider, select model, enter manual path const deps = buildDeps({ - answers: ['gemini-cli', 'gemini-2.5-flash', '/opt/gemini'], + answers: ['simple', 'gemini-cli', 'gemini-2.5-flash', '/opt/gemini'], whichBinary: vi.fn(async () => null), }); await runSetup(deps); @@ -113,7 +113,7 @@ describe('config setup wizard', () => { it('saves gemini-cli with custom model', async () => { // Answers: select provider, select custom, enter model name const deps = buildDeps({ - answers: ['gemini-cli', '__custom__', 'gemini-3.0-flash'], + answers: ['simple', 'gemini-cli', '__custom__', 'gemini-3.0-flash'], whichBinary: vi.fn(async () => '/usr/bin/gemini'), }); await runSetup(deps); @@ -130,7 +130,7 @@ describe('config setup wizard', () => { const fetchModels = vi.fn(async () => ['llama3.2', 'codellama', 'mistral']); // Answers: select provider, enter URL, select model const deps = buildDeps({ - answers: ['ollama', 'http://localhost:11434', 'codellama'], + answers: ['simple', 'ollama', 'http://localhost:11434', 'codellama'], fetchModels, }); await runSetup(deps); @@ -148,7 +148,7 @@ describe('config setup wizard', () => { const fetchModels = vi.fn(async () => []); // Answers: select provider, enter URL, enter model manually const deps = buildDeps({ - answers: ['ollama', 'http://localhost:11434', 'llama3.2'], + answers: ['simple', 'ollama', 'http://localhost:11434', 'llama3.2'], fetchModels, }); await runSetup(deps); @@ -163,7 +163,7 @@ describe('config setup wizard', () => { it('prompts for API key and saves to secret store', async () => { // Answers: select provider, enter API key, select model const deps = buildDeps({ - answers: ['anthropic', 'sk-ant-new-key', 'claude-haiku-3-5-20241022'], + answers: ['simple', 'anthropic', 'sk-ant-new-key', 'claude-haiku-3-5-20241022'], }); await runSetup(deps); @@ -181,7 +181,7 @@ describe('config setup wizard', () => { // Answers: select provider, confirm change=false, select model const deps = buildDeps({ secrets: { 'anthropic-api-key': 'sk-ant-existing-key-1234' }, - answers: ['anthropic', false, 'claude-sonnet-4-20250514'], + answers: ['simple', 'anthropic', false, 'claude-sonnet-4-20250514'], }); await runSetup(deps); @@ -196,7 +196,7 @@ describe('config setup wizard', () => { // Answers: select provider, confirm change=true, enter new key, select model const deps = buildDeps({ secrets: { 'anthropic-api-key': 'sk-ant-old' }, - answers: ['anthropic', true, 'sk-ant-new', 'claude-haiku-3-5-20241022'], + answers: ['simple', 'anthropic', true, 'sk-ant-new', 'claude-haiku-3-5-20241022'], }); await runSetup(deps); @@ -210,7 +210,7 @@ describe('config setup wizard', () => { const fetchModels = vi.fn(async () => ['my-model', 'llama-70b']); // Answers: select provider, enter URL, select model const deps = buildDeps({ - answers: ['vllm', 'http://gpu:8000', 'llama-70b'], + answers: ['simple', 'vllm', 'http://gpu:8000', 'llama-70b'], fetchModels, }); await runSetup(deps); @@ -229,7 +229,7 @@ describe('config setup wizard', () => { it('prompts for key, model, and optional custom endpoint', async () => { // Answers: select provider, enter key, enter model, confirm custom URL=true, enter URL const deps = buildDeps({ - answers: ['openai', 'sk-openai-key', 'gpt-4o', true, 'https://custom.api.com'], + answers: ['simple', 'openai', 'sk-openai-key', 'gpt-4o', true, 'https://custom.api.com'], }); await runSetup(deps); @@ -245,7 +245,7 @@ describe('config setup wizard', () => { it('skips custom URL when not requested', async () => { // Answers: select provider, enter key, enter model, confirm custom URL=false const deps = buildDeps({ - answers: ['openai', 'sk-openai-key', 'gpt-4o-mini', false], + answers: ['simple', 'openai', 'sk-openai-key', 'gpt-4o-mini', false], }); await runSetup(deps); @@ -260,7 +260,7 @@ describe('config setup wizard', () => { it('prompts for key and model', async () => { // Answers: select provider, enter key, select model const deps = buildDeps({ - answers: ['deepseek', 'sk-ds-key', 'deepseek-chat'], + answers: ['simple', 'deepseek', 'sk-ds-key', 'deepseek-chat'], }); await runSetup(deps); @@ -275,7 +275,7 @@ describe('config setup wizard', () => { describe('output messages', () => { it('shows restart instruction', async () => { - const deps = buildDeps({ answers: ['gemini-cli', 'gemini-2.5-flash'] }); + const deps = buildDeps({ answers: ['simple', 'gemini-cli', 'gemini-2.5-flash'] }); await runSetup(deps); expect(logs.some((l) => l.includes('systemctl --user restart mcplocal'))).toBe(true); @@ -283,7 +283,7 @@ describe('config setup wizard', () => { }); it('shows configured provider and model', async () => { - const deps = buildDeps({ answers: ['gemini-cli', 'gemini-2.5-flash'] }); + const deps = buildDeps({ answers: ['simple', 'gemini-cli', 'gemini-2.5-flash'] }); await runSetup(deps); expect(logs.some((l) => l.includes('gemini-cli') && l.includes('gemini-2.5-flash'))).toBe(true); diff --git a/src/cli/tests/commands/status.test.ts b/src/cli/tests/commands/status.test.ts index 0c09f45..f4b6532 100644 --- a/src/cli/tests/commands/status.test.ts +++ b/src/cli/tests/commands/status.test.ts @@ -26,6 +26,7 @@ function baseDeps(overrides?: Partial): Partial true, + fetchProviders: async () => null, isTTY: false, ...overrides, }; diff --git a/src/mcplocal/src/http/config.ts b/src/mcplocal/src/http/config.ts index 5f8436f..6a31707 100644 --- a/src/mcplocal/src/http/config.ts +++ b/src/mcplocal/src/http/config.ts @@ -44,13 +44,27 @@ export interface LlmFileConfig { binaryPath?: string; } +/** Multi-provider entry from config file. */ +export interface LlmProviderFileEntry { + name: string; + type: string; + model?: string; + url?: string; + binaryPath?: string; + tier?: 'fast' | 'heavy'; +} + export interface ProjectLlmOverride { model?: string; provider?: string; } +interface LlmMultiFileConfig { + providers: LlmProviderFileEntry[]; +} + interface McpctlConfig { - llm?: LlmFileConfig; + llm?: LlmFileConfig | LlmMultiFileConfig; projects?: Record; } @@ -70,16 +84,58 @@ function loadFullConfig(): McpctlConfig { } } +/** Type guard: is config the multi-provider format? */ +function isMultiConfig(llm: LlmFileConfig | LlmMultiFileConfig): llm is LlmMultiFileConfig { + return 'providers' in llm && Array.isArray((llm as LlmMultiFileConfig).providers); +} + /** * Load LLM configuration from ~/.mcpctl/config.json. * Returns undefined if no LLM section is configured. + * @deprecated Use loadLlmProviders() for multi-provider support. */ export function loadLlmConfig(): LlmFileConfig | undefined { const config = loadFullConfig(); - if (!config.llm?.provider || config.llm.provider === 'none') return undefined; + if (!config.llm) return undefined; + if (isMultiConfig(config.llm)) { + // Multi-provider format — return first provider as legacy compat + const first = config.llm.providers[0]; + if (!first) return undefined; + const legacy: LlmFileConfig = { provider: first.type }; + if (first.model) legacy.model = first.model; + if (first.url) legacy.url = first.url; + if (first.binaryPath) legacy.binaryPath = first.binaryPath; + return legacy; + } + if (!config.llm.provider || config.llm.provider === 'none') return undefined; return config.llm; } +/** + * Load LLM providers from ~/.mcpctl/config.json. + * Normalizes both legacy single-provider and multi-provider formats. + * Returns empty array if no LLM is configured. + */ +export function loadLlmProviders(): LlmProviderFileEntry[] { + const config = loadFullConfig(); + if (!config.llm) return []; + + if (isMultiConfig(config.llm)) { + return config.llm.providers.filter((p) => p.type !== 'none'); + } + + // Legacy single-provider format → normalize to one entry + if (!config.llm.provider || config.llm.provider === 'none') return []; + const entry: LlmProviderFileEntry = { + name: config.llm.provider, + type: config.llm.provider, + }; + if (config.llm.model) entry.model = config.llm.model; + if (config.llm.url) entry.url = config.llm.url; + if (config.llm.binaryPath) entry.binaryPath = config.llm.binaryPath; + return [entry]; +} + /** * Load per-project LLM override from ~/.mcpctl/config.json. * Returns the project-specific model/provider override, or undefined. diff --git a/src/mcplocal/src/http/server.ts b/src/mcplocal/src/http/server.ts index 9ffd13d..a296ecf 100644 --- a/src/mcplocal/src/http/server.ts +++ b/src/mcplocal/src/http/server.ts @@ -87,7 +87,7 @@ export async function createHttpServer( const LLM_HEALTH_CACHE_MS = 10 * 60 * 1000; // 10 minutes app.get('/llm/health', async (_request, reply) => { - const provider = deps.providerRegistry?.getActive() ?? null; + const provider = deps.providerRegistry?.getProvider('fast') ?? null; if (!provider) { reply.code(200).send({ status: 'not configured' }); return; @@ -127,7 +127,7 @@ export async function createHttpServer( // LLM models — list available models from the active provider app.get('/llm/models', async (_request, reply) => { - const provider = deps.providerRegistry?.getActive() ?? null; + const provider = deps.providerRegistry?.getProvider('fast') ?? null; if (!provider) { reply.code(200).send({ models: [], provider: null }); return; @@ -140,6 +140,22 @@ export async function createHttpServer( } }); + // LLM providers — list all registered providers with tier assignments + app.get('/llm/providers', async (_request, reply) => { + const registry = deps.providerRegistry; + if (!registry) { + reply.code(200).send({ providers: [], tiers: { fast: [], heavy: [] } }); + return; + } + reply.code(200).send({ + providers: registry.list(), + tiers: { + fast: registry.getTierProviders('fast'), + heavy: registry.getTierProviders('heavy'), + }, + }); + }); + // Proxy management routes to mcpd const mcpdClient = new McpdClient(config.mcpdUrl, config.mcpdToken); registerProxyRoutes(app, mcpdClient); diff --git a/src/mcplocal/src/llm-config.ts b/src/mcplocal/src/llm-config.ts index aa5c7bd..1de5b33 100644 --- a/src/mcplocal/src/llm-config.ts +++ b/src/mcplocal/src/llm-config.ts @@ -1,11 +1,12 @@ import type { SecretStore } from '@mcpctl/shared'; -import type { LlmFileConfig } from './http/config.js'; +import type { LlmFileConfig, LlmProviderFileEntry } from './http/config.js'; import { ProviderRegistry } from './providers/registry.js'; import { GeminiAcpProvider } from './providers/gemini-acp.js'; import { OllamaProvider } from './providers/ollama.js'; import { AnthropicProvider } from './providers/anthropic.js'; import { OpenAiProvider } from './providers/openai.js'; import { DeepSeekProvider } from './providers/deepseek.js'; +import type { LlmProvider } from './providers/types.js'; import type { GeminiAcpConfig } from './providers/gemini-acp.js'; import type { OllamaConfig } from './providers/ollama.js'; import type { AnthropicConfig } from './providers/anthropic.js'; @@ -13,87 +14,158 @@ import type { OpenAiConfig } from './providers/openai.js'; import type { DeepSeekConfig } from './providers/deepseek.js'; /** - * Create a ProviderRegistry from user config + secret store. - * Returns an empty registry if config is undefined or provider is 'none'. + * Thin wrapper that delegates all LlmProvider methods but overrides `name`. + * Used when the user's chosen name (e.g. "vllm-local") differs from the + * underlying provider's name (e.g. "openai"). */ -export async function createProviderFromConfig( - config: LlmFileConfig | undefined, - secretStore: SecretStore, -): Promise { - const registry = new ProviderRegistry(); - if (!config?.provider || config.provider === 'none') return registry; +class NamedProvider implements LlmProvider { + readonly name: string; + private inner: LlmProvider; - switch (config.provider) { + constructor(name: string, inner: LlmProvider) { + this.name = name; + this.inner = inner; + } + + complete(...args: Parameters) { + return this.inner.complete(...args); + } + listModels() { + return this.inner.listModels(); + } + isAvailable() { + return this.inner.isAvailable(); + } + dispose() { + this.inner.dispose?.(); + } +} + +/** + * Create a single LlmProvider from a provider entry config. + * Returns null if required config is missing (logs warning). + */ +async function createSingleProvider( + entry: LlmProviderFileEntry, + secretStore: SecretStore, +): Promise { + switch (entry.type) { case 'gemini-cli': { const cfg: GeminiAcpConfig = {}; - if (config.binaryPath) cfg.binaryPath = config.binaryPath; - if (config.model) cfg.defaultModel = config.model; + if (entry.binaryPath) cfg.binaryPath = entry.binaryPath; + if (entry.model) cfg.defaultModel = entry.model; const provider = new GeminiAcpProvider(cfg); provider.warmup(); - registry.register(provider); - break; + return provider; } case 'ollama': { const cfg: OllamaConfig = {}; - if (config.url) cfg.baseUrl = config.url; - if (config.model) cfg.defaultModel = config.model; - registry.register(new OllamaProvider(cfg)); - break; + if (entry.url) cfg.baseUrl = entry.url; + if (entry.model) cfg.defaultModel = entry.model; + return new OllamaProvider(cfg); } case 'anthropic': { const apiKey = await secretStore.get('anthropic-api-key'); if (!apiKey) { - process.stderr.write('Warning: Anthropic API key not found in secret store. Run "mcpctl config setup" to configure.\n'); - return registry; + process.stderr.write(`Warning: Anthropic API key not found for provider "${entry.name}". Run "mcpctl config setup" to configure.\n`); + return null; } const cfg: AnthropicConfig = { apiKey }; - if (config.model) cfg.defaultModel = config.model; - registry.register(new AnthropicProvider(cfg)); - break; + if (entry.model) cfg.defaultModel = entry.model; + return new AnthropicProvider(cfg); } case 'openai': { const apiKey = await secretStore.get('openai-api-key'); if (!apiKey) { - process.stderr.write('Warning: OpenAI API key not found in secret store. Run "mcpctl config setup" to configure.\n'); - return registry; + process.stderr.write(`Warning: OpenAI API key not found for provider "${entry.name}". Run "mcpctl config setup" to configure.\n`); + return null; } const cfg: OpenAiConfig = { apiKey }; - if (config.url) cfg.baseUrl = config.url; - if (config.model) cfg.defaultModel = config.model; - registry.register(new OpenAiProvider(cfg)); - break; + if (entry.url) cfg.baseUrl = entry.url; + if (entry.model) cfg.defaultModel = entry.model; + return new OpenAiProvider(cfg); } case 'deepseek': { const apiKey = await secretStore.get('deepseek-api-key'); if (!apiKey) { - process.stderr.write('Warning: DeepSeek API key not found in secret store. Run "mcpctl config setup" to configure.\n'); - return registry; + process.stderr.write(`Warning: DeepSeek API key not found for provider "${entry.name}". Run "mcpctl config setup" to configure.\n`); + return null; } const cfg: DeepSeekConfig = { apiKey }; - if (config.url) cfg.baseUrl = config.url; - if (config.model) cfg.defaultModel = config.model; - registry.register(new DeepSeekProvider(cfg)); - break; + if (entry.url) cfg.baseUrl = entry.url; + if (entry.model) cfg.defaultModel = entry.model; + return new DeepSeekProvider(cfg); } case 'vllm': { - // vLLM uses OpenAI-compatible API - if (!config.url) { - process.stderr.write('Warning: vLLM URL not configured. Run "mcpctl config setup" to configure.\n'); - return registry; + if (!entry.url) { + process.stderr.write(`Warning: vLLM URL not configured for provider "${entry.name}". Run "mcpctl config setup" to configure.\n`); + return null; } - registry.register(new OpenAiProvider({ + return new OpenAiProvider({ apiKey: 'unused', - baseUrl: config.url, - defaultModel: config.model ?? 'default', - })); - break; + baseUrl: entry.url, + defaultModel: entry.model ?? 'default', + }); + } + + default: + return null; + } +} + +/** + * Create a ProviderRegistry from multi-provider config entries + secret store. + * Registers each provider, wraps with NamedProvider if needed, assigns tiers. + */ +export async function createProvidersFromConfig( + entries: LlmProviderFileEntry[], + secretStore: SecretStore, +): Promise { + const registry = new ProviderRegistry(); + + for (const entry of entries) { + const rawProvider = await createSingleProvider(entry, secretStore); + if (!rawProvider) continue; + + // Wrap with NamedProvider if user name differs from provider's built-in name + const provider = rawProvider.name !== entry.name + ? new NamedProvider(entry.name, rawProvider) + : rawProvider; + + registry.register(provider); + + if (entry.tier) { + registry.assignTier(provider.name, entry.tier); } } return registry; } + +/** + * Create a ProviderRegistry from legacy single-provider config + secret store. + * @deprecated Use createProvidersFromConfig() with loadLlmProviders() instead. + */ +export async function createProviderFromConfig( + config: LlmFileConfig | undefined, + secretStore: SecretStore, +): Promise { + if (!config?.provider || config.provider === 'none') { + return new ProviderRegistry(); + } + + const entry: LlmProviderFileEntry = { + name: config.provider, + type: config.provider, + }; + if (config.model) entry.model = config.model; + if (config.url) entry.url = config.url; + if (config.binaryPath) entry.binaryPath = config.binaryPath; + + return createProvidersFromConfig([entry], secretStore); +} diff --git a/src/mcplocal/src/llm/pagination.ts b/src/mcplocal/src/llm/pagination.ts index 5b6985b..ac6d553 100644 --- a/src/mcplocal/src/llm/pagination.ts +++ b/src/mcplocal/src/llm/pagination.ts @@ -242,7 +242,7 @@ export class ResponsePaginator { raw: string, pages: PageInfo[], ): Promise { - const provider = this.providers?.getActive(); + const provider = this.providers?.getProvider('fast'); if (!provider) { return this.generateSimpleIndex(resultId, toolName, raw, pages); } diff --git a/src/mcplocal/src/llm/processor.ts b/src/mcplocal/src/llm/processor.ts index 7c215a5..8faf220 100644 --- a/src/mcplocal/src/llm/processor.ts +++ b/src/mcplocal/src/llm/processor.ts @@ -106,7 +106,7 @@ export class LlmProcessor { return { optimized: false, params }; } - const provider = this.providers.getActive(); + const provider = this.providers.getProvider('fast'); if (!provider) { return { optimized: false, params }; } @@ -142,7 +142,7 @@ export class LlmProcessor { return { filtered: false, result: response.result, originalSize: raw.length, filteredSize: raw.length }; } - const provider = this.providers.getActive(); + const provider = this.providers.getProvider('fast'); if (!provider) { const raw = JSON.stringify(response.result); return { filtered: false, result: response.result, originalSize: raw.length, filteredSize: raw.length }; diff --git a/src/mcplocal/src/main.ts b/src/mcplocal/src/main.ts index 4af65d1..0e0f303 100644 --- a/src/mcplocal/src/main.ts +++ b/src/mcplocal/src/main.ts @@ -7,9 +7,9 @@ import { StdioProxyServer } from './server.js'; import { StdioUpstream } from './upstream/stdio.js'; import { HttpUpstream } from './upstream/http.js'; import { createHttpServer } from './http/server.js'; -import { loadHttpConfig, loadLlmConfig } from './http/config.js'; +import { loadHttpConfig, loadLlmProviders } from './http/config.js'; import type { HttpConfig } from './http/config.js'; -import { createProviderFromConfig } from './llm-config.js'; +import { createProvidersFromConfig } from './llm-config.js'; import { createSecretStore } from '@mcpctl/shared'; import type { ProviderRegistry } from './providers/registry.js'; @@ -65,13 +65,19 @@ export async function main(argv: string[] = process.argv): Promise { const args = parseArgs(argv); const httpConfig = loadHttpConfig(); - // Load LLM provider from user config + secret store - const llmConfig = loadLlmConfig(); + // Load LLM providers from user config + secret store + const llmEntries = loadLlmProviders(); const secretStore = await createSecretStore(); - const providerRegistry = await createProviderFromConfig(llmConfig, secretStore); - const activeLlm = providerRegistry.getActive(); - if (activeLlm) { - process.stderr.write(`LLM provider: ${activeLlm.name}\n`); + const providerRegistry = await createProvidersFromConfig(llmEntries, secretStore); + if (providerRegistry.hasTierConfig()) { + const fast = providerRegistry.getTierProviders('fast'); + const heavy = providerRegistry.getTierProviders('heavy'); + process.stderr.write(`LLM providers: fast=[${fast.join(',')}] heavy=[${heavy.join(',')}]\n`); + } else { + const activeLlm = providerRegistry.getActive(); + if (activeLlm) { + process.stderr.write(`LLM provider: ${activeLlm.name}\n`); + } } let upstreamConfigs: UpstreamConfig[] = []; diff --git a/src/mcplocal/src/providers/registry.ts b/src/mcplocal/src/providers/registry.ts index f74d051..7c94d91 100644 --- a/src/mcplocal/src/providers/registry.ts +++ b/src/mcplocal/src/providers/registry.ts @@ -1,11 +1,13 @@ -import type { LlmProvider } from './types.js'; +import type { LlmProvider, Tier } from './types.js'; /** - * Registry for LLM providers. Supports switching the active provider at runtime. + * Registry for LLM providers. Supports tier-based routing (fast/heavy) + * with cross-tier fallback, and legacy single-provider mode. */ export class ProviderRegistry { private providers = new Map(); private activeProvider: string | null = null; + private tierProviders = new Map(); register(provider: LlmProvider): void { this.providers.set(provider.name, provider); @@ -20,6 +22,15 @@ export class ProviderRegistry { const first = this.providers.keys().next(); this.activeProvider = first.done ? null : first.value; } + // Remove from tier assignments + for (const [tier, names] of this.tierProviders) { + const filtered = names.filter((n) => n !== name); + if (filtered.length === 0) { + this.tierProviders.delete(tier); + } else { + this.tierProviders.set(tier, filtered); + } + } } setActive(name: string): void { @@ -34,6 +45,42 @@ export class ProviderRegistry { return this.providers.get(this.activeProvider) ?? null; } + /** Assign a provider to a tier. Call order = priority within the tier. */ + assignTier(providerName: string, tier: Tier): void { + if (!this.providers.has(providerName)) { + throw new Error(`Provider '${providerName}' is not registered`); + } + const existing = this.tierProviders.get(tier) ?? []; + if (!existing.includes(providerName)) { + this.tierProviders.set(tier, [...existing, providerName]); + } + } + + /** + * Get provider for a specific tier with fallback. + * Resolution: requested tier → other tier → getActive() (legacy). + */ + getProvider(tier: Tier): LlmProvider | null { + const primary = this.firstInTier(tier); + if (primary) return primary; + + const otherTier: Tier = tier === 'fast' ? 'heavy' : 'fast'; + const fallback = this.firstInTier(otherTier); + if (fallback) return fallback; + + return this.getActive(); + } + + /** Get provider names assigned to a tier. */ + getTierProviders(tier: Tier): string[] { + return this.tierProviders.get(tier) ?? []; + } + + /** Whether any tier assignments exist (vs legacy single-provider mode). */ + hasTierConfig(): boolean { + return this.tierProviders.size > 0; + } + get(name: string): LlmProvider | undefined { return this.providers.get(name); } @@ -46,10 +93,31 @@ export class ProviderRegistry { return this.activeProvider; } + /** Provider info for status display. */ + listProviders(): Array<{ name: string; tiers: Tier[] }> { + return this.list().map((name) => { + const tiers: Tier[] = []; + for (const [tier, names] of this.tierProviders) { + if (names.includes(name)) tiers.push(tier); + } + return { name, tiers }; + }); + } + /** Dispose all registered providers that have a dispose method. */ disposeAll(): void { for (const provider of this.providers.values()) { provider.dispose?.(); } } + + private firstInTier(tier: Tier): LlmProvider | null { + const names = this.tierProviders.get(tier); + if (!names) return null; + for (const name of names) { + const provider = this.providers.get(name); + if (provider) return provider; + } + return null; + } } diff --git a/src/mcplocal/src/providers/types.ts b/src/mcplocal/src/providers/types.ts index e6b3415..43c885a 100644 --- a/src/mcplocal/src/providers/types.ts +++ b/src/mcplocal/src/providers/types.ts @@ -44,6 +44,9 @@ export interface CompletionOptions { model?: string; } +/** LLM provider tier. 'fast' = local inference, 'heavy' = cloud reasoning. */ +export type Tier = 'fast' | 'heavy'; + export interface LlmProvider { /** Provider identifier (e.g., 'openai', 'anthropic', 'ollama') */ readonly name: string; diff --git a/src/mcplocal/tests/llm-config.test.ts b/src/mcplocal/tests/llm-config.test.ts index cf888a5..9ec9722 100644 --- a/src/mcplocal/tests/llm-config.test.ts +++ b/src/mcplocal/tests/llm-config.test.ts @@ -116,9 +116,9 @@ describe('createProviderFromConfig', () => { { provider: 'vllm', model: 'my-model', url: 'http://gpu-server:8000' }, store, ); - // vLLM reuses OpenAI provider under the hood + // vLLM reuses OpenAI provider under the hood, wrapped with NamedProvider expect(registry.getActive()).not.toBeNull(); - expect(registry.getActive()!.name).toBe('openai'); + expect(registry.getActive()!.name).toBe('vllm'); }); it('returns empty registry when vllm URL is missing', async () => { diff --git a/src/mcplocal/tests/pagination.test.ts b/src/mcplocal/tests/pagination.test.ts index 7ca2e6b..ed3474f 100644 --- a/src/mcplocal/tests/pagination.test.ts +++ b/src/mcplocal/tests/pagination.test.ts @@ -11,6 +11,7 @@ function makeProvider(response: string): ProviderRegistry { }; return { getActive: () => provider, + getProvider: () => provider, register: vi.fn(), setActive: vi.fn(), listProviders: () => [{ name: 'test', available: true, active: true }], @@ -177,6 +178,7 @@ describe('ResponsePaginator', () => { }; const registry = { getActive: () => provider, + getProvider: () => provider, register: vi.fn(), setActive: vi.fn(), listProviders: () => [{ name: 'test', available: true, active: true }], @@ -208,6 +210,7 @@ describe('ResponsePaginator', () => { }; const registry = { getActive: () => provider, + getProvider: () => provider, register: vi.fn(), setActive: vi.fn(), listProviders: () => [{ name: 'test', available: true, active: true }], @@ -231,6 +234,7 @@ describe('ResponsePaginator', () => { it('falls back to simple when no active provider', async () => { const registry = { getActive: () => null, + getProvider: () => null, register: vi.fn(), setActive: vi.fn(), listProviders: () => [], @@ -256,6 +260,7 @@ describe('ResponsePaginator', () => { }; const registry = { getActive: () => provider, + getProvider: () => provider, register: vi.fn(), setActive: vi.fn(), listProviders: () => [{ name: 'test', available: true, active: true }], @@ -281,6 +286,7 @@ describe('ResponsePaginator', () => { }; const registry = { getActive: () => provider, + getProvider: () => provider, register: vi.fn(), setActive: vi.fn(), listProviders: () => [{ name: 'test', available: true, active: true }], diff --git a/src/mcplocal/tests/providers.test.ts b/src/mcplocal/tests/providers.test.ts index 66bf520..53e22f8 100644 --- a/src/mcplocal/tests/providers.test.ts +++ b/src/mcplocal/tests/providers.test.ts @@ -115,4 +115,105 @@ describe('ProviderRegistry', () => { expect(models).toEqual(['anthropic-model-1', 'anthropic-model-2']); }); + + describe('tier management', () => { + it('assigns providers to tiers', () => { + registry.register(mockProvider('vllm')); + registry.register(mockProvider('gemini')); + + registry.assignTier('vllm', 'fast'); + registry.assignTier('gemini', 'heavy'); + + expect(registry.getTierProviders('fast')).toEqual(['vllm']); + expect(registry.getTierProviders('heavy')).toEqual(['gemini']); + expect(registry.hasTierConfig()).toBe(true); + }); + + it('getProvider returns tier-specific provider', () => { + const vllm = mockProvider('vllm'); + const gemini = mockProvider('gemini'); + registry.register(vllm); + registry.register(gemini); + + registry.assignTier('vllm', 'fast'); + registry.assignTier('gemini', 'heavy'); + + expect(registry.getProvider('fast')).toBe(vllm); + expect(registry.getProvider('heavy')).toBe(gemini); + }); + + it('getProvider falls back to other tier', () => { + const vllm = mockProvider('vllm'); + registry.register(vllm); + + registry.assignTier('vllm', 'fast'); + + // Requesting heavy but only fast exists → falls back to fast + expect(registry.getProvider('heavy')).toBe(vllm); + }); + + it('getProvider falls back to getActive when no tiers', () => { + const openai = mockProvider('openai'); + registry.register(openai); + + // No tier assignments → falls back to legacy getActive() + expect(registry.getProvider('fast')).toBe(openai); + expect(registry.getProvider('heavy')).toBe(openai); + expect(registry.hasTierConfig()).toBe(false); + }); + + it('unregister removes from tier assignments', () => { + registry.register(mockProvider('vllm')); + registry.register(mockProvider('gemini')); + + registry.assignTier('vllm', 'fast'); + registry.assignTier('gemini', 'heavy'); + + registry.unregister('vllm'); + + expect(registry.getTierProviders('fast')).toEqual([]); + expect(registry.getTierProviders('heavy')).toEqual(['gemini']); + }); + + it('assignTier throws for unregistered provider', () => { + expect(() => registry.assignTier('unknown', 'fast')).toThrow("Provider 'unknown' is not registered"); + }); + + it('multiple providers in same tier uses first', () => { + const vllm = mockProvider('vllm'); + const ollama = mockProvider('ollama'); + registry.register(vllm); + registry.register(ollama); + + registry.assignTier('vllm', 'fast'); + registry.assignTier('ollama', 'fast'); + + expect(registry.getProvider('fast')).toBe(vllm); + expect(registry.getTierProviders('fast')).toEqual(['vllm', 'ollama']); + }); + + it('listProviders includes tier info', () => { + registry.register(mockProvider('vllm')); + registry.register(mockProvider('gemini')); + + registry.assignTier('vllm', 'fast'); + registry.assignTier('gemini', 'heavy'); + + const providers = registry.listProviders(); + expect(providers).toEqual([ + { name: 'vllm', tiers: ['fast'] }, + { name: 'gemini', tiers: ['heavy'] }, + ]); + }); + + it('disposeAll calls dispose on all providers', () => { + const disposeFn = vi.fn(); + const provider = { ...mockProvider('test'), dispose: disposeFn }; + registry.register(provider); + + registry.disposeAll(); + + expect(disposeFn).toHaveBeenCalledOnce(); + }); + }); });