Skip to content

fix(models): read a discovered model's real capacity from the probe instead of hardcoding 131072 - #7817

Merged
houko merged 3 commits into
mainfrom
fix/7780-discovered-model-capacity
Aug 24, 2026
Merged

houko merged 3 commits into
mainfrom
fix/7780-discovered-model-capacity

Conversation

@houko

@houko houko commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Closes #7780.

What was wrong

merge_discovered_models (crates/librefang-runtime/src/model_catalog.rs) is the only writer for gateway- and locally-discovered catalog entries, and it hardcoded two literals:

context_window: 131_072,
max_output_tokens: 16_384,

The issue reads that as "no upstream can supply a real value", and that half is true — DiscoveredModelInfo had no field for either one. But the reason it had no field is one layer further down: parse_openai_models (crates/librefang-runtime/src/provider_health.rs) returned model_info: vec![] unconditionally, so every capacity field an OpenAI-compatible listing did carry was parsed away and discarded. Fixing the merge site alone would have papered over a probe that was throwing the answer on the floor.

Why a wrong context window is not cosmetic

The fabricated value is indistinguishable from a measured one at every consumer, and it lands directly in the turn's token budget:

  • manifest_helpers::resolve_context_window (crates/librefang-kernel/src/kernel/manifest_helpers.rs:117) takes the catalog entry's context_window with .filter(|w| *w > 0). 131_072 > 0, so the guess is accepted as arm 2 of the precedence chain.
  • agent_loop::mod.rs:878 turns that into ContextBudget::new(ctx_window). Compaction fires at 80% of the window.

So an 8K model behind a LiteLLM gateway got a 131K budget: compaction never fires, the daemon packs a prompt to ~104K tokens, and the provider rejects the request after the input tokens have been billed. That is precisely the failure the fallback comment three lines above already warns about ("a 200K assumption silently bills the user for prompts the provider then rejects with HTTP 400 (#3349)") — the discovered-entry literal reintroduced it by a different route.

The opposite direction is equally silent: a 1M-context model reached through a gateway is clamped to 131K, and ContextCompressor summarises away prompt content nobody asked to drop.

Neither failure is visible from either end, which is what makes this an area/security defect rather than a display bug: the operator reading 131072 in the Models page has every reason to believe LibreFang learned it from the gateway.

What changed

Probe layer — read what the endpoint reports.
DiscoveredModelInfo gains context_window: Option<u64> / max_output_tokens: Option<u64>, and parse_openai_models populates them through the key-priority parsers in model_metadata, which already encoded the per-server key zoo for the single-model endpoint and are now pub(crate) and shared: vLLM max_model_len, LM Studio / llama.cpp context_length, some proxies' context_window, LiteLLM max_input_tokens / max_output_tokens, OpenRouter context_length and top_provider.max_completion_tokens. A second parser, parse_openai_model_max_output, handles the output ceiling with a key list deliberately disjoint from the context-window list — max_tokens is already claimed there as a last-ditch reading of the full window, and counting it as both would silently assert an output ceiling equal to the whole context.

parse_ollama_tags reports None for both: /api/tags genuinely carries no token capacity (it lives in /api/show's model_info, which costs one extra request per model). Guessing from the family name would be the same defect wearing a different hat.

Merge site — never invent.
An entry now gets numbers only when the endpoint supplied them. When it did not, both fields stay at the catalog's documented 0 ("unknown") encoding, and every existing > 0 guard falls through to the conservative UNKNOWN_MODEL_CONTEXT_WINDOW (8192) whose warning already names agent.toml: model.context_window as the operator's fix. A conservative window plus a loud warning is recoverable; an invented one is not observable from either side.

Capacity also follows the same never-downgrade rule the capability flags already follow: a later probe fills in a limit that is still unknown, and a probe that stops reporting one (an older proxy in front of an upgraded gateway) cannot erase what an earlier probe learned.

limits_known on ModelCatalogEntry — adopting #7786's shape deliberately.
0 alone is ambiguous, and the repo's own doc comment says so: it means "unknown / not applicable". An image or audio entry legitimately has no token context. A discovered model with no reported capacity does have one and we do not know it. Those two behave differently for any consumer that wants to render a badge or clamp a request, and they were indistinguishable once written into the entry. limits_known records provenance: true when a registry entry, an operator override, or the endpoint supplied the numbers; false when nothing did. It mirrors the existing pricing_known field exactly — same #[serde(default = "default_true")], same "older entries predate this and carry real numbers" reasoning — so it is a convention the catalog already has rather than a new one.

It is threaded through model_metadata::synthesize_entry as well, because that pipeline's L5 substring table and its two provider-shaped defaults are guesses this crate invented, and leaving them marked as sourced would contradict the field's own contract. MetadataSource already drew that line; the flag puts it on the entry so a caller holding only the entry can see it too.

The three model routes emit limits_known next to pricing_known, which is what makes the fix assertable end to end.

Drive-by, forced by the new fields. The bare DiscoveredModelInfo { name, None, None, … } literal was triplicated across librefang-api (twice) and librefang-kernel (once) purely because parse_openai_models discarded its info. All three now call one DiscoveredModelInfo::bare(name) constructor, so a future field cannot be quietly given a fabricated value at one of several sites. Net deletion.

Collision with #7786 — and which side should rebase

Stated explicitly because it is a hard same-hunk conflict, not a merge hiccup. PR #7786 (feat/modelparams-tristate, @DaBlitzStein) inserts limits_known: false plus a matching field into the same ModelCatalogEntry hunk in crates/librefang-types/src/model_catalog.rs, and also touches crates/librefang-runtime/src/model_catalog.rs, crates/librefang-runtime/src/model_metadata.rs and crates/librefang-testing/src/mock_kernel.rs — every one of them a file this PR changes.

I did not touch that branch. The honest engineering answer is that the tri-state is the right model, so this PR adopts that shape rather than fighting it: limits_known: bool with default = "default_true", matching pricing_known. The two designs now agree on the field name and its semantics, which turns a design conflict into a textual one.

#7786 should rebase onto this, for one reason and one reason only: #7786 is already CONFLICTING against main independently of this PR, so it has to be rebased regardless, and after that rebase limits_known already exists and its insertion of the field simply drops out of the diff. Rebasing this PR onto #7786 instead would block a one-crate correctness fix behind a 44-file feature branch that is not landing imminently. If the maintainer prefers the other order, this branch is small and I will rebase it.

Interaction with #7816 (fix/7775-openai-compatible-discovery)

Flagging rather than acting, since it is not my branch. PR #7816 is MERGEABLE and adds a fourth copy of the bare DiscoveredModelInfo literal in crates/librefang-api/src/routes/providers.rs (in the new merge_probe_into_catalog). That copy will not compile against the two new fields, and it conflicts textually with the two copies this PR replaced with DiscoveredModelInfo::bare. Whichever lands second needs the one-line change to ::bare(name).

Worth noting for the reviewer: #7816's changelog says "being in the catalog is also what stops the runtime assuming an 8k context window for a model that handles far more". After this PR, a gateway model whose endpoint reports no capacity does get the conservative 8K — because that is the honest answer, and the issue asks for exactly that ("the compaction/budget math can treat it conservatively"). The two PRs are complementary rather than opposed: #7816 makes the model reachable, this one makes sure the number attached to it came from somewhere. The operator-facing remedy for the still-unknown case is agent.toml: model.context_window, which the fallback warning already names, and #7774 is the editing surface for it.

Verification

Commands and their actual output are in the collapsed section below. Summary:

  • cargo check -p librefang-api -p librefang-cli -p librefang-kernel-metering -p librefang-testing --lib — clean.
  • cargo test -p librefang-runtime --lib -- model_catalog::tests::test_merge_ provider_health::tests:: model_metadata::tests:: — 74 passed, 0 failed.
  • cargo test -p librefang-api --test providers_routes_test -- list_models — 4 passed, 0 failed, including the new integration test.

New tests:

  • provider_health::tests::test_parse_openai_models_reads_reported_capacity — one listing body covering the vLLM, LiteLLM and OpenRouter key shapes plus a silent entry; asserts each capacity reaches DiscoveredModelInfo and the silent one stays None.
  • provider_health::tests::test_parse_openai_models_rejects_zero_capacity — a server reporting 0 is not recorded as having answered.
  • model_catalog::tests::test_merge_records_capacity_reported_by_the_gateway — the reported-capacity requirement: gateway says 8192, entry carries 8192, limits_known is true.
  • model_catalog::tests::test_merge_does_not_invent_capacity_when_the_gateway_reports_none — the unknown-capacity requirement: asserts != 131_072 explicitly as well as == 0, so the literal cannot come back by any route, and limits_known is false.
  • model_catalog::tests::test_merge_accepts_a_partially_reported_capacity — a gateway reporting only the window.
  • model_catalog::tests::test_merge_upgrades_unknown_capacity_but_never_erases_a_known_one — three probes: silent, reporting, silent again; the measured window survives.
  • providers_routes_test::list_models_serves_probed_capacity_and_never_the_hardcoded_literal — the same two cases end to end through GET /api/models against the real router, asserting the JSON body.

Not run locally, and why: cargo clippy and any wider build. This machine's shared CARGO_TARGET_DIR is used concurrently by ~28 other worktrees; free disk fell from 45 GiB to 2.4 GiB during this session and I stopped building at that point rather than take the machine down. Clippy is left to CI. One clippy hazard was fixed by reading rather than running: the capacity back-fill in the in-place upgrade branch is written as two flat if field == 0 branches instead of a nested if let, since the enclosing block would otherwise contain only the inner if and trip collapsible_if.

A note on local test runs in this repo, since it cost me four wasted invocations: with a shared CARGO_TARGET_DIR across worktrees, cargo test reported Fresh librefang-runtime and then ran a test binary containing another worktree's tests (#7774's, by name), silently omitting mine while reporting ok. touching the edited sources before the run forces a real rebuild. Every result quoted above was taken after such a touch, and I verified by name that each new test actually appears in the run output.

Verification output
$ cargo check -p librefang-api -p librefang-cli -p librefang-kernel-metering -p librefang-testing --lib
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 11.38s

$ cargo test -p librefang-runtime --lib -- model_catalog::tests::test_merge_ provider_health::tests:: model_metadata::tests::
test model_catalog::tests::test_merge_accepts_a_partially_reported_capacity ... ok
test model_catalog::tests::test_merge_records_capacity_reported_by_the_gateway ... ok
test model_catalog::tests::test_merge_upgrades_unknown_capacity_but_never_erases_a_known_one ... ok
test model_catalog::tests::test_merge_does_not_invent_capacity_when_the_gateway_reports_none ... ok
test provider_health::tests::test_parse_openai_models_reads_reported_capacity ... ok
test provider_health::tests::test_parse_openai_models_rejects_zero_capacity ... ok
test result: ok. 74 passed; 0 failed; 0 ignored; 0 measured; 2230 filtered out; finished in 0.39s

$ cargo test -p librefang-api --test providers_routes_test -- list_models
test list_models_surfaces_cli_profile_configured_model ... ok
test list_models_serves_probed_capacity_and_never_the_hardcoded_literal ... ok
test list_models_returns_well_formed_envelope ... ok
test list_models_filters_by_unknown_provider_yields_empty ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 56 filtered out; finished in 5.89s

Deliberately out of scope

`merge_discovered_models` is the only writer for gateway- and
locally-discovered catalog entries, and it hardcoded
`context_window: 131_072` / `max_output_tokens: 16_384` because nothing
upstream could supply either — `parse_openai_models` threw away every
capacity field the listing carried and returned an empty `model_info`.

Fix it at the probe layer. `DiscoveredModelInfo` gains optional
`context_window` / `max_output_tokens`, and the OpenAI-compatible parser
now reads them through the shared key-priority parsers in
`model_metadata` (vLLM `max_model_len`, LM Studio and llama.cpp
`context_length`, LiteLLM `max_input_tokens` / `max_output_tokens`,
OpenRouter `context_length` and `top_provider.max_completion_tokens`).

When the endpoint reports nothing the entry no longer acquires a number.
Both limits stay at the catalog's documented `0` (unknown) encoding and
`ModelCatalogEntry` records provenance in a new `limits_known` flag, so
the existing `> 0` guards in `resolve_context_window` and the agent
loop's `ContextBudget` fall through to the conservative
`UNKNOWN_MODEL_CONTEXT_WINDOW` and log the warning that names the
`agent.toml` field an operator can set.

Refs #7780
@github-actions github-actions Bot added area/runtime Agent loop, LLM drivers, WASM sandbox area/kernel Core kernel (scheduling, RBAC, workflows) size/L 250-999 lines changed labels Aug 23, 2026
@houko
houko enabled auto-merge (squash) August 23, 2026 23:57
@houko
houko merged commit 48079d6 into main Aug 24, 2026
36 checks passed
@houko
houko deleted the fix/7780-discovered-model-capacity branch August 24, 2026 01:24
DaBlitzStein added a commit to DaBlitzStein/librefang that referenced this pull request Aug 24, 2026
…7819, librefang#7817, librefang#7820)

Brings in four upstream commits and reconciles them with the fork's own work in the same files.
Every conflict keeps both sides' intent; nothing from either side was dropped.

Conflicts resolved:

- crates/librefang-api/dashboard/src/components/ui/SliderInput.tsx — both sides fixed the same dimming bug from opposite directions.
  Upstream's bounds normalization (`lowerBound` / `upperBound` / `clamp` / `boundedValue` / `emitValue`) now feeds the fork's index-addressed `steps` mode, so the stepped context-window slider inherits inverted-bound and non-finite-value handling it never had.
  The fork's position-accurate tick legend replaces upstream's `flex justify-between`, which spaces labels evenly regardless of value; upstream's `${tick}-${index}` key is adopted so a repeated tick value still renders both labels.
  The fork's toggle geometry (w-9 track, p-0.5 inset, w-4 knob) is kept over upstream's w-8/h-[18px] because it contains the knob by construction.

- crates/librefang-api/dashboard/src/components/ui/SliderInput.test.tsx — added by both.
  Union of the two suites: the fork's toggle-geometry, tick-position and fixed-steps cases, plus upstream's inverted-bounds, clamping, non-finite-input and inherited-state cases.
  25 tests pass.

- crates/librefang-api/src/routes/agent_templates.rs — upstream added `promotion_preview` to `get_agent_template`; the fork had renamed that handler to `get_agent_type` behind the canonical `/api/agent-types` routes with `/templates` kept as a deprecated alias.
  `promotion_preview` is wired into the canonical handler, so both path spellings serve it.
  The response keeps the fork's full serialized manifest rather than upstream's hand-picked subset — a superset of what upstream's tests read.

- crates/librefang-kernel/src/kernel/mod.rs — both re-exports kept (`PendingSkillMcpDeclarations`, `SemanticMemoryAccess`, `SkillReloadOutcome`).

- crates/librefang-kernel/src/kernel/tools_and_skills.rs — the fork's unconditional `workflow_create` injection and upstream's semantic-memory capability gate are independent blocks; both run, injection first.

- openapi.json — regenerated from source rather than hand-merged.

- xtask/baselines/openapi.sha256 — regenerated from the regenerated spec.

Integration fixes the merge itself required:

- crates/librefang-types/src/manifest_privacy.rs — upstream's `reduce_model` exhaustively destructures `ModelConfig`, which the fork has extended with `mode` and `router_override`.
  Both survive promotion: they select how a type picks a model, which is what the type does, and neither carries a host path, credential binding or private endpoint.
  Stripping them would republish a flexible-routing type as a fixed one — a behaviour change, not a redaction.

- crates/librefang-api/src/custom_gateway_catalog.rs — upstream's new `ModelCatalogEntry::limits_known` reached the fork's gateway discovery path through `..Default::default()`, which is `true`.
  That path legitimately produces `context_window: 0` when a gateway publishes no capacity, and `limits_known: true` there records the gateway's silence as a discovered ceiling.
  The flag is now derived alongside the numbers, the way `pricing_known` already was: true only when `/model/info` reported a value or a prior entry carried a real one forward.
  Two tests pin both directions.

Verification:

- cargo check -p librefang-types -p librefang-memory -p librefang-kernel -p librefang-api --lib — clean.
- cargo check -p librefang-cli --bins — clean.
- cargo test -p librefang-types --lib manifest_privacy — 34 passed.
- cargo test -p librefang-kernel --lib tools_and_skills — 13 passed.
- cargo test -p librefang-kernel --lib semantic_memory — 6 passed.
- cargo test -p librefang-api --test profiles_templates_routes_integration — 15 passed, 2 failed.
  Both failures are pre-existing on this branch and unrelated to the merge: `templates_list_carries_provider_and_model_from_the_manifest` and `templates_list_skips_a_malformed_manifest_instead_of_failing` seed `workspaces/agents/`, which this branch's `list_agent_types` deliberately stopped listing.
  Fixed in the following commit.
- dashboard: vitest SliderInput 25 passed, tsc --noEmit clean.

Locale keys: upstream touched no `.ftl` file in these four commits; key counts are unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/kernel Core kernel (scheduling, RBAC, workflows) area/runtime Agent loop, LLM drivers, WASM sandbox size/L 250-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(models): a discovered model gets a hardcoded 131072-token context window that nothing can source or correct

1 participant