🪙 fix: Count quote tokens on message edit so context stays accurate

The message-edit route recomputed a user message's tokenCount from the edited
`text` alone, ignoring its persisted `quotes`. But the send path re-prepends
those quotes into the prompt on every turn (mergeQuotedText), so after editing a
quoted message the stored tokenCount under-reported by the whole quote block,
skewing the context gauge and any other tokenCount consumer.

The full-recount path now fetches the message's quotes and counts the merged
text+quotes via a new `mergeQuotedTextForCount` helper in packages/api (mirrors
the send path), so the stored count stays authoritative. The incremental
content-part path is left as-is: it deltas only the edited part and preserves the
rest of the count (incl. the quote contribution), and applies to content-array
messages rather than text+quotes user turns.

Deferred follow-up from #13953.
This commit is contained in:
Danny Avila 2026-06-25 15:49:01 -04:00
parent 5706e414fd
commit ec04a62b42
3 changed files with 72 additions and 1 deletions

View file

@ -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);
}

View file

@ -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);
});
});

View file

@ -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;
}