feat(channels): refine fetched model categorization (#6632)
* feat(channels): refine fetched model categorization * fix: channel category * fix: hy3 category
This commit is contained in:
@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
*/
|
*/
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { Loader2, Search, Info, ChevronDown } from 'lucide-react'
|
import { Loader2, Search, Info, ChevronDown } from 'lucide-react'
|
||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo, type ReactNode } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
@@ -41,17 +41,16 @@ import {
|
|||||||
|
|
||||||
import { fetchUpstreamModels, updateChannel } from '../../api'
|
import { fetchUpstreamModels, updateChannel } from '../../api'
|
||||||
import {
|
import {
|
||||||
channelsQueryKeys,
|
categorizeModels,
|
||||||
categorizeModelsWithRedirect,
|
categorizeModelsWithRedirect,
|
||||||
|
channelsQueryKeys,
|
||||||
normalizeModelName,
|
normalizeModelName,
|
||||||
parseModelsString,
|
parseModelsString,
|
||||||
} from '../../lib'
|
} from '../../lib'
|
||||||
import { useChannels } from '../channels-provider'
|
import { useChannels } from '../channels-provider'
|
||||||
|
|
||||||
function normalizeModelNameList(models: readonly string[]): string[] {
|
function normalizeModelNameList(models: readonly string[]): string[] {
|
||||||
return Array.from(
|
return [...new Set(models.map((m) => normalizeModelName(m)).filter(Boolean))]
|
||||||
new Set(models.map((m) => normalizeModelName(m)).filter(Boolean))
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type FetchModelsDialogProps = {
|
type FetchModelsDialogProps = {
|
||||||
@@ -140,8 +139,8 @@ export function FetchModelsDialog({
|
|||||||
setFetchedModels(list)
|
setFetchedModels(list)
|
||||||
setSelectedModels(existingModels)
|
setSelectedModels(existingModels)
|
||||||
toast.success(t('Fetched {{count}} models', { count: list.length }))
|
toast.success(t('Fetched {{count}} models', { count: list.length }))
|
||||||
} else {
|
} else if (activeChannel) {
|
||||||
const response = await fetchUpstreamModels(activeChannel!.id)
|
const response = await fetchUpstreamModels(activeChannel.id)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
const list = Array.isArray(response.data) ? response.data : []
|
const list = Array.isArray(response.data) ? response.data : []
|
||||||
setFetchedModels(list)
|
setFetchedModels(list)
|
||||||
@@ -202,45 +201,6 @@ export function FetchModelsDialog({
|
|||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Categorize models by common prefixes
|
|
||||||
const categorizeModels = (models: string[]) => {
|
|
||||||
const categories: Record<string, string[]> = {}
|
|
||||||
|
|
||||||
models.forEach((model) => {
|
|
||||||
let category = 'Other'
|
|
||||||
|
|
||||||
// Determine category based on model name
|
|
||||||
if (
|
|
||||||
model.toLowerCase().includes('gpt') ||
|
|
||||||
model.toLowerCase().includes('o1') ||
|
|
||||||
model.toLowerCase().includes('o3')
|
|
||||||
) {
|
|
||||||
category = 'OpenAI'
|
|
||||||
} else if (model.toLowerCase().includes('claude')) {
|
|
||||||
category = 'Anthropic'
|
|
||||||
} else if (model.toLowerCase().includes('gemini')) {
|
|
||||||
category = 'Gemini'
|
|
||||||
} else if (model.toLowerCase().includes('qwen')) {
|
|
||||||
category = 'Qwen'
|
|
||||||
} else if (model.toLowerCase().includes('deepseek')) {
|
|
||||||
category = 'DeepSeek'
|
|
||||||
} else if (model.toLowerCase().includes('glm')) {
|
|
||||||
category = 'Zhipu'
|
|
||||||
} else if (model.toLowerCase().includes('llama')) {
|
|
||||||
category = 'Meta'
|
|
||||||
} else if (model.toLowerCase().includes('mistral')) {
|
|
||||||
category = 'Mistral'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!categories[category]) {
|
|
||||||
categories[category] = []
|
|
||||||
}
|
|
||||||
categories[category].push(model)
|
|
||||||
})
|
|
||||||
|
|
||||||
return categories
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter models by search
|
// Filter models by search
|
||||||
const filteredModels = useMemo(() => {
|
const filteredModels = useMemo(() => {
|
||||||
if (!searchKeyword) return fetchedModels
|
if (!searchKeyword) return fetchedModels
|
||||||
@@ -249,18 +209,30 @@ export function FetchModelsDialog({
|
|||||||
)
|
)
|
||||||
}, [fetchedModels, searchKeyword])
|
}, [fetchedModels, searchKeyword])
|
||||||
|
|
||||||
// Helper to check if a model is considered "existing" (in selected or redirect)
|
const {
|
||||||
const isExistingModel = (model: string) =>
|
newModels,
|
||||||
classificationSet.has(normalizeModelName(model))
|
existingFilteredModels,
|
||||||
|
newModelsByCategory,
|
||||||
|
existingModelsByCategory,
|
||||||
|
} = useMemo(() => {
|
||||||
|
const newModels: string[] = []
|
||||||
|
const existingFilteredModels: string[] = []
|
||||||
|
|
||||||
// Separate new and existing models
|
for (const model of filteredModels) {
|
||||||
const newModels = filteredModels.filter((m) => !isExistingModel(m))
|
if (classificationSet.has(normalizeModelName(model))) {
|
||||||
const existingFilteredModels = filteredModels.filter((m) =>
|
existingFilteredModels.push(model)
|
||||||
isExistingModel(m)
|
} else {
|
||||||
)
|
newModels.push(model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const newModelsByCategory = categorizeModels(newModels)
|
return {
|
||||||
const existingModelsByCategory = categorizeModels(existingFilteredModels)
|
newModels,
|
||||||
|
existingFilteredModels,
|
||||||
|
newModelsByCategory: categorizeModels(newModels),
|
||||||
|
existingModelsByCategory: categorizeModels(existingFilteredModels),
|
||||||
|
}
|
||||||
|
}, [classificationSet, filteredModels])
|
||||||
|
|
||||||
// 厂商分类按 a-z 排序,Other 放最后,便于查找
|
// 厂商分类按 a-z 排序,Other 放最后,便于查找
|
||||||
const getSortedCategoryEntries = (
|
const getSortedCategoryEntries = (
|
||||||
@@ -345,7 +317,7 @@ export function FetchModelsDialog({
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
render={<Info className='h-3.5 w-3.5 text-amber-500' />}
|
render={<Info className='h-3.5 w-3.5 text-amber-500' />}
|
||||||
></TooltipTrigger>
|
/>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{t('From model redirect, not yet added to models list')}
|
{t('From model redirect, not yet added to models list')}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
@@ -365,24 +337,143 @@ export function FetchModelsDialog({
|
|||||||
!isFetching &&
|
!isFetching &&
|
||||||
(fetchedModels.length > 0 || removedModels.length > 0)
|
(fetchedModels.length > 0 || removedModels.length > 0)
|
||||||
|
|
||||||
|
let dialogDescription: ReactNode = t('Fetch available models from upstream')
|
||||||
|
if (activeChannel) {
|
||||||
|
dialogDescription = (
|
||||||
|
<>
|
||||||
|
{t('Channel:')} <strong>{activeChannel.name}</strong>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
} else if (channelName) {
|
||||||
|
dialogDescription = (
|
||||||
|
<>
|
||||||
|
{t('Channel:')} <strong>{channelName}</strong>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let defaultTab = 'existing'
|
||||||
|
if (newModels.length > 0) {
|
||||||
|
defaultTab = 'new'
|
||||||
|
} else if (removedModels.length > 0) {
|
||||||
|
defaultTab = 'removed'
|
||||||
|
}
|
||||||
|
|
||||||
|
let dialogBody: ReactNode
|
||||||
|
if (!activeChannel && !customFetcher) {
|
||||||
|
dialogBody = (
|
||||||
|
<div className='text-muted-foreground py-8 text-center'>
|
||||||
|
{t('No channel selected')}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else if (isFetching) {
|
||||||
|
dialogBody = (
|
||||||
|
<div className='flex items-center justify-center py-12'>
|
||||||
|
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else if (fetchedModels.length === 0 && removedModels.length === 0) {
|
||||||
|
dialogBody = (
|
||||||
|
<div className='text-muted-foreground py-8 text-center'>
|
||||||
|
<p>{t('No models fetched yet.')}</p>
|
||||||
|
<Button
|
||||||
|
className='mt-4'
|
||||||
|
onClick={handleFetchModels}
|
||||||
|
disabled={isFetching}
|
||||||
|
>
|
||||||
|
{t('Fetch Models')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
dialogBody = (
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className='relative'>
|
||||||
|
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
|
||||||
|
<Input
|
||||||
|
placeholder={t('Search models...')}
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||||
|
className='pl-9'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs for New vs Existing vs Removed */}
|
||||||
|
<Tabs
|
||||||
|
key={`${activeChannel?.id ?? 'custom'}-${fetchedModels.length}-${removedModels.length}`}
|
||||||
|
defaultValue={defaultTab}
|
||||||
|
>
|
||||||
|
<TabsList
|
||||||
|
className={`grid w-full ${removedModels.length > 0 ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||||
|
>
|
||||||
|
<TabsTrigger value='new' disabled={newModels.length === 0}>
|
||||||
|
{t('New Models ({{count}})', { count: newModels.length })}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value='existing'
|
||||||
|
disabled={existingFilteredModels.length === 0}
|
||||||
|
>
|
||||||
|
{t('Existing Models ({{count}})', {
|
||||||
|
count: existingFilteredModels.length,
|
||||||
|
})}
|
||||||
|
</TabsTrigger>
|
||||||
|
{removedModels.length > 0 && (
|
||||||
|
<TabsTrigger value='removed'>
|
||||||
|
{t('Removed Models ({{count}})', {
|
||||||
|
count: removedModels.length,
|
||||||
|
})}
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent
|
||||||
|
value='new'
|
||||||
|
className='max-h-96 space-y-2 overflow-y-auto'
|
||||||
|
>
|
||||||
|
{getSortedCategoryEntries(newModelsByCategory).map(
|
||||||
|
([category, models]) => renderModelCategory(category, models)
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent
|
||||||
|
value='existing'
|
||||||
|
className='max-h-96 space-y-2 overflow-y-auto'
|
||||||
|
>
|
||||||
|
{getSortedCategoryEntries(existingModelsByCategory).map(
|
||||||
|
([category, models]) => renderModelCategory(category, models)
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{removedModels.length > 0 && (
|
||||||
|
<TabsContent
|
||||||
|
value='removed'
|
||||||
|
className='max-h-96 space-y-2 overflow-y-auto'
|
||||||
|
>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
{t(
|
||||||
|
'These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{renderModelCategory(t('Removed'), removedModels)}
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Selection Summary */}
|
||||||
|
<div className='bg-muted/50 rounded-lg border p-3 text-sm'>
|
||||||
|
{t('{{n}} model(s) selected', { n: selectedModels.length })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={handleClose}
|
onOpenChange={handleClose}
|
||||||
title={t('Fetch Models')}
|
title={t('Fetch Models')}
|
||||||
description={
|
description={dialogDescription}
|
||||||
activeChannel ? (
|
|
||||||
<>
|
|
||||||
{t('Channel:')} <strong>{activeChannel.name}</strong>
|
|
||||||
</>
|
|
||||||
) : channelName ? (
|
|
||||||
<>
|
|
||||||
{t('Channel:')} <strong>{channelName}</strong>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
t('Fetch available models from upstream')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
contentClassName='max-w-3xl'
|
contentClassName='max-w-3xl'
|
||||||
contentHeight='auto'
|
contentHeight='auto'
|
||||||
bodyClassName='space-y-4'
|
bodyClassName='space-y-4'
|
||||||
@@ -400,113 +491,7 @@ export function FetchModelsDialog({
|
|||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{!activeChannel && !customFetcher ? (
|
{dialogBody}
|
||||||
<div className='text-muted-foreground py-8 text-center'>
|
|
||||||
{t('No channel selected')}
|
|
||||||
</div>
|
|
||||||
) : isFetching ? (
|
|
||||||
<div className='flex items-center justify-center py-12'>
|
|
||||||
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
|
|
||||||
</div>
|
|
||||||
) : fetchedModels.length === 0 && removedModels.length === 0 ? (
|
|
||||||
<div className='text-muted-foreground py-8 text-center'>
|
|
||||||
<p>{t('No models fetched yet.')}</p>
|
|
||||||
<Button
|
|
||||||
className='mt-4'
|
|
||||||
onClick={handleFetchModels}
|
|
||||||
disabled={isFetching}
|
|
||||||
>
|
|
||||||
{t('Fetch Models')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className='space-y-4'>
|
|
||||||
{/* Search Bar */}
|
|
||||||
<div className='relative'>
|
|
||||||
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
|
|
||||||
<Input
|
|
||||||
placeholder={t('Search models...')}
|
|
||||||
value={searchKeyword}
|
|
||||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
|
||||||
className='pl-9'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs for New vs Existing vs Removed */}
|
|
||||||
<Tabs
|
|
||||||
key={`${activeChannel?.id ?? 'custom'}-${fetchedModels.length}-${removedModels.length}`}
|
|
||||||
defaultValue={
|
|
||||||
newModels.length > 0
|
|
||||||
? 'new'
|
|
||||||
: removedModels.length > 0
|
|
||||||
? 'removed'
|
|
||||||
: 'existing'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TabsList
|
|
||||||
className={`grid w-full ${removedModels.length > 0 ? 'grid-cols-3' : 'grid-cols-2'}`}
|
|
||||||
>
|
|
||||||
<TabsTrigger value='new' disabled={newModels.length === 0}>
|
|
||||||
{t('New Models ({{count}})', { count: newModels.length })}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value='existing'
|
|
||||||
disabled={existingFilteredModels.length === 0}
|
|
||||||
>
|
|
||||||
{t('Existing Models ({{count}})', {
|
|
||||||
count: existingFilteredModels.length,
|
|
||||||
})}
|
|
||||||
</TabsTrigger>
|
|
||||||
{removedModels.length > 0 && (
|
|
||||||
<TabsTrigger value='removed'>
|
|
||||||
{t('Removed Models ({{count}})', {
|
|
||||||
count: removedModels.length,
|
|
||||||
})}
|
|
||||||
</TabsTrigger>
|
|
||||||
)}
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent
|
|
||||||
value='new'
|
|
||||||
className='max-h-96 space-y-2 overflow-y-auto'
|
|
||||||
>
|
|
||||||
{getSortedCategoryEntries(newModelsByCategory).map(
|
|
||||||
([category, models]) => renderModelCategory(category, models)
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent
|
|
||||||
value='existing'
|
|
||||||
className='max-h-96 space-y-2 overflow-y-auto'
|
|
||||||
>
|
|
||||||
{getSortedCategoryEntries(existingModelsByCategory).map(
|
|
||||||
([category, models]) => renderModelCategory(category, models)
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{removedModels.length > 0 && (
|
|
||||||
<TabsContent
|
|
||||||
value='removed'
|
|
||||||
className='max-h-96 space-y-2 overflow-y-auto'
|
|
||||||
>
|
|
||||||
<p className='text-muted-foreground text-xs'>
|
|
||||||
{t(
|
|
||||||
'These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.'
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
{renderModelCategory(t('Removed'), removedModels)}
|
|
||||||
</TabsContent>
|
|
||||||
)}
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{/* Selection Summary */}
|
|
||||||
<div className='bg-muted/50 rounded-lg border p-3 text-sm'>
|
|
||||||
{t('{{n}} model(s) selected', { n: selectedModels.length })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,3 +26,4 @@ export * from './channel-type-config'
|
|||||||
export * from './channel-utils'
|
export * from './channel-utils'
|
||||||
export * from './multi-key-utils'
|
export * from './multi-key-utils'
|
||||||
export * from './model-mapping-validation'
|
export * from './model-mapping-validation'
|
||||||
|
export * from './model-categories'
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2023-2026 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
type ModelCategoryRule = {
|
||||||
|
name: string
|
||||||
|
keywords?: readonly string[]
|
||||||
|
pattern?: RegExp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rules are ordered so platform-specific IDs such as Perplexity's Sonar and
|
||||||
|
// NVIDIA's Nemotron take precedence over the base Llama/Mixtral family name.
|
||||||
|
const MODEL_CATEGORY_RULES: readonly ModelCategoryRule[] = [
|
||||||
|
{ name: 'Perplexity', keywords: ['perplexity', 'sonar-'] },
|
||||||
|
{ name: 'NVIDIA', keywords: ['nvidia/', 'nvidia.', 'nemotron'] },
|
||||||
|
{
|
||||||
|
name: 'OpenAI',
|
||||||
|
keywords: [
|
||||||
|
'openai/',
|
||||||
|
'openai.',
|
||||||
|
'gpt-',
|
||||||
|
'chatgpt-',
|
||||||
|
'codex-',
|
||||||
|
'dall-e-',
|
||||||
|
'whisper-',
|
||||||
|
'tts-',
|
||||||
|
'omni-moderation-',
|
||||||
|
'text-moderation-',
|
||||||
|
'text-embedding-ada-',
|
||||||
|
'text-embedding-3-',
|
||||||
|
'text-ada-',
|
||||||
|
'text-babbage-',
|
||||||
|
'text-curie-',
|
||||||
|
'davinci-',
|
||||||
|
'babbage-',
|
||||||
|
'computer-use-preview',
|
||||||
|
'sora',
|
||||||
|
],
|
||||||
|
pattern: /(?:^|[/.:])o(?:1|3|4)(?=$|[-.:])/,
|
||||||
|
},
|
||||||
|
{ name: 'Anthropic', keywords: ['anthropic', 'claude'] },
|
||||||
|
{
|
||||||
|
name: 'Gemini',
|
||||||
|
keywords: [
|
||||||
|
'gemini',
|
||||||
|
'gemma',
|
||||||
|
'learnlm',
|
||||||
|
'imagen',
|
||||||
|
'veo',
|
||||||
|
'nano-banana',
|
||||||
|
'palm-',
|
||||||
|
],
|
||||||
|
pattern: /(?:^|[/.:])aqa$/,
|
||||||
|
},
|
||||||
|
{ name: 'xAI', keywords: ['x-ai/', 'xai/', 'xai-', 'grok'] },
|
||||||
|
{ name: 'DeepSeek', keywords: ['deepseek'] },
|
||||||
|
{
|
||||||
|
name: 'Qwen',
|
||||||
|
keywords: ['qwen', 'qwq-', 'qvq-', 'tongyi', 'gte-'],
|
||||||
|
pattern: /(?:^|[/.:])(?:text-embedding-v\d+|gui-plus|z-image)(?:$|[-_.:])/,
|
||||||
|
},
|
||||||
|
{ name: 'Wan', pattern: /(?:^|[/.:])wan(?:x?\d|[-_])/ },
|
||||||
|
{ name: 'Moonshot', keywords: ['moonshot', 'kimi-'] },
|
||||||
|
{
|
||||||
|
name: 'MiniMax',
|
||||||
|
keywords: ['minimax', 'abab', 'hailuo'],
|
||||||
|
pattern: /^(?:t2v|i2v|s2v)-01(?:-|$)/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Doubao',
|
||||||
|
keywords: ['doubao', 'volcengine', 'seedance', 'seedream', 'seed-1-'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Zhipu',
|
||||||
|
keywords: ['zhipu', 'zai-org', 'thudm', 'chatglm', 'cogview', 'cogvideo'],
|
||||||
|
pattern: /(?:^|[/._-])glm(?=$|[-._])/,
|
||||||
|
},
|
||||||
|
{ name: 'Baidu', keywords: ['baidu', 'wenxin', 'ernie'] },
|
||||||
|
{ name: 'Yi', keywords: ['01-ai/'], pattern: /(?:^|[/.:])yi(?=$|[-_])/ },
|
||||||
|
{ name: 'iFlytek', keywords: ['iflytek', 'sparkdesk'] },
|
||||||
|
{
|
||||||
|
name: 'Tencent',
|
||||||
|
keywords: ['tencent', 'hunyuan'],
|
||||||
|
pattern: /(?:^|[/.:])hy\d*(?=$|[-_.:])/,
|
||||||
|
},
|
||||||
|
{ name: 'Baichuan', keywords: ['baichuan'] },
|
||||||
|
{ name: 'InternLM', keywords: ['internlm'] },
|
||||||
|
{ name: 'StepFun', keywords: ['stepfun', 'step-'] },
|
||||||
|
{ name: 'MiMo', keywords: ['xiaomi', 'mimo-'] },
|
||||||
|
{
|
||||||
|
name: 'Mistral',
|
||||||
|
keywords: [
|
||||||
|
'mistral',
|
||||||
|
'mixtral',
|
||||||
|
'codestral',
|
||||||
|
'ministral',
|
||||||
|
'pixtral',
|
||||||
|
'magistral',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ name: 'Meta', keywords: ['meta-llama', 'llama-', 'llama2', 'llama3'] },
|
||||||
|
{
|
||||||
|
name: 'Cohere',
|
||||||
|
keywords: ['cohere', 'command-', 'c4ai-aya', 'aya-'],
|
||||||
|
pattern: /(?:^|[/.:])command$/,
|
||||||
|
},
|
||||||
|
{ name: 'Jina', keywords: ['jinaai', 'jina-'] },
|
||||||
|
{ name: 'BAAI', keywords: ['baai/', 'bge-'] },
|
||||||
|
{ name: 'Black Forest Labs', keywords: ['black-forest-labs', 'flux.'] },
|
||||||
|
{
|
||||||
|
name: 'Microsoft',
|
||||||
|
keywords: ['microsoft/'],
|
||||||
|
pattern: /(?:^|[/.:])phi(?=$|[-._])/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Amazon',
|
||||||
|
keywords: ['amazon/', 'amazon.', 'nova-', 'titan-'],
|
||||||
|
},
|
||||||
|
{ name: 'AI21 Labs', keywords: ['ai21', 'jamba'] },
|
||||||
|
{
|
||||||
|
name: 'Stability AI',
|
||||||
|
keywords: ['stabilityai', 'stable-diffusion', 'stable-image', 'sdxl-'],
|
||||||
|
},
|
||||||
|
{ name: 'Nous Research', keywords: ['nousresearch', 'hermes-'] },
|
||||||
|
{ name: '360 AI', keywords: ['360gpt', '360zhinao'] },
|
||||||
|
{ name: 'Midjourney', keywords: ['midjourney', 'mj_', 'mj-', 'swap_face'] },
|
||||||
|
{ name: 'Kling', keywords: ['kling'] },
|
||||||
|
{ name: 'Vidu', keywords: ['vidu'] },
|
||||||
|
{ name: 'Suno', keywords: ['suno'] },
|
||||||
|
{ name: 'Jimeng', keywords: ['jimeng'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getModelCategory(modelName: string): string {
|
||||||
|
const normalizedName = modelName.trim().toLowerCase()
|
||||||
|
|
||||||
|
for (const rule of MODEL_CATEGORY_RULES) {
|
||||||
|
if (
|
||||||
|
rule.keywords?.some((keyword) => normalizedName.includes(keyword)) ||
|
||||||
|
rule.pattern?.test(normalizedName)
|
||||||
|
) {
|
||||||
|
return rule.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Other'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function categorizeModels(
|
||||||
|
models: readonly string[]
|
||||||
|
): Record<string, string[]> {
|
||||||
|
const categories: Record<string, string[]> = {}
|
||||||
|
|
||||||
|
for (const model of models) {
|
||||||
|
const category = getModelCategory(model)
|
||||||
|
categories[category] ??= []
|
||||||
|
categories[category].push(model)
|
||||||
|
}
|
||||||
|
|
||||||
|
return categories
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user