Compare commits

...
Author SHA1 Message Date
grenade 4e4ffd9b60 fix(reasoning): return the model's work to the client on every surface
CI / Config consistency (push) Successful in 7s
CI / Classify changes (push) Successful in 14s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 10s
CI / Clippy (push) Successful in 2m28s
CI / Test (push) Successful in 10m5s
CI / CUDA type-check (push) Successful in 27m51s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
Three of five response paths generated reasoning, metered it in `usage`,
and then discarded it. A client that never receives reasoning cannot
replay it, so on the next turn the model faced its own prior turn as a
bare assertion with the thinking that produced it deleted — and paid
for the deletion.

  /v1/chat/completions  streaming      already returned it
  /v1/chat/completions  non-streaming  DISCARDED -> now returned
  /v1/responses         streaming      already returned it
  /v1/responses         non-streaming  DISCARDED -> now returned
  /v1/messages          (Anthropic)    DISCARDED -> now returned

**chat, non-streaming.** `collect_chat_completion` had
`InferenceEvent::ReasoningDelta(_) => {}` — inherited from the path it
replaced, with a doc comment rationalising it. Now accumulated onto
`message.reasoning_content`, the field the streaming path already emits
as `choice.delta.reasoning_content`. Still kept out of `content`: it is
not the assistant's reply.

**responses, non-streaming.** `build_response` emitted a Message item
and nothing else, while the streaming projector beside it built a full
reasoning-item lifecycle. Observed live: a response reporting
`output_tokens_details.reasoning_tokens: 4000` with an empty `output` —
4,000 tokens generated, billed, invisible. Now emits a `reasoning` item
ahead of the message, same shape and ordering as streaming, via a new
`ResponsesOutputItem::Reasoning`.

**Anthropic.** `anthropic_to_openai` deliberately accepts
`thinking.budget_tokens` — the only quantitative reasoning control any
of our wire formats offers (#223) — and `openai_to_anthropic` returned
a bare text block. Taking a parameter and silently discarding what it
buys is the defect. Now emits a `thinking` block ahead of the text.

The Anthropic block carries no `signature`. Anthropic signs theirs and a
client round-tripping one may expect the field; we cannot mint a valid
signature and inventing one would be worse than omitting it. If a real
client rejects an unsigned block the fix is a flag, not a return to
deleting the model's work — recorded in the code so the next person
does not have to rediscover the trade.

Six tests, one pair per surface: reasoning survives, and absent
reasoning leaves the previous shape untouched (absent, not empty). The
lack of exactly these tests is why three paths drifted apart while a
fourth was correct.

Also carried: two byte-exactness regression tests written while chasing
a suspected content-corruption bug — a file read back through a tool
result, and one through the whole Responses->chat->prompt chain, both
asserting `<`, `&`, backticks, `${}`, quotes and blank lines survive
verbatim. The corruption turned out to be elsewhere; the tests are worth
keeping since that path had none.
2026-08-27 08:46:37 +03:00
grenade 7ad9f48cad Merge branch 'feat/operator-repetition-knobs'
build-prerelease / Resolve version stamps + change detection (push) Successful in 13s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m44s
build-prerelease / Build helexa-angels binary (push) Has been skipped
build-prerelease / Package helexa-angels RPM (push) Has been skipped
build-prerelease / Build helexa-router binary (push) Successful in 1m41s
build-prerelease / Build helexa-bench binary (push) Successful in 1m56s
build-prerelease / Build cortex binary (push) Successful in 2m13s
build-prerelease / Build neuron-ada (push) Successful in 2m30s
build-prerelease / Build neuron-ampere (push) Successful in 2m30s
build-prerelease / Build helexa-upstream binary (push) Successful in 2m30s
build-prerelease / Test (push) Successful in 8m2s
build-prerelease / Build neuron-blackwell (push) Successful in 21m50s
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Package helexa-router RPM (push) Successful in 20s
build-prerelease / Package helexa-bench RPM (push) Successful in 21s
build-prerelease / Package helexa-upstream RPM (push) Successful in 22s
build-prerelease / Package cortex RPM (push) Successful in 28s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m2s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m9s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 4m6s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 25s
2026-08-26 21:09:39 +03:00
grenade 6141befa16 feat(sampling): let an operator pin the seed
CI / Config consistency (push) Successful in 7s
CI / Classify changes (push) Successful in 13s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 10s
CI / Clippy (push) Successful in 2m28s
CI / Test (push) Successful in 8m22s
CI / CUDA type-check (push) Successful in 28m17s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
Parameter search needs a control. Comparing two `presence_penalty`
values across unseeded runs cannot separate the parameter's effect from
sampling variance, and the client that would otherwise pin the seed
cannot: pi's Responses request builder sets `model`, `effort`, `tools`,
`signal` and `timeout` — no `seed`. A caller-side seed is unavailable on
the path that matters, so the operator side is the only place it can go.

Verified reproducible on `Qwen/Qwen3.8-27B` before adding this, four
runs of one prompt at `seed: 424242`, byte-identical every time:

  - B=1, cold prefill (cached_tokens=0)
  - B=1, prefix-cache hit (cached_tokens=13)
  - B=2, decoding alongside a concurrent 835-token stream

Two of those were worth checking rather than assuming: a restored
prefix-cache snapshot yields the same token choices as a fresh prefill,
and batch composition does not perturb the result even though `(B,1)`
matmuls could plausibly change reduction order. Untested at length —
29 tokens is a short window and divergence compounds, so a 28k think
block is a much harder claim.

Precedence unchanged: request > operator > per-request random. An unset
override still gets a fresh seed per request, pinned by a test, so this
cannot accidentally freeze the fleet's RNG.

Worth an operator's deliberate choice rather than a default: with this
set, every identical prompt yields an identical answer for every caller.
Defensible — predictable, cacheable — but it removes the variation a
user may expect from retrying, which is a product decision and not just
a testing convenience.
2026-08-26 20:39:57 +03:00
grenade e55998d224 feat(sampling): presence and frequency penalties, OpenAI semantics
CI / CUDA type-check (push) Blocked by required conditions
CI / Classify changes (push) Successful in 14s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Config consistency (push) Successful in 22s
CI / Format (push) Successful in 8s
CI / Clippy (push) Successful in 2m56s
CI / Test (push) Successful in 7m8s
CI / Build cortex SRPM (push) Has been cancelled
CI / Build neuron SRPM (push) Has been cancelled
CI / Publish cortex to COPR (push) Has been cancelled
CI / Publish neuron to COPR (push) Has been cancelled
CI / Bump version in source (push) Has been cancelled
Qwen's own remedy for the endless repetition their models fall into
during long reasoning is `presence_penalty` (0..2, 1.5 when severe) —
and we did not implement it. Zero occurrences in the tree before this.

The distinction that matters: `repetition_penalty` is scored over a
trailing window (`repeat_last_n`, default 64), while presence and
frequency are scored over the **whole** generated sequence. Inside a
28,672-token think block a passage restated 5,000 tokens later is
invisible to a 64-token window, which is how the same derivation
appears five times in one block. A wider window is not the fix: the
penalty is token-level and source code legitimately repeats tokens
constantly, so widening it trades a visible pathology for an invisible
one. Sequence-wide penalties are the instrument the model's authors
name.

    logit -= frequency * count + presence * (count > 0)

Additive on logits, not multiplicative like `repeat_penalty`, because
that is what the OpenAI parameters mean — a client sending
`presence_penalty: 1.5` and expecting OpenAI behaviour gets it.

Also closes a wire gap: both are OpenAI *core* parameters. Clients could
send them and we discarded them silently, which is the #293 class of
defect and what #294's schema conformance would catch.

Threaded through the whole chain — `/v1/chat/completions`,
`/v1/responses`, `SamplingOverride` (so an operator can set them per
model in `models.toml` and `asset/neuron/<host>.toml`, gated by
`check-config-consistency.py`), and the resolution order request >
operator > model > built-in.

Defaults are `0.0`, which is exactly a no-op — the tensor is returned
untouched rather than round-tripped through the CPU — so a deployment
that sets nothing behaves as before. Pinned by a test.

Three tests: the OpenAI semantics (presence once regardless of count,
frequency scaling with it, composing additively), a token 5,000
positions outside the window still being penalised, and zero being a
true no-op.

No default change. What value helps without degrading code is a
measurement (#299), not a guess.
2026-08-26 20:24:25 +03:00
grenade c7df005d37 feat(sampling): let an operator reach the repetition knobs
CI / Test (push) Blocked by required conditions
CI / CUDA type-check (push) Blocked by required conditions
CI / Config consistency (push) Successful in 7s
CI / Classify changes (push) Successful in 15s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 9s
CI / Clippy (push) Successful in 2m29s
CI / Build cortex SRPM (push) Has been cancelled
CI / Build neuron SRPM (push) Has been cancelled
CI / Publish cortex to COPR (push) Has been cancelled
CI / Publish neuron to COPR (push) Has been cancelled
CI / Bump version in source (push) Has been cancelled
`repeat_last_n` defaults to 64 — candle's chat-example value — and a
reasoning model's think block runs to tens of thousands of tokens.
Anything the model restates more than 64 tokens later is invisible to
the penalty, which is how a 28,672-token think block ends up containing
the same derivation five times over. Observed live.

The constant's own doc comment already said this ("poorly suited to a
24k-token reasoning block ... which is why it is now a per-request knob
rather than a constant"). But per-request was not reachable in practice:
`SamplingOverride` (#283) carried only temperature/top_p/top_k, and
OpenAI-shaped clients do not send repetition parameters at all. The 64
was effectively hardcoded in production, by a route nobody had cause to
look at.

So `repeat_penalty` and `repeat_last_n` join the same precedence chain
as the rest — request > operator > model > built-in — settable in
`models.toml` and `asset/neuron/<host>.toml`, and gated by
`check-config-consistency.py` like every other sampling field. Defaults
are untouched: an operator who sets nothing gets exactly the previous
behaviour, pinned by a test.

Deliberately no default change. A wide window is not a free win —
the penalty is token-level, and source code legitimately repeats tokens
constantly (`const`, `function`, brace runs), so a wide window at a
meaningful penalty distorts the output this fleet exists to produce.
That trade almost certainly explains the original 64. Sequence-level
repetition wants a sequence-level instrument (frequency/presence
penalties, or a DRY-style sampler); this makes the token-level trade
measurable per model instead of guessed once, and tells us whether the
sequence-level work is warranted.
2026-08-26 20:14:01 +03:00
grenade 9c1dfc1f59 fix(beast): 2 GiB of snapshots, not 3 — the budget is taken from KV
build-prerelease / Resolve version stamps + change detection (push) Successful in 13s
build-prerelease / Lint (fmt + clippy) (push) Has been skipped
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Test (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Build helexa-angels binary (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Build helexa-router binary (push) Has been skipped
build-prerelease / Package helexa-angels RPM (push) Has been skipped
build-prerelease / Package helexa-router RPM (push) Has been skipped
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been skipped
The snapshot budget is reserved *before* the KV budget, not from the
remainder. Measured on this host after deploying 3072:

    kv_budget_mb: 4713 -> 2665      (exactly 5737 - budget_mb)

Per slot at max_in_flight=2 that is 42,600 tokens, and the session this
change was made for reached 52,869. So a 3 GiB cache made the model
unable to reach the session size whose snapshot the cache had been
enlarged to hold — self-defeating, and it would have surfaced mid-run as
KV exhaustion rather than as a clean limit.

My sizing was wrong in its premise, not its arithmetic: I treated the
KV budget as fixed and the cache as fitting in the leftover headroom.
The causality is the other way round.

2 GiB satisfies both floors — kv_budget 3,689 MB (~59,000 tokens/slot,
comfortably past the deepest observed session) and enough for a
52,869-token snapshot at ~1,652 MB of K/V plus GatedDeltaNet state.

The trade table is now in the config next to the value, since the
1:1 relationship is the thing that makes this number non-obvious.
2026-08-26 19:26:12 +03:00
grenade 35e87b88ff Merge branch 'fix/292-prefix-cache-budget'
build-prerelease / Resolve version stamps + change detection (push) Successful in 13s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m6s
build-prerelease / Test (push) Successful in 11m1s
build-prerelease / Build neuron-blackwell (push) Successful in 14m35s
build-prerelease / Build helexa-angels binary (push) Has been skipped
build-prerelease / Package helexa-angels RPM (push) Has been skipped
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Successful in 1m36s
build-prerelease / Build helexa-router binary (push) Successful in 1m41s
build-prerelease / Build neuron-ada (push) Successful in 2m8s
build-prerelease / Build cortex binary (push) Successful in 2m22s
build-prerelease / Build helexa-upstream binary (push) Successful in 2m39s
build-prerelease / Build neuron-ampere (push) Successful in 3m4s
build-prerelease / Package helexa-router RPM (push) Successful in 20s
build-prerelease / Package helexa-bench RPM (push) Successful in 23s
build-prerelease / Package cortex RPM (push) Successful in 23s
build-prerelease / Package helexa-upstream RPM (push) Successful in 21s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m4s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m8s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 4m8s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 26s
2026-08-26 18:52:49 +03:00
grenade 3edd9c8010 Merge branch 'feat/stamp-preserve-thinking-state' 2026-08-26 18:52:49 +03:00
grenade 4102cbe202 fix(beast): 3 GiB of prefix-cache snapshots, not 1
CI / Classify changes (push) Successful in 14s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Config consistency (push) Successful in 8s
CI / CUDA type-check (push) Has been skipped
CI / Format (push) Has been skipped
CI / Clippy (push) Has been skipped
CI / Test (push) Has been skipped
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
`cached_tokens` was pinned at 2,016 for ten consecutive turns of a live
agentic session while the prompt grew to 52,869 — every turn
re-prefilling ~50k tokens at ~37 s, against ~20 s of actual decode. The
cache was not missing the prefix; the snapshot could not be stored.

At the measured `kv_bytes_per_token_per_card = 32768`, the 1 GiB default
cannot hold more than ~32k tokens of K/V, and less once `snapshot.rs`
deep-copies the GatedDeltaNet conv/recurrent state on top — 48 of 64
layers, in f32 per `mamba_ssm_dtype`. Sessions on this host run well
past that, so the deeper the session the more of every turn was spent
re-doing work.

Sized from this deployment rather than rounded: free at load 9,285 MB,
less the 4,713 MB KV budget, leaves 4,572 MB that snapshots share. A
52,869-token snapshot is ~1,652 MB of K/V, so 3 GiB holds a full deep
session with slack. 4 GiB would take 90% of the headroom, which is not
a trade worth making on a host that also cold-swaps image models.

One variable: `max_entries` stays at 8. The byte budget binds first
here, so the count cap is not what forces snapshots to be small, and
changing both would make the result unattributable.

Config-only, so this deploys without a binary rotation (#291) — the
classifier treats `asset/neuron/*.toml` as inert.

Expected: `cached_tokens` tracks the prompt instead of freezing, and
per-turn prefill falls from ~37 s toward the low seconds. If it stays
frozen at 2,016, the budget is not the cap and #292 moves to the
retention policy.
2026-08-26 18:51:08 +03:00
grenade 236f08e730 feat(reasoning): advertise and log the preserve_thinking a run ran under
CI / Config consistency (push) Successful in 8s
CI / Classify changes (push) Successful in 13s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 8s
CI / Clippy (push) Successful in 2m56s
CI / Test (push) Successful in 8m15s
CI / CUDA type-check (push) Successful in 20m44s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
`preserve_thinking` is an A/B knob, and a comparison whose arms cannot
be told apart afterwards is not a comparison. Recovering the value from
deploy history is guesswork — a config sync can land mid-session, and
the two tower-defence arms will be reviewed hours later from a session
file and a journal.

Two places, because the two ways a run gets reviewed are different:

- `ModelInfo.preserve_thinking` on neuron's `/models` (and through to
  cortex's `/v1/models`), so a harness can stamp the value into its own
  record at the start of a run, and so it can be checked at any moment
  without grepping logs.
- An `INFO` line at each load site naming the effective value, so a
  journal that already carries the run's turns also carries the arm
  they belong to. `None` is logged explicitly rather than omitted —
  "unset, template default" is a distinct arm from `true`, and a
  missing line is indistinguishable from an older build.

Absent from the wire when unset (`skip_serializing_if`), so a client
that does not read it is unaffected, and absent-vs-false stays the
load-bearing distinction it is everywhere else in this struct.

No behaviour change: this only reports what is already in effect.
2026-08-26 16:41:37 +03:00
grenade f1012e8308 Merge branch 'fix/296-one-step-one-assistant-turn'
build-prerelease / Resolve version stamps + change detection (push) Successful in 15s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m34s
build-prerelease / Build helexa-angels binary (push) Has been skipped
build-prerelease / Package helexa-angels RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Successful in 1m44s
build-prerelease / Build helexa-router binary (push) Successful in 1m53s
build-prerelease / Build cortex binary (push) Successful in 2m25s
build-prerelease / Build helexa-upstream binary (push) Successful in 2m43s
build-prerelease / Build neuron-ada (push) Successful in 2m56s
build-prerelease / Build neuron-ampere (push) Successful in 3m4s
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Test (push) Successful in 9m33s
build-prerelease / Build neuron-blackwell (push) Successful in 16m55s
build-prerelease / Package helexa-router RPM (push) Successful in 20s
build-prerelease / Package helexa-upstream RPM (push) Successful in 22s
build-prerelease / Package helexa-bench RPM (push) Successful in 29s
build-prerelease / Package cortex RPM (push) Successful in 29s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m5s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m9s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 4m15s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 28s
2026-08-26 16:35:39 +03:00
grenade 82a7b8420c fix(responses): one agentic step is one assistant turn
CI / Config consistency (push) Successful in 7s
CI / Classify changes (push) Successful in 17s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 11s
CI / Clippy (push) Successful in 2m30s
CI / Test (push) Successful in 10m9s
CI / CUDA type-check (push) Successful in 28m25s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
The model was copying a transcript we built for it.

Responses splits one agentic step across several input items —
`reasoning`, then `message`, then `function_call`. `request_to_chat`
emitted a chat message per item, so a step became two *consecutive*
assistant turns. Rendered through Qwen3.8-27B's template that is:

    <|im_start|>assistant
    <think> …28,672 tokens… </think><|im_end|>      ← deliberates, then stops
    <|im_start|>assistant
    <think>
    </think>
    <tool_call>…                                    ← acts with no thought

Neither shape occurs in training, and consecutive assistant turns are
not a valid transcript at all.

Captured from a real session's rendered prompt (#296): 9 assistant
turns for 4 agentic steps, 4 empty `<think></think>` blocks, and **4
assistant turns ending with no tool call**. On the fifth step the model
produced a think block and the text "Now let me verify the JavaScript
parses cleanly — I want to catch any typos before calling it done:",
then emitted EOS without the call. It had been shown that exact pattern
four times in its own context.

`function_call` items now fold into the assistant turn they belong to,
which is the shape chat has always wanted: one message carrying
`content` and `tool_calls` together. Consecutive calls accumulate on
the same turn — that is what parallel tool calling looks like on the
wire.

The fold is deliberately narrow. Only a bare tool-call carrier merges:
empty content, no reasoning of its own, and no extension key other than
`tool_calls`. Anything else keeps its own turn, so a genuine two-turn
sequence is never collapsed and no field is silently dropped — trading
one invisible loss for another is how this defect survived.

Reasoning attachment (#277) already rode the next assistant message and
is unchanged; the call was the orphan.

Two tests pin it: one step yields `[user, assistant, tool, user]` with
reasoning, text and the call all on the one turn, and parallel calls
accumulate rather than splitting.

Not a fix for the model's judgement — a fix for what we showed it.
2026-08-26 15:51:54 +03:00
grenade c37d9de527 feat(reasoning): gate reasoning replay on the model's own template switch
CI / Config consistency (push) Successful in 12s
CI / Classify changes (push) Successful in 15s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 9s
CI / Clippy (push) Successful in 2m32s
CI / CUDA type-check (push) Successful in 20m18s
CI / Test (push) Successful in 9m8s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
Makes H7's premise testable without trusting it.

H7 (`fix/replay-only-the-latest-reasoning`, unmerged) would replay only
the newest turn's reasoning. The premise is plausible — full replay is
what makes prompt growth track total completion rather than visible
output, pushing against the prefix-cache budget (#292) and the
throughput-derived context ceiling — but it was never measured, and
"withhold information from the model" is not a change to make on a
hunch.

It also turns out not to need new mechanism. `Qwen/Qwen3.8-27B`'s
template already has the switch:

    {%- if preserve_thinking is undefined or preserve_thinking is true
           or loop.index0 > ns.last_query_index %}
        ... '<think>\n' + reasoning_content + '\n</think>\n\n' + content
    {%- else %}
        ... content

`preserve_thinking = false` keeps reasoning only for turns after the
last user query — the turn in progress — which is exactly what H7 does,
expressed by the people who trained the model. And the default is
`true`: full replay is the authors' choice, not our accident.

So this exposes `preserve_thinking` as operator config rather than
changing any default. Precedence is request > operator > template
default; unset changes nothing. Flipping it is a config-only deploy
(~6 min, no binary rotation) now that the CI classifier treats
`asset/neuron/*.toml` as inert, which makes an A/B cheap enough to run
properly instead of once.

Carried on `ModelSpec` and `ModelProfile` both, so a cold-loaded model
behaves like a resident one — two config files describing one model is
how #252 happened. `check-config-consistency.py` now gates on it: a
model that replays when resident and not when cold-loaded would make
the measurement unreadable.

`main` still ships Qwen's default. Nothing changes until an operator
sets it, which is the point.
2026-08-26 14:19:31 +03:00
grenade d3341cb45c Merge branch 'fix/client-capability-misattributions'
build-prerelease / Resolve version stamps + change detection (push) Successful in 13s
build-prerelease / Build helexa-angels binary (push) Has been skipped
build-prerelease / Package helexa-angels RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Successful in 1m49s
build-prerelease / Package helexa-bench RPM (push) Successful in 23s
build-prerelease / Build cortex binary (push) Successful in 2m33s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m48s
build-prerelease / Build neuron-ampere (push) Successful in 3m0s
build-prerelease / Build neuron-ada (push) Successful in 3m0s
build-prerelease / Build helexa-upstream binary (push) Successful in 3m2s
build-prerelease / Test (push) Successful in 7m56s
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Build helexa-router binary (push) Successful in 1m20s
build-prerelease / Build neuron-blackwell (push) Successful in 15m46s
build-prerelease / Package helexa-router RPM (push) Successful in 20s
build-prerelease / Package cortex RPM (push) Successful in 26s
build-prerelease / Package helexa-upstream RPM (push) Successful in 25s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m5s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m8s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m56s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 32s
2026-08-26 14:10:56 +03:00
20 changed files with 1345 additions and 44 deletions
+34
View File
@@ -48,6 +48,40 @@ name = "candle"
# refused.
max_in_flight = 2
# Snapshot budget for the cross-request prefix cache (#292).
#
# 3 GiB, not the 1 GiB default. At the measured 32 KiB/token/card, a
# 1 GiB budget cannot hold a snapshot beyond ~32k tokens of K/V alone —
# less once the GatedDeltaNet conv/recurrent state is deep-copied on top,
# which on this arch is 48 of 64 layers in f32. Agentic sessions here run
# well past that: `cached_tokens` was observed pinned at 2,016 for ten
# consecutive turns while the prompt grew to 52,869, so every turn
# re-prefilled ~50k tokens — 37 s each, against ~20 s of actual decode.
# The cache was not missing; the snapshot would not fit.
#
# The snapshot budget is reserved BEFORE the KV budget, not from what is
# left over: measured on this host, kv_budget_mb = 5737 - budget_mb,
# exactly 1:1. So this number is a direct trade against how long a
# single session can get, and both sides have a floor:
#
# budget_mb kv_budget tokens/slot (2 slots) holds a 52.9k snapshot
# 1024 4713 75,400 no ← the #292 symptom
# 3072 2665 42,600 yes, but sessions
# cannot reach 52.9k
# 2048 3689 59,000 yes
#
# 2 GiB is the value that satisfies both: a 52,869-token snapshot is
# ~1,652 MB of K/V plus the GatedDeltaNet state (~1.73 GB total), and
# per-slot KV still covers sessions comfortably past the deepest
# observed. 3 GiB was tried first and starved KV — a session could no
# longer reach the size whose snapshot the cache was being enlarged to
# hold, which is self-defeating.
#
# max_entries stays at the default 8: the byte budget binds first here,
# so the count cap is not what is forcing snapshots to be small.
[harness.candle.prefix_cache]
budget_mb = 2048
[[default_models]]
# Qwen3.8-27B is the resident flagship (#251). Architecturally identical
# to the 3.6-27B it replaced — same model_type, layer counts and vision
+8
View File
@@ -67,6 +67,13 @@ pub struct ModelProfile {
/// model, with nothing forcing them to agree.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sampling: Option<SamplingOverride>,
/// The model's own `preserve_thinking` template control, forwarded
/// on cold load so a model this gateway loads behaves the same as
/// one the host was configured to hold resident. Two config files
/// describing one model is how #252 happened; this rides along for
/// the same reason `sampling` does.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preserve_thinking: Option<bool>,
// ── Enrichment (issue #62) ────────────────────────────────
/// Per-model token budget. When present, advertised in `/v1/models`
@@ -251,6 +258,7 @@ mod tests {
residency_priority: None,
source: None,
sampling: None,
preserve_thinking: None,
limit: None,
cost: None,
capabilities: vec![],
+97 -1
View File
@@ -55,13 +55,78 @@ pub struct SamplingOverride {
pub top_p: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub top_k: Option<usize>,
/// Multiplier applied to recently-generated tokens before sampling.
/// `1.0` disables it; above that, recently-emitted tokens get less
/// likely.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repeat_penalty: Option<f32>,
/// How many recently-generated tokens the penalty considers.
///
/// The reason this is operator-settable at all: the built-in default
/// is 64, inherited from candle's chat example, and a reasoning
/// model's think block runs to tens of thousands of tokens. Anything
/// restated more than 64 tokens later is invisible to the penalty,
/// which is how a 28,672-token think block can contain the same
/// derivation five times over.
///
/// Raising it is a real trade, not a free win: the penalty is
/// token-level, and source code legitimately repeats tokens
/// constantly (`const`, `function`, brace runs). A wide window at a
/// meaningful penalty distorts exactly the output this fleet is for.
/// Sequence-level repetition wants a sequence-level instrument
/// (frequency/presence penalties, or a DRY-style sampler); this knob
/// exists so the trade can be *measured* per model rather than
/// guessed once and frozen into a constant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repeat_last_n: Option<usize>,
/// Sequence-wide one-time penalty for already-seen tokens.
///
/// This, not `repeat_last_n`, is the remedy Qwen prescribe for the
/// endless repetition their models fall into during long reasoning
/// (0..2, 1.5 when severe). It is scored over everything generated
/// so far, so a passage restated 5,000 tokens later is still
/// penalised — the case a trailing window cannot see.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub presence_penalty: Option<f32>,
/// Sequence-wide penalty proportional to a token's existing count.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frequency_penalty: Option<f32>,
/// Pin the sampler's RNG for every request to this model.
///
/// Exists because parameter search needs a control: comparing two
/// `presence_penalty` values across unseeded runs cannot separate
/// the parameter's effect from sampling variance, and the clients
/// that would otherwise pin it cannot — pi's request builder sends
/// no `seed` at all, so a caller-side seed is not available on the
/// path that matters.
///
/// Note what this does and does not give. It removes sampling noise
/// as a confounder; it does not make two runs comparable
/// token-by-token, because changing a penalty changes the logits
/// and the sequences diverge at the first step where a token choice
/// flips.
///
/// Leaving this set is a product decision, not just a testing one:
/// every identical prompt then yields an identical answer for every
/// caller. That is defensible — predictable and cacheable — but it
/// removes the variation a user may expect from retrying. A request
/// that names its own seed still wins.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seed: Option<u64>,
}
impl SamplingOverride {
/// True when the operator set nothing — an empty `[sampling]` table
/// must behave exactly as no table at all.
pub fn is_empty(&self) -> bool {
self.temperature.is_none() && self.top_p.is_none() && self.top_k.is_none()
self.temperature.is_none()
&& self.top_p.is_none()
&& self.top_k.is_none()
&& self.repeat_penalty.is_none()
&& self.repeat_last_n.is_none()
&& self.presence_penalty.is_none()
&& self.frequency_penalty.is_none()
&& self.seed.is_none()
}
}
@@ -86,6 +151,27 @@ pub struct ModelSpec {
/// still round-trips the spec.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sampling: Option<SamplingOverride>,
/// Whether prior assistant turns keep their reasoning when the chat
/// template re-renders the conversation.
///
/// This is the model's own `preserve_thinking` template control, not
/// an invention of ours. `Qwen/Qwen3.8-27B` defaults it to *true* —
/// the whole transcript's think blocks are replayed — and `false`
/// keeps reasoning only for turns after the last user query, i.e.
/// the turn in progress.
///
/// `None` leaves the kwarg unset so the template's own default
/// applies, which is the only safe default: the model's authors
/// chose it, and overriding that fleet-wide on a hunch is how you
/// ship a regression you cannot see.
///
/// Exposed as operator config so the premise can be A/B'd on a real
/// workload — full replay is the larger prompt and pushes against
/// the prefix-cache budget and the throughput-derived context
/// ceiling, but whether it *helps* the model is a measurement, not
/// a belief. A request's own `chat_template_kwargs` still wins.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preserve_thinking: Option<bool>,
}
/// Per-model token budget advertised by the catalogue or neuron.
@@ -247,6 +333,16 @@ pub struct ModelInfo {
/// marker tokens (`<think>` / `<\/think>` or similar).
#[serde(default)]
pub reasoning: bool,
/// The operator's `preserve_thinking` for this loaded model, or
/// `None` when unset and the template's own default applies.
///
/// Advertised so a run can be *stamped* with the value it ran
/// under. This is an A/B knob whose whole purpose is comparison
/// between runs, and a comparison whose arms cannot be told apart
/// afterwards is not a comparison. Recovering it from deploy
/// history is guesswork — a config sync can land mid-session.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preserve_thinking: Option<bool>,
/// Whether this node can actually serve a request for this model
/// right now (#245).
+14
View File
@@ -43,6 +43,20 @@ pub struct ChatCompletionRequest {
/// why it is a knob rather than a constant (#272).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repeat_last_n: Option<usize>,
/// OpenAI core, `-2.0..=2.0`. Subtracted once from any token that
/// has already appeared, regardless of how often.
///
/// Unlike `repetition_penalty` this is scored over the **whole**
/// generated sequence, not a trailing window — which is what a
/// reasoning model needs, and what Qwen recommend (0..2, 1.5 for
/// severe cases) for the endless-repetition failure their own
/// models exhibit during long thinking.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub presence_penalty: Option<f32>,
/// OpenAI core, `-2.0..=2.0`. Subtracted in proportion to how many
/// times a token has already appeared.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frequency_penalty: Option<f32>,
/// The deprecated spelling of the output cap. Still what most
/// clients send; see [`ChatCompletionRequest::effective_max_tokens`].
#[serde(skip_serializing_if = "Option::is_none")]
+32
View File
@@ -70,6 +70,11 @@ pub struct ResponsesRequest {
/// Window `repetition_penalty` considers (#272).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repeat_last_n: Option<usize>,
/// OpenAI core; forwarded to the chat path unchanged.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub presence_penalty: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frequency_penalty: Option<f32>,
/// Chained-conversation identifier. We don't store responses
/// server-side yet; if this is `Some`, the handler returns 400.
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -271,6 +276,15 @@ pub enum ResponsesOutputItem {
#[serde(default = "default_item_status")]
status: String,
},
/// A think block, as its own output item ahead of the message —
/// the same shape the streaming projector emits, so a client sees
/// one contract whether or not it streams (#300).
Reasoning {
id: String,
summary: Vec<ResponsesSummaryPart>,
#[serde(default = "default_item_status")]
status: String,
},
/// Reserved for the day tool-call extraction lands. The wire
/// shape mirrors `ResponsesInputItem::FunctionCall`.
FunctionCall {
@@ -283,6 +297,24 @@ pub enum ResponsesOutputItem {
},
}
/// One `summary_text` part of a reasoning output item.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResponsesSummaryPart {
/// Always `"summary_text"`.
#[serde(rename = "type")]
pub part_type: String,
pub text: String,
}
impl ResponsesSummaryPart {
pub fn summary_text(text: impl Into<String>) -> Self {
Self {
part_type: "summary_text".into(),
text: text.into(),
}
}
}
fn default_item_status() -> String {
"completed".into()
}
+103 -7
View File
@@ -130,6 +130,8 @@ pub fn anthropic_to_openai(req: MessagesRequest) -> ChatCompletionRequest {
seed: None,
repetition_penalty: None,
repeat_last_n: None,
presence_penalty: None,
frequency_penalty: None,
max_tokens: Some(req.max_tokens),
// Anthropic has one spelling; it maps onto the legacy field.
max_completion_tokens: None,
@@ -413,17 +415,26 @@ fn anthropic_content_to_text(content: AnthropicContent) -> String {
pub fn openai_to_anthropic(resp: ChatCompletionResponse) -> MessagesResponse {
let choice = resp.choices.into_iter().next();
let (content_text, stop_reason) = match choice {
let (content_text, reasoning_text, stop_reason) = match choice {
Some(c) => {
let text = match c.message.content {
MessageContent::Text(t) => t,
MessageContent::Parts(parts) => serde_json::to_string(&parts).unwrap_or_default(),
MessageContent::Null => String::new(),
};
// The model's think block, which the harness returns on
// `message.reasoning_content` (#300).
let reasoning = c
.message
.extra
.get("reasoning_content")
.and_then(|v| v.as_str())
.map(str::to_string)
.filter(|t| !t.is_empty());
let stop = c.finish_reason.map(|r| map_stop_reason(&r));
(text, stop)
(text, reasoning, stop)
}
None => (String::new(), None),
None => (String::new(), None, None),
};
let usage = resp.usage.unwrap_or(Usage {
@@ -439,10 +450,32 @@ pub fn openai_to_anthropic(resp: ChatCompletionResponse) -> MessagesResponse {
id: resp.id,
response_type: "message".into(),
role: "assistant".into(),
content: vec![ContentBlock {
block_type: "text".into(),
data: json!({ "text": content_text }),
}],
// A `thinking` block ahead of the text, which is Anthropic's
// own ordering. This path accepted `thinking.budget_tokens` on
// the way in (see `anthropic_to_openai`) and returned nothing on
// the way out: the caller spent the tokens, could not see them,
// and could not replay the model's reasoning next turn (#300).
//
// Emitted without a `signature`. Anthropic signs its thinking
// blocks and a client that round-trips one may expect the field;
// we cannot mint a valid signature and inventing one would be
// worse than omitting it. If a real client rejects an unsigned
// block, the fix is to gate this behind a flag — not to go back
// to silently deleting the model's work.
content: {
let mut blocks = Vec::with_capacity(2);
if let Some(thinking) = reasoning_text {
blocks.push(ContentBlock {
block_type: "thinking".into(),
data: json!({ "thinking": thinking }),
});
}
blocks.push(ContentBlock {
block_type: "text".into(),
data: json!({ "text": content_text }),
});
blocks
},
model: resp.model,
stop_reason,
usage: AnthropicUsage {
@@ -1061,3 +1094,66 @@ mod request_tests {
assert_eq!(tools[0]["function"]["name"], "x");
}
}
/// The Anthropic path must return the model's thinking (#300).
///
/// It accepted `thinking.budget_tokens` on the way in and returned
/// a bare text block on the way out: the caller spent the tokens,
/// never saw them, and could not replay the model's reasoning on
/// the next turn. Taking a parameter and silently discarding what
/// it buys is the defect, not the missing field.
#[test]
fn anthropic_response_carries_a_thinking_block() {
let mut extra = serde_json::Map::new();
extra.insert("reasoning_content".into(), json!("weighing the options"));
let resp = ChatCompletionResponse {
id: "id".into(),
object: "chat.completion".into(),
created: 0,
model: "m".into(),
choices: vec![crate::openai::ChatCompletionChoice {
index: 0,
message: ChatMessage {
role: "assistant".into(),
content: MessageContent::Text("the answer".into()),
extra: Value::Object(extra),
},
finish_reason: Some("stop".into()),
extra: Value::Null,
}],
usage: None,
extra: Value::Null,
};
let a = openai_to_anthropic(resp);
assert_eq!(a.content.len(), 2, "expected thinking + text");
assert_eq!(a.content[0].block_type, "thinking", "thinking comes first");
assert_eq!(a.content[0].data["thinking"], "weighing the options");
assert_eq!(a.content[1].block_type, "text");
assert_eq!(a.content[1].data["text"], "the answer");
}
/// No thinking means the unchanged single text block.
#[test]
fn anthropic_response_without_thinking_is_unchanged() {
let resp = ChatCompletionResponse {
id: "id".into(),
object: "chat.completion".into(),
created: 0,
model: "m".into(),
choices: vec![crate::openai::ChatCompletionChoice {
index: 0,
message: ChatMessage {
role: "assistant".into(),
content: MessageContent::Text("plain".into()),
extra: Value::Object(Default::default()),
},
finish_reason: Some("stop".into()),
extra: Value::Null,
}],
usage: None,
extra: Value::Null,
};
let a = openai_to_anthropic(resp);
assert_eq!(a.content.len(), 1);
assert_eq!(a.content[0].block_type, "text");
}
+2
View File
@@ -539,6 +539,7 @@ async fn profile_to_spec(
// (#283), so a cold-loaded model samples the same way as one
// this host was configured to hold resident.
sampling: profile.sampling.clone(),
preserve_thinking: profile.preserve_thinking,
}
}
@@ -647,6 +648,7 @@ mod tests {
residency_priority: None,
source: source.map(String::from),
sampling: None,
preserve_thinking: None,
limit: None,
cost: None,
capabilities: vec![],
+2
View File
@@ -217,6 +217,7 @@ impl TargetClient {
// entry supplies the override; it would matter if this were
// ever used to load a model the host has no config for.
sampling: None,
preserve_thinking: None,
})
}
@@ -281,6 +282,7 @@ impl TargetClient {
cost: None,
tool_call: false,
reasoning: false,
preserve_thinking: None,
// A generic OpenAI endpoint says nothing about
// serviceability, and absent means "no opinion".
servable: None,
+15 -5
View File
@@ -486,9 +486,9 @@ async fn responses(
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
total_tokens: u.prompt_tokens + u.completion_tokens,
// Carry the reasoning sub-count through from the chat
// usage — the non-streaming path now splits off the
// `<think>` span and counts it (see `split_off_reasoning`).
// Carry the reasoning sub-count through from the
// chat usage: the collector counts the `<think>`
// span and returns its text separately.
output_tokens_details: u.completion_tokens_details.as_ref().map(|d| {
OutputTokensDetails {
reasoning_tokens: d.reasoning_tokens,
@@ -502,8 +502,18 @@ async fn responses(
model_id,
message_item_id: mint_message_item_id(),
};
let _ = chat_resp; // make the borrow-checker happy if `text` consumed it
let resp = openai_responses::build_response(&meta, text, finish, usage);
// The model's think block, which the chat collector now
// returns on `message.reasoning_content` (#300). Without
// this the caller is billed for `reasoning_tokens` it
// never sees, and cannot replay the model's own
// reasoning on the next turn.
let reasoning = chat_resp
.choices
.first()
.and_then(|c| c.message.extra.get("reasoning_content"))
.and_then(|v| v.as_str())
.map(str::to_string);
let resp = openai_responses::build_response(&meta, text, finish, usage, reasoning);
Json(resp).into_response()
}
Err(e) => inference_error_response(e),
+260 -5
View File
@@ -429,6 +429,17 @@ impl LoadedHandle {
}
}
/// The operator's `preserve_thinking` for this model, advertised so
/// an A/B run can be stamped with the value it ran under.
pub fn preserve_thinking(&self) -> Option<bool> {
match self {
LoadedHandle::Single(m) => m.preserve_thinking,
#[cfg(feature = "cuda")]
LoadedHandle::Tp(m) => m.preserve_thinking,
LoadedHandle::Image(_) => None,
}
}
/// Effort rungs the model's template accepts (#290).
pub fn reasoning_efforts(&self) -> super::reasoning_effort::SupportedEfforts {
match self {
@@ -653,6 +664,8 @@ fn requested_sampling(request: &ChatCompletionRequest) -> super::sampling::Reque
seed: request.seed,
repeat_penalty: request.repetition_penalty,
repeat_last_n: request.repeat_last_n,
presence_penalty: request.presence_penalty,
frequency_penalty: request.frequency_penalty,
}
}
@@ -726,6 +739,9 @@ pub struct LoadedModel {
/// no effort control. Drives both what `/models` advertises and
/// which value we forward on a request.
pub reasoning_efforts: super::reasoning_effort::SupportedEfforts,
/// Operator's `preserve_thinking` for this model, or `None` to let
/// the template decide (#H7 A/B). See `ModelSpec::preserve_thinking`.
pub preserve_thinking: Option<bool>,
/// Open/close token IDs for the model's tool-call marker
/// pair (`<tool_call>` / `</tool_call>` on Qwen3-Coder / Hermes
/// / DeepSeek / gpt-oss). `None` for models that don't emit
@@ -884,6 +900,9 @@ pub struct TpLoadedModel {
/// no effort control. Drives both what `/models` advertises and
/// which value we forward on a request.
pub reasoning_efforts: super::reasoning_effort::SupportedEfforts,
/// Operator's `preserve_thinking` for this model, or `None` to let
/// the template decide (#H7 A/B). See `ModelSpec::preserve_thinking`.
pub preserve_thinking: Option<bool>,
/// Same shape as [`LoadedModel::tool_call_tokens`].
pub tool_call_tokens: Option<ToolCallTokenPair>,
/// Same shape as [`LoadedModel::chat_template`].
@@ -2012,6 +2031,45 @@ async fn acquire_pool_lock<'a>(
}
}
/// Subtract OpenAI-semantics presence/frequency penalties from `logits`.
///
/// `logit -= frequency * count + presence * (count > 0)`, scored over
/// the whole generated sequence. Both default to `0.0`, which is
/// exactly a no-op — an operator or caller who sets neither sees the
/// previous behaviour, and the tensor is returned untouched rather than
/// round-tripped through the CPU.
///
/// Deliberately additive on logits, not multiplicative like
/// `repeat_penalty`: that is what the OpenAI parameters mean, and a
/// client that sends `presence_penalty: 1.5` expecting OpenAI behaviour
/// must get it.
fn apply_sequence_penalties(
logits: &Tensor,
history: &[u32],
presence: f32,
frequency: f32,
) -> Result<Tensor> {
if (presence == 0.0 && frequency == 0.0) || history.is_empty() {
return Ok(logits.clone());
}
let device = logits.device().clone();
let mut values = logits.to_dtype(candle_core::DType::F32)?.to_vec1::<f32>()?;
let mut counts: std::collections::HashMap<u32, f32> = std::collections::HashMap::new();
for t in history {
*counts.entry(*t).or_insert(0.0) += 1.0;
}
for (token, count) in counts {
let Some(slot) = values.get_mut(token as usize) else {
// A history token outside the vocabulary would be a bug
// elsewhere; skip it rather than panicking mid-generation.
continue;
};
*slot -= frequency * count + presence;
}
let len = values.len();
Ok(Tensor::from_vec(values, len, &device)?)
}
/// Apply the repetition penalty (if any) to the prediction logits and
/// then sample. Centralises the prefill / generation-loop call sites
/// so they share identical sampling behaviour.
@@ -2040,6 +2098,19 @@ pub(crate) fn sample_with_penalty(
&history[start..],
)?
};
// Sequence-wide penalties, over everything generated so far rather
// than a trailing window. This is what a reasoning model needs: at
// `repeat_last_n = 64` inside a 28,672-token think block, a passage
// restated 5,000 tokens later is invisible to the windowed penalty,
// and Qwen's own guidance for the endless-repetition failure their
// models show during long thinking is presence_penalty (0..2, 1.5
// when severe) — not a wider window.
let penalised = apply_sequence_penalties(
&penalised,
history,
sampling.presence_penalty,
sampling.frequency_penalty,
)?;
let sampled = logits_processor.sample(&penalised)?;
// The reasoning budget governs which token is *emitted*, not which
// is sampled: over budget inside a think block, the model gets its
@@ -2944,6 +3015,7 @@ impl CandleHarness {
loaded.chat_template.as_deref(),
&request,
&loaded.reasoning_efforts,
loaded.preserve_thinking,
)?;
let encoding = loaded
.tokenizer
@@ -3517,6 +3589,7 @@ impl Harness for CandleHarness {
cost: None,
tool_call: h.has_tool_call(),
reasoning: h.has_reasoning(),
preserve_thinking: h.preserve_thinking(),
servable: h.servability(&self.context_limit_cfg),
// The rungs this model's own template accepts (#290),
// discovered at load rather than invented. Advertising a
@@ -3556,6 +3629,7 @@ impl Harness for CandleHarness {
capabilities: snap.capabilities.clone(),
limit: None,
cost: None,
preserve_thinking: None,
tool_call: false, // snapshot doesn't carry these; safe default
reasoning: false,
// Mid-recovery: the handle is gone, so there is
@@ -3846,10 +3920,19 @@ impl Harness for CandleHarness {
.transpose()
.unwrap_or_default()
.unwrap_or_default();
// Stamp the A/B arm in the journal at load, so a session
// reviewed later can be attributed to the value it ran under
// without reconstructing deploy history.
tracing::info!(
model = %spec.model_id,
preserve_thinking = ?spec.preserve_thinking,
"reasoning replay: preserve_thinking for this load (None = the template's own default)"
);
let loaded = Arc::new(LoadedModel {
model_id: spec.model_id.clone(),
generation_defaults,
reasoning_efforts,
preserve_thinking: spec.preserve_thinking,
arch: arch_local,
tokenizer,
device,
@@ -4499,10 +4582,19 @@ impl CandleHarness {
.transpose()
.unwrap_or_default()
.unwrap_or_default();
// Stamp the A/B arm in the journal at load, so a session
// reviewed later can be attributed to the value it ran under
// without reconstructing deploy history.
tracing::info!(
model = %spec.model_id,
preserve_thinking = ?spec.preserve_thinking,
"reasoning replay: preserve_thinking for this load (None = the template's own default)"
);
let tp_loaded = StdArc::new(TpLoadedModel {
model_id: spec.model_id.clone(),
generation_defaults,
reasoning_efforts,
preserve_thinking: spec.preserve_thinking,
tokenizer,
devices: devices.clone(),
pool: StdArc::new(TMutex::new(pool)),
@@ -4639,8 +4731,12 @@ impl CandleHarness {
});
}
let prompt =
build_prompt_for_request(tp.chat_template.as_deref(), &request, &tp.reasoning_efforts)?;
let prompt = build_prompt_for_request(
tp.chat_template.as_deref(),
&request,
&tp.reasoning_efforts,
tp.preserve_thinking,
)?;
let encoding = tp
.tokenizer
.encode(prompt.as_str(), true)
@@ -5904,10 +6000,44 @@ fn apply_effort_kwarg(
serde_json::Value::Object(obj)
}
/// Apply the operator's `preserve_thinking`, if they set one.
///
/// Precedence is request > operator > the model's own template default.
/// A caller that sends `chat_template_kwargs.preserve_thinking` has
/// said something specific and keeps it; an operator who sets nothing
/// changes nothing, so the template's `|default` still governs.
///
/// Deliberately *not* defaulted by us. On `Qwen/Qwen3.8-27B` the
/// template preserves every turn's reasoning unless told otherwise —
/// its authors' choice — and `false` narrows replay to the turn in
/// progress. Both are defensible and the difference is measurable, so
/// this exists to run that measurement rather than to encode a guess.
fn apply_preserve_thinking_kwarg(
kwargs: serde_json::Value,
configured: Option<bool>,
) -> serde_json::Value {
let Some(configured) = configured else {
return kwargs;
};
if kwargs.get("preserve_thinking").is_some() {
return kwargs;
}
let mut obj = match kwargs {
serde_json::Value::Object(o) => o,
_ => serde_json::Map::new(),
};
obj.insert(
"preserve_thinking".into(),
serde_json::Value::Bool(configured),
);
serde_json::Value::Object(obj)
}
fn build_prompt_for_request(
chat_template: Option<&str>,
request: &ChatCompletionRequest,
efforts: &super::reasoning_effort::SupportedEfforts,
preserve_thinking: Option<bool>,
) -> Result<String, InferenceError> {
if !super::chat_template::chat_templates_enabled() {
return Ok(format_qwen3_prompt(&request.messages));
@@ -5932,6 +6062,9 @@ fn build_prompt_for_request(
// request was instructed to think maximally and then truncated at
// whatever budget the caller had actually asked for.
let kwargs = apply_effort_kwarg(kwargs, request, efforts);
// Whether prior turns keep their think blocks. Unset unless the
// operator chose, so the template's own default stands.
let kwargs = apply_preserve_thinking_kwarg(kwargs, preserve_thinking);
let tools = request
.extra
.get("tools")
@@ -7321,6 +7454,8 @@ mod tests {
seed: None,
repetition_penalty: None,
repeat_last_n: None,
presence_penalty: None,
frequency_penalty: None,
max_tokens: None,
max_completion_tokens: None,
stream: None,
@@ -7384,6 +7519,49 @@ mod tests {
assert_eq!(effort_in(&out), None);
}
/// Unset by default: the model's authors chose the template's
/// default and we do not override it fleet-wide on a hunch.
#[test]
fn no_operator_setting_leaves_preserve_thinking_alone() {
let out = apply_preserve_thinking_kwarg(serde_json::Value::Null, None);
assert!(out.get("preserve_thinking").is_none());
assert!(out.is_null(), "an untouched Null must stay Null");
}
/// The A/B knob: an operator can narrow replay to the turn in
/// progress, or pin full replay, without a rebuild.
#[test]
fn an_operator_setting_reaches_the_template() {
for configured in [true, false] {
let out = apply_preserve_thinking_kwarg(serde_json::Value::Null, Some(configured));
assert_eq!(out["preserve_thinking"], serde_json::json!(configured));
}
}
/// A caller that named it has said something specific; the
/// operator's default must not overwrite it.
#[test]
fn a_requested_preserve_thinking_outranks_the_operator() {
let out = apply_preserve_thinking_kwarg(
serde_json::json!({ "preserve_thinking": true }),
Some(false),
);
assert_eq!(out["preserve_thinking"], serde_json::json!(true));
}
/// It must not clobber the effort kwarg sharing the same object —
/// both are applied to one `chat_template_kwargs`.
#[test]
fn it_preserves_kwargs_already_present() {
let out = apply_preserve_thinking_kwarg(
serde_json::json!({ "reasoning_effort": "medium", "custom": 1 }),
Some(false),
);
assert_eq!(out["reasoning_effort"], "medium");
assert_eq!(out["custom"], 1);
assert_eq!(out["preserve_thinking"], serde_json::json!(false));
}
/// A model whose template has no effort control must be left alone.
#[test]
fn a_model_without_rungs_gets_no_effort_kwarg() {
@@ -7507,9 +7685,84 @@ mod tests {
seed: 0,
repeat_penalty,
repeat_last_n,
presence_penalty: 0.0,
frequency_penalty: 0.0,
}
}
/// Presence is a *one-time* subtraction for any token already seen,
/// however often. Frequency scales with the count. These are the
/// OpenAI semantics, and a client sending `presence_penalty: 1.5`
/// expecting them must get them.
#[test]
fn sequence_penalties_follow_openai_semantics() {
use candle_core::{Device, Tensor};
let logits = Tensor::from_vec(vec![0.0f32; 6], 6, &Device::Cpu).unwrap();
// token 1 appears three times, token 2 once.
let history = [1u32, 1, 1, 2];
let p = apply_sequence_penalties(&logits, &history, 1.0, 0.0)
.unwrap()
.to_vec1::<f32>()
.unwrap();
assert_eq!(p[1], -1.0, "presence subtracts once regardless of count");
assert_eq!(p[2], -1.0);
assert_eq!(p[0], 0.0, "unseen tokens untouched");
let f = apply_sequence_penalties(&logits, &history, 0.0, 1.0)
.unwrap()
.to_vec1::<f32>()
.unwrap();
assert_eq!(f[1], -3.0, "frequency scales with the count");
assert_eq!(f[2], -1.0);
assert_eq!(f[0], 0.0);
// Both together compose additively.
let b = apply_sequence_penalties(&logits, &history, 0.5, 1.0)
.unwrap()
.to_vec1::<f32>()
.unwrap();
assert_eq!(b[1], -3.5);
}
/// The whole point: a passage restated far outside `repeat_last_n`
/// is invisible to the windowed penalty but not to this one. At the
/// built-in 64-token window inside a 28,672-token think block, that
/// is every repetition worth catching (#299).
#[test]
fn a_repeat_far_outside_the_window_is_still_penalised() {
use candle_core::{Device, Tensor};
let logits = Tensor::from_vec(vec![0.0f32; 4], 4, &Device::Cpu).unwrap();
// token 3 was emitted 5,000 tokens ago and never since.
let mut history = vec![3u32];
history.extend(std::iter::repeat_n(0u32, 5_000));
let windowed = &history[history.len().saturating_sub(64)..];
assert!(
!windowed.contains(&3),
"fixture: the token must be outside the 64-token window"
);
let out = apply_sequence_penalties(&logits, &history, 1.5, 0.0)
.unwrap()
.to_vec1::<f32>()
.unwrap();
assert_eq!(out[3], -1.5, "sequence-wide penalty still sees it");
}
/// Zero on both is exactly a no-op — an operator who sets neither
/// must get byte-identical behaviour to before this existed.
#[test]
fn zero_sequence_penalties_change_nothing() {
use candle_core::{Device, Tensor};
let logits = Tensor::from_vec(vec![1.5f32, -2.0, 0.25], 3, &Device::Cpu).unwrap();
let out = apply_sequence_penalties(&logits, &[0u32, 1, 1], 0.0, 0.0)
.unwrap()
.to_vec1::<f32>()
.unwrap();
assert_eq!(out, vec![1.5, -2.0, 0.25]);
}
/// Two inlets converge on one field: an explicit token count, or
/// Anthropic's normalised `budget_tokens`, which translation folds
/// into the same shape.
@@ -7533,6 +7786,8 @@ mod tests {
seed: None,
repetition_penalty: None,
repeat_last_n: None,
presence_penalty: None,
frequency_penalty: None,
max_tokens: None,
max_completion_tokens: None,
stream: None,
@@ -8542,8 +8797,8 @@ mod tests {
"tools": [{"type": "function", "function": {"name": "x"}}]
}))
.unwrap();
let err =
build_prompt_for_request(Some(bad), &with_tools, &Default::default()).unwrap_err();
let err = build_prompt_for_request(Some(bad), &with_tools, &Default::default(), None)
.unwrap_err();
assert!(matches!(err, InferenceError::TemplateRenderFailed { .. }));
// No tools → falling back is harmless, so it stays Ok.
@@ -8552,6 +8807,6 @@ mod tests {
"messages": [{"role": "user", "content": "hi"}]
}))
.unwrap();
assert!(build_prompt_for_request(Some(bad), &no_tools, &Default::default()).is_ok());
assert!(build_prompt_for_request(Some(bad), &no_tools, &Default::default(), None).is_ok());
}
}
+133
View File
@@ -438,6 +438,139 @@ mod tests {
assert!(out.contains("<|vision_start|>") && out.contains("<|vision_end|>"));
}
/// A file read back through a tool result must reach the model
/// byte-for-byte.
///
/// The reported symptom: the model writes a file correctly, reads it
/// back, and sees errors that are not in the file — consistently,
/// after every write. Anything that mangles `<`, `&`, `"` or
/// newlines on this path would produce exactly that, and would make
/// the model burn its budget reconciling a file against a corrupted
/// view of itself.
#[test]
fn a_file_read_back_through_a_tool_result_is_byte_exact() {
let template = include_str!("../../tests/data/qwen3.8-27b.chat_template.jinja");
// The characters most likely to be mangled, in the shapes a real
// web project contains them.
let file = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n</head>\n<body>\n <script>\n if (a && b || c < d) { g.fillStyle = '#b8905a'; }\n const s = `rgba(${r},${g},0.5)`;\n\n // a comment & an ampersand\n </script>\n</body>\n</html>\n";
let mut tool_extras = serde_json::Map::new();
tool_extras.insert("tool_call_id".into(), json!("call_0"));
let mut assistant_extras = serde_json::Map::new();
assistant_extras.insert(
"tool_calls".into(),
json!([{"id":"call_0","type":"function",
"function":{"name":"read","arguments":"{\"path\":\"/tmp/index.html\"}"}}]),
);
let messages = vec![
user_msg("read the file back"),
ChatMessage {
role: "assistant".into(),
content: MessageContent::Null,
extra: Value::Object(assistant_extras),
},
ChatMessage {
role: "tool".into(),
content: MessageContent::Text(file.to_string()),
extra: Value::Object(tool_extras),
},
user_msg("is it correct?"),
];
let out =
render_chat_template(template, &messages, &Value::Null, &Value::Null).expect("render");
for probe in [
"<!DOCTYPE html>",
"<html lang=\"en\">",
"if (a && b || c < d)",
"g.fillStyle = '#b8905a';",
"`rgba(${r},${g},0.5)`",
"// a comment & an ampersand",
] {
assert!(
out.contains(probe),
"the model would not see {probe:?} verbatim — rendered prompt mangles it"
);
}
// And nothing HTML-escaped anywhere.
for bad in ["&lt;", "&gt;", "&amp;", "&quot;", "&#x27;", "&#39;"] {
assert!(
!out.contains(bad),
"found {bad:?} in the rendered prompt: content is being HTML-escaped"
);
}
}
/// Blank lines inside a replayed tool-call argument must survive
/// rendering intact.
///
/// A model that writes a source file and then re-reads it from its
/// own replayed tool call is comparing what it remembers writing
/// against what the prompt now shows. Any whitespace the pipeline
/// eats makes those disagree, and the model has no way to tell a
/// rendering artifact from a mistake of its own — which is a
/// plausible route into the announce-then-stop stall (#296).
///
/// The JSON string→object normalisation above is the step that
/// could plausibly lose them, since it round-trips the arguments
/// through a parser.
#[test]
fn a_replayed_tool_call_keeps_the_blank_lines_in_its_arguments() {
let template = include_str!("../../tests/data/qwen3.8-27b.chat_template.jinja");
// Blank lines between sections, exactly as a written source file
// carries them.
let file_body = "'use strict';\n\n// ---- constants ----\nconst A = 1;\n\n\nfunction f() {\n return A;\n}\n";
let args = json!({ "path": "/tmp/game.js", "content": file_body });
let mut assistant_extras = serde_json::Map::new();
assistant_extras.insert(
"tool_calls".into(),
json!([{
"id": "call_0", "type": "function",
"function": {
"name": "write",
// The OpenAI wire form: arguments as a JSON *string*.
"arguments": serde_json::to_string(&args).unwrap()
}
}]),
);
let mut tool_extras = serde_json::Map::new();
tool_extras.insert("tool_call_id".into(), json!("call_0"));
let messages = vec![
user_msg("write the file"),
ChatMessage {
role: "assistant".into(),
content: MessageContent::Null,
extra: Value::Object(assistant_extras),
},
ChatMessage {
role: "tool".into(),
content: MessageContent::Text("Successfully wrote 84 bytes".into()),
extra: Value::Object(tool_extras),
},
user_msg("now check it parses"),
];
let out =
render_chat_template(template, &messages, &Value::Null, &Value::Null).expect("render");
assert!(
out.contains(file_body),
"the file body must appear verbatim in the rendered prompt"
);
let blanks_in = file_body.lines().filter(|l| l.is_empty()).count();
assert_eq!(blanks_in, 3, "fixture sanity");
// And specifically the double newline the model would notice.
assert!(
out.contains("'use strict';\n\n// ---- constants ----"),
"blank line after the prologue was eaten"
);
assert!(
out.contains("const A = 1;\n\n\nfunction f()"),
"consecutive blank lines were collapsed"
);
}
/// The second turn of an OpenAI-native agentic loop: the client
/// replays its own assistant turn, which carries `tool_calls` and
/// `"content": null`. That must render as the tool-call block —
+1
View File
@@ -402,6 +402,7 @@ mod tests {
tensor_parallel: tp,
devices: None,
sampling: None,
preserve_thinking: None,
}
}
+152 -3
View File
@@ -75,6 +75,15 @@ pub struct ModelGenerationDefaults {
pub temperature: Option<f64>,
pub top_p: Option<f64>,
pub top_k: Option<usize>,
/// Not published in `generation_config.json` — this struct is the
/// resolved per-model default layer, and the operator override
/// (#283) lands here so the repetition knobs sit in the same
/// precedence chain as the rest.
pub repeat_penalty: Option<f32>,
pub repeat_last_n: Option<usize>,
pub presence_penalty: Option<f32>,
pub frequency_penalty: Option<f32>,
pub seed: Option<u64>,
}
impl ModelGenerationDefaults {
@@ -155,6 +164,21 @@ impl ModelGenerationDefaults {
if let Some(k) = op.top_k {
self.top_k = Some(k);
}
if let Some(r) = op.repeat_penalty {
self.repeat_penalty = Some(r);
}
if let Some(n) = op.repeat_last_n {
self.repeat_last_n = Some(n);
}
if let Some(v) = op.presence_penalty {
self.presence_penalty = Some(v);
}
if let Some(v) = op.frequency_penalty {
self.frequency_penalty = Some(v);
}
if let Some(v) = op.seed {
self.seed = Some(v);
}
tracing::info!(
model = %model_id,
model_temperature = ?before.temperature,
@@ -163,6 +187,11 @@ impl ModelGenerationDefaults {
temperature = ?self.temperature,
top_p = ?self.top_p,
top_k = ?self.top_k,
repeat_penalty = ?self.repeat_penalty,
repeat_last_n = ?self.repeat_last_n,
presence_penalty = ?self.presence_penalty,
frequency_penalty = ?self.frequency_penalty,
seed = ?self.seed,
"sampling: operator override applied over the model's published defaults (#283)"
);
self
@@ -179,6 +208,8 @@ pub struct RequestedSampling {
pub seed: Option<u64>,
pub repeat_penalty: Option<f32>,
pub repeat_last_n: Option<usize>,
pub presence_penalty: Option<f32>,
pub frequency_penalty: Option<f32>,
}
/// Fully-resolved sampling for one request.
@@ -190,6 +221,9 @@ pub struct SamplingParams {
pub seed: u64,
pub repeat_penalty: f32,
pub repeat_last_n: usize,
/// Sequence-wide penalties, OpenAI semantics. `0.0` disables.
pub presence_penalty: f32,
pub frequency_penalty: f32,
}
impl SamplingParams {
@@ -215,9 +249,23 @@ impl SamplingParams {
// whenever the caller stayed quiet.
top_p: requested.top_p.or(model.top_p),
top_k: requested.top_k.or(model.top_k),
seed: requested.seed.unwrap_or(random_seed),
repeat_penalty: requested.repeat_penalty.unwrap_or(DEFAULT_REPEAT_PENALTY),
repeat_last_n: requested.repeat_last_n.unwrap_or(DEFAULT_REPEAT_LAST_N),
seed: requested.seed.or(model.seed).unwrap_or(random_seed),
repeat_penalty: requested
.repeat_penalty
.or(model.repeat_penalty)
.unwrap_or(DEFAULT_REPEAT_PENALTY),
repeat_last_n: requested
.repeat_last_n
.or(model.repeat_last_n)
.unwrap_or(DEFAULT_REPEAT_LAST_N),
presence_penalty: requested
.presence_penalty
.or(model.presence_penalty)
.unwrap_or(0.0),
frequency_penalty: requested
.frequency_penalty
.or(model.frequency_penalty)
.unwrap_or(0.0),
}
}
@@ -252,6 +300,7 @@ mod tests {
temperature: Some(1.0),
top_p: Some(0.95),
top_k: Some(20),
..Default::default()
}
}
@@ -298,6 +347,7 @@ mod tests {
temperature,
top_p,
top_k,
..Default::default()
}
}
@@ -394,6 +444,103 @@ mod tests {
/// A model with no `generation_config.json` must behave exactly as
/// it did before this landed, so nothing regresses on the models
/// that do not ship one.
/// An operator can pin the seed, because the client that needs it
/// pinned cannot send one: pi's request builder emits no `seed`, so
/// without this a parameter search runs unseeded and sampling
/// variance is indistinguishable from the effect being measured.
#[test]
fn an_operator_can_pin_the_seed() {
let op = cortex_core::harness::SamplingOverride {
seed: Some(424_242),
..Default::default()
};
let defaults = ModelGenerationDefaults::default().with_operator_override("m", Some(&op));
// The random seed argument must be ignored once an operator pinned one.
let p = SamplingParams::resolve(&RequestedSampling::default(), &defaults, 999);
assert_eq!(p.seed, 424_242);
}
/// Precedence holds: a caller that names its own seed keeps it.
#[test]
fn a_requested_seed_outranks_the_operator() {
let op = cortex_core::harness::SamplingOverride {
seed: Some(424_242),
..Default::default()
};
let defaults = ModelGenerationDefaults::default().with_operator_override("m", Some(&op));
let req = RequestedSampling {
seed: Some(7),
..Default::default()
};
assert_eq!(SamplingParams::resolve(&req, &defaults, 999).seed, 7);
}
/// With neither, the per-request random seed still applies — an
/// unset override must not accidentally freeze the fleet's RNG.
#[test]
fn no_pinned_seed_leaves_generation_random() {
let d = ModelGenerationDefaults::default().with_operator_override("m", None);
assert_eq!(
SamplingParams::resolve(&RequestedSampling::default(), &d, 999).seed,
999
);
}
/// The gap this closes: `repeat_last_n` defaults to 64 — candle's
/// chat example value — while a reasoning model's think block runs
/// to tens of thousands of tokens. Anything restated more than 64
/// tokens later is invisible to the penalty, and before this there
/// was no operator route to the knob at all: `SamplingOverride`
/// carried only temperature/top_p/top_k, and OpenAI-shaped clients
/// do not send repetition parameters. The 64 was effectively
/// hardcoded in production.
#[test]
fn an_operator_can_widen_the_repetition_window() {
let op = cortex_core::harness::SamplingOverride {
repeat_penalty: Some(1.05),
repeat_last_n: Some(2048),
presence_penalty: None,
frequency_penalty: None,
..Default::default()
};
let defaults = ModelGenerationDefaults::default().with_operator_override("m", Some(&op));
let p = SamplingParams::resolve(&RequestedSampling::default(), &defaults, 7);
assert_eq!(p.repeat_last_n, 2048);
assert!((p.repeat_penalty - 1.05).abs() < f32::EPSILON);
}
/// Precedence is request > operator > built-in, same as every other
/// sampling field — a caller that named a window keeps it.
#[test]
fn a_requested_repetition_window_outranks_the_operator() {
let op = cortex_core::harness::SamplingOverride {
repeat_last_n: Some(2048),
presence_penalty: None,
frequency_penalty: None,
..Default::default()
};
let defaults = ModelGenerationDefaults::default().with_operator_override("m", Some(&op));
let req = RequestedSampling {
repeat_last_n: Some(128),
presence_penalty: None,
frequency_penalty: None,
..Default::default()
};
assert_eq!(
SamplingParams::resolve(&req, &defaults, 7).repeat_last_n,
128
);
}
/// An operator who sets nothing must not change the constants.
#[test]
fn no_operator_setting_leaves_the_repetition_defaults_alone() {
let d = ModelGenerationDefaults::default().with_operator_override("m", None);
let p = SamplingParams::resolve(&RequestedSampling::default(), &d, 7);
assert_eq!(p.repeat_last_n, DEFAULT_REPEAT_LAST_N);
assert!((p.repeat_penalty - DEFAULT_REPEAT_PENALTY).abs() < f32::EPSILON);
}
#[test]
fn without_model_defaults_the_old_behaviour_is_preserved() {
let p = SamplingParams::resolve(
@@ -495,6 +642,8 @@ mod tests {
&RequestedSampling {
repeat_penalty: Some(1.05),
repeat_last_n: Some(512),
presence_penalty: None,
frequency_penalty: None,
..Default::default()
},
&ModelGenerationDefaults::default(),
+108 -12
View File
@@ -468,11 +468,21 @@ fn usage_chunk(
/// where the same wire object could be assembled differently — the
/// shape of defect #252 and #272 both were.
///
/// Semantics deliberately preserved from the path this replaces:
/// reasoning text is **dropped** from `content` (it is counted in
/// `usage.completion_tokens_details.reasoning_tokens` but never
/// rendered), and `finish_reason` becomes `"tool_calls"` whenever any
/// tool call was parsed.
/// Reasoning is kept out of `content` — it is not the assistant's
/// reply — but it is **returned** on `message.reasoning_content`, the
/// same field the streaming path emits as
/// `choice.delta.reasoning_content`.
///
/// It used to be discarded here (#300). That was inherited from the
/// path this replaced and was wrong on both counts: the tokens are
/// metered in `usage.completion_tokens_details.reasoning_tokens`, so a
/// caller paid for text it could never see; and a client cannot replay
/// what it never received, so on the next turn the model saw its own
/// prior turn as a bare assertion with the reasoning that produced it
/// deleted.
///
/// `finish_reason` becomes `"tool_calls"` whenever any tool call was
/// parsed.
///
/// Two fields that the old path hardcoded to `None` now populate,
/// because the events carry them and the struct already had the slots:
@@ -488,6 +498,7 @@ pub async fn collect_chat_completion(
model_id: String,
) -> Result<ChatCompletionResponse, String> {
let mut content = String::new();
let mut reasoning = String::new();
let mut tool_calls: Vec<serde_json::Value> = Vec::new();
let mut finish: Option<(FinishReason, u32, u32, u32, u32, Option<FinishTiming>)> = None;
@@ -495,8 +506,8 @@ pub async fn collect_chat_completion(
match event {
InferenceEvent::Start => {}
InferenceEvent::TextDelta(t) => content.push_str(&t),
// Counted, never rendered — see the doc comment.
InferenceEvent::ReasoningDelta(_) => {}
// Kept out of `content`, returned on `reasoning_content`.
InferenceEvent::ReasoningDelta(t) => reasoning.push_str(&t),
InferenceEvent::ToolCall {
index,
id: call_id,
@@ -545,11 +556,16 @@ pub async fn collect_chat_completion(
} else {
"tool_calls".to_string()
};
let message_extra = if tool_calls.is_empty() {
serde_json::Value::Object(Default::default())
} else {
json!({ "tool_calls": tool_calls })
};
let mut message_extra = serde_json::Map::new();
if !tool_calls.is_empty() {
message_extra.insert("tool_calls".into(), json!(tool_calls));
}
// Absent rather than empty when the model did not think: a
// non-reasoning model must keep the unchanged message shape.
if !reasoning.is_empty() {
message_extra.insert("reasoning_content".into(), json!(reasoning));
}
let message_extra = serde_json::Value::Object(message_extra);
Ok(ChatCompletionResponse {
id,
@@ -588,6 +604,86 @@ pub async fn collect_chat_completion(
#[cfg(test)]
mod tests {
/// The model's think block must come back on the non-streaming
/// path too (#300).
///
/// It used to be dropped here while the streaming path emitted it
/// as `choice.delta.reasoning_content`. The tokens were metered
/// either way, so a non-streaming caller paid for text it could
/// never see — and, worse, could not replay the model's reasoning
/// on the next turn, leaving the model to face its own prior turn
/// as a bare assertion.
#[tokio::test]
async fn non_streaming_returns_the_models_reasoning() {
let (tx, rx) = mpsc::channel::<InferenceEvent>(8);
tx.send(InferenceEvent::Start).await.unwrap();
tx.send(InferenceEvent::ReasoningDelta(
"weighing the options".into(),
))
.await
.unwrap();
tx.send(InferenceEvent::TextDelta("the answer".into()))
.await
.unwrap();
tx.send(InferenceEvent::Finish {
reason: FinishReason::Stop,
prompt_tokens: 5,
completion_tokens: 7,
reasoning_tokens: 3,
cached_tokens: 0,
timing: None,
})
.await
.unwrap();
drop(tx);
let r = collect_chat_completion(rx, "id".into(), 0, "m".into())
.await
.expect("collect");
let msg = &r.choices[0].message;
assert_eq!(
msg.extra["reasoning_content"], "weighing the options",
"reasoning must be returned, not discarded"
);
// And it must NOT be folded into the visible reply.
assert!(
matches!(&msg.content, MessageContent::Text(t) if t == "the answer"),
"reasoning leaked into content"
);
}
/// A non-reasoning model must keep the unchanged message shape —
/// absent, not an empty string.
#[tokio::test]
async fn no_reasoning_means_no_reasoning_field() {
let (tx, rx) = mpsc::channel::<InferenceEvent>(8);
tx.send(InferenceEvent::Start).await.unwrap();
tx.send(InferenceEvent::TextDelta("plain".into()))
.await
.unwrap();
tx.send(InferenceEvent::Finish {
reason: FinishReason::Stop,
prompt_tokens: 1,
completion_tokens: 1,
reasoning_tokens: 0,
cached_tokens: 0,
timing: None,
})
.await
.unwrap();
drop(tx);
let r = collect_chat_completion(rx, "id".into(), 0, "m".into())
.await
.expect("collect");
assert!(
r.choices[0]
.message
.extra
.get("reasoning_content")
.is_none()
);
}
use super::*;
// ── Non-streaming collector (#285) ────────────────────────────
+291 -10
View File
@@ -176,6 +176,29 @@ pub fn request_to_chat(req: ResponsesRequest) -> Result<ChatCompletionRequest, T
{
attach_reasoning(&mut msg, text);
}
// A `function_call` item belongs to the assistant
// turn that precedes it, not to a turn of its own.
//
// Responses splits one turn across several items —
// `reasoning`, then `message`, then `function_call`
// — while chat carries all three on a single
// assistant message. Pushing each item separately
// produced *consecutive assistant turns*: one that
// deliberates and then ends without acting, and one
// that acts with an empty `<think></think>`.
//
// Neither shape exists in training, and the model
// imitates what it is shown: after four such
// examples in its own replayed context it produced
// a think block and a preamble, then ended the turn
// without the tool call it had just announced
// (#296). Observed on a real session; the model was
// copying our transcript, not misbehaving.
if let Some(prev) = messages.last_mut()
&& merge_tool_call_into(prev, &mut msg)
{
continue;
}
messages.push(msg);
}
}
@@ -239,6 +262,8 @@ pub fn request_to_chat(req: ResponsesRequest) -> Result<ChatCompletionRequest, T
seed: req.seed,
repetition_penalty: req.repetition_penalty,
repeat_last_n: req.repeat_last_n,
presence_penalty: req.presence_penalty,
frequency_penalty: req.frequency_penalty,
max_tokens: req.max_output_tokens,
// Responses spells the cap `max_output_tokens`, already
// resolved onto the legacy field above.
@@ -376,6 +401,57 @@ fn apply_reasoning_effort(extra: &mut serde_json::Map<String, Value>) {
}
}
/// Fold a `function_call`-derived assistant message into the assistant
/// turn it belongs to.
///
/// Returns `true` when the call was merged and the caller should drop
/// its message. Only ever merges a *pure* tool-call message (empty
/// content, no reasoning of its own) into a preceding assistant turn,
/// so a genuine two-turn sequence is never collapsed and a reasoning
/// item that arrived with the call keeps its own turn.
///
/// Consecutive calls accumulate, which is what parallel tool calling
/// looks like on the wire: one assistant turn, several `tool_calls`.
fn merge_tool_call_into(prev: &mut ChatMessage, msg: &mut ChatMessage) -> bool {
if prev.role != "assistant" || msg.role != "assistant" {
return false;
}
// Only a bare tool-call carrier folds in.
if !matches!(&msg.content, MessageContent::Text(t) if t.is_empty()) {
return false;
}
if msg.extra.get("reasoning_content").is_some() {
return false;
}
let Some(incoming) = msg
.extra
.get_mut("tool_calls")
.and_then(|v| v.as_array_mut())
.map(std::mem::take)
else {
return false;
};
// Everything else the item carried must survive the fold, or this
// trades one silent drop for another.
if msg
.extra
.as_object()
.is_some_and(|o| o.keys().any(|k| k != "tool_calls"))
{
return false;
}
let Some(obj) = prev.extra.as_object_mut() else {
return false;
};
match obj.get_mut("tool_calls").and_then(|v| v.as_array_mut()) {
Some(existing) => existing.extend(incoming),
None => {
obj.insert("tool_calls".into(), Value::Array(incoming));
}
}
true
}
/// The text of a replayed reasoning item, under either spelling.
///
/// `summary` is what neuron emits (`summary_text` parts) and `content`
@@ -1173,28 +1249,49 @@ fn finish_to_status(reason: FinishReason) -> &'static str {
/// Collect a chat-completions response into a non-streaming
/// [`ResponsesResponse`]. Used by the `/v1/responses` handler when
/// the request doesn't set `stream: true`.
///
/// `reasoning` is the model's think block, when it produced one. It
/// becomes its own output item ahead of the message — the same shape
/// and ordering the streaming projector emits. Omitting it (as this did
/// until #300) meant a non-streaming caller was billed for
/// `reasoning_tokens` it could never see, and could not replay the
/// model's own thinking on the next turn.
pub fn build_response(
meta: &ResponseMeta,
full_text: String,
reason: FinishReason,
usage: Option<ResponsesUsage>,
reasoning: Option<String>,
) -> ResponsesResponse {
let status = finish_to_status(reason).to_string();
let mut output: Vec<ResponsesOutputItem> = Vec::with_capacity(2);
if let Some(text) = reasoning.filter(|t| !t.is_empty()) {
output.push(ResponsesOutputItem::Reasoning {
id: reasoning_item_id(meta),
summary: vec![cortex_core::responses::ResponsesSummaryPart::summary_text(
text,
)],
status: "completed".into(),
});
}
ResponsesResponse {
id: meta.response_id.clone(),
object: "response".into(),
created_at: meta.created_at,
status: status.clone(),
model: meta.model_id.clone(),
output: vec![ResponsesOutputItem::Message {
id: meta.message_item_id.clone(),
role: "assistant".into(),
content: vec![ResponsesOutputContent::OutputText {
text: full_text,
annotations: vec![],
}],
status,
}],
output: {
output.push(ResponsesOutputItem::Message {
id: meta.message_item_id.clone(),
role: "assistant".into(),
content: vec![ResponsesOutputContent::OutputText {
text: full_text,
annotations: vec![],
}],
status,
});
output
},
// Same contract as the streaming shell: a truncated response
// says so, or the caller cannot tell it from a fault.
incomplete_details: matches!(reason, FinishReason::Length)
@@ -1208,6 +1305,173 @@ mod tests {
use super::*;
use cortex_core::openai::MessageContent;
/// The non-streaming Responses body must carry a reasoning item
/// (#300), matching the item the streaming projector emits.
///
/// Observed before the fix: a response reporting
/// `output_tokens_details.reasoning_tokens: 4000` with an empty
/// `output` — 4,000 tokens generated, metered, and invisible.
#[test]
fn non_streaming_response_carries_a_reasoning_item() {
let r = build_response(
&meta(),
"the answer".into(),
FinishReason::Stop,
None,
Some("weighing the options".into()),
);
let v = serde_json::to_value(&r).unwrap();
let items = v["output"].as_array().expect("output array");
assert_eq!(items.len(), 2, "expected a reasoning item and a message");
assert_eq!(items[0]["type"], "reasoning", "reasoning comes first");
assert_eq!(items[0]["summary"][0]["type"], "summary_text");
assert_eq!(items[0]["summary"][0]["text"], "weighing the options");
assert_eq!(items[1]["type"], "message");
assert_eq!(items[1]["content"][0]["text"], "the answer");
}
/// A model that did not think produces no reasoning item — the
/// unchanged single-item shape.
#[test]
fn no_reasoning_means_no_reasoning_item() {
let r = build_response(&meta(), "plain".into(), FinishReason::Stop, None, None);
let v = serde_json::to_value(&r).unwrap();
let items = v["output"].as_array().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0]["type"], "message");
}
/// A file read back must survive the *whole* chain: Responses
/// `function_call_output` -> chat message -> rendered prompt.
///
/// The reported symptom is a model that writes a file correctly,
/// reads it back, and consistently sees errors that are not there.
/// This walks the real path rather than a hand-built chat message,
/// because the translation is the part in between.
#[test]
fn a_file_read_back_survives_the_whole_translation() {
let file = "<!DOCTYPE html>\n<html lang=\"en\">\n\n <script>\n if (a && b || c < d) { s = '#b8905a'; }\n </script>\n</html>\n";
let req: ResponsesRequest = serde_json::from_value(serde_json::json!({
"model": "m",
"input": [
{"type": "message", "role": "user",
"content": [{"type": "input_text", "text": "read it back"}]},
{"type": "function_call", "call_id": "c1", "name": "read",
"arguments": "{\"path\":\"/tmp/index.html\"}"},
// pi sends the tool result as a plain string (its
// `convertToolResultOutput` only produces an array when
// images are involved).
{"type": "function_call_output", "call_id": "c1", "output": file},
{"type": "message", "role": "user",
"content": [{"type": "input_text", "text": "any errors?"}]}
]
}))
.unwrap();
let chat = request_to_chat(req).expect("translate");
// The tool message must carry the file verbatim.
let tool = chat
.messages
.iter()
.find(|m| m.role == "tool")
.expect("a tool message");
let got = match &tool.content {
MessageContent::Text(t) => t.clone(),
other => panic!("tool content is not text: {other:?}"),
};
assert_eq!(got, file, "translation altered the file content");
// And it must survive the render too.
let template = include_str!("../../tests/data/qwen3.8-27b.chat_template.jinja");
let out = crate::harness::chat_template::render_chat_template(
template,
&chat.messages,
&Value::Null,
&Value::Null,
)
.expect("render");
for probe in ["<!DOCTYPE html>", "if (a && b || c < d)", "s = '#b8905a';"] {
assert!(out.contains(probe), "rendered prompt lost {probe:?}");
}
for bad in ["&lt;", "&amp;", "&quot;"] {
assert!(!out.contains(bad), "content HTML-escaped: {bad:?}");
}
}
/// One agentic step must render as ONE assistant turn.
///
/// Responses splits a turn into `reasoning` + `message` +
/// `function_call` items. Emitting a message per item produced
/// *consecutive assistant turns* — one that deliberates and then
/// ends without acting, one that acts with an empty think block.
/// Shown four of those in its own replayed context, the model
/// copied the pattern: it produced a think block and a preamble,
/// then ended the turn without the tool call it had just announced
/// (#296).
#[test]
fn one_agentic_step_becomes_one_assistant_turn() {
let req: ResponsesRequest = serde_json::from_value(serde_json::json!({
"model": "m",
"input": [
{"type": "message", "role": "user",
"content": [{"type": "input_text", "text": "build it"}]},
{"type": "reasoning",
"content": [{"type": "reasoning_text", "text": "planning the file"}]},
{"type": "message", "role": "assistant",
"content": [{"type": "output_text", "text": "Writing index.html:"}]},
{"type": "function_call", "call_id": "c1",
"name": "write", "arguments": "{\"path\":\"a.html\"}"},
{"type": "function_call_output", "call_id": "c1", "output": "wrote 10 bytes"},
{"type": "message", "role": "user",
"content": [{"type": "input_text", "text": "now check it"}]}
]
}))
.unwrap();
let chat = request_to_chat(req).expect("translate");
let roles: Vec<&str> = chat.messages.iter().map(|m| m.role.as_str()).collect();
assert_eq!(
roles,
vec!["user", "assistant", "tool", "user"],
"no two assistant turns may be adjacent: got {roles:?}"
);
let a = &chat.messages[1];
assert!(
a.extra.get("reasoning_content").is_some(),
"reasoning must ride the turn it describes"
);
assert!(
matches!(&a.content, MessageContent::Text(t) if t.contains("Writing index.html")),
"the assistant's own text must survive the fold"
);
let calls = a.extra["tool_calls"].as_array().expect("tool_calls");
assert_eq!(calls.len(), 1);
assert_eq!(calls[0]["function"]["name"], "write");
}
/// Parallel tool calls are several calls on one turn, not several
/// turns.
#[test]
fn consecutive_function_calls_accumulate_on_one_turn() {
let req: ResponsesRequest = serde_json::from_value(serde_json::json!({
"model": "m",
"input": [
{"type": "message", "role": "user",
"content": [{"type": "input_text", "text": "go"}]},
{"type": "message", "role": "assistant",
"content": [{"type": "output_text", "text": "doing both:"}]},
{"type": "function_call", "call_id": "c1", "name": "a", "arguments": "{}"},
{"type": "function_call", "call_id": "c2", "name": "b", "arguments": "{}"}
]
}))
.unwrap();
let chat = request_to_chat(req).expect("translate");
let roles: Vec<&str> = chat.messages.iter().map(|m| m.role.as_str()).collect();
assert_eq!(roles, vec!["user", "assistant"]);
let calls = chat.messages[1].extra["tool_calls"].as_array().unwrap();
assert_eq!(calls.len(), 2, "both calls belong to the same turn");
}
/// Wrap typed items as `input` elements. Most translator tests
/// exercise the typed path; the bare easy-message and unknown-item
/// paths have dedicated tests below.
@@ -1245,6 +1509,8 @@ mod tests {
seed: None,
repetition_penalty: None,
repeat_last_n: None,
presence_penalty: None,
frequency_penalty: None,
previous_response_id: None,
extra: Value::Object(Default::default()),
};
@@ -1271,6 +1537,8 @@ mod tests {
seed: None,
repetition_penalty: None,
repeat_last_n: None,
presence_penalty: None,
frequency_penalty: None,
previous_response_id: None,
extra: Value::Object(Default::default()),
};
@@ -1298,6 +1566,8 @@ mod tests {
seed: None,
repetition_penalty: None,
repeat_last_n: None,
presence_penalty: None,
frequency_penalty: None,
previous_response_id: Some("resp_prev".into()),
extra: Value::Object(Default::default()),
};
@@ -1334,6 +1604,8 @@ mod tests {
seed: None,
repetition_penalty: None,
repeat_last_n: None,
presence_penalty: None,
frequency_penalty: None,
previous_response_id: None,
extra: Value::Object(Default::default()),
};
@@ -1368,6 +1640,8 @@ mod tests {
seed: None,
repetition_penalty: None,
repeat_last_n: None,
presence_penalty: None,
frequency_penalty: None,
previous_response_id: None,
extra: Value::Object(Default::default()),
};
@@ -1417,6 +1691,8 @@ mod tests {
seed: None,
repetition_penalty: None,
repeat_last_n: None,
presence_penalty: None,
frequency_penalty: None,
previous_response_id: None,
extra: Value::Object(Default::default()),
};
@@ -1458,6 +1734,8 @@ mod tests {
seed: None,
repetition_penalty: None,
repeat_last_n: None,
presence_penalty: None,
frequency_penalty: None,
previous_response_id: None,
extra: Value::Object(Default::default()),
};
@@ -1494,6 +1772,8 @@ mod tests {
seed: None,
repetition_penalty: None,
repeat_last_n: None,
presence_penalty: None,
frequency_penalty: None,
previous_response_id: None,
extra: Value::Object(Default::default()),
};
@@ -2504,6 +2784,7 @@ mod tests {
output_tokens_details: None,
input_tokens_details: None,
}),
None,
);
assert_eq!(r.status, "completed");
match &r.output[0] {
@@ -2529,7 +2810,7 @@ mod tests {
#[test]
fn build_response_length_yields_incomplete_status() {
let r = build_response(&meta(), "trunc".into(), FinishReason::Length, None);
let r = build_response(&meta(), "trunc".into(), FinishReason::Length, None, None);
assert_eq!(r.status, "incomplete");
}
}
+4
View File
@@ -30,6 +30,7 @@ async fn test_load_default_models_skips_unknown_harness() {
tensor_parallel: None,
devices: None,
sampling: None,
preserve_thinking: None,
},
ModelSpec {
model_id: "model-b".into(),
@@ -38,6 +39,7 @@ async fn test_load_default_models_skips_unknown_harness() {
tensor_parallel: None,
devices: None,
sampling: None,
preserve_thinking: None,
},
];
@@ -98,6 +100,7 @@ async fn test_load_default_models_skipped_on_driver_mismatch() {
tensor_parallel: Some(2),
devices: None,
sampling: None,
preserve_thinking: None,
}];
let activation = ActivationTracker::new(&specs);
let reason = "host NVIDIA driver/library mismatch (userspace NVML 580.159 vs loaded \
@@ -200,6 +203,7 @@ fn qwen_spec() -> ModelSpec {
tensor_parallel: None,
devices: None,
sampling: None,
preserve_thinking: None,
}
}
+1
View File
@@ -54,6 +54,7 @@ async fn test_candle_qwen3_load_unload_lifecycle() {
tensor_parallel: None,
devices: Some(vec![0]),
sampling: None,
preserve_thinking: None,
};
registry
+1
View File
@@ -107,6 +107,7 @@ fn spec(model_id: &str, tp: Option<u32>, quant: Option<&str>) -> ModelSpec {
tensor_parallel: tp,
devices: None,
sampling: None,
preserve_thinking: None,
}
}
+4 -1
View File
@@ -15,6 +15,9 @@ Checked, per model id present in both files:
* ``quant`` — the #252 field
* ``sampling`` — the #283 field, compared field-by-field
* ``preserve_thinking`` — the H7 A/B gate: a model that replays
prior reasoning when resident and not when cold-loaded would
make the A/B unreadable, which is #252's shape again
A model in only one file is not an error: plenty of catalogue entries are
never resident anywhere, and a host may hold a model the catalogue does
@@ -46,7 +49,7 @@ REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Fields that must agree wherever both files mention the same model.
# Extend this list whenever a serving-critical field lands in both.
SCALAR_FIELDS = ["quant"]
SCALAR_FIELDS = ["quant", "preserve_thinking"]
TABLE_FIELDS = ["sampling"]
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Type-check neuron's `cuda` feature locally, in CI's own image.
#
# Why this exists: `#[cfg(feature = "cuda")]` code is invisible to a
# default-feature build, so `cargo build`, `cargo clippy --all-targets`
# and the whole test suite can pass on code that does not compile for
# the target the fleet actually serves on. The only gate that catches it
# is CI's `cuda-check` job — a ~20 minute round trip, and the failure
# arrives after the push rather than before it.
#
# This runs the same command, with the same env, in the same image.
#
# Do NOT try to install the CUDA toolkit on a Fedora 44 host instead:
# CUDA 13.0's nvcc rejects host compilers newer than gcc 15 and F44
# ships gcc 16 with no compat package. The image is Fedora 43 for
# exactly that reason, and additionally patches a glibc 2.41+ /
# CUDA-header `noexcept` mismatch that nvcc hard-errors on. See
# gongfoo's images/runner-cuda-13.0/Containerfile.
#
# No GPU is required — this is a borrow-check, not a run.
#
# First run pulls the image (~GBs) and compiles the candle/flash-attn
# kernels, so it costs about what CI costs. Afterwards the cargo target
# volume makes an incremental check a fraction of that, which is the
# whole point.
set -euo pipefail
IMAGE="${CUDA_CHECK_IMAGE:-git.lair.cafe/gongfoo/runner-cuda-13.0:latest}"
ROOT="$(git rev-parse --show-toplevel)"
# A dedicated target volume. Sharing the host's `target/` would make the
# default-feature and cuda-feature builds evict each other's artifacts on
# every alternation, which would cost more than it saves.
TARGET_VOL="${CUDA_CHECK_TARGET_VOL:-helexa-cuda-check-target}"
# Mounted at the registry rather than over CARGO_HOME: the toolchain
# binaries live in /usr/local/cargo/bin and must stay visible.
REGISTRY_VOL="${CUDA_CHECK_REGISTRY_VOL:-helexa-cuda-check-registry}"
if [ "${1:-}" = "--pull" ]; then
shift
podman pull "${IMAGE}"
fi
# Default to the exact command ci.yml's cuda-check job runs. Extra
# arguments replace it, for narrowing to one crate while iterating.
if [ "$#" -gt 0 ]; then
CARGO_CMD=("$@")
else
CARGO_CMD=(cargo check -p neuron --features cuda,flash-attn --all-targets)
fi
# Allocate a TTY only when there is one to allocate. `-it` against a
# pipe or a CI log wedges the container before cargo ever starts, which
# looks exactly like a very slow compile.
TTY_ARGS=()
if [ -t 0 ] && [ -t 1 ]; then
TTY_ARGS=(-it)
fi
exec podman run --rm "${TTY_ARGS[@]}" \
-v "${ROOT}:/work:Z" \
-v "${TARGET_VOL}:/target" \
-v "${REGISTRY_VOL}:/usr/local/cargo/registry" \
-w /work \
-e CARGO_TARGET_DIR=/target \
-e CARGO_TERM_COLOR=always \
`# candle-kernels' build script falls back to nvidia-smi for` \
`# compute-cap detection when this is unset, and there is no GPU` \
`# here. Any valid cap borrow-checks; the real per-flavour caps` \
`# live in build-prerelease.yml's matrix.` \
-e CUDA_COMPUTE_CAP=86 \
`# The image ships sccache, but the local run has no shared cache` \
`# to talk to; an unset wrapper avoids a hard cargo failure.` \
-e RUSTC_WRAPPER= \
"${IMAGE}" \
bash -lc '
set -euo pipefail
# CUDA_HOME/PATH/LD_LIBRARY_PATH come from the image. LIBRARY_PATH
# does not, and cudarc needs it at link time — ci.yml exports all
# three explicitly, so keep this in step with that job.
export LIBRARY_PATH="${CUDA_HOME}/targets/x86_64-linux/lib:${CUDA_HOME}/lib64:${LIBRARY_PATH:-}"
exec "$@"
' _ "${CARGO_CMD[@]}"