Compare commits

...
Author SHA1 Message Date
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
13 changed files with 441 additions and 6 deletions
+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![],
+21
View File
@@ -86,6 +86,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.
+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![],
+1
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,
})
}
+98 -5
View File
@@ -726,6 +726,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 +887,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`].
@@ -2944,6 +2950,7 @@ impl CandleHarness {
loaded.chat_template.as_deref(),
&request,
&loaded.reasoning_efforts,
loaded.preserve_thinking,
)?;
let encoding = loaded
.tokenizer
@@ -3850,6 +3857,7 @@ impl Harness for CandleHarness {
model_id: spec.model_id.clone(),
generation_defaults,
reasoning_efforts,
preserve_thinking: spec.preserve_thinking,
arch: arch_local,
tokenizer,
device,
@@ -4503,6 +4511,7 @@ impl CandleHarness {
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 +4648,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 +5917,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 +5979,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")
@@ -7384,6 +7434,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() {
@@ -8542,8 +8635,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 +8645,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());
}
}
@@ -438,6 +438,75 @@ mod tests {
assert!(out.contains("<|vision_start|>") && out.contains("<|vision_end|>"));
}
/// 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,
}
}
+148
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);
}
}
@@ -376,6 +399,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`
@@ -1208,6 +1282,80 @@ mod tests {
use super::*;
use cortex_core::openai::MessageContent;
/// 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.
+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[@]}"