mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-01 03:27:01 +00:00
feat: add tenant and agent Langfuse configuration
This commit is contained in:
parent
3e7262cfe0
commit
03e21214d0
21 changed files with 687 additions and 10 deletions
|
|
@ -60,6 +60,70 @@ const getSafeModelParameters = (modelParameters) => {
|
|||
return typeof useResponsesApi === 'boolean' ? { useResponsesApi } : {};
|
||||
};
|
||||
|
||||
const toPlainObject = (value) =>
|
||||
value && typeof value.toObject === 'function' ? value.toObject() : value;
|
||||
|
||||
const isNonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0;
|
||||
|
||||
const normalizeLangfuseConfig = (incoming, existing) => {
|
||||
if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) {
|
||||
return incoming;
|
||||
}
|
||||
|
||||
const existingConfig = toPlainObject(existing) ?? {};
|
||||
const normalized = {};
|
||||
|
||||
if (typeof incoming.enabled === 'boolean') {
|
||||
normalized.enabled = incoming.enabled;
|
||||
}
|
||||
|
||||
for (const key of ['publicKey', 'baseUrl']) {
|
||||
if (isNonEmptyString(incoming[key])) {
|
||||
normalized[key] = incoming[key].trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (isNonEmptyString(incoming.secretKey)) {
|
||||
normalized.secretKey = incoming.secretKey.trim();
|
||||
} else if (isNonEmptyString(existingConfig.secretKey)) {
|
||||
normalized.secretKey = existingConfig.secretKey;
|
||||
}
|
||||
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
};
|
||||
|
||||
const redactLangfuseSecret = (agent) => {
|
||||
const payload = toPlainObject(agent);
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const redactSingleAgent = (value) => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
if (value.langfuse && typeof value.langfuse === 'object' && value.langfuse.secretKey) {
|
||||
return {
|
||||
...value,
|
||||
langfuse: {
|
||||
...toPlainObject(value.langfuse),
|
||||
secretKey: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const redactedPayload = redactSingleAgent(payload);
|
||||
if (Array.isArray(redactedPayload.versions)) {
|
||||
redactedPayload.versions = redactedPayload.versions.map((version) =>
|
||||
redactSingleAgent(toPlainObject(version)),
|
||||
);
|
||||
}
|
||||
|
||||
return redactedPayload;
|
||||
};
|
||||
|
||||
/**
|
||||
* Looks up each referenced agent id in Mongo, splits them into three
|
||||
* buckets the caller needs for validation: ids that don't exist at all,
|
||||
|
|
@ -284,6 +348,13 @@ const createAgentHandler = async (req, res) => {
|
|||
agentData.model_parameters = removeNullishValues(agentData.model_parameters, true);
|
||||
}
|
||||
|
||||
if (agentData.langfuse) {
|
||||
agentData.langfuse = normalizeLangfuseConfig(agentData.langfuse);
|
||||
if (!agentData.langfuse) {
|
||||
delete agentData.langfuse;
|
||||
}
|
||||
}
|
||||
|
||||
const { id: userId, role: userRole } = req.user;
|
||||
|
||||
if (agentData.tool_resources) {
|
||||
|
|
@ -389,7 +460,7 @@ const createAgentHandler = async (req, res) => {
|
|||
);
|
||||
}
|
||||
|
||||
res.status(201).json(agent);
|
||||
res.status(201).json(redactLangfuseSecret(agent));
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.error('[/Agents] Validation error', error.errors);
|
||||
|
|
@ -471,8 +542,8 @@ const getAgentHandler = async (req, res, expandProperties = false) => {
|
|||
});
|
||||
}
|
||||
|
||||
// EDIT permission: Full agent details including sensitive configuration
|
||||
return res.status(200).json(agent);
|
||||
// EDIT permission: Full agent details, with write-only Langfuse secret redacted.
|
||||
return res.status(200).json(redactLangfuseSecret(agent));
|
||||
} catch (error) {
|
||||
logger.error('[/Agents/:id] Error retrieving agent', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
|
|
@ -556,6 +627,13 @@ const updateAgentHandler = async (req, res) => {
|
|||
return res.status(404).json({ error: 'Agent not found' });
|
||||
}
|
||||
|
||||
if (updateData.langfuse) {
|
||||
updateData.langfuse = normalizeLangfuseConfig(updateData.langfuse, existingAgent.langfuse);
|
||||
if (!updateData.langfuse) {
|
||||
delete updateData.langfuse;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert legacy OCR tool resource to context format in existing agent
|
||||
const ocrConversion = mergeAgentOcrConversion(existingAgent, updateData);
|
||||
if (ocrConversion.tool_resources) {
|
||||
|
|
@ -615,7 +693,7 @@ const updateAgentHandler = async (req, res) => {
|
|||
delete updatedAgent.author;
|
||||
}
|
||||
|
||||
return res.json(updatedAgent);
|
||||
return res.json(redactLangfuseSecret(updatedAgent));
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.error('[/Agents/:id] Validation error', error.errors);
|
||||
|
|
@ -794,7 +872,7 @@ const duplicateAgentHandler = async (req, res) => {
|
|||
}
|
||||
|
||||
return res.status(201).json({
|
||||
agent: newAgent,
|
||||
agent: redactLangfuseSecret(newAgent),
|
||||
actions: newActionsList,
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -948,7 +1026,7 @@ const getListAgentsHandler = async (req, res) => {
|
|||
// Silently ignore mapping errors
|
||||
void e;
|
||||
}
|
||||
return agent;
|
||||
return redactLangfuseSecret(agent);
|
||||
});
|
||||
|
||||
return res.json(data);
|
||||
|
|
|
|||
|
|
@ -178,6 +178,39 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
|||
expect(agentInDb.author.toString()).toBe(mockReq.user.id);
|
||||
});
|
||||
|
||||
test('should persist Langfuse config but redact secret key in create response', async () => {
|
||||
mockReq.body = {
|
||||
name: 'Langfuse Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
langfuse: {
|
||||
enabled: true,
|
||||
publicKey: ' pk-test ',
|
||||
secretKey: ' sk-test ',
|
||||
baseUrl: ' https://cloud.langfuse.com ',
|
||||
},
|
||||
};
|
||||
|
||||
await createAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
||||
const createdAgent = mockRes.json.mock.calls[0][0];
|
||||
expect(createdAgent.langfuse).toEqual({
|
||||
enabled: true,
|
||||
publicKey: 'pk-test',
|
||||
secretKey: '',
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
});
|
||||
|
||||
const agentInDb = await Agent.findOne({ id: createdAgent.id }).lean();
|
||||
expect(agentInDb.langfuse).toEqual({
|
||||
enabled: true,
|
||||
publicKey: 'pk-test',
|
||||
secretKey: 'sk-test',
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
});
|
||||
});
|
||||
|
||||
test('should reject creation with unauthorized fields (mass assignment protection)', async () => {
|
||||
const maliciousData = {
|
||||
// Required fields
|
||||
|
|
@ -751,6 +784,82 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
|||
expect(agentInDb.model_parameters.maxContextTokens).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should preserve existing Langfuse secret when update sends an empty secret', async () => {
|
||||
await Agent.updateOne(
|
||||
{ id: existingAgentId },
|
||||
{
|
||||
langfuse: {
|
||||
enabled: true,
|
||||
publicKey: 'pk-original',
|
||||
secretKey: 'sk-original',
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = {
|
||||
langfuse: {
|
||||
enabled: true,
|
||||
publicKey: ' pk-updated ',
|
||||
secretKey: '',
|
||||
baseUrl: ' https://us.cloud.langfuse.com ',
|
||||
},
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
const updatedAgent = mockRes.json.mock.calls[0][0];
|
||||
expect(updatedAgent.langfuse).toEqual({
|
||||
enabled: true,
|
||||
publicKey: 'pk-updated',
|
||||
secretKey: '',
|
||||
baseUrl: 'https://us.cloud.langfuse.com',
|
||||
});
|
||||
|
||||
const agentInDb = await Agent.findOne({ id: existingAgentId }).lean();
|
||||
expect(agentInDb.langfuse).toEqual({
|
||||
enabled: true,
|
||||
publicKey: 'pk-updated',
|
||||
secretKey: 'sk-original',
|
||||
baseUrl: 'https://us.cloud.langfuse.com',
|
||||
});
|
||||
});
|
||||
|
||||
test('should update Langfuse secret explicitly while redacting it in update response', async () => {
|
||||
await Agent.updateOne(
|
||||
{ id: existingAgentId },
|
||||
{
|
||||
langfuse: {
|
||||
enabled: true,
|
||||
publicKey: 'pk-original',
|
||||
secretKey: 'sk-original',
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = {
|
||||
langfuse: {
|
||||
enabled: true,
|
||||
publicKey: 'pk-original',
|
||||
secretKey: ' sk-updated ',
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
},
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
const updatedAgent = mockRes.json.mock.calls[0][0];
|
||||
expect(updatedAgent.langfuse.secretKey).toBe('');
|
||||
|
||||
const agentInDb = await Agent.findOne({ id: existingAgentId }).lean();
|
||||
expect(agentInDb.langfuse.secretKey).toBe('sk-updated');
|
||||
});
|
||||
|
||||
test('should return 404 for non-existent agent', async () => {
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = `agent_${uuidv4()}`; // Non-existent ID
|
||||
|
|
@ -1303,6 +1412,30 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
|||
expect(response.data[0].name).toBe('Agent A1');
|
||||
});
|
||||
|
||||
test('should not expose Langfuse secrets in agent list responses', async () => {
|
||||
await Agent.updateOne(
|
||||
{ id: agentA1.id },
|
||||
{
|
||||
langfuse: {
|
||||
enabled: true,
|
||||
publicKey: 'pk-list',
|
||||
secretKey: 'sk-list',
|
||||
baseUrl: 'https://cloud.langfuse.com',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
mockReq.user.id = userB.toString();
|
||||
findAccessibleResources.mockResolvedValue([agentA1._id]);
|
||||
findPubliclyAccessibleResources.mockResolvedValue([]);
|
||||
|
||||
await getListAgentsHandler(mockReq, mockRes);
|
||||
|
||||
const response = mockRes.json.mock.calls[0][0];
|
||||
expect(response.data).toHaveLength(1);
|
||||
expect(response.data[0].langfuse).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should return multiple accessible agents', async () => {
|
||||
// User B has access to multiple agents
|
||||
mockReq.user.id = userB.toString();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue