Skip to content

fix(llm): clamp requested ctx_size to a model's real context ceiling - #3003

Open
itomek wants to merge 10 commits into
mainfrom
issue-2992
Open

fix(llm): clamp requested ctx_size to a model's real context ceiling#3003
itomek wants to merge 10 commits into
mainfrom
issue-2992

Conversation

@itomek

@itomek itomek commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Closes #2992

GAIA picked its context window from a flat per-device number (65536 GPU/CPU, 32768 NPU) with no idea which model was actually loading, then reported that number back as fact. For a model whose real trained context is smaller — Qwen3-0.6B-GGUF's is 40960 — GAIA requested and reported 65536 while llama.cpp silently capped the real window at 40960. A ~40,900-token prompt worked; ~41,000 got a 400 the user had no way to predict from GAIA's own "success" message.

Lemonade already exposes the real ceiling as max_context_window (catalog /api/v1/models and /api/v1/health). This clamps the ctx_size GAIA actually requests to that ceiling and reports the value really in force. It also covers the no-reload path: Lemonade echoes back recipe_options.ctx_size as whatever was last requested even when it capped the window lower internally, so a model already resident from an earlier process looked "already sufficient" and was never rechecked — every reading of status.context_size is now cross-checked against the loaded model's ceiling, not just ones following a reload.

A model already sitting at its known ceiling now skips the reload entirely instead of running one that reproduces the identical result — since the manager's state is per-process, an unconditional reload here would otherwise fire on every single gaia invocation, not once. And the message for that case is now specific to the ceiling: it names the shortfall as a property of the model (its trained context) and points at the only real remedy — a bigger model — instead of the "stop the server and restart it" text meant for a genuinely-too-small server config, which can't do anything about a GGUF's trained context.

Verified live against a running Lemonade 11.6.0 with Qwen3-0.6B-GGUF (real max_context_window=40960), before vs. after this fix, same repro:

  • Before: ✅ Context size updated to 65536 tokens. (false — the real window was 40960)
  • After: ⚠️ This model's trained context is 40960 tokens (requested 65536); that is the maximum available regardless of configuration. Use a model with a larger trained context if you need more.

Two explicit decisions, both tested:

  • A registry floor (MODELS[...].min_ctx_size) that exceeds a known ceiling raises loudly instead of silently picking one side.
  • A model with no discoverable ceiling (undownloaded, older Lemonade) proceeds at the requested value with a logged warning — never a silent guess.

The eval-only ctx_size_override exact-pin path (_ensure_pinned_load) is untouched — confirmed by a test asserting get_model_max_context_window is never called on that path, so a pinned eval run still gets exactly what it asked for.

Known adjacent issue, not introduced here (verified against main): switching models can print a warning naming the previous resident model instead of the one about to load, because the pre-flight ctx check runs before model-switch logic picks the new target. On main this same ordering silently reloads the wrong (soon-to-be-replaced) model and falsely reports success; this PR's honest check surfaces it as a warning instead, without the wasted reload — an improvement, but the underlying ordering issue is unfixed and out of scope here.

Test plan

  • pytest tests/unit/test_lemonade_ctx_ceiling.py tests/unit/test_lemonade_manager_preload.py tests/unit/test_lemonade_model_loading.py tests/unit/test_llm.py tests/unit/installer/test_init_verify_ctx_display.py — 71 passed
  • pytest tests/test_lemonade_client.py — 87 passed, 3 pre-existing failures (test_chat_completions, test_chat_completions_nonstream_includes_auth_header, test_validate_context_size_insufficient), confirmed present on main via git stash before this change — unrelated to this fix
  • python util/lint.py --all --fix — black/isort/pylint/flake8/bandit all pass (mypy warnings are pre-existing, non-blocking)
  • Live repro against a real Lemonade 11.6.0 server with Qwen3-0.6B-GGUF downloaded — clamp, honest reporting, the skipped reload, and the model-specific message confirmed end-to-end (see before/after strings above)
  • Real-hardware pass on a second machine (routing separately)

Lemonade reports a model's trained-context ceiling as max_context_window,
but GAIA never checked it. It picked ctx_size from a flat per-device
constant (65536 GPU/CPU, 32768 NPU) and reported that value back as
"the context size" even when the model's real training window was smaller
and llama.cpp silently capped it internally. A model trained at 40960
tokens would be requested and reported at 65536; prompts near the real
ceiling failed with a 400 the user had no way to predict from GAIA's own
"success" message.

Reads max_context_window from Lemonade's catalog and health responses,
clamps the ctx_size actually sent to /load, and reports the value really
in force. Critically, this also covers the no-reload path: Lemonade will
echo back recipe_options.ctx_size == whatever was last requested even when
it capped the window lower internally, so a model already resident from an
earlier process looked "already sufficient" and was never rechecked. Every
reading of status.context_size is now cross-checked against the loaded
model's ceiling, not just the ones following a reload.

A registry floor (MODELS[...].min_ctx_size) that exceeds a known ceiling
now raises loudly instead of silently picking one side. A model with no
discoverable ceiling (undownloaded, older Lemonade) proceeds at the
requested value with a warning naming the gap. The eval-only
ctx_size_override exact-pin path is untouched.
@github-actions github-actions Bot added llm LLM backend changes tests Test changes performance Performance-critical changes labels Aug 18, 2026
itomek added 2 commits August 18, 2026 12:15
…ntext

A model already resident at its real ceiling (e.g. Qwen3-0.6B at 40960,
GPU profile requesting 65536) was still triggering a full model reload to
the exact same clamped value on every ensure_ready() call whose context
came up short — the clamp fixed the *reported* number but not the
redundant reload. Since the manager's memoization is per-process, this
fired on every `gaia llm` / `gaia chat` / `gaia agent` invocation, not
once, trading a wrong number for a wrong number plus a repeated model
reload.

Both call sites that could enter `_try_reload_with_ctx` now check whether
the resident model is already at its known ceiling first and, if so, skip
the reload and report the honest capped value directly (`
_report_capped_at_ceiling`, reusing the same message shape the
just-preloaded path already used for this exact situation). No load_model
call, no "Reloading..." message that would have been untrue.
…ver one

The ceiling-capped message reused print_context_message, which tells the
user to stop the server and restart it with a bigger ctx_size. That
remediation does nothing for a trained-context ceiling — no restart, flag,
or gaia init raises a GGUF's n_ctx_train. The honest number was followed
by instructions that can't work, sending the user to debug a healthy
install.

Added print_ceiling_message: states the shortfall as a property of the
model and points at the only real remedy (a model with a larger trained
context). print_context_message is untouched for its real callers, where
the server-remediation text still applies.
@itomek itomek self-assigned this Aug 18, 2026
@itomek

itomek commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Independently verified against a live Lemonade 11.6.0 with Qwen3-0.6B-GGUF (real ceiling 40960), plus an earlier reproduction of the original bug on real Strix Halo silicon.

The starting state was the bug itself — the model resident with the server echoing ctx_size: 65536 next to max_context_window: 40960:

⚠️  This model's trained context is 40960 tokens (requested 65536); that is the
    maximum available regardless of configuration.
    Use a model with a larger trained context if you need more.

The no-reload behaviour is the part worth checking closely, and it holds. After three consecutive runs the server still reports ctx_size=65536 — had the fix reloaded, that echo would read 40960. So GAIA is correcting the reported value rather than forcing a reload to make it true, which is the whole point. Run times settle at 7.5s / 3.2s / 3.0s, with no reload penalty and no ⏳ Reloading line on any run.

That mattered: an earlier revision of this fix clamped only on reload, which meant a model already resident from a previous process — the normal case, since Lemonade is a persistent daemon — would have reloaded on every gaia invocation, because manager state is per-process. Worth keeping the load_model.assert_not_called() regression test if this area is ever refactored.

🔍 Technical details

Measured, post-fix, on Qwen3-0.6B-GGUF:

GAIA reports Lemonade recipe_options.ctx_size max_context_window llama.cpp actual n_ctx
40960 65536 (stale echo, untouched) 40960 40960

Pre-fix, on real Strix Halo hardware (Ryzen AI MAX+ 395 / Radeon 8060S), the same scenario printed ✅ Context size updated to 65536 tokens. while llama.cpp logged n_ctx_seq (65536) > n_ctx_train (40960), the slot context (65536) exceeds the training context of the model (40960) - capping, and new slot, n_ctx = 40960. The measured request boundary was 40,959 prompt tokens → 200, 40,960 → 400.

Two internal warnings fire correctly on the corrected path: _honest_context_size detecting the echo/ceiling divergence, and _report_capped_at_ceiling reporting the effective value.

Separately confirmed that print_context_message's server-remediation text no longer appears on the ceiling path — the earlier revision followed an honest number with "stop the Lemonade server and restart it", advice that cannot raise a GGUF's trained context.

@github-actions

Copy link
Copy Markdown
Contributor

Verdict: Request changes

This PR makes GAIA report the context window it actually has instead of echoing back what it asked for — a real bug worth fixing, and the clamp itself is well built and well tested. But the two halves of the change disagree with each other, and the result is that some users lose the ability to run the model at all.

The blocking issue: when a model's real context ceiling is smaller than the minimum GAIA has recorded for it, the startup path now says "fine, we'll use the smaller window and carry on" while the chat path treats the same situation as a fatal error and refuses to answer. The evidence run captured exactly this — a warning, then ❌ Error: and no reply, where the pre-PR build answered the question. The error also tells the user to lower a value in GAIA's source registry, which nobody outside the repo can do. Pick one behaviour: either accept the smaller window everywhere (with the honest warning this PR adds), or fail — but then fail with something a user can actually act on.

Also worth fixing before merge:

  • The Agent UI's status panel still shows the old, inflated number and still claims the context is sufficient, while the CLI now warns that it's capped. The PR's own evidence caught this. Half-honest reporting is arguably worse than the old consistent lie — same machine, same model, two different answers depending on where you look.
  • Once a small-context model has been seen, the manager stops re-checking for the rest of the process. In a long-running Agent UI session that later switches to a roomier model, it keeps reporting the old capped number.

Real-world evidence

evidence-bundle.md is present and substantial: the CLI and the Agent UI HTTP route were driven live on this runner against a contract-level Lemonade stub, with before/after builds (base 47e6bc90 vs PR b3f387ea) and every outgoing /load body recorded — so the shape of the request is asserted, not just that a call happened. Five scenarios cover the clamp firing, a roomy model correctly left unclamped, an older server with no ceiling data, and the wasted-reload removal (base: 1 reload + a false 65536 report; PR: 0 reloads, honest 40960). Rendered Agent UI pixels and real inference are marked pending strix-halo lane — appropriate for this no-inference runner, and adequate for merge-time CI.

The evidence supports the clamp working as intended. It also is the source of the blocking finding above — the bundle explicitly flags the "no answer returned" behaviour change and the stale UI number, both reproduced live:

⚠️  This model's trained context is 40960 tokens (requested 65536); that is the maximum available regardless of configuration.
   Use a model with a larger trained context if you need more.

🤖 gaia: INFO | _ensure_model_loaded_locked | Model 'Gemma-4-E4B-it-GGUF' loaded at ctx=40960 but GAIA expects ctx=65536; reloading.
❌ Error: GAIA requires ctx_size=65536 for 'Gemma-4-E4B-it-GGUF' (MODELS registry min_ctx_size), but Lemonade reports its trained context ceiling as 40960 tokens (max_context_window). Lower MODELS[...].min_ctx_size for this model or choose a different one — requesting more than the model supports would silently truncate every prompt.
GET http://127.0.0.1:4200/api/system/status -> HTTP 200
{"lemonade_running":true,"model_loaded":"Gemma-4-E4B-it-GGUF",…,
 "model_context_size":65536,…,"context_size_sufficient":true,…}

Verified against the diff — the failure is not a stub artifact; the code paths line up. Whether a real GGUF reports a max_context_window below its registry floor is the one thing this lane can't answer, and it decides how often the hard failure fires in the field. That belongs on the strix-halo lane before merge.

🔍 Technical details

🔴 Critical

1. The clamp creates the condition that makes _ensure_model_loaded_locked raise (src/gaia/llm/lemonade_client.py:3372-3384, src/gaia/llm/lemonade_manager.py:829-836)

Pre-PR: the manager requests 65536, Lemonade echoes 65536, and the client's early return at lemonade_client.py:3347 (loaded_ctx >= expected_ctx) fires — the model runs, silently capped. Post-PR: the manager requests the honest 40960, so the echo is now below expected_ctx, the early return no longer fires, and execution reaches the new floor-vs-ceiling check, which raises. The honest clamp is what unblocks the raise.

This affects any model whose max_context_window is below its MODELS[...].min_ctx_size — and min_ctx_size=GPU_CTX_SIZE (65536) is set on ~15 registry entries including DEFAULT_MODEL_NAME, so the blast radius is the default path, not an exotic corner.

The two halves also disagree by construction: _report_capped_at_ceiling (lemonade_manager.py:975) exists precisely to say "we're capped, proceed", and returns True. Then the very next client call refuses. Per CLAUDE.md's actionable-error rule, the message names what failed and where, but its "what should the caller do" is Lower MODELS[...].min_ctx_size — a source edit, unavailable to an installed user.

Suggested resolutions (maintainer call, hence no ```suggestion block):

  • Clamp expected_ctx to a known ceiling and log the warning — consistent with the manager, and with what Lemonade already does internally; or
  • Keep the raise but only when the ceiling is so low the model is genuinely unusable, and point at a runtime remedy (gaia config set model … / a specific alternative model id), not a registry edit.

Either way it needs a test that pins the manager and client agreeing on the same input, and the strix-halo lane needs to confirm what the real default GGUF reports for max_context_window.

🟡 Important

2. /api/system/status still reports the un-clamped echo (src/gaia/ui/routers/system.py:507, :598-600)

The route reads recipe_options.ctx_size from /health and derives context_size_sufficient from it, bypassing resolve_effective_ctx_size entirely. On the same machine the CLI warns "capped at 40960" while the UI banner reports model_context_size: 65536, context_size_sufficient: true. CLAUDE.md's rule about a functional change updating every surface that describes it applies here — routing this route's value through the new clamp (or through LemonadeManager.get_context_size()) is a small addition to this PR and closes the contradiction.

3. _context_ceiling is not tied to a model id, and the fast path returns before re-checking (lemonade_manager.py:276-280, :614-626)

at_known_ceiling is evaluated against class state before any get_status() call, so once _context_size >= _context_ceiling holds, ensure_ready returns True unconditionally for the rest of the process — no status refresh, no ceiling refresh. In a long-lived process (gaia.ui.server) that later loads a roomier model, _context_size / get_context_size() stay pinned at the old capped value; budget_for_ctx consumers then size their budgets off a stale number. Pre-PR the same call fell into the recheck branch and refreshed from status.

Storing the model id the ceiling came from and invalidating when the resident LLM changes would fix it:

_context_ceiling: Optional[int] = None
_context_ceiling_model: Optional[str] = None

…and gating at_known_ceiling on the id still matching the loaded LLM. reset() at :1235 already clears _context_ceiling, so only the pairing is missing.

🟢 Minor

4. Possible extra catalog round trip per ensure_ready (lemonade_manager.py:955) — _honest_context_size calls get_model_max_context_window with allow_catalog_lookup defaulted to True. When /health doesn't carry max_context_window (older Lemonade), that's a list_models(show_all=True) on every recheck and every process start. allow_catalog_lookup=False in the recheck fast path would keep it free, matching the reasoning already applied in _ensure_model_loaded_locked.

5. Recheck branch reads status.loaded_models without the None guard (lemonade_manager.py:658-667) — the init block normalises loaded_models is None to [] at :755; the recheck block doesn't, and _find_loaded_llm_id now iterates it earlier than the pre-existing any(...) did. The outer except swallows it either way, but the failure now also skips the _context_size update that previously succeeded first.

6. A known ceiling can be overwritten with None (lemonade_manager.py:1152-1156) — new_ceiling = ... or ceiling can still be None, and cls._context_ceiling = new_ceiling then discards a ceiling discovered earlier in the same run. Guarding the assignment on truthiness (as _honest_context_size already does) keeps it monotonic.

Strengths

  • resolve_effective_ctx_size is a pure function with the "unknown ≠ unbounded" rule stated in the docstring and pinned by tests — including the NPU-profile case that a GPU-only fix would have missed.
  • print_ceiling_message correctly refuses to reuse the server-restart remediation, and test_print_ceiling_message_has_no_server_remediation_text asserts the absence of the misleading text rather than just the presence of the new text. That's the right shape of test.
  • Cases 14 and 15 in test_lemonade_manager_preload.py came out of hardware observation (the config-echo that looks "already sufficient") — exactly the class of bug unit tests usually miss, and they're documented as such.
  • Back-compat is deliberate and tested: a Lemonade with no max_context_window anywhere keeps the pre-PR request and the loop-breaking assumption, with a traceable warning instead of silence.

@github-actions

Copy link
Copy Markdown
Contributor

Approve with suggestions

The ceiling-clamp logic is correct and the test coverage is thorough. One user-facing behaviour gap and one CLAUDE.md violation are worth fixing before merge.


🟡 Wrong error advice shown when reload first discovers the ceiling

When _try_reload_with_ctx discovers that the model's max_context_window is below min_context_size, it correctly prints the ceiling-specific ⚠️ message but returns False. Both call sites in ensure_ready then fall through to their shared post-reload fallback: they log "Restart Lemonade Server" and call print_context_message, which tells the user to stop and restart Lemonade with a larger ctx_size. That advice is wrong — no server restart can raise a GGUF's trained context. The right remedy is already in print_ceiling_message: "use a model with a larger trained context."

On the next ensure_ready call, _context_ceiling is set and the at-ceiling pre-check fires correctly. But the first-discovery path — which is the scenario this PR explicitly targets — still shows conflicting advice.

The fix: after _try_reload_with_ctx returns False, check whether cls._context_ceiling is now set. If it is, call _report_capped_at_ceiling and skip print_context_message.


🟡 except Exception: return None in get_model_max_context_window

This adds a new broad-except → silent-return-None that CLAUDE.md explicitly classifies as prohibited tech debt. Unexpected bugs in list_models would be swallowed here with no signal. Narrowing to (LemonadeClientError, OSError, requests.exceptions.RequestException) (or whatever the network failure types are) preserves the best-effort intent without hiding surprises.

🔍 Technical details

Wrong fallback after reload failure — two call sites in lemonade_manager.py:

lemonade_manager.py:703-718 (already-initialized branch):

if cls._try_reload_with_ctx(client, status, min_context_size, quiet, cls._lock):
    return True
cls._log.warning("... Restart Lemonade Server. ...")   # wrong for ceiling case
if not quiet:
    cls.print_context_message(cls._context_size, min_context_size, MessageType.WARNING)

lemonade_manager.py:865-876 (not-yet-initialized branch): identical pattern.

Both should add a ceiling-aware bypass after the reload attempt:

if cls._try_reload_with_ctx(client, status, min_context_size, quiet, cls._lock):
    return True
# _try_reload_with_ctx may have just discovered the ceiling — check before
# falling through to server-restart advice, which can't raise n_ctx_train.
if cls._context_ceiling is not None and cls._context_size >= cls._context_ceiling:
    cls._report_capped_at_ceiling(min_context_size, quiet)
    # fall through to return True (caller's existing path already returns True here)
else:
    cls._log.warning("... Restart Lemonade Server. ...")
    if not quiet:
        cls.print_context_message(cls._context_size, min_context_size, MessageType.WARNING)

Broad-except locationlemonade_client.py, get_model_max_context_window:

except Exception as e:  # pylint: disable=broad-except
    self.log.debug(f"Could not query model catalog for {model_id!r}: {e}")
    return None

Narrow to the specific exception types that a network call to Lemonade actually raises.

itomek added 6 commits August 19, 2026 15:39
The floor-vs-ceiling conflict check in _ensure_model_loaded_locked raised
LemonadeClientError when a model's MODELS registry floor exceeded its real
trained-context ceiling. That contradicts LemonadeManager, which treats the
identical situation as "proceed capped" and warns instead. Since
min_ctx_size=GPU_CTX_SIZE (65536) is set on most registry entries including
the default model, the raise landed on the default path: once the manager
reloads at the clamped ceiling, every subsequent chat call hit the raise
where the pre-PR build still answered.

Clamp to the ceiling and warn instead, matching the manager's stance. Also
fixes both LemonadeManager fall-through sites that told the user to
"Restart Lemonade Server" after a reload failed because it had just
discovered the model's real ceiling — that advice can't raise a GGUF's
trained context, and the reload path already printed the correct message.
The catalog lookup's except Exception -> return None swallowed anything,
not just the network/parse failures it was meant to tolerate. list_models()
only ever raises LemonadeClientError (its subclasses included) since every
HTTP/JSON failure inside _send_request is wrapped into one — narrow the
catch to that.
/api/system/status read recipe_options.ctx_size straight off /health and
reported it as context_size_sufficient, bypassing the ctx-size clamp the
CLI already applies. On a machine where the CLI correctly warns "capped at
40960", the UI could report the raw 65536 echo and claim the context is
sufficient — two different answers for the same running server.

/health's all_models_loaded entries already carry max_context_window per
loaded model, so this reuses resolve_effective_ctx_size (the same pure
clamp LemonadeManager uses) with no extra network round trip.
_context_ceiling was class state with no model id attached, so a
long-lived process (gaia.ui.server) that first loaded a small-context
model and later switched to a roomier one kept reporting the small
model's cap forever — the fast path returned "at known ceiling" before
ever re-checking live status.

Add _context_ceiling_model alongside _context_ceiling and gate the fast
path on periodically reconfirming it, bounded by the same
_RECHECK_INTERVAL that already rate-limits the chat-message hot path so
this doesn't add an unconditional status call. Also guard the ceiling
assignment in _try_reload_with_ctx against a falsy value discarding an
already-discovered ceiling, and None-guard loaded_models in the recheck
branch the same way the init branch already does.
Drop an import left unused after rewriting the raise-based test to a
clamp-and-warn one, and apply black formatting.
_ensure_model_loaded_locked compared loaded_ctx against the raw MODELS
registry floor before applying the floor-vs-ceiling clamp. A model
resident exactly at its (below-floor) ceiling never matched that
unclamped comparison, so every chat completion reloaded the model at
the same ctx_size it was already running — undoing the point of the
#2053 pre-flight probe. Moving the clamp ahead of the comparison lets
an at-ceiling model short-circuit like LemonadeManager already does.
Also dedupes the clamp warning per model so it doesn't repeat on every
call once the early return is reachable again.
@itomek

itomek commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the review. Five findings fixed, two small guards added, one pushed back.

The substantive one: the client and the manager disagreed about what to do when a model's real ceiling sits below its registry min_ctx_size. The manager warned and carried on; the client raised and refused to answer — so on the default GPU path a user could end up with no reply where the pre-PR build gave one, and the error told them to edit a source file they don't have. Both halves now do the same thing: clamp to the real ceiling and say so, naming a larger-trained-context model as the only remedy.

That reverses the "raises loudly" decision in the PR description above. Loud is still right, but a warning is loud enough here — refusing to run a model that physically cannot offer more context isn't a safer failure, just a worse one.

Also fixed: the first-discovery reload path no longer follows an honest ceiling message with "restart Lemonade Server"; the Agent UI's status panel now reports the same clamped number the CLI does instead of the old inflated one; and the cached ceiling is tied to the model it came from, so a long-running server that switches to a roomier model stops reporting the stale capped value.

Pushed back on the catalog round trip. That path is already rate-limited and already pays a /health round trip, and turning off the catalog lookup would permanently hide the ceiling on exactly the older Lemonade servers that omit it from /health — the ones this fix exists for.

🔍 Technical details
Finding Resolution
🔴 Clamp creates the condition that makes _ensure_model_loaded_locked raise Raise replaced with clamp + warning; client now agrees with _report_capped_at_ceiling
🟡 Wrong "restart Lemonade" advice after _try_reload_with_ctx returns False Ceiling-aware bypass at both ensure_ready fall-through sites; _report_capped_at_ceiling gained already_announced so the ⚠️ isn't printed twice
🟡 except Exception: return None in get_model_max_context_window Narrowed to the real network/client error types
🟡 /api/system/status reports the un-clamped echo model_context_size and context_size_sufficient routed through the clamp; covered in tests/unit/chat/ui/test_server.py
🟡 _context_ceiling not tied to a model id Added _context_ceiling_model, gated at_known_ceiling on it, cleared in reset(); _RECHECK_INTERVAL rate-limiting preserved
🟢 Ceiling overwritable with None Assignment guarded on truthiness
🟢 loaded_models None-guard missing in the recheck branch Normalised the same way the init block does
🟢 Extra catalog round trip per ensure_ready Push-back — see above

One defect was caught during this pass and fixed before it shipped: the first cut of the clamp ran after the pre-flight probe's loaded_ctx >= expected_ctx comparison, so a model resident at its below-floor ceiling never matched, and every chat completion reloaded it at the ctx_size it was already running. The clamp now runs before the comparison, so the early return fires and no reload happens — the same short-circuit LemonadeManager already does. Pinned by a two-call test asserting load_model is never called.

Verification on this branch (9461f3e8):

pytest tests/unit/test_lemonade_ctx_ceiling.py tests/unit/test_lemonade_manager_preload.py \
       tests/unit/test_lemonade_model_loading.py tests/unit/test_llm.py \
       tests/unit/chat/ui/test_server.py tests/test_lemonade_client.py -q
3 failed, 286 passed, 2 skipped in 110.99s

The three failures are test_chat_completions, test_chat_completions_nonstream_includes_auth_header and test_validate_context_size_insufficient — confirmed failing identically on main in a clean worktree at origin/main, environment-dependent (no Lemonade installed on the box), unrelated to this change.

python util/lint.py --allALL QUALITY CHECKS PASSED. Black, isort, Pylint, Flake8 and Bandit clean; the mypy findings are pre-existing and none are in the three files this PR touches.

Real-hardware pass on Strix Halo still outstanding — in particular what a real Gemma-4-E4B-it-GGUF reports for max_context_window, which decides how often the clamp path is hit in the field.

@github-actions

Copy link
Copy Markdown
Contributor

Verdict: Approve ✅

This is a solid, well-targeted fix for #2992. The approach is correct: extract a pure clamp, thread it through every site that reads or caches ctx_size, and prevent wasted reload cycles once a model's real ceiling is known. No critical or important bugs found.

Test coverage is strong: 307 new lines in test_lemonade_ctx_ceiling.py covering the pure helper, get_model_max_context_window, and 7 _ensure_model_loaded_locked scenarios; 9 new LemonadeManager.ensure_ready cases; an updated UI status test. The existing TestLemonadeManagerContextMessage tests are correctly patched with get_model_max_context_window.return_value = None to preserve pre-#2992 semantics.

A few observations (none blocking):

🟢 test_already_sufficient_echo_is_cross_checked_against_ceiling supplies two get_status side-effect entries but the at_known_ceiling fast path short-circuits before the second is consumed — the extra entry is harmless but dead weight.

🟢 The UI path (system.py) falls back gracefully when Lemonade's raw /health doesn't expose max_context_window (older server versions): resolve_effective_ctx_size(ctx, None) returns ctx unchanged, so the clamp silently does nothing. The comment is clear about this, but the consequence is that an older Lemonade could still show the CLI/UI mismatch the PR title promises to fix. Worth noting in the PR description if it isn't already.

🟢 _ceiling_clamp_warned is per-LemonadeClient instance. In the LemonadeManager recheck path a new client is created per call — but after the first ceiling discovery cls._context_ceiling is set and the fast path returns True before any new client is constructed, so the "warn once" property holds in practice. Just not immediately obvious from reading the instance variable alone.

🔍 Technical details

Dead-weight side-effect entry (tests/unit/test_lemonade_manager_preload.py, test_already_sufficient_echo_is_cross_checked_against_ceiling):

client.get_status.side_effect = [status1, status2], but the at_known_ceiling guard fires after the first get_status call and returns True without a reload (confirmed by client.load_model.assert_not_called()). The second entry is never consumed. No assertion fails; it just signals the test was written with a reload path in mind. Trivial cleanup — can drop the second element.

Older Lemonade degradation (src/gaia/ui/routers/system.py:869, 888):

m.get("max_context_window") comes from Lemonade's raw /health or /models response. If an older server version omits that field, resolve_effective_ctx_size(ctx, None) returns ctx unchanged and the UI still shows the echoed (wrong) value. The LemonadeManager path doesn't have this gap because _honest_context_size falls back to the catalog. The UI has no such fallback. Mentioned so a future Lemonade-compatibility pass can add one, not as a blocker.

_ceiling_clamp_warned scope (src/gaia/llm/lemonade_client.py:1087):

The "warn once per model" guarantee is per-instance. In LemonadeManager.ensure_ready's recheck path a fresh LemonadeClient is created at line 665/671, resetting the set. This is effectively harmless because _context_ceiling is already cached in the class by then, so the _ensure_model_loaded_locked floor/ceiling warning path is only reached on the very first call per process (before the ceiling is cached). No fix needed; just noting the gap between the intent and the implementation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

llm LLM backend changes performance Performance-critical changes tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: GAIA reports 65,536-token context while effective llama.cpp context remains 40,960

1 participant