mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 04:37:37 +00:00
📨 feat: Add Authenticated Agent Event Ingress (#15110)
* feat: add authenticated agent event ingress * style: sort agent ingress imports * fix: harden agent event ingress * fix: bind event provenance to API keys * fix: inspect event input with legacy PII filters * fix: scope event status reads to source keys * fix: bind event status reads to remote sources
This commit is contained in:
parent
f384e71f77
commit
3ebef4c84e
21 changed files with 1033 additions and 16 deletions
|
|
@ -9,6 +9,8 @@ const {
|
|||
MESSAGE_IP_WINDOW = 1,
|
||||
MESSAGE_USER_MAX = 40,
|
||||
MESSAGE_USER_WINDOW = 1,
|
||||
AGENT_EVENT_USER_MAX = 40,
|
||||
AGENT_EVENT_USER_WINDOW = 1,
|
||||
MESSAGE_VIOLATION_SCORE: score,
|
||||
} = process.env;
|
||||
|
||||
|
|
@ -20,6 +22,10 @@ const userWindowMs = MESSAGE_USER_WINDOW * 60 * 1000;
|
|||
const userMax = MESSAGE_USER_MAX;
|
||||
const userWindowInMinutes = userWindowMs / 60000;
|
||||
|
||||
const agentEventUserWindowMs = AGENT_EVENT_USER_WINDOW * 60 * 1000;
|
||||
const agentEventUserMax = AGENT_EVENT_USER_MAX;
|
||||
const agentEventUserWindowInMinutes = agentEventUserWindowMs / 60000;
|
||||
|
||||
/**
|
||||
* Creates either an IP/User message request rate limiter for excessive requests
|
||||
* that properly logs and denies the violation.
|
||||
|
|
@ -74,7 +80,31 @@ const messageIpLimiter = rateLimit(ipLimiterOptions);
|
|||
*/
|
||||
const messageUserLimiter = rateLimit(userLimiterOptions);
|
||||
|
||||
/**
|
||||
* Event admission has its own API-principal bucket. The durable worker later
|
||||
* consumes the normal message-user bucket when it executes the delivery, so
|
||||
* sharing that limiter here would charge every event twice.
|
||||
*/
|
||||
const agentEventUserLimiter = rateLimit({
|
||||
windowMs: agentEventUserWindowMs,
|
||||
max: agentEventUserMax,
|
||||
handler: async (req, res) => {
|
||||
const type = ViolationTypes.MESSAGE_LIMIT;
|
||||
const errorMessage = {
|
||||
type,
|
||||
max: agentEventUserMax,
|
||||
limiter: 'agent_event_principal',
|
||||
windowInMinutes: agentEventUserWindowInMinutes,
|
||||
};
|
||||
await logViolation(req, res, type, errorMessage, score);
|
||||
return await denyRequest(req, res, errorMessage);
|
||||
},
|
||||
keyGenerator: (req) => String(req.apiKeyId ?? req.user?.id),
|
||||
store: limiterCache('agent_event_user_limiter'),
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
agentEventUserLimiter,
|
||||
messageIpLimiter,
|
||||
messageUserLimiter,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ jest.mock('~/server/middleware', () => ({
|
|||
next();
|
||||
},
|
||||
moderateText: (req, res, next) => next(),
|
||||
agentEventUserLimiter: (req, res, next) => next(),
|
||||
messageIpLimiter: (req, res, next) => next(),
|
||||
configMiddleware: (req, res, next) => next(),
|
||||
messageUserLimiter: (req, res, next) => next(),
|
||||
|
|
|
|||
82
api/server/routes/agents/__tests__/events.spec.js
Normal file
82
api/server/routes/agents/__tests__/events.spec.js
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const mockEnqueueAgentTrigger = jest.fn();
|
||||
const mockGetAgentTriggerDeliveryStatus = jest.fn();
|
||||
const mockEnqueueEvent = jest.fn((_req, res) => res.status(202).json({ id: 'trigger-1' }));
|
||||
const mockGetEvent = jest.fn((_req, res) => res.status(200).json({ status: 'succeeded' }));
|
||||
let mockIngressDependencies;
|
||||
const mockCreateAgentTriggerIngressHandlers = jest.fn((dependencies) => {
|
||||
mockIngressDependencies = dependencies;
|
||||
return {
|
||||
enqueueEvent: mockEnqueueEvent,
|
||||
getEvent: mockGetEvent,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
createAgentTriggerIngressHandlers: mockCreateAgentTriggerIngressHandlers,
|
||||
createMessageFilterPii: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/controllers/agents/openai', () => ({
|
||||
OpenAIChatCompletionController: jest.fn(),
|
||||
ListModelsController: jest.fn(),
|
||||
GetModelController: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Agents/triggers', () => ({
|
||||
enqueueAgentTrigger: mockEnqueueAgentTrigger,
|
||||
getAgentTriggerDeliveryStatus: mockGetAgentTriggerDeliveryStatus,
|
||||
}));
|
||||
|
||||
jest.mock('~/server/middleware', () => ({
|
||||
agentEventUserLimiter: (_req, _res, next) => next(),
|
||||
configMiddleware: (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.mock('../middleware', () => ({
|
||||
preAuthTenantMiddleware: (_req, _res, next) => next(),
|
||||
requireRemoteAgentAuth: (req, _res, next) => {
|
||||
req.user = { id: 'user-1' };
|
||||
next();
|
||||
},
|
||||
checkRemoteAgentsFeature: (_req, _res, next) => next(),
|
||||
checkAgentPermission: (_req, _res, next) => next(),
|
||||
checkAgentTriggerPermission: (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
const router = require('../openai');
|
||||
|
||||
describe('Remote Agents event routes', () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/agents/v1', router);
|
||||
|
||||
beforeEach(() => {
|
||||
mockEnqueueEvent.mockClear();
|
||||
mockGetEvent.mockClear();
|
||||
});
|
||||
|
||||
it('wires durable event admission to the trigger service', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/agents/v1/events')
|
||||
.send({
|
||||
target: { agentId: 'agent-1' },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
expect(mockIngressDependencies).toEqual({
|
||||
enqueue: mockEnqueueAgentTrigger,
|
||||
getDeliveryStatus: mockGetAgentTriggerDeliveryStatus,
|
||||
});
|
||||
expect(mockEnqueueEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('wires owner-scoped event status reads to the ingress handler', async () => {
|
||||
const response = await request(app).get('/api/agents/v1/events/trigger-1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockGetEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -4,6 +4,7 @@ const {
|
|||
preAuthTenantMiddleware,
|
||||
createRequireApiKeyAuth,
|
||||
createRemoteAgentAuth,
|
||||
createCheckAgentTriggerAccess,
|
||||
createCheckRemoteAgentAccess,
|
||||
} = require('@librechat/api');
|
||||
const { getEffectivePermissions } = require('~/server/services/PermissionService');
|
||||
|
|
@ -29,13 +30,17 @@ const checkRemoteAgentsFeature = generateCheckAccess({
|
|||
getRoleByName: db.getRoleByName,
|
||||
});
|
||||
|
||||
const checkAgentPermission = createCheckRemoteAgentAccess({
|
||||
const agentAccessDependencies = {
|
||||
getAgent: db.getAgent,
|
||||
getEffectivePermissions,
|
||||
});
|
||||
};
|
||||
|
||||
const checkAgentPermission = createCheckRemoteAgentAccess(agentAccessDependencies);
|
||||
const checkAgentTriggerPermission = createCheckAgentTriggerAccess(agentAccessDependencies);
|
||||
|
||||
module.exports = {
|
||||
checkAgentPermission,
|
||||
checkAgentTriggerPermission,
|
||||
preAuthTenantMiddleware,
|
||||
requireRemoteAgentAuth,
|
||||
checkRemoteAgentsFeature,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
*
|
||||
* Usage:
|
||||
* POST /v1/chat/completions - Chat with an agent
|
||||
* POST /v1/events - Durably deliver a source-neutral event
|
||||
* GET /v1/events/:id - Read an event delivery status and result
|
||||
* GET /v1/models - List available agents
|
||||
* GET /v1/models/:model - Get agent details
|
||||
*
|
||||
|
|
@ -17,26 +19,56 @@
|
|||
* }
|
||||
*/
|
||||
const express = require('express');
|
||||
const { createAgentTriggerIngressHandlers, createMessageFilterPii } = require('@librechat/api');
|
||||
const {
|
||||
OpenAIChatCompletionController,
|
||||
ListModelsController,
|
||||
GetModelController,
|
||||
} = require('~/server/controllers/agents/openai');
|
||||
const { configMiddleware } = require('~/server/middleware');
|
||||
const { agentEventUserLimiter, configMiddleware } = require('~/server/middleware');
|
||||
const {
|
||||
enqueueAgentTrigger,
|
||||
getAgentTriggerDeliveryStatus,
|
||||
} = require('~/server/services/Agents/triggers');
|
||||
const {
|
||||
checkAgentPermission,
|
||||
checkAgentTriggerPermission,
|
||||
preAuthTenantMiddleware,
|
||||
requireRemoteAgentAuth,
|
||||
checkRemoteAgentsFeature,
|
||||
} = require('./middleware');
|
||||
|
||||
const router = express.Router();
|
||||
const eventHandlers = createAgentTriggerIngressHandlers({
|
||||
enqueue: enqueueAgentTrigger,
|
||||
getDeliveryStatus: getAgentTriggerDeliveryStatus,
|
||||
});
|
||||
|
||||
router.use(preAuthTenantMiddleware);
|
||||
router.use(requireRemoteAgentAuth);
|
||||
router.use(configMiddleware);
|
||||
router.use(checkRemoteAgentsFeature);
|
||||
|
||||
/**
|
||||
* @route POST /v1/events
|
||||
* @desc Durably deliver a source-neutral event to an agent
|
||||
* @access Private (API key auth required)
|
||||
*/
|
||||
router.post(
|
||||
'/events',
|
||||
agentEventUserLimiter,
|
||||
createMessageFilterPii({ getConfig: (req) => req.config?.messageFilter?.pii }),
|
||||
checkAgentTriggerPermission,
|
||||
eventHandlers.enqueueEvent,
|
||||
);
|
||||
|
||||
/**
|
||||
* @route GET /v1/events/:id
|
||||
* @desc Read the authenticated owner's delivery status and result
|
||||
* @access Private (API key auth required)
|
||||
*/
|
||||
router.get('/events/:id', eventHandlers.getEvent);
|
||||
|
||||
/**
|
||||
* @route POST /v1/chat/completions
|
||||
* @desc OpenAI-compatible chat completions with agents
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ module.exports = {
|
|||
dispatchAgentTrigger: service.dispatch,
|
||||
enqueueAgentTrigger: service.enqueue,
|
||||
getAgentTriggerDelivery: service.getDelivery,
|
||||
getAgentTriggerDeliveryStatus: service.getDeliveryStatus,
|
||||
getAgentTriggerDeadLetters: service.getDeadLetters,
|
||||
requeueAgentTrigger: service.requeue,
|
||||
drainAgentTriggerDeliveriesForUser: service.drainUser,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue