feat: tiered LLM providers (fast/heavy) with multi-provider config
Adds tier-based LLM routing so fast local models (vLLM, Ollama) handle
structured tasks while cloud models (Gemini, Anthropic) are reserved for
heavy reasoning. Single-provider configs continue to work via fallback.
- Tier type + ProviderRegistry with assignTier/getProvider/fallback chain
- Multi-provider config format: { providers: [{ name, type, tier, ... }] }
- NamedProvider wrapper for multiple instances of same provider type
- Setup wizard: Simple (legacy) / Advanced (fast+heavy tiers) modes
- Status display: tiered view with /llm/providers endpoint
- Call sites use getProvider('fast') instead of getActive()
- Full backward compatibility with existing single-provider configs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Per-provider setup functions (return ProviderFields for reuse in both modes) ---
|
||||
|
||||
async function setupGeminiCliFields(
|
||||
prompt: ConfigSetupPrompt,
|
||||
log: (...args: string[]) => void,
|
||||
whichBinary: (name: string) => Promise<string | null>,
|
||||
currentModel?: string,
|
||||
): Promise<ProviderFields> {
|
||||
const model = await prompt.select<string>('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<ProviderFields> {
|
||||
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<string>('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<ProviderFields> {
|
||||
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<string>('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<ProviderFields> {
|
||||
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<string>('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<string | null>,
|
||||
secretStore: SecretStore,
|
||||
): Promise<ProviderFields> {
|
||||
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<ConfigLoaderDeps>,
|
||||
prompt: ConfigSetupPrompt,
|
||||
log: (...args: string[]) => void,
|
||||
fetchModels: ConfigSetupDeps['fetchModels'],
|
||||
whichBinary: (name: string) => Promise<string | null>,
|
||||
secretStore: SecretStore,
|
||||
): Promise<void> {
|
||||
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<LlmProviderName>('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<ConfigLoaderDeps>,
|
||||
prompt: ConfigSetupPrompt,
|
||||
log: (...args: string[]) => void,
|
||||
fetchModels: ConfigSetupDeps['fetchModels'],
|
||||
whichBinary: (name: string) => Promise<string | null>,
|
||||
secretStore: SecretStore,
|
||||
): Promise<void> {
|
||||
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<LlmProviderName>('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<LlmProviderName>('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<ConfigSetupDeps>): Command {
|
||||
return new Command('setup')
|
||||
.description('Interactive LLM provider setup wizard')
|
||||
@@ -157,191 +449,16 @@ export function createConfigSetupCommand(deps?: Partial<ConfigSetupDeps>): 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<LlmProviderName>('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<string | null>,
|
||||
current?: LlmConfig,
|
||||
): Promise<LlmConfig> {
|
||||
const model = await prompt.select<string>('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<LlmConfig> {
|
||||
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<string>('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<LlmConfig> {
|
||||
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<string>('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<LlmConfig> {
|
||||
// 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<string>('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 };
|
||||
}
|
||||
|
||||
@@ -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<ConfigLoaderDeps>;
|
||||
credentialsDeps: Partial<CredentialsDeps>;
|
||||
@@ -24,6 +29,8 @@ export interface StatusCommandDeps {
|
||||
checkLlm: (mcplocalUrl: string) => Promise<string>;
|
||||
/** Fetch available models from mcplocal's /llm/models endpoint */
|
||||
fetchModels: (mcplocalUrl: string) => Promise<string[]>;
|
||||
/** Fetch provider tier info from mcplocal's /llm/providers endpoint */
|
||||
fetchProviders: (mcplocalUrl: string) => Promise<ProvidersInfo | null>;
|
||||
isTTY: boolean;
|
||||
}
|
||||
|
||||
@@ -91,6 +98,25 @@ function defaultFetchModels(mcplocalUrl: string): Promise<string[]> {
|
||||
});
|
||||
}
|
||||
|
||||
function defaultFetchProviders(mcplocalUrl: string): Promise<ProvidersInfo | null> {
|
||||
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<StatusCommandDeps>): 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<StatusCommandDeps>): 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<StatusCommandDeps>): 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<StatusCommandDeps>): 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}`);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<typeof LlmConfigSchema>;
|
||||
|
||||
/** 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<typeof LlmProviderEntrySchema>;
|
||||
|
||||
/** Multi-provider format with providers array. */
|
||||
export const LlmMultiConfigSchema = z.object({
|
||||
providers: z.array(LlmProviderEntrySchema).min(1),
|
||||
}).strict();
|
||||
|
||||
export type LlmMultiConfig = z.infer<typeof LlmMultiConfigSchema>;
|
||||
|
||||
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
|
||||
|
||||
@@ -67,7 +67,7 @@ async function runSetup(deps: ConfigSetupDeps): Promise<void> {
|
||||
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);
|
||||
|
||||
@@ -26,6 +26,7 @@ function baseDeps(overrides?: Partial<StatusCommandDeps>): Partial<StatusCommand
|
||||
log,
|
||||
write,
|
||||
checkHealth: async () => true,
|
||||
fetchProviders: async () => null,
|
||||
isTTY: false,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user