mirror of
https://github.com/deepseek-ai/deepseek-recipe.git
synced 2026-10-28 17:17:08 +08:00
Merge af4aa61e0baf7ca9c4ca3653bca931c1bc29d045 into 935817c66f5d6e64838f419692545f06787e485f
This commit is contained in:
commit
f2e66ccec3
177
deepseek-recipe-python/tests/test_messages_turns.py
Normal file
177
deepseek-recipe-python/tests/test_messages_turns.py
Normal file
@ -0,0 +1,177 @@
|
||||
"""Messages turn coalescing precedes tool-call boundary validation."""
|
||||
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
from deepseek_recipe import (
|
||||
ConversionError,
|
||||
ConversionOptions,
|
||||
DeepseekV41Encoding,
|
||||
MessagesRequest,
|
||||
)
|
||||
|
||||
USES = [
|
||||
{"type": "tool_use", "id": ident, "name": "lookup", "input": {"key": key}}
|
||||
for ident, key in (("call_a", "a"), ("call_b", "b"))
|
||||
]
|
||||
RESULTS = [
|
||||
{"type": "tool_result", "tool_use_id": ident, "content": value}
|
||||
for ident, value in (("call_a", "1"), ("call_b", "2"))
|
||||
]
|
||||
|
||||
|
||||
def convert(messages):
|
||||
return MessagesRequest(
|
||||
{
|
||||
"model": "DeepSeek-V4.1-Flash",
|
||||
"thinking": {"type": "disabled"},
|
||||
"messages": copy.deepcopy(messages),
|
||||
"tools": [
|
||||
{"name": "lookup", "input_schema": {"type": "object", "properties": {}}}
|
||||
],
|
||||
}
|
||||
).convert(ConversionOptions())
|
||||
|
||||
|
||||
def render(messages):
|
||||
return (
|
||||
DeepseekV41Encoding().render_conversation(convert(messages).conversation).prompt
|
||||
)
|
||||
|
||||
|
||||
def history():
|
||||
return [
|
||||
{"role": "user", "content": "Look up both keys."},
|
||||
{"role": "assistant", "content": USES},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reverse", [False, True])
|
||||
@pytest.mark.parametrize("split_assistant", [False, True])
|
||||
def test_parallel_tool_results_across_consecutive_user_messages(
|
||||
reverse, split_assistant
|
||||
):
|
||||
results = RESULTS[::-1] if reverse else RESULTS
|
||||
messages = history()
|
||||
if split_assistant:
|
||||
messages[1:] = [{"role": "assistant", "content": [use]} for use in USES]
|
||||
split = messages + [{"role": "user", "content": [result]} for result in results]
|
||||
combined = history() + [{"role": "user", "content": results}]
|
||||
assert render(split) == render(combined)
|
||||
converted = convert(split).conversation.messages
|
||||
assert [message.role for message in converted] == [
|
||||
"user",
|
||||
"assistant",
|
||||
"tool",
|
||||
"tool",
|
||||
]
|
||||
assert [message.tool_call_id for message in converted[-2:]] == [
|
||||
result["tool_use_id"] for result in results
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["user", "assistant"])
|
||||
@pytest.mark.parametrize("raw_first", [False, True])
|
||||
@pytest.mark.parametrize("raw_second", [False, True])
|
||||
def test_raw_and_block_content_coalesce(role, raw_first, raw_second):
|
||||
first = "first" if raw_first else [{"type": "text", "text": "first"}]
|
||||
second = "second" if raw_second else [{"type": "text", "text": "second"}]
|
||||
split = [{"role": role, "content": first}, {"role": role, "content": second}]
|
||||
combined = [
|
||||
{
|
||||
"role": role,
|
||||
"content": [
|
||||
{"type": "text", "text": "first"},
|
||||
{"type": "text", "text": "second"},
|
||||
],
|
||||
}
|
||||
]
|
||||
assert render(split) == render(combined)
|
||||
|
||||
|
||||
def test_text_can_follow_all_parallel_results_in_next_user_message():
|
||||
split = history() + [
|
||||
*[{"role": "user", "content": [result]} for result in RESULTS],
|
||||
{"role": "user", "content": "Now sum the values."},
|
||||
]
|
||||
combined = history() + [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [*RESULTS, {"type": "text", "text": "Now sum the values."}],
|
||||
}
|
||||
]
|
||||
assert render(split) == render(combined)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tail,match",
|
||||
[
|
||||
([{"role": "user", "content": RESULTS[:1]}], "without `tool_result`"),
|
||||
(
|
||||
[
|
||||
{"role": "user", "content": [RESULTS[0]]},
|
||||
{"role": "user", "content": [RESULTS[0], RESULTS[1]]},
|
||||
],
|
||||
"multiple `tool_result`",
|
||||
),
|
||||
(
|
||||
[
|
||||
{"role": "user", "content": [RESULTS[0]]},
|
||||
{"role": "user", "content": "interrupted"},
|
||||
{"role": "user", "content": [RESULTS[1]]},
|
||||
],
|
||||
"without `tool_result`",
|
||||
),
|
||||
(
|
||||
[
|
||||
{"role": "user", "content": [RESULTS[0]]},
|
||||
{"role": "assistant", "content": "interrupted"},
|
||||
{"role": "user", "content": [RESULTS[1]]},
|
||||
],
|
||||
"without `tool_result`",
|
||||
),
|
||||
(
|
||||
[
|
||||
{"role": "user", "content": [RESULTS[0]]},
|
||||
{"role": "system", "content": "boundary"},
|
||||
{"role": "user", "content": [RESULTS[1]]},
|
||||
],
|
||||
"without `tool_result`",
|
||||
),
|
||||
(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "unknown",
|
||||
"content": "x",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"unexpected",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_invalid_tool_histories_still_fail(tail, match):
|
||||
with pytest.raises(ConversionError, match=match):
|
||||
convert(history() + tail)
|
||||
|
||||
|
||||
def test_duplicate_tool_ids_across_assistant_messages_fail():
|
||||
with pytest.raises(ConversionError, match="must be unique"):
|
||||
convert(
|
||||
[
|
||||
{"role": "assistant", "content": [USES[0]]},
|
||||
{"role": "assistant", "content": [USES[0]]},
|
||||
{"role": "user", "content": [RESULTS[0]]},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["user", "assistant"])
|
||||
def test_empty_block_message_is_not_hidden_by_coalescing(role):
|
||||
with pytest.raises(ConversionError, match="messages.1: all messages"):
|
||||
convert([{"role": role, "content": "valid"}, {"role": role, "content": []}])
|
||||
@ -36,6 +36,7 @@ use super::schema::{
|
||||
MessagesMessage, MessagesRequest, MessagesSystemBlock, MessagesSystemContent, MessagesTool,
|
||||
MessagesToolChoice, MessagesToolReference, MessagesToolResultBlock, MessagesToolResultContent,
|
||||
};
|
||||
use super::turns::coalesce_messages;
|
||||
|
||||
/// The tools of a request after name validation.
|
||||
struct MessagesToolSet {
|
||||
@ -415,11 +416,12 @@ fn ensure_no_pending_tool_uses(
|
||||
/// message has an empty block list; when a `tool_use`, `server_tool_use`,
|
||||
/// `thinking`, or `web_search_tool_result` block appears outside an
|
||||
/// `assistant` message, or a `tool_result` block outside a `user` message;
|
||||
/// when a `tool_use` ID repeats within one message, a `tool_result` answers no
|
||||
/// when a `tool_use` ID repeats within one turn, a `tool_result` answers no
|
||||
/// `tool_use` of the preceding message or repeats an answered ID, a text,
|
||||
/// image, or document block follows a `tool_use` block in an `assistant`
|
||||
/// message, or a `tool_use` is not answered by the `tool_result` blocks at
|
||||
/// the start of the next message; when, for a model with image input, an
|
||||
/// the start of the next turn; consecutive messages with the same `user` or
|
||||
/// `assistant` role form one turn. When, for a model with image input, an
|
||||
/// image source is invalid or an `assistant` message holds an image block;
|
||||
/// or when an `assistant` message holds a `server_tool_use` or
|
||||
/// `web_search_tool_result` block under [`WebSearchBehavior::Reject`].
|
||||
@ -453,7 +455,7 @@ fn transform_messages(
|
||||
.map(|tool| (tool.name.as_str(), tool))
|
||||
.collect();
|
||||
|
||||
for (message_index, message) in messages.into_iter().enumerate() {
|
||||
for (message_index, message) in coalesce_messages(messages)? {
|
||||
match message {
|
||||
MessagesMessage::User(MessagesContent::Raw(content)) => {
|
||||
ensure_no_pending_tool_uses(message_index, &pending_tool_use_ids)?;
|
||||
|
||||
@ -2,3 +2,4 @@ pub use schema::*;
|
||||
|
||||
mod convert;
|
||||
mod schema;
|
||||
mod turns;
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
use crate::request::ConversionError;
|
||||
|
||||
use super::schema::{MessagesContent, MessagesContentBlock, MessagesMessage};
|
||||
|
||||
/// Anthropic combines consecutive user or assistant messages into one turn.
|
||||
/// Validate tool-call boundaries only after those turns have been assembled.
|
||||
pub(super) fn coalesce_messages(
|
||||
messages: Vec<MessagesMessage>,
|
||||
) -> Result<Vec<(usize, MessagesMessage)>, ConversionError> {
|
||||
let mut turns = Vec::with_capacity(messages.len());
|
||||
for (index, message) in messages.into_iter().enumerate() {
|
||||
if matches!(
|
||||
&message,
|
||||
MessagesMessage::User(MessagesContent::Blocks(blocks))
|
||||
| MessagesMessage::Assistant(MessagesContent::Blocks(blocks))
|
||||
if blocks.is_empty()
|
||||
) {
|
||||
return Err(ConversionError::bad_request(format!(
|
||||
"messages.{index}: all messages must have non-empty content"
|
||||
)));
|
||||
}
|
||||
match (turns.last_mut(), message) {
|
||||
(Some((_, MessagesMessage::User(left))), MessagesMessage::User(right))
|
||||
| (Some((_, MessagesMessage::Assistant(left))), MessagesMessage::Assistant(right)) => {
|
||||
append_content(left, right);
|
||||
}
|
||||
(_, message) => turns.push((index, message)),
|
||||
}
|
||||
}
|
||||
Ok(turns)
|
||||
}
|
||||
|
||||
fn append_content(left: &mut MessagesContent, right: MessagesContent) {
|
||||
let previous = std::mem::replace(left, MessagesContent::Blocks(Vec::new()));
|
||||
let mut blocks = into_blocks(previous);
|
||||
blocks.extend(into_blocks(right));
|
||||
*left = MessagesContent::Blocks(blocks);
|
||||
}
|
||||
|
||||
fn into_blocks(content: MessagesContent) -> Vec<MessagesContentBlock> {
|
||||
match content {
|
||||
MessagesContent::Raw(text) => vec![MessagesContentBlock::Text { text }],
|
||||
MessagesContent::Blocks(blocks) => blocks,
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user