Merge 2d97bff85b321a41adef296f825fa38d11893b32 into 8cadfede7063c896b944e7bae05daa3549ae97ea

This commit is contained in:
sine 2026-10-08 04:19:15 +08:00 committed by GitHub
commit f4fe26bc35
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 383 additions and 7 deletions

View File

@ -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

View File

@ -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 内容在转换时被拒绝。
## 尚未支持

View 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)

View File

@ -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);

View File

@ -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.

View File

@ -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,

View 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![]
}]
);
}

View File

@ -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={})