Repository navigation
fix(daemon): multi-slot thinking-on rejected for implicit-<think> templates (+ embedded /ui chat) - #801
Closed
ghazni101 wants to merge 9 commits into
Closed
fix(daemon): multi-slot thinking-on rejected for implicit-<think> templates (+ embedded /ui chat)#801ghazni101 wants to merge 9 commits into
ghazni101 wants to merge 9 commits into
Conversation
ghazni101
added a commit
to ghazni101/hipfire
that referenced
this pull request
Oct 3, 2026
A template that seeds reasoning text after the opener (<think>Let me…) frames OpenThink, but the suffix read classified it Plain — generation would begin with the parser outside a span the prompt had already opened, routing that reasoning to the visible channel. Classify the live generation turn (after the last <|im_start|>) by marker pairs: seeded and re-opened openers frame OpenThink, history think blocks and user-literal markup stay out of the classification, and every guard-block spelling still closes. Mirrors classify_render_tail in warpfront#801 so the beta and master fixes converge on the same shapes.
4 tasks done
ghazni101
added a commit
to ghazni101/hipfire
that referenced
this pull request
Oct 3, 2026
A template that seeds reasoning text after the opener (<think>Let me…) frames OpenThink, but the suffix read classified it Plain — generation would begin with the parser outside a span the prompt had already opened, routing that reasoning to the visible channel. Classify the live generation turn (after the last <|im_start|>) by marker pairs: seeded and re-opened openers frame OpenThink, history think blocks and user-literal markup stay out of the classification, and every guard-block spelling still closes. Mirrors classify_render_tail in warpfront#801 so the beta and master fixes converge on the same shapes.
…E_UI)
Adds a zero-dependency chat frontend to the OpenAI gateway, off by
default. No new crates: three static assets embedded via include_str!
and served from a new serve/ui.rs asset matcher, sharing the serve
listener and the existing /v1/chat/completions path.
Enablement, in precedence order:
hipfire serve --ui # flag
hipfire serve --ui --open # flag + xdg-open/open/start the URL
hipfire config set serve.ui true
HIPFIRE_SERVE_UI=1
Off is byte-for-byte the old behavior: / and /ui both 404. --open
implies --ui (the launch promise needs the route) and is forwarded to
detached children as --ui. Non-loopback binds with the UI on emit a
stderr warning — the gateway already answers cross-origin, but the UI
makes that reachable from any browser.
Routes (GET, gated on shared.ui_enabled):
/ -> 302 /ui
/ui -> embedded index.html
/ui/app.js, /ui/style.css
Strict CSP (script-src/style-src 'self', connect-src 'self',
img-src 'self' data:, no unsafe-inline) + nosniff; the API-wide CORS
wildcard is not sent on UI responses. The UI only ever writes
textContent, so untrusted model output can never become markup.
Frontend features, all backed by existing wire shapes:
- streaming via fetch + hand-rolled SSE reader (EventSource can't
POST); AbortController drives the existing AtomicBool cancel path,
so Stop really does kill GPU work
- thinking panel from delta.reasoning_content
- thinking controls driven by the model's advertised contract:
/health now reports reasoning_contract / reasoning_effort_native /
reasoning_efforts (populated from ServeRuntime, probed from the
chat template at load). qwen_jinja gets an effort dropdown over
the advertised rungs + auto/off; gemma_boolean gets on/off;
muse_glimmer and unsupported hide the controls
- tools JSON editor + tool_choice; terminal delta.tool_calls render
with a manual "reply as tool" round-trip (assistant history keeps
tool_calls so the tool turn is well-formed); hidden when
refused_request_fields includes tools (multi-slot)
- image attach gated on capabilities.vision_sidecar; PNG/JPEG data
URI, one image per request (matches the gateway's validation)
- model picker from /v1/models with sidecar/multi-slot badges;
path-loaded models are injected as options so `model` is always
sent (the gateway 400s without it)
- status bar polls /stats (queue depth, recent tok/s, loading state),
paused while the tab is hidden; per-turn ttft/tok-s/tau/usage from
the terminal chunk's timings + usage + hipfire.reasoning shows the
*resolved* effort/mode and config warnings
Route capabilities advertise "chat_ui": bool so clients can discover it.
Known gap: deepseek4 is effort-native in the request resolver but the
daemon only probes reasoning_efforts for qwen_jinja, so DS4 models get
the toggle without an effort dropdown until that probe extends.
Verified live on qwen3.5-4b.mq4 (gfx1101): redirect, all assets, CSP
headers, SSE content/reasoning deltas, terminal timings + resolved
reasoning, usage chunk, disconnect -> daemon abort, and 404 on /ui
without the flag. cargo test -p hipfire-cli serve: 152 pass; the one
failure (multi_slot_request_supported max_think_tokens=0) is
pre-existing on HEAD (confirmed via stash).
Rebuilds the /ui frontend end to end, still zero external deps (CSP
default-src 'none', textContent-only rendering).
- History: IndexedDB store (db.js), sidebar with pinned/date groups,
full-text search with hit snippets, rename/pin/delete with Undo,
export Markdown/JSON, import, storage meter, cross-tab sync via
BroadcastChannel, per-chat #id routes and per-chat drafts.
- Branching: regenerate/edit fork messages into variants; each variant
keeps its own tail, so ‹ › restores whole branches.
- Messages: live streaming with reasoning disclosure ('Thought for Xs'),
markdown (tables, nested/task lists, links, TeX-unicode math), syntax
highlighting for ~15 languages, copy/download per code block, tok/s
stats chips, tool-call cards with inline tool-result replies.
- Composer: image attach/paste/drag-drop gated on the model's vision
capability, think pill (auto/on/off + effort), ~token count, Esc stop.
- Settings drawer persists sampling params, system prompt, tools and
response_format; theme switcher (system/light/dark via light-dark()),
status pill with live tok/s and queue depth, shortcuts dialog (?),
toasts, lightbox, TTS, mobile layout with scrim.
- Unmatched backtick runs now emit literally instead of letting inner
ticks re-scan as shorter openers.
Chat history is browser-local (IndexedDB); serve gains no new write
surface. ui.rs serves db.js/render.js alongside the existing assets.
- Reasoning effort select moves from the settings drawer into the composer bar as a second pill next to the think toggle (model-advertised efforts drive its options; hidden when the route/model can't take it). - New #live-stats strip above the composer shows TTFT + tok/s while streaming and pins the server's final timings afterwards; the topbar pill goes back to state-only (ready / generating / loading). - Mobile: composer bar wraps, textarea bumped to 16px so iOS doesn't zoom on focus, per-row and per-message actions stay visible on coarse pointers, safe-area padding on composer + sidebar.
- #model had min-width:auto inside its inline-flex wrapper, so at narrow widths the transparent select painted over the status pill and settings button. Constrain it to the picker's box (width/min-width/overflow). - Composer bar under 520px: icon-only think pill, 64px effort select, char-count hidden, nowrap — send stays on the row instead of wrapping. - #scroll-bottom was fixed at bottom:128px and could overlap a grown composer; a ResizeObserver now pins it to composer height + 12px. - Verified headless at 320/360/390/768px: zero horizontal overflow, zero element overlaps, single-row composer, select ends before pill.
Dark theme now matches the warpfront/hipfire README header image: near-black surfaces (#08080a / #0e0f13 / #121318), the hot #ff6a2a/#ff8a1f flame gradient as the primary accent, teal (#41c4dd) links and violet (#a98ef0) thinking-block accents — the poster's secondary colors. Light theme keeps its warm-paper base with a deepened #e24a12 accent to match.
…plates
The slot engine conflated thinking POLICY with the rendered prompt's tail
STATE. It derived `started_in_think` from `rendered.ends_with("<think>")`,
re-derived `prefix` as `OpenThink if started else ClosedThink if tokenizer
has <think>`, then 400'd any request where `prefix != expected_prefix`.
Two defects:
- `has_think` is a tokenizer property, not a render property: a template
that emits a bare `assistant\n` tail with thinking ON (the model opens
the span itself) was misclassified as ClosedThink and rejected with
"reasoning prefix mismatch: expected OpenThink got ClosedThink".
Verified against the baked templates of every local model: Qwen3.5 /
Ornith / NuExtract3 emit `assistant\n<think>` when enabled; MiMo's
`enable_thinking is false` branch only writes the closed guard block,
so thinking-on renders Plain — every thinking request 400'd on slots.
- `think_mode` was derived from `started_in_think` and compared against
`enable_thinking`, a second policy-vs-tail conflation behind the first.
The sequential route has no such gate: `spec_assistant_prefix` treats the
request prefix as non-authoritative once the Jinja render succeeds.
Now: `classify_render_tail` scans think markers in the region after the
last `<|im_start|>` (the live generation turn), so closed spans in history
or literal markup in user text are not tail state, and a span opened by the
template but seeded with reasoning text still counts as OpenThink. The
contradiction gate fires only on real disagreement — thinking=on with a
ClosedThink tail, or thinking=off with an OpenThink tail; a Plain tail is
legal under either policy. `prefix` (continuation suffix, emit ctx) uses
the observed tail; `think_mode` uses the request policy.
Verified on hipfire-ui (MiMo-V2.6-9B, 2 slots x 81920 ctx, paged KV +
radix prefix cache): enable_thinking true/false/auto all generate,
cached_tokens=256 reuse on repeat, 30 slot tests pass including two new
classifier/contradiction tables.
…cate Adversarial-review follow-up to d5c7d68. The truth-table test asserted a LOCAL closure that re-implemented the gate expression — it could not fail if the production gate drifted, and nothing tested the wiring between classify_render_tail and the rejection decision. Extract the contradiction predicate into thinking_tail_contradicts_policy(enable_thinking, tail), call it from the handler gate, and drive the truth table plus real render shapes (Qwen open-tail, guard-block, MiMo plain-tail) through the same function the handler uses. Also drop the stale "think_mode mismatch with enable_thinking" case from qwen_ar_slot_post_start_errors_release_route_and_reuse_generation: the production emitter of that message was removed in d5c7d68, so the case label lied about what the table covers. All 34 daemon tests pass.
The advisory clippy job's only hard error on the PR: deny-by-default clippy::mut_from_ref fires on as_mut_slice(&self) -> &mut [u8]. The shape is deliberate — BO memory is device-shared with the Arc-owned lifetime and a documented caller contract (crate docs: as_slice/as_mut_slice require no other live slice; device writes happen only between submit and wait). Mutation rights come from the submission contract, not Rust borrow state, as in every device-mapped-buffer API. Scoped to the one function with the rationale; everything else in the crate stays default-denied.
ghazni101
force-pushed
the
fix/multislot-thinking-tail-state
branch
from
October 3, 2026 13:51
452a6fa to
bce9b05
Compare
Contributor
Author
|
Retargeted and rebased onto beta — which surfaced that this PR is now fully superseded on beta: #808 carries the /ui work and #811 carries this same thinking-on fix (its 'classify the rendered assistant turn by think markers' commit explicitly mirrors classify_render_tail from this branch). No unique diff remains; closing. |
Kaden-Schutt
pushed a commit
that referenced
this pull request
Oct 6, 2026
A template that seeds reasoning text after the opener (<think>Let me…) frames OpenThink, but the suffix read classified it Plain — generation would begin with the parser outside a span the prompt had already opened, routing that reasoning to the visible channel. Classify the live generation turn (after the last <|im_start|>) by marker pairs: seeded and re-opened openers frame OpenThink, history think blocks and user-literal markup stay out of the classification, and every guard-block spelling still closes. Mirrors classify_render_tail in #801 so the beta and master fixes converge on the same shapes. (cherry picked from commit 1839f36)
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.
Fixes #800
The change
The slot engine conflated thinking policy (what the request asked for) with the rendered prompt's tail state (what bytes the template emitted). It derived
started_in_thinkfromends_with("<think>"), re-derivedprefixfrom the tokenizer'shas_thinkproperty, and 400'd any request where they disagreed — so a template that emits a bareassistant\ntail with thinking ON (the model opens the span itself; MiMo's convention) was stampedClosedThinkand rejected withreasoning prefix mismatch: expected OpenThink got ClosedThink. Because the daemon's no-fields fallback enables thinking for any tokenizer with a<think>token, the default request failed too. A secondthink_mode-vs-enable_thinkingcheck repeated the same conflation one layer down.Now:
classify_render_tailclassifies the rendered tail — the region after the last<|im_start|>, so closed spans in history and literal markup in user text are not tail state — intoOpenThink(unclosed span, including seeded reasoning) /ClosedThink(guard block) /Plain(no think markup).thinking_tail_contradicts_policy) fires only on genuine contradiction: thinking on +ClosedThinktail, or thinking off +OpenThinktail.Plainis legal under either policy.prefix(continuation suffix, emit ctx) uses the observed tail;think_modeuses the request policy.think_mode mismatchcase removed.This branch also carries the embedded chat UI work it was authored on top of (
/uiat serve, history/branching/markdown/themes, palette + mobile fixes — commits 8983808..d1bbf3b). The UI is compile-time-embedded assets behind an exact-match route table with a strict CSP (default-src 'none', nounsafe-inline);render.jsbuilds every node withcreateElement/textContentand restricts link hrefs tohttps?:/mailto:, so model output cannot execute script. Path traversal is structurally impossible (probed live:/ui/../etc/passwd,..%2fvariants → 404).Verification
GPU: RX 7700 XT (gfx1101), ROCm 7.2.4 containers built hermetically from
Containerfile --target runtimefor both this branch andmaster(daemon md50fe0a38b…vsf8b88467…). MiMo-V2.6-9B,serve.multi_slot=true, 2 slots:expected OpenThink got ClosedThinkenable_thinking: trueenable_thinking: falseThinkOutputRouterflips into the reasoning channel on a model-generated<think>even when constructed from a Plain tail (emit_text.rsdrain), so MiMo's self-opened span routes toreasoning_content— verified across default/on/thinking.type/streams/multi-turn/concurrency; no think text ever leaked intocontent.<|im_start|>assistant\n<think>and stray</think>/<think>in user text — all 200, no misclassification (the last-<|im_start|>scoping neutralizes them in standard templates).contentandreasoning_content.serve_harness.pybattery (thinking low), chain (thinking low), battery (thinking off) against the containerized slots serve — every runturns=5 runaway=0 empty=0 attractor=0 retrieval_miss=0, decoded output eyeballed coherent. Chain shows turn-over-turn cache reuse (cached 276→343→464); session-continuation reusecached_tokens=31on turn 2. (Cross-request radix reuse needs paged-KV/VMM, unavailable with the fixed Q8 slot arena on this card — environment, not this change.)masterreproduces the original 400s with the exact error from multi-slot: every thinking request (incl. the no-fields default) 400s on templates that let the model open <think> itself #800 on the same fixtures (A/B above).Known follow-ups (out of scope here)
unsafe multi_slot terminal: open_think(pre-existing guard, b053ad9): a thinking-on request whose budget is exhausted inside the think span 500s with rollback and no partial content (qwen3.5,max_tokens=80→ 500; 600 → 200). vLLM-stylefinish_reason=lengthwould be friendlier; this change newly reaches that terminal for MiMo-style requests that previously died at the gate.SpecEmitCtx.think_modeis never read by the qwen35 emitter — wire it or drop it; correctness rides onassistant_prefix(tail) +started_in_think.