From 532c78e16c70c9cb2a1dec4aff37585aff3d9771 Mon Sep 17 00:00:00 2001 From: dvd233 <111864431+dvd233@users.noreply.github.com> Date: Wed, 7 Oct 2026 06:11:41 +0800 Subject: [PATCH] fix(demo): preserve Responses custom tool metadata when decoding --- .../src/decode_custom_tool_regression.rs | 161 ++++++++++++++++++ encoding-decoding-demo/src/main.rs | 71 ++++++-- 2 files changed, 218 insertions(+), 14 deletions(-) create mode 100644 encoding-decoding-demo/src/decode_custom_tool_regression.rs diff --git a/encoding-decoding-demo/src/decode_custom_tool_regression.rs b/encoding-decoding-demo/src/decode_custom_tool_regression.rs new file mode 100644 index 0000000..c1fff9e --- /dev/null +++ b/encoding-decoding-demo/src/decode_custom_tool_regression.rs @@ -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}\n\n<|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}\n\n" + ); + 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()); +} diff --git a/encoding-decoding-demo/src/main.rs b/encoding-decoding-demo/src/main.rs index 8bbf1dc..b5229b6 100644 --- a/encoding-decoding-demo/src/main.rs +++ b/encoding-decoding-demo/src/main.rs @@ -263,18 +263,23 @@ fn conversion_failed(error: ConversionError) -> DemoError { (status, Json(error.into_response())) } -fn convert_request(mut body: serde_json::Value) -> Result +fn parse_request(mut body: serde_json::Value) -> Result 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(body: serde_json::Value) -> Result +where + T: ProtocolRequest + DeserializeOwned, +{ + parse_request::(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(request: DecodeRequest) -> Result +async fn decode_output( + mut converted: ConversationRequest, + output: &str, + finish_reason: DecodeFinishReason, + configure_generator: impl FnOnce( + ::ChunkGenerator, + ) -> ::ChunkGenerator, +) -> Result where - T: ProtocolRequest + DeserializeOwned, + T: ProtocolRequest, <::ChunkGenerator as ChunkGenerator>::Chunk: Send, { - let mut converted = convert_request::(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, DemoError> { let segments = segment(&request.output); let response = match request.format { - ApiFormat::ChatCompletions => decode_output::(request).await?, - ApiFormat::Responses => decode_output::(request).await?, - ApiFormat::Messages => decode_output::(request).await?, + ApiFormat::ChatCompletions => { + decode_output::( + convert_request::(request.body)?, + &request.output, + request.finish_reason, + |generator| generator, + ) + .await? + } + ApiFormat::Responses => { + let body = parse_request::(request.body)?; + let custom_tool_names = body.custom_tool_names(); + let converted = body + .convert(ConversionOptions::default()) + .map_err(conversion_failed)?; + decode_output::( + converted, + &request.output, + request.finish_reason, + |generator| generator.with_custom_tool_names(custom_tool_names), + ) + .await? + } + ApiFormat::Messages => { + decode_output::( + convert_request::(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;