1
0
forked from boos/BoosAPI

fix: address review issues in model filter system

- brandMatcher: split keywords into includes/startsWith/segment/boundary
  modes to align with frontend BrandIcon.tsx matching semantics, preventing
  false-positive blocking from short keywords like o1, tts, command
- handleSiteBlockModel: reject wildcard route patterns (e.g. gpt-*) from
  being written to site disabled list; only allow exact model names
- Sites.tsx: extract brand grouping to useMemo, convert disabledModels
  to Set for O(1) lookup, remove duplicate IIFE computations in JSX
- handleSiteBlockModel: replace window.confirm with project-style DOM
  dialog for visual consistency
- enabledCounts: compute from filtered scope (excluding enabled filter
  itself) so counts reflect current brand/site/search filters
- Fix declaration order: move enabledCounts after sortedRoutes to avoid
  ReferenceError
This commit is contained in:
Hureru
2026-03-22 00:13:41 +08:00
parent cf6ed7db91
commit f14ffd7dc9
4 changed files with 359 additions and 139 deletions
+222 -47
View File
@@ -1,69 +1,244 @@
/**
* Server-side brand matching for global brand filtering.
*
* Brand keywords are extracted from the frontend BrandIcon.tsx BRAND_DEFINITIONS.
* Each brand maps to a set of keywords that, when found in a model name, identify
* the model as belonging to that brand.
* Matching semantics are aligned with the frontend BrandIcon.tsx BRAND_DEFINITIONS:
* - `includes`: substring match (e.g. 'claude' matches 'claude-3-opus')
* - `startsWith`: segment-level startsWith (e.g. 'gpt' matches 'gpt-4o' but not 'some-gpt')
* - `segment`: exact segment match after splitting on /:-_ delimiters
* - `boundary`: word-boundary regex match
*/
const BRAND_KEYWORDS: Record<string, string[]> = {
'OpenAI': ['gpt', 'chatgpt', 'dall-e', 'whisper', 'text-embedding', 'text-moderation', 'davinci', 'babbage', 'codex-mini', 'o1', 'o3', 'o4', 'tts'],
'Anthropic': ['claude'],
'Google': ['gemini', 'gemma', 'palm', 'paligemma', 'shieldgemma', 'recurrentgemma', 'deplot', 'codegemma', 'imagen', 'learnlm', 'aqa', 'veo'],
'DeepSeek': ['deepseek', 'ds-chat'],
'通义千问': ['qwen', 'qwq', 'tongyi'],
'智谱 AI': ['glm', 'chatglm', 'codegeex', 'cogview', 'cogvideo'],
'Meta': ['llama', 'code-llama', 'codellama'],
'Mistral': ['mistral', 'mixtral', 'codestral', 'pixtral', 'ministral', 'voxtral', 'magistral'],
'Moonshot': ['moonshot', 'kimi'],
'零一万物': ['yi-'],
'文心一言': ['ernie', 'eb-'],
'讯飞星火': ['spark', 'generalv'],
'腾讯混元': ['hunyuan', 'tencent-hunyuan'],
'豆包': ['doubao'],
'MiniMax': ['minimax', 'abab'],
'Cohere': ['command', 'c4ai-'],
'Microsoft': ['phi-', 'kosmos'],
'xAI': ['grok'],
'阶跃星辰': ['stepfun', 'step-'],
'Stability': ['flux', 'stablediffusion', 'stable-diffusion', 'sdxl', 'sd3'],
'NVIDIA': ['nemotron', 'nemoretriever', 'neva', 'riva-translate', 'cosmos'],
'IBM': ['granite'],
'BAAI': ['bge-'],
'ByteDance': ['bytedance', 'seed-oss', 'kolors', 'kwai', 'kwaipilot', 'wan-', 'kat-'],
'InternLM': ['internlm'],
'Midjourney': ['midjourney', 'mj_'],
'DeepL': ['deepl-', 'deepl/'],
'Jina AI': ['jina'],
type MatchMode = 'includes' | 'startsWith' | 'segment' | 'boundary';
type KeywordRule = { keyword: string; mode: MatchMode };
const BRAND_RULES: Record<string, KeywordRule[]> = {
// OpenAI: all startsWithAny in frontend
'OpenAI': [
{ keyword: 'gpt', mode: 'startsWith' },
{ keyword: 'chatgpt', mode: 'startsWith' },
{ keyword: 'dall-e', mode: 'startsWith' },
{ keyword: 'whisper', mode: 'startsWith' },
{ keyword: 'text-embedding', mode: 'startsWith' },
{ keyword: 'text-moderation', mode: 'startsWith' },
{ keyword: 'davinci', mode: 'startsWith' },
{ keyword: 'babbage', mode: 'startsWith' },
{ keyword: 'codex-mini', mode: 'startsWith' },
{ keyword: 'o1', mode: 'startsWith' },
{ keyword: 'o3', mode: 'startsWith' },
{ keyword: 'o4', mode: 'startsWith' },
{ keyword: 'tts', mode: 'startsWith' },
],
// Anthropic: includesAny
'Anthropic': [
{ keyword: 'claude', mode: 'includes' },
],
// Google: includesAny + startsWithAny for veo/google/
'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: includesAny + hasExactSegment
'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' },
],
// 零一万物: startsWithAny('yi-') + matchesBoundary
'零一万物': [
{ 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: includesAny + hasExactSegment
'MiniMax': [
{ keyword: 'minimax', mode: 'includes' },
{ keyword: 'abab', mode: 'includes' },
{ keyword: 'mini2.1', mode: 'segment' },
],
// Cohere: includesAny + startsWithAny('embed-')
'Cohere': [
{ keyword: 'command', mode: 'includes' },
{ keyword: 'c4ai-', mode: 'includes' },
{ keyword: 'embed-', mode: 'startsWith' },
],
// Microsoft: includesAny + hasExactSegment('phi4')
'Microsoft': [
{ keyword: 'microsoft/', mode: 'includes' },
{ keyword: 'phi-', mode: 'includes' },
{ keyword: 'kosmos', mode: 'includes' },
{ keyword: 'phi4', mode: 'segment' },
],
'xAI': [
{ keyword: 'grok', mode: 'includes' },
],
// 阶跃星辰: includesAny('stepfun') + startsWithAny('step-', 'step3')
'阶跃星辰': [
{ keyword: 'stepfun', mode: 'includes' },
{ keyword: 'step-', mode: 'startsWith' },
{ keyword: 'step3', mode: 'startsWith' },
],
// Stability: includesAny + startsWithAny('sd3')
'Stability': [
{ keyword: 'flux', mode: 'includes' },
{ keyword: 'stablediffusion', mode: 'includes' },
{ keyword: 'stable-diffusion', mode: 'includes' },
{ keyword: 'sdxl', mode: 'includes' },
{ keyword: 'sd3', mode: 'startsWith' },
],
// NVIDIA: includesAny + startsWithAny('nv-')
'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: includesAny + startsWithAny('wan-', 'kat-')
'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: includesAny + startsWithAny('mj_')
'Midjourney': [
{ keyword: 'midjourney', mode: 'includes' },
{ keyword: 'mj_', mode: 'startsWith' },
],
// DeepL: startsWithAny('deepl-') + includesAny('deepl/')
'DeepL': [
{ keyword: 'deepl/', mode: 'includes' },
{ keyword: 'deepl-', mode: 'startsWith' },
],
'Jina AI': [
{ keyword: 'jina', mode: 'includes' },
],
};
/**
* Get all available brand names.
*/
export function getAllBrandNames(): string[] {
return Object.keys(BRAND_KEYWORDS);
return Object.keys(BRAND_RULES);
}
/**
* Get the flattened list of keywords for blocked brands.
* Collect the rules for the blocked brands.
*/
export function getBlockedModelKeywords(blockedBrands: string[]): string[] {
const keywords: string[] = [];
export function getBlockedBrandRules(blockedBrands: string[]): KeywordRule[] {
const rules: KeywordRule[] = [];
for (const brand of blockedBrands) {
const brandKeywords = BRAND_KEYWORDS[brand];
if (brandKeywords) {
keywords.push(...brandKeywords);
}
const brandRules = BRAND_RULES[brand];
if (brandRules) rules.push(...brandRules);
}
return keywords;
return rules;
}
/**
* Check if a model name matches any of the blocked keywords.
* Uses a case-insensitive substring match (same approach as frontend BrandIcon).
* Split model name into segments (matching the frontend's segment logic).
*/
export function isModelBlockedByBrand(modelName: string, blockedKeywords: string[]): boolean {
const lower = modelName.trim().toLowerCase();
if (!lower) return false;
return blockedKeywords.some((keyword) => lower.includes(keyword));
function getSegments(lower: string): string[] {
return lower.split(/[/:,\s_\-]+/g).filter(Boolean);
}
/**
* Check if a model name matches a single keyword rule.
*/
function matchesRule(lower: string, segments: string[], rule: KeywordRule): boolean {
switch (rule.mode) {
case 'includes':
return lower.includes(rule.keyword);
case 'startsWith':
// Match if any segment starts with the keyword, or the full name starts with it
return lower.startsWith(rule.keyword) || segments.some((seg) => seg.startsWith(rule.keyword));
case 'segment':
return segments.includes(rule.keyword);
case 'boundary': {
const pattern = new RegExp(`(^|[^a-z0-9])${rule.keyword}(?=$|[^a-z0-9])`);
return pattern.test(lower);
}
default:
return false;
}
}
/**
* Check if a model name matches any of the blocked brand rules.
*/
export function isModelBlockedByBrand(modelName: string, rules: KeywordRule[]): boolean {
const lower = modelName.trim().toLowerCase();
if (!lower || rules.length === 0) return false;
const segments = getSegments(lower);
return rules.some((rule) => matchesRule(lower, segments, rule));
}
+3 -3
View File
@@ -18,7 +18,7 @@ import {
supportsDirectAccountRoutingConnection,
} from './accountExtraConfig.js';
import { invalidateTokenRouterCache } from './tokenRouter.js';
import { getBlockedModelKeywords, isModelBlockedByBrand } from './brandMatcher.js';
import { getBlockedBrandRules, isModelBlockedByBrand } from './brandMatcher.js';
import { config } from '../config.js';
import { setAccountRuntimeHealth } from './accountHealthService.js';
import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js';
@@ -1142,14 +1142,14 @@ export async function rebuildTokenRoutesFromAvailability() {
}
// Load global brand filter
const blockedBrandKeywords = getBlockedModelKeywords(config.globalBlockedBrands);
const blockedBrandRules = getBlockedBrandRules(config.globalBlockedBrands);
const modelCandidates = new Map<string, Map<string, { accountId: number; tokenId: number | null }>>();
const addModelCandidate = (modelNameRaw: string | null | undefined, accountId: number, tokenId: number | null, siteId: number) => {
const modelName = (modelNameRaw || '').trim();
if (!modelName) return;
if (isModelDisabledForSite(siteId, modelName)) return;
if (blockedBrandKeywords.length > 0 && isModelBlockedByBrand(modelName, blockedBrandKeywords)) return;
if (blockedBrandRules.length > 0 && isModelBlockedByBrand(modelName, blockedBrandRules)) return;
if (!modelCandidates.has(modelName)) modelCandidates.set(modelName, new Map());
const candidateKey = `${accountId}:${tokenId ?? 'account'}`;
modelCandidates.get(modelName)!.set(candidateKey, { accountId, tokenId });