diff --git a/src/api/services/index.ts b/src/api/services/index.ts index db692f30..f23cdcc9 100644 --- a/src/api/services/index.ts +++ b/src/api/services/index.ts @@ -28,3 +28,7 @@ export { // Profile write operations export { createApiProfile, removeApiProfile } from './profile-writer'; + +// OpenRouter catalog and picker +export { isOpenRouterUrl, fetchOpenRouterModels, type OpenRouterModel } from './openrouter-catalog'; +export { pickOpenRouterModel, type OpenRouterSelection } from './openrouter-picker'; diff --git a/src/api/services/openrouter-catalog.ts b/src/api/services/openrouter-catalog.ts new file mode 100644 index 00000000..b6b2d6ff --- /dev/null +++ b/src/api/services/openrouter-catalog.ts @@ -0,0 +1,119 @@ +/** + * OpenRouter Model Catalog Fetcher + * Fetches model list from OpenRouter API for CLI use + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/models'; +const CACHE_FILE = path.join(os.homedir(), '.ccs', 'openrouter-models-cache.json'); +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours + +export interface OpenRouterModel { + id: string; + name: string; + description: string; + context_length: number; + pricing: { + prompt: string; + completion: string; + }; +} + +interface CacheData { + models: OpenRouterModel[]; + fetchedAt: number; +} + +/** Check if cached data is valid */ +function getCachedModels(): OpenRouterModel[] | null { + try { + if (!fs.existsSync(CACHE_FILE)) return null; + const data = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')) as CacheData; + if (Date.now() - data.fetchedAt > CACHE_TTL_MS) return null; + return data.models; + } catch { + return null; + } +} + +/** Save models to cache */ +function setCachedModels(models: OpenRouterModel[]): void { + try { + const dir = path.dirname(CACHE_FILE); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + CACHE_FILE, + JSON.stringify({ + models, + fetchedAt: Date.now(), + }) + ); + } catch { + // Ignore cache write errors + } +} + +/** Fetch models from OpenRouter API */ +export async function fetchOpenRouterModels(): Promise { + // Try cache first + const cached = getCachedModels(); + if (cached) return cached; + + // Fetch from API + const response = await fetch(OPENROUTER_API_URL); + if (!response.ok) { + throw new Error(`Failed to fetch OpenRouter models: ${response.status}`); + } + + const data = (await response.json()) as { data: OpenRouterModel[] }; + const models = data.data.map((m) => ({ + id: m.id, + name: m.name, + description: m.description, + context_length: m.context_length, + pricing: m.pricing, + })); + + // Cache for next time + setCachedModels(models); + + return models; +} + +/** Format price per token to per million */ +export function formatPrice(perToken: string): string { + const value = parseFloat(perToken); + if (isNaN(value) || value === 0) return 'Free'; + const perMillion = value * 1_000_000; + if (perMillion < 0.01) return '<$0.01'; + if (perMillion < 1) return `$${perMillion.toFixed(2)}`; + return `$${perMillion.toFixed(perMillion < 10 ? 2 : 0)}`; +} + +/** Format pricing pair */ +export function formatPricingPair(pricing: { prompt: string; completion: string }): string { + return `${formatPrice(pricing.prompt)}/${formatPrice(pricing.completion)}`; +} + +/** Format context length */ +export function formatContext(length: number): string { + if (length >= 1_000_000) return `${(length / 1_000_000).toFixed(1)}M`; + return `${Math.round(length / 1_000)}K`; +} + +/** Search models */ +export function searchModels(models: OpenRouterModel[], query: string): OpenRouterModel[] { + if (!query.trim()) return models.slice(0, 20); // Show first 20 if no query + const q = query.toLowerCase(); + return models + .filter((m) => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q)) + .slice(0, 20); // Limit to 20 results +} + +/** Check if URL is OpenRouter */ +export function isOpenRouterUrl(url: string): boolean { + return url.toLowerCase().includes('openrouter.ai'); +} diff --git a/src/api/services/openrouter-picker.ts b/src/api/services/openrouter-picker.ts new file mode 100644 index 00000000..de2dcb9e --- /dev/null +++ b/src/api/services/openrouter-picker.ts @@ -0,0 +1,153 @@ +/** + * OpenRouter Interactive Model Picker + * CLI interface for browsing and selecting OpenRouter models + */ + +import { InteractivePrompt } from '../../utils/prompt'; +import { table, info, warn, color, dim, spinner } from '../../utils/ui'; +import { + fetchOpenRouterModels, + searchModels, + formatPricingPair, + formatContext, + type OpenRouterModel, +} from './openrouter-catalog'; + +export interface OpenRouterSelection { + model: string; + tierMapping?: { + opus?: string; + sonnet?: string; + haiku?: string; + }; +} + +/** Interactive model picker */ +export async function pickOpenRouterModel(): Promise { + // Fetch models with spinner + const s = await spinner('Fetching OpenRouter models...'); + + let models: OpenRouterModel[]; + try { + models = await fetchOpenRouterModels(); + s.succeed(`Loaded ${models.length} models from OpenRouter`); + } catch (error) { + s.fail(`Failed to fetch models: ${(error as Error).message}`); + return null; + } + + // Search loop + let selectedModel: OpenRouterModel | null = null; + + while (!selectedModel) { + const query = await InteractivePrompt.input('Search models (or press Enter to see popular)', { + default: '', + }); + + const results = searchModels(models, query); + + if (results.length === 0) { + console.log(warn('No models found. Try a different search term.')); + continue; + } + + // Display results in table + console.log(''); + const rows = results.map((m, i) => [ + String(i + 1), + m.id.length > 35 ? m.id.slice(0, 32) + '...' : m.id, + formatPricingPair(m.pricing), + formatContext(m.context_length), + ]); + + console.log( + table(rows, { + head: ['#', 'Model ID', 'Price (prompt/completion)', 'Context'], + }) + ); + console.log(''); + + // Get selection + const selection = await InteractivePrompt.input( + `Select model [1-${results.length}] or search again`, + { default: '1' } + ); + + const index = parseInt(selection, 10) - 1; + if (index >= 0 && index < results.length) { + selectedModel = results[index]; + } else if (selection.trim()) { + // Treat as new search + const newResults = searchModels(models, selection); + if (newResults.length === 1) { + selectedModel = newResults[0]; + } + } + } + + console.log(''); + console.log(info(`Selected: ${color(selectedModel.id, 'info')}`)); + + // Ask about tier mapping + const configureTiers = await InteractivePrompt.confirm( + 'Configure model tier mapping (opus/sonnet/haiku)?', + { default: false } + ); + + if (!configureTiers) { + return { model: selectedModel.id }; + } + + // Tier mapping + console.log(''); + console.log(dim('Leave blank to skip a tier.')); + + const tierMapping = { + opus: await InteractivePrompt.input('Opus tier model', { + default: suggestTier(selectedModel.id, 'opus', models), + }), + sonnet: await InteractivePrompt.input('Sonnet tier model', { + default: selectedModel.id, + }), + haiku: await InteractivePrompt.input('Haiku tier model', { + default: suggestTier(selectedModel.id, 'haiku', models), + }), + }; + + // Clean empty values + const cleanMapping = { + opus: tierMapping.opus || undefined, + sonnet: tierMapping.sonnet || undefined, + haiku: tierMapping.haiku || undefined, + }; + + return { + model: selectedModel.id, + tierMapping: cleanMapping, + }; +} + +/** Suggest tier model based on provider */ +function suggestTier( + selectedId: string, + tier: 'opus' | 'haiku', + models: OpenRouterModel[] +): string { + const [provider] = selectedId.split('/'); + const providerModels = models.filter((m) => m.id.startsWith(`${provider}/`)); + + if (providerModels.length < 2) return ''; + + // Sort by price + const sorted = [...providerModels].sort((a, b) => { + const priceA = parseFloat(a.pricing.prompt) || 0; + const priceB = parseFloat(b.pricing.prompt) || 0; + return priceB - priceA; // Descending + }); + + if (tier === 'opus') { + return sorted[0]?.id ?? ''; + } else { + return sorted[sorted.length - 1]?.id ?? ''; + } +} diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index 9aa579ec..165ecb1b 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -33,6 +33,8 @@ import { removeApiProfile, getApiProfileNames, isUsingUnifiedConfig, + isOpenRouterUrl, + pickOpenRouterModel, type ModelMapping, } from '../api/services'; @@ -130,6 +132,31 @@ async function handleCreate(args: string[]): Promise { } } + // OpenRouter detection: offer interactive model picker + let openRouterModel: string | undefined; + let openRouterTierMapping: { opus?: string; sonnet?: string; haiku?: string } | undefined; + + if (isOpenRouterUrl(baseUrl) && !parsedArgs.model) { + console.log(''); + console.log(info('OpenRouter detected!')); + + const useInteractive = await InteractivePrompt.confirm('Browse models interactively?', { + default: true, + }); + + if (useInteractive) { + const selection = await pickOpenRouterModel(); + + if (selection) { + openRouterModel = selection.model; + openRouterTierMapping = selection.tierMapping; + } + } + + console.log(''); + console.log(dim('Note: For OpenRouter, ANTHROPIC_API_KEY should be empty.')); + } + // Step 3: API Key let apiKey = parsedArgs.apiKey; if (!apiKey) { @@ -142,7 +169,7 @@ async function handleCreate(args: string[]): Promise { // Step 4: Model configuration const defaultModel = 'claude-sonnet-4-5-20250929'; - let model = parsedArgs.model; + let model = parsedArgs.model || openRouterModel; if (!model && !parsedArgs.yes) { model = await InteractivePrompt.input('Default model (ANTHROPIC_MODEL)', { default: defaultModel, @@ -151,12 +178,13 @@ async function handleCreate(args: string[]): Promise { model = model || defaultModel; // Step 5: Model mapping for Opus/Sonnet/Haiku - let opusModel = model; - let sonnetModel = model; - let haikuModel = model; + let opusModel = openRouterTierMapping?.opus || model; + let sonnetModel = openRouterTierMapping?.sonnet || model; + let haikuModel = openRouterTierMapping?.haiku || model; const isCustomModel = model !== defaultModel; + const hasOpenRouterTierMapping = openRouterTierMapping !== undefined; - if (!parsedArgs.yes) { + if (!parsedArgs.yes && !hasOpenRouterTierMapping) { let wantCustomMapping = isCustomModel; if (!isCustomModel) {