forked from boos/BoosAPI
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:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,273 +1,28 @@
|
|||||||
/**
|
import { getAllBrandNames as getSharedBrandNames, getBrand } from '../shared/modelBrand.js';
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
type MatchMode = 'includes' | 'startsWith' | 'segment' | 'boundary';
|
export type BlockedBrandRule = string;
|
||||||
type KeywordRule = { keyword: string; mode: MatchMode };
|
|
||||||
|
|
||||||
const BRAND_RULES: Record<string, KeywordRule[]> = {
|
const KNOWN_BRANDS = new Set(getSharedBrandNames());
|
||||||
'OpenAI': [
|
const KNOWN_BRAND_LIST = Array.from(KNOWN_BRANDS);
|
||||||
{ 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<string>();
|
|
||||||
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 ──
|
|
||||||
|
|
||||||
export function getAllBrandNames(): string[] {
|
export function getAllBrandNames(): string[] {
|
||||||
return Object.keys(BRAND_RULES);
|
return KNOWN_BRAND_LIST;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBlockedBrandRules(blockedBrands: string[]): KeywordRule[] {
|
export function getBlockedBrandRules(blockedBrands: string[]): BlockedBrandRule[] {
|
||||||
const rules: KeywordRule[] = [];
|
const seen = new Set<string>();
|
||||||
|
const rules: string[] = [];
|
||||||
for (const brand of blockedBrands) {
|
for (const brand of blockedBrands) {
|
||||||
const brandRules = BRAND_RULES[brand];
|
const normalized = String(brand || '').trim();
|
||||||
if (brandRules) rules.push(...brandRules);
|
if (!normalized || seen.has(normalized) || !KNOWN_BRANDS.has(normalized)) continue;
|
||||||
|
seen.add(normalized);
|
||||||
|
rules.push(normalized);
|
||||||
}
|
}
|
||||||
return rules;
|
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;
|
if (!modelName || rules.length === 0) return false;
|
||||||
const ctx = buildMatchContext(modelName);
|
const brand = getBrand(modelName);
|
||||||
if (!ctx.raw) return false;
|
return !!brand && rules.includes(brand.name);
|
||||||
return rules.some((rule) => matchesRule(ctx, rule));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<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): 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;
|
||||||
|
}
|
||||||
@@ -47,6 +47,47 @@ describe('getBrand', () => {
|
|||||||
expect(getBrand('jina-embeddings-v3')).toMatchObject({ name: 'Jina AI', icon: expect.any(String) });
|
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', () => {
|
it('returns null for unknown model names', () => {
|
||||||
expect(getBrand('totally-unknown-model')).toBeNull();
|
expect(getBrand('totally-unknown-model')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,12 @@
|
|||||||
export interface BrandMatchContext {
|
export {
|
||||||
raw: string;
|
collectBrandCandidates,
|
||||||
cleaned: string;
|
getAllBrandNames,
|
||||||
segments: string[];
|
getAllBrands,
|
||||||
}
|
getBrand,
|
||||||
|
stripCommonWrappers,
|
||||||
interface InternalBrandMatchContext extends BrandMatchContext {
|
type BrandInfo,
|
||||||
candidates: string[];
|
type BrandMatchContext,
|
||||||
}
|
} from '../../server/shared/modelBrand.js';
|
||||||
|
|
||||||
export interface BrandInfo {
|
|
||||||
name: string;
|
|
||||||
icon: string;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type BrandDefinition = BrandInfo & {
|
|
||||||
match: (context: InternalBrandMatchContext) => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const LEGACY_ICON_ALIASES: Record<string, string> = {
|
const LEGACY_ICON_ALIASES: Record<string, string> = {
|
||||||
anthropic: 'claude-color',
|
anthropic: 'claude-color',
|
||||||
@@ -45,285 +35,6 @@ function normalizeInput(value: string): string {
|
|||||||
return String(value || '').trim().toLowerCase();
|
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 = [
|
const FALLBACK_COLORS = [
|
||||||
'linear-gradient(135deg, #4f46e5, #818cf8)',
|
'linear-gradient(135deg, #4f46e5, #818cf8)',
|
||||||
'linear-gradient(135deg, #059669, #34d399)',
|
'linear-gradient(135deg, #059669, #34d399)',
|
||||||
@@ -347,38 +58,10 @@ export function getBrandIconUrl(icon: string | null | undefined, cdn: string): s
|
|||||||
return `${cdn}/${normalized}.png`;
|
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 {
|
export function hashColor(name: string): string {
|
||||||
let h = 0;
|
let h = 0;
|
||||||
for (let i = 0; i < name.length; i += 1) h = (h * 31 + name.charCodeAt(i)) | 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 {
|
export function avatarLetters(name: string): string {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<MemoryRouter initialEntries={['/models']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<Models />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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(
|
||||||
|
<MemoryRouter initialEntries={['/models']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<Models />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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 () => {
|
it('keeps a visible mobile filter entry on small screens', async () => {
|
||||||
const nextWindow = (originalWindow ? { ...originalWindow } : {}) as Window & typeof globalThis;
|
const nextWindow = (originalWindow ? { ...originalWindow } : {}) as Window & typeof globalThis;
|
||||||
nextWindow.innerWidth = 768;
|
nextWindow.innerWidth = 768;
|
||||||
|
|||||||
Reference in New Issue
Block a user