Recognize more LobeHub-backed model brands so marketplace filters stop collapsing into other

Metapi was leaving many models under the uncategorized bucket even though the
current LobeHub icon set already contains matching provider or vendor brands.
This change moves brand detection into one shared registry, expands the rules
with the user-reported gaps that have real upstream icon coverage, and makes
server-side brand blocking reuse the same classification result as the web UI.

Constraint: Existing workspace has unrelated in-progress edits, so this commit stages only the brand-classification slice
Constraint: Brand matching must stay consistent between marketplace display and server-side blocked-brand filtering
Rejected: Add more ad hoc rules separately in web and server files | would keep the drift bug alive
Rejected: Blindly import every LobeHub slug as a model brand | would pollute filters with tool and app brands
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep new brand additions in src/server/shared/modelBrand.ts and let web/server consume that single source of truth
Tested: npm test -- src/web/components/BrandIcon.test.ts src/server/services/brandMatcher.test.ts src/web/pages/Models.marketplace-text.test.tsx src/web/components/BrandIcon.architecture.test.ts src/web/components/BrandIcon.render.test.tsx
Tested: npm run typecheck
Tested: npm run repo:drift-check
Not-tested: Live browser verification against the user's current marketplace dataset after deployment
This commit is contained in:
cita-777
2026-04-09 19:43:47 +08:00
parent a3886324ca
commit 6cca74b039
6 changed files with 1121 additions and 587 deletions
+41
View File
@@ -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();
});
+10 -327
View File
@@ -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<string, string> = {
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<string>();
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 {