diff --git a/src/server/services/brandMatcher.test.ts b/src/server/services/brandMatcher.test.ts new file mode 100644 index 0000000..4fe7e67 --- /dev/null +++ b/src/server/services/brandMatcher.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { getAllBrandNames, getBlockedBrandRules, isModelBlockedByBrand } from './brandMatcher.js'; + +describe('brandMatcher', () => { + it('exposes newly supported provider and platform brands', () => { + const brands = getAllBrandNames(); + + expect(brands).toContain('OpenRouter'); + expect(brands).toContain('Groq'); + expect(brands).toContain('DeepInfra'); + expect(brands).toContain('Fireworks'); + expect(brands).toContain('Together AI'); + expect(brands).toContain('AI21 Labs'); + expect(brands).toContain('百川智能'); + expect(brands).toContain('Amazon Nova'); + expect(brands).toContain('百炼'); + expect(brands).toContain('Arcee'); + expect(brands).toContain('Xiaomi MiMo'); + expect(brands).toContain('LongCat'); + }); + + it('deduplicates blocked brands and ignores unknown entries', () => { + expect(getBlockedBrandRules(['OpenRouter', 'OpenRouter', 'Unknown Brand'])).toEqual(['OpenRouter']); + }); + + it('uses the shared frontend detection result for global brand blocking', () => { + const providerRules = getBlockedBrandRules(['OpenRouter', 'Groq', '百炼', 'LongCat']); + expect(isModelBlockedByBrand('openrouter/openrouter-auto', providerRules)).toBe(true); + expect(isModelBlockedByBrand('groq/compound-beta', providerRules)).toBe(true); + expect(isModelBlockedByBrand('dashscope/wanx2.1-t2i-turbo', providerRules)).toBe(true); + expect(isModelBlockedByBrand('LongCat-Flash-Lite', providerRules)).toBe(true); + + const vendorRules = getBlockedBrandRules(['Anthropic', 'Meta', 'Google', 'OpenAI', 'Arcee', 'Xiaomi MiMo']); + expect(isModelBlockedByBrand('openrouter/anthropic/claude-3-7-sonnet', vendorRules)).toBe(true); + expect(isModelBlockedByBrand('deepinfra/meta-llama/llama-3.3-70b-instruct', vendorRules)).toBe(true); + expect(isModelBlockedByBrand('vertexai/google/gemini-2.5-pro', vendorRules)).toBe(true); + expect(isModelBlockedByBrand('azureai/gpt-4o', vendorRules)).toBe(true); + expect(isModelBlockedByBrand('arcee-ai/trinity-mini', vendorRules)).toBe(true); + expect(isModelBlockedByBrand('xiaomi/mimo-v2-pro', vendorRules)).toBe(true); + }); +}); diff --git a/src/server/services/brandMatcher.ts b/src/server/services/brandMatcher.ts index f4a93ac..a5f67c4 100644 --- a/src/server/services/brandMatcher.ts +++ b/src/server/services/brandMatcher.ts @@ -1,273 +1,28 @@ -/** - * Server-side brand matching for global brand filtering. - * - * Context-building logic is ported from the frontend BrandIcon.tsx - * (collectBrandCandidates + buildMatchContext) to ensure identical - * matching behavior between route display and rebuild filtering. - */ +import { getAllBrandNames as getSharedBrandNames, getBrand } from '../shared/modelBrand.js'; -type MatchMode = 'includes' | 'startsWith' | 'segment' | 'boundary'; -type KeywordRule = { keyword: string; mode: MatchMode }; +export type BlockedBrandRule = string; -const BRAND_RULES: Record = { - 'OpenAI': [ - { keyword: 'gpt', mode: 'startsWith' }, - { keyword: 'chatgpt', mode: 'startsWith' }, - { keyword: 'dall-e', mode: 'startsWith' }, - { keyword: 'whisper', mode: 'startsWith' }, - { keyword: 'text-embedding', mode: 'startsWith' }, - { keyword: 'text-moderation', mode: 'startsWith' }, - { keyword: 'davinci', mode: 'startsWith' }, - { keyword: 'babbage', mode: 'startsWith' }, - { keyword: 'codex-mini', mode: 'startsWith' }, - { keyword: 'o1', mode: 'startsWith' }, - { keyword: 'o3', mode: 'startsWith' }, - { keyword: 'o4', mode: 'startsWith' }, - { keyword: 'tts', mode: 'startsWith' }, - ], - 'Anthropic': [ - { keyword: 'claude', mode: 'includes' }, - ], - 'Google': [ - { keyword: 'gemini', mode: 'includes' }, - { keyword: 'gemma', mode: 'includes' }, - { keyword: 'google/', mode: 'includes' }, - { keyword: 'palm', mode: 'includes' }, - { keyword: 'paligemma', mode: 'includes' }, - { keyword: 'shieldgemma', mode: 'includes' }, - { keyword: 'recurrentgemma', mode: 'includes' }, - { keyword: 'deplot', mode: 'includes' }, - { keyword: 'codegemma', mode: 'includes' }, - { keyword: 'imagen', mode: 'includes' }, - { keyword: 'learnlm', mode: 'includes' }, - { keyword: 'aqa', mode: 'includes' }, - { keyword: 'veo', mode: 'startsWith' }, - ], - 'DeepSeek': [ - { keyword: 'deepseek', mode: 'includes' }, - { keyword: 'ds-chat', mode: 'segment' }, - ], - '通义千问': [ - { keyword: 'qwen', mode: 'includes' }, - { keyword: 'qwq', mode: 'includes' }, - { keyword: 'tongyi', mode: 'includes' }, - ], - '智谱 AI': [ - { keyword: 'glm', mode: 'includes' }, - { keyword: 'chatglm', mode: 'includes' }, - { keyword: 'codegeex', mode: 'includes' }, - { keyword: 'cogview', mode: 'includes' }, - { keyword: 'cogvideo', mode: 'includes' }, - ], - 'Meta': [ - { keyword: 'llama', mode: 'includes' }, - { keyword: 'code-llama', mode: 'includes' }, - { keyword: 'codellama', mode: 'includes' }, - ], - 'Mistral': [ - { keyword: 'mistral', mode: 'includes' }, - { keyword: 'mixtral', mode: 'includes' }, - { keyword: 'codestral', mode: 'includes' }, - { keyword: 'pixtral', mode: 'includes' }, - { keyword: 'ministral', mode: 'includes' }, - { keyword: 'voxtral', mode: 'includes' }, - { keyword: 'magistral', mode: 'includes' }, - ], - 'Moonshot': [ - { keyword: 'moonshot', mode: 'includes' }, - { keyword: 'kimi', mode: 'includes' }, - ], - '零一万物': [ - { keyword: 'yi-', mode: 'startsWith' }, - { keyword: 'yi', mode: 'boundary' }, - ], - '文心一言': [ - { keyword: 'ernie', mode: 'includes' }, - { keyword: 'eb-', mode: 'includes' }, - ], - '讯飞星火': [ - { keyword: 'spark', mode: 'includes' }, - { keyword: 'generalv', mode: 'includes' }, - ], - '腾讯混元': [ - { keyword: 'hunyuan', mode: 'includes' }, - { keyword: 'tencent-hunyuan', mode: 'includes' }, - ], - '豆包': [ - { keyword: 'doubao', mode: 'includes' }, - ], - 'MiniMax': [ - { keyword: 'minimax', mode: 'includes' }, - { keyword: 'abab', mode: 'includes' }, - { keyword: 'mini2.1', mode: 'segment' }, - ], - 'Cohere': [ - { keyword: 'command', mode: 'includes' }, - { keyword: 'c4ai-', mode: 'includes' }, - { keyword: 'embed-', mode: 'startsWith' }, - ], - 'Microsoft': [ - { keyword: 'microsoft/', mode: 'includes' }, - { keyword: 'phi-', mode: 'includes' }, - { keyword: 'kosmos', mode: 'includes' }, - { keyword: 'phi4', mode: 'segment' }, - ], - 'xAI': [ - { keyword: 'grok', mode: 'includes' }, - ], - '阶跃星辰': [ - { keyword: 'stepfun', mode: 'includes' }, - { keyword: 'step-', mode: 'startsWith' }, - { keyword: 'step3', mode: 'startsWith' }, - ], - 'Stability': [ - { keyword: 'flux', mode: 'includes' }, - { keyword: 'stablediffusion', mode: 'includes' }, - { keyword: 'stable-diffusion', mode: 'includes' }, - { keyword: 'sdxl', mode: 'includes' }, - { keyword: 'sd3', mode: 'startsWith' }, - ], - 'NVIDIA': [ - { keyword: 'nvidia/', mode: 'includes' }, - { keyword: 'nvclip', mode: 'includes' }, - { keyword: 'nemotron', mode: 'includes' }, - { keyword: 'nemoretriever', mode: 'includes' }, - { keyword: 'neva', mode: 'includes' }, - { keyword: 'riva-translate', mode: 'includes' }, - { keyword: 'cosmos', mode: 'includes' }, - { keyword: 'nv-', mode: 'startsWith' }, - ], - 'IBM': [ - { keyword: 'ibm/', mode: 'includes' }, - { keyword: 'granite', mode: 'includes' }, - ], - 'BAAI': [ - { keyword: 'baai/', mode: 'includes' }, - { keyword: 'bge-', mode: 'includes' }, - ], - 'ByteDance': [ - { keyword: 'bytedance', mode: 'includes' }, - { keyword: 'seed-oss', mode: 'includes' }, - { keyword: 'kolors', mode: 'includes' }, - { keyword: 'kwai', mode: 'includes' }, - { keyword: 'kwaipilot', mode: 'includes' }, - { keyword: 'wan-', mode: 'startsWith' }, - { keyword: 'kat-', mode: 'startsWith' }, - ], - 'InternLM': [ - { keyword: 'internlm', mode: 'includes' }, - ], - 'Midjourney': [ - { keyword: 'midjourney', mode: 'includes' }, - { keyword: 'mj_', mode: 'startsWith' }, - ], - 'DeepL': [ - { keyword: 'deepl/', mode: 'includes' }, - { keyword: 'deepl-', mode: 'startsWith' }, - ], - 'Jina AI': [ - { keyword: 'jina', mode: 'includes' }, - ], -}; - -// ── Context building (ported from frontend BrandIcon.tsx) ── - -function stripCommonWrappers(value: string): string { - return value - .replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, '') - .replace(/^re:\s*/g, '') - .replace(/^\^+/, '') - .replace(/\$+$/, '') - .trim(); -} - -/** - * Recursively split model name on `/`, `:`, `,` to build all candidate strings. - * Mirrors the frontend collectBrandCandidates exactly. - */ -function collectCandidates(modelName: string): string[] { - const queue: string[] = []; - const seen = new Set(); - const push = (value: string) => { - const normalized = value.trim().toLowerCase(); - if (!normalized || seen.has(normalized)) return; - seen.add(normalized); - queue.push(normalized); - }; - - push(modelName); - - for (let i = 0; i < queue.length; i++) { - const candidate = queue[i]!; - const cleaned = stripCommonWrappers(candidate); - push(cleaned); - if (cleaned.includes('/')) for (const part of cleaned.split('/')) push(part); - if (cleaned.includes(':')) for (const part of cleaned.split(':')) push(part); - if (cleaned.includes(',')) for (const part of cleaned.split(',')) push(part); - } - - return queue; -} - -type MatchContext = { - raw: string; // first candidate (normalized full name) - candidates: string[]; // all candidates from recursive splitting - segments: string[]; // candidates split on /,:,\s (NOT on - or _) -}; - -function buildMatchContext(modelName: string): MatchContext { - const candidates = collectCandidates(modelName); - const raw = candidates[0] || modelName.trim().toLowerCase(); - // Segments split on /,:,whitespace only — NOT on - or _ (matches frontend) - const segments = Array.from(new Set( - candidates - .flatMap((c) => c.split(/[/:,\s]+/g)) - .map((s) => s.trim()) - .filter(Boolean), - )); - return { raw, candidates, segments }; -} - -// ── Matching functions (mirror frontend includesAny / startsWithAny / hasExactSegment / matchesBoundary) ── - -function matchesRule(ctx: MatchContext, rule: KeywordRule): boolean { - switch (rule.mode) { - case 'includes': - return ctx.raw.includes(rule.keyword) - || ctx.candidates.some((c) => c.includes(rule.keyword)); - case 'startsWith': - return ctx.raw.startsWith(rule.keyword) - || ctx.segments.some((s) => s.startsWith(rule.keyword)) - || ctx.candidates.some((c) => c.startsWith(rule.keyword)); - case 'segment': - return ctx.segments.includes(rule.keyword); - case 'boundary': { - const pattern = new RegExp(`(^|[/:_\\-\\s])${rule.keyword}(?=$|[/:_\\-\\s])`); - return pattern.test(ctx.raw) || ctx.candidates.some((c) => pattern.test(c)); - } - default: - return false; - } -} - -// ── Public API ── +const KNOWN_BRANDS = new Set(getSharedBrandNames()); +const KNOWN_BRAND_LIST = Array.from(KNOWN_BRANDS); export function getAllBrandNames(): string[] { - return Object.keys(BRAND_RULES); + return KNOWN_BRAND_LIST; } -export function getBlockedBrandRules(blockedBrands: string[]): KeywordRule[] { - const rules: KeywordRule[] = []; +export function getBlockedBrandRules(blockedBrands: string[]): BlockedBrandRule[] { + const seen = new Set(); + const rules: string[] = []; for (const brand of blockedBrands) { - const brandRules = BRAND_RULES[brand]; - if (brandRules) rules.push(...brandRules); + const normalized = String(brand || '').trim(); + if (!normalized || seen.has(normalized) || !KNOWN_BRANDS.has(normalized)) continue; + seen.add(normalized); + rules.push(normalized); } return rules; } -export function isModelBlockedByBrand(modelName: string, rules: KeywordRule[]): boolean { +export function isModelBlockedByBrand(modelName: string, rules: BlockedBrandRule[]): boolean { if (!modelName || rules.length === 0) return false; - const ctx = buildMatchContext(modelName); - if (!ctx.raw) return false; - return rules.some((rule) => matchesRule(ctx, rule)); + const brand = getBrand(modelName); + return !!brand && rules.includes(brand.name); } diff --git a/src/server/shared/modelBrand.ts b/src/server/shared/modelBrand.ts new file mode 100644 index 0000000..de93d1a --- /dev/null +++ b/src/server/shared/modelBrand.ts @@ -0,0 +1,806 @@ +export interface BrandMatchContext { + raw: string; + cleaned: string; + segments: string[]; + candidates: string[]; +} + +export interface BrandInfo { + name: string; + icon: string; + color: string; +} + +export type BrandMatchMode = 'includes' | 'startsWith' | 'segment' | 'boundary'; + +type BrandRule = { + keyword: string; + mode: BrandMatchMode; +}; + +type BrandDefinition = BrandInfo & { + rules: BrandRule[]; +}; + +const BRAND_DEFINITIONS: BrandDefinition[] = [ + { + name: 'OpenAI', + icon: 'openai', + color: 'linear-gradient(135deg, #10a37f, #1a7f5a)', + rules: [ + { keyword: 'gpt', mode: 'startsWith' }, + { keyword: 'chatgpt', mode: 'startsWith' }, + { keyword: 'dall-e', mode: 'startsWith' }, + { keyword: 'whisper', mode: 'startsWith' }, + { keyword: 'text-embedding', mode: 'startsWith' }, + { keyword: 'text-moderation', mode: 'startsWith' }, + { keyword: 'davinci', mode: 'startsWith' }, + { keyword: 'babbage', mode: 'startsWith' }, + { keyword: 'codex-mini', mode: 'startsWith' }, + { keyword: 'o1', mode: 'startsWith' }, + { keyword: 'o3', mode: 'startsWith' }, + { keyword: 'o4', mode: 'startsWith' }, + { keyword: 'tts', mode: 'startsWith' }, + ], + }, + { + name: 'Anthropic', + icon: 'claude-color', + color: 'linear-gradient(135deg, #d4a574, #c4956a)', + rules: [ + { keyword: 'claude', mode: 'includes' }, + ], + }, + { + name: 'Google', + icon: 'gemini-color', + color: 'linear-gradient(135deg, #4285f4, #34a853)', + rules: [ + { keyword: 'gemini', mode: 'includes' }, + { keyword: 'gemma', mode: 'includes' }, + { keyword: 'google/', mode: 'includes' }, + { keyword: 'palm', mode: 'includes' }, + { keyword: 'paligemma', mode: 'includes' }, + { keyword: 'shieldgemma', mode: 'includes' }, + { keyword: 'recurrentgemma', mode: 'includes' }, + { keyword: 'deplot', mode: 'includes' }, + { keyword: 'codegemma', mode: 'includes' }, + { keyword: 'imagen', mode: 'includes' }, + { keyword: 'learnlm', mode: 'includes' }, + { keyword: 'aqa', mode: 'includes' }, + { keyword: 'veo', mode: 'startsWith' }, + { keyword: 'google/', mode: 'startsWith' }, + ], + }, + { + name: 'DeepSeek', + icon: 'deepseek-color', + color: 'linear-gradient(135deg, #4d6bfe, #44a3ec)', + rules: [ + { keyword: 'deepseek', mode: 'includes' }, + { keyword: 'ds-chat', mode: 'segment' }, + ], + }, + { + name: '通义千问', + icon: 'qwen-color', + color: 'linear-gradient(135deg, #615cf7, #9b8afb)', + rules: [ + { keyword: 'qwen', mode: 'includes' }, + { keyword: 'qwq', mode: 'includes' }, + { keyword: 'tongyi', mode: 'includes' }, + ], + }, + { + name: '智谱 AI', + icon: 'zhipu-color', + color: 'linear-gradient(135deg, #3b6cf5, #6366f1)', + rules: [ + { keyword: 'glm', mode: 'includes' }, + { keyword: 'chatglm', mode: 'includes' }, + { keyword: 'codegeex', mode: 'includes' }, + { keyword: 'cogview', mode: 'includes' }, + { keyword: 'cogvideo', mode: 'includes' }, + ], + }, + { + name: 'Meta', + icon: 'meta-color', + color: 'linear-gradient(135deg, #0668E1, #1877f2)', + rules: [ + { keyword: 'llama', mode: 'includes' }, + { keyword: 'code-llama', mode: 'includes' }, + { keyword: 'codellama', mode: 'includes' }, + ], + }, + { + name: 'Mistral', + icon: 'mistral-color', + color: 'linear-gradient(135deg, #f7d046, #f2a900)', + rules: [ + { keyword: 'mistral', mode: 'includes' }, + { keyword: 'mixtral', mode: 'includes' }, + { keyword: 'codestral', mode: 'includes' }, + { keyword: 'pixtral', mode: 'includes' }, + { keyword: 'ministral', mode: 'includes' }, + { keyword: 'voxtral', mode: 'includes' }, + { keyword: 'magistral', mode: 'includes' }, + ], + }, + { + name: 'Moonshot', + icon: 'moonshot', + color: 'linear-gradient(135deg, #000000, #333333)', + rules: [ + { keyword: 'moonshot', mode: 'includes' }, + { keyword: 'kimi', mode: 'includes' }, + ], + }, + { + name: '零一万物', + icon: 'yi-color', + color: 'linear-gradient(135deg, #1d4ed8, #3b82f6)', + rules: [ + { keyword: 'yi-', mode: 'startsWith' }, + { keyword: 'yi', mode: 'boundary' }, + ], + }, + { + name: '文心一言', + icon: 'wenxin-color', + color: 'linear-gradient(135deg, #2932e1, #4468f2)', + rules: [ + { keyword: 'ernie', mode: 'includes' }, + { keyword: 'eb-', mode: 'includes' }, + ], + }, + { + name: '讯飞星火', + icon: 'spark-color', + color: 'linear-gradient(135deg, #0070f3, #00d4ff)', + rules: [ + { keyword: 'spark', mode: 'includes' }, + { keyword: 'generalv', mode: 'includes' }, + ], + }, + { + name: '腾讯混元', + icon: 'hunyuan-color', + color: 'linear-gradient(135deg, #00b7ff, #0052d9)', + rules: [ + { keyword: 'hunyuan', mode: 'includes' }, + { keyword: 'tencent-hunyuan', mode: 'includes' }, + ], + }, + { + name: '豆包', + icon: 'doubao-color', + color: 'linear-gradient(135deg, #3b5bdb, #7048e8)', + rules: [ + { keyword: 'doubao', mode: 'includes' }, + ], + }, + { + name: 'MiniMax', + icon: 'minimax-color', + color: 'linear-gradient(135deg, #6366f1, #818cf8)', + rules: [ + { keyword: 'minimax', mode: 'includes' }, + { keyword: 'abab', mode: 'includes' }, + { keyword: 'mini2.1', mode: 'segment' }, + ], + }, + { + name: 'Cohere', + icon: 'cohere-color', + color: 'linear-gradient(135deg, #39594d, #5ba77f)', + rules: [ + { keyword: 'command', mode: 'includes' }, + { keyword: 'c4ai-', mode: 'includes' }, + { keyword: 'aya', mode: 'includes' }, + { keyword: 'embed-', mode: 'startsWith' }, + ], + }, + { + name: 'Microsoft', + icon: 'microsoft-color', + color: 'linear-gradient(135deg, #00bcf2, #0078d4)', + rules: [ + { keyword: 'microsoft/', mode: 'includes' }, + { keyword: 'phi-', mode: 'includes' }, + { keyword: 'kosmos', mode: 'includes' }, + { keyword: 'phi4', mode: 'segment' }, + ], + }, + { + name: 'xAI', + icon: 'xai', + color: 'linear-gradient(135deg, #111, #444)', + rules: [ + { keyword: 'grok', mode: 'includes' }, + ], + }, + { + name: '阶跃星辰', + icon: 'stepfun-color', + color: 'linear-gradient(135deg, #0066ff, #3399ff)', + rules: [ + { keyword: 'stepfun', mode: 'includes' }, + { keyword: 'step-', mode: 'startsWith' }, + { keyword: 'step3', mode: 'startsWith' }, + ], + }, + { + name: '百川智能', + icon: 'baichuan-color', + color: 'linear-gradient(135deg, #0f766e, #14b8a6)', + rules: [ + { keyword: 'baichuan', mode: 'includes' }, + ], + }, + { + name: 'AI21 Labs', + icon: 'ai21-brand-color', + color: 'linear-gradient(135deg, #7c3aed, #a855f7)', + rules: [ + { keyword: 'ai21', mode: 'includes' }, + { keyword: 'jamba', mode: 'startsWith' }, + { keyword: 'jamba', mode: 'includes' }, + ], + }, + { + name: 'AI2', + icon: 'ai2-color', + color: 'linear-gradient(135deg, #0f766e, #14b8a6)', + rules: [ + { keyword: 'allenai', mode: 'includes' }, + { keyword: 'olmo', mode: 'includes' }, + ], + }, + { + name: 'Amazon Nova', + icon: 'nova', + color: 'linear-gradient(135deg, #f59e0b, #f97316)', + rules: [ + { keyword: 'amazon/nova', mode: 'startsWith' }, + { keyword: 'amazon.nova', mode: 'includes' }, + { keyword: 'us.amazon.nova', mode: 'includes' }, + { keyword: 'nova-', mode: 'startsWith' }, + { keyword: 'nova-lite', mode: 'startsWith' }, + { keyword: 'nova-pro', mode: 'startsWith' }, + { keyword: 'nova-micro', mode: 'startsWith' }, + { keyword: 'nova-canvas', mode: 'startsWith' }, + { keyword: 'nova-reel', mode: 'startsWith' }, + ], + }, + { + name: 'Stability', + icon: 'stability-color', + color: 'linear-gradient(135deg, #8b5cf6, #a855f7)', + rules: [ + { keyword: 'flux', mode: 'includes' }, + { keyword: 'stablediffusion', mode: 'includes' }, + { keyword: 'stable-diffusion', mode: 'includes' }, + { keyword: 'sdxl', mode: 'includes' }, + { keyword: 'sd3', mode: 'startsWith' }, + ], + }, + { + name: 'NVIDIA', + icon: 'nvidia-color', + color: 'linear-gradient(135deg, #76b900, #4a8c0b)', + rules: [ + { keyword: 'nvidia/', mode: 'includes' }, + { keyword: 'nvclip', mode: 'includes' }, + { keyword: 'nemotron', mode: 'includes' }, + { keyword: 'nemoretriever', mode: 'includes' }, + { keyword: 'neva', mode: 'includes' }, + { keyword: 'riva-translate', mode: 'includes' }, + { keyword: 'cosmos', mode: 'includes' }, + { keyword: 'nv-', mode: 'startsWith' }, + ], + }, + { + name: 'IBM', + icon: 'ibm', + color: 'linear-gradient(135deg, #0f62fe, #4589ff)', + rules: [ + { keyword: 'ibm/', mode: 'includes' }, + { keyword: 'granite', mode: 'includes' }, + ], + }, + { + name: 'BAAI', + icon: 'baai', + color: 'linear-gradient(135deg, #111827, #374151)', + rules: [ + { keyword: 'baai/', mode: 'includes' }, + { keyword: 'bge-', mode: 'includes' }, + ], + }, + { + name: 'ByteDance', + icon: 'bytedance-color', + color: 'linear-gradient(135deg, #325ab4, #0f66ff)', + rules: [ + { keyword: 'bytedance', mode: 'includes' }, + { keyword: 'seed-oss', mode: 'includes' }, + { keyword: 'kolors', mode: 'includes' }, + { keyword: 'kwai', mode: 'includes' }, + { keyword: 'kwaipilot', mode: 'includes' }, + { keyword: 'wan-', mode: 'startsWith' }, + { keyword: 'kat-', mode: 'startsWith' }, + ], + }, + { + name: 'InternLM', + icon: 'internlm-color', + color: 'linear-gradient(135deg, #1b3882, #4063c5)', + rules: [ + { keyword: 'internlm', mode: 'includes' }, + ], + }, + { + name: 'Midjourney', + icon: 'midjourney', + color: 'linear-gradient(135deg, #4c6ef5, #748ffc)', + rules: [ + { keyword: 'midjourney', mode: 'includes' }, + { keyword: 'mj_', mode: 'startsWith' }, + ], + }, + { + name: 'DeepL', + icon: 'deepl-color', + color: 'linear-gradient(135deg, #0f2b46, #21476f)', + rules: [ + { keyword: 'deepl-', mode: 'startsWith' }, + { keyword: 'deepl/', mode: 'includes' }, + ], + }, + { + name: 'Jina AI', + icon: 'jina', + color: 'linear-gradient(135deg, #111827, #4b5563)', + rules: [ + { keyword: 'jina', mode: 'includes' }, + ], + }, + { + name: 'Relace', + icon: 'relace', + color: 'linear-gradient(135deg, #7c3aed, #6366f1)', + rules: [ + { keyword: 'relace', mode: 'includes' }, + ], + }, + { + name: 'Arcee', + icon: 'arcee-color', + color: 'linear-gradient(135deg, #2563eb, #60a5fa)', + rules: [ + { keyword: 'arcee-ai', mode: 'includes' }, + { keyword: 'arcee', mode: 'includes' }, + ], + }, + { + name: 'AionLabs', + icon: 'aionlabs-color', + color: 'linear-gradient(135deg, #0f766e, #14b8a6)', + rules: [ + { keyword: 'aion-labs', mode: 'includes' }, + { keyword: 'aionlabs', mode: 'includes' }, + ], + }, + { + name: 'DeepCogito', + icon: 'deepcogito-color', + color: 'linear-gradient(135deg, #2563eb, #7c3aed)', + rules: [ + { keyword: 'deepcogito', mode: 'includes' }, + ], + }, + { + name: 'Essential AI', + icon: 'essentialai-color', + color: 'linear-gradient(135deg, #0f172a, #334155)', + rules: [ + { keyword: 'essentialai', mode: 'includes' }, + ], + }, + { + name: 'Inception', + icon: 'inception', + color: 'linear-gradient(135deg, #7c3aed, #ec4899)', + rules: [ + { keyword: 'inception', mode: 'includes' }, + ], + }, + { + name: 'Inflection', + icon: 'inflection', + color: 'linear-gradient(135deg, #1d4ed8, #2563eb)', + rules: [ + { keyword: 'inflection', mode: 'includes' }, + ], + }, + { + name: 'Liquid AI', + icon: 'liquid', + color: 'linear-gradient(135deg, #0f172a, #475569)', + rules: [ + { keyword: 'liquid', mode: 'includes' }, + { keyword: 'lfm-', mode: 'startsWith' }, + ], + }, + { + name: 'LongCat', + icon: 'longcat-color', + color: 'linear-gradient(135deg, #f97316, #fb7185)', + rules: [ + { keyword: 'longcat', mode: 'includes' }, + ], + }, + { + name: 'Morph', + icon: 'morph-color', + color: 'linear-gradient(135deg, #4f46e5, #8b5cf6)', + rules: [ + { keyword: 'morph/', mode: 'includes' }, + { keyword: 'morph-', mode: 'startsWith' }, + ], + }, + { + name: 'Nous Research', + icon: 'nousresearch', + color: 'linear-gradient(135deg, #111827, #4b5563)', + rules: [ + { keyword: 'nousresearch', mode: 'includes' }, + ], + }, + { + name: 'Upstage', + icon: 'upstage-color', + color: 'linear-gradient(135deg, #2563eb, #14b8a6)', + rules: [ + { keyword: 'upstage', mode: 'includes' }, + ], + }, + { + name: 'Xiaomi MiMo', + icon: 'xiaomimimo', + color: 'linear-gradient(135deg, #f97316, #fb923c)', + rules: [ + { keyword: 'xiaomi/mimo', mode: 'includes' }, + { keyword: 'xiaomimimo', mode: 'includes' }, + { keyword: 'mimo-v', mode: 'startsWith' }, + ], + }, + { + name: 'Z.ai', + icon: 'zai', + color: 'linear-gradient(135deg, #0f172a, #2563eb)', + rules: [ + { keyword: '2zai', mode: 'startsWith' }, + { keyword: 'z-ai', mode: 'startsWith' }, + ], + }, + { + name: 'SenseNova', + icon: 'sensenova-brand-color', + color: 'linear-gradient(135deg, #f59e0b, #f97316)', + rules: [ + { keyword: 'sensenova', mode: 'includes' }, + ], + }, + { + name: 'Perplexity', + icon: 'perplexity-color', + color: 'linear-gradient(135deg, #0f766e, #14b8a6)', + rules: [ + { keyword: 'perplexity', mode: 'includes' }, + { keyword: 'pplx-', mode: 'startsWith' }, + ], + }, + { + name: 'OpenRouter', + icon: 'openrouter', + color: 'linear-gradient(135deg, #7c3aed, #2563eb)', + rules: [ + { keyword: 'openrouter', mode: 'includes' }, + { keyword: 'openrouter-', mode: 'startsWith' }, + ], + }, + { + name: 'Groq', + icon: 'groq', + color: 'linear-gradient(135deg, #111827, #374151)', + rules: [ + { keyword: 'groq', mode: 'includes' }, + ], + }, + { + name: 'Fireworks', + icon: 'fireworks-color', + color: 'linear-gradient(135deg, #fb7185, #f97316)', + rules: [ + { keyword: 'fireworks-ai', mode: 'includes' }, + { keyword: 'fireworks', mode: 'includes' }, + ], + }, + { + name: 'DeepInfra', + icon: 'deepinfra-color', + color: 'linear-gradient(135deg, #4f46e5, #7c3aed)', + rules: [ + { keyword: 'deepinfra', mode: 'includes' }, + ], + }, + { + name: 'Together AI', + icon: 'together-brand-color', + color: 'linear-gradient(135deg, #7c3aed, #ec4899)', + rules: [ + { keyword: 'together.ai', mode: 'includes' }, + { keyword: 'together', mode: 'includes' }, + ], + }, + { + name: 'Replicate', + icon: 'replicate-brand', + color: 'linear-gradient(135deg, #111827, #6366f1)', + rules: [ + { keyword: 'replicate', mode: 'includes' }, + ], + }, + { + name: 'SambaNova', + icon: 'sambanova-color', + color: 'linear-gradient(135deg, #2563eb, #06b6d4)', + rules: [ + { keyword: 'sambanova', mode: 'includes' }, + ], + }, + { + name: 'Cerebras', + icon: 'cerebras-brand-color', + color: 'linear-gradient(135deg, #0f766e, #65a30d)', + rules: [ + { keyword: 'cerebras', mode: 'includes' }, + ], + }, + { + name: 'Ollama', + icon: 'ollama', + color: 'linear-gradient(135deg, #1f2937, #4b5563)', + rules: [ + { keyword: 'ollama', mode: 'includes' }, + ], + }, + { + name: 'ModelScope', + icon: 'modelscope-color', + color: 'linear-gradient(135deg, #2563eb, #60a5fa)', + rules: [ + { keyword: 'modelscope', mode: 'includes' }, + ], + }, + { + name: 'SiliconCloud', + icon: 'siliconcloud-color', + color: 'linear-gradient(135deg, #0ea5e9, #22d3ee)', + rules: [ + { keyword: 'siliconcloud', mode: 'includes' }, + { keyword: 'siliconflow', mode: 'includes' }, + ], + }, + { + name: 'Azure AI', + icon: 'azureai-color', + color: 'linear-gradient(135deg, #0284c7, #2563eb)', + rules: [ + { keyword: 'azureai', mode: 'includes' }, + { keyword: 'azure-openai', mode: 'includes' }, + { keyword: 'azure/openai', mode: 'includes' }, + ], + }, + { + name: 'AWS Bedrock', + icon: 'bedrock-color', + color: 'linear-gradient(135deg, #f59e0b, #f97316)', + rules: [ + { keyword: 'bedrock', mode: 'includes' }, + ], + }, + { + name: 'Vertex AI', + icon: 'vertexai-color', + color: 'linear-gradient(135deg, #2563eb, #6366f1)', + rules: [ + { keyword: 'vertexai', mode: 'includes' }, + ], + }, + { + name: 'Google Cloud', + icon: 'googlecloud-brand-color', + color: 'linear-gradient(135deg, #4285f4, #34a853)', + rules: [ + { keyword: 'googlecloud', mode: 'includes' }, + { keyword: 'google-cloud', mode: 'includes' }, + ], + }, + { + name: '百度智能云', + icon: 'baiducloud-color', + color: 'linear-gradient(135deg, #2563eb, #38bdf8)', + rules: [ + { keyword: 'baiducloud', mode: 'includes' }, + { keyword: 'qianfan', mode: 'includes' }, + ], + }, + { + name: '百炼', + icon: 'bailian-color', + color: 'linear-gradient(135deg, #7c3aed, #2563eb)', + rules: [ + { keyword: 'bailian', mode: 'includes' }, + { keyword: 'dashscope', mode: 'includes' }, + ], + }, + { + name: '阿里云', + icon: 'alibabacloud-color', + color: 'linear-gradient(135deg, #f97316, #fb923c)', + rules: [ + { keyword: 'alibabacloud', mode: 'includes' }, + ], + }, + { + name: '火山引擎', + icon: 'volcengine-color', + color: 'linear-gradient(135deg, #325ab4, #0f66ff)', + rules: [ + { keyword: 'volcengine', mode: 'includes' }, + ], + }, + { + name: '七牛云', + icon: 'qiniu-color', + color: 'linear-gradient(135deg, #06b6d4, #0891b2)', + rules: [ + { keyword: 'qiniu', mode: 'includes' }, + ], + }, +]; + +function normalizeInput(value: string): string { + return String(value || '').trim().toLowerCase(); +} + +export function stripCommonWrappers(value: string): string { + return value + .replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, '') + .replace(/^re:\s*/g, '') + .replace(/^\^+/, '') + .replace(/\$+$/, '') + .trim(); +} + +export function collectBrandCandidates(modelName: string): string[] { + const queue: string[] = []; + const seen = new Set(); + const push = (value: string) => { + const normalized = normalizeInput(value); + if (!normalized || seen.has(normalized)) return; + seen.add(normalized); + queue.push(normalized); + }; + + push(modelName); + + for (let index = 0; index < queue.length; index += 1) { + const candidate = queue[index]!; + const cleaned = stripCommonWrappers(candidate); + push(cleaned); + + if (cleaned.includes('/')) { + for (const part of cleaned.split('/')) push(part); + } + if (cleaned.includes(':')) { + for (const part of cleaned.split(':')) push(part); + } + if (cleaned.includes(',')) { + for (const part of cleaned.split(',')) push(part); + } + } + + return queue; +} + +function buildMatchContext(modelName: string): BrandMatchContext { + const candidates = collectBrandCandidates(modelName); + const raw = candidates[0] || normalizeInput(modelName); + const cleaned = stripCommonWrappers(raw); + const segments = Array.from(new Set( + candidates + .flatMap((candidate) => candidate.split(/[/:,\s]+/g)) + .map((segment) => segment.trim()) + .filter(Boolean), + )); + + return { + raw, + cleaned, + segments, + candidates, + }; +} + +function matchesRule(context: BrandMatchContext, rule: BrandRule): boolean { + switch (rule.mode) { + case 'includes': + return context.raw.includes(rule.keyword) + || context.cleaned.includes(rule.keyword) + || context.candidates.some((candidate) => candidate.includes(rule.keyword)); + case 'startsWith': + return context.raw.startsWith(rule.keyword) + || context.cleaned.startsWith(rule.keyword) + || context.segments.some((segment) => segment.startsWith(rule.keyword)) + || context.candidates.some((candidate) => candidate.startsWith(rule.keyword)); + case 'segment': + return context.segments.includes(rule.keyword); + case 'boundary': { + const pattern = new RegExp(`(^|[/:_\\-\\s])${escapeRegExp(rule.keyword)}(?=$|[/:_\\-\\s])`); + return pattern.test(context.raw) + || pattern.test(context.cleaned) + || context.candidates.some((candidate) => pattern.test(candidate)); + } + default: + return false; + } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +const BRAND_FALLBACK_BOUNDARY_RULES = BRAND_DEFINITIONS.map((brand) => ({ + brand, + boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(brand.name.toLowerCase())}(?=$|[^a-z0-9])`), +})); + +export function getAllBrands(): BrandInfo[] { + return BRAND_DEFINITIONS.map(({ name, icon, color }) => ({ name, icon, color })); +} + +export function getAllBrandNames(): string[] { + return BRAND_DEFINITIONS.map((brand) => brand.name); +} + +export function getBrand(modelName: string): BrandInfo | null { + const context = buildMatchContext(modelName); + + for (const definition of BRAND_DEFINITIONS) { + if (definition.rules.some((rule) => matchesRule(context, rule))) { + return { + name: definition.name, + icon: definition.icon, + color: definition.color, + }; + } + } + + for (const candidate of context.candidates) { + for (const rule of BRAND_FALLBACK_BOUNDARY_RULES) { + if (rule.boundaryRegex.test(candidate)) { + return { + name: rule.brand.name, + icon: rule.brand.icon, + color: rule.brand.color, + }; + } + } + } + + return null; +} diff --git a/src/web/components/BrandIcon.test.ts b/src/web/components/BrandIcon.test.ts index 1081dd2..66ba08f 100644 --- a/src/web/components/BrandIcon.test.ts +++ b/src/web/components/BrandIcon.test.ts @@ -47,6 +47,47 @@ describe('getBrand', () => { expect(getBrand('jina-embeddings-v3')).toMatchObject({ name: 'Jina AI', icon: expect.any(String) }); }); + it('detects new provider and platform brands with lobe-hub icons', () => { + expect(getBrand('openrouter/openrouter-auto')).toMatchObject({ name: 'OpenRouter', icon: expect.any(String) }); + expect(getBrand('groq/compound-beta')).toMatchObject({ name: 'Groq', icon: expect.any(String) }); + expect(getBrand('deepinfra/deepinfra-chat')).toMatchObject({ name: 'DeepInfra', icon: expect.any(String) }); + expect(getBrand('fireworks-ai/firefunction-v2')).toMatchObject({ name: 'Fireworks', icon: expect.any(String) }); + expect(getBrand('together/together-chat')).toMatchObject({ name: 'Together AI', icon: expect.any(String) }); + expect(getBrand('replicate/recraft-v3')).toMatchObject({ name: 'Replicate', icon: expect.any(String) }); + expect(getBrand('cerebras/cerebras-chat')).toMatchObject({ name: 'Cerebras', icon: expect.any(String) }); + expect(getBrand('jamba-1.5-mini')).toMatchObject({ name: 'AI21 Labs', icon: expect.any(String) }); + expect(getBrand('baichuan-m1-14b-instruct')).toMatchObject({ name: '百川智能', icon: expect.any(String) }); + expect(getBrand('sensenova/sensenova-v6')).toMatchObject({ name: 'SenseNova', icon: expect.any(String) }); + expect(getBrand('bedrock/us.amazon.nova-pro-v1:0')).toMatchObject({ name: 'Amazon Nova', icon: expect.any(String) }); + expect(getBrand('amazon/nova-premier-v1')).toMatchObject({ name: 'Amazon Nova', icon: expect.any(String) }); + expect(getBrand('dashscope/wanx2.1-t2i-turbo')).toMatchObject({ name: '百炼', icon: expect.any(String) }); + }); + + it('recognizes user-reported uncategorized brands when current lobe-hub icons exist', () => { + expect(getBrand('relace/relace-search')).toMatchObject({ name: 'Relace', icon: expect.any(String) }); + expect(getBrand('xiaomi/mimo-v2-pro')).toMatchObject({ name: 'Xiaomi MiMo', icon: expect.any(String) }); + expect(getBrand('arcee-ai/trinity-mini')).toMatchObject({ name: 'Arcee', icon: expect.any(String) }); + expect(getBrand('aion-labs/aion-2.0')).toMatchObject({ name: 'AionLabs', icon: expect.any(String) }); + expect(getBrand('allenai/olmo-3-32b-think')).toMatchObject({ name: 'AI2', icon: expect.any(String) }); + expect(getBrand('deepcogito/cogito-v2.1-671b')).toMatchObject({ name: 'DeepCogito', icon: expect.any(String) }); + expect(getBrand('essentialai/rnj-1-instruct')).toMatchObject({ name: 'Essential AI', icon: expect.any(String) }); + expect(getBrand('inflection/inflection-3-pi')).toMatchObject({ name: 'Inflection', icon: expect.any(String) }); + expect(getBrand('liquid/lfm-2-24b-a2b')).toMatchObject({ name: 'Liquid AI', icon: expect.any(String) }); + expect(getBrand('LongCat-Flash-Lite')).toMatchObject({ name: 'LongCat', icon: expect.any(String) }); + expect(getBrand('morph/morph-v3-fast')).toMatchObject({ name: 'Morph', icon: expect.any(String) }); + expect(getBrand('nousresearch/hermes-4-70b')).toMatchObject({ name: 'Nous Research', icon: expect.any(String) }); + expect(getBrand('inception/mercury')).toMatchObject({ name: 'Inception', icon: expect.any(String) }); + expect(getBrand('upstage/solar-pro-3')).toMatchObject({ name: 'Upstage', icon: expect.any(String) }); + expect(getBrand('2zai')).toMatchObject({ name: 'Z.ai', icon: expect.any(String) }); + }); + + it('prefers intrinsic model vendors over upstream hosting platforms', () => { + expect(getBrand('openrouter/anthropic/claude-3-7-sonnet')?.name).toBe('Anthropic'); + expect(getBrand('deepinfra/meta-llama/llama-3.3-70b-instruct')?.name).toBe('Meta'); + expect(getBrand('vertexai/google/gemini-2.5-pro')?.name).toBe('Google'); + expect(getBrand('azureai/gpt-4o')?.name).toBe('OpenAI'); + }); + it('returns null for unknown model names', () => { expect(getBrand('totally-unknown-model')).toBeNull(); }); diff --git a/src/web/components/brandRegistry.ts b/src/web/components/brandRegistry.ts index 5be2e35..762b29c 100644 --- a/src/web/components/brandRegistry.ts +++ b/src/web/components/brandRegistry.ts @@ -1,22 +1,12 @@ -export interface BrandMatchContext { - raw: string; - cleaned: string; - segments: string[]; -} - -interface InternalBrandMatchContext extends BrandMatchContext { - candidates: string[]; -} - -export interface BrandInfo { - name: string; - icon: string; - color: string; -} - -type BrandDefinition = BrandInfo & { - match: (context: InternalBrandMatchContext) => boolean; -}; +export { + collectBrandCandidates, + getAllBrandNames, + getAllBrands, + getBrand, + stripCommonWrappers, + type BrandInfo, + type BrandMatchContext, +} from '../../server/shared/modelBrand.js'; const LEGACY_ICON_ALIASES: Record = { anthropic: 'claude-color', @@ -45,285 +35,6 @@ function normalizeInput(value: string): string { return String(value || '').trim().toLowerCase(); } -function stripCommonWrappers(value: string): string { - return value - .replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, '') - .replace(/^re:\s*/g, '') - .replace(/^\^+/, '') - .replace(/\$+$/, '') - .trim(); -} - -function collectBrandCandidates(modelName: string): string[] { - const queue: string[] = []; - const seen = new Set(); - const push = (value: string) => { - const normalized = normalizeInput(value); - if (!normalized || seen.has(normalized)) return; - seen.add(normalized); - queue.push(normalized); - }; - - push(modelName); - - for (let index = 0; index < queue.length; index += 1) { - const candidate = queue[index]!; - const cleaned = stripCommonWrappers(candidate); - push(cleaned); - - if (cleaned.includes('/')) { - for (const part of cleaned.split('/')) push(part); - } - if (cleaned.includes(':')) { - for (const part of cleaned.split(':')) push(part); - } - if (cleaned.includes(',')) { - for (const part of cleaned.split(',')) push(part); - } - } - - return queue; -} - -function buildMatchContext(modelName: string): InternalBrandMatchContext { - const candidates = collectBrandCandidates(modelName); - const raw = candidates[0] || normalizeInput(modelName); - const cleaned = stripCommonWrappers(raw); - const segments = Array.from(new Set( - candidates - .flatMap((candidate) => candidate.split(/[/:,\s]+/g)) - .map((segment) => segment.trim()) - .filter(Boolean), - )); - - return { - raw, - cleaned, - segments, - candidates, - }; -} - -function includesAny(context: InternalBrandMatchContext, needles: string[]): boolean { - return needles.some((needle) => ( - context.raw.includes(needle) - || context.cleaned.includes(needle) - || context.candidates.some((candidate) => candidate.includes(needle)) - )); -} - -function startsWithAny(context: InternalBrandMatchContext, needles: string[]): boolean { - return needles.some((needle) => ( - context.raw.startsWith(needle) - || context.cleaned.startsWith(needle) - || context.segments.some((segment) => segment.startsWith(needle)) - || context.candidates.some((candidate) => candidate.startsWith(needle)) - )); -} - -function hasExactSegment(context: InternalBrandMatchContext, needles: string[]): boolean { - return needles.some((needle) => context.segments.includes(needle)); -} - -function matchesBoundary(context: InternalBrandMatchContext, pattern: RegExp): boolean { - return pattern.test(context.raw) || pattern.test(context.cleaned) || context.candidates.some((candidate) => pattern.test(candidate)); -} - -const BRAND_DEFINITIONS: BrandDefinition[] = [ - { - name: 'OpenAI', - icon: 'openai', - color: 'linear-gradient(135deg, #10a37f, #1a7f5a)', - match: (context) => ( - startsWithAny(context, ['gpt', 'chatgpt', 'dall-e', 'whisper', 'text-embedding', 'text-moderation', 'davinci', 'babbage', 'codex-mini']) - || startsWithAny(context, ['o1', 'o3', 'o4', 'tts']) - ), - }, - { - name: 'Anthropic', - icon: 'claude-color', - color: 'linear-gradient(135deg, #d4a574, #c4956a)', - match: (context) => includesAny(context, ['claude']), - }, - { - name: 'Google', - icon: 'gemini-color', - color: 'linear-gradient(135deg, #4285f4, #34a853)', - match: (context) => ( - includesAny(context, ['gemini', 'gemma', 'google/', 'palm', 'paligemma', 'shieldgemma', 'recurrentgemma', 'deplot', 'codegemma', 'imagen', 'learnlm', 'aqa']) - || startsWithAny(context, ['veo', 'google/']) - ), - }, - { - name: 'DeepSeek', - icon: 'deepseek-color', - color: 'linear-gradient(135deg, #4d6bfe, #44a3ec)', - match: (context) => includesAny(context, ['deepseek']) || hasExactSegment(context, ['ds-chat']), - }, - { - name: '通义千问', - icon: 'qwen-color', - color: 'linear-gradient(135deg, #615cf7, #9b8afb)', - match: (context) => includesAny(context, ['qwen', 'qwq', 'tongyi']), - }, - { - name: '智谱 AI', - icon: 'zhipu-color', - color: 'linear-gradient(135deg, #3b6cf5, #6366f1)', - match: (context) => includesAny(context, ['glm', 'chatglm', 'codegeex', 'cogview', 'cogvideo']), - }, - { - name: 'Meta', - icon: 'meta-color', - color: 'linear-gradient(135deg, #0668E1, #1877f2)', - match: (context) => includesAny(context, ['llama', 'code-llama', 'codellama']), - }, - { - name: 'Mistral', - icon: 'mistral-color', - color: 'linear-gradient(135deg, #f7d046, #f2a900)', - match: (context) => includesAny(context, ['mistral', 'mixtral', 'codestral', 'pixtral', 'ministral', 'voxtral', 'magistral']), - }, - { - name: 'Moonshot', - icon: 'moonshot', - color: 'linear-gradient(135deg, #000000, #333333)', - match: (context) => includesAny(context, ['moonshot', 'kimi']), - }, - { - name: '零一万物', - icon: 'yi-color', - color: 'linear-gradient(135deg, #1d4ed8, #3b82f6)', - match: (context) => ( - startsWithAny(context, ['yi-']) - || matchesBoundary(context, /(^|[/:_\-\s])yi(?=$|[/:_\-\s])/) - ), - }, - { - name: '文心一言', - icon: 'wenxin-color', - color: 'linear-gradient(135deg, #2932e1, #4468f2)', - match: (context) => includesAny(context, ['ernie', 'eb-']), - }, - { - name: '讯飞星火', - icon: 'spark-color', - color: 'linear-gradient(135deg, #0070f3, #00d4ff)', - match: (context) => includesAny(context, ['spark', 'generalv']), - }, - { - name: '腾讯混元', - icon: 'hunyuan-color', - color: 'linear-gradient(135deg, #00b7ff, #0052d9)', - match: (context) => includesAny(context, ['hunyuan', 'tencent-hunyuan']), - }, - { - name: '豆包', - icon: 'doubao-color', - color: 'linear-gradient(135deg, #3b5bdb, #7048e8)', - match: (context) => includesAny(context, ['doubao']), - }, - { - name: 'MiniMax', - icon: 'minimax-color', - color: 'linear-gradient(135deg, #6366f1, #818cf8)', - match: (context) => includesAny(context, ['minimax', 'abab']) || hasExactSegment(context, ['mini2.1']), - }, - { - name: 'Cohere', - icon: 'cohere-color', - color: 'linear-gradient(135deg, #39594d, #5ba77f)', - match: (context) => includesAny(context, ['command', 'c4ai-']) || startsWithAny(context, ['embed-']), - }, - { - name: 'Microsoft', - icon: 'microsoft-color', - color: 'linear-gradient(135deg, #00bcf2, #0078d4)', - match: (context) => ( - includesAny(context, ['microsoft/', 'phi-', 'kosmos']) - || hasExactSegment(context, ['phi4']) - ), - }, - { - name: 'xAI', - icon: 'xai', - color: 'linear-gradient(135deg, #111, #444)', - match: (context) => includesAny(context, ['grok']), - }, - { - name: '阶跃星辰', - icon: 'stepfun-color', - color: 'linear-gradient(135deg, #0066ff, #3399ff)', - match: (context) => includesAny(context, ['stepfun']) || startsWithAny(context, ['step-', 'step3']), - }, - { - name: 'Stability', - icon: 'stability-color', - color: 'linear-gradient(135deg, #8b5cf6, #a855f7)', - match: (context) => includesAny(context, ['flux', 'stablediffusion', 'stable-diffusion', 'sdxl']) || startsWithAny(context, ['sd3']), - }, - { - name: 'NVIDIA', - icon: 'nvidia-color', - color: 'linear-gradient(135deg, #76b900, #4a8c0b)', - match: (context) => ( - includesAny(context, ['nvidia/', 'nvclip', 'nemotron', 'nemoretriever', 'neva', 'riva-translate', 'cosmos']) - || startsWithAny(context, ['nv-']) - ), - }, - { - name: 'IBM', - icon: 'ibm', - color: 'linear-gradient(135deg, #0f62fe, #4589ff)', - match: (context) => includesAny(context, ['ibm/', 'granite']), - }, - { - name: 'BAAI', - icon: 'baai', - color: 'linear-gradient(135deg, #111827, #374151)', - match: (context) => includesAny(context, ['baai/', 'bge-']), - }, - { - name: 'ByteDance', - icon: 'bytedance-color', - color: 'linear-gradient(135deg, #325ab4, #0f66ff)', - match: (context) => includesAny(context, ['bytedance', 'seed-oss', 'kolors', 'kwai', 'kwaipilot']) || startsWithAny(context, ['wan-', 'kat-']), - }, - { - name: 'InternLM', - icon: 'internlm-color', - color: 'linear-gradient(135deg, #1b3882, #4063c5)', - match: (context) => includesAny(context, ['internlm']), - }, - { - name: 'Midjourney', - icon: 'midjourney', - color: 'linear-gradient(135deg, #4c6ef5, #748ffc)', - match: (context) => includesAny(context, ['midjourney']) || startsWithAny(context, ['mj_']), - }, - { - name: 'DeepL', - icon: 'deepl-color', - color: 'linear-gradient(135deg, #0f2b46, #21476f)', - match: (context) => startsWithAny(context, ['deepl-']) || includesAny(context, ['deepl/']), - }, - { - name: 'Jina AI', - icon: 'jina', - color: 'linear-gradient(135deg, #111827, #4b5563)', - match: (context) => includesAny(context, ['jina']), - }, -]; - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -const BRAND_FALLBACK_BOUNDARY_RULES = BRAND_DEFINITIONS.map((brand) => ({ - brand, - boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(brand.name.toLowerCase())}(?=$|[^a-z0-9])`), -})); - const FALLBACK_COLORS = [ 'linear-gradient(135deg, #4f46e5, #818cf8)', 'linear-gradient(135deg, #059669, #34d399)', @@ -347,38 +58,10 @@ export function getBrandIconUrl(icon: string | null | undefined, cdn: string): s return `${cdn}/${normalized}.png`; } -export function getBrand(modelName: string): BrandInfo | null { - const context = buildMatchContext(modelName); - - for (const definition of BRAND_DEFINITIONS) { - if (definition.match(context)) { - return { - name: definition.name, - icon: definition.icon, - color: definition.color, - }; - } - } - - for (const candidate of context.candidates) { - for (const rule of BRAND_FALLBACK_BOUNDARY_RULES) { - if (rule.boundaryRegex.test(candidate)) { - return { - name: rule.brand.name, - icon: rule.brand.icon, - color: rule.brand.color, - }; - } - } - } - - return null; -} - export function hashColor(name: string): string { let h = 0; for (let i = 0; i < name.length; i += 1) h = (h * 31 + name.charCodeAt(i)) | 0; - return FALLBACK_COLORS[Math.abs(h) % FALLBACK_COLORS.length]; + return FALLBACK_COLORS[Math.abs(h) % FALLBACK_COLORS.length]!; } export function avatarLetters(name: string): string { diff --git a/src/web/pages/Models.marketplace-text.test.tsx b/src/web/pages/Models.marketplace-text.test.tsx index e3d46f0..89b7437 100644 --- a/src/web/pages/Models.marketplace-text.test.tsx +++ b/src/web/pages/Models.marketplace-text.test.tsx @@ -195,6 +195,214 @@ describe('Models marketplace text', () => { } }); + it('shows newly added provider fallback brands without losing vendor brands in the filter panel', async () => { + apiMock.getModelsMarketplace.mockResolvedValue({ + models: [ + { + name: 'openrouter/openrouter-auto', + accountCount: 1, + tokenCount: 1, + avgLatency: 180, + successRate: 99, + description: null, + tags: [], + supportedEndpointTypes: [], + pricingSources: [], + accounts: [ + { + id: 1, + site: '平台站 A', + username: 'tester', + latency: 180, + balance: 9.9, + tokens: [{ id: 1, name: 'default', isDefault: true }], + }, + ], + }, + { + name: 'deepinfra/meta-llama/llama-3.3-70b-instruct', + accountCount: 1, + tokenCount: 1, + avgLatency: 240, + successRate: 98, + description: null, + tags: [], + supportedEndpointTypes: [], + pricingSources: [], + accounts: [ + { + id: 2, + site: '平台站 B', + username: 'tester', + latency: 240, + balance: 7.2, + tokens: [{ id: 2, name: 'default', isDefault: true }], + }, + ], + }, + { + name: 'groq/compound-beta', + accountCount: 1, + tokenCount: 1, + avgLatency: 95, + successRate: 99, + description: null, + tags: [], + supportedEndpointTypes: [], + pricingSources: [], + accounts: [ + { + id: 3, + site: '平台站 C', + username: 'tester', + latency: 95, + balance: 5.1, + tokens: [{ id: 3, name: 'default', isDefault: true }], + }, + ], + }, + ], + }); + + let root!: WebTestRenderer; + + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const text = collectText(root!.root); + expect(text).toContain('OpenRouter'); + expect(text).toContain('Meta'); + expect(text).toContain('Groq'); + expect(text).not.toContain('其他未归类的模型'); + } finally { + root?.unmount(); + } + }); + + it('shows user-reported recognizable brands in the marketplace filter panel', async () => { + apiMock.getModelsMarketplace.mockResolvedValue({ + models: [ + { + name: 'xiaomi/mimo-v2-pro', + accountCount: 1, + tokenCount: 1, + avgLatency: 180, + successRate: 99, + description: null, + tags: [], + supportedEndpointTypes: [], + pricingSources: [], + accounts: [ + { + id: 1, + site: '平台站 A', + username: 'tester', + latency: 180, + balance: 9.9, + tokens: [{ id: 1, name: 'default', isDefault: true }], + }, + ], + }, + { + name: 'arcee-ai/trinity-mini', + accountCount: 1, + tokenCount: 1, + avgLatency: 240, + successRate: 98, + description: null, + tags: [], + supportedEndpointTypes: [], + pricingSources: [], + accounts: [ + { + id: 2, + site: '平台站 B', + username: 'tester', + latency: 240, + balance: 7.2, + tokens: [{ id: 2, name: 'default', isDefault: true }], + }, + ], + }, + { + name: 'amazon/nova-premier-v1', + accountCount: 1, + tokenCount: 1, + avgLatency: 95, + successRate: 99, + description: null, + tags: [], + supportedEndpointTypes: [], + pricingSources: [], + accounts: [ + { + id: 3, + site: '平台站 C', + username: 'tester', + latency: 95, + balance: 5.1, + tokens: [{ id: 3, name: 'default', isDefault: true }], + }, + ], + }, + { + name: 'LongCat-Flash-Lite', + accountCount: 1, + tokenCount: 1, + avgLatency: 95, + successRate: 99, + description: null, + tags: [], + supportedEndpointTypes: [], + pricingSources: [], + accounts: [ + { + id: 4, + site: '平台站 D', + username: 'tester', + latency: 95, + balance: 5.1, + tokens: [{ id: 4, name: 'default', isDefault: true }], + }, + ], + }, + ], + }); + + let root!: WebTestRenderer; + + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const text = collectText(root!.root); + expect(text).toContain('Xiaomi MiMo'); + expect(text).toContain('Arcee'); + expect(text).toContain('Amazon Nova'); + expect(text).toContain('LongCat'); + expect(text).not.toContain('其他未归类的模型'); + } finally { + root?.unmount(); + } + }); + it('keeps a visible mobile filter entry on small screens', async () => { const nextWindow = (originalWindow ? { ...originalWindow } : {}) as Window & typeof globalThis; nextWindow.innerWidth = 768;