Improve the formatting of tool calls, show thinking, treat Reasoning and Thinking as the same thing (sorry Kant) (#7626)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -171,11 +171,13 @@ pub fn format_messages(messages: &[Message]) -> Vec<Value> {
|
||||
// Skip
|
||||
}
|
||||
MessageContent::Thinking(thinking) => {
|
||||
content.push(json!({
|
||||
TYPE_FIELD: THINKING_TYPE,
|
||||
THINKING_TYPE: thinking.thinking,
|
||||
SIGNATURE_FIELD: thinking.signature
|
||||
}));
|
||||
if !thinking.signature.is_empty() {
|
||||
content.push(json!({
|
||||
TYPE_FIELD: THINKING_TYPE,
|
||||
THINKING_TYPE: thinking.thinking,
|
||||
SIGNATURE_FIELD: thinking.signature
|
||||
}));
|
||||
}
|
||||
}
|
||||
MessageContent::RedactedThinking(redacted) => {
|
||||
content.push(json!({
|
||||
@@ -196,10 +198,6 @@ pub fn format_messages(messages: &[Message]) -> Vec<Value> {
|
||||
}));
|
||||
}
|
||||
}
|
||||
MessageContent::Reasoning(_reasoning) => {
|
||||
// Reasoning content is for OpenAI-compatible APIs (e.g., DeepSeek)
|
||||
// Anthropic doesn't use this format, so skip it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,8 +561,11 @@ where
|
||||
|
||||
try_stream! {
|
||||
let mut accumulated_text = String::new();
|
||||
let mut accumulated_thinking = String::new();
|
||||
let mut accumulated_thinking_signature = String::new();
|
||||
let mut accumulated_tool_calls: std::collections::HashMap<String, (String, String)> = std::collections::HashMap::new();
|
||||
let mut current_tool_id: Option<String> = None;
|
||||
let mut current_block_type: Option<String> = None;
|
||||
let mut final_usage: Option<crate::providers::base::ProviderUsage> = None;
|
||||
let mut message_id: Option<String> = None;
|
||||
|
||||
@@ -619,13 +620,33 @@ where
|
||||
"content_block_start" => {
|
||||
// A new content block started
|
||||
if let Some(content_block) = event.data.get("content_block") {
|
||||
if content_block.get("type") == Some(&json!("tool_use")) {
|
||||
if let Some(id) = content_block.get("id").and_then(|v| v.as_str()) {
|
||||
current_tool_id = Some(id.to_string());
|
||||
if let Some(name) = content_block.get("name").and_then(|v| v.as_str()) {
|
||||
accumulated_tool_calls.insert(id.to_string(), (name.to_string(), String::new()));
|
||||
let block_type = content_block.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
current_block_type = Some(block_type.to_string());
|
||||
match block_type {
|
||||
"tool_use" => {
|
||||
if let Some(id) = content_block.get("id").and_then(|v| v.as_str()) {
|
||||
current_tool_id = Some(id.to_string());
|
||||
if let Some(name) = content_block.get("name").and_then(|v| v.as_str()) {
|
||||
accumulated_tool_calls.insert(id.to_string(), (name.to_string(), String::new()));
|
||||
}
|
||||
}
|
||||
}
|
||||
THINKING_TYPE => {
|
||||
accumulated_thinking.clear();
|
||||
}
|
||||
REDACTED_THINKING_TYPE => {
|
||||
// Yield redacted thinking immediately — there are no deltas for it
|
||||
if let Some(data) = content_block.get("data").and_then(|v| v.as_str()) {
|
||||
let mut message = Message::new(
|
||||
Role::Assistant,
|
||||
chrono::Utc::now().timestamp(),
|
||||
vec![MessageContent::redacted_thinking(data)],
|
||||
);
|
||||
message.id = message_id.clone();
|
||||
yield (Some(message), None);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
@@ -646,6 +667,20 @@ where
|
||||
message.id = message_id.clone();
|
||||
yield (Some(message), None);
|
||||
}
|
||||
} else if delta.get("type") == Some(&json!("thinking_delta")) {
|
||||
// Thinking content delta — stream incrementally for real-time UI
|
||||
if let Some(thinking) = delta.get("thinking").and_then(|v| v.as_str()) {
|
||||
accumulated_thinking.push_str(thinking);
|
||||
|
||||
// Yield partial thinking (no signature yet) for live display
|
||||
let mut message = Message::new(
|
||||
Role::Assistant,
|
||||
chrono::Utc::now().timestamp(),
|
||||
vec![MessageContent::thinking(thinking, "")],
|
||||
);
|
||||
message.id = message_id.clone();
|
||||
yield (Some(message), None);
|
||||
}
|
||||
} else if delta.get("type") == Some(&json!("input_json_delta")) {
|
||||
// Tool input delta
|
||||
if let Some(tool_id) = ¤t_tool_id {
|
||||
@@ -655,12 +690,34 @@ where
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if delta.get("type") == Some(&json!("signature_delta")) {
|
||||
// Signature for a thinking block
|
||||
if let Some(sig) = delta.get("signature").and_then(|v| v.as_str()) {
|
||||
accumulated_thinking_signature.push_str(sig);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
"content_block_stop" => {
|
||||
// Content block finished
|
||||
if current_block_type.as_deref() == Some(THINKING_TYPE) && !accumulated_thinking.is_empty() {
|
||||
// Yield the complete thinking block with signature for session storage
|
||||
let mut message = Message::new(
|
||||
Role::Assistant,
|
||||
chrono::Utc::now().timestamp(),
|
||||
vec![MessageContent::thinking(
|
||||
std::mem::take(&mut accumulated_thinking),
|
||||
std::mem::take(&mut accumulated_thinking_signature),
|
||||
)],
|
||||
);
|
||||
message.id = message_id.clone();
|
||||
yield (Some(message), None);
|
||||
current_block_type = None;
|
||||
continue;
|
||||
}
|
||||
current_block_type = None;
|
||||
|
||||
if let Some(tool_id) = current_tool_id.take() {
|
||||
// Tool call finished, yield complete tool call
|
||||
if let Some((name, args)) = accumulated_tool_calls.remove(&tool_id) {
|
||||
@@ -863,80 +920,6 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_thinking_response() -> Result<()> {
|
||||
let response = json!({
|
||||
"id": "msg_456",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": "This is a step-by-step thought process...",
|
||||
"signature": "EuYBCkQYAiJAVbJNBoH7HQiDcMwwAMhWqNyoe4G2xHRprK8ICM8gZzu16i7Se4EiEbmlKqNH1GtwcX1BMK6iLu8bxWn5wPVIFBIMnptdlVal7ZX5iNPFGgwWjX+BntcEOHky4HciMFVef7FpQeqnuiL1Xt7J4OLHZSyu4tcr809AxAbclcJ5dm1xE5gZrUO+/v60cnJM2ipQp4B8/3eHI03KSV6bZR/vMrBSYCV+aa/f5KHX2cRtLGp/Ba+3Tk/efbsg01WSduwAIbR4coVrZLnGJXNyVTFW/Be2kLy/ECZnx8cqvU3oQOg="
|
||||
},
|
||||
{
|
||||
"type": "redacted_thinking",
|
||||
"data": "EmwKAhgBEgy3va3pzix/LafPsn4aDFIT2Xlxh0L5L8rLVyIwxtE3rAFBa8cr3qpP"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "I've analyzed the problem and here's the solution."
|
||||
}
|
||||
],
|
||||
"model": "claude-3-7-sonnet-20250219",
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": null,
|
||||
"usage": {
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 45,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 0,
|
||||
}
|
||||
});
|
||||
|
||||
let message = response_to_message(&response)?;
|
||||
let usage = get_usage(&response)?;
|
||||
|
||||
assert_eq!(message.content.len(), 3);
|
||||
|
||||
if let MessageContent::Thinking(thinking) = &message.content[0] {
|
||||
assert_eq!(
|
||||
thinking.thinking,
|
||||
"This is a step-by-step thought process..."
|
||||
);
|
||||
assert!(thinking
|
||||
.signature
|
||||
.starts_with("EuYBCkQYAiJAVbJNBoH7HQiDcMwwAMhWqNyoe4G2xHRprK8ICM8g"));
|
||||
} else {
|
||||
panic!("Expected Thinking content at index 0");
|
||||
}
|
||||
|
||||
if let MessageContent::RedactedThinking(redacted) = &message.content[1] {
|
||||
assert_eq!(
|
||||
redacted.data,
|
||||
"EmwKAhgBEgy3va3pzix/LafPsn4aDFIT2Xlxh0L5L8rLVyIwxtE3rAFBa8cr3qpP"
|
||||
);
|
||||
} else {
|
||||
panic!("Expected RedactedThinking content at index 1");
|
||||
}
|
||||
|
||||
if let MessageContent::Text(text) = &message.content[2] {
|
||||
assert_eq!(
|
||||
text.text,
|
||||
"I've analyzed the problem and here's the solution."
|
||||
);
|
||||
} else {
|
||||
panic!("Expected Text content at index 2");
|
||||
}
|
||||
|
||||
assert_eq!(usage.input_tokens, Some(10));
|
||||
assert_eq!(usage.output_tokens, Some(45));
|
||||
assert_eq!(usage.total_tokens, Some(55));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_to_anthropic_spec() {
|
||||
let messages = vec![
|
||||
@@ -957,6 +940,21 @@ mod tests {
|
||||
assert_eq!(spec[2]["content"][0]["text"], "How are you?");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_to_anthropic_spec_skips_unsigned_thinking() {
|
||||
let messages = vec![
|
||||
Message::assistant().with_content(MessageContent::thinking("internal", "")),
|
||||
Message::assistant().with_text("Hi there"),
|
||||
];
|
||||
|
||||
let spec = format_messages(&messages);
|
||||
|
||||
assert_eq!(spec.len(), 1);
|
||||
assert_eq!(spec[0]["role"], "assistant");
|
||||
assert_eq!(spec[0]["content"][0]["type"], "text");
|
||||
assert_eq!(spec[0]["content"][0]["text"], "Hi there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tools_to_anthropic_spec() {
|
||||
let tools = vec![
|
||||
|
||||
@@ -125,11 +125,6 @@ pub fn to_bedrock_message_content(content: &MessageContent) -> Result<bedrock::C
|
||||
.build()?,
|
||||
)
|
||||
}
|
||||
MessageContent::Reasoning(_reasoning) => {
|
||||
// Reasoning content is for OpenAI-compatible APIs (e.g., DeepSeek)
|
||||
// Bedrock doesn't use this format, so skip
|
||||
bedrock::ContentBlock::Text("".to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -206,10 +206,6 @@ fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec<Data
|
||||
MessageContent::SystemNotification(_)
|
||||
| MessageContent::ToolConfirmationRequest(_)
|
||||
| MessageContent::ActionRequired(_) => {}
|
||||
MessageContent::Reasoning(_reasoning) => {
|
||||
// Reasoning content is for OpenAI-compatible APIs (e.g., DeepSeek)
|
||||
// Databricks doesn't use this format, so skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ fn process_response_part_impl(
|
||||
if is_thought {
|
||||
match signature {
|
||||
Some(sig) => Some(MessageContent::thinking(text.to_string(), sig.to_string())),
|
||||
None => Some(MessageContent::reasoning(text.to_string())),
|
||||
None => Some(MessageContent::thinking(text.to_string(), "")),
|
||||
}
|
||||
} else {
|
||||
Some(MessageContent::text(text.to_string()))
|
||||
@@ -1050,14 +1050,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thought_without_signature_maps_to_reasoning() {
|
||||
fn test_thought_without_signature_maps_to_thinking() {
|
||||
let response = google_response(vec![json!({
|
||||
"text": "Working through options...",
|
||||
"thought": true
|
||||
})]);
|
||||
let native = response_to_message(response).unwrap();
|
||||
assert_eq!(native.content.len(), 1);
|
||||
assert!(native.content[0].as_reasoning().is_some());
|
||||
assert!(native.content[0].as_thinking().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1188,30 +1188,28 @@ mod tests {
|
||||
async fn test_streaming_with_thought_signature() {
|
||||
use futures::StreamExt;
|
||||
|
||||
async fn collect_streaming_text(raw: &str) -> (String, usize, usize) {
|
||||
async fn collect_streaming_text(raw: &str) -> (String, usize) {
|
||||
let lines: Vec<Result<String, anyhow::Error>> =
|
||||
raw.lines().map(|l| Ok(l.to_string())).collect();
|
||||
let stream = Box::pin(futures::stream::iter(lines));
|
||||
let mut msg_stream = std::pin::pin!(response_to_streaming_message(stream));
|
||||
let mut text = String::new();
|
||||
let mut thinking = 0usize;
|
||||
let mut reasoning = 0usize;
|
||||
while let Some(Ok((message, _))) = msg_stream.next().await {
|
||||
if let Some(msg) = message {
|
||||
for c in &msg.content {
|
||||
match c {
|
||||
MessageContent::Text(t) => text.push_str(&t.text),
|
||||
MessageContent::Thinking(_) => thinking += 1,
|
||||
MessageContent::Reasoning(_) => reasoning += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(text, thinking, reasoning)
|
||||
(text, thinking)
|
||||
}
|
||||
|
||||
let (text, thinking, reasoning) = collect_streaming_text(concat!(
|
||||
let (text, thinking) = collect_streaming_text(concat!(
|
||||
r#"data: {"candidates": [{"content": {"role": "model", "#,
|
||||
r#""parts": [{"text": "Hello", "thoughtSignature": "sig1"}]}}], "#,
|
||||
r#""modelVersion": "gemini-3-flash-preview"}"#,
|
||||
@@ -1221,10 +1219,9 @@ mod tests {
|
||||
))
|
||||
.await;
|
||||
assert_eq!(thinking, 0);
|
||||
assert_eq!(reasoning, 0);
|
||||
assert_eq!(text, "Hello world");
|
||||
|
||||
let (text, thinking, reasoning) = collect_streaming_text(concat!(
|
||||
let (text, thinking) = collect_streaming_text(concat!(
|
||||
r#"data: {"candidates": [{"content": {"role": "model", "#,
|
||||
r#""parts": [{"text": "SECURITY.md: Project"}]}}], "#,
|
||||
r#""modelVersion": "gemini-3-flash-preview"}"#,
|
||||
@@ -1235,10 +1232,9 @@ mod tests {
|
||||
))
|
||||
.await;
|
||||
assert_eq!(thinking, 0);
|
||||
assert_eq!(reasoning, 0);
|
||||
assert_eq!(text, "SECURITY.md: Project policies.\n\nRead it?");
|
||||
|
||||
let (text, thinking, reasoning) = collect_streaming_text(concat!(
|
||||
let (text, thinking) = collect_streaming_text(concat!(
|
||||
r#"data: {"candidates": [{"content": {"role": "model", "#,
|
||||
r#""parts": [{"text": "one "}]}}], "modelVersion": "gemini-3-flash-preview"}"#,
|
||||
"\n",
|
||||
@@ -1250,10 +1246,9 @@ mod tests {
|
||||
))
|
||||
.await;
|
||||
assert_eq!(thinking, 0);
|
||||
assert_eq!(reasoning, 0);
|
||||
assert_eq!(text, "one two three");
|
||||
|
||||
let (text, thinking, reasoning) = collect_streaming_text(concat!(
|
||||
let (text, thinking) = collect_streaming_text(concat!(
|
||||
r#"data: {"candidates": [{"content": {"role": "model", "#,
|
||||
r#""parts": [{"text": "internal chain", "thought": true, "thoughtSignature": "sig4"}]}}]}"#,
|
||||
"\n",
|
||||
@@ -1262,7 +1257,6 @@ mod tests {
|
||||
))
|
||||
.await;
|
||||
assert_eq!(thinking, 1);
|
||||
assert_eq!(reasoning, 0);
|
||||
assert_eq!(text, "visible");
|
||||
}
|
||||
|
||||
|
||||
@@ -105,20 +105,15 @@ pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec<
|
||||
}
|
||||
}
|
||||
}
|
||||
MessageContent::Thinking(_) => {
|
||||
// Thinking blocks are not directly used in OpenAI format
|
||||
continue;
|
||||
MessageContent::Thinking(t) => {
|
||||
reasoning_text.push_str(&t.thinking);
|
||||
}
|
||||
MessageContent::RedactedThinking(_) => {
|
||||
// Redacted thinking blocks are not directly used in OpenAI format
|
||||
continue;
|
||||
}
|
||||
MessageContent::SystemNotification(_) => {
|
||||
continue;
|
||||
}
|
||||
MessageContent::Reasoning(r) => {
|
||||
reasoning_text.push_str(&r.text);
|
||||
}
|
||||
MessageContent::ToolRequest(request) => match &request.tool_call {
|
||||
Ok(tool_call) => {
|
||||
let sanitized_name = sanitize_function_name(&tool_call.name);
|
||||
@@ -346,7 +341,7 @@ pub fn response_to_message(response: &Value) -> anyhow::Result<Message> {
|
||||
if let Some(reasoning_content) = reasoning_value {
|
||||
if let Some(reasoning_str) = reasoning_content.as_str() {
|
||||
if !reasoning_str.is_empty() {
|
||||
content.push(MessageContent::reasoning(reasoning_str));
|
||||
content.push(MessageContent::thinking(reasoning_str, ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -646,7 +641,7 @@ where
|
||||
|
||||
let mut contents = Vec::new();
|
||||
if !accumulated_reasoning_content.is_empty() {
|
||||
contents.push(MessageContent::reasoning(&accumulated_reasoning_content));
|
||||
contents.push(MessageContent::thinking(&accumulated_reasoning_content, ""));
|
||||
accumulated_reasoning_content.clear();
|
||||
}
|
||||
let mut sorted_indices: Vec<_> = tool_call_data.keys().cloned().collect();
|
||||
@@ -706,7 +701,7 @@ where
|
||||
|
||||
if let Some(reasoning) = &chunk.choices[0].delta.reasoning_content {
|
||||
if !reasoning.is_empty() {
|
||||
content.push(MessageContent::reasoning(reasoning));
|
||||
content.push(MessageContent::thinking(reasoning, ""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1837,11 +1832,11 @@ data: [DONE]"#;
|
||||
let message = response_to_message(&response)?;
|
||||
assert_eq!(message.content.len(), 2);
|
||||
|
||||
// First should be reasoning content
|
||||
if let MessageContent::Reasoning(reasoning) = &message.content[0] {
|
||||
assert_eq!(reasoning.text, "Let me think about this step by step...");
|
||||
// First should be thinking content (reasoning is mapped to thinking)
|
||||
if let MessageContent::Thinking(thinking) = &message.content[0] {
|
||||
assert_eq!(thinking.thinking, "Let me think about this step by step...");
|
||||
} else {
|
||||
panic!("Expected Reasoning content");
|
||||
panic!("Expected Thinking content, got {:?}", message.content[0]);
|
||||
}
|
||||
|
||||
// Second should be text content
|
||||
@@ -1858,7 +1853,10 @@ data: [DONE]"#;
|
||||
fn test_format_messages_with_reasoning_content() -> anyhow::Result<()> {
|
||||
// Test that reasoning_content is properly included in formatted messages
|
||||
let mut message = Message::assistant()
|
||||
.with_content(MessageContent::reasoning("Thinking through the problem..."))
|
||||
.with_content(MessageContent::thinking(
|
||||
"Thinking through the problem...",
|
||||
"",
|
||||
))
|
||||
.with_text("The result is 42");
|
||||
|
||||
// Add a tool call to test that reasoning_content works with tool calls
|
||||
|
||||
@@ -41,7 +41,7 @@ fn reasoning_from_summary(summary: &[SummaryText]) -> Option<MessageContent> {
|
||||
if text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(MessageContent::reasoning(text))
|
||||
Some(MessageContent::thinking(text, ""))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -881,10 +881,10 @@ mod tests {
|
||||
|
||||
let message = responses_api_to_message(&response)?;
|
||||
|
||||
let reasoning = message.content.iter().find_map(|c| c.as_reasoning());
|
||||
assert!(reasoning.is_some(), "should contain reasoning content");
|
||||
let thinking = message.content.iter().find_map(|c| c.as_thinking());
|
||||
assert!(thinking.is_some(), "should contain thinking content");
|
||||
assert_eq!(
|
||||
reasoning.unwrap().text,
|
||||
thinking.unwrap().thinking,
|
||||
"Thinking about the question...\nThe answer is straightforward."
|
||||
);
|
||||
|
||||
@@ -938,7 +938,7 @@ mod tests {
|
||||
let messages = responses_api_to_streaming_message(response_stream);
|
||||
futures::pin_mut!(messages);
|
||||
|
||||
let mut reasoning_parts = Vec::new();
|
||||
let mut thinking_parts = Vec::new();
|
||||
let mut text_parts = Vec::new();
|
||||
|
||||
while let Some(item) = messages.next().await {
|
||||
@@ -946,7 +946,7 @@ mod tests {
|
||||
if let Some(msg) = message {
|
||||
for content in msg.content {
|
||||
match &content {
|
||||
MessageContent::Reasoning(r) => reasoning_parts.push(r.text.clone()),
|
||||
MessageContent::Thinking(t) => thinking_parts.push(t.thinking.clone()),
|
||||
MessageContent::Text(t) => text_parts.push(t.text.clone()),
|
||||
_ => {}
|
||||
}
|
||||
@@ -955,10 +955,10 @@ mod tests {
|
||||
}
|
||||
|
||||
assert!(
|
||||
!reasoning_parts.is_empty(),
|
||||
"should capture reasoning from stream"
|
||||
!thinking_parts.is_empty(),
|
||||
"should capture thinking from stream"
|
||||
);
|
||||
assert_eq!(reasoning_parts.join(""), "Let me think step by step.");
|
||||
assert_eq!(thinking_parts.join(""), "Let me think step by step.");
|
||||
assert!(text_parts.concat().contains("Paris."));
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -65,10 +65,6 @@ pub fn format_messages(messages: &[Message]) -> Vec<Value> {
|
||||
MessageContent::FrontendToolRequest(_tool_request) => {
|
||||
// Skip frontend tool requests
|
||||
}
|
||||
MessageContent::Reasoning(_reasoning) => {
|
||||
// Reasoning content is for OpenAI-compatible APIs (e.g., DeepSeek)
|
||||
// Snowflake doesn't use this format, so skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user