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