Skip to content

fix(local): cap a chat question at its share of the window - #2053

Merged
MODSetter merged 1 commit into
MODSetter:devfrom
ybai08:fix/chat-question-cap
Sep 29, 2026
Merged

MODSetter merged 1 commit into
MODSetter:devfrom
ybai08:fix/chat-question-cap

Conversation

@ybai08

@ybai08 ybai08 commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

What

  • MessageText now carries max_length=QUESTION_CHARS, 4,096 characters. A longer message is a 422 at the wire, before any model is resolved or retrieval runs.
  • QUESTION_CHARS = QUESTION_TOKENS * CHARS_PER_TOKEN lives in budget.py, so the comment there is now true. CHARS_PER_TOKEN = 4 is the ratio history.py already used inline in _tokens(); it moves to budget.py and _tokens() reads it, so there is one ratio, not two.
  • The composer stops at the same cap through maxLength on the input, and once the text reaches it a status line says "Messages are limited to 4,096 characters." (role="status", in the footer row of a running thread, under the composer on an empty one). The constant is mirrored in features/chat/question-limit.ts with a comment pointing at budget.py. The string is translated in all nine catalogs; en.json regenerated.
  • Both docs: the budget bullet in chat.md and the budget table note in fit.md now describe the enforced cap, and both Known gaps lines are deleted.

The bound, and why it is global. 1,024 tokens at four characters a token is 4,096 characters. One cap for every window rather than a window-aware one, for two reasons: the refusal has to run before any model is resolved (that is what makes it a 422 at the wire rather than a failure mid-turn), and the budget already prices the question at the same 1,024 for every window, so a window-aware cap would make the question the one part whose price and limit disagree. A remote model with a 128k window gets the same 4,096, which is the tension the issue names; the one-line comment on QUESTION_CHARS records the choice. Dense scripts run richer than four characters a token, so the cap is loose for them, never tight.

Why the notice only at the cap. A counter would be noise on every message; the cap is the one moment where typing, or the tail of a paste, stops landing, so that is where the explanation goes.

Why

The budget treated the question as one of four real caps, and it was the one that was not. A pasted page pushed a full-history turn past the window, and llama.cpp cut the reply off mid-sentence with no error.

Fixes #1943

How to test

Tests first, all three failed before the change:

  • test_budget.py::test_the_question_cannot_exceed_its_share: QUESTION_CHARS // CHARS_PER_TOKEN == QUESTION_TOKENS, a message of exactly the cap passes (whitespace stripped first), one more character is refused.
  • test_chat.py::test_a_question_past_its_share_is_refused_at_the_wire: with no model selected, an oversized message is a 422, not the 409 a valid message gets, which shows the schema refused it before resolution.
  • chat-composer.test.tsx: pasting 4,097 characters leaves 4,096 in the textbox and shows the status line.
cd surfsense_local/backend
uv run pytest -m unit
uv run pytest -m integration -k chat
uv run ruff check .
cd ../frontend
pnpm test
pnpm lint
pnpm translations:verify
cd ../.. && python scripts/check_docs.py && node scripts/check_translations.mjs

Locally: 3,062 backend unit tests pass, the chat integration file passes (the encoder-backed cases skip without the fetched model, as on dev), 304 frontend tests pass (run with TZ=UTC, see #2050), lint and tsc -b clean, 805 keys × 10 languages verified, 102 docs checked.

High-level PR Summary

This PR enforces a 4,096-character limit on chat questions (corresponding to 1,024 tokens at 4 chars/token) to prevent oversized messages from exceeding the model's context window. The backend now validates message length at the wire with a 422 error, the frontend composer enforces the same limit with maxLength, and a status message appears when users hit the cap. The change consolidates the CHARS_PER_TOKEN constant in budget.py and updates documentation to reflect that this is now an enforced limit rather than just a budget estimate.

⏱️ Estimated Review Time: 15-30 minutes

💡 Review Order Suggestion
Order File Path
1 surfsense_local/backend/modules/chat/budget.py
2 surfsense_local/backend/modules/chat/schemas.py
3 surfsense_local/backend/modules/chat/history.py
4 surfsense_local/backend/tests/unit/chat/test_budget.py
5 surfsense_local/backend/tests/integration/chat/test_chat.py
6 surfsense_local/frontend/src/features/chat/question-limit.ts
7 surfsense_local/frontend/src/features/chat/chat-composer.tsx
8 surfsense_local/frontend/src/features/chat/chat-composer.test.tsx
9 docs/architecture/chat.md
10 docs/architecture/local-models/fit.md
11 surfsense_local/frontend/translations/en.json
12 surfsense_local/frontend/translations/de.json
13 surfsense_local/frontend/translations/es.json
14 surfsense_local/frontend/translations/fr.json
15 surfsense_local/frontend/translations/hi.json
16 surfsense_local/frontend/translations/ja.json
17 surfsense_local/frontend/translations/ko.json
18 surfsense_local/frontend/translations/pt-BR.json
19 surfsense_local/frontend/translations/ru.json
20 surfsense_local/frontend/translations/zh-CN.json

Need help? Join our Discord

budget.py priced the question at 1,024 tokens and said MessageText
enforced it, but MessageText had no maximum, so a pasted page walked
past its share and llama.cpp cut the reply off wherever the window ran
out, with no error.

MessageText now refuses more than QUESTION_CHARS, 4,096 characters:
QUESTION_TOKENS at the four-characters-a-token estimate history.py
already prices turns with, which moves to budget.py as CHARS_PER_TOKEN
so there is one ratio. The refusal is a 422 at the wire, before any
model is resolved or retrieval runs. One global cap rather than one per
window, because it runs before the model is known and the budget prices
the question at the same 1,024 for every window.

The composer stops at the same cap through maxLength and, once there,
says so in a status line, so nobody finds out on send.

Fixes MODSetter#1943
@vercel

vercel Bot commented Sep 29, 2026

Copy link
Copy Markdown

@ybai08 is attempting to deploy a commit to the Rohan Verma's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Sep 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: ab247520-0ae0-4c8c-9856-9da7ba2e2652

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@MODSetter
MODSetter merged commit 0b976a3 into MODSetter:dev Sep 29, 2026
17 of 19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants