fix(provider): 修复 Windsurf 原生工具桥接

This commit is contained in:
Entropy.Xu
2026-05-20 23:42:12 +08:00
parent 82637ad882
commit 129c7c90c0
30 changed files with 9090 additions and 194 deletions

View File

@@ -211,10 +211,10 @@ pub use crate::provider_compat::kiro_stream::{
KiroToClaudeCliStreamState, KIRO_MAX_THINKING_BUFFER,
};
pub use crate::provider_compat::private_envelope::{
maybe_build_provider_private_stream_normalizer, normalize_provider_private_report_context,
normalize_provider_private_response_value, provider_private_response_allows_sync_finalize,
stream_body_contains_error_event, transform_provider_private_stream_line,
ProviderPrivateStreamNormalizer,
extract_provider_private_stream_error_body, maybe_build_provider_private_stream_normalizer,
normalize_provider_private_report_context, normalize_provider_private_response_value,
provider_private_response_allows_sync_finalize, stream_body_contains_error_event,
transform_provider_private_stream_line, ProviderPrivateStreamNormalizer,
};
pub use crate::provider_compat::surfaces::{
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_anchor_api_format,

View File

@@ -1299,6 +1299,7 @@ struct OpenAIResponsesClientToolState {
name: String,
arguments: String,
output_index: Option<usize>,
web_search: bool,
}
#[derive(Clone, Default)]
@@ -1310,6 +1311,23 @@ struct OpenAIResponsesClientToolResultState {
item_started: bool,
}
fn is_responses_web_search_tool(name: &str) -> bool {
matches!(name, "web_search" | "web_search_preview")
}
fn web_search_query_from_arguments(arguments: &str) -> String {
serde_json::from_str::<Value>(arguments)
.ok()
.and_then(|value| {
value
.get("query")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or_else(|| value.as_str().map(ToOwned::to_owned))
})
.unwrap_or_default()
}
#[derive(Default)]
pub struct OpenAIResponsesClientEmitter {
response_id: Option<String>,
@@ -1985,6 +2003,26 @@ impl OpenAIResponsesClientEmitter {
} else {
state.name.clone()
};
if state.web_search {
out.extend(self.encode_response_event(
"response.output_item.done",
json!({
"type": "response.output_item.done",
"response_id": self.response_id(),
"output_index": output_index,
"item": {
"type": "web_search_call",
"id": item_id,
"status": "completed",
"action": {
"type": "search",
"query": web_search_query_from_arguments(&state.arguments),
},
}
}),
)?);
continue;
}
out.extend(self.encode_response_event(
"response.function_call_arguments.done",
json!({
@@ -2143,20 +2181,32 @@ impl OpenAIResponsesClientEmitter {
}
for (index, state) in &self.tool_calls {
if let Some(output_index) = state.output_index {
let item_id = if state.call_id.is_empty() {
build_generated_tool_call_id(*index)
} else {
state.call_id.clone()
};
if state.web_search {
ordered_output.push((
output_index,
json!({
"type": "web_search_call",
"id": item_id,
"status": "completed",
"action": {
"type": "search",
"query": web_search_query_from_arguments(&state.arguments),
},
}),
));
continue;
}
ordered_output.push((
output_index,
json!({
"type": "function_call",
"id": if state.call_id.is_empty() {
build_generated_tool_call_id(*index)
} else {
state.call_id.clone()
},
"call_id": if state.call_id.is_empty() {
build_generated_tool_call_id(*index)
} else {
state.call_id.clone()
},
"id": item_id.clone(),
"call_id": item_id,
"name": if state.name.is_empty() {
"unknown".to_string()
} else {
@@ -2322,22 +2372,36 @@ impl OpenAIResponsesClientEmitter {
let state = self.tool_calls.entry(index).or_default();
state.call_id = call_id.clone();
state.name = name.clone();
state.web_search = is_responses_web_search_tool(&name);
let emitted_call_id = state.call_id.clone();
let emitted_name = state.name.clone();
let item = if state.web_search {
json!({
"type": "web_search_call",
"id": emitted_call_id,
"status": "in_progress",
"action": {
"type": "search",
"query": "",
},
})
} else {
json!({
"type": "function_call",
"id": call_id,
"call_id": emitted_call_id,
"name": emitted_name,
"arguments": "",
"status": "in_progress",
})
};
out.extend(self.encode_response_event(
"response.output_item.added",
json!({
"type": "response.output_item.added",
"response_id": response_id,
"output_index": output_index,
"item": {
"type": "function_call",
"id": call_id,
"call_id": emitted_call_id,
"name": emitted_name,
"arguments": "",
"status": "in_progress",
}
"item": item
}),
)?);
Ok(out)
@@ -2348,6 +2412,9 @@ impl OpenAIResponsesClientEmitter {
let response_id = self.response_id().to_string();
let state = self.tool_calls.entry(index).or_default();
state.arguments.push_str(&arguments);
if state.web_search {
return Ok(out);
}
let item_id = if state.call_id.is_empty() {
build_generated_tool_call_id(index)
} else {
@@ -3167,6 +3234,56 @@ mod tests {
assert!(sse.contains("\"output\":\"{\\\"ok\\\":true}\""));
}
#[test]
fn openai_responses_client_emitter_emits_web_search_call_item() {
let mut emitter = OpenAIResponsesClientEmitter::default();
let mut bytes = emitter
.emit(CanonicalStreamFrame {
id: "resp_123".to_string(),
model: "gpt-5-5-low".to_string(),
event: CanonicalStreamEvent::ToolCallStart {
index: 0,
call_id: "call_ws_1".to_string(),
name: "web_search".to_string(),
},
})
.expect("tool start should encode");
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "resp_123".to_string(),
model: "gpt-5-5-low".to_string(),
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
index: 0,
arguments: r#"{"query":"today tech"}"#.to_string(),
},
})
.expect("arguments should encode"),
);
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "resp_123".to_string(),
model: "gpt-5-5-low".to_string(),
event: CanonicalStreamEvent::Finish {
finish_reason: Some("tool_calls".to_string()),
usage: None,
},
})
.expect("finish should encode"),
);
let sse = String::from_utf8(bytes).expect("sse should be utf8");
assert!(sse.contains("event: response.output_item.added\n"));
assert!(sse.contains(r#""type":"web_search_call""#));
assert!(sse.contains(r#""status":"in_progress""#));
assert!(sse.contains(r#""query":"""#));
assert!(sse.contains(r#""type":"search""#));
assert!(sse.contains("event: response.output_item.done\n"));
assert!(sse.contains(r#""query":"today tech""#));
assert!(!sse.contains("response.function_call_arguments.delta"));
}
#[test]
fn openai_responses_provider_state_accepts_legacy_outtext_delta_alias() {
let mut state = OpenAIResponsesProviderState::default();

View File

@@ -166,13 +166,25 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: b
&response_id,
&mut message_index,
);
output.push(json!({
"type": "function_call",
"id": id,
"call_id": id,
"name": name,
"arguments": canonicalize_tool_arguments(input),
}));
if is_responses_web_search_tool(name) {
output.push(json!({
"type": "web_search_call",
"id": id,
"status": "completed",
"action": {
"type": "search",
"query": web_search_query_from_value(input),
},
}));
} else {
output.push(json!({
"type": "function_call",
"id": id,
"call_id": id,
"name": name,
"arguments": canonicalize_tool_arguments(input),
}));
}
}
CanonicalContentBlock::ToolResult {
tool_use_id,
@@ -325,3 +337,73 @@ fn openai_responses_output_format_from_mime_type(mime_type: &str) -> String {
}
.to_string()
}
fn is_responses_web_search_tool(name: &str) -> bool {
matches!(name, "web_search" | "web_search_preview")
}
fn web_search_query_from_value(input: &Value) -> String {
input
.get("query")
.and_then(Value::as_str)
.or_else(|| input.as_str())
.unwrap_or_default()
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn responses_response_builder_emits_web_search_call_for_web_search_tool_use() {
let response = CanonicalResponse {
id: "resp_test".to_string(),
model: "gpt-5-5-low".to_string(),
content: vec![CanonicalContentBlock::ToolUse {
id: "call_ws_1".to_string(),
name: "web_search".to_string(),
input: json!({"query": "today tech"}),
extensions: BTreeMap::new(),
}],
outputs: Vec::new(),
stop_reason: Some(CanonicalStopReason::ToolUse),
usage: None,
extensions: BTreeMap::new(),
};
let body = to_raw(&response, &json!({}), false);
assert_eq!(body["output"][0]["type"], "web_search_call");
assert_eq!(body["output"][0]["id"], "call_ws_1");
assert_eq!(body["output"][0]["status"], "completed");
assert_eq!(body["output"][0]["action"]["type"], "search");
assert_eq!(body["output"][0]["action"]["query"], "today tech");
}
#[test]
fn responses_response_parser_reads_web_search_call_as_tool_use() {
let body = json!({
"id": "resp_test",
"model": "gpt-5-5-low",
"status": "incomplete",
"output": [{
"type": "web_search_call",
"id": "call_ws_1",
"status": "completed",
"action": {"type": "search", "query": "today tech"}
}]
});
let canonical = from_raw(&body).expect("response should parse");
assert!(
matches!(canonical.content.first(), Some(CanonicalContentBlock::ToolUse {
id,
name,
input,
..
}) if id == "call_ws_1" && name == "web_search" && input["query"] == "today tech")
);
}
}

View File

@@ -1556,6 +1556,39 @@ pub(crate) fn openai_responses_input_to_canonical_messages(
extensions: BTreeMap::new(),
});
}
"web_search_call" => {
let id = item_object
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| {
let generated =
format!("call_auto_{next_generated_tool_call_index}");
next_generated_tool_call_index += 1;
generated
});
let query = item_object
.get("action")
.and_then(Value::as_object)
.and_then(|action| action.get("query"))
.and_then(Value::as_str)
.unwrap_or_default();
messages.push(CanonicalMessage {
role: CanonicalRole::Assistant,
content: vec![CanonicalContentBlock::ToolUse {
id,
name: "web_search".to_string(),
input: json!({ "query": query }),
extensions: openai_responses_extensions(
item_object,
&["type", "id", "status", "action"],
),
}],
extensions: BTreeMap::new(),
});
}
"function_call_output" => {
let id = item_object
.get("call_id")
@@ -1729,6 +1762,30 @@ pub(crate) fn openai_responses_output_to_canonical_blocks(
),
});
}
"web_search_call" => {
let id = item_object
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("call_auto_{index}"));
let query = item_object
.get("action")
.and_then(Value::as_object)
.and_then(|action| action.get("query"))
.and_then(Value::as_str)
.unwrap_or_default();
blocks.push(CanonicalContentBlock::ToolUse {
id,
name: "web_search".to_string(),
input: json!({ "query": query }),
extensions: openai_responses_extensions(
item_object,
&["type", "id", "status", "action"],
),
});
}
"function_call_output" => {
let id = item_object
.get("call_id")

View File

@@ -325,6 +325,19 @@ pub fn maybe_build_provider_private_stream_normalizer<'a>(
})
}
pub fn extract_provider_private_stream_error_body(
report_context: Option<&Value>,
body: &[u8],
) -> Option<Value> {
if report_context.is_none_or(report_context_is_windsurf_envelope) {
if let Some(error_body) = extract_windsurf_connect_json_error_body(body) {
return Some(error_body);
}
}
extract_stream_error_event_body(body)
}
impl ProviderPrivateStreamNormalizer<'_> {
pub fn push_chunk(&mut self, chunk: &[u8]) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
match &mut self.mode {
@@ -536,8 +549,62 @@ fn build_openai_chat_response_from_text(source: &Value, text: String) -> Value {
}
pub fn stream_body_contains_error_event(body: &[u8]) -> bool {
if extract_windsurf_connect_json_error_body(body).is_some() {
return true;
}
extract_stream_error_event_body(body).is_some()
}
fn extract_windsurf_connect_json_error_body(body: &[u8]) -> Option<Value> {
if !buffer_looks_like_connect_frame(body) {
return None;
}
let mut offset = 0usize;
while body.len().saturating_sub(offset) >= CONNECT_FRAME_HEADER_BYTES {
let flags = body[offset];
if flags & !0x03 != 0 {
return None;
}
let len = u32::from_be_bytes([
body[offset + 1],
body[offset + 2],
body[offset + 3],
body[offset + 4],
]) as usize;
if len > MAX_CONNECT_JSON_FRAME_BYTES {
return None;
}
let frame_end = offset + CONNECT_FRAME_HEADER_BYTES + len;
if body.len() < frame_end {
return None;
}
if flags & 0x01 != 0 {
return None;
}
let payload = &body[offset + CONNECT_FRAME_HEADER_BYTES..frame_end];
offset = frame_end;
if payload.is_empty() {
continue;
}
let parsed: Value = serde_json::from_slice(payload).ok()?;
if flags & 0x02 != 0 {
if let Some(error) = parsed.get("error").filter(|value| !value.is_null()) {
return Some(normalize_provider_private_error_body(error.clone()));
}
continue;
}
if looks_like_windsurf_error(&parsed) {
return Some(normalize_provider_private_error_body(parsed));
}
}
None
}
fn extract_stream_error_event_body(body: &[u8]) -> Option<Value> {
let Ok(text) = std::str::from_utf8(body) else {
return false;
return None;
};
let mut current_event_type: Option<String> = None;
for raw_line in text.lines() {
@@ -572,11 +639,35 @@ pub fn stream_body_contains_error_event(body: &[u8]) -> bool {
.and_then(Value::as_str)
.is_some_and(|value| value.eq_ignore_ascii_case("error"))
{
return true;
return Some(normalize_provider_private_error_body(event));
}
current_event_type = None;
}
false
None
}
fn normalize_provider_private_error_body(error: Value) -> Value {
let mut error = if error.get("error").is_some_and(|value| !value.is_null()) {
error
} else {
serde_json::json!({ "error": error })
};
if let Some(error_object) = error.get_mut("error").and_then(Value::as_object_mut) {
if !error_object.contains_key("type") {
if let Some(kind) = error_object
.get("code")
.or_else(|| error_object.get("status"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
error_object.insert("type".to_string(), Value::String(kind.to_string()));
}
}
}
error
}
fn clear_private_envelope_context(report_context: &Value) -> Value {
@@ -717,9 +808,9 @@ mod tests {
use serde_json::json;
use super::{
maybe_build_provider_private_stream_normalizer, normalize_provider_private_report_context,
normalize_provider_private_response_value, stream_body_contains_error_event,
transform_provider_private_stream_line,
extract_provider_private_stream_error_body, maybe_build_provider_private_stream_normalizer,
normalize_provider_private_report_context, normalize_provider_private_response_value,
stream_body_contains_error_event, transform_provider_private_stream_line,
};
#[test]
@@ -853,6 +944,30 @@ mod tests {
assert!(text.contains(r#""content":"frame chunk""#));
}
#[test]
fn detects_windsurf_connect_json_trailer_error_frame() {
let framed = connect_json_frame(
2,
br#"{"error":{"code":"resource_exhausted","message":"quota exhausted"}}"#,
);
assert!(stream_body_contains_error_event(&framed));
}
#[test]
fn extracts_connect_json_trailer_error_without_report_context() {
let framed = connect_json_frame(
2,
br#"{"error":{"code":"resource_exhausted","message":"quota exhausted"}}"#,
);
let body = extract_provider_private_stream_error_body(None, &framed)
.expect("Connect trailer error should decode without report context");
assert_eq!(body["error"]["code"], json!("resource_exhausted"));
assert_eq!(body["error"]["message"], json!("quota exhausted"));
}
fn connect_json_frame(flags: u8, payload: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(5 + payload.len());
out.push(flags);