feat(ui): add dynamic newest models detection for OpenRouter

- add created timestamp field to OpenRouterModel type
- add getNewestModelsPerProvider() for dynamic newest models
- add formatModelAge() for relative time display (e.g., "2d ago")
- show newest models section in picker when no search query
- sort search results by created date (newest first)
This commit is contained in:
kaitranntt
2025-12-20 19:47:18 -05:00
parent d193626e3b
commit a1cbd4d923
3 changed files with 98 additions and 1 deletions
@@ -9,12 +9,14 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton';
import { Search, RefreshCw, Loader2 } from 'lucide-react';
import { Search, RefreshCw, Loader2, Sparkles } from 'lucide-react';
import { useOpenRouterCatalog, useRefreshOpenRouterModels } from '@/hooks/use-openrouter-models';
import {
searchModels,
formatPricingPair,
formatContextLength,
formatModelAge,
getNewestModelsPerProvider,
CATEGORY_LABELS,
} from '@/lib/openrouter-utils';
import type { CategorizedModel, ModelCategory } from '@/lib/openrouter-types';
@@ -46,6 +48,14 @@ export function OpenRouterModelPicker({
});
}, [models, search, selectedCategory]);
// Get newest models for presets (shown when no search)
const newestModels = useMemo(() => {
return getNewestModelsPerProvider(models, 2);
}, [models]);
// Determine if we should show presets (no search query and no category filter)
const showPresets = !search.trim() && !selectedCategory;
// Group by category
const groupedModels = useMemo(() => {
const groups: Record<ModelCategory, CategorizedModel[]> = {
@@ -159,6 +169,26 @@ export function OpenRouterModelPicker({
</div>
) : (
<div className="space-y-4 p-2">
{/* Newest Models Section (shown when no search) */}
{showPresets && newestModels.length > 0 && (
<div>
<div className="text-muted-foreground bg-background sticky top-0 mb-1 flex items-center gap-1.5 py-1 text-xs font-semibold">
<Sparkles className="h-3 w-3 text-orange-500" />
<span>Newest Models</span>
</div>
{newestModels.map((model) => (
<ModelItem
key={model.id}
model={model}
isSelected={model.id === value}
onClick={() => onChange(model.id)}
showAge
/>
))}
</div>
)}
{/* Category Groups */}
{(Object.keys(CATEGORY_LABELS) as ModelCategory[]).map((category) => {
const categoryModels = groupedModels[category];
if (categoryModels.length === 0) return null;
@@ -190,10 +220,12 @@ function ModelItem({
model,
isSelected,
onClick,
showAge = false,
}: {
model: CategorizedModel;
isSelected: boolean;
onClick: () => void;
showAge?: boolean;
}) {
return (
<button
@@ -206,6 +238,11 @@ function ModelItem({
>
<span className="flex-1 truncate">{model.name}</span>
<span className="text-muted-foreground ml-2 flex items-center gap-2 text-xs">
{showAge && model.created && (
<Badge variant="outline" className="text-[10px] text-orange-600">
{formatModelAge(model.created)}
</Badge>
)}
{model.isFree ? (
<Badge variant="secondary" className="text-xs">
Free
+1
View File
@@ -40,6 +40,7 @@ export interface OpenRouterModel {
top_provider: OpenRouterTopProvider;
supported_parameters: string[];
per_request_limits: Record<string, string> | null;
created: number; // Unix timestamp when model was added to OpenRouter
}
export interface OpenRouterModelsResponse {
+59
View File
@@ -174,3 +174,62 @@ export const CATEGORY_LABELS: Record<ModelCategory, string> = {
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 'Today';
if (diff < 172800) return 'Yesterday';
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
if (diff < 2592000) return `${Math.floor(diff / 604800)}w ago`;
if (diff < 31536000) return `${Math.floor(diff / 2592000)}mo ago`;
return `${Math.floor(diff / 31536000)}y ago`;
}