mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-26 01:16:24 +00:00
* 🛡️ fix: Strict opt-in skills activation per agent
Skills were activating on every agent run that had the capability +
RBAC enabled, regardless of whether the user (ephemeral) or author
(persisted) had opted in. `scopeSkillIds(undefined)` fell through to
"full accessible catalog" whenever `agent.skills` was unset, which is
the default state for any agent created before skills existed and for
every ephemeral agent.
Activation now requires an explicit signal:
- Ephemeral agent → per-conversation skills badge toggle.
- Persisted agent → new `skills_enabled` master switch on the agent
doc, surfaced as a toggle in the Agent Builder skills section.
Enabled + empty/undefined allowlist = full accessible catalog;
enabled + non-empty allowlist = narrow to those ids; disabled (or
undefined) = no skills available, even if an allowlist is set.
Centralised the predicate in `resolveAgentScopedSkillIds` so the
primary-agent path, handoff/discovery, the subagent loop, and both
OpenAI controllers all share one source of truth. Frontend `$`
popover scope mirrors the same logic so the UI never offers skills
the backend would refuse to activate.
* test: mock resolveAgentScopedSkillIds in agent controller specs
* refactor: address review findings on skills opt-in PR
- AgentConfig: associate skills label with toggle via htmlFor for
click/keyboard affordance; simplify Switch handler to Boolean(value).
- skills: mark scopeSkillIds as @internal so runtime callers continue
to route through resolveAgentScopedSkillIds and inherit the activation
predicate (ephemeral toggle, persisted skills_enabled).
* fix(agents): include skills_enabled in agent list projection
Without this field, agents loaded via the list endpoint hydrate into the
client agentsMap with skills_enabled === undefined, causing the `$`
skill popover to hide every skill on a fresh page load even when the
agent was saved with skills_enabled: true.
* fix(skills): fail closed for persisted agents during agentsMap hydration
Returning undefined while the agents map loads let the popover render the
full catalog for a persisted agent before we could read its
skills_enabled flag, so the user could pick a skill the backend would
then refuse for the turn. Match the strict opt-in contract by returning
[] until the map is authoritative.
* refactor(skills): extract skillsHintKey for readability
Replaces the nested ternary in the skills section JSX with a
pre-computed constant so the activation -> hint key mapping reads
top-down.
* refactor(skills): unflatten skillsHintKey to remove nested ternary
371 lines
13 KiB
JavaScript
371 lines
13 KiB
JavaScript
/**
|
|
* Unit tests for OpenAI-compatible API controller
|
|
* Tests that recordCollectedUsage is called correctly for token spending
|
|
*/
|
|
|
|
const mockProcessStream = jest.fn().mockResolvedValue(undefined);
|
|
const mockSpendTokens = jest.fn().mockResolvedValue({});
|
|
const mockSpendStructuredTokens = jest.fn().mockResolvedValue({});
|
|
const mockRecordCollectedUsage = jest
|
|
.fn()
|
|
.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
|
|
const mockGetBalanceConfig = jest.fn().mockReturnValue({ enabled: true });
|
|
const mockGetTransactionsConfig = jest.fn().mockReturnValue({ enabled: true });
|
|
|
|
jest.mock('nanoid', () => ({
|
|
nanoid: jest.fn(() => 'mock-nanoid-123'),
|
|
}));
|
|
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
logger: {
|
|
debug: jest.fn(),
|
|
error: jest.fn(),
|
|
warn: jest.fn(),
|
|
},
|
|
}));
|
|
|
|
jest.mock('@librechat/agents', () => ({
|
|
Callback: { TOOL_ERROR: 'TOOL_ERROR' },
|
|
ToolEndHandler: jest.fn(),
|
|
formatAgentMessages: jest.fn().mockReturnValue({
|
|
messages: [],
|
|
indexTokenCountMap: {},
|
|
}),
|
|
}));
|
|
|
|
jest.mock('@librechat/api', () => ({
|
|
writeSSE: jest.fn(),
|
|
createRun: jest.fn().mockResolvedValue({
|
|
processStream: mockProcessStream,
|
|
}),
|
|
createChunk: jest.fn().mockReturnValue({}),
|
|
buildToolSet: jest.fn().mockReturnValue(new Set()),
|
|
scopeSkillIds: jest.fn().mockImplementation((ids) => ids),
|
|
resolveAgentScopedSkillIds: jest
|
|
.fn()
|
|
.mockImplementation(({ accessibleSkillIds }) => accessibleSkillIds),
|
|
loadSkillStates: jest.fn().mockResolvedValue({ skillStates: {}, defaultActiveOnShare: false }),
|
|
sendFinalChunk: jest.fn(),
|
|
createSafeUser: jest.fn().mockReturnValue({ id: 'user-123' }),
|
|
validateRequest: jest
|
|
.fn()
|
|
.mockReturnValue({ request: { model: 'agent-123', messages: [], stream: false } }),
|
|
initializeAgent: jest.fn().mockResolvedValue({
|
|
id: 'agent-123',
|
|
model: 'gpt-4',
|
|
model_parameters: {},
|
|
toolRegistry: {},
|
|
edges: [],
|
|
}),
|
|
getBalanceConfig: mockGetBalanceConfig,
|
|
createErrorResponse: jest.fn(),
|
|
getTransactionsConfig: mockGetTransactionsConfig,
|
|
recordCollectedUsage: mockRecordCollectedUsage,
|
|
extractManualSkills: jest.fn().mockReturnValue(undefined),
|
|
injectSkillPrimes: jest.fn().mockReturnValue({
|
|
initialMessages: [],
|
|
indexTokenCountMap: {},
|
|
inserted: 0,
|
|
insertIdx: -1,
|
|
alwaysApplyDropped: 0,
|
|
alwaysApplyDedupedFromManual: 0,
|
|
}),
|
|
buildNonStreamingResponse: jest.fn().mockReturnValue({ id: 'resp-123' }),
|
|
createOpenAIStreamTracker: jest.fn().mockReturnValue({
|
|
addText: jest.fn(),
|
|
addReasoning: jest.fn(),
|
|
toolCalls: new Map(),
|
|
usage: { promptTokens: 0, completionTokens: 0, reasoningTokens: 0 },
|
|
}),
|
|
createOpenAIContentAggregator: jest.fn().mockReturnValue({
|
|
addText: jest.fn(),
|
|
addReasoning: jest.fn(),
|
|
getText: jest.fn().mockReturnValue(''),
|
|
getReasoning: jest.fn().mockReturnValue(''),
|
|
toolCalls: new Map(),
|
|
usage: { promptTokens: 100, completionTokens: 50, reasoningTokens: 0 },
|
|
}),
|
|
resolveRecursionLimit: jest.fn().mockReturnValue(50),
|
|
createToolExecuteHandler: jest.fn().mockReturnValue({ handle: jest.fn() }),
|
|
isChatCompletionValidationFailure: jest.fn().mockReturnValue(false),
|
|
discoverConnectedAgents: jest.fn().mockResolvedValue({
|
|
agentConfigs: new Map(),
|
|
edges: [],
|
|
skippedAgentIds: new Set(),
|
|
userMCPAuthMap: undefined,
|
|
}),
|
|
}));
|
|
|
|
jest.mock('~/server/controllers/ModelController', () => ({
|
|
getModelsConfig: jest.fn().mockResolvedValue({}),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Files/permissions', () => ({
|
|
filterFilesByAgentAccess: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/cache', () => ({
|
|
logViolation: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/ToolService', () => ({
|
|
loadAgentTools: jest.fn().mockResolvedValue([]),
|
|
loadToolsForExecution: jest.fn().mockResolvedValue([]),
|
|
}));
|
|
|
|
const mockGetMultiplier = jest.fn().mockReturnValue(1);
|
|
const mockGetCacheMultiplier = jest.fn().mockReturnValue(null);
|
|
|
|
jest.mock('~/server/controllers/agents/callbacks', () => ({
|
|
createToolEndCallback: jest.fn().mockReturnValue(jest.fn()),
|
|
buildSummarizationHandlers: jest.fn().mockReturnValue({}),
|
|
markSummarizationUsage: jest.fn().mockImplementation((usage) => usage),
|
|
agentLogHandlerObj: { handle: jest.fn() },
|
|
}));
|
|
|
|
jest.mock('~/server/services/PermissionService', () => ({
|
|
findAccessibleResources: jest.fn().mockResolvedValue([]),
|
|
checkPermission: jest.fn().mockResolvedValue(true),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Files/strategies', () => ({
|
|
getStrategyFunctions: jest.fn().mockReturnValue({}),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Files/Code/crud', () => ({
|
|
batchUploadCodeEnvFiles: jest.fn().mockResolvedValue({ session_id: '', files: [] }),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Files/Code/process', () => ({
|
|
getSessionInfo: jest.fn().mockResolvedValue(null),
|
|
checkIfActive: jest.fn().mockReturnValue(false),
|
|
}));
|
|
|
|
const mockUpdateBalance = jest.fn().mockResolvedValue({});
|
|
const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined);
|
|
|
|
jest.mock('~/models', () => ({
|
|
getAgent: jest.fn().mockResolvedValue({ id: 'agent-123', name: 'Test Agent' }),
|
|
getFiles: jest.fn(),
|
|
getUserKey: jest.fn(),
|
|
getMessages: jest.fn(),
|
|
updateFilesUsage: jest.fn(),
|
|
getUserKeyValues: jest.fn(),
|
|
getUserCodeFiles: jest.fn(),
|
|
getToolFilesByIds: jest.fn(),
|
|
getCodeGeneratedFiles: jest.fn(),
|
|
updateBalance: mockUpdateBalance,
|
|
bulkInsertTransactions: mockBulkInsertTransactions,
|
|
spendTokens: mockSpendTokens,
|
|
spendStructuredTokens: mockSpendStructuredTokens,
|
|
getMultiplier: mockGetMultiplier,
|
|
getCacheMultiplier: mockGetCacheMultiplier,
|
|
getConvoFiles: jest.fn().mockResolvedValue([]),
|
|
getConvo: jest.fn().mockResolvedValue(null),
|
|
}));
|
|
|
|
describe('OpenAIChatCompletionController', () => {
|
|
let OpenAIChatCompletionController;
|
|
let req, res;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
|
|
const controller = require('../openai');
|
|
OpenAIChatCompletionController = controller.OpenAIChatCompletionController;
|
|
|
|
req = {
|
|
body: {
|
|
model: 'agent-123',
|
|
messages: [{ role: 'user', content: 'Hello' }],
|
|
stream: false,
|
|
},
|
|
user: { id: 'user-123' },
|
|
config: {
|
|
endpoints: {
|
|
agents: { allowedProviders: ['openAI'] },
|
|
},
|
|
},
|
|
on: jest.fn(),
|
|
};
|
|
|
|
res = {
|
|
status: jest.fn().mockReturnThis(),
|
|
json: jest.fn(),
|
|
setHeader: jest.fn(),
|
|
flushHeaders: jest.fn(),
|
|
end: jest.fn(),
|
|
write: jest.fn(),
|
|
};
|
|
});
|
|
|
|
describe('conversation ownership validation', () => {
|
|
it('should skip ownership check when conversation_id is not provided', async () => {
|
|
const { getConvo } = require('~/models');
|
|
await OpenAIChatCompletionController(req, res);
|
|
expect(getConvo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return 400 when conversation_id is not a string', async () => {
|
|
const { validateRequest } = require('@librechat/api');
|
|
validateRequest.mockReturnValueOnce({
|
|
request: { model: 'agent-123', messages: [], stream: false, conversation_id: { $gt: '' } },
|
|
});
|
|
|
|
await OpenAIChatCompletionController(req, res);
|
|
expect(res.status).toHaveBeenCalledWith(400);
|
|
});
|
|
|
|
it('should return 404 when conversation is not owned by user', async () => {
|
|
const { validateRequest } = require('@librechat/api');
|
|
const { getConvo } = require('~/models');
|
|
validateRequest.mockReturnValueOnce({
|
|
request: {
|
|
model: 'agent-123',
|
|
messages: [],
|
|
stream: false,
|
|
conversation_id: 'convo-abc',
|
|
},
|
|
});
|
|
getConvo.mockResolvedValueOnce(null);
|
|
|
|
await OpenAIChatCompletionController(req, res);
|
|
expect(getConvo).toHaveBeenCalledWith('user-123', 'convo-abc');
|
|
expect(res.status).toHaveBeenCalledWith(404);
|
|
});
|
|
|
|
it('should proceed when conversation is owned by user', async () => {
|
|
const { validateRequest } = require('@librechat/api');
|
|
const { getConvo } = require('~/models');
|
|
validateRequest.mockReturnValueOnce({
|
|
request: {
|
|
model: 'agent-123',
|
|
messages: [],
|
|
stream: false,
|
|
conversation_id: 'convo-abc',
|
|
},
|
|
});
|
|
getConvo.mockResolvedValueOnce({ conversationId: 'convo-abc', user: 'user-123' });
|
|
|
|
await OpenAIChatCompletionController(req, res);
|
|
expect(getConvo).toHaveBeenCalledWith('user-123', 'convo-abc');
|
|
expect(res.status).not.toHaveBeenCalledWith(404);
|
|
});
|
|
|
|
it('should return 500 when getConvo throws a DB error', async () => {
|
|
const { validateRequest } = require('@librechat/api');
|
|
const { getConvo } = require('~/models');
|
|
validateRequest.mockReturnValueOnce({
|
|
request: {
|
|
model: 'agent-123',
|
|
messages: [],
|
|
stream: false,
|
|
conversation_id: 'convo-abc',
|
|
},
|
|
});
|
|
getConvo.mockRejectedValueOnce(new Error('DB connection failed'));
|
|
|
|
await OpenAIChatCompletionController(req, res);
|
|
expect(res.status).toHaveBeenCalledWith(500);
|
|
});
|
|
});
|
|
|
|
describe('token usage recording', () => {
|
|
it('should call recordCollectedUsage after successful non-streaming completion', async () => {
|
|
await OpenAIChatCompletionController(req, res);
|
|
|
|
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
|
|
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
|
|
{
|
|
spendTokens: mockSpendTokens,
|
|
spendStructuredTokens: mockSpendStructuredTokens,
|
|
pricing: { getMultiplier: mockGetMultiplier, getCacheMultiplier: mockGetCacheMultiplier },
|
|
bulkWriteOps: {
|
|
insertMany: mockBulkInsertTransactions,
|
|
updateBalance: mockUpdateBalance,
|
|
},
|
|
},
|
|
expect.objectContaining({
|
|
user: 'user-123',
|
|
conversationId: expect.any(String),
|
|
collectedUsage: expect.any(Array),
|
|
context: 'message',
|
|
balance: { enabled: true },
|
|
transactions: { enabled: true },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should pass balance and transactions config to recordCollectedUsage', async () => {
|
|
mockGetBalanceConfig.mockReturnValue({ enabled: true, startBalance: 1000 });
|
|
mockGetTransactionsConfig.mockReturnValue({ enabled: true, rateLimit: 100 });
|
|
|
|
await OpenAIChatCompletionController(req, res);
|
|
|
|
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
|
|
expect.any(Object),
|
|
expect.objectContaining({
|
|
balance: { enabled: true, startBalance: 1000 },
|
|
transactions: { enabled: true, rateLimit: 100 },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should pass spendTokens, spendStructuredTokens, pricing, and bulkWriteOps as dependencies', async () => {
|
|
await OpenAIChatCompletionController(req, res);
|
|
|
|
const [deps] = mockRecordCollectedUsage.mock.calls[0];
|
|
expect(deps).toHaveProperty('spendTokens', mockSpendTokens);
|
|
expect(deps).toHaveProperty('spendStructuredTokens', mockSpendStructuredTokens);
|
|
expect(deps).toHaveProperty('pricing');
|
|
expect(deps.pricing).toHaveProperty('getMultiplier', mockGetMultiplier);
|
|
expect(deps.pricing).toHaveProperty('getCacheMultiplier', mockGetCacheMultiplier);
|
|
expect(deps).toHaveProperty('bulkWriteOps');
|
|
expect(deps.bulkWriteOps).toHaveProperty('insertMany', mockBulkInsertTransactions);
|
|
expect(deps.bulkWriteOps).toHaveProperty('updateBalance', mockUpdateBalance);
|
|
});
|
|
|
|
it('should include model from primaryConfig in recordCollectedUsage params', async () => {
|
|
await OpenAIChatCompletionController(req, res);
|
|
|
|
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
|
|
expect.any(Object),
|
|
expect.objectContaining({
|
|
model: 'gpt-4',
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('recursionLimit resolution', () => {
|
|
it('should pass resolveRecursionLimit result to processStream config', async () => {
|
|
const { resolveRecursionLimit } = require('@librechat/api');
|
|
resolveRecursionLimit.mockReturnValueOnce(75);
|
|
|
|
await OpenAIChatCompletionController(req, res);
|
|
|
|
expect(mockProcessStream).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({ recursionLimit: 75 }),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it('should call resolveRecursionLimit with agentsEConfig and agent', async () => {
|
|
const { resolveRecursionLimit } = require('@librechat/api');
|
|
const { getAgent } = require('~/models');
|
|
const mockAgent = { id: 'agent-123', name: 'Test', recursion_limit: 200 };
|
|
getAgent.mockResolvedValueOnce(mockAgent);
|
|
|
|
req.config = {
|
|
endpoints: {
|
|
agents: { recursionLimit: 100, maxRecursionLimit: 150, allowedProviders: [] },
|
|
},
|
|
};
|
|
|
|
await OpenAIChatCompletionController(req, res);
|
|
|
|
expect(resolveRecursionLimit).toHaveBeenCalledWith(req.config.endpoints.agents, mockAgent);
|
|
});
|
|
});
|
|
});
|