Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82a7b8420c
|
||
|
|
c37d9de527
|
||
|
|
d3341cb45c
|
@@ -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![],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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![],
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -402,6 +402,7 @@ mod tests {
|
||||
tensor_parallel: tp,
|
||||
devices: None,
|
||||
sampling: None,
|
||||
preserve_thinking: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
|
||||
Executable
+83
@@ -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[@]}"
|
||||
Reference in New Issue
Block a user