Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/maxusai/adr/0016-reload-on-resolved-vision-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ must not reload, a genuinely different ceiling must — and
- The sentinel ambiguity itself remains: an explicit `70/1120` on nemotron is
still read as unset and is not expressible. That is now a documented
limitation rather than an accident — see the resolver's comment and
`nemotron-dynres-patch.md`.
`nemotron-dynres-patch.md`. **Superseded by [ADR 0048](0048-an-unset-image-budget-reaches-the-runner-as-zero.md):
the server sends an unset budget as 0 and an explicit 70/1120 is honoured.**
- `ResolvedImageTokenBudget` is the single place that answers "what budget
will this arch launch with", so an arch added to `visionServerArgs` must be
added there too or its budget silently stops forcing reloads. The two
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ Two constraints shaped the work, neither obvious from upstream's interface:
`image_max_tokens` is never silently ignored (ADR 0009). Each keeps its own
ceiling: a value equal to the shared api default counts as unset, the
convention `llm/llama_server.go` already applies for nemotron and qwen-VL.
**Superseded by [ADR 0048](0048-an-unset-image-budget-reaches-the-runner-as-zero.md):** unset now
arrives as 0, and an explicit shared default is honoured.
4. **Bidirectional spans come from `b.Media`, and the opening chunk carries every
one of them.** The `SeqOffsets[0] == 0` requirement is kept, because it is
load-bearing rather than vestigial: the bidi path attends over the chunk's own
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# ADR 0048: An unset image budget reaches the runner as zero

Date: 2026-10-11 · Status: proposed. Supersedes the "sentinel" parts of
[ADR 0016](0016-reload-on-resolved-vision-flags.md) (its Consequences: "an explicit `70/1120` on nemotron is still
read as unset and is not expressible") and [ADR 0021](0021-gemma4-serves-vision-through-upstreams-media-model.md)
(decision 3: "a value equal to the shared api default counts as unset"), and the matching rows of
[nemotron-dynres-patch.md](../nemotron-dynres-patch.md).

## Context

`api.DefaultOptions` fills `image_min_tokens` / `image_max_tokens` with gemma4's ladder, 70/1120
([ADR 0008](0008-gemma4-budget-fill-restores-1120.md)), so every request reached the runners carrying a budget whether or
not the caller set one. A model whose own bounds differ could not tell the two apart. Two resolvers therefore read
a value equal to 70/1120 as "unset" and substituted the model's own:
- `nemotronImageTokenBudget`, on the llama.cpp path;
- `ResolveImageBudget`, on MLX, which covers muse-glimmer, qwen3.5 and nemotron_h.

ADR 0016 kept this knowingly: an explicit 70 or 1120 on nemotron "is not expressible — pick an adjacent value". The
resolver comment named the fix: the routes layer's `hasOption` check could tell them apart "if that ever matters".

It matters now. On muse-glimmer, whose ceiling is 4096, 1120 is the value a client is most likely to send, because it
is the documented default. Measured on `muse-glimmer:30b-nvfp4` and `30b-mxfp8`, MLX, 2026-10-11 (#463), on a
2048×2048 image:

| `image_max_tokens` | unset | 1024 | 1119 | **1120** | 1121 | 2048 |
|---|---|---|---|---|---|---|
| image tokens | 4100 | 1025 | 1091 | **4100** | 1091 | 2022 |

A request for 1120 cost 3.7 times what it asked for, and nothing reported it.

## Decision

1. **The server sends an unset budget as 0.** `modelOptionsWithEmbeddingBatchDefault` records whether the request or
the Modelfile set each bound (`hasNonNilOption`, like `hasOption` for `draft_num_predict` but a key sent as JSON
`null` is unset, because `FromMap` skips it and leaves 70/1120 in place), and zeroes each one that neither set.
That holds only when a model is resolved; a nil model keeps the api defaults.
2. **The resolvers read only `<= 0` as unset.** `nemotronImageTokenBudget` and `ResolveImageBudget` lose their
equality test against the api defaults. Any explicit value is honoured, 70 and 1120 included.
3. **gemma4 is unchanged.** Its resolvers already map `<= 0` to 70/1120, its own bounds. An unset request and an
explicit 70/1120 launch the same flags and do not reload each other (`TestSchedNeedsReloadImageTokenBudget`).

## Consequences

- **An explicit 70 or 1120 is now a budget like any other** on nemotron_h (both paths), muse-glimmer and qwen3.5
(MLX). On nemotron it launches different flags than an unset request, so the scheduler reloads for it, as for any
other change of budget (ADR 0016's B3).
- **Unset behaves exactly as before on every arch.** It still resolves to each model's own bounds. The preflight
rows send no budget except the pinned probe, whose values (560 on gemma4, 3328 on nemotron) were never the
sentinel, so no expectation moves.
- **Clients that sent exactly 70 or 1120 to nemotron or glimmer expecting the model's default now get what they
asked for.** No harness in this repo does: the vision suite sends a budget only when `IMAGE_*_TOKENS` is set.
- **One rule now stands for "unset" across the fork:** `draft_num_predict` on MLX (-1) and the image budget (0) are
both decided at the routes layer, where the request's raw options are still visible. A runner never infers it from a
value.
15 changes: 7 additions & 8 deletions docs/maxusai/nemotron-dynres-patch.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,11 @@ Keeping the 128×128 grid through the compat layer and interpolating from it wou

- Defaults **256 / 3328** (the model's native bounds).
- The shared `ImageMinTokens`/`ImageMaxTokens` options arrive as the gemma4-shaped
DefaultOptions values when the caller left them alone; those exact values are treated
as unset for this arch. The sentinel is `api.DefaultImageMin/MaxTokens` themselves, so
it moves with the gemma4 default — **70 / 1120** today (ADR 0008; 40/1120 before
ADR 0007). Consequence: **explicitly requesting the current gemma4 defaults on
nemotron is not expressible** — pick an adjacent value (71, 1119, 1121…). Any other
value passes through; min clamps down to max.
DefaultOptions values when the caller left them alone. Since ADR 0048 the server sends a
bound neither the request nor the Modelfile set as 0, and only `<= 0` reads as unset
for this arch: an explicit **70 / 1120** (gemma4's defaults) is honoured like any other
value. Before ADR 0048 those exact values were a sentinel for "unset", and requesting
them on nemotron was not expressible. Min clamps down to max.
- Values above 3328 are passed through, but exceed the model's training distribution —
the reference never produces more than 13,312 patches. Don't raise the ceiling; only
lower it (e.g. to trade detail for context on busy hosts, via a Modelfile
Expand All @@ -129,14 +128,14 @@ For `modelArch == "nemotron_h_omni"` on a payload carrying the 002 patch:
|---|---|
| flags | `visionServerArgs()` always passes `--image-min-tokens` / `--image-max-tokens` |
| defaults | **256 / 3328** (= the model's 1024/13312 pre-merge patch bounds ÷ 4) |
| option resolution | `ImageMinTokens`/`ImageMaxTokens` ≤ 0 **or exactly equal to the gemma4-shaped DefaultOptions values (`api.DefaultImageMin/MaxTokens`, 70/1120 today)** are treated as unset; min clamps down to max; both are **Runner** options — changing either reloads the runner, but only when it changes the *resolved* flags (`llm.ResolvedImageTokenBudget`), so naming 256/3328 explicitly no longer reloads against an unset request |
| option resolution | `ImageMinTokens`/`ImageMaxTokens` ≤ 0 are treated as unset (the server sends an unset bound as 0, ADR 0048; before it, a value exactly equal to the gemma4-shaped DefaultOptions values, 70/1120, was also read as unset); min clamps down to max; both are **Runner** options — changing either reloads the runner, but only when it changes the *resolved* flags (`llm.ResolvedImageTokenBudget`), so naming 256/3328 explicitly no longer reloads against an unset request |
| per-image cost | `round(w/32) × round(h/32)` post-resize grid cells **+ 2** marker tokens (`<img>`/`</img>`), where the resize maps the image, aspect-preserved, into 262,144…3,407,872 px; small images are **upscaled** to the floor |
| bounds | floor 256, ceiling 3,328 visual tokens; the 32px floor-alignment lands most shapes slightly under the ceiling (3000×2000 → 3,290), which is exact only when scaled dims hit multiples of 32 (2048×1664 → 3,328) |
| ceiling caveat | the pixel budget is enforced before the per-dimension 32px minimum clamp, so **degenerate aspect ratios (≈100:1 and beyond) can exceed it** — e.g. a 4,000,000×1 input targets ~118 MPx and would exhaust memory. Inherited `dyn_size`-family behavior (qwen/kimivl/dots_ocr share it; nemotron was previously immune only because it squashed everything to 512²). Reject absurd-aspect images upstream of Ollama |
| warmup | load-time warmup probes the ceiling (`warmup_image_size` ≈ 1846) |
| unpatched payload | flags are parsed by llama-server but never consumed: exactly 256 tokens/image, no markers, letterboxed — byte-identical to today |
| audio | unaffected (compat force-sets `clip.has_audio_encoder=false`) |
| not expressible | an explicit request of exactly 40 or 1120 (collides with the DefaultOptions sentinels; a `hasOption`-style check at the routes layer could lift this if it ever matters) |
| ~~not expressible~~ | lifted by ADR 0048: the routes layer's `hasOption` check tells an explicit 70/1120 from an unset budget, as this row anticipated |
| ceiling clamp | `image_max_tokens` > 3328 clamps down to 3328 — the trained maximum, and a guard against the int32 pixel-math overflow at ≥ 2,097,152 in `set_limit_image_tokens` |

Regression tests: `TestVisionServerArgs/nemotron_h_omni_*` in
Expand Down
34 changes: 15 additions & 19 deletions llm/llama_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -1285,25 +1285,19 @@ const (
)

// nemotronImageTokenBudget resolves the nemotron_h_omni image-token budget
// from opts. The shared ImageMinTokens/ImageMaxTokens options arrive carrying
// the gemma4-shaped DefaultOptions values whenever the caller left them
// alone; at this layer those are not distinguishable from an explicit request
// (server/routes.go's hasOption pattern could tell them apart if that ever
// matters), so treat them, like <= 0, as unset and substitute this arch's
// native bounds. The sentinel is the api.Default* constants themselves, not
// literals, so it tracks the gemma4 default as ADRs move it — currently
// 70/1120 (ADR 0008; it was 40/1120 before ADR 0007). Consequence: an
// explicit 70 or 1120 is not expressible for this arch — pick an adjacent
// value. A client that explicitly sends 256/3328 no longer forces a reload
// against an unset request: ResolvedImageTokenBudget normalizes both to the
// flags they launch before the scheduler compares them. Min is clamped down
// to max like gemma4's resolver.
// from opts. Unset arrives as 0 (server/routes.go zeroes a budget neither the
// request nor the Modelfile set), and <= 0 takes this arch's native bounds.
// Any explicit value is honoured -- gemma4's 70/1120 included, which before
// stood for "unset" here and could not be requested. A client that explicitly
// sends 256/3328 does not force a reload against an unset request:
// ResolvedImageTokenBudget normalizes both to the flags they launch before the
// scheduler compares them. Min is clamped down to max like gemma4's resolver.
func nemotronImageTokenBudget(opts api.Options) (minTok, maxTok int) {
minTok, maxTok = opts.ImageMinTokens, opts.ImageMaxTokens
if minTok <= 0 || minTok == api.DefaultImageMinTokens {
if minTok <= 0 {
minTok = defaultNemotronImageMinTokens
}
if maxTok <= 0 || maxTok == api.DefaultImageMaxTokens {
if maxTok <= 0 {
maxTok = defaultNemotronImageMaxTokens
}
// 3328 is the model's trained ceiling (13312 pre-merge patches); raising
Expand Down Expand Up @@ -1701,10 +1695,12 @@ func Gemma4ImageBudget(opts api.Options) (minTok, maxTok int) {
// will actually pass for modelArch under opts, and whether those flags derive
// from opts at all.
//
// The budget options are arch-agnostic but carry gemma4's defaults, so equal
// flags do not imply equal options: on nemotron the unset sentinel and an
// explicit 256/3328 launch the identical runner. Resolving before comparing
// is what lets the scheduler tell "same launch" from "same request".
// The budget options are arch-agnostic, and an unset one arrives as 0
// (server/routes.go), which each arch resolves to its own bounds. So equal
// flags do not imply equal options: on nemotron an unset budget and an
// explicit 256/3328 launch the identical runner, as an unset budget and an
// explicit 70/1120 do on gemma4. Resolving before comparing is what lets the
// scheduler tell "same launch" from "same request".
// derived is false for arches whose vision flags ignore the options (a fixed
// floor, or none at all), where the bounds cannot affect the launch.
func ResolvedImageTokenBudget(modelArch string, opts api.Options) (minTok, maxTok int, derived bool) {
Expand Down
48 changes: 27 additions & 21 deletions llm/llama_server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1632,14 +1632,13 @@ func TestVisionServerArgs(t *testing.T) {
want: []string{"--image-min-tokens", "256", "--image-max-tokens", "3328"},
},
{
// The gemma4-shaped DefaultOptions values (70/1120 today) mean
// "untouched" and must map to the nemotron-native bounds, not be
// forwarded literally. Asserted through the constants, so the case
// keeps holding when an ADR moves the gemma4 default again.
name: "nemotron_h_omni DefaultOptions values treated as unset",
// Unset reaches the runner as 0 (server/routes.go), so gemma4's
// 70/1120 is an explicit request here like any other value and is
// forwarded; it no longer stands for "untouched".
name: "nemotron_h_omni explicit 70/1120 honoured",
arch: "nemotron_h_omni",
opts: api.Options{Runner: api.Runner{ImageMinTokens: api.DefaultImageMinTokens, ImageMaxTokens: api.DefaultImageMaxTokens}},
want: []string{"--image-min-tokens", "256", "--image-max-tokens", "3328"},
want: []string{"--image-min-tokens", "70", "--image-max-tokens", "1120"},
},
{
name: "nemotron_h_omni custom budget",
Expand All @@ -1655,17 +1654,17 @@ func TestVisionServerArgs(t *testing.T) {
},
{
// The production-realistic shape: a Modelfile sets only one param, so the
// other arrives at its DefaultOptions sentinel and must be substituted
// per-field, not only when both are sentinels.
name: "nemotron_h_omni max explicit, min at sentinel",
// other arrives unset (0) and must be substituted per-field, not only
// when both are unset.
name: "nemotron_h_omni max explicit, min unset",
arch: "nemotron_h_omni",
opts: api.Options{Runner: api.Runner{ImageMinTokens: api.DefaultImageMinTokens, ImageMaxTokens: 2048}},
opts: api.Options{Runner: api.Runner{ImageMinTokens: 0, ImageMaxTokens: 2048}},
want: []string{"--image-min-tokens", "256", "--image-max-tokens", "2048"},
},
{
name: "nemotron_h_omni min explicit, max at sentinel",
name: "nemotron_h_omni min explicit, max unset",
arch: "nemotron_h_omni",
opts: api.Options{Runner: api.Runner{ImageMinTokens: 1024, ImageMaxTokens: api.DefaultImageMaxTokens}},
opts: api.Options{Runner: api.Runner{ImageMinTokens: 1024, ImageMaxTokens: 0}},
want: []string{"--image-min-tokens", "1024", "--image-max-tokens", "3328"},
},
{
Expand All @@ -1681,7 +1680,7 @@ func TestVisionServerArgs(t *testing.T) {
// ceiling lands on 3328/3328.
name: "nemotron_h_omni min above substituted ceiling",
arch: "nemotron_h_omni",
opts: api.Options{Runner: api.Runner{ImageMinTokens: 3500, ImageMaxTokens: api.DefaultImageMaxTokens}},
opts: api.Options{Runner: api.Runner{ImageMinTokens: 3500, ImageMaxTokens: 0}},
want: []string{"--image-min-tokens", "3328", "--image-max-tokens", "3328"},
},
{
Expand Down Expand Up @@ -1843,11 +1842,11 @@ func TestMaxImageTokens(t *testing.T) {
want: 3330,
},
{
// The gemma4-shaped DefaultOptions sentinels mean "untouched".
name: "nemotron_h_omni DefaultOptions values treated as unset",
// An explicit 1120 is a ceiling like any other (unset is 0).
name: "nemotron_h_omni explicit 1120 honoured",
arch: "nemotron_h_omni",
opts: api.Options{Runner: api.Runner{ImageMinTokens: api.DefaultImageMinTokens, ImageMaxTokens: api.DefaultImageMaxTokens}},
want: 3330,
want: 1122,
},
{
name: "nemotron_h_omni custom ceiling",
Expand Down Expand Up @@ -4549,13 +4548,20 @@ func TestResolvedImageTokenBudget(t *testing.T) {
wantMin: api.DefaultImageMinTokens, wantMax: api.DefaultImageMaxTokens, wantDerived: true,
},
{
// The sentinel means "untouched" here, so it must resolve to the
// same bounds an explicit 256/3328 does — that equality is what
// keeps the scheduler from reloading for identical flags.
name: "nemotron sentinel resolves to native bounds",
arch: "nemotron_h_omni", opts: sentinel,
// Unset (0) must resolve to the same bounds an explicit 256/3328
// does -- that equality is what keeps the scheduler from reloading
// for identical flags.
name: "nemotron unset resolves to native bounds",
arch: "nemotron_h_omni", opts: api.Options{},
wantMin: 256, wantMax: 3328, wantDerived: true,
},
{
// gemma4's 70/1120 is an explicit request on nemotron, and a
// different launch than unset, so the scheduler reloads for it.
name: "nemotron explicit 70/1120 is its own budget",
arch: "nemotron_h_omni", opts: sentinel,
wantMin: 70, wantMax: 1120, wantDerived: true,
},
{
name: "nemotron explicit native bounds resolve identically",
arch: "nemotron_h_omni",
Expand Down
29 changes: 11 additions & 18 deletions mlxrunner/model/media.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (

_ "golang.org/x/image/webp"

"github.com/ollama/ollama/api"
"github.com/ollama/ollama/mlx"
)

Expand Down Expand Up @@ -106,27 +105,21 @@ type MediaBudgetModel interface {
PrepareMediaWithBudget(segments []Segment, imageMinTokens, imageMaxTokens int) (*PreparedRequest, error)
}

// ResolveImageBudget folds a request's image-token budget into a model's own,
// using the convention llm/llama_server.go already applies for nemotron and
// qwen-VL: a non-positive value, or one equal to the shared api default, counts
// as unset and the model's own bound stands.
//
// The check against the default is the load-bearing half. api.DefaultOptions
// always populates these fields with gemma4's ladder (ADR 0008), so every
// request carries a budget whether or not the caller meant one. Without this,
// a model that deliberately keeps a different ceiling — glimmer's 4096, chosen
// because lowering it hurts OCR — would have gemma4's 1120 imposed on it by
// every default request, silently discarding detail it was built to keep.
//
// The cost is that a caller cannot explicitly request exactly the shared default
// on such a model; it resolves to the model's own. That is the right trade while
// the defaults are one architecture's ladder rather than a neutral value.
// ResolveImageBudget resolves a request's image-token budget against a model's
// own bounds. A request value <= 0 is unset and takes the model's; anything
// else is the caller's and wins. The server sends 0 for a budget neither the
// request nor the Modelfile set (server/routes.go), so gemma4's 70/1120 -- the
// api defaults -- no longer stand in for "unset": before, a model that keeps a
// different ceiling (muse-glimmer's 4096, qwen3.5's, nemotron_h's) could not be
// asked for exactly 1120, which resolved to its own 4096 (measured on
// muse-glimmer:30b-nvfp4, 2026-10-11). Unset still keeps such a model's own
// ceiling, which is what the sentinel protected.
func ResolveImageBudget(reqMin, reqMax, modelMin, modelMax int) (minTok, maxTok int) {
minTok, maxTok = reqMin, reqMax
if minTok <= 0 || minTok == api.DefaultImageMinTokens {
if minTok <= 0 {
minTok = modelMin
}
if maxTok <= 0 || maxTok == api.DefaultImageMaxTokens {
if maxTok <= 0 {
maxTok = modelMax
}
return minTok, maxTok
Expand Down
Loading