mirror of
https://github.com/deepseek-ai/deepseek-recipe.git
synced 2026-10-28 17:17:08 +08:00
Merge 2d97bff85b321a41adef296f825fa38d11893b32 into 8cadfede7063c896b944e7bae05daa3549ae97ea
This commit is contained in:
commit
f4fe26bc35
@ -29,6 +29,9 @@ inference, tool execution, and HTTP transport must be provided externally.
|
||||
DeepSeek V4.1 preprocessing with OpenCV.
|
||||
- **Tool definitions:** Function tools; the Responses API also supports tool
|
||||
namespaces and the `apply_patch` custom tool.
|
||||
- **Codex agent input:** Responses `agent_message` items with plaintext `input_text`
|
||||
content are preserved as user messages with sender and recipient attribution.
|
||||
Encrypted, mixed, empty, or non-text agent content is rejected during conversion.
|
||||
|
||||
## Not yet supported
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@ deepseek-recipe 包含一套 Rust 库以及对应的 Python bindings:支持将
|
||||
- **输出解析:** 思考、工具调用、JSON object 输出和 stop sequence。
|
||||
- **图片:** 支持以 base64 或外部 URL 传入。提供基于 OpenCV 的 DeepSeek V4.1 图片预处理。
|
||||
- **工具定义:** 支持函数工具;Responses API 还支持工具命名空间和 `apply_patch` 自定义工具。
|
||||
- **Codex Agent 输入:** Responses 中包含明文 `input_text` 的 `agent_message` 会转换为注明发送者和接收者的用户消息。加密、混合、空白或非文本的 Agent 内容在转换时被拒绝。
|
||||
|
||||
## 尚未支持
|
||||
|
||||
|
||||
64
deepseek-recipe-python/tests/test_agent_messages.py
Normal file
64
deepseek-recipe-python/tests/test_agent_messages.py
Normal file
@ -0,0 +1,64 @@
|
||||
"""Codex agent input must reach the rendered prompt through the shared converter."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from deepseek_recipe import (
|
||||
ConversionError,
|
||||
ConversionOptions,
|
||||
DeepseekV41Encoding,
|
||||
ResponsesRequest,
|
||||
Tokenizer,
|
||||
UserMessage,
|
||||
)
|
||||
|
||||
|
||||
def agent_message(content):
|
||||
return {
|
||||
"type": "agent_message",
|
||||
"author": "/root/worker",
|
||||
"recipient": "/root",
|
||||
"content": content,
|
||||
}
|
||||
|
||||
|
||||
def test_agent_message_reaches_prompt_and_token_ids():
|
||||
request = {
|
||||
"model": "test-model",
|
||||
"input": [agent_message([
|
||||
{"type": "input_text", "text": "Message Type: FINAL_ANSWER\nPayload:"},
|
||||
{"type": "input_text", "text": "CHILD_RESULT_92714: 世界 🦀"},
|
||||
])],
|
||||
}
|
||||
converted = ResponsesRequest(request).convert(ConversionOptions())
|
||||
(message,) = converted.conversation.messages
|
||||
assert isinstance(message, UserMessage)
|
||||
assert "from /root/worker to /root" in message.content
|
||||
assert "FINAL_ANSWER\nPayload:\nCHILD_RESULT_92714: 世界 🦀" in message.content
|
||||
|
||||
tokenizer_path = Path(__file__).resolve().parents[2] / "static/tokenizers/v41/tokenizer.json"
|
||||
tokenizer = Tokenizer.from_file(str(tokenizer_path))
|
||||
encoding = DeepseekV41Encoding().with_tokenizer(tokenizer)
|
||||
prompt = encoding.render_conversation(converted.conversation).prompt
|
||||
token_ids = encoding.encode(converted.conversation)
|
||||
assert "CHILD_RESULT_92714: 世界 🦀" in prompt
|
||||
assert token_ids
|
||||
assert token_ids == tokenizer.encode(prompt)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content", [
|
||||
[],
|
||||
[{"type": "input_text", "text": " "}],
|
||||
[{"type": "encrypted_content", "encrypted_content": "secret-ciphertext"}],
|
||||
[
|
||||
{"type": "input_text", "text": "VISIBLE_PREFIX"},
|
||||
{"type": "encrypted_content", "encrypted_content": "secret-ciphertext"},
|
||||
],
|
||||
])
|
||||
def test_unreadable_agent_input_is_rejected(content):
|
||||
request = ResponsesRequest({"model": "test-model", "input": [agent_message(content)]})
|
||||
with pytest.raises(ConversionError) as error:
|
||||
request.convert(ConversionOptions())
|
||||
assert "secret-ciphertext" not in str(error.value)
|
||||
assert "VISIBLE_PREFIX" not in str(error.value)
|
||||
@ -20,11 +20,11 @@ use crate::request::{
|
||||
use crate::stream::state_machine::{ParsingOptions, ReasoningStage};
|
||||
|
||||
use super::schema::{
|
||||
ResponsesContentBlock, ResponsesInput, ResponsesInputImage, ResponsesInputItem,
|
||||
ResponsesInputMessage, ResponsesMessageContent, ResponsesMessageRole, ResponsesReasoningEffort,
|
||||
ResponsesReasoningItem, ResponsesRequest, ResponsesTextConfig, ResponsesTextFormat,
|
||||
ResponsesTool, ResponsesToolCallOutputContent, ResponsesToolCallOutputItem,
|
||||
ResponsesTypedInputItem,
|
||||
ResponsesAgentMessage, ResponsesContentBlock, ResponsesInput, ResponsesInputImage,
|
||||
ResponsesInputItem, ResponsesInputMessage, ResponsesMessageContent, ResponsesMessageRole,
|
||||
ResponsesReasoningEffort, ResponsesReasoningItem, ResponsesRequest, ResponsesTextConfig,
|
||||
ResponsesTextFormat, ResponsesTool, ResponsesToolCallOutputContent,
|
||||
ResponsesToolCallOutputItem, ResponsesTypedInputItem,
|
||||
};
|
||||
use super::tools::ResponsesToolSet;
|
||||
|
||||
@ -125,6 +125,14 @@ fn item_contains_image_special_token(item: &ResponsesInputItem) -> bool {
|
||||
ResponsesTypedInputItem::Message(message) => {
|
||||
message_content_contains_image_special_token(&message.content)
|
||||
}
|
||||
ResponsesTypedInputItem::AgentMessage(message) => {
|
||||
message.author.contains(IMAGE_SPECIAL_TOKEN)
|
||||
|| message.recipient.contains(IMAGE_SPECIAL_TOKEN)
|
||||
|| message.content.iter().any(|block| {
|
||||
matches!(block, ResponsesContentBlock::InputText { text }
|
||||
if text.contains(IMAGE_SPECIAL_TOKEN))
|
||||
})
|
||||
}
|
||||
ResponsesTypedInputItem::FunctionCallOutput(output) => {
|
||||
output_content_contains_image_special_token(&output.output)
|
||||
}
|
||||
@ -373,6 +381,42 @@ fn push_message(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl ResponsesAgentMessage {
|
||||
/// Preserve incoming agent content as user input with explicit attribution.
|
||||
fn into_user_message(self, item_idx: usize) -> Result<ResponsesInputMessage, ConversionError> {
|
||||
let path = format!("input[{item_idx}]");
|
||||
if self.author.trim().is_empty() || self.recipient.trim().is_empty() {
|
||||
return Err(ConversionError::bad_request(format!(
|
||||
"{path}: agent_message requires a non-empty author and recipient",
|
||||
)));
|
||||
}
|
||||
let mut texts = Vec::with_capacity(self.content.len());
|
||||
for (block_idx, block) in self.content.into_iter().enumerate() {
|
||||
match block {
|
||||
ResponsesContentBlock::InputText { text } => texts.push(text),
|
||||
_ => {
|
||||
return Err(ConversionError::bad_request(format!(
|
||||
"{path}.content[{block_idx}]: agent_message supports only plaintext input_text content",
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let text = texts.join("\n");
|
||||
if text.trim().is_empty() {
|
||||
return Err(ConversionError::bad_request(format!(
|
||||
"{path}: agent_message requires non-empty plaintext content",
|
||||
)));
|
||||
}
|
||||
Ok(ResponsesInputMessage {
|
||||
role: ResponsesMessageRole::User,
|
||||
content: ResponsesMessageContent::String(format!(
|
||||
"Agent message from {} to {}:\n{text}",
|
||||
self.author, self.recipient,
|
||||
)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn push_reasoning(reasoning: ResponsesReasoningItem, messages: &mut Vec<InputMessage>) {
|
||||
let text = reasoning
|
||||
.content
|
||||
@ -433,6 +477,11 @@ fn transform_input_items(
|
||||
append_tool_call_group(&mut pending_group, messages);
|
||||
push_message(msg, item_idx, messages)?;
|
||||
}
|
||||
ResponsesTypedInputItem::AgentMessage(msg) => {
|
||||
check_pending_calls(&pending_call_ids)?;
|
||||
append_tool_call_group(&mut pending_group, messages);
|
||||
push_message(msg.into_user_message(item_idx)?, item_idx, messages)?;
|
||||
}
|
||||
ResponsesTypedInputItem::Reasoning(reasoning) => {
|
||||
check_pending_calls(&pending_call_ids)?;
|
||||
append_tool_call_group(&mut pending_group, messages);
|
||||
|
||||
@ -3,6 +3,9 @@
|
||||
//! [`ResponsesRequest`] supports text, images, plaintext reasoning history,
|
||||
//! instructions, sampling parameters, reasoning effort, and JSON object output.
|
||||
//! Client tools include functions, namespaces, and the `apply_patch` custom tool.
|
||||
//! Codex `agent_message` input items become attributed user messages, preserving
|
||||
//! the sender, recipient, and plaintext content in history order. Encrypted,
|
||||
//! mixed, empty, or non-text agent content is rejected rather than discarded.
|
||||
//!
|
||||
//! Document content, encrypted reasoning, conversation storage, and hosted tool
|
||||
//! execution are outside the supported scope. Web-search declarations and
|
||||
@ -12,8 +15,8 @@
|
||||
//! Input text must not spell out the image placeholder. Every placeholder in a
|
||||
//! prompt corresponds to one image source, and the adapter inserts them for
|
||||
//! image blocks only, so text input, instructions, message text, tool output
|
||||
//! text, reasoning text, tool definitions, and historical tool calls that carry
|
||||
//! the placeholder spelling are rejected.
|
||||
//! text, reasoning text, agent text and attribution, tool definitions, and
|
||||
//! historical tool calls that carry the placeholder spelling are rejected.
|
||||
//!
|
||||
//! JSON Schema output, `logprobs`, and `top_logprobs` are accepted and ignored.
|
||||
//! Function-tool `strict` settings are passed through for the caller to enforce.
|
||||
|
||||
@ -58,6 +58,7 @@ impl<'de> Deserialize<'de> for ResponsesInputItem {
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ResponsesTypedInputItem {
|
||||
Message(ResponsesInputMessage),
|
||||
AgentMessage(ResponsesAgentMessage),
|
||||
FunctionCall(ResponsesFunctionCall),
|
||||
FunctionCallOutput(ResponsesFunctionCallOutput),
|
||||
Reasoning(ResponsesReasoningItem),
|
||||
@ -68,6 +69,15 @@ pub enum ResponsesTypedInputItem {
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/// A Codex agent-to-agent input item. Only plaintext `input_text` blocks are
|
||||
/// supported; encrypted or other content is rejected during conversion.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ResponsesAgentMessage {
|
||||
pub author: String,
|
||||
pub recipient: String,
|
||||
pub content: Vec<ResponsesContentBlock>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ResponsesInputMessage {
|
||||
pub role: ResponsesMessageRole,
|
||||
|
||||
214
deepseek-recipe/tests/responses_agent_message.rs
Normal file
214
deepseek-recipe/tests/responses_agent_message.rs
Normal file
@ -0,0 +1,214 @@
|
||||
use deepseek_recipe::openai::ResponsesRequest;
|
||||
use deepseek_recipe::request::{ConversionError, ConversionOptions, ProtocolRequest};
|
||||
use deepseek_recipe_core::messages::InputMessage;
|
||||
use deepseek_recipe_core::multimodal::IMAGE_SPECIAL_TOKEN;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn agent_message(author: &str, recipient: &str, content: Value) -> Value {
|
||||
json!({
|
||||
"type": "agent_message",
|
||||
"id": "amsg_test",
|
||||
"author": author,
|
||||
"recipient": recipient,
|
||||
"content": content,
|
||||
"internal_chat_message_metadata_passthrough": {"turn_id": "test-turn"},
|
||||
})
|
||||
}
|
||||
|
||||
fn convert(input: Vec<Value>) -> Result<Vec<InputMessage>, ConversionError> {
|
||||
let request: ResponsesRequest = serde_json::from_value(json!({
|
||||
"model": "test-model",
|
||||
"input": input,
|
||||
}))
|
||||
.unwrap();
|
||||
request
|
||||
.convert(ConversionOptions::default())
|
||||
.map(|converted| converted.conversation.messages)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plaintext_agent_tasks_messages_and_results_remain_in_history_order() {
|
||||
let mut input = vec![json!({"role": "user", "content": "Before agents"})];
|
||||
for (kind, author, recipient) in [
|
||||
("NEW_TASK", "/root", "/root/worker"),
|
||||
("MESSAGE", "/root", "/root/worker"),
|
||||
("FINAL_ANSWER", "/root/worker", "/root"),
|
||||
] {
|
||||
input.push(agent_message(
|
||||
author,
|
||||
recipient,
|
||||
json!([
|
||||
{"type": "input_text", "text": format!("Message Type: {kind}\nPayload:")},
|
||||
{"type": "input_text", "text": format!("{kind} marker: 世界 🦀")},
|
||||
]),
|
||||
));
|
||||
}
|
||||
input.push(json!({"role": "user", "content": "After agents"}));
|
||||
let messages = convert(input).unwrap();
|
||||
assert_eq!(messages.len(), 5);
|
||||
for (index, (kind, author, recipient)) in [
|
||||
("NEW_TASK", "/root", "/root/worker"),
|
||||
("MESSAGE", "/root", "/root/worker"),
|
||||
("FINAL_ANSWER", "/root/worker", "/root"),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let InputMessage::User {
|
||||
content,
|
||||
image_sources,
|
||||
} = &messages[index + 1]
|
||||
else {
|
||||
panic!("An incoming agent message must be user input");
|
||||
};
|
||||
assert!(content.contains(author));
|
||||
assert!(content.contains(recipient));
|
||||
assert!(content.contains(&format!(
|
||||
"Message Type: {kind}\nPayload:\n{kind} marker: 世界 🦀"
|
||||
)));
|
||||
assert!(image_sources.is_empty());
|
||||
}
|
||||
assert!(
|
||||
matches!(&messages[0], InputMessage::User { content, .. } if content == "Before agents")
|
||||
);
|
||||
assert!(
|
||||
matches!(&messages[4], InputMessage::User { content, .. } if content == "After agents")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_messages_preserve_completed_tool_groups() {
|
||||
let messages = convert(vec![
|
||||
json!({"type": "function_call", "call_id": "check", "name": "check", "arguments": "{}"}),
|
||||
json!({"type": "function_call_output", "call_id": "check", "output": "CHECK_COMPLETED"}),
|
||||
agent_message("/root/worker", "/root", json!([{"type": "input_text", "text": "CHILD_RESULT"}])),
|
||||
json!({"type": "custom_tool_call", "call_id": "patch", "name": "apply_patch", "input": "patch text"}),
|
||||
json!({"type": "custom_tool_call_output", "call_id": "patch", "output": "PATCH_COMPLETED"}),
|
||||
agent_message("/root", "/root/worker", json!([{"type": "input_text", "text": "FOLLOWUP_TASK"}])),
|
||||
]).unwrap();
|
||||
assert_eq!(messages.len(), 6);
|
||||
assert!(
|
||||
matches!(&messages[0], InputMessage::Assistant { tool_calls: Some(calls), .. } if calls[0].id == "check")
|
||||
);
|
||||
assert!(
|
||||
matches!(&messages[1], InputMessage::Tool { content, .. } if content == "CHECK_COMPLETED")
|
||||
);
|
||||
assert!(
|
||||
matches!(&messages[2], InputMessage::User { content, .. } if content.contains("CHILD_RESULT"))
|
||||
);
|
||||
assert!(
|
||||
matches!(&messages[3], InputMessage::Assistant { tool_calls: Some(calls), .. } if calls[0].id == "patch")
|
||||
);
|
||||
assert!(
|
||||
matches!(&messages[4], InputMessage::Tool { content, .. } if content == "PATCH_COMPLETED")
|
||||
);
|
||||
assert!(
|
||||
matches!(&messages[5], InputMessage::User { content, .. } if content.contains("FOLLOWUP_TASK"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_messages_do_not_bypass_unresolved_tool_calls() {
|
||||
let error = convert(vec![
|
||||
json!({"type": "function_call", "call_id": "pending", "name": "check", "arguments": "{}"}),
|
||||
agent_message(
|
||||
"/root/worker",
|
||||
"/root",
|
||||
json!([{"type": "input_text", "text": "CHILD_RESULT"}]),
|
||||
),
|
||||
json!({"type": "function_call_output", "call_id": "pending", "output": "CHECK_COMPLETED"}),
|
||||
])
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, ConversionError::BadRequest { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_mixed_unknown_and_empty_agent_content_is_rejected() {
|
||||
for content in [
|
||||
json!([]),
|
||||
json!([{"type": "input_text", "text": " \n"}]),
|
||||
json!([{"type": "encrypted_content", "encrypted_content": "secret-ciphertext"}]),
|
||||
json!([
|
||||
{"type": "input_text", "text": "VISIBLE_PREFIX"},
|
||||
{"type": "encrypted_content", "encrypted_content": "secret-ciphertext"},
|
||||
]),
|
||||
json!([{"type": "unknown_content", "text": "OPAQUE"}]),
|
||||
json!([{"type": "output_text", "text": "Not agent input text"}]),
|
||||
json!([{"type": "input_image", "image_url": "https://example.com/image.png"}]),
|
||||
json!([{"type": "input_file", "file_id": "file-test"}]),
|
||||
] {
|
||||
let error = convert(vec![agent_message("/root", "/root/worker", content)]).unwrap_err();
|
||||
assert!(matches!(error, ConversionError::BadRequest { .. }));
|
||||
assert!(!error.to_string().contains("secret-ciphertext"));
|
||||
assert!(!error.to_string().contains("VISIBLE_PREFIX"));
|
||||
}
|
||||
for (author, recipient) in [("", "/root"), ("/root", " \n")] {
|
||||
assert!(
|
||||
convert(vec![agent_message(
|
||||
author,
|
||||
recipient,
|
||||
json!([{"type": "input_text", "text": "TASK"}])
|
||||
)])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_agent_items_fail_deserialization() {
|
||||
for content in [
|
||||
Value::Null,
|
||||
json!("TASK"),
|
||||
json!([{"type": "input_text", "text": 42}]),
|
||||
] {
|
||||
let request = json!({"model": "test-model", "input": [agent_message("/root", "/root/worker", content)]});
|
||||
assert!(serde_json::from_value::<ResponsesRequest>(request).is_err());
|
||||
}
|
||||
for field in ["author", "recipient", "content"] {
|
||||
let mut item = agent_message(
|
||||
"/root",
|
||||
"/root/worker",
|
||||
json!([{"type": "input_text", "text": "TASK"}]),
|
||||
);
|
||||
item.as_object_mut().unwrap().remove(field);
|
||||
assert!(
|
||||
serde_json::from_value::<ResponsesRequest>(
|
||||
json!({"model": "test-model", "input": [item]})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_text_and_attribution_follow_image_placeholder_validation() {
|
||||
for (author, recipient, text) in [
|
||||
("/root", "/root/worker", IMAGE_SPECIAL_TOKEN),
|
||||
(IMAGE_SPECIAL_TOKEN, "/root/worker", "TASK"),
|
||||
("/root", IMAGE_SPECIAL_TOKEN, "TASK"),
|
||||
] {
|
||||
let error = convert(vec![agent_message(
|
||||
author,
|
||||
recipient,
|
||||
json!([{"type": "input_text", "text": text}]),
|
||||
)])
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, ConversionError::BadRequest { .. }));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_unsupported_items_keep_their_existing_behavior() {
|
||||
let messages = convert(vec![
|
||||
json!({"type": "web_search_call"}),
|
||||
json!({"role": "user", "content": "Continue"}),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
messages,
|
||||
vec![InputMessage::User {
|
||||
content: "Continue".into(),
|
||||
image_sources: vec![]
|
||||
}]
|
||||
);
|
||||
}
|
||||
@ -83,6 +83,38 @@ def test_custom_inference():
|
||||
assert response_text("/v1/responses", response.json()) == "Custom answer"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stream", [False, True])
|
||||
def test_agent_input_reaches_inference_and_unreadable_content_does_not(stream):
|
||||
prompts = []
|
||||
|
||||
async def infer(request):
|
||||
prompts.append(request.prompt)
|
||||
assert "from /root/worker to /root" in request.prompt
|
||||
assert "CHILD_RESULT_92714" in request.prompt
|
||||
yield InferenceChunk.text(THINKING_END_TOKEN + "Agent result received")
|
||||
|
||||
item = {
|
||||
"type": "agent_message",
|
||||
"author": "/root/worker",
|
||||
"recipient": "/root",
|
||||
"content": [{"type": "input_text", "text": "CHILD_RESULT_92714"}],
|
||||
}
|
||||
with TestClient(SERVER.create_app(infer)) as client:
|
||||
accepted = client.post("/v1/responses", json={
|
||||
"model": "test-model", "input": [item], "stream": stream,
|
||||
})
|
||||
assert accepted.status_code == 200, accepted.text
|
||||
assert "Agent result received" in accepted.text
|
||||
encrypted = {"type": "encrypted_content", "encrypted_content": "secret-ciphertext"}
|
||||
for content in [[encrypted], [*item["content"], encrypted]]:
|
||||
rejected = client.post("/v1/responses", json={
|
||||
"model": "test-model", "input": [{**item, "content": content}], "stream": stream,
|
||||
})
|
||||
assert rejected.status_code == 400, rejected.text
|
||||
assert "secret-ciphertext" not in rejected.text
|
||||
assert len(prompts) == 1
|
||||
|
||||
|
||||
def test_invalid_request():
|
||||
with TestClient(SERVER.app) as client:
|
||||
response = client.post("/v1/chat/completions", json={})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user