LibreChat/api/server/controllers/agents/__tests__/openai.spec.js
Danny Avila 3c3837bb7d
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
🧾 fix: Bill Subagent Child-Run Model Usage in Parent Transactions (#13683)
* 🧾 fix: Bill Subagent Child-Run Model Usage in Parent Transactions

* 🩹 fix: Type Subagent Usage Sink Structurally Until SDK Release

* 🔧 chore: Update @librechat/agents dependency to version 3.2.35 in package-lock.json and related package.json files
2026-06-13 14:55:48 -04:00

511 lines
18 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 });
const mockBuildSkillPrimedIdsByName = jest.fn((manualSkillPrimes, alwaysApplySkillPrimes) => {
const primed = {};
for (const skill of alwaysApplySkillPrimes ?? []) {
primed[skill.name] = skill._id.toString();
}
for (const skill of manualSkillPrimes ?? []) {
primed[skill.name] = skill._id.toString();
}
return Object.keys(primed).length > 0 ? primed : undefined;
});
const mockEnrichWithSkillConfigurable = jest.fn((result) => result);
const mockBuildAgentToolContext = jest.fn(({ agent, config }) => ({
agent,
toolRegistry: config.toolRegistry,
userMCPAuthMap: config.userMCPAuthMap,
tool_resources: config.tool_resources,
actionsEnabled: config.actionsEnabled,
accessibleSkillIds: config.accessibleSkillIds,
activeSkillNames: config.activeSkillNames,
codeEnvAvailable: config.codeEnvAvailable,
skillAuthoringAvailable: config.skillAuthoringAvailable,
fileAuthoringToolNames: config.fileAuthoringToolNames,
skillPrimedIdsByName:
mockBuildSkillPrimedIdsByName(config.manualSkillPrimes, config.alwaysApplySkillPrimes) ?? {},
}));
const mockEnrichLoadedToolsWithAgentContext = jest.fn(({ result, req, ctx }) =>
mockEnrichWithSkillConfigurable({
result,
context: {
req,
accessibleSkillIds: ctx.accessibleSkillIds,
codeEnvAvailable: ctx.codeEnvAvailable === true,
skillPrimedIdsByName: ctx.skillPrimedIdsByName,
activeSkillNames: ctx.activeSkillNames,
skillAuthoringAvailable: ctx.skillAuthoringAvailable === true,
fileAuthoringToolNames: ctx.fileAuthoringToolNames,
},
}),
);
const mockCanAuthorSkillFiles = jest.fn(
({ scopedEditableSkillIds = [], skillCreateAllowed }) =>
scopedEditableSkillIds.length > 0 || skillCreateAllowed === true,
);
const mockGetSkillToolDeps = jest.fn(() => ({}));
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,
createSubagentUsageSink: jest.fn().mockReturnValue(jest.fn()),
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),
findPiiMatchInMessages: jest.fn().mockReturnValue(null),
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('~/server/services/Endpoints/agents/skillDeps', () => ({
getSkillToolDeps: mockGetSkillToolDeps,
getSkillDbMethods: jest.fn(() => ({})),
canAuthorSkillFiles: mockCanAuthorSkillFiles,
withDeploymentSkillIds: jest.fn((ids = []) => ids),
enrichWithSkillConfigurable: mockEnrichWithSkillConfigurable,
buildSkillPrimedIdsByName: mockBuildSkillPrimedIdsByName,
buildAgentToolContext: mockBuildAgentToolContext,
enrichLoadedToolsWithAgentContext: mockEnrichLoadedToolsWithAgentContext,
}));
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);
});
});
describe('sub-agent skill priming', () => {
it('passes the sub-agent primed skill IDs into tool execution', async () => {
const {
initializeAgent,
discoverConnectedAgents,
createToolExecuteHandler,
} = require('@librechat/api');
const { loadToolsForExecution } = require('~/server/services/ToolService');
const subAgent = { id: 'agent-sub', name: 'Sub Agent' };
const subConfig = {
id: 'agent-sub',
model: 'gpt-4',
model_parameters: {},
toolRegistry: new Map(),
userMCPAuthMap: { sub: { token: 'sub-token' } },
tool_resources: { code_interpreter: { file_ids: ['sub-file'] } },
actionsEnabled: true,
accessibleSkillIds: ['sub-skill-id'],
activeSkillNames: ['sub-hidden-skill'],
codeEnvAvailable: true,
skillAuthoringAvailable: true,
fileAuthoringToolNames: ['create_file', 'edit_file'],
manualSkillPrimes: [{ name: 'sub-hidden-skill', _id: { toString: () => 'sub-manual-id' } }],
alwaysApplySkillPrimes: [
{ name: 'sub-always-skill', _id: { toString: () => 'sub-always-id' } },
],
};
initializeAgent.mockResolvedValueOnce({
id: 'agent-123',
model: 'gpt-4',
model_parameters: {},
toolRegistry: new Map(),
edges: [{ source: 'agent-123', target: 'agent-sub' }],
accessibleSkillIds: ['primary-skill-id'],
activeSkillNames: ['primary-skill'],
codeEnvAvailable: false,
skillAuthoringAvailable: false,
fileAuthoringToolNames: [],
manualSkillPrimes: [{ name: 'primary-skill', _id: { toString: () => 'primary-skill-id' } }],
});
discoverConnectedAgents.mockImplementationOnce(async (_params, deps) => {
deps.onAgentInitialized('agent-sub', subAgent, subConfig);
return {
agentConfigs: new Map([['agent-sub', subConfig]]),
edges: [],
skippedAgentIds: new Set(),
userMCPAuthMap: undefined,
};
});
await OpenAIChatCompletionController(req, res);
const toolExecuteOptions = createToolExecuteHandler.mock.calls.at(-1)[0];
await toolExecuteOptions.loadTools(['read_file'], 'agent-sub');
expect(loadToolsForExecution).toHaveBeenLastCalledWith(
expect.objectContaining({
agent: subAgent,
toolRegistry: subConfig.toolRegistry,
userMCPAuthMap: subConfig.userMCPAuthMap,
tool_resources: subConfig.tool_resources,
actionsEnabled: true,
}),
);
expect(mockEnrichWithSkillConfigurable).toHaveBeenLastCalledWith({
result: expect.anything(),
context: {
req,
accessibleSkillIds: ['sub-skill-id'],
codeEnvAvailable: true,
skillPrimedIdsByName: {
'sub-always-skill': 'sub-always-id',
'sub-hidden-skill': 'sub-manual-id',
},
activeSkillNames: ['sub-hidden-skill'],
skillAuthoringAvailable: true,
fileAuthoringToolNames: ['create_file', 'edit_file'],
},
});
});
});
});