Merge origin/main into codex/single-source-consolidation
This commit is contained in:
@@ -114,6 +114,7 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
|
||||
proxyFileRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_FILE_RETENTION_PRUNE_INTERVAL_MINUTES, 60))),
|
||||
proxyErrorKeywords: parseCsvList(env.PROXY_ERROR_KEYWORDS),
|
||||
proxyEmptyContentFailEnabled: parseBoolean(env.PROXY_EMPTY_CONTENT_FAIL, false),
|
||||
globalBlockedBrands: [] as string[],
|
||||
codexResponsesWebsocketBeta: parseOptionalSecret(env.CODEX_RESPONSES_WEBSOCKET_BETA) || 'responses_websockets=2026-02-06',
|
||||
codexHeaderDefaults: {
|
||||
userAgent: parseOptionalSecret(env.CODEX_HEADER_DEFAULTS_USER_AGENT),
|
||||
|
||||
@@ -147,6 +147,11 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) {
|
||||
config.proxyEmptyContentFailEnabled = proxyEmptyContentFailEnabled;
|
||||
}
|
||||
|
||||
const globalBlockedBrands = parseSettingFromMap<string[]>(settingsMap, 'global_blocked_brands');
|
||||
if (Array.isArray(globalBlockedBrands)) {
|
||||
config.globalBlockedBrands = globalBlockedBrands.filter((b): b is string => typeof b === 'string').map((b) => b.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
const codexHeaderDefaults = parseSettingFromMap<unknown>(settingsMap, 'codex_header_defaults');
|
||||
if (codexHeaderDefaults && typeof codexHeaderDefaults === 'object') {
|
||||
const next = codexHeaderDefaults as Record<string, unknown>;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { config } from '../../config.js';
|
||||
import { db, runtimeDbDialect, schema } from '../../db/index.js';
|
||||
import { upsertSetting } from '../../db/upsertSetting.js';
|
||||
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||
import { getAllBrandNames } from '../../services/brandMatcher.js';
|
||||
import { updateBalanceRefreshCron, updateCheckinSchedule, updateLogCleanupSettings } from '../../services/checkinScheduler.js';
|
||||
import { sendNotification } from '../../services/notifyService.js';
|
||||
import {
|
||||
@@ -71,6 +72,7 @@ interface RuntimeSettingsBody {
|
||||
routingWeights?: Partial<RoutingWeights>;
|
||||
proxyErrorKeywords?: string[] | string;
|
||||
proxyEmptyContentFailEnabled?: boolean;
|
||||
globalBlockedBrands?: string[];
|
||||
}
|
||||
|
||||
interface DatabaseMigrationBody {
|
||||
@@ -395,6 +397,29 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'global_blocked_brands': {
|
||||
try {
|
||||
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
||||
if (Array.isArray(parsed)) {
|
||||
const nextBrands = parsed.filter((b): b is string => typeof b === 'string').map((b) => b.trim()).filter(Boolean);
|
||||
const prev = JSON.stringify(config.globalBlockedBrands);
|
||||
config.globalBlockedBrands = nextBrands;
|
||||
if (prev !== JSON.stringify(nextBrands)) {
|
||||
startBackgroundTask(
|
||||
{
|
||||
type: 'maintenance',
|
||||
title: '品牌屏蔽变更后重建路由',
|
||||
dedupeKey: 'refresh-models-and-rebuild-routes',
|
||||
},
|
||||
async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'webhook_url': {
|
||||
if (typeof value !== 'string') return;
|
||||
config.webhookUrl = value.trim();
|
||||
@@ -562,6 +587,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
|
||||
proxyErrorKeywords: config.proxyErrorKeywords,
|
||||
proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled,
|
||||
proxyTokenMasked: maskSecret(config.proxyToken),
|
||||
globalBlockedBrands: config.globalBlockedBrands,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -639,6 +665,10 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
return getRuntimeSettingsResponse(currentAdminIp);
|
||||
});
|
||||
|
||||
app.get('/api/settings/brand-list', async () => {
|
||||
return { brands: getAllBrandNames() };
|
||||
});
|
||||
|
||||
app.post<{ Body: SystemProxyTestBody }>('/api/settings/system-proxy/test', async (request, reply) => {
|
||||
const rawProxyUrl = request.body?.proxyUrl === undefined
|
||||
? config.systemProxyUrl
|
||||
@@ -946,6 +976,31 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
upsertSetting('proxy_empty_content_fail_enabled', config.proxyEmptyContentFailEnabled);
|
||||
}
|
||||
|
||||
if (body.globalBlockedBrands !== undefined) {
|
||||
if (!Array.isArray(body.globalBlockedBrands)) {
|
||||
return reply.code(400).send({ error: 'globalBlockedBrands must be an array of strings' });
|
||||
}
|
||||
const nextBrands = body.globalBlockedBrands.filter((b): b is string => typeof b === 'string').map((b) => b.trim()).filter(Boolean);
|
||||
const uniqueBrands = Array.from(new Set(nextBrands));
|
||||
const prev = JSON.stringify(config.globalBlockedBrands);
|
||||
const next = JSON.stringify(uniqueBrands);
|
||||
if (prev !== next) {
|
||||
changedLabels.push('全局品牌屏蔽');
|
||||
}
|
||||
config.globalBlockedBrands = uniqueBrands;
|
||||
upsertSetting('global_blocked_brands', JSON.stringify(uniqueBrands));
|
||||
if (prev !== next) {
|
||||
startBackgroundTask(
|
||||
{
|
||||
type: 'maintenance',
|
||||
title: '品牌屏蔽变更后重建路由',
|
||||
dedupeKey: 'refresh-models-and-rebuild-routes',
|
||||
},
|
||||
async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (body.webhookUrl !== undefined) {
|
||||
if (String(body.webhookUrl || '').trim() !== config.webhookUrl) {
|
||||
changedLabels.push('Webhook 地址');
|
||||
|
||||
@@ -70,6 +70,32 @@ describe('sites proxy settings', () => {
|
||||
expect(payload.globalWeight).toBe(1.5);
|
||||
});
|
||||
|
||||
it('returns a conflict response when the same platform and url already exist', async () => {
|
||||
const first = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
payload: {
|
||||
name: 'existing-site',
|
||||
url: 'https://duplicate-site.example.com/',
|
||||
platform: 'new-api',
|
||||
},
|
||||
});
|
||||
expect(first.statusCode).toBe(200);
|
||||
|
||||
const duplicate = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
payload: {
|
||||
name: 'duplicate-site',
|
||||
url: 'https://duplicate-site.example.com',
|
||||
platform: 'new-api',
|
||||
},
|
||||
});
|
||||
|
||||
expect(duplicate.statusCode).toBe(409);
|
||||
expect((duplicate.json() as { error?: string }).error).toContain('already exists');
|
||||
});
|
||||
|
||||
it('rejects invalid useSystemProxy flag', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
@@ -163,6 +189,43 @@ describe('sites proxy settings', () => {
|
||||
expect(payload.useSystemProxy).toBe(true);
|
||||
});
|
||||
|
||||
it('returns a conflict response when updating a site to an existing platform and url', async () => {
|
||||
const first = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
payload: {
|
||||
name: 'first-site',
|
||||
url: 'https://first-site.example.com',
|
||||
platform: 'new-api',
|
||||
},
|
||||
});
|
||||
expect(first.statusCode).toBe(200);
|
||||
|
||||
const second = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
payload: {
|
||||
name: 'second-site',
|
||||
url: 'https://second-site.example.com',
|
||||
platform: 'new-api',
|
||||
},
|
||||
});
|
||||
expect(second.statusCode).toBe(200);
|
||||
|
||||
const { id } = second.json() as { id: number };
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sites/${id}`,
|
||||
payload: {
|
||||
url: 'https://first-site.example.com/',
|
||||
platform: 'new-api',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect((response.json() as { error?: string }).error).toContain('already exists');
|
||||
});
|
||||
|
||||
it('rejects invalid custom headers json', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
|
||||
+154
-16
@@ -1,4 +1,4 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { detectSite } from '../../services/siteDetector.js';
|
||||
@@ -76,6 +76,64 @@ function normalizeOptionalExternalCheckinUrl(input: unknown): {
|
||||
return { valid: true, present: true, url: parsed.toString().replace(/\/+$/, '') };
|
||||
}
|
||||
|
||||
type ErrorLike = {
|
||||
message?: string;
|
||||
code?: string | number;
|
||||
cause?: unknown;
|
||||
};
|
||||
|
||||
function normalizeSiteUrl(url: string): string {
|
||||
return url.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function getErrorChain(error: unknown): ErrorLike[] {
|
||||
const chain: ErrorLike[] = [];
|
||||
const seen = new Set<unknown>();
|
||||
let current: unknown = error;
|
||||
while (current && typeof current === 'object' && !seen.has(current)) {
|
||||
seen.add(current);
|
||||
chain.push(current as ErrorLike);
|
||||
current = (current as ErrorLike).cause;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
function isSitesPlatformUrlConflict(error: unknown): boolean {
|
||||
return getErrorChain(error).some((entry) => {
|
||||
const message = String(entry.message || '');
|
||||
const lowered = message.toLowerCase();
|
||||
const code = String(entry.code || '');
|
||||
const isSqliteSitesUnique = (
|
||||
(code === 'SQLITE_CONSTRAINT' || code === 'SQLITE_CONSTRAINT_UNIQUE')
|
||||
&& lowered.includes('unique constraint failed: sites.platform, sites.url')
|
||||
);
|
||||
return (code === '23505' && lowered.includes('sites_platform_url_unique'))
|
||||
|| (code === 'ER_DUP_ENTRY' && lowered.includes('sites_platform_url_unique'))
|
||||
|| isSqliteSitesUnique
|
||||
|| (lowered.includes('duplicate key value violates unique constraint') && lowered.includes('sites_platform_url_unique'))
|
||||
|| (lowered.includes('duplicate entry') && lowered.includes('sites_platform_url_unique'));
|
||||
});
|
||||
}
|
||||
|
||||
function findExistingSiteBinding(
|
||||
siteRows: Array<{ id: number; url: string; platform: string }>,
|
||||
platform: string,
|
||||
normalizedUrl: string,
|
||||
excludedSiteId?: number,
|
||||
) {
|
||||
return siteRows.find((site) => (
|
||||
site.platform === platform
|
||||
&& site.url === normalizedUrl
|
||||
&& site.id !== excludedSiteId
|
||||
));
|
||||
}
|
||||
|
||||
function sendSiteBindingConflict(reply: FastifyReply, platform: string, normalizedUrl: string) {
|
||||
return reply.code(409).send({
|
||||
error: `A ${platform} site with URL ${normalizedUrl} already exists.`,
|
||||
});
|
||||
}
|
||||
|
||||
type SiteSubscriptionAggregate = {
|
||||
activeCount: number;
|
||||
totalUsedUsd: number;
|
||||
@@ -267,6 +325,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
|
||||
const existingSites = await db.select().from(schema.sites).all();
|
||||
const maxSortOrder = existingSites.reduce((max, site) => Math.max(max, site.sortOrder || 0), -1);
|
||||
const normalizedUrl = normalizeSiteUrl(url);
|
||||
|
||||
let detectedPlatform = platform;
|
||||
if (!detectedPlatform) {
|
||||
@@ -276,19 +335,32 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
if (!detectedPlatform) {
|
||||
return { error: 'Could not detect platform. Please specify manually.' };
|
||||
}
|
||||
const inserted = await db.insert(schema.sites).values({
|
||||
name,
|
||||
url: url.replace(/\/+$/, ''),
|
||||
platform: detectedPlatform,
|
||||
proxyUrl: normalizedProxyUrl.proxyUrl,
|
||||
useSystemProxy: normalizedUseSystemProxy ?? false,
|
||||
customHeaders: normalizedCustomHeaders.customHeaders,
|
||||
externalCheckinUrl: normalizedExternalCheckinUrl.url,
|
||||
status: normalizedStatus ?? 'active',
|
||||
isPinned: normalizedPinned ?? false,
|
||||
sortOrder: normalizedSortOrder ?? (maxSortOrder + 1),
|
||||
globalWeight: normalizedGlobalWeight ?? 1,
|
||||
}).run();
|
||||
const conflictingSite = findExistingSiteBinding(existingSites, detectedPlatform, normalizedUrl);
|
||||
if (conflictingSite) {
|
||||
return sendSiteBindingConflict(reply, detectedPlatform, normalizedUrl);
|
||||
}
|
||||
|
||||
let inserted;
|
||||
try {
|
||||
inserted = await db.insert(schema.sites).values({
|
||||
name,
|
||||
url: normalizedUrl,
|
||||
platform: detectedPlatform,
|
||||
proxyUrl: normalizedProxyUrl.proxyUrl,
|
||||
useSystemProxy: normalizedUseSystemProxy ?? false,
|
||||
customHeaders: normalizedCustomHeaders.customHeaders,
|
||||
externalCheckinUrl: normalizedExternalCheckinUrl.url,
|
||||
status: normalizedStatus ?? 'active',
|
||||
isPinned: normalizedPinned ?? false,
|
||||
sortOrder: normalizedSortOrder ?? (maxSortOrder + 1),
|
||||
globalWeight: normalizedGlobalWeight ?? 1,
|
||||
}).run();
|
||||
} catch (error) {
|
||||
if (isSitesPlatformUrlConflict(error)) {
|
||||
return sendSiteBindingConflict(reply, detectedPlatform, normalizedUrl);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const siteId = Number(inserted.lastInsertRowid || 0);
|
||||
if (siteId <= 0) {
|
||||
return reply.code(500).send({ error: 'Create site failed' });
|
||||
@@ -360,8 +432,23 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ error: normalizedCustomHeaders.error || 'Invalid customHeaders.' });
|
||||
}
|
||||
|
||||
const nextUrl = body.url !== undefined ? normalizeSiteUrl(body.url) : existingSite.url;
|
||||
const nextPlatform = body.platform !== undefined ? body.platform : existingSite.platform;
|
||||
const siteIdentityChanged = nextUrl !== existingSite.url || nextPlatform !== existingSite.platform;
|
||||
if (siteIdentityChanged) {
|
||||
const siteRows = await db.select({
|
||||
id: schema.sites.id,
|
||||
url: schema.sites.url,
|
||||
platform: schema.sites.platform,
|
||||
}).from(schema.sites).all();
|
||||
const conflictingSite = findExistingSiteBinding(siteRows, nextPlatform, nextUrl, id);
|
||||
if (conflictingSite) {
|
||||
return sendSiteBindingConflict(reply, nextPlatform, nextUrl);
|
||||
}
|
||||
}
|
||||
|
||||
if (body.name !== undefined) updates.name = body.name;
|
||||
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
|
||||
if (body.url !== undefined) updates.url = nextUrl;
|
||||
if (body.platform !== undefined) updates.platform = body.platform;
|
||||
if (normalizedProxyUrl.present) updates.proxyUrl = normalizedProxyUrl.proxyUrl;
|
||||
if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy;
|
||||
@@ -372,7 +459,14 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
if (body.sortOrder !== undefined) updates.sortOrder = normalizedSortOrder;
|
||||
if (body.globalWeight !== undefined) updates.globalWeight = normalizedGlobalWeight;
|
||||
updates.updatedAt = new Date().toISOString();
|
||||
await db.update(schema.sites).set(updates).where(eq(schema.sites.id, id)).run();
|
||||
try {
|
||||
await db.update(schema.sites).set(updates).where(eq(schema.sites.id, id)).run();
|
||||
} catch (error) {
|
||||
if (isSitesPlatformUrlConflict(error)) {
|
||||
return sendSiteBindingConflict(reply, nextPlatform, nextUrl);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (body.status !== undefined && normalizedStatus) {
|
||||
await applySiteStatusSideEffects(id, existingSite.name, normalizedStatus);
|
||||
@@ -497,6 +591,50 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
return { siteId: id, models: uniqueModels };
|
||||
});
|
||||
|
||||
// Get all discovered models for a site (from model_availability and token_model_availability)
|
||||
app.get<{ Params: { id: string } }>('/api/sites/:id/available-models', async (request, reply) => {
|
||||
const id = parseInt(request.params.id);
|
||||
if (Number.isNaN(id)) {
|
||||
return reply.code(400).send({ error: 'Invalid site id' });
|
||||
}
|
||||
const existingSite = await db.select().from(schema.sites).where(eq(schema.sites.id, id)).get();
|
||||
if (!existingSite) {
|
||||
return reply.code(404).send({ error: 'Site not found' });
|
||||
}
|
||||
|
||||
// Get models from model_availability (account-level)
|
||||
const accountModels = await db.select({ modelName: schema.modelAvailability.modelName })
|
||||
.from(schema.modelAvailability)
|
||||
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.accounts.siteId, id),
|
||||
eq(schema.modelAvailability.available, true),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
|
||||
// Get models from token_model_availability (token-level)
|
||||
const tokenModels = await db.select({ modelName: schema.tokenModelAvailability.modelName })
|
||||
.from(schema.tokenModelAvailability)
|
||||
.innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id))
|
||||
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.accounts.siteId, id),
|
||||
eq(schema.tokenModelAvailability.available, true),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
|
||||
const models = Array.from(new Set([
|
||||
...accountModels.map((r) => r.modelName.trim()),
|
||||
...tokenModels.map((r) => r.modelName.trim()),
|
||||
])).filter((m) => m.length > 0).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||||
|
||||
return { siteId: id, models };
|
||||
});
|
||||
|
||||
// Detect platform for a URL
|
||||
app.post<{ Body: { url: string } }>('/api/sites/detect', async (request) => {
|
||||
const result = await detectSite(request.body.url);
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* 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';
|
||||
type KeywordRule = { keyword: string; mode: MatchMode };
|
||||
|
||||
const BRAND_RULES: Record<string, KeywordRule[]> = {
|
||||
'OpenAI': [
|
||||
{ keyword: 'gpt', mode: 'startsWith' },
|
||||
{ keyword: 'chatgpt', mode: 'startsWith' },
|
||||
{ keyword: 'dall-e', mode: 'startsWith' },
|
||||
{ keyword: 'whisper', mode: 'startsWith' },
|
||||
{ keyword: 'text-embedding', mode: 'startsWith' },
|
||||
{ keyword: 'text-moderation', mode: 'startsWith' },
|
||||
{ keyword: 'davinci', mode: 'startsWith' },
|
||||
{ keyword: 'babbage', mode: 'startsWith' },
|
||||
{ keyword: 'codex-mini', mode: 'startsWith' },
|
||||
{ keyword: 'o1', mode: 'startsWith' },
|
||||
{ keyword: 'o3', mode: 'startsWith' },
|
||||
{ keyword: 'o4', mode: 'startsWith' },
|
||||
{ keyword: 'tts', mode: 'startsWith' },
|
||||
],
|
||||
'Anthropic': [
|
||||
{ keyword: 'claude', mode: 'includes' },
|
||||
],
|
||||
'Google': [
|
||||
{ keyword: 'gemini', mode: 'includes' },
|
||||
{ keyword: 'gemma', mode: 'includes' },
|
||||
{ keyword: 'google/', mode: 'includes' },
|
||||
{ keyword: 'palm', mode: 'includes' },
|
||||
{ keyword: 'paligemma', mode: 'includes' },
|
||||
{ keyword: 'shieldgemma', mode: 'includes' },
|
||||
{ keyword: 'recurrentgemma', mode: 'includes' },
|
||||
{ keyword: 'deplot', mode: 'includes' },
|
||||
{ keyword: 'codegemma', mode: 'includes' },
|
||||
{ keyword: 'imagen', mode: 'includes' },
|
||||
{ keyword: 'learnlm', mode: 'includes' },
|
||||
{ keyword: 'aqa', mode: 'includes' },
|
||||
{ keyword: 'veo', mode: 'startsWith' },
|
||||
],
|
||||
'DeepSeek': [
|
||||
{ keyword: 'deepseek', mode: 'includes' },
|
||||
{ keyword: 'ds-chat', mode: 'segment' },
|
||||
],
|
||||
'通义千问': [
|
||||
{ keyword: 'qwen', mode: 'includes' },
|
||||
{ keyword: 'qwq', mode: 'includes' },
|
||||
{ keyword: 'tongyi', mode: 'includes' },
|
||||
],
|
||||
'智谱 AI': [
|
||||
{ keyword: 'glm', mode: 'includes' },
|
||||
{ keyword: 'chatglm', mode: 'includes' },
|
||||
{ keyword: 'codegeex', mode: 'includes' },
|
||||
{ keyword: 'cogview', mode: 'includes' },
|
||||
{ keyword: 'cogvideo', mode: 'includes' },
|
||||
],
|
||||
'Meta': [
|
||||
{ keyword: 'llama', mode: 'includes' },
|
||||
{ keyword: 'code-llama', mode: 'includes' },
|
||||
{ keyword: 'codellama', mode: 'includes' },
|
||||
],
|
||||
'Mistral': [
|
||||
{ keyword: 'mistral', mode: 'includes' },
|
||||
{ keyword: 'mixtral', mode: 'includes' },
|
||||
{ keyword: 'codestral', mode: 'includes' },
|
||||
{ keyword: 'pixtral', mode: 'includes' },
|
||||
{ keyword: 'ministral', mode: 'includes' },
|
||||
{ keyword: 'voxtral', mode: 'includes' },
|
||||
{ keyword: 'magistral', mode: 'includes' },
|
||||
],
|
||||
'Moonshot': [
|
||||
{ keyword: 'moonshot', mode: 'includes' },
|
||||
{ keyword: 'kimi', mode: 'includes' },
|
||||
],
|
||||
'零一万物': [
|
||||
{ keyword: 'yi-', mode: 'startsWith' },
|
||||
{ keyword: 'yi', mode: 'boundary' },
|
||||
],
|
||||
'文心一言': [
|
||||
{ keyword: 'ernie', mode: 'includes' },
|
||||
{ keyword: 'eb-', mode: 'includes' },
|
||||
],
|
||||
'讯飞星火': [
|
||||
{ keyword: 'spark', mode: 'includes' },
|
||||
{ keyword: 'generalv', mode: 'includes' },
|
||||
],
|
||||
'腾讯混元': [
|
||||
{ keyword: 'hunyuan', mode: 'includes' },
|
||||
{ keyword: 'tencent-hunyuan', mode: 'includes' },
|
||||
],
|
||||
'豆包': [
|
||||
{ keyword: 'doubao', mode: 'includes' },
|
||||
],
|
||||
'MiniMax': [
|
||||
{ keyword: 'minimax', mode: 'includes' },
|
||||
{ keyword: 'abab', mode: 'includes' },
|
||||
{ keyword: 'mini2.1', mode: 'segment' },
|
||||
],
|
||||
'Cohere': [
|
||||
{ keyword: 'command', mode: 'includes' },
|
||||
{ keyword: 'c4ai-', mode: 'includes' },
|
||||
{ keyword: 'embed-', mode: 'startsWith' },
|
||||
],
|
||||
'Microsoft': [
|
||||
{ keyword: 'microsoft/', mode: 'includes' },
|
||||
{ keyword: 'phi-', mode: 'includes' },
|
||||
{ keyword: 'kosmos', mode: 'includes' },
|
||||
{ keyword: 'phi4', mode: 'segment' },
|
||||
],
|
||||
'xAI': [
|
||||
{ keyword: 'grok', mode: 'includes' },
|
||||
],
|
||||
'阶跃星辰': [
|
||||
{ keyword: 'stepfun', mode: 'includes' },
|
||||
{ keyword: 'step-', mode: 'startsWith' },
|
||||
{ keyword: 'step3', mode: 'startsWith' },
|
||||
],
|
||||
'Stability': [
|
||||
{ keyword: 'flux', mode: 'includes' },
|
||||
{ keyword: 'stablediffusion', mode: 'includes' },
|
||||
{ keyword: 'stable-diffusion', mode: 'includes' },
|
||||
{ keyword: 'sdxl', mode: 'includes' },
|
||||
{ keyword: 'sd3', mode: 'startsWith' },
|
||||
],
|
||||
'NVIDIA': [
|
||||
{ keyword: 'nvidia/', mode: 'includes' },
|
||||
{ keyword: 'nvclip', mode: 'includes' },
|
||||
{ keyword: 'nemotron', mode: 'includes' },
|
||||
{ keyword: 'nemoretriever', mode: 'includes' },
|
||||
{ keyword: 'neva', mode: 'includes' },
|
||||
{ keyword: 'riva-translate', mode: 'includes' },
|
||||
{ keyword: 'cosmos', mode: 'includes' },
|
||||
{ keyword: 'nv-', mode: 'startsWith' },
|
||||
],
|
||||
'IBM': [
|
||||
{ keyword: 'ibm/', mode: 'includes' },
|
||||
{ keyword: 'granite', mode: 'includes' },
|
||||
],
|
||||
'BAAI': [
|
||||
{ keyword: 'baai/', mode: 'includes' },
|
||||
{ keyword: 'bge-', mode: 'includes' },
|
||||
],
|
||||
'ByteDance': [
|
||||
{ keyword: 'bytedance', mode: 'includes' },
|
||||
{ keyword: 'seed-oss', mode: 'includes' },
|
||||
{ keyword: 'kolors', mode: 'includes' },
|
||||
{ keyword: 'kwai', mode: 'includes' },
|
||||
{ keyword: 'kwaipilot', mode: 'includes' },
|
||||
{ keyword: 'wan-', mode: 'startsWith' },
|
||||
{ keyword: 'kat-', mode: 'startsWith' },
|
||||
],
|
||||
'InternLM': [
|
||||
{ keyword: 'internlm', mode: 'includes' },
|
||||
],
|
||||
'Midjourney': [
|
||||
{ keyword: 'midjourney', mode: 'includes' },
|
||||
{ keyword: 'mj_', mode: 'startsWith' },
|
||||
],
|
||||
'DeepL': [
|
||||
{ keyword: 'deepl/', mode: 'includes' },
|
||||
{ keyword: 'deepl-', mode: 'startsWith' },
|
||||
],
|
||||
'Jina AI': [
|
||||
{ keyword: 'jina', mode: 'includes' },
|
||||
],
|
||||
};
|
||||
|
||||
// ── Context building (ported from frontend BrandIcon.tsx) ──
|
||||
|
||||
function stripCommonWrappers(value: string): string {
|
||||
return value
|
||||
.replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, '')
|
||||
.replace(/^re:\s*/g, '')
|
||||
.replace(/^\^+/, '')
|
||||
.replace(/\$+$/, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively split model name on `/`, `:`, `,` to build all candidate strings.
|
||||
* Mirrors the frontend collectBrandCandidates exactly.
|
||||
*/
|
||||
function collectCandidates(modelName: string): string[] {
|
||||
const queue: string[] = [];
|
||||
const seen = new Set<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[] {
|
||||
return Object.keys(BRAND_RULES);
|
||||
}
|
||||
|
||||
export function getBlockedBrandRules(blockedBrands: string[]): KeywordRule[] {
|
||||
const rules: KeywordRule[] = [];
|
||||
for (const brand of blockedBrands) {
|
||||
const brandRules = BRAND_RULES[brand];
|
||||
if (brandRules) rules.push(...brandRules);
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
export function isModelBlockedByBrand(modelName: string, rules: KeywordRule[]): boolean {
|
||||
if (!modelName || rules.length === 0) return false;
|
||||
const ctx = buildMatchContext(modelName);
|
||||
if (!ctx.raw) return false;
|
||||
return rules.some((rule) => matchesRule(ctx, rule));
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
supportsDirectAccountRoutingConnection,
|
||||
} from './accountExtraConfig.js';
|
||||
import { invalidateTokenRouterCache } from './tokenRouter.js';
|
||||
import { getBlockedBrandRules, isModelBlockedByBrand } from './brandMatcher.js';
|
||||
import { config } from '../config.js';
|
||||
import { setAccountRuntimeHealth } from './accountHealthService.js';
|
||||
import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js';
|
||||
import { withAccountProxyOverride } from './siteProxy.js';
|
||||
@@ -917,11 +919,15 @@ export async function rebuildTokenRoutesFromAvailability() {
|
||||
return !!disabled && disabled.has(modelName);
|
||||
}
|
||||
|
||||
// Load global brand filter
|
||||
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 (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 });
|
||||
|
||||
Reference in New Issue
Block a user