Repository navigation
feat(server): honor response_fields, stream_options and real prefill timings - #1476
Merged
Merged
Conversation
…timings Four b10621 native request fields that `/completion` and `/completions` accepted and dropped are now honored, and every expectation below is a capture of the pinned b10621 binary (build 10621, commit c1d0e7a00) serving a real checkpoint rather than a reading of the schema, which is wrong about two of them. `response_fields` projects the finished response down to the listed paths. Upstream walks each path splitting on `/` and stores the value under the WHOLE path string, so `generation_settings/n_predict` comes back as a root key literally named `generation_settings/n_predict`; the schema description claims the value is unnested to the root and the binary does not do that. Reproduced across nine probes: a missing path, a path that walks into a non-object, and an empty path are omitted without an error, a repeated path yields one key, a nested object can be selected whole, the result keeps the request's order, and a value that is not an array of strings returns the whole object with a 200 instead of a type error. Applied to the non-streaming body and to the final streaming frame only, which is where upstream applies it. `stream_options` and `stream_options.include_usage` are declared and type-checked instead of being read past as unknown keys. Measured: on the native route the block is inert on both sides, because the native final frame already carries the counts and the whole `timings` object; a non-object `stream_options` is tolerated, and a non-boolean `include_usage` is refused with upstream's own wording. `timings_per_token` frames now carry the real prefill figures. A one-shot `GenerateEvent::Prefill` snapshot, emitted by `SequenceInfo::mark_first_token` when the scheduler stamps the first token, reaches the streaming route before the first frame, so `cache_n`, `prompt_n`, `prompt_ms` and `tokens_evaluated` report from frame one what the final frame reports. They were zero on every frame until the last one. `n_predict` accepts upstream's whole value domain instead of serde's. `-1` means "as many as the context allows" and is clamped to the effective context window rather than refused with a 422; `0` is verified against the binary, which emits exactly one token for it despite its description saying none, and mlxcel answers the same one token on the same prompt; a value outside `[-1, INT32_MAX]` is refused with upstream's wording. Two derivations inside `timings` were also corrected against the binary: `prompt_n` is `tokens_evaluated - cache_n` (a cached prefix measured `prompt_n: 1` against `tokens_evaluated: 8` with `cache_n: 7`), and the generation rates divide by `predicted_n - 1` because `predicted_ms` starts at the first token, so `predicted_n: 4` with `predicted_ms: 15.132` reports `predicted_per_token_ms: 5.044`. Manifest: `field:response_fields`, `field:stream_options`, `field:stream_options.include_usage`, `field:timings_per_token` and `field:n_predict` move from `deferred` to `supported` in `routes.json`. `POST /completion` and `POST /completions` stay `deferred` under #1441 with three divergences left: `generation_settings` is mlxcel's resolved set rather than upstream's `task_params`, `tokens_cached` counts what the prefix cache supplied rather than the slot's occupancy after the request, and the six fields mlxcel refuses. The `id_slot` divergence is reworded to name #1440, which owns slot exposure. The native-field claim gate in `llama_compat_tests.rs` gained a rule for subfield entries, whose parent block is modeled by a nested struct rather than a root key. Refs #1441, #1440
4 of 6 tasks
`src/server/batch/xla_worker_tests.rs` is gated behind `xla-iree`, which no local gate compiles (`make verify` and the nightly build `metal,accelerate`), so its two exhaustive `GenerateEvent` matches were not updated with the rest when the `Prefill` variant landed. The `OpenXLA feature compile` CI job, which runs `cargo check --features cuda,xla-iree --all-targets`, is the gate that catches exactly this and it went red on the first push. `receive_done` now ignores the snapshot alongside the token events; the withheld-EOS assertion panics on it, because that test drives the worker directly and no prefill snapshot can reach its channel. Refs #1441
This was referenced Aug 27, 2026
inureyes
added a commit
that referenced
this pull request
Aug 28, 2026
#1441 separated the native `/completion` and `/embeddings` routes from the OpenAI routes across PR #1467 and PR #1476, and this moves what it did not finish so the issue can close against what it actually delivered. Eight entries in `routes.json` move from #1441 to #1477. Two are the `POST /completion` and `POST /completions` route entries, still `deferred` behind four divergences: `generation_settings` carries mlxcel's 19 resolved keys against b10621's 49-key `task_params` block, `id_slot` is always -1 because the continuous-batching scheduler exposes no stable per-request slot number, `tokens_cached` counts the prompt tokens the KV prefix cache supplied rather than the slot occupancy after the request, and six native request fields are refused with a diagnostic rather than honored. The other six are the `field:n_cmpl`, `field:n_indent`, `field:return_progress`, `field:return_tokens`, `field:t_max_predict_ms` and `field:verbose` entries, which are `not_applicable` and therefore never blocked the gate, but whose "no equivalent" notes become false the moment #1477 honors the corresponding field, so ownership follows the work rather than the history. The refused-fields divergence is the substantive part of the remainder: `return_tokens` and `return_progress` are named in #1441's own scope, so leaving them under a closed issue would have left real work with no owner in any wave of epic #1431. `id_slot` stays recorded but names #1440 as its owner, because slot exposure belongs to that issue. `generation_settings` shrinks on its own as #1436 and #1433 land their options, so #1477 re-measures it rather than chasing it now. `pin.json` gains #1477 as an owner of the `routes` shard, without which the ownership check would reject every repointed entry, and the shard ownership table in the operator documentation follows. After this, no `deferred` entry references #1441. Part of #1431.
This was referenced Aug 30, 2026
inureyes
added a commit
that referenced
this pull request
Aug 30, 2026
…tive route (#1525) ## Summary Settles two of the four divergences on `POST /completion` and `POST /completions`, both against the pinned b10621 binary rather than by argument, and rewrites the manifest entries for the rest with fresh measurement. **This pull request carries no closing keyword**: issue 1477 keeps the four request fields that are still refused and stays open, deliberately. Two premises in that issue's body were wrong and are corrected in place, with the captures recorded in a comment on it. Implementing against them would have produced a `__verbose` block mlxcel invented and a JSON array behind a limit upstream refuses. ## What changed - **`tokens_cached` is b10621's slot cache occupancy.** It reported the prompt tokens mlxcel's prefix cache supplied for the request; upstream reports the slot's occupancy after it, which six independent measurements agree is `tokens_evaluated + tokens_predicted - 1`. The subtraction saturates. `timings.cache_n` keeps the cache-supplied count, which is the different quantity `timings.prompt_n` is derived from and which PR #1476 verified against the binary; conflating them would have broken `prompt_n` silently. **Breaking for a client that read `tokens_cached` as a cache-hit figure**, with the migration in `docs/llama-server-compat.md`. - **`verbose` is accepted and inert, as upstream is.** `__verbose` is written only from the OAI-compat response builders (`server-task.cpp` lines 404, 452, 521 and 1098, each inside a `to_json_oaicompat*`) and the native completion object IS `to_json_non_oaicompat()`. Captured against the pinned binary, a request with `verbose: true` returns exactly the key set of the same request without it. Refusing it was the divergence; `field:verbose` becomes `supported`. - **The remaining entries are restated against measurement, not carried forward.** `field:n_cmpl` records that upstream's schema limit refuses rather than clamps and is bounded by the server's own slot count, so at one slot mlxcel's refusal is byte-equivalent to b10621's. `field:return_tokens` records the re-measurement the issue asked for: streaming frames carry per-frame ids unconditionally, with and without the field, and the final streaming frame carries `tokens: []` in both cases, so honoring it means an opt-in channel for the non-streaming half and an unconditional per-frame id for the streaming half. `field:return_progress`, `field:n_indent` and `field:t_max_predict_ms` each carry upstream's rule with the line in `server-context.cpp` and the number that pins it. - **The `generation_settings` divergence is re-measured**: 25 of upstream's 49 keys are reported and the 24 absent ones are named with the issue that owns each (#1436 for the sampler stages, #1433 for `speculative.types`, #1439 and those two for the rest). ## Validation Head to head against the pinned binary (build 10621, commit `c1d0e7a00`) serving `ggml-org/Qwen3-0.6B-GGUF`, mlxcel serving `qwen3-0.6b-4bit` at `--ctx-size 4096 --parallel 1`, same request body to both. | case | binary | `tokens_evaluated` | `tokens_predicted` | `tokens_cached` | `e+p-1` | `cache_n` | `prompt_n` | |---|---|---|---|---|---|---|---| | limit stop, `n_predict 8` | b10621 | 5 | 8 | 12 | 12 | 0 | 5 | | limit stop, `n_predict 8` | mlxcel | 5 | 8 | 12 | 12 | 0 | 5 | | prompt only, `n_predict 0` | b10621 | 5 | 1 | 5 | 5 | 0 | 5 | | prompt only, `n_predict 0` | mlxcel | 5 | 1 | 5 | 5 | 0 | 5 | | longer run, `n_predict 24` | b10621 | 8 | 24 | 31 | 31 | 0 | 8 | | longer run, `n_predict 24` | mlxcel | 8 | 24 | 31 | 31 | 0 | 8 | | `verbose: true` | b10621 | 1 | 4 | 4 | 4 | 0 | 1 | | `verbose: true` | mlxcel | 1 | 4 | 4 | 4 | 0 | 1 | The `n_predict: 0` row is the underflow case the issue asked to measure before choosing a formula: upstream answers it with `tokens_predicted: 1`, so `5 + 1 - 1 = 5` and the saturating subtraction never fires in practice. The `verbose: true` row is the control for that change: the same request, the same numbers, and the key set unchanged. Two further captures behind the restated entries, from the same session: `tokens_cached` also held at `15 + 4 - 1 = 18` on a fully cache-hit request (`cache_n` 14, `prompt_n` 1), so the figure is independent of the hit; and with `-b 64` on a 321-token prompt, `return_progress` emitted `processed` 0, 64, 128, 192, 256, 320, 321 against `total: 321`, one frame per prefill chunk, which maps directly onto mlxcel's own chunked prefill when that work is done. ## What remains on issue 1477 `return_tokens`, `return_progress`, `n_indent` and `t_max_predict_ms` are still refused, and each manifest entry now names the upstream rule, the source line, and the measurement rather than a general "no equivalent". `return_tokens` needs an opt-in id channel out of the scheduler plus unconditional per-frame ids on the streaming path; `return_progress` needs a per-chunk progress event mapped onto `PromptProgress`; the two stop rules need a second per-token guard on the sequence with upstream's newline and indentation conditions. `n_cmpl` above 1 stays `not_applicable`: shipping a partial array shape would be worse than the refusal, which at one slot is already what upstream answers. ## Test plan - [x] `cargo fmt --all -- --check` and `cargo clippy --profile test-fast --features metal,accelerate --lib --tests --bins -- -D warnings` - [x] `cargo test --profile test-fast --features metal,accelerate --lib server::` (2653) - [x] `cargo test --profile test-fast --features metal,accelerate --test llama_compat_manifest` (4) - [x] `python3 scripts/ci/check_llama_compat_manifest.py` - [x] The head-to-head table above against the pinned binary Tracked on issue 1477, which stays open.
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.
Summary
Issue 1441 landed its route separation in #1467 and was deliberately left open, because closing it would have forced manifest entries into
supportedthat were not. This PR closes the implementable half of what remained:response_fields,stream_optionsandstream_options.include_usage, the real prefill figures ontimings_per_tokenframes, and the wholen_predictvalue domain including then_predict: 0prompt-only case, which is now verified against the pinned binary rather than assumed.Issue 1441 stays open, and this body carries no closing keyword on purpose.
POST /completionandPOST /completionsstill carry divergences and therefore staydeferredunder it; the validator requires a deferred entry's issue to be OPEN, so resolving it now would turn thellama-compat manifestjob red for every open PR in the repository. What remains is listed at the bottom.The seven entries and where they land
field:response_fieldsdeferred, no divergence recorded, simply unimplementedsupportedfield:stream_optionsdeferred, no divergence recordedsupportedfield:stream_options.include_usagedeferred, no divergence recordedsupportedfield:timings_per_tokendeferred, zeroedprompt_n/prompt_msper framesupportedfield:n_predictdeferred,n_predict: 0unverifiedsupportedPOST /completiondeferred, 3 divergencesdeferred, 3 divergences (one reworded to name #1440, one added)POST /completionsdeferred, 3 divergencesdeferred, 3 divergences (same)Evidence: measured before and after
Every expectation is a capture of the pinned b10621 binary (build 10621, commit
c1d0e7a00,Qwen3-0.6B-Q8_0.gguf) against mlxcel servingmodels/mlx/qwen3-0.6b. The schema descriptions are wrong about two of these fields, so the binary is the reference, not the source text.response_fieldsBefore: silently ignored, all nine probes answered the full 16-key object. After: identical to b10621 on all nine.
["content","tokens_predicted"]["content","tokens_predicted"]["content","tokens_predicted"]["content","generation_settings/n_predict","timings/predicted_n"]generation_settings/n_predict = 8["content","no_such_field"]["content"]["content"]["timings/predicted_n","content","index"][""]{}{}["content","content"]["content"]["content"]"content"(a string)["content/x","tokens/0"]{}{}The slash form is the trap: the description says
generation_settings/n_predict"movesn_predictto the root", and the binary instead emits a root key literally namedgeneration_settings/n_predict. The implementation follows the binary.stream_optionsandinclude_usage{"include_usage": true}vs absent"stream_options": "garbage"{"include_usage": "yes"}Field 'include_usage': [json.exception.type_error.302] type must be boolean, but is stringField 'include_usage': type must be boolean, but is stringInert on the native route is the correct behavior, not a shortcut: the native final frame already carries
tokens_predicted,tokens_evaluatedand the wholetimingsobject whether or not the field is sent, which is why upstream's own/completionignores it. mlxcel's OpenAI routes already honor the same block the way b10621's do.timings_per_tokenStreaming a fresh 22-token prompt with
timings_per_token: true, per frame:tokens_evaluatedtimings.cache_ntimings.prompt_ntimings.prompt_mstimings.predicted_msat frame 1The mechanism is a one-shot
GenerateEvent::Prefillsnapshot emitted bySequenceInfo::mark_first_tokenwhen the scheduler stamps the first token, which reaches the streaming route before the first frame. The route could not previously learn the prefill figures untilGenerateEvent::Done.Two derivations inside
timingswere wrong against the binary and are corrected in the same change, so a client that graphs them will see different numbers:prompt_nistokens_evaluated - cache_n, nottokens_evaluated. Measured on a repeated prompt upstream:tokens_evaluated: 8,cache_n: 7,prompt_n: 1.predicted_per_token_msandpredicted_per_seconddivide bypredicted_n - 1, becausepredicted_msstarts at the first token. Measured:predicted_n: 4,predicted_ms: 15.132,predicted_per_token_ms: 5.044(15.132 / 3) andpredicted_per_second: 198.255(1e3 / 15.132 * 3). The old test assertingms / nwas namedtimings_rates_are_derived_the_way_upstream_derives_themand was not.n_predict0, prompt"A fresh unique prompt zzz 12345 alpha beta gamma"content: " delta",tokens_predicted: 1,tokens_evaluated: 15,stop_type: "limit"0, streamingtokens_predicted: 1-1withstop: [" 9"]tokens_predicted: 12,stop_type: "word",stopping_word: " 9"invalid value: integer -1, expected usizetokens_predicted: 12,stop_type: "word",stopping_word: " 9"-2Field 'n_predict': Value must be between -1 <= value <= 2147483647, but got -2The
n_predict: 0case is worth stating plainly, because the schema description says "no tokens will be generated but the prompt is evaluated into the cache" and the binary emits exactly one token. mlxcel emits the same one token on the same prompt, so the two agree with each other and both disagree with the description.-1is upstream's "as many as the context allows" and now resolves through the existing effective-context clamp instead of being refused.What changed
src/server/model_provider.rs:PrefillStatsandGenerateEvent::Prefill, adrain_generation_events_with_prefilldrain, andgenerate_streaming_native_reservedfor the native route. The new variant does not open the bounded decode window, so it cannot start the hang timer while the first token is still being sampled.src/server/batch/sequence.rs:SequenceInfo::mark_first_token, idempotent, replacing the two barefirst_token_time = Some(...)assignments inscheduler.rsandspeculative_burst.rs.prompt_msusescreated_atas its origin, the same origintake_generation_resulthands tofinish_with_cache, so the streamed figures and the final frame cannot disagree.src/server/types/native_completion.rs:select_response_fields, andNativeTimings::newnow takestokens_evaluatedand derivesprompt_nand the corrected rates.src/server/types/request.rs:n_predictbecomesOption<i64>withresolve_n_predict, plusresponse_fieldsandstream_optionsheld as raw values withresponse_field_pathsandvalidate_stream_options, so upstream's tolerant-parse-then-reject behavior is reproducible rather than pre-empted by serde.src/server/routes/native_completion.rs: projection applied to the non-streaming body and the final streaming frame through oneproject_native_response, and the streaming frames read the prefill snapshot.src/server/llama_compat_tests.rs: the native-field claim gate gained a rule for entries with a non-nullparent, which are modeled by a nested struct rather than a root key. It is documented as a deliberately weaker ratchet than the root-field rule, with the behavioral half asserted innative_route_tests.rs.compat/llama-server/b10621/routes.jsonanddocs/llama-server-compat.md.What moved to #1440, and why
POST /completionandPOST /completionscarried a divergence reading "id_slotis always -1: the continuous-batching scheduler exposes no stable per-request slot number (#1441)". Exposing a slot number is #1440's scope ("Expand/slotsto expose compatible slot state"), and #1440 is already an owner ofroutes.json, so the line now reads "... so exposing one is #1440's scope, not this entry's (#1440)". Only that divergence line moved: the entries stay filed under #1441, because their other divergences are #1441's.What remains on #1441, and why it stays open
POST /completionandPOST /completionskeep three divergences:generation_settingsechoes the settings mlxcel resolved rather than b10621's fulltask_paramsblock (49 keys), so keys mlxcel has no analogue for are absent. Reporting an invented value for them would be worse than their absence.tokens_cachedcounts the prompt tokens mlxcel's KV prefix cache supplied, while b10621 reports the slot's cache occupancy after the request, which istokens_evaluated + tokens_predicted - 1. Measured on a repeated 51-token prompt answered with 4 tokens: b10621tokens_cached: 54, mlxcel its own cache-supplied count. This divergence is newly recorded by this PR; it was not in the list before, and I found it while measuring the cache-hit path.return_tokens,return_progress,verbose,n_indent,t_max_predict_msandn_cmplabove 1 are refused with a diagnostic rather than honored. Each has its ownnot_applicableentry, and those do not block the issue.Items 1 and 2 are the substantial remainder and are probably worth their own issue, since both are response-body fidelity work with no dependency on the field work here. I have not filed it; say the word and it can be filed with the measurements above.
Two smaller observations recorded here rather than in the manifest, because neither belongs to an entry this issue owns:
"code": nullwhere b10621 reports the numeric status ("code": 400). It is the sharedErrorResponseacross every mlxcel route, not a property of these fields, so recording it onfield:stream_optionswould misattribute it.cache_n: 0) where b10621 reused 50 tokens. That is prompt-cache behavior, owned by the prompt-caching issues, not by these entries.Test plan
cargo test --profile test-fast --features metal,accelerate --lib -- server::(2207 passed, 0 failed, 8 ignored)cargo test --profile test-fast --features metal,accelerate --lib -- server::batch::(387 passed, 0 failed, 7 ignored)cargo test --profile test-fast --features metal,accelerate --test llama_compat_manifest(4 passed)cargo clippy --lib --bins --tests --features metal,accelerate --profile test-fast -- -D warningscargo fmt --all -- --checkpython3 scripts/ci/check_llama_compat_manifest.pypython3 scripts/ci/check_cross_repo_refs.pymodels/mlx/qwen3-0.6bRefs #1441, #1440