mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 22:14:22 +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:
1 parent
677f9d1e72
commit
d193626e3b
4 files changed
+309
-5
No files matched your search
@@ -28,3 +28,7 @@ export {
|
|||||||
|
|
||||||
// Profile write operations
|
// Profile write operations
|
||||||
export { createApiProfile, removeApiProfile } from './profile-writer';
|
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,
|
removeApiProfile,
|
||||||
getApiProfileNames,
|
getApiProfileNames,
|
||||||
isUsingUnifiedConfig,
|
isUsingUnifiedConfig,
|
||||||
|
isOpenRouterUrl,
|
||||||
|
pickOpenRouterModel,
|
||||||
type ModelMapping,
|
type ModelMapping,
|
||||||
} from '../api/services';
|
} 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
|
// Step 3: API Key
|
||||||
let apiKey = parsedArgs.apiKey;
|
let apiKey = parsedArgs.apiKey;
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
@@ -142,7 +169,7 @@ async function handleCreate(args: string[]): Promise<void> {
|
|||||||
|
|
||||||
// Step 4: Model configuration
|
// Step 4: Model configuration
|
||||||
const defaultModel = 'claude-sonnet-4-5-20250929';
|
const defaultModel = 'claude-sonnet-4-5-20250929';
|
||||||
let model = parsedArgs.model;
|
let model = parsedArgs.model || openRouterModel;
|
||||||
if (!model && !parsedArgs.yes) {
|
if (!model && !parsedArgs.yes) {
|
||||||
model = await InteractivePrompt.input('Default model (ANTHROPIC_MODEL)', {
|
model = await InteractivePrompt.input('Default model (ANTHROPIC_MODEL)', {
|
||||||
default: defaultModel,
|
default: defaultModel,
|
||||||
@@ -151,12 +178,13 @@ async function handleCreate(args: string[]): Promise<void> {
|
|||||||
model = model || defaultModel;
|
model = model || defaultModel;
|
||||||
|
|
||||||
// Step 5: Model mapping for Opus/Sonnet/Haiku
|
// Step 5: Model mapping for Opus/Sonnet/Haiku
|
||||||
let opusModel = model;
|
let opusModel = openRouterTierMapping?.opus || model;
|
||||||
let sonnetModel = model;
|
let sonnetModel = openRouterTierMapping?.sonnet || model;
|
||||||
let haikuModel = model;
|
let haikuModel = openRouterTierMapping?.haiku || model;
|
||||||
const isCustomModel = model !== defaultModel;
|
const isCustomModel = model !== defaultModel;
|
||||||
|
const hasOpenRouterTierMapping = openRouterTierMapping !== undefined;
|
||||||
|
|
||||||
if (!parsedArgs.yes) {
|
if (!parsedArgs.yes && !hasOpenRouterTierMapping) {
|
||||||
let wantCustomMapping = isCustomModel;
|
let wantCustomMapping = isCustomModel;
|
||||||
|
|
||||||
if (!isCustomModel) {
|
if (!isCustomModel) {
|
||||||
|
|||||||
Reference in new issue
Block a user