mirror of
https://github.com/deepseek-ai/deepseek-recipe.git
synced 2026-10-28 17:17:08 +08:00
Merge 532c78e16c70c9cb2a1dec4aff37585aff3d9771 into 8cadfede7063c896b944e7bae05daa3549ae97ea
This commit is contained in:
commit
f95633cb66
161
encoding-decoding-demo/src/decode_custom_tool_regression.rs
Normal file
161
encoding-decoding-demo/src/decode_custom_tool_regression.rs
Normal file
@ -0,0 +1,161 @@
|
||||
use super::{ApiFormat, DecodeFinishReason, DecodeRequest, decode_handler};
|
||||
use axum::Json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const PATCH_INPUT: &str =
|
||||
"*** Begin Patch\n*** Add File: hello.txt\n+Hello, 世界 😀\n*** End Patch";
|
||||
|
||||
fn model_output() -> String {
|
||||
format!(
|
||||
"<|DSML| calls>\n<|DSML| invoke name=\"apply_patch\">\n<|DSML| parameter name=\"input\" string=\"true\">{PATCH_INPUT}</|DSML| parameter>\n</|DSML| invoke>\n</|DSML| calls><|end▁of▁sentence|>"
|
||||
)
|
||||
}
|
||||
|
||||
fn parameters() -> Value {
|
||||
json!({"type": "object", "properties": {"input": {"type": "string"}}, "required": ["input"]})
|
||||
}
|
||||
|
||||
async fn decode(format: ApiFormat, body: Value) -> Value {
|
||||
let result = decode_handler(Json(DecodeRequest {
|
||||
format,
|
||||
body,
|
||||
output: model_output(),
|
||||
finish_reason: DecodeFinishReason::Stop,
|
||||
}))
|
||||
.await;
|
||||
match result {
|
||||
Ok(Json(payload)) => payload.response,
|
||||
Err((status, _)) => panic!("demo handler unexpectedly rejected fixture with {status}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn named_output(response: &Value) -> &Value {
|
||||
response["output"]
|
||||
.as_array()
|
||||
.expect("Responses output must be an array")
|
||||
.iter()
|
||||
.find(|item| item["name"] == "apply_patch")
|
||||
.expect("the native demo handler must produce the declared tool call")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn responses_custom_tool_keeps_native_input() {
|
||||
let response = decode(
|
||||
ApiFormat::Responses,
|
||||
json!({
|
||||
"input": "Create hello.txt",
|
||||
"tools": [{"type": "custom", "name": "apply_patch"}]
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
eprintln!("native-demo-response: {response}");
|
||||
let call = named_output(&response);
|
||||
let actual_type = call["type"]
|
||||
.as_str()
|
||||
.expect("tool call type must be a string");
|
||||
assert_eq!(
|
||||
actual_type, "custom_tool_call",
|
||||
"CUSTOM_TOOL_TYPE_MISMATCH: demo must retain the Responses custom tool declaration"
|
||||
);
|
||||
assert_eq!(call["input"], PATCH_INPUT);
|
||||
assert!(call.get("arguments").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn control_responses_function_with_same_name_keeps_arguments() {
|
||||
let response = decode(
|
||||
ApiFormat::Responses,
|
||||
json!({
|
||||
"input": "Create hello.txt",
|
||||
"tools": [{"type": "function", "name": "apply_patch", "parameters": parameters()}]
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let call = named_output(&response);
|
||||
assert_eq!(call["type"], "function_call");
|
||||
let arguments: Value = serde_json::from_str(call["arguments"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(arguments["input"], PATCH_INPUT);
|
||||
assert!(call.get("input").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn control_chat_completions_keeps_function_arguments() {
|
||||
let response = decode(
|
||||
ApiFormat::ChatCompletions,
|
||||
json!({
|
||||
"messages": [{"role": "user", "content": "Create hello.txt"}],
|
||||
"tools": [{"type": "function", "function": {"name": "apply_patch", "parameters": parameters()}}]
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let call = &response["choices"][0]["message"]["tool_calls"][0];
|
||||
assert_eq!(call["type"], "function");
|
||||
assert_eq!(call["function"]["name"], "apply_patch");
|
||||
let arguments: Value =
|
||||
serde_json::from_str(call["function"]["arguments"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(arguments["input"], PATCH_INPUT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn control_messages_keeps_tool_input() {
|
||||
let response = decode(
|
||||
ApiFormat::Messages,
|
||||
json!({
|
||||
"messages": [{"role": "user", "content": "Create hello.txt"}],
|
||||
"tools": [{"name": "apply_patch", "input_schema": parameters()}]
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let call = response["content"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|item| item["type"] == "tool_use")
|
||||
.expect("Messages tool-use item");
|
||||
assert_eq!(call["name"], "apply_patch");
|
||||
assert_eq!(call["input"]["input"], PATCH_INPUT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn responses_stream_request_still_returns_complete_custom_tool_call() {
|
||||
let response = decode(
|
||||
ApiFormat::Responses,
|
||||
json!({
|
||||
"input": "Create hello.txt",
|
||||
"stream": true,
|
||||
"tools": [{"type": "custom", "name": "apply_patch"}]
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response["object"], "response");
|
||||
assert_eq!(response["status"], "completed");
|
||||
let call = named_output(&response);
|
||||
assert_eq!(call["type"], "custom_tool_call");
|
||||
assert_eq!(call["input"], PATCH_INPUT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn responses_custom_input_preserves_metadata_names_and_escapes() {
|
||||
let input = "*** Begin Patch\n*** Add File: metadata.json\n+{\"id\":\"keep\",\"model\":\"literal\",\"usage\":7,\"status\":\"ready\",\"input\":\"C:\\\\work\"}\n+世界 😀\n*** End Patch";
|
||||
let output = format!(
|
||||
"<|DSML| calls>\n<|DSML| invoke name=\"apply_patch\">\n<|DSML| parameter name=\"input\" string=\"true\">{input}</|DSML| parameter>\n</|DSML| invoke>\n</|DSML| calls>"
|
||||
);
|
||||
let result = decode_handler(Json(DecodeRequest {
|
||||
format: ApiFormat::Responses,
|
||||
body: json!({
|
||||
"input": "Create metadata.json",
|
||||
"tools": [{"type": "custom", "name": "apply_patch"}]
|
||||
}),
|
||||
output,
|
||||
finish_reason: DecodeFinishReason::Stop,
|
||||
}))
|
||||
.await;
|
||||
let response = match result {
|
||||
Ok(Json(payload)) => payload.response,
|
||||
Err((status, _)) => panic!("demo handler unexpectedly rejected fixture with {status}"),
|
||||
};
|
||||
let call = named_output(&response);
|
||||
assert_eq!(call["type"], "custom_tool_call");
|
||||
assert_eq!(call["input"], input);
|
||||
assert!(call.get("arguments").is_none());
|
||||
}
|
||||
@ -263,18 +263,23 @@ fn conversion_failed(error: ConversionError) -> DemoError {
|
||||
(status, Json(error.into_response()))
|
||||
}
|
||||
|
||||
fn convert_request<T>(mut body: serde_json::Value) -> Result<ConversationRequest, DemoError>
|
||||
fn parse_request<T>(mut body: serde_json::Value) -> Result<T, DemoError>
|
||||
where
|
||||
T: ProtocolRequest + DeserializeOwned,
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
// Requests use V4.1 prompt rendering and a default model name.
|
||||
if let Some(body) = body.as_object_mut() {
|
||||
body.entry("model")
|
||||
.or_insert_with(|| "encoding-decoding-demo".into());
|
||||
}
|
||||
let request: T = serde_json::from_value(body)
|
||||
.map_err(|err| bad_request(format!("invalid request body: {err}")))?;
|
||||
request
|
||||
serde_json::from_value(body).map_err(|err| bad_request(format!("invalid request body: {err}")))
|
||||
}
|
||||
|
||||
fn convert_request<T>(body: serde_json::Value) -> Result<ConversationRequest, DemoError>
|
||||
where
|
||||
T: ProtocolRequest + DeserializeOwned,
|
||||
{
|
||||
parse_request::<T>(body)?
|
||||
.convert(ConversionOptions::default())
|
||||
.map_err(conversion_failed)
|
||||
}
|
||||
@ -301,18 +306,24 @@ async fn render_handler(
|
||||
Ok(Json(RenderResponse { prompt, segments }))
|
||||
}
|
||||
|
||||
async fn decode_output<T>(request: DecodeRequest) -> Result<serde_json::Value, DemoError>
|
||||
async fn decode_output<T>(
|
||||
mut converted: ConversationRequest,
|
||||
output: &str,
|
||||
finish_reason: DecodeFinishReason,
|
||||
configure_generator: impl FnOnce(
|
||||
<T::Response as ProtocolResponse>::ChunkGenerator,
|
||||
) -> <T::Response as ProtocolResponse>::ChunkGenerator,
|
||||
) -> Result<serde_json::Value, DemoError>
|
||||
where
|
||||
T: ProtocolRequest + DeserializeOwned,
|
||||
T: ProtocolRequest,
|
||||
<<T::Response as ProtocolResponse>::ChunkGenerator as ChunkGenerator>::Chunk: Send,
|
||||
{
|
||||
let mut converted = convert_request::<T>(request.body)?;
|
||||
// This endpoint always accumulates a complete response, including usage,
|
||||
// even when the input request originally selected streaming transport.
|
||||
converted.stream = false;
|
||||
let (output, finish_reason) = match request.output.split_once(EOS_TOKEN) {
|
||||
let (output, finish_reason) = match output.split_once(EOS_TOKEN) {
|
||||
Some((output, _)) => (output, InferenceFinishReason::Stop),
|
||||
None => (request.output.as_str(), request.finish_reason.into()),
|
||||
None => (output, finish_reason.into()),
|
||||
};
|
||||
// The demo accepts complete assistant output. Consume its leading frame
|
||||
// markers here; the stream parser normally starts after a prompt prefill.
|
||||
@ -332,7 +343,7 @@ where
|
||||
.clone()
|
||||
.unwrap_or_else(|| "encoding-decoding-demo".into());
|
||||
let id = "demo-id".to_owned();
|
||||
let generator = T::chunk_generator(&converted, id.clone(), model.clone());
|
||||
let generator = configure_generator(T::chunk_generator(&converted, id.clone(), model.clone()));
|
||||
let processor = StreamProcessor::new(generator, converted.parsing_options);
|
||||
let inference = tokio_stream::iter([
|
||||
InferenceChunk::Text {
|
||||
@ -363,9 +374,38 @@ async fn decode_handler(
|
||||
) -> Result<Json<DecodeResponse>, DemoError> {
|
||||
let segments = segment(&request.output);
|
||||
let response = match request.format {
|
||||
ApiFormat::ChatCompletions => decode_output::<ChatCompletionRequest>(request).await?,
|
||||
ApiFormat::Responses => decode_output::<ResponsesRequest>(request).await?,
|
||||
ApiFormat::Messages => decode_output::<MessagesRequest>(request).await?,
|
||||
ApiFormat::ChatCompletions => {
|
||||
decode_output::<ChatCompletionRequest>(
|
||||
convert_request::<ChatCompletionRequest>(request.body)?,
|
||||
&request.output,
|
||||
request.finish_reason,
|
||||
|generator| generator,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
ApiFormat::Responses => {
|
||||
let body = parse_request::<ResponsesRequest>(request.body)?;
|
||||
let custom_tool_names = body.custom_tool_names();
|
||||
let converted = body
|
||||
.convert(ConversionOptions::default())
|
||||
.map_err(conversion_failed)?;
|
||||
decode_output::<ResponsesRequest>(
|
||||
converted,
|
||||
&request.output,
|
||||
request.finish_reason,
|
||||
|generator| generator.with_custom_tool_names(custom_tool_names),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
ApiFormat::Messages => {
|
||||
decode_output::<MessagesRequest>(
|
||||
convert_request::<MessagesRequest>(request.body)?,
|
||||
&request.output,
|
||||
request.finish_reason,
|
||||
|generator| generator,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok(Json(DecodeResponse { response, segments }))
|
||||
}
|
||||
@ -415,3 +455,6 @@ async fn main() {
|
||||
println!("deepseek-recipe demo page: http://{addr}");
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod decode_custom_tool_regression;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user