Files
ccs/ui/src/lib/openrouter-utils.ts
T
Tam Nhu Tran c3401f0a91 feat(ui): complete dashboard i18n coverage across 162 files
Replace all remaining hardcoded English strings with t() calls across
the CCS dashboard UI. Add zh-CN, vi, ja translations for all new keys.

- 162 files updated (components, hooks, pages, lib utilities)
- 7 domains covered: layout/shared, accounts/auth, CLIProxy,
  compatible CLI (Codex/Cursor/Droid/Copilot), logs/monitoring,
  analytics, and lib utilities
- 4 locales: en, zh-CN, vi, ja with natural (not literal) translations
- Fix duplicate customPresetDialog key shadowing in en/zh-CN
- Fix missing ja nav.logs and providerModelSelector keys
- Fix openrouter-banner count type (string → number)
- Fix add-account-dialog double setLocalError call
- Fix codex-model-providers-card missing useTranslation

Closes #983
2026-04-13 20:52:30 -04:00

266 lines
8.2 KiB
TypeScript

/**
* OpenRouter Model Catalog Utilities
* Search, filter, pricing, and categorization
*/
import type { OpenRouterModel, CategorizedModel, ModelCategory } from './openrouter-types';
import i18n from './i18n';
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 i18n.t('openrouterUtils.priceFree');
if (perMillion < 0.01) return i18n.t('openrouterUtils.priceLessThanCent');
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 === i18n.t('openrouterUtils.priceFree') &&
completionPrice === i18n.t('openrouterUtils.priceFree')
)
return i18n.t('openrouterUtils.priceFree');
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',
isExacto: model.id.includes(':exacto'), // Exacto variants - optimized for agentic/tool use
};
}
/** 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)
);
});
}
/**
* Sort models with priority: Free > Exacto > Regular
* Within each tier, sort by name alphabetically
*/
export function sortModelsByPriority(models: CategorizedModel[]): CategorizedModel[] {
return [...models].sort((a, b) => {
// Priority 1: Free models first
if (a.isFree && !b.isFree) return -1;
if (!a.isFree && b.isFree) return 1;
// Priority 2: Exacto models second (only if both not free)
if (!a.isFree && !b.isFree) {
if (a.isExacto && !b.isExacto) return -1;
if (!a.isExacto && b.isExacto) return 1;
}
// Same tier: sort by name
return a.name.localeCompare(b.name);
});
}
/** 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',
};
/** Provider prefixes for detecting newest models */
const PROVIDER_PREFIXES: Record<ModelCategory, string[]> = {
anthropic: ['anthropic/'],
openai: ['openai/'],
google: ['google/'],
meta: ['meta-llama/', 'meta/'],
mistral: ['mistralai/'],
opensource: ['deepseek/', 'qwen/', 'cohere/'],
other: [],
};
/** Get the newest models per provider (sorted by created timestamp) */
export function getNewestModelsPerProvider(
allModels: CategorizedModel[],
modelsPerProvider: number = 2
): CategorizedModel[] {
const result: CategorizedModel[] = [];
const categories: ModelCategory[] = [
'anthropic',
'openai',
'google',
'meta',
'mistral',
'opensource',
];
for (const category of categories) {
const prefixes = PROVIDER_PREFIXES[category];
if (prefixes.length === 0) continue;
// Get models for this provider
const providerModels = allModels.filter((m) =>
prefixes.some((prefix) => m.id.toLowerCase().startsWith(prefix))
);
// Sort by created timestamp (newest first)
const sorted = [...providerModels].sort((a, b) => (b.created ?? 0) - (a.created ?? 0));
// Take top N
result.push(...sorted.slice(0, modelsPerProvider));
}
// Sort final result by created (newest first)
return result.sort((a, b) => (b.created ?? 0) - (a.created ?? 0));
}
/** Format relative time for model creation date */
export function formatModelAge(created: number): string {
const now = Date.now() / 1000; // Convert to seconds
const diff = now - created;
if (diff < 86400) return i18n.t('openrouterUtils.ageToday');
if (diff < 172800) return i18n.t('openrouterUtils.ageYesterday');
if (diff < 604800)
return i18n.t('openrouterUtils.ageDaysAgo', { count: Math.floor(diff / 86400) });
if (diff < 2592000)
return i18n.t('openrouterUtils.ageWeeksAgo', { count: Math.floor(diff / 604800) });
if (diff < 31536000)
return i18n.t('openrouterUtils.ageMonthsAgo', { count: Math.floor(diff / 2592000) });
return i18n.t('openrouterUtils.ageYearsAgo', { count: Math.floor(diff / 31536000) });
}