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 { execFile } from 'node:child_process';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { loadConfig, saveConfig } from '../config/index.js';
|
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 type { SecretStore } from '@mcpctl/shared';
|
||||||
import { createSecretStore } from '@mcpctl/shared';
|
import { createSecretStore } from '@mcpctl/shared';
|
||||||
|
|
||||||
@@ -32,13 +32,28 @@ interface ProviderChoice {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROVIDER_CHOICES: ProviderChoice[] = [
|
/** Provider config fields returned by per-provider setup functions. */
|
||||||
{ name: 'Gemini CLI', value: 'gemini-cli', description: 'Google Gemini via local CLI (free, no API key)' },
|
interface ProviderFields {
|
||||||
{ name: 'Ollama', value: 'ollama', description: 'Local models via Ollama' },
|
model?: string;
|
||||||
{ name: 'Anthropic (Claude)', value: 'anthropic', description: 'Claude API (requires API key)' },
|
url?: string;
|
||||||
|
binaryPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAST_PROVIDER_CHOICES: ProviderChoice[] = [
|
||||||
{ name: 'vLLM', value: 'vllm', description: 'Self-hosted vLLM (OpenAI-compatible)' },
|
{ 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: 'OpenAI', value: 'openai', description: 'OpenAI API (requires API key)' },
|
||||||
{ name: 'DeepSeek', value: 'deepseek', description: 'DeepSeek 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' },
|
{ 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 {
|
export function createConfigSetupCommand(deps?: Partial<ConfigSetupDeps>): Command {
|
||||||
return new Command('setup')
|
return new Command('setup')
|
||||||
.description('Interactive LLM provider setup wizard')
|
.description('Interactive LLM provider setup wizard')
|
||||||
@@ -157,191 +449,16 @@ export function createConfigSetupCommand(deps?: Partial<ConfigSetupDeps>): Comma
|
|||||||
const secretStore = deps?.secretStore ?? await createSecretStore();
|
const secretStore = deps?.secretStore ?? await createSecretStore();
|
||||||
|
|
||||||
const config = loadConfig(configDeps);
|
const config = loadConfig(configDeps);
|
||||||
const currentLlm = config.llm;
|
|
||||||
|
|
||||||
// Annotate current provider in choices
|
const mode = await prompt.select<'simple' | 'advanced'>('Setup mode:', [
|
||||||
const choices = PROVIDER_CHOICES.map((c) => {
|
{ name: 'Simple', value: 'simple', description: 'One provider for everything' },
|
||||||
if (currentLlm?.provider === c.value) {
|
{ name: 'Advanced', value: 'advanced', description: 'Multiple providers with fast/heavy tiers' },
|
||||||
return { ...c, name: `${c.name} (current)` };
|
]);
|
||||||
}
|
|
||||||
return c;
|
|
||||||
});
|
|
||||||
|
|
||||||
const provider = await prompt.select<LlmProviderName>('Select LLM provider:', choices);
|
if (mode === 'simple') {
|
||||||
|
await simpleSetup(config, configDeps, prompt, log, fetchModels, whichBinary, secretStore);
|
||||||
if (provider === 'none') {
|
} else {
|
||||||
const updated: McpctlConfig = { ...config, llm: { provider: 'none' } };
|
await advancedSetup(config, configDeps, prompt, log, fetchModels, whichBinary, secretStore);
|
||||||
saveConfig(updated, configDeps);
|
|
||||||
log('LLM disabled. Restart mcplocal: systemctl --user restart mcplocal');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 RESET = '\x1b[0m';
|
||||||
const CLEAR_LINE = '\x1b[2K\r';
|
const CLEAR_LINE = '\x1b[2K\r';
|
||||||
|
|
||||||
|
interface ProvidersInfo {
|
||||||
|
providers: string[];
|
||||||
|
tiers: { fast: string[]; heavy: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
export interface StatusCommandDeps {
|
export interface StatusCommandDeps {
|
||||||
configDeps: Partial<ConfigLoaderDeps>;
|
configDeps: Partial<ConfigLoaderDeps>;
|
||||||
credentialsDeps: Partial<CredentialsDeps>;
|
credentialsDeps: Partial<CredentialsDeps>;
|
||||||
@@ -24,6 +29,8 @@ export interface StatusCommandDeps {
|
|||||||
checkLlm: (mcplocalUrl: string) => Promise<string>;
|
checkLlm: (mcplocalUrl: string) => Promise<string>;
|
||||||
/** Fetch available models from mcplocal's /llm/models endpoint */
|
/** Fetch available models from mcplocal's /llm/models endpoint */
|
||||||
fetchModels: (mcplocalUrl: string) => Promise<string[]>;
|
fetchModels: (mcplocalUrl: string) => Promise<string[]>;
|
||||||
|
/** Fetch provider tier info from mcplocal's /llm/providers endpoint */
|
||||||
|
fetchProviders: (mcplocalUrl: string) => Promise<ProvidersInfo | null>;
|
||||||
isTTY: boolean;
|
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 SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||||
|
|
||||||
const defaultDeps: StatusCommandDeps = {
|
const defaultDeps: StatusCommandDeps = {
|
||||||
@@ -101,11 +127,35 @@ const defaultDeps: StatusCommandDeps = {
|
|||||||
checkHealth: defaultCheckHealth,
|
checkHealth: defaultCheckHealth,
|
||||||
checkLlm: defaultCheckLlm,
|
checkLlm: defaultCheckLlm,
|
||||||
fetchModels: defaultFetchModels,
|
fetchModels: defaultFetchModels,
|
||||||
|
fetchProviders: defaultFetchProviders,
|
||||||
isTTY: process.stdout.isTTY ?? false,
|
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 {
|
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')
|
return new Command('status')
|
||||||
.description('Show mcpctl status and connectivity')
|
.description('Show mcpctl status and connectivity')
|
||||||
@@ -114,16 +164,16 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
|
|||||||
const config = loadConfig(configDeps);
|
const config = loadConfig(configDeps);
|
||||||
const creds = loadCredentials(credentialsDeps);
|
const creds = loadCredentials(credentialsDeps);
|
||||||
|
|
||||||
const llmLabel = config.llm && config.llm.provider !== 'none'
|
const llmLabel = getLlmLabel(config.llm);
|
||||||
? `${config.llm.provider}${config.llm.model ? ` / ${config.llm.model}` : ''}`
|
const multiProvider = isMultiProvider(config.llm);
|
||||||
: null;
|
|
||||||
|
|
||||||
if (opts.output !== 'table') {
|
if (opts.output !== 'table') {
|
||||||
// JSON/YAML: run everything in parallel, wait, output at once
|
// 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.mcplocalUrl),
|
||||||
checkHealth(config.mcpdUrl),
|
checkHealth(config.mcpdUrl),
|
||||||
llmLabel ? checkLlm(config.mcplocalUrl) : Promise.resolve(null),
|
llmLabel ? checkLlm(config.mcplocalUrl) : Promise.resolve(null),
|
||||||
|
multiProvider ? fetchProviders(config.mcplocalUrl) : Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const llm = llmLabel
|
const llm = llmLabel
|
||||||
@@ -141,6 +191,7 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
|
|||||||
outputFormat: config.outputFormat,
|
outputFormat: config.outputFormat,
|
||||||
llm,
|
llm,
|
||||||
llmStatus,
|
llmStatus,
|
||||||
|
...(providersInfo ? { providers: providersInfo } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
log(opts.output === 'json' ? formatJson(status) : formatYaml(status));
|
log(opts.output === 'json' ? formatJson(status) : formatYaml(status));
|
||||||
@@ -167,35 +218,58 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// LLM check + models fetch in parallel — queries mcplocal endpoints
|
// LLM check + models + providers fetch in parallel
|
||||||
const llmPromise = checkLlm(config.mcplocalUrl);
|
const llmPromise = checkLlm(config.mcplocalUrl);
|
||||||
const modelsPromise = fetchModels(config.mcplocalUrl);
|
const modelsPromise = fetchModels(config.mcplocalUrl);
|
||||||
|
const providersPromise = multiProvider ? fetchProviders(config.mcplocalUrl) : Promise.resolve(null);
|
||||||
|
|
||||||
if (isTTY) {
|
if (isTTY) {
|
||||||
let frame = 0;
|
let frame = 0;
|
||||||
const interval = setInterval(() => {
|
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++;
|
frame++;
|
||||||
}, 80);
|
}, 80);
|
||||||
|
|
||||||
const [llmStatus, models] = await Promise.all([llmPromise, modelsPromise]);
|
const [llmStatus, models, providersInfo] = await Promise.all([llmPromise, modelsPromise, providersPromise]);
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
|
|
||||||
if (llmStatus === 'ok' || llmStatus === 'ok (key stored)') {
|
if (providersInfo && (providersInfo.tiers.fast.length > 0 || providersInfo.tiers.heavy.length > 0)) {
|
||||||
write(`${CLEAR_LINE}LLM: ${llmLabel} ${GREEN}✓ ${llmStatus}${RESET}\n`);
|
// 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 {
|
} 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) {
|
if (models.length > 0) {
|
||||||
log(`${DIM} Available: ${models.join(', ')}${RESET}`);
|
log(`${DIM} Available: ${models.join(', ')}${RESET}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Non-TTY: no spinner, just wait and print
|
// Non-TTY: no spinner, just wait and print
|
||||||
const [llmStatus, models] = await Promise.all([llmPromise, modelsPromise]);
|
const [llmStatus, models, providersInfo] = await Promise.all([llmPromise, modelsPromise, providersPromise]);
|
||||||
if (llmStatus === 'ok' || llmStatus === 'ok (key stored)') {
|
|
||||||
log(`LLM: ${llmLabel} ✓ ${llmStatus}`);
|
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 {
|
} 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) {
|
if (models.length > 0) {
|
||||||
log(`${DIM} Available: ${models.join(', ')}${RESET}`);
|
log(`${DIM} Available: ${models.join(', ')}${RESET}`);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export { McpctlConfigSchema, LlmConfigSchema, LLM_PROVIDERS, DEFAULT_CONFIG } from './schema.js';
|
export { McpctlConfigSchema, LlmConfigSchema, LlmProviderEntrySchema, LlmMultiConfigSchema, LLM_PROVIDERS, LLM_TIERS, DEFAULT_CONFIG } from './schema.js';
|
||||||
export type { McpctlConfig, LlmConfig, LlmProviderName } from './schema.js';
|
export type { McpctlConfig, LlmConfig, LlmProviderEntry, LlmMultiConfig, LlmProviderName, LlmTier } from './schema.js';
|
||||||
export { loadConfig, saveConfig, mergeConfig, getConfigPath } from './loader.js';
|
export { loadConfig, saveConfig, mergeConfig, getConfigPath } from './loader.js';
|
||||||
export type { ConfigLoaderDeps } 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 const LLM_PROVIDERS = ['gemini-cli', 'ollama', 'anthropic', 'openai', 'deepseek', 'vllm', 'none'] as const;
|
||||||
export type LlmProviderName = typeof LLM_PROVIDERS[number];
|
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({
|
export const LlmConfigSchema = z.object({
|
||||||
/** LLM provider name */
|
/** LLM provider name */
|
||||||
provider: z.enum(LLM_PROVIDERS),
|
provider: z.enum(LLM_PROVIDERS),
|
||||||
@@ -16,6 +20,31 @@ export const LlmConfigSchema = z.object({
|
|||||||
|
|
||||||
export type LlmConfig = z.infer<typeof LlmConfigSchema>;
|
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({
|
export const McpctlConfigSchema = z.object({
|
||||||
/** mcplocal daemon endpoint (local LLM pre-processing proxy) */
|
/** mcplocal daemon endpoint (local LLM pre-processing proxy) */
|
||||||
mcplocalUrl: z.string().default('http://localhost:3200'),
|
mcplocalUrl: z.string().default('http://localhost:3200'),
|
||||||
@@ -35,8 +64,8 @@ export const McpctlConfigSchema = z.object({
|
|||||||
outputFormat: z.enum(['table', 'json', 'yaml']).default('table'),
|
outputFormat: z.enum(['table', 'json', 'yaml']).default('table'),
|
||||||
/** Smithery API key */
|
/** Smithery API key */
|
||||||
smitheryApiKey: z.string().optional(),
|
smitheryApiKey: z.string().optional(),
|
||||||
/** LLM provider configuration for smart features (pagination summaries, etc.) */
|
/** LLM provider configuration — accepts legacy single-provider or multi-provider format */
|
||||||
llm: LlmConfigSchema.optional(),
|
llm: z.union([LlmConfigSchema, LlmMultiConfigSchema]).optional(),
|
||||||
}).transform((cfg) => {
|
}).transform((cfg) => {
|
||||||
// Backward compatibility: if old daemonUrl is set but mcplocalUrl wasn't explicitly changed,
|
// Backward compatibility: if old daemonUrl is set but mcplocalUrl wasn't explicitly changed,
|
||||||
// use daemonUrl as mcplocalUrl
|
// use daemonUrl as mcplocalUrl
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ async function runSetup(deps: ConfigSetupDeps): Promise<void> {
|
|||||||
describe('config setup wizard', () => {
|
describe('config setup wizard', () => {
|
||||||
describe('provider: none', () => {
|
describe('provider: none', () => {
|
||||||
it('disables LLM and saves config', async () => {
|
it('disables LLM and saves config', async () => {
|
||||||
const deps = buildDeps({ answers: ['none'] });
|
const deps = buildDeps({ answers: ['simple', 'none'] });
|
||||||
await runSetup(deps);
|
await runSetup(deps);
|
||||||
|
|
||||||
const config = readConfig();
|
const config = readConfig();
|
||||||
@@ -81,7 +81,7 @@ describe('config setup wizard', () => {
|
|||||||
it('auto-detects binary path and saves config', async () => {
|
it('auto-detects binary path and saves config', async () => {
|
||||||
// Answers: select provider, select model (no binary prompt — auto-detected)
|
// Answers: select provider, select model (no binary prompt — auto-detected)
|
||||||
const deps = buildDeps({
|
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'),
|
whichBinary: vi.fn(async () => '/home/user/.npm-global/bin/gemini'),
|
||||||
});
|
});
|
||||||
await runSetup(deps);
|
await runSetup(deps);
|
||||||
@@ -98,7 +98,7 @@ describe('config setup wizard', () => {
|
|||||||
it('prompts for manual path when binary not found', async () => {
|
it('prompts for manual path when binary not found', async () => {
|
||||||
// Answers: select provider, select model, enter manual path
|
// Answers: select provider, select model, enter manual path
|
||||||
const deps = buildDeps({
|
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),
|
whichBinary: vi.fn(async () => null),
|
||||||
});
|
});
|
||||||
await runSetup(deps);
|
await runSetup(deps);
|
||||||
@@ -113,7 +113,7 @@ describe('config setup wizard', () => {
|
|||||||
it('saves gemini-cli with custom model', async () => {
|
it('saves gemini-cli with custom model', async () => {
|
||||||
// Answers: select provider, select custom, enter model name
|
// Answers: select provider, select custom, enter model name
|
||||||
const deps = buildDeps({
|
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'),
|
whichBinary: vi.fn(async () => '/usr/bin/gemini'),
|
||||||
});
|
});
|
||||||
await runSetup(deps);
|
await runSetup(deps);
|
||||||
@@ -130,7 +130,7 @@ describe('config setup wizard', () => {
|
|||||||
const fetchModels = vi.fn(async () => ['llama3.2', 'codellama', 'mistral']);
|
const fetchModels = vi.fn(async () => ['llama3.2', 'codellama', 'mistral']);
|
||||||
// Answers: select provider, enter URL, select model
|
// Answers: select provider, enter URL, select model
|
||||||
const deps = buildDeps({
|
const deps = buildDeps({
|
||||||
answers: ['ollama', 'http://localhost:11434', 'codellama'],
|
answers: ['simple', 'ollama', 'http://localhost:11434', 'codellama'],
|
||||||
fetchModels,
|
fetchModels,
|
||||||
});
|
});
|
||||||
await runSetup(deps);
|
await runSetup(deps);
|
||||||
@@ -148,7 +148,7 @@ describe('config setup wizard', () => {
|
|||||||
const fetchModels = vi.fn(async () => []);
|
const fetchModels = vi.fn(async () => []);
|
||||||
// Answers: select provider, enter URL, enter model manually
|
// Answers: select provider, enter URL, enter model manually
|
||||||
const deps = buildDeps({
|
const deps = buildDeps({
|
||||||
answers: ['ollama', 'http://localhost:11434', 'llama3.2'],
|
answers: ['simple', 'ollama', 'http://localhost:11434', 'llama3.2'],
|
||||||
fetchModels,
|
fetchModels,
|
||||||
});
|
});
|
||||||
await runSetup(deps);
|
await runSetup(deps);
|
||||||
@@ -163,7 +163,7 @@ describe('config setup wizard', () => {
|
|||||||
it('prompts for API key and saves to secret store', async () => {
|
it('prompts for API key and saves to secret store', async () => {
|
||||||
// Answers: select provider, enter API key, select model
|
// Answers: select provider, enter API key, select model
|
||||||
const deps = buildDeps({
|
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);
|
await runSetup(deps);
|
||||||
|
|
||||||
@@ -181,7 +181,7 @@ describe('config setup wizard', () => {
|
|||||||
// Answers: select provider, confirm change=false, select model
|
// Answers: select provider, confirm change=false, select model
|
||||||
const deps = buildDeps({
|
const deps = buildDeps({
|
||||||
secrets: { 'anthropic-api-key': 'sk-ant-existing-key-1234' },
|
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);
|
await runSetup(deps);
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@ describe('config setup wizard', () => {
|
|||||||
// Answers: select provider, confirm change=true, enter new key, select model
|
// Answers: select provider, confirm change=true, enter new key, select model
|
||||||
const deps = buildDeps({
|
const deps = buildDeps({
|
||||||
secrets: { 'anthropic-api-key': 'sk-ant-old' },
|
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);
|
await runSetup(deps);
|
||||||
|
|
||||||
@@ -210,7 +210,7 @@ describe('config setup wizard', () => {
|
|||||||
const fetchModels = vi.fn(async () => ['my-model', 'llama-70b']);
|
const fetchModels = vi.fn(async () => ['my-model', 'llama-70b']);
|
||||||
// Answers: select provider, enter URL, select model
|
// Answers: select provider, enter URL, select model
|
||||||
const deps = buildDeps({
|
const deps = buildDeps({
|
||||||
answers: ['vllm', 'http://gpu:8000', 'llama-70b'],
|
answers: ['simple', 'vllm', 'http://gpu:8000', 'llama-70b'],
|
||||||
fetchModels,
|
fetchModels,
|
||||||
});
|
});
|
||||||
await runSetup(deps);
|
await runSetup(deps);
|
||||||
@@ -229,7 +229,7 @@ describe('config setup wizard', () => {
|
|||||||
it('prompts for key, model, and optional custom endpoint', async () => {
|
it('prompts for key, model, and optional custom endpoint', async () => {
|
||||||
// Answers: select provider, enter key, enter model, confirm custom URL=true, enter URL
|
// Answers: select provider, enter key, enter model, confirm custom URL=true, enter URL
|
||||||
const deps = buildDeps({
|
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);
|
await runSetup(deps);
|
||||||
|
|
||||||
@@ -245,7 +245,7 @@ describe('config setup wizard', () => {
|
|||||||
it('skips custom URL when not requested', async () => {
|
it('skips custom URL when not requested', async () => {
|
||||||
// Answers: select provider, enter key, enter model, confirm custom URL=false
|
// Answers: select provider, enter key, enter model, confirm custom URL=false
|
||||||
const deps = buildDeps({
|
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);
|
await runSetup(deps);
|
||||||
|
|
||||||
@@ -260,7 +260,7 @@ describe('config setup wizard', () => {
|
|||||||
it('prompts for key and model', async () => {
|
it('prompts for key and model', async () => {
|
||||||
// Answers: select provider, enter key, select model
|
// Answers: select provider, enter key, select model
|
||||||
const deps = buildDeps({
|
const deps = buildDeps({
|
||||||
answers: ['deepseek', 'sk-ds-key', 'deepseek-chat'],
|
answers: ['simple', 'deepseek', 'sk-ds-key', 'deepseek-chat'],
|
||||||
});
|
});
|
||||||
await runSetup(deps);
|
await runSetup(deps);
|
||||||
|
|
||||||
@@ -275,7 +275,7 @@ describe('config setup wizard', () => {
|
|||||||
|
|
||||||
describe('output messages', () => {
|
describe('output messages', () => {
|
||||||
it('shows restart instruction', async () => {
|
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);
|
await runSetup(deps);
|
||||||
|
|
||||||
expect(logs.some((l) => l.includes('systemctl --user restart mcplocal'))).toBe(true);
|
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 () => {
|
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);
|
await runSetup(deps);
|
||||||
|
|
||||||
expect(logs.some((l) => l.includes('gemini-cli') && l.includes('gemini-2.5-flash'))).toBe(true);
|
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,
|
log,
|
||||||
write,
|
write,
|
||||||
checkHealth: async () => true,
|
checkHealth: async () => true,
|
||||||
|
fetchProviders: async () => null,
|
||||||
isTTY: false,
|
isTTY: false,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,13 +44,27 @@ export interface LlmFileConfig {
|
|||||||
binaryPath?: string;
|
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 {
|
export interface ProjectLlmOverride {
|
||||||
model?: string;
|
model?: string;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LlmMultiFileConfig {
|
||||||
|
providers: LlmProviderFileEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
interface McpctlConfig {
|
interface McpctlConfig {
|
||||||
llm?: LlmFileConfig;
|
llm?: LlmFileConfig | LlmMultiFileConfig;
|
||||||
projects?: Record<string, { llm?: ProjectLlmOverride }>;
|
projects?: Record<string, { llm?: ProjectLlmOverride }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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.
|
* Load LLM configuration from ~/.mcpctl/config.json.
|
||||||
* Returns undefined if no LLM section is configured.
|
* Returns undefined if no LLM section is configured.
|
||||||
|
* @deprecated Use loadLlmProviders() for multi-provider support.
|
||||||
*/
|
*/
|
||||||
export function loadLlmConfig(): LlmFileConfig | undefined {
|
export function loadLlmConfig(): LlmFileConfig | undefined {
|
||||||
const config = loadFullConfig();
|
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;
|
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.
|
* Load per-project LLM override from ~/.mcpctl/config.json.
|
||||||
* Returns the project-specific model/provider override, or undefined.
|
* Returns the project-specific model/provider override, or undefined.
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export async function createHttpServer(
|
|||||||
const LLM_HEALTH_CACHE_MS = 10 * 60 * 1000; // 10 minutes
|
const LLM_HEALTH_CACHE_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
app.get('/llm/health', async (_request, reply) => {
|
app.get('/llm/health', async (_request, reply) => {
|
||||||
const provider = deps.providerRegistry?.getActive() ?? null;
|
const provider = deps.providerRegistry?.getProvider('fast') ?? null;
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
reply.code(200).send({ status: 'not configured' });
|
reply.code(200).send({ status: 'not configured' });
|
||||||
return;
|
return;
|
||||||
@@ -127,7 +127,7 @@ export async function createHttpServer(
|
|||||||
|
|
||||||
// LLM models — list available models from the active provider
|
// LLM models — list available models from the active provider
|
||||||
app.get('/llm/models', async (_request, reply) => {
|
app.get('/llm/models', async (_request, reply) => {
|
||||||
const provider = deps.providerRegistry?.getActive() ?? null;
|
const provider = deps.providerRegistry?.getProvider('fast') ?? null;
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
reply.code(200).send({ models: [], provider: null });
|
reply.code(200).send({ models: [], provider: null });
|
||||||
return;
|
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
|
// Proxy management routes to mcpd
|
||||||
const mcpdClient = new McpdClient(config.mcpdUrl, config.mcpdToken);
|
const mcpdClient = new McpdClient(config.mcpdUrl, config.mcpdToken);
|
||||||
registerProxyRoutes(app, mcpdClient);
|
registerProxyRoutes(app, mcpdClient);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { SecretStore } from '@mcpctl/shared';
|
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 { ProviderRegistry } from './providers/registry.js';
|
||||||
import { GeminiAcpProvider } from './providers/gemini-acp.js';
|
import { GeminiAcpProvider } from './providers/gemini-acp.js';
|
||||||
import { OllamaProvider } from './providers/ollama.js';
|
import { OllamaProvider } from './providers/ollama.js';
|
||||||
import { AnthropicProvider } from './providers/anthropic.js';
|
import { AnthropicProvider } from './providers/anthropic.js';
|
||||||
import { OpenAiProvider } from './providers/openai.js';
|
import { OpenAiProvider } from './providers/openai.js';
|
||||||
import { DeepSeekProvider } from './providers/deepseek.js';
|
import { DeepSeekProvider } from './providers/deepseek.js';
|
||||||
|
import type { LlmProvider } from './providers/types.js';
|
||||||
import type { GeminiAcpConfig } from './providers/gemini-acp.js';
|
import type { GeminiAcpConfig } from './providers/gemini-acp.js';
|
||||||
import type { OllamaConfig } from './providers/ollama.js';
|
import type { OllamaConfig } from './providers/ollama.js';
|
||||||
import type { AnthropicConfig } from './providers/anthropic.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';
|
import type { DeepSeekConfig } from './providers/deepseek.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a ProviderRegistry from user config + secret store.
|
* Thin wrapper that delegates all LlmProvider methods but overrides `name`.
|
||||||
* Returns an empty registry if config is undefined or provider is 'none'.
|
* 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(
|
class NamedProvider implements LlmProvider {
|
||||||
config: LlmFileConfig | undefined,
|
readonly name: string;
|
||||||
secretStore: SecretStore,
|
private inner: LlmProvider;
|
||||||
): Promise<ProviderRegistry> {
|
|
||||||
const registry = new ProviderRegistry();
|
|
||||||
if (!config?.provider || config.provider === 'none') return registry;
|
|
||||||
|
|
||||||
switch (config.provider) {
|
constructor(name: string, inner: LlmProvider) {
|
||||||
|
this.name = name;
|
||||||
|
this.inner = inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
complete(...args: Parameters<LlmProvider['complete']>) {
|
||||||
|
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<LlmProvider | null> {
|
||||||
|
switch (entry.type) {
|
||||||
case 'gemini-cli': {
|
case 'gemini-cli': {
|
||||||
const cfg: GeminiAcpConfig = {};
|
const cfg: GeminiAcpConfig = {};
|
||||||
if (config.binaryPath) cfg.binaryPath = config.binaryPath;
|
if (entry.binaryPath) cfg.binaryPath = entry.binaryPath;
|
||||||
if (config.model) cfg.defaultModel = config.model;
|
if (entry.model) cfg.defaultModel = entry.model;
|
||||||
const provider = new GeminiAcpProvider(cfg);
|
const provider = new GeminiAcpProvider(cfg);
|
||||||
provider.warmup();
|
provider.warmup();
|
||||||
registry.register(provider);
|
return provider;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'ollama': {
|
case 'ollama': {
|
||||||
const cfg: OllamaConfig = {};
|
const cfg: OllamaConfig = {};
|
||||||
if (config.url) cfg.baseUrl = config.url;
|
if (entry.url) cfg.baseUrl = entry.url;
|
||||||
if (config.model) cfg.defaultModel = config.model;
|
if (entry.model) cfg.defaultModel = entry.model;
|
||||||
registry.register(new OllamaProvider(cfg));
|
return new OllamaProvider(cfg);
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'anthropic': {
|
case 'anthropic': {
|
||||||
const apiKey = await secretStore.get('anthropic-api-key');
|
const apiKey = await secretStore.get('anthropic-api-key');
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
process.stderr.write('Warning: Anthropic API key not found in secret store. Run "mcpctl config setup" to configure.\n');
|
process.stderr.write(`Warning: Anthropic API key not found for provider "${entry.name}". Run "mcpctl config setup" to configure.\n`);
|
||||||
return registry;
|
return null;
|
||||||
}
|
}
|
||||||
const cfg: AnthropicConfig = { apiKey };
|
const cfg: AnthropicConfig = { apiKey };
|
||||||
if (config.model) cfg.defaultModel = config.model;
|
if (entry.model) cfg.defaultModel = entry.model;
|
||||||
registry.register(new AnthropicProvider(cfg));
|
return new AnthropicProvider(cfg);
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'openai': {
|
case 'openai': {
|
||||||
const apiKey = await secretStore.get('openai-api-key');
|
const apiKey = await secretStore.get('openai-api-key');
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
process.stderr.write('Warning: OpenAI API key not found in secret store. Run "mcpctl config setup" to configure.\n');
|
process.stderr.write(`Warning: OpenAI API key not found for provider "${entry.name}". Run "mcpctl config setup" to configure.\n`);
|
||||||
return registry;
|
return null;
|
||||||
}
|
}
|
||||||
const cfg: OpenAiConfig = { apiKey };
|
const cfg: OpenAiConfig = { apiKey };
|
||||||
if (config.url) cfg.baseUrl = config.url;
|
if (entry.url) cfg.baseUrl = entry.url;
|
||||||
if (config.model) cfg.defaultModel = config.model;
|
if (entry.model) cfg.defaultModel = entry.model;
|
||||||
registry.register(new OpenAiProvider(cfg));
|
return new OpenAiProvider(cfg);
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'deepseek': {
|
case 'deepseek': {
|
||||||
const apiKey = await secretStore.get('deepseek-api-key');
|
const apiKey = await secretStore.get('deepseek-api-key');
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
process.stderr.write('Warning: DeepSeek API key not found in secret store. Run "mcpctl config setup" to configure.\n');
|
process.stderr.write(`Warning: DeepSeek API key not found for provider "${entry.name}". Run "mcpctl config setup" to configure.\n`);
|
||||||
return registry;
|
return null;
|
||||||
}
|
}
|
||||||
const cfg: DeepSeekConfig = { apiKey };
|
const cfg: DeepSeekConfig = { apiKey };
|
||||||
if (config.url) cfg.baseUrl = config.url;
|
if (entry.url) cfg.baseUrl = entry.url;
|
||||||
if (config.model) cfg.defaultModel = config.model;
|
if (entry.model) cfg.defaultModel = entry.model;
|
||||||
registry.register(new DeepSeekProvider(cfg));
|
return new DeepSeekProvider(cfg);
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'vllm': {
|
case 'vllm': {
|
||||||
// vLLM uses OpenAI-compatible API
|
if (!entry.url) {
|
||||||
if (!config.url) {
|
process.stderr.write(`Warning: vLLM URL not configured for provider "${entry.name}". Run "mcpctl config setup" to configure.\n`);
|
||||||
process.stderr.write('Warning: vLLM URL not configured. Run "mcpctl config setup" to configure.\n');
|
return null;
|
||||||
return registry;
|
|
||||||
}
|
}
|
||||||
registry.register(new OpenAiProvider({
|
return new OpenAiProvider({
|
||||||
apiKey: 'unused',
|
apiKey: 'unused',
|
||||||
baseUrl: config.url,
|
baseUrl: entry.url,
|
||||||
defaultModel: config.model ?? 'default',
|
defaultModel: entry.model ?? 'default',
|
||||||
}));
|
});
|
||||||
break;
|
}
|
||||||
|
|
||||||
|
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<ProviderRegistry> {
|
||||||
|
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;
|
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<ProviderRegistry> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ export class ResponsePaginator {
|
|||||||
raw: string,
|
raw: string,
|
||||||
pages: PageInfo[],
|
pages: PageInfo[],
|
||||||
): Promise<PaginationIndex> {
|
): Promise<PaginationIndex> {
|
||||||
const provider = this.providers?.getActive();
|
const provider = this.providers?.getProvider('fast');
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
return this.generateSimpleIndex(resultId, toolName, raw, pages);
|
return this.generateSimpleIndex(resultId, toolName, raw, pages);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export class LlmProcessor {
|
|||||||
return { optimized: false, params };
|
return { optimized: false, params };
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = this.providers.getActive();
|
const provider = this.providers.getProvider('fast');
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
return { optimized: false, params };
|
return { optimized: false, params };
|
||||||
}
|
}
|
||||||
@@ -142,7 +142,7 @@ export class LlmProcessor {
|
|||||||
return { filtered: false, result: response.result, originalSize: raw.length, filteredSize: raw.length };
|
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) {
|
if (!provider) {
|
||||||
const raw = JSON.stringify(response.result);
|
const raw = JSON.stringify(response.result);
|
||||||
return { filtered: false, result: response.result, originalSize: raw.length, filteredSize: raw.length };
|
return { filtered: false, result: response.result, originalSize: raw.length, filteredSize: raw.length };
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import { StdioProxyServer } from './server.js';
|
|||||||
import { StdioUpstream } from './upstream/stdio.js';
|
import { StdioUpstream } from './upstream/stdio.js';
|
||||||
import { HttpUpstream } from './upstream/http.js';
|
import { HttpUpstream } from './upstream/http.js';
|
||||||
import { createHttpServer } from './http/server.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 type { HttpConfig } from './http/config.js';
|
||||||
import { createProviderFromConfig } from './llm-config.js';
|
import { createProvidersFromConfig } from './llm-config.js';
|
||||||
import { createSecretStore } from '@mcpctl/shared';
|
import { createSecretStore } from '@mcpctl/shared';
|
||||||
import type { ProviderRegistry } from './providers/registry.js';
|
import type { ProviderRegistry } from './providers/registry.js';
|
||||||
|
|
||||||
@@ -65,13 +65,19 @@ export async function main(argv: string[] = process.argv): Promise<MainResult> {
|
|||||||
const args = parseArgs(argv);
|
const args = parseArgs(argv);
|
||||||
const httpConfig = loadHttpConfig();
|
const httpConfig = loadHttpConfig();
|
||||||
|
|
||||||
// Load LLM provider from user config + secret store
|
// Load LLM providers from user config + secret store
|
||||||
const llmConfig = loadLlmConfig();
|
const llmEntries = loadLlmProviders();
|
||||||
const secretStore = await createSecretStore();
|
const secretStore = await createSecretStore();
|
||||||
const providerRegistry = await createProviderFromConfig(llmConfig, secretStore);
|
const providerRegistry = await createProvidersFromConfig(llmEntries, secretStore);
|
||||||
const activeLlm = providerRegistry.getActive();
|
if (providerRegistry.hasTierConfig()) {
|
||||||
if (activeLlm) {
|
const fast = providerRegistry.getTierProviders('fast');
|
||||||
process.stderr.write(`LLM provider: ${activeLlm.name}\n`);
|
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[] = [];
|
let upstreamConfigs: UpstreamConfig[] = [];
|
||||||
|
|||||||
@@ -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 {
|
export class ProviderRegistry {
|
||||||
private providers = new Map<string, LlmProvider>();
|
private providers = new Map<string, LlmProvider>();
|
||||||
private activeProvider: string | null = null;
|
private activeProvider: string | null = null;
|
||||||
|
private tierProviders = new Map<Tier, string[]>();
|
||||||
|
|
||||||
register(provider: LlmProvider): void {
|
register(provider: LlmProvider): void {
|
||||||
this.providers.set(provider.name, provider);
|
this.providers.set(provider.name, provider);
|
||||||
@@ -20,6 +22,15 @@ export class ProviderRegistry {
|
|||||||
const first = this.providers.keys().next();
|
const first = this.providers.keys().next();
|
||||||
this.activeProvider = first.done ? null : first.value;
|
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 {
|
setActive(name: string): void {
|
||||||
@@ -34,6 +45,42 @@ export class ProviderRegistry {
|
|||||||
return this.providers.get(this.activeProvider) ?? null;
|
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 {
|
get(name: string): LlmProvider | undefined {
|
||||||
return this.providers.get(name);
|
return this.providers.get(name);
|
||||||
}
|
}
|
||||||
@@ -46,10 +93,31 @@ export class ProviderRegistry {
|
|||||||
return this.activeProvider;
|
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. */
|
/** Dispose all registered providers that have a dispose method. */
|
||||||
disposeAll(): void {
|
disposeAll(): void {
|
||||||
for (const provider of this.providers.values()) {
|
for (const provider of this.providers.values()) {
|
||||||
provider.dispose?.();
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ export interface CompletionOptions {
|
|||||||
model?: string;
|
model?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** LLM provider tier. 'fast' = local inference, 'heavy' = cloud reasoning. */
|
||||||
|
export type Tier = 'fast' | 'heavy';
|
||||||
|
|
||||||
export interface LlmProvider {
|
export interface LlmProvider {
|
||||||
/** Provider identifier (e.g., 'openai', 'anthropic', 'ollama') */
|
/** Provider identifier (e.g., 'openai', 'anthropic', 'ollama') */
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
|
|||||||
@@ -116,9 +116,9 @@ describe('createProviderFromConfig', () => {
|
|||||||
{ provider: 'vllm', model: 'my-model', url: 'http://gpu-server:8000' },
|
{ provider: 'vllm', model: 'my-model', url: 'http://gpu-server:8000' },
|
||||||
store,
|
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()).not.toBeNull();
|
||||||
expect(registry.getActive()!.name).toBe('openai');
|
expect(registry.getActive()!.name).toBe('vllm');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty registry when vllm URL is missing', async () => {
|
it('returns empty registry when vllm URL is missing', async () => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ function makeProvider(response: string): ProviderRegistry {
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
getActive: () => provider,
|
getActive: () => provider,
|
||||||
|
getProvider: () => provider,
|
||||||
register: vi.fn(),
|
register: vi.fn(),
|
||||||
setActive: vi.fn(),
|
setActive: vi.fn(),
|
||||||
listProviders: () => [{ name: 'test', available: true, active: true }],
|
listProviders: () => [{ name: 'test', available: true, active: true }],
|
||||||
@@ -177,6 +178,7 @@ describe('ResponsePaginator', () => {
|
|||||||
};
|
};
|
||||||
const registry = {
|
const registry = {
|
||||||
getActive: () => provider,
|
getActive: () => provider,
|
||||||
|
getProvider: () => provider,
|
||||||
register: vi.fn(),
|
register: vi.fn(),
|
||||||
setActive: vi.fn(),
|
setActive: vi.fn(),
|
||||||
listProviders: () => [{ name: 'test', available: true, active: true }],
|
listProviders: () => [{ name: 'test', available: true, active: true }],
|
||||||
@@ -208,6 +210,7 @@ describe('ResponsePaginator', () => {
|
|||||||
};
|
};
|
||||||
const registry = {
|
const registry = {
|
||||||
getActive: () => provider,
|
getActive: () => provider,
|
||||||
|
getProvider: () => provider,
|
||||||
register: vi.fn(),
|
register: vi.fn(),
|
||||||
setActive: vi.fn(),
|
setActive: vi.fn(),
|
||||||
listProviders: () => [{ name: 'test', available: true, active: true }],
|
listProviders: () => [{ name: 'test', available: true, active: true }],
|
||||||
@@ -231,6 +234,7 @@ describe('ResponsePaginator', () => {
|
|||||||
it('falls back to simple when no active provider', async () => {
|
it('falls back to simple when no active provider', async () => {
|
||||||
const registry = {
|
const registry = {
|
||||||
getActive: () => null,
|
getActive: () => null,
|
||||||
|
getProvider: () => null,
|
||||||
register: vi.fn(),
|
register: vi.fn(),
|
||||||
setActive: vi.fn(),
|
setActive: vi.fn(),
|
||||||
listProviders: () => [],
|
listProviders: () => [],
|
||||||
@@ -256,6 +260,7 @@ describe('ResponsePaginator', () => {
|
|||||||
};
|
};
|
||||||
const registry = {
|
const registry = {
|
||||||
getActive: () => provider,
|
getActive: () => provider,
|
||||||
|
getProvider: () => provider,
|
||||||
register: vi.fn(),
|
register: vi.fn(),
|
||||||
setActive: vi.fn(),
|
setActive: vi.fn(),
|
||||||
listProviders: () => [{ name: 'test', available: true, active: true }],
|
listProviders: () => [{ name: 'test', available: true, active: true }],
|
||||||
@@ -281,6 +286,7 @@ describe('ResponsePaginator', () => {
|
|||||||
};
|
};
|
||||||
const registry = {
|
const registry = {
|
||||||
getActive: () => provider,
|
getActive: () => provider,
|
||||||
|
getProvider: () => provider,
|
||||||
register: vi.fn(),
|
register: vi.fn(),
|
||||||
setActive: vi.fn(),
|
setActive: vi.fn(),
|
||||||
listProviders: () => [{ name: 'test', available: true, active: true }],
|
listProviders: () => [{ name: 'test', available: true, active: true }],
|
||||||
|
|||||||
@@ -115,4 +115,105 @@ describe('ProviderRegistry', () => {
|
|||||||
|
|
||||||
expect(models).toEqual(['anthropic-model-1', 'anthropic-model-2']);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user