[codex] improve LobeHub brand detection coverage (#464)
* 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 * Preserve provider brand blocking without changing display-brand priority The first pass unified brand detection but server-side blocked-brand checks started relying on the single display brand. Mixed model paths such as `deepinfra/meta-llama/...` and `azureai/gpt-4o` still need provider markers to match global blocked brands even while the UI continues to show the preferred vendor label. Constraint: Marketplace display should keep the current preferred vendor-first brand label Constraint: Server-side blocked-brand matching must still recognize provider and vendor markers on the same model id Rejected: Reorder display-brand priority to make provider blocks pass | would regress the marketplace labels the PR was improving Rejected: Reintroduce a separate server-only matcher | would recreate the web/server drift this PR removed Confidence: high Scope-risk: narrow Reversibility: clean Directive: Use getMatchingBrandNames for blocking-style checks and reserve getBrand for single-label display surfaces Tested: npm test -- src/server/shared/modelBrand.test.ts src/server/services/brandMatcher.test.ts src/web/components/BrandIcon.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 GitHub review-thread auto-resolution behavior after push * Honor legacy blocked brand casing when canonicalizing global brand filters The shared brand matcher now supports multi-match provider blocking, but the blocked-brand rule parser still required exact display-case brand names. That meant imported or legacy settings like `openai` or `aws bedrock` were silently ignored even though they should map to known brands. Constraint: Existing saved settings may contain lower-case or whitespace-variant brand names from imports or older data Constraint: The server should keep emitting canonical display names after normalizing user-provided blocked brand entries Rejected: Assume the settings UI always writes canonical brand strings | does not cover imported or hand-edited configuration Rejected: Normalize only at the settings route | would still leave hydrated legacy config values ineffective in modelService Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep blocked-brand parsing tolerant of case and spacing drift, but always return canonical brand names for downstream use Tested: npm test -- src/server/services/brandMatcher.test.ts src/server/shared/modelBrand.test.ts src/web/components/BrandIcon.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: Manual settings UI round-trip with legacy lowercase globalBlockedBrands payloads
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
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('canonicalizes blocked brand names before validating them', () => {
|
||||||
|
expect(getBlockedBrandRules(['openai', ' aws bedrock ', 'unknown brand', 'OPENAI'])).toEqual(['OpenAI', 'AWS Bedrock']);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
expect(isModelBlockedByBrand('deepinfra/meta-llama/llama-3.3-70b-instruct', getBlockedBrandRules(['DeepInfra']))).toBe(true);
|
||||||
|
expect(isModelBlockedByBrand('azureai/gpt-4o', getBlockedBrandRules(['Azure AI']))).toBe(true);
|
||||||
|
expect(isModelBlockedByBrand('bedrock/us.amazon.nova-pro-v1:0', getBlockedBrandRules(['AWS Bedrock']))).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,35 @@
|
|||||||
/**
|
import { getAllBrandNames as getSharedBrandNames, getMatchingBrandNames } 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 CANONICAL_BRAND_BY_KEY = new Map(
|
||||||
{ keyword: 'gpt', mode: 'startsWith' },
|
getSharedBrandNames().map((brand) => [normalizeBrandKey(brand), brand] as const),
|
||||||
{ keyword: 'chatgpt', mode: 'startsWith' },
|
);
|
||||||
{ keyword: 'dall-e', mode: 'startsWith' },
|
const KNOWN_BRAND_LIST = Array.from(KNOWN_BRANDS);
|
||||||
{ 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 normalizeBrandKey(value: string): string {
|
||||||
|
return String(value || '').trim().replace(/\s+/g, ' ').toLowerCase();
|
||||||
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 canonical = CANONICAL_BRAND_BY_KEY.get(normalizeBrandKey(brand));
|
||||||
if (brandRules) rules.push(...brandRules);
|
if (!canonical || seen.has(canonical)) continue;
|
||||||
|
seen.add(canonical);
|
||||||
|
rules.push(canonical);
|
||||||
}
|
}
|
||||||
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 matchedBrands = new Set(getMatchingBrandNames(modelName));
|
||||||
if (!ctx.raw) return false;
|
return rules.some((rule) => matchedBrands.has(rule));
|
||||||
return rules.some((rule) => matchesRule(ctx, rule));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { getBrand, getMatchingBrandNames } from './modelBrand.js';
|
||||||
|
|
||||||
|
describe('modelBrand matching helpers', () => {
|
||||||
|
it('returns all matched provider and vendor brands while preserving display priority', () => {
|
||||||
|
expect(getMatchingBrandNames('openrouter/anthropic/claude-3-7-sonnet')).toEqual(['Anthropic', 'OpenRouter']);
|
||||||
|
expect(getMatchingBrandNames('deepinfra/meta-llama/llama-3.3-70b-instruct')).toEqual(['Meta', 'DeepInfra']);
|
||||||
|
expect(getMatchingBrandNames('azureai/gpt-4o')).toEqual(['OpenAI', 'Azure AI']);
|
||||||
|
expect(getMatchingBrandNames('bedrock/us.amazon.nova-pro-v1:0')).toEqual(['Amazon Nova', 'AWS Bedrock']);
|
||||||
|
|
||||||
|
expect(getBrand('openrouter/anthropic/claude-3-7-sonnet')?.name).toBe('Anthropic');
|
||||||
|
expect(getBrand('deepinfra/meta-llama/llama-3.3-70b-instruct')?.name).toBe('Meta');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,822 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBrandInfo(brand: BrandDefinition): BrandInfo {
|
||||||
|
return {
|
||||||
|
name: brand.name,
|
||||||
|
icon: brand.icon,
|
||||||
|
color: brand.color,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMatchingBrands(modelName: string): BrandInfo[] {
|
||||||
|
const context = buildMatchContext(modelName);
|
||||||
|
const matches: BrandInfo[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
const add = (brand: BrandDefinition) => {
|
||||||
|
if (seen.has(brand.name)) return;
|
||||||
|
seen.add(brand.name);
|
||||||
|
matches.push(toBrandInfo(brand));
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const definition of BRAND_DEFINITIONS) {
|
||||||
|
if (definition.rules.some((rule) => matchesRule(context, rule))) {
|
||||||
|
add(definition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of context.candidates) {
|
||||||
|
for (const rule of BRAND_FALLBACK_BOUNDARY_RULES) {
|
||||||
|
if (rule.boundaryRegex.test(candidate)) {
|
||||||
|
add(rule.brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMatchingBrandNames(modelName: string): string[] {
|
||||||
|
return getMatchingBrands(modelName).map((brand) => brand.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrand(modelName: string): BrandInfo | null {
|
||||||
|
return getMatchingBrands(modelName)[0] || 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