forked from boos/BoosAPI
default deny empty downstream key exposure
This commit is contained in:
@@ -1,10 +1,17 @@
|
|||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { getProxyAuthContext } from '../../middleware/auth.js';
|
import { getProxyAuthContext } from '../../middleware/auth.js';
|
||||||
import { isModelAllowedByPolicyOrAllowedRoutes, recordManagedKeyCostUsage } from '../../services/downstreamApiKeyService.js';
|
import { isModelAllowedByPolicyOrAllowedRoutes, recordManagedKeyCostUsage } from '../../services/downstreamApiKeyService.js';
|
||||||
import { EMPTY_DOWNSTREAM_ROUTING_POLICY, type DownstreamRoutingPolicy } from '../../services/downstreamPolicyTypes.js';
|
import {
|
||||||
|
EMPTY_DOWNSTREAM_ROUTING_POLICY,
|
||||||
|
GLOBAL_UNRESTRICTED_DOWNSTREAM_ROUTING_POLICY,
|
||||||
|
type DownstreamRoutingPolicy,
|
||||||
|
} from '../../services/downstreamPolicyTypes.js';
|
||||||
|
|
||||||
export function getDownstreamRoutingPolicy(request: FastifyRequest): DownstreamRoutingPolicy {
|
export function getDownstreamRoutingPolicy(request: FastifyRequest): DownstreamRoutingPolicy {
|
||||||
return getProxyAuthContext(request)?.policy || EMPTY_DOWNSTREAM_ROUTING_POLICY;
|
const authContext = getProxyAuthContext(request);
|
||||||
|
if (!authContext) return EMPTY_DOWNSTREAM_ROUTING_POLICY;
|
||||||
|
if (authContext.keyId === null) return GLOBAL_UNRESTRICTED_DOWNSTREAM_ROUTING_POLICY;
|
||||||
|
return authContext.policy;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureModelAllowedForDownstreamKey(
|
export async function ensureModelAllowedForDownstreamKey(
|
||||||
@@ -14,6 +21,7 @@ export async function ensureModelAllowedForDownstreamKey(
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const authContext = getProxyAuthContext(request);
|
const authContext = getProxyAuthContext(request);
|
||||||
if (!authContext) return true;
|
if (!authContext) return true;
|
||||||
|
if (authContext.keyId === null) return true;
|
||||||
|
|
||||||
if (await isModelAllowedByPolicyOrAllowedRoutes(requestedModel, authContext.policy)) {
|
if (await isModelAllowedByPolicyOrAllowedRoutes(requestedModel, authContext.policy)) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type DbModule = typeof import('../../db/index.js');
|
|||||||
type ProxyRouterModule = typeof import('./router.js');
|
type ProxyRouterModule = typeof import('./router.js');
|
||||||
type TokenRouterModule = typeof import('../../services/tokenRouter.js');
|
type TokenRouterModule = typeof import('../../services/tokenRouter.js');
|
||||||
type TokensRoutesModule = typeof import('../api/tokens.js');
|
type TokensRoutesModule = typeof import('../api/tokens.js');
|
||||||
|
type ConfigModule = typeof import('../../config.js');
|
||||||
|
|
||||||
describe('/v1/models route', () => {
|
describe('/v1/models route', () => {
|
||||||
let db: DbModule['db'];
|
let db: DbModule['db'];
|
||||||
@@ -15,6 +16,7 @@ describe('/v1/models route', () => {
|
|||||||
let proxyRoutes: ProxyRouterModule['proxyRoutes'];
|
let proxyRoutes: ProxyRouterModule['proxyRoutes'];
|
||||||
let tokensRoutes: TokensRoutesModule['tokensRoutes'];
|
let tokensRoutes: TokensRoutesModule['tokensRoutes'];
|
||||||
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
|
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
|
||||||
|
let config: ConfigModule['config'];
|
||||||
let app: FastifyInstance;
|
let app: FastifyInstance;
|
||||||
let dataDir = '';
|
let dataDir = '';
|
||||||
|
|
||||||
@@ -27,12 +29,15 @@ describe('/v1/models route', () => {
|
|||||||
const proxyRouterModule = await import('./router.js');
|
const proxyRouterModule = await import('./router.js');
|
||||||
const tokenRouterModule = await import('../../services/tokenRouter.js');
|
const tokenRouterModule = await import('../../services/tokenRouter.js');
|
||||||
const tokensRoutesModule = await import('../api/tokens.js');
|
const tokensRoutesModule = await import('../api/tokens.js');
|
||||||
|
const configModule = await import('../../config.js');
|
||||||
|
|
||||||
db = dbModule.db;
|
db = dbModule.db;
|
||||||
schema = dbModule.schema;
|
schema = dbModule.schema;
|
||||||
proxyRoutes = proxyRouterModule.proxyRoutes;
|
proxyRoutes = proxyRouterModule.proxyRoutes;
|
||||||
tokensRoutes = tokensRoutesModule.tokensRoutes;
|
tokensRoutes = tokensRoutesModule.tokensRoutes;
|
||||||
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
|
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
|
||||||
|
config = configModule.config;
|
||||||
|
config.proxyToken = 'sk-global-proxy-token';
|
||||||
|
|
||||||
app = Fastify();
|
app = Fastify();
|
||||||
await app.register(tokensRoutes);
|
await app.register(tokensRoutes);
|
||||||
@@ -108,6 +113,7 @@ describe('/v1/models route', () => {
|
|||||||
name: 'managed-key',
|
name: 'managed-key',
|
||||||
key: 'sk-managed-models',
|
key: 'sk-managed-models',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
supportedModels: JSON.stringify(['routable-model']),
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
@@ -129,6 +135,64 @@ describe('/v1/models route', () => {
|
|||||||
expect(ids).not.toContain('orphan-model');
|
expect(ids).not.toContain('orphan-model');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps global proxy token unrestricted when no managed key matches', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'global-site',
|
||||||
|
url: 'https://global.example.com',
|
||||||
|
platform: 'openai',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
accessToken: 'global-access-token',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const token = await db.insert(schema.accountTokens).values({
|
||||||
|
accountId: account.id,
|
||||||
|
name: 'default',
|
||||||
|
token: 'global-api-token',
|
||||||
|
enabled: true,
|
||||||
|
isDefault: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
await db.insert(schema.modelAvailability).values({
|
||||||
|
accountId: account.id,
|
||||||
|
modelName: 'global-routable-model',
|
||||||
|
available: true,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const route = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'global-routable-model',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: account.id,
|
||||||
|
tokenId: token.id,
|
||||||
|
sourceModel: 'global-routable-model',
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/v1/models',
|
||||||
|
headers: {
|
||||||
|
authorization: 'Bearer sk-global-proxy-token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json() as {
|
||||||
|
object: 'list';
|
||||||
|
data: Array<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(body.data.map((item) => item.id)).toContain('global-routable-model');
|
||||||
|
});
|
||||||
|
|
||||||
it('returns only whitelist models for managed key with supportedModels policy', async () => {
|
it('returns only whitelist models for managed key with supportedModels policy', async () => {
|
||||||
const site = await db.insert(schema.sites).values({
|
const site = await db.insert(schema.sites).values({
|
||||||
name: 'test-site',
|
name: 'test-site',
|
||||||
@@ -290,6 +354,58 @@ describe('/v1/models route', () => {
|
|||||||
expect(ids).not.toContain('claude-sonnet-4-5');
|
expect(ids).not.toContain('claude-sonnet-4-5');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns no models for managed key with empty model and group selections', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'deny-all-site',
|
||||||
|
url: 'https://deny-all.example.com',
|
||||||
|
platform: 'openai',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
accessToken: 'deny-all-access-token',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
await db.insert(schema.modelAvailability).values([
|
||||||
|
{
|
||||||
|
accountId: account.id,
|
||||||
|
modelName: 'gpt-4o-mini',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountId: account.id,
|
||||||
|
modelName: 'claude-opus-4-6',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
]).run();
|
||||||
|
|
||||||
|
await db.insert(schema.downstreamApiKeys).values({
|
||||||
|
name: 'managed-key-deny-all',
|
||||||
|
key: 'sk-managed-deny-all',
|
||||||
|
enabled: true,
|
||||||
|
supportedModels: JSON.stringify([]),
|
||||||
|
allowedRouteIds: JSON.stringify([]),
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/v1/models',
|
||||||
|
headers: {
|
||||||
|
authorization: 'Bearer sk-managed-deny-all',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json() as {
|
||||||
|
object: 'list';
|
||||||
|
data: Array<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(body.data).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns only explicit-group public name while hiding source exact routes', async () => {
|
it('returns only explicit-group public name while hiding source exact routes', async () => {
|
||||||
const site = await db.insert(schema.sites).values({
|
const site = await db.insert(schema.sites).values({
|
||||||
name: 'explicit-group-site',
|
name: 'explicit-group-site',
|
||||||
@@ -461,6 +577,7 @@ describe('/v1/models route', () => {
|
|||||||
name: 'search-key',
|
name: 'search-key',
|
||||||
key: 'sk-search-key',
|
key: 'sk-search-key',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
supportedModels: JSON.stringify(['gpt-4.1']),
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
|
|||||||
@@ -136,6 +136,17 @@ describe('downstreamApiKeyService', () => {
|
|||||||
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('gemini-2.0-flash', policy)).toBe(false);
|
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('gemini-2.0-flash', policy)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('denies all models when both supportedModels and allowedRouteIds are empty', async () => {
|
||||||
|
const policy = {
|
||||||
|
supportedModels: [],
|
||||||
|
allowedRouteIds: [],
|
||||||
|
siteWeightMultipliers: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('gpt-4o-mini', policy)).toBe(false);
|
||||||
|
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-6', policy)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('authorizes by selected group model pattern only, not arbitrary internal models', async () => {
|
it('authorizes by selected group model pattern only, not arbitrary internal models', async () => {
|
||||||
const virtualModelGroup = await db.insert(schema.tokenRoutes).values({
|
const virtualModelGroup = await db.insert(schema.tokenRoutes).values({
|
||||||
modelPattern: 'claude-opus-4-6',
|
modelPattern: 'claude-opus-4-6',
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ export async function isModelAllowedByPolicyOrAllowedRoutes(model: string, polic
|
|||||||
const hasPatternRules = patterns.length > 0;
|
const hasPatternRules = patterns.length > 0;
|
||||||
const hasRouteRules = allowedRouteIds.length > 0;
|
const hasRouteRules = allowedRouteIds.length > 0;
|
||||||
|
|
||||||
if (!hasPatternRules && !hasRouteRules) return true;
|
if (!hasPatternRules && !hasRouteRules) return false;
|
||||||
|
|
||||||
if (hasPatternRules && patterns.some((pattern) => matchesDownstreamModelPattern(model, pattern))) {
|
if (hasPatternRules && patterns.some((pattern) => matchesDownstreamModelPattern(model, pattern))) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -9,3 +9,9 @@ export const EMPTY_DOWNSTREAM_ROUTING_POLICY: DownstreamRoutingPolicy = {
|
|||||||
allowedRouteIds: [],
|
allowedRouteIds: [],
|
||||||
siteWeightMultipliers: {},
|
siteWeightMultipliers: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const GLOBAL_UNRESTRICTED_DOWNSTREAM_ROUTING_POLICY: DownstreamRoutingPolicy = {
|
||||||
|
supportedModels: ['*'],
|
||||||
|
allowedRouteIds: [],
|
||||||
|
siteWeightMultipliers: {},
|
||||||
|
};
|
||||||
|
|||||||
@@ -102,6 +102,46 @@ describe('TokenRouter downstream policy', () => {
|
|||||||
expect(blockedPick).toBeNull();
|
expect(blockedPick).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects route selection when both supportedModels and allowedRouteIds are empty', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'site-deny-all',
|
||||||
|
url: 'https://deny-all.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'user-deny-all',
|
||||||
|
accessToken: 'access-deny-all',
|
||||||
|
apiToken: 'sk-deny-all',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const route = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'gpt-4o-mini',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: account.id,
|
||||||
|
tokenId: null,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const router = new TokenRouter();
|
||||||
|
const deniedPick = await router.selectChannel('gpt-4o-mini', {
|
||||||
|
allowedRouteIds: [],
|
||||||
|
supportedModels: [],
|
||||||
|
siteWeightMultipliers: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deniedPick).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('applies site weight multipliers to probability explanation', async () => {
|
it('applies site weight multipliers to probability explanation', async () => {
|
||||||
const siteHigh = await db.insert(schema.sites).values({
|
const siteHigh = await db.insert(schema.sites).values({
|
||||||
name: 'high-site',
|
name: 'high-site',
|
||||||
|
|||||||
@@ -535,10 +535,13 @@ function isModelAllowedByDownstreamPolicy(requestedModel: string, policy: Downst
|
|||||||
const supportedPatterns = Array.isArray(policy.supportedModels)
|
const supportedPatterns = Array.isArray(policy.supportedModels)
|
||||||
? policy.supportedModels
|
? policy.supportedModels
|
||||||
: [];
|
: [];
|
||||||
|
const hasSupportedPatterns = supportedPatterns.length > 0;
|
||||||
|
const hasAllowedRoutes = policy.allowedRouteIds.length > 0;
|
||||||
|
if (!hasSupportedPatterns && !hasAllowedRoutes) return false;
|
||||||
const matchedSupportedPattern = supportedPatterns.some((pattern) => matchesModelPattern(requestedModel, pattern));
|
const matchedSupportedPattern = supportedPatterns.some((pattern) => matchesModelPattern(requestedModel, pattern));
|
||||||
if (matchedSupportedPattern) return true;
|
if (matchedSupportedPattern) return true;
|
||||||
if (policy.allowedRouteIds.length > 0) return true;
|
if (hasAllowedRoutes) return true;
|
||||||
return supportedPatterns.length === 0;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveMappedModel(requestedModel: string, modelMapping?: string | null): string {
|
function resolveMappedModel(requestedModel: string, modelMapping?: string | null): string {
|
||||||
|
|||||||
@@ -404,6 +404,80 @@ describe('DownstreamKeys page', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lets operators explicitly select all exact models and all group routes before saving', async () => {
|
||||||
|
apiMock.getRoutesLite.mockResolvedValue([
|
||||||
|
{ id: 11, modelPattern: 'claude-*', displayName: '默认群组', enabled: true },
|
||||||
|
{ id: 12, modelPattern: 'gpt-4.1-mini', displayName: 'GPT 4.1 Mini', enabled: true },
|
||||||
|
{ id: 13, modelPattern: 're:^gemini-2\\..*$', displayName: 'Gemini 全家桶', enabled: true },
|
||||||
|
{ id: 14, modelPattern: 'claude-opus-4-6', displayName: 'Claude Opus 4.6', enabled: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/downstream-keys']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<DownstreamKeys />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const createBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('新增下游密钥'))[0];
|
||||||
|
await act(async () => {
|
||||||
|
createBtn.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const advancedBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('高级配置'))[0];
|
||||||
|
await act(async () => {
|
||||||
|
advancedBtn.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const panels = root!.root.findAll((node) => node.props.className === 'downstream-key-advanced-panel');
|
||||||
|
const modelPanel = panels.find((node) => collectText(node).includes('模型白名单'));
|
||||||
|
const groupPanel = panels.find((node) => collectText(node).includes('群组范围'));
|
||||||
|
expect(modelPanel).toBeTruthy();
|
||||||
|
expect(groupPanel).toBeTruthy();
|
||||||
|
|
||||||
|
const modelSelectAllBtn = modelPanel!.findAll((node) => node.type === 'button' && collectText(node).includes('全选'))[0];
|
||||||
|
const groupSelectAllBtn = groupPanel!.findAll((node) => node.type === 'button' && collectText(node).includes('全选'))[0];
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
modelSelectAllBtn.props.onClick();
|
||||||
|
groupSelectAllBtn.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const inputs = root!.root.findAllByType('input');
|
||||||
|
const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端');
|
||||||
|
const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...');
|
||||||
|
await act(async () => {
|
||||||
|
nameInput!.props.onChange({ target: { value: 'select-all-key' } });
|
||||||
|
keyInput!.props.onChange({ target: { value: 'sk-select-all-key-0319' } });
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const saveBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('创建密钥'))[0];
|
||||||
|
await act(async () => {
|
||||||
|
saveBtn.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(apiMock.createDownstreamApiKey).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
name: 'select-all-key',
|
||||||
|
key: 'sk-select-all-key-0319',
|
||||||
|
supportedModels: ['claude-opus-4-6', 'gpt-4.1-mini'],
|
||||||
|
allowedRouteIds: [11, 13],
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('uses backend batch api for selected rows', async () => {
|
it('uses backend batch api for selected rows', async () => {
|
||||||
let root: ReturnType<typeof create> | null = null;
|
let root: ReturnType<typeof create> | null = null;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -277,13 +277,13 @@ function buildEditorForm(item?: ManagedItem | DownstreamApiKeyItem | null): Edit
|
|||||||
}
|
}
|
||||||
|
|
||||||
function summarizeModelLimit(models: string[]): string {
|
function summarizeModelLimit(models: string[]): string {
|
||||||
if (!Array.isArray(models) || models.length === 0) return '全部模型';
|
if (!Array.isArray(models) || models.length === 0) return '未授权模型';
|
||||||
if (models.length === 1) return models[0];
|
if (models.length === 1) return models[0];
|
||||||
return `${models[0]} +${models.length - 1}`;
|
return `${models[0]} +${models.length - 1}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function summarizeRouteLimit(routeIds: number[], routeMap: Map<number, RouteSelectorItem>): string {
|
function summarizeRouteLimit(routeIds: number[], routeMap: Map<number, RouteSelectorItem>): string {
|
||||||
if (!Array.isArray(routeIds) || routeIds.length === 0) return '全部群组';
|
if (!Array.isArray(routeIds) || routeIds.length === 0) return '未授权群组';
|
||||||
const names = routeIds
|
const names = routeIds
|
||||||
.map((id) => routeMap.get(id))
|
.map((id) => routeMap.get(id))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -956,12 +956,27 @@ function EditorModal({
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||||
<div>
|
<div>
|
||||||
<div className="downstream-key-modal-section-title">模型白名单</div>
|
<div className="downstream-key-modal-section-title">模型白名单</div>
|
||||||
<div className="downstream-key-modal-help">只展示精确模型,未勾选则视为全部模型可用</div>
|
<div className="downstream-key-modal-help">只展示精确模型;未勾选时默认不允许任何精确模型,可点“全选”一次性放开。</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, selectedModels: [] }))}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
|
onClick={() => onChange((prev) => ({ ...prev, selectedModels: exactModels }))}
|
||||||
|
>
|
||||||
|
全选
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
|
onClick={() => onChange((prev) => ({ ...prev, selectedModels: [] }))}
|
||||||
|
>
|
||||||
清空
|
清空
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="downstream-key-modal-meta">已选 {selectedModelCount} 个模型</div>
|
<div className="downstream-key-modal-meta">已选 {selectedModelCount} 个模型</div>
|
||||||
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
|
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
|
||||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
@@ -995,12 +1010,27 @@ function EditorModal({
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||||
<div>
|
<div>
|
||||||
<div className="downstream-key-modal-section-title">群组范围</div>
|
<div className="downstream-key-modal-section-title">群组范围</div>
|
||||||
<div className="downstream-key-modal-help">限制可访问的群组路由,未勾选则视为全部群组可用</div>
|
<div className="downstream-key-modal-help">限制可访问的群组路由;未勾选时默认不允许任何群组,可点“全选”一次性放开。</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => onChange((prev) => ({ ...prev, selectedGroupRouteIds: [] }))}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
|
onClick={() => onChange((prev) => ({ ...prev, selectedGroupRouteIds: groupRouteOptions.map((route) => route.id) }))}
|
||||||
|
>
|
||||||
|
全选
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
|
onClick={() => onChange((prev) => ({ ...prev, selectedGroupRouteIds: [] }))}
|
||||||
|
>
|
||||||
清空
|
清空
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="downstream-key-modal-meta">已选 {selectedGroupCount} 个群组</div>
|
<div className="downstream-key-modal-meta">已选 {selectedGroupCount} 个群组</div>
|
||||||
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
|
<div className="toolbar-search" style={{ maxWidth: '100%' }}>
|
||||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
|||||||
Reference in New Issue
Block a user