fix(providers): clamp max_tokens to the model context window - #416
Closed
basil-k-aji-dev wants to merge 1 commit into
Closed
basil-k-aji-dev wants to merge 1 commit into
basil-k-aji-dev wants to merge 1 commit into
Conversation
OpenAI-compatible backends such as vLLM reject a request outright when
prompt_tokens + tool_schema_tokens + max_tokens > max_model_len
The rejection is an HTTP 400 with an empty body, so the agent chain retries,
exhausts its attempts and stops with nothing useful logged. The UI just looks
stuck. It is easy to reach on a 32k window: provider settings routinely persist
max_tokens near the whole window, and the Generator sends a large system prompt
plus several tool schemas on its first call, before any history exists to
summarize.
Nothing compared the configured budget against the window. pconfig.BuildOptions
attaches llms.WithMaxTokens(ac.MaxTokens) unconditionally and no provider
checked it.
Add a preflight clamp at provider.WrapGenerateContent, the single point every
provider's CallEx and CallWithTools funnels through, so one implementation
covers openai, openaicompat, bedrock, ollama and custom alike. It estimates the
prompt from message parts and tool schemas, and when the request would overflow
it lowers max_tokens to what the window leaves, keeping a safety margin for the
gap between a byte estimate and the backend's real tokenizer.
Deliberately conservative:
- Off unless LLM_SERVER_MAX_MODEL_LEN is set, so behaviour is unchanged for
anyone who has not opted in.
- No clamp when the prompt alone fills the window. Trimming to a stub reply
would hide the real problem; the backend should report it.
- No clamp when max_tokens is unset, since the provider default applies and
there is nothing of ours to lower.
- The estimate rounds against us everywhere: tool schemas are measured as the
JSON actually sent, and unsizeable parts carry a flat cost rather than being
counted as free.
Covers the clamp half of vxcontrol#399. Preflight summarization of older turns, hard
truncation, and retry-on-overflow are left out; they need history rewriting
rather than a per-call adjustment.
Refs vxcontrol#399
Collaborator
|
Implemented in our own line and shipping with 2.2.0 — thanks for the patch. Closing this PR. |
Author
|
Good to hear it is landing in 2.2.0. The empty-bodied 400 was the painful part, since the chain just retried itself out with nothing in the log, so I am glad the clamp is in regardless of whose patch carries it. One thing worth keeping if it did not survive the port: the clamp only engages when the context window is known, so it is a no-op for anyone who has not set it. That is what keeps it from trimming a budget on a provider whose real window is larger than we guessed. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #399 — implements the clamp half.
The problem
pconfig.BuildOptionsattachesllms.WithMaxTokens(ac.MaxTokens)unconditionally (pkg/providers/pconfig/config.go:681) and nothing compares it to the model window. vLLM and other OpenAI-compatible backends reject the request whenwith an empty-bodied HTTP 400, so
callWithRetriesburns its attempts and stops. The UI just looks stuck.Where the fix goes
provider.WrapGenerateContent— every provider'sCallExandCallWithToolsfunnels through it (openai, openaicompat, bedrock, ollama, custom), and it already holds both the message chain and the resolved call options. One implementation, no interface change, no per-provider duplication.The issue proposes hooking
callWithRetriesandperformSimpleChainseparately.WrapGenerateContentsits below both, so it covers the Summarizer/Adviser path in step 5 for free.What it does
EstimatePromptTokenssizes messages and tool schemas at 4 bytes/token;ClampMaxTokenslowersmax_tokensto what the window leaves, keeping a 512-token margin for the gap between a byte estimate and the backend's real tokenizer.Deliberately conservative — it declines to act in every case where acting could do harm:
LLM_SERVER_MAX_MODEL_LENunsetmax_tokensunsetThe estimate rounds against us throughout: tool schemas are measured as the JSON actually sent, and parts with no cheap size (binary, provider-specific) carry a flat cost rather than counting as free. Undercounting would defeat the clamp.
A clamp emits a Langfuse warning event carrying the chosen budget and the window, so it is visible rather than silent.
Scope
This is the clamp, not the whole issue. Left out: preflight summarization of older turns, hard truncation, and retry-on-overflow. Those rewrite the message chain rather than adjusting one call, and belong in
csumwith their own review. The clamp alone fixes the reported scenario — 32k window, large first Generator prompt,max_tokensleaving no room — which is the part that fails before any history exists to summarize.I picked an env var over parsing
max_model_lenfrom/modelsdeliberately:LoadModelsFromHTTPcurrently drops extra fields, and changing it is a larger change with its own failure modes. If you would rather the window came from/models, say so and I will rework it.Tests
Eight tests in
preflight_test.go, no network or provider needed.The important one is
TestClampMaxTokensCountsToolsTowardWindow: the messages alone fit the window and the tool schemas tip it over, so it fails if schemas are ever dropped from the estimate. I verified it bites by deleting the tool-counting loop:TestClampMaxTokensShrinksOversizedBudgetreproduces the issue's example — 32k window, ~20k-token prompt,max_tokens=26214— and asserts the clamped request satisfies the inequality.Verification
go build ./pkg/providers/...,go vet ./pkg/providers/provider/,gofmt -landgo test ./pkg/providers/provider/all clean. No new dependencies —go.modandgo.sumare untouched; the new file uses onlyencoding/json,os,strconvandsync.What I could not run: I do not have a vLLM instance, so the end-to-end path — oversized request against a real 32k backend — is unverified. The clamp arithmetic and the estimator are covered by unit tests; the integration is reasoned from
config.go:681and the call graph throughWrapGenerateContent. Worth a check against a real server before merging. I also could not rungolangci-lint, onlygo vet.