diff --git a/api/server/routes/messages.js b/api/server/routes/messages.js index 17e740c515..2aaed7cd5f 100644 --- a/api/server/routes/messages.js +++ b/api/server/routes/messages.js @@ -7,6 +7,7 @@ const { countTokens, sendFeedbackScore, traceIdForMessage, + mergeQuotedTextForCount, } = require('@librechat/api'); const { findAllArtifacts, replaceArtifactContent } = require('~/server/services/Artifacts/update'); const { requireJwtAuth, validateMessageReq } = require('~/server/middleware'); @@ -329,7 +330,22 @@ router.put('/:conversationId/:messageId', validateMessageReq, async (req, res) = const { text, index, model } = req.body; if (index === undefined) { - const tokenCount = await countTokens(text, model); + /** A user turn's persisted `quotes` are re-prepended into the prompt on + * every send, but this edit only changes `text`. Count the merged + * text+quotes so the stored `tokenCount` stays authoritative (matching the + * send path); a plain text-only count under-reports by the quote block. */ + const existing = ( + await db.getMessages( + { conversationId, messageId, user: req.user.id }, + 'quotes isCreatedByUser', + ) + )?.[0]; + const textToCount = mergeQuotedTextForCount( + text, + existing?.quotes, + existing?.isCreatedByUser === true, + ); + const tokenCount = await countTokens(textToCount, model); const result = await db.updateMessage(req?.user?.id, { messageId, text, tokenCount }); return res.status(200).json(result); } diff --git a/packages/api/src/utils/quotes.spec.ts b/packages/api/src/utils/quotes.spec.ts new file mode 100644 index 0000000000..d5b517194f --- /dev/null +++ b/packages/api/src/utils/quotes.spec.ts @@ -0,0 +1,36 @@ +import { mergeQuotedTextForCount, QUOTE_MAX_COUNT } from './quotes'; + +describe('mergeQuotedTextForCount', () => { + it('returns text unchanged for non-user messages', () => { + expect(mergeQuotedTextForCount('hi', ['quote'], false)).toBe('hi'); + }); + + it('returns text unchanged when there are no usable quotes', () => { + expect(mergeQuotedTextForCount('hi', undefined, true)).toBe('hi'); + expect(mergeQuotedTextForCount('hi', [], true)).toBe('hi'); + expect(mergeQuotedTextForCount('hi', ['', ' '], true)).toBe('hi'); + expect(mergeQuotedTextForCount('hi', 'not-an-array', true)).toBe('hi'); + }); + + it('prepends merged quotes for a user message with quotes', () => { + const out = mergeQuotedTextForCount('my question', ['excerpt one'], true); + expect(out).toBe('> excerpt one\n\nmy question'); + /** Longer than the bare text, so counting `out` exceeds counting text alone — + * the under-report this fix addresses. */ + expect(out.length).toBeGreaterThan('my question'.length); + }); + + it('normalizes quotes (drops non-strings and empties, trims) before merging', () => { + const out = mergeQuotedTextForCount('q', ['keep', 42, '', ' trim '], true); + expect(out).toContain('> keep'); + expect(out).toContain('> trim'); + expect(out).not.toContain('42'); + }); + + it('caps the number of merged excerpts at QUOTE_MAX_COUNT', () => { + const many = Array.from({ length: QUOTE_MAX_COUNT + 5 }, (_, i) => `q${i}`); + const out = mergeQuotedTextForCount('body', many, true); + const quoteBlocks = out.split('\n\n').filter((block) => block.startsWith('>')); + expect(quoteBlocks).toHaveLength(QUOTE_MAX_COUNT); + }); +}); diff --git a/packages/api/src/utils/quotes.ts b/packages/api/src/utils/quotes.ts index 877ff5aafe..436643574b 100644 --- a/packages/api/src/utils/quotes.ts +++ b/packages/api/src/utils/quotes.ts @@ -67,3 +67,22 @@ export function mergeQuotedText(text: string, quotes: string[]): string { const body = text ?? ''; return body.length > 0 ? `${block}\n\n${body}` : block; } + +/** + * Resolves the prompt text to tokenize for a (possibly quoted) user turn, + * mirroring the send path: a user message's persisted `quotes` are prepended + * into the prompt on every turn, so an edit that only changes `text` must count + * the merged text+quotes to keep the stored `tokenCount` authoritative. Returns + * `text` unchanged for non-user messages or when there are no usable quotes. + */ +export function mergeQuotedTextForCount( + text: string, + rawQuotes: unknown, + isCreatedByUser: boolean, +): string { + if (!isCreatedByUser) { + return text; + } + const quotes = getReferencedQuotes(rawQuotes); + return quotes ? mergeQuotedText(text, quotes) : text; +}