feat(ui): add OpenRouter model catalog core infrastructure

- add openrouter-types.ts with API type definitions

- add openrouter-utils.ts with search, pricing, caching utils

- add use-openrouter-models.ts React Query hook with 24h cache

- copy openrouter.svg icon to public/icons/
This commit is contained in:
kaitranntt
2025-12-20 18:29:44 -05:00
parent 277480d00c
commit 80beb1dada
4 changed files with 325 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenRouter</title><path d="M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z"></path></svg>

After

Width:  |  Height:  |  Size: 906 B

+77
View File
@@ -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<OpenRouterModel[]> {
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,
};
}
+71
View File
@@ -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<string, string> | 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;
}
+176
View File
@@ -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<ModelCategory, string> = {
anthropic: 'Anthropic (Claude)',
openai: 'OpenAI (GPT)',
google: 'Google (Gemini)',
meta: 'Meta (Llama)',
mistral: 'Mistral',
opensource: 'Open Source',
other: 'Other',
};