use serde::Deserialize;
use crate::policy::parser::ReasoningFormat;
use crate::{chat_template, invalid_request, unsupported_feature, ApiError, ChatMessage, ToolDef};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Continuation {
Auto,
Reasoning,
Content,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum ContinueFinalMessage {
#[default]
Unset,
Off,
Mode(Continuation),
}
pub(crate) const PREFILL_ASSISTANT_ENV: &str = "FERROX_PREFILL_ASSISTANT";
fn prefill_assistant_enabled() -> bool {
prefill_assistant_from_env(std::env::var(PREFILL_ASSISTANT_ENV).ok().as_deref())
}
fn prefill_assistant_from_env(value: Option<&str>) -> bool {
!matches!(
value.map(|v| v.trim().to_ascii_lowercase()).as_deref(),
Some("0") | Some("false") | Some("off") | Some("no")
)
}
impl ContinueFinalMessage {
pub(crate) fn resolve(self, history: &[crate::ChatMessage]) -> Option<(Continuation, bool)> {
self.resolve_with(prefill_assistant_enabled(), history)
}
fn resolve_with(
self,
prefill_assistant: bool,
history: &[crate::ChatMessage],
) -> Option<(Continuation, bool)> {
match self {
ContinueFinalMessage::Mode(mode) => Some((mode, false)),
ContinueFinalMessage::Off => None,
ContinueFinalMessage::Unset => {
let trailing_assistant = history.last().is_some_and(|m| m.role == "assistant");
(prefill_assistant && trailing_assistant).then_some((Continuation::Auto, true))
}
}
}
}
#[derive(Deserialize)]
#[serde(untagged)]
enum ContinueWire {
Flag(bool),
Mode(String),
}
pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<ContinueFinalMessage, D::Error>
where
D: serde::Deserializer<'de>,
{
let Some(wire) = Option::<ContinueWire>::deserialize(deserializer)? else {
return Ok(ContinueFinalMessage::Unset);
};
match wire {
ContinueWire::Flag(false) => Ok(ContinueFinalMessage::Off),
ContinueWire::Flag(true) => Ok(ContinueFinalMessage::Mode(Continuation::Auto)),
ContinueWire::Mode(mode) => match mode.as_str() {
"reasoning_content" => Ok(ContinueFinalMessage::Mode(Continuation::Reasoning)),
"content" => Ok(ContinueFinalMessage::Mode(Continuation::Content)),
other => Err(serde::de::Error::custom(format!(
"continue_final_message must be true, false, \"reasoning_content\" or \
\"content\", not {other:?}"
))),
},
}
}
pub(crate) fn implied_by_default(error: ApiError) -> ApiError {
let (status, mut body) = error;
if let Some(message) = body.0["error"]["message"].as_str() {
let message = format!(
"{message} (This continuation was implied by the server default: a trailing \
assistant message is continued, as llama.cpp's --prefill-assistant does. Send \
`continue_final_message: false` to render it as a closed turn, or start the \
server with --no-prefill-assistant.)"
);
body.0["error"]["message"] = serde_json::Value::String(message);
}
(status, body)
}
impl Continuation {
fn resolve(self, reasoning: &str, content: &str) -> Continuation {
match self {
Continuation::Auto if !reasoning.is_empty() && content.is_empty() => {
Continuation::Reasoning
}
Continuation::Auto => Continuation::Content,
explicit => explicit,
}
}
}
pub(crate) fn prompt_continuing_final_message(
messages: &[ChatMessage],
template: &chat_template::PromptTemplate,
tools: &[ToolDef],
extra: serde_json::Map<String, serde_json::Value>,
format: Option<ReasoningFormat>,
mode: Continuation,
) -> Result<String, ApiError> {
let Some((last, head)) = messages.split_last() else {
return Err(invalid_request(
"continue_final_message needs a message to continue",
"messages",
));
};
if last.role != "assistant" {
return Err(invalid_request(
"continue_final_message: the last message must be an assistant message",
"messages",
));
}
if head.last().is_some_and(|m| m.role == "assistant") {
return Err(invalid_request(
"continue_final_message: cannot have 2 or more assistant messages at the end of \
the list",
"messages",
));
}
if last
.tool_calls
.as_ref()
.is_some_and(|calls| !calls.is_empty())
{
return Err(unsupported_feature(
"continue_final_message: continuing an assistant message that contains tool calls \
is not implemented",
));
}
let reasoning = last.reasoning_content.as_deref().unwrap_or("");
let content = last
.content
.as_ref()
.map(crate::MessageContent::as_text)
.unwrap_or_default();
let mode = mode.resolve(reasoning, &content);
let markers = match format {
None => None,
Some(format) => match format.continuation_markers() {
Some(pair) => Some((format, pair)),
None => {
return Err(unsupported_feature(&format!(
"continue_final_message is not implemented for the {} reasoning format: \
its chain of thought is a channel grammar, not a marker pair a replayed \
thought can be written between",
format.as_str()
)))
}
},
};
if markers.is_none() && !reasoning.is_empty() {
return Err(unsupported_feature(
"continue_final_message: the served model has no reasoning format, so a \
reasoning_content on the message to continue cannot be rendered back into a prompt",
));
}
if let (Some((format, _)), Continuation::Content) = (markers, mode) {
if format.always_open() {
return Err(unsupported_feature(&format!(
"continue_final_message: \"content\" is not implemented for the {} reasoning \
format, whose parser reads every generation as starting inside the thinking \
block; only a reasoning continuation can be rendered for it",
format.as_str()
)));
}
}
let mut prompt = crate::prompt_from_messages(head, template, tools, extra)?;
if let Some((format, (start, end))) = markers {
if format.prompt_opens_reasoning(&prompt) {
if let Some(at) = prompt.rfind(start) {
prompt.truncate(at);
}
}
prompt.push_str(start);
prompt.push_str(reasoning);
if mode == Continuation::Content {
prompt.push_str(end);
}
}
if mode == Continuation::Content {
prompt.push_str(&content);
}
Ok(prompt)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chat_template::PromptTemplate;
use crate::output::OutputPosture;
use crate::MessageContent;
const R1: &str = "{% for m in messages %}{% if m.role == 'user' %}<|User|>{{ m.content }}{% else %}<|Assistant|>{{ m.content }}<|end▁of▁sentence|>{% endif %}{% endfor %}{% if add_generation_prompt %}<|Assistant|><think>\n{% endif %}";
const CHATML: &str = "{% for m in messages %}<|im_start|>{{ m.role }}\n{{ m.content }}<|im_end|>\n{% endfor %}{% if add_generation_prompt %}<|im_start|>assistant\n{% endif %}";
fn template(source: &str) -> PromptTemplate {
PromptTemplate::from_gguf_metadata(Some(source), Some("llama"), false, true, None, None)
}
fn msg(role: &str, content: &str, reasoning: Option<&str>) -> ChatMessage {
ChatMessage {
role: role.to_string(),
content: Some(MessageContent::Text(content.to_string())),
tool_calls: None,
tool_call_id: None,
reasoning_content: reasoning.map(str::to_string),
}
}
fn render(
source: &str,
messages: &[ChatMessage],
format: Option<ReasoningFormat>,
mode: Continuation,
) -> Result<String, ApiError> {
prompt_continuing_final_message(
messages,
&template(source),
&[],
serde_json::Map::new(),
format,
mode,
)
}
fn parse(value: serde_json::Value) -> Result<ContinueFinalMessage, String> {
#[derive(Deserialize)]
struct Body {
#[serde(default, deserialize_with = "deserialize")]
continue_final_message: ContinueFinalMessage,
}
serde_json::from_value::<Body>(serde_json::json!({ "continue_final_message": value }))
.map(|b| b.continue_final_message)
.map_err(|e| e.to_string())
}
#[test]
fn a_cut_off_thought_is_continued_inside_an_open_block() {
let prompt = render(
R1,
&[
msg("user", "why", None),
msg("assistant", "", Some("Let me think about")),
],
Some(ReasoningFormat::Think),
Continuation::Auto,
)
.expect("renders");
assert_eq!(
prompt, "<|User|>why<|Assistant|><think>Let me think about",
"the template's own opener must not be doubled"
);
let split = OutputPosture::resolve("DeepSeek-R1-Distill-Qwen-1.5B", &prompt)
.reasoning_parser()
.expect("R1 has a format")
.parse_complete(" this.</think>Because.");
assert_eq!(
split.reasoning, "this.",
"the parser must pick up inside the block"
);
assert_eq!(split.content, "Because.");
}
#[test]
fn a_cut_off_answer_is_continued_after_a_closed_block() {
let prompt = render(
R1,
&[
msg("user", "why", None),
msg("assistant", "Because", Some("thought")),
],
Some(ReasoningFormat::Think),
Continuation::Auto,
)
.expect("renders");
assert_eq!(
prompt,
"<|User|>why<|Assistant|><think>thought</think>Because"
);
let split = OutputPosture::resolve("DeepSeek-R1-Distill-Qwen-1.5B", &prompt)
.reasoning_parser()
.expect("R1 has a format")
.parse_complete(" it is so.");
assert_eq!(
split.reasoning, "",
"the block is closed; nothing is reasoning"
);
assert_eq!(split.content, "it is so.");
}
#[test]
fn an_explicit_mode_wins_over_the_auto_rule() {
let messages = [
msg("user", "why", None),
msg("assistant", "Because", Some("thought")),
];
let reasoning = render(
R1,
&messages,
Some(ReasoningFormat::Think),
Continuation::Reasoning,
)
.expect("renders");
assert_eq!(reasoning, "<|User|>why<|Assistant|><think>thought");
let content = render(
R1,
&[
msg("user", "why", None),
msg("assistant", "", Some("thought")),
],
Some(ReasoningFormat::Think),
Continuation::Content,
)
.expect("renders");
assert_eq!(content, "<|User|>why<|Assistant|><think>thought</think>");
}
#[test]
fn a_plain_model_continues_its_content() {
let prompt = render(
CHATML,
&[msg("user", "hi", None), msg("assistant", "Hello, I", None)],
None,
Continuation::Auto,
)
.expect("renders");
assert_eq!(
prompt,
"<|im_start|>user\nhi<|im_end|>\n<|im_start|>assistant\nHello, I"
);
}
#[test]
fn reasoning_for_a_model_with_no_format_is_refused() {
let err = render(
CHATML,
&[
msg("user", "hi", None),
msg("assistant", "", Some("thought")),
],
None,
Continuation::Auto,
)
.expect_err("refused");
assert_eq!(err.0, axum::http::StatusCode::NOT_IMPLEMENTED);
}
#[test]
fn channel_grammars_and_always_open_content_are_refused_by_name() {
let messages = [msg("user", "hi", None), msg("assistant", "x", Some("t"))];
for format in [ReasoningFormat::GptOss, ReasoningFormat::MuseGlimmer] {
let err =
render(CHATML, &messages, Some(format), Continuation::Auto).expect_err("refused");
assert_eq!(err.0, axum::http::StatusCode::NOT_IMPLEMENTED, "{format:?}");
}
let err = render(
CHATML,
&messages,
Some(ReasoningFormat::DeepSeekV32),
Continuation::Content,
)
.expect_err("refused");
assert_eq!(err.0, axum::http::StatusCode::NOT_IMPLEMENTED);
render(
CHATML,
&[msg("user", "hi", None), msg("assistant", "", Some("t"))],
Some(ReasoningFormat::DeepSeekV32),
Continuation::Auto,
)
.expect("a reasoning continuation renders");
}
#[test]
fn the_last_message_must_be_a_lone_assistant_turn_without_tool_calls() {
let bad_role = render(CHATML, &[msg("user", "hi", None)], None, Continuation::Auto)
.expect_err("refused");
assert_eq!(bad_role.0, axum::http::StatusCode::BAD_REQUEST);
let two = render(
CHATML,
&[
msg("user", "hi", None),
msg("assistant", "a", None),
msg("assistant", "b", None),
],
None,
Continuation::Auto,
)
.expect_err("refused");
assert_eq!(two.0, axum::http::StatusCode::BAD_REQUEST);
let empty = render(CHATML, &[], None, Continuation::Auto).expect_err("refused");
assert_eq!(empty.0, axum::http::StatusCode::BAD_REQUEST);
}
#[test]
fn the_wire_value_set_is_llama_cpps_plus_an_explicit_off() {
assert_eq!(
parse(serde_json::json!(true)).unwrap(),
ContinueFinalMessage::Mode(Continuation::Auto)
);
assert_eq!(
parse(serde_json::json!(false)).unwrap(),
ContinueFinalMessage::Off
);
assert_eq!(
parse(serde_json::json!(null)).unwrap(),
ContinueFinalMessage::Unset
);
assert_eq!(
parse(serde_json::json!("reasoning_content")).unwrap(),
ContinueFinalMessage::Mode(Continuation::Reasoning)
);
assert_eq!(
parse(serde_json::json!("content")).unwrap(),
ContinueFinalMessage::Mode(Continuation::Content)
);
assert!(parse(serde_json::json!("auto")).is_err());
assert!(parse(serde_json::json!(1)).is_err());
assert_eq!(ContinueFinalMessage::default(), ContinueFinalMessage::Unset);
}
#[test]
fn a_trailing_assistant_message_is_continued_by_default() {
let trailing = [msg("user", "why", None), msg("assistant", "Because", None)];
let no_trailing = [msg("user", "why", None)];
assert_eq!(
ContinueFinalMessage::Unset.resolve_with(true, &trailing),
Some((Continuation::Auto, true)),
"implied, and marked as implied"
);
assert_eq!(
ContinueFinalMessage::Unset.resolve_with(true, &no_trailing),
None
);
assert_eq!(
ContinueFinalMessage::Unset.resolve_with(false, &trailing),
None,
"--no-prefill-assistant"
);
assert_eq!(
ContinueFinalMessage::Off.resolve_with(true, &trailing),
None,
"false opts out of the default"
);
assert_eq!(
ContinueFinalMessage::Mode(Continuation::Content).resolve_with(false, &trailing),
Some((Continuation::Content, false)),
"a named mode stands whatever the server flag says"
);
assert!(prefill_assistant_from_env(None), "unset is on, as upstream");
assert!(prefill_assistant_from_env(Some("1")));
assert!(!prefill_assistant_from_env(Some("0")));
assert!(!prefill_assistant_from_env(Some("false")));
}
#[test]
fn an_implied_refusal_says_how_to_turn_the_default_off() {
let (status, body) = implied_by_default(unsupported_feature("not for this family"));
assert_eq!(status, axum::http::StatusCode::NOT_IMPLEMENTED);
let message = body.0["error"]["message"].as_str().unwrap();
assert!(message.starts_with("not for this family"));
assert!(message.contains("continue_final_message: false"));
assert!(message.contains("--no-prefill-assistant"));
}
}