Bug fix: Goose unable to work with DeepSeek and Alibaba using OpenAI responses API. (#11298)
Co-authored-by: Kirill Frolov <k.frolov+1@samsung.com>
This commit is contained in:
@@ -95,6 +95,9 @@ pub enum ResponseContentBlock {
|
||||
Refusal {
|
||||
refusal: String,
|
||||
},
|
||||
ReasoningText {
|
||||
text: String,
|
||||
},
|
||||
ToolCall {
|
||||
id: String,
|
||||
name: String,
|
||||
@@ -386,6 +389,9 @@ pub enum ContentBlockPart {
|
||||
Refusal {
|
||||
refusal: String,
|
||||
},
|
||||
ReasoningText {
|
||||
text: String,
|
||||
},
|
||||
ToolCall {
|
||||
id: String,
|
||||
name: String,
|
||||
@@ -830,6 +836,12 @@ pub fn responses_api_to_message(response: &ResponsesApiResponse) -> anyhow::Resu
|
||||
content.push(MessageContentBlock::text(refusal));
|
||||
}
|
||||
}
|
||||
ResponseContentBlock::ReasoningText { text } => {
|
||||
let text = sanitize_unicode_tags(text);
|
||||
if !text.is_empty() {
|
||||
content.push(MessageContentBlock::thinking(text, ""));
|
||||
}
|
||||
}
|
||||
ResponseContentBlock::ToolCall { id, name, input } => {
|
||||
let id = sanitize_tool_request_id(id, &mut tool_request_ids)?;
|
||||
content.push(MessageContentBlock::tool_request(
|
||||
@@ -906,6 +918,12 @@ fn process_streaming_output_items(
|
||||
content.push(MessageContentBlock::text(refusal));
|
||||
}
|
||||
}
|
||||
ContentBlockPart::ReasoningText { text } => {
|
||||
let text = sanitize_unicode_tags(&text);
|
||||
if !text.is_empty() {
|
||||
content.push(MessageContentBlock::thinking(text, ""));
|
||||
}
|
||||
}
|
||||
ContentBlockPart::ToolCall {
|
||||
id,
|
||||
name,
|
||||
@@ -957,6 +975,26 @@ fn output_token_limit_marker(id: Option<String>) -> Message {
|
||||
message
|
||||
}
|
||||
|
||||
/// Parse a line per the SSE grammar and return its field name:
|
||||
/// - `field: value` / `field:value` -> `Some(field)`
|
||||
/// - `field` (no colon, empty value) -> `Some(field)`
|
||||
/// - `: comment` -> `Some("")`
|
||||
///
|
||||
/// Returns `None` when the line does not look like an SSE field (e.g. a
|
||||
/// bare JSON payload such as `{"type": ...}`), so callers can fall back
|
||||
/// to parsing it as JSON.
|
||||
fn sse_field_name(line: &str) -> Option<&str> {
|
||||
let field = line.split_once(':').map_or(line, |(name, _)| name);
|
||||
if field.is_empty() {
|
||||
return Some("");
|
||||
}
|
||||
let field_like = !field.contains(char::is_whitespace)
|
||||
&& field
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'));
|
||||
field_like.then_some(field)
|
||||
}
|
||||
|
||||
pub fn responses_api_to_streaming_message<S>(
|
||||
mut stream: S,
|
||||
) -> impl Stream<Item = anyhow::Result<(Option<Message>, Option<ProviderUsage>)>> + 'static
|
||||
@@ -992,11 +1030,13 @@ where
|
||||
response_str.strip_prefix("data: ").unwrap()
|
||||
} else if response_str.starts_with("data:") {
|
||||
response_str.strip_prefix("data:").unwrap()
|
||||
} else if response_str.starts_with("event: ") || response_str.starts_with("event:") {
|
||||
// Skip event type lines
|
||||
} else if sse_field_name(&response_str).is_some_and(|f| f != "data") {
|
||||
// Skip payload-free SSE fields: event, id, retry, comments,
|
||||
// colon-less fields with empty values, and unknown extension
|
||||
// fields — the SSE spec requires all of these to be ignored.
|
||||
continue;
|
||||
} else {
|
||||
// Try to parse as-is when there's no prefix
|
||||
// Try to parse as-is when there's no prefix (bare JSON frames)
|
||||
&response_str
|
||||
};
|
||||
|
||||
@@ -1214,6 +1254,42 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_responses_stream_ignores_sse_field_lines() -> anyhow::Result<()> {
|
||||
let lines = vec![
|
||||
"id:1".to_string(),
|
||||
"id".to_string(),
|
||||
"x-trace: abc123".to_string(),
|
||||
"retry: 100".to_string(),
|
||||
": keepalive comment".to_string(),
|
||||
r#"data: {"type":"response.created","sequence_number":1,"response":{"id":"resp_1","object":"response","created_at":1737368310,"status":"in_progress","model":"qwen3.8-max","output":[]}}"#.to_string(),
|
||||
"event: response.output_text.delta".to_string(),
|
||||
r#"data: {"type":"response.output_text.delta","sequence_number":2,"item_id":"msg_1","output_index":0,"content_index":0,"delta":"Hello"}"#.to_string(),
|
||||
r#"data: {"type":"response.output_text.delta","sequence_number":3,"item_id":"msg_1","output_index":0,"content_index":0,"delta":" world"}"#.to_string(),
|
||||
r#"data: {"type":"response.completed","sequence_number":4,"response":{"id":"resp_1","object":"response","created_at":1737368310,"status":"completed","model":"qwen3.8-max","output":[],"usage":{"input_tokens":10,"output_tokens":4,"total_tokens":14}}}"#.to_string(),
|
||||
"data: [DONE]".to_string(),
|
||||
];
|
||||
|
||||
let response_stream = tokio_stream::iter(lines.into_iter().map(Ok));
|
||||
let messages = responses_api_to_streaming_message(response_stream);
|
||||
futures::pin_mut!(messages);
|
||||
|
||||
let mut text_parts = Vec::new();
|
||||
while let Some(item) = messages.next().await {
|
||||
let (message, _) = item?;
|
||||
if let Some(msg) = message {
|
||||
for content in msg.content {
|
||||
if let MessageContentBlock::Text(text) = content {
|
||||
text_parts.push(text.text.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(text_parts.concat(), "Hello world");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_responses_stream_completed_allows_missing_output() -> anyhow::Result<()> {
|
||||
let lines = vec![
|
||||
@@ -2587,6 +2663,43 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reasoning_text_content_part_deserializes() {
|
||||
let json = r#"{"type":"response.content_part.added","content_index":0,"item_id":"64cda83e-03b0-4e61-ab77-30d06ffe7f23","output_index":0,"part":{"type":"reasoning_text","text":"thinking..."},"sequence_number":3}"#;
|
||||
|
||||
let event: ResponsesStreamEvent = serde_json::from_str(json).unwrap();
|
||||
match event {
|
||||
ResponsesStreamEvent::ContentBlockPartAdded { part, .. } => match part {
|
||||
ContentBlockPart::ReasoningText { text } => {
|
||||
assert_eq!(text, "thinking...");
|
||||
}
|
||||
_ => panic!("expected ReasoningText part"),
|
||||
},
|
||||
_ => panic!("expected ContentBlockPartAdded event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reasoning_text_surfaces_as_thinking_block() -> anyhow::Result<()> {
|
||||
let output_items = vec![ResponseOutputItemInfo::Message {
|
||||
id: Some("msg_1".to_string()),
|
||||
status: Some("completed".to_string()),
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlockPart::ReasoningText {
|
||||
text: "thinking...".to_string(),
|
||||
}],
|
||||
}];
|
||||
|
||||
let content = process_streaming_output_items(output_items, true)?;
|
||||
assert_eq!(content.len(), 1, "reasoning text should be kept");
|
||||
assert!(
|
||||
matches!(content[0], MessageContentBlock::Thinking(_)),
|
||||
"reasoning text should become a thinking block"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_function_call_output_requires_call_id_or_id() {
|
||||
let output_items = vec![ResponseOutputItemInfo::FunctionCall {
|
||||
|
||||
Reference in New Issue
Block a user