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 @@
+
\ 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',
+};