fix(demo): preserve Responses custom tool metadata when decoding

This commit is contained in:
dvd233 2026-10-07 06:11:41 +08:00
parent 8cadfede70
commit 532c78e16c
2 changed files with 218 additions and 14 deletions

View 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());
}

View File

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