feat: LLM provider configuration, secret store, and setup wizard
Add secure credential storage (GNOME Keyring + file fallback), LLM provider config in ~/.mcpctl/config.json, interactive setup wizard (mcpctl config setup), and wire configured provider into mcplocal for smart pagination summaries. - Secret store: SecretStore interface, GnomeKeyringStore, FileSecretStore - Config schema: LlmConfigSchema with provider/model/url/binaryPath - Setup wizard: arrow-key provider/model selection, dynamic model fetch - Provider factory: creates ProviderRegistry from config + secrets - Status: shows LLM line with hint when not configured - 572 tests passing across all packages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
133
src/mcplocal/tests/llm-config.test.ts
Normal file
133
src/mcplocal/tests/llm-config.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createProviderFromConfig } from '../src/llm-config.js';
|
||||
import type { SecretStore } from '@mcpctl/shared';
|
||||
|
||||
function mockSecretStore(secrets: Record<string, string> = {}): SecretStore {
|
||||
return {
|
||||
get: vi.fn(async (key: string) => secrets[key] ?? null),
|
||||
set: vi.fn(async () => {}),
|
||||
delete: vi.fn(async () => true),
|
||||
backend: () => 'mock',
|
||||
};
|
||||
}
|
||||
|
||||
describe('createProviderFromConfig', () => {
|
||||
it('returns empty registry for undefined config', async () => {
|
||||
const store = mockSecretStore();
|
||||
const registry = await createProviderFromConfig(undefined, store);
|
||||
expect(registry.getActive()).toBeNull();
|
||||
expect(registry.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty registry for provider=none', async () => {
|
||||
const store = mockSecretStore();
|
||||
const registry = await createProviderFromConfig({ provider: 'none' }, store);
|
||||
expect(registry.getActive()).toBeNull();
|
||||
});
|
||||
|
||||
it('creates gemini-cli provider', async () => {
|
||||
const store = mockSecretStore();
|
||||
const registry = await createProviderFromConfig(
|
||||
{ provider: 'gemini-cli', model: 'gemini-2.5-flash', binaryPath: '/usr/bin/gemini' },
|
||||
store,
|
||||
);
|
||||
expect(registry.getActive()).not.toBeNull();
|
||||
expect(registry.getActive()!.name).toBe('gemini-cli');
|
||||
});
|
||||
|
||||
it('creates ollama provider', async () => {
|
||||
const store = mockSecretStore();
|
||||
const registry = await createProviderFromConfig(
|
||||
{ provider: 'ollama', model: 'llama3.2', url: 'http://localhost:11434' },
|
||||
store,
|
||||
);
|
||||
expect(registry.getActive()!.name).toBe('ollama');
|
||||
});
|
||||
|
||||
it('creates anthropic provider with API key from secret store', async () => {
|
||||
const store = mockSecretStore({ 'anthropic-api-key': 'sk-ant-test' });
|
||||
const registry = await createProviderFromConfig(
|
||||
{ provider: 'anthropic', model: 'claude-haiku-3-5-20241022' },
|
||||
store,
|
||||
);
|
||||
expect(registry.getActive()!.name).toBe('anthropic');
|
||||
expect(store.get).toHaveBeenCalledWith('anthropic-api-key');
|
||||
});
|
||||
|
||||
it('returns empty registry when anthropic API key is missing', async () => {
|
||||
const store = mockSecretStore();
|
||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
const registry = await createProviderFromConfig(
|
||||
{ provider: 'anthropic', model: 'claude-haiku-3-5-20241022' },
|
||||
store,
|
||||
);
|
||||
expect(registry.getActive()).toBeNull();
|
||||
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Anthropic API key not found'));
|
||||
stderrSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('creates openai provider with API key from secret store', async () => {
|
||||
const store = mockSecretStore({ 'openai-api-key': 'sk-test' });
|
||||
const registry = await createProviderFromConfig(
|
||||
{ provider: 'openai', model: 'gpt-4o', url: 'https://api.openai.com' },
|
||||
store,
|
||||
);
|
||||
expect(registry.getActive()!.name).toBe('openai');
|
||||
expect(store.get).toHaveBeenCalledWith('openai-api-key');
|
||||
});
|
||||
|
||||
it('returns empty registry when openai API key is missing', async () => {
|
||||
const store = mockSecretStore();
|
||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
const registry = await createProviderFromConfig(
|
||||
{ provider: 'openai' },
|
||||
store,
|
||||
);
|
||||
expect(registry.getActive()).toBeNull();
|
||||
stderrSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('creates deepseek provider with API key from secret store', async () => {
|
||||
const store = mockSecretStore({ 'deepseek-api-key': 'sk-ds-test' });
|
||||
const registry = await createProviderFromConfig(
|
||||
{ provider: 'deepseek', model: 'deepseek-chat' },
|
||||
store,
|
||||
);
|
||||
expect(registry.getActive()!.name).toBe('deepseek');
|
||||
expect(store.get).toHaveBeenCalledWith('deepseek-api-key');
|
||||
});
|
||||
|
||||
it('returns empty registry when deepseek API key is missing', async () => {
|
||||
const store = mockSecretStore();
|
||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
const registry = await createProviderFromConfig(
|
||||
{ provider: 'deepseek' },
|
||||
store,
|
||||
);
|
||||
expect(registry.getActive()).toBeNull();
|
||||
stderrSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('creates vllm provider using OpenAI provider', async () => {
|
||||
const store = mockSecretStore();
|
||||
const registry = await createProviderFromConfig(
|
||||
{ provider: 'vllm', model: 'my-model', url: 'http://gpu-server:8000' },
|
||||
store,
|
||||
);
|
||||
// vLLM reuses OpenAI provider under the hood
|
||||
expect(registry.getActive()).not.toBeNull();
|
||||
expect(registry.getActive()!.name).toBe('openai');
|
||||
});
|
||||
|
||||
it('returns empty registry when vllm URL is missing', async () => {
|
||||
const store = mockSecretStore();
|
||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
const registry = await createProviderFromConfig(
|
||||
{ provider: 'vllm' },
|
||||
store,
|
||||
);
|
||||
expect(registry.getActive()).toBeNull();
|
||||
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('vLLM URL not configured'));
|
||||
stderrSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user