diff --git a/ui/public/icons/openrouter.svg b/ui/public/icons/openrouter.svg new file mode 100644 index 00000000..e6cca2a8 --- /dev/null +++ b/ui/public/icons/openrouter.svg @@ -0,0 +1 @@ +OpenRouter \ No newline at end of file diff --git a/ui/src/hooks/use-openrouter-models.ts b/ui/src/hooks/use-openrouter-models.ts new file mode 100644 index 00000000..706987c6 --- /dev/null +++ b/ui/src/hooks/use-openrouter-models.ts @@ -0,0 +1,77 @@ +/** + * OpenRouter Models Hook + * Fetches and caches OpenRouter model catalog + */ + +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import type { OpenRouterModel, CategorizedModel } from '@/lib/openrouter-types'; +import { + getCachedModels, + setCachedModels, + clearCachedModels, + enrichModel, +} from '@/lib/openrouter-utils'; + +const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models'; +const QUERY_KEY = ['openrouter-models']; +const STALE_TIME = 24 * 60 * 60 * 1000; // 24 hours + +async function fetchOpenRouterModels(): Promise { + const response = await fetch(OPENROUTER_MODELS_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; + + // Cache for offline use + setCachedModels(models); + + return models; +} + +export function useOpenRouterModels() { + return useQuery({ + queryKey: QUERY_KEY, + queryFn: fetchOpenRouterModels, + staleTime: STALE_TIME, + gcTime: STALE_TIME, + // Use cached data as initial data (instant display) + initialData: () => getCachedModels() ?? undefined, + // Don't refetch on window focus for this heavy payload + refetchOnWindowFocus: false, + }); +} + +/** Get enriched models with categories and pricing */ +export function useOpenRouterCatalog() { + const query = useOpenRouterModels(); + + const enrichedModels: CategorizedModel[] = (query.data ?? []).map(enrichModel); + + return { + ...query, + models: enrichedModels, + }; +} + +/** Force refresh hook */ +export function useRefreshOpenRouterModels() { + const queryClient = useQueryClient(); + + return () => { + clearCachedModels(); + return queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + }; +} + +/** Check if OpenRouter catalog is loaded */ +export function useOpenRouterReady() { + const { data, isLoading, isError } = useOpenRouterModels(); + return { + isReady: !!data && data.length > 0, + isLoading, + isError, + modelCount: data?.length ?? 0, + }; +} diff --git a/ui/src/lib/openrouter-types.ts b/ui/src/lib/openrouter-types.ts new file mode 100644 index 00000000..a46266f5 --- /dev/null +++ b/ui/src/lib/openrouter-types.ts @@ -0,0 +1,71 @@ +/** + * OpenRouter Model Catalog Types + * Based on https://openrouter.ai/docs/api-reference/list-available-models + */ + +export interface OpenRouterPricing { + prompt: string; // USD per token, e.g., "0.000003" + completion: string; + request: string; + image: string; + audio?: string; + web_search?: string; + internal_reasoning?: string; + input_cache_read?: string; +} + +export interface OpenRouterArchitecture { + modality: string; // "text+image->text" + input_modalities: string[]; // ["text", "image"] + output_modalities: string[]; // ["text"] + tokenizer: string; // "GPT", "Claude", "Gemini" + instruct_type: string | null; +} + +export interface OpenRouterTopProvider { + context_length: number; + max_completion_tokens: number | null; + is_moderated: boolean; +} + +export interface OpenRouterModel { + id: string; // "anthropic/claude-sonnet-4" + name: string; // "Anthropic: Claude Sonnet 4" + canonical_slug: string; + hugging_face_id: string | null; + description: string; + context_length: number; + architecture: OpenRouterArchitecture; + pricing: OpenRouterPricing; + top_provider: OpenRouterTopProvider; + supported_parameters: string[]; + per_request_limits: Record | null; +} + +export interface OpenRouterModelsResponse { + data: OpenRouterModel[]; +} + +export interface OpenRouterCatalogCache { + models: OpenRouterModel[]; + fetchedAt: number; + version: string; +} + +/** Model category for grouping */ +export type ModelCategory = + | 'anthropic' + | 'openai' + | 'google' + | 'meta' + | 'mistral' + | 'opensource' + | 'other'; + +/** Categorized model for UI display */ +export interface CategorizedModel extends OpenRouterModel { + category: ModelCategory; + pricePerMillionPrompt: number; + pricePerMillionCompletion: number; + isFree: boolean; +} diff --git a/ui/src/lib/openrouter-utils.ts b/ui/src/lib/openrouter-utils.ts new file mode 100644 index 00000000..38726714 --- /dev/null +++ b/ui/src/lib/openrouter-utils.ts @@ -0,0 +1,176 @@ +/** + * OpenRouter Model Catalog Utilities + * Search, filter, pricing, and categorization + */ + +import type { OpenRouterModel, CategorizedModel, ModelCategory } from './openrouter-types'; + +const CACHE_KEY = 'ccs:openrouter-models'; +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours +const CACHE_VERSION = '1'; + +/** Convert per-token price to per-million */ +export function pricePerMillion(perToken: string): number { + const value = parseFloat(perToken); + if (isNaN(value) || value === 0) return 0; + return value * 1_000_000; +} + +/** Format price for display */ +export function formatPrice(perToken: string): string { + const perMillion = pricePerMillion(perToken); + if (perMillion === 0) return 'Free'; + if (perMillion < 0.01) return '<$0.01'; + if (perMillion < 1) return `$${perMillion.toFixed(2)}`; + return `$${perMillion.toFixed(perMillion < 10 ? 2 : 0)}`; +} + +/** Format pricing pair (prompt/completion) */ +export function formatPricingPair(pricing: { prompt: string; completion: string }): string { + const promptPrice = formatPrice(pricing.prompt); + const completionPrice = formatPrice(pricing.completion); + if (promptPrice === 'Free' && completionPrice === 'Free') return 'Free'; + return `${promptPrice}/${completionPrice}`; +} + +/** Categorize model by provider */ +export function categorizeModel(model: OpenRouterModel): ModelCategory { + const id = model.id.toLowerCase(); + if (id.startsWith('anthropic/')) return 'anthropic'; + if (id.startsWith('openai/')) return 'openai'; + if (id.startsWith('google/')) return 'google'; + if (id.startsWith('meta-llama/') || id.startsWith('meta/')) return 'meta'; + if (id.startsWith('mistralai/')) return 'mistral'; + // Open source indicators + if (id.includes(':free') || id.includes('qwen') || id.includes('deepseek')) return 'opensource'; + return 'other'; +} + +/** Enrich model with computed fields */ +export function enrichModel(model: OpenRouterModel): CategorizedModel { + return { + ...model, + category: categorizeModel(model), + pricePerMillionPrompt: pricePerMillion(model.pricing.prompt), + pricePerMillionCompletion: pricePerMillion(model.pricing.completion), + isFree: model.pricing.prompt === '0' && model.pricing.completion === '0', + }; +} + +/** Search models by query */ +export function searchModels( + models: CategorizedModel[], + query: string, + filters?: { + category?: ModelCategory; + freeOnly?: boolean; + minContext?: number; + } +): CategorizedModel[] { + const q = query.toLowerCase().trim(); + + return models.filter((model) => { + // Apply filters + if (filters?.category && model.category !== filters.category) return false; + if (filters?.freeOnly && !model.isFree) return false; + if (filters?.minContext && model.context_length < filters.minContext) return false; + + // Search query + if (!q) return true; + return ( + model.id.toLowerCase().includes(q) || + model.name.toLowerCase().includes(q) || + model.description?.toLowerCase().includes(q) + ); + }); +} + +/** Get cached models from localStorage */ +export function getCachedModels(): OpenRouterModel[] | null { + try { + const cached = localStorage.getItem(CACHE_KEY); + if (!cached) return null; + + const data = JSON.parse(cached) as { + models: OpenRouterModel[]; + fetchedAt: number; + version: string; + }; + + // Check version + if (data.version !== CACHE_VERSION) return null; + + // Check TTL + if (Date.now() - data.fetchedAt > CACHE_TTL_MS) return null; + + return data.models; + } catch { + return null; + } +} + +/** Save models to localStorage cache */ +export function setCachedModels(models: OpenRouterModel[]): void { + try { + localStorage.setItem( + CACHE_KEY, + JSON.stringify({ + models, + fetchedAt: Date.now(), + version: CACHE_VERSION, + }) + ); + } catch { + // Storage full or unavailable, ignore + } +} + +/** Clear cached models */ +export function clearCachedModels(): void { + localStorage.removeItem(CACHE_KEY); +} + +/** Suggest tier mappings based on selected model */ +export function suggestTierMappings( + selectedModelId: string, + allModels: CategorizedModel[] +): { opus?: string; sonnet?: string; haiku?: string } { + // Extract provider prefix + const [provider] = selectedModelId.split('/'); + if (!provider) return {}; + + const providerModels = allModels.filter((m) => m.id.startsWith(`${provider}/`)); + if (providerModels.length === 0) return {}; + + // Sort by price (expensive = opus, mid = sonnet, cheap = haiku) + const sorted = [...providerModels].sort( + (a, b) => b.pricePerMillionPrompt - a.pricePerMillionPrompt + ); + + // Simple heuristic: top 1/3 = opus, middle = sonnet, bottom = haiku + const third = Math.ceil(sorted.length / 3); + + return { + opus: sorted[0]?.id, + sonnet: sorted[Math.min(third, sorted.length - 1)]?.id, + haiku: sorted[sorted.length - 1]?.id, + }; +} + +/** Format context length for display */ +export function formatContextLength(length: number): string { + if (length >= 1_000_000) return `${(length / 1_000_000).toFixed(1)}M`; + if (length >= 1_000) return `${Math.round(length / 1_000)}K`; + return String(length); +} + +/** Category display names */ +export const CATEGORY_LABELS: Record = { + anthropic: 'Anthropic (Claude)', + openai: 'OpenAI (GPT)', + google: 'Google (Gemini)', + meta: 'Meta (Llama)', + mistral: 'Mistral', + opensource: 'Open Source', + other: 'Other', +};