feat(cli): add interactive OpenRouter model picker for api create

- add openrouter-catalog.ts with fetcher and 24h file cache

- add openrouter-picker.ts with interactive search UI

- detect OpenRouter URL in api-command.ts handleCreate()

- offer interactive browse when no --model flag provided

- support tier mapping configuration (opus/sonnet/haiku)
This commit is contained in:
kaitranntt
2025-12-20 18:31:40 -05:00
parent 677f9d1e72
commit d193626e3b
4 changed files with 309 additions and 5 deletions
+4
View File
@@ -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';
+119
View File
@@ -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<OpenRouterModel[]> {
// 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');
}
+153
View File
@@ -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<OpenRouterSelection | null> {
// 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 ?? '';
}
}
+33 -5
View File
@@ -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<void> {
}
}
// 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<void> {
// 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<void> {
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) {