mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 06:17:09 +00:00
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:
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,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) {
|
||||
|
||||
Reference in New Issue
Block a user