Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 72 additions & 7 deletions rust/src/server/src/routes/openai/utils/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ pub fn validate_stop(stop: &StringOrArray) -> Result<(), validator::ValidationEr

#[cfg(test)]
mod tests {
use super::{StringOrArray, validate_stop};
use super::{ChatMessage, StringOrArray, validate_stop};

#[test]
fn validate_stop_accepts_four_stop_strings() {
Expand All @@ -151,6 +151,47 @@ mod tests {
"stop strings must contain at most 4 items"
);
}

#[test]
fn assistant_reasoning_aliases_fold_into_reasoning() {
for (fields, expected) in [
(
r#""reasoning":"canonical","reasoning_content":"legacy","thinking":"thought""#,
Some("canonical"),
),
(
r#""reasoning_content":"legacy","reasoning":"canonical""#,
Some("canonical"),
),
(
r#""reasoning":null,"reasoning_content":"legacy""#,
Some("legacy"),
),
(r#""reasoning":"","reasoning_content":"legacy""#, Some("")),
(
r#""reasoning":"canonical","reasoning_content":42"#,
Some("canonical"),
),
(
r#""reasoning_content":"legacy","thinking":"thought""#,
Some("legacy"),
),
(
r#""reasoning_content":null,"thinking":"thought""#,
Some("thought"),
),
(r#""reasoning_content":null"#, None),
] {
let message = serde_json::from_str(&format!(
r#"{{"role":"assistant","content":"answer",{fields}}}"#
))
.unwrap_or_else(|error| panic!("{fields}: {error}"));
let ChatMessage::Assistant { reasoning, .. } = message else {
panic!("{fields}: expected an assistant message, got {message:?}");
};
assert_eq!(reasoning.as_deref(), expected, "{fields}");
}
}
}

// ============================================================================
Expand Down Expand Up @@ -458,9 +499,8 @@ pub enum ChatMessage {
content: Option<MessageContent>,
name: Option<String>,
tool_calls: Option<Vec<ToolCall>>,
/// Reasoning content for reasoning-capable models.
#[serde(alias = "reasoning_content")]
#[serde(alias = "thinking")]
/// Reasoning content for reasoning-capable models. Its aliases are
/// folded in by `normalize_reasoning_aliases`.
reasoning: Option<String>,
},
#[serde(rename = "tool")]
Expand Down Expand Up @@ -516,14 +556,39 @@ impl Serialize for ChatMessage {
}
}

/// Fold the `reasoning_content` and `thinking` aliases of a message into
/// `reasoning`, so clients replaying history may send several spellings.
///
/// Mirrors the Python frontend's `_normalize_messages_before`: aliases are
/// always removed, and the first non-null one fills `reasoning` only when it is
/// absent or null. `thinking` is a Rust-only alias with the lowest precedence.
fn normalize_reasoning_aliases(message: &mut Value) {
let Some(message) = message.as_object_mut() else {
return;
};
for alias in ["reasoning_content", "thinking"] {
let Some(alias_value) = message.remove(alias) else {
continue;
};
if alias_value.is_null() {
continue;
}
if message.get("reasoning").is_none_or(Value::is_null) {
message.insert("reasoning".to_string(), alias_value);
}
}
}

impl<'de> Deserialize<'de> for ChatMessage {
/// Dispatch on the role tag: standard roles keep their derived errors,
/// and any other string role becomes [`ChatMessage::Custom`].
/// Normalize the reasoning aliases, then dispatch on the role tag: standard
/// roles keep their derived errors, and any other string role becomes
/// [`ChatMessage::Custom`].
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
let mut value = Value::deserialize(deserializer)?;
normalize_reasoning_aliases(&mut value);
let is_custom = value
.get("role")
.and_then(Value::as_str)
Expand Down