From 573cb435346d4a339a37600ff883d119693674ce Mon Sep 17 00:00:00 2001 From: William Valentin Date: Tue, 10 Feb 2026 09:31:43 -0800 Subject: [PATCH] feat(setup): add model provider setup flows --- src/cli/setup/providers.test.ts | 64 +++++++++++++++++++++++++++++++ src/cli/setup/providers.ts | 67 +++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 src/cli/setup/providers.test.ts create mode 100644 src/cli/setup/providers.ts diff --git a/src/cli/setup/providers.test.ts b/src/cli/setup/providers.test.ts new file mode 100644 index 0000000..5eab1bd --- /dev/null +++ b/src/cli/setup/providers.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; +import { createInterface } from 'readline/promises'; +import { EventEmitter } from 'events'; +import { createPrompter } from './prompts.js'; +import { ConfigBuilder } from './config.js'; +import { setupProviders } from './providers.js'; + +function mockReadline(inputs: string[]) { + let questionIdx = 0; + const emitter = new EventEmitter(); + + return { + async question(query: string) { + const answer = inputs[questionIdx++]; + return answer ?? ''; + }, + + close() { + // no-op + }, + + [Symbol.asyncIterator]() { + return this; + }, + + async next() { + return { done: true }; + }, + } as any; +} + +describe('setupProviders', () => { + it('configures anthropic as default provider', async () => { + const rl = mockReadline(['1', 'sk-ant-test123', '', 'n']); + const p = createPrompter(rl); + const builder = new ConfigBuilder(); + await setupProviders(p, builder); + const config = builder.build(); + expect(config.models.default.provider).toBe('anthropic'); + expect(config.models.default.api_key).toBe('sk-ant-test123'); + expect(config.models.default.model).toBe('claude-sonnet-4-20250514'); + }); + + it('configures ollama as default provider', async () => { + const rl = mockReadline(['3', '', '', 'n']); + const p = createPrompter(rl); + const builder = new ConfigBuilder(); + await setupProviders(p, builder); + const config = builder.build(); + expect(config.models.default.provider).toBe('ollama'); + expect(config.models.default.endpoint).toBe('http://localhost:11434'); + }); + + it('configures anthropic with fast tier', async () => { + const rl = mockReadline(['1', 'sk-ant-test123', '', 'y', '']); + const p = createPrompter(rl); + const builder = new ConfigBuilder(); + await setupProviders(p, builder); + const config = builder.build(); + expect(config.models.default.provider).toBe('anthropic'); + expect(config.models.fast).toBeDefined(); + expect(config.models.fast.provider).toBe('anthropic'); + }); +}); diff --git a/src/cli/setup/providers.ts b/src/cli/setup/providers.ts new file mode 100644 index 0000000..5fc0801 --- /dev/null +++ b/src/cli/setup/providers.ts @@ -0,0 +1,67 @@ +import type { Prompter } from './prompts.js'; +import type { ConfigBuilder } from './config.js'; + +interface ProviderDef { + name: string; + provider: string; + defaultModel: string; + fastModel?: string; + needsApiKey: boolean; + needsEndpoint: boolean; + defaultEndpoint?: string; + apiKeyLabel?: string; +} + +const TOP_TIER: ProviderDef[] = [ + { name: 'Anthropic', provider: 'anthropic', defaultModel: 'claude-sonnet-4-20250514', fastModel: 'claude-haiku-4-5-20251001', needsApiKey: true, needsEndpoint: false, apiKeyLabel: 'Anthropic API key' }, + { name: 'OpenAI', provider: 'openai', defaultModel: 'gpt-4.1', fastModel: 'gpt-4.1-mini', needsApiKey: true, needsEndpoint: false, apiKeyLabel: 'OpenAI API key' }, + { name: 'Ollama (local)', provider: 'ollama', defaultModel: 'llama3.3', fastModel: 'llama3.2:3b', needsApiKey: false, needsEndpoint: true, defaultEndpoint: 'http://localhost:11434' }, +]; + +const SECOND_TIER: ProviderDef[] = [ + { name: 'Gemini', provider: 'gemini', defaultModel: 'gemini-2.5-flash', fastModel: 'gemini-2.0-flash-lite', needsApiKey: true, needsEndpoint: false, apiKeyLabel: 'Gemini API key' }, + { name: 'OpenRouter', provider: 'openrouter', defaultModel: 'anthropic/claude-sonnet-4', needsApiKey: true, needsEndpoint: false, apiKeyLabel: 'OpenRouter API key' }, + { name: 'xAI (Grok)', provider: 'xai', defaultModel: 'grok-3', fastModel: 'grok-3-mini', needsApiKey: true, needsEndpoint: false, apiKeyLabel: 'xAI API key' }, + { name: 'Amazon Bedrock', provider: 'bedrock', defaultModel: 'anthropic.claude-sonnet-4-20250514-v1:0', needsApiKey: false, needsEndpoint: false }, + { name: 'GitHub Models', provider: 'github', defaultModel: 'claude-sonnet-4-20250514', needsApiKey: false, needsEndpoint: false }, +]; + +async function configureProvider(p: Prompter, def: ProviderDef): Promise<{ + provider: string; model: string; api_key?: string; endpoint?: string; +}> { + const config: Record = { provider: def.provider }; + if (def.needsApiKey) config.api_key = await p.password(def.apiKeyLabel ?? 'API key'); + if (def.needsEndpoint) config.endpoint = await p.ask('Host', def.defaultEndpoint); + config.model = await p.ask('Model', def.defaultModel); + return config as { provider: string; model: string; api_key?: string; endpoint?: string }; +} + +export async function setupProviders(p: Prompter, builder: ConfigBuilder): Promise { + const allOptions = [ + ...TOP_TIER.map(d => ({ label: d.name, value: d })), + { label: 'More providers...', value: null as ProviderDef | null }, + ]; + + let chosen: ProviderDef; + const selection = await p.choose('Model provider:', allOptions); + + if (selection === null) { + const secondOptions = SECOND_TIER.map(d => ({ label: d.name, value: d })); + chosen = await p.choose('Model provider:', secondOptions); + } else { + chosen = selection; + } + + p.println(); + const cfg = await configureProvider(p, chosen); + builder.setProvider('default', cfg); + + if (chosen.fastModel) { + p.println(); + const wantFast = await p.confirm('Configure a fast tier for compaction/delegation?', false); + if (wantFast) { + const fastModel = await p.ask('Fast model', chosen.fastModel); + builder.setProvider('fast', { ...cfg, model: fastModel }); + } + } +}