🪭 feat: Add opt-in Langfuse fanout gateway + collector (#13872)

* feat: add opt-in Langfuse fanout collector

* feat: fan out Langfuse feedback scores

* docs: prepare Langfuse fanout for OSS setup

* fix: clarify Langfuse fanout collector config

* test: stabilize librechat suite

* test: fix upload dialog import order

* fix: omit empty Langfuse tenant fields

* fix: gate tenant Langfuse fanout

* test: cover central Langfuse env fallback

* style: format Langfuse fanout config

* feat: route langfuse fanout by destination

* docs: clarify langfuse compose destination scope

* test: remove unrelated suite stabilization

* style: sort agent imports

* fix: treat blank tenant fanout toggle as disabled

* fix: rename tenant fanout emergency toggle

* test: guard langfuse fanout collector config drift

* feat: tune langfuse fanout batching

* test: render fanout helm tests without dependencies

* fix: narrow remote agent run config

* refactor: share string normalization helper

* fix: align langfuse fanout env parsing

* fix(langfuse): align score fanout toggles with traces

* fix(langfuse): keep central fanout config collector-only

* fix(langfuse): type fanout collector config

* fix(langfuse): harden tenant fanout config

* feat(langfuse): support media fanout gateway

* fix(langfuse): route tenant fanout through destination URL

* fix(langfuse): harden fanout routing checks

* ci(langfuse): test fanout gateway changes

* ci(langfuse): check fanout go formatting

* fix(langfuse): satisfy api typecheck
This commit is contained in:
Ravi Kumar L 2026-06-26 17:26:39 +02:00 committed by GitHub
parent 0789a04d11
commit a0529c9af7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 5699 additions and 130 deletions

View file

@ -11,6 +11,8 @@ jest.mock('@librechat/agents', () => ({
jest.mock('@librechat/api', () => ({
unescapeLaTeX: jest.fn((x) => x),
countTokens: jest.fn().mockResolvedValue(10),
sendFeedbackScore: jest.fn().mockResolvedValue(undefined),
traceIdForMessage: jest.fn((messageId) => `trace-${messageId}`),
}));
jest.mock('@librechat/data-schemas', () => ({
@ -49,6 +51,7 @@ jest.mock('~/server/middleware/requireJwtAuth', () => (req, res, next) => next()
jest.mock('~/server/middleware', () => ({
requireJwtAuth: (req, res, next) => next(),
validateMessageReq: (req, res, next) => next(),
configMiddleware: (req, res, next) => next(),
}));
jest.mock('~/db/models', () => ({

View file

@ -10,7 +10,7 @@ const {
mergeQuotedTextForCount,
} = require('@librechat/api');
const { findAllArtifacts, replaceArtifactContent } = require('~/server/services/Artifacts/update');
const { requireJwtAuth, validateMessageReq } = require('~/server/middleware');
const { requireJwtAuth, validateMessageReq, configMiddleware } = require('~/server/middleware');
const db = require('~/models');
const router = express.Router();
@ -398,49 +398,56 @@ router.put('/:conversationId/:messageId', validateMessageReq, async (req, res) =
}
});
router.put('/:conversationId/:messageId/feedback', validateMessageReq, async (req, res) => {
try {
const { conversationId, messageId } = req.params;
const { feedback } = req.body;
router.put(
'/:conversationId/:messageId/feedback',
validateMessageReq,
configMiddleware,
async (req, res) => {
try {
const { conversationId, messageId } = req.params;
const { feedback } = req.body;
const updatedMessage = await db.updateMessage(
req?.user?.id,
{
messageId,
feedback: feedback || null,
},
{ context: 'updateFeedback' },
);
// Best-effort: Assistants messages do not have deterministic AgentRun traces.
if (!isAssistantsEndpoint(updatedMessage.endpoint)) {
sendFeedbackScore({
traceId: traceIdForMessage(messageId),
feedback: updatedMessage.feedback,
metadata: {
messageId: updatedMessage.messageId ?? messageId,
parentMessageId: updatedMessage.parentMessageId,
conversationId: updatedMessage.conversationId ?? conversationId,
sessionId: updatedMessage.conversationId ?? conversationId,
userId: req?.user?.id,
endpoint: updatedMessage.endpoint,
sender: updatedMessage.sender,
isCreatedByUser: updatedMessage.isCreatedByUser,
tokenCount: updatedMessage.tokenCount,
const updatedMessage = await db.updateMessage(
req?.user?.id,
{
messageId,
feedback: feedback || null,
},
}).catch((err) => logger.error('[langfuse] feedback score failed:', err));
}
{ context: 'updateFeedback' },
);
res.json({
messageId,
conversationId,
feedback: updatedMessage.feedback,
});
} catch (error) {
logger.error('Error updating message feedback:', error);
res.status(500).json({ error: 'Failed to update feedback' });
}
});
// Best-effort: Assistants messages do not have deterministic AgentRun traces.
if (!isAssistantsEndpoint(updatedMessage.endpoint)) {
sendFeedbackScore({
traceId: traceIdForMessage(messageId),
feedback: updatedMessage.feedback,
appConfig: req.config,
metadata: {
messageId: updatedMessage.messageId ?? messageId,
parentMessageId: updatedMessage.parentMessageId,
conversationId: updatedMessage.conversationId ?? conversationId,
sessionId: updatedMessage.conversationId ?? conversationId,
userId: req?.user?.id,
tenantId: req?.user?.tenantId,
endpoint: updatedMessage.endpoint,
sender: updatedMessage.sender,
isCreatedByUser: updatedMessage.isCreatedByUser,
tokenCount: updatedMessage.tokenCount,
},
}).catch((err) => logger.error('[langfuse] feedback score failed:', err));
}
res.json({
messageId,
conversationId,
feedback: updatedMessage.feedback,
});
} catch (error) {
logger.error('Error updating message feedback:', error);
res.status(500).json({ error: 'Failed to update feedback' });
}
},
);
router.delete('/:conversationId/:messageId', validateMessageReq, async (req, res) => {
try {